Files
Rocky e09ad34437 feat(flow): on-demand host handoff with reliable return and a back-tap tip
Keyboard
- Only jump to the app when the voice surface is shown (tab tap / opened
  there); typing tabs no longer leave the host on keyboard open.
- Wait up to 0.4 s for the arbiter to report the host app before leaving,
  and late-bind an id that arrives within 6 s of the jump.
- Empty arbiter callbacks no longer wipe an already captured host id;
  install the capture handler in every build configuration.

App
- Return to the previous app with up to four retries using iOS's real
  open result, and honor the skip-app-switch preference.
- Keep the pending host id across cold-start zombie reconciliation, and
  expire it after two minutes (legacy values without a timestamp stay valid).
- Show a light tip pointing at the system back control when we cannot
  return automatically ("点击返回,立即使用").

Tests: TTL behaviour and return retry policy.
2026-10-29 16:32:52 +08:00

134 lines
4.8 KiB
Objective-C
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// OSGHostArbiterProbe.m
// OSGKeyboard · Keyboard Extension
//
// 宿主识别探针(所有构建配置均编译),见头文件说明。所有私有符号都在运行时按字符串查找,
// 任何一步找不到都静默放弃,并保证原始实现照常执行。
#import "OSGHostArbiterProbe.h"
#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 的回调(焦点切换、空事件)不再清掉已拿到的值:
// 之前“最后一个事件为准”会让一次空回调把正确的宿主抹掉,是识别忽有忽无的来源之一。
// 宿主真的变了,由键盘收起时的 invalidateCapturedBundleId 作废旧值。
if (bundleId.length > 0) {
sLastCapturedBundleId = bundleId;
}
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