From ad79c2ea13c9add5b6e4ba31c8421a139781ad56 Mon Sep 17 00:00:00 2001 From: Rocky Date: Tue, 20 Oct 2026 20:06:19 +0200 Subject: [PATCH] chore(flow): expand host-return whitelist and add a DEBUG host bundle probe - Rebuild the return-to-host whitelist from the CN/US top charts plus Apple system apps; drop the canOpenURL pre-check (LSApplicationQueriesSchemes is capped at 50) and let open() report failure. - Never record an OSG bundle id as the host to return to. - DEBUG only: probe the host bundle id through the keyboard arbiter callback and surface the log on a Home test card. Release builds keep the previous behaviour (no host identification). --- OSGKeyboard/Services/AppURLHandler.swift | 16 +- OSGKeyboard/Services/HostReturnService.swift | 17 +- .../Components/HostBundleProbeCard.swift | 94 ++++ OSGKeyboard/Views/HomeView.swift | 4 + OSGKeyboard/en.lproj/Localizable.strings | 108 ++++- OSGKeyboard/zh-Hans.lproj/Localizable.strings | 110 ++++- OSGKeyboardExt/KeyboardViewController.swift | 5 + .../OSGKeyboardExt-Bridging-Header.h | 6 + .../Services/KeyboardFlowCoordinator.swift | 1 + .../Utilities/HostBundleProbe.swift | 68 +++ .../Utilities/OSGHostArbiterProbe.h | 38 ++ .../Utilities/OSGHostArbiterProbe.m | 133 ++++++ .../Services/HostAppURLRegistry.swift | 445 +++++++----------- .../Utilities/HostBundleProbeLog.swift | 47 ++ .../HostAppURLRegistryTests.swift | 41 +- project.yml | 1 + 16 files changed, 807 insertions(+), 327 deletions(-) create mode 100644 OSGKeyboard/Views/Components/HostBundleProbeCard.swift create mode 100644 OSGKeyboardExt/OSGKeyboardExt-Bridging-Header.h create mode 100644 OSGKeyboardExt/Utilities/HostBundleProbe.swift create mode 100644 OSGKeyboardExt/Utilities/OSGHostArbiterProbe.h create mode 100644 OSGKeyboardExt/Utilities/OSGHostArbiterProbe.m create mode 100644 OSGKeyboardShared/Utilities/HostBundleProbeLog.swift diff --git a/OSGKeyboard/Services/AppURLHandler.swift b/OSGKeyboard/Services/AppURLHandler.swift index 4c2e777..831e3a2 100644 --- a/OSGKeyboard/Services/AppURLHandler.swift +++ b/OSGKeyboard/Services/AppURLHandler.swift @@ -112,12 +112,16 @@ final class AppSceneDelegate: NSObject, UIWindowSceneDelegate { MainActor.assumeIsolated { for item in items { // `sourceApplication` is only non-nil when the caller belongs to - // the same Apple Developer Team (our own keyboard extension) — - // exactly what the host-return whitelist relies on. Record it - // ONLY for the `startflow` handoff: overwriting it for every - // deep link (e.g. `osgkeyboard://settings`) could point a later - // cold-start "return to host" at the wrong app. - if let source = item.source, item.url.host == "startflow" { + // the same Apple Developer Team, so for the keyboard handoff it is + // our own extension, not the app the user was typing in. The + // keyboard publishes the real host before opening `startflow`; + // never overwrite that with an OSG bundle id. Record it ONLY for + // the `startflow` handoff: overwriting it for every deep link + // (e.g. `osgkeyboard://settings`) could point a later cold-start + // "return to host" at the wrong app. + if let source = item.source, + item.url.host == "startflow", + !HostAppURLRegistry.isOwnBundleId(source) { FlowSessionBridge.setPendingHostBundleId(source) } AppOpenURLRouter.shared.route(item.url) diff --git a/OSGKeyboard/Services/HostReturnService.swift b/OSGKeyboard/Services/HostReturnService.swift index c207032..4c9de48 100644 --- a/OSGKeyboard/Services/HostReturnService.swift +++ b/OSGKeyboard/Services/HostReturnService.swift @@ -2,6 +2,10 @@ // OSGKeyboard · Main App // // Opens a whitelisted host-app URL after a cold-start Flow handoff. +// +// No `canOpenURL` pre-check: iOS caps `LSApplicationQueriesSchemes` at 50 +// entries (already spent) and the whitelist is far larger. `open` needs no +// declaration and reports failure through its completion instead. import OSGKeyboardShared import UIKit @@ -13,12 +17,11 @@ enum HostReturnService { let bundleId = FlowSessionBridge.pendingHostBundleId() guard let entry = HostAppURLRegistry.lookup(bundleId: bundleId), let url = entry.returnURL else { - return false - } - guard UIApplication.shared.canOpenURL(url) else { + logReturn(bundleId: bundleId, outcome: "no-entry") return false } UIApplication.shared.open(url, options: [:]) { success in + logReturn(bundleId: bundleId, outcome: success ? "opened" : "open-failed") if success { FlowSessionBridge.clearPendingHostBundleId() } @@ -28,7 +31,7 @@ enum HostReturnService { @MainActor static func openHost(entry: HostAppEntry) -> Bool { - guard let url = entry.returnURL, UIApplication.shared.canOpenURL(url) else { + guard let url = entry.returnURL else { return false } UIApplication.shared.open(url, options: [:]) { success in @@ -43,4 +46,10 @@ enum HostReturnService { static func pendingHostEntry() -> HostAppEntry? { HostAppURLRegistry.lookup(bundleId: FlowSessionBridge.pendingHostBundleId()) } + + private static func logReturn(bundleId: String?, outcome: String) { + #if DEBUG + HostBundleProbeLog.append(event: "return", bundleId: bundleId, detail: outcome) + #endif + } } diff --git a/OSGKeyboard/Views/Components/HostBundleProbeCard.swift b/OSGKeyboard/Views/Components/HostBundleProbeCard.swift new file mode 100644 index 0000000..cbaf90d --- /dev/null +++ b/OSGKeyboard/Views/Components/HostBundleProbeCard.swift @@ -0,0 +1,94 @@ +// HostBundleProbeCard.swift +// OSGKeyboard · Main App +// +// 临时调试卡片(仅 DEBUG):展示键盘扩展识别到的宿主 bundle ID, +// 用于真机验证 iOS 26.4+ 宿主识别。验证结束后删除。 + +#if DEBUG +import OSGKeyboardShared +import SwiftUI + +struct HostBundleProbeCard: View { + @Environment(\.themePalette) private var palette: ThemePalette + @State private var entries: [HostBundleProbeLog.Entry] = [] + @State private var draft = "" + + private static let timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss.SSS" + return formatter + }() + + var body: some View { + VStack(alignment: .leading, spacing: Spacing.sm) { + HStack { + Text("宿主识别测试(临时)") + .font(TypeStyle.footnote.weight(.semibold)) + .foregroundStyle(palette.textTertiary) + Spacer(minLength: Spacing.xs) + Button("清空") { + HostBundleProbeLog.clear() + reload() + } + .font(TypeStyle.caption2) + .foregroundStyle(palette.accent) + } + + // 在 OSG 自己里唤起键盘时,预期识别结果是主 App 的 bundle ID。 + TextField("点这里唤起 OSG 键盘", text: $draft) + .textFieldStyle(.roundedBorder) + + if entries.isEmpty { + Text("暂无记录:去其他 App 唤起 OSG 键盘后回到这里查看") + .font(TypeStyle.caption2) + .foregroundStyle(palette.textTertiary) + } else { + VStack(alignment: .leading, spacing: 6) { + ForEach(entries) { entry in + entryRow(entry) + } + } + .textSelection(.enabled) + } + } + .padding(Spacing.md) + .frame(maxWidth: .infinity, alignment: .leading) + .surfaceCard() + // 卡片可见期间每秒刷新,在本 App 内唤起键盘时也能实时看到结果。 + .task { + while !Task.isCancelled { + reload() + try? await Task.sleep(for: .seconds(1)) + } + } + } + + private func entryRow(_ entry: HostBundleProbeLog.Entry) -> some View { + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: Spacing.xs) { + Text(Self.timeFormatter.string(from: entry.date)) + .foregroundStyle(palette.textTertiary) + Text(entry.event) + .foregroundStyle(entry.event == "appear" ? palette.textSecondary : palette.accent) + if let detail = entry.detail { + Text(detail) + .foregroundStyle(palette.textTertiary) + .lineLimit(1) + } + } + .font(.system(size: 11, design: .monospaced)) + + if entry.event != "appear" { + Text(entry.bundleId ?? "nil") + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundStyle(entry.bundleId == nil ? palette.warning : palette.textPrimary) + } + } + } + + private func reload() { + let latest = HostBundleProbeLog.entries() + if latest != entries { entries = latest } + } +} +#endif diff --git a/OSGKeyboard/Views/HomeView.swift b/OSGKeyboard/Views/HomeView.swift index 4ed623b..7acc3f7 100644 --- a/OSGKeyboard/Views/HomeView.swift +++ b/OSGKeyboard/Views/HomeView.swift @@ -369,6 +369,10 @@ struct HomeView: View { layout: UsageStatsClusterLayout, compact: Bool ) -> some View { + #if DEBUG + HostBundleProbeCard() + #endif + AccountRewardsCard { path.append(HomeRoute.account) } diff --git a/OSGKeyboard/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings index 0daf4b6..aea8db4 100644 --- a/OSGKeyboard/en.lproj/Localizable.strings +++ b/OSGKeyboard/en.lproj/Localizable.strings @@ -829,6 +829,9 @@ "hostApp.wecom" = "WeCom"; "hostApp.dingtalk" = "DingTalk"; "hostApp.lark" = "Lark"; +"hostApp.tencentMeeting" = "Tencent Meeting"; +"hostApp.qqmail" = "QQ Mail"; +"hostApp.wps" = "WPS Office"; "hostApp.telegram" = "Telegram"; "hostApp.whatsapp" = "WhatsApp"; "hostApp.line" = "LINE"; @@ -836,28 +839,56 @@ "hostApp.slack" = "Slack"; "hostApp.teams" = "Microsoft Teams"; "hostApp.discord" = "Discord"; -"hostApp.notion" = "Notion"; -"hostApp.bear" = "Bear"; -"hostApp.obsidian" = "Obsidian"; -"hostApp.drafts" = "Drafts"; -"hostApp.gmail" = "Gmail"; -"hostApp.outlook" = "Outlook"; -"hostApp.chrome" = "Chrome"; -"hostApp.weibo" = "Weibo"; -"hostApp.xiaohongshu" = "RED"; "hostApp.signal" = "Signal"; "hostApp.kakaotalk" = "KakaoTalk"; "hostApp.viber" = "Viber"; "hostApp.zalo" = "Zalo"; -"hostApp.skype" = "Skype"; "hostApp.zoom" = "Zoom"; +"hostApp.tinder" = "Tinder"; +"hostApp.notion" = "Notion"; +"hostApp.bear" = "Bear"; +"hostApp.obsidian" = "Obsidian"; +"hostApp.drafts" = "Drafts"; "hostApp.things" = "Things"; "hostApp.todoist" = "Todoist"; "hostApp.evernote" = "Evernote"; "hostApp.onenote" = "OneNote"; +"hostApp.word" = "Microsoft Word"; +"hostApp.excel" = "Microsoft Excel"; +"hostApp.gmail" = "Gmail"; +"hostApp.outlook" = "Outlook"; "hostApp.spark" = "Spark"; +"hostApp.yahooMail" = "Yahoo Mail"; +"hostApp.googleDocs" = "Google Docs"; +"hostApp.googleSheets" = "Google Sheets"; +"hostApp.googleDrive" = "Google Drive"; +"hostApp.googleTranslate" = "Google Translate"; +"hostApp.google" = "Google"; +"hostApp.chrome" = "Chrome"; "hostApp.firefox" = "Firefox"; "hostApp.edge" = "Edge"; +"hostApp.chatgpt" = "ChatGPT"; +"hostApp.claude" = "Claude"; +"hostApp.weibo" = "Weibo"; +"hostApp.xiaohongshu" = "RED"; +"hostApp.zhihu" = "Zhihu"; +"hostApp.bilibili" = "Bilibili"; +"hostApp.douyin" = "Douyin"; +"hostApp.douyinLite" = "Douyin Lite"; +"hostApp.kuaishou" = "Kuaishou"; +"hostApp.kuaishouLite" = "Kuaishou Lite"; +"hostApp.toutiao" = "Toutiao"; +"hostApp.douban" = "Douban"; +"hostApp.baidu" = "Baidu"; +"hostApp.youku" = "Youku"; +"hostApp.tencentVideo" = "Tencent Video"; +"hostApp.iqiyi" = "iQIYI"; +"hostApp.mangoTV" = "Mango TV"; +"hostApp.qqMusic" = "QQ Music"; +"hostApp.neteaseMusic" = "NetEase Cloud Music"; +"hostApp.kugou" = "Kugou Music"; +"hostApp.ximalaya" = "Ximalaya"; +"hostApp.weread" = "WeRead"; "hostApp.facebook" = "Facebook"; "hostApp.instagram" = "Instagram"; "hostApp.x" = "X"; @@ -865,10 +896,61 @@ "hostApp.snapchat" = "Snapchat"; "hostApp.reddit" = "Reddit"; "hostApp.pinterest" = "Pinterest"; -"hostApp.zhihu" = "Zhihu"; -"hostApp.bilibili" = "Bilibili"; -"hostApp.douyin" = "Douyin"; "hostApp.tiktok" = "TikTok"; +"hostApp.youtube" = "YouTube"; +"hostApp.linkedin" = "LinkedIn"; +"hostApp.tumblr" = "Tumblr"; +"hostApp.twitch" = "Twitch"; +"hostApp.spotify" = "Spotify"; +"hostApp.netflix" = "Netflix"; +"hostApp.capcut" = "CapCut"; +"hostApp.duolingo" = "Duolingo"; +"hostApp.taobao" = "Taobao"; +"hostApp.tmall" = "Tmall"; +"hostApp.jd" = "JD.com"; +"hostApp.pinduoduo" = "Pinduoduo"; +"hostApp.xianyu" = "Xianyu"; +"hostApp.alibaba1688" = "1688"; +"hostApp.vipshop" = "Vipshop"; +"hostApp.meituan" = "Meituan"; +"hostApp.meituanWaimai" = "Meituan Waimai"; +"hostApp.eleme" = "Taobao Shangou"; +"hostApp.dianping" = "Dianping"; +"hostApp.wuba" = "58.com"; +"hostApp.didi" = "DiDi"; +"hostApp.amap" = "Amap"; +"hostApp.baiduMap" = "Baidu Maps"; +"hostApp.tencentMap" = "Tencent Maps"; +"hostApp.ctrip" = "Ctrip"; +"hostApp.baiduNetdisk" = "Baidu Netdisk"; +"hostApp.ucBrowser" = "UC Browser"; +"hostApp.qqBrowser" = "QQ Browser"; +"hostApp.alipay" = "Alipay"; +"hostApp.unionpay" = "UnionPay"; +"hostApp.cmb" = "China Merchants Bank"; +"hostApp.ccb" = "China Construction Bank"; +"hostApp.abc" = "Agricultural Bank of China"; +"hostApp.amazon" = "Amazon"; +"hostApp.walmart" = "Walmart"; +"hostApp.ebay" = "eBay"; +"hostApp.aliexpress" = "AliExpress"; +"hostApp.uber" = "Uber"; +"hostApp.lyft" = "Lyft"; +"hostApp.doordash" = "DoorDash"; +"hostApp.airbnb" = "Airbnb"; +"hostApp.googleMaps" = "Google Maps"; +"hostApp.paypal" = "PayPal"; +"hostApp.venmo" = "Venmo"; +"hostApp.cashApp" = "Cash App"; +"hostApp.messages" = "Messages"; +"hostApp.notes" = "Notes"; +"hostApp.mail" = "Mail"; +"hostApp.reminders" = "Reminders"; +"hostApp.calendar" = "Calendar"; +"hostApp.shortcuts" = "Shortcuts"; +"hostApp.files" = "Files"; +"hostApp.maps" = "Maps"; +"hostApp.appStore" = "App Store"; "flow.warning.polishDegradedQuality" = "Polish output failed validation; inserted a conservative version."; "flow.error.editLastInputFailed" = "Could not complete the edit. Please try again."; "flow.error.aiQuestionFailed" = "AI response failed. Please try again."; diff --git a/OSGKeyboard/zh-Hans.lproj/Localizable.strings b/OSGKeyboard/zh-Hans.lproj/Localizable.strings index 1ed358a..6832db0 100644 --- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings +++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings @@ -829,6 +829,9 @@ "hostApp.wecom" = "企业微信"; "hostApp.dingtalk" = "钉钉"; "hostApp.lark" = "飞书"; +"hostApp.tencentMeeting" = "腾讯会议"; +"hostApp.qqmail" = "QQ邮箱"; +"hostApp.wps" = "WPS Office"; "hostApp.telegram" = "Telegram"; "hostApp.whatsapp" = "WhatsApp"; "hostApp.line" = "LINE"; @@ -836,28 +839,56 @@ "hostApp.slack" = "Slack"; "hostApp.teams" = "Microsoft Teams"; "hostApp.discord" = "Discord"; -"hostApp.notion" = "Notion"; -"hostApp.bear" = "Bear"; -"hostApp.obsidian" = "Obsidian"; -"hostApp.drafts" = "Drafts"; -"hostApp.gmail" = "Gmail"; -"hostApp.outlook" = "Outlook"; -"hostApp.chrome" = "Chrome"; -"hostApp.weibo" = "微博"; -"hostApp.xiaohongshu" = "小红书"; "hostApp.signal" = "Signal"; "hostApp.kakaotalk" = "KakaoTalk"; "hostApp.viber" = "Viber"; "hostApp.zalo" = "Zalo"; -"hostApp.skype" = "Skype"; "hostApp.zoom" = "Zoom"; +"hostApp.tinder" = "Tinder"; +"hostApp.notion" = "Notion"; +"hostApp.bear" = "熊掌记"; +"hostApp.obsidian" = "Obsidian"; +"hostApp.drafts" = "Drafts"; "hostApp.things" = "Things"; "hostApp.todoist" = "Todoist"; -"hostApp.evernote" = "印象笔记"; +"hostApp.evernote" = "Evernote"; "hostApp.onenote" = "OneNote"; +"hostApp.word" = "Word"; +"hostApp.excel" = "Excel"; +"hostApp.gmail" = "Gmail"; +"hostApp.outlook" = "Outlook"; "hostApp.spark" = "Spark"; +"hostApp.yahooMail" = "雅虎邮箱"; +"hostApp.googleDocs" = "Google 文档"; +"hostApp.googleSheets" = "Google 表格"; +"hostApp.googleDrive" = "Google 云端硬盘"; +"hostApp.googleTranslate" = "Google 翻译"; +"hostApp.google" = "Google"; +"hostApp.chrome" = "Chrome"; "hostApp.firefox" = "Firefox"; "hostApp.edge" = "Edge"; +"hostApp.chatgpt" = "ChatGPT"; +"hostApp.claude" = "Claude"; +"hostApp.weibo" = "微博"; +"hostApp.xiaohongshu" = "小红书"; +"hostApp.zhihu" = "知乎"; +"hostApp.bilibili" = "哔哩哔哩"; +"hostApp.douyin" = "抖音"; +"hostApp.douyinLite" = "抖音极速版"; +"hostApp.kuaishou" = "快手"; +"hostApp.kuaishouLite" = "快手极速版"; +"hostApp.toutiao" = "今日头条"; +"hostApp.douban" = "豆瓣"; +"hostApp.baidu" = "百度"; +"hostApp.youku" = "优酷视频"; +"hostApp.tencentVideo" = "腾讯视频"; +"hostApp.iqiyi" = "爱奇艺"; +"hostApp.mangoTV" = "芒果TV"; +"hostApp.qqMusic" = "QQ音乐"; +"hostApp.neteaseMusic" = "网易云音乐"; +"hostApp.kugou" = "酷狗音乐"; +"hostApp.ximalaya" = "喜马拉雅"; +"hostApp.weread" = "微信读书"; "hostApp.facebook" = "Facebook"; "hostApp.instagram" = "Instagram"; "hostApp.x" = "X"; @@ -865,10 +896,61 @@ "hostApp.snapchat" = "Snapchat"; "hostApp.reddit" = "Reddit"; "hostApp.pinterest" = "Pinterest"; -"hostApp.zhihu" = "知乎"; -"hostApp.bilibili" = "哔哩哔哩"; -"hostApp.douyin" = "抖音"; "hostApp.tiktok" = "TikTok"; +"hostApp.youtube" = "YouTube"; +"hostApp.linkedin" = "领英"; +"hostApp.tumblr" = "Tumblr"; +"hostApp.twitch" = "Twitch"; +"hostApp.spotify" = "Spotify"; +"hostApp.netflix" = "Netflix"; +"hostApp.capcut" = "CapCut"; +"hostApp.duolingo" = "多邻国"; +"hostApp.taobao" = "淘宝"; +"hostApp.tmall" = "天猫"; +"hostApp.jd" = "京东"; +"hostApp.pinduoduo" = "拼多多"; +"hostApp.xianyu" = "闲鱼"; +"hostApp.alibaba1688" = "阿里巴巴 1688"; +"hostApp.vipshop" = "唯品会"; +"hostApp.meituan" = "美团"; +"hostApp.meituanWaimai" = "美团外卖"; +"hostApp.eleme" = "淘宝闪购"; +"hostApp.dianping" = "大众点评"; +"hostApp.wuba" = "58同城"; +"hostApp.didi" = "滴滴出行"; +"hostApp.amap" = "高德地图"; +"hostApp.baiduMap" = "百度地图"; +"hostApp.tencentMap" = "腾讯地图"; +"hostApp.ctrip" = "携程旅行"; +"hostApp.baiduNetdisk" = "百度网盘"; +"hostApp.ucBrowser" = "UC浏览器"; +"hostApp.qqBrowser" = "QQ浏览器"; +"hostApp.alipay" = "支付宝"; +"hostApp.unionpay" = "云闪付"; +"hostApp.cmb" = "招商银行"; +"hostApp.ccb" = "中国建设银行"; +"hostApp.abc" = "中国农业银行"; +"hostApp.amazon" = "亚马逊"; +"hostApp.walmart" = "沃尔玛"; +"hostApp.ebay" = "eBay"; +"hostApp.aliexpress" = "速卖通"; +"hostApp.uber" = "Uber"; +"hostApp.lyft" = "Lyft"; +"hostApp.doordash" = "DoorDash"; +"hostApp.airbnb" = "爱彼迎"; +"hostApp.googleMaps" = "Google 地图"; +"hostApp.paypal" = "PayPal"; +"hostApp.venmo" = "Venmo"; +"hostApp.cashApp" = "Cash App"; +"hostApp.messages" = "信息"; +"hostApp.notes" = "备忘录"; +"hostApp.mail" = "邮件"; +"hostApp.reminders" = "提醒事项"; +"hostApp.calendar" = "日历"; +"hostApp.shortcuts" = "快捷指令"; +"hostApp.files" = "文件"; +"hostApp.maps" = "地图"; +"hostApp.appStore" = "App Store"; "flow.warning.polishDegradedQuality" = "本次润色结果未通过校验,已插入保守处理版本。"; "flow.error.editLastInputFailed" = "未能完成编辑,请重试。"; "flow.error.aiQuestionFailed" = "AI 回答失败,请重试。"; diff --git a/OSGKeyboardExt/KeyboardViewController.swift b/OSGKeyboardExt/KeyboardViewController.swift index b997fd1..24cc549 100644 --- a/OSGKeyboardExt/KeyboardViewController.swift +++ b/OSGKeyboardExt/KeyboardViewController.swift @@ -155,6 +155,9 @@ public final class KeyboardViewController: UIInputViewController { public override func viewDidLoad() { super.viewDidLoad() + #if DEBUG + HostBundleProbe.installLogging() + #endif state.showsSystemGlobeKey = UIDevice.current.userInterfaceIdiom == .pad // Voice-first keyboard — hide the misleading "English" subtitle in Settings. primaryLanguage = "mis" @@ -227,6 +230,7 @@ public final class KeyboardViewController: UIInputViewController { public override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) isKeyboardPresentationActive = false + HostAppIdentity.invalidate() typingDocumentPresentationID = nil typingSessionStorage?.endDocumentPresentation() assistantFieldActionRefreshTask?.cancel() @@ -353,6 +357,7 @@ public final class KeyboardViewController: UIInputViewController { self.flowCoordinator.ensurePiPReadyOnKeyboardOpen() } #if DEBUG + HostBundleProbe.recordPresentationAndPing() WhatsNewDemoDriver.resetForNewPresentation() WhatsNewDemoDriver.startIfNeeded( state: state, diff --git a/OSGKeyboardExt/OSGKeyboardExt-Bridging-Header.h b/OSGKeyboardExt/OSGKeyboardExt-Bridging-Header.h new file mode 100644 index 0000000..e8e7464 --- /dev/null +++ b/OSGKeyboardExt/OSGKeyboardExt-Bridging-Header.h @@ -0,0 +1,6 @@ +// OSGKeyboardExt-Bridging-Header.h +// OSGKeyboard · Keyboard Extension +// +// Exposes extension-local Objective-C helpers to Swift. + +#import "Utilities/OSGHostArbiterProbe.h" diff --git a/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift b/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift index 0acb64f..e17f280 100644 --- a/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift +++ b/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift @@ -1138,6 +1138,7 @@ final class KeyboardFlowCoordinator { state.lastTranscript = "" recomputeMicVoiceAvailability() FlowSessionBridge.markPiPArmAttempt() + HostAppIdentity.publishReturnTarget() OSGDiag.log( "beginFlowStart → openHostApp(startflow) recordAfterHandoff=\(recordAfterHandoff) " + "\(OSGDiag.memoryTag())", diff --git a/OSGKeyboardExt/Utilities/HostBundleProbe.swift b/OSGKeyboardExt/Utilities/HostBundleProbe.swift new file mode 100644 index 0000000..80c029f --- /dev/null +++ b/OSGKeyboardExt/Utilities/HostBundleProbe.swift @@ -0,0 +1,68 @@ +// HostBundleProbe.swift +// OSGKeyboard · Keyboard Extension +// +// 识别当前宿主 App 的 bundle ID,供冷启动跳转主 App 后自动跳回。 +// 目前依赖仅在 DEBUG 编译的私有 arbiter 探针(`OSGHostArbiterProbe`), +// Release 构建里识别结果恒为 nil,行为与接入前一致。 + +import Foundation +import OSGKeyboardShared + +enum HostAppIdentity { + /// 最近一次识别到的宿主;识别不到、或宿主是 OSG 自己时为 nil。 + static var currentBundleId: String? { + #if DEBUG + guard let bundleId = OSGHostArbiterProbe.lastCapturedBundleId, + !HostAppURLRegistry.isOwnBundleId(bundleId) else { + return nil + } + return bundleId + #else + return nil + #endif + } + + /// 跳转主 App(`startflow`)前调用:把当前宿主写进 App Group,主 App 就绪后据此跳回。 + /// 识别不到时写 nil,清掉上一次残留,避免跳回错误的 App。 + static func publishReturnTarget() { + let bundleId = currentBundleId + FlowSessionBridge.setPendingHostBundleId(bundleId) + #if DEBUG + let returnable = HostAppURLRegistry.lookup(bundleId: bundleId) != nil + HostBundleProbeLog.append( + event: "startflow", + bundleId: bundleId, + detail: returnable ? "returnable" : "no-return-url" + ) + #endif + } + + /// 键盘收起时作废识别结果,下次弹出由新回调重新填充。 + static func invalidate() { + #if DEBUG + OSGHostArbiterProbe.invalidateCapturedBundleId() + #endif + } +} + +#if DEBUG +/// 临时调试:把探针回调写进 App Group,主 App 首页的测试卡片读取展示。 +enum HostBundleProbe { + /// 只需安装一次;回调可能来自非主线程,闭包必须是 nonisolated。 + static func installLogging() { + OSGHostArbiterProbe.setCaptureHandler { @Sendable bundleId, detail in + HostBundleProbeLog.append(event: "arbiter", bundleId: bundleId, detail: detail) + } + } + + /// 键盘弹出时记一条标记,再主动触发回调,便于把记录和每次弹出对应起来。 + static func recordPresentationAndPing() { + HostBundleProbeLog.append( + event: "appear", + bundleId: nil, + detail: OSGHostArbiterProbe.isInstalled ? "hook=installed" : "hook=missing" + ) + OSGHostArbiterProbe.pingArbiter() + } +} +#endif diff --git a/OSGKeyboardExt/Utilities/OSGHostArbiterProbe.h b/OSGKeyboardExt/Utilities/OSGHostArbiterProbe.h new file mode 100644 index 0000000..c199b87 --- /dev/null +++ b/OSGKeyboardExt/Utilities/OSGHostArbiterProbe.h @@ -0,0 +1,38 @@ +// OSGHostArbiterProbe.h +// OSGKeyboard · Keyboard Extension +// +// 临时调试探针(仅 DEBUG):iOS 26.4+ 通过 _UIKeyboardArbiterClient 回调 +// 读取宿主 App 的 bundle ID。原理参考 editorss/KeyboardHostBundleID(MIT)。 +// 必须在 +load 安装,否则可能错过键盘首次切换宿主时的回调。 + +#import + +#ifdef DEBUG + +NS_ASSUME_NONNULL_BEGIN + +/// 回调可能在 arbiter 的私有队列上触发,不保证主线程。 +typedef void (^OSGHostArbiterCaptureHandler)(NSString *_Nullable bundleId, NSString *_Nullable detail); + +@interface OSGHostArbiterProbe : NSObject + +/// 钩子是否已成功安装(私有类或方法不存在时为 NO)。 +@property (class, nonatomic, readonly) BOOL isInstalled; + +/// 最近一次回调读到的宿主 bundle ID;回调读不到或已作废时为 nil。线程安全。 +@property (class, nonatomic, readonly, nullable) NSString *lastCapturedBundleId; + +/// 作废上一次的识别结果(键盘收起时调用,避免把上一个宿主当成当前宿主)。 ++ (void)invalidateCapturedBundleId; + +/// 每次 arbiter 回调都会调用;设置为 nil 取消。 ++ (void)setCaptureHandler:(nullable OSGHostArbiterCaptureHandler)handler; + +/// 主动让 arbiter 检查连接,可能触发一次回调,但不保证。 ++ (void)pingArbiter; + +@end + +NS_ASSUME_NONNULL_END + +#endif diff --git a/OSGKeyboardExt/Utilities/OSGHostArbiterProbe.m b/OSGKeyboardExt/Utilities/OSGHostArbiterProbe.m new file mode 100644 index 0000000..2520c5b --- /dev/null +++ b/OSGKeyboardExt/Utilities/OSGHostArbiterProbe.m @@ -0,0 +1,133 @@ +// OSGHostArbiterProbe.m +// OSGKeyboard · Keyboard Extension +// +// 临时调试探针(仅 DEBUG),见头文件说明。所有私有符号都在运行时按字符串查找, +// 任何一步找不到都静默放弃,并保证原始实现照常执行。 + +#import "OSGHostArbiterProbe.h" + +#ifdef DEBUG + +#import +#import +#import +#import + +static NSString *const kArbiterClientClassName = @"_UIKeyboardArbiterClient"; +static NSString *const kDestinationClassName = @"_UIKeyboardArbiterClientInputDestination"; +static NSString *const kEnabledSelectorName = @"enabled"; +static NSString *const kSharedClientSelectorName = @"automaticSharedArbiterClient"; +static NSString *const kCheckConnectionSelectorName = @"checkConnection"; +static NSString *const kKeyboardChangedSelectorName = @"queue_keyboardChanged:onComplete:"; +static NSString *const kSourceBundleIdKey = @"_sourceBundleIdentifier"; + +static os_unfair_lock sLock = OS_UNFAIR_LOCK_INIT; +static OSGHostArbiterCaptureHandler _Nullable sHandler = nil; +static NSString *_Nullable sLastCapturedBundleId = nil; +static IMP _Nullable sOriginalKeyboardChangedIMP = NULL; +static BOOL sInstalled = NO; + +#pragma mark - Trampolines + +// 替换 -[_UIKeyboardArbiterClientInputDestination queue_keyboardChanged:onComplete:]。 +// bundle ID 在参数 change 上,不在 self 上。 +static void OSGSwizzledKeyboardChanged(id self, SEL _cmd, id change, id completion) { + NSString *bundleId = nil; + if (change) { + @try { + id value = [change valueForKey:kSourceBundleIdKey]; + if ([value isKindOfClass:[NSString class]]) { + bundleId = [(NSString *)value copy]; + } + } @catch (__unused NSException *exception) { + // 字段改名时 KVC 会抛异常;吞掉,保证原始实现继续执行。 + } + } + NSString *detail = change ? NSStringFromClass([change class]) : @"change=nil"; + + os_unfair_lock_lock(&sLock); + // 读不到 ID 的回调同样覆盖旧值:宿主已经变了,旧 ID 不能再用。 + sLastCapturedBundleId = bundleId.length > 0 ? bundleId : nil; + OSGHostArbiterCaptureHandler handler = sHandler; + os_unfair_lock_unlock(&sLock); + if (handler) { + handler(bundleId, detail); + } + + if (sOriginalKeyboardChangedIMP) { + ((void (*)(id, SEL, id, id))sOriginalKeyboardChangedIMP)(self, _cmd, change, completion); + } +} + +// 替换 +[_UIKeyboardArbiterClient enabled]:iOS 26.4 用它控制是否派发上面的回调。 +static BOOL OSGAlwaysEnabled(__unused id self, __unused SEL _cmd) { + return YES; +} + +@implementation OSGHostArbiterProbe + ++ (void)load { + if (@available(iOS 26.4, *)) { + [self installHooks]; + } +} + ++ (void)installHooks { + Class clientClass = NSClassFromString(kArbiterClientClassName); + if (clientClass) { + Method enabled = class_getClassMethod(clientClass, NSSelectorFromString(kEnabledSelectorName)); + if (enabled) { + method_setImplementation(enabled, (IMP)&OSGAlwaysEnabled); + } + } + + Class destinationClass = NSClassFromString(kDestinationClassName); + if (!destinationClass) return; + Method keyboardChanged = class_getInstanceMethod( + destinationClass, + NSSelectorFromString(kKeyboardChangedSelectorName) + ); + if (!keyboardChanged) return; + sOriginalKeyboardChangedIMP = method_getImplementation(keyboardChanged); + method_setImplementation(keyboardChanged, (IMP)&OSGSwizzledKeyboardChanged); + sInstalled = YES; +} + ++ (BOOL)isInstalled { + return sInstalled; +} + ++ (NSString *)lastCapturedBundleId { + os_unfair_lock_lock(&sLock); + NSString *bundleId = [sLastCapturedBundleId copy]; + os_unfair_lock_unlock(&sLock); + return bundleId; +} + ++ (void)invalidateCapturedBundleId { + os_unfair_lock_lock(&sLock); + sLastCapturedBundleId = nil; + os_unfair_lock_unlock(&sLock); +} + ++ (void)setCaptureHandler:(OSGHostArbiterCaptureHandler)handler { + os_unfair_lock_lock(&sLock); + sHandler = [handler copy]; + os_unfair_lock_unlock(&sLock); +} + ++ (void)pingArbiter { + Class clientClass = NSClassFromString(kArbiterClientClassName); + SEL sharedSelector = NSSelectorFromString(kSharedClientSelectorName); + if (!clientClass || ![clientClass respondsToSelector:sharedSelector]) return; + + id client = ((id (*)(id, SEL))objc_msgSend)(clientClass, sharedSelector); + SEL checkSelector = NSSelectorFromString(kCheckConnectionSelectorName); + if (client && [client respondsToSelector:checkSelector]) { + ((void (*)(id, SEL))objc_msgSend)(client, checkSelector); + } +} + +@end + +#endif diff --git a/OSGKeyboardShared/Services/HostAppURLRegistry.swift b/OSGKeyboardShared/Services/HostAppURLRegistry.swift index 3f7cba3..1a620a9 100644 --- a/OSGKeyboardShared/Services/HostAppURLRegistry.swift +++ b/OSGKeyboardShared/Services/HostAppURLRegistry.swift @@ -3,6 +3,10 @@ // // Public URL-scheme whitelist for returning to a known host app after a // cold-start Flow session handoff. One bundle id maps to one preferred URL. +// +// Bundle ids are checked against the App Store lookup API. URL schemes have no +// public source of truth; a wrong one only makes the open fail (the user stays +// in OSG), it never sends the user to a different app. import Foundation @@ -25,287 +29,159 @@ public struct HostAppEntry: Sendable, Equatable { } public enum HostAppURLRegistry { - /// Curated whitelist for high-frequency host apps (IM, work, notes). + /// Curated whitelist: high-frequency typing hosts first, then the CN / US + /// App Store top charts, then Apple system apps. public static let entries: [HostAppEntry] = [ // Tier 1 — China IM / work - HostAppEntry( - bundleId: "com.tencent.xin", - displayNameKey: "hostApp.wechat", - returnURLString: "weixin://", - tier: 1 - ), - HostAppEntry( - bundleId: "com.tencent.mqq", - displayNameKey: "hostApp.qq", - returnURLString: "mqq://", - tier: 1 - ), - HostAppEntry( - bundleId: "com.tencent.wework", - displayNameKey: "hostApp.wecom", - returnURLString: "wxwork://", - tier: 1 - ), - HostAppEntry( - bundleId: "com.laiwang.DingTalk", - displayNameKey: "hostApp.dingtalk", - returnURLString: "dingtalk://", - tier: 1 - ), - HostAppEntry( - bundleId: "com.bytedance.ee.lark", - displayNameKey: "hostApp.lark", - returnURLString: "lark://", - tier: 1 - ), - // Tier 2 — global IM / collaboration - HostAppEntry( - bundleId: "ph.telegra.Telegraph", - displayNameKey: "hostApp.telegram", - returnURLString: "tg://", - tier: 2 - ), - HostAppEntry( - bundleId: "net.whatsapp.WhatsApp", - displayNameKey: "hostApp.whatsapp", - returnURLString: "whatsapp://", - tier: 2 - ), - HostAppEntry( - bundleId: "jp.naver.line", - displayNameKey: "hostApp.line", - returnURLString: "line://", - tier: 2 - ), - HostAppEntry( - bundleId: "com.facebook.Messenger", - displayNameKey: "hostApp.messenger", - returnURLString: "fb-messenger://", - tier: 2 - ), - HostAppEntry( - bundleId: "com.tinyspeck.chatlyio", - displayNameKey: "hostApp.slack", - returnURLString: "slack://", - tier: 2 - ), - HostAppEntry( - bundleId: "com.microsoft.skype.teams", - displayNameKey: "hostApp.teams", - returnURLString: "msteams://", - tier: 2 - ), - HostAppEntry( - bundleId: "com.hammerandchisel.discord", - displayNameKey: "hostApp.discord", - returnURLString: "discord://", - tier: 2 - ), - // Tier 3 — notes / mail / browser - HostAppEntry( - bundleId: "notion.id", - displayNameKey: "hostApp.notion", - returnURLString: "notion://", - tier: 3 - ), - HostAppEntry( - bundleId: "net.shinyfrog.bear", - displayNameKey: "hostApp.bear", - returnURLString: "bear://", - tier: 3 - ), - HostAppEntry( - bundleId: "md.obsidian", - displayNameKey: "hostApp.obsidian", - returnURLString: "obsidian://", - tier: 3 - ), - HostAppEntry( - bundleId: "com.agiletortoise.Drafts5", - displayNameKey: "hostApp.drafts", - returnURLString: "drafts5://", - tier: 3 - ), - HostAppEntry( - bundleId: "com.google.Gmail", - displayNameKey: "hostApp.gmail", - returnURLString: "googlegmail://", - tier: 3 - ), - HostAppEntry( - bundleId: "com.microsoft.Office.Outlook", - displayNameKey: "hostApp.outlook", - returnURLString: "ms-outlook://", - tier: 3 - ), - HostAppEntry( - bundleId: "com.google.chrome.ios", - displayNameKey: "hostApp.chrome", - returnURLString: "googlechrome://", - tier: 3 - ), - // Tier 4 — China social - HostAppEntry( - bundleId: "com.sina.weibo", - displayNameKey: "hostApp.weibo", - returnURLString: "sinaweibo://", - tier: 4 - ), - HostAppEntry( - bundleId: "com.xingin.discover", - displayNameKey: "hostApp.xiaohongshu", - returnURLString: "xhsdiscover://", - tier: 4 - ), - // Tier 2 (cont.) — global IM / calls - HostAppEntry( - bundleId: "org.whispersystems.signal", - displayNameKey: "hostApp.signal", - returnURLString: "sgnl://", - tier: 2 - ), - HostAppEntry( - bundleId: "com.iwilab.KakaoTalk", - displayNameKey: "hostApp.kakaotalk", - returnURLString: "kakaotalk://", - tier: 2 - ), - HostAppEntry( - bundleId: "com.viber", - displayNameKey: "hostApp.viber", - returnURLString: "viber://", - tier: 2 - ), - HostAppEntry( - bundleId: "com.vng.zaloapp", - displayNameKey: "hostApp.zalo", - returnURLString: "zalo://", - tier: 2 - ), - HostAppEntry( - bundleId: "com.skype.skype", - displayNameKey: "hostApp.skype", - returnURLString: "skype://", - tier: 2 - ), - HostAppEntry( - bundleId: "us.zoom.videomeetings", - displayNameKey: "hostApp.zoom", - returnURLString: "zoomus://", - tier: 2 - ), - // Tier 3 (cont.) — notes / mail / browser - HostAppEntry( - bundleId: "com.culturedcode.ThingsiPhone", - displayNameKey: "hostApp.things", - returnURLString: "things://", - tier: 3 - ), - HostAppEntry( - bundleId: "com.todoist.ios", - displayNameKey: "hostApp.todoist", - returnURLString: "todoist://", - tier: 3 - ), - HostAppEntry( - bundleId: "com.evernote.iPhone.Evernote", - displayNameKey: "hostApp.evernote", - returnURLString: "evernote://", - tier: 3 - ), - HostAppEntry( - bundleId: "com.microsoft.onenote", - displayNameKey: "hostApp.onenote", - returnURLString: "onenote://", - tier: 3 - ), - HostAppEntry( - bundleId: "com.readdle.smartemail", - displayNameKey: "hostApp.spark", - returnURLString: "readdle-spark://", - tier: 3 - ), - HostAppEntry( - bundleId: "org.mozilla.ios.Firefox", - displayNameKey: "hostApp.firefox", - returnURLString: "firefox://", - tier: 3 - ), - HostAppEntry( - bundleId: "com.microsoft.msedge", - displayNameKey: "hostApp.edge", - returnURLString: "microsoft-edge://", - tier: 3 - ), - // Tier 4 (cont.) — global / China social - HostAppEntry( - bundleId: "com.facebook.Facebook", - displayNameKey: "hostApp.facebook", - returnURLString: "fb://", - tier: 4 - ), - HostAppEntry( - bundleId: "com.burbn.instagram", - displayNameKey: "hostApp.instagram", - returnURLString: "instagram://", - tier: 4 - ), - HostAppEntry( - bundleId: "com.atebits.Tweetie2", - displayNameKey: "hostApp.x", - returnURLString: "twitter://", - tier: 4 - ), - HostAppEntry( - bundleId: "com.burbn.barcelona", - displayNameKey: "hostApp.threads", - returnURLString: "barcelona://", - tier: 4 - ), - HostAppEntry( - bundleId: "com.toyopagroup.picaboo", - displayNameKey: "hostApp.snapchat", - returnURLString: "snapchat://", - tier: 4 - ), - HostAppEntry( - bundleId: "com.reddit.Reddit", - displayNameKey: "hostApp.reddit", - returnURLString: "reddit://", - tier: 4 - ), - HostAppEntry( - bundleId: "pinterest", - displayNameKey: "hostApp.pinterest", - returnURLString: "pinterest://", - tier: 4 - ), - HostAppEntry( - bundleId: "com.zhihu.ios", - displayNameKey: "hostApp.zhihu", - returnURLString: "zhihu://", - tier: 4 - ), - HostAppEntry( - bundleId: "tv.danmaku.bili", - displayNameKey: "hostApp.bilibili", - returnURLString: "bilibili://", - tier: 4 - ), - HostAppEntry( - bundleId: "com.ss.iphone.ugc.Aweme", - displayNameKey: "hostApp.douyin", - returnURLString: "snssdk1128://", - tier: 4 - ), - HostAppEntry( - bundleId: "com.zhiliaoapp.musically", - displayNameKey: "hostApp.tiktok", - returnURLString: "tiktok://", - tier: 4 - ) + app("com.tencent.xin", "wechat", "weixin://", tier: 1), + app("com.tencent.mqq", "qq", "mqq://", tier: 1), + app("com.tencent.ww", "wecom", "wxwork://", tier: 1), + app("com.laiwang.DingTalk", "dingtalk", "dingtalk://", tier: 1), + app("com.bytedance.ee.lark", "lark", "lark://", tier: 1), + app("com.tencent.meeting", "tencentMeeting", "wemeet://", tier: 1), + app("com.tencent.qqmail", "qqmail", "qqmail://", tier: 1), + app("com.kingsoft.www.office.wpsoffice", "wps", "wpsoffice://", tier: 1), + + // Tier 2 — global IM / calls / dating + app("ph.telegra.Telegraph", "telegram", "tg://", tier: 2), + app("net.whatsapp.WhatsApp", "whatsapp", "whatsapp://", tier: 2), + app("jp.naver.line", "line", "line://", tier: 2), + app("com.facebook.Messenger", "messenger", "fb-messenger://", tier: 2), + app("com.tinyspeck.chatlyio", "slack", "slack://", tier: 2), + app("com.microsoft.skype.teams", "teams", "msteams://", tier: 2), + app("com.hammerandchisel.discord", "discord", "discord://", tier: 2), + app("org.whispersystems.signal", "signal", "sgnl://", tier: 2), + app("com.iwilab.KakaoTalk", "kakaotalk", "kakaotalk://", tier: 2), + app("com.viber", "viber", "viber://", tier: 2), + app("vn.com.vng.zingalo", "zalo", "zalo://", tier: 2), + app("us.zoom.videomeetings", "zoom", "zoomus://", tier: 2), + app("com.cardify.tinder", "tinder", "tinder://", tier: 2), + + // Tier 3 — notes / mail / browser / office / AI + app("notion.id", "notion", "notion://", tier: 3), + app("net.shinyfrog.bear", "bear", "bear://", tier: 3), + app("md.obsidian", "obsidian", "obsidian://", tier: 3), + app("com.agiletortoise.Drafts5", "drafts", "drafts5://", tier: 3), + app("com.culturedcode.ThingsiPhone", "things", "things://", tier: 3), + app("com.todoist.ios", "todoist", "todoist://", tier: 3), + app("com.evernote.iPhone.Evernote", "evernote", "evernote://", tier: 3), + app("com.microsoft.onenote", "onenote", "onenote://", tier: 3), + app("com.microsoft.Office.Word", "word", "ms-word://", tier: 3), + app("com.microsoft.Office.Excel", "excel", "ms-excel://", tier: 3), + app("com.google.Gmail", "gmail", "googlegmail://", tier: 3), + app("com.microsoft.Office.Outlook", "outlook", "ms-outlook://", tier: 3), + app("com.readdle.smartemail", "spark", "readdle-spark://", tier: 3), + app("com.yahoo.Aerogram", "yahooMail", "ymail://", tier: 3), + app("com.google.Docs", "googleDocs", "googledocs://", tier: 3), + app("com.google.Sheets", "googleSheets", "googlesheets://", tier: 3), + app("com.google.Drive", "googleDrive", "googledrive://", tier: 3), + app("com.google.Translate", "googleTranslate", "googletranslate://", tier: 3), + app("com.google.GoogleMobile", "google", "google://", tier: 3), + app("com.google.chrome.ios", "chrome", "googlechrome://", tier: 3), + app("org.mozilla.ios.Firefox", "firefox", "firefox://", tier: 3), + app("com.microsoft.msedge", "edge", "microsoft-edge://", tier: 3), + app("com.openai.chat", "chatgpt", "chatgpt://", tier: 3), + app("com.anthropic.claude", "claude", "claude://", tier: 3), + + // Tier 4 — China social / content + app("com.sina.weibo", "weibo", "sinaweibo://", tier: 4), + app("com.xingin.discover", "xiaohongshu", "xhsdiscover://", tier: 4), + app("com.zhihu.ios", "zhihu", "zhihu://", tier: 4), + app("tv.danmaku.bilianime", "bilibili", "bilibili://", tier: 4), + app("com.ss.iphone.ugc.Aweme", "douyin", "snssdk1128://", tier: 4), + app("com.ss.iphone.ugc.aweme.lite", "douyinLite", "snssdk2329://", tier: 4), + app("com.jiangjia.gif", "kuaishou", "kwai://", tier: 4), + app("com.kuaishou.nebula", "kuaishouLite", "ksnebula://", tier: 4), + app("com.ss.iphone.article.News", "toutiao", "snssdk141://", tier: 4), + app("com.douban.frodo", "douban", "douban://", tier: 4), + app("com.baidu.BaiduMobile", "baidu", "baiduboxapp://", tier: 4), + app("com.youku.YouKu", "youku", "youku://", tier: 4), + app("com.tencent.live4iphone", "tencentVideo", "tenvideo://", tier: 4), + app("com.qiyi.iphone", "iqiyi", "iqiyi://", tier: 4), + app("com.hunantv.imgotv", "mangoTV", "imgotv://", tier: 4), + app("com.tencent.QQMusic", "qqMusic", "qqmusic://", tier: 4), + app("com.netease.cloudmusic", "neteaseMusic", "orpheus://", tier: 4), + app("com.kugou.kugou1002", "kugou", "kugouURL://", tier: 4), + app("com.gemd.iting", "ximalaya", "iting://", tier: 4), + app("com.tencent.weread", "weread", "weread://", tier: 4), + + // Tier 4 — global social / content + app("com.facebook.Facebook", "facebook", "fb://", tier: 4), + app("com.burbn.instagram", "instagram", "instagram://", tier: 4), + app("com.atebits.Tweetie2", "x", "twitter://", tier: 4), + app("com.burbn.barcelona", "threads", "barcelona://", tier: 4), + app("com.toyopagroup.picaboo", "snapchat", "snapchat://", tier: 4), + app("com.reddit.Reddit", "reddit", "reddit://", tier: 4), + app("pinterest", "pinterest", "pinterest://", tier: 4), + app("com.zhiliaoapp.musically", "tiktok", "tiktok://", tier: 4), + app("com.google.ios.youtube", "youtube", "youtube://", tier: 4), + app("com.linkedin.LinkedIn", "linkedin", "linkedin://", tier: 4), + app("com.tumblr.tumblr", "tumblr", "tumblr://", tier: 4), + app("tv.twitch", "twitch", "twitch://", tier: 4), + app("com.spotify.client", "spotify", "spotify://", tier: 4), + app("com.netflix.Netflix", "netflix", "nflx://", tier: 4), + app("com.lemon.lvoverseas", "capcut", "capcut://", tier: 4), + app("com.duolingo.DuolingoMobile", "duolingo", "duolingo://", tier: 4), + + // Tier 5 — China shopping / local services / travel / finance + app("com.taobao.taobao4iphone", "taobao", "taobao://", tier: 5), + app("com.taobao.tmall", "tmall", "tmall://", tier: 5), + app("com.360buy.jdmobile", "jd", "openapp.jdmobile://", tier: 5), + app("com.xunmeng.pinduoduo", "pinduoduo", "pinduoduo://", tier: 5), + app("com.taobao.fleamarket", "xianyu", "fleamarket://", tier: 5), + app("com.alibaba.wireless", "alibaba1688", "wireless1688://", tier: 5), + app("com.vipshop.iphone", "vipshop", "vipshop://", tier: 5), + app("com.meituan.imeituan", "meituan", "imeituan://", tier: 5), + app("com.meituan.itakeaway", "meituanWaimai", "meituanwaimai://", tier: 5), + app("me.ele.ios.eleme", "eleme", "eleme://", tier: 5), + app("com.dianping.dpscope", "dianping", "dianping://", tier: 5), + app("com.taofang.iphone", "wuba", "wbmain://", tier: 5), + app("com.xiaojukeji.didi", "didi", "diditaxi://", tier: 5), + app("com.autonavi.amap", "amap", "iosamap://", tier: 5), + app("com.baidu.map", "baiduMap", "baidumap://", tier: 5), + app("com.tencent.sosomap", "tencentMap", "qqmap://", tier: 5), + app("ctrip.com", "ctrip", "ctrip://", tier: 5), + app("com.baidu.netdisk", "baiduNetdisk", "baiduyun://", tier: 5), + app("com.ucweb.iphone.lowversion", "ucBrowser", "ucbrowser://", tier: 5), + app("com.tencent.mttlite", "qqBrowser", "mttbrowser://", tier: 5), + app("com.alipay.iphoneclient", "alipay", "alipay://", tier: 5), + app("com.unionpay.chsp", "unionpay", "upwallet://", tier: 5), + app("com.cmbchina.MPBBank", "cmb", "cmbmobilebank://", tier: 5), + app("com.ccb.ccbDemo", "ccb", "ccbmobilebank://", tier: 5), + app("com.bankabc.iphonerelease", "abc", "bankabc://", tier: 5), + + // Tier 5 — US shopping / local services / finance + app("com.amazon.Amazon", "amazon", "com.amazon.mobile.shopping://", tier: 5), + app("com.walmart.electronics", "walmart", "walmart://", tier: 5), + app("com.ebay.iphone", "ebay", "ebay://", tier: 5), + app("com.alibaba.iAliexpress", "aliexpress", "aliexpress://", tier: 5), + app("com.ubercab.UberClient", "uber", "uber://", tier: 5), + app("com.zimride.instant", "lyft", "lyft://", tier: 5), + app("doordash.DoorDashConsumer", "doordash", "doordash://", tier: 5), + app("com.airbnb.app", "airbnb", "airbnb://", tier: 5), + app("com.google.Maps", "googleMaps", "comgooglemaps://", tier: 5), + app("com.yourcompany.PPClient", "paypal", "paypal://", tier: 5), + app("net.kortina.labs.Venmo", "venmo", "venmo://", tier: 5), + app("com.squareup.cash", "cashApp", "cashme://", tier: 5), + + // Tier 6 — Apple system apps. Safari has no scheme that reopens the + // current tab (every https/x-web-search URL opens a new page), so it is + // deliberately absent. + app("com.apple.MobileSMS", "messages", "messages://", tier: 6), + app("com.apple.mobilenotes", "notes", "mobilenotes://", tier: 6), + app("com.apple.mobilemail", "mail", "message://", tier: 6), + app("com.apple.reminders", "reminders", "x-apple-reminderkit://", tier: 6), + app("com.apple.mobilecal", "calendar", "calshow://", tier: 6), + app("com.apple.shortcuts", "shortcuts", "shortcuts://", tier: 6), + app("com.apple.DocumentsApp", "files", "shareddocuments://", tier: 6), + app("com.apple.Maps", "maps", "maps://", tier: 6), + app("com.apple.AppStore", "appStore", "itms-apps://", tier: 6) ] + private static func app(_ bundleId: String, _ name: String, _ url: String, tier: Int) -> HostAppEntry { + HostAppEntry(bundleId: bundleId, displayNameKey: "hostApp.\(name)", returnURLString: url, tier: tier) + } + private static let byBundleId: [String: HostAppEntry] = { Dictionary(uniqueKeysWithValues: entries.map { ($0.bundleId, $0) }) }() @@ -315,15 +191,10 @@ public enum HostAppURLRegistry { return byBundleId[bundleId] } - /// URL schemes declared in `LSApplicationQueriesSchemes` for `canOpenURL`. - public static var querySchemes: [String] { - Array( - Set( - entries.compactMap { entry -> String? in - guard let url = entry.returnURL, let scheme = url.scheme else { return nil } - return scheme - } - ) - ).sorted() + /// OSG's own targets (host app, keyboard, Mac) are never a return destination. + public static func isOwnBundleId(_ bundleId: String) -> Bool { + bundleId == ownBundleIdRoot || bundleId.hasPrefix(ownBundleIdRoot + ".") } + + private static let ownBundleIdRoot = "com.osgkeyboard" } diff --git a/OSGKeyboardShared/Utilities/HostBundleProbeLog.swift b/OSGKeyboardShared/Utilities/HostBundleProbeLog.swift new file mode 100644 index 0000000..cf91399 --- /dev/null +++ b/OSGKeyboardShared/Utilities/HostBundleProbeLog.swift @@ -0,0 +1,47 @@ +// HostBundleProbeLog.swift +// OSGKeyboard · Shared +// +// 临时调试:键盘扩展把 arbiter 回调识别到的宿主 bundle ID 写进 App Group, +// 主 App 首页读出来展示,用于真机验证 iOS 26.4+ 宿主识别方案。验证结束后删除。 + +#if DEBUG +import Foundation + +public enum HostBundleProbeLog { + public struct Entry: Codable, Equatable, Identifiable, Sendable { + public let id: UUID + public let date: Date + /// `appear`:键盘弹出(随后主动触发一次回调);`arbiter`:系统回调。 + public let event: String + /// 回调里读到的宿主 bundle ID;读不到时为 nil。 + public let bundleId: String? + /// 回调参数的类名等辅助信息,方便字段失效时排查。 + public let detail: String? + } + + private static let key = "debug.hostBundleProbe.entries" + private static let limit = 40 + + /// 新记录插在最前面,只保留最近 `limit` 条。 + public static func append(event: String, bundleId: String?, detail: String? = nil) { + guard let defaults = AppGroup.defaultsIfAvailable else { return } + let entry = Entry(id: UUID(), date: Date(), event: event, bundleId: bundleId, detail: detail) + let updated = Array(([entry] + entries()).prefix(limit)) + guard let data = try? JSONEncoder().encode(updated) else { return } + defaults.set(data, forKey: key) + } + + /// 按时间倒序返回。 + public static func entries() -> [Entry] { + guard let data = AppGroup.defaultsIfAvailable?.data(forKey: key), + let decoded = try? JSONDecoder().decode([Entry].self, from: data) else { + return [] + } + return decoded + } + + public static func clear() { + AppGroup.defaultsIfAvailable?.removeObject(forKey: key) + } +} +#endif diff --git a/OSGKeyboardTests/HostAppURLRegistryTests.swift b/OSGKeyboardTests/HostAppURLRegistryTests.swift index 0156eda..3d1088b 100644 --- a/OSGKeyboardTests/HostAppURLRegistryTests.swift +++ b/OSGKeyboardTests/HostAppURLRegistryTests.swift @@ -11,18 +11,53 @@ final class HostAppURLRegistryTests: XCTestCase { XCTAssertEqual(entry?.displayNameKey, "hostApp.wechat") } + /// App Store bundle ids; the earlier `com.tencent.wework` / `tv.danmaku.bili` never matched. + func testCorrectedBundleIds() { + XCTAssertEqual(HostAppURLRegistry.lookup(bundleId: "com.tencent.ww")?.returnURLString, "wxwork://") + XCTAssertEqual(HostAppURLRegistry.lookup(bundleId: "tv.danmaku.bilianime")?.returnURLString, "bilibili://") + } + + func testSystemAppLookup() { + XCTAssertEqual(HostAppURLRegistry.lookup(bundleId: "com.apple.mobilenotes")?.returnURLString, "mobilenotes://") + XCTAssertNil(HostAppURLRegistry.lookup(bundleId: "com.apple.mobilesafari")) + } + func testUnknownBundleReturnsNil() { - XCTAssertNil(HostAppURLRegistry.lookup(bundleId: "com.apple.MobileSMS")) + XCTAssertNil(HostAppURLRegistry.lookup(bundleId: "com.example.unknown")) XCTAssertNil(HostAppURLRegistry.lookup(bundleId: nil)) XCTAssertNil(HostAppURLRegistry.lookup(bundleId: "")) } - func testQuerySchemesIncludesWeChat() { - XCTAssertTrue(HostAppURLRegistry.querySchemes.contains("weixin")) + func testEveryEntryHasParseableReturnURL() { + for entry in HostAppURLRegistry.entries { + XCTAssertNotNil(entry.returnURL?.scheme, entry.bundleId) + } + } + + func testOwnBundleIdsAreNeverReturnTargets() { + XCTAssertTrue(HostAppURLRegistry.isOwnBundleId("com.osgkeyboard")) + XCTAssertTrue(HostAppURLRegistry.isOwnBundleId("com.osgkeyboard.ios")) + XCTAssertTrue(HostAppURLRegistry.isOwnBundleId("com.osgkeyboard.ios.keyboard")) + XCTAssertFalse(HostAppURLRegistry.isOwnBundleId("com.osgkeyboardx.ios")) + XCTAssertFalse(HostAppURLRegistry.isOwnBundleId("com.tencent.xin")) } func testEntriesHaveUniqueBundleIds() { let ids = HostAppURLRegistry.entries.map(\.bundleId) XCTAssertEqual(Set(ids).count, ids.count) } + + func testEveryEntryHasLocalizedDisplayName() throws { + let bundle = Bundle(identifier: "com.osgkeyboard.ios") ?? .main + for language in ["en", "zh-Hans"] { + let path = try XCTUnwrap(bundle.path(forResource: language, ofType: "lproj"), language) + let strings = try XCTUnwrap( + NSDictionary(contentsOfFile: "\(path)/Localizable.strings") as? [String: String], + language + ) + for entry in HostAppURLRegistry.entries { + XCTAssertNotNil(strings[entry.displayNameKey], "\(language): \(entry.displayNameKey)") + } + } + } } diff --git a/project.yml b/project.yml index c253cf0..ab80317 100644 --- a/project.yml +++ b/project.yml @@ -353,6 +353,7 @@ targets: settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios.keyboard + SWIFT_OBJC_BRIDGING_HEADER: OSGKeyboardExt/OSGKeyboardExt-Bridging-Header.h TARGETED_DEVICE_FAMILY: "1,2" SUPPORTS_MACCATALYST: NO SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO