mirror of
https://github.com/keymanapp/keyman.git
synced 2026-09-28 10:37:41 +00:00
Merge pull request #3466 from keymanapp/feat/ios/app/universal-links
feat(ios/app): universal links for keyboard installation
This commit is contained in:
commit
9b6fea7fd8
8 changed files with 194 additions and 67 deletions
|
|
@ -210,6 +210,8 @@
|
|||
CEA1486C2407808F00C6ECD2 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = CEA1486F2407808F00C6ECD2 /* Localizable.strings */; };
|
||||
CEA1486D2407808F00C6ECD2 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = CEA1486F2407808F00C6ECD2 /* Localizable.strings */; };
|
||||
CEA14870240780E100C6ECD2 /* ResourceInfoView.xib in Resources */ = {isa = PBXBuildFile; fileRef = CEA14872240780E100C6ECD2 /* ResourceInfoView.xib */; };
|
||||
CE9E95CD24CE786900F6DD78 /* UniversalLinks.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE9E95CC24CE786900F6DD78 /* UniversalLinks.swift */; };
|
||||
CE9E95CF24CE7A2C00F6DD78 /* UniversalLinkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE9E95CE24CE7A2C00F6DD78 /* UniversalLinkTests.swift */; };
|
||||
CEA6BA9D249872E8002D44CE /* Simple 13.0 Migration.bundle in Resources */ = {isa = PBXBuildFile; fileRef = CEA6BA9C249872E7002D44CE /* Simple 13.0 Migration.bundle */; };
|
||||
CEA70CCF24CAC3AE001C12E6 /* Obsoletions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEA70CCE24CAC3AE001C12E6 /* Obsoletions.swift */; };
|
||||
CEA9670924BEC4030035AACF /* ResourceUpdateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEA9670824BEC4030035AACF /* ResourceUpdateTests.swift */; };
|
||||
|
|
@ -515,6 +517,8 @@
|
|||
CEA14871240780E100C6ECD2 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/ResourceInfoView.xib; sourceTree = "<group>"; };
|
||||
CEA14874240780EF00C6ECD2 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/ResourceInfoView.strings; sourceTree = "<group>"; };
|
||||
CEA148772407869200C6ECD2 /* km */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = km; path = km.lproj/ResourceInfoView.strings; sourceTree = "<group>"; };
|
||||
CE9E95CC24CE786900F6DD78 /* UniversalLinks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UniversalLinks.swift; sourceTree = "<group>"; };
|
||||
CE9E95CE24CE7A2C00F6DD78 /* UniversalLinkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UniversalLinkTests.swift; sourceTree = "<group>"; };
|
||||
CEA6BA9C249872E7002D44CE /* Simple 13.0 Migration.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = "Simple 13.0 Migration.bundle"; sourceTree = "<group>"; };
|
||||
CEA70CCE24CAC3AE001C12E6 /* Obsoletions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Obsoletions.swift; sourceTree = "<group>"; };
|
||||
CEA9670824BEC4030035AACF /* ResourceUpdateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceUpdateTests.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -778,6 +782,7 @@
|
|||
CED8B63124A9C2400054E300 /* ResourceDownloadManagerTests.swift */,
|
||||
CEA9670824BEC4030035AACF /* ResourceUpdateTests.swift */,
|
||||
CE88143924A9B7F4002809C3 /* ResourceDownloadQueueTests.swift */,
|
||||
CE9E95CE24CE7A2C00F6DD78 /* UniversalLinkTests.swift */,
|
||||
);
|
||||
path = KeymanEngineTests;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -1096,6 +1101,7 @@
|
|||
CE79B24823C711FF007E72AE /* KeyboardScaleMap.swift */,
|
||||
CE7ADD6523DE89FC00BC9A00 /* Alerts.swift */,
|
||||
CEA70CCE24CAC3AE001C12E6 /* Obsoletions.swift */,
|
||||
CE9E95CC24CE786900F6DD78 /* UniversalLinks.swift */,
|
||||
);
|
||||
path = Classes;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -1498,6 +1504,7 @@
|
|||
CEDFEF8F23FE43B700BECF39 /* MigrationTests.swift in Sources */,
|
||||
CED8B63224A9C2400054E300 /* ResourceDownloadManagerTests.swift in Sources */,
|
||||
CE9CD88023FCC1CA002BF2F8 /* TestUtils.swift in Sources */,
|
||||
CE9E95CF24CE7A2C00F6DD78 /* UniversalLinkTests.swift in Sources */,
|
||||
CE973D812484A56500F66045 /* PackageJSON.swift in Sources */,
|
||||
CE88143A24A9B7F4002809C3 /* ResourceDownloadQueueTests.swift in Sources */,
|
||||
CE8EDEB123F53D1A009E1FF6 /* FileManagementTests.swift in Sources */,
|
||||
|
|
@ -1540,6 +1547,7 @@
|
|||
C0B901AA1FA1AFC200764EB8 /* UserDefaults+Types.swift in Sources */,
|
||||
CE1F67A32304EB3800FF6972 /* ResourceDownloadManager.swift in Sources */,
|
||||
9A079E372238680700581263 /* KMPLexicalModel.swift in Sources */,
|
||||
CE9E95CD24CE786900F6DD78 /* UniversalLinks.swift in Sources */,
|
||||
CE89641F24A4686000D5EB8E /* Queries.swift in Sources */,
|
||||
CE5C8BE324B5B3BA00FAFB7F /* Queries+LexicalModel.swift in Sources */,
|
||||
9A3E832522EAC14A00D22D2A /* KeyboardSwitcherViewController.swift in Sources */,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@ public struct FullKeyboardID: Codable, LanguageResourceFullID, Equatable {
|
|||
public var type: LanguageResourceType {
|
||||
return .keyboard
|
||||
}
|
||||
|
||||
public init(keyboardID: String, languageID: String) {
|
||||
self.keyboardID = keyboardID
|
||||
self.languageID = languageID
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CustomStringConvertible
|
||||
|
|
|
|||
|
|
@ -80,8 +80,6 @@ public class KeyboardSearchViewController: UIViewController, WKNavigationDelegat
|
|||
return baseURL
|
||||
}
|
||||
|
||||
private static let REGEX_FOR_DOWNLOAD_INTERCEPT = try! NSRegularExpression(pattern: "^http(?:s)?:\\/\\/[^\\/]+\\/keyboards\\/install\\/([^?\\/]+)(?:\\?(.+))?$")
|
||||
|
||||
public init(languageCode: String? = nil,
|
||||
withSession session: URLSession = URLSession.shared,
|
||||
keyboardSelectionBlock: @escaping SelectionCompletedHandler<FullKeyboardID>) {
|
||||
|
|
@ -115,13 +113,13 @@ public class KeyboardSearchViewController: UIViewController, WKNavigationDelegat
|
|||
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||
if navigationAction.navigationType == .linkActivated {
|
||||
let link = navigationAction.request.url!
|
||||
if let (keyboard_id, lang_id) = KeyboardSearchViewController.tryParseLink(link) {
|
||||
if let parsedLink = UniversalLinks.tryParseKeyboardInstallLink(link) {
|
||||
decisionHandler(.cancel)
|
||||
|
||||
// Notify our caller of the search results.
|
||||
self.hasFinalized = true // Prevent popViewController from triggering cancellation events.
|
||||
self.navigationController?.popViewController(animated: true) // Rewind UI
|
||||
finalize(with: keyboard_id, for: lang_id)
|
||||
finalize(with: parsedLink)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -136,35 +134,12 @@ public class KeyboardSearchViewController: UIViewController, WKNavigationDelegat
|
|||
}
|
||||
}
|
||||
|
||||
internal static func tryParseLink(_ link: URL) -> (String, String?)? {
|
||||
let linkString = link.absoluteString
|
||||
|
||||
// If it matches the format for the Keyboard Universal Link URL Pattern...
|
||||
// (see https://docs.google.com/document/d/1rhgMeJlCdXCi6ohPb_CuyZd0PZMoSzMqGpv1A8cMFHY/edit?ts=5f11cb13#heading=h.qw7pas2adckj)
|
||||
if let match = REGEX_FOR_DOWNLOAD_INTERCEPT.firstMatch(in: linkString,
|
||||
options: [],
|
||||
range: NSRange(location: 0, length: linkString.utf16.count)) {
|
||||
let keyboard_id_range = Range(match.range(at: 1), in: linkString)!
|
||||
let keyboard_id = String(linkString[keyboard_id_range])
|
||||
|
||||
var lang_id: String? = nil
|
||||
let urlComponents = URLComponents(string: linkString)!
|
||||
if let lang_id_component = urlComponents.queryItems?.first(where: { $0.name == "bcp47" }) {
|
||||
lang_id = lang_id_component.value
|
||||
}
|
||||
|
||||
return (keyboard_id, lang_id)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
internal func finalize(with keyboard_id: String, for lang_id: String?) {
|
||||
let packageKey = KeymanPackage.Key(id: keyboard_id, type: .keyboard)
|
||||
internal func finalize(with parsedLink: UniversalLinks.ParsedKeyboardInstallLink) {
|
||||
let packageKey = KeymanPackage.Key(id: parsedLink.keyboard_id, type: .keyboard)
|
||||
|
||||
// If we have a language ID AND do not yet have a model for it.
|
||||
if let lang_id = lang_id {
|
||||
let resourceKey = FullKeyboardID(keyboardID: keyboard_id, languageID: lang_id)
|
||||
if let lang_id = parsedLink.lang_id {
|
||||
let resourceKey = FullKeyboardID(keyboardID: parsedLink.keyboard_id, languageID: lang_id)
|
||||
let kbdURL = ResourceDownloadManager.shared.defaultDownloadURL(forPackage: packageKey, andResource: resourceKey, asUpdate: false)
|
||||
self.keyboardSelectionClosure(.tagged(packageKey, kbdURL, resourceKey))
|
||||
} else {
|
||||
|
|
|
|||
45
ios/engine/KMEI/KeymanEngine/Classes/UniversalLinks.swift
Normal file
45
ios/engine/KMEI/KeymanEngine/Classes/UniversalLinks.swift
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
//
|
||||
// UniversalLinks.swift
|
||||
// KeymanEngine
|
||||
//
|
||||
// Created by Joshua Horton on 7/27/20.
|
||||
// Copyright © 2020 SIL International. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public class UniversalLinks {
|
||||
public struct ParsedKeyboardInstallLink {
|
||||
public let keyboard_id: String
|
||||
public let lang_id: String?
|
||||
|
||||
public var packageKey: KeymanPackage.Key {
|
||||
return KeymanPackage.Key(id: keyboard_id, type: .keyboard)
|
||||
}
|
||||
}
|
||||
|
||||
private static let KEYBOARD_INSTALL_LINK_REGEX = try! NSRegularExpression(pattern: "^http(?:s)?:\\/\\/[^\\/]+\\/keyboards\\/install\\/([^?\\/]+)(?:\\?(.+))?$")
|
||||
|
||||
public static func tryParseKeyboardInstallLink(_ link: URL) -> ParsedKeyboardInstallLink? {
|
||||
let linkString = link.absoluteString
|
||||
|
||||
// If it matches the format for the Keyboard Universal Link URL Pattern...
|
||||
// (see https://docs.google.com/document/d/1rhgMeJlCdXCi6ohPb_CuyZd0PZMoSzMqGpv1A8cMFHY/edit?ts=5f11cb13#heading=h.qw7pas2adckj)
|
||||
if let match = KEYBOARD_INSTALL_LINK_REGEX.firstMatch(in: linkString,
|
||||
options: [],
|
||||
range: NSRange(location: 0, length: linkString.utf16.count)) {
|
||||
let keyboard_id_range = Range(match.range(at: 1), in: linkString)!
|
||||
let keyboard_id = String(linkString[keyboard_id_range])
|
||||
|
||||
var lang_id: String? = nil
|
||||
let urlComponents = URLComponents(string: linkString)!
|
||||
if let lang_id_component = urlComponents.queryItems?.first(where: { $0.name == "bcp47" }) {
|
||||
lang_id = lang_id_component.value
|
||||
}
|
||||
|
||||
return ParsedKeyboardInstallLink(keyboard_id: keyboard_id, lang_id: lang_id)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -32,40 +32,6 @@ class KeyboardSearchTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
func testTryLinkParse() {
|
||||
var tuple = KeyboardSearchViewController.tryParseLink(URL.init(string: "\(KeymanHosts.KEYMAN_COM)/randomURL")!)
|
||||
|
||||
XCTAssertNil(tuple)
|
||||
|
||||
tuple = KeyboardSearchViewController.tryParseLink(URL.init(string: "\(KeymanHosts.KEYMAN_COM)/keyboards/install/khmer_angkor")!)
|
||||
|
||||
if let tuple = tuple {
|
||||
XCTAssertEqual(tuple.0, "khmer_angkor")
|
||||
XCTAssertNil(tuple.1)
|
||||
}
|
||||
|
||||
tuple = KeyboardSearchViewController.tryParseLink(URL.init(string: "\(KeymanHosts.KEYMAN_COM)/keyboards/install/khmer_angkor?bcp47=km")!)
|
||||
|
||||
if let tuple = tuple {
|
||||
XCTAssertEqual(tuple.0, "khmer_angkor")
|
||||
XCTAssertEqual(tuple.1, "km")
|
||||
}
|
||||
|
||||
tuple = KeyboardSearchViewController.tryParseLink(URL.init(string: "\(KeymanHosts.KEYMAN_COM)/keyboards/install/sil_euro_latin")!)
|
||||
|
||||
if let tuple = tuple {
|
||||
XCTAssertEqual(tuple.0, "sil_euro_latin")
|
||||
XCTAssertNil(tuple.1)
|
||||
}
|
||||
|
||||
tuple = KeyboardSearchViewController.tryParseLink(URL.init(string: "\(KeymanHosts.KEYMAN_COM)/keyboards/install/foo?bcp47=bar")!)
|
||||
|
||||
if let tuple = tuple {
|
||||
XCTAssertEqual(tuple.0, "foo")
|
||||
XCTAssertEqual(tuple.1, "bar")
|
||||
}
|
||||
}
|
||||
|
||||
func testFinalizeNoLanguage() {
|
||||
let kbdExpectation = XCTestExpectation()
|
||||
|
||||
|
|
@ -84,7 +50,7 @@ class KeyboardSearchTests: XCTestCase {
|
|||
let searchNoLang = KeyboardSearchViewController(languageCode: nil,
|
||||
withSession: mockedURLSession!,
|
||||
keyboardSelectionBlock: kbdBlock)
|
||||
searchNoLang.finalize(with: "khmer_angkor", for: nil)
|
||||
searchNoLang.finalize(with: UniversalLinks.ParsedKeyboardInstallLink(keyboard_id: "khmer_angkor", lang_id: nil))
|
||||
|
||||
wait(for: [kbdExpectation], timeout: 5)
|
||||
}
|
||||
|
|
@ -107,7 +73,7 @@ class KeyboardSearchTests: XCTestCase {
|
|||
let search = KeyboardSearchViewController(languageCode: nil,
|
||||
withSession: mockedURLSession!,
|
||||
keyboardSelectionBlock: kbdBlock)
|
||||
search.finalize(with: "sil_euro_latin", for: "en")
|
||||
search.finalize(with: UniversalLinks.ParsedKeyboardInstallLink(keyboard_id: "sil_euro_latin", lang_id: "en"))
|
||||
|
||||
wait(for: [kbdExpectation], timeout: 5)
|
||||
}
|
||||
|
|
|
|||
74
ios/engine/KMEI/KeymanEngineTests/UniversalLinkTests.swift
Normal file
74
ios/engine/KMEI/KeymanEngineTests/UniversalLinkTests.swift
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
//
|
||||
// UniversalLinkTests.swift
|
||||
// KeymanEngineTests
|
||||
//
|
||||
// Created by Joshua Horton on 7/27/20.
|
||||
// Copyright © 2020 SIL International. All rights reserved.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import KeymanEngine
|
||||
|
||||
class UniversalLinkTests: XCTestCase {
|
||||
func testTryLinkParse() {
|
||||
var parsedLink = UniversalLinks.tryParseKeyboardInstallLink(URL.init(string: "\(KeymanHosts.KEYMAN_COM)/randomURL")!)
|
||||
|
||||
XCTAssertNil(parsedLink)
|
||||
|
||||
parsedLink = UniversalLinks.tryParseKeyboardInstallLink(URL.init(string: "\(KeymanHosts.KEYMAN_COM)/keyboards/install/khmer_angkor")!)
|
||||
|
||||
if let parsedLink = parsedLink {
|
||||
XCTAssertEqual(parsedLink.keyboard_id, "khmer_angkor")
|
||||
XCTAssertNil(parsedLink.lang_id)
|
||||
} else {
|
||||
XCTFail()
|
||||
}
|
||||
|
||||
parsedLink = UniversalLinks.tryParseKeyboardInstallLink(URL.init(string: "\(KeymanHosts.KEYMAN_COM)/keyboards/install/khmer_angkor?bcp47=km")!)
|
||||
|
||||
if let parsedLink = parsedLink {
|
||||
XCTAssertEqual(parsedLink.keyboard_id, "khmer_angkor")
|
||||
XCTAssertEqual(parsedLink.lang_id, "km")
|
||||
} else {
|
||||
XCTFail()
|
||||
}
|
||||
|
||||
parsedLink = UniversalLinks.tryParseKeyboardInstallLink(URL.init(string: "\(KeymanHosts.KEYMAN_COM)/keyboards/install/sil_euro_latin")!)
|
||||
|
||||
if let parsedLink = parsedLink {
|
||||
XCTAssertEqual(parsedLink.keyboard_id, "sil_euro_latin")
|
||||
XCTAssertNil(parsedLink.lang_id)
|
||||
} else {
|
||||
XCTFail()
|
||||
}
|
||||
|
||||
parsedLink = UniversalLinks.tryParseKeyboardInstallLink(URL.init(string: "\(KeymanHosts.KEYMAN_COM)/keyboards/install/foo?bcp47=bar")!)
|
||||
|
||||
if let parsedLink = parsedLink {
|
||||
XCTAssertEqual(parsedLink.keyboard_id, "foo")
|
||||
XCTAssertEqual(parsedLink.lang_id, "bar")
|
||||
} else {
|
||||
XCTFail()
|
||||
}
|
||||
}
|
||||
|
||||
func testTryLinkParseWithExtraneousComponents() {
|
||||
var parsedLink = UniversalLinks.tryParseKeyboardInstallLink(URL.init(string: "\(KeymanHosts.KEYMAN_COM)/keyboards/install/foo?bcp47=bar&baz=nope")!)
|
||||
|
||||
if let parsedLink = parsedLink {
|
||||
XCTAssertEqual(parsedLink.keyboard_id, "foo")
|
||||
XCTAssertEqual(parsedLink.lang_id, "bar")
|
||||
} else {
|
||||
XCTFail()
|
||||
}
|
||||
|
||||
parsedLink = UniversalLinks.tryParseKeyboardInstallLink(URL.init(string: "\(KeymanHosts.KEYMAN_COM)/keyboards/install/foo?_t=123&bcp47=bar")!)
|
||||
|
||||
if let parsedLink = parsedLink {
|
||||
XCTAssertEqual(parsedLink.keyboard_id, "foo")
|
||||
XCTAssertEqual(parsedLink.lang_id, "bar")
|
||||
} else {
|
||||
XCTFail()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -92,6 +92,55 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||
return true
|
||||
}
|
||||
|
||||
// Handles universal links.
|
||||
func application(_ application: UIApplication,
|
||||
continue userActivity: NSUserActivity,
|
||||
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
|
||||
if userActivity.activityType == NSUserActivityTypeBrowsingWeb {
|
||||
guard let incomingURL = userActivity.webpageURL else {
|
||||
return false
|
||||
}
|
||||
|
||||
if let parsedLink = UniversalLinks.tryParseKeyboardInstallLink(incomingURL) {
|
||||
// We use this mostly to shorten line lengths, b/c lint warnings.
|
||||
let downloadManager = ResourceDownloadManager.shared
|
||||
|
||||
// Aha! We know this link type!
|
||||
let downloadLink: URL
|
||||
if let langID = parsedLink.lang_id {
|
||||
let fullID = FullKeyboardID(keyboardID: parsedLink.keyboard_id, languageID: langID)
|
||||
downloadLink = downloadManager.defaultDownloadURL(forPackage: parsedLink.packageKey,
|
||||
andResource: fullID,
|
||||
asUpdate: false)
|
||||
} else {
|
||||
downloadLink = downloadManager.defaultDownloadURL(forPackage: parsedLink.packageKey, asUpdate: false)
|
||||
}
|
||||
downloadManager.downloadPackage(withKey: parsedLink.packageKey,
|
||||
from: downloadLink) { (package: KeyboardKeymanPackage?, error: Error?) in
|
||||
guard error == nil, let package = package else {
|
||||
// Maybe add an alert about the package error?
|
||||
return
|
||||
}
|
||||
if let vc = self.window?.rootViewController {
|
||||
// Force the app to the top-level view. (Prompts won't display if we're in a submenu!)
|
||||
vc.dismiss(animated: true, completion: nil)
|
||||
|
||||
// We choose to prompt the user for comfirmation, rather
|
||||
// than automatically installing the package.
|
||||
let nvc = UINavigationController.init()
|
||||
ResourceFileManager.shared.promptPackageInstall(of: package, in: nvc, isCustom: true)
|
||||
vc.present(nvc, animated: true, completion: nil)
|
||||
} else {
|
||||
log.error("Cannot find app's root UIViewController")
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func applicationDidEnterBackground(_ application: UIApplication) {
|
||||
_overlayWindow = nil
|
||||
FontManager.shared.unregisterCustomFonts()
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@
|
|||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.associated-domains</key>
|
||||
<array>
|
||||
<string>applinks:keyman.com</string>
|
||||
<string>applinks:keyman-staging.com</string>
|
||||
</array>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.KM4I</string>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue