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).
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -369,6 +369,10 @@ struct HomeView: View {
|
||||
layout: UsageStatsClusterLayout,
|
||||
compact: Bool
|
||||
) -> some View {
|
||||
#if DEBUG
|
||||
HostBundleProbeCard()
|
||||
#endif
|
||||
|
||||
AccountRewardsCard {
|
||||
path.append(HomeRoute.account)
|
||||
}
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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 回答失败,请重试。";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// OSGKeyboardExt-Bridging-Header.h
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// Exposes extension-local Objective-C helpers to Swift.
|
||||
|
||||
#import "Utilities/OSGHostArbiterProbe.h"
|
||||
@@ -1138,6 +1138,7 @@ final class KeyboardFlowCoordinator {
|
||||
state.lastTranscript = ""
|
||||
recomputeMicVoiceAvailability()
|
||||
FlowSessionBridge.markPiPArmAttempt()
|
||||
HostAppIdentity.publishReturnTarget()
|
||||
OSGDiag.log(
|
||||
"beginFlowStart → openHostApp(startflow) recordAfterHandoff=\(recordAfterHandoff) "
|
||||
+ "\(OSGDiag.memoryTag())",
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,38 @@
|
||||
// OSGHostArbiterProbe.h
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// 临时调试探针(仅 DEBUG):iOS 26.4+ 通过 _UIKeyboardArbiterClient 回调
|
||||
// 读取宿主 App 的 bundle ID。原理参考 editorss/KeyboardHostBundleID(MIT)。
|
||||
// 必须在 +load 安装,否则可能错过键盘首次切换宿主时的回调。
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
#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
|
||||
@@ -0,0 +1,133 @@
|
||||
// OSGHostArbiterProbe.m
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// 临时调试探针(仅 DEBUG),见头文件说明。所有私有符号都在运行时按字符串查找,
|
||||
// 任何一步找不到都静默放弃,并保证原始实现照常执行。
|
||||
|
||||
#import "OSGHostArbiterProbe.h"
|
||||
|
||||
#ifdef DEBUG
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
#import <objc/message.h>
|
||||
#import <objc/runtime.h>
|
||||
#import <os/lock.h>
|
||||
|
||||
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
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user