feat(mac): localization support

added localizable types where necessary
created Localizable.xcstrings for both the
config app and the Keyman settings package
This commit is contained in:
Shawn Schantz 2026-09-02 22:05:53 -04:00
parent 7fae710528
commit ab30341f59
12 changed files with 571 additions and 53 deletions

View file

@ -9,6 +9,7 @@
/* Begin PBXBuildFile section */
D83272A82F57614400F71698 /* KeymanSettings in Frameworks */ = {isa = PBXBuildFile; productRef = D83272A72F57614400F71698 /* KeymanSettings */; };
D88B79CA30472B3B00F10D14 /* Sentry-Dynamic in Frameworks */ = {isa = PBXBuildFile; productRef = D88B79C930472B3B00F10D14 /* Sentry-Dynamic */; };
D8F10B623048F12A00C1E597 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = D8F10B613048F12A00C1E597 /* Localizable.xcstrings */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -32,6 +33,7 @@
D88F03C62F50ED5000C02A31 /* Keyman Configuration.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Keyman Configuration.app"; sourceTree = BUILT_PRODUCTS_DIR; };
D88F03D32F50ED5100C02A31 /* ConfigTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ConfigTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
D88F03DD2F50ED5100C02A31 /* ConfigUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ConfigUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
D8F10B613048F12A00C1E597 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@ -95,6 +97,7 @@
D88F03BD2F50ED5000C02A31 = {
isa = PBXGroup;
children = (
D8F10B613048F12A00C1E597 /* Localizable.xcstrings */,
D87D6F492FAF95400083A95E /* Installation */,
D88F03C82F50ED5000C02A31 /* Config */,
D88F03E02F50ED5100C02A31 /* ConfigUITests */,
@ -243,6 +246,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D8F10B623048F12A00C1E597 /* Localizable.xcstrings in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -361,6 +365,7 @@
REGISTER_APP_GROUPS = YES;
SDKROOT = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
@ -419,6 +424,7 @@
REGISTER_APP_GROUPS = YES;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_EMIT_LOC_STRINGS = YES;
};
name = Release;
};

View file

@ -32,11 +32,12 @@ struct AddKeyboardView: View {
.font(.headline)
// Native progress bar bound to the coordinator's value (0.0 to 1.0)
ProgressView(value: downloadCoordinator.downloadProgress, total: 1.0)
ProgressView(value: downloadCoordinator.downloadProgressFraction, total: 1.0)
.progressViewStyle(.linear)
.frame(width: 250)
Text("\(Int(downloadCoordinator.downloadProgress * 100))%")
// Text("\(Int(downloadCoordinator.downloadProgressFraction * 100))%")
Text(downloadCoordinator.downloadProgressFraction, format: .percent.precision(.fractionLength(0)))
.font(.body)
.foregroundColor(.secondary)
}

View file

@ -24,7 +24,7 @@ import OSLog
public class DownloadCoordinator: NSObject, ObservableObject, WKNavigationDelegate, WKDownloadDelegate {
@Published var isDownloading = false
// progress is between 0.0 and 1.0
@Published var downloadProgress: Double = 0.0
@Published var downloadProgressFraction: Double = 0.0
@Published var showConfirmPackageSheet = false
@Published var installHelper: PackageInstallHelper?
@ -100,13 +100,13 @@ public class DownloadCoordinator: NSObject, ObservableObject, WKNavigationDelega
// reset progress states
self.isDownloading = true
self.downloadProgress = 0.0
self.downloadProgressFraction = 0.0
progressObserver = download.progress.observe(\.fractionCompleted, options: [.new]) { [weak self] _, change in
guard let newValue = change.newValue else { return }
Task { @MainActor [weak self] in
self?.downloadProgress = newValue
self?.downloadProgressFraction = newValue
}
}
}

View file

@ -12,7 +12,7 @@ public struct IconButtonView: View {
let action: () -> Void
let systemImage: String
let font: Font
let helpText: String
let helpText: LocalizedStringKey
public var body: some View {
@ -32,7 +32,7 @@ public struct IconButtonView: View {
// the view for buttons with a label
public struct LabelButtonView: View {
let action: () -> Void
let label: String
let label: LocalizedStringKey
let systemImage: String
let font: Font

View file

@ -3,7 +3,9 @@
*
* Created by Shawn Schantz on 2026-02-26
*
* Main view used for configuring Keyman
* Test view used for configuring Keyman
* Not included in ConfigApp.swift, but can be added temporarily for testing purposes
* All text and labels are marked verbatim, so that they do not get extracted for localization
*/
import SwiftUI
@ -18,7 +20,7 @@ struct InstallDebugView: View {
Image(systemName: "gear")
.imageScale(.large)
.foregroundColor(.accentColor)
Text("Current task = \(taskText)")
Text(verbatim: "Current task = \(taskText)")
.onAppear() {
if let installTask = installation.currentTask() {
taskText = installTask.taskType.rawValue
@ -26,52 +28,78 @@ struct InstallDebugView: View {
}
}
HStack {
Button("Next...") {
Button {
installation.executeCurrentInstallationTask()
if let installTask = installation.currentTask() {
taskText = installTask.taskType.rawValue
}
} label: {
Text(verbatim: "Next...")
}
.disabled(installation.isInstallationComplete())
Button("Migrate Data") {
Button {
_ = installation.migrateData()
} label: {
Text(verbatim: "Migrate Data")
}
Button("Register Keyman") {
Button {
_ = installation.registerKeymanInputMethod()
} label: {
Text(verbatim: "Register Keyman")
}
Button("Enable Keyman") {
Button {
_ = installation.enableKeymanInputMethod()
} label: {
Text(verbatim: "Enable Keyman")
}
Button("Select Keyman") {
Button {
_ = installation.selectKeymanInputMethod()
} label: {
Text(verbatim: "Select Keyman")
}
Button("Check Permission") {
Button {
installation.checkAccessibilityPermissionGranted()
} label: {
Text(verbatim: "Check Permission")
}
Button("Request Permission") {
Button {
_ = installation.requestAccessibility()
} label: {
Text(verbatim: "Request Permission")
}
Spacer()
}
.padding()
HStack {
Button("Request Restart") {
Button {
_ = installation.notifyUserPromptedToRestart()
} label: {
Text(verbatim: "Request Restart")
}
Button("Check Restart") {
Button {
_ = installation.validateUserHasRestarted()
} label: {
Text(verbatim: "Check Restart")
}
Button("Set Displayed Complete") {
Button {
installation.setHasDisplayedInstallationComplete()
} label: {
Text(verbatim: "Set Displayed Complete")
}
Button("debug") {
Button {
installation.debug()
} label: {
Text(verbatim: "debug")
}
Button("Disable Keyman") {
Button {
_ = installation.disableKeymanInputMethod()
} label: {
Text(verbatim: "Disable Keyman")
}
Button("Kill Keyman") {
Button {
_ = installation.killKeymanInputMethod()
} label: {
Text(verbatim: "Kill Keyman")
}
Spacer()
}

View file

@ -136,7 +136,7 @@ struct MainConfigView: View {
.dropDestination(for: URL.self) { urls, _ in
// reject drop if it is more than one file
guard let droppedFileUrl = urls.first, urls.count < 2 else {
let error = DropKmpError.tooManyFiles
let error = DropKmpError.exceededFileDropLimit
self.alertMessage = error.localizedDescription
self.isShowingDropKmpAlert = true
return false // the drop failed

View file

@ -0,0 +1,141 @@
{
"sourceLanguage" : "en",
"strings" : {
"Access has not been granted." : {
},
"Add Keyboard" : {
},
"Are you sure you want to delete the Keyman package '%@'?" : {
},
"Author:" : {
},
"Cancel" : {
},
"Checking..." : {
},
"Close" : {
},
"Configuration" : {
},
"Continue" : {
},
"Copy link" : {
},
"Copyright:" : {
},
"Delete" : {
},
"Downloading File..." : {
},
"Enable" : {
},
"Enable Keyman" : {
},
"enabled" : {
},
"Ensure Keyman.app is set to provide it with necessary control in System Settings > Privacy & Security > Accessibility." : {
},
"Finish installation" : {
},
"Fonts:" : {
},
"Grant Accessibility Permission" : {
},
"Help" : {
},
"Help not available." : {
},
"Install" : {
},
"Installation" : {
},
"Installation complete" : {
},
"Keyboards" : {
},
"Missing Keyman Components" : {
},
"OK" : {
},
"One or more Keyman components or permissions require your attention. Complete the following steps to restore your Keyman installation." : {
},
"Open Settings" : {
},
"Package Installation Failed" : {
},
"Package Version:" : {
},
"Proceed to continue with installation" : {
},
"Read me not available." : {
},
"Remove Package" : {
},
"Repairs Required" : {
},
"Resolve Issues" : {
},
"Restart Computer" : {
},
"Restart your Mac to complete the installation. After restarting, open Keyman Configuration again if it doesn't launch automatically." : {
},
"Run Keyman installer" : {
},
"Share Keyboard" : {
},
"Show keyboard help" : {
},
"To use Keyman, enable the Keyman input method in System Settings." : {
},
"Website:" : {
},
"Your Keyman input method is either missing or outdated. Run the Keyman installer to install a new version." : {
}
},
"version" : "1.2"
}

View file

@ -5,6 +5,7 @@ import PackageDescription
let package = Package(
name: "KeymanSettings",
defaultLocalization: "en",
platforms: [
.iOS(.v16),
.macOS(.v13)

View file

@ -0,0 +1,237 @@
{
"sourceLanguage" : "en",
"strings" : {
"contains.no.files" : {
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "The keyboard package contains no files."
}
}
}
},
"contains.no.keyboards" : {
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "The keyboard package contains no keyboards."
}
}
}
},
"could.not.unzip" : {
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "The keyboard package could not be unzipped."
}
}
}
},
"error.internal" : {
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "An internal error occurred."
}
}
}
},
"exceeded.file.drop.limit" : {
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Only a single .KMP file can be installed at a time."
}
}
}
},
"font.copy.error" : {
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "There was an error copying the font."
}
}
}
},
"font.registration.error" : {
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "There was an error registering the font."
}
}
}
},
"installation.in.progress" : {
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "A package installation is already in progress."
}
}
}
},
"insufficient.keyman.version" : {
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "The keyboard package '%1$@' requires Keyman version %2$@ but your version is %3$@."
}
}
}
},
"invalid.url" : {
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "The URL is not valid."
}
}
}
},
"keyboard.id.missing" : {
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "A keyboard in the package has no ID."
}
}
}
},
"keyboard.missing.kmx.file" : {
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "A keyboard in the package has no corresponding KMX file."
}
}
}
},
"keyboard.missing.version" : {
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "A keyboard in the package has no version."
}
}
}
},
"keyboard.name.missing" : {
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "A keyboard in the package has no name."
}
}
}
},
"kmp.json.not.found" : {
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "The package's kmp.json file was not found."
}
}
}
},
"kmp.json.unreadable" : {
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "The package's kmp.json file could not be parsed."
}
}
}
},
"non.kmp.file" : {
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "The file %@ is not a .KMP file."
}
}
}
},
"prompt.new.package" : {
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "The package '%@' is ready to install"
}
}
}
},
"prompt.replace.newer.package" : {
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "The package '%1$@' is ready to downgrade from version %2$@ to %3$@"
}
}
}
},
"prompt.replace.older.package" : {
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "The package '%1$@' is ready to update from version %2$@ to %3$@"
}
}
}
},
"prompt.replace.same.version.package" : {
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "The package '%@' is ready to re-install"
}
}
}
}
},
"version" : "1.2"
}

View file

@ -33,12 +33,35 @@ public enum InstallPackageError: LocalizedError {
case fontRegistrationError
case internalError // due to invalid state, should never occur
private var packageBundle: LocalizedStringResource.BundleDescription {
.atURL(Bundle.module.bundleURL)
}
public var errorDescription: String? {
switch self {
case .packageInstallationAlreadyInProgress: return "A package installation is already in progress."
case .fontCopyError: return "There was an error copying the font."
case .fontRegistrationError: return "There was an error registering the font."
case .internalError: return "An internal error occurred."
case .packageInstallationAlreadyInProgress:
let resource = LocalizedStringResource(
"installation.in.progress",
defaultValue: "A package installation is already in progress.",
bundle: packageBundle)
return String(localized: resource)
case .fontCopyError:
let resource = LocalizedStringResource(
"font.copy.error",
defaultValue: "There was an error copying the font.",
bundle: packageBundle)
return String(localized: resource)
case .fontRegistrationError:
let resource = LocalizedStringResource(
"font.registration.error",
defaultValue: "There was an error registering the font.",
bundle: packageBundle)
return String(localized: resource)
case .internalError:
let resource = LocalizedStringResource(
"error.internal",
defaultValue: "An internal error occurred.",
bundle: packageBundle)
return String(localized: resource)
}
}
}
@ -57,20 +80,27 @@ public extension Notification.Name {
static let dataMigrated = Notification.Name("com.keyman.data.migrated")
}
// define LocalizedError so that UI can present a localizable message
// when the attempt to install a KMP file using drag and drop fails
public enum DropKmpError: LocalizedError {
case invalidFileType(String)
case alreadyInstalled(String)
case installFailed(String)
case tooManyFiles
case exceededFileDropLimit
private var packageBundle: LocalizedStringResource.BundleDescription {
.atURL(Bundle.module.bundleURL)
}
public var errorDescription: String? {
switch self {
case .invalidFileType(let fileName): return "The file \(fileName) is not a .KMP file."
case .alreadyInstalled(let fileName): return "The package \(fileName) is already installed."
case .installFailed(let fileName): return "The file \(fileName) could not be installed."
case .tooManyFiles: return "Only a single .KMP file can be installed at a time."
case .invalidFileType(let fileName):
let resource = LocalizedStringResource(
"non.kmp.file",
defaultValue: "The file \(fileName) is not a .KMP file.",
bundle: packageBundle)
return String(localized: resource)
case .exceededFileDropLimit:
let resource = LocalizedStringResource(
"exceeded.file.drop.limit",
defaultValue: "Only a single .KMP file can be installed at a time.",
bundle: packageBundle)
return String(localized: resource)
}
}
}

View file

@ -21,14 +21,30 @@ public enum PackageInstallationType {
public var prompt: LocalizedStringResource {
switch self {
case .newPackage(let packageName):
return "The package '\(packageName)' is ready to install"
case .newPackage(let packageName):
return LocalizedStringResource(
"prompt.new.package",
defaultValue: "The package '\(packageName)' is ready to install",
bundle: .atURL(Bundle.module.bundleURL)
)
case .replaceSameVersionPackage(let packageName):
return "The package '\(packageName)' is ready to re-install"
return LocalizedStringResource(
"prompt.replace.same.version.package",
defaultValue: "The package '\(packageName)' is ready to re-install",
bundle: .atURL(Bundle.module.bundleURL)
)
case .replaceOlderPackage(let packageName, let existingVersion, let newVersion):
return "The package '\(packageName)' is ready to update from version \(existingVersion) to \(newVersion)"
return LocalizedStringResource(
"prompt.replace.older.package",
defaultValue: "The package '\(packageName)' is ready to update from version \(existingVersion) to \(newVersion)",
bundle: .atURL(Bundle.module.bundleURL)
)
case .replaceNewerPackage(let packageName, let existingVersion, let newVersion):
return "The package '\(packageName)' is ready to downgrade from version \(existingVersion) to \(newVersion)"
return LocalizedStringResource(
"prompt.replace.newer.package",
defaultValue: "The package '\(packageName)' is ready to downgrade from version \(existingVersion) to \(newVersion)",
bundle: .atURL(Bundle.module.bundleURL)
)
}
}
}

View file

@ -23,20 +23,78 @@ public enum LoadPackageError: LocalizedError {
case missingKeyboardVersion
case missingKmxFile
case insufficientKeymanVersion(packageName: String, requiredKeymanVersion: String, actualKeymanVersion: String)
private var packageBundle: LocalizedStringResource.BundleDescription {
.atURL(Bundle.module.bundleURL)
}
public var errorDescription: String? {
switch self {
case .invalidUrl: return "The URL is not valid."
case .unzipError: return "The keyboard package could not be unzipped."
case .containsNoFiles: return "The keyboard package contains no files."
case .containsNoKeyboards: return "The keyboard package contains no keyboards."
case .kmpJsonFileUnreadable: return "The package's kmp.json file could not be parsed."
case .kmpJsonFileNotFound: return "The package's kmp.json file was not found."
case .missingKeyboardName: return "A keyboard in the package has no name."
case .missingKeyboardId: return "A keyboard in the package has no ID."
case .missingKeyboardVersion: return "A keyboard in the package has no version."
case .missingKmxFile: return "A keyboard in the package has no corresponding KMX file."
case .insufficientKeymanVersion(let packageName, let requiredKeymanVersion, let actualKeymanVersion): return "The keyboard package '\(packageName)' requires Keyman version \(requiredKeymanVersion) but your version is \(actualKeymanVersion)."
case .invalidUrl:
let resource = LocalizedStringResource(
"invalid.url",
defaultValue: "The URL is not valid.",
bundle: packageBundle)
return String(localized: resource)
case .unzipError:
let resource = LocalizedStringResource(
"could.not.unzip",
defaultValue: "The keyboard package could not be unzipped.",
bundle: packageBundle)
return String(localized: resource)
case .containsNoFiles:
let resource = LocalizedStringResource(
"contains.no.files",
defaultValue: "The keyboard package contains no files.",
bundle: packageBundle)
return String(localized: resource)
case .containsNoKeyboards:
let resource = LocalizedStringResource(
"contains.no.keyboards",
defaultValue: "The keyboard package contains no keyboards.",
bundle: packageBundle)
return String(localized: resource)
case .kmpJsonFileUnreadable:
let resource = LocalizedStringResource(
"kmp.json.unreadable",
defaultValue: "The package's kmp.json file could not be parsed.",
bundle: packageBundle)
return String(localized: resource)
case .kmpJsonFileNotFound:
let resource = LocalizedStringResource(
"kmp.json.not.found",
defaultValue: "The package's kmp.json file was not found.",
bundle: packageBundle)
return String(localized: resource)
case .missingKeyboardName:
let resource = LocalizedStringResource(
"keyboard.name.missing",
defaultValue: "A keyboard in the package has no name.",
bundle: packageBundle)
return String(localized: resource)
case .missingKeyboardId:
let resource = LocalizedStringResource(
"keyboard.id.missing",
defaultValue: "A keyboard in the package has no ID.",
bundle: packageBundle)
return String(localized: resource)
case .missingKeyboardVersion:
let resource = LocalizedStringResource(
"keyboard.missing.version",
defaultValue: "A keyboard in the package has no version.",
bundle: packageBundle)
return String(localized: resource)
case .missingKmxFile:
let resource = LocalizedStringResource(
"keyboard.missing.kmx.file",
defaultValue: "A keyboard in the package has no corresponding KMX file.",
bundle: packageBundle)
return String(localized: resource)
case .insufficientKeymanVersion(let packageName, let requiredKeymanVersion, let actualKeymanVersion):
let resource = LocalizedStringResource(
"insufficient.keyman.version",
defaultValue: "The keyboard package '\(packageName)' requires Keyman version \(requiredKeymanVersion) but your version is \(actualKeymanVersion).",
bundle: packageBundle)
return String(localized: resource)
}
}
}