Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ All notable changes to iPhoneMirror are documented here. The project follows

## [Unreleased]

### Added

- Add opt-in absolute Bluetooth mouse input in the main and independent previews, with seamless cursor movement between Windows and the mirrored image.

### Fixed

- Improve Chinese IME key forwarding, shortcut press/release recovery and preview focus handling. See the [Sogou verification record](docs/SOGOU_IME_VERIFICATION_2026-10-31.zh-CN.md) for tested scope.
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.zh-TW.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ iPhoneMirror 的重要變更均記錄於此。已發布版本遵循[語意化版

## [Unreleased]

### 新增

- 新增可選的絕對座標藍牙滑鼠模式,可在主預覽、獨立投影視窗和 Windows 桌面之間直接移動游標。

### 修正

- 改善中文輸入法按鍵轉發、快捷鍵按下與放開恢復,以及預覽焦點處理。
Expand Down
8 changes: 7 additions & 1 deletion docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,12 +121,18 @@ iPhoneMirror 的控制链路与投屏链路分开:电脑蓝牙会以标准 BLE
3. 选择设备后打开左侧“投屏”面板,点击“启用蓝牙反向控制”。可以在开始投屏前完成蓝牙
广播和配对;首次连接时,在蓝牙客户端绑定窗口中确认与当前手机对应的客户端。绑定完成后,
主预览中的鼠标移动、单击、拖拽、滚轮和常用键盘按键会发送到该设备。
4. 反向控制开启后会隐藏 Windows 本机光标;默认按 `F9` 可释放鼠标并暂停输入,再按一次恢复控制。正常暂停期间保留蓝牙连接,恢复时不重复弹出连接提示;释放输入失败时会关闭连接,下次开启需重新连接。独立窗口可恢复拖动(手动固定的窗口除外)。也可以在
4. 默认的相对鼠标模式会隐藏 Windows 本机光标;默认按 `F9` 可释放鼠标并暂停输入,再按一次恢复控制。正常暂停期间保留蓝牙连接,恢复时不重复弹出连接提示;释放输入失败时会关闭连接,下次开启需重新连接。独立窗口可恢复拖动(手动固定的窗口除外)。也可以在
“设置快捷键”窗口中改为其他全局快捷键。该窗口还可配置有线投屏、无线投屏、蓝牙控制、
控制中心、通知中心、App 切换器、主屏幕、Boss 键、音量、锁屏、Dock 和 Siri。F12 保留为
不可设置的系统键;F1-F11、右键和中键可以直接录入。滚轮方向和鼠标灵敏度可按竖屏/横屏
分别设置,打开独立预览窗口时,主窗口反向控制会自动关闭。

如需直接点选投屏画面,请先停止蓝牙反向控制,在“设置 → 反向控制设置”勾选
“在投屏画面内使用绝对坐标鼠标(无需 F9)”,再启动蓝牙反向控制。主预览和独立窗口
会把鼠标位置映射到视频内容,移出画面或失去焦点时释放按键。Windows 光标保持可见。
切换相对与绝对模式会更换 BLE HID 报告描述符。若设备缓存了旧描述符,请在系统蓝牙
设置中忽略该设备,按连接提示重新配对。设备对绝对坐标鼠标的支持取决于系统版本。

控制能力受 iOS 系统鼠标模型限制:主要是单指指针操作,不能注入真正的多指触控或游戏
触摸事件。停止投屏时控制广播会自动关闭;也可以在同一面板手动关闭。客户端断开后不会
自动转移到另一台手机,原客户端重新连接后才会恢复;可在设置中解除单个绑定或清除全部绑定。
Expand Down
226 changes: 226 additions & 0 deletions src/App.Runtime.Tests/BluetoothInputToggleTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ private static int RunBluetoothInputTransportTests()
{
Task.Run(TestBluetoothPauseRouteChangeAsync).GetAwaiter().GetResult();
Task.Run(TestBluetoothMouseAdmissionAsync).GetAwaiter().GetResult();
Task.Run(TestWindowBluetoothMouseClickAsync).GetAwaiter().GetResult();
Task.Run(TestAbsoluteWindowMouseAsync).GetAwaiter().GetResult();
Task.Run(TestBluetoothHidSessionAdmissionAsync).GetAwaiter().GetResult();
TestBluetoothNotificationResults();
return 0;
Expand Down Expand Up @@ -380,6 +382,20 @@ void Ready()
KeyboardCall(shell, "ToggleFixedWindow");
Require(Hit() == 2, "unfixing a paused independent window did not restore dragging.");
Console.WriteLine("PASS independent Bluetooth window: temporary input hit-testing, restored caption dragging/capture, explicit fixed preference retained.");
Toggle();
SetKeyboardField(main, "_controlPointerFlushInFlight", 1);
try
{
TestWindowBluetoothMouseHandoff(main, device, shell.Handle, hwnd => foreground = hwnd);
TestWindowBluetoothPointer(main, device);
}
finally
{
SetKeyboardField(main, "_bluetoothWindowMouseEnabled", false);
KeyboardCall(main, "ResetControlRouteState");
SetKeyboardField(main, "_controlPointerFlushInFlight", 0);
}
Toggle();
AwaitMapping(vm.DisableBluetoothControlAsync());
Require(!vm.IsBluetoothControlEnabled && !vm.BluetoothControlIsInputPaused &&
vm.BluetoothControlTargetUdid is null,
Expand Down Expand Up @@ -546,6 +562,216 @@ private static void TestBluetoothNotificationResults()
Console.WriteLine("PASS notification result contract: only Success with exact bytes and no protocol error confirms delivery.");
}

private static void TestWindowBluetoothMouseHandoff(MainWindow window, DeviceViewModel device,
nint independent, Action<nint> setForeground)
{
SetKeyboardField(window, "_bluetoothWindowMouseEnabled", true);
var vm = KeyboardField(window, "_viewModel");
var service = KeyboardField(vm, "_bluetoothControl");
var main = new WindowInteropHelper(window).Handle;
var preview = KeyboardField(window, "MainPreviewHost");
var sends = (HashSet<Task>)KeyboardField(window, "_keyboardSends");
SetKeyboardField(service, "_useAbsoluteMouse", true);
try
{
SetKeyboardField(window, "_controlButtons", (byte)1);
KeyboardCall(window, "ApplyBluetoothControlInputState", false);
InteractionAssert((byte)KeyboardField(window, "_controlButtons") == 0,
"Enabling absolute input must discard previous drag state.");

foreach (var owner in new[] { main, independent })
{
var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
sends.Add(pending.Task);
try
{
setForeground(owner);
KeyboardCall(window, "FocusControlDevice", device.Udid, owner == main ? (nint)0 : owner);
KeyboardCall(window, "ResetKeyboardOwnership");
var handoff = (Task)KeyboardField(window, "_keyboardHandoff");
InteractionAssert(!handoff.IsCompleted &&
!(bool)KeyboardField(window, "_rawMouseInputEnabled"),
"Absolute window mode must keep regular mouse input while keyboard input drains.");
pending.SetResult();
AwaitMapping(handoff);
InteractionAssert(!(bool)KeyboardField(window, "_rawMouseInputEnabled") &&
!(bool)KeyboardField(preview, "SuppressLegacyMouseButtons"),
"A delayed keyboard handoff must not suppress normal mouse clicks in either preview.");
}
finally
{
pending.TrySetResult();
sends.Remove(pending.Task);
}
}

void Pointer(PreviewPointerKind kind, byte button = 0, nint? keys = null)
{
KeyboardCall(window, "OnIndependentPointerInput", device.Udid,
new PreviewPointerEventArgs(kind, 150, 150, button, 0, 300, 300, 100, 300)
{
WindowsButtons = keys is nint state ? NativePreviewMouse.PressedButtons(state) : null,
});
}
byte Buttons() => (byte)KeyboardField(window, "_controlButtons");
Pointer(PreviewPointerKind.Move, keys: 0x01);
InteractionAssert(Buttons() == 0, "Movement must not inherit a Windows button held before activation.");
Pointer(PreviewPointerKind.ButtonDown, button: 1);
Pointer(PreviewPointerKind.ButtonDown, button: 2);
Pointer(PreviewPointerKind.ButtonDown, button: 4);
Pointer(PreviewPointerKind.Move, keys: 0x12);
InteractionAssert(Buttons() == 6, "Movement must clear only the released button and retain right/middle.");
Pointer(PreviewPointerKind.Wheel, keys: unchecked((nint)0xFF880000L));
InteractionAssert(Buttons() == 0, "A wheel snapshot must clear missed releases even without a scroll tick.");
}
finally
{
SetKeyboardField(service, "_useAbsoluteMouse", false);
}
Console.WriteLine("PASS absolute mouse: both-window handoff, startup drag isolation and missed-release recovery.");
}

private static void TestWindowBluetoothPointer(MainWindow window, DeviceViewModel device)
{
KeyboardCall(window, "ResetControlRouteState");
SetKeyboardField(window, "_bluetoothWindowMouseEnabled", true);
KeyboardCall(window, "ApplyBluetoothControlInputState", false);
InteractionAssert(!(bool)KeyboardField(window, "_rawMouseInputEnabled") &&
!(bool)KeyboardField(window, "_windowsCursorHidden"),
"Window mouse must leave Windows mouse messages and cursor available.");

void Send(PreviewPointerKind kind, short x = 150, byte button = 0, int wheel = 0,
int width = 300) => KeyboardCall(window, "OnIndependentPointerInput", device.Udid,
new PreviewPointerEventArgs(kind, x, 150, button, wheel, width, 300, 100, 300));
byte Buttons() => (byte)KeyboardField(window, "_controlButtons");
int Delta() => (int)KeyboardField(window, "_pendingControlDx");

Send(PreviewPointerKind.ButtonDown, x: 20, button: 1);
InteractionAssert(Buttons() == 0, "Clicks on black bars must stay local.");
Send(PreviewPointerKind.Move, x: 145);
InteractionAssert(Delta() == 0, "Entering the picture must anchor relative movement.");
Send(PreviewPointerKind.ButtonDown, button: 1);
Send(PreviewPointerKind.Move, x: 170);
InteractionAssert(Buttons() == 1 && Delta() != 0, "An in-picture drag must send motion and a held button.");
Send(PreviewPointerKind.Move, x: 210);
InteractionAssert(Buttons() == 0 && Delta() == 0,
"Crossing into a black bar must release the drag and discard queued movement.");
Send(PreviewPointerKind.Move, x: 165);
Send(PreviewPointerKind.ButtonUp, button: 1);
InteractionAssert(Buttons() == 0 && Delta() == 0,
"Re-entry must anchor again without restarting the old drag.");
Send(PreviewPointerKind.Wheel, wheel: 120);
InteractionAssert((int)KeyboardField(window, "_pendingControlWheel") != 0,
"The wheel must reach the device inside the picture.");
Send(PreviewPointerKind.Move, x: 175, width: 320);
InteractionAssert(Delta() == 0, "Resizing must re-anchor without a pointer jump.");

Send(PreviewPointerKind.ButtonDown, button: 1);
Send(PreviewPointerKind.Reset); // WM_MOUSELEAVE or capture/focus loss
InteractionAssert(Buttons() == 0 && (int)KeyboardField(window, "_pendingControlWheel") == 0,
"Leaving the HWND must release the drag and clear pending wheel input.");
Console.WriteLine("Bluetooth window pointer: legacy Windows input, black bars, drag release, re-entry, wheel and resize passed.");
}

private static async Task TestAbsoluteWindowMouseAsync()
{
var delivered = new ConcurrentQueue<(byte Id, byte[] Payload)>();
await using var service = new BluetoothHidMouseService((id, payload, guard) =>
{
if (id is 2 or 6 && guard?.Invoke() != false) delivered.Enqueue((id, payload.ToArray()));
return Task.FromResult(true);
});
const string target = "absolute-window-mouse";
SetKeyboardField(service, "_useAbsoluteMouse", true);
SetKeyboardField(service, "_targetDeviceUdid", target);
void HoldPump() => SetKeyboardField(service, "_mousePumpRunning", true);
Task Pump() => (Task)KeyboardCall(service, "PumpReportsAsync",
KeyboardField(service, "_mousePumpGeneration"))!;
Task Point(double x, double y, byte buttons, bool reset = false) =>
service.SendAbsoluteMouseAsync(x, y, buttons, 0, target, () => true, reset);
static int X(byte[] report) => report[1] | report[2] << 8;

HoldPump();
await Point(0, 1, 1);
await Point(0, 1, 0);
await Pump();
MappingAssert(delivered.Select(report => (report.Id, report.Payload[0], X(report.Payload)))
.SequenceEqual(new (byte, byte, int)[] { (2, 0, 0), (6, 1, 0), (6, 0, 0), (2, 0, 0) }),
"A quick click must position first and preserve both relative button edges.");
delivered.Clear();

for (var i = 0; i < 2; i++)
{
HoldPump();
await Point(0.5, 0.25, 1);
await Point(0.75, 0.25, 0);
await Pump();
MappingAssert(delivered.Select(report => (report.Id, report.Payload[0], X(report.Payload)))
.SequenceEqual(new (byte, byte, int)[] { (2, 0, 16384), (6, 1, 0),
(6, 1, 256), (6, 0, 0), (2, 0, 24575) }),
"Each consecutive drag must move and release on report 6, then restore the absolute pointer.");
delivered.Clear();
}

HoldPump();
await Point(0.5, 0.25, 1);
await Point(0.75, 0.25, 1);
await Pump();
delivered.Clear();

HoldPump();
await Point(0.5, 0.25, 0);
await Pump();
var returnDrag = delivered.ToArray();
MappingAssert(returnDrag.Select(report => report.Id).SequenceEqual(new byte[] { 6, 6, 2, 2 }) &&
returnDrag[1].Payload[0] == 0 && X(returnDrag[2].Payload) != 16384 &&
X(returnDrag[3].Payload) == 16384,
"A drag that returns to its original position needs a changed anchor before restoring that position.");
delivered.Clear();

HoldPump();
await Point(0.5, 0.25, 1);
await Pump();
delivered.Clear();

HoldPump();
await Point(0.9, 0.9, 1);
await Point(0.75, 0.25, 0, reset: true);
await Point(1, 0, 0);
await Pump();
MappingAssert(delivered.Select(report => (report.Id, report.Payload[0], X(report.Payload)))
.SequenceEqual(new (byte, byte, int)[] { (6, 0, 0), (2, 0, 24575), (2, 0, 32767) }),
"Leaving the surface must release without sending stale drag movement before re-entry.");
delivered.Clear();

MappingAssert(await service.PauseInputAsync(), "Absolute mouse pause could not release the device.");
MappingAssert(delivered.Select(report => (report.Id, report.Payload[0], X(report.Payload)))
.SequenceEqual(new (byte, byte, int)[] { (6, 0, 0) }),
"Pausing must release the relative buttons without repositioning.");
Console.WriteLine("PASS hybrid window mouse: click, consecutive drags, re-anchor, re-entry and pause.");
}

private static async Task TestWindowBluetoothMouseClickAsync()
{
var delivered = new ConcurrentQueue<byte>();
await using var service = new BluetoothHidMouseService((id, payload, guard) =>
{
if (id == 2 && guard?.Invoke() != false) delivered.Enqueue(payload[0]);
return Task.FromResult(true);
});
SetKeyboardField(service, "_targetDeviceUdid", "window-mouse");
SetKeyboardField(service, "_mousePumpRunning", true);
Task Send(byte buttons, int wheel = 0) => service.SendMouseAsync(0, 0, buttons, wheel,
"window-mouse", preserveButtonTransitions: true);
await Send(1);
await Send(1, wheel: 1);
await Send(0);
await (Task)KeyboardCall(service, "PumpReportsAsync", KeyboardField(service, "_mousePumpGeneration"))!;
MappingAssert(delivered.SequenceEqual(new byte[] { 1, 0 }),
"A fast window click must retain both button transitions while BLE is busy.");
Console.WriteLine("PASS Bluetooth window mouse: queued click edges survive a wheel tick and a busy BLE pump.");
}

private static async Task TestBluetoothMouseAdmissionAsync()
{
var delivered = new ConcurrentQueue<byte[]>();
Expand Down
Loading