diff --git a/mac/.gitignore b/mac/.gitignore index 590a6ee1aa..4d63a3d6c6 100644 --- a/mac/.gitignore +++ b/mac/.gitignore @@ -15,3 +15,6 @@ setup/textinputsource/textinputsource setup/textinputsource/textinputsource.arm64 setup/textinputsource/textinputsource.x86_64 setup/Install Keyman.app + +# notarytool log +developer_log.json \ No newline at end of file diff --git a/mac/Config/Config.xcodeproj/project.pbxproj b/mac/Config/Config.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..9522140b14 --- /dev/null +++ b/mac/Config/Config.xcodeproj/project.pbxproj @@ -0,0 +1,675 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* 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 */ + D88F03D42F50ED5100C02A31 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = D88F03BE2F50ED5000C02A31 /* Project object */; + proxyType = 1; + remoteGlobalIDString = D88F03C52F50ED5000C02A31; + remoteInfo = Config; + }; + D88F03DE2F50ED5100C02A31 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = D88F03BE2F50ED5000C02A31 /* Project object */; + proxyType = 1; + remoteGlobalIDString = D88F03C52F50ED5000C02A31; + remoteInfo = Config; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 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 = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + D88532ED3040EF85009D61EE /* Exceptions for "Config" folder in "Config" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = D88F03C52F50ED5000C02A31 /* Config */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 3A589DC630539F2B00E2DD63 /* Resources */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Resources; + sourceTree = ""; + }; + D87D6F492FAF95400083A95E /* Installation */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Installation; + sourceTree = ""; + }; + D88F03C82F50ED5000C02A31 /* Config */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + D88532ED3040EF85009D61EE /* Exceptions for "Config" folder in "Config" target */, + ); + path = Config; + sourceTree = ""; + }; + D88F03E02F50ED5100C02A31 /* ConfigUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = ConfigUITests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + D88F03C32F50ED5000C02A31 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D83272A82F57614400F71698 /* KeymanSettings in Frameworks */, + D88B79CA30472B3B00F10D14 /* Sentry-Dynamic in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D88F03D02F50ED5100C02A31 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D88F03DA2F50ED5100C02A31 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + D88F03BD2F50ED5000C02A31 = { + isa = PBXGroup; + children = ( + 3A589DC630539F2B00E2DD63 /* Resources */, + D8F10B613048F12A00C1E597 /* Localizable.xcstrings */, + D87D6F492FAF95400083A95E /* Installation */, + D88F03C82F50ED5000C02A31 /* Config */, + D88F03E02F50ED5100C02A31 /* ConfigUITests */, + D88F04022F512FE800C02A31 /* Frameworks */, + D88F03C72F50ED5000C02A31 /* Products */, + ); + sourceTree = ""; + }; + D88F03C72F50ED5000C02A31 /* Products */ = { + isa = PBXGroup; + children = ( + D88F03C62F50ED5000C02A31 /* Keyman Configuration.app */, + D88F03D32F50ED5100C02A31 /* ConfigTests.xctest */, + D88F03DD2F50ED5100C02A31 /* ConfigUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + D88F04022F512FE800C02A31 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + D88F03C52F50ED5000C02A31 /* Config */ = { + isa = PBXNativeTarget; + buildConfigurationList = D88F03E72F50ED5100C02A31 /* Build configuration list for PBXNativeTarget "Config" */; + buildPhases = ( + D88F03C22F50ED5000C02A31 /* Sources */, + D88F03C32F50ED5000C02A31 /* Frameworks */, + D88F03C42F50ED5000C02A31 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 3A589DC630539F2B00E2DD63 /* Resources */, + D87D6F492FAF95400083A95E /* Installation */, + D88F03C82F50ED5000C02A31 /* Config */, + ); + name = Config; + packageProductDependencies = ( + D83272A72F57614400F71698 /* KeymanSettings */, + D88B79C930472B3B00F10D14 /* Sentry-Dynamic */, + ); + productName = Config; + productReference = D88F03C62F50ED5000C02A31 /* Keyman Configuration.app */; + productType = "com.apple.product-type.application"; + }; + D88F03D22F50ED5100C02A31 /* ConfigTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = D88F03EA2F50ED5100C02A31 /* Build configuration list for PBXNativeTarget "ConfigTests" */; + buildPhases = ( + D88F03CF2F50ED5100C02A31 /* Sources */, + D88F03D02F50ED5100C02A31 /* Frameworks */, + D88F03D12F50ED5100C02A31 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + D88F03D52F50ED5100C02A31 /* PBXTargetDependency */, + ); + name = ConfigTests; + packageProductDependencies = ( + ); + productName = ConfigTests; + productReference = D88F03D32F50ED5100C02A31 /* ConfigTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + D88F03DC2F50ED5100C02A31 /* ConfigUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = D88F03ED2F50ED5100C02A31 /* Build configuration list for PBXNativeTarget "ConfigUITests" */; + buildPhases = ( + D88F03D92F50ED5100C02A31 /* Sources */, + D88F03DA2F50ED5100C02A31 /* Frameworks */, + D88F03DB2F50ED5100C02A31 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + D88F03DF2F50ED5100C02A31 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + D88F03E02F50ED5100C02A31 /* ConfigUITests */, + ); + name = ConfigUITests; + packageProductDependencies = ( + ); + productName = ConfigUITests; + productReference = D88F03DD2F50ED5100C02A31 /* ConfigUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + D88F03BE2F50ED5000C02A31 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2620; + LastUpgradeCheck = 2620; + TargetAttributes = { + D88F03C52F50ED5000C02A31 = { + CreatedOnToolsVersion = 26.2; + }; + D88F03D22F50ED5100C02A31 = { + CreatedOnToolsVersion = 26.2; + TestTargetID = D88F03C52F50ED5000C02A31; + }; + D88F03DC2F50ED5100C02A31 = { + CreatedOnToolsVersion = 26.2; + TestTargetID = D88F03C52F50ED5000C02A31; + }; + }; + }; + buildConfigurationList = D88F03C12F50ED5000C02A31 /* Build configuration list for PBXProject "Config" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = D88F03BD2F50ED5000C02A31; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + D88B79C830472B3B00F10D14 /* XCRemoteSwiftPackageReference "sentry-cocoa" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = D88F03C72F50ED5000C02A31 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + D88F03C52F50ED5000C02A31 /* Config */, + D88F03D22F50ED5100C02A31 /* ConfigTests */, + D88F03DC2F50ED5100C02A31 /* ConfigUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + D88F03C42F50ED5000C02A31 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D8F10B623048F12A00C1E597 /* Localizable.xcstrings in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D88F03D12F50ED5100C02A31 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D88F03DB2F50ED5100C02A31 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + D88F03C22F50ED5000C02A31 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D88F03CF2F50ED5100C02A31 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D88F03D92F50ED5100C02A31 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + D88F03D52F50ED5100C02A31 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D88F03C52F50ED5000C02A31 /* Config */; + targetProxy = D88F03D42F50ED5100C02A31 /* PBXContainerItemProxy */; + }; + D88F03DF2F50ED5100C02A31 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D88F03C52F50ED5000C02A31 /* Config */; + targetProxy = D88F03DE2F50ED5100C02A31 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + D88F03E52F50ED5100C02A31 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = 3YE4W86L3G; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 26.2; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + REGISTER_APP_GROUPS = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + D88F03E62F50ED5100C02A31 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 3YE4W86L3G; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 26.2; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + REGISTER_APP_GROUPS = YES; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + }; + name = Release; + }; + D88F03E82F50ED5100C02A31 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = ""; + CODE_SIGN_ENTITLEMENTS = Config/ConfigDebug.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Mac Developer"; + CODE_SIGN_STYLE = Manual; + COMBINE_HIDPI_IMAGES = YES; + COPYRIGHT_STRING = "Copyright © SIL Global."; + CURRENT_PROJECT_VERSION = "$(PRODUCT_VERSION)"; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=macosx*]" = 3YE4W86L3G; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Config/Info.plist; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © SIL Global."; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 13.5; + MARKETING_VERSION = "$(PRODUCT_VERSION)"; + PRODUCT_BUNDLE_IDENTIFIER = com.keyman.config; + PRODUCT_NAME = "Keyman Configuration"; + PRODUCT_VERSION = 0.0.0; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "Keyman Mac Config Dev"; + REGISTER_APP_GROUPS = YES; + SENTRY_ENVIRONMENT = local; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 6.0; + TIER = alpha; + VERSION_GIT_TAG = "release@19.0.267-alpha-local"; + VERSION_RELEASE = 19.0; + VERSION_TAG = "-alpha-local"; + VERSION_WITH_TAG = "19.0.267-alpha-local"; + }; + name = Debug; + }; + D88F03E92F50ED5100C02A31 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = ""; + CODE_SIGN_ENTITLEMENTS = Config/ConfigRelease.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application"; + CODE_SIGN_STYLE = Manual; + COMBINE_HIDPI_IMAGES = YES; + COPYRIGHT_STRING = "Copyright © SIL Global."; + CURRENT_PROJECT_VERSION = "$(PRODUCT_VERSION)"; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=macosx*]" = 3YE4W86L3G; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Config/Info.plist; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © SIL Global."; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 13.5; + MARKETING_VERSION = "$(PRODUCT_VERSION)"; + PRODUCT_BUNDLE_IDENTIFIER = com.keyman.config; + PRODUCT_NAME = "Keyman Configuration"; + PRODUCT_VERSION = 0.0.0; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "Keyman Mac Config Dist"; + REGISTER_APP_GROUPS = YES; + SENTRY_ENVIRONMENT = local; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 6.0; + TIER = alpha; + VERSION_GIT_TAG = "release@19.0.267-alpha-local"; + VERSION_RELEASE = 19.0; + VERSION_TAG = "-alpha-local"; + VERSION_WITH_TAG = "19.0.267-alpha-local"; + }; + name = Release; + }; + D88F03EB2F50ED5100C02A31 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 3YE4W86L3G; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 26.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.keyman.ConfigTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Config.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Config"; + }; + name = Debug; + }; + D88F03EC2F50ED5100C02A31 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 3YE4W86L3G; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 26.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.keyman.ConfigTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Config.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Config"; + }; + name = Release; + }; + D88F03EE2F50ED5100C02A31 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 3YE4W86L3G; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.keyman.ConfigUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = Config; + }; + name = Debug; + }; + D88F03EF2F50ED5100C02A31 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 3YE4W86L3G; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.keyman.ConfigUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = Config; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + D88F03C12F50ED5000C02A31 /* Build configuration list for PBXProject "Config" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D88F03E52F50ED5100C02A31 /* Debug */, + D88F03E62F50ED5100C02A31 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + D88F03E72F50ED5100C02A31 /* Build configuration list for PBXNativeTarget "Config" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D88F03E82F50ED5100C02A31 /* Debug */, + D88F03E92F50ED5100C02A31 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + D88F03EA2F50ED5100C02A31 /* Build configuration list for PBXNativeTarget "ConfigTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D88F03EB2F50ED5100C02A31 /* Debug */, + D88F03EC2F50ED5100C02A31 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + D88F03ED2F50ED5100C02A31 /* Build configuration list for PBXNativeTarget "ConfigUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D88F03EE2F50ED5100C02A31 /* Debug */, + D88F03EF2F50ED5100C02A31 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + D88B79C830472B3B00F10D14 /* XCRemoteSwiftPackageReference "sentry-cocoa" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/getsentry/sentry-cocoa.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 9.26.1; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + D83272A72F57614400F71698 /* KeymanSettings */ = { + isa = XCSwiftPackageProductDependency; + productName = KeymanSettings; + }; + D88B79C930472B3B00F10D14 /* Sentry-Dynamic */ = { + isa = XCSwiftPackageProductDependency; + package = D88B79C830472B3B00F10D14 /* XCRemoteSwiftPackageReference "sentry-cocoa" */; + productName = "Sentry-Dynamic"; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = D88F03BE2F50ED5000C02A31 /* Project object */; +} diff --git a/mac/Config/Config/About/AboutPanelView.swift b/mac/Config/Config/About/AboutPanelView.swift new file mode 100644 index 0000000000..fa28d0dac6 --- /dev/null +++ b/mac/Config/Config/About/AboutPanelView.swift @@ -0,0 +1,56 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Eli Schantz on 2026-07-23 + * + * View shown when About Keyman Configuration is selected from the menu. + */ + +import SwiftUI +import KeymanSettings + +struct AboutPanelView: View { + + var body: some View { + VStack(spacing: 20) { + HStack(alignment: .top, spacing: 28) { + Image(nsImage: NSApp.applicationIconImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 104, height: 104) + + VStack(alignment: .leading, spacing: 6) { + Text("Keyman Configuration") + .font(.system(size: 32, weight: .semibold)) + Text("Version \(ConfigAppUtil.configAppVersion())") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + + Spacer(minLength: 14) + + Text("Copyright © SIL Global") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 0) + } + + HStack { + Spacer() + Button("License Agreement") { + if let url = Bundle.main.url(forResource: "keyman-for-mac-os-license", withExtension: "html") { + NSWorkspace.shared.open(url) + } + } + } + } + .padding(.top, 32) + .padding(.horizontal, 42) + .padding(.bottom, 20) + .frame(width: 570, height: 200) + } +} +#Preview { + AboutPanelView() +} diff --git a/mac/Config/Config/AddKeyboardView.swift b/mac/Config/Config/AddKeyboardView.swift new file mode 100644 index 0000000000..9daa3c0c7c --- /dev/null +++ b/mac/Config/Config/AddKeyboardView.swift @@ -0,0 +1,112 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2026-06-16 + * + * Contains webview to search for keyboards + * Injects DownloadCoordinator to bridge back to SwiftUI + */ + +import SwiftUI +import KeymanSettings +import OSLog + +struct AddKeyboardView: View { + @EnvironmentObject var settings: SettingsContainer + @Environment(\.dismiss) private var dismissAddKeyboardView + @StateObject private var downloadCoordinator = DownloadCoordinator() + + var body: some View { + ZStack { + KeyboardSearchView(coordinator: downloadCoordinator) + .environmentObject(settings) + .padding() + + if downloadCoordinator.isDownloading { + // Dim the background slightly to focus on the progress panel + Color.black.opacity(0.2) + .transition(.opacity) + + VStack(spacing: 16) { + Text("Downloading File...") + .font(.headline) + + // Native progress bar bound to the coordinator's value (0.0 to 1.0) + ProgressView(value: downloadCoordinator.downloadProgressFraction, total: 1.0) + .progressViewStyle(.linear) + .frame(width: 250) + + Text(downloadCoordinator.downloadProgressFraction, format: .percent.precision(.fractionLength(0))) + .font(.body) + .foregroundColor(.secondary) + } + .padding(24) + // translucent macOS look + .background(VisualEffectBlur()) + .cornerRadius(12) + .shadow(radius: 10) + .transition(.scale.combined(with: .opacity)) + } + } + .animation(.default, value: downloadCoordinator.isDownloading) + .toolbar { + // Placement determines where on the bar it sits + ToolbarItem(placement: .cancellationAction) { + Button("Close") { + Logger.app.debug("AddKeyboardView close button clicked") + dismissAddKeyboardView() + if settings.isInstallationInProgress() { + settings.userCanceledPackageInstallation() + } + } + } + } + .onDisappear { + Logger.app.debug("AddKeyboardView onDisappear") + downloadCoordinator.cancelActiveDownload() + } + .alert("Package Installation Failed", isPresented: $downloadCoordinator.loadPackageFailed) { + Button("OK", role: .cancel) { } + } message: { + if let message = downloadCoordinator.loadFailureMessage { + Text(message) + } + } + .sheet(isPresented: $downloadCoordinator.showConfirmPackageSheet) { + if let helper = downloadCoordinator.installHelper { + PackageConfirmationView(installHelper: helper) { accepted in + if accepted { + Logger.download.info("installing validated package: \(helper.packageName ?? "unknown package", privacy: .public)") + LogUtil.infoBreadcrumb("installing validated package: \(helper.packageName ?? "unknown package")", category: .download) + do { + try settings.installPackage() + } catch { + Logger.download.error("failed to install package: \(helper.packageName ?? "unknown package") with error: \(error as NSError, privacy: .public)") + LogUtil.errorBreadcrumb("failed to install package: \(helper.packageName ?? "unknown package") with error: \(error as NSError)", category: .download) + } + } else { + settings.userCanceledPackageInstallation() + } + + // close sheet + downloadCoordinator.showConfirmPackageSheet = false + // close + dismissAddKeyboardView() + } + } + } + } +} + +struct VisualEffectBlur: NSViewRepresentable { + func makeNSView(context: Context) -> NSVisualEffectView { + let view = NSVisualEffectView() + view.material = .hudWindow // matches native dark/light HUD styling + view.blendingMode = .withinWindow + view.state = .active + return view + } + + func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} +} + diff --git a/mac/Config/Config/Assets.xcassets/AppIcon.appiconset/Contents.json b/mac/Config/Config/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..fb399c8a84 --- /dev/null +++ b/mac/Config/Config/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,59 @@ +{ + "images" : [ + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "filename" : "keyman-1024.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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 0000000000..a076fccf27 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/Contents.json b/mac/Config/Config/Assets.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/mac/Config/Config/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mac/Config/Config/Assets.xcassets/Keyman Blue.colorset/Contents.json b/mac/Config/Config/Assets.xcassets/Keyman Blue.colorset/Contents.json new file mode 100644 index 0000000000..046b90de30 --- /dev/null +++ b/mac/Config/Config/Assets.xcassets/Keyman Blue.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.855", + "green" : "0.765", + "red" : "0.475" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "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/Keyman Orange.colorset/Contents.json b/mac/Config/Config/Assets.xcassets/Keyman Orange.colorset/Contents.json new file mode 100644 index 0000000000..44b85879f9 --- /dev/null +++ b/mac/Config/Config/Assets.xcassets/Keyman Orange.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.141", + "green" : "0.537", + "red" : "0.965" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.141", + "green" : "0.537", + "red" : "0.965" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mac/Config/Config/Assets.xcassets/Keyman Red.colorset/Contents.json b/mac/Config/Config/Assets.xcassets/Keyman Red.colorset/Contents.json new file mode 100644 index 0000000000..dfe597c45c --- /dev/null +++ b/mac/Config/Config/Assets.xcassets/Keyman Red.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.275", + "green" : "0.220", + "red" : "0.800" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.275", + "green" : "0.220", + "red" : "0.800" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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 0000000000..2067810645 --- /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 0000000000..0f877ea211 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 0000000000..2be7a808a4 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 0000000000..d34523091e --- /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 0000000000..cc091257d4 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 0000000000..86bb56002b 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 0000000000..0aba0726d0 --- /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 0000000000..67ddbb7bea 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 0000000000..d7243fbab6 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 new file mode 100644 index 0000000000..1e902c9a0c --- /dev/null +++ b/mac/Config/Config/ConfigApp.swift @@ -0,0 +1,113 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2026-02-26 + * + * The Configuration Application App object + */ + +import SwiftUI +import AppKit +import KeymanSettings +import OSLog +import Sentry + +extension Logger { + private static let configSubsystem = ConfigAppUtil.configBundleId + static let app = Logger(subsystem: configSubsystem, category: "app") + static let download = Logger(subsystem: configSubsystem, category: "download") +} + +@main +struct ConfigApp: App { + @StateObject var settings = SettingsContainer() + @StateObject var installation = InstallationContainer() + @Environment(\.openWindow) private var openWindow + + init() { + Logger.app.log("Starting Keyman Configuration, version: \(ConfigAppUtil.versionWithTag), versionWithTag: \(ConfigAppUtil.versionWithTag)") + let sentryDsnUrl = "https://960f8b8e574c46e3be385d60ce8e1fea@o1005580.ingest.sentry.io/5983522" + + // Initialize Sentry only once here + SentrySDK.start { options in + options.dsn = sentryDsnUrl + options.releaseName = ConfigAppUtil.versionGitTag + options.environment = ConfigAppUtil.sentryEnvironment + } + } + + var body: some Scene { + Window("Configuration", id: "main-config") { + MainConfigView() + .frame( + minWidth: 600, maxWidth: 1000, + minHeight: 400, maxHeight: .infinity + ) + .environmentObject(settings) + .task { + if !installation.getHasDisplayedInstallationComplete() { + openWindow(id: "install") + } + } + .onReceive(NotificationCenter.default.publisher(for: .installationRepairStarted)) { notification in openWindow(id: "install") + } + } + // the size of the window when first opened + .defaultSize(width: 800, height: 600) + .windowResizability(.contentSize) + + Window("Installation", id: "install") { + MainInstallView() + .environmentObject(installation) + } + .windowResizability(.contentSize) + .defaultSize(width: 600, height: 500) + .commands { + CommandGroup(replacing: .appInfo) { + Button { + AboutPanelPresenter.showAboutPanel() + } label: { + Label("About Keyman Configuration", systemImage: "info.circle") + } + } + } + + // for testing purposes +// Window("Install Test", id: "install-debug") { +// InstallDebugView() +// .environmentObject(installation) +// } + } +} + +@MainActor +private enum AboutPanelPresenter { + private static var aboutWindow: NSWindow? + + static func showAboutPanel() { + let contentView = AboutPanelView() + + let window = aboutWindow ?? makeAboutWindow() + window.contentView = NSHostingView(rootView: contentView) + window.center() + window.makeKeyAndOrderFront(nil) + aboutWindow = window + + NSApp.activate(ignoringOtherApps: true) + } + + private static func makeAboutWindow() -> NSWindow { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 570, height: 200), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.isReleasedWhenClosed = false + window.backgroundColor = .windowBackgroundColor + return window + } +} diff --git a/mac/Config/Config/ConfigDebug.entitlements b/mac/Config/Config/ConfigDebug.entitlements new file mode 100644 index 0000000000..13a8093916 --- /dev/null +++ b/mac/Config/Config/ConfigDebug.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + group.com.keyman + + + diff --git a/mac/Config/Config/ConfigRelease.entitlements b/mac/Config/Config/ConfigRelease.entitlements new file mode 100644 index 0000000000..13a8093916 --- /dev/null +++ b/mac/Config/Config/ConfigRelease.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + group.com.keyman + + + diff --git a/mac/Config/Config/DownloadCoordinator.swift b/mac/Config/Config/DownloadCoordinator.swift new file mode 100644 index 0000000000..cfb12f02aa --- /dev/null +++ b/mac/Config/Config/DownloadCoordinator.swift @@ -0,0 +1,203 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2026-08-21 + * + * For coordination between WKWebview and SwiftUI views. + * Implements WKNavigationDelegate and WKDownloadDelegate to support downloads + * of Keyman packages and publishes several fields to allow SwiftUI views to + * - display download progress + * - display errors that cause the download or package validation to fail + * - prompt with a confirm sheet including a package read me and button to install + */ + +import WebKit +import Combine +import KeymanSettings +import OSLog + +// safe to designate the whole Coordinator class as @MainActor with Swift 6.0 +// when delegate calls come on a background thread, Swift 6 will +// intercept and switch to the main thread for calls to our code + +@MainActor +public class DownloadCoordinator: NSObject, ObservableObject, WKNavigationDelegate, WKDownloadDelegate { + @Published var isDownloading = false + // progress is between 0.0 and 1.0 + @Published var downloadProgressFraction: Double = 0.0 + + @Published var showConfirmPackageSheet = false + @Published var installHelper: PackageInstallHelper? + + @Published var loadFailureMessage: String? + @Published var loadPackageFailed = false + + var settings: SettingsContainer? + private var progressObserver: NSKeyValueObservation? + private var activeDownload: WKDownload? + + // Swift regex literals, introduced in macOS 13+ and using instead of NSRegularExpression + private static let regexInstall = /^http(s)?:\/\/keyman(-staging)?\.com(\.localhost)?\/keyboards\/install\/([^?\/]+)(\?(.+))?$/ + private static let regexRoot = /^http(s)?:\/\/keyman(-staging)?\.com(\.localhost)?\/keyboards([\/?].*)?$/ + private static let regexGo = /^http(s)?:\/\/keyman(-staging)?\.com(\.localhost)?\/go\/macos\/[^\/]+\/download-keyboards/ + + public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping @MainActor (WKNavigationActionPolicy) -> Void) { + + guard let urlString = navigationAction.request.url?.absoluteString else { + decisionHandler(.cancel) + return + } + Logger.download.log("received url: \(urlString, privacy: .public)") + + // if the url matches the install url pattern, then cancel the request, + // build the standard URLRequest for a package installation and send it + if let match = try? DownloadCoordinator.regexInstall.firstMatch(in: urlString) { + decisionHandler(.cancel) + + // get the package id (though it appears to be identifying a keyboard in the URL) + let matchPackageId = String(match.4) + if let downloadUrl = self.settings?.buildDownloadPackageUrl(for: matchPackageId) { + Logger.download.info("package install, download url = \(downloadUrl.cleanUrlPath(), privacy: .public)") + LogUtil.infoBreadcrumb("package install, download url = \(downloadUrl.cleanUrlPath())", category: .download) + + let newRequest = URLRequest(url: downloadUrl) + + DispatchQueue.main.async { + webView.startDownload(using: newRequest) { download in + Logger.download.info("download initiated to \(newRequest.url?.cleanUrlPath() ?? "nil", privacy: .public)") + LogUtil.infoBreadcrumb("download initiated to \(newRequest.url?.cleanUrlPath() ?? "nil")", category: .download) + download.delegate = self + self.setupDownloadTracking(download) + } + } + } + } + else if urlString.contains(DownloadCoordinator.regexRoot) || + urlString.contains(DownloadCoordinator.regexGo) { + Logger.download.info("requested root or go url: load in webview") + LogUtil.infoBreadcrumb("requested root or go url: load in webview", category: .download) + + decisionHandler(.allow) + } + else { + Logger.download.info("default case, open in external browser") + LogUtil.infoBreadcrumb("default case, open in external browser", category: .download) + + decisionHandler(.cancel) + if let targetUrl = URL(string: urlString) { + NSWorkspace.shared.open(targetUrl) + } + } + } + + /** + * Setup the observer to track progress of the download. + */ + private func setupDownloadTracking(_ download: WKDownload) { + // record download in case we need to cancel + self.activeDownload = download + + // reset progress states + self.isDownloading = true + 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?.downloadProgressFraction = newValue + } + } + } + + /** + * Called when the AddKeyboardView is closed. If there is a download in progress, it will be canceled. + */ + public func cancelActiveDownload() { + guard isDownloading else { return } // only applied during downloads + + self.activeDownload?.cancel() + self.activeDownload = nil + self.progressObserver = nil + self.isDownloading = false + self.installHelper = nil + self.settings?.userCanceledPackageInstallation() + } + + public func download(_ download: WKDownload, decideDestinationUsing response: URLResponse, suggestedFilename: String, completionHandler: @escaping @MainActor @Sendable (URL?) -> Void) { + Logger.download.debug("download initiated") + + guard let keymanSettings = self.settings else { + Logger.download.error("tried to access settings before they were intialized") + LogUtil.errorBreadcrumb("tried to access settings before they were intialized", category: .download) + self.loadPackageFailed = true + self.loadFailureMessage = InstallPackageError.internalError.localizedDescription + completionHandler(nil) + return + } + + // notify settings that a keyboard download is beginning and get the + // helper that is managing state for the package installation + + do { + if let helper = try keymanSettings.initiateKmpFileDownload(kmpFilename: suggestedFilename) { + Logger.download.info("download suggested filename: \(suggestedFilename, privacy: .public)") + LogUtil.infoBreadcrumb("download suggested filename: \(suggestedFilename)", category: .download) + self.loadFailureMessage = nil // Reset previous error + self.loadPackageFailed = false + + self.installHelper = helper + + completionHandler(helper.temporaryKmpFileLocation) + } + } catch { + Logger.download.error("could not initiate package download, error: \(error as NSError, privacy: .public)") + LogUtil.errorBreadcrumb("could not initiate package download, error: \(error as NSError)", category: .download) + self.loadPackageFailed = true + self.loadFailureMessage = error.localizedDescription + completionHandler(nil) + } + } + + public func downloadDidFinish(_ download: WKDownload) { + self.isDownloading = false + self.progressObserver = nil + + if let downloadDestination = installHelper?.temporaryKmpFileLocation { + Logger.download.info("download of \(downloadDestination.cleanUrlPath(), privacy: .public) was successful.") + LogUtil.infoBreadcrumb("download of \(downloadDestination.cleanUrlPath()) was successful.", category: .download) + + if let settings { + do { + try settings.packageDownloadComplete(kmpFileUrl: downloadDestination) + // Trigger the SwiftUI modal sheet + self.showConfirmPackageSheet = true + } catch { + self.loadPackageFailed = true + self.loadFailureMessage = error.localizedDescription + } + } + } + } + + public func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) { + Logger.download.error("download failed with error: \(error as NSError, privacy: .public)") + LogUtil.errorBreadcrumb("download failed with error: \(error as NSError)", category: .download) + self.isDownloading = false + self.progressObserver = nil + self.loadPackageFailed = true + self.loadFailureMessage = error.localizedDescription + self.installHelper = nil + if let settings { + settings.packageInstallationFailed() + } + } + + public func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { + // The web process crashed. Reload the webview safely. + Logger.download.error("webkit process terminated unexpectedly: reloading content") + LogUtil.errorBreadcrumb("webkit process terminated unexpectedly: reloading content", category: .download) + webView.reload() + } +} diff --git a/mac/Config/Config/IconButtonView.swift b/mac/Config/Config/IconButtonView.swift new file mode 100644 index 0000000000..a5c2cedca6 --- /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: LocalizedStringKey + + 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: LocalizedStringKey + 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/Info.plist b/mac/Config/Config/Info.plist new file mode 100644 index 0000000000..e57da417fb --- /dev/null +++ b/mac/Config/Config/Info.plist @@ -0,0 +1,21 @@ + + + + + Keyman + + SentryEnvironment + $(SENTRY_ENVIRONMENT) + Tier + $(TIER) + VersionGitTag + $(VERSION_GIT_TAG) + VersionRelease + $(VERSION_RELEASE) + VersionTag + $(VERSION_TAG) + VersionWithTag + $(VERSION_WITH_TAG) + + + diff --git a/mac/Config/Config/InstallDebugView.swift b/mac/Config/Config/InstallDebugView.swift new file mode 100644 index 0000000000..31b987a4c7 --- /dev/null +++ b/mac/Config/Config/InstallDebugView.swift @@ -0,0 +1,117 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2026-02-26 + * + * 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 + +struct InstallDebugView: View { + @EnvironmentObject var installation: InstallationContainer + @State private var taskText: String = "[task]" + + var body: some View { + VStack { + HStack { + Image(systemName: "gear") + .imageScale(.large) + .foregroundColor(.accentColor) + Text(verbatim: "Current task = \(taskText)") + .onAppear() { + if let installTask = installation.currentTask() { + taskText = installTask.taskType.rawValue + } + } + } + HStack { + Button { + installation.executeCurrentInstallationTask() + if let installTask = installation.currentTask() { + taskText = installTask.taskType.rawValue + } + } label: { + Text(verbatim: "Next...") + } + .disabled(installation.isInstallationComplete()) + Button { + _ = installation.migrateData() + } label: { + Text(verbatim: "Migrate Data") + } + Button { + _ = installation.registerKeymanInputMethod() + } label: { + Text(verbatim: "Register Keyman") + } + Button { + _ = installation.enableKeymanInputMethod() + } label: { + Text(verbatim: "Enable Keyman") + } + Button { + _ = installation.selectKeymanInputMethod() + } label: { + Text(verbatim: "Select Keyman") + } + Button { + installation.checkAccessibilityPermissionGranted() + } label: { + Text(verbatim: "Check Permission") + } + Button { + _ = installation.requestAccessibility() + } label: { + Text(verbatim: "Request Permission") + } + Spacer() + } + .padding() + HStack { + Button { + _ = installation.notifyUserPromptedToRestart() + } label: { + Text(verbatim: "Request Restart") + } + Button { + _ = installation.checkUserHasRestarted() + } label: { + Text(verbatim: "Check Restart") + } + Button { + installation.setHasDisplayedInstallationComplete() + } label: { + Text(verbatim: "Set Displayed Complete") + } + Button { + installation.debug() + } label: { + Text(verbatim: "debug") + } + Button { + _ = installation.disableKeymanInputMethod() + } label: { + Text(verbatim: "Disable Keyman") + } + Button { + _ = installation.killKeymanInputMethod() + } label: { + Text(verbatim: "Kill Keyman") + } + Spacer() + } + .padding() + } + .padding() +// .onReceive(NotificationCenter.default.publisher(for: .inputMethodMissing), perform: {_ in print("input method missing")}) + } +} + +#Preview { + let installation = InstallationContainer() + InstallDebugView() + .environmentObject(installation) +} diff --git a/mac/Config/Config/InstallationViews/CompletedInstallView.swift b/mac/Config/Config/InstallationViews/CompletedInstallView.swift new file mode 100644 index 0000000000..79f4f6f677 --- /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 0000000000..2b5c229ba1 --- /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 0000000000..5b9e3a1c1d --- /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: 3) + .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 0000000000..c47b39d706 --- /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 0000000000..0c391c2699 --- /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(.leading) + .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 0000000000..0d15b46724 --- /dev/null +++ b/mac/Config/Config/InstallationViews/InitialRepairView.swift @@ -0,0 +1,57 @@ +/* + * 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 { + Text("Repairs Required") + .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)) + .symbolRenderingMode(.palette) + .foregroundStyle( + Color("Keyman Blue"), // first color for the wrench + Color("Keyman Orange") // second color for the screwdriver + ) + .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(.leading) + } + .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 0000000000..f91c5860eb --- /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 0000000000..97236988f0 --- /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 0000000000..ae6632d7b4 --- /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 0000000000..ffd78c72b5 --- /dev/null +++ b/mac/Config/Config/InstallationViews/RerunInstallerView.swift @@ -0,0 +1,54 @@ +/* + * 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)) + .symbolRenderingMode(.palette) + .foregroundStyle( + Color("Keyman Blue"), // first color for the wrench + Color("Keyman Orange") // second color for the screwdriver + ) + .padding(.bottom, 16) + Spacer() + } + Text("Your Keyman input method is either missing or outdated. Run the Keyman installer to install a new version.") + .multilineTextAlignment(.leading) + .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 0000000000..493b698a94 --- /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(.leading) + .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/KeyboardSearchView.swift b/mac/Config/Config/KeyboardSearchView.swift new file mode 100644 index 0000000000..9a3900da60 --- /dev/null +++ b/mac/Config/Config/KeyboardSearchView.swift @@ -0,0 +1,51 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2026-06-16 + * + * Webview to search for Keyman keyboards/packages + */ + +import Foundation + +import SwiftUI +import Combine +import WebKit +import KeymanSettings +import OSLog + +struct KeyboardSearchView: NSViewRepresentable { + @ObservedObject var coordinator: DownloadCoordinator + @EnvironmentObject var settings: SettingsContainer + + // note that the EnvironmentObject is not available within init (if we were to implement that) + // it is injected just before makeNSView and updateNSView are called + + /** Creates the underlying NSView (WKWebView) for macOS */ + func makeNSView(context: Context) -> WKWebView { + Logger.app.debug("KeyboardSearchView makeNSView called") + + let webView = WKWebView() + + // assign the coordinator as the navigation delegate + webView.navigationDelegate = self.coordinator + + let request = URLRequest(url: settings.keyboardSearchUrl) + webView.load(request) + return webView + } + + /** + * Updates the view when the state changes. + * This is a safe place to pass the SettingsContainer to the Coordinator + * as the environment has been loaded by now. + */ + func updateNSView(_ nsView: WKWebView, context: Context) { + if coordinator.settings == nil { + coordinator.settings = self.settings + Logger.app.debug("KeyboardSearchView updateNSView, settings intialized for coordinator") + } + } +} + + diff --git a/mac/Config/Config/MainConfigView.swift b/mac/Config/Config/MainConfigView.swift new file mode 100644 index 0000000000..0baf935fb6 --- /dev/null +++ b/mac/Config/Config/MainConfigView.swift @@ -0,0 +1,217 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Gabriel Schantz on 2026-06-29 + * + * Main view used for configuring Keyman + */ + +import SwiftUI +import KeymanSettings +import OSLog +import Sentry + +struct MainConfigView: View { + + @EnvironmentObject var settings: SettingsContainer + // visibilty state for the add package sheet + @State private var isShowingAddKeyboardSheet = 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 + + // for drag and drop package installation + @State private var packageInstallHelper: PackageInstallHelper? = nil + @State private var isShowingDropKmpAlert = false + @State private var alertMessage = "" + @State private var isHovering = false + + // item being targeted for deletion + @State private var idToDelete: UUID? = nil + + private var packageNameToDelete: String { + guard let uuid = idToDelete else { return "this item" } + guard let package = settings.findInstalledPackage(with: uuid) else { return "this item" } + return package.packageName + } + + @Environment(\.colorScheme) var colorScheme + var canvasColor: Color { + colorScheme == .dark ? Color(white: 0.12) : Color(white: 0.94) + } + var cardColor: Color { + colorScheme == .dark ? Color(white: 0.20) : Color(.white) + } + + /** + * 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 { + // uncomment this Button to force sentry error (must edit scheme and disable 'Debug executable' to test) + /* + Button("Capture Sentry Error") { + let testError = NSError(domain: "SentryTest", code: 404, userInfo: [NSLocalizedDescriptionKey: "Testing Sentry from Keyman Config on Mac"]) + SentrySDK.capture(error: testError) + } + .padding() + .buttonStyle(.borderedProminent) + .tint(.red) + */ + // the add keyboard button + LabelButtonView( + action: { isShowingAddKeyboardSheet = true }, + label: "Add Keyboard", + systemImage: "plus", + font: .title2 + ) + .clipShape(.capsule) + .padding([.top, .leading, .trailing]) + // binds the visibility state to the sheet builder + .sheet(isPresented: $isShowingAddKeyboardSheet) { + AddKeyboardView() + // disable escape key for closing view to avoid issues with canceling downloads + .interactiveDismissDisabled(true) + .frame(minWidth: 800, minHeight: 600) + } + + List { + // the view for single keyboard packages + PackageRowView(packages: settings.singleKeyboardPackages, isSingleKeyboardPackage: true, expandedPackageID: $expandedPackageID, + idToDelete: $idToDelete, showHelpTab: { url in + showHelpTab(for: url)}) + + // the view for multi keyboard packages + PackageRowView(packages: settings.multiKeyboardPackages, isSingleKeyboardPackage: false, expandedPackageID: $expandedPackageID, + idToDelete: $idToDelete, showHelpTab: { url in + showHelpTab(for: url) }) + } + .listStyle(.inset) + + // confirmation dialog for deleting a package + + .confirmationDialog( + "Are you sure you want to delete the Keyman package '\(packageNameToDelete)'?", + isPresented: Binding( + get: { idToDelete != nil }, + set: { if !$0 { idToDelete = nil } } + ), + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { + if let uuid = idToDelete { + Logger.app.info("deleting package.id: \(uuid)") + LogUtil.infoBreadcrumb("deleting package.id: \(uuid)", category: .app) + + // use multiple expanded states? + //expandedStates.removeValue(forKey: uuid) + + withAnimation(.easeInOut(duration: 0.3)) { + expandedPackageID = nil + settings.removeInstalledPackage(with: uuid) + } + } + idToDelete = nil // dismiss safely + } + + Button("Cancel", role: .cancel) { + idToDelete = nil + } + } + + // drag and drop + + // highlight border with accent color when hovering over view + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.accentColor, lineWidth: 2).opacity(isHovering ? 1 : 0)) + .animation(.easeInOut(duration: 0.2), value: isHovering) + // accepts URL drops + .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.exceededFileDropLimit + self.alertMessage = error.localizedDescription + self.isShowingDropKmpAlert = true + return false // the drop failed + } + do { + packageInstallHelper = try settings.initiateKmpFileInstallation(at: droppedFileUrl) + return true // the drop was successful + } catch { + self.alertMessage = error.localizedDescription + self.isShowingDropKmpAlert = true + return false + } + } isTargeted: { hovering in + isHovering = hovering + } + + // alert to indicate failed package installation + + .alert("Package Installation Failed", isPresented: $isShowingDropKmpAlert) { + Button("OK", role: .cancel) { } + } message: { + Text(alertMessage) + } + + // package installation confirmation, displays readme contents for package + + .sheet(item: $packageInstallHelper) { helper in + PackageConfirmationView(installHelper: helper) { accepted in + + // close PackageConfirmationView sheet before updating list + packageInstallHelper = nil + + if accepted { + Logger.app.info("installing validated package: \(helper.packageName ?? "unknown package", privacy: .public)") + LogUtil.infoBreadcrumb("installing validated package: \(helper.packageName ?? "unknown package")", category: .app) + do { + try settings.installPackage() + } catch { + self.alertMessage = error.localizedDescription + self.isShowingDropKmpAlert = true + Logger.app.error("failed to install package: \(helper.packageName ?? "unknown package", privacy: .public), error: \(error as NSError, privacy: .public)") + LogUtil.errorBreadcrumb("failed to install package: \(helper.packageName ?? "unknown package"), error: \(error)", category: .app) + } + } else { + settings.userCanceledPackageInstallation() + } + } + // disable escape key for closing view to avoid issues with canceling downloads + .interactiveDismissDisabled(true) + } + + // 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 { + PackageContentWebView(packageFileUrl: 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/PackageConfirmationView.swift b/mac/Config/Config/PackageConfirmationView.swift new file mode 100644 index 0000000000..7dc310597b --- /dev/null +++ b/mac/Config/Config/PackageConfirmationView.swift @@ -0,0 +1,60 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2026-06-16 + * + * View presented as modal sheet in response to initiating a package installation. + * Displays readme.htm contents and allows user to proceed with install or cancel. + */ + +import SwiftUI +import KeymanSettings + +struct PackageConfirmationView: View { + let installHelper: PackageInstallHelper + let completion: (Bool) -> Void + + var body: some View { + VStack(spacing: 16) { + if let installationPrompt = installHelper.packageInstallationType?.prompt { + let packageName = installHelper.packageToInstall?.packageName ?? "Unknown" + Label(packageName, systemImage: "keyboard") + .font(.title) + .foregroundStyle(Color("Keyman Orange")) + .bold() + .frame(maxWidth: .infinity, alignment: .center) + Text(installationPrompt) + .font(.title3) + .multilineTextAlignment(.leading) + } + + if let readmeFileUrl = installHelper.packageToInstall?.readmeFileUrl { + PackageContentWebView(packageFileUrl: readmeFileUrl) + .cornerRadius(8) + .padding(6) + .overlay( + RoundedRectangle(cornerRadius: 14) + .stroke(Color("Keyman Orange"), lineWidth: 2) + ) + .padding() + } else { + Text("Read me not available.") + .font(.title) + } + + HStack { + Button("Cancel") { + completion(false) + } + .keyboardShortcut(.cancelAction) + + Button("Install") { + completion(true) + } + .buttonStyle(.borderedProminent) + } + } + .padding() + .frame(width: 580, height: 500) + } +} diff --git a/mac/Config/Config/PackageContentWebView.swift b/mac/Config/Config/PackageContentWebView.swift new file mode 100644 index 0000000000..305d2d736f --- /dev/null +++ b/mac/Config/Config/PackageContentWebView.swift @@ -0,0 +1,122 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Gabriel Schantz on 2026-08-03 + * + * Webview used to display html content from within the Keyman package + * Any http links clicked are opened in a browser window + */ +import Foundation + +import SwiftUI +import WebKit +import KeymanSettings + +public struct PackageContentWebView: NSViewRepresentable { + + let packageFileUrl: URL + + // create the AppKit view instance + public func makeNSView(context: Context) -> WKWebView { + let webView = WKWebView() + + // Connect the delegate to catch link clicks + webView.navigationDelegate = context.coordinator + + return webView + } + + /** + * update the view when SwiftUI state changes + */ + public func updateNSView(_ nsView: WKWebView, context: Context) { + // first update the parent in the coordinator so we know which + // package we are displaying content for + context.coordinator.parent = self + + let request = URLRequest(url: packageFileUrl) + + // only load the request if it's not already loading/loaded to prevent infinite loops + if nsView.url != packageFileUrl { + if let fileUrl = request.url { + nsView.loadFileURL(fileUrl, allowingReadAccessTo: fileUrl.deletingLastPathComponent()) + } + } + } + + /** + * Coordinator acts as the WKNavigationDelegate + */ + public func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + @MainActor + public class Coordinator: NSObject, WKNavigationDelegate { + var parent: PackageContentWebView + + init(_ parent: PackageContentWebView) { + self.parent = parent + } + + /** + * If a url links to the web rather than locally, open it in the default browser + * Must force load links in the package content that change the main frame + * because WKWebView's security sandbox will block loading of new pages + */ + public func webView(_ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void) { + + // if no url, cancel + guard let url = navigationAction.request.url else { + decisionHandler(.cancel) + return + } + + // if not user-activated, pass through, e.g. for redirects + guard navigationAction.navigationType == .linkActivated else { + print("url not user activated \(url.path())") + decisionHandler(.allow) + return + } + + // load local files in webview, force-loading files that are in the same directory + if url.isFileURL { + let standardizedIncomingUrl = url.standardizedFileURL + let packageDirectoryUrl = parent.packageFileUrl.deletingLastPathComponent() + let standardizedPackageDirectoryUrl = packageDirectoryUrl.standardizedFileURL + + // check whether the file is from the current package + if standardizedIncomingUrl.path.hasPrefix(standardizedPackageDirectoryUrl.path) { + decisionHandler(.cancel) // Cancel regular navigation + + // Force load with read permissions of package directory + webView.loadFileURL(standardizedIncomingUrl, allowingReadAccessTo: standardizedPackageDirectoryUrl) + return + } else { + // block local files outside of your target directory, not sure how we would receive one + decisionHandler(.cancel) + return + } + } + + // not a file URL: handle external links by opening in web browser + decisionHandler(.cancel) + + var externalUrl = url + + // strip "link:" prefix if present + let urlString = url.absoluteString + if urlString.hasPrefix("link:https://") || urlString.hasPrefix("link:http://") { + let cleanString = urlString.replacingOccurrences(of: "link:", with: "") + if let cleanUrl = URL(string: cleanString) { + externalUrl = cleanUrl + } + } + + // Open the external link in the default browser + NSWorkspace.shared.open(externalUrl) + } + } +} diff --git a/mac/Config/Config/PackageInfoView.swift b/mac/Config/Config/PackageInfoView.swift new file mode 100644 index 0000000000..f56dd435ad --- /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 0000000000..460ab0e747 --- /dev/null +++ b/mac/Config/Config/PackageRowView.swift @@ -0,0 +1,171 @@ +/* + * 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 + // used to identify the selected KeymanPackage for the delete package alert + + @Environment(\.colorScheme) var colorScheme + var canvasColor: Color { + colorScheme == .dark ? Color(white: 0.12) : Color(white: 0.94) + } + var cardColor: Color { + colorScheme == .dark ? Color(white: 0.20) : Color(.white) + } + + // could be 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? + @Binding var idToDelete: UUID? + // closure passed from the parent view + let showHelpTab: (URL) -> Void + + + init(packages: [KeymanPackage], isSingleKeyboardPackage: Bool, expandedPackageID: Binding, idToDelete: Binding, showHelpTab: @escaping (URL) -> Void) { + self.packages = packages + self.isSingleKeyboardPackage = isSingleKeyboardPackage + self._expandedPackageID = expandedPackageID + self._idToDelete = idToDelete + self.showHelpTab = showHelpTab + } + + 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 + idToDelete = package.id + //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 + } + } + } + } + .listRowBackground( + Rectangle() + .fill(cardColor) // native Mac card color = Color(.controlBackgroundColor) + ) + } + } + // animate changes in the package list + .animation(.easeInOut, value: packages) + .padding(.vertical, 8) + } + + // 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/ConfigUITests/ConfigUITests.swift b/mac/Config/ConfigUITests/ConfigUITests.swift new file mode 100644 index 0000000000..ea65c1cef3 --- /dev/null +++ b/mac/Config/ConfigUITests/ConfigUITests.swift @@ -0,0 +1,43 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2026-02-26 + * + * UI Tests for Config app + * + */ + +import XCTest + +final class ConfigUITests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + @MainActor + func testExample() throws { + // UI tests must launch the application that they test. + let app = XCUIApplication() + app.launch() + + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + @MainActor + func testLaunchPerformance() throws { + // This measures how long it takes to launch your application. + measure(metrics: [XCTApplicationLaunchMetric()]) { + XCUIApplication().launch() + } + } +} diff --git a/mac/Config/ConfigUITests/ConfigUITestsLaunchTests.swift b/mac/Config/ConfigUITests/ConfigUITestsLaunchTests.swift new file mode 100644 index 0000000000..06754240e0 --- /dev/null +++ b/mac/Config/ConfigUITests/ConfigUITestsLaunchTests.swift @@ -0,0 +1,35 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2026-02-26 + * + * Launch Tests for Config app + * + */ + +import XCTest + +final class ConfigUITestsLaunchTests: XCTestCase { + + override class var runsForEachTargetApplicationUIConfiguration: Bool { + true + } + + override func setUpWithError() throws { + continueAfterFailure = false + } + + @MainActor + func testLaunch() throws { + let app = XCUIApplication() + app.launch() + + // Insert steps here to perform after app launch but before taking a screenshot, + // such as logging into a test account or navigating somewhere in the app + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Launch Screen" + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/mac/Config/Installation/InstallationCheck.swift b/mac/Config/Installation/InstallationCheck.swift new file mode 100644 index 0000000000..a1490153b9 --- /dev/null +++ b/mac/Config/Installation/InstallationCheck.swift @@ -0,0 +1,459 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2026-06-01 + * + * Class for evaluating the current state of Keyman to determine is complete or in need of repair + * and what remaining tasks are needed to complete the installation. + * + * If the Keyman input method exists in the Input Methods folder and is is the correct version, + * and the installation is marked as completed, then it will be checked to see if it is completely + * valid or in need of repair. If so, 'createRepairInstallationState()' creates a new + * InstallationState object that replaces the completed one. + */ + +import Foundation +import KeymanSettings +import OSLog + +// The data model version number is updated by the input method. +// We only read the value here, and if it is current, then there is no need +// to trigger data migration. If data migration is necessary, then it must be +// performed by the input method because the input method alone has access +// to the data before it was shared in an app group beginning with Keyman 19. +let dataModelVersionStoreInGroupContainer = 2 + +public enum InstallationPhase: String { + case inputMethodMissing + case inputMethodOutdated + case evaluatingInstallation + case newInstallation + case installationInProgress + case installationComplete + case installationRepairNeeded + + // indicates whether the installation contains tasks to complete installation + public var hasTasks: Bool { + switch self { + case .newInstallation, + .installationInProgress, + .installationRepairNeeded: + return true + default: + return false + } + } +} + +enum InstallationStateCondition: String { + case stale + case new + case inProgress +} + +@MainActor +public class InstallationCheck { + public var installationState: InstallationState? + // with isEvaluatingNewInstallation==true, we are awaiting + // message from input method to determine what tasks are needed + public var isEvaluatingNewInstallation: Bool + fileprivate let isInputMethodInstalled: Bool + fileprivate let isInputMethodCurrent: Bool + fileprivate let inputMethodVersion: String + fileprivate let configurationVersion: String + fileprivate let defaultsRepository: DefaultsRepo + fileprivate let inputMethodUtil: InputMethodUtil + + // a classification of the install state + // provided so UI knows what to present to the user + public var installationPhase: InstallationPhase { + if !self.isInputMethodInstalled { + return .inputMethodMissing + } else if !self.isInputMethodCurrent { + return .inputMethodOutdated + } else if self.isEvaluatingNewInstallation { + return .evaluatingInstallation + } + + if let state = self.installationState { + if state.isComplete { + return .installationComplete + } else { + if state.isNew { + return .newInstallation + } else if state.isRepair { + return .installationRepairNeeded + } + + return .installationInProgress + } + } + + // In case installationState (optional) == nil -- + // though we will never reach this case because if it is nil + // we return inputMethodMissing or inputMethodOutdated + return .newInstallation + } + + public init(defaultsRepo: DefaultsRepo, inputMethodUtil: InputMethodUtil) { + self.defaultsRepository = defaultsRepo + self.inputMethodUtil = inputMethodUtil + self.isEvaluatingNewInstallation = false + self.configurationVersion = ConfigAppUtil.configAppVersion() + + var keymanIsCurrent = false + var keymanVersion: String = "unknown" + + let keymanExists = inputMethodUtil.keymanInputMethodExists() + if keymanExists { + keymanVersion = (try? inputMethodUtil.getKeymanInputMethodVersion()) ?? "unknown" + keymanIsCurrent = InstallationCheck.isVersionCurrent(inputMethodVersion: keymanVersion, configurationVersion: self.configurationVersion) + } + + self.isInputMethodInstalled = keymanExists + self.isInputMethodCurrent = keymanIsCurrent + self.inputMethodVersion = keymanVersion + + if self.isMigrationNeeded() { + _ = self.migrateData() + } + + let installState = InstallationCheck.readInstallationState(from: defaultsRepo) + + if (keymanExists && keymanIsCurrent) { + // the input method is valid, examine the installation state recorded on disk + // + let installationStateCondition = InstallationCheck.evaluateInstallationState(state: installState, for: keymanVersion); + Logger.app.log("installationStateCondition: \(installationStateCondition.rawValue, privacy: .public)") + + switch installationStateCondition { + case .inProgress: + self.installationState = installState // resume with the existing installation + case .new: + self.isEvaluatingNewInstallation = true // evaluate before creating a new InstallationState + case .stale: + self.clearInstallationState() // delete the existing installation state from the UserDefaults + self.isEvaluatingNewInstallation = true // evaluate before creating a new InstallationState + } + } + + self.registerObservers() + } + + /** + * Check the user defaults to see whether a migration is needed + */ + public func isMigrationNeeded() -> Bool { + var migrationNeeded = true + + if self.defaultsRepository.readDataModelVersion() >= dataModelVersionStoreInGroupContainer { + migrationNeeded = false + } + + Logger.app.debug("isMigrationNeeded: \(migrationNeeded)") + return migrationNeeded + } + + /** + * Run the Keyman input method as a subprocess to migrate data to the shared space and immediately exit + */ + public func migrateData() -> Bool { + let success = self.inputMethodUtil.invokeKeymanInputMethodMigration() + Logger.app.debug("migrateData migration suceeded: \(success)") + + // check whether + if success { + NotificationCenter.default.post(name: .dataMigrated, object: nil) + } + return success + } + + /** + * Check the condition of the InstallationState as recorded in the UserDefaults. + * Determine whether it is `stale` and should be deleted + * (which then is treated as a new installation) or + * is `inProgress` and should be loaded and used, or + * is `new`. All new installations are re-evaluated to determine + * what tasks must be executed to complete the installation. + */ + static func evaluateInstallationState(state: InstallationState?, for version: String) -> InstallationStateCondition { + + // If the installationState does not exist, then this is a new installation. + // The installationState will be created when evaluation is complete. + guard state != nil else { return .new } + + var condition = InstallationStateCondition.inProgress + + if let installState = state { + // If the installationState remains from a different install, mark it as stale. + // It will be deleted and we will evaluate for a new installation. + if installState.keymanVersion != version { + condition = .stale + } else if installState.isNew { + condition = .new + } else { + // If we're already in progress or completed or doing a repair, pick up where we left off + // Note that a completed installation will be checked for repairs + condition = .inProgress + } + } + + return condition + } + + /** + * Should be called immediately after init to evaluate what is needed for installation + * or, if the installation is complete, whether it needs repairs. + * When the notification from the input method is received and the evaluation is done, + * the installation can move out of the `evaluatingInstallation` phase + */ + public func startInstallationEvaluation() { + // call the input method to check whether Accessibility permission has been granted + if (self.isInputMethodInstalled && self.isInputMethodCurrent) && + (self.isEvaluatingNewInstallation || self.isReadyForRepairCheckAtStartup()) { + self.inputMethodUtil.doAsyncAccessibilityCheck(forceInputMethodRestart: false) + } + } + + /** + * Returns true if we should check at app startup whether the installation needs repair. + * Simply returns true if the installation is complete. + */ + func isReadyForRepairCheckAtStartup() -> Bool { + let readyForRepairCheck = self.installationState?.isComplete == true + + Logger.app.debug("isReadyForRepairCheckAtStartup: \(readyForRepairCheck, privacy: .public)") + + return readyForRepairCheck + } + + /** + * Check whether the input method and configuration app are the same version. + * Because the version of the config app will not be sent when build locally, this can be overridden, + * for testing purposes, by specifying `kTestConfigVersion` in config app's standard UserDefaults + */ + static func isVersionCurrent(inputMethodVersion: String, configurationVersion: String) -> Bool { + Logger.app.log("isVersionCurrent, comparing input method version: \(inputMethodVersion, privacy: .public) and config app version: \(configurationVersion, privacy: .public)") + return inputMethodVersion == configurationVersion + } + + /** + * register the observer to listen for the response from the input method which + * checks the current state of Accessibility permissions + */ + func registerObservers() { + Logger.app.debug("InstallationCheck registerObservers") + + DistributedNotificationCenter.default().addObserver( + self, + selector: #selector(self.handleAccessibilityResponse(_:)), + name: NSNotification.Name.accessibilityStateResponse, + object: nil // Observe notifications from any sender + ) + // TODO: add timeout in case response is not received + // see issue #16579 + } + + /** + * called when `NSNotification.Name.accessibilityStateResponse` is received + */ + @objc func handleAccessibilityResponse(_ notification: Notification) { + var installCompleted = false + + // Extract message from the notification if available + if let message = notification.object as? String { + let permissionGranted = self.processAccessibilityResponse(with: message) + + Logger.app.debug("handleAccessibilityResponse, message: \(message, privacy: .public)") + + if let state = self.installationState { + installCompleted = state.isComplete + } + + if self.isEvaluatingNewInstallation { + // if evaluating the current state for a new installation, + // complete the evaluation using the results of the permission check + self.completeNewInstallationEvaluation(accessibilityPermissionGranted: permissionGranted) + } else if installCompleted { + // if this is a completed install, check whether repairs are needed + self.checkForRepair(accessibilityPermissionGranted: permissionGranted) + } else { + // otherwise, this is for an install step, post results + if permissionGranted { + NotificationCenter.default.post(name: .accessibilityGranted, object: nil) + } else { + NotificationCenter.default.post(name: .accessibilityNotGranted, object: nil) + } + } + } else { + Logger.app.debug("handleAccessibilityResponse, received but did not include message") + } + } + + /** + * Process the distributed notification message that we received from the Keyman input method. + */ + func processAccessibilityResponse(with message: String) -> Bool { + let timeStyle = Date.FormatStyle() + .hour(.twoDigits(amPM: Date.FormatStyle.Symbol.Hour.AMPMStyle.abbreviated)) + .minute(.twoDigits) + .second(.twoDigits) + .secondFraction(.fractional(3)) + Logger.app.debug("processAccessibilityResponse received message: \(message, privacy: .public), time: \(Date().formatted(timeStyle), privacy: .public)") + + // if the message indicates that access was granted, then return true + return !message.isEmpty && message == kAccessibilityPermissionGrantedMessage + } + + /** + * Save the new InstallationState and notify observers to start new installation + */ + func applyNewInstallationState(state: InstallationState) { + self.defaultsRepository.writeInstallationState(state.toUserDefaultsDictionary()) + self.installationState = state + NotificationCenter.default.post(name: .startNewInstallation, object: state) + } + + /** + * Save the new InstallationState for handling repairs and notify observers + */ + func applyRepairedInstallationState(state: InstallationState) { + self.defaultsRepository.writeInstallationState(state.toUserDefaultsDictionary()) + self.installationState = state + NotificationCenter.default.post(name: .startInstallationRepair, object: state) + } + + /** + * Load the installation state and the tasks required to complete the installation of Keyman. + * This is accomplished by one of the following: + * 1. reading the saved installation which is either + * completed or + * in progress + * 2. creating a new installation + * + */ + public func loadState() -> InstallationState? { + var installationState: InstallationState? = nil + + guard self.isInputMethodInstalled && self.isInputMethodCurrent else { + return nil + } + + if let savedInstallationState = readInstallationState() { + installationState = savedInstallationState + } + + return installationState + } + + /** + * Clear the installation state from the UserDefaults + */ + func clearInstallationState() { + self.defaultsRepository.deleteInstallationState() + } + + /** + * Using the accessibility state returned from the input method, build the new task list + * and determine what is actually required for the new installation. + */ + func completeNewInstallationEvaluation(accessibilityPermissionGranted: Bool) { + // see what tasks remain based on the evaluation + let neededTasks = determineInstallationTasksNeeded(isRepair: false, with: accessibilityPermissionGranted) + let newState = self.createNewInstallationState(with: neededTasks) + self.applyNewInstallationState(state: newState) + } + + /** + * Creates a InstallationState object describing a new installation + */ + func createNewInstallationState(with neededTasks: Set) -> InstallationState { + Logger.app.debug("completeNewInstallationEvaluation: created new installation state") + + var fullTaskList = neededTasks + + // add prepareNewInstall InstallationTask + fullTaskList.insert(InstallationTask.createNewInstallationTask(type: .prepareNewInstall)) + + let installationState = InstallationState(version: self.inputMethodVersion, tasks: fullTaskList) + + return installationState + } + + /** + * Determine whether the completed installation has been altered in some way and needs repair. + * If repair is needed, then call `applyRepairedInstallationState` with the new `InstallationState` + */ + func checkForRepair(accessibilityPermissionGranted: Bool) { + // check whether the installation requires repair + if let state = self.createRepairInstallationState(accessibilityPermissionGranted: accessibilityPermissionGranted) { + Logger.app.log("checkForRepair completed: repair is required") + self.applyRepairedInstallationState(state: state) + } else { + Logger.app.log("checkForRepair completed: no repair needed") + } + } + +/** + * Read the currently saved installation state as an object + */ +func readInstallationState() -> InstallationState? { + return InstallationCheck.readInstallationState(from: self.defaultsRepository) +} + +/** + * Read the currently saved installation state as an object + */ +static func readInstallationState(from repo: DefaultsRepo) -> InstallationState? { + guard let installationMap = repo.readInstallationState() else { + return nil + } + + return InstallationState(from: installationMap) +} + + /** + * 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(isRepair: Bool, with accessibilityPermissionGranted: Bool) -> Set { + var newTasks = Set() + + // add task to request Accessibility permission if needed + if !accessibilityPermissionGranted { + newTasks.insert(InstallationTask.createNewInstallationTask(type: .requestAccess)) + newTasks.insert(InstallationTask.createNewInstallationTask(type: .confirmAccess)) + } + + // add enable input method and restart mac tasks if needed + if !self.inputMethodUtil.isKeymanInputMethodEnabled() { + newTasks.insert(InstallationTask.createNewInstallationTask(type: .enableInputMethod)) + + // 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 + } + + /** + * The provided parameter `accessibilityPermissionGranted` was returned asynchronously from the input method. + * Check the installation to see of it is valid -- something may have been tampered with after installation was completed. + * If the installation needs repair, create the info needed for repairing the installation. + */ + func createRepairInstallationState(accessibilityPermissionGranted: Bool) -> InstallationState? { + var repairInstallationState: InstallationState? = nil + + var repairTasks = self.determineInstallationTasksNeeded(isRepair: true, with: accessibilityPermissionGranted) + if !repairTasks.isEmpty { + // add prepareNewRepair + repairTasks.insert(InstallationTask.createNewInstallationTask(type: .prepareNewRepair)) + repairInstallationState = InstallationState(version: self.inputMethodVersion, isRepair: true, tasks: repairTasks) + } + + return repairInstallationState + } +} diff --git a/mac/Config/Installation/InstallationContainer.swift b/mac/Config/Installation/InstallationContainer.swift new file mode 100644 index 0000000000..ffe6123a10 --- /dev/null +++ b/mac/Config/Installation/InstallationContainer.swift @@ -0,0 +1,479 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2026-02-24 + * + * Manages the steps for completing the installation of the Keyman input method + * + */ +import SwiftUI +import Combine +import KeymanSettings +import OSLog + +// in-app notifications sent +public extension Notification.Name { + static let startNewInstallation = Notification.Name("start.new.installation") + static let startInstallationRepair = Notification.Name("start.installation.repair") + static let installationRepairStarted = Notification.Name("installation.repair.started") + static let accessibilityGranted = Notification.Name("installation.accessibility.granted") + static let accessibilityNotGranted = Notification.Name("installation.accessibility.not.granted") + static let checkAccessibilitySuccess = Notification.Name("accessibility.success") + static let checkAccessibilityFailure = Notification.Name("accessibility.failure") +} + +@MainActor // run on the main actor since data is published directly to the UI +public class InstallationContainer : ObservableObject { + public var installationPhase: InstallationPhase { + return self.installationCheck.installationPhase + } + + var installationState: InstallationState? { + return self.installationCheck.installationState + } + + fileprivate let installationCheck: InstallationCheck + fileprivate let defaultsRepository: DefaultsRepo + fileprivate let inputMethodUtil: InputMethodUtil + + public init() { + let defaultsRepo: DefaultsRepository + // create the settings repository, gaining access to the app group UserDefaults + do { + defaultsRepo = try DefaultsRepository(suiteName: InputMethodUtil.groupId) + Logger.app.log("found group container") + } catch UserDefaultsError.unknownSuite { + Logger.app.error("group container not found: \(UserDefaultsError.unknownSuite)") + fatalError("Group container not found.") + } catch { + Logger.app.error("unable to access settings in group container: \(error as NSError, privacy: .public)") + fatalError("Unable to access settings in group container.") + } + + self.defaultsRepository = defaultsRepo + + do { + try inputMethodUtil = InputMethodUtil() + } catch { + fatalError("Unable to access group container path for InputMethodUtil: \(error.localizedDescription).") + } + + self.installationCheck = InstallationCheck(defaultsRepo: defaultsRepo, inputMethodUtil: inputMethodUtil) + + // If we can now confirm that the user restarted (the final task), then the installation + // will be complete and there is no need to evaluate the state. + // Otherwise, evaluate the installation to prepare for a new installation or check for repairs. + if self.confirmRestartRequired() { + self.confirmUserRestarted() + } else { + self.registerObservers() + self.installationCheck.startInstallationEvaluation() + } + } + + /** + * register observers to learn of results of InstallationState evaluation + */ + func registerObservers() { + Logger.app.debug("InstallationContainer registerObservers") + + NotificationCenter.default.addObserver( + self, + selector: #selector(self.handleStartNewInstallation(_:)), + name: NSNotification.Name.startNewInstallation, + object: nil // Observe notifications from any sender + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(self.handleStartInstallationRepair(_:)), + name: NSNotification.Name.startInstallationRepair, + object: nil // Observe notifications from any sender + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(self.handleAccessibilityGranted(_:)), + name: NSNotification.Name.accessibilityGranted, + object: nil // Observe notifications from any sender + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(self.handleAccessibilityNotGranted(_:)), + name: NSNotification.Name.accessibilityNotGranted, + object: nil // Observe notifications from any sender + ) + } + + /** + * called when `NSNotification.Name.startNewInstallation` is received + */ + @objc func handleStartNewInstallation(_ notification: Notification) { + Logger.app.debug("handleStartNewInstallation received") + + // the evaluation is done + self.installationCheck.isEvaluatingNewInstallation = false + } + + /** + * called when `NSNotification.Name.startInstallationRepair` is received + */ + @objc func handleStartInstallationRepair(_ notification: Notification) { + Logger.app.debug("handleStartInstallationRepair received") + + // notify observers + NotificationCenter.default.post(name: .installationRepairStarted, object: nil, userInfo: nil) + } + + /** + * called when `NSNotification.Name.accessibilityGranted` is received + */ + @objc func handleAccessibilityGranted(_ notification: Notification) { + guard self.installationState != nil else { return } + Logger.app.debug("handleAccessibilityGranted received") + + // the confirmAccess task can now be marked as completed + if let task = self.currentTask() { + if task.taskType == .confirmAccess { + self.updateTaskAsCompleted(taskType: .confirmAccess) + } + } + + NotificationCenter.default.post(name: .checkAccessibilitySuccess, object: nil, userInfo: nil) + } + + /** + * called when `NSNotification.Name.accessibilityNotGranted` is received + */ + @objc func handleAccessibilityNotGranted(_ notification: Notification) { + Logger.app.debug("handleAccessibilityNotGranted received") + NotificationCenter.default.post(name: .checkAccessibilityFailure, object: nil, userInfo: nil) + } + + /** + * If the current task is confirmRestart, mark it as complete if the user has restarted their mac. + */ + func confirmUserRestarted() { + guard let task = self.currentTask() else { return } + guard self.installationState != nil else { return } + + if task.taskType == .confirmRestart && self.checkUserHasRestarted() { + // the confirmAccess task can now be marked as completed + self.updateTaskAsCompleted(taskType: .confirmRestart) + } + } + + /** + * Check whether waiting to confirm that the user restarted. + */ + func confirmRestartRequired() -> Bool { + guard let task = self.currentTask() else { return false } + guard self.installationState != nil else { return false } + + return task.taskType == .confirmRestart + } + + /** + * Returns true if the Accessibility permission has been granted by the user for the Keyman input method. + * This is an optional return value because it is only set in response to a call to `checkAccessibilityPermissionGranted` + * and is not populated until an asynchronous message is received in response. + */ + public func isAccessibilityPermissionGranted() -> Bool? { + return self.inputMethodUtil.accessibilityPermissionGranted + } + + /** + * return trues if every installation task has been completed + */ + public func isInstallationComplete() -> Bool { + guard let state = self.installationState else { return false } + + return state.isComplete + } + + /** + * Returns the current incompleted installation task, if there is one. + * Note that this function determines the order in which the tasks are executed as they are stored in an unsorted Set. + */ + public func currentTask() -> InstallationTask? { + guard let state = self.installationState else { return nil } + guard self.installationPhase.hasTasks else { + Logger.app.error("the installation phase \(self.installationPhase.rawValue, privacy: .public) has no tasks") + LogUtil.errorBreadcrumb("the installation phase \(self.installationPhase.rawValue) has no tasks", category: .app) + return nil + } + + let incompleteTasks = state.tasks.filter { !$0.isComplete } + + if let incompleteTask = incompleteTasks.first(where: { $0.taskType == .prepareNewInstall }) { + return incompleteTask + } else if let incompleteTask = incompleteTasks.first(where: { $0.taskType == .prepareNewRepair }) { + return incompleteTask + } else if let incompleteTask = incompleteTasks.first(where: { $0.taskType == .enableInputMethod }) { + return incompleteTask + } else if let incompleteTask = incompleteTasks.first(where: { $0.taskType == .requestAccess }) { + return incompleteTask + } else if let incompleteTask = incompleteTasks.first(where: { $0.taskType == .confirmAccess }) { + return incompleteTask + } else if let incompleteTask = incompleteTasks.first(where: { $0.taskType == .requestRestart }) { + return incompleteTask + } else if let incompleteTask = incompleteTasks.first(where: { $0.taskType == .confirmRestart }) { + return incompleteTask + } + + return nil + } + + /** + * Executes the specified installation task. + */ + func executeTask(_ task: InstallationTask) { + guard self.installationState != nil else { return } + guard self.installationPhase.hasTasks else { + Logger.app.error("executeTask: the installation phase \(self.installationPhase.rawValue) has no tasks") + LogUtil.errorBreadcrumb("executeTask: the installation phase \(self.installationPhase.rawValue) has no tasks", category: .app) + return + } + + var completedTask = false + + switch task.taskType { + case .prepareNewInstall: + completedTask = true + case .prepareNewRepair: + completedTask = true + case .enableInputMethod: + completedTask = self.enableKeymanInputMethod() + case .requestAccess: + completedTask = self.requestAccessibility() + case .confirmAccess: + self.checkAccessibilityPermissionGranted() + // this task is completed asynchronously when the response is returned from the input method + completedTask = false + case .requestRestart: + completedTask = self.notifyUserPromptedToRestart() + case .confirmRestart: + completedTask = self.checkUserHasRestarted() + } + + if completedTask { + self.updateTaskAsCompleted(taskType: task.taskType) + } + } + + /** + * Marks the specified task as completed and saves it to the UserDefaults. + * Note that this actually creates a copy of the InstallationState object and updates + * the property in InstallationCheck with the new reference. + */ + public func updateTaskAsCompleted(taskType: InstallationTaskType) { + Logger.app.debug("executeTask: \(taskType.rawValue, privacy: .public) completed") + if let existingState = self.installationState { + let updatedState = InstallationState.createCopyWithCompletedTask(from: existingState, with: taskType) + self.installationCheck.installationState = updatedState + self.writeInstallationState() + } + } + + /** + * Executes the current incomplete installation task, if one remains. + */ + public func executeCurrentInstallationTask() { + if let installTask = self.currentTask() { + self.executeTask(installTask) + } + } + + /** + * Run the Keyman input method as a subprocess to migrate data to the shared space and immediately exit + */ + public func migrateData() -> Bool { + let success = self.installationCheck.migrateData() + Logger.app.debug("migration suceeded: \(success)") + return success + } + + /** + * Save the installation state + */ + func writeInstallationState() { + guard let state = self.installationState else { return } + + self.defaultsRepository.writeInstallationState(state.toUserDefaultsDictionary()) + } + + /** + * Record that the installation complete view has been shown to the user + */ + public func setHasDisplayedInstallationComplete() { + if let existingState = self.installationState { + let updatedState = InstallationState.createCopy(from: existingState) + updatedState.hasDisplayedInstallComplete = true + self.installationCheck.installationState = updatedState + self.writeInstallationState() + } + } + + /** + * Return whether the installation complete view has been shown to the user + */ + func getHasDisplayedInstallationComplete() -> Bool { + guard let state = self.installationState else { return false } + + return state.hasDisplayedInstallComplete + } + + /** + * Write the time that the user was requested to restart their machine + */ + func writeRestartRequestTime() { + if let existingState = self.installationState { + let updatedState = InstallationState.createCopy(from: existingState) + updatedState.dateRestartRequested = Date() + self.installationCheck.installationState = updatedState + self.writeInstallationState() + } + } + + /** + * Read the time that the user was requested to restart their machine + */ + func readRestartRequestTime() -> Date? { + guard let state = self.installationState else { return nil } + + return state.dateRestartRequested + } + + /** + * Notify that the user has been prompted to restart the machine. + */ + public func notifyUserPromptedToRestart() -> Bool { + self.writeRestartRequestTime() + return true + } + + /** + * Check whether the user has restarted by comparing the latest startup time to the time we requested the user to restart + */ + public func checkUserHasRestarted() -> Bool { + var hasRestarted = false + + guard let state = self.installationState else { return false } + + if let timeRestartRequested = state.dateRestartRequested { + if let mostRecentStartupTime = self.getMostRecentRestartTime() { + hasRestarted = mostRecentStartupTime > timeRestartRequested + Logger.app.debug("mostRecentStartupTime: \(mostRecentStartupTime), timeRestartRequested: \(timeRestartRequested)") + } + } + Logger.app.debug("validateRestarted: \(hasRestarted)") + return hasRestarted + } + + /** + * return the last time the system was booted + */ + func getMostRecentRestartTime() -> Date? { + var timeSince1970 = timeval() + var size = MemoryLayout.size + + // Query the kernel for the boot time + let result = sysctlbyname("kern.boottime", &timeSince1970, &size, nil, 0) + + if result == 0 { + // Convert the timeval (seconds since 1970) into a Swift Date + return Date(timeIntervalSince1970: Double(timeSince1970.tv_sec) + Double(timeSince1970.tv_usec) / 1_000_000.0) + } else { + return nil + } + } + + /** + * used to report on some current state + */ + public func debug() { + var permissionString = "unknown" + if let permissionGranted = self.isAccessibilityPermissionGranted() { + permissionString = permissionGranted ? "granted" : "denied" + } + + let version = (try? inputMethodUtil.getKeymanInputMethodVersion()) ?? "unknown" + let enabled = inputMethodUtil.isKeymanInputMethodEnabled() + let running = inputMethodUtil.isKeymanInputMethodRunning() + + Logger.app.debug("Keyman status, version: \(version, privacy: .private), enabled: \(enabled), running: \(running), permissionGranted: \(permissionString)") + } + + /** + * register may need to happen before enabling + */ + public func registerKeymanInputMethod() -> Bool { + let success = self.inputMethodUtil.registerKeymanInputMethod() + Logger.app.debug("registerKeymanInputMethod suceeded: \(success)") + + return success + } + + /** + * set Keyman as the current input method (same effect as choosing Keyman in the input source menu) + */ + public func selectKeymanInputMethod() -> Bool { + let success = self.inputMethodUtil.selectKeymanInputMethod() + Logger.app.debug("selectKeymanInputMethod suceeded: \(success)") + + return success + } + + /** + * true if the system recognizes Keyman as an enabled input method + */ + public func isKeymanInputMethodEnabled() -> Bool { + return inputMethodUtil.isKeymanInputMethodEnabled() + } + + /** + * ask the system to enable the Keyman input method + * register it first, just to be safe + */ + public func enableKeymanInputMethod() -> Bool { + var success = self.inputMethodUtil.registerKeymanInputMethod() + if success { + success = self.inputMethodUtil.enableKeymanInputMethod() + } + + Logger.app.debug("enableKeymanInputMethod suceeded: \(success)") + return success + } + + /** + * call Keyman as a separate process with an argument that checks whether accessibility has been granted by the user + */ + public func checkAccessibilityPermissionGranted() { + self.inputMethodUtil.doAsyncAccessibilityCheck(forceInputMethodRestart: false) + } + + /** + * Call Keyman as a separate process with an argument that requests the system to prompt the user to grant accessibility. + * To learn the result, we must poll with `checkAccessibilityPermissionGranted()` + */ + public func requestAccessibility() -> Bool { + var requested = false + + requested = self.inputMethodUtil.invokeKeymanInputMethodRequestAccess() + Logger.app.debug("requestAccessibility called, requested: \(requested)") + + return requested + } + + /** + * kill the Keyman Input Method process + */ + public func killKeymanInputMethod() -> Bool { + return self.inputMethodUtil.killKeymanInputMethod() + } + + /** + * disable Keyman as an Input Method + */ + public func disableKeymanInputMethod() -> Bool { + return self.inputMethodUtil.disableKeymanInputMethod() + } +} diff --git a/mac/Config/Installation/InstallationState.swift b/mac/Config/Installation/InstallationState.swift new file mode 100644 index 0000000000..11d04fdf21 --- /dev/null +++ b/mac/Config/Installation/InstallationState.swift @@ -0,0 +1,136 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2026-06-01 + * + * Class for tracking overall state of the installation. + * Includes functions for mapping to a Dictionary to store in the UserDefaults. + */ + +import Foundation +import KeymanSettings + +public class InstallationState { + // keys to the fields we are storing in the UserDefaults KMInstallationState dictionary + let kVersionKey = "version" + let kDateRestartRequestedKey = "dateRestartRequested" + let kRepairKey = "isRepair" + let kDisplayedInstallComplete = "displayedInstallComplete" + + public let keymanVersion: String + public var dateRestartRequested: Date? + // indicates whether we are repairing a previous installation or doing a full installation + public let isRepair: Bool + // indicates whether the installation complete message has been displayed to the user + public var hasDisplayedInstallComplete: Bool + // this list of tasks that make up this installation + public var tasks: Set + + /** + * The installation is complete if all its tasks are complete. + * Returns true if the tasks list is empty, but it should never be empty. + */ + public var isComplete: Bool { + return tasks.allSatisfy(\.isComplete) + } + + /** + * The installation is new if this is not a repair and no tasks are complete. + * Returns true if the tasks list is empty, but it should never be empty. + */ + public var isNew: Bool { + return !isRepair && tasks.allSatisfy{ !$0.isComplete } + } + + /** + * The installation has at least one task complete and at least one remaining to complete. + * It does not matter if this is a repair or not. + */ + public var isInProgress: Bool { + return tasks.contains(where: { !$0.isComplete }) && tasks.contains(where: { $0.isComplete}) + } + + init(version: String, dateRestartRequested: Date? = nil, isRepair: Bool = false, hasDisplayedInstallComplete: Bool = false, tasks: Set) { + self.keymanVersion = version + self.dateRestartRequested = dateRestartRequested + self.isRepair = isRepair + self.hasDisplayedInstallComplete = hasDisplayedInstallComplete + self.tasks = tasks + } + + /** + * create a new InstallationState with an existing InstallationState and a task to mark as completed + */ + public static func createCopyWithCompletedTask (from state: InstallationState, with completedTask: InstallationTaskType) -> InstallationState { + var newTaskList = state.tasks + let completedTask = InstallationTask(task: completedTask, completed: true) + newTaskList.update(with: completedTask) + + return InstallationState(version: state.keymanVersion, dateRestartRequested: state.dateRestartRequested, isRepair: state.isRepair, hasDisplayedInstallComplete: state.hasDisplayedInstallComplete, tasks: newTaskList) + } + + /** + * create a new InstallationState with an existing InstallationState and a task to mark as completed + */ + public static func createCopy (from state: InstallationState) -> InstallationState { + let newTaskList = state.tasks + + return InstallationState(version: state.keymanVersion, dateRestartRequested: state.dateRestartRequested, isRepair: state.isRepair, hasDisplayedInstallComplete: state.hasDisplayedInstallComplete, tasks: newTaskList) + } + + /** + * initialize using the dictionary from UserDefaults + */ + init?(from dictionary: Dictionary) { + self.keymanVersion = dictionary[kVersionKey] as? String ?? "" + self.dateRestartRequested = dictionary[kDateRestartRequestedKey] as? Date + self.isRepair = dictionary[kRepairKey] as? Bool ?? false + self.hasDisplayedInstallComplete = dictionary[kDisplayedInstallComplete] as? Bool ?? false + var installationTasks = Set() + + // for every task flag found in dictionary, insert a task in the tasks array + if let taskFlag = dictionary[InstallationTaskType.prepareNewInstall.rawValue] as? Bool { + installationTasks.insert(InstallationTask(task: .prepareNewInstall, completed: taskFlag)) + } + if let taskFlag = dictionary[InstallationTaskType.prepareNewRepair.rawValue] as? Bool { + installationTasks.insert(InstallationTask(task: .prepareNewRepair, completed: taskFlag)) + } + if let taskFlag = dictionary[InstallationTaskType.enableInputMethod.rawValue] as? Bool { + installationTasks.insert(InstallationTask(task: .enableInputMethod, completed: taskFlag)) + } + if let taskFlag = dictionary[InstallationTaskType.requestAccess.rawValue] as? Bool { + installationTasks.insert(InstallationTask(task: .requestAccess, completed: taskFlag)) + } + if let taskFlag = dictionary[InstallationTaskType.confirmAccess.rawValue] as? Bool { + installationTasks.insert(InstallationTask(task: .confirmAccess, completed: taskFlag)) + } + if let taskFlag = dictionary[InstallationTaskType.requestRestart.rawValue] as? Bool { + installationTasks.insert(InstallationTask(task: .requestRestart, completed: taskFlag)) + } + if let taskFlag = dictionary[InstallationTaskType.confirmRestart.rawValue] as? Bool { + installationTasks.insert(InstallationTask(task: .confirmRestart, completed: taskFlag)) + } + + self.tasks = installationTasks + } + + /** + * create a dictionary for storing the current InstallationState state in the UserDefaults + */ + public func toUserDefaultsDictionary() -> Dictionary { + var dictionary: Dictionary = [:] + + dictionary[kVersionKey] = keymanVersion + if let dateRestartRequested = self.dateRestartRequested { + dictionary[kDateRestartRequestedKey] = dateRestartRequested + } + dictionary[kRepairKey] = self.isRepair + dictionary[kDisplayedInstallComplete] = self.hasDisplayedInstallComplete + + for task in self.tasks { + dictionary[task.taskType.rawValue] = task.isComplete + } + + return dictionary + } +} diff --git a/mac/Config/Installation/InstallationTask.swift b/mac/Config/Installation/InstallationTask.swift new file mode 100644 index 0000000000..3db1f5b135 --- /dev/null +++ b/mac/Config/Installation/InstallationTask.swift @@ -0,0 +1,60 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2026-06-01 + * + * Class for tracking the state of a single installation task. + */ + +import Foundation +import KeymanSettings + +// String raw value for each task type is used as key for Dictionary +// when storing the task state in the UserDefaults +public enum InstallationTaskType: String { + case prepareNewInstall = "prepareNewInstall" // only added for new installs + case prepareNewRepair = "prepareRepair" // only added for repairs + case enableInputMethod = "enableInputMethod" // triggers register and enable + case requestAccess = "requestAccess" // causes prompt to user to grant Accessibility + case confirmAccess = "confirmAccess" // confirms that Accessibility was granted + case requestRestart = "requestRestart" // prompts user to restart machine + case confirmRestart = "confirmRestart" // confirms that the machine was restarted +} + +public struct InstallationTask: Hashable, Equatable { + public let taskType: InstallationTaskType + public let isComplete: Bool + + /** + * Hashable conformance based on taskType + */ + public func hash(into hasher: inout Hasher) { + hasher.combine(taskType) + } + + /** + * Hashable conformance based on taskType + */ + public static func == (lhs: InstallationTask, rhs: InstallationTask) -> Bool { + return lhs.taskType == rhs.taskType + } + + /** + * Create new InstallationTask: `completed` set to `false` + */ + static func createNewInstallationTask(type: InstallationTaskType) -> InstallationTask { + return InstallationTask(task: type, completed: false) + } + + /** + * Create InstallationTask with `completed` set to `true` + */ + static func createCompletedInstallationTask(type: InstallationTaskType) -> InstallationTask { + return InstallationTask(task: type, completed: true) + } + + init(task: InstallationTaskType, completed: Bool) { + self.taskType = task + self.isComplete = completed + } +} diff --git a/mac/Config/Localizable.xcstrings b/mac/Config/Localizable.xcstrings new file mode 100644 index 0000000000..2c2735ae2e --- /dev/null +++ b/mac/Config/Localizable.xcstrings @@ -0,0 +1,161 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "About Keyman Configuration" : { + "comment" : "A label for the about panel.", + "isCommentAutoGenerated" : true + }, + "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 © SIL Global" : { + "comment" : "A copyright notice.", + "isCommentAutoGenerated" : true + }, + "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" : { + + }, + "Keyman Configuration" : { + "comment" : "The name of the app.", + "isCommentAutoGenerated" : true + }, + "License Agreement" : { + "comment" : "A button that opens a web page with the license agreement for Keyman for macOS.", + "isCommentAutoGenerated" : true + }, + "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." : { + + }, + "Version %@" : { + "comment" : "A description of the app, including its version and copyright.", + "isCommentAutoGenerated" : true + }, + "Website:" : { + + }, + "Your Keyman input method is either missing or outdated. Run the Keyman installer to install a new version." : { + + } + }, + "version" : "1.2" +} \ No newline at end of file diff --git a/mac/Config/Resources/keyman-for-mac-os-license.html b/mac/Config/Resources/keyman-for-mac-os-license.html new file mode 100644 index 0000000000..feb7e7ac7e --- /dev/null +++ b/mac/Config/Resources/keyman-for-mac-os-license.html @@ -0,0 +1,123 @@ + + + + + + + + + + + + +
+ +

Keyman­® for macOS +End User Licence and Services Agreement

+ +

The MIT License

+

Copyright (c) 2017 SIL International

+

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

+

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

+ +

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

+ + + +
+ +

 

+ +
+ +
+ + + + diff --git a/mac/Keyman.xcworkspace/contents.xcworkspacedata b/mac/Keyman.xcworkspace/contents.xcworkspacedata index 0af1fa3a4a..1d46bb83cf 100644 --- a/mac/Keyman.xcworkspace/contents.xcworkspacedata +++ b/mac/Keyman.xcworkspace/contents.xcworkspacedata @@ -1,6 +1,12 @@ + + + + @@ -13,4 +19,7 @@ + + diff --git a/mac/Keyman.xcworkspace/xcshareddata/swiftpm/Package.resolved b/mac/Keyman.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000000..581bbf535d --- /dev/null +++ b/mac/Keyman.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "d6053e837382e5b9950aabdaac059058993d8ef6619f809121310d22cd4216b5", + "pins" : [ + { + "identity" : "sentry-cocoa", + "kind" : "remoteSourceControl", + "location" : "https://github.com/getsentry/sentry-cocoa", + "state" : { + "revision" : "a00a9784fff7193cbb0f9fae509fec8107e07744", + "version" : "9.26.1" + } + }, + { + "identity" : "zipfoundation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/weichsel/ZIPFoundation.git", + "state" : { + "revision" : "22787ffb59de99e5dc1fbfe80b19c97a904ad48d", + "version" : "0.9.20" + } + } + ], + "version" : 3 +} diff --git a/mac/Keyman4Mac/Keyman4Mac.xcodeproj/project.pbxproj b/mac/Keyman4Mac/Keyman4Mac.xcodeproj/project.pbxproj index 25cc7ca800..2b90c86fb1 100644 --- a/mac/Keyman4Mac/Keyman4Mac.xcodeproj/project.pbxproj +++ b/mac/Keyman4Mac/Keyman4Mac.xcodeproj/project.pbxproj @@ -386,8 +386,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; COMBINE_HIDPI_IMAGES = YES; - COPYRIGHT_STRING = "Copyright © $(COPYRIGHT_YEARS) SIL International. All rights reserved."; - COPYRIGHT_YEARS = "2017-2019"; + COPYRIGHT_STRING = "Copyright © SIL Global. All rights reserved."; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Keyman4Mac/Frameworks", @@ -412,8 +411,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; COMBINE_HIDPI_IMAGES = YES; - COPYRIGHT_STRING = "Copyright © $(COPYRIGHT_YEARS) SIL International. All rights reserved."; - COPYRIGHT_YEARS = "2017-2019"; + COPYRIGHT_STRING = "Copyright © SIL Global. All rights reserved."; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Keyman4Mac/Frameworks", diff --git a/mac/Keyman4Mac/Keyman4Mac/Info.plist b/mac/Keyman4Mac/Keyman4Mac/Info.plist index 74709c8071..07ed2ec3a2 100644 --- a/mac/Keyman4Mac/Keyman4Mac/Info.plist +++ b/mac/Keyman4Mac/Keyman4Mac/Info.plist @@ -10,8 +10,6 @@ CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleGetInfoString - . CFBundleInfoDictionaryVersion 6.0 CFBundleName diff --git a/mac/Keyman4MacIM/Keyman-template.dmg b/mac/Keyman4MacIM/Keyman-template.dmg index f23b470e2c..5725a8fcf0 100644 Binary files a/mac/Keyman4MacIM/Keyman-template.dmg and b/mac/Keyman4MacIM/Keyman-template.dmg differ diff --git a/mac/Keyman4MacIM/Keyman.entitlements b/mac/Keyman4MacIM/Keyman.entitlements index 49ad0bb090..6799e05868 100644 --- a/mac/Keyman4MacIM/Keyman.entitlements +++ b/mac/Keyman4MacIM/Keyman.entitlements @@ -2,6 +2,10 @@ + com.apple.security.application-groups + + group.com.keyman + com.apple.security.automation.apple-events diff --git a/mac/Keyman4MacIM/Keyman4MacIM.xcodeproj/project.pbxproj b/mac/Keyman4MacIM/Keyman4MacIM.xcodeproj/project.pbxproj index 89bbfb022e..4a612a060d 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM.xcodeproj/project.pbxproj +++ b/mac/Keyman4MacIM/Keyman4MacIM.xcodeproj/project.pbxproj @@ -12,7 +12,6 @@ 290BC75E274F3FD7005CD1C3 /* KMKeyboardInfo.m in Sources */ = {isa = PBXBuildFile; fileRef = 290BC75D274F3FD7005CD1C3 /* KMKeyboardInfo.m */; }; 2915DC512BFE35DB0051FC52 /* KMLogs.m in Sources */ = {isa = PBXBuildFile; fileRef = 29B4A0D32BF7675A00682049 /* KMLogs.m */; }; 293EA3E627140D8100545EED /* KMAboutWindowController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 293EA3E827140D8100545EED /* KMAboutWindowController.xib */; }; - 293EA3EB27140DEC00545EED /* preferences.xib in Resources */ = {isa = PBXBuildFile; fileRef = 293EA3ED27140DEC00545EED /* preferences.xib */; }; 293EA3F427181FDA00545EED /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 293EA3F627181FDA00545EED /* Localizable.strings */; }; 296105232C8E91C7007BF6B7 /* KMInputMethodLifecycle.m in Sources */ = {isa = PBXBuildFile; fileRef = 296105222C8E91C7007BF6B7 /* KMInputMethodLifecycle.m */; }; 296FE2FC275DD21600F46898 /* KMPackageReader.m in Sources */ = {isa = PBXBuildFile; fileRef = 296FE2FB275DD21600F46898 /* KMPackageReader.m */; }; @@ -28,7 +27,6 @@ 299ABD7129ECE75B00AA5948 /* KeySender.m in Sources */ = {isa = PBXBuildFile; fileRef = 299ABD6F29ECE75B00AA5948 /* KeySender.m */; }; 299B5E2029126CA20070841D /* keyman-icon@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 299B5E1E29126CA20070841D /* keyman-icon@2x.png */; }; 299B5E2129126CA20070841D /* keyman-icon.png in Resources */ = {isa = PBXBuildFile; fileRef = 299B5E1F29126CA20070841D /* keyman-icon.png */; }; - 29B42A572728339900EDD5D3 /* KMInfoWindowController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 29B42A592728339900EDD5D3 /* KMInfoWindowController.xib */; }; 29B42A602728343B00EDD5D3 /* KMKeyboardHelpWindowController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 29B42A622728343B00EDD5D3 /* KMKeyboardHelpWindowController.xib */; }; 29B4A0D52BF7675A00682049 /* KMLogs.m in Sources */ = {isa = PBXBuildFile; fileRef = 29B4A0D32BF7675A00682049 /* KMLogs.m */; }; 29B6FB732BC39DD60074BF7F /* TextApiComplianceTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 29B6FB722BC39DD60074BF7F /* TextApiComplianceTests.m */; }; @@ -45,8 +43,6 @@ 9800EC5A1C02940300BF0FB5 /* keyman-for-mac-os-license.html in Resources */ = {isa = PBXBuildFile; fileRef = 9800EC591C02940300BF0FB5 /* keyman-for-mac-os-license.html */; }; 9803C9D81B0EFB3B0082A6F9 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9803C9D71B0EFB3B0082A6F9 /* WebKit.framework */; }; 983247281A9EA28F0010B90C /* KeymanEngine4Mac.framework in Copy Files */ = {isa = PBXBuildFile; fileRef = 984C2B211A79DF1B0023F89D /* KeymanEngine4Mac.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 9832472E1A9EABC70010B90C /* KMConfigColumn1CellView.m in Sources */ = {isa = PBXBuildFile; fileRef = 9832472D1A9EABC70010B90C /* KMConfigColumn1CellView.m */; }; - 983247B21A9EB9690010B90C /* KMConfigColumn3CellView.m in Sources */ = {isa = PBXBuildFile; fileRef = 983247B11A9EB9690010B90C /* KMConfigColumn3CellView.m */; }; 9836B3701AE5F11D00780482 /* ioapi.c in Sources */ = {isa = PBXBuildFile; fileRef = 9836B3661AE5F11D00780482 /* ioapi.c */; }; 9836B3711AE5F11D00780482 /* mztools.c in Sources */ = {isa = PBXBuildFile; fileRef = 9836B3681AE5F11D00780482 /* mztools.c */; }; 9836B3721AE5F11D00780482 /* unzip.c in Sources */ = {isa = PBXBuildFile; fileRef = 9836B36A1AE5F11D00780482 /* unzip.c */; }; @@ -72,12 +68,8 @@ 98D6DA7B1A799F0B00B09822 /* InputMethodKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 98D6DA7A1A799F0B00B09822 /* InputMethodKit.framework */; }; 98D6DA7E1A799FF400B09822 /* KMInputController.m in Sources */ = {isa = PBXBuildFile; fileRef = 98D6DA7D1A799FF400B09822 /* KMInputController.m */; }; 98E2CEA11A92C39C00AE2455 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 98E2CEA01A92C39C00AE2455 /* InfoPlist.strings */; }; - 98E6729F1B532F5E00DBDE2F /* KMDownloadKBWindowController.m in Sources */ = {isa = PBXBuildFile; fileRef = 98E6729D1B532F5D00DBDE2F /* KMDownloadKBWindowController.m */; }; - 98E672A01B532F5E00DBDE2F /* KMDownloadKBWindowController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98E6729E1B532F5E00DBDE2F /* KMDownloadKBWindowController.xib */; }; 98E672A41B532F9900DBDE2F /* KMKeyboardHelpWindowController.m in Sources */ = {isa = PBXBuildFile; fileRef = 98E672A21B532F9900DBDE2F /* KMKeyboardHelpWindowController.m */; }; - 98F3C8931A8D739500F675E3 /* KMConfigurationWindowController.m in Sources */ = {isa = PBXBuildFile; fileRef = 98F3C8911A8D739500F675E3 /* KMConfigurationWindowController.m */; }; 98FE105E1B4DE8F400525F54 /* NSWindow+SuppMethods.m in Sources */ = {isa = PBXBuildFile; fileRef = 98FE105D1B4DE8F400525F54 /* NSWindow+SuppMethods.m */; }; - 98FE10631B4DEE5600525F54 /* KMInfoWindowController.m in Sources */ = {isa = PBXBuildFile; fileRef = 98FE10611B4DEE5600525F54 /* KMInfoWindowController.m */; }; 9A3D6C5D221531B0008785A3 /* KMOSVersion.m in Sources */ = {isa = PBXBuildFile; fileRef = 9A3D6C5C221531B0008785A3 /* KMOSVersion.m */; }; B90818AF7ED302187DE0E026 /* Pods_Keyman.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B644E63217FFE54B91C71C94 /* Pods_Keyman.framework */; }; D861B03F2C5747F70003675E /* KMSettingsRepository.m in Sources */ = {isa = PBXBuildFile; fileRef = D861B03E2C5747F70003675E /* KMSettingsRepository.m */; }; @@ -87,7 +79,6 @@ E213601E2142D7C000A043B7 /* keyman-88.png in Resources */ = {isa = PBXBuildFile; fileRef = E213601D2142D7BF00A043B7 /* keyman-88.png */; }; E21799051FC5B7BC00F2D66A /* KMInputMethodEventHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = E21799041FC5B7BC00F2D66A /* KMInputMethodEventHandler.m */; }; E21E645820C04EA8000D6274 /* logo.png in Resources */ = {isa = PBXBuildFile; fileRef = E21E645720C04EA7000D6274 /* logo.png */; }; - E240F599202DED740000067D /* KMPackage.m in Sources */ = {isa = PBXBuildFile; fileRef = E240F598202DED740000067D /* KMPackage.m */; }; E2585A7F20DD6C3C00CBB994 /* KMMethodEventHandlerTests.kmx in Resources */ = {isa = PBXBuildFile; fileRef = E2585A7E20DD6C3C00CBB994 /* KMMethodEventHandlerTests.kmx */; }; E2585A8120DD7CF100CBB994 /* TestAppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = E2585A8020DD7CF100CBB994 /* TestAppDelegate.m */; }; E27BA9D32020D8BF00D273E7 /* SimpleTest.kmx in Resources */ = {isa = PBXBuildFile; fileRef = E27BA9D22020D8BF00D273E7 /* SimpleTest.kmx */; }; @@ -145,12 +136,10 @@ /* Begin PBXFileReference section */ 29015ABB2C58D86F00CCBB94 /* KMDataRepository.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KMDataRepository.h; sourceTree = ""; }; 29015ABC2C58D86F00CCBB94 /* KMDataRepository.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KMDataRepository.m; sourceTree = ""; }; - 2901BA8A292332B3009903EC /* ff-NG */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ff-NG"; path = "ff-NG.lproj/KMAboutWindowController.strings"; sourceTree = ""; }; - 2901BA8B292332B3009903EC /* ff-NG */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ff-NG"; path = "ff-NG.lproj/preferences.strings"; sourceTree = ""; }; - 2901BA8C292332B3009903EC /* ff-NG */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ff-NG"; path = "ff-NG.lproj/KMInfoWindowController.strings"; sourceTree = ""; }; - 2901BA8D292332B3009903EC /* ff-NG */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ff-NG"; path = "ff-NG.lproj/KMKeyboardHelpWindowController.strings"; sourceTree = ""; }; - 2901BA8E292332B4009903EC /* ff-NG */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ff-NG"; path = "ff-NG.lproj/MainMenu.strings"; sourceTree = ""; }; - 2901BA8F292332CF009903EC /* ff-NG */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ff-NG"; path = "ff-NG.lproj/Localizable.strings"; sourceTree = ""; }; + 2901BA8A292332B3009903EC /* ff-ng */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ff-ng"; path = "ff-ng.lproj/KMAboutWindowController.strings"; sourceTree = ""; }; + 2901BA8D292332B3009903EC /* ff-ng */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ff-ng"; path = "ff-ng.lproj/KMKeyboardHelpWindowController.strings"; sourceTree = ""; }; + 2901BA8E292332B4009903EC /* ff-ng */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ff-ng"; path = "ff-ng.lproj/MainMenu.strings"; sourceTree = ""; }; + 2901BA8F292332CF009903EC /* ff-ng */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ff-ng"; path = "ff-ng.lproj/Localizable.strings"; sourceTree = ""; }; 290BC67E274B9DB1005CD1C3 /* KMPackageInfo.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KMPackageInfo.h; sourceTree = ""; }; 290BC67F274B9DB1005CD1C3 /* KMPackageInfo.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KMPackageInfo.m; sourceTree = ""; }; 290BC75C274F3FD7005CD1C3 /* KMKeyboardInfo.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KMKeyboardInfo.h; sourceTree = ""; }; @@ -158,8 +147,6 @@ 293EA3E527140D3A00545EED /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; 293EA3E727140D8100545EED /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/KMAboutWindowController.xib; sourceTree = ""; }; 293EA3EA27140D8E00545EED /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/KMAboutWindowController.strings; sourceTree = ""; }; - 293EA3EC27140DEC00545EED /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/preferences.xib; sourceTree = ""; }; - 293EA3EF27140DFA00545EED /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/preferences.strings; sourceTree = ""; }; 293EA3F02714158600545EED /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/MainMenu.strings; sourceTree = ""; }; 293EA3F527181FDA00545EED /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 296105212C8E91C7007BF6B7 /* KMInputMethodLifecycle.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KMInputMethodLifecycle.h; sourceTree = ""; }; @@ -167,8 +154,6 @@ 296FE2FA275DD21600F46898 /* KMPackageReader.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KMPackageReader.h; sourceTree = ""; }; 296FE2FB275DD21600F46898 /* KMPackageReader.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KMPackageReader.m; sourceTree = ""; }; 29781101297FB262007C886D /* kn */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = kn; path = kn.lproj/KMAboutWindowController.strings; sourceTree = ""; }; - 29781102297FB262007C886D /* kn */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = kn; path = kn.lproj/preferences.strings; sourceTree = ""; }; - 29781103297FB263007C886D /* kn */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = kn; path = kn.lproj/KMInfoWindowController.strings; sourceTree = ""; }; 29781104297FB263007C886D /* kn */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = kn; path = kn.lproj/KMKeyboardHelpWindowController.strings; sourceTree = ""; }; 29781105297FB263007C886D /* kn */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = kn; path = kn.lproj/MainMenu.strings; sourceTree = ""; }; 29781106297FB27E007C886D /* kn */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = kn; path = kn.lproj/Localizable.strings; sourceTree = ""; }; @@ -178,26 +163,18 @@ 297A501528DF4D360074EB1B /* PrivacyWindowController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = PrivacyWindowController.h; sourceTree = ""; }; 297A501628DF4D360074EB1B /* PrivacyConsent.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = PrivacyConsent.m; sourceTree = ""; }; 298566BC29802962004ACA95 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/KMAboutWindowController.strings; sourceTree = ""; }; - 298566BD29802963004ACA95 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/preferences.strings; sourceTree = ""; }; - 298566BE29802963004ACA95 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/KMInfoWindowController.strings; sourceTree = ""; }; 298566BF29802963004ACA95 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/KMKeyboardHelpWindowController.strings; sourceTree = ""; }; 298566C029802963004ACA95 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/MainMenu.strings; sourceTree = ""; }; 298566C129802970004ACA95 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = ""; }; 298566C82980C9AD004ACA95 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/KMAboutWindowController.strings; sourceTree = ""; }; - 298566C92980C9AD004ACA95 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/preferences.strings; sourceTree = ""; }; - 298566CA2980C9AD004ACA95 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/KMInfoWindowController.strings; sourceTree = ""; }; 298566CB2980C9AE004ACA95 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/KMKeyboardHelpWindowController.strings; sourceTree = ""; }; 298566CC2980C9AE004ACA95 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/MainMenu.strings; sourceTree = ""; }; 298566CD2980C9B9004ACA95 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = ""; }; 298566D42980D47C004ACA95 /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/KMAboutWindowController.strings; sourceTree = ""; }; - 298566D52980D47D004ACA95 /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/preferences.strings; sourceTree = ""; }; - 298566D62980D47D004ACA95 /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/KMInfoWindowController.strings; sourceTree = ""; }; 298566D72980D47D004ACA95 /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/KMKeyboardHelpWindowController.strings; sourceTree = ""; }; 298566D82980D47E004ACA95 /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/MainMenu.strings; sourceTree = ""; }; 298566D92980D489004ACA95 /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = ""; }; 298566E02980E579004ACA95 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/KMAboutWindowController.strings; sourceTree = ""; }; - 298566E12980E579004ACA95 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/preferences.strings; sourceTree = ""; }; - 298566E22980E57A004ACA95 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/KMInfoWindowController.strings; sourceTree = ""; }; 298566E32980E57A004ACA95 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/KMKeyboardHelpWindowController.strings; sourceTree = ""; }; 298566E42980E57A004ACA95 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/MainMenu.strings; sourceTree = ""; }; 298566E52980E584004ACA95 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; @@ -209,71 +186,49 @@ 299B5E1E29126CA20070841D /* keyman-icon@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "keyman-icon@2x.png"; sourceTree = ""; }; 299B5E1F29126CA20070841D /* keyman-icon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "keyman-icon.png"; sourceTree = ""; }; 29A4730527E9D6D500DE07C7 /* ha */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ha; path = ha.lproj/KMAboutWindowController.strings; sourceTree = ""; }; - 29A4730627E9D6D500DE07C7 /* ha */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ha; path = ha.lproj/preferences.strings; sourceTree = ""; }; - 29A4730727E9D6D600DE07C7 /* ha */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ha; path = ha.lproj/KMInfoWindowController.strings; sourceTree = ""; }; 29A4730827E9D6D700DE07C7 /* ha */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ha; path = ha.lproj/KMKeyboardHelpWindowController.strings; sourceTree = ""; }; 29A4730927E9D6D700DE07C7 /* ha */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ha; path = ha.lproj/MainMenu.strings; sourceTree = ""; }; 29A4730A27E9D84B00DE07C7 /* ha */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ha; path = ha.lproj/Localizable.strings; sourceTree = ""; }; 29B27FF229062FF50036917E /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/KMAboutWindowController.strings; sourceTree = ""; }; - 29B27FF329062FF50036917E /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/preferences.strings; sourceTree = ""; }; - 29B27FF429062FF50036917E /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/KMInfoWindowController.strings; sourceTree = ""; }; 29B27FF529062FF60036917E /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/KMKeyboardHelpWindowController.strings; sourceTree = ""; }; 29B27FF629062FF60036917E /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/MainMenu.strings; sourceTree = ""; }; 29B27FF7290630170036917E /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; 29B348AA2726F1C1009B1C82 /* km */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = km; path = km.lproj/KMAboutWindowController.strings; sourceTree = ""; }; - 29B348AB2726F1C2009B1C82 /* km */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = km; path = km.lproj/preferences.strings; sourceTree = ""; }; 29B348AC2726F1C2009B1C82 /* km */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = km; path = km.lproj/MainMenu.strings; sourceTree = ""; }; 29B348AE2726F1C7009B1C82 /* km */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = km; path = km.lproj/Localizable.strings; sourceTree = ""; }; - 29B42A582728339900EDD5D3 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/KMInfoWindowController.xib; sourceTree = ""; }; - 29B42A5B2728339E00EDD5D3 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/KMInfoWindowController.strings; sourceTree = ""; }; - 29B42A5F272833A600EDD5D3 /* km */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = km; path = km.lproj/KMInfoWindowController.strings; sourceTree = ""; }; 29B42A612728343B00EDD5D3 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/KMKeyboardHelpWindowController.xib; sourceTree = ""; }; 29B42A642728343F00EDD5D3 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/KMKeyboardHelpWindowController.strings; sourceTree = ""; }; 29B42A682728344600EDD5D3 /* km */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = km; path = km.lproj/KMKeyboardHelpWindowController.strings; sourceTree = ""; }; 29B4A0D32BF7675A00682049 /* KMLogs.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = KMLogs.m; sourceTree = ""; }; 29B4A0D42BF7675A00682049 /* KMLogs.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = KMLogs.h; sourceTree = ""; }; 29B5BC0328169A6300B86A17 /* ff */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ff; path = ff.lproj/KMAboutWindowController.strings; sourceTree = ""; }; - 29B5BC0428169A6400B86A17 /* ff */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ff; path = ff.lproj/preferences.strings; sourceTree = ""; }; - 29B5BC0528169A6400B86A17 /* ff */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ff; path = ff.lproj/KMInfoWindowController.strings; sourceTree = ""; }; 29B5BC0628169A6500B86A17 /* ff */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ff; path = ff.lproj/KMKeyboardHelpWindowController.strings; sourceTree = ""; }; 29B5BC0728169A6600B86A17 /* ff */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ff; path = ff.lproj/MainMenu.strings; sourceTree = ""; }; 29B5BC0828169A8600B86A17 /* ff */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ff; path = ff.lproj/Localizable.strings; sourceTree = ""; }; 29B6BB5B292239B4008F04BD /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/KMAboutWindowController.strings"; sourceTree = ""; }; - 29B6BB5C292239B4008F04BD /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/preferences.strings"; sourceTree = ""; }; - 29B6BB5D292239B4008F04BD /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/KMInfoWindowController.strings"; sourceTree = ""; }; 29B6BB5E292239B4008F04BD /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/KMKeyboardHelpWindowController.strings"; sourceTree = ""; }; 29B6BB5F292239B4008F04BD /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/MainMenu.strings"; sourceTree = ""; }; 29B6BB60292239DC008F04BD /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; 29B6FB722BC39DD60074BF7F /* TextApiComplianceTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = TextApiComplianceTests.m; sourceTree = ""; }; 29B70A23283CB6680086B580 /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-419"; path = "es-419.lproj/KMAboutWindowController.strings"; sourceTree = ""; }; - 29B70A24283CB6690086B580 /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-419"; path = "es-419.lproj/preferences.strings"; sourceTree = ""; }; - 29B70A25283CB66A0086B580 /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-419"; path = "es-419.lproj/KMInfoWindowController.strings"; sourceTree = ""; }; 29B70A26283CB66B0086B580 /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-419"; path = "es-419.lproj/KMKeyboardHelpWindowController.strings"; sourceTree = ""; }; 29B70A27283CB66C0086B580 /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-419"; path = "es-419.lproj/MainMenu.strings"; sourceTree = ""; }; 29B70A28283CB6900086B580 /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-419"; path = "es-419.lproj/Localizable.strings"; sourceTree = ""; }; 29BE9D852CA3C21900B67DE7 /* KMModifierMapping.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KMModifierMapping.h; sourceTree = ""; }; 29BE9D862CA3C21900B67DE7 /* KMModifierMapping.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KMModifierMapping.m; sourceTree = ""; }; 29BFA761282936D4009FCCC3 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/KMAboutWindowController.strings; sourceTree = ""; }; - 29BFA762282936D5009FCCC3 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/preferences.strings; sourceTree = ""; }; - 29BFA763282936D5009FCCC3 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/KMInfoWindowController.strings; sourceTree = ""; }; 29BFA764282936D6009FCCC3 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/KMKeyboardHelpWindowController.strings; sourceTree = ""; }; 29BFA765282936D7009FCCC3 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/MainMenu.strings; sourceTree = ""; }; 29BFA76628293760009FCCC3 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; 29BFA76D282948BD009FCCC3 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/KMAboutWindowController.strings; sourceTree = ""; }; - 29BFA76E282948BE009FCCC3 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/preferences.strings; sourceTree = ""; }; - 29BFA76F282948BE009FCCC3 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/KMInfoWindowController.strings; sourceTree = ""; }; 29BFA770282948BF009FCCC3 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/KMKeyboardHelpWindowController.strings; sourceTree = ""; }; 29BFA771282948C0009FCCC3 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/MainMenu.strings; sourceTree = ""; }; 29BFA7722829490A009FCCC3 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; 29C1E1742800227500759EDE /* pt-PT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-PT"; path = "pt-PT.lproj/KMAboutWindowController.strings"; sourceTree = ""; }; - 29C1E1752800227600759EDE /* pt-PT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-PT"; path = "pt-PT.lproj/preferences.strings"; sourceTree = ""; }; - 29C1E1762800227700759EDE /* pt-PT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-PT"; path = "pt-PT.lproj/KMInfoWindowController.strings"; sourceTree = ""; }; 29C1E1772800227700759EDE /* pt-PT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-PT"; path = "pt-PT.lproj/KMKeyboardHelpWindowController.strings"; sourceTree = ""; }; 29C1E1782800227800759EDE /* pt-PT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-PT"; path = "pt-PT.lproj/MainMenu.strings"; sourceTree = ""; }; 29C1E179280025A900759EDE /* pt-PT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-PT"; path = "pt-PT.lproj/Localizable.strings"; sourceTree = ""; }; 29D470942C648D5100224B4F /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/KMAboutWindowController.strings; sourceTree = ""; }; - 29D470952C648D5100224B4F /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/preferences.strings; sourceTree = ""; }; - 29D470962C648D5200224B4F /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/KMInfoWindowController.strings; sourceTree = ""; }; 29D470972C648D5200224B4F /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/KMKeyboardHelpWindowController.strings; sourceTree = ""; }; 29D470982C648D5200224B4F /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/MainMenu.strings; sourceTree = ""; }; 29D470992C648D7100224B4F /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/Localizable.strings; sourceTree = ""; }; @@ -281,8 +236,6 @@ 29DBBF662D35053E00D8E33C /* KMSentryHelper.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KMSentryHelper.m; sourceTree = ""; }; 29DD5F432CFEF88000683388 /* SILAndikaV1RGB.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = SILAndikaV1RGB.png; sourceTree = ""; }; 29DD8400276C49E20066A16E /* am */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = am; path = am.lproj/KMAboutWindowController.strings; sourceTree = ""; }; - 29DD8401276C49E20066A16E /* am */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = am; path = am.lproj/preferences.strings; sourceTree = ""; }; - 29DD8402276C49E30066A16E /* am */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = am; path = am.lproj/KMInfoWindowController.strings; sourceTree = ""; }; 29DD8403276C49E40066A16E /* am */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = am; path = am.lproj/KMKeyboardHelpWindowController.strings; sourceTree = ""; }; 29DD8404276C49E50066A16E /* am */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = am; path = am.lproj/MainMenu.strings; sourceTree = ""; }; 29DD8408276C4D2F0066A16E /* am */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = am; path = am.lproj/Localizable.strings; sourceTree = ""; }; @@ -307,10 +260,6 @@ 878287E1B18ED4EE910C4BC7 /* Pods-Keyman.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Keyman.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Keyman/Pods-Keyman.debug.xcconfig"; sourceTree = ""; }; 9800EC591C02940300BF0FB5 /* keyman-for-mac-os-license.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = "keyman-for-mac-os-license.html"; sourceTree = ""; }; 9803C9D71B0EFB3B0082A6F9 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; }; - 9832472C1A9EABC70010B90C /* KMConfigColumn1CellView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = KMConfigColumn1CellView.h; path = Keyman4MacIM/KMConfiguration/KMConfigColumn1CellView.h; sourceTree = ""; }; - 9832472D1A9EABC70010B90C /* KMConfigColumn1CellView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = KMConfigColumn1CellView.m; path = Keyman4MacIM/KMConfiguration/KMConfigColumn1CellView.m; sourceTree = ""; }; - 983247B01A9EB9690010B90C /* KMConfigColumn3CellView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = KMConfigColumn3CellView.h; path = Keyman4MacIM/KMConfiguration/KMConfigColumn3CellView.h; sourceTree = ""; }; - 983247B11A9EB9690010B90C /* KMConfigColumn3CellView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = KMConfigColumn3CellView.m; path = Keyman4MacIM/KMConfiguration/KMConfigColumn3CellView.m; sourceTree = ""; }; 9836B3651AE5F11D00780482 /* crypt.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = crypt.h; sourceTree = ""; }; 9836B3661AE5F11D00780482 /* ioapi.c */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.c; path = ioapi.c; sourceTree = ""; }; 9836B3671AE5F11D00780482 /* ioapi.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ioapi.h; sourceTree = ""; }; @@ -352,32 +301,21 @@ 98D6DA7C1A799FF400B09822 /* KMInputController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = KMInputController.h; sourceTree = ""; }; 98D6DA7D1A799FF400B09822 /* KMInputController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = KMInputController.m; sourceTree = ""; }; 98E2CEA01A92C39C00AE2455 /* InfoPlist.strings */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; path = InfoPlist.strings; sourceTree = ""; }; - 98E6729C1B532F5D00DBDE2F /* KMDownloadKBWindowController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = KMDownloadKBWindowController.h; path = Keyman4MacIM/KMDownloadKeyboard/KMDownloadKBWindowController.h; sourceTree = ""; }; - 98E6729D1B532F5D00DBDE2F /* KMDownloadKBWindowController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = KMDownloadKBWindowController.m; path = Keyman4MacIM/KMDownloadKeyboard/KMDownloadKBWindowController.m; sourceTree = ""; }; - 98E6729E1B532F5E00DBDE2F /* KMDownloadKBWindowController.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; name = KMDownloadKBWindowController.xib; path = Keyman4MacIM/KMDownloadKeyboard/KMDownloadKBWindowController.xib; sourceTree = ""; }; 98E672A11B532F9900DBDE2F /* KMKeyboardHelpWindowController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = KMKeyboardHelpWindowController.h; path = Keyman4MacIM/KMKeyboardHelpWindow/KMKeyboardHelpWindowController.h; sourceTree = ""; }; 98E672A21B532F9900DBDE2F /* KMKeyboardHelpWindowController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = KMKeyboardHelpWindowController.m; path = Keyman4MacIM/KMKeyboardHelpWindow/KMKeyboardHelpWindowController.m; sourceTree = ""; }; - 98F3C8901A8D739500F675E3 /* KMConfigurationWindowController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = KMConfigurationWindowController.h; path = Keyman4MacIM/KMConfiguration/KMConfigurationWindowController.h; sourceTree = ""; }; - 98F3C8911A8D739500F675E3 /* KMConfigurationWindowController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = KMConfigurationWindowController.m; path = Keyman4MacIM/KMConfiguration/KMConfigurationWindowController.m; sourceTree = ""; }; 98FE105C1B4DE8F400525F54 /* NSWindow+SuppMethods.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = "NSWindow+SuppMethods.h"; path = "Categories/NSWindow+SuppMethods.h"; sourceTree = ""; }; 98FE105D1B4DE8F400525F54 /* NSWindow+SuppMethods.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = "NSWindow+SuppMethods.m"; path = "Categories/NSWindow+SuppMethods.m"; sourceTree = ""; }; - 98FE10601B4DEE5600525F54 /* KMInfoWindowController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = KMInfoWindowController.h; path = Keyman4MacIM/KMInfoWindow/KMInfoWindowController.h; sourceTree = ""; }; - 98FE10611B4DEE5600525F54 /* KMInfoWindowController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = KMInfoWindowController.m; path = Keyman4MacIM/KMInfoWindow/KMInfoWindowController.m; sourceTree = ""; }; 9A3D6C5B221531B0008785A3 /* KMOSVersion.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KMOSVersion.h; sourceTree = ""; }; 9A3D6C5C221531B0008785A3 /* KMOSVersion.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KMOSVersion.m; sourceTree = ""; }; A71FD8BA8404531DFBD861B6 /* Pods_KeymanTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_KeymanTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; B644E63217FFE54B91C71C94 /* Pods_Keyman.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Keyman.framework; sourceTree = BUILT_PRODUCTS_DIR; }; CEFFECD82A417FEA00D58C36 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/KMAboutWindowController.strings; sourceTree = ""; }; - CEFFECD92A417FEB00D58C36 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/preferences.strings; sourceTree = ""; }; - CEFFECDA2A417FEB00D58C36 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/KMInfoWindowController.strings; sourceTree = ""; }; CEFFECDB2A417FEC00D58C36 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/KMKeyboardHelpWindowController.strings; sourceTree = ""; }; CEFFECDC2A417FEC00D58C36 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/MainMenu.strings; sourceTree = ""; }; CEFFECDD2A4180FD00D58C36 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; D861B03D2C5747F70003675E /* KMSettingsRepository.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KMSettingsRepository.h; sourceTree = ""; }; D861B03E2C5747F70003675E /* KMSettingsRepository.m */ = {isa = PBXFileReference; indentWidth = 2; lastKnownFileType = sourcecode.c.objc; path = KMSettingsRepository.m; sourceTree = ""; tabWidth = 2; }; D88EC4072D7878C10022CB80 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/KMAboutWindowController.strings; sourceTree = ""; }; - D88EC4082D7878C20022CB80 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/preferences.strings; sourceTree = ""; }; - D88EC4092D7878C20022CB80 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/KMInfoWindowController.strings; sourceTree = ""; }; D88EC40A2D7878C20022CB80 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/KMKeyboardHelpWindowController.strings; sourceTree = ""; }; D88EC40B2D7878C30022CB80 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/MainMenu.strings; sourceTree = ""; }; D88EC40C2D7878ED0022CB80 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; @@ -390,8 +328,6 @@ E21799031FC5B74D00F2D66A /* KMInputMethodEventHandler.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KMInputMethodEventHandler.h; sourceTree = ""; }; E21799041FC5B7BC00F2D66A /* KMInputMethodEventHandler.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KMInputMethodEventHandler.m; sourceTree = ""; }; E21E645720C04EA7000D6274 /* logo.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = logo.png; path = Keyman4MacIM/KMConfiguration/logo.png; sourceTree = SOURCE_ROOT; }; - E240F597202DED300000067D /* KMPackage.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KMPackage.h; sourceTree = ""; }; - E240F598202DED740000067D /* KMPackage.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KMPackage.m; sourceTree = ""; }; E2585A7E20DD6C3C00CBB994 /* KMMethodEventHandlerTests.kmx */ = {isa = PBXFileReference; lastKnownFileType = file; path = KMMethodEventHandlerTests.kmx; sourceTree = ""; }; E2585A8020DD7CF100CBB994 /* TestAppDelegate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = TestAppDelegate.m; sourceTree = ""; }; E2585A8220DD7D3F00CBB994 /* TestAppDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = TestAppDelegate.h; sourceTree = ""; }; @@ -402,7 +338,6 @@ E2C92D4720B46CEA00742A7D /* KeymanEngine4Mac.framework.dSYM */ = {isa = PBXFileReference; lastKnownFileType = wrapper.dsym; name = KeymanEngine4Mac.framework.dSYM; path = Keyman4MacIM/Frameworks/KeymanEngine4Mac.framework.dSYM; sourceTree = ""; }; E2D5529B20DD8D6100D77CFB /* LegacyTestClient.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = LegacyTestClient.h; sourceTree = ""; }; E2D5529C20DD8E0200D77CFB /* LegacyTestClient.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = LegacyTestClient.m; sourceTree = ""; }; - E2EE23962142E2AC00EAA98C /* logo.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = logo.png; path = Keyman4MacIM/KMConfiguration/logo.png; sourceTree = ""; }; FA67C64E3D2743D2BCF46ED1 /* Pods-KeymanTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-KeymanTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-KeymanTests/Pods-KeymanTests.release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -480,22 +415,6 @@ path = ConfigurationHost; sourceTree = ""; }; - 983246EC1A9E8E860010B90C /* KMConfiguration */ = { - isa = PBXGroup; - children = ( - E2EE23962142E2AC00EAA98C /* logo.png */, - 9832472C1A9EABC70010B90C /* KMConfigColumn1CellView.h */, - 9832472D1A9EABC70010B90C /* KMConfigColumn1CellView.m */, - 983247B01A9EB9690010B90C /* KMConfigColumn3CellView.h */, - 983247B11A9EB9690010B90C /* KMConfigColumn3CellView.m */, - 98F3C8901A8D739500F675E3 /* KMConfigurationWindowController.h */, - 98F3C8911A8D739500F675E3 /* KMConfigurationWindowController.m */, - 293EA3ED27140DEC00545EED /* preferences.xib */, - ); - name = KMConfiguration; - path = ..; - sourceTree = ""; - }; 9836B3631AE5F11D00780482 /* ZipArchive */ = { isa = PBXGroup; children = ( @@ -545,17 +464,6 @@ path = Images; sourceTree = ""; }; - 9897BD8E1B099A2400C3F58A /* KMDownloadKeyboard */ = { - isa = PBXGroup; - children = ( - 98E6729C1B532F5D00DBDE2F /* KMDownloadKBWindowController.h */, - 98E6729D1B532F5D00DBDE2F /* KMDownloadKBWindowController.m */, - 98E6729E1B532F5E00DBDE2F /* KMDownloadKBWindowController.xib */, - ); - name = KMDownloadKeyboard; - path = ..; - sourceTree = ""; - }; 989C9BFF1A7876DE00A20425 = { isa = PBXGroup; children = ( @@ -598,9 +506,6 @@ 989C9C121A7876DE00A20425 /* Images.xcassets */, 98BDD3681BC3511200FAC7C4 /* Keyman4MacIM.entitlements */, 98BF92401BF01CBE0002126A /* KMAboutWindow */, - 983246EC1A9E8E860010B90C /* KMConfiguration */, - 9897BD8E1B099A2400C3F58A /* KMDownloadKeyboard */, - 98FE105F1B4DEE1100525F54 /* KMInfoWindow */, 98D6DA7C1A799FF400B09822 /* KMInputController.h */, 98D6DA7D1A799FF400B09822 /* KMInputController.m */, 98A778C21A8C53BF00CF809D /* KMInputMethodAppDelegate.h */, @@ -618,8 +523,6 @@ 984B8F3E1AF1C2F400E096A8 /* OnScreenKeyboard */, 989C9C0B1A7876DE00A20425 /* Supporting Files */, 9836B3631AE5F11D00780482 /* ZipArchive */, - E240F597202DED300000067D /* KMPackage.h */, - E240F598202DED740000067D /* KMPackage.m */, 9A3D6C5B221531B0008785A3 /* KMOSVersion.h */, 9A3D6C5C221531B0008785A3 /* KMOSVersion.m */, 290BC67E274B9DB1005CD1C3 /* KMPackageInfo.h */, @@ -698,17 +601,6 @@ name = Categories; sourceTree = ""; }; - 98FE105F1B4DEE1100525F54 /* KMInfoWindow */ = { - isa = PBXGroup; - children = ( - 98FE10601B4DEE5600525F54 /* KMInfoWindowController.h */, - 98FE10611B4DEE5600525F54 /* KMInfoWindowController.m */, - 29B42A592728339900EDD5D3 /* KMInfoWindowController.xib */, - ); - name = KMInfoWindow; - path = ..; - sourceTree = ""; - }; E27BA9CC2020321F00D273E7 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -801,7 +693,6 @@ TargetAttributes = { 989C9C071A7876DE00A20425 = { CreatedOnToolsVersion = 6.1.1; - DevelopmentTeam = 3YE4W86L3G; ProvisioningStyle = Manual; SystemCapabilities = { com.apple.HardenedRuntime = { @@ -836,7 +727,7 @@ "es-419", nl, "zh-Hans", - "ff-NG", + "ff-ng", cs, kn, sv, @@ -868,20 +759,17 @@ 98E2CEA11A92C39C00AE2455 /* InfoPlist.strings in Resources */, E21E645820C04EA8000D6274 /* logo.png in Resources */, 9874C3211B536847000BB543 /* info.png in Resources */, - 98E672A01B532F5E00DBDE2F /* KMDownloadKBWindowController.xib in Resources */, 37AE5C9D239A7B770086CC7C /* qrcode.min.js in Resources */, 37C2B0CB25FF2C350092E16A /* Help in Resources */, 299B5E2029126CA20070841D /* keyman-icon@2x.png in Resources */, 297A501828DF4D360074EB1B /* PrivacyWindowController.xib in Resources */, 984B8F451AF1C3D900E096A8 /* OSKWindowController.xib in Resources */, - 29B42A572728339900EDD5D3 /* KMInfoWindowController.xib in Resources */, 299B5E2129126CA20070841D /* keyman-icon.png in Resources */, E213601E2142D7C000A043B7 /* keyman-88.png in Resources */, 293EA3F427181FDA00545EED /* Localizable.strings in Resources */, 98BF92471BF01D890002126A /* image.jpg in Resources */, 293EA3E627140D8100545EED /* KMAboutWindowController.xib in Resources */, 37A245C12565DFA6000BBF92 /* Assets.xcassets in Resources */, - 293EA3EB27140DEC00545EED /* preferences.xib in Resources */, 29DD5F442CFEF88000683388 /* SILAndikaV1RGB.png in Resources */, 9800EC5A1C02940300BF0FB5 /* keyman-for-mac-os-license.html in Resources */, 989C9C161A7876DE00A20425 /* MainMenu.xib in Resources */, @@ -1017,14 +905,12 @@ buildActionMask = 2147483647; files = ( 9836B3731AE5F11D00780482 /* zip.c in Sources */, - 98F3C8931A8D739500F675E3 /* KMConfigurationWindowController.m in Sources */, 9A3D6C5D221531B0008785A3 /* KMOSVersion.m in Sources */, 989C9C111A7876DE00A20425 /* main.m in Sources */, 290BC680274B9DB1005CD1C3 /* KMPackageInfo.m in Sources */, 29DBBF672D35053E00D8E33C /* KMSentryHelper.m in Sources */, 29B4A0D52BF7675A00682049 /* KMLogs.m in Sources */, 98BF924F1BF02DC20002126A /* KMBarView.m in Sources */, - E240F599202DED740000067D /* KMPackage.m in Sources */, 296105232C8E91C7007BF6B7 /* KMInputMethodLifecycle.m in Sources */, D861B03F2C5747F70003675E /* KMSettingsRepository.m in Sources */, 984B8F441AF1C3D900E096A8 /* OSKWindowController.m in Sources */, @@ -1032,21 +918,17 @@ 29015ABD2C58D86F00CCBB94 /* KMDataRepository.m in Sources */, 9836B3701AE5F11D00780482 /* ioapi.c in Sources */, E21799051FC5B7BC00F2D66A /* KMInputMethodEventHandler.m in Sources */, - 98E6729F1B532F5E00DBDE2F /* KMDownloadKBWindowController.m in Sources */, 98E672A41B532F9900DBDE2F /* KMKeyboardHelpWindowController.m in Sources */, 98A778C41A8C53BF00CF809D /* KMInputMethodAppDelegate.m in Sources */, 98D6DA7E1A799FF400B09822 /* KMInputController.m in Sources */, 98BF92441BF01CBE0002126A /* KMAboutWindowController.m in Sources */, 299ABD7129ECE75B00AA5948 /* KeySender.m in Sources */, - 98FE10631B4DEE5600525F54 /* KMInfoWindowController.m in Sources */, 298D09F72A1F4533006B9DFE /* TextApiCompliance.m in Sources */, 9836B3721AE5F11D00780482 /* unzip.c in Sources */, 296FE2FC275DD21600F46898 /* KMPackageReader.m in Sources */, - 9832472E1A9EABC70010B90C /* KMConfigColumn1CellView.m in Sources */, 297A501928DF4D360074EB1B /* PrivacyConsent.m in Sources */, 98FE105E1B4DE8F400525F54 /* NSWindow+SuppMethods.m in Sources */, 9836B3741AE5F11D00780482 /* ZipArchive.mm in Sources */, - 983247B21A9EB9690010B90C /* KMConfigColumn3CellView.m in Sources */, 290BC75E274F3FD7005CD1C3 /* KMKeyboardInfo.m in Sources */, 297A501728DF4D360074EB1B /* PrivacyWindowController.m in Sources */, 98BF92521BF03D4D0002126A /* KMAboutBGView.m in Sources */, @@ -1104,7 +986,7 @@ 29B70A23283CB6680086B580 /* es-419 */, 29B27FF229062FF50036917E /* nl */, 29B6BB5B292239B4008F04BD /* zh-Hans */, - 2901BA8A292332B3009903EC /* ff-NG */, + 2901BA8A292332B3009903EC /* ff-ng */, 298566BC29802962004ACA95 /* cs */, 29781101297FB262007C886D /* kn */, 298566C82980C9AD004ACA95 /* sv */, @@ -1117,35 +999,6 @@ name = KMAboutWindowController.xib; sourceTree = ""; }; - 293EA3ED27140DEC00545EED /* preferences.xib */ = { - isa = PBXVariantGroup; - children = ( - 293EA3EC27140DEC00545EED /* Base */, - 293EA3EF27140DFA00545EED /* en */, - 29B348AB2726F1C2009B1C82 /* km */, - 29DD8401276C49E20066A16E /* am */, - 29A4730627E9D6D500DE07C7 /* ha */, - 29C1E1752800227600759EDE /* pt-PT */, - 29B5BC0428169A6400B86A17 /* ff */, - 29BFA762282936D5009FCCC3 /* it */, - 29BFA76E282948BE009FCCC3 /* pl */, - 29B70A24283CB6690086B580 /* es-419 */, - 29B27FF329062FF50036917E /* nl */, - 29B6BB5C292239B4008F04BD /* zh-Hans */, - 2901BA8B292332B3009903EC /* ff-NG */, - 298566BD29802963004ACA95 /* cs */, - 29781102297FB262007C886D /* kn */, - 298566C92980C9AD004ACA95 /* sv */, - 298566D52980D47D004ACA95 /* uk */, - 298566E12980E579004ACA95 /* ru */, - CEFFECD92A417FEB00D58C36 /* es */, - 29D470952C648D5100224B4F /* el */, - D88EC4082D7878C20022CB80 /* vi */, - ); - name = preferences.xib; - path = Keyman4MacIM/KMConfiguration; - sourceTree = ""; - }; 293EA3F627181FDA00545EED /* Localizable.strings */ = { isa = PBXVariantGroup; children = ( @@ -1160,7 +1013,7 @@ 29B70A28283CB6900086B580 /* es-419 */, 29B27FF7290630170036917E /* nl */, 29B6BB60292239DC008F04BD /* zh-Hans */, - 2901BA8F292332CF009903EC /* ff-NG */, + 2901BA8F292332CF009903EC /* ff-ng */, 298566C129802970004ACA95 /* cs */, 29781106297FB27E007C886D /* kn */, 298566CD2980C9B9004ACA95 /* sv */, @@ -1173,35 +1026,6 @@ name = Localizable.strings; sourceTree = ""; }; - 29B42A592728339900EDD5D3 /* KMInfoWindowController.xib */ = { - isa = PBXVariantGroup; - children = ( - 29B42A582728339900EDD5D3 /* Base */, - 29B42A5B2728339E00EDD5D3 /* en */, - 29B42A5F272833A600EDD5D3 /* km */, - 29DD8402276C49E30066A16E /* am */, - 29A4730727E9D6D600DE07C7 /* ha */, - 29C1E1762800227700759EDE /* pt-PT */, - 29B5BC0528169A6400B86A17 /* ff */, - 29BFA763282936D5009FCCC3 /* it */, - 29BFA76F282948BE009FCCC3 /* pl */, - 29B70A25283CB66A0086B580 /* es-419 */, - 29B27FF429062FF50036917E /* nl */, - 29B6BB5D292239B4008F04BD /* zh-Hans */, - 2901BA8C292332B3009903EC /* ff-NG */, - 298566BE29802963004ACA95 /* cs */, - 29781103297FB263007C886D /* kn */, - 298566CA2980C9AD004ACA95 /* sv */, - 298566D62980D47D004ACA95 /* uk */, - 298566E22980E57A004ACA95 /* ru */, - CEFFECDA2A417FEB00D58C36 /* es */, - 29D470962C648D5200224B4F /* el */, - D88EC4092D7878C20022CB80 /* vi */, - ); - name = KMInfoWindowController.xib; - path = Keyman4MacIM/KMInfoWindow; - sourceTree = ""; - }; 29B42A622728343B00EDD5D3 /* KMKeyboardHelpWindowController.xib */ = { isa = PBXVariantGroup; children = ( @@ -1217,7 +1041,7 @@ 29B70A26283CB66B0086B580 /* es-419 */, 29B27FF529062FF60036917E /* nl */, 29B6BB5E292239B4008F04BD /* zh-Hans */, - 2901BA8D292332B3009903EC /* ff-NG */, + 2901BA8D292332B3009903EC /* ff-ng */, 298566BF29802963004ACA95 /* cs */, 29781104297FB263007C886D /* kn */, 298566CB2980C9AE004ACA95 /* sv */, @@ -1246,7 +1070,7 @@ 29B70A27283CB66C0086B580 /* es-419 */, 29B27FF629062FF60036917E /* nl */, 29B6BB5F292239B4008F04BD /* zh-Hans */, - 2901BA8E292332B4009903EC /* ff-NG */, + 2901BA8E292332B4009903EC /* ff-ng */, 298566C029802963004ACA95 /* cs */, 29781105297FB263007C886D /* kn */, 298566CC2980C9AE004ACA95 /* sv */, @@ -1312,6 +1136,7 @@ MACOSX_DEPLOYMENT_TARGET = 10.13; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; + REGISTER_APP_GROUPS = YES; SDKROOT = macosx; }; name = Debug; @@ -1359,6 +1184,7 @@ GCC_WARN_UNUSED_VARIABLE = YES; MACOSX_DEPLOYMENT_TARGET = 10.13; MTL_ENABLE_DEBUG_INFO = NO; + REGISTER_APP_GROUPS = YES; SDKROOT = macosx; }; name = Release; @@ -1370,14 +1196,14 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = Keyman.entitlements; CODE_SIGN_IDENTITY = "Developer ID Application"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Mac Developer"; CODE_SIGN_INJECT_BASE_ENTITLEMENTS = YES; COMBINE_HIDPI_IMAGES = YES; - COPYRIGHT_STRING = "Copyright © $(COPYRIGHT_YEARS) SIL International."; - COPYRIGHT_STRING_RIGHTS = "$(COPYRIGHT_STRING) All rights reserved."; - COPYRIGHT_YEARS = "2017-2019"; + COPYRIGHT_STRING = "Copyright © SIL Global."; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 3YE4W86L3G; + "DEVELOPMENT_TEAM[sdk=macosx*]" = 3YE4W86L3G; ENABLE_HARDENED_RUNTIME = YES; EXCLUDED_RECURSIVE_SEARCH_PATH_SUBDIRECTORIES = "*.nib *.lproj *.framework *.gch *.xcode* *.xcassets (*) .DS_Store CVS .svn .git .hg *.pbproj *.pbxproj"; FRAMEWORK_SEARCH_PATHS = ( @@ -1385,7 +1211,6 @@ "$(PROJECT_DIR)/Keyman4MacIM/Frameworks", "$(PROJECT_DIR)/../Carthage/Build/Mac/**", ); - GET_INFO_STRING = "Keyman $(PRODUCT_VERSION) for macOS, $(COPYRIGHT_STRING_RIGHTS)"; HEADER_SEARCH_PATHS = ( "$(inherited)", "{$PROJECT_DIR}/Keyman4MacIM/KMAboutWindow", @@ -1404,6 +1229,8 @@ PRODUCT_VERSION = 0.0.1; PROVISIONING_PROFILE = ""; PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "Keyman Mac IM Dev"; + REGISTER_APP_GROUPS = YES; STRIP_INSTALLED_PRODUCT = YES; }; name = Debug; @@ -1415,13 +1242,13 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = Keyman.entitlements; CODE_SIGN_IDENTITY = "Developer ID Application"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application"; CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO; COMBINE_HIDPI_IMAGES = YES; - COPYRIGHT_STRING = "Copyright © $(COPYRIGHT_YEARS) SIL International."; - COPYRIGHT_STRING_RIGHTS = "$(COPYRIGHT_STRING) All rights reserved."; - COPYRIGHT_YEARS = "2017-2019"; + COPYRIGHT_STRING = "Copyright © SIL Global."; COPY_PHASE_STRIP = NO; DEVELOPMENT_TEAM = 3YE4W86L3G; + "DEVELOPMENT_TEAM[sdk=macosx*]" = 3YE4W86L3G; ENABLE_HARDENED_RUNTIME = YES; EXCLUDED_RECURSIVE_SEARCH_PATH_SUBDIRECTORIES = "*.nib *.lproj *.framework *.gch *.xcode* *.xcassets (*) .DS_Store CVS .svn .git .hg *.pbproj *.pbxproj"; FRAMEWORK_SEARCH_PATHS = ( @@ -1429,7 +1256,6 @@ "$(PROJECT_DIR)/Keyman4MacIM/Frameworks", "$(PROJECT_DIR)/../Carthage/Build/Mac/**", ); - GET_INFO_STRING = "Keyman $(PRODUCT_VERSION) for macOS, $(COPYRIGHT_STRING_RIGHTS)"; HEADER_SEARCH_PATHS = ( "$(inherited)", "{$PROJECT_DIR}/Keyman4MacIM/KMAboutWindow", @@ -1447,6 +1273,8 @@ PRODUCT_VERSION = 0.0.1; PROVISIONING_PROFILE = ""; PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "Keyman Mac IM Dist"; + REGISTER_APP_GROUPS = YES; STRIP_INSTALLED_PRODUCT = YES; }; name = Release; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/Base.lproj/MainMenu.xib b/mac/Keyman4MacIM/Keyman4MacIM/Base.lproj/MainMenu.xib index 0696c81eba..657c560f02 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/Base.lproj/MainMenu.xib +++ b/mac/Keyman4MacIM/Keyman4MacIM/Base.lproj/MainMenu.xib @@ -1,8 +1,8 @@ - + - + @@ -19,8 +19,7 @@ - - + diff --git a/mac/Keyman4MacIM/Keyman4MacIM/Info.plist b/mac/Keyman4MacIM/Keyman4MacIM/Info.plist index 49f532055e..31641d7ff5 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/Info.plist +++ b/mac/Keyman4MacIM/Keyman4MacIM/Info.plist @@ -4,27 +4,8 @@ CFBundleDevelopmentRegion en - CFBundleDocumentTypes - - - CFBundleTypeExtensions - - kmp - - CFBundleTypeIconFile - keyman - CFBundleTypeName - Keyman Package - CFBundleTypeRole - Editor - NSDocumentClass - KMPackage - - CFBundleExecutable $(EXECUTABLE_NAME) - CFBundleGetInfoString - $(GET_INFO_STRING) CFBundleIconFile CFBundleIdentifier @@ -56,8 +37,6 @@ Keyman_Input_Connection InputMethodServerControllerClass KMInputController - InputMethodServerPreferencesWindowControllerClass - KMConfigurationWindowController Keyman SentryEnvironment diff --git a/mac/Keyman4MacIM/Keyman4MacIM/InfoPlist.strings b/mac/Keyman4MacIM/Keyman4MacIM/InfoPlist.strings index 14875de26c..6a3be00bd4 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/InfoPlist.strings +++ b/mac/Keyman4MacIM/Keyman4MacIM/InfoPlist.strings @@ -15,9 +15,8 @@ CFBundleDisplayName = "Keyman"; */ // NOTE: Do not put version-related strings in here. The following are now computed based -// on User-Defined Build Setting variables such as PRODUCT_VERSION and COPYRIGHT_STRING_RIGHTS +// on the User-Defined Build Setting variables PRODUCT_VERSION and COPYRIGHT_STRING // -//NSHumanReadableCopyright = "Copyright © 2018 SIL International. All rights reserved."; +//NSHumanReadableCopyright = "Copyright © SIL Global. All rights reserved."; //CFBundleShortVersionString = "10.0"; //CFBundleVersion = "10.0"; -//CFBundleGetInfoString = "Keyman 10.0 for macOS, Copyright 2018, SIL International"; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMAboutWindow/Base.lproj/KMAboutWindowController.xib b/mac/Keyman4MacIM/Keyman4MacIM/KMAboutWindow/Base.lproj/KMAboutWindowController.xib index fa15b43024..cc63416206 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMAboutWindow/Base.lproj/KMAboutWindowController.xib +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMAboutWindow/Base.lproj/KMAboutWindowController.xib @@ -1,8 +1,8 @@ - + - + @@ -20,7 +20,7 @@ - + @@ -28,39 +28,33 @@ - - + - - - - - - - + + - + - + - + - + @@ -81,7 +75,7 @@ - + @@ -102,49 +96,33 @@ - + - - + - - + + + @@ -156,20 +134,22 @@ - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMAboutWindow/KMAboutWindowController.m b/mac/Keyman4MacIM/Keyman4MacIM/KMAboutWindow/KMAboutWindowController.m index 08b4d1b1e6..8333b29931 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMAboutWindow/KMAboutWindowController.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMAboutWindow/KMAboutWindowController.m @@ -17,7 +17,6 @@ @property (nonatomic, weak) IBOutlet NSButton *licenseButton; // not needed at the moment but might be: @property (nonatomic, weak) IBOutlet NSButton *closeButton; -@property (nonatomic, weak) IBOutlet NSButton *configureButton; @end @implementation KMAboutWindowController @@ -47,24 +46,6 @@ [self setLicenseButtonTitle:self.licenseButton.title underlined:NO]; } -- (IBAction)configAction:(id)sender { - // Using `showConfigurationWindow` instead of `showPreferences:` because `showPreferences:` is missing in - // High Sierra (10.13.1 - 10.13.3). See: https://bugreport.apple.com/web/?problemID=35422518 - // rrb: where Apple's API is broken (10.13.1-10.13.3) call our workaround, otherwise, call showPreferences - u_int16_t systemVersion = [KMOSVersion SystemVersion]; - if ([KMOSVersion Version_10_13_1] <= systemVersion && systemVersion <= [KMOSVersion Version_10_13_3]) // between 10.13.1 and 10.13.3 inclusive - { - os_log([KMLogs uiLog], "About Box: calling workaround instead of showPreferences (sys ver %x)", systemVersion); - [self.AppDelegate showConfigurationWindow]; // call our workaround - } - else - { - os_log([KMLogs uiLog], "About Box: calling Apple's showPreferences (sys ver %x)", systemVersion); - [self.AppDelegate.inputController showPreferences:sender]; // call Apple API - } - [self close]; -} - - (IBAction)closeAction:(id)sender { [self close]; } diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/Base.lproj/preferences.xib b/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/Base.lproj/preferences.xib deleted file mode 100644 index a257c34419..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/Base.lproj/preferences.xib +++ /dev/null @@ -1,321 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8QD05T -S2V5ZWRBcmNoaXZlctEICVRyb290gAGuCwwZGh8UJCkqMTQ3PUBVJG51bGzWDQ4PEBESExQVFhcYVk5T -U2l6ZV5OU1Jlc2l6aW5nTW9kZVYkY2xhc3NcTlNJbWFnZUZsYWdzVk5TUmVwc1dOU0NvbG9ygAIQAIAN -EiDDAACAA4ALVnsxLCAxfdIbDxweWk5TLm9iamVjdHOhHYAEgArSGw8gI6IhIoAFgAaACdMPJSYnKBRf -EBROU1RJRkZSZXByZXNlbnRhdGlvbl8QGU5TSW50ZXJuYWxMYXlvdXREaXJlY3Rpb26ACIAHTxEIjE1N -ACoAAAAKAAAAEAEAAAMAAAABAAEAAAEBAAMAAAABAAEAAAECAAMAAAACAAgACAEDAAMAAAABAAEAAAEG -AAMAAAABAAEAAAEKAAMAAAABAAEAAAERAAQAAAABAAAACAESAAMAAAABAAEAAAEVAAMAAAABAAIAAAEW -AAMAAAABAAEAAAEXAAQAAAABAAAAAgEcAAMAAAABAAEAAAEoAAMAAAABAAIAAAFSAAMAAAABAAEAAAFT -AAMAAAACAAEAAYdzAAcAAAe8AAAA0AAAAAAAAAe8YXBwbAIgAABtbnRyR1JBWVhZWiAH0AACAA4ADAAA -AABhY3NwQVBQTAAAAABub25lAAAAAAAAAAAAAAAAAAAAAAAA9tYAAQAAAADTLWFwcGwAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVkZXNjAAAAwAAAAG9kc2NtAAABMAAA -Bi5jcHJ0AAAHYAAAADh3dHB0AAAHmAAAABRrVFJDAAAHrAAAAA5kZXNjAAAAAAAAABVHZW5lcmljIEdy -YXkgUHJvZmlsZQAAAAAAAAAAAAAAFUdlbmVyaWMgR3JheSBQcm9maWxlAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAbWx1YwAAAAAAAAAeAAAADHNrU0sAAAAqAAABeGRh -REsAAAA0AAABomNhRVMAAAAsAAAB1nB0QlIAAAAqAAACAnVrVUEAAAAsAAACLGZyRlUAAAAqAAACWGh1 -SFUAAAAuAAACgnpoVFcAAAAQAAACsG5iTk8AAAAsAAACwGNzQ1oAAAAkAAAC7GhlSUwAAAAgAAADEGl0 -SVQAAAAuAAADMHJvUk8AAAAkAAADXmRlREUAAAA6AAADgmtvS1IAAAAYAAADvHN2U0UAAAAuAAAD1Hpo -Q04AAAAQAAAEAmphSlAAAAAWAAAEEmVsR1IAAAAkAAAEKHB0UE8AAAA4AAAETG5sTkwAAAAqAAAEhGVz -RVMAAAAoAAAErnRoVEgAAAAkAAAE1nRyVFIAAAAiAAAE+mZpRkkAAAAsAAAFHGhySFIAAAA6AAAFSHBs -UEwAAAA2AAAFgnJ1UlUAAAAmAAAFuGFyRUcAAAAoAAAF3mVuVVMAAAAoAAAGBgBWAWEAZQBvAGIAZQBj -AG4A/QAgAHMAaQB2AP0AIABwAHIAbwBmAGkAbABHAGUAbgBlAHIAZQBsACAAZwByAOUAdABvAG4AZQBi -AGUAcwBrAHIAaQB2AGUAbABzAGUAUABlAHIAZgBpAGwAIABkAGUAIABnAHIAaQBzACAAZwBlAG4A6ABy -AGkAYwBQAGUAcgBmAGkAbAAgAEMAaQBuAHoAYQAgAEcAZQBuAOkAcgBpAGMAbwQXBDAEMwQwBDsETAQ9 -BDgEOQAgBD8EQAQ+BEQEMAQ5BDsAIABHAHIAYQB5AFAAcgBvAGYAaQBsACAAZwDpAG4A6QByAGkAcQB1 -AGUAIABnAHIAaQBzAMEAbAB0AGEAbADhAG4AbwBzACAAcwB6APwAcgBrAGUAIABwAHIAbwBmAGkAbJAa -dShwcJaOgnJfaWPPj/AARwBlAG4AZQByAGkAcwBrACAAZwByAOUAdABvAG4AZQBwAHIAbwBmAGkAbABP -AGIAZQBjAG4A/QAgAWEAZQBkAP0AIABwAHIAbwBmAGkAbAXkBegF1QXkBdkF3AAgAEcAcgBhAHkAIAXb -BdwF3AXZAFAAcgBvAGYAaQBsAG8AIABnAHIAaQBnAGkAbwAgAGcAZQBuAGUAcgBpAGMAbwBQAHIAbwBm -AGkAbAAgAGcAcgBpACAAZwBlAG4AZQByAGkAYwBBAGwAbABnAGUAbQBlAGkAbgBlAHMAIABHAHIAYQB1 -AHMAdAB1AGYAZQBuAC0AUAByAG8AZgBpAGzHfLwYACAARwByAGEAeQAg1QS4XNMMx3wARwBlAG4AZQBy -AGkAcwBrACAAZwByAOUAcwBrAGEAbABlAHAAcgBvAGYAaQBsZm6QGnBwXqZjz4/wZYdO9k4AgiwwsDDs -MKQw1zDtMNUwoTCkMOsDkwO1A70DuQO6A8wAIAPAA8EDvwPGA68DuwAgA7MDugPBA7kAUABlAHIAZgBp -AGwAIABnAGUAbgDpAHIAaQBjAG8AIABkAGUAIABjAGkAbgB6AGUAbgB0AG8AcwBBAGwAZwBlAG0AZQBl -AG4AIABnAHIAaQBqAHMAcAByAG8AZgBpAGUAbABQAGUAcgBmAGkAbAAgAGcAcgBpAHMAIABnAGUAbgDp -AHIAaQBjAG8OQg4bDiMORA4fDiUOTA4qDjUOQA4XDjIOFw4xDkgOJw5EDhsARwBlAG4AZQBsACAARwBy -AGkAIABQAHIAbwBmAGkAbABpAFkAbABlAGkAbgBlAG4AIABoAGEAcgBtAGEAYQBwAHIAbwBmAGkAaQBs -AGkARwBlAG4AZQByAGkBDQBrAGkAIABwAHIAbwBmAGkAbAAgAHMAaQB2AGkAaAAgAHQAbwBuAG8AdgBh -AFUAbgBpAHcAZQByAHMAYQBsAG4AeQAgAHAAcgBvAGYAaQBsACAAcwB6AGEAcgBvAVsAYwBpBB4EMQRJ -BDgEOQAgBEEENQRABEsEOQAgBD8EQAQ+BEQEOAQ7BEwGRQZEBkEAIAYqBjkGMQZKBkEAIABHAHIAYQB5 -ACAGJwZEBjkGJwZFAEcAZQBuAGUAcgBpAGMAIABHAHIAYQB5ACAAUAByAG8AZgBpAGwAZQAAdGV4dAAA -AABDb3B5cmlnaHQgMjAwNyBBcHBsZSBJbmMuLCBhbGwgcmlnaHRzIHJlc2VydmVkLgBYWVogAAAAAAAA -81EAAQAAAAEWzGN1cnYAAAAAAAAAAQHNAADSKywtLlokY2xhc3NuYW1lWCRjbGFzc2VzXxAQTlNCaXRt -YXBJbWFnZVJlcKMtLzBaTlNJbWFnZVJlcFhOU09iamVjdNIrLDIzV05TQXJyYXmiMjDSKyw1Nl5OU011 -dGFibGVBcnJheaM1MjDTODkPOjs8V05TV2hpdGVcTlNDb2xvclNwYWNlRDAgMAAQA4AM0issPj9XTlND -b2xvcqI+MNIrLEFCV05TSW1hZ2WiQTAACAARABoAJAApADIANwBJAEwAUQBTAGIAaAB1AHwAiwCSAJ8A -pgCuALAAsgC0ALkAuwC9AMQAyQDUANYA2ADaAN8A4gDkAOYA6ADvAQYBIgEkASYJtgm7CcYJzwniCeYJ -8Qn6Cf8KBwoKCg8KHgoiCikKMQo+CkMKRQpHCkwKVApXClwKZAAAAAAAAAIBAAAAAAAAAEMAAAAAAAAA -AAAAAAAAAApnA - - - - diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/KMConfigColumn1CellView.h b/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/KMConfigColumn1CellView.h deleted file mode 100644 index 146fd3245e..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/KMConfigColumn1CellView.h +++ /dev/null @@ -1,13 +0,0 @@ -// -// KMConfigColumn1CellView.h -// Keyman4MacIM -// -// Created by Serkan Kurt on 26/02/2015. -// Copyright (c) 2017 SIL International. All rights reserved. -// - -#import - -@interface KMConfigColumn1CellView : NSTableCellView -@property (nonatomic, weak) IBOutlet NSButton *checkBox; -@end diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/KMConfigColumn1CellView.m b/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/KMConfigColumn1CellView.m deleted file mode 100644 index c76fa0ef2c..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/KMConfigColumn1CellView.m +++ /dev/null @@ -1,14 +0,0 @@ -// -// KMConfigColumn1CellView.m -// Keyman4MacIM -// -// Created by Serkan Kurt on 26/02/2015. -// Copyright (c) 2017 SIL International. All rights reserved. -// - -#import "KMConfigColumn1CellView.h" - -@implementation KMConfigColumn1CellView -@synthesize checkBox; - -@end diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/KMConfigColumn3CellView.h b/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/KMConfigColumn3CellView.h deleted file mode 100644 index 01c6886f43..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/KMConfigColumn3CellView.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// KMConfigColumn3CellView.h -// Keyman4MacIM -// -// Created by Serkan Kurt on 26/02/2015. -// Copyright (c) 2017 SIL International. All rights reserved. -// - -#import - -@interface KMConfigColumn3CellView : NSTableCellView -@property (nonatomic, weak) IBOutlet NSButton *infoButton; -@property (nonatomic, weak) IBOutlet NSButton *helpButton; -@property (nonatomic, weak) IBOutlet NSButton *removeButton; -@end diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/KMConfigColumn3CellView.m b/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/KMConfigColumn3CellView.m deleted file mode 100644 index a8c597aa4f..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/KMConfigColumn3CellView.m +++ /dev/null @@ -1,13 +0,0 @@ -// -// KMConfigColumn3CellView.m -// Keyman4MacIM -// -// Created by Serkan Kurt on 26/02/2015. -// Copyright (c) 2017 SIL International. All rights reserved. -// - -#import "KMConfigColumn3CellView.h" - -@implementation KMConfigColumn3CellView -@synthesize infoButton, helpButton, removeButton; -@end diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/KMConfigurationWindowController.h b/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/KMConfigurationWindowController.h deleted file mode 100644 index adc7343bd6..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/KMConfigurationWindowController.h +++ /dev/null @@ -1,19 +0,0 @@ -// -// KMConfigurationWindowController.h -// Keyman4MacIM -// -// Created by Serkan Kurt on 24/02/2015. -// Copyright (c) 2017 SIL International. All rights reserved. -// - -#import -#import "KMInputMethodAppDelegate.h" -#import "KMConfigColumn1CellView.h" -#import "KMConfigColumn3CellView.h" -#import "KMPackage.h" - -@interface KMConfigurationWindowController : NSWindowController - -- (void)handleRequestToInstallPackage:(KMPackage *) package; - -@end diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/KMConfigurationWindowController.m b/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/KMConfigurationWindowController.m deleted file mode 100644 index 54b27f6873..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/KMConfigurationWindowController.m +++ /dev/null @@ -1,593 +0,0 @@ -// -// KMConfigurationWindowController.m -// Keyman4MacIM -// -// Created by Serkan Kurt on 24/02/2015. -// Copyright (c) 2017 SIL International. All rights reserved. -// - -#import "KMConfigurationWindowController.h" -#import "KMDownloadKBWindowController.h" -#import "KMDataRepository.h" -#import "KMLogs.h" -#import "KMSentryHelper.h" - -@interface KMConfigurationWindowController () -@property (nonatomic, weak) IBOutlet NSTableView *tableView; -@property (nonatomic, weak) IBOutlet WebView *webView; -@property (nonatomic, weak) IBOutlet NSButton *supportBack; -@property (nonatomic, weak) IBOutlet NSButton *supportForward; -@property (nonatomic, weak) IBOutlet NSButton *supportHome; -@property (nonatomic, strong) NSMutableArray *tableContents; -@property (nonatomic, strong) NSTimer *reloadTimer; -@property (nonatomic, strong) NSDate *lastReloadDate; -@property (nonatomic, strong) NSAlert *deleteAlertView; -@property (nonatomic, strong) NSAlert *confirmKmpInstallAlertView; -@end - -@implementation KMConfigurationWindowController - -- (KMInputMethodAppDelegate *)AppDelegate { - return (KMInputMethodAppDelegate *)[NSApp delegate]; -} - -- (id)initWithWindowNibName:(NSString *)windowNibName { - self = [super initWithWindowNibName:windowNibName]; - if (self) { - if (self.AppDelegate.infoWindow_.window != nil) { - [self.window addChildWindow:self.AppDelegate.infoWindow_.window ordered:NSWindowAbove]; - [self.AppDelegate.infoWindow_.window centerInParent]; - } - else if (self.AppDelegate.downloadKBWindow_.window != nil) { - [self.window addChildWindow:self.AppDelegate.downloadKBWindow_.window ordered:NSWindowAbove]; - [self.AppDelegate.downloadKBWindow_.window centerInParent]; - } - - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(timerAction:) name:kKeymanKeyboardDownloadCompletedNotification object:nil]; - } - - return self; -} - -- (void)dealloc { - [[NSNotificationCenter defaultCenter] removeObserver:self]; - [self stopTimer]; -} - -- (void)windowDidLoad { - [self.AppDelegate registerConfigurationWindow:self]; - [super windowDidLoad]; - [self.window center]; - [_tableView registerForDraggedTypes:@[NSFilenamesPboardType]]; - _lastReloadDate = [NSDate date]; - [self startTimer]; - - [self.webView setFrameLoadDelegate:(id)self]; - [self.webView setPolicyDelegate:(id)self]; - - NSURL *homeUrl = [[NSBundle mainBundle] URLForResource:@"index" withExtension:@"html" subdirectory:@"Help"]; - [self.webView.mainFrame loadRequest:[NSURLRequest requestWithURL:homeUrl]]; -} - -- (void)webView:(WebView *)sender decidePolicyForNewWindowAction:(NSDictionary *)actionInformation request:(NSURLRequest *)request newFrameName:(NSString *)frameName decisionListener:(id)listener { - [[NSWorkspace sharedWorkspace] openURL:[actionInformation objectForKey:WebActionOriginalURLKey]]; - [listener ignore]; -} - -- (void)webView:(WebView *)webView decidePolicyForNavigationAction:(NSDictionary *)actionInformation request:(NSURLRequest *)request frame:(WebFrame *)frame decisionListener:(id)listener { - NSString* url = [[request URL] absoluteString]; - os_log_debug([KMLogs uiLog], "decidePolicyForNavigationAction, navigating to %{public}@", url); - - if([url hasPrefix: @"file:"]) { - [listener use]; - } else { - [listener ignore]; - [[NSWorkspace sharedWorkspace] openURL: [request URL]]; - } -} - -- (void)webView:(WebView *)sender didFinishLoadForFrame:(WebFrame *)frame; { - self.supportBack.enabled = [self.webView canGoBack]; - self.supportForward.enabled = [self.webView canGoForward]; -} - -- (IBAction)supportBackAction:(id)sender { - [self.webView goBack]; -} - -- (IBAction)supportForwardAction:(id)sender { - [self.webView goForward]; -} - -- (IBAction)supportHomeAction:(id)sender { - NSURL *homeUrl = [[NSBundle mainBundle] URLForResource:@"index" withExtension:@"html" subdirectory:@"Help"]; - [self.webView.mainFrame loadRequest:[NSURLRequest requestWithURL:homeUrl]]; - -} - -- (void)setTableView:(NSTableView *)tableView { - _tableView = tableView; - NSMenu *menu = [[NSMenu alloc] init]; - NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:@"Refresh" action:@selector(refreshAction:) keyEquivalent:@""]; - [menu addItem:item]; - [_tableView setMenu:menu]; -} - -- (void)refreshAction:(id)sender { - [self.AppDelegate setKmxFileList:nil]; - [self setTableContents:nil]; - [self saveActiveKeyboards]; - [self.tableView reloadData]; - _lastReloadDate = [NSDate date]; -} - -- (NSArray *)tableContents { - if (_tableContents == nil) { - _tableContents = [[NSMutableArray alloc] initWithCapacity:0]; - for (int i = 0; i < self.kmxFileList.count; i++) { - id obj = [self.kmxFileList objectAtIndex:i]; - if ([obj isKindOfClass:[NSArray class]]) { - NSArray *pArray = (NSArray *)obj; - NSString *packageFolder = [self packageFolderFromPath:[pArray objectAtIndex:0]]; - NSString *packageName = [self.AppDelegate packageNameFromPackageInfo:packageFolder]; - - if (packageName) { - os_log_debug([KMLogs testLog], "tableContents, packageFolder: %{public}@, packageName: %{public}@", packageFolder, packageName); - [_tableContents addObject:[NSDictionary dictionaryWithObjectsAndKeys:packageName, @"HeaderTitle", nil]]; - } else { - os_log_error([KMLogs testLog], "tableContents, no packageName for packageFolder: %{public}@", packageFolder); - NSString *noPackageName = NSLocalizedString(@"unknown-package-name", nil); - [_tableContents addObject:[NSDictionary dictionaryWithObjectsAndKeys:noPackageName, @"HeaderTitle", nil]]; - } - for (NSString *path in pArray) { - os_log_debug([KMLogs uiLog], "tableContents, path = '%{public}@'", path); - NSDictionary *info = [KMXFile keyboardInfoFromKmxFile:path]; - if (!info) { - info = [[NSDictionary alloc] initWithObjectsAndKeys: - NSLocalizedString(@"message-error-loading-keyboard", nil), kKMKeyboardNameKey, - NSLocalizedString(@"message-error-unknown-metadata", nil), kKMKeyboardVersionKey, - NSLocalizedString(@"message-error-unknown-metadata", nil), kKMKeyboardCopyrightKey, - NSLocalizedString(@"message-error-unknown-metadata", nil), kKMVisualKeyboardKey, - nil]; - } - [_tableContents addObject:info]; - } - } - else { - NSString *path = (NSString *)obj; - os_log_debug([KMLogs uiLog], "tableContents, path = '%{public}@'", path); - NSDictionary *info = [KMXFile keyboardInfoFromKmxFile:path]; - if (!info) { - info = [[NSDictionary alloc] initWithObjectsAndKeys: - NSLocalizedString(@"message-error-loading-keyboard", nil), kKMKeyboardNameKey, - NSLocalizedString(@"message-error-unknown-metadata", nil), kKMKeyboardVersionKey, - NSLocalizedString(@"message-error-unknown-metadata", nil), kKMKeyboardCopyrightKey, - NSLocalizedString(@"message-error-unknown-metadata", nil), kKMVisualKeyboardKey, - nil]; - } - [_tableContents addObject:info]; - } - } - } - - return _tableContents; -} - -- (NSString *)kmxFilePathAtIndex:(NSUInteger)index { - return [self.AppDelegate kmxFilePathAtIndex:index]; -} - -- (NSString *)packagePathAtIndex:(NSUInteger)index { - return [self.AppDelegate packagePathAtIndex:index]; -} - -- (NSInteger)indexForPackageFolder:(NSString *)packageFolder { - return [self.AppDelegate indexForPackageFolder:packageFolder]; -} - -- (NSString *)packageFolderFromPath:(NSString *)path { - return [self.AppDelegate packageFolderFromPath:path]; -} - -- (NSString *)keyboardsPath { - return self.AppDelegate.keyboardsPath; -} - -- (NSArray *)kmxFileList { - return self.AppDelegate.kmxFileList; -} - -- (NSMutableArray *)activeKeyboards { - return self.AppDelegate.activeKeyboards; -} - -- (void)saveActiveKeyboards { - [self.AppDelegate saveActiveKeyboards]; -} - -- (NSInteger)numberOfRowsInTableView:(NSTableView *)tableView { - return self.tableContents.count; -} - -- (NSIndexSet *)tableView:(NSTableView *)tableView selectionIndexesForProposedSelection:(NSIndexSet *)proposedSelectionIndexes { - return 0; -} - -- (NSTableRowView *)tableView:(NSTableView *)tableView rowViewForRow:(NSInteger)row { - NSDictionary *info = [self.tableContents objectAtIndex:row]; - BOOL isHeader = ([info objectForKey:@"HeaderTitle"] != nil); - if (isHeader) { - NSTableRowView *rowView = [[NSTableRowView alloc] init]; - NSTextField *textField = [[NSTextField alloc] initWithFrame:NSMakeRect(4, 4, tableView.frame.size.width-86, tableView.rowHeight-8)]; - [textField setEditable:NO]; - [textField setBordered:NO]; - [textField setBackgroundColor:[NSColor clearColor]]; - [textField setAlignment:NSTextAlignmentLeft]; - [textField setFont:[NSFont systemFontOfSize:tableView.rowHeight*0.5]]; - [textField setTextColor:[NSColor colorWithSRGBRed:0.0 green:0.0 blue:0.1 alpha:1.0]]; - [textField setStringValue:[info objectForKey:@"HeaderTitle"]]; - [rowView addSubview:textField]; - return rowView; - } - - return nil; -} - -- (void)tableView:(NSTableView *)tableView didAddRowView:(NSTableRowView *)rowView forRow:(NSInteger)row { - NSDictionary *info = [self.tableContents objectAtIndex:row]; - BOOL isHeader = ([info objectForKey:@"HeaderTitle"] != nil); - if (isHeader) - [rowView setBackgroundColor:[NSColor colorWithSRGBRed:186.0/255.0 green:211.0/255.0 blue:1.0 alpha:1.0]]; -} - -- (NSView *)tableView:(NSTableView *)tableView viewForTableColumn:(NSTableColumn *)tableColumn row:(NSInteger)row { - NSDictionary *info = [self.tableContents objectAtIndex:row]; - NSString *identifier = [tableColumn identifier]; - NSString *headerTitle = [info objectForKey:@"HeaderTitle"]; - BOOL isHeader = (headerTitle != nil); - BOOL isOthers = NO; - NSString *kmxFilePath = [self kmxFilePathAtIndex:row]; - if (kmxFilePath != nil) { - isOthers = [[self packageFolderFromPath:kmxFilePath] isEqualToString:@"Others"]; - } - else if (isHeader && [headerTitle isEqualToString:@"Others"]) { - isOthers = YES; - } - if ([identifier isEqualToString:@"Column1"]) { - KMConfigColumn1CellView *cellView = [tableView makeViewWithIdentifier:identifier owner:self]; - - if (isHeader) { - [cellView setHidden:YES]; - } - else { - [cellView setHidden:NO]; - cellView.imageView.objectValue = [info objectForKey:kKMKeyboardIconKey]; - [cellView.checkBox setTag:row]; - [cellView.checkBox setAction:@selector(checkBoxAction:)]; - NSString *kmxFilePath = [self kmxFilePathAtIndex:row]; - NSString *partialPath = [KMDataRepository.shared trimToPartialPath:kmxFilePath]; - os_log_debug([KMLogs uiLog], "tableView:viewForTableColumn, kmxFilePath = %{public}@ for row %li, partialPath = %{public}@", kmxFilePath, (long)row, partialPath); - [cellView.checkBox setState:([self.activeKeyboards containsObject:partialPath])?NSOnState:NSOffState]; - } - - return cellView; - } - else if ([identifier isEqualToString:@"Column2"]) { - NSTableCellView *cellView = [tableView makeViewWithIdentifier:identifier owner:self]; - if (!isHeader) { - [cellView setHidden:NO]; - //cellView.textField.stringValue = [NSString stringWithFormat:@"%@ (%@)", [info objectForKey:kKMKeyboardNameKey], [info objectForKey:kKMKeyboardVersionKey]]; - cellView.textField.stringValue = [info objectForKey:kKMKeyboardNameKey]; - } - else { - cellView.textField.stringValue = @""; - [cellView setHidden:YES]; - } - - return cellView; - } - else if ([identifier isEqualToString:@"Column3"]) { - KMConfigColumn3CellView *cellView = [tableView makeViewWithIdentifier:identifier owner:self]; - - if ((!isHeader && !isOthers) || (isHeader && isOthers)) { - [cellView.removeButton setHidden:YES]; - [cellView.infoButton setHidden:YES]; - [cellView.helpButton setHidden:YES]; - } - else if (isOthers) { - [cellView.removeButton setHidden:NO]; - [cellView.infoButton setHidden:YES]; - [cellView.helpButton setHidden:YES]; - } - else { - [cellView.removeButton setHidden:NO]; - - [cellView.infoButton setHidden:NO]; - [cellView.infoButton setTag:row]; - [cellView.infoButton setAction:@selector(infoAction:)]; - [cellView.infoButton setBordered:NO]; - [cellView.infoButton setWantsLayer:YES]; - CALayer *btnLayer = [cellView.infoButton layer]; - btnLayer.backgroundColor = [NSColor clearColor].CGColor; - btnLayer.cornerRadius = 9.5; - - [cellView.helpButton setHidden:NO]; - [cellView.helpButton setTag:row]; - [cellView.helpButton setAction:@selector(helpAction:)]; - [cellView.helpButton setEnabled:[self hasHelpDocumentation:row]]; - } - - if (![cellView.removeButton isHidden]) { - [cellView.removeButton setTag:row]; - [cellView.removeButton setAction:@selector(removeAction:)]; - [cellView.removeButton setBordered:NO]; - [cellView.removeButton setWantsLayer:YES]; - CALayer *btnLayer = [cellView.removeButton layer]; - btnLayer.backgroundColor = [NSColor whiteColor].CGColor; - btnLayer.cornerRadius = 9.5; - btnLayer.borderWidth = 1.0; - btnLayer.borderColor = [NSColor grayColor].CGColor; - } - - return cellView; - } - - return nil; -} - -- (NSDragOperation)tableView:(NSTableView*)tv validateDrop:(id )info proposedRow:(NSInteger)row proposedDropOperation:(NSTableViewDropOperation)op { - return NSDragOperationCopy; -} - -- (BOOL)tableView:(NSTableView *)tableView acceptDrop:(id)info row:(NSInteger)row dropOperation:(NSTableViewDropOperation)dropOperation { - NSPasteboard *pboard = [info draggingPasteboard]; - NSArray *filenames = [pboard propertyListForType:NSFilenamesPboardType]; - NSMutableArray *kmpFiles = nil; - for (NSString *filename in filenames) { - if ([filename hasSuffix:@".kmp"]) { - if (kmpFiles == nil) - kmpFiles = [NSMutableArray arrayWithObject:filename]; - else - [kmpFiles addObject:filename]; - } - } - - for (NSString *kmpFile in kmpFiles) { - if ([self.AppDelegate unzipFile:kmpFile]) - [self refreshAction:nil]; - else - [kmpFiles removeObject:kmpFile]; - } - - if (kmpFiles.count < 1) - kmpFiles = nil; - - return (kmpFiles==nil?NO:YES); -} - -- (void)checkBoxAction:(id)sender { - NSButton *checkBox = (NSButton *)sender; - NSString *kmxFilePath = [self kmxFilePathAtIndex:checkBox.tag]; - NSString * kmxFileName = [kmxFilePath lastPathComponent]; - NSString *partialPath = [KMDataRepository.shared trimToPartialPath:kmxFilePath]; - os_log_debug([KMLogs uiLog], "checkBoxAction, kmxFilePath = %{public}@ for checkBox.tag %li, partialPath = %{public}@", kmxFilePath, checkBox.tag, partialPath); - if (checkBox.state == NSOnState) { - os_log_debug([KMLogs uiLog], "enabling active keyboard: %{public}@", kmxFileName); - NSString *message = [NSString stringWithFormat:@"enabling active keyboard: %@", kmxFileName]; - [KMSentryHelper addUserBreadCrumb:@"config" message:message]; - [self.activeKeyboards addObject:partialPath]; - [self saveActiveKeyboards]; - } - else if (checkBox.state == NSOffState) { - os_log_debug([KMLogs uiLog], "disabling active keyboard: %{public}@", kmxFileName); - NSString *message = [NSString stringWithFormat:@"disabling active keyboard: %@", kmxFileName]; - [KMSentryHelper addUserBreadCrumb:@"config" message:message]; - [self.activeKeyboards removeObject:partialPath]; - [self saveActiveKeyboards]; - } -} - -- (void)infoAction:(id)sender { - [KMSentryHelper addUserBreadCrumb:@"user" message:@"getting package information"]; - NSButton *infoButton = (NSButton *)sender; - NSString *packagePath = [self packagePathAtIndex:infoButton.tag]; - if (packagePath != nil) { - if (self.AppDelegate.infoWindow_.window != nil) - [self.AppDelegate.infoWindow_ close]; - - [self.window addChildWindow:self.AppDelegate.infoWindow.window ordered:NSWindowAbove]; - [self.AppDelegate.infoWindow.window centerInParent]; - [self.AppDelegate.infoWindow.window makeKeyAndOrderFront:nil]; - [self.AppDelegate.infoWindow setPackagePath:packagePath]; - } -} - -- (void)helpAction:(id)sender { - [KMSentryHelper addUserBreadCrumb:@"user" message:@"displaying help"]; - NSButton *helpButton = (NSButton *)sender; - NSString *packagePath = [self packagePathAtIndex:helpButton.tag]; - if (packagePath != nil) { - if (self.AppDelegate.kbHelpWindow_.window != nil) - [self.AppDelegate.kbHelpWindow_ close]; - - [self.window addChildWindow:self.AppDelegate.kbHelpWindow.window ordered:NSWindowAbove]; - [self.AppDelegate.kbHelpWindow.window centerInParent]; - [self.AppDelegate.kbHelpWindow.window makeKeyAndOrderFront:nil]; - [self.AppDelegate.kbHelpWindow setPackagePath:packagePath]; - } -} - -- (void)removeAction:(id)sender { - NSButton *deleteButton = (NSButton *)sender; - NSDictionary *info = [self.tableContents objectAtIndex:deleteButton.tag]; - NSString *deleteKeyboardMessage = NSLocalizedString(@"message-confirm-delete-keyboard", nil); - NSString *keyboardName = nil; - - if ([info objectForKey:@"HeaderTitle"] != nil) { - keyboardName = [info objectForKey:@"HeaderTitle"]; - } else { - keyboardName = [info objectForKey:kKMKeyboardNameKey]; - } - [self.deleteAlertView setMessageText:[NSString localizedStringWithFormat:deleteKeyboardMessage, keyboardName]]; - os_log_debug([KMLogs configLog], "entered removeAction for keyboardName: %{public}@", keyboardName); - - [self.deleteAlertView beginSheetModalForWindow:self.window completionHandler:^(NSModalResponse returnCode) { - if (returnCode == NSAlertFirstButtonReturn) { - os_log_debug([KMLogs uiLog], "confirm delete keyboard alert dismissed"); - [self deleteFileAtIndex:[NSNumber numberWithInteger:deleteButton.tag]]; - - os_log_debug([KMLogs configLog], "removeAction for keyboardName: %{public}@", keyboardName); - [KMSentryHelper addUserBreadCrumb:@"configure" message:[NSString stringWithFormat:@"remove keyboard: %@", keyboardName]]; - } - self.deleteAlertView = nil; - }]; -} - -- (IBAction)downloadAction:(id)sender { - [KMSentryHelper addUserBreadCrumb:@"user" message:@"download keyboard"]; - - if (self.AppDelegate.infoWindow_.window != nil) - [self.AppDelegate.infoWindow_ close]; - - if (self.AppDelegate.kbHelpWindow_.window != nil) - [self.AppDelegate.kbHelpWindow_ close]; - - [self.window addChildWindow:self.AppDelegate.downloadKBWindow.window ordered:NSWindowAbove]; - [self.AppDelegate.downloadKBWindow.window centerInParent]; - [self.AppDelegate.downloadKBWindow.window makeKeyAndOrderFront:nil]; -} - -- (void)handleRequestToInstallPackage:(KMPackage *) package { - os_log_debug([KMLogs dataLog], "handleRequestToInstallPackage"); - NSString *keyboardInfoString = NSLocalizedString(@"info-install-keyboard-filename", nil); - [self.confirmKmpInstallAlertView setInformativeText:[NSString localizedStringWithFormat:keyboardInfoString, package.getOrigKmpFilename]]; - - os_log_debug([KMLogs uiLog], "Asking user to confirm installation of %{public}@, KMP - temp file name: %{public}@", package.getOrigKmpFilename, package.getTempKmpFilename); - - [self.confirmKmpInstallAlertView beginSheetModalForWindow:self.window completionHandler:^(NSModalResponse returnCode) { - os_log_debug([KMLogs uiLog], "confirm keyboard installation alert dismissed"); - if (returnCode == NSAlertFirstButtonReturn) { - [self installPackageFile: package.getTempKmpFilename]; - } - [package releaseTempKMPFile]; - self.confirmKmpInstallAlertView = nil; - }]; -} - -- (void)installPackageFile:(NSString *)kmpFile { - // kmpFile could be a temp file (in fact, it always is!), so don't display the name. - os_log_debug([KMLogs dataLog], "kmpFile - ready to unzip/install Package File: %{public}@", kmpFile); - [KMSentryHelper addInfoBreadCrumb:@"configure" message:@"install package file"]; - - BOOL didUnzip = [self.AppDelegate unzipFile:kmpFile]; - - if (!didUnzip) { - os_log_debug([KMLogs dataLog], "kmpFile, unzipFile failed"); - NSAlert *failure = [[NSAlert alloc] init]; - [failure addButtonWithTitle:NSLocalizedString(@"button-keyboard-file-unreadable", @"Alert button")]; - - NSString *errorString = NSLocalizedString(@"message-keyboard-file-unreadable", nil); - [failure setMessageText:[NSString localizedStringWithFormat:errorString, kmpFile.lastPathComponent]]; - - [failure setIcon:[[NSBundle mainBundle] imageForResource:@"logo.png"]]; - [failure setAlertStyle:NSAlertStyleWarning]; - [failure beginSheetModalForWindow:self.window completionHandler:^(NSModalResponse returnCode) { - os_log_debug([KMLogs uiLog], "kmpFile, keyboard file unreadable alert dismissed with returnCode: %ld", (long)returnCode); - }]; - } - else { - os_log_debug([KMLogs dataLog], "kmpFile, completed installation of KMP file"); - } -} - -- (void)startTimer { - if (_reloadTimer == nil) { - TimerTarget *timerTarget = [[TimerTarget alloc] init]; - timerTarget.target = self; - _reloadTimer = [NSTimer scheduledTimerWithTimeInterval:5.0f - target:timerTarget - selector:@selector(timerAction:) - userInfo:nil - repeats:YES]; - } -} - -- (void)stopTimer { - if (_reloadTimer != nil) { - [_reloadTimer invalidate]; - _reloadTimer = nil; - } -} - -- (void)timerAction:(NSTimer *)timer { - if ([self shouldReloadData]) { - [self.AppDelegate setKmxFileList:nil]; - [self setTableContents:nil]; - [self saveActiveKeyboards]; - [self.tableView reloadData]; - _lastReloadDate = [NSDate date]; - } -} - -- (BOOL)shouldReloadData { - NSDictionary *attrs = [[NSFileManager defaultManager] attributesOfItemAtPath:self.keyboardsPath error:nil]; - NSDate *lastModDate = [attrs fileModificationDate]; - return ([lastModDate compare:_lastReloadDate] == NSOrderedDescending); -} - -- (BOOL)hasHelpDocumentation:(NSUInteger)row { - NSString *packagePath = [self packagePathAtIndex:row]; - if (packagePath != nil) { - NSString *welcomeFile = [packagePath stringByAppendingPathComponent:@"welcome.htm"]; - if ([[NSFileManager defaultManager] fileExistsAtPath:welcomeFile]) - return YES; - } - - return NO; -} - -- (NSAlert *)deleteAlertView { - if (_deleteAlertView == nil) { - _deleteAlertView = [[NSAlert alloc] init]; - [_deleteAlertView setInformativeText:NSLocalizedString(@"info-cannot-undo-delete-keyboard", nil)]; - [_deleteAlertView addButtonWithTitle:NSLocalizedString(@"button-delete-keyboard", nil)]; - [_deleteAlertView addButtonWithTitle:NSLocalizedString(@"button-cancel-delete-keyboard", nil)]; - [_deleteAlertView setAlertStyle:NSAlertStyleWarning]; - [_deleteAlertView setIcon:[[NSBundle mainBundle] imageForResource:@"logo.png"]]; - } - - return _deleteAlertView; -} - -- (NSAlert *)confirmKmpInstallAlertView { - if (_confirmKmpInstallAlertView == nil) { - _confirmKmpInstallAlertView = [[NSAlert alloc] init]; - [_confirmKmpInstallAlertView addButtonWithTitle:NSLocalizedString(@"button-install-keyboard", nil)]; - [_confirmKmpInstallAlertView addButtonWithTitle:NSLocalizedString(@"button-cancel-install-keyboard", nil)]; - [_confirmKmpInstallAlertView setMessageText:NSLocalizedString(@"message-confirm-install-keyboard", nil)]; - [_confirmKmpInstallAlertView setAlertStyle:NSAlertStyleInformational]; - [_confirmKmpInstallAlertView setIcon:[[NSBundle mainBundle] imageForResource:@"logo.png"]]; - } - - return _confirmKmpInstallAlertView; -} - -- (void)deleteFileAtIndex:(NSNumber *) n { - NSInteger index = [n integerValue]; - NSString *path2Remove = nil; - NSString *kmxFilePath = [self kmxFilePathAtIndex:index]; - if (kmxFilePath == nil) { - kmxFilePath = [self kmxFilePathAtIndex:index+1]; - path2Remove = [[self keyboardsPath] stringByAppendingPathComponent:[self packageFolderFromPath:kmxFilePath]]; - } - else { - path2Remove = kmxFilePath; - } - - NSError *error; - [[NSFileManager defaultManager] removeItemAtPath:path2Remove error:&error]; - if (error == nil) { - [self performSelector:@selector(timerAction:) withObject:nil afterDelay:1.0]; - } -} -@end diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/am.lproj/preferences.strings b/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/am.lproj/preferences.strings deleted file mode 100644 index d40a9cbfba..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/am.lproj/preferences.strings +++ /dev/null @@ -1,38 +0,0 @@ -/* Checkbox text to always show on-screen keyboard */ -"4UX-80-0Vo.title" = "የስክሪን ላይ ኪቦርዱን ሁልጊዜ አሳይ"; - -/* Checkbox text to enable verbose Console logging */ -"7J6-zy-R20.title" = "የቃል መዝገብ ማስታወሻ ተጠቀም"; - -/* Support button text */ -"93O-x6-RLF.label" = "ድጋፍ"; - -/* Download Keyboard button text */ -"CTw-kf-WNS.title" = "የፊደል ገበታ ሰሌዳ አውርድ..."; - -/* Keyman Configuration window title */ -"F0z-JX-Cv5.title" = "ኪይማንን ለአጠቃቀም ማዘጋጃ"; - -/* button text to go back to previous page */ -"JOK-JV-n8w.title" = "ተመለስ"; - -/* Keyboards button text */ -"MPN-9N-wWc.label" = "የፊደል ገበታ ሰሌዳ"; - -/* text to explain verbose Console logging option */ -"MrI-GM-7d6.title" = "የቃል መዝግብ ማስታወሻ አማራጩ ሲበራ ኪይማን የኬይማን ድጋፍ ችግርን ለመመርመር የሚረዱ ድርጊቶችን ይመዘግባል። የኮንሶል ፕሮግራሙ ከኪይማን የመልእክት ምዝግብ ማስታወሻን ለማየት ሊያገለግል ይችላል። በኮንሶል ውስጥ ሁሉንም የ\"ኪይማን\" መልእክቶችን ለማሳየት ያጣሩ። ይህ የምዝገባ ማስታወሻ ከተፈለገ ወደ ውጭ ሊላክ እና ለየ ኪይማን ድጋፍ ሊላክ ይችላል።"; - -/* button text to move forward to the next page */ -"eXr-8V-h1g.title" = "ወደፊት"; - -/* button text to return to the home help page */ -"fXS-aC-CMH.title" = "መግቢያ ገጽ"; - -/* Options button text */ -"frd-No-seV.label" = "ምርጫዎች"; - -/* Installed Keyboard column heading */ -"jex-Nd-Qzg.headerCell.title" = "የፊደል ሰሌዳን መጫን"; - -/* Verbose logging tooltip text */ -"uWx-3J-U0D.ibShadowedToolTip" = "በአንድ የተወሰነ የቁልፍ ሰሌዳ ወይም በአጠቃላይ ከኪይማን ጋር ችግሮች ካጋጠሙዎት ይህንን ቁልፍ ያብሩት።"; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/cs.lproj/preferences.strings b/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/cs.lproj/preferences.strings deleted file mode 100644 index 9477b4c637..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/cs.lproj/preferences.strings +++ /dev/null @@ -1,38 +0,0 @@ -/* Checkbox text to always show on-screen keyboard */ -"4UX-80-0Vo.title" = "Vždy zobrazovat klávesnici na obrazovce"; - -/* Checkbox text to enable verbose Console logging */ -"7J6-zy-R20.title" = "Použít detailní logování konzole"; - -/* Support button text */ -"93O-x6-RLF.label" = "Podpora"; - -/* Download Keyboard button text */ -"CTw-kf-WNS.title" = "Stáhnout klávesnici..."; - -/* Keyman Configuration window title */ -"F0z-JX-Cv5.title" = "Konfigurace klíče"; - -/* button text to go back to previous page */ -"JOK-JV-n8w.title" = "Zpět"; - -/* Keyboards button text */ -"MPN-9N-wWc.label" = "Klávesnice"; - -/* text to explain verbose Console logging option */ -"MrI-GM-7d6.title" = "Když je zapnuta možnost detailního logování, Keyman zaznamená akce, které mohou pomoci Keymanovi diagnostikovat problém. Program konzole může být použit k zobrazení protokolu zpráv od Keymana. V konzoli filtr zobrazuje všechny zprávy z procesu \"Klíčové\". Tento log může být exportován a v případě potřeby odeslán na podporu klíče."; - -/* button text to move forward to the next page */ -"eXr-8V-h1g.title" = "Přeposlat"; - -/* button text to return to the home help page */ -"fXS-aC-CMH.title" = "Domů"; - -/* Options button text */ -"frd-No-seV.label" = "Možnosti"; - -/* Installed Keyboard column heading */ -"jex-Nd-Qzg.headerCell.title" = "Instalovaná klávesnice"; - -/* Verbose logging tooltip text */ -"uWx-3J-U0D.ibShadowedToolTip" = "Zapněte, pokud máte problémy s konkrétní klávesnicí nebo s klíčem obecně."; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/el.lproj/preferences.strings b/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/el.lproj/preferences.strings deleted file mode 100644 index 6b750343d0..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/el.lproj/preferences.strings +++ /dev/null @@ -1,38 +0,0 @@ -/* Checkbox text to always show on-screen keyboard */ -"4UX-80-0Vo.title" = "Νὰ ἐμφανίζεται πάντοτε τὸ πληκτρολόγιο ὀθόνης"; - -/* Checkbox text to enable verbose Console logging */ -"7J6-zy-R20.title" = "Χρησιμοποιῆστε λεπτομερῆ καταγραφὴ μηνυμάτων κονσόλας"; - -/* Support button text */ -"93O-x6-RLF.label" = "Ὑποστήριξη"; - -/* Download Keyboard button text */ -"CTw-kf-WNS.title" = "Κατεβάστε πληκτρολόγιο..."; - -/* Keyman Configuration window title */ -"F0z-JX-Cv5.title" = "Παράμετροι Keyman"; - -/* button text to go back to previous page */ -"JOK-JV-n8w.title" = "Πίσω"; - -/* Keyboards button text */ -"MPN-9N-wWc.label" = "Πληκτρολόγια"; - -/* text to explain verbose Console logging option */ -"MrI-GM-7d6.title" = "Ὅταν εἶναι ἐνεργὴ ἡ λεπτομερὴς καταγραφὴ μηνυμάτων κονσόλας, τὸ Keyman θὰ καταγράφει ἐνέργειες ποὺ θὰ βοηθήσουν τὴν ὁμάδα ὑποστήριξης Keyman νὰ διαγνώσει τυχὸν πρόβλημα. Μὲ τὸ πρόγραμμα κονσόλας μπορεῖτε νὰ δεῖτε τὰ μηνύματα τοῦ Keyman σὲ ἀρχεῖο. Στὴν Κονσόλα φιλτράρετε καὶ προβάλετε ὅλα τὰ μηνύματα ἀπὸ τὴν διεργασία \"Keyman\". Ἂν χρειασθεῖ, αὐτὴ ἡ ἀναφορὰ μπορεῖ νὰ ἐξαχθεῖ καὶ νὰ ἀποσταλεῖ στὴν ὑποστήριξη τοῦ Keyman."; - -/* button text to move forward to the next page */ -"eXr-8V-h1g.title" = "Μπροστά"; - -/* button text to return to the home help page */ -"fXS-aC-CMH.title" = "Ἀρχική"; - -/* Options button text */ -"frd-No-seV.label" = "Ἐπιλογές"; - -/* Installed Keyboard column heading */ -"jex-Nd-Qzg.headerCell.title" = "Ἐγκατεστημένο Πληκτρολόγιο"; - -/* Verbose logging tooltip text */ -"uWx-3J-U0D.ibShadowedToolTip" = "Ἐνεργοποιῆστε ἂν ἔχετε προβλήματα μὲ ἕνα συγκεκριμένο πληκτρολόγιο ἢ μὲ τὸ Keyman γενικῶς."; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/en.lproj/preferences.strings b/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/en.lproj/preferences.strings deleted file mode 100644 index a950d9fdec..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/en.lproj/preferences.strings +++ /dev/null @@ -1,39 +0,0 @@ - -/* Checkbox text to always show on-screen keyboard */ -"4UX-80-0Vo.title" = "Always show on-screen keyboard"; - -/* Checkbox text to enable verbose Console logging */ -"7J6-zy-R20.title" = "Use verbose Console logging"; - -/* Support button text */ -"93O-x6-RLF.label" = "Support"; - -/* Download Keyboard button text */ -"CTw-kf-WNS.title" = "Download Keyboard..."; - -/* Keyman Configuration window title */ -"F0z-JX-Cv5.title" = "Keyman Configuration"; - -/* button text to go back to previous page */ -"JOK-JV-n8w.title" = "Back"; - -/* Keyboards button text */ -"MPN-9N-wWc.label" = "Keyboards"; - -/* text to explain verbose Console logging option */ -"MrI-GM-7d6.title" = "When the verbose logging option is on, Keyman will log actions that might help Keyman Support diagnose a problem. The Console program can be used to see a log of messages from Keyman. In Console, filter to show all messages from the \"Keyman\" process. This log can be exported and sent to Keyman support if needed."; - -/* button text to move forward to the next page */ -"eXr-8V-h1g.title" = "Forward"; - -/* button text to return to the home help page */ -"fXS-aC-CMH.title" = "Home"; - -/* Options button text */ -"frd-No-seV.label" = "Options"; - -/* Installed Keyboard column heading */ -"jex-Nd-Qzg.headerCell.title" = "Installed Keyboard"; - -/* Verbose logging tooltip text */ -"uWx-3J-U0D.ibShadowedToolTip" = "Turn this on if you are having problems with a specific keyboard or with Keyman in general."; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/es-419.lproj/preferences.strings b/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/es-419.lproj/preferences.strings deleted file mode 100644 index d835bfd432..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/es-419.lproj/preferences.strings +++ /dev/null @@ -1,38 +0,0 @@ -/* Checkbox text to always show on-screen keyboard */ -"4UX-80-0Vo.title" = "Mostrar siempre el teclado en pantalla"; - -/* Checkbox text to enable verbose Console logging */ -"7J6-zy-R20.title" = "Usar registro detallado"; - -/* Support button text */ -"93O-x6-RLF.label" = "Soporte"; - -/* Download Keyboard button text */ -"CTw-kf-WNS.title" = "Descargar teclado..."; - -/* Keyman Configuration window title */ -"F0z-JX-Cv5.title" = "Configuración de Keyman"; - -/* button text to go back to previous page */ -"JOK-JV-n8w.title" = "Atrás"; - -/* Keyboards button text */ -"MPN-9N-wWc.label" = "Teclados"; - -/* text to explain verbose Console logging option */ -"MrI-GM-7d6.title" = "Cuando la opción de registro detallado está activada, Keyman registrará acciones que podrían ayudar a Keyman Support diagnosticar un problema. La aplicación de consola puede ser usada para ver un registro de mensajes de Keyman. En la aplicación de consola, puede filtrar para mostrar todos los mensajes del proceso \"Keyman\". Este registro puede ser exportado y enviado al soporte de Keyman si es necesario."; - -/* button text to move forward to the next page */ -"eXr-8V-h1g.title" = "Siguiente"; - -/* button text to return to the home help page */ -"fXS-aC-CMH.title" = "Inicio"; - -/* Options button text */ -"frd-No-seV.label" = "Opciones"; - -/* Installed Keyboard column heading */ -"jex-Nd-Qzg.headerCell.title" = "Teclado instalado"; - -/* Verbose logging tooltip text */ -"uWx-3J-U0D.ibShadowedToolTip" = "Active esta opción si tiene problemas con un teclado específico o con Keyman en general."; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/es.lproj/preferences.strings b/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/es.lproj/preferences.strings deleted file mode 100644 index aadead65f4..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/es.lproj/preferences.strings +++ /dev/null @@ -1,38 +0,0 @@ -/* Checkbox text to always show on-screen keyboard */ -"4UX-80-0Vo.title" = "Mostrar siempre el teclado en pantalla"; - -/* Checkbox text to enable verbose Console logging */ -"7J6-zy-R20.title" = "Utilizar el registro detallado de la consola"; - -/* Support button text */ -"93O-x6-RLF.label" = "Soporte"; - -/* Download Keyboard button text */ -"CTw-kf-WNS.title" = "Descargar teclado..."; - -/* Keyman Configuration window title */ -"F0z-JX-Cv5.title" = "Configuración de Keyman"; - -/* button text to go back to previous page */ -"JOK-JV-n8w.title" = "Volver"; - -/* Keyboards button text */ -"MPN-9N-wWc.label" = "Teclados"; - -/* text to explain verbose Console logging option */ -"MrI-GM-7d6.title" = "Si la opción de registro detallado está activada, Keyman registrará las acciones que puedan ayudar al servicio de asistencia técnica de Keyman a diagnosticar un problema. El programa Consola puede utilizarse para ver un registro de los mensajes de Keyman. En Consola, filtre para mostrar todos los mensajes del proceso \"Keyman\". Este registro puede exportarse y enviarse al servicio de asistencia de Keyman si es necesario."; - -/* button text to move forward to the next page */ -"eXr-8V-h1g.title" = "Adelante"; - -/* button text to return to the home help page */ -"fXS-aC-CMH.title" = "Inicio"; - -/* Options button text */ -"frd-No-seV.label" = "Opciones"; - -/* Installed Keyboard column heading */ -"jex-Nd-Qzg.headerCell.title" = "Teclado instalado"; - -/* Verbose logging tooltip text */ -"uWx-3J-U0D.ibShadowedToolTip" = "Active esta opción si tiene problemas con un teclado específico o con Keyman en general."; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/ff-ng.lproj/preferences.strings b/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/ff-ng.lproj/preferences.strings deleted file mode 100644 index 19d96968b5..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/ff-ng.lproj/preferences.strings +++ /dev/null @@ -1,38 +0,0 @@ -/* Checkbox text to always show on-screen keyboard */ -"4UX-80-0Vo.title" = "Koonday holla kibod dow yeeso na`ura"; - -/* Checkbox text to enable verbose Console logging */ -"7J6-zy-R20.title" = "Nafturu bee verbose Consol logging"; - -/* Support button text */ -"93O-x6-RLF.label" = "Mballa"; - -/* Download Keyboard button text */ -"CTw-kf-WNS.title" = "Jippunu kibod..."; - -/* Keyman Configuration window title */ -"F0z-JX-Cv5.title" = "Konfigoreshon Keyman"; - -/* button text to go back to previous page */ -"JOK-JV-n8w.title" = "Ɓaawo"; - -/* Keyboards button text */ -"MPN-9N-wWc.label" = "Tappirɗe"; - -/* text to explain verbose Console logging option */ -"MrI-GM-7d6.title" = "To cuɓol logging verbose man ɗo maɓɓiti, Keyman nastay nder kuuɗe ko mballata Keyman Mballita laarol nder masala. kuuɗe Consol man naftiran nasta nder lelle diga Keyman. Nder Consol, seɗu ngam holla kala lelle fuu diga kuuɗe \"Keyman\" man. nastugo nder man ɓe wurtinay ɗum ɓe yerɓa ɗum haa Keyman to ɗo mari haaje."; - -/* button text to move forward to the next page */ -"eXr-8V-h1g.title" = "Yeeso"; - -/* button text to return to the home help page */ -"fXS-aC-CMH.title" = "Home"; - -/* Options button text */ -"frd-No-seV.label" = "Cuɓi"; - -/* Installed Keyboard column heading */ -"jex-Nd-Qzg.headerCell.title" = "Wa`unu kibod"; - -/* Verbose logging tooltip text */ -"uWx-3J-U0D.ibShadowedToolTip" = "Kunnu ɗo to a` ɗo fotta bee masala bee nanta kala kibod fuu ko bo bee Keyman nder fuu man."; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/ff.lproj/preferences.strings b/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/ff.lproj/preferences.strings deleted file mode 100644 index 527693851e..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/ff.lproj/preferences.strings +++ /dev/null @@ -1,38 +0,0 @@ -/* Checkbox text to always show on-screen keyboard */ -"4UX-80-0Vo.title" = "Hollu tappirde yaynirde sahaa kala"; - -/* Checkbox text to enable verbose Console logging */ -"7J6-zy-R20.title" = "Huutoro tonnginagol Ginorde njuutnuka"; - -/* Support button text */ -"93O-x6-RLF.label" = "Wallitorde"; - -/* Download Keyboard button text */ -"CTw-kf-WNS.title" = "Aawto Tappirde..."; - -/* Keyman Configuration window title */ -"F0z-JX-Cv5.title" = "Teeltagol Keyman"; - -/* button text to go back to previous page */ -"JOK-JV-n8w.title" = "Rutto"; - -/* Keyboards button text */ -"MPN-9N-wWc.label" = "Tappirɗe"; - -/* text to explain verbose Console logging option */ -"MrI-GM-7d6.title" = "So tonnginagol njuuñukka ena hurmi, maa Keyman tonngino baɗe baawɗe wallitde Wallirorde Keyman yiytuse caɗeele. Yuɓɓo Ginorde ndee ena waawi huutoreede ngam yiyde tonnginagol ɓatakuuje Keyman. E Ginorde ndee, seɗtu ngam hollude ɓatakuuje iwɗe e silsil Keyman ɗee fof. Ngol tonnginagol ena waawi jiggiteede kadi neldee to wallitorde Keyman so sokkaama."; - -/* button text to move forward to the next page */ -"eXr-8V-h1g.title" = "Yeeso"; - -/* button text to return to the home help page */ -"fXS-aC-CMH.title" = "Jaɓɓorgo"; - -/* Options button text */ -"frd-No-seV.label" = "Cuɓe"; - -/* Installed Keyboard column heading */ -"jex-Nd-Qzg.headerCell.title" = "Tappirde Aafaande"; - -/* Verbose logging tooltip text */ -"uWx-3J-U0D.ibShadowedToolTip" = "Huɓɓu ɗuum so aɗa jogii caɗeele e tappirde heeriinde walla e Keyman jaati."; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/ha.lproj/preferences.strings b/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/ha.lproj/preferences.strings deleted file mode 100644 index 221b215624..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/ha.lproj/preferences.strings +++ /dev/null @@ -1,38 +0,0 @@ -/* Checkbox text to always show on-screen keyboard */ -"4UX-80-0Vo.title" = "Ko da yaushe a nuna madannai na allon kwamfuta"; - -/* Checkbox text to enable verbose Console logging */ -"7J6-zy-R20.title" = "Ayi amfani da shigar verbose Console"; - -/* Support button text */ -"93O-x6-RLF.label" = "Tallafi"; - -/* Download Keyboard button text */ -"CTw-kf-WNS.title" = "A sauke Madannai..."; - -/* Keyman Configuration window title */ -"F0z-JX-Cv5.title" = "Haɗa Keyman"; - -/* button text to go back to previous page */ -"JOK-JV-n8w.title" = "Baya"; - -/* Keyboards button text */ -"MPN-9N-wWc.label" = "Keyboards"; - -/* text to explain verbose Console logging option */ -"MrI-GM-7d6.title" = "Idan zabin shigar verbose na kunne, Keyman zai shigar da ayyukan da zasu taimakawa hanyoyin nemo matsala na Keyman. Za'a iya amfani da shirin Keyman wajen ganin sakonni da yawa daga Keyman. A Console, a tace a nuna dukkan sakonni daga hanyar \"Keyman\". Za'a iya tura wadannan sakonni masu yawa zuwa Tallafin Keyman idan da bukata."; - -/* button text to move forward to the next page */ -"eXr-8V-h1g.title" = "Gaba"; - -/* button text to return to the home help page */ -"fXS-aC-CMH.title" = "Babban Shafi"; - -/* Options button text */ -"frd-No-seV.label" = "Zabuka"; - -/* Installed Keyboard column heading */ -"jex-Nd-Qzg.headerCell.title" = "Madannin da aka saka"; - -/* Verbose logging tooltip text */ -"uWx-3J-U0D.ibShadowedToolTip" = "Ka kunna wannan idan kana da matsala da da wani madanni ko Keyman gabadaya."; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/it.lproj/preferences.strings b/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/it.lproj/preferences.strings deleted file mode 100644 index 23333e41df..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/it.lproj/preferences.strings +++ /dev/null @@ -1,38 +0,0 @@ -/* Checkbox text to always show on-screen keyboard */ -"4UX-80-0Vo.title" = "Mostra sempre la tastiera sullo schermo"; - -/* Checkbox text to enable verbose Console logging */ -"7J6-zy-R20.title" = "Usa il log della console prolissa"; - -/* Support button text */ -"93O-x6-RLF.label" = "Supporto"; - -/* Download Keyboard button text */ -"CTw-kf-WNS.title" = "Scarica Tastiera..."; - -/* Keyman Configuration window title */ -"F0z-JX-Cv5.title" = "Configurazione Keyman"; - -/* button text to go back to previous page */ -"JOK-JV-n8w.title" = "Indietro"; - -/* Keyboards button text */ -"MPN-9N-wWc.label" = "Tastiere"; - -/* text to explain verbose Console logging option */ -"MrI-GM-7d6.title" = "Quando l'opzione di registrazione dettagliata è attiva, Keyman registrerà le azioni che potrebbero aiutare Keyman Supporto diagnosticare un problema. Il programma Console può essere utilizzato per vedere un registro dei messaggi da Keyman. In Console, filtrare per mostrare tutti i messaggi dal processo \"Keyman\". Questo registro può essere esportato e inviato al supporto di Keyman, se necessario."; - -/* button text to move forward to the next page */ -"eXr-8V-h1g.title" = "Avanti"; - -/* button text to return to the home help page */ -"fXS-aC-CMH.title" = "Home"; - -/* Options button text */ -"frd-No-seV.label" = "Opzioni"; - -/* Installed Keyboard column heading */ -"jex-Nd-Qzg.headerCell.title" = "Tastiera Installata"; - -/* Verbose logging tooltip text */ -"uWx-3J-U0D.ibShadowedToolTip" = "Accendi questo se hai problemi con una tastiera specifica o con Keyman in generale."; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/km.lproj/preferences.strings b/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/km.lproj/preferences.strings deleted file mode 100644 index fd960cc5cb..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/km.lproj/preferences.strings +++ /dev/null @@ -1,38 +0,0 @@ -/* Checkbox text to always show on-screen keyboard */ -"4UX-80-0Vo.title" = "បង្ហាញក្ដារចុចលើអេក្រង់ជានិច្ច"; - -/* Checkbox text to enable verbose Console logging */ -"7J6-zy-R20.title" = "ប្រើកំណត់ហេតុកុងសូលលម្អិត"; - -/* Support button text */ -"93O-x6-RLF.label" = "គាំទ្រ"; - -/* Download Keyboard button text */ -"CTw-kf-WNS.title" = "ទាញយកក្ដារចុច..."; - -/* Keyman Configuration window title */ -"F0z-JX-Cv5.title" = "ការកំណត់រចនាសម្ព័ន្ធ Keyman"; - -/* button text to go back to previous page */ -"JOK-JV-n8w.title" = "ថយក្រោយ"; - -/* Keyboards button text */ -"MPN-9N-wWc.label" = "ក្ដារចុច"; - -/* text to explain verbose Console logging option */ -"MrI-GM-7d6.title" = "នៅពេលបើកជម្រើសកំណត់ហេតុលម្អិត Keyman នឹងរក្សារទុកកំណត់ហេតុសកម្មភាពដែលអាចជួយក្រុមការងារ​ Keyman វិភាគបញ្ហា។ កម្មវិធីកុងសូលអាចប្រើបើកមើលសារកំណត់ហេតុពី Keyman។ ក្នុងកុងសូល ត្រងដើម្បីបង្ហាញសារទាំងអស់ពីដំណើរការរបស់ \"Keyman\"។ បើចាំបាច់ អ្នកអាចនាំចេញ ឬផ្ញើកំណត់ហេតុនេទៅក្រុមការងារ Keyman។"; - -/* button text to move forward to the next page */ -"eXr-8V-h1g.title" = "ទៅមុខ"; - -/* button text to return to the home help page */ -"fXS-aC-CMH.title" = "ដើម"; - -/* Options button text */ -"frd-No-seV.label" = "ជម្រើស"; - -/* Installed Keyboard column heading */ -"jex-Nd-Qzg.headerCell.title" = "បានដំឡើង​ក្ដារចុច"; - -/* Verbose logging tooltip text */ -"uWx-3J-U0D.ibShadowedToolTip" = "បើកចុះ ប្រសិនបើអ្នកកំពុងមានបញ្ហាជាមួយ Keyman ឬក្ដារចុចណាមួយជាក់លាក់។"; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/kn.lproj/preferences.strings b/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/kn.lproj/preferences.strings deleted file mode 100644 index 8307f70406..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/kn.lproj/preferences.strings +++ /dev/null @@ -1,38 +0,0 @@ -/* Checkbox text to always show on-screen keyboard */ -"4UX-80-0Vo.title" = "ಯಾವಾಗಲೂ ತೆರೆಯ ಮೇಲೆ ಕೀಲಿಮಣೆ ತೋರಿಸು"; - -/* Checkbox text to enable verbose Console logging */ -"7J6-zy-R20.title" = "ಹೆಚ್ಚಿನ ವಿವರಗಳನ್ನು ಕನ್ಸೋಲ್‌ನಲ್ಲಿ ದಾಖಲಿಸು"; - -/* Support button text */ -"93O-x6-RLF.label" = "ಸಹಾಯ"; - -/* Download Keyboard button text */ -"CTw-kf-WNS.title" = "ಕೀಲಿಮಣೆ ಡೌನ್ಲೋಡ್ ಮಾಡಿ..."; - -/* Keyman Configuration window title */ -"F0z-JX-Cv5.title" = "ಕೀಮ್ಯಾನ್ ಸಂರಚನೆ (ಕಾನ್ಫಿಗರೇಷನ್)"; - -/* button text to go back to previous page */ -"JOK-JV-n8w.title" = "ಹಿಂದೆ"; - -/* Keyboards button text */ -"MPN-9N-wWc.label" = "ಕೀಲಿಮಣೆಗಳು"; - -/* text to explain verbose Console logging option */ -"MrI-GM-7d6.title" = "ವರ್ಬೋಸ್ ಲಾಗಿಂಗ್ ಆಯ್ಕೆಯು ಆನ್ ಆಗಿರುವಾಗ, ಕೀಮ್ಯಾನ್ (support) ಬೆಂಬಲವು ಸಮಸ್ಯೆಯನ್ನು ಪತ್ತೆಹಚ್ಚಲು ಸಹಾಯ ಮಾಡುವ ಕ್ರಿಯೆಗಳನ್ನು ಕೀಮ್ಯಾನ್ ಲಾಗ್ ಮಾಡುತ್ತದೆ. ಕೀಮ್ಯಾನ್‌ನಿಂದ ಸಂದೇಶಗಳ ಲಾಗ್ ಅನ್ನು ನೋಡಲು ಕನ್ಸೋಲ್ ಪ್ರೋಗ್ರಾಂ ಅನ್ನು ಬಳಸಬಹುದು. ಕನ್ಸೋಲ್‌ನಲ್ಲಿ, \"ಕೀಮ್ಯಾನ್\" ಪ್ರಕ್ರಿಯೆಯಿಂದ ಎಲ್ಲಾ ಸಂದೇಶಗಳನ್ನು ತೋರಿಸಲು ಫಿಲ್ಟರ್ ಮಾಡಿ. ಅಗತ್ಯವಿದ್ದರೆ ಈ ಲಾಗ್ ಅನ್ನು ರಫ್ತು ಮಾಡಬಹುದು ಮತ್ತು ಕೀಮ್ಯಾನ್ (support) ಬೆಂಬಲಕ್ಕೆ ಕಳುಹಿಸಬಹುದು."; - -/* button text to move forward to the next page */ -"eXr-8V-h1g.title" = "ಮುಂದಕ್ಕೆ"; - -/* button text to return to the home help page */ -"fXS-aC-CMH.title" = "ಹೋಮ್"; - -/* Options button text */ -"frd-No-seV.label" = "ಆಯ್ಕೆಗಳು"; - -/* Installed Keyboard column heading */ -"jex-Nd-Qzg.headerCell.title" = "ಅನುಸ್ಥಾಪಿಸಲಾದ ಕೀಲಿಮಣೆಯನ್ನು"; - -/* Verbose logging tooltip text */ -"uWx-3J-U0D.ibShadowedToolTip" = "ನಿರ್ದಿಷ್ಟ ಕೀಲಿಮಣೆ ಅಥವಾ ಸಾಮಾನ್ಯವಾಗಿ ಕೀಮ್ಯಾನ್‌ನೊಂದಿಗೆ ನೀವು ಸಮಸ್ಯೆಗಳನ್ನು ಹೊಂದಿದ್ದರೆ, ಇದನ್ನು ಆನ್ ಮಾಡಿ."; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/nl.lproj/preferences.strings b/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/nl.lproj/preferences.strings deleted file mode 100644 index 1aae254502..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/nl.lproj/preferences.strings +++ /dev/null @@ -1,38 +0,0 @@ -/* Checkbox text to always show on-screen keyboard */ -"4UX-80-0Vo.title" = "Altijd het schermtoetsenbord weergeven"; - -/* Checkbox text to enable verbose Console logging */ -"7J6-zy-R20.title" = "Gebruik verbose console logging"; - -/* Support button text */ -"93O-x6-RLF.label" = "Ondersteuning"; - -/* Download Keyboard button text */ -"CTw-kf-WNS.title" = "Download toetsenbord..."; - -/* Keyman Configuration window title */ -"F0z-JX-Cv5.title" = "Sleutelman configuratie"; - -/* button text to go back to previous page */ -"JOK-JV-n8w.title" = "Achterzijde"; - -/* Keyboards button text */ -"MPN-9N-wWc.label" = "Keyboards"; - -/* text to explain verbose Console logging option */ -"MrI-GM-7d6.title" = "Als de uitgebreide logging optie is ingeschakeld, zal Keyman acties loggen die Keyman Support kunnen helpen een probleem te diagnosticeren. Het console programma kan worden gebruikt om een logboek van berichten van Keyman te zien. In de console filteren om alle berichten van het \"Keyman\"-proces te tonen. Deze log kan worden geëxporteerd en naar Keyman-ondersteuning worden verzonden indien nodig."; - -/* button text to move forward to the next page */ -"eXr-8V-h1g.title" = "Doorsturen"; - -/* button text to return to the home help page */ -"fXS-aC-CMH.title" = "Startpagina"; - -/* Options button text */ -"frd-No-seV.label" = "Instellingen"; - -/* Installed Keyboard column heading */ -"jex-Nd-Qzg.headerCell.title" = "Geïnstalleerd toetsenbord"; - -/* Verbose logging tooltip text */ -"uWx-3J-U0D.ibShadowedToolTip" = "Zet dit aan als je problemen hebt met een specifiek toetsenbord of met Keyman in het algemeen."; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/pl.lproj/preferences.strings b/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/pl.lproj/preferences.strings deleted file mode 100644 index b58e6e6291..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/pl.lproj/preferences.strings +++ /dev/null @@ -1,38 +0,0 @@ -/* Checkbox text to always show on-screen keyboard */ -"4UX-80-0Vo.title" = "Zawsze pokazuj klawiaturę ekranową"; - -/* Checkbox text to enable verbose Console logging */ -"7J6-zy-R20.title" = "Użyj rejestrowania konsoli"; - -/* Support button text */ -"93O-x6-RLF.label" = "Wsparcie"; - -/* Download Keyboard button text */ -"CTw-kf-WNS.title" = "Pobierz klawiaturę..."; - -/* Keyman Configuration window title */ -"F0z-JX-Cv5.title" = "Konfiguracja Keymana"; - -/* button text to go back to previous page */ -"JOK-JV-n8w.title" = "Powrót"; - -/* Keyboards button text */ -"MPN-9N-wWc.label" = "Keyboards"; - -/* text to explain verbose Console logging option */ -"MrI-GM-7d6.title" = "Gdy włączona jest opcja rejestrowania szczegółowego, Keyman będzie rejestrować działania, które mogą pomóc w diagnozowaniu problemu przez obsługę Keyman. Program Konsoli może być używany do wyświetlania dziennika wiadomości od Keyman. W Konsoli filtr, aby wyświetlić wszystkie wiadomości z procesu \"Keyman\". Ten dziennik można wyeksportować i wysłać do obsługi Keyman w razie potrzeby."; - -/* button text to move forward to the next page */ -"eXr-8V-h1g.title" = "Przekaż"; - -/* button text to return to the home help page */ -"fXS-aC-CMH.title" = "Strona główna"; - -/* Options button text */ -"frd-No-seV.label" = "Opcje"; - -/* Installed Keyboard column heading */ -"jex-Nd-Qzg.headerCell.title" = "Zainstalowana Klawiatura"; - -/* Verbose logging tooltip text */ -"uWx-3J-U0D.ibShadowedToolTip" = "Włącz tę opcję jeśli masz problemy z określoną klawiaturą lub ogólnie z Keymanem."; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/pt-PT.lproj/preferences.strings b/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/pt-PT.lproj/preferences.strings deleted file mode 100644 index def43607c3..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/pt-PT.lproj/preferences.strings +++ /dev/null @@ -1,38 +0,0 @@ -/* Checkbox text to always show on-screen keyboard */ -"4UX-80-0Vo.title" = "Sempre mostrar o teclado na tela"; - -/* Checkbox text to enable verbose Console logging */ -"7J6-zy-R20.title" = "Usar registro do Console detalhado"; - -/* Support button text */ -"93O-x6-RLF.label" = "Suporte"; - -/* Download Keyboard button text */ -"CTw-kf-WNS.title" = "Baixar teclado..."; - -/* Keyman Configuration window title */ -"F0z-JX-Cv5.title" = "Configuração do Keyman "; - -/* button text to go back to previous page */ -"JOK-JV-n8w.title" = "Voltar"; - -/* Keyboards button text */ -"MPN-9N-wWc.label" = "Teclados"; - -/* text to explain verbose Console logging option */ -"MrI-GM-7d6.title" = "Quando a opção de registro detalhada estiver ativada, o Keyman irá registrar ações que podem ajudar o suporte ao Keyman a diagnosticar um problema. O programa Console pode ser usado para ver um registro de mensagens do Keyman. Em Console, filtro para mostrar todas as mensagens do processo \"Keyman\". Este log pode ser exportado e enviado para o suporte a Keyman, se necessário."; - -/* button text to move forward to the next page */ -"eXr-8V-h1g.title" = "Avançar"; - -/* button text to return to the home help page */ -"fXS-aC-CMH.title" = "Residencial"; - -/* Options button text */ -"frd-No-seV.label" = "Opções"; - -/* Installed Keyboard column heading */ -"jex-Nd-Qzg.headerCell.title" = "Teclado instalado"; - -/* Verbose logging tooltip text */ -"uWx-3J-U0D.ibShadowedToolTip" = "Ative isso se você estiver tendo problemas com um teclado específico ou com o Keyman em geral."; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/ru.lproj/preferences.strings b/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/ru.lproj/preferences.strings deleted file mode 100644 index 24b4393b03..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/ru.lproj/preferences.strings +++ /dev/null @@ -1,38 +0,0 @@ -/* Checkbox text to always show on-screen keyboard */ -"4UX-80-0Vo.title" = "Всегда показывать экранную клавиатуру"; - -/* Checkbox text to enable verbose Console logging */ -"7J6-zy-R20.title" = "Использовать подробное ведение журнала Консоли"; - -/* Support button text */ -"93O-x6-RLF.label" = "Поддержка"; - -/* Download Keyboard button text */ -"CTw-kf-WNS.title" = "Скачать клавиатуру..."; - -/* Keyman Configuration window title */ -"F0z-JX-Cv5.title" = "Настройки Keyman"; - -/* button text to go back to previous page */ -"JOK-JV-n8w.title" = "Назад"; - -/* Keyboards button text */ -"MPN-9N-wWc.label" = "Keyboards"; - -/* text to explain verbose Console logging option */ -"MrI-GM-7d6.title" = "Когда включена опция ведения подробного журнала, Keyman будет записывать действия, которые могут помочь в диагностировании проблемы с помощью Keyman Support. Программа консоли может быть использована для просмотра журнала сообщений от Keyman. В консоли фильтровать для показа всех сообщений из процесса \"Keyman\". Этот журнал можно экспортировать и при необходимости отправлять в службу поддержки Keyman."; - -/* button text to move forward to the next page */ -"eXr-8V-h1g.title" = "Вперед"; - -/* button text to return to the home help page */ -"fXS-aC-CMH.title" = "Домашний"; - -/* Options button text */ -"frd-No-seV.label" = "Параметры"; - -/* Installed Keyboard column heading */ -"jex-Nd-Qzg.headerCell.title" = "Установленная клавиатура"; - -/* Verbose logging tooltip text */ -"uWx-3J-U0D.ibShadowedToolTip" = "Включите это, если у вас возникли проблемы с определенной клавиатурой или с Keyman в целом."; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/sv.lproj/preferences.strings b/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/sv.lproj/preferences.strings deleted file mode 100644 index 88d3107cba..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/sv.lproj/preferences.strings +++ /dev/null @@ -1,38 +0,0 @@ -/* Checkbox text to always show on-screen keyboard */ -"4UX-80-0Vo.title" = "Visa alltid skärmtangentbord"; - -/* Checkbox text to enable verbose Console logging */ -"7J6-zy-R20.title" = "Använd verbose Console-loggning"; - -/* Support button text */ -"93O-x6-RLF.label" = "Stöd"; - -/* Download Keyboard button text */ -"CTw-kf-WNS.title" = "Ladda ner tangentbord..."; - -/* Keyman Configuration window title */ -"F0z-JX-Cv5.title" = "Keyman konfiguration"; - -/* button text to go back to previous page */ -"JOK-JV-n8w.title" = "Tillbaka"; - -/* Keyboards button text */ -"MPN-9N-wWc.label" = "Keyboards"; - -/* text to explain verbose Console logging option */ -"MrI-GM-7d6.title" = "När verbose loggning alternativet är på, kommer Keyman logga åtgärder som kan hjälpa Keyman Support diagnostisera ett problem. Programmet Konsol kan användas för att se en logg av meddelanden från Keyman. I Console, filter för att visa alla meddelanden från \"Keyman\" processen. Denna logg kan exporteras och skickas till Keyman support om det behövs."; - -/* button text to move forward to the next page */ -"eXr-8V-h1g.title" = "Framåt"; - -/* button text to return to the home help page */ -"fXS-aC-CMH.title" = "Hem"; - -/* Options button text */ -"frd-No-seV.label" = "Alternativ"; - -/* Installed Keyboard column heading */ -"jex-Nd-Qzg.headerCell.title" = "Installerat tangentbord"; - -/* Verbose logging tooltip text */ -"uWx-3J-U0D.ibShadowedToolTip" = "Aktivera detta om du har problem med ett specifikt tangentbord eller med Keyman i allmänhet."; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/uk.lproj/preferences.strings b/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/uk.lproj/preferences.strings deleted file mode 100644 index 040cb82624..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/uk.lproj/preferences.strings +++ /dev/null @@ -1,38 +0,0 @@ -/* Checkbox text to always show on-screen keyboard */ -"4UX-80-0Vo.title" = "Завжди показувати на екранній клавіатурі"; - -/* Checkbox text to enable verbose Console logging */ -"7J6-zy-R20.title" = "Використати докладне ведення журналу"; - -/* Support button text */ -"93O-x6-RLF.label" = "Підтримка"; - -/* Download Keyboard button text */ -"CTw-kf-WNS.title" = "Завантажити клавіатуру..."; - -/* Keyman Configuration window title */ -"F0z-JX-Cv5.title" = "Налаштування Keyman"; - -/* button text to go back to previous page */ -"JOK-JV-n8w.title" = "Відмінити"; - -/* Keyboards button text */ -"MPN-9N-wWc.label" = "Keyboards"; - -/* text to explain verbose Console logging option */ -"MrI-GM-7d6.title" = "Коли відбувається докладне ведення журналу, Keyman буде журналювати, які можуть допомогти Keyman підтримувати проблему. Програма Консолі може бути використана щоб побачити журнал повідомлень з Keyman. У Console, фільтр для показу всіх повідомлень з процесу \"Keyman\". За необхідності, цей журнал можна експортувати та відправляти в підтримку Keyman."; - -/* button text to move forward to the next page */ -"eXr-8V-h1g.title" = "Переслати"; - -/* button text to return to the home help page */ -"fXS-aC-CMH.title" = "Домашній екран"; - -/* Options button text */ -"frd-No-seV.label" = "Опції"; - -/* Installed Keyboard column heading */ -"jex-Nd-Qzg.headerCell.title" = "Встановлена клавіатура"; - -/* Verbose logging tooltip text */ -"uWx-3J-U0D.ibShadowedToolTip" = "Увімкніть це, якщо у вас виникають проблеми з певною клавіатурою або з Keyman загалом."; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/vi.lproj/preferences.strings b/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/vi.lproj/preferences.strings deleted file mode 100644 index f99aa6f525..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/vi.lproj/preferences.strings +++ /dev/null @@ -1,38 +0,0 @@ -/* Checkbox text to always show on-screen keyboard */ -"4UX-80-0Vo.title" = "Luôn hiện bàn phím ảo"; - -/* Checkbox text to enable verbose Console logging */ -"7J6-zy-R20.title" = "Chọn verbose Console logging"; - -/* Support button text */ -"93O-x6-RLF.label" = "Hỗ trợ"; - -/* Download Keyboard button text */ -"CTw-kf-WNS.title" = "Tải về bàn phím..."; - -/* Keyman Configuration window title */ -"F0z-JX-Cv5.title" = "Cấu hình Keyman"; - -/* button text to go back to previous page */ -"JOK-JV-n8w.title" = "Quay lại"; - -/* Keyboards button text */ -"MPN-9N-wWc.label" = "Bàn phím"; - -/* text to explain verbose Console logging option */ -"MrI-GM-7d6.title" = "Khi tuỳ chọn ghi nhật ký chi tiết được bật, Keyman sẽ ghi các hành động có thể giúp Bộ phận Hỗ trợ Keyman chẩn đoán sự cố. Chương trình Console có thể được sử dụng để xem nhật ký tin nhắn từ Keyman. Trong Console, bộ lọc để hiển thị tất cả tin nhắn từ quá trình \"Keyman\". Nhật ký này có thể được xuất khẩu và gửi cho Bộ phận Hỗ trợ Keyman nếu cần."; - -/* button text to move forward to the next page */ -"eXr-8V-h1g.title" = "Chuyển tiếp"; - -/* button text to return to the home help page */ -"fXS-aC-CMH.title" = "Trang chủ"; - -/* Options button text */ -"frd-No-seV.label" = "Tùy chỉnh"; - -/* Installed Keyboard column heading */ -"jex-Nd-Qzg.headerCell.title" = "Bàn phím được tải về"; - -/* Verbose logging tooltip text */ -"uWx-3J-U0D.ibShadowedToolTip" = "Mở cái này khi bạn có khó khăn với một bàn phím cụ thể nói riêng hoặc với Keyman nói chung."; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/zh-Hans.lproj/preferences.strings b/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/zh-Hans.lproj/preferences.strings deleted file mode 100644 index f0a5d7d0a8..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMConfiguration/zh-Hans.lproj/preferences.strings +++ /dev/null @@ -1,38 +0,0 @@ -/* Checkbox text to always show on-screen keyboard */ -"4UX-80-0Vo.title" = "总是在屏幕上显示键盘"; - -/* Checkbox text to enable verbose Console logging */ -"7J6-zy-R20.title" = "使用详细的控制台日志"; - -/* Support button text */ -"93O-x6-RLF.label" = "支持"; - -/* Download Keyboard button text */ -"CTw-kf-WNS.title" = "下载键盘..."; - -/* Keyman Configuration window title */ -"F0z-JX-Cv5.title" = "Keyman 配置"; - -/* button text to go back to previous page */ -"JOK-JV-n8w.title" = "返回"; - -/* Keyboards button text */ -"MPN-9N-wWc.label" = "键盘"; - -/* text to explain verbose Console logging option */ -"MrI-GM-7d6.title" = "当详细日志选项开启时,Keyman 将记录可能有助于 Keyman 支持诊断问题的动作。控制台程序可以用来查看来自 Keyman 的消息日志。在控制台,过滤后可显示来自\"Keyman\"进程的所有消息。此日志可导出,且可在需要时将其发送至 Keyman 支持。"; - -/* button text to move forward to the next page */ -"eXr-8V-h1g.title" = "前进"; - -/* button text to return to the home help page */ -"fXS-aC-CMH.title" = "主页"; - -/* Options button text */ -"frd-No-seV.label" = "选项"; - -/* Installed Keyboard column heading */ -"jex-Nd-Qzg.headerCell.title" = "已安装的键盘"; - -/* Verbose logging tooltip text */ -"uWx-3J-U0D.ibShadowedToolTip" = "如果特定的键盘或 Keyman 中出现问题,请打开它。"; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMDataRepository.h b/mac/Keyman4MacIM/Keyman4MacIM/KMDataRepository.h index 35d3f427e6..1829e5fae6 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMDataRepository.h +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMDataRepository.h @@ -9,17 +9,23 @@ NS_ASSUME_NONNULL_BEGIN -@interface KMDataRepository : NSObject -// keymanDataDirectory: '~/Library/Application Support/keyman.inputmethod.Keyman' -@property (readonly) NSURL *keymanDataDirectory; +extern NSString *const kKeymanGroupId; -// keymanKeyboardsDirectory: '~/Library/Application Support/keyman.inputmethod.Keyman/Keyman-Keyboards' -@property (readonly) NSURL *keymanKeyboardsDirectory; +@interface KMDataRepository : NSObject + +// keyman18DataDirectory: '~/Library/Application Support/keyman.inputmethod.Keyman' +@property (readonly) NSURL *keyman18DataDirectory; + +// keyman18KeyboardsDirectory: '~/Library/Application Support/keyman.inputmethod.Keyman/Keyman-Keyboards' +@property (readonly) NSURL *keyman18KeyboardsDirectory; + +@property (readonly) NSURL *keyman19ContainerDirectory; +@property (readonly) NSURL *keyman19KeyboardsDirectory; + (KMDataRepository *)shared; -- (void)createDataDirectoryIfNecessary; -- (void)createKeyboardsDirectoryIfNecessary; -- (BOOL)migrateData; +- (void)createSharedDirectoriesIfNecessary; +- (void)migrateDataFromKeyman17; +- (void)migrateDataFromKeyman18; - (NSString*)buildFullPath:(NSString *)fromPartialPath; - (NSString*)trimToPartialPath:(NSString *)fromFullPath; - (NSString *)buildPartialPathFrom:(NSString *)keyboardSubdirectory keyboardFile:(NSString *)kmxFilename; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMDataRepository.m b/mac/Keyman4MacIM/Keyman4MacIM/KMDataRepository.m index 4722a86fec..1c8101ed01 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMDataRepository.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMDataRepository.m @@ -10,30 +10,41 @@ #import "KMDataRepository.h" #import "KMLogs.h" +/** + * the name of the iOS-style app group shared by the Keyman input method and the Keyman configuration app + * (Mac apps are now using iOS-style app groups that begin with 'group') + * It is defined for Keyman on developer.apple.com and linked to the provisioning profile for both apps. + */ +NSString *const kKeymanGroupId = @"group.com.keyman"; @interface KMDataRepository () @property (readonly) NSURL *applicationSupportSubDirectory; @property (readonly) NSURL *documentsSubDirectory; -@property (readonly) NSURL *obsoleteKeymanKeyboardsDirectory; +@property (readonly) NSURL *keyman17KeyboardsDirectory; @end @implementation KMDataRepository /** - * Two directory trees are represented by the following properties, one in active use - * and one that is obsolete. - * The actively used directories, begin with the parent + * Three directory trees are represented by the following properties, one in active use + * and two that are obsolete. + * The actively used directories, introduced in Keyman 19, are shared via the app group `group.com.keyman`: + * '~/Library/Group Containers/group.com.keyman/Library/Application Support/Keyman-Packages/' + * The obsolete directories from Keyman 18 are: * applicationSupportSubDirectory: '~/Library/Application Support' - * keymanDataDirectory: '~/Library/Application Support/keyman.inputmethod.Keyman' - * keymanKeyboardsDirectory: '~/Library/Application Support/keyman.inputmethod.Keyman/Keyman-Keyboards' - * The obsolete directories, begin with the parent + * keyman18DataDirectory: '~/Library/Application Support/keyman.inputmethod.Keyman' + * keyman18KeyboardsDirectory: '~/Library/Application Support/keyman.inputmethod.Keyman/Keyman-Keyboards' + * The obsolete directories from Keyman 17 and earlier are: * documentsSubDirectory: '~/Documents' - * obsoleteKeymanKeyboardsDirectory: '~/Documents/Keyman-Keyboards' + * keyman17KeyboardsDirectory: '~/Documents/Keyman-Keyboards' */ @synthesize applicationSupportSubDirectory = _applicationSupportSubDirectory; -@synthesize keymanDataDirectory = _keymanDataDirectory; -@synthesize keymanKeyboardsDirectory = _keymanKeyboardsDirectory; +@synthesize keyman18DataDirectory = _keyman18DataDirectory; +@synthesize keyman18KeyboardsDirectory = _keyman18KeyboardsDirectory; @synthesize documentsSubDirectory = _documentsSubDirectory; -@synthesize obsoleteKeymanKeyboardsDirectory = _obsoleteKeymanKeyboardsDirectory; +@synthesize keyman17KeyboardsDirectory = _keyman17KeyboardsDirectory; + +@synthesize keyman19ContainerDirectory = _keyman19ContainerDirectory; +@synthesize keyman19KeyboardsDirectory = _keyman19KeyboardsDirectory; NSString *const kKeyboardsDirectoryName = @"Keyman-Keyboards"; /** @@ -44,6 +55,8 @@ NSString *const kKeyboardsDirectoryName = @"Keyman-Keyboards"; */ NSString *const kKeymanSubdirectoryName = @"keyman.inputmethod.Keyman"; +NSString *const kContainerKeyboardsPartialPath = @"Library/Application Support/Keyman-Packages"; + + (KMDataRepository *)shared { static KMDataRepository *shared = nil; static dispatch_once_t onceToken; @@ -53,6 +66,51 @@ NSString *const kKeymanSubdirectoryName = @"keyman.inputmethod.Keyman"; return shared; } +/** + * Returns true if URL is an existing directory that is not empty + */ ++ (BOOL)isExistingNonEmptyDirectory:(NSURL *)directoryUrl { + return [KMDataRepository isExistingDirectory: directoryUrl] && ![KMDataRepository isEmptyDirectory: directoryUrl]; +} + +/** + * Returns true if URL is an existing directory that is empty + */ ++ (BOOL)isExistingEmptyDirectory:(NSURL *)directoryUrl { + return [KMDataRepository isExistingDirectory: directoryUrl] && [KMDataRepository isEmptyDirectory: directoryUrl]; +} + +/** + * Returns true if the directory exists + */ ++ (BOOL)isExistingDirectory:(NSURL *)directoryUrl { + NSFileManager *fileManager = [NSFileManager defaultManager]; + BOOL isDirectory; + BOOL urlExists = ([fileManager fileExistsAtPath:directoryUrl.path isDirectory:&isDirectory]); + return urlExists && isDirectory; +} + +/** + * Returns true if the directory is empty + */ ++ (BOOL)isEmptyDirectory:(NSURL *)directoryUrl { + NSFileManager *fileManager = [NSFileManager defaultManager]; + BOOL isEmpty = true; + + NSError *error = nil; + NSArray *contents = [fileManager contentsOfDirectoryAtPath:directoryUrl.path error:&error]; + + if (error) { + os_log_debug([KMLogs dataLog], "cannot read directory: %@", directoryUrl.path); + } else if (contents.count > 0) { + isEmpty = false; + } else { + isEmpty = true; + } + + return isEmpty; +} + - (NSURL *)documentsSubDirectory { if (_documentsSubDirectory == nil) { NSError *directoryError = nil; @@ -87,117 +145,99 @@ NSString *const kKeymanSubdirectoryName = @"keyman.inputmethod.Keyman"; return _applicationSupportSubDirectory; } -- (NSURL *)keymanDataDirectory { - if (_keymanDataDirectory == nil) { +- (NSURL *)keyman18DataDirectory { + if (_keyman18DataDirectory == nil) { NSURL *keymanDataUrl = [self.applicationSupportSubDirectory URLByAppendingPathComponent:kKeymanSubdirectoryName isDirectory: TRUE]; - _keymanDataDirectory = keymanDataUrl; + _keyman18DataDirectory = keymanDataUrl; } - return _keymanDataDirectory; + return _keyman18DataDirectory; } -- (NSURL *)keymanKeyboardsDirectory { - if (_keymanKeyboardsDirectory == nil) { - NSURL *keyboardsUrl = [self.keymanDataDirectory URLByAppendingPathComponent:kKeyboardsDirectoryName isDirectory: TRUE]; - _keymanKeyboardsDirectory = keyboardsUrl; +- (NSURL *)keyman18KeyboardsDirectory { + if (_keyman18KeyboardsDirectory == nil) { + NSURL *keyboardsUrl = [self.keyman18DataDirectory URLByAppendingPathComponent:kKeyboardsDirectoryName isDirectory: TRUE]; + _keyman18KeyboardsDirectory = keyboardsUrl; } - return _keymanKeyboardsDirectory; + return _keyman18KeyboardsDirectory; +} + +- (NSURL *)keyman19ContainerDirectory { + if (_keyman19ContainerDirectory == nil) { + + NSFileManager *fileManager = [NSFileManager defaultManager]; + os_log_info([KMLogs dataLog], "about to fetch containerUrl"); + NSURL *containerUrl = [fileManager containerURLForSecurityApplicationGroupIdentifier: kKeymanGroupId]; + os_log_info([KMLogs dataLog], "containerUrl: '%{public}@'", containerUrl.path); + + _keyman19ContainerDirectory = containerUrl; + } + return _keyman19ContainerDirectory; +} + +- (NSURL *)keyman19KeyboardsDirectory { + if (_keyman19KeyboardsDirectory == nil) { + NSURL *keyboardsUrl = [self.keyman19ContainerDirectory URLByAppendingPathComponent:kContainerKeyboardsPartialPath isDirectory: TRUE]; + _keyman19KeyboardsDirectory = keyboardsUrl; + } + return _keyman19KeyboardsDirectory; } /** - * Creates Keyman data directory if it do not exist yet. This is the main data subdirectory: keyman.inputmethod.Keyman + * Creates Keyman 19 data directory if it does not exist yet. This is under 'Group Containers'. */ -- (void)createDataDirectoryIfNecessary { +- (void)createSharedDirectoriesIfNecessary { NSFileManager *fileManager = [NSFileManager defaultManager]; BOOL isDir; - BOOL exists = [fileManager fileExistsAtPath:self.keymanDataDirectory.path isDirectory:&isDir]; + BOOL exists = [fileManager fileExistsAtPath:self.keyman19KeyboardsDirectory.path isDirectory:&isDir]; if (!exists) { NSError *createError = nil; - os_log_info([KMLogs dataLog], "createDataDirectoryIfNecessary, about to attempt createDirectoryAtPath for: '%{public}@'", self.keymanDataDirectory.path); - [fileManager createDirectoryAtPath:self.keymanDataDirectory.path withIntermediateDirectories:YES attributes:nil error:nil]; + os_log_info([KMLogs dataLog], "createKeyman19SharedDirectoriesIfNecessary, about to attempt createDirectoryAtPath for: '%{public}@'", self.keyman19KeyboardsDirectory.path); + [fileManager createDirectoryAtPath:self.keyman19KeyboardsDirectory.path withIntermediateDirectories:YES attributes:nil error:&createError]; if (createError) { os_log_error([KMLogs dataLog], "error creating Keyman data directory: '%{public}@'", createError.localizedDescription); } else { - os_log_info([KMLogs dataLog], "created Keyman data directory: '%{public}@'", self.keymanDataDirectory.path); + os_log_info([KMLogs dataLog], "created Keyman data directory: '%{public}@'", self.keyman19KeyboardsDirectory.path); } } else { - os_log_info([KMLogs dataLog], "Keyman data directory already exists: '%{public}@'", self.keymanDataDirectory.path); + os_log_info([KMLogs dataLog], "Keyman data directory already exists: '%{public}@'", self.keyman19KeyboardsDirectory.path); } } -/** - * Creates Keyman keyboard directory if it does not exist yet. This is the 'Keyman-Keyboards' directory. - * It should not be created until after migrating because its existence would block migrating data from the old location. - */ -- (void)createKeyboardsDirectoryIfNecessary { - NSFileManager *fileManager = [NSFileManager defaultManager]; - BOOL isDir; - BOOL exists = [fileManager fileExistsAtPath:self.keymanKeyboardsDirectory.path isDirectory:&isDir]; - - if (!exists) { - NSError *createError = nil; - os_log_info([KMLogs dataLog], "createKeyboardsDirectoryIfNecessary, about to attempt createDirectoryAtPath for: '%{public}@'", self.keymanKeyboardsDirectory.path); - [fileManager createDirectoryAtPath:self.keymanKeyboardsDirectory.path withIntermediateDirectories:YES attributes:nil error:nil]; - if (createError) { - os_log_error([KMLogs dataLog], "error creating Keyman-Keyboards directory: '%{public}@'", createError.localizedDescription); - } else { - os_log_info([KMLogs dataLog], "created Keyman-Keyboards subdirectory: '%{public}@'", self.keymanKeyboardsDirectory.path); - } - } else { - os_log_info([KMLogs dataLog], "Keyman-Keyboards already exists: '%{public}@'", self.keymanKeyboardsDirectory.path); - } -} - -- (NSURL *)obsoleteKeymanKeyboardsDirectory { - if (_obsoleteKeymanKeyboardsDirectory == nil) { +- (NSURL *)keyman17KeyboardsDirectory { + if (_keyman17KeyboardsDirectory == nil) { NSURL *keymanUrl = [self.documentsSubDirectory URLByAppendingPathComponent:kKeyboardsDirectoryName isDirectory: TRUE]; - _obsoleteKeymanKeyboardsDirectory = keymanUrl; + _keyman17KeyboardsDirectory = keymanUrl; } - return _obsoleteKeymanKeyboardsDirectory; + return _keyman17KeyboardsDirectory; } /** - * Only called from migrateData. + * Only called from migrateDataFromKeyman17. * Causes user to be prompted for permission to access ~/Documents, but they should already have it. * otherwise we would not be attempting to migrate. */ -- (BOOL)keyboardsExistInObsoleteDirectory { - NSFileManager *fileManager = [NSFileManager defaultManager]; - BOOL isDir; - BOOL exists = ([fileManager fileExistsAtPath:self.obsoleteKeymanKeyboardsDirectory.path isDirectory:&isDir]); - return exists; +- (BOOL)keyboardsExistInDocumentsDirectory { + return [KMDataRepository isExistingNonEmptyDirectory: self.keyman17KeyboardsDirectory]; } /** - * Migrate the keyboards data from the old location in '~/Documents' to the new location '~/Application Support/keyman.inputmethod.Keyman/' - * This should only be called if the Keyman settings written to the UserDefaults indicates that we have data in the old location. - * If this is the case, then we expect the user to have already granted permission for Keyman to access the ~/Documents directory. - * If that permission has been removed for some reason, then calling this code will cause the user to be asked for permission again. + * Only called from migrateDataFromKeyman18. + * Checks to see if the keyboards directory exists in '~/Library/Application Support/keyman.inputmethod.Keyman' and is not empty */ -- (BOOL)migrateData { - BOOL didMoveData = NO; - NSFileManager *fileManager = [NSFileManager defaultManager]; - BOOL dataExistsInOldLocation = [self keyboardsExistInObsoleteDirectory]; - os_log_debug([KMLogs dataLog], "obsolete keyman keyboards directory exists: %@", dataExistsInOldLocation?@"YES":@"NO"); +- (BOOL)keyboardsExistInInputMethodDataDirectory { + return [KMDataRepository isExistingNonEmptyDirectory: self.keyman18KeyboardsDirectory]; +} - // only move data if there is something to move - if (dataExistsInOldLocation) { - NSError *moveError = nil; - didMoveData = [fileManager moveItemAtURL:self.obsoleteKeymanKeyboardsDirectory - toURL:self.keymanKeyboardsDirectory - error:&moveError]; - if (moveError) { - os_log_error([KMLogs dataLog], "data migration failed: '%{public}@'", moveError.localizedDescription); - } else { - os_log_info([KMLogs dataLog], "data migrated successfully to: '%{public}@'", self.keymanKeyboardsDirectory.path); - } - } - - return didMoveData; +/** + * Checks to see if the keyboards directory exists in '~/Library/Group Containers/group.com.keyman/Library/Application Support' and is not empty + */ +- (BOOL)keyboardsExistInGroupContainerDirectory { + return [KMDataRepository isExistingNonEmptyDirectory: self.keyman19KeyboardsDirectory]; } - (NSString*)buildFullPath:(NSString *)fromPartialPath { - NSString *fullPath = [self.keymanKeyboardsDirectory.path stringByAppendingString:fromPartialPath]; + NSString *fullPath = [self.keyman19KeyboardsDirectory.path stringByAppendingString:fromPartialPath]; os_log_debug([KMLogs dataLog], "buildFullPath: '%{public}@' fromPartialPath '%{public}@'", fullPath, fromPartialPath); return fullPath; @@ -226,5 +266,102 @@ NSString *const kKeymanSubdirectoryName = @"keyman.inputmethod.Keyman"; return keyboardPartialPath; } +// MARK: Data migration + +/** + * Move all the packages contained in the directory + */ ++ (void)movePackages: (NSURL*)sourceDirectory to: (NSURL*)destinationDirectory { + NSFileManager *fileManager = [NSFileManager defaultManager]; + NSArray *keys = @[NSURLIsDirectoryKey]; + + // fetch the individual keyboard package directories within the keyboards directory + NSArray *packageDirectories = [fileManager contentsOfDirectoryAtURL:sourceDirectory includingPropertiesForKeys:keys options:NSDirectoryEnumerationSkipsHiddenFiles error:nil]; + + // Iterate and filter for directories + for (NSURL *packageUrl in packageDirectories) { + NSNumber *isDirectory; + [packageUrl getResourceValue:&isDirectory forKey:NSURLIsDirectoryKey error:nil]; + + if ([isDirectory boolValue]) { + NSString *packageName = packageUrl.lastPathComponent; + NSURL *packageSourceUrl = [sourceDirectory URLByAppendingPathComponent:packageName isDirectory: true]; + NSURL *packageDestinationUrl = [destinationDirectory URLByAppendingPathComponent:packageName isDirectory: true]; + + os_log_info([KMLogs dataLog], "moving package directory: '%{public}@'", packageName); + BOOL movedDirectory = [KMDataRepository moveDirectory: packageSourceUrl to: packageDestinationUrl]; + } + } +} + +/** + * Move a single directory + */ ++ (BOOL)moveDirectory: (NSURL*)sourceDirectory to: (NSURL*)destinationDirectory { + NSFileManager *fileManager = [NSFileManager defaultManager]; + NSError *moveError = nil; + BOOL didMoveData = [fileManager moveItemAtURL:sourceDirectory + toURL:destinationDirectory + error:&moveError]; + if (moveError) { + os_log_error([KMLogs dataLog], "data migration failed with error: '%{public}@', source: '%{public}@', destination: '%{public}@'", moveError.localizedDescription, sourceDirectory.path, destinationDirectory.path); + } else { + os_log_info([KMLogs dataLog], "data migrated successfully, source: '%{public}@' destination: '%{public}@'", sourceDirectory.path, destinationDirectory.path); + } + return didMoveData; +} + +/** + * Migrate the keyboards data from the old location in '~/Documents' to the shared location in + * '~/Library/Group Containers/group.com.keyman/Library/Application Support' + * This should only be called if the Keyman settings written to the UserDefaults indicates that we have data in the old location. + * If this is the case, then we expect the user to have already granted permission for Keyman to access the ~/Documents directory. + * If that permission has been removed for some reason, then calling this code will cause the user to be asked for permission again. + */ +- (void)migrateDataFromKeyman17 { + NSFileManager *fileManager = [NSFileManager defaultManager]; + BOOL dataExistsInOldLocation = [self keyboardsExistInDocumentsDirectory]; + os_log_debug([KMLogs dataLog], "keyman 17 keyboards directory exists: %@", dataExistsInOldLocation?@"YES":@"NO"); + + // only move data if there is something to move + if (dataExistsInOldLocation) { + [KMDataRepository movePackages:[self keyman17KeyboardsDirectory] to:[self keyman19KeyboardsDirectory]]; + + // delete the Keyman-Keyboards directory + NSError *error = nil; + [fileManager removeItemAtURL: self.keyman17KeyboardsDirectory error:&error]; + if (error == nil) { + os_log_debug([KMLogs dataLog], "deleted obsolete keyboards directory: '%{public}@'", [self keyman17KeyboardsDirectory]); + } else { + os_log_error([KMLogs dataLog], "error attempting to delete obsolete keyboards directory: '%{public}@'", [error localizedDescription]); + } + } +} + +/** + * Migrate the keyboards data from the input method specific location in '~/Application Support/keyman.inputmethod.Keyman/' + * to the shared location in '~/Library/Group Containers/group.com.keyman/Library/Application Support' + * This should only be called if the Keyman settings written to the UserDefaults indicates that we have data in the old location. + */ +- (void)migrateDataFromKeyman18 { + NSFileManager *fileManager = [NSFileManager defaultManager]; + BOOL dataExistsInOldLocation = [self keyboardsExistInInputMethodDataDirectory]; + os_log_debug([KMLogs dataLog], "keyman 18 keyboards directory exists: %{public}@", dataExistsInOldLocation?@"YES":@"NO"); + + // only move data if there is something to move + if (dataExistsInOldLocation) { + [KMDataRepository movePackages:[self keyman18KeyboardsDirectory] to:[self keyman19KeyboardsDirectory]]; + + // delete the Keyman-Keyboards directory + NSError *error = nil; + [fileManager removeItemAtURL: self.keyman18KeyboardsDirectory error:&error]; + + if (error == nil) { + os_log_debug([KMLogs dataLog], "deleted obsolete keyboards directory: '%{public}@'", [self keyman18KeyboardsDirectory]); + } else { + os_log_error([KMLogs dataLog], "error attempting to delete obsolete keyboards directory: '%{public}@'", [error localizedDescription]); + } + } +} @end diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMDownloadKeyboard/KMDownloadKBWindowController.h b/mac/Keyman4MacIM/Keyman4MacIM/KMDownloadKeyboard/KMDownloadKBWindowController.h deleted file mode 100644 index 6b82fad59d..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMDownloadKeyboard/KMDownloadKBWindowController.h +++ /dev/null @@ -1,14 +0,0 @@ -// -// KMDownloadKBWindowController.h -// Keyman4MacIM -// -// Created by Serkan Kurt on 18/05/2015. -// Copyright (c) 2017 SIL International. All rights reserved. -// - -#import -#import - -@interface KMDownloadKBWindowController : NSWindowController - -@end diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMDownloadKeyboard/KMDownloadKBWindowController.m b/mac/Keyman4MacIM/Keyman4MacIM/KMDownloadKeyboard/KMDownloadKBWindowController.m deleted file mode 100644 index 93b5b1eef9..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMDownloadKeyboard/KMDownloadKBWindowController.m +++ /dev/null @@ -1,101 +0,0 @@ -// -// KMDownloadKBWindowController.m -// Keyman4MacIM -// -// Created by Serkan Kurt on 18/05/2015. -// Copyright (c) 2018 SIL International. All rights reserved. -// - -#import "KMDownloadKBWindowController.h" -#import "KMInputMethodAppDelegate.h" -#import "KMLogs.h" - -@interface KMDownloadKBWindowController () -@property (nonatomic, weak) IBOutlet WebView *webView; -@end - -@implementation KMDownloadKBWindowController - -- (KMInputMethodAppDelegate *)AppDelegate { - return (KMInputMethodAppDelegate *)[NSApp delegate]; -} - -- (void)windowDidLoad { - [super windowDidLoad]; - - [self.webView setFrameLoadDelegate:(id)self]; - [self.webView setGroupName:@"KMDownloadKB"]; - [self.webView setPolicyDelegate:(id)self]; - - NSString *version = [[[NSBundle mainBundle] infoDictionary] objectForKey:@"CFBundleShortVersionString"]; - KeymanVersionInfo keymanVersionInfo = [[self AppDelegate] versionInfo]; - NSString *url = [NSString stringWithFormat:@"https://%@/go/macos/14.0/download-keyboards/?version=%@", keymanVersionInfo.keymanCom, version]; - - os_log_debug([KMLogs uiLog], "KMDownloadKBWindowController opening url = %@, version = '%@'", url, version); - [self.webView.mainFrame loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:url]]]; -} - -- (void)webView:(WebView *)sender decidePolicyForNewWindowAction:(NSDictionary *)actionInformation request:(NSURLRequest *)request newFrameName:(NSString *)frameName decisionListener:(id)listener { - [[NSWorkspace sharedWorkspace] openURL:[actionInformation objectForKey:WebActionOriginalURLKey]]; - [listener ignore]; -} - -- (void)webView:(WebView *)webView decidePolicyForNavigationAction:(NSDictionary *)actionInformation request:(NSURLRequest *)request frame:(WebFrame *)frame decisionListener:(id)listener { - NSString* url = [[request URL] absoluteString]; - os_log_debug([KMLogs uiLog], "decidePolicyForNavigationAction, navigating to %@", url); - - // The pattern for matching links matches work in #3602 - NSString* urlPathMatchKeyboardsInstall = @"^http(?:s)?://keyman(?:-staging)?\\.com(?:\\.localhost)?/keyboards/install/([^?/]+)(?:\\?(.+))?$"; - // e.g. https://keyman.com/keyboards/install/foo - NSString* urlPathMatchKeyboardsRoot = @"^http(?:s)?://keyman(?:-staging)?\\.com(?:\\.localhost)?/keyboards([/?].*)?$"; - // http://keyman.com.localhost/keyboards/foo - NSString* urlPathMatchKeyboardsGo = @"^http(?:s)?://keyman(?:-staging)?\\.com(?:\\.localhost)?/go/macos/[^/]+/download-keyboards"; - // https://keyman-staging.com/go/macos/14.0/download-keyboards?version=14.0.146.0 - NSRange range = NSMakeRange(0, url.length); - - NSError* error; - NSRegularExpression* regexInstall = [NSRegularExpression regularExpressionWithPattern: urlPathMatchKeyboardsInstall options: 0 error: &error]; - NSRegularExpression* regexRoot = [NSRegularExpression regularExpressionWithPattern: urlPathMatchKeyboardsRoot options: 0 error: &error]; - NSRegularExpression* regexGo = [NSRegularExpression regularExpressionWithPattern: urlPathMatchKeyboardsGo options: 0 error: &error]; - - NSArray* matchesInstall = [regexInstall matchesInString:url options:0 range:range]; - - if(matchesInstall.count > 0) { - os_log_debug([KMLogs uiLog], "Delegating download to app delegate."); - [listener ignore]; - NSTextCheckingResult* match = (NSTextCheckingResult*) matchesInstall[0]; - NSString* matchKeyboardId = [url substringWithRange:[match rangeAtIndex:1]]; - // Install keyboard link - [self.AppDelegate downloadKeyboardFromKeyboardId:matchKeyboardId]; - //[self.AppDelegate processURL:url]; - } - else if([regexRoot numberOfMatchesInString:url options:0 range:range] > 0 || - [regexGo numberOfMatchesInString:url options:0 range:range] > 0) { - // allow https://keyman.com/keyboards* to go through - // allow https://keyman.com/go/macos/download-keyboards to go through - os_log_debug([KMLogs uiLog], "Accepting link in this browser."); - [listener use]; - } - else if([url startsWith:@"keyman:"]) { - if ([url startsWith:@"keyman:link?url="]) - { - os_log_debug([KMLogs uiLog], "Opening keyman:link URL in default browser: %@", url); - [listener ignore]; - url = [request.URL.absoluteString substringFromIndex:[@"keyman:link?url=" length]]; - [[NSWorkspace sharedWorkspace] openURL: [[NSURL alloc] initWithString:url]]; - } - else { - os_log_debug([KMLogs uiLog], "Delegating download to app delegate."); - [listener ignore]; - [self.AppDelegate processURL:url]; - } - } - else - { - // Open in external browser - os_log_debug([KMLogs uiLog], "Opening URL in default browser: %@", url); - [listener ignore]; - [[NSWorkspace sharedWorkspace] openURL: [[NSURL alloc] initWithString:url]]; - } -} -@end diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMDownloadKeyboard/KMDownloadKBWindowController.xib b/mac/Keyman4MacIM/Keyman4MacIM/KMDownloadKeyboard/KMDownloadKBWindowController.xib deleted file mode 100644 index 5377f29425..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMDownloadKeyboard/KMDownloadKBWindowController.xib +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/Base.lproj/KMInfoWindowController.xib b/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/Base.lproj/KMInfoWindowController.xib deleted file mode 100644 index ed3e50a4cb..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/Base.lproj/KMInfoWindowController.xib +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/KMInfoWindowController.h b/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/KMInfoWindowController.h deleted file mode 100644 index ea89b0cb19..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/KMInfoWindowController.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// KMInfoWindowController.h -// Keyman4MacIM -// -// Created by Serkan Kurt on 9/07/2015. -// Copyright (c) 2017 SIL International. All rights reserved. -// - -#import - -@interface KMInfoWindowController : NSWindowController - -@property (nonatomic, strong) NSString *packagePath; - -@end diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/KMInfoWindowController.m b/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/KMInfoWindowController.m deleted file mode 100644 index 32a8377b64..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/KMInfoWindowController.m +++ /dev/null @@ -1,227 +0,0 @@ -// -// KMInfoWindowController.m -// Keyman4MacIM -// -// Created by Serkan Kurt on 9/07/2015. -// Copyright (c) 2017 SIL International. All rights reserved. -// - -#import "KMInfoWindowController.h" -#import "KMInputMethodAppDelegate.h" -#import "KMPackageInfo.h" -#import "KMKeyboardInfo.h" -#import "KMLogs.h" -#import - -@interface KMInfoWindowController () -@property (nonatomic, weak) IBOutlet NSImageView *imageView; -@property (nonatomic, weak) IBOutlet NSTabView *tabView; -@property (nonatomic, weak) IBOutlet WebView *detailsView; -@property (nonatomic, weak) IBOutlet WebView *readmeView; -@property (nonatomic, strong) KMPackageInfo *packageInfo; -@property (nonatomic, strong) NSTabViewItem *readMeTab; -@end - -@implementation KMInfoWindowController - -- (KMInputMethodAppDelegate *)AppDelegate { - return (KMInputMethodAppDelegate *)[NSApp delegate]; -} - -- (void)windowDidLoad { - [super windowDidLoad]; - - [self.tabView setDelegate:self]; - [self.detailsView setFrameLoadDelegate:(id)self]; - [self.detailsView setPolicyDelegate:(id)self]; - [self.readmeView setFrameLoadDelegate:(id)self]; - [self.readmeView setPolicyDelegate:(id)self]; -} - -// TODO: rename, refactor, reset PackageInfo directly rather than making getter do it? -- (void)setPackagePath:(NSString *)packagePath { - _packagePath = packagePath; - _packageInfo = nil; - [self.tabView selectTabViewItemAtIndex:0]; - if (packagePath != nil && packagePath.length) { - NSString *imgName = self.packageInfo.graphicFilename; - if (imgName != nil) { - NSString *imgFile = [packagePath stringByAppendingPathComponent:imgName]; - NSImage *img = [[NSImage alloc] initWithContentsOfFile:imgFile]; - [self.imageView setImage:img]; - } - else { - [self.imageView setImage:[NSImage imageNamed:@"SideImage.bmp"]]; - } - - NSString *detailsHtml = [self detailsHtml]; - if (detailsHtml != nil) - [self.detailsView.mainFrame loadHTMLString:detailsHtml baseURL:[[NSBundle mainBundle] resourceURL]]; - else - [self.detailsView.mainFrame loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"about:blank"]]]; - - NSString *readmeFilename = self.packageInfo.readmeFilename; - if (readmeFilename != nil) { - [self.readmeView.mainFrame loadRequest:[NSURLRequest requestWithURL:[NSURL fileURLWithPath:[packagePath stringByAppendingPathComponent:readmeFilename]]]]; - if (_readMeTab != nil && ![self.tabView.tabViewItems containsObject:_readMeTab]) { - [self.tabView addTabViewItem:_readMeTab]; - _readMeTab = nil; - } - } - else { - [self.readmeView.mainFrame loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"about:blank"]]]; - if ([self.tabView.tabViewItems count] > 1) { - _readMeTab = [self.tabView tabViewItemAtIndex:1]; - [self.tabView removeTabViewItem:_readMeTab]; - } - } - } -} - -- (BOOL)tabView:(NSTabView *)tabView shouldSelectTabViewItem:(NSTabViewItem *)tabViewItem { - return YES; -} - -// TODO: refactor: any reason for this to be HTML? hard to read stringWithFormat applied to template with 16 arguments -- (NSString *)detailsHtml { - - @try { - NSString *htmlFormat = - @"" - "" - "" - "Keyman" - "" - "" - "" - "" - "

%@

" - "
" - "
" - "
" - "" - "
Scan this code to load this keyboard on another device or " - "share online
" - "
" - "

%@


" - "%@" - "
" - "

%@


" - "%@" - "
" - "

%@

%@


" - "

%@

%@


" - "

%@

%@


" - "

%@

%@

" - "
" - "" - ""; - - NSString *pBodyFormat = @"

%@


"; - NSMutableString *keyboardString = [NSMutableString stringWithString:@""]; - - if (self.packageInfo.keyboards.count) { - for (KMKeyboardInfo *keyboard in self.packageInfo.keyboards) { - [keyboardString appendString:[NSString stringWithFormat:pBodyFormat, keyboard.name]]; - } - } else { - keyboardString = [NSMutableString stringWithString:@"


"]; - } - - NSArray *fonts = self.packageInfo.fonts; - NSMutableString *fontsStr = [NSMutableString stringWithString:@""]; - - if (fonts != nil && fonts.count) { - for (NSString *font in fonts) { - [fontsStr appendString:[NSString stringWithFormat:pBodyFormat, font]]; - } - } - else { - fontsStr = [NSMutableString stringWithString:@"


"]; - } - - NSString *linkFormat = @"%@"; - NSString *packageId = [self.packagePath lastPathComponent]; - - KeymanVersionInfo keymanVersionInfo = [[self AppDelegate] versionInfo]; - NSString *shareUrl = [NSString stringWithFormat:@"https://%@/go/keyboard/%@/share", keymanVersionInfo.keymanCom, packageId]; - - NSString *name = self.packageInfo.packageName; - if (name == nil) - name = @"Unknown Keyboard Package"; - - NSString *version = self.packageInfo.packageVersion; - if (version == nil) - version = @""; - - NSString *authorV1 = self.packageInfo.authorName; - NSString *authorV2 = self.packageInfo.authorUrl; - NSString *author = @""; - if (authorV1.length && authorV2.length) - author = [NSString stringWithFormat:linkFormat, authorV2, authorV1]; - else if (authorV1.length) - author = authorV1; - else if (authorV2.length) - author = [NSString stringWithFormat:linkFormat, authorV2, authorV2]; - - NSString *website = @""; - if (self.packageInfo.website.length) - website = [NSString stringWithFormat:linkFormat, self.packageInfo.website, self.packageInfo.website]; - - NSString *copyright = @""; - if (self.packageInfo.copyright.length) - copyright = self.packageInfo.copyright; - - // get localized label strings - NSString *keyboardsLabel = NSLocalizedString(@"keyboards-label", nil); - NSString *fontsLabel = NSLocalizedString(@"fonts-label", nil); - NSString *packageVersionLabel = NSLocalizedString(@"package-version-label", nil); - NSString *authorLabel = NSLocalizedString(@"author-label", nil); - NSString *websiteLabel = NSLocalizedString(@"website-label", nil); - NSString *copyrightLabel = NSLocalizedString(@"copyright-label", nil); - - - NSString *htmlStr = [NSString stringWithFormat:htmlFormat, name, shareUrl, shareUrl, - keyboardsLabel, keyboardString, fontsLabel, fontsStr, - packageVersionLabel, version, authorLabel, author, - websiteLabel, website, copyrightLabel, copyright]; - - return htmlStr; - } - @catch (NSException *e) { - os_log_error([KMLogs uiLog], "Error = %@", e.description); - return nil; - } -} - -- (KMPackageInfo *)packageInfo { - if(_packageInfo == nil) { - _packageInfo = [self.AppDelegate loadPackageInfo:self.packagePath]; - } - - return _packageInfo; -} - -- (void)webView:(WebView *)webView decidePolicyForNavigationAction:(NSDictionary *)actionInformation request:(NSURLRequest *)request frame:(WebFrame *)frame decisionListener:(id)listener { - NSNumber *navType = [actionInformation objectForKey:WebActionNavigationTypeKey]; - if (navType.integerValue == WebNavigationTypeLinkClicked && ![request.URL isFileURL]) { - NSString *urlStr = [[request URL] absoluteString]; - if ([urlStr startsWith:@"link:"]) - urlStr = [urlStr substringFromIndex:5]; - - [[NSWorkspace sharedWorkspace] openURL:[NSURL URLWithString:urlStr]]; - } - else - [listener use]; -} - -@end diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/am.lproj/KMInfoWindowController.strings b/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/am.lproj/KMInfoWindowController.strings deleted file mode 100644 index deaa6e1bcc..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/am.lproj/KMInfoWindowController.strings +++ /dev/null @@ -1,8 +0,0 @@ -/* Package Information window title */ -"F0z-JX-Cv5.title" = "የፓኬጅ መረጃዎች"; - -/* button text to show Details */ -"Fvy-XJ-s38.label" = "ዝርዝሮች"; - -/* button text to show Read Me */ -"waA-IW-Qyn.label" = "የሚነበብ"; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/cs.lproj/KMInfoWindowController.strings b/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/cs.lproj/KMInfoWindowController.strings deleted file mode 100644 index 4c9f26c0f8..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/cs.lproj/KMInfoWindowController.strings +++ /dev/null @@ -1,8 +0,0 @@ -/* Package Information window title */ -"F0z-JX-Cv5.title" = "Informace o balíčku"; - -/* button text to show Details */ -"Fvy-XJ-s38.label" = "Detaily"; - -/* button text to show Read Me */ -"waA-IW-Qyn.label" = "Číst mě"; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/el.lproj/KMInfoWindowController.strings b/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/el.lproj/KMInfoWindowController.strings deleted file mode 100644 index af78cc7f5f..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/el.lproj/KMInfoWindowController.strings +++ /dev/null @@ -1,8 +0,0 @@ -/* Package Information window title */ -"F0z-JX-Cv5.title" = "Πληροφορίες πακέτου"; - -/* button text to show Details */ -"Fvy-XJ-s38.label" = "Λεπτομέρειες"; - -/* button text to show Read Me */ -"waA-IW-Qyn.label" = "Read Me"; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/en.lproj/KMInfoWindowController.strings b/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/en.lproj/KMInfoWindowController.strings deleted file mode 100644 index 98bfd7086a..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/en.lproj/KMInfoWindowController.strings +++ /dev/null @@ -1,9 +0,0 @@ - -/* Package Information window title */ -"F0z-JX-Cv5.title" = "Package Information"; - -/* button text to show Details */ -"Fvy-XJ-s38.label" = "Details"; - -/* button text to show Read Me */ -"waA-IW-Qyn.label" = "Read Me"; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/es-419.lproj/KMInfoWindowController.strings b/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/es-419.lproj/KMInfoWindowController.strings deleted file mode 100644 index 73e939c51e..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/es-419.lproj/KMInfoWindowController.strings +++ /dev/null @@ -1,8 +0,0 @@ -/* Package Information window title */ -"F0z-JX-Cv5.title" = "Información del paquete"; - -/* button text to show Details */ -"Fvy-XJ-s38.label" = "Detalles"; - -/* button text to show Read Me */ -"waA-IW-Qyn.label" = "Léame"; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/es.lproj/KMInfoWindowController.strings b/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/es.lproj/KMInfoWindowController.strings deleted file mode 100644 index d300224130..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/es.lproj/KMInfoWindowController.strings +++ /dev/null @@ -1,8 +0,0 @@ -/* Package Information window title */ -"F0z-JX-Cv5.title" = "Información del Paquete"; - -/* button text to show Details */ -"Fvy-XJ-s38.label" = "Detalles"; - -/* button text to show Read Me */ -"waA-IW-Qyn.label" = "Léame"; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/ff-ng.lproj/KMInfoWindowController.strings b/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/ff-ng.lproj/KMInfoWindowController.strings deleted file mode 100644 index 84fb8acc10..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/ff-ng.lproj/KMInfoWindowController.strings +++ /dev/null @@ -1,8 +0,0 @@ -/* Package Information window title */ -"F0z-JX-Cv5.title" = "Humpito Haɓɓere"; - -/* button text to show Details */ -"Fvy-XJ-s38.label" = "Bayanuji gootel gootel"; - -/* button text to show Read Me */ -"waA-IW-Qyn.label" = "Rid Mi"; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/ff.lproj/KMInfoWindowController.strings b/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/ff.lproj/KMInfoWindowController.strings deleted file mode 100644 index e269d2f129..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/ff.lproj/KMInfoWindowController.strings +++ /dev/null @@ -1,8 +0,0 @@ -/* Package Information window title */ -"F0z-JX-Cv5.title" = "Humpito Haɓɓere"; - -/* button text to show Details */ -"Fvy-XJ-s38.label" = "Cariiɗe"; - -/* button text to show Read Me */ -"waA-IW-Qyn.label" = "Tar Ɗuum"; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/ha.lproj/KMInfoWindowController.strings b/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/ha.lproj/KMInfoWindowController.strings deleted file mode 100644 index 97b0e4b4a9..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/ha.lproj/KMInfoWindowController.strings +++ /dev/null @@ -1,8 +0,0 @@ -/* Package Information window title */ -"F0z-JX-Cv5.title" = "Package Information"; - -/* button text to show Details */ -"Fvy-XJ-s38.label" = "Cikakken bayani"; - -/* button text to show Read Me */ -"waA-IW-Qyn.label" = "Karanta ni"; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/it.lproj/KMInfoWindowController.strings b/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/it.lproj/KMInfoWindowController.strings deleted file mode 100644 index 3d2755066c..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/it.lproj/KMInfoWindowController.strings +++ /dev/null @@ -1,8 +0,0 @@ -/* Package Information window title */ -"F0z-JX-Cv5.title" = "Informazioni pacchetto"; - -/* button text to show Details */ -"Fvy-XJ-s38.label" = "Dettagli"; - -/* button text to show Read Me */ -"waA-IW-Qyn.label" = "Leggermi"; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/km.lproj/KMInfoWindowController.strings b/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/km.lproj/KMInfoWindowController.strings deleted file mode 100644 index edd32b1829..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/km.lproj/KMInfoWindowController.strings +++ /dev/null @@ -1,8 +0,0 @@ -/* Package Information window title */ -"F0z-JX-Cv5.title" = "ព័ត៌មានកញ្ចប់"; - -/* button text to show Details */ -"Fvy-XJ-s38.label" = "លម្អិត"; - -/* button text to show Read Me */ -"waA-IW-Qyn.label" = "អានខ្ញុំ"; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/kn.lproj/KMInfoWindowController.strings b/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/kn.lproj/KMInfoWindowController.strings deleted file mode 100644 index b453ae2e7f..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/kn.lproj/KMInfoWindowController.strings +++ /dev/null @@ -1,8 +0,0 @@ -/* Package Information window title */ -"F0z-JX-Cv5.title" = "ಪ್ಯಾಕೇಜ್ ಮಾಹಿತಿ"; - -/* button text to show Details */ -"Fvy-XJ-s38.label" = "ವಿವರಗಳು"; - -/* button text to show Read Me */ -"waA-IW-Qyn.label" = "ಈ ಸಂದೇಶವನ್ನು ಓದಿ"; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/nl.lproj/KMInfoWindowController.strings b/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/nl.lproj/KMInfoWindowController.strings deleted file mode 100644 index e728781fa1..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/nl.lproj/KMInfoWindowController.strings +++ /dev/null @@ -1,8 +0,0 @@ -/* Package Information window title */ -"F0z-JX-Cv5.title" = "Package Information"; - -/* button text to show Details */ -"Fvy-XJ-s38.label" = "Beschrijving"; - -/* button text to show Read Me */ -"waA-IW-Qyn.label" = "Lees mij"; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/pl.lproj/KMInfoWindowController.strings b/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/pl.lproj/KMInfoWindowController.strings deleted file mode 100644 index 2cb2b1f075..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/pl.lproj/KMInfoWindowController.strings +++ /dev/null @@ -1,8 +0,0 @@ -/* Package Information window title */ -"F0z-JX-Cv5.title" = "Package Information"; - -/* button text to show Details */ -"Fvy-XJ-s38.label" = "Szczegóły"; - -/* button text to show Read Me */ -"waA-IW-Qyn.label" = "Przeczytaj mnie"; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/pt-PT.lproj/KMInfoWindowController.strings b/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/pt-PT.lproj/KMInfoWindowController.strings deleted file mode 100644 index 65f53f48d5..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/pt-PT.lproj/KMInfoWindowController.strings +++ /dev/null @@ -1,8 +0,0 @@ -/* Package Information window title */ -"F0z-JX-Cv5.title" = "Informação de pacote"; - -/* button text to show Details */ -"Fvy-XJ-s38.label" = "Detalhes"; - -/* button text to show Read Me */ -"waA-IW-Qyn.label" = "Me Ler"; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/ru.lproj/KMInfoWindowController.strings b/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/ru.lproj/KMInfoWindowController.strings deleted file mode 100644 index d466f39f8d..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/ru.lproj/KMInfoWindowController.strings +++ /dev/null @@ -1,8 +0,0 @@ -/* Package Information window title */ -"F0z-JX-Cv5.title" = "Package Information"; - -/* button text to show Details */ -"Fvy-XJ-s38.label" = "Подробности"; - -/* button text to show Read Me */ -"waA-IW-Qyn.label" = "Читать меня"; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/sv.lproj/KMInfoWindowController.strings b/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/sv.lproj/KMInfoWindowController.strings deleted file mode 100644 index 296a10433a..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/sv.lproj/KMInfoWindowController.strings +++ /dev/null @@ -1,8 +0,0 @@ -/* Package Information window title */ -"F0z-JX-Cv5.title" = "Package Information"; - -/* button text to show Details */ -"Fvy-XJ-s38.label" = "Detaljer"; - -/* button text to show Read Me */ -"waA-IW-Qyn.label" = "Läs mig"; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/uk.lproj/KMInfoWindowController.strings b/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/uk.lproj/KMInfoWindowController.strings deleted file mode 100644 index e887a8a345..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/uk.lproj/KMInfoWindowController.strings +++ /dev/null @@ -1,8 +0,0 @@ -/* Package Information window title */ -"F0z-JX-Cv5.title" = "Package Information"; - -/* button text to show Details */ -"Fvy-XJ-s38.label" = "Подробиці"; - -/* button text to show Read Me */ -"waA-IW-Qyn.label" = "Читати мене"; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/vi.lproj/KMInfoWindowController.strings b/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/vi.lproj/KMInfoWindowController.strings deleted file mode 100644 index 1a9024deec..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/vi.lproj/KMInfoWindowController.strings +++ /dev/null @@ -1,8 +0,0 @@ -/* Package Information window title */ -"F0z-JX-Cv5.title" = "Thông tin gói"; - -/* button text to show Details */ -"Fvy-XJ-s38.label" = "Chi tiết"; - -/* button text to show Read Me */ -"waA-IW-Qyn.label" = "Đọc tôi"; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/zh-Hans.lproj/KMInfoWindowController.strings b/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/zh-Hans.lproj/KMInfoWindowController.strings deleted file mode 100644 index 6e47b1fd05..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInfoWindow/zh-Hans.lproj/KMInfoWindowController.strings +++ /dev/null @@ -1,8 +0,0 @@ -/* Package Information window title */ -"F0z-JX-Cv5.title" = "软件包信息"; - -/* button text to show Details */ -"Fvy-XJ-s38.label" = "详细信息"; - -/* button text to show Read Me */ -"waA-IW-Qyn.label" = "说明"; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInputController.m b/mac/Keyman4MacIM/Keyman4MacIM/KMInputController.m index c628311b9f..ba5b89d822 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInputController.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMInputController.m @@ -146,12 +146,8 @@ KMInputMethodEventHandler* _eventHandler; - (void)menuAction:(id)sender { NSMenuItem *mItem = [sender objectForKey:kIMKCommandMenuItemName]; NSInteger itag = mItem.tag; - os_log_debug([KMLogs uiLog], "Keyman menu clicked - tag: %lu", itag); - if (itag == CONFIG_MENUITEM_TAG) { - [KMSentryHelper addUserBreadCrumb:@"menu" message:@"Configuration..."]; - [self showConfigurationWindow:sender]; - } - else if (itag == OSK_MENUITEM_TAG) { + os_log_debug([KMLogs uiLog], "Keyman menu item selected - tag: %lu", itag); + if (itag == OSK_MENUITEM_TAG) { [KMSentryHelper addUserBreadCrumb:@"menu" message:@"On-screen Keyboard"]; [KMSettingsRepository.shared writeShowOskOnActivate:YES]; os_log_debug([KMLogs oskLog], "menuAction OSK_MENUITEM_TAG, updating settings writeShowOsk to YES"); @@ -167,21 +163,4 @@ KMInputMethodEventHandler* _eventHandler; } } -- (void)showConfigurationWindow:(id)sender { - // Using `showConfigurationWindow` instead of `showPreferences:` because `showPreferences:` is missing in - // High Sierra (10.13.1 - 10.13.3). See: https://bugreport.apple.com/web/?problemID=35422518 - // rrb: where Apple's API is broken (10.13.1-10.13.3) call our workaround, otherwise, call showPreferences - u_int16_t systemVersion = [KMOSVersion SystemVersion]; - if ([KMOSVersion Version_10_13_1] <= systemVersion && systemVersion <= [KMOSVersion Version_10_13_3]) // between 10.13.1 and 10.13.3 inclusive - { - os_log_info([KMLogs uiLog], "Input Menu: calling workaround instead of showPreferences (sys ver %x)", systemVersion); - [self.appDelegate showConfigurationWindow]; // call our workaround - } - else - { - os_log_info([KMLogs uiLog], "Input Menu: calling Apple's showPreferences (sys ver %x)", systemVersion); - [self showPreferences:sender]; // call Apple API - } -} - @end diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodAppDelegate.h b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodAppDelegate.h index eade3d6d2f..9590418ae5 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodAppDelegate.h +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodAppDelegate.h @@ -16,7 +16,6 @@ #import "KMPackageReader.h" #import "KMInputController.h" #import "KMAboutWindowController.h" -#import "KMInfoWindowController.h" #import "KMKeyboardHelpWindowController.h" #import "OSKWindowController.h" #import "NSWindow+SuppMethods.h" @@ -24,8 +23,6 @@ typedef void(^PostEventCallback)(CGEventRef eventToPost); -extern NSString *const kKeymanKeyboardDownloadCompletedNotification; - typedef struct { NSString *sentryEnvironment; NSString *versionRelease; @@ -38,13 +35,12 @@ typedef struct { } KeymanVersionInfo; // tags for default menu items, displayed whether keyboards are active or not -static const int DIVIDER_MENUITEM_TAG = -4; -static const int CONFIG_MENUITEM_TAG = -3; +static const int DIVIDER_MENUITEM_TAG = -3; static const int OSK_MENUITEM_TAG = -2; static const int ABOUT_MENUITEM_TAG = -1; // the number of menu items that do not represent active keyboards -static const int DEFAULT_KEYMAN_MENU_ITEM_COUNT = 4; +static const int DEFAULT_KEYMAN_MENU_ITEM_COUNT = 3; // the tag for the first keyboard dynamically added to the menu static const int KEYMAN_FIRST_KEYBOARD_MENUITEM_TAG = 0; @@ -71,9 +67,7 @@ static const int KEYMAN_FIRST_KEYBOARD_MENUITEM_INDEX = 0; @property (nonatomic, strong) NSImage *keyboardIcon; // TODO: refactor above properties @property (nonatomic, strong) NSString *keyboardsPath; -@property (nonatomic, strong) NSString *fontsPath; -@property (nonatomic, strong) NSMutableArray *kmxFileList; -@property (nonatomic, strong) NSMutableArray *activeKeyboards; +@property (nonatomic, strong) NSMutableArray *enabledKeyboards; @property (assign) int numberOfKeyboardMenuItems; @property (nonatomic, strong) NSMutableString *contextBuffer; @property (nonatomic, assign) NSEventModifierFlags currentModifiers; @@ -81,49 +75,29 @@ static const int KEYMAN_FIRST_KEYBOARD_MENUITEM_INDEX = 0; @property (nonatomic, assign) CFRunLoopSourceRef runLoopEventSrc; @property (nonatomic, assign) BOOL contextChangedByLowLevelEvent; @property (nonatomic, strong) OSKWindowController *oskWindow; -@property (nonatomic, strong) NSAlert *downloadInfoView; -@property (nonatomic, strong) NSProgressIndicator *progressIndicator; @property (nonatomic, weak) KMInputController *inputController; -@property (nonatomic, strong) NSWindowController *configWindow; -@property (nonatomic, strong) NSWindowController *downloadKBWindow; @property (nonatomic, strong) KMAboutWindowController *aboutWindow; -@property (nonatomic, strong) KMInfoWindowController *infoWindow; @property (nonatomic, strong) KMKeyboardHelpWindowController *kbHelpWindow; -@property (nonatomic, strong) NSURLConnection *connection; -@property (nonatomic, strong) NSString *downloadFilename; -@property (nonatomic, strong) NSMutableData *receivedData; -@property (nonatomic, assign) NSUInteger expectedBytes; - (NSMenu *)menu; -- (void)saveActiveKeyboards; +- (void)saveEnabledKeyboards; - (void)applyPersistedOptions; - (void)showAboutWindow; - (void)showOSK; -- (void)showConfigurationWindow; - (void)selectKeyboardFromMenu:(NSInteger)tag; - (void)handleKeyEvent:(NSEvent *)event; - (void)loadKeyboardFromKmxFile:(KMXFile *)kmx; - (void)resetKmx; - (NSEventModifierFlags) determineModifiers; -- (BOOL)unzipFile:(NSString *)filePath; -- (NSWindowController *)downloadKBWindow_; - (NSWindowController *)aboutWindow_; -- (NSWindowController *)infoWindow_; - (NSWindowController *)kbHelpWindow_; -- (void)processURL:(NSString*)rawUrl; -- (void)downloadKeyboardFromKeyboardId:(NSString *)keyboardId; -- (NSString *)kmxFilePathAtIndex:(NSUInteger)index; -- (NSString *)packagePathAtIndex:(NSUInteger)index; -- (NSInteger)indexForPackageFolder:(NSString *)packageFolder; - (NSString *)packageFolderFromPath:(NSString *)path; - (KMPackageInfo *)loadPackageInfo:(NSString *)path; -- (NSString *)packageNameFromPackageInfo:(NSString *)packageFolder; - (NSArray *)keyboardNamesFromFolder:(NSString *)packageFolder; - (NSString *)kvkFilePathFromFilename:(NSString *)kvkFilename; - (NSString *)oskWindowTitle; - (void)postKeyboardEventWithSource: (CGEventSourceRef)source code:(CGKeyCode) virtualKey postCallback:(PostEventCallback)postEvent; - (KeymanVersionInfo)versionInfo; -- (void)registerConfigurationWindow:(NSWindowController *)window; - (BOOL)canForceSentryEvents; @end diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodAppDelegate.m b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodAppDelegate.m index afd1ce9f73..878033e6b7 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodAppDelegate.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodAppDelegate.m @@ -10,8 +10,6 @@ #import "KMInputMethodLifecycle.h" #import "KMSettingsRepository.h" #import "KMDataRepository.h" -#import "KMConfigurationWindowController.h" -#import "KMDownloadKBWindowController.h" #import "ZipArchive.h" #import "KMPackageReader.h" #import "KMPackageInfo.h" @@ -28,7 +26,10 @@ NSString *processorType = @"Intel"; NSString *processorType = @"Unknown"; #endif -NSString *const kKeymanKeyboardDownloadCompletedNotification = @"kKeymanKeyboardDownloadCompletedNotification"; +// distributed notifications +NSString *const kKeyboardsChanged = @"com.keyman.keyboards.changed"; +NSString *const kAccessibilityCheckedRequest = @"com.keyman.accessibility.check.request"; +NSString *const kAccessibilityRequest = @"com.keyman.accessibility.request"; @implementation NSString (VersionNumbers) /** @@ -67,16 +68,18 @@ NSString *const kKeymanKeyboardDownloadCompletedNotification = @"kKeymanKeyboard @synthesize kvk = _kvk; @synthesize keyboardName = _keyboardName; @synthesize keyboardsPath = _keyboardsPath; -@synthesize kmxFileList = _kmxFileList; +//@synthesize kmxFileList = _kmxFileList; @synthesize selectedKeyboard = _selectedKeyboard; -@synthesize activeKeyboards = _activeKeyboards; +@synthesize enabledKeyboards = _enabledKeyboards; @synthesize contextBuffer = _contextBuffer; id _lastServerWithOSKShowing = nil; - (id)init { self = [super init]; - if (self) { + if (self) { + [self registerObservers]; + // first notify user and request access to Accessibility/PostEvent permissions // pass block as completion handler to complete init with initCompletion [PrivacyConsent.shared requestPrivacyAccess:^void (void){ @@ -89,10 +92,6 @@ id _lastServerWithOSKShowing = nil; - (void)initCompletion { os_log_info([KMLogs startupLog], "initCompletionHandler method invoked"); - [[NSAppleEventManager sharedAppleEventManager] setEventHandler:self - andSelector:@selector(handleURLEvent:withReplyEvent:) - forEventClass:kInternetEventClass - andEventID:kAEGetURL]; self.lowLevelEventTap = CGEventTapCreate(kCGAnnotatedSessionEventTap, kCGHeadInsertEventTap, @@ -119,14 +118,48 @@ id _lastServerWithOSKShowing = nil; if (self.runLoopEventSrc && runLoop) { CFRunLoopAddSource(runLoop, self.runLoopEventSrc, kCFRunLoopDefaultMode); } - + + // start Input Method lifecycle + [KMInputMethodLifecycle.shared startLifecycle]; +} + +/** + * Register observers for local and distributed notifications + */ +- (void)registerObservers { // register to receive notifications generated from KMInputMethodLifecycle [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(inputMethodActivated:) name:kInputMethodActivatedNotification object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(inputMethodDeactivated:) name:kInputMethodDeactivatedNotification object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(inputMethodChangedClient:) name:kInputMethodClientChangeNotification object:nil]; - // start Input Method lifecycle - [KMInputMethodLifecycle.shared startLifecycle]; + // register to receive notifications generated from Keyman Configuration App + [[NSDistributedNotificationCenter defaultCenter] addObserver:self selector:@selector(handleKeyboardsChanged:) name:kKeyboardsChanged object:nil]; + [[NSDistributedNotificationCenter defaultCenter] addObserver:self selector:@selector(handleAccessibilityCheckRequested:) name:kAccessibilityCheckedRequest object:nil]; + [[NSDistributedNotificationCenter defaultCenter] addObserver:self selector:@selector(handleAccessibilityRequested:) name:kAccessibilityRequest object:nil]; +} + +/** + * When packages have been installed, removed, enabled or disabled -- notification from the Keyman Configuration app + */ +- (void)handleKeyboardsChanged:(NSNotification *)notification { + os_log_debug([KMLogs configLog], "***KMInputMethodAppDelegate handleKeyboardsChanged"); + [self reloadEnabledKeyboards]; +} + +/** + * When the state of Accessibility permission has been requested -- notification from the Keyman Configuration app + */ +- (void)handleAccessibilityCheckRequested:(NSNotification *)notification { + BOOL hasAccess = checkAccessibility(); + os_log_info([KMLogs startupLog], "KMInputMethodAppDelegate handleAccessibilityCheckRequested, hasAccess: %{public}@", hasAccess?@"YES":@"NO"); +} + +/** + * When Accessibility permission has been requested -- notification from the Keyman Configuration app + */ +- (void)handleAccessibilityRequested:(NSNotification *)notification { + requestAccessibility(); + os_log_info([KMLogs startupLog], "KMInputMethodAppDelegate handleAccessibilityRequested"); } /** @@ -266,38 +299,6 @@ id _lastServerWithOSKShowing = nil; [KMSentryHelper addArchitectureTag:processorType]; } -- (void)handleURLEvent:(NSAppleEventDescriptor*)event withReplyEvent:(NSAppleEventDescriptor*)replyEvent { - - [self processURL:[[event paramDescriptorForKeyword:keyDirectObject] stringValue]]; -} - -- (void)processURL:(NSString*)rawUrl { - NSMutableString *urlStr = [NSMutableString stringWithString:rawUrl]; - [urlStr replaceOccurrencesOfString:@"keyman:" withString:@"keyman/" options:0 range:NSMakeRange(0, 7)]; - NSURL *url = [NSURL URLWithString:urlStr]; - os_log_debug([KMLogs keyboardLog], "processURL, url = %{public}@", url); - - if ([url.lastPathComponent isEqualToString:@"download"]) { - if (_connection != nil) { - os_log_debug([KMLogs keyboardLog], "Already downloading a keyboard."); - return; - } - - NSURL *downloadUrl; - NSArray *params = [[url query] componentsSeparatedByString:@"&"]; - for (NSString *value in params) { - NSUInteger index = NSNotFound; - if ((index = [value rangeOfString:@"filename="].location) != NSNotFound) - _downloadFilename = [NSString stringWithString:[value substringFromIndex:index+9]]; - else if ((index = [value rangeOfString:@"url="].location) != NSNotFound) { - NSString *urlString = [NSString stringWithString:[value substringFromIndex:index+4]]; - urlString = [urlString stringByRemovingPercentEncoding]; - downloadUrl = [NSURL URLWithString:urlString]; - } - } - } -} - + (KMInputMethodAppDelegate *)AppDelegate { return (KMInputMethodAppDelegate *)[NSApp delegate]; } @@ -509,101 +510,12 @@ CGEventRef eventTapFunction(CGEventTapProxy proxy, CGEventType type, CGEventRef */ - (NSString *)keyboardsPath { if (_keyboardsPath == nil) { - _keyboardsPath = [KMDataRepository shared].keymanKeyboardsDirectory.path; + _keyboardsPath = [KMDataRepository shared].keyman19KeyboardsDirectory.path; } return _keyboardsPath; } -- (NSArray *)kmxFileList { - if (_kmxFileList == nil) { - os_log_debug([KMLogs dataLog], "creating kmxFileList"); - NSArray *kmxFiles = [self getKmxFilesInKeyboardsDirectory]; - _kmxFileList = [[NSMutableArray alloc] initWithCapacity:0]; - NSMutableArray *others = nil; - for (NSString *filePath in kmxFiles) { - os_log_debug([KMLogs dataLog], "kmxFileList, filePath: %{public}@", filePath); - NSString *packageFolder = [self packageFolderFromPath:filePath]; - NSInteger index = [self indexForPackageFolder:packageFolder]; - if ([packageFolder isEqualToString:@"Others"]) { - if (others == nil) - others = [NSMutableArray arrayWithCapacity:0]; - [others addObject:filePath]; - } - else { - if (index >= 0) { - [[_kmxFileList objectAtIndex:index] addObject:filePath]; - } - else { - NSMutableArray *pArray = [[NSMutableArray alloc] initWithObjects:filePath, nil]; - [_kmxFileList addObject:pArray]; - } - } - } - - if (others != nil) - [_kmxFileList addObject:others]; - } - - return _kmxFileList; -} - -- (NSString *)kmxFilePathAtIndex:(NSUInteger)index { - NSUInteger x = 0; - NSUInteger len = _kmxFileList.count; - for (int i = 0; i < len; i++) { - x++; - NSArray *pArray = (NSArray *)[_kmxFileList objectAtIndex:i]; - for (NSString *path in pArray) { - if (index == x) - return path; - x++; - } - } - - return nil; -} - -- (NSString *)packagePathAtIndex:(NSUInteger)index { - NSString *packagePath = nil; - NSUInteger x = 0; - NSUInteger len = _kmxFileList.count; - for (int i = 0; i < len; i++) { - NSArray *pArray = (NSArray *)[_kmxFileList objectAtIndex:i]; - if (!pArray.count) { - i++; - x++; - continue; - } - - if (index >= x && index <= (x+pArray.count)) { - packagePath = [[pArray objectAtIndex:0] stringByDeletingLastPathComponent]; - break; - } - - x += (pArray.count+1); - } - - return packagePath; -} - -- (NSInteger)indexForPackageFolder:(NSString *)packageFolder { - NSInteger index = -1; - NSUInteger len = _kmxFileList.count; - for (int i = 0; i < len; i++) { - NSArray *pArray = (NSArray *)[_kmxFileList objectAtIndex:i]; - if (pArray.count > 0) { - NSString *packageFolder2 = [self packageFolderFromPath:[pArray objectAtIndex:0]]; - if ([packageFolder isEqualToString:packageFolder2]) { - index = i; - break; - } - } - } - - return index; -} - - (NSString *)packageFolderFromPath:(NSString *)path { NSString *packageFolder = nil; NSString *sourcePath = [self keyboardsPath]; @@ -623,19 +535,6 @@ CGEventRef eventTapFunction(CGEventTapProxy proxy, CGEventType type, CGEventRef return [self.packageReader loadPackageInfo:path]; } -- (NSString *)packageNameFromPackageInfo:(NSString *)packageFolder { - NSString *packageName = nil; - - NSString *path = [[self keyboardsPath] stringByAppendingPathComponent:packageFolder]; - KMPackageInfo *packageInfo = [self.packageReader loadPackageInfo:path]; - - if (packageInfo) { - packageName = packageInfo.packageName; - } - - return packageName; -} - - (NSArray *)keyboardNamesFromFolder:(NSString *)packageFolder { os_log_debug([KMLogs dataLog], "keyboardNamesFromFolder, folder = %{public}@", packageFolder); NSMutableArray *kbNames = [[NSMutableArray alloc] initWithCapacity:0];; @@ -666,62 +565,80 @@ CGEventRef eventTapFunction(CGEventTapProxy proxy, CGEventType type, CGEventRef _selectedKeyboard = selectedKeyboard; } -- (NSMutableArray *)activeKeyboards { - if (!_activeKeyboards) { - os_log_debug([KMLogs dataLog], "initializing activeKeyboards"); - _activeKeyboards = [[KMSettingsRepository.shared readActiveKeyboards] mutableCopy]; +- (NSMutableArray *)enabledKeyboards { + if (!_enabledKeyboards) { + os_log_debug([KMLogs dataLog], "initializing enabledKeyboards"); + _enabledKeyboards = [[KMSettingsRepository.shared readEnabledKeyboards] mutableCopy]; - [KMSentryHelper addActiveKeyboardCountTag:_activeKeyboards.count]; + [KMSentryHelper addEnabledKeyboardCountTag:_enabledKeyboards.count]; } - return _activeKeyboards; + return _enabledKeyboards; } -- (void)saveActiveKeyboards { - os_log_debug([KMLogs dataLog], "saveActiveKeyboards"); - [KMSettingsRepository.shared writeActiveKeyboards:_activeKeyboards]; - [self resetActiveKeyboards]; - [self updateKeyboardMenuItems]; - [KMSentryHelper addActiveKeyboardCountTag:self.activeKeyboards.count]; -} - -- (void)clearActiveKeyboards { - [KMSettingsRepository.shared clearActiveKeyboards]; +/** + * Called when the enabled keyboards have been changed by the Keyman Config app. + * Reload the enabled keyboards from the UserDefaults + * Then update the Keyman menu + */ +- (void)reloadEnabledKeyboards { + os_log_debug([KMLogs configLog], "reloadEnabledKeyboards"); + + // read the enabled keyboards from UserDefaults + _enabledKeyboards = [[KMSettingsRepository.shared readEnabledKeyboards] mutableCopy]; + + // set Sentry tag for how many keyboards are enabled + [KMSentryHelper addEnabledKeyboardCountTag:_enabledKeyboards.count]; + + // update the keyboard menu to reflect the updated list of enabled keyboards [self updateKeyboardMenuItems]; } -- (void)addActiveKeyboard:(NSString *) partialPath { - if (![self.activeKeyboards containsObject:partialPath]) { +- (void)saveEnabledKeyboards { + os_log_debug([KMLogs dataLog], "saveEnabledKeyboards"); + [KMSettingsRepository.shared writeEnabledKeyboards:_enabledKeyboards]; + [self resetEnabledKeyboards]; + [self updateKeyboardMenuItems]; + [KMSentryHelper addEnabledKeyboardCountTag:self.enabledKeyboards.count]; +} + +- (void)clearEnabledKeyboards { + [KMSettingsRepository.shared clearEnabledKeyboards]; + [self updateKeyboardMenuItems]; +} + +- (void)addEnabledKeyboard:(NSString *) partialPath { + if (![self.enabledKeyboards containsObject:partialPath]) { os_log_debug([KMLogs keyboardLog], "addActiveKeyboard, adding '%{public}@' to list of active keyboards: ", partialPath); - [self.activeKeyboards addObject:partialPath]; + [self.enabledKeyboards addObject:partialPath]; } } -- (void)resetActiveKeyboards { +- (void)resetEnabledKeyboards { // Remove entries with missing files NSMutableArray *pathsToRemove = [[NSMutableArray alloc] initWithCapacity:0]; - for (NSString *path in self.activeKeyboards) { + for (NSString *path in self.enabledKeyboards) { NSString *fullPath = [KMDataRepository.shared buildFullPath:path]; - os_log_debug([KMLogs dataLog], "resetActiveKeyboards, checking fullPath: '%{public}@' for path: '%{public}@'", fullPath, path); + os_log_debug([KMLogs dataLog], "resetEnabledKeyboards, checking fullPath: '%{public}@' for path: '%{public}@'", fullPath, path); if (![[NSFileManager defaultManager] fileExistsAtPath:fullPath]) { - os_log_debug([KMLogs dataLog], "resetActiveKeyboards, need to remove non-existent path: %{public}@", fullPath); + os_log_debug([KMLogs dataLog], "resetEnabledKeyboards, need to remove non-existent path: %{public}@", fullPath); [pathsToRemove addObject:path]; } } BOOL found = FALSE; if (pathsToRemove.count > 0) { - [self.activeKeyboards removeObjectsInArray:pathsToRemove]; + [self.enabledKeyboards removeObjectsInArray:pathsToRemove]; found = TRUE; } // Remove duplicate entries NSUInteger i = 0; - while(i < [self.activeKeyboards count]) { - NSString *item = self.activeKeyboards[i]; - NSUInteger n = [self.activeKeyboards indexOfObject:item inRange: NSMakeRange(i+1, [self.activeKeyboards count]-i-1)]; + while(i < [self.enabledKeyboards count]) { + NSString *item = self.enabledKeyboards[i]; + NSUInteger n = [self.enabledKeyboards indexOfObject:item inRange: NSMakeRange(i+1, [self.enabledKeyboards count]-i-1)]; if(n != NSNotFound) { - [self.activeKeyboards removeObjectAtIndex:n]; + [self.enabledKeyboards removeObjectAtIndex:n]; found = TRUE; } else { i++; @@ -729,7 +646,7 @@ CGEventRef eventTapFunction(CGEventTapProxy proxy, CGEventType type, CGEventRef } if (found) { - [KMSettingsRepository.shared writeActiveKeyboards:_activeKeyboards]; + [KMSettingsRepository.shared writeEnabledKeyboards:_enabledKeyboards]; } } @@ -754,23 +671,33 @@ CGEventRef eventTapFunction(CGEventTapProxy proxy, CGEventType type, CGEventRef * namely, the keyboard data on disk and the settings in UserDefaults */ - (void)prepareStorage { - [KMDataRepository.shared createDataDirectoryIfNecessary]; - - if ([KMSettingsRepository.shared dataMigrationNeeded]) { - [KMDataRepository.shared migrateData]; - [KMSettingsRepository.shared convertSettingsForMigration]; + SettingsState state = [KMSettingsRepository.shared determineSettingsState]; + + switch (state) { + case KeymanSettingsVersion17: + os_log_info([KMLogs dataLog], "prepareStorage, migration needed for Keyman 17 to current"); + [KMDataRepository.shared createSharedDirectoriesIfNecessary]; + [KMDataRepository.shared migrateDataFromKeyman17]; + [KMSettingsRepository.shared migrateSettingsFromKeyman17]; + break; + case KeymanSettingsVersion18: + os_log_info([KMLogs dataLog], "prepareStorage, migration needed for Keyman 18 to current"); + [KMDataRepository.shared createSharedDirectoriesIfNecessary]; + [KMDataRepository.shared migrateDataFromKeyman18]; + [KMSettingsRepository.shared migrateSettingsFromKeyman18]; + break; + case KeymanSettingsNotFound: + os_log_info([KMLogs dataLog], "prepareStorage, settings not found, create them"); + [KMDataRepository.shared createSharedDirectoriesIfNecessary]; + [KMSettingsRepository.shared createSharedSettingsIfNecessary]; + break; + case KeymanSettingsVersionCurrent: + os_log_info([KMLogs dataLog], "prepareStorage: settings are current, no migration needed"); + break; } - - [KMDataRepository.shared createKeyboardsDirectoryIfNecessary]; - [KMSettingsRepository.shared setDataModelVersionIfNecessary]; } - (void)setDefaultKeymanMenuItems { - NSMenuItem *config = [self.menu itemWithTag:CONFIG_MENUITEM_TAG]; - if (config) { - [config setAction:@selector(menuAction:)]; - } - NSMenuItem *osk = [self.menu itemWithTag:OSK_MENUITEM_TAG]; if (osk) { [osk setAction:@selector(menuAction:)]; @@ -790,11 +717,11 @@ CGEventRef eventTapFunction(CGEventTapProxy proxy, CGEventType type, CGEventRef - (int)calculateNumberOfKeyboardMenuItems { os_log_debug([KMLogs uiLog], "calculateNumberOfKeyboardMenuItems, entered"); - if (self.activeKeyboards.count == 0) { + if (self.enabledKeyboards.count == 0) { // if there are no active keyboards, then we will insert one placeholder menu item 'No Active Keyboards' return 1; } else { - return (int) self.activeKeyboards.count; + return (int) self.enabledKeyboards.count; } } @@ -817,10 +744,10 @@ CGEventRef eventTapFunction(CGEventTapProxy proxy, CGEventType type, CGEventRef NSString *keyboardMenuName = @""; int menuItemIndex = KEYMAN_FIRST_KEYBOARD_MENUITEM_INDEX; - os_log_debug([KMLogs configLog], "*** populateKeyboardMenuItems, number of active keyboards=%lu", self.activeKeyboards.count); + os_log_debug([KMLogs configLog], "*** populateKeyboardMenuItems, number of active keyboards=%lu", self.enabledKeyboards.count); // loop through the active keyboards list and add them to the menu - for (NSString *path in self.activeKeyboards) { + for (NSString *path in self.enabledKeyboards) { NSString *fullPath = [KMDataRepository.shared buildFullPath:path]; os_log_debug([KMLogs dataLog], "addDynamicKeyboardMenuItems, path = '%{public}@', full path = '%{public}@'", path, fullPath); NSDictionary *infoDict = [KMXFile keyboardInfoFromKmxFile:fullPath]; @@ -843,7 +770,7 @@ CGEventRef eventTapFunction(CGEventTapProxy proxy, CGEventType type, CGEventRef [self.menu insertItem:item atIndex:menuItemIndex++]; } - if (self.activeKeyboards.count == 0) { + if (self.enabledKeyboards.count == 0) { [self addKeyboardPlaceholderMenuItem]; } else if (!didSetSelectedKeyboard) { [self setDefaultSelectedKeyboard]; @@ -875,7 +802,7 @@ CGEventRef eventTapFunction(CGEventTapProxy proxy, CGEventType type, CGEventRef // defaults to the whatever keyboard happens to be first in the list - (void) setDefaultSelectedKeyboard { NSMenuItem* menuItem = [self.menu itemAtIndex:KEYMAN_FIRST_KEYBOARD_MENUITEM_INDEX]; - NSString *keyboardName = [self.activeKeyboards objectAtIndex:0]; + NSString *keyboardName = [self.enabledKeyboards objectAtIndex:0]; [self setSelectedKeyboard:keyboardName inMenuItem:menuItem]; [self setSelectedKeyboard:keyboardName]; [self clearContextBuffer]; @@ -911,7 +838,7 @@ CGEventRef eventTapFunction(CGEventTapProxy proxy, CGEventType type, CGEventRef } } - NSString *path = [self.activeKeyboards objectAtIndex:tag-KEYMAN_FIRST_KEYBOARD_MENUITEM_TAG]; + NSString *path = [self.enabledKeyboards objectAtIndex:tag-KEYMAN_FIRST_KEYBOARD_MENUITEM_TAG]; NSString *fullPath = [KMDataRepository.shared buildFullPath:path]; os_log_debug([KMLogs dataLog], "setSelectedKeyboard, keyboardName = '%{public}@', full path = '%{public}@'", path, fullPath); @@ -991,17 +918,6 @@ CGEventRef eventTapFunction(CGEventTapProxy proxy, CGEventType type, CGEventRef return _oskWindow; } -- (void)showConfigurationWindow { - os_log_debug([KMLogs uiLog], "Showing config window..."); - [self.configWindow.window centerInParent]; - [self.configWindow.window makeKeyAndOrderFront:nil]; - [self.configWindow.window setLevel:NSFloatingWindowLevel]; -} - -- (void)registerConfigurationWindow:(NSWindowController *)window { - _configWindow = window; -} - - (void)showOSK { [self.oskWindow prepareToShowOsk]; [[self.oskWindow window] makeKeyAndOrderFront:nil]; @@ -1022,16 +938,6 @@ CGEventRef eventTapFunction(CGEventTapProxy proxy, CGEventType type, CGEventRef * probably be managed better. */ -- (NSWindowController *)configWindow { - if (_configWindow.window == nil) { - os_log_debug([KMLogs uiLog], "Creating config window..."); - _configWindow = [[KMConfigurationWindowController alloc] initWithWindowNibName:@"preferences"]; - [self observeCloseFor:_configWindow.window]; - } - - return _configWindow; -} - // TODO: rewrite confusing pattern, multiple methods differing only by underscore - (NSWindowController *)aboutWindow_ { return _aboutWindow; @@ -1046,19 +952,6 @@ CGEventRef eventTapFunction(CGEventTapProxy proxy, CGEventType type, CGEventRef return _aboutWindow; } -- (NSWindowController *)infoWindow_ { - return _infoWindow; -} - -- (NSWindowController *)infoWindow { - if (_infoWindow.window == nil) { - _infoWindow = [[KMInfoWindowController alloc] initWithWindowNibName:@"KMInfoWindowController"]; - [self observeCloseFor:_infoWindow.window]; - } - - return _infoWindow; -} - - (NSWindowController *)kbHelpWindow_ { return _kbHelpWindow; } @@ -1072,19 +965,6 @@ CGEventRef eventTapFunction(CGEventTapProxy proxy, CGEventType type, CGEventRef return _kbHelpWindow; } -- (NSWindowController *)downloadKBWindow_ { - return _downloadKBWindow; -} - -- (NSWindowController *)downloadKBWindow { - if (_downloadKBWindow.window == nil) { - _downloadKBWindow = [[KMDownloadKBWindowController alloc] initWithWindowNibName:@"KMDownloadKBWindowController"]; - [self observeCloseFor:_downloadKBWindow.window]; - } - - return _downloadKBWindow; -} - /* * Release windows after closing -- no need to keep them hanging about */ @@ -1098,200 +978,12 @@ CGEventRef eventTapFunction(CGEventTapProxy proxy, CGEventType type, CGEventRef - (void)windowWillClose:(NSNotification *)notification { NSWindow* window = notification.object; - if (window == _downloadKBWindow.window) { - _downloadKBWindow.window = nil; - } else if(window == _kbHelpWindow.window) { + if(window == _kbHelpWindow.window) { _kbHelpWindow.window = nil; - } else if(window == _infoWindow.window) { - _infoWindow.window = nil; - } else if(window == _configWindow.window) { - _configWindow.window = nil; } [[NSNotificationCenter defaultCenter] removeObserver:self name:NSWindowWillCloseNotification object:window]; } -/* - * Endpoints for download process - * TODO: this should really be refactored - */ - -- (void)downloadComplete:(NSModalResponse) returnCode { - os_log_debug([KMLogs uiLog], "downloadComplete, NSModalResponse returnCode: %ld", (long)returnCode); - if (returnCode == NSModalResponseCancel) { - os_log_debug([KMLogs uiLog], "downloadComplete, returnCode == NSModalResponseCancel"); - [_connection cancel]; - } - else if (returnCode == NSModalResponseOK) { - os_log_debug([KMLogs uiLog], "downloadComplete, returnCode == NSModalResponseOK"); - - [_downloadKBWindow close]; - - if (self.configWindow.window != nil) { - os_log_debug([KMLogs uiLog], "downloadComplete, self.configWindow.window != nil"); - [self.configWindow.window makeKeyAndOrderFront:nil]; - if (![[self.configWindow.window childWindows] containsObject:self.infoWindow.window]) { - [self.configWindow.window addChildWindow:self.infoWindow.window ordered:NSWindowAbove]; - } - [self.infoWindow.window centerInParent]; - [self.infoWindow.window makeKeyAndOrderFront:nil]; - } - else { - os_log_debug([KMLogs uiLog], "downloadComplete, self.configWindow.window == nil"); - [self.infoWindow.window centerInParent]; - [self.infoWindow.window makeKeyAndOrderFront:nil]; - [self.infoWindow.window setLevel:NSFloatingWindowLevel]; - } - - NSString *packagePath = [self.keyboardsPath stringByAppendingPathComponent:[self.downloadFilename stringByDeletingPathExtension]]; - [self.infoWindow setPackagePath:packagePath]; - } - - _downloadInfoView = nil; - _connection = nil; - _downloadFilename = nil; - _receivedData = nil; - _expectedBytes = 0; -} - -- (NSAlert *)downloadInfoView { - os_log_debug([KMLogs uiLog], "downloadInfoView"); - if (_downloadInfoView == nil) { - _downloadInfoView = [[NSAlert alloc] init]; - [_downloadInfoView setMessageText:NSLocalizedString(@"message-keyboard-downloading", nil)]; - [_downloadInfoView setInformativeText:@""]; - [_downloadInfoView addButtonWithTitle:NSLocalizedString(@"button-cancel-downloading", nil)]; - [_downloadInfoView setAlertStyle:NSAlertStyleInformational]; - [_downloadInfoView setAccessoryView:self.progressIndicator]; - } - - return _downloadInfoView; -} - -- (NSProgressIndicator *)progressIndicator { - os_log_debug([KMLogs uiLog], "progressIndicator"); - if (_progressIndicator == nil) { - _progressIndicator = [[NSProgressIndicator alloc] initWithFrame:NSMakeRect(0, 0, 300, 20)]; - [_progressIndicator setIndeterminate:NO]; - [_progressIndicator setMinValue:0]; - [_progressIndicator setMaxValue:100]; - [_progressIndicator setDoubleValue:0]; - } - - return _progressIndicator; -} - -- (void)downloadKeyboardFromKeyboardId:(NSString *)keyboardId { - KeymanVersionInfo keymanVersionInfo = [self versionInfo]; - NSURL* url = [NSURL URLWithString:[NSString stringWithFormat:@"https://%@/go/package/download/%@?platform=macos&tier=%@", - keymanVersionInfo.keymanCom, keyboardId, keymanVersionInfo.tier]]; //&bcp47=%@&update=0 - _downloadFilename = [NSString stringWithFormat:@"%@.kmp", keyboardId]; - [self downloadKeyboardFromURL:url]; -} - - -- (void)downloadKeyboardFromURL:(NSURL *)url { - NSURL* downloadUrl = url; - os_log_debug([KMLogs uiLog], "downloadKeyboardFromURL, url.path: %{public}@", url.path); - - if (downloadUrl && _downloadFilename) { - if (_infoWindow.window != nil) - [_infoWindow close]; - - [self.downloadInfoView setInformativeText:self.downloadFilename]; - - if (self.configWindow.window != nil) { - os_log_debug([KMLogs uiLog], "downloadKeyboardFromURL, self.configWindow.window != nil"); - [self.configWindow.window makeKeyAndOrderFront:nil]; - if (![[self.configWindow.window childWindows] containsObject:self.downloadKBWindow.window]) { - [self.configWindow.window addChildWindow:self.downloadKBWindow.window ordered:NSWindowAbove]; - } - [self.downloadKBWindow.window centerInParent]; - [self.downloadKBWindow.window makeKeyAndOrderFront:nil]; - - /* - Open sheet off of config window, not the download window. - This is because, if the download is successful, - the download window will be closed by the sheet. - */ - [self.downloadInfoView beginSheetModalForWindow:self.configWindow.window completionHandler:^(NSModalResponse returnCode) { - [self downloadComplete:returnCode]; - }]; - } - else { - os_log_debug([KMLogs uiLog], "downloadKeyboardFromURL, self.configWindow.window == nil"); - [self.downloadKBWindow.window centerInParent]; - [self.downloadKBWindow.window makeKeyAndOrderFront:nil]; - [self.downloadKBWindow.window setLevel:NSFloatingWindowLevel]; - /* - Open sheet off of config window, same as above. - */ - [self.downloadInfoView beginSheetModalForWindow:self.configWindow.window completionHandler:^(NSModalResponse returnCode) { - [self downloadComplete:returnCode]; - }]; - } - - if (_connection == nil) { - os_log_debug([KMLogs uiLog], "downloadKeyboardFromURL, _connection == nil, set button title cancel downloading, tag = -1"); - [_downloadInfoView setMessageText:NSLocalizedString(@"message-keyboard-downloading", nil)]; - NSButton *button = (NSButton *)[_downloadInfoView.buttons objectAtIndex:0]; - [button setTitle:NSLocalizedString(@"button-cancel-downloading", nil)]; - [button setTag:-1]; - [self.progressIndicator setDoubleValue:0]; - NSURLRequest *request = [NSURLRequest requestWithURL:url cachePolicy:NSURLRequestReloadIgnoringCacheData timeoutInterval:60]; - _receivedData = [[NSMutableData alloc] initWithLength:0]; - _connection = [[NSURLConnection alloc] initWithRequest:request delegate:self startImmediately:YES]; - } - } -} - -- (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response { - [self.receivedData setLength:0]; - self.expectedBytes = [response expectedContentLength]; -} - -- (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data { - [self.receivedData appendData:data]; - float progress = ((float)[self.receivedData length]/(float)self.expectedBytes)*100; - [self.progressIndicator setDoubleValue:progress]; -} - -- (void)connection:(NSURLConnection *)connection didFailWithError:(NSError *)error { - _connection = nil; - _downloadFilename = nil; - _receivedData = nil; - _expectedBytes = 0; -} - -- (NSURLRequest *)connection:(NSURLConnection *)connection - willSendRequest:(nonnull NSURLRequest *)request - redirectResponse:(nullable NSURLResponse *)response { - return request; -} - -- (NSCachedURLResponse *)connection:(NSURLConnection *)connection willCacheResponse:(NSCachedURLResponse *)cachedResponse { - return nil; -} - -- (void)connectionDidFinishLoading:(NSURLConnection *)connection { - os_log_debug([KMLogs uiLog], "connectionDidFinishLoading"); - NSString *filePath = [self.keyboardsPath stringByAppendingPathComponent:self.downloadFilename]; - [self.receivedData writeToFile:filePath atomically:YES]; - [self unzipFile:filePath]; - [[NSFileManager defaultManager] removeItemAtPath:filePath error:nil]; - - [_downloadInfoView setMessageText:NSLocalizedString(@"message-keyboard-download-complete", nil)]; - NSButton *button = (NSButton *)[_downloadInfoView.buttons objectAtIndex:0]; - os_log_debug([KMLogs uiLog], "connectionDidFinishLoading, set button title download complete, tag = 1"); - [button setTitle:NSLocalizedString(@"button-download-complete", nil)]; - [button setTag:1]; - [[NSNotificationCenter defaultCenter] postNotificationName:kKeymanKeyboardDownloadCompletedNotification - object:self - userInfo:nil]; - _connection = nil; - _receivedData = nil; - _expectedBytes = 0; -} - - (void)handleKeyEvent:(NSEvent *)event { os_log_debug([KMLogs keyTraceLog], "handleKeyEvent: %{public}@", event); @@ -1360,160 +1052,4 @@ extern const CGKeyCode kProcessPendingBuffer; } } -// Check the package info to ensure that we support this version -// (e.g. Keyman 15 does not support a 16.0 version package) -- (BOOL)verifyPackageVersionInTempFolder: (NSString *)tempDestFolder filePath:(NSString *)filePath { - KMPackageInfo *packageInfo = [self loadPackageInfo:tempDestFolder]; - if(packageInfo == nil) { - os_log_info([KMLogs eventsLog], "Could not find kmp.json in %{public}@", filePath); - } else { - NSString* requiredVersion = [packageInfo.fileVersion minimalVersionNumberString]; - KeymanVersionInfo keymanVersionInfo = [self versionInfo]; - NSString *currentVersion = [keymanVersionInfo.versionRelease minimalVersionNumberString]; - - if ([requiredVersion compare:currentVersion options:NSNumericSearch] == NSOrderedDescending) { - // currentVersion is lower than the requiredVersion - os_log_error([KMLogs keyboardLog], "Package %{public}@ requires a newer version of Keyman: %{public}@", filePath, requiredVersion); - } else { - return YES; - } - } - return NO; -} - -// TODO: This seriously needs to be refactored out of the app delegate and into -// a keyboard install module -- (BOOL)unzipFile:(NSString *)filePath { - BOOL didUnzip = NO; - NSError *error = nil; - NSString *fileName = filePath.lastPathComponent; - NSString *folderName = [fileName stringByDeletingPathExtension]; - - os_log_debug([KMLogs keyboardLog], "unzipFile, folderName: %{public}@, fileName: %{public}@", folderName, fileName); - - // First we unzip into a temp folder, and check kmp.json for the fileVersion - // before we continue installation. We don't want to overwrite existing - // package if it is there if the files are not compatible with the installed - // version of Keyman. - - NSString *tempFolderName = [folderName stringByAppendingString:@".tmp.install"]; - NSString *tempDestFolder = [self.keyboardsPath stringByAppendingPathComponent:tempFolderName]; - - ZipArchive *za = [[ZipArchive alloc] init]; - if ([za UnzipOpenFile:filePath]) { - os_log_debug([KMLogs keyboardLog], "unzipFile, Unzipping %{public}@ to %{public}@", filePath, tempDestFolder); - if ([[NSFileManager defaultManager] fileExistsAtPath:tempDestFolder]) { - os_log_debug([KMLogs keyboardLog], "unzipFile, The temp destination folder already exists. Overwriting..."); - } - - didUnzip = [za UnzipFileTo:tempDestFolder overWrite:YES]; - [za UnzipCloseFile]; - } - - if (!didUnzip) { - os_log_error([KMLogs keyboardLog], "unzipFile, Failed to unzip file: %{public}@", filePath); - return NO; - } - - os_log_debug([KMLogs keyboardLog], "unzipFile, Unzipped file: %{public}@", filePath); - - BOOL didInstall = [self verifyPackageVersionInTempFolder:tempDestFolder filePath:filePath]; - - NSString *destFolder = [self.keyboardsPath stringByAppendingPathComponent:folderName]; - - // Remove existing package if it exists - if (didInstall && [[NSFileManager defaultManager] fileExistsAtPath:destFolder]) { - os_log_debug([KMLogs keyboardLog], "unzipFile, The destination folder already exists. Overwriting..."); - [[NSFileManager defaultManager] removeItemAtPath:destFolder error:&error]; - if (error != nil) { - os_log_error([KMLogs keyboardLog], "unzipFile, Unable to remove destination folder %{public}@", destFolder); - didInstall = NO; - } - } - - // - // We believe this package is valid, let's go ahead and install it - // - - // Rename the temp folder to the desired dest folder. removing existing folder first - if(didInstall) { - [[NSFileManager defaultManager] moveItemAtPath:tempDestFolder toPath:destFolder error:&error]; - if (error != nil) { - os_log_error([KMLogs keyboardLog], "unzipFile, Unable to move temp folder %{public}@ to dest folder %{public}@", tempDestFolder, destFolder); - didInstall = NO; - } - } - - if(!didInstall) { - [[NSFileManager defaultManager] removeItemAtPath:tempDestFolder error:&error]; - if (error != nil) { - os_log_error([KMLogs keyboardLog], "unzipFile, Unable to remove temp folder %{public}@", tempDestFolder); - } - - return NO; - } - - // Package has installed, now scan for keyboards and fonts - // TODO: we need to be reading the kmp.json data to determine keyboards to install - NSString * keyboardFolderPath = [self.keyboardsPath stringByAppendingPathComponent:folderName]; - os_log_debug([KMLogs keyboardLog], "unzipFile, folderName: %{public}@, keyboardFolderPath: %{public}@", folderName, keyboardFolderPath); - [self installFontsAtPath:keyboardFolderPath]; - - for (NSString *kmxFile in [self getKmxFilesAtPath:keyboardFolderPath]) { - NSString *partialPath = [KMDataRepository.shared buildPartialPathFrom:folderName keyboardFile:[kmxFile lastPathComponent]]; - [self addActiveKeyboard:partialPath]; - } - [self saveActiveKeyboards]; - - return YES; -} - -- (NSString *)fontsPath { - if (_fontsPath == nil) { - BOOL isDir; - NSArray *paths = NSSearchPathForDirectoriesInDomains(NSLibraryDirectory, NSUserDomainMask, YES); - - if (paths.count == 1) { - NSString *path = [[paths objectAtIndex:0] stringByAppendingPathComponent:@"Fonts"]; - if ([[NSFileManager defaultManager] fileExistsAtPath:path isDirectory:&isDir] && isDir) - _fontsPath = [NSString stringWithString:path]; - } - } - - return _fontsPath; -} - -- (void)installFontsAtPath:(NSString *)path { - NSString *fontsPath = self.fontsPath; - if (fontsPath == nil) - return; - - NSArray *fonts = [self FontFilesAtPath:path]; - for (NSString *srcPath in fonts) { - NSString *destPath = [fontsPath stringByAppendingPathComponent:[srcPath lastPathComponent]]; - NSError *error; - if ([[NSFileManager defaultManager] fileExistsAtPath:destPath]) - [[NSFileManager defaultManager] removeItemAtPath:destPath error:&error]; - - if (error == nil) - [[NSFileManager defaultManager] copyItemAtPath:srcPath toPath:destPath error:&error]; - - if (error != nil) - os_log_error([KMLogs keyboardLog], "installFontsAtPath error = %{public}@", error); - } -} - -- (NSArray *)FontFilesAtPath:(NSString *)path { - NSDirectoryEnumerator *dirEnum = [[NSFileManager defaultManager] enumeratorAtPath:path]; - NSMutableArray *fontFiles = [[NSMutableArray alloc] initWithCapacity:0]; - NSString *filePath; - while (filePath = (NSString *)[dirEnum nextObject]) { - NSString *extension = [[filePath pathExtension] lowercaseString]; - if ([extension isEqualToString:@"ttf"] || [extension isEqualToString:@"otf"]) - [fontFiles addObject:[path stringByAppendingPathComponent:filePath]]; - } - - return fontFiles; -} - @end diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMPackage.h b/mac/Keyman4MacIM/Keyman4MacIM/KMPackage.h deleted file mode 100644 index b558a89c64..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMPackage.h +++ /dev/null @@ -1,22 +0,0 @@ -// -// KMPackage.h -// Keyman -// -// Created by tom on 2/9/18. -// Copyright © 2018 SIL International. All rights reserved. -// - -#ifndef KMPackage_h -#define KMPackage_h - -#import -#import "KMInputMethodAppDelegate.h" - -@interface KMPackage : NSDocument --(NSString *) getOrigKmpFilename; --(NSString *) getTempKmpFilename; - --(void) releaseTempKMPFile; -@end - -#endif /* KMPackage_h */ diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMPackage.m b/mac/Keyman4MacIM/Keyman4MacIM/KMPackage.m deleted file mode 100644 index 8f204706bd..0000000000 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMPackage.m +++ /dev/null @@ -1,112 +0,0 @@ -// -// KMPackage.m -// Keyman -// -// Created by tom on 2/9/18. -// Copyright © 2018 SIL International. All rights reserved. -// - -#import -#import "KMPackage.h" -#import "KMConfigurationWindowController.h" -#import "KMLogs.h" - -@implementation KMPackage - -const NSInteger kUnexpectedFileAsscociationType = 42; -NSString *filenameTempKMP = nil; -NSString *filename = nil; // This is the filename from the FileWrapper (if any) - - --(NSString *) getOrigKmpFilename { - return filename; -} - --(NSString *) getTempKmpFilename { - return filenameTempKMP; -} - -+ (BOOL)autosavesInPlace { - return NO; -} - -// This method gets called whenever a user double-clicks a KMP file, regardless of whether -// they decide to install it or cancel the installation. It also gets called one time up-front -// when Keyman first loads. -- (void)makeWindowControllers { - os_log_debug([KMLogs dataLog], "KMP - in KMPackage.makeWindowControllers"); - - if (filename != nil) { // Should be true every time except maybe the very first time (when loading). - filename = nil; - [self closeIfFilesAreReleased]; - } -} - --(void) closeIfFilesAreReleased { - // Since we aren't really opening this file as a document, after reading it (or deciding not to) - // we need to close this document so the controller doesn't hold onto it. Otherwise, if the user - // double-clicks the same KMP file a second time it will just assume we want to open the window - // to display the already open document, rather than attempting to read it again. - - os_log_debug([KMLogs dataLog], "Closing document to release presenter..."); - if (filename == nil && filenameTempKMP == nil) - [self close]; -} - -- (KMInputMethodAppDelegate *)AppDelegate { - return (KMInputMethodAppDelegate *)[NSApp delegate]; -} - --(BOOL)readFromFileWrapper:(NSFileWrapper *)fileWrapper ofType:(NSString *)typeName error:(NSError * _Nullable __autoreleasing *)outError { - - filename = [fileWrapper filename]; - - os_log_debug([KMLogs dataLog], "readFromFileWrapper called with file: %{public}@", filename); - - if (![typeName isEqualToString: @"Keyman Package"]) { - if (outError != NULL) { - NSString *description = [@"Unexpected file association for type " stringByAppendingString:typeName]; - NSDictionary *errorDictionary = @{ NSLocalizedDescriptionKey : description, - NSFilePathErrorKey : filename - }; - *outError = [[NSError alloc] initWithDomain:@"Keyman" code:kUnexpectedFileAsscociationType userInfo:errorDictionary]; - } - return NO; - } - - // Strip out any browser-appended number, e.g. khmer_angkor (1).kmp - // We work on a copy of the file, so this is safe to do -- the fileWrapper - // saves to the filename we give it. - NSError *error = nil; - NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@" \\(\\d+\\)\\.kmp$" options:NSRegularExpressionCaseInsensitive error:&error]; - filename = [regex stringByReplacingMatchesInString:filename - options:0 - range:NSMakeRange(0, [filename length]) - withTemplate:@".kmp"]; - - filenameTempKMP = [NSTemporaryDirectory() stringByAppendingPathComponent:filename]; - assert(filenameTempKMP != nil); - NSURL *fileURL = [[NSURL alloc] initFileURLWithPath:filenameTempKMP]; - if (![fileWrapper writeToURL:fileURL options:NSFileWrapperWritingAtomic originalContentsURL:nil error:outError]) { - filenameTempKMP = nil; - return NO; - } - - KMConfigurationWindowController *configWindowController = (KMConfigurationWindowController *)[self.AppDelegate configWindow]; - [configWindowController handleRequestToInstallPackage:self]; - - return YES; // Even if the user decides not to, we need to return Yes to prevent displaying an error message. -} - --(void) releaseTempKMPFile { - assert(filenameTempKMP != nil); - [[NSFileManager defaultManager] removeItemAtPath:filenameTempKMP error:nil]; - filenameTempKMP = nil; - [self closeIfFilesAreReleased]; -} - -// This tells the system that we aren't reading the Keyman Package file lazily. --(BOOL) isEntireFileLoaded { - return YES; -} -@end diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMSettingsRepository.h b/mac/Keyman4MacIM/Keyman4MacIM/KMSettingsRepository.h index 846b9e11c6..a028c7184c 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMSettingsRepository.h +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMSettingsRepository.h @@ -12,16 +12,28 @@ NS_ASSUME_NONNULL_BEGIN +// For classifying the state of the settings (UserDefaults) and data. +// Used to determine whether migration is needed of the UserDefaults and Keyman packages +// to a new format and/or location. +typedef NS_ENUM(NSInteger, SettingsState) { + KeymanSettingsNotFound = 0, + KeymanSettingsVersion17 = 17, + KeymanSettingsVersion18 = 18, + KeymanSettingsVersionCurrent = 19 +}; + @interface KMSettingsRepository : NSObject + (KMSettingsRepository *)shared; -- (BOOL)dataMigrationNeeded; -- (void)convertSettingsForMigration; -- (void)setDataModelVersionIfNecessary; +- (instancetype)init; +- (SettingsState)determineSettingsState; +- (void)createSharedSettingsIfNecessary; // introduced with Keyman 19 +- (void)migrateSettingsFromKeyman17; +- (void)migrateSettingsFromKeyman18; - (NSString *)readSelectedKeyboard; - (void)writeSelectedKeyboard:(NSString *)selectedKeyboard; -- (NSArray *)readActiveKeyboards; -- (void)writeActiveKeyboards: (NSArray *) keyboards; -- (void)clearActiveKeyboards; +- (NSArray *)readEnabledKeyboards; +- (void)writeEnabledKeyboards: (NSArray *) keyboards; +- (void)clearEnabledKeyboards; - (NSDictionary *)readOptionsForSelectedKeyboard; - (void)writeOptionForSelectedKeyboard:(NSString *)key withValue:(NSString*)value; - (BOOL)readShowOskOnActivate; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMSettingsRepository.m b/mac/Keyman4MacIM/Keyman4MacIM/KMSettingsRepository.m index 709b1c1d6d..a46fa3b865 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMSettingsRepository.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMSettingsRepository.m @@ -11,10 +11,26 @@ #import "KMLogs.h" #import "KMDataRepository.h" +/** + * The UserDefaults key `KMActiveKeyboardsKey` identifies the list of installed keyboard which + * the user has enabled to be included in the Keyman keyboard menu. This key name is a little confusing + * because the only truly active keyboard is the one that Keyman is applying while typing. + * So, with the migration of data for Keyman version 19, this key is renamed to `KMEnabledKeyboardsKey` + * Every enabled keyboard will appear in the Keyman keyboards menu. + * Only one of these keyboards can be selected at a time, and the selected keyboard is the one that + * is actively being applied by Keyman with each keystroke. + */ +// the list of keyboards displayed in the Keyman keyboards menu, Keyman 18 and earlier NSString *const kActiveKeyboardsKey = @"KMActiveKeyboardsKey"; +// the list of keyboards displayed in the Keyman keyboards menu, as of Keyman 19 +NSString *const kEnabledKeyboardsKey = @"KMEnabledKeyboardsKey"; +// the single keyboard Keyman is currently using to process each keydown event NSString *const kSelectedKeyboardKey = @"KMSelectedKeyboardKey"; +// the maps of options saved for each keyboard NSString *const kPersistedOptionsKey = @"KMPersistedOptionsKey"; +// whether the OSK should be displayed when Keyman is activated NSString *const kShowOskOnActivate = @"KMShowOskOnActivate"; +// internal flag used for testing Sentry NSString *const kForceSentryError = @"KMForceSentryError"; /** @@ -37,7 +53,6 @@ NSString *const kAlwaysShowOSKKey = @"KMAlwaysShowOSKKey"; NSString *const kUseVerboseLogging = @"KMUseVerboseLogging"; NSString *const kObsoletePathComponent = @"/Documents/Keyman-Keyboards"; -NSString *const kNewPathComponent = @"/Library/Application Support/keyman.inputmethod.Keyman/"; /** * Store the version number of the data model in the UserDefaults with this key. @@ -45,8 +60,14 @@ NSString *const kNewPathComponent = @"/Library/Application Support/keyman.inputm * directory instead of in the Documents directory. */ NSString *const kDataModelVersion = @"KMDataModelVersion"; -NSInteger const kVersionStoreDataInLibraryDirectory = 1; -NSInteger const kCurrentDataModelVersionNumber = kVersionStoreDataInLibraryDirectory; +NSInteger const kVersionStoreDataInLibraryDirectory = 1; // introduced with Keyman 18, obsolete in Keyman 19 +NSInteger const kVersionStoreDataInGroupContainer = 2; // introduced with Keyman 19 +NSInteger const kCurrentDataModelVersionNumber = kVersionStoreDataInGroupContainer; + +@interface KMSettingsRepository () +@property (nonatomic, strong) NSUserDefaults *appDefaults; +@property (nonatomic, strong) NSUserDefaults *groupDefaults; +@end @implementation KMSettingsRepository @@ -60,20 +81,81 @@ NSInteger const kCurrentDataModelVersionNumber = kVersionStoreDataInLibraryDirec return shared; } -- (void)setDataModelVersionIfNecessary { - if (![self dataModelWithKeyboardsInLibrary]) { - [[NSUserDefaults standardUserDefaults] setInteger:kVersionStoreDataInLibraryDirectory forKey:kDataModelVersion]; - } +-(instancetype)init { + self = [super init]; + self.appDefaults = [NSUserDefaults standardUserDefaults]; + self.groupDefaults = [[NSUserDefaults alloc] initWithSuiteName:kKeymanGroupId]; + return self; } /** - * If the selectedKeyboard has not been set, then the settings have not been saved in the UserDefaults. - * If this method is called after applicationDidFinishLaunching, then it will always return true. - * If called from awakeFromNib, then it will return false when running for the first time. + * Determines the current state of the Keyman settings (UserDefaults), indicating + * whether migration is needed to a new format and/or location or whether this is + * a first-time install and settings must be created. */ -- (BOOL)settingsExist +- (SettingsState)determineSettingsState { + SettingsState state = KeymanSettingsVersionCurrent; + + // settings were moved to share app group beginning in Keyman 19 + if ([self settingsExistForAppGroup]) { + os_log([KMLogs dataLog], "keyman shared settings exist, version is current"); + state = KeymanSettingsVersionCurrent; + } else if ([self settingsExistForInputMethod]) { + // In Keyman 18, KMDataModelVersion was added to settings and set to value of 1 + // No need to check the value, if it exists in the app UserDefaults, then it needs to be migrated + if ([self version18SettingsExistForInputMethod]) { + os_log([KMLogs dataLog], "keyman app (unshared) settings version indicates Keyman 18, packages stored in ~/Library"); + state = KeymanSettingsVersion18; + } else { + // no KMDataModelVersion key is found in the app UserDefaults + os_log([KMLogs dataLog], "lack of keyman settings version indicates Keyman 17 or earlier, packages stored in ~/Documents"); + state = KeymanSettingsVersion17; + } + } else { + // settings do not exist, must be a new install + os_log([KMLogs dataLog], "keyman settings do not exist, must be created for new install"); + state = KeymanSettingsNotFound; + } + + return state; +} + +- (void)createSharedSettingsIfNecessary { + // set kDataModelVersion for the current format + [self writeCurrentDataModelVersion]; +} + +- (void)writeCurrentDataModelVersion { + [self.groupDefaults setInteger:kCurrentDataModelVersionNumber forKey:kDataModelVersion]; +} + +/** + * The dataModelVersion field will always exist for Keyman 18 and later, and starting + * with Keyman 19, it will be located in the UserDefaults for the app group. + */ +- (BOOL)settingsExistForAppGroup { - return ([[NSUserDefaults standardUserDefaults] objectForKey:kSelectedKeyboardKey] != nil); + return ([self.groupDefaults objectForKey:kDataModelVersion] != nil); +} + +/** + * If the selectedKeyboard exists in the app UserDefaults, as opposed to the shared app group + * UserDefaults, then this is version 18 or earlier of Keyman. + * For versions of Keyman, 17 and earlier, there was no`KMDataModelVersion` to check, but + * the format and location of the settings was the same for those versions. + */ +- (BOOL)settingsExistForInputMethod +{ + return ([self.appDefaults objectForKey:kSelectedKeyboardKey] != nil); +} + +/** + * The dataModelVersion field will always exist for Keyman 18 and later, but, for version 18 only, it will be located + * in the app UserDefaults instead of the shared app group UserDefaults + */ +- (BOOL)version18SettingsExistForInputMethod +{ + return ([self.appDefaults objectForKey:kDataModelVersion] != nil); } - (void)writeOptionForSelectedKeyboard:(NSString *)key withValue:(NSString*)value { @@ -113,82 +195,47 @@ NSInteger const kCurrentDataModelVersionNumber = kVersionStoreDataInLibraryDirec [self writeFullOptionsMap:newFullOptionsMap]; } -/** - * For the first numbered version of the data model, the app stores the keyboards under the ~/Library directory - * For versions before version 1, the keyboards were stored under the ~/Documents directory. - */ -- (BOOL)dataModelWithKeyboardsInLibrary { - // [NSUserDefaults integerForKey] returns zero if the key does not exist - NSInteger dataModelVersion = [[NSUserDefaults standardUserDefaults] integerForKey:kDataModelVersion]; - - return dataModelVersion >= kVersionStoreDataInLibraryDirectory; -} - -/** - * Determines whether the keyboard data needs to be moved from the old location to the new location - * This is true if - * 1) the UserDefaults exist (indicating that this is not a new installation of Keyman) and - * 2) the value for kVersionStoreDataInLibraryDirectory is < 1, - */ -- (BOOL)dataMigrationNeeded { - BOOL keymanSettingsExist = [self settingsExist]; - os_log([KMLogs dataLog], "keyman settings exist: %{public}@", keymanSettingsExist ? @"YES" : @"NO" ); - - BOOL keyboardsStoredInLibrary = [self dataModelWithKeyboardsInLibrary]; - os_log([KMLogs dataLog], "settings indicate that keyboards are stored in ~/Library: %{public}@", keyboardsStoredInLibrary ? @"YES" : @"NO" ); - - BOOL migrationNeeded = keymanSettingsExist && !keyboardsStoredInLibrary; - os_log([KMLogs dataLog], "dataMigrationNeeded: %{public}@", migrationNeeded ? @"YES" : @"NO" ); - - return migrationNeeded; -} - - (NSString *)readSelectedKeyboard { - return [[NSUserDefaults standardUserDefaults] objectForKey:kSelectedKeyboardKey]; + return [self.groupDefaults objectForKey:kSelectedKeyboardKey]; } - (void)writeSelectedKeyboard:(NSString *)selectedKeyboard { if (selectedKeyboard != nil) { - NSUserDefaults *userData = [NSUserDefaults standardUserDefaults]; - [userData setObject:selectedKeyboard forKey:kSelectedKeyboardKey]; + [self.groupDefaults setObject:selectedKeyboard forKey:kSelectedKeyboardKey]; } } -- (NSMutableArray *)activeKeyboards { - NSMutableArray * activeKeyboards = [[[NSUserDefaults standardUserDefaults] arrayForKey:kActiveKeyboardsKey] mutableCopy]; +- (NSMutableArray *)enabledKeyboards { + NSMutableArray * enabledKeyboards = [[self.groupDefaults arrayForKey:kEnabledKeyboardsKey] mutableCopy]; - if (!activeKeyboards) { - activeKeyboards = [[NSMutableArray alloc] initWithCapacity:0]; + if (!enabledKeyboards) { + enabledKeyboards = [[NSMutableArray alloc] initWithCapacity:0]; } - return activeKeyboards; + return enabledKeyboards; } -- (NSArray *)readActiveKeyboards { - os_log_debug([KMLogs dataLog], "KMSettingsRepository readActiveKeyboards"); - NSUserDefaults *userData = [NSUserDefaults standardUserDefaults]; - NSArray *keyboardsArray = [userData arrayForKey:kActiveKeyboardsKey]; +- (NSArray *)readEnabledKeyboards { + os_log_debug([KMLogs dataLog], "KMSettingsRepository readEnabledKeyboards"); + NSArray *keyboardsArray = [self.groupDefaults arrayForKey:kEnabledKeyboardsKey]; - // if the kActiveKeyboardsKey does not exist, then create an empty array + // if the kEnabledKeyboardsKey does not exist, then create an empty array if (!keyboardsArray) { - os_log_debug([KMLogs dataLog], "KMActiveKeyboardsKey key not found in NSUserDefualts"); + os_log_debug([KMLogs dataLog], "kEnabledKeyboardsKey key not found in NSUserDefualts"); keyboardsArray = [[NSArray alloc] init]; } return keyboardsArray; } -- (void)writeActiveKeyboards: (NSArray *) keyboards { - os_log_debug([KMLogs dataLog], "KMSettingsRepository writeActiveKeyboards"); - NSUserDefaults *userData = [NSUserDefaults standardUserDefaults]; - [userData setObject:keyboards forKey:kActiveKeyboardsKey]; +- (void)writeEnabledKeyboards: (NSArray *) keyboards { + os_log_debug([KMLogs dataLog], "KMSettingsRepository writeEnabledKeyboards"); + [self.groupDefaults setObject:keyboards forKey:kEnabledKeyboardsKey]; } -- (void)clearActiveKeyboards { - os_log_debug([KMLogs dataLog], "KMSettingsRepository clearActiveKeyboards"); - NSUserDefaults *userData = [NSUserDefaults standardUserDefaults]; - [userData setObject:nil forKey:kActiveKeyboardsKey]; +- (void)clearEnabledKeyboards { + os_log_debug([KMLogs dataLog], "KMSettingsRepository clearEnabledKeyboards"); + [self.groupDefaults setObject:nil forKey:kEnabledKeyboardsKey]; } - /** * returns dictionary of persisted options for the single selected keyboard */ @@ -207,34 +254,85 @@ NSInteger const kCurrentDataModelVersionNumber = kVersionStoreDataInLibraryDirec return selectedOptionsMap; } +/** + * Read options map from the obsolete app defaults location: used only for migration to group defaults + */ +- (NSDictionary *)readFullOptionsMapFromAppDefaults { + return [self.appDefaults dictionaryForKey:kPersistedOptionsKey]; +} + /** * returns dictionary of all persisted options for all keyboards * (options are stored in UserDefaults as a map of maps) */ - (NSDictionary *)readFullOptionsMap { - NSUserDefaults *userData = [NSUserDefaults standardUserDefaults]; - return [userData dictionaryForKey:kPersistedOptionsKey]; + return [self.groupDefaults dictionaryForKey:kPersistedOptionsKey]; } - (void)writeFullOptionsMap:(NSDictionary *) fullOptionsMap { - NSUserDefaults *userData = [NSUserDefaults standardUserDefaults]; - [userData setObject:fullOptionsMap forKey:kPersistedOptionsKey]; + [self.groupDefaults setObject:fullOptionsMap forKey:kPersistedOptionsKey]; } - (void)removeAllOptions { - NSUserDefaults *userData = [NSUserDefaults standardUserDefaults]; - return [userData removeObjectForKey:kPersistedOptionsKey]; + return [self.groupDefaults removeObjectForKey:kPersistedOptionsKey]; } -- (void)convertSettingsForMigration { - os_log_debug([KMLogs dataLog], "converting settings in UserDefaults for migration"); - [self convertSelectedKeyboardPathForMigration]; - [self convertActiveKeyboardArrayForMigration]; - [self convertOptionsPathsForMigration]; +- (BOOL)readShowOskOnActivate { + return [self.groupDefaults boolForKey:kShowOskOnActivate]; } -- (void)convertSelectedKeyboardPathForMigration { - NSString *selectedKeyboardPath = [self readSelectedKeyboard]; +- (void)writeShowOskOnActivate:(BOOL)show { + [self.groupDefaults setBool:show forKey:kShowOskOnActivate]; +} + +- (BOOL)readForceSentryError { + return [self.groupDefaults boolForKey:kForceSentryError]; +} + +// MARK: Settings Migration + +- (void)migrateSettingsFromKeyman17 { + os_log_debug([KMLogs dataLog], "migrating settings in UserDefaults from Keyman 17 "); + + [self migrateSettingsFromKeyman17ToAppGroup]; + + // set kDataModelVersion for the current format + [self writeCurrentDataModelVersion]; + + [self removeMigratedInputMethodSettings]; +} + +/** + * Used only for setting migration from Keyman 17 to 19 + * Read the settings in the input method's user defaults + * Convert paths from full path to partial path + * Write to share app group user defaults (and write to a different key for enabled keyboards) + */ +- (void)migrateSettingsFromKeyman17ToAppGroup { + [self convertSelectedKeyboardPathFromKeyman17]; + [self convertActiveKeyboardArrayFromKeyman17]; + + // read showOsk from one defaults suite to another + if ([self.appDefaults objectForKey:kShowOskOnActivate] != nil) { + BOOL showOsk = [self.appDefaults boolForKey:kShowOskOnActivate]; + [self.groupDefaults setBool:showOsk forKey:kShowOskOnActivate]; + } + + // read forceSentryError from one defaults suite to another + if ([self.appDefaults objectForKey:kForceSentryError] != nil) { + BOOL forceSentryError = [self.appDefaults boolForKey:kForceSentryError]; + [self.groupDefaults setBool:forceSentryError forKey:kForceSentryError]; + } + + [self convertOptionsPathsFromKeyman17]; +} + +/** + * Convert the selectedKeyboard path from the full path of Keyman 17 to a partial path. + * Read it from the app defaults and write it to the group defaults. + */ +- (void)convertSelectedKeyboardPathFromKeyman17 { + NSString *selectedKeyboardPath = [self.appDefaults objectForKey:kSelectedKeyboardKey]; if (selectedKeyboardPath != nil) { NSString *newPathString = [self trimObsoleteKeyboardPath:selectedKeyboardPath]; @@ -245,6 +343,75 @@ NSInteger const kCurrentDataModelVersionNumber = kVersionStoreDataInLibraryDirec } } +/** + * Convert the activeKeyboards array and the full path for each keyboard from Keyman 17 to + * a partial path for each in the enabledKeyboards array. + * Read the array from the app defaults and write it to the group defaults. + */ +- (void)convertActiveKeyboardArrayFromKeyman17 { + // load from old location with old key name + NSArray *activeKeyboards = [self.appDefaults arrayForKey:kActiveKeyboardsKey]; + if (activeKeyboards != nil) { + NSMutableArray *enabledKeyboards = [[NSMutableArray alloc] initWithCapacity:0]; + BOOL didConvert = NO; + + for (NSString *oldPath in activeKeyboards) { + // shorten from full path to partial path + NSString *newPath = [self trimObsoleteKeyboardPath:oldPath]; + if ([oldPath isNotEqualTo:newPath]) { + [enabledKeyboards addObject:newPath]; + os_log_debug([KMLogs dataLog], "converted enabled keyboard from old path '%{public}@' to '%{public}@'", oldPath, newPath); + // if we have adjusted at least one path, set flag + didConvert = YES; + } else { + // if, somehow, the path does not need converting then retain it in new array + [enabledKeyboards addObject:oldPath]; + } + } + + // only write array to UserDefaults if we actually converted something + if (didConvert) { + [self writeEnabledKeyboards:enabledKeyboards]; + } + } +} + +/** + * Convert the options paths map from the full path of Keyman 17 to a partial path. + * Read it from the app defaults and write it to the group defaults. + */ +- (void)convertOptionsPathsFromKeyman17 { + NSDictionary * optionsMap = [self readFullOptionsMapFromAppDefaults]; + NSMutableDictionary *mutableOptionsMap = nil; + + if (optionsMap != nil) { + os_log_info([KMLogs configLog], "optionsMap != nil"); + mutableOptionsMap = [[NSMutableDictionary alloc] initWithCapacity:0]; + for (id key in optionsMap) { + os_log_info([KMLogs configLog], "persisted options found in UserDefaults with key = %{public}@", key); + } + for (NSString *keyboardPath in optionsMap) { + os_log_info([KMLogs configLog], "persisted options keyboard path = %{public}@", keyboardPath); + NSDictionary *keyboardOptions = [optionsMap objectForKey:keyboardPath]; + + NSString *newPathString = [self trimObsoleteKeyboardPath:keyboardPath]; + os_log_info([KMLogs configLog], "persisted options converted key = %{public}@", newPathString); + + if ([keyboardPath isNotEqualTo:newPathString]) { + // insert options into new map with newly converted path as key + [mutableOptionsMap setObject:keyboardOptions forKey:newPathString]; + os_log_debug([KMLogs dataLog], "converted option key from '%{public}@' to '%{public}@'", keyboardPath, newPathString); + } else { + // retain options that did not need converting + [mutableOptionsMap setObject:keyboardOptions forKey:keyboardPath]; + os_log_debug([KMLogs dataLog], "no conversion needed, adding options for '%{public}@'", keyboardPath); + } + } + // write the full option map for all keyboards to the group UserDefaults + [self writeFullOptionsMap:mutableOptionsMap]; + } +} + /** * To convert the keyboard path for the new location, just trim the parent directory from the path * No need to repeatedly store the parent directory with the path of each keyboard @@ -262,75 +429,60 @@ NSInteger const kCurrentDataModelVersionNumber = kVersionStoreDataInLibraryDirec return newPath; } -- (void)convertActiveKeyboardArrayForMigration { - NSMutableArray *keyboards = [self activeKeyboards]; - NSMutableArray *convertedActiveKeyboards = [[NSMutableArray alloc] initWithCapacity:0]; - BOOL didConvert = NO; - - for (NSString *oldPath in keyboards) { - NSString *newPath = [self trimObsoleteKeyboardPath:oldPath]; - if ([oldPath isNotEqualTo:newPath]) { - [convertedActiveKeyboards addObject:newPath]; - os_log_debug([KMLogs dataLog], "converted active keyboard from old path '%{public}@' to '%{public}@'", oldPath, newPath); - // if we have adjusted at least one path, set flag - didConvert = YES; - } else { - // if, somehow, the path does not need converting then retain it in new array - [convertedActiveKeyboards addObject:oldPath]; - } +- (void)migrateSettingsFromKeyman18 { + os_log_debug([KMLogs dataLog], "migrating settings in UserDefaults from Keyman 18"); + [self migrateSettingsFromKeyman18ToAppGroup]; + + // set kDataModelVersion for the current format + [self writeCurrentDataModelVersion]; + + [self removeMigratedInputMethodSettings]; +} + +/** + * Used only for setting migration from Keyman 18 to 19 + * Read the settings in the input method's user defaults + * Write them to the shared app group user defaults + * Delete them from the input method's user defaults + */ +- (void)migrateSettingsFromKeyman18ToAppGroup { + NSString *selectedKeyboard = [self.appDefaults stringForKey:kSelectedKeyboardKey]; + if (selectedKeyboard != nil) { + [self.groupDefaults setObject:selectedKeyboard forKey:kSelectedKeyboardKey]; + } + + NSArray * activeKeyboards = [self.appDefaults arrayForKey:kActiveKeyboardsKey]; + if (activeKeyboards != nil) { + [self.groupDefaults setObject:activeKeyboards forKey:kEnabledKeyboardsKey]; + } + + if ([self.appDefaults objectForKey:kShowOskOnActivate] != nil) { + BOOL showOsk = [self.appDefaults boolForKey:kShowOskOnActivate]; + [self.groupDefaults setBool:showOsk forKey:kShowOskOnActivate]; } - // only write array to UserDefaults if we actually converted something - if (didConvert) { - [self writeActiveKeyboards:convertedActiveKeyboards]; + if ([self.appDefaults objectForKey:kForceSentryError] != nil) { + BOOL forceSentryError = [self.appDefaults boolForKey:kForceSentryError]; + [self.groupDefaults setBool:forceSentryError forKey:kForceSentryError]; + } + + NSDictionary * persistedOptions = [self.appDefaults dictionaryForKey:kPersistedOptionsKey]; + if (persistedOptions != nil) { + [self.groupDefaults setObject:persistedOptions forKey:kPersistedOptionsKey]; } } -- (void)convertOptionsPathsForMigration { - NSDictionary * optionsMap = [self readFullOptionsMap]; - NSMutableDictionary *mutableOptionsMap = nil; - BOOL optionsChanged = NO; - - if (optionsMap != nil) { - os_log_info([KMLogs configLog], "optionsMap != nil"); - mutableOptionsMap = [[NSMutableDictionary alloc] initWithCapacity:0]; - for(id key in optionsMap) { - os_log_info([KMLogs configLog], "persisted options found in UserDefaults with key = %{public}@", key); - } - for (NSString *key in optionsMap) { - NSString *newPathString = [self trimObsoleteKeyboardPath:key]; - NSDictionary *optionsValue = [optionsMap objectForKey:key]; - - if ([key isNotEqualTo:newPathString]) { - optionsChanged = YES; - - // insert options into new map with newly converted path as key - [mutableOptionsMap setObject:optionsValue forKey:newPathString]; - os_log_debug([KMLogs dataLog], "converted option key from '%{public}@' to '%{public}@'", key, newPathString); - } else { - // retain options that did not need converting - [mutableOptionsMap setObject:optionsValue forKey:key]; - } - } - if (optionsChanged) { - [self writeFullOptionsMap:mutableOptionsMap]; - } - } +/** + * Removes input method settings that have been migrated to the app group. + * Does not eradicate everything but only those created by Keyman code. + * For example, the OSK window coordinates, created by NSWindow, must remain. + */ +- (void)removeMigratedInputMethodSettings { + [self.appDefaults removeObjectForKey:kSelectedKeyboardKey]; + [self.appDefaults removeObjectForKey:kActiveKeyboardsKey]; + [self.appDefaults removeObjectForKey:kShowOskOnActivate]; + [self.appDefaults removeObjectForKey:kForceSentryError]; + [self.appDefaults removeObjectForKey:kPersistedOptionsKey]; + [self.appDefaults removeObjectForKey:kDataModelVersion]; } - -- (BOOL)readShowOskOnActivate { - NSUserDefaults *userData = [NSUserDefaults standardUserDefaults]; - return [userData boolForKey:kShowOskOnActivate]; -} - -- (void)writeShowOskOnActivate:(BOOL)show { - NSUserDefaults *userData = [NSUserDefaults standardUserDefaults]; - [userData setBool:show forKey:kShowOskOnActivate]; -} - -- (BOOL)readForceSentryError { - NSUserDefaults *userData = [NSUserDefaults standardUserDefaults]; - return [userData boolForKey:kForceSentryError]; -} - @end diff --git a/mac/Keyman4MacIM/Keyman4MacIM/Logging/KMSentryHelper.h b/mac/Keyman4MacIM/Keyman4MacIM/Logging/KMSentryHelper.h index 75a5c27282..be8e8bdf70 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/Logging/KMSentryHelper.h +++ b/mac/Keyman4MacIM/Keyman4MacIM/Logging/KMSentryHelper.h @@ -18,7 +18,7 @@ NS_ASSUME_NONNULL_BEGIN + (void)addClientAppIdTag:(NSString *)value; + (void)addKeyboardTag:(NSString *)value; + (void)addHasAccessibilityTag:(BOOL)value; -+ (void)addActiveKeyboardCountTag:(NSUInteger)value; ++ (void)addEnabledKeyboardCountTag:(NSUInteger)value; + (void)addInfoBreadCrumb:(NSString *)category message:(NSString *)messageText; + (void)addDebugBreadCrumb:(NSString *)category message:(NSString *)messageText; + (void)addUserBreadCrumb:(NSString *)category message:(NSString *)messageText; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/Logging/KMSentryHelper.m b/mac/Keyman4MacIM/Keyman4MacIM/Logging/KMSentryHelper.m index 7c37be9ad9..d526694453 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/Logging/KMSentryHelper.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/Logging/KMSentryHelper.m @@ -17,7 +17,7 @@ NSString * const kOskVisibleTagName = @"oskVisible"; NSString * const kClientAppIdTagName = @"clientAppId"; NSString * const kKeyboardTagName = @"keyboard"; NSString * const kHasAccessibilityTagName = @"accessibilityEnabled"; -NSString * const kActiveKeyboardCountTagName = @"activeKeyboardCount"; +NSString * const kEnabledKeyboardCountTagName = @"enabledKeyboardCount"; NSString * const kLifecycleStateTagName = @"lifecycleState"; + (void)addLifecycleStateTag: (NSString *)value { @@ -48,8 +48,8 @@ NSString * const kLifecycleStateTagName = @"lifecycleState"; [self addCustomTag:kHasAccessibilityTagName withValue:value?@"true":@"false"]; } -+ (void)addActiveKeyboardCountTag:(NSUInteger)value { - [self addCustomTag:kActiveKeyboardCountTagName withValue:[[NSNumber numberWithUnsignedLong:value] stringValue]]; ++ (void)addEnabledKeyboardCountTag:(NSUInteger)value { + [self addCustomTag:kEnabledKeyboardCountTagName withValue:[[NSNumber numberWithUnsignedLong:value] stringValue]]; } /** diff --git a/mac/Keyman4MacIM/Keyman4MacIM/OnScreenKeyboard/OSKWindowController.m b/mac/Keyman4MacIM/Keyman4MacIM/OnScreenKeyboard/OSKWindowController.m index d4f457c754..7babc0166b 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/OnScreenKeyboard/OSKWindowController.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/OnScreenKeyboard/OSKWindowController.m @@ -98,7 +98,7 @@ } - (void)resetOSK { - os_log_debug([KMLogs oskLog], "OSKWindowController windowDidLoad"); + os_log_debug([KMLogs oskLog], "OSKWindowController resetOSK"); [self.oskView setKvk:[self.AppDelegate kvk]]; [self.oskView resetOSK]; if (_helpButton) { diff --git a/mac/Keyman4MacIM/Keyman4MacIM/OnScreenKeyboard/OSKWindowController.xib b/mac/Keyman4MacIM/Keyman4MacIM/OnScreenKeyboard/OSKWindowController.xib index 6a55541cb5..4da27bcdeb 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/OnScreenKeyboard/OSKWindowController.xib +++ b/mac/Keyman4MacIM/Keyman4MacIM/OnScreenKeyboard/OSKWindowController.xib @@ -1,8 +1,8 @@ - + - + @@ -18,7 +18,7 @@ - + diff --git a/mac/Keyman4MacIM/Keyman4MacIM/Privacy/PrivacyConsent.h b/mac/Keyman4MacIM/Keyman4MacIM/Privacy/PrivacyConsent.h index 22cdc8cae6..846bda8390 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/Privacy/PrivacyConsent.h +++ b/mac/Keyman4MacIM/Keyman4MacIM/Privacy/PrivacyConsent.h @@ -13,12 +13,26 @@ NS_ASSUME_NONNULL_BEGIN +// command strings passed from Keyman Configuration +extern NSString *kMigrateCommand; +extern NSString *kAccessCommand; +extern NSString *kCheckCommand; + +// notification messages sent to Keyman Configuration +extern NSString *kAcessibilityPermissionGrantedMessage; +extern NSString *kAcessibilityPermissionNotGrantedMessage; + +int requestAccessibility(void); +int checkAccessibility(void); + @interface PrivacyConsent : NSObject @property (nonatomic, strong) PrivacyWindowController *privacyDialog; @property (nonatomic, copy, nullable) void (^completionHandler)(void); + (PrivacyConsent *)shared; - (BOOL)checkAccessibility; +- (BOOL)checkPostEventAccess; - (void)requestPrivacyAccess:(void (^)(void))withCompletionHandler; +- (void)requestPrivacyAccessForKeyman19:(void (^)(void))withCompletionHandler; @end NS_ASSUME_NONNULL_END diff --git a/mac/Keyman4MacIM/Keyman4MacIM/Privacy/PrivacyConsent.m b/mac/Keyman4MacIM/Keyman4MacIM/Privacy/PrivacyConsent.m index 89e6338852..b4bd4000b2 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/Privacy/PrivacyConsent.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/Privacy/PrivacyConsent.m @@ -34,6 +34,47 @@ #import "PrivacyConsent.h" #import "KMLogs.h" +// command strings passed from Keyman Configuration +NSString *kMigrateCommand = @"migrate"; +NSString *kAccessCommand = @"access"; +NSString *kCheckCommand = @"check"; + +// notification messages sent to Keyman Configuration +NSString *kAcessibilityPermissionGrantedMessage = @"granted"; +NSString *kAcessibilityPermissionNotGrantedMessage = @"not-granted"; + +/** + * Make a request to the system to add Accessibility permissions for the Keyman input method. + * Executed as requested by the Keyman Configuration app. + */ +int requestAccessibility(void) { + os_log_info([KMLogs startupLog], "requestAccessibility executed"); + [PrivacyConsent.shared requestPrivacyAccessForKeyman19:^void (void){ + os_log_info([KMLogs startupLog], "requestAccessibility completion handler: requestPrivacyAccessForKeyman19 completed"); + }]; + return 0; +} + +/** + * Check whether Accessibility permissions have been granted by the user for the Keyman input method. + * Executed as requested by the Keyman Configuration app. + */ +int checkAccessibility(void) { + BOOL hasAccess = NO; + NSString *message = kAcessibilityPermissionNotGrantedMessage; + + hasAccess = [PrivacyConsent.shared checkPostEventAccess]; + os_log_info([KMLogs startupLog], "checkAccessibility hasAccess: %{public}@", hasAccess?@"YES":@"NO"); + + if (hasAccess) { + message = kAcessibilityPermissionGrantedMessage; + } + + [[NSDistributedNotificationCenter defaultCenter] postNotificationName:@"com.keyman.accessibility.state" object:message userInfo:nil deliverImmediately:YES]; + + return hasAccess; +} + @implementation PrivacyConsent + (PrivacyConsent *)shared @@ -88,6 +129,30 @@ } } +/** + * Designed to be called from main.m when Keyman is invoked from Keyman Configuration + * Does not pose privacy dialog + * No need to support OS version prior to 11.0 + */ +- (void)requestPrivacyAccessForKeyman19:(void (^)(void))withCompletionHandler +{ + BOOL hasAccessibility = NO; + + // check if we already have accessibility + hasAccessibility = [self checkPostEventAccess]; + + if (hasAccessibility) { + os_log([KMLogs privacyLog], "already has Accessibility: no need to make request."); + // call completionHandler immediately + withCompletionHandler(); + } else { + os_log([KMLogs privacyLog], "does not have Accessibility, calling requestPostEventAccess"); + [self requestPostEventAccess]; + withCompletionHandler(); + } +} + + /** * For macOS earlier than 11.0: request Accessibility access. */ @@ -238,4 +303,5 @@ return granted; } + @end diff --git a/mac/Keyman4MacIM/Keyman4MacIM/en.lproj/Localizable.strings b/mac/Keyman4MacIM/Keyman4MacIM/en.lproj/Localizable.strings index 367c301cea..f300927a85 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/en.lproj/Localizable.strings +++ b/mac/Keyman4MacIM/Keyman4MacIM/en.lproj/Localizable.strings @@ -72,10 +72,10 @@ "copyright-label" = "Copyright:"; /* message displayed to alert user to grant accessibility permission in Preferences, pre-Ventura */ -"privacy-alert-text" = "To function properly, Keyman requires accessibility features:\n\nGrant access in System Preferences, Security & Privacy.\nRestart your system."; +"privacy-alert-text" = "To function properly, Keyman requires accessibility features.\n\nGrant access in System Preferences, Security & Privacy."; /* message displayed to alert user to grant accessibility permission in Settings, for macOS Ventura and later */ -"privacy-alert-text-ventura" = "To function properly, Keyman requires accessibility features:\n\nGrant access in System Settings, Privacy & Security.\nRestart your system."; +"privacy-alert-text-ventura" = "To function properly, Keyman requires accessibility features.\n\nGrant access in System Settings, Privacy & Security."; /* Text of menu item in Input Menu when no Keyboards are configured -- include parentheses */ "no-keyboard-configured-menu-placeholder" = "(No Keyboard Configured)"; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/main.m b/mac/Keyman4MacIM/Keyman4MacIM/main.m index 3c8eb752e6..c17d51df5e 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/main.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/main.m @@ -1,30 +1,87 @@ -// -// main.m -// Keyman4MacIM -// -// Created by Serkan Kurt on 28/01/2015. -// Copyright (c) 2017 SIL International. All rights reserved. -// +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Serkan Kurt on 2015-01-28 + * + */ #import #import +#import "KMSettingsRepository.h" +#import "KMDataRepository.h" +#import "PrivacyConsent.h" #import "KMLogs.h" const NSString *kConnectionName = @"Keyman_Input_Connection"; IMKServer *server; +void runAsInputMethod(void) { + os_log_info([KMLogs startupLog], "main runAsInputMethod"); + NSString *identifier = [[NSBundle mainBundle] bundleIdentifier]; + server = [[IMKServer alloc] initWithName:(NSString *)kConnectionName bundleIdentifier:identifier]; + + BOOL didLoadNib = [[NSBundle mainBundle] loadNibNamed:@"MainMenu" owner:[NSApplication sharedApplication] topLevelObjects: nil]; + + os_log_info([KMLogs startupLog], "main Did load MainMenu nib: %@", didLoadNib?@"YES":@"NO"); + + [[NSApplication sharedApplication] run]; +} + +/** + * Migrate data from older locations to the appropriate place for the current version. + * Executed as requested by the Keyman Configuration app. + */ +int doMigration(void) { + os_log_info([KMLogs startupLog], "doMigration executed"); + + SettingsState state = [KMSettingsRepository.shared determineSettingsState]; + + switch (state) { + case KeymanSettingsVersion17: + os_log_info([KMLogs startupLog], "doMigration executed for Keyman 17 to current"); + [KMDataRepository.shared migrateDataFromKeyman17]; + [KMSettingsRepository.shared migrateSettingsFromKeyman17]; + break; + case KeymanSettingsVersion18: + os_log_info([KMLogs startupLog], "doMigration executed for Keyman 18 to current"); + [KMDataRepository.shared migrateDataFromKeyman18]; + [KMSettingsRepository.shared migrateSettingsFromKeyman18]; + break; + case KeymanSettingsNotFound: + os_log_info([KMLogs startupLog], "doMigration: no settings found, creating settings and directories"); + [KMSettingsRepository.shared createSharedSettingsIfNecessary]; + [KMDataRepository.shared createSharedDirectoriesIfNecessary]; + break; + case KeymanSettingsVersionCurrent: + os_log_info([KMLogs startupLog], "doMigration: no migration needed, settings are current"); + break; + } + + return 0; +} + int main(int argc, const char * argv[]) { - NSString *identifier; @autoreleasepool { - identifier = [[NSBundle mainBundle] bundleIdentifier]; - server = [[IMKServer alloc] initWithName:(NSString *)kConnectionName bundleIdentifier:identifier]; - - BOOL didLoadNib = [[NSBundle mainBundle] loadNibNamed:@"MainMenu" owner:[NSApplication sharedApplication] topLevelObjects: nil]; - - os_log_info([KMLogs startupLog], "main Did load MainMenu nib: %@", didLoadNib?@"YES":@"NO"); - - [[NSApplication sharedApplication] run]; + os_log_info([KMLogs startupLog], "main argument count: %d", argc); + if (argc == 1) { + runAsInputMethod(); + } else if (argc > 1) { + const char *installCommand = argv[1]; + NSString *commandString = [NSString stringWithUTF8String:installCommand]; + os_log_info([KMLogs startupLog], "main command: %{public}@", commandString); + + if ([commandString isEqualToString:kMigrateCommand]) { + return doMigration(); + } else if ([commandString isEqualToString:kAccessCommand]) { + return requestAccessibility(); + } else if ([commandString isEqualToString:kCheckCommand]) { + return checkAccessibility(); + } else { + os_log_info([KMLogs startupLog], "*** unknown command: %{public}@", commandString); + } + } } return 0; } + diff --git a/mac/KeymanEngine4Mac/KeymanEngine4Mac.xcodeproj/project.pbxproj b/mac/KeymanEngine4Mac/KeymanEngine4Mac.xcodeproj/project.pbxproj index 916087e100..98997bb8ac 100644 --- a/mac/KeymanEngine4Mac/KeymanEngine4Mac.xcodeproj/project.pbxproj +++ b/mac/KeymanEngine4Mac/KeymanEngine4Mac.xcodeproj/project.pbxproj @@ -661,16 +661,13 @@ CODE_SIGN_IDENTITY = "Mac Developer"; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - COPYRIGHT_STRING = "Copyright © $(COPYRIGHT_YEARS) SIL International."; - COPYRIGHT_STRING_RIGHTS = "$(COPYRIGHT_STRING) All rights reserved."; - COPYRIGHT_YEARS = "2017-2019"; + COPYRIGHT_STRING = "Copyright © SIL Global."; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; FRAMEWORK_VERSION = A; - GET_INFO_STRING = "Keyman Engine $(PRODUCT_VERSION) for macOS. $(COPYRIGHT_STRING_RIGHTS)"; INFOPLIST_FILE = KeymanEngine4Mac/Info.plist; INSTALL_PATH = "@executable_path/../Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; @@ -703,9 +700,7 @@ CODE_SIGN_IDENTITY = "Mac Developer"; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - COPYRIGHT_STRING = "Copyright © $(COPYRIGHT_YEARS) SIL International."; - COPYRIGHT_STRING_RIGHTS = "$(COPYRIGHT_STRING) All rights reserved."; - COPYRIGHT_YEARS = "2017-2019"; + COPYRIGHT_STRING = "Copyright © SIL Global."; COPY_PHASE_STRIP = NO; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = ""; @@ -713,7 +708,6 @@ DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; FRAMEWORK_VERSION = A; - GET_INFO_STRING = "Keyman Engine $(PRODUCT_VERSION) for macOS. $(COPYRIGHT_STRING_RIGHTS)"; INFOPLIST_FILE = KeymanEngine4Mac/Info.plist; INSTALL_PATH = "@executable_path/../Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; diff --git a/mac/KeymanEngine4Mac/KeymanEngine4Mac/Info.plist b/mac/KeymanEngine4Mac/KeymanEngine4Mac/Info.plist index 3f42d3e63f..a1a9900f6d 100644 --- a/mac/KeymanEngine4Mac/KeymanEngine4Mac/Info.plist +++ b/mac/KeymanEngine4Mac/KeymanEngine4Mac/Info.plist @@ -6,8 +6,6 @@ en CFBundleExecutable $(EXECUTABLE_NAME) - CFBundleGetInfoString - $(GET_INFO_STRING) CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion @@ -38,7 +36,7 @@ x.y
NSHumanReadableCopyright - $(COPYRIGHT_STRING_RIGHTS) + $(COPYRIGHT_STRING) NSPrincipalClass
diff --git a/mac/KeymanSettings/.gitignore b/mac/KeymanSettings/.gitignore new file mode 100644 index 0000000000..0023a53406 --- /dev/null +++ b/mac/KeymanSettings/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/mac/KeymanSettings/Package.resolved b/mac/KeymanSettings/Package.resolved new file mode 100644 index 0000000000..abfdf2cece --- /dev/null +++ b/mac/KeymanSettings/Package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "04c07e57c9c1c1c0bfd7062e1f880a134d063796215b35b16cdebf4fc3b4c0a9", + "pins" : [ + { + "identity" : "sentry-cocoa", + "kind" : "remoteSourceControl", + "location" : "https://github.com/getsentry/sentry-cocoa", + "state" : { + "revision" : "a00a9784fff7193cbb0f9fae509fec8107e07744", + "version" : "9.26.1" + } + }, + { + "identity" : "zipfoundation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/weichsel/ZIPFoundation.git", + "state" : { + "revision" : "22787ffb59de99e5dc1fbfe80b19c97a904ad48d", + "version" : "0.9.20" + } + } + ], + "version" : 3 +} diff --git a/mac/KeymanSettings/Package.swift b/mac/KeymanSettings/Package.swift new file mode 100644 index 0000000000..15f9a23b76 --- /dev/null +++ b/mac/KeymanSettings/Package.swift @@ -0,0 +1,47 @@ +// swift-tools-version: 6.2 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "KeymanSettings", + defaultLocalization: "en", + platforms: [ + .iOS(.v16), + .macOS(.v13) + ], + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "KeymanSettings", + targets: ["KeymanSettings"] + ), + ], + dependencies: [ + .package(url: "https://github.com/weichsel/ZIPFoundation.git", .upToNextMajor(from: "0.9.0")), + .package(url: "https://github.com/getsentry/sentry-cocoa", from: "9.26.1") + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "KeymanSettings", + dependencies: [ + .product(name: "ZIPFoundation", package: "ZIPFoundation"), + .product(name: "Sentry-Dynamic", package: "sentry-cocoa") + ], + path: "Sources", + resources: [ + .process("Resources") + ] + ), + .testTarget( + name: "KeymanSettingsTests", + dependencies: ["KeymanSettings"], + path: "Tests", + resources: [ + .copy("Resources/amharic.kmp.json") // The test data files, copy files without modifying them + ] + ), + ] +) diff --git a/mac/KeymanSettings/Sources/KeymanSettings/ConfigAppUtil.swift b/mac/KeymanSettings/Sources/KeymanSettings/ConfigAppUtil.swift new file mode 100644 index 0000000000..5212efc17e --- /dev/null +++ b/mac/KeymanSettings/Sources/KeymanSettings/ConfigAppUtil.swift @@ -0,0 +1,75 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2026-07-13 + * + * Used for getting application metadata from the Config application + */ + +import Foundation +import OSLog +import Sentry + +extension Logger { + private static let settingsSubsystem = "com.keyman.settings" + static let setup = Logger(subsystem: settingsSubsystem, category: "setup") + static let data = Logger(subsystem: settingsSubsystem, category: "data") +} + +public struct ConfigAppUtil { + static public let configBundleId = "com.keyman.config" + + // executes exactly once, the first time any config variable is read + private static let configMap: [String: String]? = { + guard let map = Bundle.main.infoDictionary?["Keyman"] as? [String: String] else { + let message = "Keyman dictionary not found in main app bundle." + LogUtil.errorBreadcrumb(message, category: .setup) + fatalError(message) + } + return map + }() + + public static let sentryEnvironment: String = { + return configMap?["SentryEnvironment"] as? String ?? "" + }() + + public static let appTier: String = { + return configMap?["Tier"] as? String ?? "" + }() + + public static let versionTag: String = { + return configMap?["VersionTag"] as? String ?? "" + }() + + public static let versionWithTag: String = { + return configMap?["VersionWithTag"] as? String ?? "" + }() + + public static let versionGitTag: String = { + return configMap?["VersionGitTag"] as? String ?? "" + }() + + public static let versionRelease: String = { + return configMap?["VersionRelease"] as? String ?? "" + }() + + /** + * returns the short version string from the bundle of the Config app + */ + static public func configAppVersion() -> String { + let kConfigTestVersionKey = "testConfigVersion" + + if let configTestVersion = UserDefaults.standard.string(forKey: kConfigTestVersionKey) { + // for testing only, if a test version string is found in the standard UserDefaults of the config app + // (not in the group container UserDefaults), then use it instead + return configTestVersion + } else { + // get the actual version number from the application bundle + return Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown" + } + } + + static func captureSentryError(_ error: Error) { + SentrySDK.capture(error: error) + } +} diff --git a/mac/KeymanSettings/Sources/KeymanSettings/DefaultsRepo.swift b/mac/KeymanSettings/Sources/KeymanSettings/DefaultsRepo.swift new file mode 100644 index 0000000000..14c5102372 --- /dev/null +++ b/mac/KeymanSettings/Sources/KeymanSettings/DefaultsRepo.swift @@ -0,0 +1,23 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2026-04-28 + * + * DefaultsRepo is a protocol that exposes the ability to read, write and remove + * Keyman UserDefaults settings. + * + */ + +import Foundation + +public protocol DefaultsRepo { + func readDataModelVersion() -> Int + func readInstallationState() -> Dictionary? + func writeInstallationState(_ dictionary: Dictionary) + func deleteInstallationState() + func readEnabledKeyboards() -> Set + func writeEnabledKeyboards(enabledKeyboardsArray: [String]) + func readSelectedKeyboard() -> String + func writeSelectedKeyboard(keyboardName: String) + func clearDefaults() +} diff --git a/mac/KeymanSettings/Sources/KeymanSettings/InputMethodUtil.swift b/mac/KeymanSettings/Sources/KeymanSettings/InputMethodUtil.swift new file mode 100644 index 0000000000..388de6c03f --- /dev/null +++ b/mac/KeymanSettings/Sources/KeymanSettings/InputMethodUtil.swift @@ -0,0 +1,527 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2026-05-11 + * + * Stateless utility for enabling, selecting, killing, requesting Accessibility permission + * and getting information about the Keyman input method and for selecting another input source + */ + +import Foundation +import Carbon.HIToolbox +import AppKit +import OSLog + +public enum KeymanVersionCheckError: Error { + case inputMethodNotFound + case versionNotFound +} + +public enum KeymanInvocationError: Error { + case inputMethodNotFound + case inputMethodCouldNotBeInvoked +} + +public let kAccessibilityPermissionGrantedMessage = "granted" + +public class InputMethodUtil { + public static let keymanBundleId = "keyman.inputmethod.Keyman" + public static let groupId = "group.com.keyman" + + public static let keymanDomain = "keyman.com" + public static let keymanHelpDomain = "help.keyman.com" + public static let keymanApiDomain = "api.keyman.com" + + public let keymanInputMethodApplicationName = "Keyman.app" + + // only initialized after message is received from input method + public var accessibilityPermissionGranted: Bool? = nil + fileprivate let pathUtil: KeymanPaths + fileprivate var observer: NSObjectProtocol? + + let kMigrateCommand = "migrate" + let kAccessCommand = "access" + let kCheckCommand = "check" + + public init() throws { + try self.pathUtil = KeymanPaths() + } + + /** + * true if the Keyman input method exists at `~/Library/Input Methods` + */ + public func keymanInputMethodExists() -> Bool { + guard let inputMethodUrl = pathUtil.buildInputMethodPathUrl(fileName: self.keymanInputMethodApplicationName) else { + Logger.setup.log("Keyman input method not found, failed to create input method url") + return false + } + + return FileManager.default.fileExists(atPath: inputMethodUrl.path(percentEncoded: false)) + } + + /** + * Returns version number string of Keyman input method + */ + public func getKeymanInputMethodVersion() throws -> String { + return try self.appVersionForInputMethod(applicationName: keymanInputMethodApplicationName) + } + + /** + * Returns true if the Keyman input method is running + */ + public func isKeymanInputMethodRunning() -> Bool { + return self.isApplicationRunning(bundleId: InputMethodUtil.keymanBundleId) + } + + /** + * returns true if the specified bundleId is enabled + */ + public func isKeymanInputMethodEnabled() -> Bool { + return self.isInputMethodEnabled(bundleId: InputMethodUtil.keymanBundleId) + } + + /** + * attempts to register the Keyman input method and returns true if successful + * a newly installed input method must be registered before enabling + */ + public func registerKeymanInputMethod() -> Bool { + return self.registerInputMethod(bundleId: InputMethodUtil.keymanBundleId) + } + + /** + * attempts to enable the Keyman input method and returns true if successful + */ + public func enableKeymanInputMethod() -> Bool { + return self.enableInputMethod(bundleId: InputMethodUtil.keymanBundleId) + } + + /** + * attempts to select the Keyman input method and returns true if successful + */ + public func selectKeymanInputMethod() -> Bool { + return self.selectInputSource(inputSourceId: InputMethodUtil.keymanBundleId) + } + + /** + * attempts to disable the Keyman input method and returns true if successful + */ + public func disableKeymanInputMethod() -> Bool { + return self.disableInputMethod(bundleId: InputMethodUtil.keymanBundleId) + } + + /** + * Kill Keyman -- only permitted when running oustide sandbox + */ + public func killKeymanInputMethod() -> Bool { + return killApplication(bundleId: InputMethodUtil.keymanBundleId) + } + + /** + * uninstalls the Keyman input method + * note: commenting out for now as default security settings prevent us from deleting the app + */ +// public func uninstallKeyman() { +// _ = self.killKeymanInputMethod() +// _ = self.disableKeymanInputMethod() +// self.deleteKeyman() +// } + + /** + * Returns version number string for the specifed app located at `~/Library/Input Methods` + */ + func appVersionForInputMethod(applicationName: String) throws -> String { + guard let location = pathUtil.buildInputMethodPathUrl(fileName: applicationName) else { + throw KeymanVersionCheckError.inputMethodNotFound + } + + guard let keymanBundle = Bundle(url: location) else { + throw KeymanVersionCheckError.inputMethodNotFound + } + + guard let infoDictionary = keymanBundle.infoDictionary else { + throw KeymanVersionCheckError.versionNotFound + } + + guard let appVersionString = infoDictionary["CFBundleShortVersionString"] as? String else { + throw KeymanVersionCheckError.versionNotFound + } + + return appVersionString + } + + /** + * returns true if the application with the specified bundleId is running + */ + func isApplicationRunning(bundleId: String) -> Bool { + return !NSRunningApplication.runningApplications(withBundleIdentifier: bundleId).isEmpty + } + + /** + * launch the specified input method + */ + func runInputMethod(inputMethodName: String) -> Bool { + if let inputMethodUrl = pathUtil.buildInputMethodPathUrl(fileName: inputMethodName) { + return NSWorkspace.shared.open(inputMethodUrl) + } else { + return false + } + } + + public func invokeKeymanInputMethodMigration() -> Bool { + Logger.setup.log("invokeKeymanInputMethodMigration()") + return self.invokeKeymanInputMethodAsSubProcess(argument: kMigrateCommand) == 0 + } + + /** + * Calls Keyman input method to request accessibility permission. + * The call varies based on whether Keyman is running or not. + * + * If the input method is running, then send a notification to the input method + * to request access. + * + * If the input method is not running, invoke the input method as a separate process + * with a special argument that causes it to run, request access and exit. + */ + + public func invokeKeymanInputMethodRequestAccess() -> Bool { + var success = false + + Logger.setup.info("invokeKeymanInputMethodRequestAccess()") + LogUtil.infoBreadcrumb("invokeKeymanInputMethodRequestAccess()", category: .setup) + do { + if self.isKeymanInputMethodRunning() { + // Keyman is running, send notification to input method + Logger.setup.debug("invokeKeymanInputMethodRequestAccess(), calling sendAccessibilityRequest()") + self.sendAccessibilityRequest() + } else { + // Keyman is not running, launch input method and request accessibility with specific command line argument + Logger.setup.debug("invokeKeymanInputMethodRequestAccess(), calling launchKeymanInputMethodAsSeparateProcess()") + try self.launchKeymanInputMethodAsSeparateProcess(argument: kAccessCommand) + } + + success = true + } catch { + Logger.setup.error("error requesting Accessibility from input method: \(error as NSError, privacy: .public)") + LogUtil.errorBreadcrumb("error requesting Accessibility from input method: \(error as NSError)", category: .setup) + } + + return success + } + + /** + * Calls Keyman input method to check whether it has accessibility permission granted. + * The call varies based on whether Keyman is running or not. + * + * If the input method is not running, then invoke it as a separate process with a special argument + * so that it runs, checks the access, sends a notification and exits. + * + * If the input method is running, then do not kill it unless `forceRestart` flag is set to true. + * Instead send a notification to instruct it to make the check and respond. + * + * If the input method is running and `forceRestart` is true, then kill Keyman and invoke it + * as a separate process. This is needed when a user is in the middle of an install or repair. + * + * The actual result is not returned from Keyman. + * After this function is called, listen to the `DistributedNotificationCenter` for the notification named + * `accessibilityStateResponse` + * It contains a message with a value of `granted` or `not-granted` + */ + func invokeKeymanInputMethodCheckAccess(forceInputMethodRestart forceRestart: Bool) throws { + Logger.setup.info("invokeKeymanInputMethodCheckAccess()") + LogUtil.infoBreadcrumb("invokeKeymanInputMethodCheckAccess()", category: .setup) + + var keymanIsRunning = self.isKeymanInputMethodRunning() + + if keymanIsRunning, forceRestart { + // kill Keyman so that it refreshes its accessibility state + _ = self.killKeymanInputMethod() + keymanIsRunning = false + Logger.setup.debug("invokeKeymanInputMethodCheckAccess(), killed Keyman") + } + + if keymanIsRunning { + // Keyman is running: check accessibility by sending distributed notification + Logger.setup.debug("invokeKeymanInputMethodCheckAccess(), calling sendAccessibilityCheckRequest()") + self.sendAccessibilityCheckRequest() + } else { + // Keyman is not running, launch and check accessibility with specific command line argument + Logger.setup.debug("invokeKeymanInputMethodCheckAccess(), calling launchKeymanInputMethodAsSeparateProcess()") + try self.launchKeymanInputMethodAsSeparateProcess(argument: kCheckCommand) + } + } + + /** + * Send a distributed notification to check the state of Accessibility permission. + */ + func sendAccessibilityCheckRequest() { + DistributedNotificationCenter.default().postNotificationName ( + .accessibilityCheckRequest, + object: nil, + userInfo: nil, + deliverImmediately: true + ) + } + + /** + * Send a distributed notification to request Accessibility permission. + */ + func sendAccessibilityRequest() { + DistributedNotificationCenter.default().postNotificationName ( + .accessibilityRequest, + object: nil, + userInfo: nil, + deliverImmediately: true + ) + } + + /** + * run Keyman as a subprocess with the specifed argument and return the result + */ + func invokeKeymanInputMethodAsSubProcess(argument: String) -> Int { + var result = -1 + let process = Process() + if let executableUrl = self.pathUtil.buildInputMethodExecutableUrl(fileName: self.keymanInputMethodApplicationName) { + process.executableURL = executableUrl + Logger.setup.info("invoking Keyman at: \(String(describing: process.executableURL?.cleanUrlPath()), privacy: .public)") + LogUtil.infoBreadcrumb("invoking Keyman at: \(String(describing: process.executableURL?.cleanUrlPath()))", category: .setup) + process.arguments = [argument] + } + + var currentEnv = ProcessInfo.processInfo.environment + + currentEnv["__CFBundleIdentifier"] = InputMethodUtil.keymanBundleId // set bundle ID to that of the Keyman input method + process.environment = currentEnv + + do { + try process.run() // start Keyman + process.waitUntilExit() // wait for it to finish + result = Int(process.terminationStatus) + } catch { + Logger.setup.error("Failed to run process: \(error as NSError, privacy: .public)") + LogUtil.errorBreadcrumb("Failed to run process: \(error as NSError)", category: .setup) + } + + return result + } + + /** + * launches the Keyman input method as an independent process with the specifed argument and return the result + */ + func launchKeymanInputMethodAsSeparateProcess(argument: String = "") throws { + let openConfig = NSWorkspace.OpenConfiguration() + if !argument.isEmpty { + openConfig.arguments = [argument] + } + + guard let inputMethodUrl = pathUtil.buildInputMethodPathUrl(fileName: self.keymanInputMethodApplicationName) else { + Logger.setup.error("launchKeymanInputMethodAsSeparateProcess, failed to create input method url") + LogUtil.errorBreadcrumb("launchKeymanInputMethodAsSeparateProcess, failed to create input method url", category: .setup) + throw KeymanInvocationError.inputMethodNotFound + } + + NSWorkspace.shared.openApplication(at: inputMethodUrl, configuration: openConfig) { (app, error) in + if let error = error { + Logger.setup.error("Could not launch Keyman input method at \(inputMethodUrl.cleanUrlPath()), due to error: \(error as NSError, privacy: .public)") + LogUtil.errorBreadcrumb("Could not launch Keyman input method at \(inputMethodUrl.cleanUrlPath()), due to error: \(error as NSError)", category: .setup) + } + } + } + + /** + * Calls Keyman input method to check whether it has accessibility permission granted. + * Receives response as distributed notification named `accessibilityStateResponse` + */ + public func doAsyncAccessibilityCheck(forceInputMethodRestart forceRestart: Bool) { + do { + try self.invokeKeymanInputMethodCheckAccess(forceInputMethodRestart: forceRestart) + } catch { + Logger.setup.error("invoking Keyman failed: \(error as NSError, privacy: .public)") + LogUtil.errorBreadcrumb("invoking Keyman failed: \(error as NSError)", category: .setup) + } + + let timeStyle = Date.FormatStyle() + .hour(.twoDigits(amPM: Date.FormatStyle.Symbol.Hour.AMPMStyle.abbreviated)) + .minute(.twoDigits) + .second(.twoDigits) + .secondFraction(.fractional(3)) + Logger.setup.log("doAsyncAccessibilityCheck, listening across process boundaries, time: \(Date().formatted(timeStyle))") + } + + /** + * Kill the application with the specified bundle Id + * This is only permitted when running outside sandbox + */ + func killApplication(bundleId: String) -> Bool { + let runningApps = NSRunningApplication.runningApplications(withBundleIdentifier: bundleId) + var didTerminate = false + + Logger.setup.debug("Running app count for \(bundleId, privacy: .public) = \(runningApps.count)") + if let runningApp = runningApps.first { + let processId = runningApp.processIdentifier + didTerminate = runningApp.terminate() + Logger.setup.log("process \(processId) for \(bundleId, privacy: .public) was terminated: \(didTerminate)") + } + + return didTerminate + } + + /** + * returns the TISInputSource for the specified bundleId + */ + func getKeymanInputSource() -> TISInputSource? { + return self.getInputSource(bundleId: InputMethodUtil.keymanBundleId) + } + + /** + * returns the TISInputSource for the specified bundleId + */ + func getInputSource(bundleId: String) -> TISInputSource? { + let properties: [String: Any] = [ + kTISPropertyInputSourceID as String: bundleId + ] + let inputSourceList = TISCreateInputSourceList(properties as CFDictionary, true) + guard let sources = inputSourceList?.takeRetainedValue() as? [TISInputSource], + let targetSource = sources.first else { + Logger.setup.error("Could not find the specified input source with bundleID: \(bundleId, privacy: .public)") + LogUtil.errorBreadcrumb("Could not find the specified input source with bundleID: \(bundleId)", category: .setup) + return(nil) + } + + return targetSource + } + + /** + * returns true if the input method with the specified bundleId is enabled + */ + func isInputMethodEnabled(bundleId: String) -> Bool { + var enabled = false + + if let inputSource = self.getInputSource(bundleId: bundleId) { + let enabledValue = TISGetInputSourceProperty(inputSource, kTISPropertyInputSourceIsEnabled) + if let cfType = enabledValue { + // Bridge the CFTypeRef to an Unmanaged and then to a Swift String + if let inputMethodEnabled = Unmanaged.fromOpaque(cfType).takeUnretainedValue() as? Bool { + enabled = inputMethodEnabled + Logger.setup.info("isInputMethodEnabled: \(enabled)") + LogUtil.infoBreadcrumb("isInputMethodEnabled: \(enabled)", category: .setup) + } else { + Logger.setup.error("Could not read retrieved enabled property for bundleId: \(bundleId, privacy: .public)") + LogUtil.errorBreadcrumb("Could not read retrieved enabled property for bundleId: \(bundleId)", category: .setup) + } + } else { + Logger.setup.error("Failed to get enabled property for bundleId: \(bundleId, privacy: .public)") + } + } else { + Logger.setup.error("Failed to get input source for bundleId: \(bundleId, privacy: .public)") + LogUtil.errorBreadcrumb("Failed to get input source for bundleId: \(bundleId)", category: .setup) + } + return enabled + } + + /** + * register the newly installed input method with the specified bundleId + * this will allow a `TISInputSourceRef` to be obtained to access the input source + */ + func registerInputMethod(bundleId: String) -> Bool { + var success = false + + guard let inputMethodUrl = pathUtil.buildInputMethodPathUrl(fileName: self.keymanInputMethodApplicationName) else { + Logger.setup.error("registerInputMethod, failed to create input method url for bundleId: \(bundleId, privacy: .public)") + LogUtil.errorBreadcrumb("registerInputMethod, failed to create input method url for bundleId: \(bundleId)", category: .setup) + return false + } + let cfUrl = inputMethodUrl as CFURL + + let result = TISRegisterInputSource(cfUrl) + success = result == noErr + + if (success) { + Logger.setup.log("registerInputMethod for bundle ID '\(bundleId, privacy: .public)': success") + } else { + Logger.setup.error("registerInputMethod for bundle ID '\(bundleId, privacy: .public)' failed, result = \(result)") + LogUtil.errorBreadcrumb("registerInputMethod for bundle ID '\(bundleId)' failed, result = \(result)", category: .setup) + } + + return success + } + + /** + * attempts to enable the input method with the specified bundleId, returns true if successful + */ + func enableInputMethod(bundleId: String) -> Bool { + var success = false + if let inputSource = self.getInputSource(bundleId: bundleId) { + let result = TISEnableInputSource(inputSource) + success = result == noErr + if (success) { + Logger.setup.log("enableInputMethod for bundle ID '\(bundleId, privacy: .public)': success") + } else { + Logger.setup.error("enableInputMethod for bundle ID '\(bundleId, privacy: .public)' failed, result = \(result)") + LogUtil.errorBreadcrumb("enableInputMethod for bundle ID '\(bundleId)' failed, result = \(result)", category: .setup) + } + } + return success + } + + /** + * attempts to disable the input method with the specified bundleId, returns true if successful + */ + func disableInputMethod(bundleId: String) -> Bool { + var success = false + if let inputSource = self.getInputSource(bundleId: bundleId) { + let result = TISDisableInputSource(inputSource) + success = result == noErr + if (success) { + Logger.setup.log("disableInputMethod for bundle ID '\(bundleId, privacy: .public)': success") + } else { + Logger.setup.error("disableInputMethod for bundle ID '\(bundleId, privacy: .public)' failed, result = \(result)") + LogUtil.errorBreadcrumb("disableInputMethod for bundle ID '\(bundleId)' failed, result = \(result)", category: .setup) + } + } + return success + } + + /** + * deletes the Keyman input method + * note: commenting out for now as default security settings prevent us from deleting the app + */ +// func deleteKeyman() { +// let fileManager = FileManager.default +// if let keymanFile = self.pathUtil.buildInputMethodPathUrl(fileName: keymanInputMethodApplicationName) { +// do { +// try fileManager.removeItem(at: keymanFile) +// print("Successfully deleted Keyman.app") +// } catch { +// print("Error deleting Keyman.app: \(error)") +// } +// } else { +// print("Keyman.app not found") +// } +// } + + /** + * select the input source with the specified input source id and return true if successful + */ + public func selectInputSource(inputSourceId: String) -> Bool { + let properties: [String: Any] = [ + kTISPropertyInputSourceID as String: inputSourceId + ] + let inputSourceList = TISCreateInputSourceList(properties as CFDictionary, false) + guard let sources = inputSourceList?.takeRetainedValue() as? [TISInputSource], + let targetSource = sources.first else { + Logger.setup.error("Error: Could not find the input source '\(inputSourceId, privacy: .public)'.") + LogUtil.errorBreadcrumb("Error: Could not find the input source '\(inputSourceId)", category: .setup) + return false + } + + let result = TISSelectInputSource(targetSource) + if result != noErr { + Logger.setup.error("Error selecting input source '\(inputSourceId, privacy: .public)'.") + LogUtil.errorBreadcrumb("Error selecting input source '\(inputSourceId)", category: .setup) + return false + } else { + Logger.setup.log("Successfully selected input source '\(inputSourceId, privacy: .public)'.") + return true + } + } +} diff --git a/mac/KeymanSettings/Sources/KeymanSettings/Localizable.xcstrings b/mac/KeymanSettings/Sources/KeymanSettings/Localizable.xcstrings new file mode 100644 index 0000000000..709c7911da --- /dev/null +++ b/mac/KeymanSettings/Sources/KeymanSettings/Localizable.xcstrings @@ -0,0 +1,248 @@ +{ + "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.inf.unreadable" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The package's kmp.inf file could not be parsed." + } + } + } + }, + "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" +} \ No newline at end of file diff --git a/mac/KeymanSettings/Sources/KeymanSettings/LogUtil.swift b/mac/KeymanSettings/Sources/KeymanSettings/LogUtil.swift new file mode 100644 index 0000000000..027c9fb190 --- /dev/null +++ b/mac/KeymanSettings/Sources/KeymanSettings/LogUtil.swift @@ -0,0 +1,66 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2026-09-01 + * + * Convenience messages for logging to Sentry + */ + +import Sentry + +import Foundation + +/** + * Extend URL to add function that cleans path strings, removing the home directory from the path. + */ +extension URL { + /** + * If the path contains the user's home, replace it with ~ as the home directory name + * may contain the user's name and should not be written to the logs. + */ + public func cleanUrlPath() -> String { + guard self.isFileURL else { return self.absoluteString } + + let unescapedPath = self.path(percentEncoded: false) + let homeDirectory = NSHomeDirectory() + + if unescapedPath.hasPrefix(homeDirectory) { + let relativeComponent = unescapedPath.dropFirst(homeDirectory.count) + return "~\(relativeComponent)" + } + + return unescapedPath + } +} + +public struct LogUtil { + public enum LogCategory: String { + case setup // related to start of app and installation + case data // related to settings and reading and writing packages + case app // general app functionality and UI + case download // downloading keyboard packages + } + + public static func debugBreadcrumb(_ message: String, category: LogCategory) { + addBreadcrumb(message: message, category: category.rawValue, level: .debug) + } + + public static func infoBreadcrumb(_ message: String, category: LogCategory) { + addBreadcrumb(message: message, category: category.rawValue, level: .info) + } + + public static func warningBreadcrumb(_ message: String, category: LogCategory) { + addBreadcrumb(message: message, category: category.rawValue, level: .warning) + } + + public static func errorBreadcrumb(_ message: String, category: LogCategory) { + addBreadcrumb(message: message, category: category.rawValue, level: .error) + } + + // Private helper to talk to Sentry + private static func addBreadcrumb(message: String, category: String, level: SentryLevel) { + let crumb = Breadcrumb(level: level, category: category) + crumb.message = message + SentrySDK.addBreadcrumb(crumb) + } +} diff --git a/mac/KeymanSettings/Sources/KeymanSettings/PackageRepo.swift b/mac/KeymanSettings/Sources/KeymanSettings/PackageRepo.swift new file mode 100644 index 0000000000..505fa16bf5 --- /dev/null +++ b/mac/KeymanSettings/Sources/KeymanSettings/PackageRepo.swift @@ -0,0 +1,24 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2025-12-10 + * + * PackageRepo is a protocol that exposes the ability to read, write and remove + * Keyman packages. + * + */ + +import Foundation + +public protocol PackageRepo { + func keyman19SharedDataDirectoryExists() -> Bool + func createKeyman19SharedDataDirectoriesIfNeeded() throws + func loadAllPackages() -> [KeymanPackage] + func deletePackage(package: KeymanPackage) + func unzipKmpFile(at kmpFileUrl: URL, to packageDestinationUrl: URL) throws + func loadSinglePackage(packageUrl: URL) throws -> KeymanPackage + func getDownloadUrl(for kmpFilename: String) -> URL + func getUnzipDestinationUrl(for packageName: String) -> URL + func buildInstallationUrlForPackageName(directoryName: String) -> URL + func cleanupTempDirectory() +} diff --git a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift new file mode 100644 index 0000000000..c91626f857 --- /dev/null +++ b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift @@ -0,0 +1,639 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2026-04-02 + * + * Class that exposes all settings information to the Keyman Configuration app + * Provides a place for the config app to bind directly to the settings + * and update when changes are made + * + * The Settings consist of two types of data: + * - Keyman packages that have been installed on disk in the Group Containers directory + * - Some lightweight preferences that are stored in the macOS UserDefaults Database + * + * Both the packages and the defaults are in a shared location that can be accessed by + * the Keyman config app and the Keyman input method. + * + * This class accesses the two types of data through PackageRepository and DefaultsRepository + * and publishes the combined data to the UI through the `installedPackages` array. + * + * All installed packages are display in the configuration app. + * Only installed packages that are enabled appear in the Keyman menu presented by the input method. + */ + +import Foundation +import Combine +import ZIPFoundation +import OSLog +import Sentry + +public enum InstallPackageError: LocalizedError { + case packageInstallationAlreadyInProgress + case fontCopyError + 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: + 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) + } + } +} + +// distributed notifications +public extension Notification.Name { + // sent from input method, received by InstallationCheck + static let accessibilityStateResponse = Notification.Name("com.keyman.accessibility.state") + // sent from config app (DefaultsRepository), received by input method + static let keyboardsChanged = Notification.Name("com.keyman.keyboards.changed") + // sent from config app (InputMethodUtil), received by input method + static let accessibilityCheckRequest = Notification.Name("com.keyman.accessibility.check.request") + // sent from config app (InputMethodUtil), received by input method + static let accessibilityRequest = Notification.Name("com.keyman.accessibility.request") +} + +// in-app notifications +public extension Notification.Name { + // sent from InstallationContainer to SettingsContainer so it can reload packages + static let dataMigrated = Notification.Name("com.keyman.data.migrated") +} + +public enum DropKmpError: LocalizedError { + case invalidFileType(String) + case exceededFileDropLimit + + private var packageBundle: LocalizedStringResource.BundleDescription { + .atURL(Bundle.module.bundleURL) + } + public var errorDescription: String? { + switch self { + 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) + } + } +} + +package let kmpFileExtension = ".kmp" +package let kmpFileExtensionWithoutDot = "kmp" + +@MainActor // run on the main actor since data is published directly to the UI +public class SettingsContainer : ObservableObject { + // installed packages are loaded from disk, each package may contain one or more keyboard + fileprivate var installedPackages: [KeymanPackage] { + didSet { + // whenever this array is modified, update the arrays that are derived from it + self.updatePackageArrays() + } + } + + // Maintain two arrays for use by configuration views. + // One is for packages with single keyboards and one for packages with multiple keyboards. + // Each array is sorted alphabetically by package name. + // These arrays are updated by a property observer on installedPackages. + // (Consider installedPackages as the source of truth and these arrays for presentation purposes.) + @Published public private(set) var singleKeyboardPackages: [KeymanPackage] + @Published public private(set) var multiKeyboardPackages: [KeymanPackage] + + // when a new package is being installed, it is tracked here + fileprivate var packageInstall: PackageInstallHelper? = nil + + fileprivate let packageRepository: PackageRepo + fileprivate let defaultsRepository: DefaultsRepo + + // the selected keyboard is stored in the UserDefaults + // not indicated in the Config app but this could change + fileprivate var selectedKeyboard: String + + private let keyboardSearchPrefix = "https://keyman.com/go/macos/14.0/download-keyboards" + + public var keyboardSearchUrl: URL { + var searchUrl = URL(string: keyboardSearchPrefix)! + + let versionQueryItem = URLQueryItem(name:"version", value: ConfigAppUtil.configAppVersion()) + searchUrl.append(queryItems:[versionQueryItem]) + + if let languageString = self.getPreferredLangauge() { + let languageQueryItem = URLQueryItem(name:"lang", value: languageString) + searchUrl.append(queryItems:[languageQueryItem]) + } + Logger.setup.info("full searchUrl: \(searchUrl.absoluteString, privacy: .public)") + LogUtil.infoBreadcrumb("full searchUrl: \(searchUrl.absoluteString)", category: .setup) + return searchUrl + } + + /** + * returns the preferred language based on the user's preferences set in System Settings + */ + private func getPreferredLangauge() -> String? { + // returns an array of language identifiers ordered by the user's preference + // this is independent of what the languages the app actually supports + let systemLanguages = Locale.preferredLanguages + + var primaryLanguage: String? + if let language = systemLanguages.first { + primaryLanguage = language + Logger.setup.info("primary system language: \(language, privacy: .public)") + LogUtil.infoBreadcrumb("primary system language: \(language)", category: .setup) + } + + return primaryLanguage + } + + public init() { + // initialize arrays before loading packages + self.singleKeyboardPackages = [] + self.multiKeyboardPackages = [] + self.installedPackages = [] + + // create the package repository, gaining access to the app group container directory + do { + try self.packageRepository = PackageRepository() + Logger.data.log("Found documents group container") + } catch KeymanPathError.groupContainerNotFound { + Logger.data.error("Document group container not found") + fatalError("Document group container not found.") + } catch { + Logger.data.error("Unable to access documents in group container, error \(error as NSError, privacy: .public)") + fatalError("Unable to access documents in group container.") + } + + // create the settings repository, gaining access to the app group UserDefaults + do { + try self.defaultsRepository = DefaultsRepository(suiteName: InputMethodUtil.groupId) + Logger.data.log("Found defaults group container") + } catch UserDefaultsError.unknownSuite { + Logger.data.error("Defaults group container not found") + fatalError("Defaults group container not found.") + } catch { + Logger.data.error("Unable to access defaults in group container, error \(error as NSError, privacy: .public)") + fatalError("Unable to access defaults in group container: \(error.localizedDescription).") + } + + self.selectedKeyboard = self.defaultsRepository.readSelectedKeyboard() + + // load all the installed packages from disk + // though we are in the initializer, didset will be called to update + // the two dependent package arrays because of the call to this helper method + self.loadPackages() + + // next, apply the settings to the packages + // this mainly consists of marking them as enabled or not + self.applyUserDefaultsToInstalledPackages() + + self.registerObservers() + } + + /** + * initializer only for use with unit tests, allows injection of stubs for `DefaultsRepo` and `PackageRepo` + */ + init(defaultsRepo: DefaultsRepo, packageRepo: PackageRepo) { + self.defaultsRepository = defaultsRepo + self.packageRepository = packageRepo + self.selectedKeyboard = self.defaultsRepository.readSelectedKeyboard() + + self.singleKeyboardPackages = [] + self.multiKeyboardPackages = [] + self.installedPackages = [] + } + + /** + * register observers to receive + */ + func registerObservers() { + NotificationCenter.default.addObserver(self, selector: #selector(self.reloadPackages), name: .dataMigrated, object: nil) + } + + /** + * Refresh the packages array and apply settings. + * This should only be needed after a migration and could be removed if + * the migration were made earlier. + */ + @objc public func reloadPackages() { + self.loadPackages() + self.applyUserDefaultsToInstalledPackages() + } + + /** + * Whenever the installedPackages array changes, recreate the two subarrays + */ + public func updatePackageArrays() { + self.singleKeyboardPackages = [] + self.multiKeyboardPackages = [] + + // divide the installedPackages array into two separate arrays based on whether they have one or more keyboards + let partitionedPackages = self.installedPackages.reduce(into: (single: [KeymanPackage](), multiple: [KeymanPackage]())) { result, element in + if element.keyboards.count == 1 { + result.single.append(element) + } else if element.keyboards.count > 1 { + result.multiple.append(element) + } + } + + // sort subarrays alphabetically, without regard to case, using the 'packageName' property + self.singleKeyboardPackages = partitionedPackages.single.sorted { $0.packageName.caseInsensitiveCompare($1.packageName) == .orderedAscending } + self.multiKeyboardPackages = partitionedPackages.multiple.sorted { $0.packageName.caseInsensitiveCompare($1.packageName) == .orderedAscending } + } + + + /** + * Build the URL necessary to download the specified package from the Keyman website + */ + public func buildDownloadPackageUrl(for packageId: String) -> URL? { + let urlString = "https://keyman.com/go/package/download/\(packageId)?platform=macos&tier=\(ConfigAppUtil.appTier)" + + guard let downloadPackageUrl = URL(string: urlString) else { + return nil + } + + return downloadPackageUrl + } + + /** + * Called when user chooses to cancel downgrade of package + */ + public func userCanceledPackageInstallation() { + Logger.data.log("User cancelled package installation") + self.packageInstall?.cleanupFailedInstallation() + + self.packageInstall = nil + } + + /** + * Called when user chooses to cancel downgrade of package + */ + public func packageInstallationFailed() { + Logger.data.log("Package installation failed") + self.packageInstall?.cleanupFailedInstallation() + + self.packageInstall = nil + } + + /** + * for each enabled keyboard in the package being installed, add it to the enabled keyboards set and save it in the UserDefaults + */ + func addEnabledKeyboards(for installedPackage: KeymanPackage) { + let currentlyEnabledKeyboards = self.defaultsRepository.readEnabledKeyboards() + var updatedEnabledKeyboards = currentlyEnabledKeyboards + + // set enabled flag if the keyboard is contained in the set of enabledKeyboards + installedPackage.keyboards.forEach { + updatedEnabledKeyboards.insert($0.keyboardKey) + } + + if updatedEnabledKeyboards != currentlyEnabledKeyboards { + self.defaultsRepository.writeEnabledKeyboards(enabledKeyboardsArray: Array(updatedEnabledKeyboards)) + } + } + + /** + * find the installed package with the specified UUID + */ + public func findInstalledPackage(with id: UUID) -> KeymanPackage? { + guard let package = self.installedPackages.first(where: { $0.id == id }) else { + Logger.setup.error("error: could not find package with UUID: \(id)") + LogUtil.errorBreadcrumb("error: could not find package with UUID: \(id)", category: .setup) + return nil + } + + return package + } + + /** + * remove/uninstall the package with the specified UUID + */ + public func removeInstalledPackage(with id: UUID) { + if let package = findInstalledPackage(with: id) { + self.removeInstalledPackage(package: package) + } + } + + /** + * remove the installed package + */ + func removeInstalledPackage(package: KeymanPackage) { + // will removing this package cause the removal of any enabled keyboards? + let removingEnabledKeyboards = !package.getEnabledKeyboardsKeys().isEmpty + + // delete package from disk + self.packageRepository.deletePackage(package: package) + + // remove package from installed packages list + if let index = self.installedPackages.firstIndex(where: { $0.id == package.id }) { + self.installedPackages.remove(at: index) + } + + // if we removed any enabled keyboards, then update settings + if removingEnabledKeyboards { + self.saveKeyboardState() + } + } + + /** + * returns true if the keyboard is enabled + * when enabled, the keyboard appears in the Keyman sub menu in the mac + */ + public func isKeyboardEnabled(packageId: UUID, keyboardKey: String) -> Bool { + guard let package = self.findInstalledPackage(with: packageId) else { + Logger.setup.error("isKeyboardEnabled, not read keyboard state for package: \(packageId) and keyboard: \(keyboardKey, privacy: .public)") + LogUtil.errorBreadcrumb("isKeyboardEnabled, not read keyboard state for package: \(packageId) and keyboard: \(keyboardKey)", category: .setup) + return false + } + + let enabled = package.isKeyboardEnabled(keyboardKey: keyboardKey) + return enabled + } + + /** + * enable or disable the keyboard + */ + public func setKeyboardEnabled(packageId: UUID, keyboardKey: String, enabled: Bool) { + guard let package = self.findInstalledPackage(with: packageId) else { + Logger.setup.error("setKeyboardEnabled, could not read keyboard state for package: \(packageId) and keyboard: \(keyboardKey, privacy: .public)") + LogUtil.errorBreadcrumb("setKeyboardEnabled, could not read keyboard state for package: \(packageId) and keyboard: \(keyboardKey)", category: .setup) + return + } + + Logger.setup.info("setKeyboardEnabled for \(keyboardKey, privacy: .public) setting to \(enabled)") + LogUtil.infoBreadcrumb("setKeyboardEnabled for \(keyboardKey)", category: .setup) + package.enableKeyboard(keyboardKey: keyboardKey, enabled: enabled) + + // update persisted state in UserDefaults enabledKeyboards array + self.saveKeyboardState() + } + + /** + * save the keyboard state in the UserDefaults + */ + func saveKeyboardState() { + let enabledKeyboards = self.getEnabledKeyboardKeys() + self.defaultsRepository.writeEnabledKeyboards(enabledKeyboardsArray: Array(enabledKeyboards)) + } + + /** + * read the Keyman packages from the group container directory and store in the installedPackages array + */ + func loadPackages() { + self.installedPackages = self.packageRepository.loadAllPackages() + } + + /** + * returns set containing the keyboards settings keys for all installed keyboards + */ + func getInstalledKeyboardKeys() -> Set { + var settingsKeys = Set() + + // loop through all the installed packages and for each of the package's keyboards, + // insert the settings key for the keyboard + self.installedPackages.forEach { $0.keyboards.forEach + {settingsKeys.insert($0.keyboardKey)} + } + + return settingsKeys + } + + /** + * returns set containing the keyboards settings keys for all installed keyboards which are enabled + */ + func getEnabledKeyboardKeys() -> Set { + var settingsKeys = Set() + + // loop through all the installed packages and for each of the package's keyboards, + // insert the settings key for every enabled keyboard + self.installedPackages.forEach { $0.keyboards.forEach { + if ($0.enabled) { + settingsKeys.insert($0.keyboardKey) + } + } + } + + return settingsKeys + } + + /** + * ensure that UserDefaults are consistent with the installed packages + * remove any UserDefaults for which no installed package exists + */ + func validateUserDefaults() { + let installedKeyboardKeys = self.getInstalledKeyboardKeys() + let enabledKeyboardKeys = self.defaultsRepository.readEnabledKeyboards() + + if (enabledKeyboardKeys.isSubset(of: installedKeyboardKeys)) { + Logger.setup.info("only installed keyboards are listed as enabled: no need to update defaults") + } else { + Logger.setup.info("enabled keyboards list contains uninstalled keyboards: align with enabled keyboards list") + let installedEnabledKeyboardKeys = enabledKeyboardKeys.intersection(installedKeyboardKeys) + self.defaultsRepository.writeEnabledKeyboards(enabledKeyboardsArray: Array(installedEnabledKeyboardKeys)) + } + } + + /** + * apply the state from the current UserDefaults to the installed packages + */ + func applyUserDefaultsToInstalledPackages() { + self.validateUserDefaults() + + let enabledKeyboards = self.defaultsRepository.readEnabledKeyboards() + + // set enabled flag if the keyboard is contained in the set of enabledKeyboards + self.installedPackages.forEach { $0.keyboards.forEach + { + $0.enabled = enabledKeyboards.contains($0.keyboardKey) + } + } + } + + // MARK: Package Download and Installation + + /** + * check whether an installation is already in progress + */ + public func isInstallationInProgress() -> Bool { + return self.packageInstall != nil + } + + /** + * Called by the WebView DownloadCoordinator before initiating a package download. + * Returns a PackageInstallHelper instance to manage the state of the package being downloaded with the specified name. + */ + public func initiateKmpFileDownload(kmpFilename: String) throws -> PackageInstallHelper? { + + guard !self.isInstallationInProgress() else { + throw InstallPackageError.packageInstallationAlreadyInProgress + } + + if let helper = self.preparePackageDownload(kmpFilename: kmpFilename) { + self.packageInstall = helper + } + + return self.packageInstall + } + + /** + * Creates a PackageInstallHelper instance to manage the state of the package being downloaded with the specified name. + */ + func preparePackageDownload(kmpFilename: String) -> PackageInstallHelper? { + return PackageInstallHelper(filename: kmpFilename, packageRepo: self.packageRepository, installedPackages: self.installedPackages, isDownload: true) + } + + /** + * Called by the WebView DownloadCoordinator after the download is complete. + * Delegates to the PackageInstallHelper instance to decide whether the package should be installed. + */ + public func packageDownloadComplete(kmpFileUrl: URL) throws { + Logger.setup.info("packageDownloadComplete \(kmpFileUrl.cleanUrlPath(), privacy: .public)") + LogUtil.infoBreadcrumb("packageDownloadComplete \(kmpFileUrl.cleanUrlPath())", category: .setup) + + do { + try self.packageInstall?.prepareToInstall(for: kmpFileUrl) + } catch { + // clear failed download + self.packageInstall = nil + throw error + } + } + + /** + * The package is approved for installation, so add it to the package list and update the UserDefaults for enabled keyboards + */ + func addInstalledPackage() { + if let package = self.packageInstall?.packageToInstall { + self.installedPackages.append(package) + self.addEnabledKeyboards(for: package) + } + } + + /** + * The package is approved for installation, so replace the package of the same name in the package list. + * Also update the UserDefaults for enabled keyboards because the new package is enabled by default, and the existing may be disabled + */ + func replaceInstalledPackage() { + if let package = self.packageInstall?.packageToInstall { + // find the existing package with the same name in the installedPackages array and replace it + // (we cannot use the id for this search, as the ids are unique) + if let index = self.installedPackages.firstIndex(where: { $0.packageName == package.packageName }) { + self.installedPackages[index] = package + self.addEnabledKeyboards(for: package) + } else { + Logger.setup.error("Error: package '\(package.packageName, privacy: .public)' not found for replacement") + LogUtil.errorBreadcrumb("Error: package '\(package.packageName)' not found for replacement", category: .setup) + } + } + } + + // MARK: Drag and drop Package Installation + + /** + * Begin installation of a package from a KMP file. + * Called when a .KMP file is dropped on the Configuration view + */ + public func initiateKmpFileInstallation(at fileLocation: URL) throws -> PackageInstallHelper? { + guard !self.isInstallationInProgress() else { + throw InstallPackageError.packageInstallationAlreadyInProgress + } + + // validate the URL of the KMP file + try self.validateDroppedFile(from: fileLocation) + + let kmpFilename = fileLocation.lastPathComponent + if let helper = self.preparePackageDrop(kmpFilename: kmpFilename) { + self.packageInstall = helper + do { + try helper.prepareToInstall(for: fileLocation) + } catch { + // clear failed download + self.packageInstall = nil + throw error + } + } + + return self.packageInstall + } + + /** + * Install the package and add it to the installedPackages array and UserDefaults + */ + public func installPackage() throws { + if let install = self.packageInstall { + do { + try install.installPackage() + } catch { + self.packageInstall?.cleanupFailedInstallation() + self.packageInstall = nil + throw error + } + commitPackageInstall() + } + } + + /** + * Update the data model for the installed package. + */ + func commitPackageInstall() { + if let install = self.packageInstall { + + guard let installationType = install.packageInstallationType else { return } + + switch installationType { + case .newPackage: + self.addInstalledPackage() + case .replaceSameVersionPackage, .replaceNewerPackage, .replaceOlderPackage: + self.replaceInstalledPackage() + } + } + + self.packageInstall = nil + } + + /** + * Creates a PackageInstallHelper instance to manage the state of the package being installed with the specified name. + */ + func preparePackageDrop(kmpFilename: String) -> PackageInstallHelper? { + return PackageInstallHelper(filename: kmpFilename, packageRepo: self.packageRepository, installedPackages: self.installedPackages, isDownload: false) + } + + /** + * Validate the URL for the file we are dropping + */ + func validateDroppedFile(from fileLocation: URL) throws { + // if the file does not end with .kmp, reject it + if fileLocation.pathExtension.lowercased() != kmpFileExtensionWithoutDot { + throw DropKmpError.invalidFileType(fileLocation.lastPathComponent) + } + } +} diff --git a/mac/KeymanSettings/Sources/Model/Keyboard.swift b/mac/KeymanSettings/Sources/Model/Keyboard.swift new file mode 100644 index 0000000000..12a83023ce --- /dev/null +++ b/mac/KeymanSettings/Sources/Model/Keyboard.swift @@ -0,0 +1,94 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2026-02-24 + * + * Object that represents a Keyman keyboard + * Each keyboard can be marked as enabled/disabled + * + */ + +import Foundation +import AppKit +import OSLog + +public class Keyboard: Identifiable, Hashable, Equatable { + + public var id = UUID() + public var enabled: Bool + public var name: String + public let oskFont: String? + public let displayFont: String? + public var keyboardId: String + // 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, packageDirectoryName: String) { + self.enabled = true + self.name = keyboardSource.name + self.oskFont = keyboardSource.oskFont + self.displayFont = keyboardSource.displayFont + self.keyboardId = keyboardSource.id + 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, packageDirectoryName: String, enabled: Bool) { + self.name = name + self.oskFont = oskFont + self.displayFont = displayFont + self.keyboardId = keyboardId + self.enabled = enabled + self.keyboardKey = Keyboard.deriveKeyboardSettingsKey(from: packageDirectoryName, keyboardId: self.keyboardId) + } + + /** + * provided for Equatable conformance + */ + public static func == (lhs: Keyboard, rhs: Keyboard) -> Bool { + return lhs.keyboardId == rhs.keyboardId && lhs.enabled == rhs.enabled + } + + /** + * provided for Hashable conformance + */ + public func hash(into hasher: inout Hasher) { + hasher.combine(keyboardId) + } + + /** + * generate the url for the keyboard's kmx file + */ + 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 packageDirectoryName: String, keyboardId: String) -> String { + // get filename from keyboardId + let kmxFilename = "\(keyboardId).kmx" + + let settingsKey = "/\(packageDirectoryName)/\(kmxFilename)" + + return settingsKey + } + + /** + * validate whether a corresponding kmx file exists for this keyboard + */ + public func validateKmxFile(in packageDirectory: URL) throws { + let kmxFilePath = self.deriveKmxFileUrl(from: packageDirectory) + if !FileManager.default.fileExists(atPath: kmxFilePath.path(percentEncoded: false)) { + Logger.data.error("error: could not find kmx file \(kmxFilePath.cleanUrlPath(), privacy: .public)") + throw LoadPackageError.missingKmxFile + } + } +} diff --git a/mac/KeymanSettings/Sources/Model/KeymanPackage.swift b/mac/KeymanSettings/Sources/Model/KeymanPackage.swift new file mode 100644 index 0000000000..45cea383a1 --- /dev/null +++ b/mac/KeymanSettings/Sources/Model/KeymanPackage.swift @@ -0,0 +1,346 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2026-02-24 + * + * Object that represents a Keyman package + * KeymanPackage is immutable, but the state of the Keyboards in the keyboards array can change + */ + +import Foundation +import AppKit +import Cocoa +import CoreImage +import CoreImage.CIFilterBuiltins +import OSLog + +public class KeymanPackage: Identifiable, Hashable, Equatable { + static let defaultImage: NSImage? = { + var image: NSImage? = nil + if let imageUrl = Bundle.module.url(forResource: "SideImage", withExtension: "bmp") { + image = NSImage(contentsOf: imageUrl) + } else { + Logger.setup.error("error: could not find SideImage.bmp in the module bundle") + LogUtil.errorBreadcrumb("error: could not find SideImage.bmp in the module bundle", category: .setup) + } + return image + }() + + public let id: UUID + + // 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? + + public let keyboards: [Keyboard] + public let fonts: [String] + public let packageName: String + public let packageVersion: String + public let minimumSupportedKeymanVersion: String + + 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 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 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 location of the package and the PackageSource object created from the kmp.json + */ + init(packageUrl: URL, packageSource: PackageSource) { + self.id = UUID() + self.sourceDirectoryUrl = packageUrl + + self.packageName = packageSource.info.name.description + self.packageVersion = packageSource.info.version.description + self.minimumSupportedKeymanVersion = packageSource.system.fileVersion.description + + self.author = packageSource.info.author?.description + if let websiteUrlString = packageSource.info.website?.url { + self.websiteUrl = URL(string: websiteUrlString) + } else { + self.websiteUrl = nil + } + self.copyright = packageSource.info.copyright?.description + + self.readmeFilename = packageSource.readmeFilename + self.helpFilename = packageSource.helpFilename + self.graphicFilename = packageSource.graphicFilename + + self.sharePackageUrl = KeymanPackage.buildSharePackageUrl(packageUrl: self.sourceDirectoryUrl) + + let packageDirectory = packageUrl.lastPathComponent + let keyboardsArray = KeymanPackage.buildKeyboardsArray(packageSource: packageSource, packageDirectoryName: packageDirectory) + self.keyboards = keyboardsArray + + self.fonts = KeymanPackage.buildFontNamesArray(keyboards: keyboardsArray) + } + + /** + * initializer to create for kmp.inf file + */ + public init( + packageUrl: URL, + keyboards: [Keyboard], + fonts: [String], + packageName: String, + packageVersion: String, + minimumKeymanVersion: String = "7.0.0", + author: String? = nil, + websiteUrl: URL? = nil, + copyright: String? = nil, + readmeFilename: String? = nil, + helpFilename: String? = nil, + graphicFilename: String? = nil + ) throws { + self.id = UUID() + self.sourceDirectoryUrl = packageUrl + self.sharePackageUrl = KeymanPackage.buildSharePackageUrl(packageUrl: self.sourceDirectoryUrl) + self.keyboards = keyboards + self.fonts = fonts + self.packageName = packageName + self.packageVersion = packageVersion + self.minimumSupportedKeymanVersion = minimumKeymanVersion + self.author = author + self.websiteUrl = websiteUrl + self.copyright = copyright + self.readmeFilename = readmeFilename + self.helpFilename = helpFilename + self.graphicFilename = graphicFilename + } + + /** + * 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, packageDirectoryName: String) -> [Keyboard] { + var keyboardsArray = [Keyboard]() + + if let keyboards = packageSource.keyboards { + for keyboardSource in keyboards { + let keyboard = Keyboard(keyboardSource: keyboardSource, packageDirectoryName: packageDirectoryName) + keyboardsArray.append(keyboard) + } + } + + return keyboardsArray + } + + /** + * build an array of all the fonts used by the specified array of keyboards + */ + private static func buildFontNamesArray(keyboards: [Keyboard]) -> [String] { + var fontNames: Set = [] + for keyboard in keyboards { + if let oskFontName = keyboard.oskFont { + fontNames.insert(oskFontName) + } + if let displayFontName = keyboard.displayFont { + fontNames.insert(displayFontName) + } + } + return fontNames.sorted() + } + + /** + * find the keyboard with the specified key in the package and return its enabled state + */ + public func isKeyboardEnabled(keyboardKey: String) -> Bool { + var enabled = false + if let keyboard = self.keyboards.first(where: { $0.keyboardKey == keyboardKey }) { + enabled = keyboard.enabled + } + + return enabled + } + + /** + * find the keyboard with the specified key in the package and set its enabled state + */ + public func enableKeyboard(keyboardKey: String, enabled: Bool) { + let keyboard = self.keyboards.first(where: { $0.keyboardKey == keyboardKey }) + if (keyboard != nil) { + keyboard!.enabled = enabled + } + } + + /** + * get all the keys for enabled keyboards in the package + */ + public func getEnabledKeyboardsKeys() -> [String] { + var settingsKeyArray = [String]() + + self.keyboards.forEach { keyboard in + if (keyboard.enabled) { + settingsKeyArray.append(keyboard.keyboardKey) + } + } + + return settingsKeyArray + } + + /** + * validate the package + * 1. whether the package can be loaded by this version of Keyman + * 2. whether it contains a kmx file for each of its keyboards + */ + public func validate() throws { + try self.validateKeymanVersionForPackage() + + // if validateKmxFile throws an error, then the loop is stopped and the error is propagated + try self.keyboards.forEach { keyboard in + try keyboard.validateKmxFile(in: self.sourceDirectoryUrl) + } + } + + /** + * verify that the version of Keyman is equal to our newer than the + * minimum required Keyman version specifed by the package + */ + func validateKeymanVersionForPackage() throws { + let keymanVersion = ConfigAppUtil.configAppVersion() + let minimumKeymanVersion = self.minimumSupportedKeymanVersion + var meetsRequiredVersion: Bool = false + let comparisonResult = keymanVersion.compare(minimumKeymanVersion, options: .numeric) + + if comparisonResult == .orderedAscending { + // keyman version is too old + meetsRequiredVersion = false + Logger.data.log("validateKeymanVersionForPackage for package: '\(self.packageName, privacy: .public)' keyman version \(keymanVersion, privacy: .public) is older than required version \(minimumKeymanVersion, privacy: .public)") + } else { + meetsRequiredVersion = true + } + + if (!meetsRequiredVersion) { + throw LoadPackageError.insufficientKeymanVersion(packageName: self.packageName, requiredKeymanVersion: minimumKeymanVersion, actualKeymanVersion: keymanVersion) + } + } + + /** + * create the image specified for the package + * if none specified, load the default image + */ + static func loadImage(imageUrl: URL?) -> NSImage? { + var packageImage: NSImage? = nil; + + if let fileUrl = imageUrl, let image = NSImage(contentsOf: fileUrl) { + packageImage = image + } else { + packageImage = KeymanPackage.defaultImage + } + + return packageImage + } + + /** + * build the URL where the keyboard can be installed from the Keyman website + */ + static func buildSharePackageUrl(packageUrl: URL) -> URL? { + return URL(string: "https://\(InputMethodUtil.keymanDomain)/go/keyboard/\(packageUrl.lastPathComponent)/share") + } + + /** + * 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 image for sharing the Keyman Package URL + */ + 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 + let filter = CIFilter.qrCodeGenerator() + filter.message = data + filter.correctionLevel = "M" + + guard let rawQRImage = filter.outputImage else { return nil } + + // calculate scaling factor to ensure crisp rendering of QR code + let rawWidth = rawQRImage.extent.width + guard rawWidth > 0 else { return nil } + let scale = size/rawWidth + + // apply transform to scale up without blurring (use interpolation = none in SwiftUI image) + let scaledQRImage = rawQRImage.transformed(by: CGAffineTransform(scaleX: scale, y: scale)) + + // convert the CIImage to a macOS NSImage + let rep = NSCIImageRep(ciImage: scaledQRImage) + let nsImage = NSImage(size: NSSize(width: size, height: size)) + nsImage.addRepresentation(rep) + + return nsImage + } + + /** + * provided for Hashable conformance + */ + public func hash(into hasher: inout Hasher) { + hasher.combine(id) // only combine the unique ID + } + + /** + * provided for Equatable conformance + */ + public static func == (lhs: KeymanPackage, rhs: KeymanPackage) -> Bool { + return lhs.id == rhs.id // only compare unique IDs + } +} diff --git a/mac/KeymanSettings/Sources/Persistence/Data/KeyboardSource.swift b/mac/KeymanSettings/Sources/Persistence/Data/KeyboardSource.swift new file mode 100644 index 0000000000..f1e3d21636 --- /dev/null +++ b/mac/KeymanSettings/Sources/Persistence/Data/KeyboardSource.swift @@ -0,0 +1,55 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2025-12-19 + * + * Value object for loading a keyboard from kmp.json + * + */ + + +public struct KeyboardSource: Identifiable, Decodable, Hashable, Equatable { + let name: String + public let id: String + let version: String? + let oskFont: String? + let displayFont: String? + let languages: [LanguageSource]? + + enum CodingKeys: String, CodingKey { + case name + case id + case version + case oskFont + case displayFont + case languages + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let name = try container.decodeIfPresent(String.self, forKey: .name) + let id = try container.decodeIfPresent(String.self, forKey: .id) + let version = try container.decodeIfPresent(String.self, forKey: .version) + + // Invalidation: name, id and version required and non-empty + guard let validName = name, !validName.isEmpty else { + throw LoadPackageError.missingKeyboardName + } + + guard let validId = id, !validId.isEmpty else { + throw LoadPackageError.missingKeyboardId + } + + guard let validVersion = version, !validVersion.isEmpty else { + throw LoadPackageError.missingKeyboardVersion + } + + self.name = validName + self.id = validId + self.version = validVersion + self.oskFont = try container.decodeIfPresent(String.self, forKey: .oskFont) + self.displayFont = try container.decodeIfPresent(String.self, forKey: .displayFont) + self.languages = try container.decodeIfPresent([LanguageSource].self, forKey: .languages) + } +} diff --git a/mac/KeymanSettings/Sources/Persistence/Data/LanguageSource.swift b/mac/KeymanSettings/Sources/Persistence/Data/LanguageSource.swift new file mode 100644 index 0000000000..81e06f6089 --- /dev/null +++ b/mac/KeymanSettings/Sources/Persistence/Data/LanguageSource.swift @@ -0,0 +1,37 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2025-12-19 + * + * Value object for loading a language from kmp.json + * + */ + + +public struct LanguageSource: Identifiable, Decodable, Hashable, Equatable { + let name: String? + public let id: String + + enum CodingKeys: String, CodingKey { + case name + case id + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.name = try container.decodeIfPresent(String.self, forKey: .name) + + let id = try container.decodeIfPresent(String.self, forKey: .id) + + guard let validId = id, !validId.isEmpty else { + throw DecodingError.dataCorruptedError( + forKey: .id, + in: container, + debugDescription: "Language.id is required and cannot be empty." + ) + } + + self.id = validId + } +} diff --git a/mac/KeymanSettings/Sources/Persistence/Data/PackageSource.swift b/mac/KeymanSettings/Sources/Persistence/Data/PackageSource.swift new file mode 100644 index 0000000000..4ec0e86e23 --- /dev/null +++ b/mac/KeymanSettings/Sources/Persistence/Data/PackageSource.swift @@ -0,0 +1,175 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2025-12-19 + * + * Value object for loading a package from kmp.json + * + */ + + +import Foundation + +let defaultHelpFilename = "welcome.htm" +let defaultReadmeFilename = "readme.htm" + +public struct PackageSource: Identifiable, Decodable, Hashable, Equatable { + public var id = UUID() + let system: SystemInfo + let options: Options + let info: Info + let files: [PackageFile]? + let keyboards: [KeyboardSource]? + + // computed properties for convenience + var packageName: String { + return info.name.description + } + var packageVersion: String { + return info.version.description + } + var copyright: String? { + return info.copyright?.description + } + var readmeFilename: String? { + if let filename = options.readmeFile { + return filename + } + 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 } + + if let fileArray = self.files { + if fileArray.contains(where: { $0.name == defaultHelpFilename }) { + return defaultHelpFilename + } + } + + return nil + } + var graphicFilename: String? { + return options.graphicFile + } + + enum CodingKeys: String, CodingKey { + case system + case options + case info + case files + case keyboards + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.info = try container.decode(Info.self, forKey: .info) + self.keyboards = try container.decodeIfPresent([KeyboardSource].self, forKey: .keyboards) + self.system = try container.decode(SystemInfo.self, forKey: .system) + self.options = try container.decode(Options.self, forKey: .options) + self.files = try container.decodeIfPresent([PackageFile].self, forKey: .files) + + if files?.isEmpty ?? true { + throw LoadPackageError.containsNoFiles + } + + if keyboards?.isEmpty ?? true { + throw LoadPackageError.containsNoKeyboards + } + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) // only combine the unique ID + } + + // Custom Equatable conformance (required by Hashable) + public static func == (lhs: PackageSource, rhs: PackageSource) -> Bool { + return lhs.id == rhs.id // only compare unique IDs + } +} + +struct Info: Decodable { + let name: DescribedValue + let version: DescribedValue + let copyright: DescribedValue? + let author: Author? + let website: Website? + + enum CodingKeys: String, CodingKey { + case name + case copyright + case author + case version + case website + } +} + +struct DescribedValue: Decodable { + let description: String + + enum CodingKeys: String, CodingKey { + case description + } +} + +struct Author: Decodable { + let description: String? + let url: String? + + enum CodingKeys: String, CodingKey { + case description + case url + } +} + +struct Website: Decodable { + let description: String? + let url: String? + + enum CodingKeys: String, CodingKey { + case description + case url + } +} + +struct SystemInfo: Decodable { + let keymanDeveloperVersion: String? + let fileVersion: String + + enum CodingKeys: String, CodingKey { + case keymanDeveloperVersion + case fileVersion + } +} + +struct Options: Decodable { + let readmeFile: String? + let graphicFile: String? + let licenseFile: String? + let welcomeFile: String? + + enum CodingKeys: String, CodingKey { + case readmeFile + case graphicFile + case licenseFile + case welcomeFile + } +} + +struct PackageFile: Decodable { + let name: String? + let description: String? + + enum CodingKeys: String, CodingKey { + case name + case description + } +} + + + diff --git a/mac/KeymanSettings/Sources/Persistence/DefaultsRepository.swift b/mac/KeymanSettings/Sources/Persistence/DefaultsRepository.swift new file mode 100644 index 0000000000..f1c0639fc1 --- /dev/null +++ b/mac/KeymanSettings/Sources/Persistence/DefaultsRepository.swift @@ -0,0 +1,163 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2025-12-10 + * + * DefaultsRepository is responsible for reading, writing and removing + * values stored in the UserDefaults + * + */ + +import Foundation + +public enum UserDefaultsError: Error { + case unknownSuite +} + +public class DefaultsRepository: DefaultsRepo { + fileprivate let pathUtil: KeymanPaths + let defaultsSuiteName: String + let groupDefaults: UserDefaults + + let kEnabledKeyboardsKey = "KMEnabledKeyboardsKey" + let kSelectedKeyboardKey = "KMSelectedKeyboardKey" + let kPersistedOptionsKey = "KMPersistedOptionsKey" + let kDataModelVersionKey = "KMDataModelVersion" + let kShowOskOnActivateKey = "KMShowOskOnActivate" + let kForceSentryErrorKey = "KMForceSentryError" + + let kInstallationState = "KMInstallationState" + + let kTimeRestartRequested = "KMTimeRestartRequested" + + public init(suiteName: String) throws(Error) { + try self.pathUtil = KeymanPaths() + self.defaultsSuiteName = suiteName + + // create defaults for the suite + guard let userDefaults = UserDefaults(suiteName: suiteName) else { + throw(UserDefaultsError.unknownSuite) + } + + self.groupDefaults = userDefaults + } + + /** + * read the dictionary of the installation info from the UserDefaults + */ + public func readInstallationState() -> Dictionary? { + return self.groupDefaults.dictionary(forKey: kInstallationState) + } + + /** + * write the dictionary of the installation info to the UserDefaults + */ + public func writeInstallationState(_ dictionary: Dictionary) { + self.groupDefaults.set(dictionary, forKey: kInstallationState) + } + + /** + * delete the dictionary of the installation info to the UserDefaults + */ + public func deleteInstallationState() { + self.groupDefaults.removeObject(forKey: kInstallationState) + } + + /** + * get the list of enabled keyboards from the UserDefaults + * The enabled keyboards are those that are shown in the Keyman menu + * when it is selected from the System Input Source menu. + */ + public func readEnabledKeyboards() -> Set { + let enabledKeyboardsArray = self.groupDefaults.stringArray(forKey: kEnabledKeyboardsKey) ?? [] + let enabledKeyboardsSet = Set(enabledKeyboardsArray) + + return enabledKeyboardsSet + } + + /** + * update the list of enabled keyboards in the UserDefaults + * each String in the array must be formatted `/[packageName]/[keyboardName].kmx` + */ + public func writeEnabledKeyboards(enabledKeyboardsArray: [String]) { + self.groupDefaults.set(enabledKeyboardsArray, forKey: kEnabledKeyboardsKey) + self.sendKeyboardsChangedNotification() + } + + /** + * Send a distributed notification that the keyboards have changed. + * The input method will receive this and reload the enabled keyboards. + */ + func sendKeyboardsChangedNotification() { + DistributedNotificationCenter.default().postNotificationName ( + .keyboardsChanged, + object: nil, + userInfo: nil, + deliverImmediately: true + ) + } + + /** + * return the selected keyboard from the UserDefaults + * The selected keyboard is the one that Keyman is applying for each keydown event. + */ + public func readSelectedKeyboard() -> String { + return self.groupDefaults.string(forKey: kSelectedKeyboardKey) ?? "" + } + + /** + * update the selected keyboard keyboards in the UserDefaults + * `keyboardName` must be formatted `/[packageName]/[keyboardName].kmx` + */ + public func writeSelectedKeyboard(keyboardName: String) { + self.groupDefaults.set(keyboardName, forKey: kSelectedKeyboardKey) + } + + /** + * read the boolean value that allows a Sentry error to be generated for testing + * This value is set in the **standard** application UserDefaults -- not the shared app group defaults. + * This is necessary because it must be set to true from the command line, and the + * app group defaults are not accessible from the command line. + */ + public func readForceSentryError() -> Bool { + return UserDefaults.standard.bool(forKey: kForceSentryErrorKey) + } + + /** + * read the data model version to know what values and format to expect + */ + public func readDataModelVersion() -> Int { + // note that zero is returned if key is not found in UserDefaults, + return self.groupDefaults.integer(forKey: kDataModelVersionKey) + } + + /** + * read the boolean value that indicates whether the OSK is opened when the input method is activated + * There may be no need to read this from with the config app. + */ + public func readShowOskOnActivate() -> Bool { + return self.groupDefaults.bool(forKey: kShowOskOnActivateKey) + } + + /** + * read the list of persisted options that are recorded dynamically from the input method + * There may be no need to read this from with the config app. + */ + public func readPersistedOptions() -> Dictionary { + if let options: Dictionary = self.groupDefaults.dictionary(forKey: kPersistedOptionsKey) { + return options + } else { + return [:] + } + } + + /** + * for debugging: clear all the entries for the app group UserDefaults + * unlike standard application-level UserDefaults, there is no way to view from the command line + */ + public func clearDefaults() { + self.groupDefaults.dictionaryRepresentation().keys.forEach { key in + self.groupDefaults.removeObject(forKey: key) + } + } +} diff --git a/mac/KeymanSettings/Sources/Persistence/KeymanPaths.swift b/mac/KeymanSettings/Sources/Persistence/KeymanPaths.swift new file mode 100644 index 0000000000..bc3b9c8c82 --- /dev/null +++ b/mac/KeymanSettings/Sources/Persistence/KeymanPaths.swift @@ -0,0 +1,263 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2025-12-10 + * + * KeymanPaths provides the file paths of the Keyman input method and + * all Keyman package data. This is used for installing and removing + * the input method and packages. + */ + +import Foundation +import OSLog + +/** + * Three directory trees are represented by the following properties, one in active use + * and two that are obsolete. + * + * Each of the directory properties defined ending with the word 'Directory' represents a full path . + * Variables that represent only a single directory end with 'DirectoryName' rather than 'Directory' + * For Keyman versions 17 and earlier, the directory hierarchy is as follows: + * documentsDirectory: '~/Documents' + * keyman17PackagesDirectory: '~/Documents/Keyman-Keyboards' + * For Keyman version 18: + * supportDirectory: '~/Library/Application Support' + * supportKeymanDirectory: '~/Library/Application Support/keyman.inputmethod.Keyman' + * keyman18PackagesDirectory: '~/Library/Application Support/keyman.inputmethod.Keyman/Keyman-Keyboards' + * For Keyman version 19 (and later): + * containerDirectory: '~/Library/Group Containers' + * containerKeymanDirectory: '~/Library/Group Containers/group.com.keyman' + * groupKeymanSupportDirectory: '~/Library/Group Containers/group.com.keyman/Library/Application Support' + * keyman19PackagesDirectory: '~/Library/Group Containers/group.com.keyman/Library/Application Support/Keyman-Packages' + */ + +public enum KeymanPathError: Error { + case groupContainerNotFound +} + +public struct KeymanPaths { + // keyman file extensions + static public let keymanPackageFileExtension: String = "kmp" + + static private let preKeyman19PackagesDirectoryName = "Keyman-Keyboards" + static private let keymanSubdirectoryName = InputMethodUtil.keymanBundleId + + static public var getFontsDirectory: URL { + // force unwrap is safe here because the system user domain library always exists + let libraryURL = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first! + let fontsDirectory = libraryURL.appendingPathComponent(fontsDirectoryName, isDirectory: true) + + // if for some reason it doesn't exist, create it + let fileManager = FileManager.default + if !fileManager.fileExists(atPath: fontsDirectory.path(percentEncoded: false)) { + do { + try fileManager.createDirectory(at: fontsDirectory, withIntermediateDirectories: true, attributes: nil) + } catch { + Logger.setup.error("error: could not create fonts directory: \(error as NSError, privacy: .public)") + LogUtil.errorBreadcrumb("error: could not create fonts directory: \(error as NSError)", category: .setup) + } + } + + return fontsDirectory + } + + // keyman 19 directory names + static private let containerPreferencesPartialPath = "Library/Preferences" + static private let containerPackagesPartialPath = "Library/Application Support/Keyman-Packages" + static private let containerTempPartialPath = "Library/Application Support/temp" + + // system directory names + static private let fontsDirectoryName = "Fonts" + + // keyman 17 and earlier + let keyman17DocumentsDirectory: URL? + let keyman17PackagesDirectory: URL? + + // keyman 18 + let keyman18SupportDirectory: URL? + let keyman18DataDirectory: URL? + let keyman18PackagesDirectory: URL? + + // current directories for keyman 19 + let keyman19ContainerDirectory: URL + let keyman19PackagesDirectory: URL + let keyman19TempDirectory: URL + let keyman19PreferencesDirectory: URL + + /** + * Build the paths to the Keyman data locations + * throws `KeymanPathError` if the group container directory cannot be located + * The app cannot function without access to where the Keyman packages are stored. + */ + public init() throws { + let documentsDir = KeymanPaths.buildDocumentsUrl() + self.keyman17DocumentsDirectory = documentsDir + + self.keyman17PackagesDirectory = KeymanPaths.buildKeyman17PackagesUrl(documents: self.keyman17DocumentsDirectory) + + let supportDir = KeymanPaths.buildSupportDirectory() + self.keyman18SupportDirectory = supportDir + + let keyman18DataDir = KeymanPaths.buildKeyman18DataDirectory(support: supportDir) + self.keyman18DataDirectory = keyman18DataDir + self.keyman18PackagesDirectory = KeymanPaths.buildKeyman18PackagesUrl(data: keyman18DataDir) + + guard let containerDir = KeymanPaths.buildContainerUrl() else { throw KeymanPathError.groupContainerNotFound } + self.keyman19ContainerDirectory = containerDir + self.keyman19PreferencesDirectory = KeymanPaths.buildContainerPreferencesUrl(container: containerDir) + + self.keyman19PackagesDirectory = KeymanPaths.buildKeyman19PackagesUrl(container: containerDir) + self.keyman19TempDirectory = KeymanPaths.buildKeyman19TempUrl(container: containerDir) + + self.logPaths() + } + + fileprivate func logPaths() { + Logger.setup.debug("container: \(self.keyman19ContainerDirectory.cleanUrlPath())") + Logger.setup.debug("preferences: \(self.keyman19PreferencesDirectory.cleanUrlPath())") + Logger.setup.debug("keyman 19 packages: \(self.keyman19PackagesDirectory.cleanUrlPath())") + } + + /** + * build the URL to specified file in the Input Methods directory + */ + public func buildInputMethodPathUrl(fileName:String) -> URL? { + let inputMethodUrl:URL + + do { + let inputMethodDirectoryUrl = try FileManager.default.url( + for: .inputMethodsDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + + inputMethodUrl = inputMethodDirectoryUrl.appendingPathComponent(fileName, isDirectory: false) + return inputMethodUrl + } catch { + Logger.setup.error("buildInputMethodPathUrl error: \(error as NSError, privacy: .public)") + LogUtil.errorBreadcrumb("buildInputMethodPathUrl error: \(error as NSError)", category: .setup) + return nil + } + } + + /** + * build the URL to the executable inside the specified Input Method app + */ + public func buildInputMethodExecutableUrl(fileName:String) -> URL? { + if let inputMethodUrl = self.buildInputMethodPathUrl(fileName: fileName) { + let executableName = inputMethodUrl.deletingPathExtension().lastPathComponent + return inputMethodUrl.appendingPathComponent("Contents/MacOS/\(executableName)") + } else { + Logger.setup.error("buildInputMethodExecutableUrl error: could not build input method executable directory") + LogUtil.errorBreadcrumb("buildInputMethodExecutableUrl error: could not build input method executable directory", category: .setup) + return nil + } + } + + /** + * build the URL to the user's Documents directory + */ + static func buildDocumentsUrl() -> URL? { + var documentsDirectoryUrl:URL + + do { + documentsDirectoryUrl = try FileManager.default.url( + for: .documentDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + return documentsDirectoryUrl + } catch { + Logger.setup.error("buildDocumentsUrl error: \(error as NSError, privacy: .public)") + LogUtil.errorBreadcrumb("buildDocumentsUrl error: \(error as NSError)", category: .setup) + return nil + } + } + + /** + * build the URL to the packages directory for Keyman 17, inside the user's Documents directory + */ + private static func buildKeyman17PackagesUrl(documents: URL?) -> URL? { + if let keyman17PackagesDirectory = documents?.appendingPathComponent(preKeyman19PackagesDirectoryName, isDirectory: true) { + return keyman17PackagesDirectory + } else { + Logger.setup.error("buildKeyman17PackagesUrl error: could not build keyman17 packages directory") + LogUtil.errorBreadcrumb("buildKeyman17PackagesUrl error: could not build keyman17 packages directory", category: .setup) + return nil + } + } + + /** + * build the URL to the application support directory for the Keyman input method + */ + private static func buildSupportDirectory() -> URL? { + let supportDirectoryUrl:URL + + do { + supportDirectoryUrl = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + + return supportDirectoryUrl + } catch { + Logger.setup.error("buildSupportDirectory error: \(error as NSError, privacy: .public)") + LogUtil.errorBreadcrumb("buildSupportDirectory error: \(error as NSError)", category: .setup) + return nil + } + } + + /** + * build the URL to the data directory for Keyman 18, inside the application support directory for the Keyman input method + */ + private static func buildKeyman18DataDirectory(support: URL?) -> URL? { + if let supportUrl = support { + return supportUrl.appendingPathComponent(KeymanPaths.keymanSubdirectoryName, isDirectory: true) + } else { + return nil + } + } + + /** + * build the URL to the packages directory for Keyman 18, inside the application support directory for the Keyman input method + */ + private static func buildKeyman18PackagesUrl(data: URL?) -> URL? { + if let keymanDataUrl = data { + return keymanDataUrl.appendingPathComponent(KeymanPaths.preKeyman19PackagesDirectoryName, isDirectory: true) + } else { + return nil + } + } + + /** + * build the URL to the app group container + */ + private static func buildContainerUrl() -> URL? { + return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: InputMethodUtil.groupId) + } + + /** + * build the URL to the preference directory inside the app group container + */ + private static func buildContainerPreferencesUrl(container: URL) -> URL { + return container.appendingPathComponent(containerPreferencesPartialPath, isDirectory: true) + } + + /** + * build the URL to the packages directory inside the app group container directory + */ + private static func buildKeyman19PackagesUrl(container: URL) -> URL { + return container.appendingPathComponent(KeymanPaths.containerPackagesPartialPath, isDirectory: true) + } + + /** + * build the URL to the temp directory inside the app group container directory + */ + private static func buildKeyman19TempUrl(container: URL) -> URL { + return container.appendingPathComponent(KeymanPaths.containerTempPartialPath, isDirectory: true) + } +} diff --git a/mac/KeymanSettings/Sources/Persistence/KmpInfParser.swift b/mac/KeymanSettings/Sources/Persistence/KmpInfParser.swift new file mode 100644 index 0000000000..deea3c9286 --- /dev/null +++ b/mac/KeymanSettings/Sources/Persistence/KmpInfParser.swift @@ -0,0 +1,424 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2026-09-04 + * + * Parses the contents of a kmp.inf file, a legacy format for describing a Keyman package. + * The kmp.json superseded the kmp.inf file somewhere around Keyman version 10, so the + * kmp.inf will only be read if the kmp.json is not found in the package. + + * After parsing and collecting the data, it is validated and a KeymanPackage is created. + */ + +import Foundation +import OSLog + +// the section headings +enum Section: String { + case package = "package" + case info = "info" // found in version kmp.inf version 6.0 and later + case packageinfo = "packageinfo" // found in version kmp.inf version 5.0 + case startmenu = "startmenu" // ignored + case files = "files" +} + +// the property names for the Keyboard section(s) +enum KeyboardProperty: String { + case name + case id + case version + case rtl + case oskfont + case displayfont +} + +// the property names for the Package section +enum PackageProperty: String { + case version // the version of Keyman Developer used to create the keyboard + case graphicfile + case readmefile +} + +// the property names for the Info section +enum InfoProperty: String { + case name + case version // the version number of the package + case copyright + case author + case website +} + +class KmpInfParser { + private let kmpInfFile: URL + private let packageDirectoryUrl: URL + private let packageDirectoryName: String + private let fileContent: String + + // default filenames -- used in case they are not specified in kmp.inf + private let defaultHelpFilename = "welcome.htm" + private let defaultReadmeFilename = "readme.htm" + + // prefixes for dynamic sections/keys + private let keyboardPrefix = "keyboard" + private let languagePrefix = "language" + + // the section that is currently being parsed + private var currentSection = "" + + // collected parsed data + private var packageMap: [String: String] = [:] + private var infoMap: [String: String] = [:] + private var keyboardMapArray: [[String: String]] = [] + private var fileMap: [String:String] = [:] + private var currentKeyboardMap: [String: String] = [:] + private var currentLanguageMap: [String: String] = [:] + + // private read-only computed properties to provide easy access + // to the data we have parsed and saved for package creation + private var version: String? { + return self.infoMap[InfoProperty.version.rawValue] + } + private var readmeFilename: String? { + if let filename = self.packageMap[PackageProperty.readmefile.rawValue] { + return filename + } + if self.fileMap.keys.contains(where: { $0 == defaultReadmeFilename }) { + return defaultReadmeFilename + } + return nil + } + private var graphicFilename: String? { + return self.packageMap[PackageProperty.graphicfile.rawValue] + } + private var helpFilename: String? { + if self.fileMap.keys.contains(where: { $0 == defaultHelpFilename }) { + return defaultHelpFilename + } + return nil + } + private var packageName: String? { + return self.infoMap[InfoProperty.name.rawValue] + } + private var websiteUrl: URL? { + let websitePath = self.infoMap[InfoProperty.website.rawValue] + return websitePath.flatMap(URL.init(string:)) + } + private var copyright: String? { + return self.infoMap[InfoProperty.copyright.rawValue] + } + private var author: String? { + return self.infoMap[InfoProperty.author.rawValue] + } + + /** + * Initializes the parser, reads the file contents with windowsCP1252 encoding and changes to Mac line endings. + */ + init(fileUrl: URL, in directoryUrl: URL) throws { + self.kmpInfFile = fileUrl + self.packageDirectoryUrl = directoryUrl + self.packageDirectoryName = self.packageDirectoryUrl.lastPathComponent + + let path = self.kmpInfFile.path(percentEncoded: false) + let encoding = String.Encoding.windowsCP1252 + + self.fileContent = try String(contentsOfFile: path, encoding: encoding) + .replacingOccurrences(of: "\r", with: "") + } + + /** + * Collects all the data describing the package and use it to create a KeymanPackage. + */ + public func parse() throws -> KeymanPackage? { + + let lines = self.fileContent.components(separatedBy: .newlines) + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { continue } + + // determine the current section + if self.containsSectionHeading(rowString: trimmed) { + self.prepareNewSection(sectionName: self.extractSectionName(from: trimmed)) + } else if self.containsKeyValuePair(rowString: trimmed) { + // extract key-value pair, where the value is actually a String array + let pair = self.extractKeyValuePair(from: trimmed) + self.addProperty(key: pair.key, valueArray: pair.valueArray) + } + } + + // done reading properties, add the current keyboard map to the array + if !self.currentKeyboardMap.isEmpty { + // copy the current keyboard map to the array + self.keyboardMapArray.append(self.currentKeyboardMap) + } + + // validate collected data before building + try self.validateParsedData() + + return try self.buildPackage() + } + + /** + * validates the collected data to be sure we have what is required to build a package + */ + private func validateParsedData() throws { + if self.fileMap.isEmpty { + throw LoadPackageError.containsNoFiles + } + if self.keyboardMapArray.isEmpty { + throw LoadPackageError.containsNoKeyboards + } + for keyboardMap in self.keyboardMapArray { + if !keyboardMap.keys.contains(KeyboardProperty.id.rawValue) { throw LoadPackageError.missingKeyboardId } + if !keyboardMap.keys.contains(KeyboardProperty.name.rawValue) { throw LoadPackageError.missingKeyboardName } + if !keyboardMap.keys.contains(KeyboardProperty.version.rawValue) { throw LoadPackageError.missingKeyboardVersion } + } + } + + /** + * Constructs the KeymanPackage with its array of Keyboard objects using the data from the .inf file. + */ + private func buildPackage() throws -> KeymanPackage? { + var keymanPackage: KeymanPackage? = nil + var keyboardsArray: [Keyboard] = [] + var fontsSet: Set = [] + + // first build the keyboards array from the array of keyboard maps + for keyboardMap in self.keyboardMapArray { + if let name = keyboardMap[KeyboardProperty.name.rawValue], + let id = keyboardMap[KeyboardProperty.id.rawValue] { + let oskFont = keyboardMap[KeyboardProperty.oskfont.rawValue] + let displayFont = keyboardMap[KeyboardProperty.displayfont.rawValue] + + // add fonts to fonts list + if let oskFont, !oskFont.isEmpty { + fontsSet.insert(oskFont) + } + if let displayFont, !displayFont.isEmpty { + fontsSet.insert(displayFont) + } + + let keyboard = Keyboard(name: name, oskFont: oskFont, displayFont: displayFont, keyboardId: id, packageDirectoryName: self.packageDirectoryName, enabled: true) + + keyboardsArray.append(keyboard) + } + } + + if let packageName = self.packageName, + let version = self.version { + + keymanPackage = try KeymanPackage(packageUrl: self.packageDirectoryUrl, keyboards: keyboardsArray, fonts: Array(fontsSet), packageName: packageName, packageVersion: version, author: self.author, websiteUrl: self.websiteUrl, copyright: self.copyright, readmeFilename: self.readmeFilename, helpFilename: self.helpFilename, graphicFilename: self.graphicFilename) + } + + return keymanPackage + } + + /** + * prepare to add properties to a new section + */ + private func prepareNewSection(sectionName: String) { + self.currentSection = sectionName + + if sectionName.hasPrefix(keyboardPrefix) { + self.startNewKeyboard() + } + } + + /** + * If we have been collecting data for one keyboard, save its map to the keyboard array. + * Initialize a new keyboard map to collect data for this keyboard + */ + private func startNewKeyboard() { + if !self.currentKeyboardMap.isEmpty { + // if we were already collecting data for a keyboard, then copy it to the keyboard array + self.keyboardMapArray.append(self.currentKeyboardMap) + } + // reset the keyboard and language maps + self.currentKeyboardMap = [:] + self.currentLanguageMap = [:] + } + + /** + * add the property for the current section + */ + private func addProperty(key: String, valueArray: [String]) { + if !self.currentSection.isEmpty && + (!key.isEmpty) && + (!valueArray.isEmpty) { + + if self.currentSection == Section.files.rawValue { + self.addFile(filename: valueArray[1], description: valueArray[0]); + } else if self.currentSection.hasPrefix(keyboardPrefix) { + self.addKeyboardProperty(key: key, value: valueArray) + } else if self.currentSection == Section.info.rawValue || self.currentSection == Section.packageinfo.rawValue { + self.addInfoProperty(key: key, value: valueArray) + } else if self.currentSection == Section.package.rawValue { + self.addPackageProperty(key: key, value: valueArray) + } + } + } + + /** + * read the name of the section that is surrounded in square brackets + */ + private func extractSectionName(from sectionString: String) -> String { + var extractedString = "" + + // regex that looks for square brackets and captures everything inside + let regex = /\[([^\]]+)\]/ + + if let match = sectionString.firstMatch(of: regex) { + // match.1 retrieves the first capture group, the text inside the brackets + extractedString = String(match.1).lowercased() + } + + return extractedString + } + + /** + * takes a row of data from the file and parses it into a key-value tuple + * where the key is a String and the value is an array of Strings + */ + private func extractKeyValuePair(from rowString: String) -> (key: String, valueArray: [String]) { + var pair = (key: "", valueArray: [String]()) + + let cleaned = rowString.trimmingCharacters(in: .whitespacesAndNewlines) + + // break the row into, at most, two parts: the key and the value + let parts = cleaned.split(separator: "=", maxSplits: 1) + let key = parts[0] + + if parts.count == 1 { + // if there is only one string after split, then we have no values + pair = (key.lowercased(), []) + } else { + // create an array of strings from the value part of the string, converting each Substring to a String + let valueArray = self.splitValuesPreservingQuotes(parts[1]) + + // remove the quote marks from each element of the value array + let cleanValueArray = valueArray.map { + $0.replacingOccurrences(of: "\"", with: "") + } + + pair = (key.lowercased(), cleanValueArray) + } + + return pair + } + + /** + * Splits a value string while ignoring any commas that appear inside double-quoted sections. + * Doing this instead of a basic `String.split()` to allow values to contain a comma. + */ + private func splitValuesPreservingQuotes(_ fullValue: Substring) -> [String] { + var valueArray: [String] = [] + var subValue = "" + var insideQuotes = false + + for char in fullValue { + if char == "\"" { + // starting or ending subValue + insideQuotes.toggle() + subValue.append(char) // preserve the quote; it's stripped later + } else if char == "," && !insideQuotes { + // collect subValue and clear for next + valueArray.append(subValue) + subValue = "" + } else { + // add content to current subValue + subValue.append(char) + } + } + valueArray.append(subValue) + return valueArray + } + + /** + * return true if the specified row of data from the file contains a section heading + */ + private func containsSectionHeading(rowString: String) -> Bool { + return rowString.hasPrefix("[") && rowString.hasSuffix("]") + } + + /** + * return true if the specified row of data from the file contains a key-value pair + */ + private func containsKeyValuePair(rowString: String) -> Bool { + return rowString.contains("=") + } + + /** + * add a property from a keyboard section of the kmp.inf file and store with the current keyboard + */ + private func addKeyboardProperty(key: String, value: [String]) { + if key.hasPrefix(languagePrefix) { + self.addLanguage(key: key, value: value) + } + if let property = KeyboardProperty(rawValue: key.lowercased()) { + switch property { + case .id, .name, .version, .rtl, .displayfont, .oskfont: + self.currentKeyboardMap[key.lowercased()] = value[0] + } + } else { + Logger.data.debug("ignoring unknown keyboard property: \(key, privacy: .public)") + } + } + + /** + * add a property from the info section of the kmp.inf file + */ + private func addInfoProperty(key: String, value: [String]) { + if let property = InfoProperty(rawValue: key.lowercased()) { + switch property { + case .name: + self.infoMap[key.lowercased()] = value[0] + case .version: + self.infoMap[key.lowercased()] = value[0] + case .copyright: + self.infoMap[key.lowercased()] = value[0] + case .author: + self.infoMap[key.lowercased()] = value[0] + case .website: + // get second value if it exists, first is description + if (value.count > 1) { + self.infoMap[key.lowercased()] = value[1] + } + } + } else { + Logger.data.debug("ignoring unknown info property: \(key, privacy: .public)") + } + } + + /** + * add a property from the package section of the kmp.inf file + */ + private func addPackageProperty(key: String, value: [String]) { + if let property = PackageProperty(rawValue: key.lowercased()) { + switch property { + case .version: + self.packageMap[key.lowercased()] = value[0] + case .graphicfile: + self.packageMap[key.lowercased()] = value[0] + case .readmefile: + self.packageMap[key.lowercased()] = value[0] + } + } else { + Logger.data.debug("ignoring unknown package property: \(key, privacy: .public)") + } + } + + /** + * insert the language in the map with the bcp47code as the key and the description as the value + */ + private func addLanguage(key: String, value: [String]) { + let bcp47code = value[0] + let description = value[1] + self.currentLanguageMap[bcp47code] = description + } + + /** + * add a file to the array of filenames + */ + private func addFile(filename: String, description: String) { + self.fileMap[filename] = description + } +} diff --git a/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift b/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift new file mode 100644 index 0000000000..288c018aa8 --- /dev/null +++ b/mac/KeymanSettings/Sources/Persistence/PackageInstallHelper.swift @@ -0,0 +1,415 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2026-06-30 + * + * Tracks the state of a package being installed with functions + * to derive its temporary install location, compare it to a + * package of the same type if it exists and replace or delete depending + * on its version and user feedback. + */ + +import Foundation +import CoreText +import OSLog + +public enum PackageInstallationType { + case newPackage(String) + case replaceSameVersionPackage(String) + case replaceOlderPackage(String, String, String) + case replaceNewerPackage(String, String, String) + + public var prompt: LocalizedStringResource { + switch self { + 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 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 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 LocalizedStringResource( + "prompt.replace.newer.package", + defaultValue: "The package '\(packageName)' is ready to downgrade from version \(existingVersion) to \(newVersion)", + bundle: .atURL(Bundle.module.bundleURL) + ) + } + } +} + +@MainActor // run on the main actor as it is called from SettingsContainer +public class PackageInstallHelper: Identifiable { + public let id = UUID() + public let temporaryKmpFileLocation: URL + let temporaryPackageLocation: URL + let installedPackages: [KeymanPackage] // needed to check for existing package after download + let isDownload: Bool // if not download, then the package was opened from disk or dropped + + // following properties cannot be set until new package is unzipped and loaded + public private(set) var installPackageLocation: URL? // derived from new package name + public private(set) var packageToInstall: KeymanPackage? // the newly downloaded package + public private(set) var packageToReplace: KeymanPackage? // the package to replace, if it exists + public private(set) var packageInstallationType: PackageInstallationType? + + public var packageName: String? { + return packageToInstall?.packageName + } + + fileprivate let packageRepository: PackageRepo + + public init(filename: String, packageRepo: PackageRepo, installedPackages: [KeymanPackage], isDownload: Bool) { + self.packageRepository = packageRepo + self.temporaryKmpFileLocation = self.packageRepository.getDownloadUrl(for: filename) + + // unzip in a directory named the same as the kmp file minus .kmp extension + self.temporaryPackageLocation = self.packageRepository.getUnzipDestinationUrl(for: filename.replacingOccurrences(of: kmpFileExtension, with: "")) + self.installedPackages = installedPackages + self.isDownload = isDownload + + // if any packages are remaining from an earlier download, delete them + self.packageRepository.cleanupTempDirectory() + } + + /** + * Indicates that a package has been downloaded and can be prepared for installation + */ + public func packageDownloadComplete(for kmpFileUrl: URL) throws { + Logger.data.log("packageDownloadComplete \(kmpFileUrl.cleanUrlPath(), privacy: .public)") + + try self.prepareToInstall(for: kmpFileUrl) + } + + /** + * Prepare for installation by unzipping and loading the package and determining where it should be installed. + * + */ + public func prepareToInstall(for kmpFileUrl: URL) throws { + Logger.data.log("prepareToInstall \(kmpFileUrl.cleanUrlPath(), privacy: .public)") + + do { + // unzip to the temp directory + try self.packageRepository.unzipKmpFile(at: kmpFileUrl, to: self.temporaryPackageLocation) + + // load the unzipped package from the temp directory and save a reference to it + let package = try self.packageRepository.loadSinglePackage(packageUrl: self.temporaryPackageLocation) + self.packageToInstall = package + + // if there is an existing package of the same name, use its location as the place to install + if let existingPackage = findExistingPackage() { + self.packageToReplace = existingPackage + self.installPackageLocation = existingPackage.sourceDirectoryUrl + } else { + // if this is a new package, then use the same name as the temporary install directory + let directoryName = self.temporaryPackageLocation.lastPathComponent + self.installPackageLocation = self.packageRepository.buildInstallationUrlForPackageName(directoryName: directoryName) + } + + // now that we know what we are installing, determine the type of install + self.packageInstallationType = self.determinePackageInstallationType(newPackage: package) + } catch { + self.cleanupFailedInstallation() + Logger.data.error("package installation failed for \(kmpFileUrl.cleanUrlPath(), privacy: .public) with error: \(error as NSError, privacy: .public)") + LogUtil.errorBreadcrumb("package installation failed for \(kmpFileUrl.cleanUrlPath()) with error: \(error as NSError)", category: .data) + throw error + } + } + + /** + * Install the new package and replace existing package if necessary + */ + public func installPackage() throws { + Logger.data.info ("installPackage \(self.packageToInstall?.packageName ?? "unknown package", privacy: .public)") + LogUtil.infoBreadcrumb("installPackage \(self.packageToInstall?.packageName ?? "unknown package")", category: .data) + + // prepareToInstall will always set this + guard let installationType = self.packageInstallationType else { + Logger.data.error("error: installationType not set before call to installPackage") + throw InstallPackageError.internalError + } + + switch installationType { + case .newPackage: + try self.installNewPackage() + case .replaceSameVersionPackage, .replaceNewerPackage, .replaceOlderPackage: + try self.replaceExistingPackageWithNewPackage() + } + } + + /** + * Decides what type of package installation this is: + * - a new package + * - an update of an existing package + * - a downgrade of an existing package + */ + func determinePackageInstallationType(newPackage: KeymanPackage) -> PackageInstallationType { + var installationType: PackageInstallationType = .newPackage(newPackage.packageName) + + // if we are replacing an existing package, then determine what type of replacement this is + if let existingPackage = self.packageToReplace { + let newVersion = newPackage.packageVersion + let existingVersion = existingPackage.packageVersion + + let comparisonResult = newVersion.compare(existingVersion, options: .numeric) + + if comparisonResult == .orderedAscending { + Logger.data.info("package downgrade: new version is older than existing version") + LogUtil.infoBreadcrumb("package downgrade: new version is older than existing version", category: .data) + installationType = PackageInstallationType.replaceNewerPackage(newPackage.packageName, existingVersion, newVersion) + } else if comparisonResult == .orderedDescending { + Logger.data.info("package upgrade: new version is newer than existing version") + LogUtil.infoBreadcrumb("package upgrade: new version is newer than existing version", category: .data) + installationType = PackageInstallationType.replaceOlderPackage(newPackage.packageName, existingVersion, newVersion) + } else { + Logger.data.info("new and existing package versions are identical") + LogUtil.infoBreadcrumb("new and existing package versions are identical", category: .data) + installationType = PackageInstallationType.replaceSameVersionPackage(newPackage.packageName) + } + } + + return installationType + } + + /** + * Install all fonts found in the package (files with an extension of .ttf or .otf). + * The package has been copied to the installation directory, so all fonts are located at `installPackageLocation` + * If any fonts fail to install, log the error but continue to the next font + */ + func installFontsForPackage() { + let fileManager = FileManager.default + + guard let installLocation = self.installPackageLocation else { + Logger.data.error("error: installPackageLocation not set when installing fonts") + return + } + + var fileUrls: [URL] = [] + do { + fileUrls = try fileManager.contentsOfDirectory( + at: installLocation, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles]) } + catch { + Logger.data.error("error: unable to get contents of package fonts directory at \(installLocation.cleanUrlPath(), privacy: .public) with error: \(error as NSError, privacy: .public)") + } + + for fontUrl in fileUrls { + let ext = fontUrl.pathExtension.lowercased() + if ext == "ttf" || ext == "otf" { + // if a font fails to install, log error and continue + guard self.validateFont(at: fontUrl) else { + Logger.data.error("error: the font \(fontUrl.lastPathComponent, privacy: .public) is not valid") + LogUtil.errorBreadcrumb("error: the font \(fontUrl.lastPathComponent) is not valid", category: .data) + continue + } + do { + try self.copyFontToFontsDirectory(at: fontUrl) + try self.registerFontWithSystem(at: fontUrl) + } catch { + Logger.data.error("error: the font \(fontUrl.lastPathComponent, privacy: .public) could not be installed with error: \(error as NSError, privacy: .public)") + LogUtil.errorBreadcrumb("error: the font \(fontUrl.lastPathComponent) could not be installed with error: \(error as NSError)", category: .data) + } + } + } + } + + /** + * Check to see whether the font appears to be valid before installing it. + */ + func validateFont(at url: URL) -> Bool { + guard let descriptors = CTFontManagerCreateFontDescriptorsFromURL(url as CFURL) as? [CTFontDescriptor] else { + return false + } + return !descriptors.isEmpty + } + + /** + * Copy the font to the fonts directory and return the URL for its new location. + * If a font of the same name already exists, then remove it before copying the new one. + */ + func copyFontToFontsDirectory(at fontUrl: URL) throws { + let fontsDirectory = KeymanPaths.getFontsDirectory + let fontDestinationUrl = fontsDirectory.appendingPathComponent(fontUrl.lastPathComponent) + let fileManager = FileManager.default + + // remove the font from the fonts directory just in case it is an old one + if fileManager.fileExists(atPath: fontDestinationUrl.path(percentEncoded: false)) { + Logger.data.info("removed existing font: \(fontDestinationUrl.lastPathComponent, privacy: .public)") + LogUtil.infoBreadcrumb("removed existing font: \(fontDestinationUrl.lastPathComponent)", category: .data) + try? fileManager.removeItem(at: fontDestinationUrl) + } + + try fileManager.copyItem(at: fontUrl, to: fontDestinationUrl) + Logger.data.info("added font: \(fontDestinationUrl.lastPathComponent, privacy: .public)") + LogUtil.infoBreadcrumb("added font: \(fontDestinationUrl.lastPathComponent)", category: .data) + } + + /** + * Register the font in the macOS font manager. + * The scope is specified as `CTFontManagerScope.user` which makes the font available to any app + * and causes it to appear in the macOS Font Book application. + */ + func registerFontWithSystem(at fontUrl: URL) throws { + let dispatchGroup = DispatchGroup() + var registrationError: Error? + + // pause current thread until background tasks are complete + dispatchGroup.enter() + + // CTFontManagerRegisterFontURLs returns void -- errors must be captured in the block + CTFontManagerRegisterFontURLs([fontUrl] as CFArray, .user, true) { (errors, done) -> Bool in + let errorArray = errors as? [CFError] ?? [] + + if !errorArray.isEmpty { + + for cfError in errorArray { + let errorCode = CFErrorGetCode(cfError) + + // code 105 = kCTFontManagerErrorAlreadyRegistered + // It is safe to ignore because the font is + if errorCode == 105 { + Logger.data.info("font \(fontUrl.lastPathComponent) is already registered") + LogUtil.infoBreadcrumb("font \(fontUrl.lastPathComponent) is already registered", category: .data) + continue + } + + // if it's any other error, capture it to throw later + Logger.data.error("registerFontWithSystem failed for \(fontUrl.lastPathComponent), error: \(cfError as CFError, privacy: .public)") + LogUtil.errorBreadcrumb("registerFontWithSystem failed for \(fontUrl.lastPathComponent), error: \(cfError as CFError)", category: .data) + registrationError = InstallPackageError.fontRegistrationError + } + + dispatchGroup.leave() + return false // stop registration execution + } + + if done { + dispatchGroup.leave() + } + return true // Continue processing + } + + // wait synchronously for CoreText to finish processing the font file + dispatchGroup.wait() + + // throw an error out to your installation pipeline if registration failed + if let error = registrationError { + throw error + } + } + + /** + * If a package of the same name exists, return it. + */ + func findExistingPackage() -> KeymanPackage? { + var existingPackage: KeymanPackage? = nil + + if let package = self.installedPackages.first(where: { $0.packageName == self.packageToInstall?.packageName }) { + existingPackage = package + } + return existingPackage + } + + /** + * Install the newly downloaded package (no existing package to replace) + */ + func installNewPackage() throws { + if (self.isDownload) { + do { + try self.deleteDownloadedKmpFile() + } catch { + Logger.data.error("installNewPackage failed to delete downloaded .kmp file: \(self.temporaryKmpFileLocation.lastPathComponent, privacy: .public), error: \(error as NSError, privacy: .public)") + LogUtil.errorBreadcrumb("installNewPackage failed to delete downloaded .kmp file: \(self.temporaryKmpFileLocation.lastPathComponent), error: \(error as NSError)", category: .data) + } + } + + try self.movePackageFromTemporaryToInstalled() + self.installFontsForPackage() + } + + /** + * Replace the existing installed package with the newly download package + */ + func replaceExistingPackageWithNewPackage() throws { + try self.deleteInstalledPackage() + if (self.isDownload) { + do { + try self.deleteDownloadedKmpFile() + } catch { + Logger.data.error("replaceExistingPackageWithNewPackage failed to delete downloaded .kmp file: \(self.temporaryKmpFileLocation.lastPathComponent, privacy: .public), error: \(error as NSError, privacy: .public)") + LogUtil.errorBreadcrumb("replaceExistingPackageWithNewPackage failed to delete downloaded .kmp file: \(self.temporaryKmpFileLocation.lastPathComponent), error: \(error as NSError)", category: .data) + } + } + try self.movePackageFromTemporaryToInstalled() + self.installFontsForPackage() + } + + /** + * Clean up the downloaded .kmp file and package folder + */ + func cleanupFailedInstallation() { + // we only have a .kmp file in the temp directory for downloads + if (self.isDownload) { + do { + try self.deleteDownloadedKmpFile() + } catch { + Logger.data.error("cleanupFailedInstallation did not delete downloaded .kmp file: \(self.temporaryKmpFileLocation.lastPathComponent, privacy: .public), error: \(error as NSError, privacy: .public)") + LogUtil.errorBreadcrumb("cleanupFailedInstallation did not delete downloaded .kmp file: \(self.temporaryKmpFileLocation.lastPathComponent), error: \(error as NSError)", category: .data) + } + } + do { + try self.deleteUnzippedPackage() + } catch { + Logger.data.error("cleanupFailedInstallation did not delete downloaded package: \(self.temporaryKmpFileLocation.lastPathComponent, privacy: .public), error: \(error as NSError, privacy: .public)") + LogUtil.errorBreadcrumb("cleanupFailedInstallation did not delete downloaded package: \(self.temporaryKmpFileLocation.lastPathComponent), error as NSError)", category: .data) + } + } + + /** + * Delete the existing installed package that matches the downloaded package + */ + func deleteInstalledPackage() throws { + if let installLocation = self.installPackageLocation { + try FileManager.default.removeItem(at: installLocation) + } + } + + /** + * Move the downloaded package into the keyman packages directory. + */ + func movePackageFromTemporaryToInstalled() throws { + if let installLocation = self.installPackageLocation { + try FileManager.default.moveItem(at: self.temporaryPackageLocation, to: installLocation) + + // Update the KeymanPackage object with its new location + if let package = self.packageToInstall { + package.sourceDirectoryUrl = installLocation + } + } + } + + /** + * Delete the downloaded .kmp file from the temp directory + */ + func deleteDownloadedKmpFile() throws { + try FileManager.default.removeItem(at: self.temporaryKmpFileLocation) + } + + /** + * Delete the unzipped package in the temp directory + */ + func deleteUnzippedPackage() throws { + try FileManager.default.removeItem(at: self.temporaryPackageLocation) + } +} diff --git a/mac/KeymanSettings/Sources/Persistence/PackageRepository.swift b/mac/KeymanSettings/Sources/Persistence/PackageRepository.swift new file mode 100644 index 0000000000..6b08102cff --- /dev/null +++ b/mac/KeymanSettings/Sources/Persistence/PackageRepository.swift @@ -0,0 +1,398 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2025-12-10 + * + * PackageRepository is responsible for reading, writing and removing + * Keyman data on disk. + * + */ + +import Foundation +import OSLog + +public enum LoadPackageError: LocalizedError { + case invalidUrl + case unzipError + case containsNoFiles + case containsNoKeyboards + case kmpJsonFileUnreadable + case kmpInfFileUnreadable + case kmpJsonFileNotFound + case missingKeyboardName + case missingKeyboardId + 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: + 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 .kmpInfFileUnreadable: + let resource = LocalizedStringResource( + "kmp.inf.unreadable", + defaultValue: "The package's kmp.inf 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) + } + } +} + +public class PackageRepository: PackageRepo { + fileprivate let packageJsonFilename = "kmp.json" + fileprivate let packageInfFilename = "kmp.inf" + fileprivate let pathUtil: KeymanPaths + + public init() throws { + self.pathUtil = try KeymanPaths() + + try self.createKeyman19SharedDataDirectoriesIfNeeded() + } + + /** + * Load the Keyman packages from disk and add it to the `installedPackages` array if it passes validation. + */ + public func loadAllPackages() -> [KeymanPackage] { + var installedPackages: [KeymanPackage] = [] + let packageMap = self.readPackages() + + for (url, package) in packageMap { + do { + try package.validate() + installedPackages.append(package) + } catch { + Logger.data.error("validation failed for \(url.lastPathComponent, privacy: .public) with error: \(error as NSError, privacy: .public)") + LogUtil.errorBreadcrumb("validation failed for \(url.lastPathComponent) with error: \(error as NSError)", category: .data) + } + } + + return installedPackages + } + + /** + * read packages at Keyman 19 location, Keyman-Packages directory inside Group Containers directory + */ + func readPackages() -> [URL: KeymanPackage] { + let packageDirectoryUrl = self.pathUtil.keyman19PackagesDirectory + var packageMap: [URL: KeymanPackage] = [:] + + do { + // Get the URLs for all items in the directory that are not hidden + let directoryContents = try FileManager.default.contentsOfDirectory( + at: packageDirectoryUrl, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) + + for itemUrl in directoryContents { + // if the item is a directory, then attempt to read it as a keyboard package + if (itemUrl.hasDirectoryPath) { + do { + if let package = try readPackageFromDirectory(packageDirectoryUrl: itemUrl) { + packageMap[itemUrl] = package + } + } catch let error as LoadPackageError { + Logger.data.error("package at \(itemUrl.cleanUrlPath(), privacy: .public) could not be loaded: \(error as NSError, privacy: .public)") + LogUtil.errorBreadcrumb("package at \(itemUrl.cleanUrlPath()) could not be loaded: \(error as NSError)", category: .data) + } + } + } + } catch { + Logger.data.error("failed to read directory: \(error as NSError, privacy: .public)") + LogUtil.errorBreadcrumb("failed to read directory: \(error as NSError)", category: .data) + } + + Logger.data.info("readPackageSource: \(packageMap.count) packages read") + LogUtil.infoBreadcrumb("readPackageSource: \(packageMap.count) packages read", category: .data) + return packageMap + } + + /** + * Load the single package from disk and wrap it as a `KeymanPackage` object + * If the `KeymanPackage` passes validation, then add it to the `installedPackages` array. + * + */ + public func loadSinglePackage(packageUrl: URL) throws -> KeymanPackage { + Logger.data.info("loadSinglePackage from url: \(packageUrl.cleanUrlPath(), privacy: .public)") + LogUtil.infoBreadcrumb("loadSinglePackage from url: \(packageUrl.cleanUrlPath())", category: .data) + + guard let package = try readPackageFromDirectory(packageDirectoryUrl: packageUrl) else { throw LoadPackageError.invalidUrl } + + try package.validate() + return package + } + + /** + * delete the package from disk + */ + public func deletePackage(package: KeymanPackage) { + Logger.data.info("deleting package: \(package.sourceDirectoryUrl.cleanUrlPath(), privacy: .public)") + LogUtil.infoBreadcrumb("deleting package: \(package.sourceDirectoryUrl.cleanUrlPath())", category: .data) + do { + try FileManager.default.removeItem(at: package.sourceDirectoryUrl) + Logger.data.info("deleted package: \(package.sourceDirectoryUrl.cleanUrlPath(), privacy: .public)") + LogUtil.infoBreadcrumb("deleted package: \(package.sourceDirectoryUrl.cleanUrlPath())", category: .data) + } catch { + Logger.data.error("could not delete directory: \(error as NSError, privacy: .public)") + LogUtil.errorBreadcrumb("could not delete directory: \(error as NSError)", category: .data) + } + } + + /** + * Creates the directory tree where packages are stored under the standard 'Group Containers' directory + * Also creates the temp directory used for keyboard installation + */ + public func createKeyman19SharedDataDirectoriesIfNeeded() throws { + let packageDirectory = pathUtil.keyman19PackagesDirectory + let packageTempDirectory = pathUtil.keyman19TempDirectory + + // create the keyman-packages directory if it doesn't already exist + if !FileManager.default.fileExists(atPath: packageDirectory.path(percentEncoded: false)) { + try FileManager.default.createDirectory(at: packageDirectory, withIntermediateDirectories: true, attributes: nil) + Logger.data.info("Created directory: \(packageDirectory.cleanUrlPath(), privacy: .public)") + LogUtil.infoBreadcrumb("Created directory: \(packageDirectory.cleanUrlPath())", category: .data) + } else { + Logger.data.info("Directory already exists: \(packageDirectory.cleanUrlPath(), privacy: .public)") + LogUtil.infoBreadcrumb("Directory already exists: \(packageDirectory.cleanUrlPath())", category: .data) + } + + // create the temp directory if it doesn't already exist + if !FileManager.default.fileExists(atPath: packageTempDirectory.path(percentEncoded: false)) { + try FileManager.default.createDirectory(at: packageTempDirectory, withIntermediateDirectories: true, attributes: nil) + Logger.data.info("Created directory: \(packageTempDirectory.cleanUrlPath(), privacy: .public)") + LogUtil.infoBreadcrumb("Created directory: \(packageTempDirectory.cleanUrlPath())", category: .data) + } else { + Logger.data.info("Directory already exists: \(packageTempDirectory.cleanUrlPath(), privacy: .public)") + LogUtil.infoBreadcrumb("Directory already exists: \(packageTempDirectory.cleanUrlPath())", category: .data) + } + } + + /** + * Delete all the files in the temp directory + */ + public func cleanupTempDirectory() { + let fileManager = FileManager.default + + do { + let fileURLs = try fileManager.contentsOfDirectory( + at: self.pathUtil.keyman19TempDirectory, + includingPropertiesForKeys: nil, + options: .skipsHiddenFiles + ) + + for fileURL in fileURLs { + try fileManager.removeItem(at: fileURL) + } + + Logger.data.info("successfully cleared temp directory") + LogUtil.infoBreadcrumb("successfully cleared temp directory", category: .data) + } catch { + Logger.data.error("error clearing temp directory: \(error as NSError, privacy: .public)") + LogUtil.errorBreadcrumb("error clearing temp directory: \(error as NSError)", category: .data) + } + } + + /** + * get the url to where the specified kmp file should be downloaded + */ + public func getDownloadUrl(for kmpFilename: String) -> URL { + return self.pathUtil.keyman19TempDirectory.appendingPathComponent(kmpFilename) + } + + /** + * get the url to where the specified package should initially be unzipped + */ + public func getUnzipDestinationUrl(for packageName: String) -> URL { + return self.pathUtil.keyman19TempDirectory.appendingPathComponent(packageName) + } + + /** + * build the URL where the specified package will be installed + */ + public func buildInstallationUrlForPackageName(directoryName: String) -> URL { + return self.pathUtil.keyman19PackagesDirectory.appendingPathComponent(directoryName) + } + + /** + * install keyboard at specified URL + */ + public func unzipKmpFile(at kmpFileUrl: URL, to packageDestinationUrl: URL) throws { + do { + try FileManager.default.unzipItem(at: kmpFileUrl, to: packageDestinationUrl) + Logger.data.info("successfully unzipped the file") + LogUtil.infoBreadcrumb("successfully unzipped the file", category: .data) + } catch { + Logger.data.error("extraction failed: \(error as NSError, privacy: .public)") + LogUtil.errorBreadcrumb("extraction failed: \(error as NSError)", category: .data) + throw LoadPackageError.unzipError + } + } + + /** + * Check to see whether the shared Keyman data directory exists under 'Library/Group Containers/' + */ + public func keyman19SharedDataDirectoryExists() -> Bool { + return self.directoryExistsAtPath(directoryUrl: self.pathUtil.keyman19PackagesDirectory) + } + + /** + * returns true if a directory exists at the specified URL + */ + func directoryExistsAtPath(directoryUrl: URL) -> Bool { + var isDirectory: ObjCBool = false + let exists = FileManager.default.fileExists(atPath: directoryUrl.path(percentEncoded: false), isDirectory: &isDirectory) + return exists && isDirectory.boolValue + } + + /** + * check the specified directory for the kmp.json file and read it if it exists + */ + func readPackageFromDirectory(packageDirectoryUrl: URL) throws -> KeymanPackage? { + Logger.data.info("readKeymanPackageFromDirectory from url: \(packageDirectoryUrl.cleanUrlPath(), privacy: .public)") + LogUtil.infoBreadcrumb("readKeymanPackageFromDirectory from url: \(packageDirectoryUrl.cleanUrlPath())", category: .data) + var keymanPackage: KeymanPackage? = nil + let kmpJsonFileUrl = packageDirectoryUrl.appendingPathComponent(packageJsonFilename) + let kmpInfFileUrl = packageDirectoryUrl.appendingPathComponent(packageInfFilename) + + if FileManager.default.fileExists(atPath: kmpJsonFileUrl.path(percentEncoded: false)) { + // if an error occurs, it will be propagated to caller + if let package = try readPackageFromJson(kmpJsonFileUrl: kmpJsonFileUrl, in: packageDirectoryUrl) { + keymanPackage = package + } + } else { + // if no kmp.json file, look for kmp.inf instead + if FileManager.default.fileExists(atPath: kmpInfFileUrl.path(percentEncoded: false)) { + Logger.data.info("fallback to kmp.inf file at: \(kmpInfFileUrl.cleanUrlPath(), privacy: .public)") + LogUtil.infoBreadcrumb("fallback to kmp.inf file at: \(kmpInfFileUrl.cleanUrlPath())", category: .data) + + if let package = try readPackageFromInf(kmpInfFileUrl: kmpInfFileUrl, in: packageDirectoryUrl) { + keymanPackage = package + } + } else { + // no kmp.json and no kmp.inf, so throw error + // the error designates only kmp.json missing as kmp.inf is a fallback + throw LoadPackageError.kmpJsonFileNotFound + } + } + + return keymanPackage + } + + /** + * read and parse the kmp.json file at the specified URL + */ + func readPackageFromJson(kmpJsonFileUrl: URL, in packageDirectoryUrl: URL) throws -> KeymanPackage? { + var packageSource: PackageSource? + var package: KeymanPackage? + do { + let jsonData = try Data(contentsOf: kmpJsonFileUrl, options: .mappedIfSafe) + packageSource = try JSONDecoder().decode(PackageSource.self, from: jsonData) + if let source = packageSource { + package = KeymanPackage(packageUrl: packageDirectoryUrl, packageSource: source) + } + } catch let error as LoadPackageError { + // if we encounter a LoadPackageError, propagate it + throw error + } catch { + // otherwise convert the error to a LoadPackageError error + Logger.data.error("readKeymanPackageFromJson error: \(error as NSError, privacy: .public)") + LogUtil.errorBreadcrumb("readKeymanPackageFromJson error: \(error as NSError)", category: .data) + throw LoadPackageError.kmpJsonFileUnreadable + } + return package + } + + /** + * read and parse the kmp.json file at the specified URL + */ + func readPackageFromInf(kmpInfFileUrl: URL, in packageDirectoryUrl: URL) throws -> KeymanPackage? { + var package: KeymanPackage? = nil + + do { + let packageReader = try KmpInfParser(fileUrl: kmpInfFileUrl, in: packageDirectoryUrl) + if let keymanPackage = try packageReader.parse() { + package = keymanPackage + Logger.data.info("successfully read package using kmp.inf: \(keymanPackage.packageName, privacy: .public)") + LogUtil.infoBreadcrumb("successfully read package using kmp.inf: \(keymanPackage.packageName)", category: .data) + } + } catch let error as LoadPackageError { + // if we encounter a LoadPackageError, propagate it + throw error + } catch { + // otherwise convert the error to a LoadPackageError error + Logger.data.error("readPackageFromInf, failed to read kmp.inf file: \(kmpInfFileUrl.cleanUrlPath(), privacy: .public), error: \(error as NSError)") + LogUtil.errorBreadcrumb("readPackageFromInf, failed to read kmp.inf file: \(kmpInfFileUrl.cleanUrlPath()), error: \(error as NSError)", category: .data) + throw LoadPackageError.kmpInfFileUnreadable + } + + return package + } +} diff --git a/mac/KeymanSettings/Sources/Resources/SideImage.bmp b/mac/KeymanSettings/Sources/Resources/SideImage.bmp new file mode 100644 index 0000000000..ac4aa2dded Binary files /dev/null and b/mac/KeymanSettings/Sources/Resources/SideImage.bmp differ diff --git a/mac/KeymanSettings/Tests/KeymanSettingsTests/KeymanSettingsTests.swift b/mac/KeymanSettings/Tests/KeymanSettingsTests/KeymanSettingsTests.swift new file mode 100644 index 0000000000..12965719ea --- /dev/null +++ b/mac/KeymanSettings/Tests/KeymanSettingsTests/KeymanSettingsTests.swift @@ -0,0 +1,287 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2026-03-06 + * + * Tests for KeymanSettings package + * + */ + +import Testing +import Foundation +import OSLog +@testable import KeymanSettings + +@Suite("Settings Container") struct SettingsContainersTests { + + fileprivate init() async throws { + Logger.setup.info("init") + } + + @Test("Check settings creation") @MainActor func testSettingsCreation() async throws { + let defaultsRepo = DefaultsRepoStub(enabledKeyboards: Set([moabiteKeyboardKey, hittiteKeyboardKey])) + let packageRepo = PackageRepoStub() + let settingsContainer = SettingsContainer(defaultsRepo: defaultsRepo, packageRepo: packageRepo) + + #expect(settingsContainer.multiKeyboardPackages.isEmpty) + #expect(settingsContainer.singleKeyboardPackages.isEmpty) + + settingsContainer.loadPackages() + + #expect(settingsContainer.findInstalledPackage(with: packageRepo.testPackageId) != nil) + #expect(defaultsRepo.readEnabledKeyboards().contains(moabiteKeyboardKey)) + } + + @Test("Check finding packages by UUID") @MainActor func testFindPackage() async throws { + let defaultsRepo = DefaultsRepoStub(enabledKeyboards: Set([moabiteKeyboardKey, hittiteKeyboardKey])) + let packageRepo = PackageRepoStub() + let settingsContainer = SettingsContainer(defaultsRepo: defaultsRepo, packageRepo: packageRepo) + settingsContainer.loadPackages() + + // verify that we can find that test package and cannot find the null package + #expect(settingsContainer.findInstalledPackage(with: packageRepo.testPackageId) != nil) + #expect(settingsContainer.findInstalledPackage(with: packageRepo.nullPackageId) == nil) + } + + @Test("Check remove package") @MainActor func testRemovePackage() async throws { + let defaultsRepo = DefaultsRepoStub(enabledKeyboards: Set([moabiteKeyboardKey, hittiteKeyboardKey])) + let packageRepo = PackageRepoStub() + let settingsContainer = SettingsContainer(defaultsRepo: defaultsRepo, packageRepo: packageRepo) + settingsContainer.loadPackages() + + // verify that we cannot find the test package after removing it + #expect(settingsContainer.findInstalledPackage(with: packageRepo.testPackageId) != nil) + settingsContainer.removeInstalledPackage(with: packageRepo.testPackageId) + #expect(settingsContainer.findInstalledPackage(with: packageRepo.testPackageId) == nil) + } + + /** + * test whether an uninstalled keyboard is removed from the list of enabled keyboards when calling `validateSettings()` + */ + @Test("Check settings validation ") @MainActor func testSettingsValidation() async throws { + let defaultsRepo = DefaultsRepoStub(enabledKeyboards: Set([moabiteKeyboardKey, uninstalledKeyboardKey])) + let packageRepo = PackageRepoStub() + let settingsContainer = SettingsContainer(defaultsRepo: defaultsRepo, packageRepo: packageRepo) + + settingsContainer.loadPackages() + settingsContainer.validateUserDefaults() + let enabledKeyboards = defaultsRepo.readEnabledKeyboards() + + #expect(!enabledKeyboards.contains(uninstalledKeyboardKey)) + #expect(enabledKeyboards.contains(moabiteKeyboardKey)) + } + + /** + * test whether keyboard state changes from disabled to enabled when settings are applied + */ + @Test("Check settings applied ") @MainActor func testSettingsApplied() async throws { + let defaultsRepo = DefaultsRepoStub(enabledKeyboards: Set([moabiteKeyboardKey, hittiteKeyboardKey])) + let packageRepo = PackageRepoStub() + let settingsContainer = SettingsContainer(defaultsRepo: defaultsRepo, packageRepo: packageRepo) + + settingsContainer.loadPackages() + let package = try #require(settingsContainer.findInstalledPackage(with: packageRepo.testPackageId)) + + // the hittite keyboard is initialized as disabled + #expect(!package.isKeyboardEnabled(keyboardKey: hittiteKeyboardKey)) + + // it should be enabled after user defaults are applied + settingsContainer.applyUserDefaultsToInstalledPackages() + + #expect(package.isKeyboardEnabled(keyboardKey: hittiteKeyboardKey)) + } + + @Test("Check get installed keyboards") @MainActor func testGetInstalledKeyboards() async throws { + let defaultsRepo = DefaultsRepoStub(enabledKeyboards: Set([moabiteKeyboardKey, hittiteKeyboardKey])) + let packageRepo = PackageRepoStub() + let settingsContainer = SettingsContainer(defaultsRepo: defaultsRepo, packageRepo: packageRepo) + settingsContainer.loadPackages() + + // verify that two keyboards are installed + #expect(settingsContainer.getInstalledKeyboardKeys().count == 2) + } + + @Test("Check get enabled keyboards") @MainActor func testGetEnabledKeyboards() async throws { + let defaultsRepo = DefaultsRepoStub(enabledKeyboards: Set([moabiteKeyboardKey])) + let packageRepo = PackageRepoStub() + let settingsContainer = SettingsContainer(defaultsRepo: defaultsRepo, packageRepo: packageRepo) + settingsContainer.loadPackages() + + // verify that one keyboard is enabled + #expect(settingsContainer.getEnabledKeyboardKeys().count == 1) + } + + @Test("Check keyboard enabled") @MainActor func testIsKeyboardEnabled() async throws { + let defaultsRepo = DefaultsRepoStub(enabledKeyboards: Set([moabiteKeyboardKey])) + let packageRepo = PackageRepoStub() + let settingsContainer = SettingsContainer(defaultsRepo: defaultsRepo, packageRepo: packageRepo) + settingsContainer.loadPackages() + + // verify that moabite keyboard is enabled + #expect(settingsContainer.isKeyboardEnabled(packageId: packageRepo.testPackageId, keyboardKey: moabiteKeyboardKey)) + } + + @Test("Check enable keyboard") @MainActor func testEnableKeyboard() async throws { + let defaultsRepo = DefaultsRepoStub(enabledKeyboards: Set([moabiteKeyboardKey])) + let packageRepo = PackageRepoStub() + let settingsContainer = SettingsContainer(defaultsRepo: defaultsRepo, packageRepo: packageRepo) + settingsContainer.loadPackages() + + // verify that the hittite keyboard changes from disabled to enabled + #expect(!settingsContainer.isKeyboardEnabled(packageId: packageRepo.testPackageId, keyboardKey: hittiteKeyboardKey)) + settingsContainer.setKeyboardEnabled(packageId: packageRepo.testPackageId, keyboardKey: hittiteKeyboardKey, enabled: true) + #expect(settingsContainer.isKeyboardEnabled(packageId: packageRepo.testPackageId, keyboardKey: hittiteKeyboardKey)) + } +} + + + +@Suite("Check Keyman paths") struct KeymanPathsTests { + + fileprivate init() async throws { + Logger.setup.info("init") + } + + @Test("Check Keyman 17 documents directory") func testKeyman17DocumentsDirectory() async throws { + let documentsDirectory = try #require(KeymanPaths().keyman17DocumentsDirectory) + #expect(documentsDirectory.absoluteString.hasSuffix("Documents/")) + } + + @Test("Check Keyman 17 packages directory") func testKeyman17KeyboardsDirectory() async throws { + #expect(true) + let keyboardsDirectory = try #require(KeymanPaths().keyman17PackagesDirectory) + #expect(keyboardsDirectory.absoluteString.hasSuffix("Documents/Keyman-Keyboards/")) + } + + @Test("Check Keyman 18 support directory") func testKeyman18SupportDirectory() async throws { + #expect(true) + let supportDirectory = try #require(KeymanPaths().keyman18SupportDirectory) + #expect(supportDirectory.absoluteString.hasSuffix("Library/Application%20Support/")) + } + + @Test("Check Keyman 18 data directory") func testKeyman18SupportKeymanDirectory() async throws { + #expect(true) + let supportDirectory = try #require(KeymanPaths().keyman18DataDirectory) + #expect(supportDirectory.absoluteString.hasSuffix("Library/Application%20Support/keyman.inputmethod.Keyman/")) + } + + @Test("Check Keyman 18 packages directory") func testKeyman18SupportKeyboardsDirectory() async throws { + #expect(true) + let keyboardsDirectory = try #require(KeymanPaths().keyman18PackagesDirectory) + #expect(keyboardsDirectory.absoluteString.hasSuffix("Library/Application%20Support/keyman.inputmethod.Keyman/Keyman-Keyboards/")) + } + + @Test("Check Keyman 19 container data directory") func testKeyman19ContainerDataDirectory() async throws { + #expect(true) + let containerDirectory = try KeymanPaths().keyman19ContainerDirectory + #expect(containerDirectory.absoluteString.hasSuffix("Group%20Containers/group.com.keyman/")) + } + + @Test("Check Keyman 19 packages directory") func testKeyman19KeyboardsDirectory() async throws { + #expect(true) + let keyboardsDirectory = try KeymanPaths().keyman19PackagesDirectory + #expect(keyboardsDirectory.absoluteString.hasSuffix("Group%20Containers/group.com.keyman/Library/Application%20Support/Keyman-Packages/")) + } +} + +// defaults read test.suite.name + +@Suite("UserDefaults check") struct KeymanSettingsRepositoryTests { + var settingsRepo: DefaultsRepo + let hittiteKeyboardKey = "/sil_extinct/hittite.kmx" + let moabiteKeyboardKey = "/sil_extinct/moabite.kmx" + + fileprivate init() async throws { + Logger.setup.info("init") + do { + try self.settingsRepo = DefaultsRepository(suiteName: "test.suite.name") + } catch UserDefaultsError.unknownSuite { + fatalError("Cannot access UserDefaults suite.") + } catch { + fatalError("Cannot access UserDefaults suite.") + } + } + + @Test("Write and read selected keyboard") func writeSelectedKeyboard() async throws { + settingsRepo.writeSelectedKeyboard(keyboardName: moabiteKeyboardKey) + #expect(settingsRepo.readSelectedKeyboard() == moabiteKeyboardKey) + } + + @Test("Write and read enabled keyboards") func writeEnabledKeyboards() async throws { + settingsRepo.writeEnabledKeyboards(enabledKeyboardsArray: [hittiteKeyboardKey, moabiteKeyboardKey]) + #expect(settingsRepo.readEnabledKeyboards().count == 2) + #expect(settingsRepo.readEnabledKeyboards().contains(hittiteKeyboardKey)) + #expect(settingsRepo.readEnabledKeyboards().contains(moabiteKeyboardKey)) + } +} + +@Suite("Read Keyman package") struct KeymanPackageRepositoryTests { + let packageRepo: PackageRepository + let kmpUrl: URL + let keymanPackage: KeymanPackage? + let fakePackageUrl: URL = URL(fileURLWithPath: "/fake/test/destination/amharic-fake") + let fakeDirectoryUrl: URL = URL(fileURLWithPath: "/fake/test/destination") + + fileprivate init() async throws { + try packageRepo = PackageRepository() + self.kmpUrl = try #require(Bundle.module.url(forResource: "amharic.kmp", withExtension: "json")) + keymanPackage = try packageRepo.readPackageFromJson(kmpJsonFileUrl: self.kmpUrl, in: fakeDirectoryUrl) + } + + @Test("Read package name") func readPackageName() async throws { + let package = try #require(keymanPackage) + #expect(package.packageName == "GFF Amharic Keyboard") + } + + @Test("Read package version") func readPackageVersion() async throws { + let package = try #require(keymanPackage) + #expect(package.packageVersion == "3.1.2") + } + + @Test("Read copyright") func readCopyright() async throws { + let package = try #require(keymanPackage) + #expect(package.copyright == "© Geʾez Frontier Foundation, SIL International") + } + + @Test("Read keyboard count") func readKeyboardCount() async throws { + let package = try #require(keymanPackage) + #expect(package.keyboards.count == 1) + } + + @Test("Read keyboard name") func readKeyboardName() async throws { + let package = try #require(keymanPackage) + let keyboard = try #require(package.keyboards.first) + #expect(keyboard.name == "አማርኛ (Amharic)") + } + + /** + * Ensure that Keyboard objects are correctly created from Keyboardsource + */ + @Suite("Check keyboard state") struct KeyboardStateTests { + let packageRepo: PackageRepository + let kmpUrl: URL + let keymanPackage: KeymanPackage? + let fakePackageUrl: URL = URL(fileURLWithPath: "/fake/test/amharic-fake") + let fakePackageDirectoryUrl: URL = URL(fileURLWithPath: "/fake/test/amharic-fake") + + fileprivate init() async throws { + try packageRepo = PackageRepository() + self.kmpUrl = try #require(Bundle.module.url(forResource: "amharic.kmp", withExtension: "json")) + self.keymanPackage = try packageRepo.readPackageFromJson(kmpJsonFileUrl: self.kmpUrl, in: fakePackageDirectoryUrl) + } + + @Test("Check keyboard is enabled") func checkKeyboardEnabled() async throws { + let package = try #require(self.keymanPackage) + let keyboard = try #require(package.keyboards.first) + + #expect(keyboard.enabled) + } + + @Test("Check keyboard key") func checkKeyboardKey() async throws { + let package = try #require(self.keymanPackage) + let keyboard = try #require(package.keyboards.first) + + #expect(keyboard.keyboardKey == "/amharic-fake/gff_amharic.kmx") + } + } +} diff --git a/mac/KeymanSettings/Tests/KeymanSettingsTests/KeymanSettingsTests.xctestplan b/mac/KeymanSettings/Tests/KeymanSettingsTests/KeymanSettingsTests.xctestplan new file mode 100644 index 0000000000..a74221863c --- /dev/null +++ b/mac/KeymanSettings/Tests/KeymanSettingsTests/KeymanSettingsTests.xctestplan @@ -0,0 +1,24 @@ +{ + "configurations" : [ + { + "id" : "35B1C8B8-56CD-43D4-9EF1-29C4417C786A", + "name" : "Test Scheme Action", + "options" : { + + } + } + ], + "defaultOptions" : { + "performanceAntipatternCheckerEnabled" : true + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:", + "identifier" : "KeymanSettingsTests", + "name" : "KeymanSettingsTests" + } + } + ], + "version" : 1 +} diff --git a/mac/KeymanSettings/Tests/KeymanSettingsTests/RepoStubs.swift b/mac/KeymanSettings/Tests/KeymanSettingsTests/RepoStubs.swift new file mode 100644 index 0000000000..87ac814dac --- /dev/null +++ b/mac/KeymanSettings/Tests/KeymanSettingsTests/RepoStubs.swift @@ -0,0 +1,142 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2026-04-29 + * + * Contains stubs for the repository classes to support unit tests + * so that they do not access the file system or UserDefaults database. + */ + +import Foundation +import KeymanSettings + +/** + * conforms to DefaultsRepo but takes a set of enabled keyboard keys in the initializer and + * keeps everything in memory rather than accessing UserDefaults. + */ + +public let extinctPackageName = "sil_extinct" +public let moabiteKeyboardKey = "/\(extinctPackageName)/moabite_basic.kmx" +public let hittiteKeyboardKey = "/\(extinctPackageName)/hittite_basic.kmx" +public let uninstalledKeyboardKey = "/\(extinctPackageName)/girgashite_basic.kmx" + +class DefaultsRepoStub: DefaultsRepo { + private var selectedKeyboard: String + private var enabledKeyboards: Set + + init(enabledKeyboards: Set) { + self.enabledKeyboards = enabledKeyboards + + // selectedKeyboard not relevant yet in config app + // but may need validation later + selectedKeyboard = moabiteKeyboardKey + } + + func readEnabledKeyboards() -> Set { + return enabledKeyboards + } + + func writeEnabledKeyboards(enabledKeyboardsArray: [String]) { + self.enabledKeyboards = Set(enabledKeyboardsArray) + } + + func readSelectedKeyboard() -> String { + return self.selectedKeyboard + } + + func writeSelectedKeyboard(keyboardName: String) { + self.selectedKeyboard = keyboardName + } + + + func clearDefaults() { + selectedKeyboard = "" + enabledKeyboards = [] + } + + func installationStateExists() -> Bool { + return false + } + + func readInstallationState() -> Dictionary? { + return nil + } + + func writeInstallationState(_ dictionary: Dictionary) { + } + + func deleteInstallationState() { + } +} + +/** + * conforms to PackageRepo but creates a package with two keyboards and + * stores them in memory with no access to disk + */ +class PackageRepoStub: PackageRepo { + func cleanupTempDirectory() { + } + + func unzipKmpFile(at kmpFileUrl: URL, to packageDestinationUrl: URL) throws { + } + + func getDownloadUrl(for kmpFilename: String) -> URL { + return URL(fileURLWithPath: "") + } + + func getUnzipDestinationUrl(for packageName: String) -> URL { + return URL(fileURLWithPath: "") + } + + func buildInstallationUrlForPackageName(directoryName: String) -> URL { + return URL(fileURLWithPath: "") + } + + public let nullPackageId = UUID(uuid: (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)) + public var testPackageId = UUID(uuid: (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)) + + func keyman19SharedDataDirectoryExists() -> Bool { + true + } + + func createKeyman19SharedDataDirectoriesIfNeeded() throws { + // do nothing + } + + /** + * creates a package containing two keyboards, one enabled and one disabled + */ + func loadAllPackages() -> [KeymanPackage] { + var packageArray: [KeymanPackage] = [] + + let packagesDirectoryUrl = URL(filePath: "/Users/linguist-sil/Library/Group%20Containers/group.com.keyman/Library/Application%20Support/Keyman-Packages/")! + let testPackageDirectoryName = extinctPackageName + let testPackageUrl = packagesDirectoryUrl.appendingPathComponent(testPackageDirectoryName) + let moabiteKeyboardId = "moabite_basic" + let moabiteKeyboard = Keyboard(name: "moabite basic", keyboardId: moabiteKeyboardId, packageDirectoryName: testPackageDirectoryName, enabled: true) + let hittiteKeyboardId = "hittite_basic" + let hittiteKeyboard = Keyboard(name: "hittite basic", keyboardId: hittiteKeyboardId, packageDirectoryName: testPackageDirectoryName, enabled: false) + + do { + let testPackage = try KeymanPackage(packageUrl: testPackageUrl, keyboards: [moabiteKeyboard, hittiteKeyboard], fonts: [], + packageName: "Extinct Languages", packageVersion: "1.1.03") + self.testPackageId = testPackage.id + packageArray.append(testPackage) + } catch { + print("loadAllPackages() failed") + } + + return packageArray + } + + func deletePackage(package: KeymanPackage) { + } + + func getDownloadUrlForPackageName(packageName: String) -> URL? { + return nil + } + + func loadSinglePackage(packageUrl: URL) throws -> KeymanSettings.KeymanPackage { + fatalError("loadSinglePackage is not implemented") + } +} diff --git a/mac/KeymanSettings/Tests/Resources/amharic.kmp.json b/mac/KeymanSettings/Tests/Resources/amharic.kmp.json new file mode 100644 index 0000000000..5c2a817f26 --- /dev/null +++ b/mac/KeymanSettings/Tests/Resources/amharic.kmp.json @@ -0,0 +1,215 @@ +{ + "system": { + "fileVersion": "15.0", + "keymanDeveloperVersion": "17.0.330" + }, + "options": { + "graphicFile": "sideimage.bmp", + "readmeFile": "readme.htm", + "licenseFile": "LICENSE.md", + "welcomeFile": "welcome.htm" + }, + "info": { + "author": { + "description": "The Geʾez Frontier Foundation", + "url": "mailto:keyboards@ethiopic.org" + }, + "copyright": { + "description": "© Geʾez Frontier Foundation, SIL International" + }, + "name": { + "description": "GFF Amharic Keyboard" + }, + "version": { + "description": "3.1.2" + }, + "website": { + "description": "http://keyboards.ethiopic.org", + "url": "http://keyboards.ethiopic.org" + }, + "description": { + "description": "

This is an Amharic (amh, አማርኛ) language mnemonic input method. It\nrequires a font supporting Ethiopic script under the Unicode 4.1\nstandard.

" + } + }, + "relatedPackages": [ + { + "id": "gff_amh_powerpack_7", + "relationship": "deprecates" + }, + { + "id": "gff_amh_7", + "relationship": "deprecates" + } + ], + "files": [ + { + "name": "readme.htm", + "description": "File readme.htm" + }, + { + "name": "sideimage.bmp", + "description": "File sideimage.bmp" + }, + { + "name": "AmharicTyping-Amharic.pdf", + "description": "File AmharicTyping-Amharic.pdf" + }, + { + "name": "AmharicTyping-English.pdf", + "description": "File AmharicTyping-English.pdf" + }, + { + "name": "OFL.txt", + "description": "File OFL.txt" + }, + { + "name": "GNU-GPLv2.txt", + "description": "File GNU-GPLv2.txt" + }, + { + "name": "welcome.htm", + "description": "File welcome.htm" + }, + { + "name": "fantuwua.ttf", + "description": "Font Ethiopic Fantuwua" + }, + { + "name": "FreeSerif.otf", + "description": "Font Free Serif" + }, + { + "name": "goffer.ttf", + "description": "Font Ethiopic Yigezu Bisrat Goffer" + }, + { + "name": "hiwua.ttf", + "description": "Font Ethiopic Hiwua" + }, + { + "name": "jiret.ttf", + "description": "Font Ethiopia Jiret" + }, + { + "name": "jiretsl.ttf", + "description": "Font Ethiopia Jiret Slant" + }, + { + "name": "tint.ttf", + "description": "Font Ethiopic Tint" + }, + { + "name": "washrab.ttf", + "description": "Font Ethiopic WashRa Bold" + }, + { + "name": "washrabsl.ttf", + "description": "Font Ethiopic WashRa Bold Slant" + }, + { + "name": "washrasb.ttf", + "description": "Font Ethiopic WashRa SemiBold" + }, + { + "name": "washrasbsl.ttf", + "description": "Font Ethiopic WashRa SemiBold Slant" + }, + { + "name": "wookianos.ttf", + "description": "Font Ethiopic Wookianos" + }, + { + "name": "yebse.ttf", + "description": "Font Ethiopic Yebse" + }, + { + "name": "gff_amharic.js", + "description": "File gff_amharic.js" + }, + { + "name": "gff_amharic.kmx", + "description": "Keyboard አማርኛ (Amharic)" + }, + { + "name": "Brana-Regular.ttf", + "description": "Font Brana" + }, + { + "name": "gff_amharic.kvk", + "description": "File gff_amharic.kvk" + }, + { + "name": "AbyssinicaSIL-Regular.ttf", + "description": "Font Abyssinica SIL" + }, + { + "name": "AbyssinicaSILConnected-Regular.ttf", + "description": "Font Abyssinica SIL Connected" + }, + { + "name": "LICENSE.md", + "description": "" + }, + { + "name": "kmp.inf", + "description": "Package information" + }, + { + "name": "kmp.json", + "description": "Package information (JSON)" + } + ], + "keyboards": [ + { + "displayFont": "AbyssinicaSIL-Regular.ttf", + "oskFont": "AbyssinicaSIL-Regular.ttf", + "name": "አማርኛ (Amharic)", + "id": "gff_amharic", + "version": "3.1.2", + "languages": [ + { + "name": "Amharic", + "id": "am" + } + ], + "examples": [ + { + "id": "am", + "keys": "T i e n a space y s T l N", + "text": "ጤና ይስጥልኝ", + "note": "\"Hello\"" + } + ] + } + ], + "startMenu": { + "addUninstallEntry": true, + "folder": "GFF Amharic Keyboard", + "items": [ + { + "filename": "welcome.htm", + "name": "Welcome" + }, + { + "filename": "AmharicTyping-Amharic.pdf", + "name": "Amharic Keyboard Manual (Amharic)" + }, + { + "filename": "AmharicTyping-English.pdf", + "name": "Amharic Keyboard Manual (English)" + }, + { + "filename": "OFL.txt", + "name": "OFL (Abyssinica SIL Licence)" + }, + { + "filename": "GNU-GPLv2.txt", + "name": "GPL (WashRa + Free Serif Licence)" + }, + { + "filename": "http://keyboards.ethiopic.org/docs/AmharicTypingPractice.docx", + "name": "Amharic Typing Practice" + } + ] + } +} \ No newline at end of file diff --git a/mac/README.md b/mac/README.md index b4b500dfc3..879c77b0c5 100644 --- a/mac/README.md +++ b/mac/README.md @@ -136,3 +136,7 @@ Then run this command to fix the build environment: `sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer` [Install Keyman for macOS]: https://help.keyman.com/products/mac/current-version/start/install-keyman + +### New Mac Config App + +This is now in development. diff --git a/mac/build.sh b/mac/build.sh index dd1e60ac87..88eaedf42d 100755 --- a/mac/build.sh +++ b/mac/build.sh @@ -15,11 +15,12 @@ builder_describe "Builds Keyman for macOS." \ "clean" \ "configure" \ "build" \ - "publish Publishes debug info to Sentry and builds DMG, .download_info for distribution" \ + "publish Publishes debug info to Sentry and creates installer, .download_info for distribution" \ "test" \ - "install Installs result of Keyman4MacIM locally." \ + "install Installs Keyman.app and Keyman Configuration.app locally." \ ":engine KeymanEngine4Mac" \ ":app Keyman4MacIM" \ + ":configapp Keyman Configuration app" \ ":help Online documentation" \ ":mcompile mnemonic layout recompiler- mac" \ ":testapp Keyman4Mac (test harness)" \ @@ -43,6 +44,7 @@ fi ENGINE_NAME="KeymanEngine4Mac" TESTAPP_NAME="Keyman4Mac" IM_NAME="Keyman4MacIM" +CONFIGAPP_NAME="Config" XCODE_PROJ_EXT=".xcodeproj" PRODUCT_NAME="Keyman" @@ -56,12 +58,17 @@ KME4M_PROJECT_PATH="$KME4M_BASE_PATH/$ENGINE_NAME$XCODE_PROJ_EXT" KMTESTAPP_PROJECT_PATH="$KMTESTAPP_BASE_PATH/$TESTAPP_NAME$XCODE_PROJ_EXT" KMIM_WORKSPACE_PATH="$KM4MIM_BASE_PATH/$IM_NAME.xcworkspace" +KEYMAN_WORKSPACE_PATH="$KEYMAN_MAC_BASE_PATH/$PRODUCT_NAME.xcworkspace" +CONFIGAPP_BASE_PATH="$KEYMAN_MAC_BASE_PATH/$CONFIGAPP_NAME" +CONFIGAPP_PROJ_PATH="$CONFIGAPP_BASE_PATH/$CONFIGAPP_NAME$XCODE_PROJ_EXT" + PODS_FOLDER="/mac/Keyman4MacIM/Pods/Target Support Files/Pods-Keyman" builder_describe_outputs \ configure "$PODS_FOLDER/$CONFIG_TARGET" \ build:engine "/mac/$ENGINE_NAME/build/$CONFIG" \ build:app "/mac/$IM_NAME/build/$CONFIG" \ + build:configapp "/mac/$CONFIGAPP_NAME/build/Build/Products/$CONFIG" \ build:testapp "/mac/$TESTAPP_NAME/build/$CONFIG" ### DEFINE HELPER FUNCTIONS ### @@ -95,6 +102,7 @@ if [[ -f "$THIS_SCRIPT_PATH/localenv.sh" ]]; then fi BUILD_OPTIONS="-configuration $CONFIG $BUILD_OPTIONS PRODUCT_VERSION=$KEYMAN_VERSION" +builder_echo info "BUILD_OPTIONS: $BUILD_OPTIONS" ### START OF THE BUILD ### @@ -117,10 +125,14 @@ execBuildCommand() { } do_clean ( ) { + builder_launch /mac/installer/build.sh clean rm -rf "$KME4M_BASE_PATH/build" rm -rf "$KM4MIM_BASE_PATH/build" rm -rf "$KMTESTAPP_BASE_PATH/build" rm -rf "$KEYMAN_ROOT/mac/setup/Install Keyman.app" + rm -rf "$CONFIGAPP_BASE_PATH/build" + rm -rf "$KEYMAN_MAC_BASE_PATH/build" + rm -rf "$KEYMAN_MAC_BASE_PATH/output" builder_heading "Cleaning pods folder (CocoaPods)" rm -rf "$PODS_FOLDER" @@ -157,8 +169,6 @@ updatePlist() { /usr/libexec/Plistbuddy -c "Set :Keyman:VersionWithTag $KEYMAN_VERSION_WITH_TAG" "$KM_PLIST" /usr/libexec/Plistbuddy -c "Set :Keyman:VersionGitTag $KEYMAN_VERSION_GIT_TAG" "$KM_PLIST" /usr/libexec/Plistbuddy -c "Set :Keyman:VersionRelease $KEYMAN_VERSION_RELEASE" "$KM_PLIST" - /usr/libexec/Plistbuddy -c "Set CFBundleGetInfoString $APPNAME $KEYMAN_VERSION_WITH_TAG for macOS, Copyright © 2017-$YEAR SIL International." "$KM_PLIST" - /usr/libexec/Plistbuddy -c "Set NSHumanReadableCopyright Copyright © 2017-$YEAR SIL International." "$KM_PLIST" fi } @@ -174,24 +184,89 @@ do_update_engine_metadata ( ) { } do_build_app ( ) { - ### Build Keyman.app (Input Method and Configuration app) ### + ### Build Keyman.app (Input Method) ### builder_heading "Building help" build_help_html mac Keyman4MacIM/Keyman4MacIM/Help builder_heading "Building Keyman.app" - execBuildCommand $IM_NAME "xcodebuild -workspace \"$KMIM_WORKSPACE_PATH\" $CODESIGNING_SUPPRESSION $BUILD_OPTIONS $BUILD_ACTIONS -scheme Keyman SYMROOT=\"$KM4MIM_BASE_PATH/build\"" + # no provisioning with codesigning suppressed + #execBuildCommand $IM_NAME "xcodebuild -workspace \"$KMIM_WORKSPACE_PATH\" $CODESIGNING_SUPPRESSION $BUILD_OPTIONS $BUILD_ACTIONS -scheme Keyman SYMROOT=\"$KM4MIM_BASE_PATH/build\"" + # without codesigning suppression, provisioning profile is included but no application ID?? + execBuildCommand $IM_NAME "xcodebuild -workspace \"$KMIM_WORKSPACE_PATH\" $BUILD_OPTIONS $BUILD_ACTIONS -scheme Keyman SYMROOT=\"$KM4MIM_BASE_PATH/build\"" + + #check_code_sign_status +} + +do_build_config_app ( ) { + ### Build Keyman Configuration.app (Configuration app) ### + builder_heading "Building Keyman Configuration.app" + #xcodebuild -resolvePackageDependencies -workspace Keyman.xcworkspace -scheme Config + xcodebuild -resolvePackageDependencies -workspace $KEYMAN_WORKSPACE_PATH -scheme Config + echo "building $KEYMAN_WORKSPACE_PATH with scheme Config" + execBuildCommand "Keyman Configuration" xcodebuild -workspace $KEYMAN_WORKSPACE_PATH \ + $BUILD_OPTIONS $BUILD_ACTIONS -scheme Config -derivedDataPath ./Config/build } do_update_app_metadata ( ) { updatePlist "$KM4MIM_BASE_PATH/build/$CONFIG/Keyman.app/Contents/Info.plist" "Keyman" + # re-sign the app after updating the plist file + alternate_sign_app +} + +check_code_sign_status() { + KEYMAN_APP_PATH="$KM4MIM_BASE_PATH/build/$CONFIG/Keyman.app" + + if [ -e "$KEYMAN_APP_PATH" ]; then + echo "check_code_sign_status, $KEYMAN_APP_PATH exists, listing entitlements using codesign:" + codesign -d --entitlements - --xml "$KEYMAN_APP_PATH" | plutil -convert xml1 -o - - + + #codesign -dvvv --entitlements - --xml "$KEYMAN_APP_PATH" + + # Check the exit status of the command + if [ $? -eq 0 ]; then + echo "codesign command executed successfully." + else + echo "codesign command failed." + fi + else + echo "check_code_sign_status, $KEYMAN_APP_PATH does not exist." + fi + +} + +# preserve the metadata included in the Keyman.app bundle, but use the runtime option to ensure hardened runtime is enabled for notarization +alternate_sign_app () { + builder_echo "alternate signing method..." + if builder_is_debug_build; then + ENTITLEMENTS_FILE=Keyman.Debug.entitlements + else + ENTITLEMENTS_FILE=Keyman.entitlements + + # re-sign the app after updating the plist file + builder_if_release_build_level mac_codesign eval --force --sign $CERTIFICATE_ID --timestamp --verbose --preserve-metadata=identifier,entitlements "$KM4MIM_BASE_PATH/build/$CONFIG/Keyman.app/Contents/Frameworks/Sentry.framework" + + builder_if_release_build_level mac_codesign eval --force --sign $CERTIFICATE_ID --timestamp --verbose --preserve-metadata=identifier,entitlements "$KM4MIM_BASE_PATH/build/$CONFIG/Keyman.app/Contents/Frameworks/KeymanEngine4Mac.framework" + + builder_if_release_build_level mac_codesign eval --force --sign $CERTIFICATE_ID --timestamp --verbose --options runtime \ + --preserve-metadata=identifier,entitlements \ + --requirements "'=designated => anchor apple generic and identifier \"\$self.identifier\" and ((cert leaf[field.1.2.840.113635.100.6.1.9] exists) or ( certificate 1[field.1.2.840.113635.100.6.2.6] exists and certificate leaf[field.1.2.840.113635.100.6.1.13] exists and certificate leaf[subject.OU] = \"$DEVELOPMENT_TEAM\" ))'" \ + "$KM4MIM_BASE_PATH/build/$CONFIG/Keyman.app" + + #check_code_sign_status + fi +} + +sign_app () { + builder_echo info "original signing method..." + if builder_is_debug_build; then ENTITLEMENTS_FILE=Keyman.Debug.entitlements else ENTITLEMENTS_FILE=Keyman.entitlements - # We need to re-sign the app after updating the plist file + # re-sign the app after updating the plist file builder_if_release_build_level mac_codesign eval --force --sign $CERTIFICATE_ID --timestamp --verbose --preserve-metadata=identifier,entitlements "$KM4MIM_BASE_PATH/build/$CONFIG/Keyman.app/Contents/Frameworks/Sentry.framework" builder_if_release_build_level mac_codesign eval --force --sign $CERTIFICATE_ID --timestamp --verbose --preserve-metadata=identifier,entitlements "$KM4MIM_BASE_PATH/build/$CONFIG/Keyman.app/Contents/Frameworks/KeymanEngine4Mac.framework" @@ -232,11 +307,18 @@ do_notarize() { # Note: get-task-allow entitlement must be *off* in our release build (to do this, don't include base entitlements in project build settings) + #builder_echo info "checking Keyman code sign status in notarize prior to re-signing $TARGET_APP_PATH..." + #check_code_sign_status + + # commenting out this re-signing step for now, as it is stripping entitlements # We may need to re-run the code signing if a custom certificate has been passed in - if [ ! -z "${CERTIFICATE_ID+x}" ]; then - builder_heading "Signing with custom certificate (CERTIFICATE_ID environment variable)." - builder_if_release_build_level mac_codesign direct --force --options runtime --entitlements Keyman4MacIM/Keyman.entitlements --deep --sign "${CERTIFICATE_ID}" "$TARGET_APP_PATH" - fi + #if [ ! -z "${CERTIFICATE_ID+x}" ]; then + # builder_heading "Signing with custom certificate (CERTIFICATE_ID environment variable)." + # builder_if_release_build_level mac_codesign direct --force --options runtime --entitlements Keyman4MacIM/Keyman.entitlements --deep --sign "${CERTIFICATE_ID}" "$TARGET_APP_PATH" + #fi + + #builder_echo info "checking Keyman code sign status in notarize after re-signing $TARGET_APP_PATH..." + #check_code_sign_status builder_heading "Zipping Keyman.app for notarization to $TARGET_ZIP_PATH" @@ -261,14 +343,23 @@ do_sentry() { sentry-cli upload-dif "build/$CONFIG" popd > /dev/null + pushd "$CONFIGAPP_BASE_PATH" > /dev/null + sentry-cli upload-dif "build/Build/Products/$CONFIG" + popd > /dev/null } do_install() { if builder_is_ci_build; then builder_die "build.sh install should not be run on CI" - elif ! builder_has_option --quick; then + elif ! builder_has_option --quick && ! builder_is_debug_build; then + echo "about to notarize" + # do not notarize if quick option is specified or not a release build + # notarization fails unless signed with Developer ID certificate + # which is not used in debug builds do_notarize else + alternate_sign_app + echo "not notarizing" if [ "$(spctl --status)" == "assessments enabled" ]; then echo builder_warn "WARNING: Notarization is disabled but SecAssessment security policy is still active. Keyman will not run correctly." @@ -278,24 +369,29 @@ do_install() { fi builder_heading "Attempting local deployment with command:" + KM4MIM_APP_BASE_PATH="$KM4MIM_BASE_PATH/build/$CONFIG" - builder_echo info "$KM4MIM_BASE_PATH/localdeploy.sh \"$KM4MIM_APP_BASE_PATH\"" - "$KM4MIM_BASE_PATH/localdeploy.sh" "$KM4MIM_APP_BASE_PATH" + KM4M_CONFIG_APP_BASE_PATH="$CONFIGAPP_BASE_PATH/build/Build/Products/$CONFIG" + builder_echo info "$KEYMAN_MAC_BASE_PATH/local-deploy.sh \"$KM4MIM_APP_BASE_PATH\" \"$KM4M_CONFIG_APP_BASE_PATH\"" + "$KEYMAN_MAC_BASE_PATH/local-deploy.sh" "$KM4MIM_APP_BASE_PATH" "$KM4M_CONFIG_APP_BASE_PATH" } -do_publish() { - builder_if_release_build_level do_notarize +do_create_installer() { + builder_heading "Creating installer package..." - builder_heading "Preparing files for release deployment..." - ./setup/build.sh + builder_launch /mac/installer/build.sh build +} - "${KM4MIM_BASE_PATH}/make-km-dmg.sh" - - local UPLOAD_PATH="${KM4MIM_BASE_PATH}/output/upload/${KEYMAN_VERSION}" - write_download_info "${UPLOAD_PATH}" "keyman-${KEYMAN_VERSION_FOR_FILENAME}.dmg" "Keyman4MacIM" dmg mac +do_publish_installer() { + builder_heading "Publishing installer package..." if builder_is_ci_build && builder_is_ci_build_level_release; then + builder_echo info "writing download info for Keyman installer..." + local UPLOAD_PATH="${KEYMAN_MAC_BASE_PATH}/build/upload/${KEYMAN_VERSION}" + write_download_info "${UPLOAD_PATH}" "Keyman-${KEYMAN_VERSION_FOR_FILENAME}.pkg" "Keyman Installer Package" pkg mac do_sentry + else + builder_echo info "not writing download info because we are not on a CI build..." fi } @@ -317,9 +413,12 @@ builder_run_action test:app execBuildCommand "$IM_NAME-tests" "xcodebuild builder_run_action test:help check-markdown "$KEYMAN_ROOT/mac/docs/help" builder_run_action build:app do_update_app_metadata +builder_run_action build:configapp do_build_config_app + builder_run_action build:testapp do_build_testapp builder_run_action install do_install -builder_run_action publish do_publish +builder_run_action publish do_create_installer +builder_run_action publish do_publish_installer diff --git a/mac/installer/ExportOptions.plist b/mac/installer/ExportOptions.plist new file mode 100644 index 0000000000..e49c8141a1 --- /dev/null +++ b/mac/installer/ExportOptions.plist @@ -0,0 +1,25 @@ + + + + + compileBitcode + + method + developer-id + signingStyle + manual + manageAppVersionAndBuildNumber + + provisioningProfiles + + keyman.inputmethod.Keyman + Keyman Mac IM Dist + com.keyman.config + Keyman Mac Config Dist + + teamID + 3YE4W86L3G + signingCertificate + 039B24583A84F88168463F17FDA6F176C7985044 + + \ No newline at end of file diff --git a/mac/installer/build.sh b/mac/installer/build.sh new file mode 100755 index 0000000000..37a77c1ac2 --- /dev/null +++ b/mac/installer/build.sh @@ -0,0 +1,180 @@ +#!/usr/bin/env bash + +## START STANDARD BUILD SCRIPT INCLUDE +# adjust relative paths as necessary +THIS_SCRIPT="$(readlink -f "${BASH_SOURCE[0]}")" +. "${THIS_SCRIPT%/*}/../../resources/build/builder-full.inc.sh" +## END STANDARD BUILD SCRIPT INCLUDE + +. "$KEYMAN_ROOT/resources/build/utils.inc.sh" +. "$KEYMAN_ROOT/resources/build/mac/mac.inc.sh" + +builder_describe "Build Keyman for Mac installer package" clean configure build test +builder_parse "$@" + +mac_verify_on_mac + +### SET PATHS ### + +INPUT_METHOD_DIR_NAME="Keyman4MacIM" +XCODE_WORKSPACE_EXT=".xcworkspace" +PRODUCT_NAME="Keyman" + +KEYMAN_BUILD_PATH="${KEYMAN_ROOT}/mac/build" +INPUT_METHOD_BASE_PATH="${KEYMAN_ROOT}/mac/${INPUT_METHOD_DIR_NAME}" + +KEYMAN_WORKSPACE_PATH="${KEYMAN_ROOT}/mac/${PRODUCT_NAME}${XCODE_WORKSPACE_EXT}" +INPUT_METHOD_WORKSPACE_PATH="${INPUT_METHOD_BASE_PATH}/${INPUT_METHOD_DIR_NAME}${XCODE_WORKSPACE_EXT}" + +OUTPUT_DIRECTORY_PATH="${KEYMAN_BUILD_PATH}/upload/${KEYMAN_VERSION}" +PACKAGE_NAME="Keyman-${KEYMAN_VERSION_FOR_FILENAME}.pkg" +OUTPUT_PACKAGE_PATH="${OUTPUT_DIRECTORY_PATH}/${PACKAGE_NAME}" + +OUTPUT__DISTRIBUTION_XML="${KEYMAN_BUILD_PATH}/distribution.xml" + +# bundle IDs for installer packages +INSTALL_INPUT_METHOD_BUNDLE_ID="com.keyman.im.installer" +INSTALL_CONFIG_BUNDLE_ID="com.keyman.config.installer" + +# --------------------- + +function do_build() { + archive_apps + export_apps + create_packages + build_distribution_xml + combine_packages + builder_echo "build and package of Keyman installer complete" +} + +function do_clean() { + builder_echo "cleaning Keyman installer..." + rm -rf "${KEYMAN_BUILD_PATH}/Config.xcarchive" + rm -rf "${KEYMAN_BUILD_PATH}/Keyman.xcarchive" + rm -rf "${KEYMAN_BUILD_PATH}/ConfigExport" + rm -rf "${KEYMAN_BUILD_PATH}/KeymanExport" + rm -rf "${KEYMAN_BUILD_PATH}/keyman-config.pkg" + rm -rf "${KEYMAN_BUILD_PATH}/keyman-input-method.pkg" + rm -rf "${KEYMAN_BUILD_PATH}/upload/" +} + +function archive_apps() { + # xcodebuild for x86_64 and arm64 (universal binary) + mac_xcodebuild archive -workspace "$INPUT_METHOD_WORKSPACE_PATH" \ + -scheme Keyman \ + -configuration Release \ + -archivePath "${KEYMAN_BUILD_PATH}/Keyman.xcarchive" \ + ARCHS=\"arm64 x86_64\" \ + ONLY_ACTIVE_ARCH=NO \ + PRODUCT_VERSION=$KEYMAN_VERSION + + # xcodebuild for x86_64 and arm64 (universal binary) + mac_xcodebuild archive -workspace "$KEYMAN_WORKSPACE_PATH" \ + -scheme Config \ + -configuration Release \ + -archivePath "${KEYMAN_BUILD_PATH}/Config.xcarchive" \ + ARCHS=\"arm64 x86_64\" \ + ONLY_ACTIVE_ARCH=NO \ + SENTRY_ENVIRONMENT="$KEYMAN_VERSION_ENVIRONMENT" \ + TIER="$KEYMAN_TIER" \ + VERSION_TAG="$KEYMAN_VERSION_TAG" \ + VERSION_WITH_TAG="$KEYMAN_VERSION_WITH_TAG" \ + VERSION_GIT_TAG="$KEYMAN_VERSION_GIT_TAG" \ + VERSION_RELEASE="$KEYMAN_VERSION_RELEASE" \ + PRODUCT_VERSION=$KEYMAN_VERSION +} + +function export_apps() { + mac_xcodebuild -exportArchive -archivePath "${KEYMAN_BUILD_PATH}/Keyman.xcarchive" \ + -exportOptionsPlist ./ExportOptions.plist \ + -exportPath "${KEYMAN_BUILD_PATH}/KeymanExport" + + mac_xcodebuild -exportArchive -archivePath "${KEYMAN_BUILD_PATH}/Config.xcarchive" \ + -exportOptionsPlist ./ExportOptions.plist \ + -exportPath "${KEYMAN_BUILD_PATH}/ConfigExport" +} + +function create_packages() { + builder_echo "packaging Keyman input method" + + pkgbuild --component "${KEYMAN_BUILD_PATH}/KeymanExport/Keyman.app" \ + --install-location /tmp \ + --identifier "$INSTALL_INPUT_METHOD_BUNDLE_ID" \ + --scripts ./scripts \ + --version "$KEYMAN_VERSION" \ + "${KEYMAN_BUILD_PATH}/keyman-input-method.pkg" + + builder_echo "packaging Keyman Configuration app" + pkgbuild --component "${KEYMAN_BUILD_PATH}/ConfigExport/Keyman Configuration.app" \ + --install-location /Applications \ + --identifier "$INSTALL_CONFIG_BUNDLE_ID" \ + --version "$KEYMAN_VERSION" \ + "${KEYMAN_BUILD_PATH}/keyman-config.pkg" +} + +function build_distribution_xml(){ +cat < "${OUTPUT__DISTRIBUTION_XML}" + + + Keyman for macOS + + + + + + + + + + + + + + + + + + + + + + + + + + + + keyman-config.pkg + + + + keyman-input-method.pkg + +EOF + +echo "Created distribution.xml at ${OUTPUT__DISTRIBUTION_XML}" +} + +function combine_packages() { + builder_echo "combining packages into product" + + mkdir -p "$OUTPUT_DIRECTORY_PATH" + + productbuild --distribution "${KEYMAN_BUILD_PATH}/distribution.xml" \ + --package-path "${KEYMAN_BUILD_PATH}" \ + --resources ./installer-resources \ + --version "$KEYMAN_VERSION" \ + --sign 5FCED4988F27D172C5628A16DBA4AE6CA0015D11 \ + "$OUTPUT_PACKAGE_PATH" + + builder_heading "Uploading install package to Apple for notarization" + mac_notarize "$OUTPUT_DIRECTORY_PATH" "$OUTPUT_PACKAGE_PATH" + + builder_heading "Attempting to staple notarization to install package" + xcrun stapler staple "$OUTPUT_PACKAGE_PATH" || builder_die "stapler failed" +} + +builder_run_action clean do_clean +# builder_run_action configure +builder_run_action build do_build +# builder_run_action test diff --git a/mac/installer/installer-resources/en.lproj/conclusion.rtf b/mac/installer/installer-resources/en.lproj/conclusion.rtf new file mode 100644 index 0000000000..1344b408ab --- /dev/null +++ b/mac/installer/installer-resources/en.lproj/conclusion.rtf @@ -0,0 +1,13 @@ +{\rtf1\ansi\ansicpg1252\cocoartf2870 +\cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fnil\fcharset0 HelveticaNeue;\f1\fnil\fcharset0 HelveticaNeue-Bold;} +{\colortbl;\red255\green255\blue255;} +{\*\expandedcolortbl;;} +\margl1440\margr1440\vieww19520\viewh9160\viewkind0 +\deftab720 +\pard\pardeftab720\partightenfactor0 + +\f0\fs26 \cf0 \expnd0\expndtw0\kerning0 +Restart your Mac, then run +\f1\b Keyman Configuration +\f0\b0 located in the Applications folder to complete setup of Keyman.\ +} \ No newline at end of file diff --git a/mac/installer/installer-resources/en.lproj/license.rtf b/mac/installer/installer-resources/en.lproj/license.rtf new file mode 100644 index 0000000000..938bebec76 --- /dev/null +++ b/mac/installer/installer-resources/en.lproj/license.rtf @@ -0,0 +1,17 @@ +{\rtf1\ansi\ansicpg1252\cocoartf2870 +\cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fswiss\fcharset0 Helvetica;} +{\colortbl;\red255\green255\blue255;} +{\*\expandedcolortbl;;} +\margl1440\margr1440\vieww12720\viewh7800\viewkind0 +\pard\tx720\tx1440\tx2160\tx2880\tx3600\tx4320\tx5040\tx5760\tx6480\tx7200\tx7920\tx8640\pardirnatural\partightenfactor0 + +\f0\fs24 \cf0 MIT LICENSE\ +\ +Copyright (c) SIL Global\ +\ +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\ +\ +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\ +\ +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\ +} \ No newline at end of file diff --git a/mac/installer/installer-resources/en.lproj/welcome.rtf b/mac/installer/installer-resources/en.lproj/welcome.rtf new file mode 100644 index 0000000000..a73796bb8a --- /dev/null +++ b/mac/installer/installer-resources/en.lproj/welcome.rtf @@ -0,0 +1,18 @@ +{\rtf1\ansi\ansicpg1252\cocoartf2870 +\cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fswiss\fcharset0 Helvetica;\f1\fswiss\fcharset0 Helvetica-Bold;} +{\colortbl;\red255\green255\blue255;} +{\*\expandedcolortbl;;} +{\info +{\author Shawn Schantz}}\margl1440\margr1440\vieww12920\viewh16520\viewkind1 +\deftab720 +\pard\pardeftab720\ri0\sl278\slmult1\sa160\partightenfactor0 + +\f0\fs26 \cf0 Click +\f1\b Continue +\f0\b0 to begin.\ +\pard\pardeftab720\ri0\sl278\slmult1\sa160\partightenfactor0 + +\fs24 \cf0 \ +\pard\pardeftab720\ri0\sl278\slmult1\sa160\qc\partightenfactor0 +\cf0 \ +} \ No newline at end of file diff --git a/mac/installer/installer-resources/keyman-x20-y36-144.png b/mac/installer/installer-resources/keyman-x20-y36-144.png new file mode 100644 index 0000000000..79df8fc121 Binary files /dev/null and b/mac/installer/installer-resources/keyman-x20-y36-144.png differ diff --git a/mac/installer/scripts/postinstall b/mac/installer/scripts/postinstall new file mode 100755 index 0000000000..c9f45e2035 --- /dev/null +++ b/mac/installer/scripts/postinstall @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Keyman is copyright (C) SIL Global. MIT License. + +# input parameters to this script +# $1 - full path of installer package +# $2 - path to the target installation folder +# $3 - root mountpoint of the destination drive receiving the installation ("/" for current startup disk) +# $4 - path to the root directory of the currently booted OS + +echo "Keyman for Mac: postinstall script" + +# Get the actual user currently logged into the console +# This works even if the installer is run with sudo or as root +CONSOLE_USER=$(stat -f "%Su" /dev/console) +echo "console user is: $CONSOLE_USER" + +# Get the home directory of the console user +USER_HOME=$(eval echo "~$CONSOLE_USER") +echo "user home directory is: $USER_HOME" + +# Define the source file path within the package payload +# the input method, Keyman.app, is added to /tmp when building the installer +# $3 is the target volume mount point, e.g., "/" + +SOURCE_FILE="$3tmp/Keyman.app" +echo "source file is: $SOURCE_FILE" + +# Define the path to the Input Methods directory in the user's Library +DEST_DIR="$USER_HOME/Library/Input Methods" +echo "destination directory is: $DEST_DIR" + +# Create the /Library/Input Methods directory if it doesn't exist +if [ ! -d "$DEST_DIR" ]; then + mkdir -p "$DEST_DIR" + # Set the user as owner of the Input Methods directory + chown "$CONSOLE_USER":staff "$DEST_DIR" +fi + +# Copy the input method to the destination directory +# must use cp -R to maintain symbolic links and preserve permissions +# if we use -r, the input method's code signature will be invalid and the system will not load it +cp -R "$SOURCE_FILE" "$DEST_DIR/" + +# Set the user as owner +chown -R "$CONSOLE_USER":staff "$DEST_DIR/Keyman.app" + +# TODO: add code here to extract keyboard name from package path +echo "The installer package name is: $PACKAGE_PATH" + +# define path of tmp file to write keyboard info +KEYBOARD_FILE_PATH="$3/var/tmp/keyboard-to-install.txt" + +#write keyboard file +/bin/echo "sil_euro_latin" > "${KEYBOARD_FILE_PATH}" + +exit 0 diff --git a/mac/local-deploy.sh b/mac/local-deploy.sh new file mode 100755 index 0000000000..fc0e4295d0 --- /dev/null +++ b/mac/local-deploy.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash + +# does not produce an input method and config app that can communicate +# when built locally -- to access app group settings, each app must +# be signed with 'Developer ID Application' certificate + +# Please note that this build script (understandably) assumes that it is running on Mac OS X. +if [[ "${OSTYPE}" != "darwin"* ]]; then + echo "This build script will only run in a Mac environment." + exit 1 +fi + +IMLIBRARY_PATH="$HOME/Library/Input Methods" +APPS_PATH="/Applications" + +display_usage() { + echo "Use local-deploy.sh to deploy the Keyman Input Method app to $IMLIBRARY_PATH" + echo "and the Keyman Configuration app to $APPS_PATH" + echo "This kills the existing (running) process if needed. Typically called from" + echo "the Keyman mac build script." + echo + echo "usage: local-deploy.sh keymanInputMethodPath keymanConfigAppPath" + echo + echo "where keymanInputMethodPath is the path to a folder containing the" + echo "Keyman Input Method app and keymanConfigAppPath is the path to the" + echo "folder containing the Keyman Configuration app." + exit 1 +} + +if [ "$#" -ne 2 ]; then + display_usage +fi + +KMIM_APPNAME="Keyman.app" +KM4MIM_APP_PATH="$1/$KMIM_APPNAME" + +# verify that input method exists at path +if ! [[ -d "$KM4MIM_APP_PATH" ]]; then + echo "input method does not exist at path $KM4MIM_APP_PATH" + display_usage +fi + +KM_CONFIG_APPNAME="Keyman Configuration.app" +KM_CONFIG_APP_PATH="$2/$KM_CONFIG_APPNAME" + +# verify that the config app exists at path +if ! [[ -d "$KM_CONFIG_APP_PATH" ]]; then + echo "config app does not exist at path $KM_CONFIG_APP_PATH" + display_usage +fi + +# kill both apps +pid=$(pgrep Keyman) +if [[ -n $pid ]]; then + kill $pid +fi + +pid=$(pgrep 'Keyman Configuration') +if [[ -n $pid ]]; then + kill $pid +fi + +# remove existing input method and install new one +KM4MIM_INSTALL_PATH="$IMLIBRARY_PATH/$KMIM_APPNAME" + +if [[ -d "$KM4MIM_INSTALL_PATH" ]]; then + rm -Rf "$KM4MIM_INSTALL_PATH" +fi +echo "Copying $KM4MIM_APP_PATH to $IMLIBRARY_PATH..." +/bin/cp -R "$KM4MIM_APP_PATH" "$IMLIBRARY_PATH" + +# remove existing configuration app and install new one +KMCONFIG_INSTALL_PATH="$APPS_PATH/$KM_CONFIG_APPNAME" + +if [[ -d "$KMCONFIG_INSTALL_PATH" ]]; then + rm -Rf "$KMCONFIG_INSTALL_PATH" +fi +echo "Copying $KM_CONFIG_APP_PATH to $APPS_PATH..." +/bin/cp -R "$KM_CONFIG_APP_PATH" "$APPS_PATH" + +# exit successfully if Keyman is installed +if [[ -d "$KM4MIM_INSTALL_PATH" ]]; then + exit 0 +fi + +exit 1 \ No newline at end of file diff --git a/resources/build/utils.inc.sh b/resources/build/utils.inc.sh index 63f57d75c5..6474940702 100644 --- a/resources/build/utils.inc.sh +++ b/resources/build/utils.inc.sh @@ -42,7 +42,7 @@ util_curl_download_file_with_retry() { write_download_info() { local UPLOAD_DIR="$1" local ARTIFACT_FILENAME="$2" - local ARTFIACT_NAME="$3" + local ARTIFACT_NAME="$3" local ARTIFACT_TYPE="$4" local PLATFORM="$5" @@ -71,7 +71,7 @@ write_download_info() { # shellcheck disable=SC2016,SC2154 DOWNLOAD_INFO=$( "${JQ}" -n \ - --arg NAME "${ARTFIACT_NAME}" \ + --arg NAME "${ARTIFACT_NAME}" \ --arg BUILD_NUMBER "${KEYMAN_VERSION}" \ --arg DATE "${DATE}" \ --arg PLATFORM "${PLATFORM}" \