diff --git a/mac/Config/Config.xcodeproj/project.pbxproj b/mac/Config/Config.xcodeproj/project.pbxproj index 56b4e676045..3b8bb555a05 100644 --- a/mac/Config/Config.xcodeproj/project.pbxproj +++ b/mac/Config/Config.xcodeproj/project.pbxproj @@ -33,6 +33,23 @@ D88F03DD2F50ED5100C02A31 /* ConfigUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ConfigUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 375D21122FF2F21800FCD24A /* Exceptions for "Config" folder in "Config" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + ConfigTests/ConfigTests.swift, + ); + target = D88F03C52F50ED5000C02A31 /* Config */; + }; + 375D21132FF2F21800FCD24A /* Exceptions for "Config" folder in "ConfigTests" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + ConfigTests/ConfigTests.swift, + ); + target = D88F03D22F50ED5100C02A31 /* ConfigTests */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ D87D6F492FAF95400083A95E /* Installation */ = { isa = PBXFileSystemSynchronizedRootGroup; @@ -41,14 +58,13 @@ }; D88F03C82F50ED5000C02A31 /* Config */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 375D21122FF2F21800FCD24A /* Exceptions for "Config" folder in "Config" target */, + 375D21132FF2F21800FCD24A /* Exceptions for "Config" folder in "ConfigTests" target */, + ); path = Config; sourceTree = ""; }; - D88F03D62F50ED5100C02A31 /* ConfigTests */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = ConfigTests; - sourceTree = ""; - }; D88F03E02F50ED5100C02A31 /* ConfigUITests */ = { isa = PBXFileSystemSynchronizedRootGroup; path = ConfigUITests; @@ -87,7 +103,6 @@ children = ( D87D6F492FAF95400083A95E /* Installation */, D88F03C82F50ED5000C02A31 /* Config */, - D88F03D62F50ED5100C02A31 /* ConfigTests */, D88F03E02F50ED5100C02A31 /* ConfigUITests */, D88F04022F512FE800C02A31 /* Frameworks */, D88F03C72F50ED5000C02A31 /* Products */, @@ -151,9 +166,6 @@ dependencies = ( D88F03D52F50ED5100C02A31 /* PBXTargetDependency */, ); - fileSystemSynchronizedGroups = ( - D88F03D62F50ED5100C02A31 /* ConfigTests */, - ); name = ConfigTests; packageProductDependencies = ( ); @@ -416,7 +428,7 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = ""; CODE_SIGN_ENTITLEMENTS = Config/ConfigDebug.entitlements; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Mac Developer"; CODE_SIGN_STYLE = Manual; @@ -455,7 +467,7 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = ""; CODE_SIGN_ENTITLEMENTS = Config/ConfigRelease.entitlements; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application"; CODE_SIGN_STYLE = Manual; diff --git a/mac/Config/Config/Assets.xcassets/AccentColor.colorset/Contents.json b/mac/Config/Config/Assets.xcassets/AccentColor.colorset/Contents.json deleted file mode 100644 index 3cdd0f53568..00000000000 --- a/mac/Config/Config/Assets.xcassets/AccentColor.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "display-p3", - "components" : { - "alpha" : "1.000", - "blue" : "0.844", - "green" : "0.758", - "red" : "0.542" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/mac/Config/Config/Assets.xcassets/AppIcon.appiconset/Contents.json b/mac/Config/Config/Assets.xcassets/AppIcon.appiconset/Contents.json index 47ef0f802b4..fb399c8a844 100644 --- a/mac/Config/Config/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/mac/Config/Config/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -26,7 +26,6 @@ "size" : "128x128" }, { - "filename" : "Keyman-256.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" @@ -47,6 +46,7 @@ "size" : "512x512" }, { + "filename" : "keyman-1024.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" diff --git a/mac/Config/Config/Assets.xcassets/AppIcon.appiconset/Keyman-256.png b/mac/Config/Config/Assets.xcassets/AppIcon.appiconset/Keyman-256.png deleted file mode 100644 index 8aebcb40337..00000000000 Binary files a/mac/Config/Config/Assets.xcassets/AppIcon.appiconset/Keyman-256.png and /dev/null differ diff --git a/mac/Config/Config/Assets.xcassets/AppIcon.appiconset/keyman-1024.png b/mac/Config/Config/Assets.xcassets/AppIcon.appiconset/keyman-1024.png new file mode 100644 index 00000000000..a076fccf273 Binary files /dev/null and b/mac/Config/Config/Assets.xcassets/AppIcon.appiconset/keyman-1024.png differ diff --git a/mac/Config/Config/Assets.xcassets/accessibility-permission.imageset/Contents.json b/mac/Config/Config/Assets.xcassets/accessibility-permission.imageset/Contents.json new file mode 100644 index 00000000000..20678106459 --- /dev/null +++ b/mac/Config/Config/Assets.xcassets/accessibility-permission.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "accessibility-permission-light.pdf", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "accessibility-permission-dark.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mac/Config/Config/Assets.xcassets/accessibility-permission.imageset/accessibility-permission-dark.pdf b/mac/Config/Config/Assets.xcassets/accessibility-permission.imageset/accessibility-permission-dark.pdf new file mode 100644 index 00000000000..0f877ea2111 Binary files /dev/null and b/mac/Config/Config/Assets.xcassets/accessibility-permission.imageset/accessibility-permission-dark.pdf differ diff --git a/mac/Config/Config/Assets.xcassets/accessibility-permission.imageset/accessibility-permission-light.pdf b/mac/Config/Config/Assets.xcassets/accessibility-permission.imageset/accessibility-permission-light.pdf new file mode 100644 index 00000000000..2be7a808a46 Binary files /dev/null and b/mac/Config/Config/Assets.xcassets/accessibility-permission.imageset/accessibility-permission-light.pdf differ diff --git a/mac/Config/Config/Assets.xcassets/enable-Keyman.imageset/Contents.json b/mac/Config/Config/Assets.xcassets/enable-Keyman.imageset/Contents.json new file mode 100644 index 00000000000..d34523091eb --- /dev/null +++ b/mac/Config/Config/Assets.xcassets/enable-Keyman.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "enable-keyman-light.pdf", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "enable-keyman-dark.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mac/Config/Config/Assets.xcassets/enable-Keyman.imageset/enable-keyman-dark.pdf b/mac/Config/Config/Assets.xcassets/enable-Keyman.imageset/enable-keyman-dark.pdf new file mode 100644 index 00000000000..cc091257d43 Binary files /dev/null and b/mac/Config/Config/Assets.xcassets/enable-Keyman.imageset/enable-keyman-dark.pdf differ diff --git a/mac/Config/Config/Assets.xcassets/enable-Keyman.imageset/enable-keyman-light.pdf b/mac/Config/Config/Assets.xcassets/enable-Keyman.imageset/enable-keyman-light.pdf new file mode 100644 index 00000000000..86bb56002ba Binary files /dev/null and b/mac/Config/Config/Assets.xcassets/enable-Keyman.imageset/enable-keyman-light.pdf differ diff --git a/mac/Config/Config/Assets.xcassets/keyman-logo.imageset/Contents.json b/mac/Config/Config/Assets.xcassets/keyman-logo.imageset/Contents.json new file mode 100644 index 00000000000..0aba0726d05 --- /dev/null +++ b/mac/Config/Config/Assets.xcassets/keyman-logo.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images" : [ + { + "filename" : "keyman-logo-light.png", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "keyman-logo-dark.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/mac/Config/Config/Assets.xcassets/keyman-logo.imageset/keyman-logo-dark.png b/mac/Config/Config/Assets.xcassets/keyman-logo.imageset/keyman-logo-dark.png new file mode 100644 index 00000000000..67ddbb7bea1 Binary files /dev/null and b/mac/Config/Config/Assets.xcassets/keyman-logo.imageset/keyman-logo-dark.png differ diff --git a/mac/Config/Config/Assets.xcassets/keyman-logo.imageset/keyman-logo-light.png b/mac/Config/Config/Assets.xcassets/keyman-logo.imageset/keyman-logo-light.png new file mode 100644 index 00000000000..d7243fbab63 Binary files /dev/null and b/mac/Config/Config/Assets.xcassets/keyman-logo.imageset/keyman-logo-light.png differ diff --git a/mac/Config/Config/ConfigApp.swift b/mac/Config/Config/ConfigApp.swift index d94e5d5f21d..49f8d2c6493 100644 --- a/mac/Config/Config/ConfigApp.swift +++ b/mac/Config/Config/ConfigApp.swift @@ -16,6 +16,23 @@ struct ConfigApp: App { @Environment(\.openWindow) private var openWindow var body: some Scene { + Window("Configuration", id: "main-config") { + MainConfigView() + .environmentObject(settings) + .task { + if !installation.getHasDisplayedInstallationComplete() { + openWindow(id: "install") + } + } + .onReceive(NotificationCenter.default.publisher(for: .installationRepairStarted)) { notification in openWindow(id: "install") + } + } + Window("Installation", id: "install") { + MainInstallView() + .environmentObject(installation) + } + .windowResizability(.contentSize) + .defaultSize(width: 600, height: 500) Window("Config Test", id: "config-debug") { ConfigDebugView() .environmentObject(settings) diff --git a/mac/Config/Config/ConfigDebugView.swift b/mac/Config/Config/ConfigDebugView.swift index cb1692e956d..c8d735f261a 100644 --- a/mac/Config/Config/ConfigDebugView.swift +++ b/mac/Config/Config/ConfigDebugView.swift @@ -21,9 +21,6 @@ struct ConfigDebugView: View { .foregroundColor(.accentColor) Text("multiple keyboard package count = \(settings.multiKeyboardPackages.count)") Text("single keyboard package count = \(settings.singleKeyboardPackages.count)") - Button("debug") { - settings.debug() - } Button("log defaults") { settings.logUserDefaults() } @@ -50,22 +47,6 @@ struct ConfigDebugView: View { ForEach(Array(settings.singleKeyboardPackages.enumerated()), id: \.offset) { index, package in VStack { HStack(alignment: .center, spacing: 10) { - VStack(spacing: 16) { - Text("Scan to visit website:") - .font(.headline) - - if let qrImage = package.generateSharePackageQRCode(size: 200) { - Image(nsImage: qrImage) - .interpolation(.none) // important: keeps the QR edges sharp - .resizable() - .frame(width: 200, height: 200) - .background(Color.white) // ensures good scanning contrast - } else { - Text("Failed to generate QR Code") - .foregroundColor(.red) - } - } - .padding() Text(package.packageName) .font(.headline) Text(package.packageVersion) diff --git a/mac/Config/ConfigTests/ConfigTests.swift b/mac/Config/Config/ConfigTests/ConfigTests.swift similarity index 100% rename from mac/Config/ConfigTests/ConfigTests.swift rename to mac/Config/Config/ConfigTests/ConfigTests.swift diff --git a/mac/Config/Config/HelpView.swift b/mac/Config/Config/HelpView.swift new file mode 100644 index 00000000000..d4f603364e3 --- /dev/null +++ b/mac/Config/Config/HelpView.swift @@ -0,0 +1,34 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Gabriel Schantz on 2026-08-03 + * + * Webview used to show help for Keyman keyboards + */ +import Foundation + +import SwiftUI +import WebKit +import KeymanSettings + +public struct HelpView: NSViewRepresentable { + let helpFileURL: URL + + // create the AppKit view instance + public func makeNSView(context: Context) -> WKWebView { + let webView = WKWebView() + return webView + } + + // update the view when SwiftUI state changes + public func updateNSView(_ nsView: WKWebView, context: Context) { + let request = URLRequest(url: helpFileURL) + + // only load the request if it's not already loading/loaded to prevent infinite loops + if nsView.url != helpFileURL { + if let helpUrl = request.url { + nsView.loadFileURL(helpUrl, allowingReadAccessTo: helpUrl.deletingLastPathComponent()) + } + } + } +} diff --git a/mac/Config/Config/IconButtonView.swift b/mac/Config/Config/IconButtonView.swift new file mode 100644 index 00000000000..49bc963cd11 --- /dev/null +++ b/mac/Config/Config/IconButtonView.swift @@ -0,0 +1,46 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Gabriel Schantz on 2026-07-03 + * + * The view used for image-only buttons + */ + +import SwiftUI + +public struct IconButtonView: View { + let action: () -> Void + let systemImage: String + let font: Font + let helpText: String + + public var body: some View { + + Button { + action() + } label: { + Image(systemName: systemImage) + .font(font) + } + .buttonStyle(.plain) + .accessibilityLabel(helpText) + .help(helpText) + + } +} + +// the view for buttons with a label +public struct LabelButtonView: View { + let action: () -> Void + let label: String + let systemImage: String + let font: Font + + public var body: some View { + Button(action: action) { + Label(label, systemImage: systemImage) + .font(font) + } + .buttonStyle(.bordered) + } +} diff --git a/mac/Config/Config/InstallationViews/CompletedInstallView.swift b/mac/Config/Config/InstallationViews/CompletedInstallView.swift new file mode 100644 index 00000000000..79f4f6f6777 --- /dev/null +++ b/mac/Config/Config/InstallationViews/CompletedInstallView.swift @@ -0,0 +1,44 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Eli Schantz on 2026-07-01 + * + * View used for notifying the user that installation is complete. + */ + +import SwiftUI + +struct CompletedInstallView: View { + @EnvironmentObject var installation: InstallationContainer + let namespace: Namespace.ID + + var versionText: String { + if let version = installation.installationState?.keymanVersion { + return "Version: \(version)" + } else { + return "No version to display" + } + } + + var body: some View { + VStack { + + Spacer() + + KeymanLogo(namespace: namespace) + Text(versionText) + .foregroundStyle(.secondary) + + Spacer() + + GradientDivider(namespace: namespace) + .padding(.bottom, 8) + HStack { + Text("Installation complete") + .font(.title2) + .frame(maxWidth: .infinity, alignment: .leading) + NavigationButton(action: .dismiss) + } + } + } +} diff --git a/mac/Config/Config/InstallationViews/EnableInputMethodView.swift b/mac/Config/Config/InstallationViews/EnableInputMethodView.swift new file mode 100644 index 00000000000..2b5c229ba18 --- /dev/null +++ b/mac/Config/Config/InstallationViews/EnableInputMethodView.swift @@ -0,0 +1,66 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Eli Schantz on 2026-07-01 + * + * View used for directing the user to enable the Keyman input method. + */ + +import SwiftUI + +struct EnableInputMethodView: View { + @EnvironmentObject var installation: InstallationContainer + let namespace: Namespace.ID + let onContinue: () -> Void + @State var enableButtonPressed : Bool = false + + var body: some View { + VStack { + Text("Enable Keyman") + .font(.title) + .bold() + .frame(maxWidth: .infinity, alignment: .center) + .matchedGeometryEffect(id: "title", in: namespace) + GradientDivider(namespace: namespace) + + Form { + Section { + HStack { + Spacer() + Image("enable-keyman") + .interpolation(.high) + .resizable() + .scaledToFit() + .frame(maxHeight: 200) + Spacer() + } + Text("To use Keyman, enable the Keyman input method in System Settings.") + .lineSpacing(6) + .foregroundStyle(.secondary) + } + } + .formStyle(.grouped) + .padding(.top, 25) + + HStack { + + Spacer() + + Button { + enableButtonPressed = true + installation.executeCurrentInstallationTask() + } label: { + Text("Enable") + .padding(.horizontal, 16) + .padding(.vertical, 4) + } + .buttonStyle(.borderedProminent) + .tint(.blue) + .clipShape(Capsule()) + .matchedGeometryEffect(id: "actionButton", in: namespace) + NavigationButton(action: .advance, onContinue: onContinue) + .disabled(!enableButtonPressed) + } + } + } +} diff --git a/mac/Config/Config/InstallationViews/GradientDivider.swift b/mac/Config/Config/InstallationViews/GradientDivider.swift new file mode 100644 index 00000000000..b59a884e077 --- /dev/null +++ b/mac/Config/Config/InstallationViews/GradientDivider.swift @@ -0,0 +1,30 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Eli Schantz on 2026-07-01 + * + * View used for creating a thin divider with a Keyman color gradient. + */ + +import SwiftUI + +struct GradientDivider: View { + let namespace: Namespace.ID + var id: String = "divider" + + var body: some View { + Rectangle() + .fill(LinearGradient( + colors: [ + Color(.keymanBlue), + Color(.keymanOrange), + Color(.keymanRed) + ], + startPoint: .leading, + endPoint: .trailing + )) + .frame(height: 2) + .opacity(0.5) + .matchedGeometryEffect(id: id, in: namespace) + } +} diff --git a/mac/Config/Config/InstallationViews/GrantAccessibiltyPermissionView.swift b/mac/Config/Config/InstallationViews/GrantAccessibiltyPermissionView.swift new file mode 100644 index 00000000000..c47b39d706b --- /dev/null +++ b/mac/Config/Config/InstallationViews/GrantAccessibiltyPermissionView.swift @@ -0,0 +1,132 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Eli Schantz on 2026-07-03 + * + * View used for directing the user to grant Keyman accessibility permission. + */ + +import SwiftUI +import AppKit + +func openAccessibilitySettings() { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") { + NSWorkspace.shared.open(url) + } +} + +struct GrantAccessibiltyPermissionView: View { + @EnvironmentObject var installation: InstallationContainer + let namespace: Namespace.ID + let onContinue: () -> Void + + /** + * The flow of this view depends on the following @State variables. + * Once the user presses "Open Settings" in order to toggle the security permission they will be allowed to continue. + * When they press "Continue," a loading symbol will run until the view receives a notification of whether the access is + * granted or not. If not granted, an error message will appear. If granted, the user will be moved to the next screen. + */ + + // Tracks if the user clicked "Open Settings" (Enables the "Continue" button) + @State var openSettingsButtonPressed: Bool = false + // Tracks if the app is currently running the background permission check + @State var checkingPermission: Bool = false + // Tracks if the user clicked "Continue" but permission is still missing + @State var permissionNotGrantedAfterPrompt: Bool = false + + var body: some View { + VStack { + Text("Grant Accessibility Permission") + .font(.title) + .bold() + .frame(maxWidth: .infinity, alignment: .center) + .matchedGeometryEffect(id: "title", in: namespace) + GradientDivider(namespace: namespace) + + Form { + Section { + Image("accessibility-permission") + .interpolation(.high) + .resizable() + .aspectRatio(contentMode: .fit) + .padding(.bottom, 8) + VStack(alignment: .leading, spacing: 8) { + Text("Ensure Keyman.app is set to provide it with necessary control in System Settings > Privacy & Security > Accessibility.") + .lineSpacing(6) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) // prevents vertical compression + .foregroundStyle(.secondary) + } + } + } + .formStyle(.grouped) + .frame(maxHeight: .infinity, alignment: .center) + .padding(.top, 10) + + HStack { + + Spacer() + + if checkingPermission { + HStack { + // Shows spinner AKA ProgressView() + ProgressView() + .controlSize(.small) + + Text("Checking...") + } + } else if permissionNotGrantedAfterPrompt { + Text("Access has not been granted.") + .foregroundStyle(Color.red) + .padding(7) + .background(.thinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + + Button { + if installation.currentTask()?.taskType == .requestAccess { + installation.executeCurrentInstallationTask() + openSettingsButtonPressed = true + } else { + openAccessibilitySettings() + openSettingsButtonPressed = true + } + } label: { + Text("Open Settings") + .padding(.horizontal, 16) + .padding(.vertical, 4) + } + .buttonStyle(.borderedProminent) + .tint(.blue) + .clipShape(Capsule()) + .matchedGeometryEffect(id: "actionButton", in: namespace) + + Button { + // Trigger the system task to check for accessibility permission + checkingPermission = true + installation.executeCurrentInstallationTask() + } label: { + Text("Continue") + .padding(.horizontal, 16) + .padding(.vertical, 4) + } + .disabled(!openSettingsButtonPressed) + .clipShape(Capsule()) + } + } + // Triggered when the system confirms accessibility has been granted. + .onReceive( NotificationCenter.default.publisher(for: .checkAccessibilitySuccess)) { notification in + withAnimation(.smooth) { + permissionNotGrantedAfterPrompt = false + onContinue() // Moves the user to the next screen + } + } + // Triggered when the system confirms accessibility has not been granted. + .onReceive( NotificationCenter.default.publisher(for: .checkAccessibilityFailure)) { notification in + withAnimation(.smooth) { + checkingPermission = false // Stops showing the loading spinner + permissionNotGrantedAfterPrompt = true // Shows the red error text + } + } + } +} diff --git a/mac/Config/Config/InstallationViews/InitialInstallView.swift b/mac/Config/Config/InstallationViews/InitialInstallView.swift new file mode 100644 index 00000000000..69bbc12d36a --- /dev/null +++ b/mac/Config/Config/InstallationViews/InitialInstallView.swift @@ -0,0 +1,65 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Eli Schantz on 2026-07-01 + * + * View used for providing an opening screen after the installation package has been run + */ + +import SwiftUI +import Foundation + + +struct InitialInstallView: View { + @EnvironmentObject var installation: InstallationContainer + + let namespace: Namespace.ID + let onContinue: () -> Void + + var versionText: String { + if let version = installation.installationState?.keymanVersion { + return "Version: \(version)" + } else { + return "No version to display" + } + } + + var body: some View { + + VStack { + + Spacer() + + KeymanLogo(namespace: namespace) + + + Text(versionText) + .foregroundStyle(.secondary) + + Spacer() + + GradientDivider(namespace: namespace) + .padding(.bottom, 8) + + HStack { + Text("Proceed to continue with installation") + .font(.title2) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .leading) + NavigationButton(action: .advance, onContinue: onContinue) + } + } + } +} + +struct InitialInstallView_Previews: PreviewProvider { + @Namespace static var namespace + + static var previews: some View { + InitialInstallView( + namespace: namespace, + onContinue: { } + ) + .environmentObject(InstallationContainer()) + } +} diff --git a/mac/Config/Config/InstallationViews/InitialRepairView.swift b/mac/Config/Config/InstallationViews/InitialRepairView.swift new file mode 100644 index 00000000000..745dabd1a2a --- /dev/null +++ b/mac/Config/Config/InstallationViews/InitialRepairView.swift @@ -0,0 +1,52 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Eli Schantz on 2026-07-27 + * + * View used for providing an opening screen for the repair phase. + */ + +import SwiftUI +import AppKit +internal import UniformTypeIdentifiers + +struct InitialRepairView: View { + @EnvironmentObject var installation: InstallationContainer + let namespace: Namespace.ID + let onContinue: () -> Void + + var body: some View { + VStack { + Label("Repairs Required", systemImage: "hand.raised.fill") + .font(.title) + .bold() + .frame(maxWidth: .infinity, alignment: .center) + .matchedGeometryEffect(id: "title", in: namespace) + GradientDivider(namespace: namespace) + + Form { + HStack { + Spacer() + Image(systemName: "hammer.circle.fill") + .font(.system(size: 100)) + .padding(.bottom, 16) + Spacer() + } + Text("One or more Keyman components or permissions require your attention. Complete the following steps to restore your Keyman installation.") + .multilineTextAlignment(.center) + } + .formStyle(.grouped) + .padding(.top, 50) + + HStack { + Text("Resolve Issues") + .font(.title2) + .frame(maxWidth: .infinity, alignment: .leading) + NavigationButton(action: .advance, onContinue: onContinue) + .buttonStyle(.borderedProminent) + .tint(.blue) + .clipShape(Capsule()) + } + } + } +} diff --git a/mac/Config/Config/InstallationViews/KeymanLogo.swift b/mac/Config/Config/InstallationViews/KeymanLogo.swift new file mode 100644 index 00000000000..f91c5860eb6 --- /dev/null +++ b/mac/Config/Config/InstallationViews/KeymanLogo.swift @@ -0,0 +1,23 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Eli Schantz on 2026-07-02 + * + * View used to display the large Keyman logo. + */ + +import SwiftUI + +struct KeymanLogo: View { + let namespace: Namespace.ID + + var body: some View { + Image("keyman-logo") + .interpolation(.high) + .resizable() + .scaledToFit() + .frame(height: 60) + .frame(maxWidth: .infinity, alignment: .center) + .matchedGeometryEffect(id: "title", in: namespace, anchor: .top) + } +} diff --git a/mac/Config/Config/InstallationViews/MainInstallView.swift b/mac/Config/Config/InstallationViews/MainInstallView.swift new file mode 100644 index 00000000000..97236988f0a --- /dev/null +++ b/mac/Config/Config/InstallationViews/MainInstallView.swift @@ -0,0 +1,111 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Eli Schantz on 2026-06-29 + * + * Main view used for configuring Keyman + */ + +import SwiftUI + +enum InstallPage: String, CaseIterable { + case loading + case initialInstall + case initialRepair + case completed + case enableInputMethod + case allowSecurityPermission + case rerunInstaller + case restartMac +} + +struct MainInstallView: View { + @EnvironmentObject var installation: InstallationContainer + /** + * A namespace is created here and passed to child views. + * Any subviews with the same string id and this namespace + * will animate smoothly when changing positions or states. + */ + @Namespace var animation + @State public var currentPage: InstallPage = .loading + + /** + * chooseCurrentPage() will update the @State var currentPage according to the current task. + * If there is a task involved with the installationPhase, it will display the page associated with that task. + * If there is not a task involved with the installationPhase, it will display the page associated with that phase. + */ + + func chooseCurrentPage() { + if installation.installationPhase.hasTasks { + switch installation.currentTask()?.taskType { + case .prepareNewInstall: currentPage = .initialInstall + case .prepareNewRepair: currentPage = .initialRepair + case .enableInputMethod: currentPage = .enableInputMethod + case .requestAccess: currentPage = .allowSecurityPermission + case .confirmAccess: currentPage = .allowSecurityPermission + case .requestRestart: currentPage = .restartMac + case .confirmRestart: currentPage = .restartMac + default: currentPage = .completed + } + } else { + switch installation.installationPhase { + case .evaluatingInstallation: currentPage = .loading + case .inputMethodMissing, .inputMethodOutdated: currentPage = .rerunInstaller + case .installationComplete: + currentPage = .completed + if !installation.getHasDisplayedInstallationComplete() { + installation.setHasDisplayedInstallationComplete() + } + default: + currentPage = .completed + } + } + + } + + var body: some View { + + VStack { + // The switch statement below updates the view this VStack contains whenever currentPage changes value + switch currentPage { + case .loading: ProgressView() + case .initialInstall: InitialInstallView(namespace: animation,onContinue: { + installation.executeCurrentInstallationTask() + chooseCurrentPage() + }) + case .initialRepair: InitialRepairView(namespace: animation,onContinue: { + installation.executeCurrentInstallationTask() + chooseCurrentPage() + }) + case .completed: CompletedInstallView(namespace: animation) + case .enableInputMethod: EnableInputMethodView(namespace: animation, onContinue: chooseCurrentPage) + case .allowSecurityPermission: GrantAccessibiltyPermissionView(namespace: animation, onContinue: chooseCurrentPage) + case .rerunInstaller: RerunInstallerView(namespace: animation) + case .restartMac: RestartComputerView(namespace: animation) + } + } + // While the installer is evaluating the Keyman installation, the loading screen will be shown + .onAppear { + if installation.installationPhase == .evaluatingInstallation { + currentPage = .loading + Task { + while installation.installationPhase == .evaluatingInstallation { + try? await Task.sleep(for: .milliseconds(200)) + } + await MainActor.run { + withAnimation(.smooth) { + chooseCurrentPage() + } + } + } + } else { + chooseCurrentPage() + } + } + .padding() + .frame( + minWidth: 600, idealWidth: 600, maxWidth: 600, + minHeight: 500, idealHeight: 500, maxHeight: 500 + ) + } +} diff --git a/mac/Config/Config/InstallationViews/NavigationButton.swift b/mac/Config/Config/InstallationViews/NavigationButton.swift new file mode 100644 index 00000000000..ae6632d7b4c --- /dev/null +++ b/mac/Config/Config/InstallationViews/NavigationButton.swift @@ -0,0 +1,48 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Eli Schantz on 2026-07-02 + * + * View used to display a simple continue or close button. + */ + +import SwiftUI +import Combine + +enum ButtonAction { + case advance + case dismiss +} + +struct NavigationButton: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject var installation: InstallationContainer + + var action: ButtonAction = .advance + var onContinue: () -> Void = {} + + var body: some View { + Button { + switch action { + case .advance: + withAnimation(.smooth) { + onContinue() + } + case .dismiss: + dismiss() + } + } label: { + switch action { + case .advance: + Text("Continue") + .padding(.horizontal, 16) + .padding(.vertical, 4) + case .dismiss: + Text("Close") + .padding(.horizontal, 16) + .padding(.vertical, 4) + } + } + .clipShape(Capsule()) + } +} diff --git a/mac/Config/Config/InstallationViews/RerunInstallerView.swift b/mac/Config/Config/InstallationViews/RerunInstallerView.swift new file mode 100644 index 00000000000..9cc1872cecb --- /dev/null +++ b/mac/Config/Config/InstallationViews/RerunInstallerView.swift @@ -0,0 +1,49 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Eli Schantz on 2026-07-21 + * + * View used for directing the user to rerun the installer. + * This is in the case that the input method needs to be restored. + */ + +import SwiftUI +import AppKit +internal import UniformTypeIdentifiers + +struct RerunInstallerView: View { + @EnvironmentObject var installation: InstallationContainer + let namespace: Namespace.ID + + var body: some View { + VStack { + Text("Missing Keyman Components") + .font(.title) + .bold() + .frame(maxWidth: .infinity, alignment: .center) + .matchedGeometryEffect(id: "title", in: namespace) + GradientDivider(namespace: namespace) + + Form { + HStack { + Spacer() + Image(systemName: "wrench.and.screwdriver.fill") + .font(.system(size: 100)) + .padding(.bottom, 16) + Spacer() + } + Text("Your Keyman input method is either missing or outdated. Run the Keyman installer to install a new version.") + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + } + .formStyle(.grouped) + + HStack { + Text("Run Keyman installer") + .font(.title2) + .frame(maxWidth: .infinity, alignment: .leading) + NavigationButton(action: .dismiss) + } + } + } +} diff --git a/mac/Config/Config/InstallationViews/RestartComputerView.swift b/mac/Config/Config/InstallationViews/RestartComputerView.swift new file mode 100644 index 00000000000..c5cab61c28e --- /dev/null +++ b/mac/Config/Config/InstallationViews/RestartComputerView.swift @@ -0,0 +1,47 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Eli Schantz on 2026-07-21 + * + * View used for directing the user to restart their mac. + */ + +import SwiftUI + +struct RestartComputerView: View { + @EnvironmentObject var installation: InstallationContainer + let namespace: Namespace.ID + + var body: some View { + VStack { + Text("Restart Computer") + .font(.title) + .bold() + .frame(maxWidth: .infinity, alignment: .center) + .matchedGeometryEffect(id: "title", in: namespace) + + Spacer() + + Image(systemName: "restart.circle.fill") + .font(.system(size: 100)) + .padding(16) + Text("Restart your Mac to complete the installation. After restarting, open Keyman Configuration again if it doesn't launch automatically.") + .multilineTextAlignment(.center) + .padding(.bottom, 8) + + Spacer() + + GradientDivider(namespace: namespace) + .padding(.bottom, 8) + HStack { + Text("Finish installation") + .font(.title2) + .frame(maxWidth: .infinity, alignment: .leading) + NavigationButton(action: .dismiss) + } + } + .onAppear { + installation.executeCurrentInstallationTask() + } + } +} diff --git a/mac/Config/Config/MainConfigView.swift b/mac/Config/Config/MainConfigView.swift new file mode 100644 index 00000000000..66f53c5aeb6 --- /dev/null +++ b/mac/Config/Config/MainConfigView.swift @@ -0,0 +1,88 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Gabriel Schantz on 2026-06-29 + * + * Main view used for configuring Keyman + * MAC-CONFIG-TODO: Set default width and height for window + */ + +import SwiftUI +import KeymanSettings + +struct MainConfigView: View { + + @EnvironmentObject var settings: SettingsContainer + // visibilty state for the add package sheet + @State private var isShowingSheet = false + // used to identify the expanded KeymanPackage id + // both single and multi package views share the same state variable so only a single disclosure group is expanded at once + @State private var expandedPackageID: UUID? = nil + @State private var selectedTab = 0 + @State private var packageSelectedForHelpUrl: URL? = nil + + /** + * Assigns packageSelectedForHelpUrl the url argument and changes the selected tab to the help tab + */ + public func showHelpTab(for url: URL) { + packageSelectedForHelpUrl = url + selectedTab = 1 + } + + var body: some View { + TabView (selection: $selectedTab) { + VStack { + // the add keyboard button + LabelButtonView( + action: { isShowingSheet = true }, + label: "Add Keyboard", + systemImage: "plus", + font: .title2 + ) + .clipShape(.capsule) + .padding([.top, .leading, .trailing]) + // binds the visibility state to the sheet builder + .sheet(isPresented: $isShowingSheet) { + InstallKeyboardView() + .frame(width: 960, height: 390) + // MAC-CONFIG-TODO: Make width and height percentages + } + + Form { + // the view for single keyboard packages + PackageRowView(packages: settings.singleKeyboardPackages, isSingleKeyboardPackage: true, expandedPackageID: $expandedPackageID, showHelpTab: { url in + showHelpTab(for: url)}) + + // the view for multi keyboard packages + PackageRowView(packages: settings.multiKeyboardPackages, isSingleKeyboardPackage: false, expandedPackageID: $expandedPackageID, showHelpTab: { url in + showHelpTab(for: url) }) + } + .formStyle(.grouped) + + // the Spacer pushes the contents of the VStack to the top of the VStack + Spacer() + } + .padding([.leading, .trailing, .bottom]) + .tabItem { Text("Keyboards") } + .tag(0) + + if let url = packageSelectedForHelpUrl { + HelpView(helpFileURL: url) + .padding() + .tabItem { Text("Help") } + .tag(1) + } else { + Text("Help not available.") + .font(.title) + .tabItem { Text("Help") } + .tag(1) + } + } + } +} + +#Preview { + let settings = SettingsContainer() + MainConfigView() + .environmentObject(settings) +} diff --git a/mac/Config/Config/PackageInfoView.swift b/mac/Config/Config/PackageInfoView.swift new file mode 100644 index 00000000000..f56dd435ad0 --- /dev/null +++ b/mac/Config/Config/PackageInfoView.swift @@ -0,0 +1,128 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Gabriel Schantz on 2026-07-20 + * + * The view used to display package info inside the disclosure group opened from PackageRowView + */ + +import SwiftUI +import KeymanSettings + +public struct PackageInfoView: View { + + let package: KeymanPackage + // closure passed from the parent view + let showAlertFunction: (KeymanPackage) -> Void + /** + * Copies the text argument to the system clipboard + */ + private func copyTextToClipboard (text: String) -> Void { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + } + + public var body: some View { + + HStack (alignment: .top) { + + // the custom package image + if let packageImage = package.graphicImage { + Image(nsImage: packageImage) + .resizable() + .frame(maxWidth: 84, maxHeight: 150) + } + + VStack (alignment: .leading) { + // the text-based package properties presented in a grid + Grid(horizontalSpacing: 10, verticalSpacing: 5) { + + // the package version + GridRow { + Text("Package Version:").bold() + .gridColumnAlignment(.trailing) // all elements underneath inherit the .trailing alignment + Text(package.packageVersion) + .gridColumnAlignment(.leading) // all elements underneath inherit the .leading alignment + } + + // the fonts + GridRow { + Text("Fonts:").bold() + HStack { + ForEach(package.fonts, id: \.self) { font in + Text(font) + } + } + } + + // the copyright + GridRow { + Text("Copyright:").bold() + Text(package.copyright ?? "") + } + + // the author + GridRow { + Text("Author:").bold() + Text(package.author ?? "") + } + + // the website + GridRow { + Text("Website:").bold() + if let websiteUrl = package.websiteUrl { + Link(destination: websiteUrl) { + Text(websiteUrl.absoluteString) + .underline() + .multilineTextAlignment(.leading) + } + } + } + } + .padding(5) + + // uninstall button + Button { + showAlertFunction(package) + } label: { + Text("Remove Package") + } + .padding(.horizontal) + } + + Spacer() + + // the package QR Code and link to share the package online + VStack { + let qrCodeSize: CGFloat = 106 + if let qrCode = package.getSharePackageQRCode(for: qrCodeSize) { + + // the package QR Code + Image(nsImage: qrCode) + .interpolation(.none) // important: ensures the edges of the QR Code remain sharp + .resizable() + .frame(width: qrCodeSize, height: qrCodeSize) + .background(Color.white) // ensures good contrast for scanning + } + + if let sharePackageUrl = package.sharePackageUrl { + HStack { + + // the link to share the package online + Link(destination: sharePackageUrl) { + Text("Share Keyboard") + .underline() + } + + // the button to copy the link to share the package online + IconButtonView(action: { copyTextToClipboard(text: sharePackageUrl.absoluteString) }, systemImage: "doc.on.doc", font: .body , helpText: "Copy link") + } + } + } + .padding(5) + .border(Color.black, width: 1) + } + .frame(height: 150) + } +} diff --git a/mac/Config/Config/PackageRowView.swift b/mac/Config/Config/PackageRowView.swift new file mode 100644 index 00000000000..b607412636e --- /dev/null +++ b/mac/Config/Config/PackageRowView.swift @@ -0,0 +1,177 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Gabriel Schantz on 2026-07-27 + * + * Displays an array of Keyman Packages as disclosure groups + * If the argument isSingleKeyboardPackage is true, displays information in a format suited to a single keyboard, + * otherwise displays information in a format suited to a multi-keyboard package + */ + +import SwiftUI +import Combine +import KeymanSettings + +public struct PackageRowView: View { + + @EnvironmentObject var settings: SettingsContainer + // visibilty state for the delete package alert + @State private var isShowingDeleteAlert = false + // used to identify the selected KeymanPackage for the delete package alert + @State private var selectedPackage: KeymanPackage? = nil + + // settings.singleKeyboardPackages or settings.multiKeyboardPackages + let packages: [KeymanPackage] + // a boolean for whether or not a package contains multiple keyboards + let isSingleKeyboardPackage: Bool + // binded to the shared state variable in the parent view + @Binding var expandedPackageID: UUID? + // closure passed from the parent view + let showHelpTab: (URL) -> Void + + init(packages: [KeymanPackage], isSingleKeyboardPackage: Bool, expandedPackageID: Binding, showHelpTab: @escaping (URL) -> Void) { + self.packages = packages + self.isSingleKeyboardPackage = isSingleKeyboardPackage + self._expandedPackageID = expandedPackageID + self.showHelpTab = showHelpTab + } + + /** + * Sets isShowingDeleteAlert to true and assigns the state variable selectedPackage the KeymanPackage argument + */ + public func showDeleteAlert(for package: KeymanPackage) { + isShowingDeleteAlert = true + selectedPackage = package + } + + public var body: some View { + ForEach(packages, id: \.id) { package in + ForEach(isSingleKeyboardPackage ? package.keyboards : package.keyboards.onlyFirst) { keyboard in + DisclosureGroup(isExpanded: isExpanded(package: package)) { + // the package info view is shown inside each disclosure group + if expandedPackageID == package.id { + PackageInfoView(package: package, showAlertFunction: { package in + showDeleteAlert(for: package) + }) + .transition(.move(edge: .top)) + } + } label: { + // a VStack is shown as the label for each disclosure group + VStack (alignment: .leading, spacing: 0) { + HStack { + // if the package contains one keyboard, show the keyboard name, otherwise show the package name + Text(isSingleKeyboardPackage ? keyboard.name: package.packageName) + .font(.title) + + // see keyboard help button + if let url = package.helpFileUrl { + IconButtonView( + action: { showHelpTab(url) }, + systemImage: "questionmark.circle", + font: .title2, + helpText: "Show keyboard help" + ) + } + + // the Spacer pushes the contents of the HStack to the either edge + Spacer() + + // if the package contains one keyboard shows the toggle button for the keyboard + if isSingleKeyboardPackage { + Toggle("enabled", isOn: isEnabled(packageId: package.id, keyboardKey: keyboard.keyboardKey)) + .controlSize(.mini) + .labelsHidden() + .toggleStyle(.switch) + .gridColumnAlignment(.leading) + } + + + } + + // if the package contains multiple keyboards shows an HStack with the keyboard name and toggle button for each keyboard in the package + if !isSingleKeyboardPackage { + ForEach (package.keyboards) { keyboard in + HStack { + Text(keyboard.name) + .font(.title2) + .foregroundStyle(.primary) + .gridColumnAlignment(.leading) + + // the Spacer pushes the other views inside the HStack to the opposite edge + Spacer() + + // the toggle button for the keyboard + Toggle("enabled", isOn: isEnabled(packageId: package.id, keyboardKey: keyboard.keyboardKey)) + .controlSize(.mini) + .labelsHidden() + .toggleStyle(.switch) + .gridColumnAlignment(.leading) + } + } + } + } + .contentShape(Rectangle()) + // handles when the HStack is clicked by the user + .onTapGesture { + withAnimation () { + if self.expandedPackageID == package.id { + self.expandedPackageID = nil + } else { + self.expandedPackageID = package.id + } + } + } + } + } + } + // binds the visibilty state to the alert builder + .alert("Are you sure you want to delete the keyboard \"\(selectedPackage?.packageName ?? "")\"?", + isPresented: $isShowingDeleteAlert, + presenting: selectedPackage) { package in + // cancel button + Button("Cancel", role: .cancel) { } + // delete button + Button("Delete", role: .destructive) { + settings.removeInstalledPackage(with: package.id) + } + } message: { package in + Text("You can't undo this action.") + } + } + + // the helper method to generate the custom binding for whether a package's disclosure group is expanded or not + func isExpanded(package: KeymanPackage) -> Binding { + Binding( + // the getter renders the position of the disclosure group + get: { expandedPackageID == package.id }, + // setter handles when the chevron arrow is clicked by the user + // $0 = true when disclosure group is open and $0 = false when disclosure group is closed + set: { isExpanded in + withAnimation { + expandedPackageID = isExpanded ? package.id : nil } + } + ) + } + + // the helper method to generate the custom binding for whether a keyboard is enabled or not + func isEnabled(packageId: UUID, keyboardKey: String) -> Binding { + Binding( + // the getter renders the state of the toggle button based on the enabled property of the keyboard + get: { settings.isKeyboardEnabled(packageId: packageId, keyboardKey: keyboardKey) }, + // the setter handles when the toggle button is clicked by the user + // $0 = true when the toggle button is on and $0 = false when the toggle button is off + set: { + settings.setKeyboardEnabled(packageId: packageId, keyboardKey: keyboardKey, enabled: $0) + settings.objectWillChange.send() + } + ) + } +} + +extension Collection { + // returns the first element of an array in an array or returns an empty array + var onlyFirst: [Element] { + guard let first = self.first else { return [] } + return [first] + } +} diff --git a/mac/Config/Installation/InstallationCheck.swift b/mac/Config/Installation/InstallationCheck.swift index 55c2139488d..f37cb19b988 100644 --- a/mac/Config/Installation/InstallationCheck.swift +++ b/mac/Config/Installation/InstallationCheck.swift @@ -304,7 +304,7 @@ public class InstallationCheck { */ func completeNewInstallationEvaluation(accessibilityPermissionGranted: Bool) { // see what tasks remain based on the evaluation - let neededTasks = determineInstallationTasksNeeded(for: accessibilityPermissionGranted) + let neededTasks = determineInstallationTasksNeeded(isRepair: false, with: accessibilityPermissionGranted) let newState = self.createNewInstallationState(with: neededTasks) self.applyNewInstallationState(state: newState) } @@ -316,12 +316,9 @@ public class InstallationCheck { print("completeNewInstallationEvaluation: created new installation state") var fullTaskList = neededTasks - // add prepareNewInstall, requestRestart and confirmRestart InstallationTask + // add prepareNewInstall InstallationTask fullTaskList.insert(InstallationTask.createNewInstallationTask(type: .prepareNewInstall)) - // MAC-CONFIG_TODO: should we always restart for a new install or only when enabling input method? - fullTaskList.insert(InstallationTask.createNewInstallationTask(type: .requestRestart)) - fullTaskList.insert(InstallationTask.createNewInstallationTask(type: .confirmRestart)) let installationState = InstallationState(version: self.inputMethodVersion, tasks: fullTaskList) return installationState @@ -363,7 +360,7 @@ static func readInstallationState(from repo: DefaultsRepo) -> InstallationState? * The provided parameter `accessibilityPermissionGranted` was returned asynchronously from the input method. * Use it and other info to see what tasks are needed to complete installation. */ - func determineInstallationTasksNeeded(for accessibilityPermissionGranted: Bool) -> Set { + func determineInstallationTasksNeeded(isRepair: Bool, with accessibilityPermissionGranted: Bool) -> Set { var newTasks = Set() // add task to request Accessibility permission if needed @@ -376,9 +373,11 @@ static func readInstallationState(from repo: DefaultsRepo) -> InstallationState? if !self.inputMethodUtil.isKeymanInputMethodEnabled() { newTasks.insert(InstallationTask.createNewInstallationTask(type: .enableInputMethod)) - // prompt user to restart after enabling the input method - newTasks.insert(InstallationTask.createNewInstallationTask(type: .requestRestart)) - newTasks.insert(InstallationTask.createNewInstallationTask(type: .confirmRestart)) + // when repairing, prompt to restart to ensure that the input method has been loaded by the system + if (isRepair) { + newTasks.insert(InstallationTask.createNewInstallationTask(type: .requestRestart)) + newTasks.insert(InstallationTask.createNewInstallationTask(type: .confirmRestart)) + } } return newTasks @@ -392,7 +391,7 @@ static func readInstallationState(from repo: DefaultsRepo) -> InstallationState? func createRepairInstallationState(accessibilityPermissionGranted: Bool) -> InstallationState? { var repairInstallationState: InstallationState? = nil - var repairTasks = self.determineInstallationTasksNeeded(for: accessibilityPermissionGranted) + var repairTasks = self.determineInstallationTasksNeeded(isRepair: true, with: accessibilityPermissionGranted) if !repairTasks.isEmpty { // add prepareNewRepair repairTasks.insert(InstallationTask.createNewInstallationTask(type: .prepareNewRepair)) diff --git a/mac/Config/Installation/InstallationContainer.swift b/mac/Config/Installation/InstallationContainer.swift index 6ee46a1c93d..23e93cf4f80 100644 --- a/mac/Config/Installation/InstallationContainer.swift +++ b/mac/Config/Installation/InstallationContainer.swift @@ -417,7 +417,7 @@ public class InstallationContainer : ObservableObject { * register it first, just to be safe */ public func enableKeymanInputMethod() -> Bool { - var success = self.inputMethodUtil.registerInputMethod(bundleId: KeymanPaths.keymanBundleId) + var success = self.inputMethodUtil.registerKeymanInputMethod() if success { success = self.inputMethodUtil.enableKeymanInputMethod() } diff --git a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift index f15b1d22b30..78eb0ab249a 100644 --- a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift +++ b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift @@ -186,8 +186,13 @@ public class SettingsContainer : ObservableObject { * Called when user approves the downgrade of package */ public func userConfirmedPackageDowngrade() { - self.replaceInstalledPackage() - self.packageDownload = nil + if let download = self.packageDownload { + do { + try download.replaceExistingPackageWithNewPackage() + } catch { + print("unable to downgrade package: \(download.packageToInstall?.packageName ?? "unknown")") + } + } } /** @@ -195,25 +200,13 @@ public class SettingsContainer : ObservableObject { */ public func userCanceledPackageDowngrade() { if let download = self.packageDownload { - do { - try download.cancelInstallation() - } catch { - print("downgrade cancelled but failed to cancel installation") - } + print("user cancelled package downgrade") + download.cleanupFailedInstallation() } self.packageDownload = nil } - // MAC-CONFIG-TODO: delete test code - public func debug() { - self.installedPackages .forEach { package in - package.keyboards.forEach { keyboard in - print("\(keyboard.keyboardId) enabled: \(keyboard.enabled)") - } - } - } - /** * for debugging: prints UserDefaults values */ diff --git a/mac/KeymanSettings/Sources/Model/Keyboard.swift b/mac/KeymanSettings/Sources/Model/Keyboard.swift index d67e2e14d6b..b368fd4b0fb 100644 --- a/mac/KeymanSettings/Sources/Model/Keyboard.swift +++ b/mac/KeymanSettings/Sources/Model/Keyboard.swift @@ -19,39 +19,31 @@ public class Keyboard: Identifiable, Hashable, Equatable { public let oskFont: String? public let displayFont: String? public var keyboardId: String - // the directory we are reading the keyboard from - public var keyboardDirectoryUrl: URL - // the URL of the .kmx file for the package - public let kmxFileUrl: URL // a key to uniquely identify the keyboard // in the UserDefaults this key is used for the selected Keyboard and enabled keyboards properties // the key is in the form "/[package directory]/[package name].kmx" // for example, "/khmer_angkor/khmer_angkor.kmx" public let keyboardKey: String - public init(keyboardSource: KeyboardSource, directoryUrl: URL) { + public init(keyboardSource: KeyboardSource, packageDirectoryName: String) { self.enabled = true self.name = keyboardSource.name self.oskFont = keyboardSource.oskFont self.displayFont = keyboardSource.displayFont self.keyboardId = keyboardSource.id - self.keyboardDirectoryUrl = directoryUrl - self.kmxFileUrl = Keyboard.deriveKmxFileUrl(from: self.keyboardDirectoryUrl, keyboardId: self.keyboardId) - self.keyboardKey = Keyboard.deriveKeyboardSettingsKey(from: self.keyboardDirectoryUrl, keyboardId: self.keyboardId) + self.keyboardKey = Keyboard.deriveKeyboardSettingsKey(from: packageDirectoryName, keyboardId: self.keyboardId) } /** * initializer that does not rely on package source -- provided to create unit test data */ - public init(name: String, oskFont: String? = nil, displayFont: String? = nil, keyboardId: String, keyboardDirectoryUrl: URL, enabled: Bool) { + public init(name: String, oskFont: String? = nil, displayFont: String? = nil, keyboardId: String, packageDirectoryName: String, enabled: Bool) { self.name = name self.oskFont = oskFont self.displayFont = displayFont self.keyboardId = keyboardId - self.keyboardDirectoryUrl = keyboardDirectoryUrl - self.kmxFileUrl = Keyboard.deriveKmxFileUrl(from: self.keyboardDirectoryUrl, keyboardId: self.keyboardId) self.enabled = enabled - self.keyboardKey = Keyboard.deriveKeyboardSettingsKey(from: self.keyboardDirectoryUrl, keyboardId: self.keyboardId) + self.keyboardKey = Keyboard.deriveKeyboardSettingsKey(from: packageDirectoryName, keyboardId: self.keyboardId) } /** @@ -71,22 +63,19 @@ public class Keyboard: Identifiable, Hashable, Equatable { /** * generate the url for the keyboard's kmx file */ - static func deriveKmxFileUrl(from keyboardDirectory: URL, keyboardId: String) -> URL { + func deriveKmxFileUrl(from keyboardDirectory: URL) -> URL { return keyboardDirectory.appendingPathComponent("\(keyboardId).kmx") } - + /** * generate the keyboard's key * see the above comment for `keyboardKey` for a description of the format of the key */ - static func deriveKeyboardSettingsKey(from keyboardDirectory: URL, keyboardId: String) -> String { - // get parent directory - let parentDirectoryName = keyboardDirectory.lastPathComponent - + static func deriveKeyboardSettingsKey(from packageDirectoryName: String, keyboardId: String) -> String { // get filename from keyboardId let kmxFilename = "\(keyboardId).kmx" - let settingsKey = "/\(parentDirectoryName)/\(kmxFilename)" + let settingsKey = "/\(packageDirectoryName)/\(kmxFilename)" return settingsKey } @@ -94,9 +83,10 @@ public class Keyboard: Identifiable, Hashable, Equatable { /** * validate whether a corresponding kmx file exists for this keyboard */ - public func validateKmxFile() throws { - if !FileManager.default.fileExists(atPath: self.kmxFileUrl.path) { - print("** error: could not find kmx file \(self.kmxFileUrl.path)") + public func validateKmxFile(in packageDirectory: URL) throws { + let kmxFilePath = self.deriveKmxFileUrl(from: packageDirectory).path + if !FileManager.default.fileExists(atPath: kmxFilePath) { + print("** error: could not find kmx file \(kmxFilePath)") throw LoadPackageError.missingKmxFile } } diff --git a/mac/KeymanSettings/Sources/Model/KeymanPackage.swift b/mac/KeymanSettings/Sources/Model/KeymanPackage.swift index 569202d32e7..ffbc6bc3a29 100644 --- a/mac/KeymanSettings/Sources/Model/KeymanPackage.swift +++ b/mac/KeymanSettings/Sources/Model/KeymanPackage.swift @@ -26,10 +26,12 @@ public class KeymanPackage: Identifiable, Hashable, Equatable { public let id: UUID - // the URL of the directory in which the package is contained - public let sourceDirectoryUrl: URL - // the URL of the kmp.json file for the package - public let jsonFileUrl: URL + // The directory where this package is contained. + // It is used to delete/uninstall the package if requested by the user. + // This value only changes when the package is moved + // from the temp directory during package installation. + public var sourceDirectoryUrl: URL + // the URL for downloading the package from keyman.com public let sharePackageUrl: URL? @@ -41,19 +43,50 @@ public class KeymanPackage: Identifiable, Hashable, Equatable { public let author: String? public let websiteUrl: URL? public let copyright: String? + + // the name of the readme file used to generate the Url + let readmeFilename: String? // the URL of the readme file within the package - public let readmeFileUrl: URL? + public var readmeFileUrl: URL? { + return readmeFilename.map { sourceDirectoryUrl.appendingPathComponent($0).standardizedFileURL } + } + + // the name of the help file used to generate the Url + let helpFilename: String? // the URL of the help file within the package, named 'welcomeFile' in kmp.json - public let helpFileUrl: URL? - // the URL of the graphic file within the package - public let graphicFileUrl: URL? - public let graphicImage: NSImage? + public var helpFileUrl: URL? { + return helpFilename.map { sourceDirectoryUrl.appendingPathComponent($0).standardizedFileURL } + } + + // the name of the graphicFile used to generate the Url + let graphicFilename: String? + // a cache of the image + private var cachedGraphicImage: NSImage? + private var graphicFileUrl: URL? { + return graphicFilename.map { sourceDirectoryUrl.appendingPathComponent($0).standardizedFileURL } + } + public var graphicImage: NSImage? { + get { + if let cachedImage = self.cachedGraphicImage { + return cachedImage + } else { + let newImage = KeymanPackage.loadImage(imageUrl: self.graphicFileUrl) + self.cachedGraphicImage = newImage + return newImage + } + } + } + + // the qrCode image does not change size so a single cached image per package is sufficient + var qrCodeImageCache: (image: NSImage, size: CGFloat)? = nil /** - * create a KeymanPackage object using the PackageSource object created from the kmp.json + * create a KeymanPackage object using the location of the package and the PackageSource object created from the kmp.json */ - init(packageSource: PackageSource) { + init(packageUrl: URL, packageSource: PackageSource) { self.id = UUID() + self.sourceDirectoryUrl = packageUrl + self.packageName = packageSource.info.name.description self.packageVersion = packageSource.info.version.description self.author = packageSource.info.author?.description @@ -63,43 +96,29 @@ public class KeymanPackage: Identifiable, Hashable, Equatable { self.websiteUrl = nil } self.copyright = packageSource.info.copyright?.description - self.sourceDirectoryUrl = packageSource.directoryUrl! - self.jsonFileUrl = packageSource.kmpJsonFileUrl! - if let readmeFilename = packageSource.readmeFilename { - let fileUrl = sourceDirectoryUrl.appendingPathComponent(readmeFilename) - self.readmeFileUrl = fileUrl - } else { - self.readmeFileUrl = nil - } - - if let helpFilename = packageSource.helpFilename { - let fileUrl = sourceDirectoryUrl.appendingPathComponent(helpFilename) - self.helpFileUrl = fileUrl - } else { - self.helpFileUrl = nil - } - - self.graphicFileUrl = KeymanPackage.buildGraphicFileUrl(source: packageSource) - self.graphicImage = KeymanPackage.loadImage(imageUrl: self.graphicFileUrl) + self.readmeFilename = packageSource.readmeFilename + self.helpFilename = packageSource.helpFilename + self.graphicFilename = packageSource.graphicFilename self.sharePackageUrl = KeymanPackage.buildSharePackageUrl(packageUrl: self.sourceDirectoryUrl) - let keyboardsArray = KeymanPackage.buildKeyboardsArray(packageSource: packageSource) + let packageDirectory = packageUrl.lastPathComponent + let keyboardsArray = KeymanPackage.buildKeyboardsArray(packageSource: packageSource, packageDirectoryName: packageDirectory) self.keyboards = keyboardsArray self.fonts = KeymanPackage.buildFontNamesArray(keyboards: keyboardsArray) } /** - * build an array of Keyboard objects using the array of KeyboardSource object created from the kmp.json + * build an array of Keyboard objects using the array of KeyboardSource object created from the kmp.json and the package URL */ - private static func buildKeyboardsArray(packageSource: PackageSource) -> [Keyboard] { + private static func buildKeyboardsArray(packageSource: PackageSource, packageDirectoryName: String) -> [Keyboard] { var keyboardsArray = [Keyboard]() - if let keyboards = packageSource.keyboards, let directoryUrl = packageSource.directoryUrl { + if let keyboards = packageSource.keyboards { for keyboardSource in keyboards { - let keyboard = Keyboard(keyboardSource: keyboardSource, directoryUrl: directoryUrl) + let keyboard = Keyboard(keyboardSource: keyboardSource, packageDirectoryName: packageDirectoryName) keyboardsArray.append(keyboard) } } @@ -126,7 +145,7 @@ public class KeymanPackage: Identifiable, Hashable, Equatable { /** * initializer that does not rely on package source -- provided to create unit test data */ - public init(sourceDirectoryUrl: URL, sharePackageUrl: URL? = nil, keyboards: [Keyboard], packageName: String, packageVersion: String, author: String? = nil, website: URL? = nil, copyright: String? = nil, jsonFileUrl: URL, readmeFileUrl: URL? = nil, helpFileUrl: URL? = nil, graphicFileUrl: URL? = nil, graphicImage: NSImage? = nil) { + public init(sourceDirectoryUrl: URL, sharePackageUrl: URL? = nil, keyboards: [Keyboard], packageName: String, packageVersion: String, author: String? = nil, website: URL? = nil, copyright: String? = nil, readmeFileName: String? = nil, helpFilename: String? = nil, graphicName: String? = nil) { self.id = UUID() self.sourceDirectoryUrl = sourceDirectoryUrl self.sharePackageUrl = sharePackageUrl @@ -136,11 +155,9 @@ public class KeymanPackage: Identifiable, Hashable, Equatable { self.author = author self.websiteUrl = website self.copyright = copyright - self.jsonFileUrl = jsonFileUrl - self.readmeFileUrl = readmeFileUrl - self.helpFileUrl = helpFileUrl - self.graphicFileUrl = graphicFileUrl - self.graphicImage = graphicImage + self.readmeFilename = readmeFileName + self.helpFilename = helpFilename + self.graphicFilename = graphicName self.fonts = [] } @@ -187,23 +204,10 @@ public class KeymanPackage: Identifiable, Hashable, Equatable { public func validate() throws { // if validateKmxFile throws an error, then the loop is stopped and the error is propagated try self.keyboards.forEach { keyboard in - try keyboard.validateKmxFile() + try keyboard.validateKmxFile(in: self.sourceDirectoryUrl) } } - /** - * build the URL for the graphic file specified for the package - */ - static func buildGraphicFileUrl(source: PackageSource) -> URL? { - var fileUrl: URL? = nil - - if let graphicFilename = source.graphicFilename { - fileUrl = source.directoryUrl!.appendingPathComponent(graphicFilename) - } - - return fileUrl - } - /** * create the image specified for the package * if none specified, load the default image @@ -226,13 +230,35 @@ public class KeymanPackage: Identifiable, Hashable, Equatable { static func buildSharePackageUrl(packageUrl: URL) -> URL? { return URL(string: "https://\(KeymanPaths.keymanDomain)/go/keyboard/\(packageUrl.lastPathComponent)/share") } - - // MAC-CONFIG-TODO: cache QR code image, but must be size specific - + + /** + * get a QR code image of the specified size from the cache or generate a new one + */ + public func getSharePackageQRCode(for size: CGFloat) -> NSImage? { + var qrCodeImage: NSImage? = nil + + // if the image is already cached for the specified size, then use it + if let qrImageCache = self.qrCodeImageCache { + if size == qrImageCache.size { + qrCodeImage = qrImageCache.image + } + } + + // if no matching image cached, then create one and cache it + if qrCodeImage == nil { + if let newImage = self.generateSharePackageQRCode(for: size) { + qrCodeImage = newImage + self.qrCodeImageCache = (newImage, size) + } + } + + return qrCodeImage + } + /** - * generate a QR code for sharing the Keyman Package URL + * generate a QR code image for sharing the Keyman Package URL */ - public func generateSharePackageQRCode(size: CGFloat = 300) -> NSImage? { + func generateSharePackageQRCode(for size: CGFloat) -> NSImage? { guard let data = self.sharePackageUrl?.absoluteString.data(using: .utf8) else { return nil } // initialize the built-in Apple QR filter diff --git a/mac/KeymanSettings/Sources/Persistence/Data/PackageSource.swift b/mac/KeymanSettings/Sources/Persistence/Data/PackageSource.swift index 6e982f9c0b0..e9d4013dabf 100644 --- a/mac/KeymanSettings/Sources/Persistence/Data/PackageSource.swift +++ b/mac/KeymanSettings/Sources/Persistence/Data/PackageSource.swift @@ -10,10 +10,11 @@ import Foundation +let defaultHelpFilename = "welcome.htm" +let defaultReadmeFilename = "readme.htm" + public struct PackageSource: Identifiable, Decodable, Hashable, Equatable { public var id = UUID() - var directoryUrl: URL? - var kmpJsonFileUrl: URL? let system: SystemInfo? let options: Options? let info: Info @@ -37,16 +38,25 @@ public struct PackageSource: Identifiable, Decodable, Hashable, Equatable { var readmeFilename: String? { if let filename = options?.readmeFile { return filename - } else { - return nil } + if let fileArray = self.files { + if fileArray.contains(where: { $0.name == defaultReadmeFilename }) { + return defaultReadmeFilename + } + } + return nil } var helpFilename: String? { if let filename = options?.welcomeFile { return filename - } else { - return nil } + if let fileArray = self.files { + if fileArray.contains(where: { $0.name == defaultHelpFilename }) { + return defaultHelpFilename + } + } + + return nil } var graphicFilename: String? { if let filename = options?.graphicFile { @@ -66,8 +76,6 @@ public struct PackageSource: Identifiable, Decodable, Hashable, Equatable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - directoryUrl = nil - kmpJsonFileUrl = nil self.info = try container.decode(Info.self, forKey: .info) self.keyboards = try container.decodeIfPresent([KeyboardSource].self, forKey: .keyboards) diff --git a/mac/KeymanSettings/Sources/Persistence/PackageDownload.swift b/mac/KeymanSettings/Sources/Persistence/PackageDownload.swift index 013d942fb39..ebee061c7de 100644 --- a/mac/KeymanSettings/Sources/Persistence/PackageDownload.swift +++ b/mac/KeymanSettings/Sources/Persistence/PackageDownload.swift @@ -32,7 +32,6 @@ public class PackageDownload { // cannot be initialized until after download when packageName of new package is known self.packageToReplace = nil - // MAC-CONFIG-TODO: should we resume a download if the app was quit or killed before completing a keyboard install? // if any packages are remaining from an earlier download, delete them self.packageRepository.cleanupTempDirectory() } @@ -47,7 +46,9 @@ public class PackageDownload { try self.unzipDownloadedPackage(for: kmpFileUrl) try self.handleNewPackage() } catch { - print ("package installation failed with error '\(error)' for \(kmpFileUrl)") + self.cleanupFailedInstallation() + + print ("package installation failed with error '\(error)' for \(kmpFileUrl)") // MAC-CONFIG-TODO: handle error // send notification that installation failed? } @@ -128,9 +129,18 @@ public class PackageDownload { /** * Clean up the downloaded .kmp file and package folder */ - func cancelInstallation() throws { - try self.deleteDownloadedKmpFile() - try self.deleteDownloadedPackage() + func cleanupFailedInstallation() { + print("cleanupFailedInstallation of: \(self.temporaryPackageLocation.lastPathComponent)") + do { + try self.deleteDownloadedKmpFile() + } catch { + print("cleanupFailedInstallation did not delete downloaded .kmp file: \(self.temporaryKmpFileLocation.lastPathComponent)") + } + do { + try self.deleteDownloadedPackage() + } catch { + print("cleanupFailedInstallation did not delete downloaded package: \(self.temporaryPackageLocation.lastPathComponent)") + } } /** @@ -141,10 +151,15 @@ public class PackageDownload { } /** - * Move the downloaded package into the keyman packages directory + * Move the downloaded package into the keyman packages directory. */ func movePackageFromTemporaryToInstalled() throws { try FileManager.default.moveItem(at: self.temporaryPackageLocation, to: self.installPackageLocation) + + // Update the KeymanPackage object with its new location + if let package = self.packageToInstall { + package.sourceDirectoryUrl = self.installPackageLocation + } } /** diff --git a/mac/KeymanSettings/Sources/Persistence/PackageRepository.swift b/mac/KeymanSettings/Sources/Persistence/PackageRepository.swift index ce8edf62d2d..e35541969aa 100644 --- a/mac/KeymanSettings/Sources/Persistence/PackageRepository.swift +++ b/mac/KeymanSettings/Sources/Persistence/PackageRepository.swift @@ -67,21 +67,18 @@ public class PackageRepository: PackageRepo { */ public func loadAllPackages() -> [KeymanPackage] { var installedPackages: [KeymanPackage] = [] - let packageSourceArray = self.readKeymanPackagesForKeyman19() + let packageSourceMap = self.readKeymanPackagesForKeyman19() - // create a KeymanPackage object for each PackageSource object and insert it in the array if it is valid - for source in packageSourceArray { - let package = KeymanPackage(packageSource: source) + for (url, source) in packageSourceMap { + let package = KeymanPackage(packageUrl: url, packageSource: source) do { try package.validate() - - // only install packages that pass validation installedPackages.append(package) } catch { - print("** package '\(source.packageName)' is not valid: \(error.localizedDescription)") + print("validation failed for \(url) with error: \(error)") } } - + return installedPackages } @@ -94,7 +91,7 @@ public class PackageRepository: PackageRepo { print("loadSinglePackage from url: \(packageUrl)") guard let source = try readPackageFromDirectory(packageDirectoryUrl: packageUrl) else { throw InstallPackageError.invalidUrl } - let package = KeymanPackage(packageSource: source) + let package = KeymanPackage(packageUrl: packageUrl, packageSource: source) try package.validate() return package } @@ -212,15 +209,15 @@ public class PackageRepository: PackageRepo { /** * read packages at Keyman 19 location, inside Group Containers directory */ - func readKeymanPackagesForKeyman19() -> [PackageSource] { + func readKeymanPackagesForKeyman19() -> [URL: PackageSource] { return readPackageSource(packageDirectoryUrl: self.pathUtil.keyman19PackagesDirectory) } /** * loop through all the sub-directories in the packages directory and try to read them as packages */ - func readPackageSource(packageDirectoryUrl: URL) -> [PackageSource] { - var packages: [PackageSource] = [] + func readPackageSource(packageDirectoryUrl: URL) -> [URL: PackageSource] { + var packageMap: [URL: PackageSource] = [:] do { // Get the URLs for all items in the directory that are not hidden @@ -235,7 +232,7 @@ public class PackageRepository: PackageRepo { if (itemUrl.hasDirectoryPath) { do { if let packageSource = try readPackageFromDirectory(packageDirectoryUrl: itemUrl) { - packages.append(packageSource) + packageMap[itemUrl] = packageSource } } catch let error as LoadPackageError { print("** package at \(itemUrl) could not be loaded: \(error.localizedDescription)") @@ -246,8 +243,8 @@ public class PackageRepository: PackageRepo { print("Failed to read directory: \(error.localizedDescription)") } - print("\(packages.count) packages read") - return packages + print("\(packageMap.count) packages read") + return packageMap } /** @@ -262,9 +259,8 @@ public class PackageRepository: PackageRepo { throw LoadPackageError.kmpJsonFileNotFound } - // use try without do block - // if an error occurs, it will not be handled but propagated to caller - if let source = try readPackage(packageDirectoryUrl: packageDirectoryUrl, kmpFileUrl: kmpJsonFileUrl) { + // if an error occurs, it will be propagated to caller + if let source = try readPackage(kmpFileUrl: kmpJsonFileUrl) { packageSource = source } @@ -274,17 +270,12 @@ public class PackageRepository: PackageRepo { /** * read and parse the kmp.json file at the specified URL */ - func readPackage(packageDirectoryUrl: URL, kmpFileUrl: URL) throws -> PackageSource? { + func readPackage(kmpFileUrl: URL) throws -> PackageSource? { var packageSource: PackageSource? do { let jsonData = try Data(contentsOf: kmpFileUrl, options: .mappedIfSafe) - var source: PackageSource = try JSONDecoder().decode(PackageSource.self, from: jsonData) + packageSource = try JSONDecoder().decode(PackageSource.self, from: jsonData) - print("readPackage, packageName: \(source.packageName)") - // save the keyboard directory and path of the kmp.json for this keyboard - source.directoryUrl = packageDirectoryUrl - source.kmpJsonFileUrl = kmpFileUrl - packageSource = source } catch let error as LoadPackageError { // if we encounter a LoadPackageError, propagate it throw error diff --git a/mac/KeymanSettings/Tests/KeymanSettingsTests/KeymanSettingsTests.swift b/mac/KeymanSettings/Tests/KeymanSettingsTests/KeymanSettingsTests.swift index 68a87d5bada..84065244d77 100644 --- a/mac/KeymanSettings/Tests/KeymanSettingsTests/KeymanSettingsTests.swift +++ b/mac/KeymanSettings/Tests/KeymanSettingsTests/KeymanSettingsTests.swift @@ -50,7 +50,7 @@ import Foundation // verify that we cannot find the test package after removing it #expect(settingsContainer.findInstalledPackage(with: packageRepo.testPackageId) != nil) - settingsContainer.removeMultipleKeyboardPackage(at: 0) + settingsContainer.removeInstalledPackage(with: packageRepo.testPackageId) #expect(settingsContainer.findInstalledPackage(with: packageRepo.testPackageId) == nil) } @@ -223,7 +223,7 @@ import Foundation fileprivate init() async throws { try packageRepo = PackageRepository() self.kmpUrl = try #require(Bundle.module.url(forResource: "amharic.kmp", withExtension: "json")) - source = try packageRepo.readPackage(packageDirectoryUrl: fakePackageUrl, kmpFileUrl: self.kmpUrl) + source = try packageRepo.readPackage(kmpFileUrl: self.kmpUrl) } @Test("Read package name") func readPackageName() async throws { @@ -264,25 +264,23 @@ import Foundation fileprivate init() async throws { try packageRepo = PackageRepository() self.kmpUrl = try #require(Bundle.module.url(forResource: "amharic.kmp", withExtension: "json")) - self.packageSource = try packageRepo.readPackage(packageDirectoryUrl: fakePackageUrl, kmpFileUrl: self.kmpUrl) + self.packageSource = try packageRepo.readPackage(kmpFileUrl: self.kmpUrl) } @Test("Check keyboard is disabled") func checkKeyboardDisabled() async throws { let package = try #require(self.packageSource) - let directoryUrl = try #require(package.directoryUrl) let keyboards = try #require(package.keyboards) let keyboardSource = try #require(keyboards.first) - let keyboard = Keyboard(keyboardSource: keyboardSource, directoryUrl: directoryUrl) + let keyboard = Keyboard(keyboardSource: keyboardSource, packageDirectoryName: "amharic-fake") #expect(keyboard.enabled) } @Test("Check keyboard key") func checkKeyboardKey() async throws { let package = try #require(self.packageSource) - let directoryUrl = try #require(package.directoryUrl) let keyboards = try #require(package.keyboards) let keyboardSource = try #require(keyboards.first) - let keyboard = Keyboard(keyboardSource: keyboardSource, directoryUrl: directoryUrl) + let keyboard = Keyboard(keyboardSource: keyboardSource, packageDirectoryName: "amharic-fake") #expect(keyboard.keyboardKey == "/amharic-fake/gff_amharic.kmx") } diff --git a/mac/KeymanSettings/Tests/KeymanSettingsTests/RepoStubs.swift b/mac/KeymanSettings/Tests/KeymanSettingsTests/RepoStubs.swift index b7947375ad8..ed1697c2c80 100644 --- a/mac/KeymanSettings/Tests/KeymanSettingsTests/RepoStubs.swift +++ b/mac/KeymanSettings/Tests/KeymanSettingsTests/RepoStubs.swift @@ -117,12 +117,12 @@ class PackageRepoStub: PackageRepo { let testPackageDirectoryName = extinctPackageName let testPackageUrl = packagesDirectoryUrl.appendingPathComponent(testPackageDirectoryName) let moabiteKeyboardId = "moabite_basic" - let moabiteKeyboard = Keyboard(name: "moabite basic", keyboardId: moabiteKeyboardId, keyboardDirectoryUrl: testPackageUrl, enabled: true) + let moabiteKeyboard = Keyboard(name: "moabite basic", keyboardId: moabiteKeyboardId, packageDirectoryName: testPackageDirectoryName, enabled: true) let hittiteKeyboardId = "hittite_basic" - let hittiteKeyboard = Keyboard(name: "hittite basic", keyboardId: hittiteKeyboardId, keyboardDirectoryUrl: testPackageUrl, enabled: false) + let hittiteKeyboard = Keyboard(name: "hittite basic", keyboardId: hittiteKeyboardId, packageDirectoryName: testPackageDirectoryName, enabled: false) let testPackage = KeymanPackage(sourceDirectoryUrl: testPackageUrl, - keyboards: [moabiteKeyboard, hittiteKeyboard], packageName: "Extinct Languages", packageVersion: "1.1.03", jsonFileUrl: testPackageUrl.appendingPathComponent("kmp.json")) + keyboards: [moabiteKeyboard, hittiteKeyboard], packageName: "Extinct Languages", packageVersion: "1.1.03") testPackageId = testPackage.id return [testPackage]