diff --git a/.editorconfig b/.editorconfig index 1ba19987d4..a17a535817 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,3 +10,6 @@ trim_trailing_whitespace = true [*.md] max_line_length = off + +[{Makefile,rules}] +indent_style = tab diff --git a/android/KMEA/app/src/main/assets/nrc.en.mtnt.model.kmp b/android/KMEA/app/src/main/assets/nrc.en.mtnt.model.kmp index f736a99c30..7969866c3c 100644 Binary files a/android/KMEA/app/src/main/assets/nrc.en.mtnt.model.kmp and b/android/KMEA/app/src/main/assets/nrc.en.mtnt.model.kmp differ diff --git a/android/history.md b/android/history.md index 7379877ae8..6723097407 100644 --- a/android/history.md +++ b/android/history.md @@ -1,5 +1,15 @@ # Keyman for Android Version History +## 2020-02-10 13.0.6055 beta +* Change: + * Update default nrc.en.mtnt model to version 0.1.4 (#2608) + +## 2020-02-06 13.0.6054 beta +* No change to Keyman for Android (updated Keyman Web Engine, #2561) + +## 2020-02-04 13.0.6053 beta +* No change to Keyman for Android (updated Keyman Web Engine, #2559) + ## 2020-01-31 13.0.6051 beta * Bug fix: * Fix cancelling dictionary update notifications (#2547) diff --git a/common/engine/keyboardprocessor/src/km_kbp_context_api.cpp b/common/engine/keyboardprocessor/src/km_kbp_context_api.cpp index 2b9f628f26..964f7867b4 100644 --- a/common/engine/keyboardprocessor/src/km_kbp_context_api.cpp +++ b/common/engine/keyboardprocessor/src/km_kbp_context_api.cpp @@ -242,7 +242,7 @@ json & operator << (json & j, km::kbp::context const & ctxt) { json & operator << (json & j, km_kbp_context_item const & i) { - utf8::codeunit_t cps[4] = {0,}; + utf8::codeunit_t cps[7] = {0,}; // 6 bytes for maximal UTF-8 char (e.g. U+10FFFF) + nul terminator int8_t l = 4; switch (i.type) diff --git a/common/engine/keyboardprocessor/src/kmx/kmx_context.cpp b/common/engine/keyboardprocessor/src/kmx/kmx_context.cpp index 77ab4341da..9ee3c5584d 100644 --- a/common/engine/keyboardprocessor/src/kmx/kmx_context.cpp +++ b/common/engine/keyboardprocessor/src/kmx/kmx_context.cpp @@ -78,11 +78,28 @@ void KMX_Context::Set(const KMX_WCHAR *buf) { const KMX_WCHAR *p; KMX_WCHAR *q; - for(p = buf, q = CurContext; *p && (int)(q-CurContext) < MAXCONTEXT - 1; p++, q++) + + // We may be past a buffer longer than our internal + // buffer. So we shift to make sure we capture the end + // of the string, not the start + p = u16chr(buf, 0); + q = (KMX_WCHAR*)p; + while(p > buf && (int)(q-p) < MAXCONTEXT - 1) { + p = decxstr((KMX_WCHAR*)p); + } + + // If the first character in the buffer is a surrogate pair, + // or a deadkey, our buffer may be too long, so move to the + // next character in the buffer + if ((int)(q-p) > MAXCONTEXT - 1) { + p = incxstr((KMX_WCHAR*)p); + } + + for(q = CurContext; *p; p++, q++) { *q = *p; - if(*p >= 0xD800 && *p <= 0xDBFF) { *(++q) = *(++p); } } + *q = 0; pos = (int)(q-CurContext); CurContext[MAXCONTEXT-1] = 0; diff --git a/common/engine/keyboardprocessor/src/kmx/kmx_options.cpp b/common/engine/keyboardprocessor/src/kmx/kmx_options.cpp index 6b48e59255..51538d2157 100644 --- a/common/engine/keyboardprocessor/src/kmx/kmx_options.cpp +++ b/common/engine/keyboardprocessor/src/kmx/kmx_options.cpp @@ -33,7 +33,7 @@ void KMX_Options::AddOptionsStoresFromXString(PKMX_WCHAR s) { case CODE_SAVEOPT: case CODE_RESETOPT: idx = *(s + 2) - 1; - if (idx >= 0 && idx < _kp->Keyboard->cxStoreArray && _kp->Keyboard->dpStoreArray[idx].dpName != NULL) { + if (idx < _kp->Keyboard->cxStoreArray && _kp->Keyboard->dpStoreArray[idx].dpName != NULL) { _kp->KeyboardOptions[idx].OriginalStore = _kp->Keyboard->dpStoreArray[idx].dpString; } break; diff --git a/common/engine/keyboardprocessor/tests/unit/kmx/040 - long context.kmn b/common/engine/keyboardprocessor/tests/unit/kmx/040 - long context.kmn new file mode 100644 index 0000000000..994f38bede --- /dev/null +++ b/common/engine/keyboardprocessor/tests/unit/kmx/040 - long context.kmn @@ -0,0 +1,15 @@ +c Description: Tests context longer than 64 chars +c keys: [K_SPACE][K_F] +c expected: 012345678901234567890123456789012345678901234567890123456789012\u0020 +c context: 012345678901234567890123456789012345678901234567890123456789012 + +store(&version) '6.0' + +begin Unicode > use(Main) + +group(Main) using keys + ++ [K_SPACE] > U+0020 + +c prevent entering symbols after space +U+0020 + 'f' > U+0020 diff --git a/common/engine/keyboardprocessor/tests/unit/kmx/040 - long context.kmx b/common/engine/keyboardprocessor/tests/unit/kmx/040 - long context.kmx new file mode 100644 index 0000000000..fba5a48cb4 Binary files /dev/null and b/common/engine/keyboardprocessor/tests/unit/kmx/040 - long context.kmx differ diff --git a/common/engine/keyboardprocessor/tests/unit/kmx/041 - long context and deadkeys.kmn b/common/engine/keyboardprocessor/tests/unit/kmx/041 - long context and deadkeys.kmn new file mode 100644 index 0000000000..c899b7063c --- /dev/null +++ b/common/engine/keyboardprocessor/tests/unit/kmx/041 - long context and deadkeys.kmn @@ -0,0 +1,25 @@ +c Description: Tests that we don't split a dk in context:set +c keys: [K_1][K_2][K_3] +c context: \u00ffff\u000008\u00000001abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefgh +c expected: \u00ffff\u000008\u00000001abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefgh123 + +store(&version) '6.0' + +begin Unicode > use(Main) + +group(Main) using keys + +c Test 1 +dk(1) 'abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefgh' + '1' > context '1' + +c Test 2: Test that we haven't split a deadkey code: UC_SENTINEL CODE_DEADKEY +c because: CODE_DEADKEY = U+0008; deadkey_number = U+0001 +U+0008 U+0001 'abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefgh1' + '2' > 'FAIL TEST 2' +U+0001 'abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefgh1' + '2' > 'FAIL TEST 2' +'abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefgh1' + '2' > context '2' + +c Test 3: Test that we haven't split a deadkey code: UC_SENTINEL CODE_DEADKEY +c because: CODE_DEADKEY = U+0008; deadkey_number = U+0001 +U+0008 U+0001 'abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefgh12' + '3' > 'FAIL TEST 3' +U+0001 'abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefgh12' + '3' > 'FAIL TEST 3' +'abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefgh12' + '3' > context '3' diff --git a/common/engine/keyboardprocessor/tests/unit/kmx/041 - long context and deadkeys.kmx b/common/engine/keyboardprocessor/tests/unit/kmx/041 - long context and deadkeys.kmx new file mode 100644 index 0000000000..7478dcaacc Binary files /dev/null and b/common/engine/keyboardprocessor/tests/unit/kmx/041 - long context and deadkeys.kmx differ diff --git a/common/engine/keyboardprocessor/tests/unit/kmx/042 - long context and split deadkeys.kmn b/common/engine/keyboardprocessor/tests/unit/kmx/042 - long context and split deadkeys.kmn new file mode 100644 index 0000000000..8f840146d2 --- /dev/null +++ b/common/engine/keyboardprocessor/tests/unit/kmx/042 - long context and split deadkeys.kmn @@ -0,0 +1,14 @@ +c Description: Tests that we don't split a dk in context +c keys: [K_SPACE] +c context: \u00ffff\u000008\u00000001abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefgh +c expected: \u00ffff\u000008\u00000001abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefgh\u0020 + +store(&version) '6.0' + +begin Unicode > use(Main) + +group(Main) using keys + +c Test 1: Test that we haven't split a deadkey code: UC_SENTINEL CODE_DEADKEY +c Here, looking at last char only: deadkey_number = U+0001 +U+0001 'abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefgh' + ' ' > 'FAIL' diff --git a/common/engine/keyboardprocessor/tests/unit/kmx/042 - long context and split deadkeys.kmx b/common/engine/keyboardprocessor/tests/unit/kmx/042 - long context and split deadkeys.kmx new file mode 100644 index 0000000000..0f53a069af Binary files /dev/null and b/common/engine/keyboardprocessor/tests/unit/kmx/042 - long context and split deadkeys.kmx differ diff --git a/common/engine/keyboardprocessor/tests/unit/kmx/kmp.json b/common/engine/keyboardprocessor/tests/unit/kmx/kmp.json index 82cd8bfec5..29237b2de3 100644 --- a/common/engine/keyboardprocessor/tests/unit/kmx/kmp.json +++ b/common/engine/keyboardprocessor/tests/unit/kmx/kmp.json @@ -378,6 +378,39 @@ "id": "und" } ] + }, + { + "name": "040 - long context", + "id": "040 - long context", + "version": "0.0", + "languages": [ + { + "name": "Undetermined", + "id": "und" + } + ] + }, + { + "name": "041 - long context and deadkeys", + "id": "041 - long context and deadkeys", + "version": "0.0", + "languages": [ + { + "name": "Undetermined", + "id": "und" + } + ] + }, + { + "name": "042 - long context and split deadkeys", + "id": "042 - long context and split deadkeys", + "version": "0.0", + "languages": [ + { + "name": "Undetermined", + "id": "und" + } + ] } ] } diff --git a/common/engine/keyboardprocessor/tests/unit/kmx/kmx.cpp b/common/engine/keyboardprocessor/tests/unit/kmx/kmx.cpp index a876b46253..bb7c255732 100644 --- a/common/engine/keyboardprocessor/tests/unit/kmx/kmx.cpp +++ b/common/engine/keyboardprocessor/tests/unit/kmx/kmx.cpp @@ -317,9 +317,9 @@ int run_test(const km::kbp::path & source, const km::kbp::path & compiled) { try_status(km_kbp_context_items_to_utf16(citems, buf, &n)); km_kbp_context_items_dispose(citems); - std::cout << "expected: " << expected << std::endl; + std::cout << "expected : " << expected << std::endl; std::cout << "text store: " << text_store << std::endl; - std::cout << "result: " << buf << std::endl; + std::cout << "result : " << buf << std::endl; // Compare internal context with expected result if (buf != expected) return __LINE__; @@ -362,7 +362,8 @@ std::u16string parse_source_string(std::string const & s) { size_t n; std::string s1 = s.substr(p - s.begin(), 8); v = std::stoul(s1, &n, 16); - assert(v >= 0x20 && v <= 0x10FFFF); + // Allow deadkey_number (U+0001) characters and onward + assert(v >= 0x0001 && v <= 0x10FFFF); p += n-1; if (v < 0x10000) { t += km_kbp_cp(v); diff --git a/common/engine/keyboardprocessor/tests/unit/kmx/meson.build b/common/engine/keyboardprocessor/tests/unit/kmx/meson.build index 9268064091..05f66f38e1 100644 --- a/common/engine/keyboardprocessor/tests/unit/kmx/meson.build +++ b/common/engine/keyboardprocessor/tests/unit/kmx/meson.build @@ -54,7 +54,10 @@ tests = [ '036 - options - double reset staged', '037 - options - double reset', '038 - punctkeys', - '039 - generic ctrlalt' + '039 - generic ctrlalt', + '040 - long context', + '041 - long context and deadkeys', + '042 - long context and split deadkeys' ] if build_machine.system() == 'windows' diff --git a/configure-repo.sh b/configure-repo.sh index 126c8b33a2..4b8ead8d02 100755 --- a/configure-repo.sh +++ b/configure-repo.sh @@ -22,8 +22,10 @@ case $1 in # https://stackoverflow.com/a/39160850/1836776 WinPWD=$(cmd //C cd) cmd //C "mklink $WinPWD\\.git\\hooks\\commit-msg $WinPWD\\resources\\git-hooks\\commit-msg" + cmd //C "mklink $WinPWD\\.git\\hooks\\prepare-commit-msg $WinPWD\\resources\\git-hooks\\prepare-commit-msg" else ln -sf "$PWD/resources/git-hooks/commit-msg" "$PWD/.git/hooks/commit-msg" + ln -sf "$PWD/resources/git-hooks/prepare-commit-msg" "$PWD/.git/hooks/prepare-commit-msg" fi ;; *) diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Colors+Extension.swift b/ios/engine/KMEI/KeymanEngine/Classes/Colors+Extension.swift index 641a669cc1..599fc6db97 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Colors+Extension.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Colors+Extension.swift @@ -9,7 +9,7 @@ import Foundation import UIKit -// TODO: Relocated to the Keyman App's project. Not possible while the Settings menus are part of KMEI. +// TODO: Relocate to the Keyman App's project. Not possible while the Settings menus are part of KMEI. extension Colors { public static var systemBackground: UIColor { get { diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Colors.swift b/ios/engine/KMEI/KeymanEngine/Classes/Colors.swift index c7ec015661..8f9fe290aa 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Colors.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Colors.swift @@ -22,9 +22,6 @@ public class Colors { public static var popupBorder: UIColor { get { -// if #available(iOSApplicationExtension 11.0, *) { -// return UIColor(named: "SelectionPrimary")! -// } else { return UIColor(red: 134.0 / 255.0, green: 137.0 / 255.0, blue: 139.0 / 255.0, @@ -95,4 +92,17 @@ public class Colors { } } } + + public static var keyboardSelectionPrimary: UIColor { + get { + if #available(iOS 11.0, *) { + return UIColor(named: "KeyboardSelectionPrimary", in: engineBundle, compatibleWith: nil)! + } else { + return UIColor(red: 204.0 / 255.0, + green: 136.0 / 255.0, + blue: 34.0 / 255.0, + alpha: 1.0) + } + } + } } diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Constants.swift b/ios/engine/KMEI/KeymanEngine/Classes/Constants.swift index 4312d88e76..0e0282d9fc 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Constants.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Constants.swift @@ -77,7 +77,7 @@ public enum Defaults { public static let lexicalModel = InstallableLexicalModel(id: "nrc.en.mtnt", name: "English dictionary (MTNT)", languageID: "en", - version: "0.1.2", + version: "0.1.4", isCustom: false) } diff --git a/ios/engine/KMEI/KeymanEngine/Classes/InputViewController.swift b/ios/engine/KMEI/KeymanEngine/Classes/InputViewController.swift index 4d67fcde9f..2dbca8517d 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/InputViewController.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/InputViewController.swift @@ -431,9 +431,20 @@ open class InputViewController: UIInputViewController, KeymanWebDelegate { baseWidthConstraint.isActive = true } + public var isTopBarActive: Bool { + let userData = Storage.active.userDefaults + let alwaysShow = userData.bool(forKey: Key.optShouldShowBanner) + + if alwaysShow || Manager.shared.isSystemKeyboard || keymanWeb.activeModel { + return true + } + + return false + } + public var activeTopBarHeight: CGFloat { // If 'isSystemKeyboard' is true, always show the top bar. - return isSystemKeyboard ? CGFloat(InputViewController.topBarHeight) : 0 + return isTopBarActive ? CGFloat(InputViewController.topBarHeight) : 0 } public var kmwHeight: CGFloat { diff --git a/ios/engine/KMEI/KeymanEngine/Classes/KeymanWebViewController.swift b/ios/engine/KMEI/KeymanEngine/Classes/KeymanWebViewController.swift index bbbc9af3e9..120a920386 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/KeymanWebViewController.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/KeymanWebViewController.swift @@ -317,7 +317,13 @@ extension KeymanWebViewController { func setBannerImage(to path: String) { bannerImgPath = path // Save the path in case delayed initializaiton is needed. - log.debug("Banner image path: '\(path).'") + var logString: String + if path.contains("base64") || path.count > 256 { + logString = "" + } else { + logString = path + } + log.debug("Banner image path: '\(logString).'") webView?.evaluateJavaScript("setBannerImage(\"\(path)\");", completionHandler: nil) } diff --git a/ios/engine/KMEI/KeymanEngine/Classes/LanguagePicker/KeyboardSwitcherViewController.swift b/ios/engine/KMEI/KeymanEngine/Classes/LanguagePicker/KeyboardSwitcherViewController.swift index 44c89162ea..1f5526fe71 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/LanguagePicker/KeyboardSwitcherViewController.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/LanguagePicker/KeyboardSwitcherViewController.swift @@ -69,7 +69,7 @@ class KeyboardSwitcherViewController: UITableViewController, UIAlertViewDelegate let cell = UITableViewCell(style: .subtitle, reuseIdentifier: cellIdentifier) let selectionColor = UIView() - selectionColor.backgroundColor = Colors.selectionPrimary + selectionColor.backgroundColor = Colors.keyboardSelectionPrimary cell.selectedBackgroundView = selectionColor return cell } diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Manager.swift b/ios/engine/KMEI/KeymanEngine/Classes/Manager.swift index a1beefaf01..ff1799ba0c 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Manager.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Manager.swift @@ -183,10 +183,18 @@ public class Manager: NSObject, UIGestureRecognizerDelegate { URLProtocol.registerClass(KeymanURLProtocol.self) Migrations.migrate(storage: Storage.active) + Migrations.updateResources(storage: Storage.active) + if Storage.active.userDefaults.userKeyboards?.isEmpty ?? true { Storage.active.userDefaults.userKeyboards = [Defaults.keyboard] + + // Ensure the default keyboard is installed in this case. + do { + try Storage.active.installDefaultKeyboard(from: Resources.bundle) + } catch { + log.error("Failed to copy default keyboard from bundle: \(error)") + } } - Migrations.updateResources(storage: Storage.active) Migrations.engineVersion = Version.current if Util.isSystemKeyboard || Storage.active.userDefaults.bool(forKey: Key.keyboardPickerDisplayed) { @@ -810,9 +818,10 @@ public class Manager: NSObject, UIGestureRecognizerDelegate { let keyboardDir = Storage.active.keyboardDir(forID: keyboardID) try FileManager.default.createDirectory(at: keyboardDir, withIntermediateDirectories: true) for url in urls { - try Storage.copyAndExcludeFromBackup(at: url, - to: keyboardDir.appendingPathComponent(url.lastPathComponent), - shouldOverwrite: shouldOverwrite) + try Storage.copy(at: url, + to: keyboardDir.appendingPathComponent(url.lastPathComponent), + shouldOverwrite: shouldOverwrite, + excludeFromBackup: true) } } diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Migrations.swift b/ios/engine/KMEI/KeymanEngine/Classes/Migrations.swift index 96f07e990d..b7ba94c99c 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Migrations.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Migrations.swift @@ -50,6 +50,12 @@ public enum Migrations { oskFont: nil, isCustom: false) + let nrc_en_mtnt_0_1_2 = InstallableLexicalModel(id: "nrc.en.mtnt", + name: "English dictionary (MTNT)", + languageID: "en", + version: "0.1.2", + isCustom: false) + let sil_euro_latin = Defaults.keyboard // We're already storing the exact metadata needed. let nrc_en_mtnt = Defaults.lexicalModel @@ -62,12 +68,14 @@ public enum Migrations { let legacy_resources = VersionResourceSet(version: Version.fallback, resources: [european]) let v10_resources = VersionResourceSet(version: Version("10.0")!, resources: [european2]) let v11_resources = VersionResourceSet(version: Version("11.0")!, resources: [sil_euro_latin]) - let v12_resources = VersionResourceSet(version: Version("12.0")!, resources: [sil_euro_latin, nrc_en_mtnt]) + let v12_resources = VersionResourceSet(version: Version("12.0")!, resources: [sil_euro_latin, nrc_en_mtnt_0_1_2]) + let v13_resources = VersionResourceSet(version: Version("13.0.65")!, resources: [sil_euro_latin, nrc_en_mtnt]) timeline.append(legacy_resources) timeline.append(v10_resources) timeline.append(v11_resources) timeline.append(v12_resources) + timeline.append(v13_resources) return timeline }() @@ -177,9 +185,19 @@ public enum Migrations { if lastVersion != nil { // Time to deinstall the old version's resources. - let resources = resourceHistory.first(where: { set in - return set.version == lastVersion - })!.resources + // First, find the most recent version with a listed history. + let possibleHistories: [VersionResourceSet] = resourceHistory.compactMap { set in + if set.version <= lastVersion! { + return set + } else { + return nil + } + } + + // Assumes the history definition is in ascending Version order; takes the last in the list + // as the correct "old version" resource set. This allows covering gaps, + // such as for a 'plain' 13.0 prior install. + let resources = possibleHistories[possibleHistories.count-1].resources resources.forEach { res in if let kbd = res as? InstallableKeyboard { @@ -205,14 +223,26 @@ public enum Migrations { // Now to install the new version's resources. var userKeyboards = Storage.active.userDefaults.userKeyboards ?? [] var userModels = Storage.active.userDefaults.userLexicalModels ?? [] + let defaultsNeedBackup = (lastVersion ?? Version.fallback) < Version.defaultsNeedBackup + var installKbd = false + var installLex = false - // Don't add the keyboard a second time if it's already installed! Can happen + // Don't add the keyboard a second time if it's already installed and backed up. Can happen // if multiple Keyman versions match. if !userKeyboards.contains(where: { kbd in kbd.id == Defaults.keyboard.id && kbd.languageID == Defaults.keyboard.languageID }) { userKeyboards = [Defaults.keyboard] + userKeyboards // Make sure the default goes in the first slot! Storage.active.userDefaults.userKeyboards = userKeyboards + + installKbd = true + } + if(defaultsNeedBackup || installKbd) { + do { + try Storage.active.installDefaultKeyboard(from: Resources.bundle) + } catch { + log.error("Failed to copy default keyboard from bundle: \(error)") + } } if !userModels.contains(where: { lex in @@ -220,9 +250,16 @@ public enum Migrations { }) { userModels = [Defaults.lexicalModel] + userModels Storage.active.userDefaults.userLexicalModels = userModels - } - // Must still do the actual install. This comes after the copyKMWFiles step, though. + installLex = true + } + if(defaultsNeedBackup || installLex) { + do { + try Storage.active.installDefaultLexicalModel(from: Resources.bundle) + } catch { + log.error("Failed to copy default lexical model from bundle: \(error)") + } + } // Store the version we just upgraded to. storage.userDefaults.lastEngineVersion = Version.current diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Model/Version.swift b/ios/engine/KMEI/KeymanEngine/Classes/Model/Version.swift index d3d5e0e3c7..8936def380 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Model/Version.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Model/Version.swift @@ -11,7 +11,7 @@ import Foundation /// Dotted-decimal version. public struct Version: Comparable { public static let fallback = Version("1.0")! - public static let current = Version("13.0")! + public static let current = Version("13.0.65")! // The Engine first started tracking the 'last loaded version' in 12.0. public static let firstTracked = Version("12.0")! @@ -20,6 +20,7 @@ public struct Version: Comparable { // this prompted a mild rework of KMP installation file management, // since this exposed byproducts to the users in the Files app. public static let fileBrowserImplemented = Version("13.0")! + public static let defaultsNeedBackup = Version("13.0.65")! private let components: [Int] public let string: String diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/ResourceDownloadManager.swift b/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/ResourceDownloadManager.swift index cefc8f1649..4eea67946b 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/ResourceDownloadManager.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/ResourceDownloadManager.swift @@ -533,7 +533,7 @@ public class ResourceDownloadManager { } } } else if let lex = res as? InstallableLexicalModel { - if let filename = Manager.shared.apiLexicalModelRepository.lexicalModels?[lex.id]?.filename, + if let filename = Manager.shared.apiLexicalModelRepository.lexicalModels?[lex.id]?.packageFilename, let path = URL.init(string: filename) { if let batch = self.buildLexicalModelDownloadBatch(for: lex, withFilename: path, asActivity: .update) { batches.append(batch) diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Storage.swift b/ios/engine/KMEI/KeymanEngine/Classes/Storage.swift index 9e538047af..8f6b011529 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Storage.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Storage.swift @@ -157,35 +157,56 @@ extension Storage { func copyKMWFiles(from bundle: Bundle) throws { try Storage.copy(from: bundle, resourceName: Resources.kmwFilename, - dstDir: baseDir) + dstDir: baseDir, + excludeFromBackup: true) try Storage.copy(from: bundle, resourceName: "keymanios.js", - dstDir: baseDir) + dstDir: baseDir, + excludeFromBackup: true) // For debug compilations - IF we have a sourcemap file, copy that over too. if bundle.url(forResource: "keyman.js.map", withExtension: nil) != nil { try Storage.copy(from: bundle, resourceName: "keyman.js.map", - dstDir: baseDir) + dstDir: baseDir, + excludeFromBackup: true) } try Storage.copy(from: bundle, resourceName: "kmwosk.css", - dstDir: baseDir) + dstDir: baseDir, + excludeFromBackup: true) try Storage.copy(from: bundle, resourceName: "keymanweb-osk.ttf", - dstDir: baseDir) + dstDir: baseDir, + excludeFromBackup: true) + } + + func installDefaultKeyboard(from bundle: Bundle) throws { let defaultKeyboardDir = self.keyboardDir(forID: Defaults.keyboard.id) - let defaultLexicalModelDir = self.lexicalModelDir(forID: Defaults.lexicalModel.id) try FileManager.default.createDirectory(at: defaultKeyboardDir, withIntermediateDirectories: true) - try FileManager.default.createDirectory(at: defaultLexicalModelDir, withIntermediateDirectories: true) + + // Since we only want to do this installation the first time (rather than constantly force-reinstalling + // the resource), we don't want this excluded from backup. try Storage.copy(from: bundle, resourceName: "\(Defaults.keyboard.id)-\(Defaults.keyboard.version).js", - dstDir: defaultKeyboardDir) + dstDir: defaultKeyboardDir, + excludeFromBackup: false) try Storage.copy(from: bundle, resourceName: "DejaVuSans.ttf", - dstDir: defaultKeyboardDir) + dstDir: defaultKeyboardDir, + excludeFromBackup: false) + } + + func installDefaultLexicalModel(from bundle: Bundle) throws { + let defaultLexicalModelDir = self.lexicalModelDir(forID: Defaults.lexicalModel.id) + + try FileManager.default.createDirectory(at: defaultLexicalModelDir, withIntermediateDirectories: true) + + // Since we only want to do this installation the first time (rather than constantly force-reinstalling + // the resource), we don't want this excluded from backup. try Storage.copy(from: bundle, resourceName: "\(Defaults.lexicalModel.id)-\(Defaults.lexicalModel.version).model.kmp", - dstDir: defaultLexicalModelDir) + dstDir: defaultLexicalModelDir, + excludeFromBackup: false) // Perform an auto-install of the lexical model's KMP if not already installed. let lexicalModelURLasZIP = Storage.active.lexicalModelPackageURL(forID: Defaults.lexicalModel.id, @@ -197,7 +218,7 @@ extension Storage { // Because of how our .zip dependency works, we need to make the .kmp look like a .zip. A simple rename will do. do { - try Storage.copyAndExcludeFromBackup(at: lexicalModelURL, to: lexicalModelURLasZIP) + try Storage.copy(at: lexicalModelURL, to: lexicalModelURLasZIP, excludeFromBackup: true) let downloader = ResourceDownloadQueue() // Hijacking the download queue's KMP installer. @@ -207,7 +228,6 @@ extension Storage { } catch { log.error("Failed to install the default lexical model from the bundled KMP: \(error)") } - } func copyFiles(to dst: Storage) throws { @@ -227,20 +247,20 @@ extension Storage { dst.userDefaults.synchronize() } - private static func copy(from bundle: Bundle, resourceName: String, dstDir: URL) throws { + private static func copy(from bundle: Bundle, resourceName: String, dstDir: URL, excludeFromBackup: Bool = false) throws { guard let srcURL = bundle.url(forResource: resourceName, withExtension: nil) else { let message = "Could not locate \(resourceName) in the Keyman bundle" throw NSError(domain: "Keyman", code: 0, userInfo: [NSLocalizedDescriptionKey: message]) } let dstURL = dstDir.appendingPathComponent(srcURL.lastPathComponent) - return try Storage.copyAndExcludeFromBackup(at: srcURL, to: dstURL) + return try Storage.copy(at: srcURL, to: dstURL, excludeFromBackup: true) } private static func copyDirectoryContents(at srcDir: URL, to dstDir: URL) throws { let srcContents = try FileManager.default.contentsOfDirectory(at: srcDir, includingPropertiesForKeys: []) for srcFile in srcContents { - try Storage.copyAndExcludeFromBackup(at: srcFile, to: dstDir.appendingPathComponent(srcFile.lastPathComponent)) + try Storage.copy(at: srcFile, to: dstDir.appendingPathComponent(srcFile.lastPathComponent), excludeFromBackup: true) } } @@ -254,9 +274,10 @@ extension Storage { } /// Copy and exclude from iCloud backup if `src` is newer than `dst`. Does nothing for a directory. - static func copyAndExcludeFromBackup(at src: URL, - to dst: URL, - shouldOverwrite: Bool = true) throws { + static func copy(at src: URL, + to dst: URL, + shouldOverwrite: Bool = true, + excludeFromBackup: Bool = false) throws { let fm = FileManager.default var isDirectory: ObjCBool = false @@ -278,6 +299,9 @@ extension Storage { } else { return } - try Storage.addSkipBackupAttribute(to: dst) + + if excludeFromBackup { + try Storage.addSkipBackupAttribute(to: dst) + } } } diff --git a/ios/engine/KMEI/KeymanEngine/Keyboard Colors.xcassets/KeyboardSelectionPrimary.colorset/Contents.json b/ios/engine/KMEI/KeymanEngine/Keyboard Colors.xcassets/KeyboardSelectionPrimary.colorset/Contents.json new file mode 100644 index 0000000000..3427e0b471 --- /dev/null +++ b/ios/engine/KMEI/KeymanEngine/Keyboard Colors.xcassets/KeyboardSelectionPrimary.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + }, + "colors" : [ + { + "idiom" : "universal", + "color" : { + "color-space" : "srgb", + "components" : { + "red" : "0.800", + "alpha" : "1.000", + "blue" : "0.133", + "green" : "0.533" + } + } + }, + { + "idiom" : "universal", + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "red" : "0.400", + "alpha" : "1.000", + "blue" : "0.067", + "green" : "0.267" + } + } + } + ] +} \ No newline at end of file diff --git a/ios/engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/nrc.en.mtnt-0.1.2.model.kmp b/ios/engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/nrc.en.mtnt-0.1.2.model.kmp deleted file mode 100644 index a5803f5579..0000000000 Binary files a/ios/engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/nrc.en.mtnt-0.1.2.model.kmp and /dev/null differ diff --git a/ios/engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/nrc.en.mtnt-0.1.4.model.kmp b/ios/engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/nrc.en.mtnt-0.1.4.model.kmp new file mode 100644 index 0000000000..7969866c3c Binary files /dev/null and b/ios/engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/nrc.en.mtnt-0.1.4.model.kmp differ diff --git a/ios/history.md b/ios/history.md index bf083601c8..6a5c5e2c6c 100644 --- a/ios/history.md +++ b/ios/history.md @@ -1,5 +1,13 @@ # Keyman for iPhone and iPad Version History +## 2020-02-11 13.0.67 beta +* Bug fix: fixed issue with sample apps and engine keyboard switcher (#2626) +* Bug fix: fixed issue with dictionary updates (#2627) + +## 2020-02-10 13.0.66 beta +* Bug fix: Fixed migration of old resources to new app versions, updated default MTNT dictionary (#2512) +* Bug fix: Fixed issues when iOS system transitions between light and dark modes (#2593, #2594) + ## 2020-02-03 13.0.63 beta * Bug fix: Fixes keyboard widths/margins in iOS 9 and 10 (#2560) diff --git a/ios/keyman/Keyman/Keyman.xcodeproj/project.pbxproj b/ios/keyman/Keyman/Keyman.xcodeproj/project.pbxproj index e3175aa0ba..028ec80771 100644 --- a/ios/keyman/Keyman/Keyman.xcodeproj/project.pbxproj +++ b/ios/keyman/Keyman/Keyman.xcodeproj/project.pbxproj @@ -134,6 +134,7 @@ CE2B1E4C21B6112B007D092E /* DeviceKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE2B1E4B21B6112B007D092E /* DeviceKit.framework */; }; CE6138011FB99538009D0EF2 /* KeymanEngine.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = CE6137FF1FB99538009D0EF2 /* KeymanEngine.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; CE6138021FB999C8009D0EF2 /* KeymanEngine.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE6138031FB999C8009D0EF2 /* KeymanEngine.framework */; }; + CE76FF8023EBB5CB005648A8 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 98C893F119F0811A000B9AC8 /* Images.xcassets */; }; CE79B23C23C6E8E6007E72AE /* KeyboardViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0FF769A1F5D368C00BD23C3 /* KeyboardViewController.swift */; }; CE79B23D23C6ECB2007E72AE /* banner-Landscape-568h@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 985EA9D419E64A580006C017 /* banner-Landscape-568h@2x.png */; }; CE79B23E23C6ECB4007E72AE /* banner-Landscape-568h@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 985EA9D519E64A580006C017 /* banner-Landscape-568h@3x.png */; }; @@ -149,6 +150,10 @@ CE79CDB52370111200010C06 /* Themes+Colors.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CE79CDB42370111200010C06 /* Themes+Colors.xcassets */; }; CE7C1AE2236925D800100C2C /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = CE7C1AE1236925D800100C2C /* LaunchScreen.storyboard */; }; CE7FF1F0239A0293007859D9 /* PackageBrowserViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7FF1EF239A0293007859D9 /* PackageBrowserViewController.swift */; }; + CEF4E55623E95B7B0065B9C7 /* ImageBanner.xib in Resources */ = {isa = PBXBuildFile; fileRef = CEF4E55523E95B7B0065B9C7 /* ImageBanner.xib */; }; + CEF4E55723E95B7B0065B9C7 /* ImageBanner.xib in Resources */ = {isa = PBXBuildFile; fileRef = CEF4E55523E95B7B0065B9C7 /* ImageBanner.xib */; }; + CEF4E55923E967140065B9C7 /* ImageBannerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEF4E55823E967140065B9C7 /* ImageBannerViewController.swift */; }; + CEF4E55A23E967140065B9C7 /* ImageBannerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEF4E55823E967140065B9C7 /* ImageBannerViewController.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -315,6 +320,8 @@ CE7A26D523CEEB8D0005955C /* UIColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = UIColors.swift; path = ../../../engine/KMEI/KeymanEngine/Classes/UIColors.swift; sourceTree = ""; }; CE7C1AE1236925D800100C2C /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; CE7FF1EF239A0293007859D9 /* PackageBrowserViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PackageBrowserViewController.swift; sourceTree = ""; }; + CEF4E55523E95B7B0065B9C7 /* ImageBanner.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ImageBanner.xib; sourceTree = ""; }; + CEF4E55823E967140065B9C7 /* ImageBannerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageBannerViewController.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -633,6 +640,8 @@ 98CD127619CA866B0089385F /* SWKeyboard.entitlements */, 98CD125E19CA85710089385F /* Supporting Files */, C0FF769A1F5D368C00BD23C3 /* KeyboardViewController.swift */, + CEF4E55523E95B7B0065B9C7 /* ImageBanner.xib */, + CEF4E55823E967140065B9C7 /* ImageBannerViewController.swift */, ); path = SWKeyboard; sourceTree = ""; @@ -841,6 +850,7 @@ 981AFAC819EF44DE006706BF /* navbar-Portrait.png in Resources */, 981AFAC919EF44DE006706BF /* 711-trash.png in Resources */, 9845A7D51A439A9200544E2E /* SetUpViewController.xib in Resources */, + CEF4E55623E95B7B0065B9C7 /* ImageBanner.xib in Resources */, CE79B24723C6ECC9007E72AE /* banner-Portrait~ipad.png in Resources */, 9845A8D51A439F1000544E2E /* UIAccessoryButtonX@2x.png in Resources */, 982D149919FF462A007D4629 /* 887-notepad@3x.png in Resources */, @@ -877,12 +887,14 @@ 985EA9E819E64A580006C017 /* banner-Portrait@3x.png in Resources */, 985EA9E419E64A580006C017 /* banner-Landscape~ipad.png in Resources */, 985EA9E019E64A580006C017 /* banner-Landscape-568h@3x.png in Resources */, + CE76FF8023EBB5CB005648A8 /* Images.xcassets in Resources */, 985EA9E719E64A580006C017 /* banner-Portrait@2x~ipad.png in Resources */, 985EA9E519E64A580006C017 /* banner-Portrait.png in Resources */, 985EA9E319E64A580006C017 /* banner-Landscape@2x~ipad.png in Resources */, 985EA9E119E64A580006C017 /* banner-Landscape.png in Resources */, 985EA9DF19E64A580006C017 /* banner-Landscape-568h@2x.png in Resources */, 985EA9E619E64A580006C017 /* banner-Portrait@2x.png in Resources */, + CEF4E55723E95B7B0065B9C7 /* ImageBanner.xib in Resources */, 985EA9E219E64A580006C017 /* banner-Landscape@2x.png in Resources */, 985EA9E919E64A580006C017 /* banner-Portrait~ipad.png in Resources */, ); @@ -959,6 +971,7 @@ CE7FF1F0239A0293007859D9 /* PackageBrowserViewController.swift in Sources */, C0E943F81F61234C00E7D98C /* UIImage+Helpers.swift in Sources */, C0E943FE1F61377900E7D98C /* MainViewController.swift in Sources */, + CEF4E55923E967140065B9C7 /* ImageBannerViewController.swift in Sources */, 162E2C9920926C8600F40769 /* UIView+Extensions.swift in Sources */, 162E2C9B20926C9B00F40769 /* KMNavigationBarBackgroundView.swift in Sources */, C055F0B31F60F7A400140735 /* DropDownView.swift in Sources */, @@ -979,6 +992,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + CEF4E55A23E967140065B9C7 /* ImageBannerViewController.swift in Sources */, C0FF769B1F5D368C00BD23C3 /* KeyboardViewController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/ios/keyman/Keyman/Keyman/MainViewController.swift b/ios/keyman/Keyman/Keyman/MainViewController.swift index 777f11831f..f1280edf3d 100644 --- a/ios/keyman/Keyman/Keyman/MainViewController.swift +++ b/ios/keyman/Keyman/Keyman/MainViewController.swift @@ -319,26 +319,22 @@ class MainViewController: UIViewController, TextViewDelegate, UIActionSheetDeleg imageScale scaleF: CGFloat, action selector: Selector, orientation: UIInterfaceOrientation) -> UIBarButtonItem { - let icon = image.resize(to: CGSize(width: image.size.width * scaleF, height: image.size.height * scaleF)) - .withRenderingMode(.alwaysOriginal) - let iconHighlighted = imageHighlighted.resize(to: - CGSize(width: imageHighlighted.size.width * scaleF, height: imageHighlighted.size.height * scaleF)) - .withRenderingMode(.alwaysOriginal) + let size = CGSize(width: image.size.width * scaleF, height: image.size.height * scaleF) let vAdjPort: CGFloat = UIScreen.main.scale == 2.0 ? -3.6 : -2.6 let vAdjLscpe: CGFloat = -1.6 let vAdj = orientation.isPortrait ? vAdjPort : vAdjLscpe let navBarHeight = self.navBarHeight() - let y = (navBarHeight - icon.size.height) / 2.0 + vAdj + let y = (navBarHeight - size.height) / 2.0 + vAdj let customButton = UIButton(type: .custom) - customButton.setImage(icon, for: .normal) - customButton.setImage(iconHighlighted, for: .highlighted) - customButton.frame = CGRect(x: 0.0, y: y, width: icon.size.width, height: icon.size.height) + customButton.setImage(image, for: .normal) + customButton.setImage(imageHighlighted, for: .highlighted) + customButton.frame = CGRect(x: 0.0, y: y, width: size.width, height: size.height) customButton.addTarget(self, action: selector, for: .touchUpInside) - let containerView = UIView(frame: CGRect(x: 0.0, y: 0.0, width: icon.size.width, height: navBarHeight)) + let containerView = UIView(frame: CGRect(x: 0.0, y: 0.0, width: size.width, height: navBarHeight)) containerView.backgroundColor = UIColor.clear containerView.addSubview(customButton) diff --git a/ios/keyman/Keyman/SWKeyboard/ImageBanner.xib b/ios/keyman/Keyman/SWKeyboard/ImageBanner.xib new file mode 100644 index 0000000000..1b3e5aa64a --- /dev/null +++ b/ios/keyman/Keyman/SWKeyboard/ImageBanner.xib @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/keyman/Keyman/SWKeyboard/ImageBannerViewController.swift b/ios/keyman/Keyman/SWKeyboard/ImageBannerViewController.swift new file mode 100644 index 0000000000..76692b6761 --- /dev/null +++ b/ios/keyman/Keyman/SWKeyboard/ImageBannerViewController.swift @@ -0,0 +1,81 @@ +// +// ImageBannerViewController.swift +// Keyman +// +// Created by Joshua Horton on 2/4/20. +// Copyright © 2020 SIL International. All rights reserved. +// + +import Foundation +import UIKit +import KeymanEngine // for log commands + +/** + * Takes in a XIB spec for an image banner and makes it renderable, applying size constraints when rendered + * to ensure proper scale. Note that the loaded UIView will never be displayed in the view hierarchy. + */ +class ImageBannerViewController: UIViewController { + // Since we never actually put this UIView into the view hierarchy, we need controlled constraints + // to control the actual frame size. + var widthConstraint: NSLayoutConstraint? + var heightConstraint: NSLayoutConstraint? + + init(nibName: String, bundle: Bundle) { + super.init(nibName: nibName, bundle: bundle) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + super.loadView() + + view.translatesAutoresizingMaskIntoConstraints = false + view.clipsToBounds = true + + // Usually these would be in viewDidLoad, but they need to be redone with our manual-reload, + // and that method doesn't retrigger. + widthConstraint = view.widthAnchor.constraint(equalToConstant: 0) + widthConstraint!.isActive = true + heightConstraint = view.heightAnchor.constraint(equalToConstant: 0) + heightConstraint!.isActive = true + } + + func renderToImage(size: CGSize) -> UIImage? { + if size.width == 0 || size.height == 0 { + return nil + } + + let frame = CGRect(origin: self.view.frame.origin, size: size) + + log.debug("Rendering banner image of size \(size)") + self.view.frame = frame + widthConstraint?.constant = size.width + heightConstraint?.constant = size.height + self.view.setNeedsLayout() + self.view.layoutIfNeeded() + + // Many thanks to https://stackoverflow.com/a/4334902 + var image: UIImage? + UIGraphicsBeginImageContextWithOptions(size, true, 0.0) + if let ctx = UIGraphicsGetCurrentContext() { + view.layer.render(in: ctx) + image = UIGraphicsGetImageFromCurrentImageContext() + } + UIGraphicsEndImageContext() + + return image + } + + public func renderAsBase64(size: CGSize) -> String? { + if let image = renderToImage(size: size), let data = image.pngData() { + // Thanks to https://stackoverflow.com/a/38748135 + let base64 = data.base64EncodedString(options: []) + let url = "data:application/png;base64," + base64 + return url + } else { + return nil + } + } +} diff --git a/ios/keyman/Keyman/SWKeyboard/KeyboardViewController.swift b/ios/keyman/Keyman/SWKeyboard/KeyboardViewController.swift index 6c1781af0e..4d7e69d368 100644 --- a/ios/keyman/Keyman/SWKeyboard/KeyboardViewController.swift +++ b/ios/keyman/Keyman/SWKeyboard/KeyboardViewController.swift @@ -10,6 +10,8 @@ import KeymanEngine import UIKit class KeyboardViewController: InputViewController { + var topBarImageSource: ImageBannerViewController! + override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { #if DEBUG KeymanEngine.log.outputLevel = .debug @@ -19,6 +21,9 @@ class KeyboardViewController: InputViewController { #endif Manager.applicationGroupIdentifier = "group.KM4I" + let bundle = Bundle(for: KeyboardViewController.self) + topBarImageSource = ImageBannerViewController(nibName: "ImageBanner", bundle: bundle) + super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) } @@ -33,40 +38,25 @@ class KeyboardViewController: InputViewController { return } - setupTopBarImage(isPortrait: InputViewController.isPortrait) + setupTopBarImage(size: view.frame.size) } override func viewDidLoad() { super.viewDidLoad() - setupTopBarImage(isPortrait: InputViewController.isPortrait) + setupTopBarImage(size: view.frame.size) } override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator) - setupTopBarImage(isPortrait: UIDevice.current.orientation.isPortrait) + setupTopBarImage(size: size) } - func getTopBarImage(isPortrait: Bool) -> String? { - if isPortrait { - return Bundle.main.path(forResource: "banner-Portrait@2x", ofType: "png") - } - - // iPad - if UIDevice.current.userInterfaceIdiom != UIUserInterfaceIdiom.phone { - return Bundle.main.path(forResource: "banner-Landscape@2x", ofType: "png") - } - - // iPhone - let screenRect = UIScreen.main.bounds - if CGFloat.maximum(screenRect.height, screenRect.width) >= 568.0 { - return Bundle.main.path(forResource: "banner-Landscape-568h@2x", ofType: "png") - } else { - return Bundle.main.path(forResource: "banner-Landscape@2x", ofType: "png") - } + func getTopBarImage(size: CGSize) -> String? { + return topBarImageSource.renderAsBase64(size: CGSize(width: size.width, height: self.activeTopBarHeight)) } - func setupTopBarImage(isPortrait: Bool) { - let imgPath = getTopBarImage(isPortrait: isPortrait) + func setupTopBarImage(size: CGSize) { + let imgPath = getTopBarImage(size: size) guard let path = imgPath else { log.error("No image specified for the image banner!") return @@ -74,4 +64,32 @@ class KeyboardViewController: InputViewController { self.setBannerImage(to: path) } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + guard let previousTraitCollection = previousTraitCollection else {return} + if #available(iOS 13.0, *) { + if previousTraitCollection.hasDifferentColorAppearance(comparedTo: traitCollection) { + /* Ensure that the keyboard banner image transitions! Noting that the backing view isn't in the view hierarchy, + * and that manipulating said hierarchy is key for triggering the appearance change + * (see https://developer.apple.com/documentation/uikit/uiappearance)... + * + * We have to reload the banner's backing view to trigger the change. Thanks, Apple. + * The only other alternative - creating a new instance of the view & its controller. + * + * With loadView, at least we can reuse the old instance, which would serve far better + * for engine API calls if we decide to let the base InputViewController perform UIView + * render functionality (as a KMEI offering) instead of having it only in our app. + * That said, note https://developer.apple.com/documentation/uikit/uiviewcontroller/1621454-loadview: + * + * > You should never call this method directly. + * + * Well... I wouldn't, if it weren't the only way to trigger this without requiring a new instance. + * We should be fine since we never place the banner's backing view into the actual hierarchy. + */ + topBarImageSource.loadView() + setupTopBarImage(size: view.frame.size) + } + } + } } diff --git a/ios/samples/KMSample2/KMSample2.xcodeproj/project.pbxproj b/ios/samples/KMSample2/KMSample2.xcodeproj/project.pbxproj index fcb56d7f9a..394159bf40 100644 --- a/ios/samples/KMSample2/KMSample2.xcodeproj/project.pbxproj +++ b/ios/samples/KMSample2/KMSample2.xcodeproj/project.pbxproj @@ -28,6 +28,9 @@ C05B142E1FD8F45E0082A316 /* XCGLogger.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C05B14281FD8F4510082A316 /* XCGLogger.framework */; }; C05B142F1FD8F45E0082A316 /* XCGLogger.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = C05B14281FD8F4510082A316 /* XCGLogger.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; C05B14311FD8F45E0082A316 /* Zip.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = C05B14261FD8F4510082A316 /* Zip.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + CE6ECBBC23F25B6B00277E50 /* DeviceKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE6ECBBB23F25B6B00277E50 /* DeviceKit.framework */; }; + CE6ECBBD23F25B6B00277E50 /* DeviceKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = CE6ECBBB23F25B6B00277E50 /* DeviceKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + CE6ECBBE23F25B9400277E50 /* DeviceKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE6ECBBB23F25B6B00277E50 /* DeviceKit.framework */; }; CEBEFB94231FB4E000F8F67C /* banner-portrait-text.png in Resources */ = {isa = PBXBuildFile; fileRef = CEBEFB92231FB4DF00F8F67C /* banner-portrait-text.png */; }; CEBEFB95231FB4E000F8F67C /* banner-landscape-text.png in Resources */ = {isa = PBXBuildFile; fileRef = CEBEFB93231FB4DF00F8F67C /* banner-landscape-text.png */; }; CEBEFB98231FB6BD00F8F67C /* Reachability.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CEBEFB8D231F9FEE00F8F67C /* Reachability.framework */; }; @@ -66,6 +69,7 @@ CEBEFB9B231FB7DE00F8F67C /* Reachability.framework in Embed Frameworks */, C05B14311FD8F45E0082A316 /* Zip.framework in Embed Frameworks */, C05B142D1FD8F45E0082A316 /* ObjcExceptionBridging.framework in Embed Frameworks */, + CE6ECBBD23F25B6B00277E50 /* DeviceKit.framework in Embed Frameworks */, C01AB5A11F834706009B37D3 /* KeymanEngine.framework in Embed Frameworks */, C05B142F1FD8F45E0082A316 /* XCGLogger.framework in Embed Frameworks */, ); @@ -93,6 +97,7 @@ C05B14261FD8F4510082A316 /* Zip.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Zip.framework; path = ../../Carthage/Build/iOS/Zip.framework; sourceTree = ""; }; C05B14271FD8F4510082A316 /* ObjcExceptionBridging.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ObjcExceptionBridging.framework; path = ../../Carthage/Build/iOS/ObjcExceptionBridging.framework; sourceTree = ""; }; C05B14281FD8F4510082A316 /* XCGLogger.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCGLogger.framework; path = ../../Carthage/Build/iOS/XCGLogger.framework; sourceTree = ""; }; + CE6ECBBB23F25B6B00277E50 /* DeviceKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = DeviceKit.framework; path = ../../Carthage/Build/iOS/DeviceKit.framework; sourceTree = ""; }; CEBEFB8D231F9FEE00F8F67C /* Reachability.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Reachability.framework; path = ../../Carthage/Build/iOS/Reachability.framework; sourceTree = ""; }; CEBEFB92231FB4DF00F8F67C /* banner-portrait-text.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "banner-portrait-text.png"; sourceTree = ""; }; CEBEFB93231FB4DF00F8F67C /* banner-landscape-text.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "banner-landscape-text.png"; sourceTree = ""; }; @@ -106,6 +111,7 @@ C01AB5A01F834706009B37D3 /* KeymanEngine.framework in Frameworks */, C05B142C1FD8F45E0082A316 /* ObjcExceptionBridging.framework in Frameworks */, CEBEFB98231FB6BD00F8F67C /* Reachability.framework in Frameworks */, + CE6ECBBC23F25B6B00277E50 /* DeviceKit.framework in Frameworks */, C05B142E1FD8F45E0082A316 /* XCGLogger.framework in Frameworks */, C059FCB51FD923E900BD1A64 /* Zip.framework in Frameworks */, ); @@ -118,6 +124,7 @@ CEBEFB99231FB6BE00F8F67C /* Reachability.framework in Frameworks */, C01AB59F1F8346FC009B37D3 /* KeymanEngine.framework in Frameworks */, C059FCB21FD9231D00BD1A64 /* ObjcExceptionBridging.framework in Frameworks */, + CE6ECBBE23F25B9400277E50 /* DeviceKit.framework in Frameworks */, C059FCB31FD9231D00BD1A64 /* XCGLogger.framework in Frameworks */, C059FCB41FD9231D00BD1A64 /* Zip.framework in Frameworks */, ); @@ -171,6 +178,7 @@ 98ABFD601AD3A087005534F0 /* Frameworks */ = { isa = PBXGroup; children = ( + CE6ECBBB23F25B6B00277E50 /* DeviceKit.framework */, CEBEFB8D231F9FEE00F8F67C /* Reachability.framework */, C05B14271FD8F4510082A316 /* ObjcExceptionBridging.framework */, C05B14281FD8F4510082A316 /* XCGLogger.framework */, @@ -573,7 +581,6 @@ 98ABFD971AD3A232005534F0 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = SWKeyboard/SWKeyboard.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; @@ -599,7 +606,6 @@ 98ABFD981AD3A232005534F0 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = SWKeyboard/SWKeyboard.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; diff --git a/linux/.editorconfig b/linux/.editorconfig new file mode 100644 index 0000000000..0d465fe281 --- /dev/null +++ b/linux/.editorconfig @@ -0,0 +1,7 @@ +root = true + +[*.py] +trim_trailing_whitespace = true +intent_stype = space +intent_size = 4 + diff --git a/linux/history.md b/linux/history.md index 6bdf641fcd..9f4cc15ccb 100644 --- a/linux/history.md +++ b/linux/history.md @@ -1,5 +1,17 @@ # Keyman for Linux Version History +## 2020-02-10 13.0.27 beta +* Bug Fix: + * Fix setting context when >= 64 characters (common #2607) + +## 2020-02-06 13.0.26 beta +* New Feature: + * Add ability to set keyboard options from options.htm dialog (#2566) + +## 2020-02-05 13.0.25 beta +* Bug Fix: + * Fix "About" keyboard when missing copyright info (#2583) + ## 2020-02-04 13.0.23 beta * Remove cosmic and disco releases. Add eoan (#2574) diff --git a/linux/ibus-keyman/src/engine.c b/linux/ibus-keyman/src/engine.c index 586a85a203..080c3edbd9 100644 --- a/linux/ibus-keyman/src/engine.c +++ b/linux/ibus-keyman/src/engine.c @@ -3,7 +3,7 @@ /* * Keyman Input Method for IBUS (The Input Bus) * - * Copyright (C) 2009-2018 SIL International + * Copyright (C) 2009-2020 SIL International * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public @@ -26,6 +26,7 @@ #include #include +#include #include #include "keymanutil.h" @@ -325,7 +326,14 @@ ibus_keyman_engine_constructor (GType type, } g_free(kmx_file); - km_kbp_option_item *keyboard_opts = g_new0(km_kbp_option_item, 4); + // Retrieve keyboard options from DConf + // TODO: May need unique packageID and keyboard ID + g_message("Loading options for kb_name: %s", keyman->kb_name); + GQueue *queue_options = keyman_get_options_queue_fromdconf(keyman->kb_name, keyman->kb_name); + int num_options = g_queue_get_length(queue_options); + + // Allocate enough options for: 3 environments plus num_options plus 1 pad struct of 0's + km_kbp_option_item *keyboard_opts = g_new0(km_kbp_option_item, KEYMAN_ENVIRONMENT_OPTIONS + num_options + 1); keyboard_opts[0].scope = KM_KBP_OPT_ENVIRONMENT; km_kbp_cp *cp = g_utf8_to_utf16 ("platform", -1, NULL, NULL, NULL); @@ -371,7 +379,13 @@ ibus_keyman_engine_constructor (GType type, // g_free(lang); keyboard_opts[2].value = cp; - // keyboard_opts[3] already initialised to {0, 0, 0} + // If queue_options contains keyboard options, pop them into keyboard_opts[3] onward + for(int i=0; ikeyboard)); g_free(abs_kmx_path); @@ -388,10 +402,11 @@ ibus_keyman_engine_constructor (GType type, { g_warning("problem creating km_kbp_state"); } - for (int i =0; i < 3; i++) { + for (int i=0; i < KEYMAN_ENVIRONMENT_OPTIONS + num_options + 1; i++) { g_free((km_kbp_cp *)keyboard_opts[i].key); g_free((km_kbp_cp *)keyboard_opts[i].value); } + g_queue_free_full(queue_options, NULL); g_free(keyboard_opts); reset_context(engine); @@ -793,6 +808,29 @@ ibus_keyman_engine_process_key_event (IBusEngine *engine, break; case KM_KBP_IT_PERSIST_OPT: g_message("PERSIST_OPT action %d/%d", i+1, (int)num_action_items); + // Save keyboard option + if (action_items[i].option != NULL) + { + // Allocate for 1 option plus 1 pad struct of 0's + km_kbp_option_item *keyboard_opts = g_new0(km_kbp_option_item, 2); + memmove(&(keyboard_opts[0]), action_items[i].option, sizeof(km_kbp_option_item)); + event_status = km_kbp_state_options_update(keyman->state, keyboard_opts); + if (event_status != KM_KBP_STATUS_OK) + { + g_warning("problem saving option for km_kbp_keyboard"); + } + g_free(keyboard_opts); + + // Put the keyboard option into DConf + if (action_items[i].option != NULL && action_items[i].option->key != NULL && + action_items[i].option->value != NULL) + { + g_message("Saving keyboard option to DConf"); + // Load the current keyboard options from DConf + keyman_put_options_todconf(keyman->kb_name, keyman->kb_name, + action_items[i].option->key, action_items[i].option->value); + } + } break; case KM_KBP_IT_EMIT_KEYSTROKE: if (keyman->char_buffer != NULL) @@ -976,3 +1014,4 @@ ibus_keyman_engine_property_activate (IBusEngine *engine, parent_class->property_activate (engine, prop_name, prop_state); } + diff --git a/linux/ibus-keyman/src/keymanutil.c b/linux/ibus-keyman/src/keymanutil.c index 9f2734bc3d..75a7344fed 100644 --- a/linux/ibus-keyman/src/keymanutil.c +++ b/linux/ibus-keyman/src/keymanutil.c @@ -55,7 +55,7 @@ #include #include #include - +#include #include "keymanutil.h" #include "kmpdetails.h" @@ -300,6 +300,154 @@ ibus_keyman_get_component (void) return component; } + +// Obtain Keyboard Options from DConf and parse into a GQueue of struct km_kbp_option_item +// DConf options are in a list of strings like ['option_key1=value1', 'option_key2=value2'] +// +// Parameters: +// package_id (gchar *): Package ID +// keyboard_id (gchar *): Keyboard ID +// +// Returns a newly allocated gchar**; free with g_strfreev() +gchar** +keyman_get_options_fromdconf(gchar *package_id, + gchar *keyboard_id) +{ + g_message("keyman_get_options_fromdconf"); + + // Obtain keyboard options from DConf + gchar *path = g_strdup_printf("%s%s/%s/", KEYMAN_DCONF_PATH, package_id, keyboard_id); + GSettings *child_settings = g_settings_new_with_path(KEYMAN_CHILD_DCONF_NAME, path); + gchar **options = NULL; + if (child_settings != NULL) + { + options = g_settings_get_strv(child_settings, KEYMAN_DCONF_OPTIONS_KEY); + } + + g_object_unref(G_OBJECT(child_settings)); + g_free(path); + + return options; +} + +// Obtain Keyboard Options from DConf and parse into a GQueue of struct km_kbp_option_item +// DConf options are in a list of strings like ['option_key1=value1', 'option_key2=value2'] +// +// Parameters: +// package_id (gchar *): Package ID +// keyboard_id (gchar *): Keyboard ID +// +// Return a newly allocated GQueue; free with g_queue_free_full() +GQueue* +keyman_get_options_queue_fromdconf(gchar *package_id, + gchar *keyboard_id) +{ + g_message("keyman_get_options_queue_fromdconf"); + GQueue *queue_options = g_queue_new(); + + // Obtain keyboard options from DConf + gchar **options = keyman_get_options_fromdconf(package_id, keyboard_id); + + // Parse options into queue_options + if (*options != NULL) + { + int index = 0; + while (options[index] != NULL) + { + gchar **option_tokens = g_strsplit(options[index], "=", 2); + if (option_tokens != NULL && option_tokens[0] != NULL && option_tokens[1] != NULL) + { + g_message("Keyboard Option [%d], %s=%s", index, option_tokens[0], option_tokens[1]); + km_kbp_option_item *opt = g_new0(km_kbp_option_item, 1); + opt[0].scope = KM_KBP_OPT_KEYBOARD; + km_kbp_cp *ocp = g_utf8_to_utf16(option_tokens[0], -1, NULL, NULL, NULL); + opt[0].key = ocp; + ocp = g_utf8_to_utf16 (option_tokens[1], -1, NULL, NULL, NULL); + opt[0].value = ocp; + g_queue_push_tail(queue_options, opt); + } + index++; + } + } + + return queue_options; +} + +// Write new keyboard option to DConf. +// DConf options are in a list of strings like ['option_key1=value1', 'option_key2=value2'] +// If the option key already exists, the value is updated. Otherwise a new string 'option_key=option_value' is appended. +// +// Parameters: +// package_id (gchar *): Package ID +// keyboard_id (gchar *): Keyboard ID +// option_key (gchar *): Key for the new option +// option_value (gchar *): Value of the new option +void +keyman_put_options_todconf(gchar *package_id, + gchar *keyboard_id, + gchar *option_key, + gchar *option_value) +{ + g_message("keyman_put_options_todconf"); + if (package_id == NULL || keyboard_id == NULL || option_key == NULL || option_value == NULL) + { + return; + } + + // Obtain keyboard options from DConf + gchar **options = keyman_get_options_fromdconf(package_id, keyboard_id); + gchar *needle = g_strdup_printf("%s=", option_key); + gchar *kvp = g_strdup_printf("%s=%s", option_key, option_value); + + if (*options != NULL) + { + int index = 0; + gboolean option_updated = FALSE; + while (options[index] != NULL) + { + // If option_key already exists, update value with option_value + if (g_strrstr(options[index], needle) != NULL) + { + options[index] = kvp; + option_updated = TRUE; + break; + } + index++; + } + + if (!option_updated) + { + // Resize to add new option and null-terminate + options = g_realloc(options, strlen(kvp) + 1); + options[index] = kvp; + options[index+1] = NULL; + } + } + else + { + // If options don't exist, allocate space for new option and null-terminate + options = g_malloc(strlen(kvp) + 1); + options[0] = kvp; + options[1] = NULL; + } + + // Write to DConf + gchar *path = g_strdup_printf("%s%s/%s/", KEYMAN_DCONF_PATH, package_id, keyboard_id); + GSettings *child_settings = g_settings_new_with_path(KEYMAN_CHILD_DCONF_NAME, path); + if (child_settings != NULL) + { + g_message("writing keyboard options to DConf"); + g_settings_set_strv(child_settings, KEYMAN_DCONF_OPTIONS_KEY, options); + } + + g_object_unref(G_OBJECT(child_settings)); + g_free(path); + g_free(kvp); + g_free(needle); + g_free(options); +} + + #ifdef DEBUG #include diff --git a/linux/ibus-keyman/src/keymanutil.h b/linux/ibus-keyman/src/keymanutil.h index d2e32faa54..9f95a9fecd 100644 --- a/linux/ibus-keyman/src/keymanutil.h +++ b/linux/ibus-keyman/src/keymanutil.h @@ -3,7 +3,7 @@ /* * Keyman Input Method for IBUS (The Input Bus) * - * Copyright (C) 2018 SIL International + * Copyright (C) 2018, 2020 SIL International * * kmflutil is dual licensed under the MIT or GPL licenses as described below. * @@ -53,9 +53,60 @@ #define __KEYMANUTIL_H__ #include +#include + #include +// Number of default Keyboard processor environment options for: "platform", "baseLayout", and "baseLayoutAlt" +#define KEYMAN_ENVIRONMENT_OPTIONS 3 + +// Path information for Keyman keyboard options in DConf +#define KEYMAN_DCONF_NAME "com.keyman.options" +#define KEYMAN_CHILD_DCONF_NAME "com.keyman.options.child" +#define KEYMAN_DCONF_PATH "/desktop/ibus/keyman/options/" +#define KEYMAN_DCONF_OPTIONS_KEY "options" + void ibus_keyman_init (void); GList *ibus_keyman_list_engines (void); IBusComponent *ibus_keyman_get_component (void); + +// Obtain Keyboard Options from DConf and parse into a GQueue of struct km_kbp_option_item +// DConf options are in a list of strings like ['option_key1=value1', 'option_key2=value2'] +// +// Parameters: +// package_id (gchar *): Package ID +// keyboard_id (gchar *): Keyboard ID +// +// Returns a newly allocated gchar**; free with g_strfreev() +gchar** keyman_get_options_fromdconf + (gchar *package_id, + gchar *keyboard_id); + +// Obtain Keyboard Options from DConf and parse into a GQueue of struct km_kbp_option_item +// DConf options are in a list of strings like ['option_key1=value1', 'option_key2=value2'] +// +// Parameters: +// package_id (gchar *): Package ID +// keyboard_id (gchar *): Keyboard ID +// +// Return a newly allocated GQueue; free with g_queue_free_full() +GQueue* keyman_get_options_queue_fromdconf + (gchar *package_id, + gchar *keyboard_id); + +// Write new keyboard option to DConf. +// DConf options are in a list of strings like ['option_key1=value1', 'option_key2=value2'] +// If the option key already exists, the value is updated. Otherwise a new string 'option_key=option_value' is appended. +// +// Parameters: +// package_id (gchar *): Package ID +// keyboard_id (gchar *): Keyboard ID +// option_key (gchar *): Key for the new option +// option_value (gchar *): Value of the new option +void keyman_put_options_todconf + (gchar *package_id, + gchar *keyboard_id, + gchar *option_key, + gchar *option_value); + #endif diff --git a/linux/keyman-config/Makefile b/linux/keyman-config/Makefile index c0efaab064..7ee8dedd78 100644 --- a/linux/keyman-config/Makefile +++ b/linux/keyman-config/Makefile @@ -16,7 +16,7 @@ install-temp: mkdir -p /tmp/kmfl/`python3 -c 'import sys;import os;pythonver="python%d.%d" % (sys.version_info[0], sys.version_info[1]);sitedir = os.path.join("lib", pythonver, "site-packages");print(sitedir)'` PYTHONUSERBASE=/tmp/kmfl python3 setup.py install --user -uninstall: # run as sudo +uninstall: clean # run as sudo rm -rf /usr/local/share/keyman/icons rm -f /usr/local/share/man/man1/km-*.1 pip3 uninstall keyman_config diff --git a/linux/keyman-config/README.md b/linux/keyman-config/README.md index d1283bfe4a..beac7e7012 100644 --- a/linux/keyman-config/README.md +++ b/linux/keyman-config/README.md @@ -6,13 +6,20 @@ If you are running from the repo or installing keyman-config manually rather tha then you will need to `sudo apt install python3-lxml python3-magic python3-numpy python3-qrcode python3-pil python3-requests -python3-requests-cache python3 python3-gi gir1.2-webkit-3.0 dconf-cli python3-setuptools` +python3-requests-cache python3 python3-gi gir1.2-webkit-2.4 dconf-cli python3-setuptools` You will also need kmflcomp either from a package or built and installed locally. -run the script `./createkeymandirs.sh` to create the directories for these programs to +Run the script `./createkeymandirs.sh` to create the directories for these programs to install the packages to +Also copy and compile the GSettings schema +```bash +cd keyman_config +sudo cp com.keyman.gschema.xml /usr/share/glib-2.0/schemas +sudo glib-compile-schemas /usr/share/glib-2.0/schemas +``` + ### Installing manually from the repo `make && sudo make install` will install locally to /usr/local @@ -32,6 +39,7 @@ This displays a configuration panel that shows the currently installed Keyman ke * `Uninstall` - uninstall selected keyboard * `About` - show information about selected keyboard * `Help` - display help documentation about selected keyboard +* `Options` - display options.htm form for setting keyboard options ----------------------------------- * `Refresh` - useful if you install or uninstall on the commandline while running km-config. @@ -43,12 +51,9 @@ This displays a configuration panel that shows the currently installed Keyman ke This uses the keyman.com website to install kmps. -The website doesn't know about linux yet (until after 10.0 release) so -pretending to be a mac for now. - -Search for a language or keyboard in the search box -Select a keyboard from the list -In 'Downloads for your device' there will be a 'Install keyboard' button for the keyboard for macOS +Search for a language or keyboard in the search box. +Select a keyboard from the list. +In 'Downloads for your device' there will be an 'Install keyboard' button for the keyboard for Linux. Click it to download the keyboard and bring up the `InstallKmpWindow` for more details and to confirm installing. Secondary-click gives you a menu including 'Back' to go back a page. diff --git a/linux/keyman-config/com.keyman.gschema.xml b/linux/keyman-config/com.keyman.gschema.xml new file mode 100644 index 0000000000..8f05a42bf9 --- /dev/null +++ b/linux/keyman-config/com.keyman.gschema.xml @@ -0,0 +1,15 @@ + + + + + + + + + + [] + keyboard options.htm settings + List of strings + + + diff --git a/linux/keyman-config/debian/control b/linux/keyman-config/debian/control index 202f601990..5d49d99979 100644 --- a/linux/keyman-config/debian/control +++ b/linux/keyman-config/debian/control @@ -2,8 +2,10 @@ Source: keyman-config Maintainer: Daniel Glassey Section: python Priority: optional -Build-Depends: dh-python, python3-setuptools, python3-all, debhelper (>= 9), bash-completion -Standards-Version: 3.9.7 +Build-Depends: dh-python, python3-setuptools, python3-all, debhelper (>= 9), bash-completion, + python3-magic, python3-qrcode , + python-qrcode +Standards-Version: 4.5.0 Homepage: http://www.keyman.com/ Package: python3-keyman-config diff --git a/linux/keyman-config/keyman_config/dconf_util.py b/linux/keyman-config/keyman_config/dconf_util.py new file mode 100644 index 0000000000..116551d1e0 --- /dev/null +++ b/linux/keyman-config/keyman_config/dconf_util.py @@ -0,0 +1,73 @@ +#!/usr/bin/python3 + +import logging +from gi.repository import Gio, Gtk + +# DConf path destkop/ibus/keyman/options +DCONF_BASE = "com.keyman.options" + +# Utilities to get and set Keyman options in DConf: +# /desktop/ibus/keyman/options/packageID/keyboardID/options + + +def get_child_schema(info): + settings = Gio.Settings.new(DCONF_BASE) + path = settings.get_property('path') + if not path.endswith('/'): + path += '/' + path += info['packageID'] + '/' + info['keyboardID'] + '/' + return Gio.Settings(DCONF_BASE + '.child', path) + + +def get_option(info): + """ + Get the Keyman keyboard options from DConf + Convert from list of comma-separated strings into dictionary + + Args: + info: dictionary + packageID (str): Package ID + keyboardID(str): Keyboard ID + + Returns: + result (dictionary): Keyboard options + """ + result = {} + if "packageID" in info and "keyboardID" in info: + child_schema = get_child_schema(info) + list_options = child_schema.get_strv("options") + result = dict(option.split("=") for option in list_options) + return result + + +def set_option(info, options): + """ + Store the Keyman keyboard options in DConf as a list of strings + + Args: + info: dictionary + packageID (str): package ID + keyboardID (str): keyboard ID + options: dictionary + key and values to store + """ + if "packageID" in info and "keyboardID" in info and options: + # Convert dictionary of options into a list of comma-separated option strings + list_options = [] + for key, value in options.items(): + list_options.append(key+"="+value) + + child_schema = get_child_schema(info) + child_schema.set_strv("options", list_options) + + +if __name__ == '__main__': + info = {"packageID": "invalid", "keyboardID": "invalid"} + options = get_option(info) + assert(options == {}) + + info = {"packageID": "sil_cipher_music", "keyboardID": "sil_cipher_music"} + options = get_option(info) + + options["set_nfc"] = "1" + set_option(info, options) diff --git a/linux/keyman-config/keyman_config/keyboard_details.py b/linux/keyman-config/keyman_config/keyboard_details.py index 9e16cffcba..f7af4adf48 100644 --- a/linux/keyman-config/keyman_config/keyboard_details.py +++ b/linux/keyman-config/keyman_config/keyboard_details.py @@ -122,16 +122,17 @@ class KeyboardDetailsView(Gtk.Window): label.set_selectable(True) grid.attach_next_to(label, lbl_pkg_auth, Gtk.PositionType.RIGHT, 1, 1) - lbl_pkg_cpy = Gtk.Label() - lbl_pkg_cpy.set_text("Package copyright: ") - lbl_pkg_cpy.set_halign(Gtk.Align.END) - grid.attach_next_to(lbl_pkg_cpy, prevlabel, Gtk.PositionType.BOTTOM, 1, 1) - prevlabel = lbl_pkg_cpy - label = Gtk.Label() - label.set_text(info['copyright']['description']) - label.set_halign(Gtk.Align.START) - label.set_selectable(True) - grid.attach_next_to(label, lbl_pkg_cpy, Gtk.PositionType.RIGHT, 1, 1) + if "copyright" in info: + lbl_pkg_cpy = Gtk.Label() + lbl_pkg_cpy.set_text("Package copyright: ") + lbl_pkg_cpy.set_halign(Gtk.Align.END) + grid.attach_next_to(lbl_pkg_cpy, prevlabel, Gtk.PositionType.BOTTOM, 1, 1) + prevlabel = lbl_pkg_cpy + label = Gtk.Label() + label.set_text(info['copyright']['description']) + label.set_halign(Gtk.Align.START) + label.set_selectable(True) + grid.attach_next_to(label, lbl_pkg_cpy, Gtk.PositionType.RIGHT, 1, 1) # Padding and full width horizontal divider lbl_pad = Gtk.Label() diff --git a/linux/keyman-config/keyman_config/list_installed_kmp.py b/linux/keyman-config/keyman_config/list_installed_kmp.py index a024afa738..dd0cb1aa9e 100755 --- a/linux/keyman-config/keyman_config/list_installed_kmp.py +++ b/linux/keyman-config/keyman_config/list_installed_kmp.py @@ -15,6 +15,32 @@ class InstallArea(GObject.GEnum): IA_USER = 3 IA_UNKNOWN = 99 + +def get_install_area_path(area): + """ + Get the path of an install area. + + Args: + area (InstallArea): install area to check + InstallArea.IA_USER: ~/.local/share/keyman + InstallArea.IA_SHARED: /usr/local/share/keyman + InstallArea.IA_OS: /usr/share/keyman + InstallArea.IA_UNKNOWN: /usr/share/keyman + + Returns: + string: path of the install area + """ + check_path = "/usr/share/keyman" + if area == InstallArea.IA_USER: + check_path = user_keyman_dir() + elif area == InstallArea.IA_SHARED: + check_path = "/usr/local/share/keyman" + elif area == InstallArea.IA_OS: + check_path = "/usr/share/keyman" + + return check_path + + def get_installed_kmp(area): """ Get list of installed kmp in an install area. @@ -35,13 +61,7 @@ def get_installed_kmp(area): path (str): base path where keyboard is installed description (str): Keyboard description """ - check_paths = [] - if area == InstallArea.IA_USER: - check_paths = [ user_keyman_dir() ] - elif area == InstallArea.IA_SHARED: - check_paths = [ "/usr/local/share/keyman" ] - elif area == InstallArea.IA_OS: - check_paths = [ "/usr/share/keyman" ] + check_paths = [ get_install_area_path(area) ] return get_installed_kmp_paths(check_paths) @@ -57,12 +77,15 @@ def get_installed_kmp_paths(check_paths): list: Installed kmp dict: Keyboard packageID (str): kmp ID + keyboardID (str): Keyboard ID name (str): Keyboard name kmpname (str): Keyboard name in local version (str): Keyboard version kmpversion (str): - path (str): base path of area where kmp is installed + areapath (str): base path of area where kmp is installed description (str): Keyboard description + has_kbjson (bool): Keyboard is in the json file + has_kboptions (bool): Keyboard uses options.htm form """ installed_keyboards = {} for keymanpath in check_paths: @@ -96,7 +119,15 @@ def get_installed_kmp_paths(check_paths): version = md_version name = md_name - installed_keyboards[o] = { "packageID" : o, "keyboardID" : keyboardID, "name" : name, "kmpname" : md_name, "version" : version, "kmpversion" : md_version, "areapath" : keymanpath, "description" : description, "has_kbjson" : has_kbjson} + has_kboptions = False + if files: + for fileinfo in files: + if fileinfo['name'] == "options.htm": + has_kboptions = True + break + installed_keyboards[o] = {"packageID" : o, "keyboardID" : keyboardID, "name" : name, "kmpname" : md_name, + "version" : version, "kmpversion" : md_version, "areapath" : keymanpath, + "description" : description, "has_kbjson" : has_kbjson, "has_kboptions" : has_kboptions} return installed_keyboards @@ -117,7 +148,7 @@ def get_kmp_version(packageID): os_kmp = get_installed_kmp(InstallArea.IA_OS) if packageID in os_kmp: - version = os_kmp[keyboardid]['version'] + version = os_kmp[packageID]['version'] if packageID in shared_kmp: shared_version = shared_kmp[packageID]['version'] @@ -148,7 +179,7 @@ def get_kmp_version_user(packageID): str: kmp version if kmp ID is installed None: if not found """ - user_kmp = get_installed_kmp_user() + user_kmp = get_installed_kmp(InstallArea.IA_USER) if packageID in user_kmp: return user_kmp[packageID]['version'] else: diff --git a/linux/keyman-config/keyman_config/options.py b/linux/keyman-config/keyman_config/options.py new file mode 100644 index 0000000000..a2273a195a --- /dev/null +++ b/linux/keyman-config/keyman_config/options.py @@ -0,0 +1,86 @@ +#!/usr/bin/python3 + +import ast +import gi +import logging +import webbrowser +import urllib.parse +gi.require_version('Gtk', '3.0') +gi.require_version('WebKit2', '4.0') +from gi.repository import Gtk, WebKit2 +from urllib.parse import parse_qsl, urlencode +from keyman_config.accelerators import bind_accelerator, init_accel +from keyman_config.dconf_util import get_option, set_option + + +class OptionsView(Gtk.Window): + + def __init__(self, info): + self.accelerators = None + self.optionurl = info["optionurl"] + self.packageID = info["packageID"] + self.keyboardID = info["keyboardID"] + kbtitle = self.packageID + " Settings" + Gtk.Window.__init__(self, title=kbtitle) + init_accel(self) + + vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + + # Keyman Desktop gets current option settings from the registry. + # Similarly, we'll read Keyman options from DConf and update optionurl + info = {"packageID": self.packageID, "keyboardID": self.keyboardID} + self.options = get_option(info) + params = "" + + if self.options: + # Convert dictionary to query + params = "?" + urlencode(self.options) + + s = Gtk.ScrolledWindow() + self.webview = WebKit2.WebView() + self.webview.connect("decide-policy", self.doc_policy) + self.webview.load_uri(self.optionurl + params) + s.add(self.webview) + vbox.pack_start(s, True, True, 0) + + hbox = Gtk.Box(spacing=12) + vbox.pack_start(hbox, False, False, 6) + + self.add(vbox) + + def doc_policy(self, web_view, decision, decision_type): + logging.info("Checking policy") + logging.debug("received policy decision request of type: {0}".format(decision_type.value_name)) + if decision_type == WebKit2.PolicyDecisionType.NAVIGATION_ACTION or \ + decision_type == WebKit2.PolicyDecisionType.NEW_WINDOW_ACTION: + nav_action = decision.get_navigation_action() + request = nav_action.get_request() + uri = request.get_uri() + logging.debug("nav request is for uri %s", uri) + parsed = urllib.parse.urlparse(uri) + if parsed.scheme == "keyman": + logging.debug("using keyman scheme") + if parsed.path == "cancel": + pass + elif parsed.path == "ok": + logging.debug("submit options form") + # Parse the response for the option_key value and merge the updates + if parsed.query: + updated_options = parse_qsl(parsed.query, keep_blank_values=True) + self.options.update(updated_options) + self.process_option() + + self.close() + return True + + if not "options.htm" in uri: + logging.debug("opening uri %s in webbrowser") + webbrowser.open(uri) + decision.ignore() + return True + return False + + def process_option(self): + # Write the Keyman options to DConf + info = { "packageID": self.packageID, "keyboardID": self.keyboardID} + set_option(info, self.options) diff --git a/linux/keyman-config/keyman_config/view_installed.py b/linux/keyman-config/keyman_config/view_installed.py index dc78e74916..e1eb7f5d87 100755 --- a/linux/keyman-config/keyman_config/view_installed.py +++ b/linux/keyman-config/keyman_config/view_installed.py @@ -9,8 +9,9 @@ gi.require_version('Gtk', '3.0') gi.require_version('Gdk', '3.0') from gi.repository import Gtk, Gdk, GdkPixbuf, GObject -from keyman_config.list_installed_kmp import get_installed_kmp, InstallArea +from keyman_config.list_installed_kmp import get_install_area_path, get_installed_kmp, InstallArea from keyman_config.welcome import WelcomeView +from keyman_config.options import OptionsView from keyman_config.keyboard_details import KeyboardDetailsView from keyman_config.downloadkeyboard import DownloadKmpWindow from keyman_config.install_window import InstallKmpWindow, find_keyman_image @@ -68,7 +69,7 @@ class ViewInstalledWindow(ViewInstalledWindowBase): # window is split left/right hbox # right is ButtonBox # possibly 2 ButtonBox in a vbox -# top one with _Remove, _About, ?_Welcome? or ?Read_Me? +# top one with _Remove, _About, ?_Welcome? or ?Read_Me?, _Options # bottom one with _Download, _Install, Re_fresh, _Close # left is GtkTreeView - does it need to be inside anything else apart from the hbox? # with liststore which defines columns @@ -91,7 +92,8 @@ class ViewInstalledWindow(ViewInstalledWindowBase): str, # version str, # packageID int, # enum InstallArea (KmpArea is GObject version) - str) # path to welcome file if it exists or None + str, # path to welcome file if it exists or None + str) # path to options file if it exists or None # add installed keyboards to the the store e.g. # treeiter = store.append([GdkPixbuf.Pixbuf.new_from_file_at_size("/usr/local/share/keyman/libtralo/libtralo.ico.png", 16, 16), \ @@ -136,6 +138,12 @@ class ViewInstalledWindow(ViewInstalledWindowBase): self.help_button.connect("clicked", self.on_help_clicked) bbox_top.add(self.help_button) + self.options_button = Gtk.Button.new_with_mnemonic("_Options") + self.options_button.set_tooltip_text("Settings for keyboard") + self.options_button.connect("clicked", self.on_options_clicked) + self.options_button.set_sensitive(False) + bbox_top.add(self.options_button) + vbox.pack_start(bbox_top, False, False, 12) @@ -176,16 +184,19 @@ class ViewInstalledWindow(ViewInstalledWindowBase): if install_area == InstallArea.IA_USER: welcome_file = os.path.join(user_keyboard_dir(kmpdata['packageID']), "welcome.htm") + options_file = os.path.join(user_keyboard_dir(kmpdata['packageID']), "options.htm") icofile = os.path.join(user_keyboard_dir(kmpdata['packageID']), kmpdata['packageID'] + bmppng) if not os.path.isfile(icofile): icofile = os.path.join(user_keyboard_dir(kmpdata['packageID']), kmpdata['keyboardID'] + bmppng) elif install_area == InstallArea.IA_SHARED: welcome_file = os.path.join("/usr/local/share/keyman", kmpdata['packageID'], "welcome.htm") + options_file = os.path.join("/usr/local/share/keyman", kmpdata['packageID'], "options.htm") icofile = os.path.join("/usr/local/share/keyman", kmpdata['packageID'], kmpdata['packageID'] + bmppng) if not os.path.isfile(icofile): icofile = os.path.join("/usr/local/share/keyman", kmpdata['packageID'], kmpdata['keyboardID'] + bmppng) else: welcome_file = os.path.join("/usr/share/keyman", kmpdata['packageID'], "welcome.htm") + options_file = os.path.join("/usr/share/keyman", kmpdata['packageID'], "options.htm") icofile = os.path.join("/usr/share/keyman", kmpdata['packageID'], kmpdata['packageID'] + bmppng) if not os.path.isfile(icofile): icofile = os.path.join("/usr/share/keyman", kmpdata['packageID'], kmpdata['keyboardID'] + bmppng) @@ -194,13 +205,15 @@ class ViewInstalledWindow(ViewInstalledWindowBase): if not os.path.isfile(welcome_file): welcome_file = None - + if not os.path.isfile(options_file): + options_file = None treeiter = store.append([GdkPixbuf.Pixbuf.new_from_file_at_size(icofile, 16, 16), \ kmpdata['name'], \ kmpdata['version'], \ kmpdata['packageID'], \ install_area, \ - welcome_file]) + welcome_file, \ + options_file]) def refresh_installed_kmp(self): logging.debug("Refreshing listview") @@ -225,13 +238,13 @@ class ViewInstalledWindow(ViewInstalledWindowBase): self.incomplete_kmp.append(kmpdata) self.addlistitems(os_kmp, self.store, InstallArea.IA_OS) - def on_tree_selection_changed(self, selection): model, treeiter = selection.get_selected() if treeiter is not None: self.uninstall_button.set_tooltip_text("Uninstall keyboard package " + model[treeiter][1]) self.help_button.set_tooltip_text("Help for keyboard package " + model[treeiter][1]) self.about_button.set_tooltip_text("About keyboard package " + model[treeiter][1]) + self.options_button.set_tooltip_text("Settings for keyboard package " + model[treeiter][1]) logging.debug("You selected %s version %s", model[treeiter][1], model[treeiter][2]) if model[treeiter][4] == InstallArea.IA_USER: logging.debug("Enabling uninstall button for %s in %s", model[treeiter][3], model[treeiter][4]) @@ -239,10 +252,16 @@ class ViewInstalledWindow(ViewInstalledWindowBase): else: self.uninstall_button.set_sensitive(False) logging.debug("Disabling uninstall button for %s in %s ", model[treeiter][3], model[treeiter][4]) + # welcome file if it exists if model[treeiter][5]: self.help_button.set_sensitive(True) else: self.help_button.set_sensitive(False) + # options file if it exists + if model[treeiter][6]: + self.options_button.set_sensitive(True) + else: + self.options_button.set_sensitive(False) def on_help_clicked(self, button): model, treeiter = self.tree.get_selection().get_selected() @@ -258,6 +277,22 @@ class ViewInstalledWindow(ViewInstalledWindowBase): else: logging.info("welcome.htm not available") + def on_options_clicked(self, button): + model, treeiter = self.tree.get_selection().get_selected() + if treeiter is not None: + logging.info("Open options.htm for %s if available", model[treeiter][1]) + options_file = model[treeiter][6] + if options_file and os.path.isfile(options_file): + uri_path = pathlib.Path(options_file).as_uri() + logging.info("opening" + uri_path) + # TODO: Determine keyboardID + info = { "optionurl": uri_path, "packageID": model[treeiter][3], "keyboardID": model[treeiter][3] } + w = OptionsView(info) + w.resize(800, 600) + w.show_all() + else: + logging.info("options.htm not available") + def on_uninstall_clicked(self, button): model, treeiter = self.tree.get_selection().get_selected() if treeiter is not None: @@ -281,12 +316,7 @@ class ViewInstalledWindow(ViewInstalledWindowBase): model, treeiter = self.tree.get_selection().get_selected() if treeiter is not None: logging.info("Show keyboard details of " + model[treeiter][1]) - if model[treeiter][4] == InstallArea.IA_USER: - areapath = user_keyman_dir() - elif model[treeiter][4] == InstallArea.IA_SHARED: - areapath = "/usr/local/share/keyman" - else: - areapath = "/usr/share/keyman" + areapath = get_install_area_path(model[treeiter][4]) kmp = { "name" : model[treeiter][1], "version" : model[treeiter][2], "packageID" : model[treeiter][3], "areapath" : areapath} w = KeyboardDetailsView(kmp) w.resize(800, 450) diff --git a/linux/scripts/install.sh b/linux/scripts/install.sh index 2f8f3346a0..d9db4f4ec0 100755 --- a/linux/scripts/install.sh +++ b/linux/scripts/install.sh @@ -78,6 +78,9 @@ if [[ "${SUDOINSTALL}" == "yes" ]]; then echo "keyman-config must be built before it is installed. Run 'make configure' if needed then 'make'" exit 1 fi + echo "doing sudo glib-compile-schemas for keyman-config" + cp com.keyman.gschema.xml /usr/share/glib-2.0/schemas/ + glib-compile-schemas /usr/share/glib-2.0/schemas/ echo "doing sudo install of keyman-config" make install elif [[ "${SUDOINSTALL}" == "uninstall" ]]; then diff --git a/linux/scripts/jenkins.sh b/linux/scripts/jenkins.sh index d52a0e029e..2d2f5685fd 100755 --- a/linux/scripts/jenkins.sh +++ b/linux/scripts/jenkins.sh @@ -1,6 +1,8 @@ #!/bin/bash -e # $1 - project name with appended tier, e.g. ibus-kmfl-alpha +PROGRAM_NAME="$(basename "$0")" + . $HOME/ci-builder-scripts/bash/common.sh init --no-package @@ -34,6 +36,36 @@ fi sourcename=${fullsourcename%"-alpha"} sourcename=${sourcename%"-beta"} +checkAndInstallRequirements() +{ + local TOINSTALL + + for p in dh-python gir1.2-webkit2-4.0 python3-all python3-setuptools \ + python3-requests python3-requests-cache python3-numpy python3-pil python3-lxml \ + python3-gi python3-magic python3-qrcode + do + if ! dpkg -l | grep -q $p; then + TOINSTALL="$TOINSTALL $p" + fi + done + + if [ ! -f /usr/bin/help2man ]; then + TOINSTALL="$TOINSTALL help2man" + fi + + if [ ! -f /usr/bin/meson ]; then + TOINSTALL="$TOINSTALL meson" + fi + + if [ -n "$TOINSTALL" ]; then + log "Installing prerequisites:$TOINSTALL" + sudo apt-get update + sudo apt-get -qy install $TOINSTALL + fi +} + +checkAndInstallRequirements + # clean up prev deb builds log "cleaning previous builds of $1" diff --git a/resources/build/build-utils.sh b/resources/build/build-utils.sh new file mode 100644 index 0000000000..596055a265 --- /dev/null +++ b/resources/build/build-utils.sh @@ -0,0 +1,139 @@ +#!/bin/bash +# +# This script sets build environment variables: +# VERSION: Full current build version, e.g. "14.0.1" +# VERSION_WIN: Full current build version for Windows, e.g. "14.0.1.0" +# VERSION_RELEASE: Current release version, e.g. "14.0" +# VERSION_MAJOR: Major version, e.g. "14" +# VERSION_MINOR: Minor version, e.g. "0" +# VERSION_PATCH: Patch version, e.g. "1" +# TIER: Current tier, one of "alpha", "beta" or "stable" +# VERSION_TAG: Tier + Pull Request + Location of build [-alpha|-beta][-test[-1234]][-local] +# VERSION_WITH_TAG: e.g. "14.0.1-alpha-test-1234" or "14.0.5-beta-local" or "14.0.1-alpha-test" +# KEYMAN_ROOT: fully resolved root path of Keyman repository +# +# On macOS, this script requires coreutils (`brew install coreutils`) +# +# Here is how to include this script reliably, cross-platform: +# ## START STANDARD BUILD SCRIPT INCLUDE +# # adjust relative paths as necessary +# THIS_SCRIPT="$(greadlink -f "${BASH_SOURCE[0]}" 2>/dev/null || readlink -f "${BASH_SOURCE[0]}")" +# . "$(dirname "$THIS_SCRIPT")/../resources/build/build-utils.sh" +# # END STANDARD BUILD SCRIPT INCLUDE +# + +function die () { + # TODO: consolidate this with fail() from shellHelperFunctions.sh + echo + echo "$*" + echo + exit 1 +} + +function findRepositoryRoot() { + # See https://stackoverflow.com/questions/59895/how-to-get-the-source-directory-of-a-bash-script-from-within-the-script-itself + # None of the answers are 100% correct for cross-platform + # On macOS, requires coreutils (`brew install coreutils`) + + local SCRIPT=$(greadlink -f "${BASH_SOURCE[0]}" 2>/dev/null || readlink -f "${BASH_SOURCE[0]}") + KEYMAN_ROOT=$(dirname $(dirname $(dirname "$SCRIPT"))) + readonly KEYMAN_ROOT +} + +function findVersion() { + local VERSION_MD="$KEYMAN_ROOT/VERSION.md" + VERSION=`cat $VERSION_MD | tr -d "[:space:]"` + [[ "$VERSION" =~ ^([[:digit:]]+)\.([[:digit:]]+)\.([[:digit:]]+)$ ]] && { + VERSION_MAJOR="${BASH_REMATCH[1]}" + VERSION_MINOR="${BASH_REMATCH[2]}" + VERSION_PATCH="${BASH_REMATCH[3]}" + VERSION_RELEASE="$VERSION_MAJOR.$VERSION_MINOR" + } || { + echo "Invalid VERSION.md file: expected major.minor.patch"; + exit 1; + } + + # Used for Windows, which requires a four part version string + VERSION_WIN="$VERSION.0" + + # + # Build a tag to append to the version string. This is not assigned + # to the version number used in the projects but may be used as a + # display string and in TeamCity configuration + # + + if [ "$TIER" == "alpha" ] || [ "$TIER" == "beta" ]; then + VERSION_TAG="-$TIER" + else + VERSION_TAG= + fi + + if [ -z "${TEAMCITY_VERSION-}" ]; then + # Local dev machine, not TeamCity + VERSION_TAG="$VERSION_TAG-local" + else + # On TeamCity; are we running a pull request build or a master/beta/stable build? + if [ ! -z "${TEAMCITY_PR_NUMBER-}" ]; then + # Note TEAMCITY_PR_NUMBER can also be 'master', 'beta', or 'stable-x.y' + # This indicates we are running a Test build. + if [[ $TEAMCITY_PR_NUMBER =~ ^(master|beta|stable(-[0-9]+\.[0-9]+)?)$ ]]; then + VERSION_TAG="$VERSION_TAG-test" + else + VERSION_TAG="$VERSION_TAG-test-$TEAMCITY_PR_NUMBER" + fi + fi + fi + + VERSION_WITH_TAG="$VERSION$VERSION_TAG" + + readonly VERSION + readonly VERSION_MAJOR + readonly VERSION_MINOR + readonly VERSION_PATCH + readonly VERSION_RELEASE + readonly VERSION_WIN + readonly VERSION_TAG + readonly VERSION_WITH_TAG +} + +function findTier() { + local TIER_MD="$KEYMAN_ROOT/TIER.md" + TIER=`cat $TIER_MD | tr -d "[:space:]"` + [[ "$TIER" =~ ^(alpha|beta|stable)$ ]] || { + echo "Invalid TIER.md file: expected alpha, beta or stable." + exit 1; + } +} + +function printBuildNumberForTeamCity() { + if [ ! -z "${TEAMCITY_VERSION-}" ]; then + if [ ! -z "${TEAMCITY_PR_NUMBER-}" ]; then + echo "##teamcity[buildNumber '$VERSION_WITH_TAG']" + else + # For alpha/beta builds, for now we don't append the + # version tag as buildNumber is used in the delivery + # of the build version. We may improve this in the + # future. + echo "##teamcity[buildNumber '$VERSION']" + fi + fi +} + +function printVersionUtilsDebug() { + echo "KEYMAN_ROOT: $KEYMAN_ROOT" + echo "VERSION: $VERSION" + echo "VERSION_WIN: $VERSION_WIN" + echo "VERSION_RELEASE: $VERSION_RELEASE" + echo "VERSION_MAJOR: $VERSION_MAJOR" + echo "VERSION_MINOR: $VERSION_MINOR" + echo "VERSION_PATCH: $VERSION_PATCH" + echo "TIER: $TIER" + echo "VERSION_TAG: $VERSION_TAG" + echo "VERSION_WITH_TAG: $VERSION_WITH_TAG" +} + +findRepositoryRoot +findTier +findVersion +# printVersionUtilsDebug +printBuildNumberForTeamCity diff --git a/resources/build/run-required-test-builds.sh b/resources/build/run-required-test-builds.sh new file mode 100644 index 0000000000..c04f6d2764 --- /dev/null +++ b/resources/build/run-required-test-builds.sh @@ -0,0 +1,142 @@ +#!/bin/bash +# +# Determine if we need to do a build based on rules in +# trigger-definitions.sh, rather than calculating changes in +# TeamCity. If a build is needed, then we ask TeamCity to +# start the build. +# + +set -e +set -u + +if [[ $# -lt 1 ]]; then + echo 'Usage: run-required-test-build.sh pull-request-number|branch' + echo ' where branch can be master, beta' + exit 1 +else + PRNUM="$1" +fi + +# +# This must be run under a TeamCity environment in order to receive all variables +# + +## START STANDARD BUILD SCRIPT INCLUDE +# adjust relative paths as necessary +THIS_SCRIPT="$(greadlink -f "${BASH_SOURCE[0]}" 2>/dev/null || readlink -f "${BASH_SOURCE[0]}")" + +KEYMAN_ROOT=$(dirname $(dirname $(dirname "$THIS_SCRIPT"))) +readonly KEYMAN_ROOT + +# THIS DOESN'T WORK FOR 13.0 BETA +# but we don't need it +# . "$(dirname "$THIS_SCRIPT")/build-utils.sh" + +## END STANDARD BUILD SCRIPT INCLUDE + +. "$(dirname "$THIS_SCRIPT")/trigger-definitions.sh" +. "$(dirname "$THIS_SCRIPT")/trigger-builds.sh" + +# +# Iterate through the platforms 'array' passed in and +# run builds associated with each platform found +# + +function triggerTestBuilds() { + # Note: we always run builds for 'all' platforms + local platforms=( `echo "all $1"` ) + local branch="$2" + + for platform in "${platforms[@]}"; do + echo "# $platform: changes detected" + eval test_builds='(${'bc_test_$platform'[@]})' + for test_build in "${test_builds[@]}"; do + if [[ $test_build == "" ]]; then continue; fi + echo " -- Triggering build configuration $test_build" + triggerBuild "$test_build" "$vcs_test" "$branch" + done + done +} + +# +# Following is not an error; this script can run against master/beta/stable +# branches as well as pull requests +# + +if [[ ! "$PRNUM" =~ ^[[:digit:]]+$ ]]; then + # branch name is 'master', 'beta' [, or 'stable' -- in the future] + echo ". Branch $PRNUM needs to pass tests on all platforms." + triggerTestBuilds "`echo ${available_platforms[@]}`" "$PRNUM" + exit 0 +fi + +# +# Find the base and head of the pull request from GitHub (while we can +# just use the current branch name, this lets us test the script against +# different pull requests; and there is no way to know which base is +# targeted by the PR other than to ask GitHub, anyway.) +# + +echo ". Get information about pull request #$PRNUM from GitHub" +prinfo=`curl -s -H "User-Agent: @keymanapp" https://api.github.com/repos/keymanapp/keyman/pulls/$PRNUM` +prbase=`echo ${prinfo} | sed -E 's/.+"base".+"ref":"([^"]+)".+/\1/'` +prhead=`echo ${prinfo} | sed -E 's/.+"head".+"ref":"([^"]+)".+"base".+"ref":.+/\1/'` + +#debug: echo "Base of $PRNUM ($prhead) is $prbase" + +# +# By default, TeamCity does not fetch any remote branch information +# so let's grab the remote branch info before we try and run a diff +# + +# (Ensure we are within the repository before running git calls) +echo ". Ensure our local branch is up to date" +pushd "$KEYMAN_ROOT" > /dev/null +git fetch origin > /dev/null + +# +# Then get a list of changed files between BASE of the branch and the branch itself +# We work from origin so we don't need the branches in our local copy +# + +echo ". Get list of changed files in the pull request" +prfiles=`git diff "origin/$prbase"..."origin/$prhead" --name-only || ( if [ $? == 128 ]; then echo abort; else exit $?; fi )` +if [ "$prfiles" == "abort" ]; then + # Don't trigger any builds, exit with success + echo "Remote branch origin/$prhead has gone away; probably an automatic pull request. Skipping build." + exit 0 +fi + +popd > /dev/null + +# +# Find the platforms that have changes based on the watch_ variables in trigger-definitions.sh +# + +echo ". Find platforms that have changes" +build_platforms=() + +# Scan the files found +while IFS= read -r line; do + # for each platform + for platform in "${available_platforms[@]}"; do + if [[ ! " ${build_platforms[@]} " =~ " $platform " ]]; then + # Which platform are we watching? + eval watch='$'watch_$platform + # Add common patterns to the watch list + watch="^($platform|(oem/[^/]+/$platform)|resources|$watch)" + if [[ "$line" =~ $watch ]]; then + build_platforms+=($platform) + fi + fi + done +done <<< "$prfiles" + +# +# Start the test builds +# + +echo ". Start test builds" +triggerTestBuilds "`echo ${build_platforms[@]}`" "$PRNUM" + +exit 0 diff --git a/resources/build/trigger-builds.sh b/resources/build/trigger-builds.sh new file mode 100644 index 0000000000..56f0d702ae --- /dev/null +++ b/resources/build/trigger-builds.sh @@ -0,0 +1,50 @@ +#!/bin/bash + +# +# Tell TeamCity to trigger new builds +# + +function triggerBuilds() { + local base=`git branch --show-current` + if [[ $base == master ]]; then + local TEAMCITY_BUILDTYPES=( KeymanAndroid_Build Keyman_iOS_Master KeymanLinux_Master KeymanMac_Master Keyman_Build Keymanweb_Build ) + local TEAMCITY_VCS_ID=HttpsGithubComKeymanappKeyman + elif [[ $base == beta ]]; then + local TEAMCITY_BUILDTYPES=( KeymanAndroid_Beta Keyman_iOS_Beta KeymanLinux_Beta KeymanMac_Beta KeymanDesktop_Beta Keymanweb_Beta ) + local TEAMCITY_VCS_ID=Keyman_KeymanappKeymanBeta + else + exit 1 + fi + + for TEAMCITY_BUILDTYPE in "${TEAMCITY_BUILDTYPES[@]}"; do + triggerBuild $TEAMCITY_BUILDTYPE $TEAMCITY_VCS_ID + done +} + +function triggerBuild() { + local TEAMCITY_BUILDTYPE="$1" + local TEAMCITY_VCS_ID="$2" + + if [[ $# -gt 2 ]]; then + local TEAMCITY_BRANCH_NAME="$3" + #debug echo " Triggering build for: $TEAMCITY_BRANCH_NAME" + TEAMCITY_BRANCH_NAME="branchName='$TEAMCITY_BRANCH_NAME' defaultBranch='false'" + else + local TEAMCITY_BRANCH_NAME= + fi + + local GIT_OID=`git rev-parse HEAD` + local TEAMCITY_SERVER=https://build.palaso.org + + local command="" + + #debug echo "Call: $command" + + curl -s --header "Authorization: Bearer $TEAMCITY_TOKEN" \ + -X POST \ + -H "Content-Type: application/xml" \ + -H "Accept: application/json" \ + -H "Origin: $TEAMCITY_SERVER" \ + $TEAMCITY_SERVER/app/rest/buildQueue \ + -d "$command" +} diff --git a/resources/build/trigger-definitions.sh b/resources/build/trigger-definitions.sh new file mode 100644 index 0000000000..7d6114f001 --- /dev/null +++ b/resources/build/trigger-definitions.sh @@ -0,0 +1,69 @@ +#!/bin/bash +# +# This file maps specific paths to build triggers +# + +available_platforms=(android ios linux mac web windows) + +# the base folder for each pattern does not need to be included, nor oem folders +# e.g. android='common/predictive-text|common/lexical-model-types' +# will expand into android='^(android|(oem/[^/]+/android)|common/predictive-text|common/lexical-model-types)' + +watch_android='web|common/predictive-text|common/lexical-model-types' +watch_ios='web|common/predictive-text|common/lexical-model-types' +watch_linux='common/engine' +watch_mac='common/engine' +watch_web='common/predictive-text|common/lexical-model-types' + +# Windows currently builds Developer and Desktop, so we need everything from common,developer,web +watch_windows='common|developer|web' + +# +# Available build configurations and VCS identifeirs; identifiers are somewhat inconsistent due +# to legacy updates and changes. +# + +# Test Build Configurations + +# One day we may have tests that run on all platform changes +bc_test_all=() + +bc_test_android=(KeymanAndroid_TestPullRequests KeymanAndroid_TestSamplesAndTestProjects) +bc_test_ios=(Keyman_iOS_TestPullRequests Keyman_iOS_TestSamplesAndTestProjects) +bc_test_linux=(KeymanLinux_TestPullRequests Keyman_Common_KPAPI_TestPullRequests_Linux) +bc_test_mac=(Keyman_KeymanMac_PullRequests Keyman_Common_KPAPI_TestPullRequests_macOS) +bc_test_windows=(KeymanDesktop_TestPullRequests KeymanDesktop_TestPrRenderOnScreenKeyboards Keyman_Common_KPAPI_TestPullRequests_Windows) +bc_test_web=(Keymanweb_TestPullRequests Keyman_Common_LMLayer_TestPullRequests) +# Keymanweb_TestPullRequestRegressions : currently this is timing out so disabled until we have +# time to investigate further + +vcs_test=HttpsGithubComKeymanappKeymanPRs + +# +# TODO: these following entries relate to trigger-builds.sh and +# at some point that script should be updated to use these +# definitions rather than having them inline. +# +# Master (Alpha) Build Configurations + +bc_master_android=KeymanAndroid_Build +bc_master_ios=Keyman_iOS_Master +bc_master_linux=KeymanLinux_Master +bc_master_mac=KeymanMac_Master +bc_master_windows=Keyman_Build +bc_master_web=Keymanweb_Build + +vcs_master=HttpsGithubComKeymanappKeyman + +# Beta Build Configurations + +bc_beta_android=KeymanAndroid_Beta +bc_beta_ios=Keyman_iOS_Beta +bc_beta_linux=KeymanLinux_Beta +bc_beta_mac=KeymanMac_Beta +bc_beta_windows=KeymanDesktop_Beta +bc_beta_web=Keymanweb_Beta + +vcs_beta=Keyman_KeymanappKeymanBeta + +# TODO: Stable Build Configurations diff --git a/resources/git-hooks/commit-msg b/resources/git-hooks/commit-msg index ce21376361..8620e69390 100755 --- a/resources/git-hooks/commit-msg +++ b/resources/git-hooks/commit-msg @@ -12,14 +12,17 @@ # Reference: https://github.com/keymanapp/keyman/wiki/Pull-Request-and-Commit-workflow-notes # -# -# Configurable options for types, scopes and message length. -# +# Step 1 - resolve the source directory of the hook, ALSO resolving any symlinks. +# From https://stackoverflow.com/a/246128. +SCRIPT_SOURCE="${BASH_SOURCE[0]}" +while [ -h "$SOURCE" ]; do # resolve $SOURCE until the file is no longer a symlink + DIR="$( cd -P "$( dirname "$SCRIPT_SOURCE" )" >/dev/null 2>&1 && pwd )" + SCRIPT_SOURCE="$(readlink "$SCRIPT_SOURCE")" + [[ $SCRIPT_SOURCE != /* ]] && SCRIPT_SOURCE="$DIR/$SCRIPT_SOURCE" # if $SOURCE was a relative symlink, we need to resolve it relative to the path where the symlink file was located +done +HOOK_DIRECTORY="$( cd -P "$( dirname "$SCRIPT_SOURCE" )" >/dev/null 2>&1 && pwd )" -types=(fix feat chore change docs style refactor test) -scopes=(android ci common developer ios linux mac web windows) -min_length=4 -max_length=72 +. $HOOK_DIRECTORY/commit-msg-defs # # Define terminal colours. @@ -51,6 +54,15 @@ if (git remote -v | grep -E "origin|upstream" | grep -Eq "keyboards|lexical-mode exit 0 fi +# If this script is called by another hook, it may insert a -q parameter to silence the help text. +QUIET=0 +case $1 in + -q|--quiet) + shift # Remove the parameter, making it equivalent to a standard git-hook call. + QUIET=1 + ;; +esac + # build the regex pattern based on the config file function build_regex() { retypes= @@ -69,9 +81,9 @@ function build_regex() { rescope="(\((${rescope%?})\))?" remessage=".{$min_length,$max_length}[^.]" - refixes="( \. Fixes #\d+)?" + refixes="(\. Fixes #[[:digit:]]+)?" - regexp="^${retypes}${rescope}: ${remessage}${refixes}\s*$" + regexp="^${retypes}${rescope}: ${remessage}${refixes}[[:space:]]*$" } # Print out a standard error message which explains @@ -97,6 +109,8 @@ build_regex if [[ ! $msg =~ $regexp ]]; then # commit message is invalid according to config - block commit - print_error + if [ $QUIET -eq 0 ]; then + print_error + fi exit 1 fi \ No newline at end of file diff --git a/resources/git-hooks/commit-msg-defs b/resources/git-hooks/commit-msg-defs new file mode 100755 index 0000000000..71a5b7f77c --- /dev/null +++ b/resources/git-hooks/commit-msg-defs @@ -0,0 +1,18 @@ +#!/bin/bash +# Configurable options for types, scopes and message length. +# + +types=(fix feat chore change docs style refactor test auto) +scopes=( \ + android android/app android/browser android/engine android/oem \ + ci \ + common common/core common/lmlayer \ + developer developer/compiler developer/ide developer/tools \ + ios ios/app ios/browser ios/engine ios/oem \ + linux linux/config linux/engine linux/ibus linux/oem \ + mac mac/config mac/engine mac/oem \ + web web/engine web/oem web/ui \ + windows windows/config windows/engine windows/oem \ +) +min_length=4 +max_length=128 diff --git a/resources/git-hooks/prepare-commit-msg b/resources/git-hooks/prepare-commit-msg new file mode 100755 index 0000000000..c5dbf2cf21 --- /dev/null +++ b/resources/git-hooks/prepare-commit-msg @@ -0,0 +1,135 @@ +#!/bin/bash + +# +# This prepare-commit-msg hook prepares commit messages to ensure +# that they meet our conventional commit standard. +# +# Whenever `git` detects an incoming commit, it will trigger this hook. If the message does not +# our conventional commit format, it will set it to the following format. (commit and merge only) +# +# "type(scope): message" +# If an issue number is detected at the front of the message and it is not a merge, also adds "___. Fixes #1234" +# +# Reference: https://github.com/keymanapp/keyman/wiki/Pull-Request-and-Commit-workflow-notes +# + +COMMIT_MSG_FILE=$1 +COMMIT_SOURCE=$2 +SHA1=$3 + +# Step 1 - resolve the source directory of the hook, ALSO resolving any symlinks. +# From https://stackoverflow.com/a/246128. +SCRIPT_SOURCE="${BASH_SOURCE[0]}" +while [ -h "$SOURCE" ]; do # resolve $SOURCE until the file is no longer a symlink + DIR="$( cd -P "$( dirname "$SCRIPT_SOURCE" )" >/dev/null 2>&1 && pwd )" + SCRIPT_SOURCE="$(readlink "$SCRIPT_SOURCE")" + [[ $SCRIPT_SOURCE != /* ]] && SCRIPT_SOURCE="$DIR/$SCRIPT_SOURCE" # if $SOURCE was a relative symlink, we need to resolve it relative to the path where the symlink file was located +done +HOOK_DIRECTORY="$( cd -P "$( dirname "$SCRIPT_SOURCE" )" >/dev/null 2>&1 && pwd )" + +. $HOOK_DIRECTORY/commit-msg-defs + +# build the regex pattern based on the config file +function build_regex() { + retypes= + for type in "${types[@]}" + do + retypes="${retypes}$type|" + done + retypes="(${retypes%?})" + + rescope= + for scope in "${scopes[@]}" + do + rescope="${rescope}$scope|" + done + # %? removes last | + rescope="(${rescope%?})?" + + recherry="(cherry-pick\/)?" + + # Optionally captures an issue number at the front of the branch's name. + rebranchname="(([[:digit:]]+)\-)?(.+)" + + regexp="^${retypes}\/${rescope}\/?${recherry}${rebranchname}\s*$" +} + +function prepend_scope() { + # COMMIT_MSG_FILE and $COMMIT_SOURCE are already available. + + # Step 1: if the branch follows our preferred branch conventions, we can use that! + branch=$(git rev-parse --abbrev-ref HEAD) + build_regex + + # Perform the actual regex check. + if [[ $branch =~ $regexp ]]; then + TYPE=${BASH_REMATCH[1]} + SCOPE=${BASH_REMATCH[2]} + # 3 - cherry-pick (if it exists) + # 4 - just ISSUE, but with an appended '-'. Ashame BASH doesn't support non-capture groups. + ISSUE=${BASH_REMATCH[5]} + NAME=${BASH_REMATCH[6]} + + EXTRA_WHITESPACE="\n" + + # For known merge commits, consider the merge operation to be a 'chore'. + if [ "${COMMIT_SOURCE}" = "merge" ]; then + TYPE="chore" + ISSUE= # Invalidates adding the 'postfix' for these commit types. + EXTRA_WHITESPACE= + fi + + # Now that we have the components finalized, we can proceed. + if [ -n "${ISSUE// }" ]; then # Refer to https://unix.stackexchange.com/a/146945 for explanation. + # This technically does pass the conventional commits test - it doesn't -ensure- that the + # "Fixes #____" section doesn't count toward the core message. + postfix="\n" + postfix="${postfix}# Keyman Conventional Commit suggestions:\n" + postfix="${postfix}# - Consider appending the text \". Fixes #$ISSUE\" to your commit message." + else + postfix="" + fi + prefix="$TYPE($SCOPE): $EXTRA_WHITESPACE" + + # Reuse any existing message text, wrapping it in our conventional commit formatting before + # presenting it to the user for any final edits. + echo -e "$prefix$(cat $COMMIT_MSG_FILE)$postfix" > $COMMIT_MSG_FILE + fi + + # If it doesn't follow our branch-name format, we can't provide any help. +} + +# +# Configurable options for types, scopes and message length. +# + +# Step 1 - is the prepared message already in our conventional commits format? +$HOOK_DIRECTORY/commit-msg -q $COMMIT_MSG_FILE +isConventional=$? # Grabs the exit code. + +if [ $isConventional -eq 0 ]; then + # If so, great! Don't do a thing! + exit 0 +fi + +# Debugging aides +#echo "COMMIT_MSG_FILE=$COMMIT_MSG_FILE" +#echo "COMMIT_SOURCE=$COMMIT_SOURCE" + +# The default message is NOT in conventional format. Time to help out with that. +case $COMMIT_SOURCE in + squash) + # We don't help with squashes - this tends to combine two separate messages, + # and that can be messy. + exit 0 + ;; + merge|commit|'') # '' = plain `git commit` + # We can help here! + prepend_scope + ;; + *) + # Default handler - we don't handle these types yet. Might be worth considering. + # Known possibilities: message (-m), template (-t) + exit 0 + ;; +esac \ No newline at end of file diff --git a/web/source/build.sh b/web/source/build.sh index d5ddb1630e..c6eee05844 100755 --- a/web/source/build.sh +++ b/web/source/build.sh @@ -6,9 +6,10 @@ display_usage ( ) { echo "build.sh [-ui | -test | -embed | -web | -debug_embedded] [-no_minify] [-clean]" echo - echo " -ui to compile desktop user interface modules to output folder" + echo " -ui to compile only desktop user interface modules" echo " -test to compile for testing without copying resources or" - echo " updating the saved version number." + echo " updating the saved version number. Assumes dependencies + are unaltered." echo " -embed to compile only the KMEA/KMEI embedded engine." echo " -web to compile only the KeymanWeb engine." echo " -debug_embedded to compile a readable version of the embedded KMEA/KMEI code" @@ -42,30 +43,10 @@ WEB_TARGET=( "keymanweb.js" ) UI_TARGET=( "kmwuibutton.js" "kmwuifloat.js" "kmwuitoggle.js" "kmwuitoolbar.js" ) EMBED_TARGET=( "keyman.js" ) -# Ensure the dependencies are downloaded. --no-optional should help block fsevents warnings. -echo "Node.js + dependencies check" -npm install --no-optional - - # Variables for the LMLayer PREDICTIVE_TEXT_SOURCE="../../common/predictive-text/unit_tests/in_browser/resources/models/simple-trie.js" PREDICTIVE_TEXT_OUTPUT="../testing/prediction-ui/simple-en-trie.js" -# Ensure that the LMLayer compiles properly, readying the build product for comsumption by KMW. -cd ../../common/predictive-text/ -echo "" -echo "Compiling the Language Modeling layer module..." -./build.sh || fail "Failed to compile the language modeling layer module." -cd ../../web/source -echo "Copying ${PREDICTIVE_TEXT_SOURCE} to ${PREDICTIVE_TEXT_OUTPUT}" -cp "${PREDICTIVE_TEXT_SOURCE}" "${PREDICTIVE_TEXT_OUTPUT}" || fail "Failed to copy predictive text model" -echo "Language Modeling layer compilation successful." -echo "" - -if [ $? -ne 0 ]; then - fail "Build environment setup error detected! Please ensure Node.js is installed!" -fi - : ${CLOSURECOMPILERPATH:=../node_modules/google-closure-compiler} : ${JAVA:=java} @@ -92,12 +73,6 @@ minifier_warnings="--jscomp_error=* --jscomp_off=lintChecks --jscomp_off=unusedL minifier_lang_specs="--language_in ECMASCRIPT5 --language_out ECMASCRIPT5" minifycmd="$JAVA -jar $minifier --compilation_level WHITESPACE_ONLY $minifier_warnings --generate_exports $minifier_lang_specs" -if ! [ -f $minifier ]; -then - echo File $minifier does not exist: have you set the environment variable \$CLOSURECOMPILERPATH? - exit 1 -fi - readonly minifier readonly minifycmd @@ -233,6 +208,7 @@ compilecmd="$compiler" # Establish default build parameters set_default_vars ( ) { + BUILD_LMLAYER=true BUILD_UI=true BUILD_EMBED=true BUILD_FULLWEB=true @@ -255,12 +231,14 @@ while [[ $# -gt 0 ]] ; do case $key in -ui) set_default_vars + BUILD_LMLAYER=false BUILD_EMBED=false BUILD_FULLWEB=false BUILD_COREWEB=false ;; -test) set_default_vars + BUILD_LMLAYER=false BUILD_TEST=true BUILD_UI=false BUILD_EMBED=false @@ -297,6 +275,7 @@ while [[ $# -gt 0 ]] ; do shift # past argument done +readonly BUILD_LMLAYER readonly BUILD_UI readonly BUILD_EMBED readonly BUILD_FULLWEB @@ -304,6 +283,21 @@ readonly BUILD_DEBUG_EMBED readonly BUILD_COREWEB readonly DO_MINIFY +# Ensure the dependencies are downloaded. --no-optional should help block fsevents warnings. +echo "Node.js + dependencies check" +npm install --no-optional + +if [ $? -ne 0 ]; then + fail "Build environment setup error detected! Please ensure Node.js is installed!" +fi + +# NPM install is required for the file to be present. +if ! [ -f $minifier ]; +then + echo File $minifier does not exist: have you set the environment variable \$CLOSURECOMPILERPATH? + exit 1 +fi + generate_environment_ts_file ( ) { if [ -f $ENVIRONMENT_FILE ]; then @@ -317,6 +311,19 @@ namespace com.keyman.environment { " >> $ENVIRONMENT_FILE } +if [ $BUILD_LMLAYER = true ]; then + # Ensure that the LMLayer compiles properly, readying the build product for comsumption by KMW. + cd ../../common/predictive-text/ + echo "" + echo "Compiling the Language Modeling layer module..." + ./build.sh || fail "Failed to compile the language modeling layer module." + cd ../../web/source + echo "Copying ${PREDICTIVE_TEXT_SOURCE} to ${PREDICTIVE_TEXT_OUTPUT}" + cp "${PREDICTIVE_TEXT_SOURCE}" "${PREDICTIVE_TEXT_OUTPUT}" || fail "Failed to copy predictive text model" + echo "Language Modeling layer compilation successful." + echo "" +fi + generate_environment_ts_file if [ $FULL_BUILD = true ]; then @@ -324,7 +331,6 @@ if [ $FULL_BUILD = true ]; then echo "" fi - if [ $BUILD_EMBED = true ]; then echo Compile KMEI/KMEA build $BUILD diff --git a/web/source/kmwuifloat.ts b/web/source/kmwuifloat.ts index c3dc1b0d00..69a7c54e78 100644 --- a/web/source/kmwuifloat.ts +++ b/web/source/kmwuifloat.ts @@ -127,7 +127,7 @@ if(!window['keyman']['ui']['name']) { // Check required interface alignment and default keyboard var opt=util['getOption']('ui'),dfltKeyboard='English'; - if(typeof(opt) == 'object') + if(opt && typeof(opt) == 'object') { if(typeof(opt['position']) == 'string' && opt['position'] == 'right') ui.floatRight = true; @@ -484,7 +484,7 @@ if(!window['keyman']['ui']['name']) { keymanweb['addEventListener']('controlfocused', function(params) { - if(params['activeControl'] == null || params['activeControl']['LEnabled']) + if(params['activeControl'] == null || params['activeControl']['_kmwAttachment']) { /*if(keymanweb._IsIEEditableIframe(Ltarg)) Ltarg = Ltarg.ownerDocument.parentWindow.frameElement; diff --git a/web/source/kmwuitoggle.ts b/web/source/kmwuitoggle.ts index a5926fb1d4..6f1efd3da7 100644 --- a/web/source/kmwuitoggle.ts +++ b/web/source/kmwuitoggle.ts @@ -596,6 +596,11 @@ if(!window['keyman']['ui']['name']) { _k.className='selected'; ui.selectedMenuItem=_k; } + // Occurs for desktop form-factors when no keyboard (aka the sys default) is active. + if(!ui.oskButton) { + return; + } + // Hide the OSK button for CJK keyboards (or non-mapped) if(lgCode=='cmn' || lgCode=='jpn' || lgCode=='kor') { diff --git a/web/source/osk/banner.ts b/web/source/osk/banner.ts index 715b508685..835e665a5f 100644 --- a/web/source/osk/banner.ts +++ b/web/source/osk/banner.ts @@ -127,7 +127,7 @@ namespace com.keyman.osk { export class ImageBanner extends Banner { private img: HTMLElement; - constructor(imagePath, height?: number) { + constructor(imagePath: string, height?: number) { if (imagePath.length > 0) { super(); if (height) { @@ -137,7 +137,11 @@ namespace com.keyman.osk { super(0); } - console.log("Loading img with src '" + imagePath + "'"); + if(imagePath.indexOf('base64') >=0) { + console.log("Loading img from base64 data"); + } else { + console.log("Loading img with src '" + imagePath + "'"); + } this.img = document.createElement('img'); this.img.setAttribute('src', imagePath); let ds = this.img.style; diff --git a/windows/src/developer/TIKE/compile/MergeKeyboardInfo.pas b/windows/src/developer/TIKE/compile/MergeKeyboardInfo.pas index 86f1fe6501..8187aceee8 100644 --- a/windows/src/developer/TIKE/compile/MergeKeyboardInfo.pas +++ b/windows/src/developer/TIKE/compile/MergeKeyboardInfo.pas @@ -118,6 +118,7 @@ uses BCP47Tag, JsonUtil, Keyman.System.KeyboardInfoFile, + Keyman.System.KeyboardUtils, Keyman.System.LanguageCodeUtils, utilfiletypes, VersionInfo; @@ -925,6 +926,17 @@ var end; end; + function packageContainsKeyboardJs(const id: string): Boolean; + var + kf: TKeyboardInfoMap; + begin + for kf in FPackageJSFileInfos do + if SameText(TKeyboardUtils.KeyboardFileNameToID(kf.Filename), id) then + Exit(True); + + Result := False; + end; + begin try // Validate pre-existing platformSupport @@ -978,8 +990,11 @@ begin AddNewPair('windows', 'full'); AddNewPair('macos', 'full'); AddNewPair('linux', 'full'); - AddNewPair('android', 'full'); - AddNewPair('ios', 'full'); + if packageContainsKeyboardJs(TKeyboardUtils.KeyboardFileNameToId(keyboardFile.Filename)) then + begin + AddNewPair('android', 'full'); + AddNewPair('ios', 'full'); + end; end else begin @@ -1006,20 +1021,23 @@ begin if target = ktLinux then AddNewPair('linux', 'full'); - // FPackageKMXFileInfos can contain target information for web/mobile targets. - // This is a current limitation of FPackageJSFileInfos if there's no kmx files - if (target = ktMobile) then + if packageContainsKeyboardJs(TKeyboardUtils.KeyboardFileNameToId(keyboardFile.Filename)) then begin - AddNewPair('android', 'full'); - AddNewPair('ios', 'full'); - end; - if (target = ktIphone) or (target = ktIpad) then - begin - AddNewPair('ios', 'full'); - end; - if (target = ktAndroidphone) or (target = ktAndroidtablet) then - begin - AddNewPair('android', 'full'); + // FPackageKMXFileInfos can contain target information for web/mobile targets. + // This is a current limitation of FPackageJSFileInfos if there's no kmx files + if (target = ktMobile) then + begin + AddNewPair('android', 'full'); + AddNewPair('ios', 'full'); + end; + if (target = ktIphone) or (target = ktIpad) then + begin + AddNewPair('ios', 'full'); + end; + if (target = ktAndroidphone) or (target = ktAndroidtablet) then + begin + AddNewPair('android', 'full'); + end; end; end; end; diff --git a/windows/src/developer/TIKE/http/Keyman.Developer.System.HttpServer.Base.pas b/windows/src/developer/TIKE/http/Keyman.Developer.System.HttpServer.Base.pas index 692fba5fc5..3fa77fa3c7 100644 --- a/windows/src/developer/TIKE/http/Keyman.Developer.System.HttpServer.Base.pas +++ b/windows/src/developer/TIKE/http/Keyman.Developer.System.HttpServer.Base.pas @@ -58,7 +58,11 @@ begin Exit; end; - AResponseInfo.ContentType := AResponseInfo.HTTPServer.MIMETable.GetFileMIMEType(AFileName); + if AResponseInfo.ContentType = '' then + begin + AResponseInfo.HTTPServer.MIMETable.LoadTypesFromOS := False; + AResponseInfo.ContentType := AResponseInfo.HTTPServer.MIMETable.GetFileMIMEType(AFileName); + end; AResponseInfo.CharSet := 'UTF-8'; AResponseInfo.ContentLength := FileSizeByName(AFileName); //AResponseInfo.LastModified := GetFileDate(doc); diff --git a/windows/src/developer/TIKE/http/Keyman.Developer.System.HttpServer.Debugger.pas b/windows/src/developer/TIKE/http/Keyman.Developer.System.HttpServer.Debugger.pas index 9ad35f5bc5..fbdc2ba16b 100644 --- a/windows/src/developer/TIKE/http/Keyman.Developer.System.HttpServer.Debugger.pas +++ b/windows/src/developer/TIKE/http/Keyman.Developer.System.HttpServer.Debugger.pas @@ -652,6 +652,7 @@ begin Delete(doc, 1, 6); // Models always expire immediately + AResponseInfo.ContentType := 'application/javascript'; AResponseInfo.Expires := EncodeDate(1990, 1, 1); // I4037 AResponseInfo.CacheControl := 'no-cache, no-store'; // I4037 AResponseInfo.LastModified := Now; // I4037 @@ -686,7 +687,11 @@ begin // Serve the file - AResponseInfo.ContentType := AResponseInfo.HTTPServer.MIMETable.GetFileMIMEType(doc); + if AResponseInfo.ContentType = '' then + begin + AResponseInfo.HTTPServer.MIMETable.LoadTypesFromOS := False; + AResponseInfo.ContentType := AResponseInfo.HTTPServer.MIMETable.GetFileMIMEType(doc); + end; AResponseInfo.CharSet := 'UTF-8'; AResponseInfo.ContentLength := FileSizeByName(doc); //AResponseInfo.LastModified := GetFileDate(doc); diff --git a/windows/src/developer/TIKE/oskbuilder/UframeTouchLayoutBuilder.pas b/windows/src/developer/TIKE/oskbuilder/UframeTouchLayoutBuilder.pas index 8c56439806..9af7a7c85a 100644 --- a/windows/src/developer/TIKE/oskbuilder/UframeTouchLayoutBuilder.pas +++ b/windows/src/developer/TIKE/oskbuilder/UframeTouchLayoutBuilder.pas @@ -686,7 +686,7 @@ end; procedure TframeTouchLayoutBuilder.SetupCharMapDrop; begin - GetCharMapDropTool.Handle(cef.cefwp, cmimDefault, CharMapDragOver, CharMapDragDrop); + GetCharMapDropTool.Handle(cef, cmimDefault, CharMapDragOver, CharMapDragDrop); end; end. diff --git a/windows/src/developer/history.md b/windows/src/developer/history.md index 60cc4eb08f..969db6d955 100644 --- a/windows/src/developer/history.md +++ b/windows/src/developer/history.md @@ -1,5 +1,13 @@ # Keyman Developer Version History +## 2020-02-12 13.0.72 beta +* Bug Fix(IDE/Debugger): MIME type mismatch on some systems could cause web test to fail (#2633) + +## 2020-02-11 13.0.66 beta +* Bug Fix(Compiler): Merge of .keyboard_info files would not always get platform support correct (#2621) +* Bug Fix(Compiler): Missing keyboard file led to misleading error in package compiler (#2620) +* Bug Fix(Compiler): Double-click to drop character onto touch layout was disabled (#2619) + ## 2020-01-28 13.0.65 beta * Feature(Compiler): Hotkeys defined in .kmn no longer need to be quoted (#2432) * Feature(IDE): Enhanced Lexical Model Editor (#2396) diff --git a/windows/src/engine/keyman/touchkeyboard/Keyman.System.FrameworkInputPane.pas b/windows/src/engine/keyman/touchkeyboard/Keyman.System.FrameworkInputPane.pas index 5b8163b1e9..76cc66009d 100644 --- a/windows/src/engine/keyman/touchkeyboard/Keyman.System.FrameworkInputPane.pas +++ b/windows/src/engine/keyman/touchkeyboard/Keyman.System.FrameworkInputPane.pas @@ -63,6 +63,7 @@ type implementation uses + System.Types, Winapi.ActiveX, Keyman.System.DebugLogClient; diff --git a/windows/src/global/delphi/packages/Keyman.System.PackageInfoRefreshKeyboards.pas b/windows/src/global/delphi/packages/Keyman.System.PackageInfoRefreshKeyboards.pas index 8699449c36..d59affcd55 100644 --- a/windows/src/global/delphi/packages/Keyman.System.PackageInfoRefreshKeyboards.pas +++ b/windows/src/global/delphi/packages/Keyman.System.PackageInfoRefreshKeyboards.pas @@ -63,6 +63,7 @@ const SError_KeyboardVersionsDoNotMatch = 'Keyboard with ID ''%0:s'' is included with two differing versions: ''%1:s'', ''%2:s''. The keyboard versions must be consistent.'; SError_TooManyTargetFilesForKeyboardID = 'Keyboard with ID ''%0:s'' is included too many times (%1:d) in the package. You should have at most 1 .kmx and 1 .js file'; SError_CannotHaveSameKeyboardTwiceWithSameTarget = 'Keyboard with ID ''%0:s'' should have at most 1 .kmx and 1 .js file in the package'; + SWarning_CannotFindKeyboardFile = 'Keyboard file ''%0:s'' cannot be found to verify file version. Skipping verification.'; SError_LanguageTagIsNotValid = 'Keyboard with ID ''%0:s'' has a BCP 47 error: %2:s.'; SWarning_LanguageTagIsNotCanonical = 'Keyboard with ID ''%0:s'' has a BCP 47 warning: %2:s.'; @@ -140,6 +141,7 @@ var i, j: Integer; v: array[0..1] of TPackageKeyboardInfo; ids: TIntegerDynArray; + FileNotFound: Boolean; begin // Test that each keyboard has at most one target file for each platform and // that the versions match @@ -165,8 +167,16 @@ begin Exit(False); end; + FileNotFound := False; for j := 0 to 1 do - FillKeyboardDetails(pack.Files[ids[j]], v[j]); + if not FillKeyboardDetails(pack.Files[ids[j]], v[j]) then + begin + DoError(Format(SWarning_CannotFindKeyboardFile, [pack.Files[ids[j]].FileName]), plsWarning); + FileNotFound := True; + Break; + end; + if FileNotFound then + Continue; if v[1].Version <> v[0].Version then begin