Files
OSGKeyboard/OSGKeyboardShared/Services/HostAppURLRegistry.swift
Rocky 9ef0196d52 feat(flow): harden host handoff, session UX, and Live Activity brand
- fix(keyboard): drop unusable extensionContext.open; report the real
  result of UIApplication.open and cancel the start watchdog immediately
  on failure with clear "open OSGKeyboard" guidance instead of a 30s spin
- feat(capture): recover from audio route changes (AirPods/wired headset
  plug-unplug) and phone-call interruptions by rebuilding the engine/tap
  against the live hardware format
- feat(home): add a "Start" button in the Home footer when a session is
  inactive and permissions are granted (no-jump manual restart)
- feat(hostapps): expand the return whitelist to 46 apps (Signal, Zoom,
  Instagram, X, Douyin, Zhihu, Bilibili, Things, Firefox, ...) with
  en/zh-Hans names and synced LSApplicationQueriesSchemes
- feat(island): use a "ready" checkmark instead of a mic glyph while idle
  and set a staleDate so an orphaned Live Activity fades after a force-quit
- feat(coldstart): use the template OSG brand mark instead of a system
  waveform glyph in the cold-start overlay
- perf(keyboard): rely on Darwin notifications as the primary session
  signal and drop the fallback poll from 1s to 3s
- fix(keyboard): remove the redundant "Start" text button next to the
  inactive-session hint on the keyboard
- docs: correct the per-take cap to 210s (3.5 min) and document that
  force-quitting no longer resurrects the session (tracker + READMEs)
2026-07-06 17:56:10 +08:00

330 lines
10 KiB
Swift

// HostAppURLRegistry.swift
// OSGKeyboard · Shared
//
// 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.
import Foundation
public struct HostAppEntry: Sendable, Equatable {
public let bundleId: String
public let displayNameKey: String
public let returnURLString: String
public let tier: Int
public var returnURL: URL? {
URL(string: returnURLString)
}
public init(bundleId: String, displayNameKey: String, returnURLString: String, tier: Int) {
self.bundleId = bundleId
self.displayNameKey = displayNameKey
self.returnURLString = returnURLString
self.tier = tier
}
}
public enum HostAppURLRegistry {
/// Curated whitelist for high-frequency host apps (IM, work, notes).
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
)
]
private static let byBundleId: [String: HostAppEntry] = {
Dictionary(uniqueKeysWithValues: entries.map { ($0.bundleId, $0) })
}()
public static func lookup(bundleId: String?) -> HostAppEntry? {
guard let bundleId, !bundleId.isEmpty else { return nil }
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()
}
}