Skip to content
Merged
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
1 change: 1 addition & 0 deletions src/ClickZen.App/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ public App()
// Devices
sc.AddSingleton<AdbServerHost>();
sc.AddSingleton<AdbService>();
sc.AddSingleton<ClickZen.Device.Adb.Wireless.IAdbMdnsDiscovery, ClickZen.Device.Adb.Wireless.AdbMdnsDiscovery>();
sc.AddSingleton<DeviceWatcher>();
sc.AddSingleton<SavedDeviceStore>();
sc.AddSingleton(sp => new ClickZen.Core.Devices.EmulatorProfileStore(sp.GetRequiredService<AppPaths>(), sp.GetRequiredService<ILogger<ClickZen.Core.Devices.EmulatorProfileStore>>()));
Expand Down
125 changes: 109 additions & 16 deletions src/ClickZen.App/Services/DeviceHub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,19 @@ public DeviceHub(AdbServerHost server, AdbService adb, DeviceWatcher watcher, Au
_watcher.DevicesChanged += OnDevicesChanged;
_watcher.TrackingChanged += (_, tracking) => Ui(() => IsAdbAvailable = tracking);
_profiles.Changed += (_, _) => Ui(SyncProfiles);
_settings.Changed += OnSettingsChanged;
}

private void OnSettingsChanged(object? sender, ClickZen.Core.Settings.AppSettings settings)
{
var suppress = settings.Devices.SuppressSoftKeyboard;
foreach (var entry in Devices.ToArray())
{
if (entry.Session is { } session)
{
_ = session.SetSuppressSoftKeyboardAsync(suppress);
}
}
}

public ObservableCollection<DeviceEntry> Devices { get; } = [];
Expand Down Expand Up @@ -291,25 +304,21 @@ public async Task RefreshAsync(CancellationToken ct = default)
var pref = _settings.Current.Devices.InputMethod;
if (pref == InputMethodPreference.Root)
{
var (touch, raw) = await RootInputOfAsync(entry.Serial, ct);
if (touch is not null)
{
return new ClickZen.Device.Input.RootSendeventInjector(
(cmd, c) => _adb.RootShellAsync(entry.Serial, cmd, c), touch,
() => entry.EffectivePhysicalSize,
() => entry.Session is { } s && !s.VideoSize.IsEmpty && s.VideoSize.IsLandscape != entry.EffectivePhysicalSize.IsLandscape
? ClickZen.Core.Geometry.DisplayRotation.Rotation90
: ClickZen.Core.Geometry.DisplayRotation.Rotation0,
raw);
}

_log.LogWarning("No touchscreen found for root input on {Serial}; using adb input as root", entry.Serial);
return ClickZen.Device.Input.AdbInputInjector.For(_adb, entry.Serial, entry.Info.SdkLevel, asRoot: true, () => entry.ActiveDisplayId);
return await GetRootInjectorCoreAsync(entry, ct);
}

if (pref == InputMethodPreference.Scrcpy && entry.Session is { IsControlAvailable: true } session)
if (pref == InputMethodPreference.Scrcpy && entry.Session is { } session)
{
return session.Injector;
if (session.IsControlAvailable)
{
return session.Injector;
}

if (session.IsInputRejected && await HasRootAccessCachedAsync(entry.Serial, ct))
{
_log.LogInformation("ROM blocked scrcpy INJECT_EVENTS on {Serial}; automatically routing input via persistent root", entry.Serial);
return await GetRootInjectorCoreAsync(entry, ct);
}
}

return ClickZen.Device.Input.AdbInputInjector.For(_adb, entry.Serial, entry.Info.SdkLevel, asRoot: false, () => entry.ActiveDisplayId);
Expand All @@ -318,6 +327,68 @@ public async Task RefreshAsync(CancellationToken ct = default)
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, Task<(ClickZen.Device.Input.InputDeviceInfo? Touch, bool Raw)>> _rootInput =
new(StringComparer.Ordinal);

private readonly System.Collections.Concurrent.ConcurrentDictionary<string, ClickZen.Device.Input.PersistentRootInjector> _persistentRoot =
new(StringComparer.Ordinal);

private readonly System.Collections.Concurrent.ConcurrentDictionary<string, Task<bool>> _rootAvailability =
new(StringComparer.Ordinal);

private async Task<ClickZen.Core.Input.ITouchInjector> GetRootInjectorCoreAsync(DeviceEntry entry, CancellationToken ct)
{
var (touch, raw) = await RootInputOfAsync(entry.Serial, ct);
if (touch is not null)
{
return _persistentRoot.GetOrAdd(entry.Serial, s =>
{
var fallback = new ClickZen.Device.Input.RootSendeventInjector(
(cmd, c) => _adb.RootShellAsync(s, cmd, c), touch,
() => entry.EffectivePhysicalSize,
() => entry.Session is { } sess && !sess.VideoSize.IsEmpty && sess.VideoSize.IsLandscape != entry.EffectivePhysicalSize.IsLandscape
? ClickZen.Core.Geometry.DisplayRotation.Rotation90
: ClickZen.Core.Geometry.DisplayRotation.Rotation0,
raw);
return ClickZen.Device.Input.PersistentRootInjector.ForAdbSocket(
_server.EndPoint,
s,
fallback,
touch,
raw,
() => entry.ActiveDisplayId,
logger: _loggers.CreateLogger<ClickZen.Device.Input.PersistentRootInjector>());
});
}

_log.LogWarning("No touchscreen found for root input on {Serial}; using adb input as root", entry.Serial);
return ClickZen.Device.Input.AdbInputInjector.For(_adb, entry.Serial, entry.Info.SdkLevel, asRoot: true, () => entry.ActiveDisplayId);
}

private async Task<bool> HasRootAccessCachedAsync(string serial, CancellationToken ct)
{
var task = _rootAvailability.GetOrAdd(serial, async s =>
{
try
{
var check = await _adb.CheckRootAsync(s, TimeSpan.FromSeconds(4), CancellationToken.None);
return check.IsRooted;
}
catch (Exception)
{
return false;
}
});
return await task.WaitAsync(ct);
}

private async Task ResetDeviceInputCachesAsync(string serial)
{
_rootInput.TryRemove(serial, out _);
_rootAvailability.TryRemove(serial, out _);
if (_persistentRoot.TryRemove(serial, out var rootInj))
{
await rootInj.DisposeAsync();
}
}

/// <summary>
/// Touchscreen node and raw-event support of a rooted device, probed once per connection (getevent -p and the
/// shell probe cost about a second, which every tap paid before). Failed probes are not cached.
Expand Down Expand Up @@ -372,6 +443,7 @@ public async Task ConnectAsync(DeviceEntry entry, CancellationToken ct = default
MaxFps = d.MaxFps,
StayAwake = m.StayAwake,
ShowTouches = m.ShowTouches,
SuppressSoftKeyboard = d.SuppressSoftKeyboard,
AutoReconnect = d.AutoReconnect,
}, _loggers.CreateLogger<ScrcpySession>())
{
Expand All @@ -382,6 +454,7 @@ public async Task ConnectAsync(DeviceEntry entry, CancellationToken ct = default
entry.SessionState = s;
entry.SessionError = session.LastError;
});
session.InputRejected += (_, _) => _ = HandleInputRejectedAsync(entry);
entry.Session = session;
Current ??= entry;
await session.StartAsync(ct);
Expand Down Expand Up @@ -527,9 +600,22 @@ public async Task DisconnectAsync(DeviceEntry entry)

entry.Session = null;
await session.DisposeAsync();
await ResetDeviceInputCachesAsync(entry.Serial);
entry.SessionState = SessionState.Disconnected;
}

private async Task HandleInputRejectedAsync(DeviceEntry entry)
{
if (await HasRootAccessCachedAsync(entry.Serial, CancellationToken.None))
{
Ui(() => entry.SessionError = _loc["Devices_InputSwitchedToRoot"]);
}
else
{
Ui(() => entry.SessionError = _loc["Devices_InputPermissionHint"]);
}
}

private void EnsureFfmpeg()
{
if (_ffmpegReady)
Expand Down Expand Up @@ -637,13 +723,20 @@ private void Ui(Action action)

public async ValueTask DisposeAsync()
{
_settings.Changed -= OnSettingsChanged;
_windowMonitor?.Cancel();
_displayMonitor?.Cancel();
foreach (var e in Devices.ToList())
{
await DisconnectAsync(e);
}

foreach (var kv in _persistentRoot)
{
await kv.Value.DisposeAsync();
}

_persistentRoot.Clear();
await _watcher.DisposeAsync();
}
}
5 changes: 4 additions & 1 deletion src/ClickZen.App/Services/DeviceUi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ public static string StateText(DeviceAdbState adb, SessionState session, string?
},
};

return string.IsNullOrEmpty(error) || session is SessionState.Streaming or SessionState.Connected
return string.IsNullOrEmpty(error)
? text
: $"{text}: {error}";
}
Expand All @@ -102,6 +102,9 @@ public static Visibility CanStart(DeviceAdbState adb, ILiveFrameSource? frames)

public static Visibility IsNetwork(ConnectionKind kind) => kind is ConnectionKind.Wireless or ConnectionKind.Emulator ? Visibility.Visible : Visibility.Collapsed;

public static Visibility CanSwitchUsbToWireless(ConnectionKind kind, DeviceAdbState adb) =>
kind == ConnectionKind.Usb && adb == DeviceAdbState.Online ? Visibility.Visible : Visibility.Collapsed;

public static Visibility IsWindowKind(ConnectionKind kind) => kind == ConnectionKind.Window ? Visibility.Visible : Visibility.Collapsed;

/// <summary>"Start mirroring" for adb devices, "Start capture" / "Reconnect" for window devices.</summary>
Expand Down
63 changes: 62 additions & 1 deletion src/ClickZen.App/Services/McpBackend.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
using ClickZen.Core.Settings;
using ClickZen.Core.Variables;
using ClickZen.Device.Adb;
using ClickZen.Device.Adb.Wireless;
using ClickZen.Device.Scrcpy;
using ClickZen.Mcp;
using Microsoft.Extensions.Logging;
Expand All @@ -30,6 +31,9 @@ public sealed class McpBackend : IClickZenBackend
private static readonly TimeSpan FreshFrameTimeout = TimeSpan.FromMilliseconds(600);

private readonly DeviceHub _hub;
private readonly AdbService _adb;
private readonly IAdbMdnsDiscovery _mdns;
private readonly SavedDeviceStore _saved;
private readonly EmulatorService _emulators;
private readonly AutomationService _automation;
private readonly RecordingService _recording;
Expand All @@ -45,10 +49,14 @@ public sealed class McpBackend : IClickZenBackend
private CancellationTokenSource? _playCts;
private McpPlaybackInfo? _playback;

public McpBackend(DeviceHub hub, EmulatorService emulators, AutomationService automation, RecordingService recording, VariableStore globals,
public McpBackend(DeviceHub hub, AdbService adb, IAdbMdnsDiscovery mdns, SavedDeviceStore saved,
EmulatorService emulators, AutomationService automation, RecordingService recording, VariableStore globals,
IImageMatcher matcher, SettingsService settings, AppPaths paths, ILogger<McpBackend> log)
{
_hub = hub;
_adb = adb;
_mdns = mdns;
_saved = saved;
_emulators = emulators;
_automation = automation;
_recording = recording;
Expand Down Expand Up @@ -216,6 +224,59 @@ public async Task<McpEmulatorBind> BindEmulatorWindowAsync(string? emulator, Can
});
}

public async Task<IReadOnlyList<McpLanDevice>> DiscoverLanDevicesAsync(CancellationToken ct)
{
var list = await _mdns.DiscoverAsync(TimeSpan.FromMilliseconds(1100), ct);
return list.Select(s => new McpLanDevice(
s.Address,
s.Host,
s.Port,
s.Kind switch
{
MdnsAdbServiceKind.TlsConnect => "tlsConnect",
MdnsAdbServiceKind.TlsPairing => "tlsPairing",
_ => "legacyTcp",
},
s.InstanceName)).ToList();
}

public async Task<McpWirelessEnableResult> EnableWirelessAdbAsync(string? device, int port, bool remember, CancellationToken ct)
{
var (serial, name) = await OnUi(() =>
{
var e = Resolve(device);
var adbSerial = e.IsWindow
? e.AdbSerial ?? throw new McpToolException($"Device \"{e.Info.DisplayName}\" has no linked ADB device.")
: e.Serial;
return (adbSerial, e.Info.DisplayName);
});

var r = await _adb.EnableWirelessAsync(serial, port, ct: ct);
var saved = false;
if (r.Success && remember)
{
var colon = r.Address.LastIndexOf(':');
if (colon > 0 && int.TryParse(r.Address[(colon + 1)..], out var parsedPort))
{
_saved.AddOrUpdate(new SavedDevice
{
Name = name,
Host = r.Address[..colon],
Port = parsedPort,
AutoConnect = true,
});
saved = true;
}
}

if (r.Success)
{
await _hub.RefreshAsync(ct);
}

return new McpWirelessEnableResult(r.Success, r.Address, r.Message, saved);
}

/// <summary>Detects again and resolves the argument; a clear error lists the running emulators when nothing matches.</summary>
private async Task<DetectedEmulator> ResolveEmulatorAsync(string? emulator, CancellationToken ct)
{
Expand Down
16 changes: 16 additions & 0 deletions src/ClickZen.App/Strings/en-US/Resources.resw
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
<data name="Devices_StartMirror.Content" xml:space="preserve"><value>Start mirroring</value></data>
<data name="Devices_StopMirror.Content" xml:space="preserve"><value>Stop mirroring</value></data>
<data name="Devices_SetCurrent.Content" xml:space="preserve"><value>Set as current</value></data>
<data name="Devices_SwitchWireless.Content" xml:space="preserve"><value>Switch to Wi-Fi</value></data>
<data name="Devices_DisconnectWireless.Content" xml:space="preserve"><value>Disconnect</value></data>
<data name="Devices_SavedHeader.Text" xml:space="preserve"><value>Saved wireless devices</value></data>
<data name="Devices_SavedEmpty.Text" xml:space="preserve"><value>Give a wireless device a name when connecting to save it for one-click or automatic reconnection.</value></data>
Expand All @@ -66,6 +67,9 @@
<data name="Devices_RemoveSaved.ToolTipService.ToolTip" xml:space="preserve"><value>Remove</value></data>
<data name="Devices_Connected" xml:space="preserve"><value>Connected to {0}</value></data>
<data name="Devices_Disconnected" xml:space="preserve"><value>Disconnected {0}</value></data>
<data name="Devices_UsbSwitchedWireless" xml:space="preserve"><value>Wireless ADB enabled and connected to {0}; you can unplug USB now</value></data>
<data name="Devices_InputSwitchedToRoot" xml:space="preserve"><value>System blocked standard touch injection; switched to Root fast injection</value></data>
<data name="Devices_InputPermissionHint" xml:space="preserve"><value>System blocked touch injection: enable "USB debugging (Security settings)" or "Disable permission monitoring" in Developer options</value></data>
<data name="Devices_Paired" xml:space="preserve"><value>Paired. Now enter the IP address and port shown on the phone's Wireless debugging screen to connect.</value></data>
<data name="Devices_ScanNone" xml:space="preserve"><value>No local emulators found. Make sure the emulator is running with ADB enabled.</value></data>
<data name="Devices_ScanFound" xml:space="preserve"><value>Connected {0} emulator(s).</value></data>
Expand All @@ -84,9 +88,20 @@
<data name="AdbError_ServerStartFailed" xml:space="preserve"><value>The ADB server could not be started.</value></data>
<data name="AdbError_ConnectionRefused" xml:space="preserve"><value>Connection refused. Check that wireless debugging is on and the address is correct.</value></data>
<data name="AddWireless_Dialog.Title" xml:space="preserve"><value>Add wireless device</value></data>
<data name="AddWireless_TabQr.Text" xml:space="preserve"><value>QR &amp; LAN</value></data>
<data name="AddWireless_TabDirect.Text" xml:space="preserve"><value>Connect by IP</value></data>
<data name="AddWireless_TabPair.Text" xml:space="preserve"><value>Pair with code</value></data>
<data name="AddWireless_TabScan.Text" xml:space="preserve"><value>Scan emulators</value></data>
<data name="AddWireless_QrHint.Text" xml:space="preserve"><value>Android 11+: on the phone open Developer options -&gt; Wireless debugging -&gt; Pair device with QR code and scan below to pair and connect automatically.</value></data>
<data name="AddWireless_QrRefresh.Content" xml:space="preserve"><value>New QR code</value></data>
<data name="AddWireless_QrWaiting" xml:space="preserve"><value>Waiting for phone to scan…</value></data>
<data name="AddWireless_QrPairing" xml:space="preserve"><value>Phone found at {0}, pairing…</value></data>
<data name="AddWireless_QrConnecting" xml:space="preserve"><value>Paired; connecting to wireless debugging port…</value></data>
<data name="AddWireless_QrPairedManualPort" xml:space="preserve"><value>Paired, but no connect port was broadcast; enter the connect address on the Pair tab.</value></data>
<data name="AddWireless_QrFailed" xml:space="preserve"><value>QR pairing did not finish; generate a new QR code and try again.</value></data>
<data name="AddWireless_LanHeader.Text" xml:space="preserve"><value>Discovered on LAN (mDNS)</value></data>
<data name="AddWireless_LanRefresh.Content" xml:space="preserve"><value>Refresh</value></data>
<data name="AddWireless_LanEmpty.Text" xml:space="preserve"><value>No wireless debugging broadcasts found on LAN yet. Turn on Wireless debugging on the phone to appear here.</value></data>
<data name="AddWireless_DirectHint.Text" xml:space="preserve"><value>For devices with TCP debugging enabled (adb tcpip) or emulators on the network. Port defaults to 5555.</value></data>
<data name="AddWireless_Address.Header" xml:space="preserve"><value>Address</value></data>
<data name="AddWireless_Address.PlaceholderText" xml:space="preserve"><value>192.168.1.20:5555</value></data>
Expand Down Expand Up @@ -684,6 +699,7 @@
<data name="Settings_Devices_VideoBitRateMbps" xml:space="preserve"><value>Bit rate (Mbps)</value></data>
<data name="Settings_Devices_MaxFps" xml:space="preserve"><value>Maximum FPS</value></data>
<data name="Settings_Devices_InputMethod" xml:space="preserve"><value>Input method</value></data>
<data name="Settings_Devices_SuppressSoftKeyboard" xml:space="preserve"><value>Suppress on-screen soft keyboard (UHID hardware keyboard)</value></data>
<data name="Settings_Mirror_HighQualityScaling" xml:space="preserve"><value>High-quality scaling</value></data>
<data name="Settings_Mirror_ShowTouches" xml:space="preserve"><value>Show touches</value></data>
<data name="Settings_Mirror_StayAwake" xml:space="preserve"><value>Stay awake</value></data>
Expand Down
1 change: 1 addition & 0 deletions src/ClickZen.App/Strings/strings.settings.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Settings_Devices_MaxSize 最长边(0 为原始分辨率) Maximum edge (0 for
Settings_Devices_VideoBitRateMbps 码率(Mbps) Bit rate (Mbps)
Settings_Devices_MaxFps 最大帧率 Maximum FPS
Settings_Devices_InputMethod 输入方式 Input method
Settings_Devices_SuppressSoftKeyboard 抑制手机软键盘弹出(UHID 外接键盘模式) Suppress on-screen soft keyboard (UHID hardware keyboard)
Settings_Mirror_HighQualityScaling 高质量缩放 High-quality scaling
Settings_Mirror_ShowTouches 显示触点 Show touches
Settings_Mirror_StayAwake 保持常亮 Stay awake
Expand Down
Loading
Loading