From 9eeabb8b8653f3c419a0ad509b950d50fe8b54c5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 19:54:04 +0000 Subject: [PATCH 1/4] feat: add simulator extras (privacy, appearance, status_bar, openurl, push, location, addmedia, screen capture) Agent-Logs-Url: https://github.com/dotnet/macios-devtools/sessions/249ea126-48c7-436b-b123-232b305a6be3 Co-authored-by: rmarinho <1235097+rmarinho@users.noreply.github.com> --- Xamarin.MacDev/NullableAttributes.cs | 8 + Xamarin.MacDev/PrivacyPermission.cs | 25 ++ Xamarin.MacDev/SimCtl.cs | 2 +- Xamarin.MacDev/SimulatorAppearance.cs | 15 + Xamarin.MacDev/SimulatorLocation.cs | 76 ++++ Xamarin.MacDev/SimulatorPrivacy.cs | 90 +++++ Xamarin.MacDev/SimulatorScreenCapture.cs | 202 ++++++++++ Xamarin.MacDev/SimulatorScreenCaptureTypes.cs | 38 ++ Xamarin.MacDev/SimulatorService.cs | 2 +- Xamarin.MacDev/SimulatorServiceExtras.cs | 164 ++++++++ Xamarin.MacDev/SimulatorStatusBar.cs | 171 ++++++++ tests/SimulatorServiceExtrasTests.cs | 377 ++++++++++++++++++ 12 files changed, 1168 insertions(+), 2 deletions(-) create mode 100644 Xamarin.MacDev/PrivacyPermission.cs create mode 100644 Xamarin.MacDev/SimulatorAppearance.cs create mode 100644 Xamarin.MacDev/SimulatorLocation.cs create mode 100644 Xamarin.MacDev/SimulatorPrivacy.cs create mode 100644 Xamarin.MacDev/SimulatorScreenCapture.cs create mode 100644 Xamarin.MacDev/SimulatorScreenCaptureTypes.cs create mode 100644 Xamarin.MacDev/SimulatorServiceExtras.cs create mode 100644 Xamarin.MacDev/SimulatorStatusBar.cs create mode 100644 tests/SimulatorServiceExtrasTests.cs diff --git a/Xamarin.MacDev/NullableAttributes.cs b/Xamarin.MacDev/NullableAttributes.cs index 66091d9..d043fc3 100644 --- a/Xamarin.MacDev/NullableAttributes.cs +++ b/Xamarin.MacDev/NullableAttributes.cs @@ -42,3 +42,11 @@ internal sealed class NotNullIfNotNullAttribute : Attribute { } } #endif // !NET + +#if NETSTANDARD2_0 +namespace System.Runtime.CompilerServices { + // Required polyfill for C# 9 records on netstandard2.0 targets. + // See: https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-9.0/records + internal static class IsExternalInit { } +} +#endif // NETSTANDARD2_0 diff --git a/Xamarin.MacDev/PrivacyPermission.cs b/Xamarin.MacDev/PrivacyPermission.cs new file mode 100644 index 0000000..41123a1 --- /dev/null +++ b/Xamarin.MacDev/PrivacyPermission.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +namespace Xamarin.MacDev; + +/// +/// Privacy service categories for xcrun simctl privacy. +/// +public enum PrivacyPermission { + All, + Calendar, + ContactsLimited, + Contacts, + Location, + LocationAlways, + PhotosAdd, + Photos, + MediaLibrary, + Microphone, + Motion, + Reminders, + Siri, +} diff --git a/Xamarin.MacDev/SimCtl.cs b/Xamarin.MacDev/SimCtl.cs index 517549e..b0ae6f2 100644 --- a/Xamarin.MacDev/SimCtl.cs +++ b/Xamarin.MacDev/SimCtl.cs @@ -16,7 +16,7 @@ namespace Xamarin.MacDev; /// public class SimCtl { - static readonly string XcrunPath = "/usr/bin/xcrun"; + internal static readonly string XcrunPath = "/usr/bin/xcrun"; readonly ICustomLogger log; diff --git a/Xamarin.MacDev/SimulatorAppearance.cs b/Xamarin.MacDev/SimulatorAppearance.cs new file mode 100644 index 0000000..5a27172 --- /dev/null +++ b/Xamarin.MacDev/SimulatorAppearance.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +namespace Xamarin.MacDev; + +/// +/// The UI appearance (theme) of a simulator. +/// Used with xcrun simctl ui <udid> appearance. +/// +public enum SimulatorAppearance { + Light, + Dark, +} diff --git a/Xamarin.MacDev/SimulatorLocation.cs b/Xamarin.MacDev/SimulatorLocation.cs new file mode 100644 index 0000000..5536876 --- /dev/null +++ b/Xamarin.MacDev/SimulatorLocation.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Globalization; + +#nullable enable + +namespace Xamarin.MacDev; + +/// +/// Wraps xcrun simctl location operations for setting, clearing, +/// and simulating GPS routes on a simulator. +/// +public class SimulatorLocation { + + readonly ICustomLogger log; + readonly SimCtl simctl; + + internal SimulatorLocation (ICustomLogger log, SimCtl simctl) + { + this.log = log; + this.simctl = simctl; + } + + /// + /// Sets the simulated GPS location on the simulator. + /// Wraps xcrun simctl location <udid> set <lat>,<lng>. + /// + public bool Set (string udidOrName, double latitude, double longitude) + { + if (string.IsNullOrWhiteSpace (udidOrName)) + throw new ArgumentException ("Simulator UDID or name must not be null or empty.", nameof (udidOrName)); + + var coords = string.Format (CultureInfo.InvariantCulture, "{0},{1}", latitude, longitude); + var result = simctl.Run ("location", udidOrName, "set", coords); + var success = result is not null; + if (success) + log.LogInfo ("simctl location set '{0}' to {1} succeeded.", udidOrName, coords); + return success; + } + + /// + /// Clears the simulated GPS location on the simulator. + /// Wraps xcrun simctl location <udid> clear. + /// + public bool Clear (string udidOrName) + { + if (string.IsNullOrWhiteSpace (udidOrName)) + throw new ArgumentException ("Simulator UDID or name must not be null or empty.", nameof (udidOrName)); + + var result = simctl.Run ("location", udidOrName, "clear"); + var success = result is not null; + if (success) + log.LogInfo ("simctl location clear '{0}' succeeded.", udidOrName); + return success; + } + + /// + /// Runs a GPX route simulation on the simulator. + /// Wraps xcrun simctl location <udid> run <gpxPath>. + /// + public bool Run (string udidOrName, string gpxPath) + { + if (string.IsNullOrWhiteSpace (udidOrName)) + throw new ArgumentException ("Simulator UDID or name must not be null or empty.", nameof (udidOrName)); + if (string.IsNullOrWhiteSpace (gpxPath)) + throw new ArgumentException ("GPX path must not be null or empty.", nameof (gpxPath)); + + var result = simctl.Run ("location", udidOrName, "run", gpxPath); + var success = result is not null; + if (success) + log.LogInfo ("simctl location run '{0}' with '{1}' succeeded.", udidOrName, gpxPath); + return success; + } +} diff --git a/Xamarin.MacDev/SimulatorPrivacy.cs b/Xamarin.MacDev/SimulatorPrivacy.cs new file mode 100644 index 0000000..8462c3f --- /dev/null +++ b/Xamarin.MacDev/SimulatorPrivacy.cs @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; + +#nullable enable + +namespace Xamarin.MacDev; + +/// +/// Wraps xcrun simctl privacy operations for granting, revoking, +/// and resetting simulator privacy permissions. +/// +public class SimulatorPrivacy { + + readonly ICustomLogger log; + readonly SimCtl simctl; + + internal SimulatorPrivacy (ICustomLogger log, SimCtl simctl) + { + this.log = log; + this.simctl = simctl; + } + + /// + /// Grants a privacy permission for all apps or a specific bundle on the simulator. + /// Wraps xcrun simctl privacy <udid> grant <service> [bundleId]. + /// + public bool Grant (string udidOrName, PrivacyPermission permission, string? bundleIdentifier = null) + { + return RunPrivacy ("grant", udidOrName, permission, bundleIdentifier); + } + + /// + /// Revokes a privacy permission for all apps or a specific bundle on the simulator. + /// Wraps xcrun simctl privacy <udid> revoke <service> [bundleId]. + /// + public bool Revoke (string udidOrName, PrivacyPermission permission, string? bundleIdentifier = null) + { + return RunPrivacy ("revoke", udidOrName, permission, bundleIdentifier); + } + + /// + /// Resets a privacy permission for all apps or a specific bundle on the simulator. + /// Wraps xcrun simctl privacy <udid> reset <service> [bundleId]. + /// + public bool Reset (string udidOrName, PrivacyPermission permission, string? bundleIdentifier = null) + { + return RunPrivacy ("reset", udidOrName, permission, bundleIdentifier); + } + + bool RunPrivacy (string action, string udidOrName, PrivacyPermission permission, string? bundleIdentifier) + { + if (string.IsNullOrWhiteSpace (udidOrName)) + throw new ArgumentException ("Simulator UDID or name must not be null or empty.", nameof (udidOrName)); + + var service = ToSimctlServiceName (permission); + + string? result; + if (!string.IsNullOrEmpty (bundleIdentifier)) + result = simctl.Run ("privacy", udidOrName, action, service, bundleIdentifier!); + else + result = simctl.Run ("privacy", udidOrName, action, service); + + var success = result is not null; + if (success) + log.LogInfo ("simctl privacy {0} {1} {2} succeeded.", udidOrName, action, service); + return success; + } + + public static string ToSimctlServiceName (PrivacyPermission permission) + { + return permission switch { + PrivacyPermission.All => "all", + PrivacyPermission.Calendar => "calendar", + PrivacyPermission.ContactsLimited => "contacts-limited", + PrivacyPermission.Contacts => "contacts", + PrivacyPermission.Location => "location", + PrivacyPermission.LocationAlways => "location-always", + PrivacyPermission.PhotosAdd => "photos-add", + PrivacyPermission.Photos => "photos", + PrivacyPermission.MediaLibrary => "media-library", + PrivacyPermission.Microphone => "microphone", + PrivacyPermission.Motion => "motion", + PrivacyPermission.Reminders => "reminders", + PrivacyPermission.Siri => "siri", + _ => throw new ArgumentOutOfRangeException (nameof (permission), permission, null), + }; + } +} diff --git a/Xamarin.MacDev/SimulatorScreenCapture.cs b/Xamarin.MacDev/SimulatorScreenCapture.cs new file mode 100644 index 0000000..af6a01a --- /dev/null +++ b/Xamarin.MacDev/SimulatorScreenCapture.cs @@ -0,0 +1,202 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; + +#nullable enable + +namespace Xamarin.MacDev; + +/// +/// Wraps xcrun simctl io operations for taking screenshots and recording video +/// from a simulator. +/// +public class SimulatorScreenCapture { + + readonly ICustomLogger log; + readonly SimCtl simctl; + + internal SimulatorScreenCapture (ICustomLogger log, SimCtl simctl) + { + this.log = log; + this.simctl = simctl; + } + + /// + /// Takes a screenshot of the simulator and saves it to . + /// Wraps xcrun simctl io <udid> screenshot [--type=png|jpeg|tiff|bmp] <path>. + /// + public bool Screenshot (string udidOrName, string outputPath, ScreenshotFormat format = ScreenshotFormat.Png) + { + if (string.IsNullOrWhiteSpace (udidOrName)) + throw new ArgumentException ("Simulator UDID or name must not be null or empty.", nameof (udidOrName)); + if (string.IsNullOrWhiteSpace (outputPath)) + throw new ArgumentException ("Output path must not be null or empty.", nameof (outputPath)); + + var formatArg = "--type=" + ToSimctlFormatName (format); + var result = simctl.Run ("io", udidOrName, "screenshot", formatArg, outputPath); + var success = result is not null; + if (success) + log.LogInfo ("simctl io screenshot '{0}' to '{1}' succeeded.", udidOrName, outputPath); + return success; + } + + /// + /// Starts a video recording of the simulator. Dispose the returned handle to stop recording. + /// Wraps xcrun simctl io <udid> recordVideo [options] <path>. + /// + /// + /// A disposable handle; call to stop the recording. + /// Returns null if xcrun cannot be started. + /// + public IDisposable? StartRecording (string udidOrName, string outputPath, RecordingOptions? options = null) + { + if (string.IsNullOrWhiteSpace (udidOrName)) + throw new ArgumentException ("Simulator UDID or name must not be null or empty.", nameof (udidOrName)); + if (string.IsNullOrWhiteSpace (outputPath)) + throw new ArgumentException ("Output path must not be null or empty.", nameof (outputPath)); + + if (!File.Exists (SimCtl.XcrunPath)) { + log.LogInfo ("xcrun not found at '{0}'.", SimCtl.XcrunPath); + return null; + } + + var simctlArgs = BuildRecordArgs (udidOrName, outputPath, options); + + // Build the full argument list: "simctl io recordVideo [opts] " + var allArgs = new string [simctlArgs.Length + 1]; + allArgs [0] = "simctl"; + Array.Copy (simctlArgs, 0, allArgs, 1, simctlArgs.Length); + + log.LogInfo ("Executing: {0} {1}", SimCtl.XcrunPath, string.Join (" ", allArgs)); + + var psi = new ProcessStartInfo (SimCtl.XcrunPath) { + CreateNoWindow = true, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + +#if NETSTANDARD2_0 + psi.Arguments = QuoteArguments (allArgs); +#else + foreach (var arg in allArgs) + psi.ArgumentList.Add (arg); +#endif + + try { + var process = new Process { StartInfo = psi }; + process.Start (); + log.LogInfo ("simctl io recordVideo started for '{0}'.", udidOrName); + return new VideoRecordingSession (process, log); + } catch (System.ComponentModel.Win32Exception ex) { + log.LogInfo ("Could not start xcrun simctl io recordVideo: {0}", ex.Message); + return null; + } catch (InvalidOperationException ex) { + log.LogInfo ("Could not start xcrun simctl io recordVideo: {0}", ex.Message); + return null; + } + } + + static string [] BuildRecordArgs (string udidOrName, string outputPath, RecordingOptions? options) + { + var args = new List { "io", udidOrName, "recordVideo" }; + + if (options?.Format is { } fmt) + args.Add ("--type=" + ToSimctlVideoFormatName (fmt)); + + if (options?.Force == true) + args.Add ("--force"); + + args.Add (outputPath); + return args.ToArray (); + } + + public static string ToSimctlFormatName (ScreenshotFormat format) + { + return format switch { + ScreenshotFormat.Png => "png", + ScreenshotFormat.Jpeg => "jpeg", + ScreenshotFormat.Tiff => "tiff", + ScreenshotFormat.Bmp => "bmp", + _ => throw new ArgumentOutOfRangeException (nameof (format), format, null), + }; + } + + public static string ToSimctlVideoFormatName (VideoRecordingFormat format) + { + return format switch { + VideoRecordingFormat.Mp4 => "mp4", + VideoRecordingFormat.H264 => "h264", + VideoRecordingFormat.Fmp4 => "fmp4", + VideoRecordingFormat.Gif => "gif", + _ => throw new ArgumentOutOfRangeException (nameof (format), format, null), + }; + } + +#if NETSTANDARD2_0 + static string QuoteArguments (string [] arguments) + { + if (arguments.Length == 0) + return string.Empty; + + var sb = new System.Text.StringBuilder (); + for (int i = 0; i < arguments.Length; i++) { + if (i > 0) + sb.Append (' '); + + var arg = arguments [i]; + if (arg.Length > 0 && arg.IndexOfAny (new [] { ' ', '\t', '"' }) < 0) { + sb.Append (arg); + } else { + sb.Append ('"'); + sb.Append (arg.Replace ("\"", "\\\"")); + sb.Append ('"'); + } + } + return sb.ToString (); + } +#endif + + /// + /// Disposable handle that terminates a running simctl io recordVideo process + /// when disposed. + /// + sealed class VideoRecordingSession : IDisposable { + + readonly Process process; + readonly ICustomLogger log; + bool disposed; + + public VideoRecordingSession (Process process, ICustomLogger log) + { + this.process = process; + this.log = log; + } + + public void Dispose () + { + if (disposed) + return; + + disposed = true; + + try { + if (!process.HasExited) { + process.Kill (); + process.WaitForExit (5000); + log.LogInfo ("simctl io recordVideo process stopped."); + } + } catch (InvalidOperationException) { + // Process already exited + } catch (System.ComponentModel.Win32Exception) { + // Cannot kill process (e.g. access denied) + } finally { + process.Dispose (); + } + } + } +} diff --git a/Xamarin.MacDev/SimulatorScreenCaptureTypes.cs b/Xamarin.MacDev/SimulatorScreenCaptureTypes.cs new file mode 100644 index 0000000..b0abf39 --- /dev/null +++ b/Xamarin.MacDev/SimulatorScreenCaptureTypes.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +namespace Xamarin.MacDev; + +/// +/// Screenshot image format for xcrun simctl io screenshot. +/// +public enum ScreenshotFormat { + Png, + Jpeg, + Tiff, + Bmp, +} + +/// +/// Video recording format for xcrun simctl io recordVideo. +/// +public enum VideoRecordingFormat { + Mp4, + H264, + Fmp4, + Gif, +} + +/// +/// Options for xcrun simctl io recordVideo. +/// +public class RecordingOptions { + + /// The output video format. Defaults to mp4 when null. + public VideoRecordingFormat? Format { get; set; } + + /// When true, passes --force to overwrite an existing file. + public bool Force { get; set; } +} diff --git a/Xamarin.MacDev/SimulatorService.cs b/Xamarin.MacDev/SimulatorService.cs index 33f48b6..6cb2fc3 100644 --- a/Xamarin.MacDev/SimulatorService.cs +++ b/Xamarin.MacDev/SimulatorService.cs @@ -15,7 +15,7 @@ namespace Xamarin.MacDev; /// Operation patterns validated against Redth/AppleDev.Tools SimCtl and /// ClientTools.Platform RemoteSimulatorValidator. /// -public class SimulatorService { +public partial class SimulatorService { readonly ICustomLogger log; readonly SimCtl simctl; diff --git a/Xamarin.MacDev/SimulatorServiceExtras.cs b/Xamarin.MacDev/SimulatorServiceExtras.cs new file mode 100644 index 0000000..8c6aaaf --- /dev/null +++ b/Xamarin.MacDev/SimulatorServiceExtras.cs @@ -0,0 +1,164 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; + +#nullable enable + +namespace Xamarin.MacDev; + +public partial class SimulatorService { + + SimulatorPrivacy? privacyService; + SimulatorStatusBar? statusBarService; + SimulatorLocation? locationService; + SimulatorScreenCapture? screenCaptureService; + + /// + /// Provides xcrun simctl privacy operations (grant, revoke, reset). + /// + public SimulatorPrivacy Privacy => privacyService ??= new SimulatorPrivacy (log, simctl); + + /// + /// Provides xcrun simctl status_bar operations (override, clear). + /// + public SimulatorStatusBar StatusBar => statusBarService ??= new SimulatorStatusBar (log, simctl); + + /// + /// Provides xcrun simctl location operations (set, clear, run). + /// + public SimulatorLocation Location => locationService ??= new SimulatorLocation (log, simctl); + + /// + /// Provides xcrun simctl io operations (screenshot, recordVideo). + /// + public SimulatorScreenCapture ScreenCapture => screenCaptureService ??= new SimulatorScreenCapture (log, simctl); + + /// + /// Sets the UI appearance (light/dark) of the simulator. + /// Wraps xcrun simctl ui <udid> appearance light|dark. + /// + public bool SetAppearance (string udidOrName, SimulatorAppearance appearance) + { + if (string.IsNullOrWhiteSpace (udidOrName)) + throw new ArgumentException ("Simulator UDID or name must not be null or empty.", nameof (udidOrName)); + + var value = appearance == SimulatorAppearance.Dark ? "dark" : "light"; + var result = simctl.Run ("ui", udidOrName, "appearance", value); + var success = result is not null; + if (success) + log.LogInfo ("simctl ui appearance '{0}' set to {1}.", udidOrName, value); + return success; + } + + /// + /// Gets the current UI appearance of the simulator. + /// Wraps xcrun simctl ui <udid> appearance. + /// Returns null if the query fails or the output is unrecognised. + /// + public SimulatorAppearance? GetAppearance (string udidOrName) + { + if (string.IsNullOrWhiteSpace (udidOrName)) + throw new ArgumentException ("Simulator UDID or name must not be null or empty.", nameof (udidOrName)); + + var output = simctl.Run ("ui", udidOrName, "appearance"); + if (output is null) + return null; + + var trimmed = output.Trim (); + if (string.Equals (trimmed, "dark", StringComparison.OrdinalIgnoreCase)) + return SimulatorAppearance.Dark; + if (string.Equals (trimmed, "light", StringComparison.OrdinalIgnoreCase)) + return SimulatorAppearance.Light; + + log.LogInfo ("Unrecognised appearance value from simctl: '{0}'.", trimmed); + return null; + } + + /// + /// Opens a URL on the simulator, triggering the registered URL handler or browser. + /// Wraps xcrun simctl openurl <udid> <url>. + /// + public bool OpenUrl (string udidOrName, string url) + { + if (string.IsNullOrWhiteSpace (udidOrName)) + throw new ArgumentException ("Simulator UDID or name must not be null or empty.", nameof (udidOrName)); + if (string.IsNullOrWhiteSpace (url)) + throw new ArgumentException ("URL must not be null or empty.", nameof (url)); + + var result = simctl.Run ("openurl", udidOrName, url); + var success = result is not null; + if (success) + log.LogInfo ("simctl openurl '{0}' succeeded.", udidOrName); + return success; + } + + /// + /// Sends a push notification to the simulator. + /// may be a file path to an APNS JSON payload file + /// or a raw JSON string (starting with {), which is written to a temporary file. + /// Wraps xcrun simctl push <udid> <bundleId> <file>. + /// + public bool Push (string udidOrName, string bundleIdentifier, string payloadJsonOrPath) + { + if (string.IsNullOrWhiteSpace (udidOrName)) + throw new ArgumentException ("Simulator UDID or name must not be null or empty.", nameof (udidOrName)); + if (string.IsNullOrWhiteSpace (bundleIdentifier)) + throw new ArgumentException ("Bundle identifier must not be null or empty.", nameof (bundleIdentifier)); + if (string.IsNullOrWhiteSpace (payloadJsonOrPath)) + throw new ArgumentException ("Payload JSON or path must not be null or empty.", nameof (payloadJsonOrPath)); + + var isInlineJson = payloadJsonOrPath.TrimStart ().StartsWith ("{", StringComparison.Ordinal); + if (isInlineJson) { + var tempPath = Path.GetTempFileName (); + try { + File.WriteAllText (tempPath, payloadJsonOrPath, Encoding.UTF8); + return RunPush (udidOrName, bundleIdentifier, tempPath); + } finally { + try { if (File.Exists (tempPath)) File.Delete (tempPath); } catch { } + } + } + + return RunPush (udidOrName, bundleIdentifier, payloadJsonOrPath); + } + + bool RunPush (string udidOrName, string bundleIdentifier, string payloadPath) + { + var result = simctl.Run ("push", udidOrName, bundleIdentifier, payloadPath); + var success = result is not null; + if (success) + log.LogInfo ("simctl push to '{0}' ({1}) succeeded.", udidOrName, bundleIdentifier); + return success; + } + + /// + /// Adds media files (photos, videos) to the simulator's media library. + /// Wraps xcrun simctl addmedia <udid> <file> .... + /// + public bool AddMedia (string udidOrName, IEnumerable paths) + { + if (string.IsNullOrWhiteSpace (udidOrName)) + throw new ArgumentException ("Simulator UDID or name must not be null or empty.", nameof (udidOrName)); + if (paths is null) + throw new ArgumentNullException (nameof (paths)); + + var pathList = new List (paths); + if (pathList.Count == 0) + throw new ArgumentException ("At least one media path must be provided.", nameof (paths)); + + var args = new string [pathList.Count + 2]; + args [0] = "addmedia"; + args [1] = udidOrName; + for (int i = 0; i < pathList.Count; i++) + args [i + 2] = pathList [i]; + + var result = simctl.Run (args); + var success = result is not null; + if (success) + log.LogInfo ("simctl addmedia '{0}' ({1} file(s)) succeeded.", udidOrName, pathList.Count); + return success; + } +} diff --git a/Xamarin.MacDev/SimulatorStatusBar.cs b/Xamarin.MacDev/SimulatorStatusBar.cs new file mode 100644 index 0000000..581359e --- /dev/null +++ b/Xamarin.MacDev/SimulatorStatusBar.cs @@ -0,0 +1,171 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; + +#nullable enable + +namespace Xamarin.MacDev; + +/// +/// Battery state values for the simulator status-bar override. +/// Used with xcrun simctl status_bar <udid> override --batteryState. +/// +public enum SimulatorBatteryState { + Charging, + Charged, + Discharging, +} + +/// +/// Data network type values for the simulator status-bar override. +/// Used with xcrun simctl status_bar <udid> override --dataNetwork. +/// +public enum SimulatorDataNetwork { + Wifi, + ThreeG, + FourG, + Lte, + LteA, + LtePlus, + FiveG, + FiveGPlus, + FiveGUc, + FiveGA, +} + +/// +/// Override values for the simulator status bar. +/// All fields are optional; pass only the fields you want to override. +/// +public record StatusBarOverrides ( + string? Time = null, + int? BatteryLevel = null, + SimulatorBatteryState? BatteryState = null, + SimulatorDataNetwork? DataNetwork = null, + int? CellularBars = null, + int? WifiBars = null, + string? OperatorName = null); + +/// +/// Wraps xcrun simctl status_bar operations for overriding and clearing +/// simulator status bar values. +/// +public class SimulatorStatusBar { + + readonly ICustomLogger log; + readonly SimCtl simctl; + + internal SimulatorStatusBar (ICustomLogger log, SimCtl simctl) + { + this.log = log; + this.simctl = simctl; + } + + /// + /// Overrides status-bar values on the simulator. + /// Wraps xcrun simctl status_bar <udid> override [options]. + /// + public bool Override (string udidOrName, StatusBarOverrides overrides) + { + if (string.IsNullOrWhiteSpace (udidOrName)) + throw new ArgumentException ("Simulator UDID or name must not be null or empty.", nameof (udidOrName)); + if (overrides is null) + throw new ArgumentNullException (nameof (overrides)); + + var args = BuildOverrideArgs (udidOrName, overrides); + var result = simctl.Run (args); + var success = result is not null; + if (success) + log.LogInfo ("simctl status_bar override '{0}' succeeded.", udidOrName); + return success; + } + + /// + /// Clears all status-bar overrides on the simulator. + /// Wraps xcrun simctl status_bar <udid> clear. + /// + public bool Clear (string udidOrName) + { + if (string.IsNullOrWhiteSpace (udidOrName)) + throw new ArgumentException ("Simulator UDID or name must not be null or empty.", nameof (udidOrName)); + + var result = simctl.Run ("status_bar", udidOrName, "clear"); + var success = result is not null; + if (success) + log.LogInfo ("simctl status_bar clear '{0}' succeeded.", udidOrName); + return success; + } + + static string [] BuildOverrideArgs (string udidOrName, StatusBarOverrides overrides) + { + var args = new List { + "status_bar", udidOrName, "override", + }; + + if (overrides.Time is not null) { + args.Add ("--time"); + args.Add (overrides.Time); + } + + if (overrides.BatteryLevel.HasValue) { + args.Add ("--batteryLevel"); + args.Add (overrides.BatteryLevel.Value.ToString ()); + } + + if (overrides.BatteryState.HasValue) { + args.Add ("--batteryState"); + args.Add (ToSimctlBatteryState (overrides.BatteryState.Value)); + } + + if (overrides.DataNetwork.HasValue) { + args.Add ("--dataNetwork"); + args.Add (ToSimctlDataNetwork (overrides.DataNetwork.Value)); + } + + if (overrides.CellularBars.HasValue) { + args.Add ("--cellularBars"); + args.Add (overrides.CellularBars.Value.ToString ()); + } + + if (overrides.WifiBars.HasValue) { + args.Add ("--wifiBars"); + args.Add (overrides.WifiBars.Value.ToString ()); + } + + if (overrides.OperatorName is not null) { + args.Add ("--operatorName"); + args.Add (overrides.OperatorName); + } + + return args.ToArray (); + } + + public static string ToSimctlBatteryState (SimulatorBatteryState state) + { + return state switch { + SimulatorBatteryState.Charging => "Charging", + SimulatorBatteryState.Charged => "Charged", + SimulatorBatteryState.Discharging => "Discharging", + _ => throw new ArgumentOutOfRangeException (nameof (state), state, null), + }; + } + + public static string ToSimctlDataNetwork (SimulatorDataNetwork network) + { + return network switch { + SimulatorDataNetwork.Wifi => "wifi", + SimulatorDataNetwork.ThreeG => "3g", + SimulatorDataNetwork.FourG => "4g", + SimulatorDataNetwork.Lte => "lte", + SimulatorDataNetwork.LteA => "lte-a", + SimulatorDataNetwork.LtePlus => "lte+", + SimulatorDataNetwork.FiveG => "5g", + SimulatorDataNetwork.FiveGPlus => "5g+", + SimulatorDataNetwork.FiveGUc => "5g-uc", + SimulatorDataNetwork.FiveGA => "5g-a", + _ => throw new ArgumentOutOfRangeException (nameof (network), network, null), + }; + } +} diff --git a/tests/SimulatorServiceExtrasTests.cs b/tests/SimulatorServiceExtrasTests.cs new file mode 100644 index 0000000..4a804a6 --- /dev/null +++ b/tests/SimulatorServiceExtrasTests.cs @@ -0,0 +1,377 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; + +using NUnit.Framework; + +using Xamarin.MacDev; + +#nullable enable + +namespace tests; + +[TestFixture] +public class SimulatorServiceExtrasTests { + + // ── SimulatorService construction ──────────────────────────────────────── + + [Test] + public void Constructor_ThrowsOnNullLogger () + { + Assert.Throws (() => new SimulatorService (null!)); + } + + // ── Privacy service lazy-initialisation ───────────────────────────────── + + [Test] + public void Privacy_PropertyIsNotNull () + { + var svc = new SimulatorService (ConsoleLogger.Instance); + Assert.That (svc.Privacy, Is.Not.Null); + } + + [Test] + public void Privacy_ReturnsSameInstance () + { + var svc = new SimulatorService (ConsoleLogger.Instance); + Assert.That (svc.Privacy, Is.SameAs (svc.Privacy)); + } + + [Test] + public void Privacy_Grant_ThrowsOnNullOrEmptyUdid () + { + var p = new SimulatorService (ConsoleLogger.Instance).Privacy; + Assert.Throws (() => p.Grant (null!, PrivacyPermission.Calendar)); + Assert.Throws (() => p.Grant ("", PrivacyPermission.Calendar)); + Assert.Throws (() => p.Grant (" ", PrivacyPermission.Calendar)); + } + + [Test] + public void Privacy_Revoke_ThrowsOnNullOrEmptyUdid () + { + var p = new SimulatorService (ConsoleLogger.Instance).Privacy; + Assert.Throws (() => p.Revoke (null!, PrivacyPermission.Photos)); + Assert.Throws (() => p.Revoke ("", PrivacyPermission.Photos)); + } + + [Test] + public void Privacy_Reset_ThrowsOnNullOrEmptyUdid () + { + var p = new SimulatorService (ConsoleLogger.Instance).Privacy; + Assert.Throws (() => p.Reset (null!, PrivacyPermission.All)); + Assert.Throws (() => p.Reset ("", PrivacyPermission.All)); + } + + [Test] + public void PrivacyPermission_ToSimctlServiceName_ConvertsAllValues () + { + Assert.That (SimulatorPrivacy.ToSimctlServiceName (PrivacyPermission.All), Is.EqualTo ("all")); + Assert.That (SimulatorPrivacy.ToSimctlServiceName (PrivacyPermission.Calendar), Is.EqualTo ("calendar")); + Assert.That (SimulatorPrivacy.ToSimctlServiceName (PrivacyPermission.ContactsLimited), Is.EqualTo ("contacts-limited")); + Assert.That (SimulatorPrivacy.ToSimctlServiceName (PrivacyPermission.Contacts), Is.EqualTo ("contacts")); + Assert.That (SimulatorPrivacy.ToSimctlServiceName (PrivacyPermission.Location), Is.EqualTo ("location")); + Assert.That (SimulatorPrivacy.ToSimctlServiceName (PrivacyPermission.LocationAlways), Is.EqualTo ("location-always")); + Assert.That (SimulatorPrivacy.ToSimctlServiceName (PrivacyPermission.PhotosAdd), Is.EqualTo ("photos-add")); + Assert.That (SimulatorPrivacy.ToSimctlServiceName (PrivacyPermission.Photos), Is.EqualTo ("photos")); + Assert.That (SimulatorPrivacy.ToSimctlServiceName (PrivacyPermission.MediaLibrary), Is.EqualTo ("media-library")); + Assert.That (SimulatorPrivacy.ToSimctlServiceName (PrivacyPermission.Microphone), Is.EqualTo ("microphone")); + Assert.That (SimulatorPrivacy.ToSimctlServiceName (PrivacyPermission.Motion), Is.EqualTo ("motion")); + Assert.That (SimulatorPrivacy.ToSimctlServiceName (PrivacyPermission.Reminders), Is.EqualTo ("reminders")); + Assert.That (SimulatorPrivacy.ToSimctlServiceName (PrivacyPermission.Siri), Is.EqualTo ("siri")); + } + + // ── StatusBar service lazy-initialisation ──────────────────────────────── + + [Test] + public void StatusBar_PropertyIsNotNull () + { + var svc = new SimulatorService (ConsoleLogger.Instance); + Assert.That (svc.StatusBar, Is.Not.Null); + } + + [Test] + public void StatusBar_ReturnsSameInstance () + { + var svc = new SimulatorService (ConsoleLogger.Instance); + Assert.That (svc.StatusBar, Is.SameAs (svc.StatusBar)); + } + + [Test] + public void StatusBar_Override_ThrowsOnNullOrEmptyUdid () + { + var sb = new SimulatorService (ConsoleLogger.Instance).StatusBar; + var overrides = new StatusBarOverrides (Time: "09:41"); + Assert.Throws (() => sb.Override (null!, overrides)); + Assert.Throws (() => sb.Override ("", overrides)); + } + + [Test] + public void StatusBar_Override_ThrowsOnNullOverrides () + { + var sb = new SimulatorService (ConsoleLogger.Instance).StatusBar; + Assert.Throws (() => sb.Override ("booted", null!)); + } + + [Test] + public void StatusBar_Clear_ThrowsOnNullOrEmptyUdid () + { + var sb = new SimulatorService (ConsoleLogger.Instance).StatusBar; + Assert.Throws (() => sb.Clear (null!)); + Assert.Throws (() => sb.Clear ("")); + } + + [Test] + public void StatusBar_BatteryState_ConvertsAllValues () + { + Assert.That (SimulatorStatusBar.ToSimctlBatteryState (SimulatorBatteryState.Charging), Is.EqualTo ("Charging")); + Assert.That (SimulatorStatusBar.ToSimctlBatteryState (SimulatorBatteryState.Charged), Is.EqualTo ("Charged")); + Assert.That (SimulatorStatusBar.ToSimctlBatteryState (SimulatorBatteryState.Discharging), Is.EqualTo ("Discharging")); + } + + [Test] + public void StatusBar_DataNetwork_ConvertsAllValues () + { + Assert.That (SimulatorStatusBar.ToSimctlDataNetwork (SimulatorDataNetwork.Wifi), Is.EqualTo ("wifi")); + Assert.That (SimulatorStatusBar.ToSimctlDataNetwork (SimulatorDataNetwork.ThreeG), Is.EqualTo ("3g")); + Assert.That (SimulatorStatusBar.ToSimctlDataNetwork (SimulatorDataNetwork.FourG), Is.EqualTo ("4g")); + Assert.That (SimulatorStatusBar.ToSimctlDataNetwork (SimulatorDataNetwork.Lte), Is.EqualTo ("lte")); + Assert.That (SimulatorStatusBar.ToSimctlDataNetwork (SimulatorDataNetwork.LteA), Is.EqualTo ("lte-a")); + Assert.That (SimulatorStatusBar.ToSimctlDataNetwork (SimulatorDataNetwork.LtePlus), Is.EqualTo ("lte+")); + Assert.That (SimulatorStatusBar.ToSimctlDataNetwork (SimulatorDataNetwork.FiveG), Is.EqualTo ("5g")); + Assert.That (SimulatorStatusBar.ToSimctlDataNetwork (SimulatorDataNetwork.FiveGPlus), Is.EqualTo ("5g+")); + Assert.That (SimulatorStatusBar.ToSimctlDataNetwork (SimulatorDataNetwork.FiveGUc), Is.EqualTo ("5g-uc")); + Assert.That (SimulatorStatusBar.ToSimctlDataNetwork (SimulatorDataNetwork.FiveGA), Is.EqualTo ("5g-a")); + } + + // ── Location service lazy-initialisation ───────────────────────────────── + + [Test] + public void Location_PropertyIsNotNull () + { + var svc = new SimulatorService (ConsoleLogger.Instance); + Assert.That (svc.Location, Is.Not.Null); + } + + [Test] + public void Location_ReturnsSameInstance () + { + var svc = new SimulatorService (ConsoleLogger.Instance); + Assert.That (svc.Location, Is.SameAs (svc.Location)); + } + + [Test] + public void Location_Set_ThrowsOnNullOrEmptyUdid () + { + var loc = new SimulatorService (ConsoleLogger.Instance).Location; + Assert.Throws (() => loc.Set (null!, 37.33, -122.03)); + Assert.Throws (() => loc.Set ("", 37.33, -122.03)); + } + + [Test] + public void Location_Clear_ThrowsOnNullOrEmptyUdid () + { + var loc = new SimulatorService (ConsoleLogger.Instance).Location; + Assert.Throws (() => loc.Clear (null!)); + Assert.Throws (() => loc.Clear ("")); + } + + [Test] + public void Location_Run_ThrowsOnNullOrEmptyUdidOrPath () + { + var loc = new SimulatorService (ConsoleLogger.Instance).Location; + Assert.Throws (() => loc.Run (null!, "/tmp/route.gpx")); + Assert.Throws (() => loc.Run ("", "/tmp/route.gpx")); + Assert.Throws (() => loc.Run ("booted", null!)); + Assert.Throws (() => loc.Run ("booted", "")); + } + + // ── ScreenCapture service lazy-initialisation ──────────────────────────── + + [Test] + public void ScreenCapture_PropertyIsNotNull () + { + var svc = new SimulatorService (ConsoleLogger.Instance); + Assert.That (svc.ScreenCapture, Is.Not.Null); + } + + [Test] + public void ScreenCapture_ReturnsSameInstance () + { + var svc = new SimulatorService (ConsoleLogger.Instance); + Assert.That (svc.ScreenCapture, Is.SameAs (svc.ScreenCapture)); + } + + [Test] + public void ScreenCapture_Screenshot_ThrowsOnNullOrEmptyUdid () + { + var sc = new SimulatorService (ConsoleLogger.Instance).ScreenCapture; + Assert.Throws (() => sc.Screenshot (null!, "/tmp/out.png")); + Assert.Throws (() => sc.Screenshot ("", "/tmp/out.png")); + } + + [Test] + public void ScreenCapture_Screenshot_ThrowsOnNullOrEmptyPath () + { + var sc = new SimulatorService (ConsoleLogger.Instance).ScreenCapture; + Assert.Throws (() => sc.Screenshot ("booted", null!)); + Assert.Throws (() => sc.Screenshot ("booted", "")); + } + + [Test] + public void ScreenCapture_StartRecording_ThrowsOnNullOrEmptyUdid () + { + var sc = new SimulatorService (ConsoleLogger.Instance).ScreenCapture; + Assert.Throws (() => sc.StartRecording (null!, "/tmp/out.mp4")); + Assert.Throws (() => sc.StartRecording ("", "/tmp/out.mp4")); + } + + [Test] + public void ScreenCapture_StartRecording_ThrowsOnNullOrEmptyPath () + { + var sc = new SimulatorService (ConsoleLogger.Instance).ScreenCapture; + Assert.Throws (() => sc.StartRecording ("booted", null!)); + Assert.Throws (() => sc.StartRecording ("booted", "")); + } + + [Test] + public void ScreenCapture_ScreenshotFormat_ConvertsAllValues () + { + Assert.That (SimulatorScreenCapture.ToSimctlFormatName (ScreenshotFormat.Png), Is.EqualTo ("png")); + Assert.That (SimulatorScreenCapture.ToSimctlFormatName (ScreenshotFormat.Jpeg), Is.EqualTo ("jpeg")); + Assert.That (SimulatorScreenCapture.ToSimctlFormatName (ScreenshotFormat.Tiff), Is.EqualTo ("tiff")); + Assert.That (SimulatorScreenCapture.ToSimctlFormatName (ScreenshotFormat.Bmp), Is.EqualTo ("bmp")); + } + + [Test] + public void ScreenCapture_VideoRecordingFormat_ConvertsAllValues () + { + Assert.That (SimulatorScreenCapture.ToSimctlVideoFormatName (VideoRecordingFormat.Mp4), Is.EqualTo ("mp4")); + Assert.That (SimulatorScreenCapture.ToSimctlVideoFormatName (VideoRecordingFormat.H264), Is.EqualTo ("h264")); + Assert.That (SimulatorScreenCapture.ToSimctlVideoFormatName (VideoRecordingFormat.Fmp4), Is.EqualTo ("fmp4")); + Assert.That (SimulatorScreenCapture.ToSimctlVideoFormatName (VideoRecordingFormat.Gif), Is.EqualTo ("gif")); + } + + // ── Direct methods: SetAppearance / GetAppearance ──────────────────────── + + [Test] + public void SetAppearance_ThrowsOnNullOrEmptyUdid () + { + var svc = new SimulatorService (ConsoleLogger.Instance); + Assert.Throws (() => svc.SetAppearance (null!, SimulatorAppearance.Dark)); + Assert.Throws (() => svc.SetAppearance ("", SimulatorAppearance.Dark)); + } + + [Test] + public void GetAppearance_ThrowsOnNullOrEmptyUdid () + { + var svc = new SimulatorService (ConsoleLogger.Instance); + Assert.Throws (() => svc.GetAppearance (null!)); + Assert.Throws (() => svc.GetAppearance ("")); + } + + // ── Direct method: OpenUrl ─────────────────────────────────────────────── + + [Test] + public void OpenUrl_ThrowsOnNullOrEmptyUdid () + { + var svc = new SimulatorService (ConsoleLogger.Instance); + Assert.Throws (() => svc.OpenUrl (null!, "https://example.com")); + Assert.Throws (() => svc.OpenUrl ("", "https://example.com")); + } + + [Test] + public void OpenUrl_ThrowsOnNullOrEmptyUrl () + { + var svc = new SimulatorService (ConsoleLogger.Instance); + Assert.Throws (() => svc.OpenUrl ("booted", null!)); + Assert.Throws (() => svc.OpenUrl ("booted", "")); + } + + // ── Direct method: Push ────────────────────────────────────────────────── + + [Test] + public void Push_ThrowsOnNullOrEmptyUdid () + { + var svc = new SimulatorService (ConsoleLogger.Instance); + Assert.Throws (() => svc.Push (null!, "com.example.app", "{}")); + Assert.Throws (() => svc.Push ("", "com.example.app", "{}")); + } + + [Test] + public void Push_ThrowsOnNullOrEmptyBundleId () + { + var svc = new SimulatorService (ConsoleLogger.Instance); + Assert.Throws (() => svc.Push ("booted", null!, "{}")); + Assert.Throws (() => svc.Push ("booted", "", "{}")); + } + + [Test] + public void Push_ThrowsOnNullOrEmptyPayload () + { + var svc = new SimulatorService (ConsoleLogger.Instance); + Assert.Throws (() => svc.Push ("booted", "com.example.app", null!)); + Assert.Throws (() => svc.Push ("booted", "com.example.app", "")); + } + + // ── Direct method: AddMedia ────────────────────────────────────────────── + + [Test] + public void AddMedia_ThrowsOnNullOrEmptyUdid () + { + var svc = new SimulatorService (ConsoleLogger.Instance); + Assert.Throws (() => svc.AddMedia (null!, new [] { "/tmp/photo.png" })); + Assert.Throws (() => svc.AddMedia ("", new [] { "/tmp/photo.png" })); + } + + [Test] + public void AddMedia_ThrowsOnNullPaths () + { + var svc = new SimulatorService (ConsoleLogger.Instance); + Assert.Throws (() => svc.AddMedia ("booted", null!)); + } + + [Test] + public void AddMedia_ThrowsOnEmptyPathsList () + { + var svc = new SimulatorService (ConsoleLogger.Instance); + Assert.Throws (() => svc.AddMedia ("booted", new List ())); + } + + // ── StatusBarOverrides record ───────────────────────────────────────────── + + [Test] + public void StatusBarOverrides_DefaultsToAllNull () + { + var o = new StatusBarOverrides (); + Assert.That (o.Time, Is.Null); + Assert.That (o.BatteryLevel, Is.Null); + Assert.That (o.BatteryState, Is.Null); + Assert.That (o.DataNetwork, Is.Null); + Assert.That (o.CellularBars, Is.Null); + Assert.That (o.WifiBars, Is.Null); + Assert.That (o.OperatorName, Is.Null); + } + + [Test] + public void StatusBarOverrides_CanSetAllFields () + { + var o = new StatusBarOverrides ( + Time: "09:41", + BatteryLevel: 100, + BatteryState: SimulatorBatteryState.Charging, + DataNetwork: SimulatorDataNetwork.Wifi, + CellularBars: 4, + WifiBars: 3, + OperatorName: "MAUI Mobile"); + + Assert.That (o.Time, Is.EqualTo ("09:41")); + Assert.That (o.BatteryLevel, Is.EqualTo (100)); + Assert.That (o.BatteryState, Is.EqualTo (SimulatorBatteryState.Charging)); + Assert.That (o.DataNetwork, Is.EqualTo (SimulatorDataNetwork.Wifi)); + Assert.That (o.CellularBars, Is.EqualTo (4)); + Assert.That (o.WifiBars, Is.EqualTo (3)); + Assert.That (o.OperatorName, Is.EqualTo ("MAUI Mobile")); + } +} From 5e81f2408ecfa583d55047b542bc24778f02b5a7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 19:57:39 +0000 Subject: [PATCH 2/4] fix: address code review feedback (IsExternalInit comment, QuoteArguments backslash escaping, specific catch types) Agent-Logs-Url: https://github.com/dotnet/macios-devtools/sessions/249ea126-48c7-436b-b123-232b305a6be3 Co-authored-by: rmarinho <1235097+rmarinho@users.noreply.github.com> --- Xamarin.MacDev/NullableAttributes.cs | 1 + Xamarin.MacDev/SimulatorScreenCapture.cs | 4 ++-- Xamarin.MacDev/SimulatorServiceExtras.cs | 6 +++++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Xamarin.MacDev/NullableAttributes.cs b/Xamarin.MacDev/NullableAttributes.cs index d043fc3..74a83ba 100644 --- a/Xamarin.MacDev/NullableAttributes.cs +++ b/Xamarin.MacDev/NullableAttributes.cs @@ -46,6 +46,7 @@ internal sealed class NotNullIfNotNullAttribute : Attribute { #if NETSTANDARD2_0 namespace System.Runtime.CompilerServices { // Required polyfill for C# 9 records on netstandard2.0 targets. + // Enables init-only setters (C# 9 'init' keyword) in netstandard2.0 projects. // See: https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-9.0/records internal static class IsExternalInit { } } diff --git a/Xamarin.MacDev/SimulatorScreenCapture.cs b/Xamarin.MacDev/SimulatorScreenCapture.cs index af6a01a..9d9a63a 100644 --- a/Xamarin.MacDev/SimulatorScreenCapture.cs +++ b/Xamarin.MacDev/SimulatorScreenCapture.cs @@ -149,11 +149,11 @@ static string QuoteArguments (string [] arguments) sb.Append (' '); var arg = arguments [i]; - if (arg.Length > 0 && arg.IndexOfAny (new [] { ' ', '\t', '"' }) < 0) { + if (arg.Length > 0 && arg.IndexOfAny (new [] { ' ', '\t', '"', '\\' }) < 0) { sb.Append (arg); } else { sb.Append ('"'); - sb.Append (arg.Replace ("\"", "\\\"")); + sb.Append (arg.Replace ("\\", "\\\\").Replace ("\"", "\\\"")); sb.Append ('"'); } } diff --git a/Xamarin.MacDev/SimulatorServiceExtras.cs b/Xamarin.MacDev/SimulatorServiceExtras.cs index 8c6aaaf..0d96e3c 100644 --- a/Xamarin.MacDev/SimulatorServiceExtras.cs +++ b/Xamarin.MacDev/SimulatorServiceExtras.cs @@ -118,7 +118,11 @@ public bool Push (string udidOrName, string bundleIdentifier, string payloadJson File.WriteAllText (tempPath, payloadJsonOrPath, Encoding.UTF8); return RunPush (udidOrName, bundleIdentifier, tempPath); } finally { - try { if (File.Exists (tempPath)) File.Delete (tempPath); } catch { } + try { if (File.Exists (tempPath)) File.Delete (tempPath); } catch (IOException ex) { + log.LogInfo ("Failed to delete temporary push payload file '{0}': {1}", tempPath, ex.Message); + } catch (UnauthorizedAccessException ex) { + log.LogInfo ("Failed to delete temporary push payload file '{0}': {1}", tempPath, ex.Message); + } } } From 5a3fd2aaf07746656dad0003ee75ba19a7235eae Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Tue, 5 May 2026 17:23:38 +0100 Subject: [PATCH 3/4] Fix review bugs: graceful stop, pipe deadlock, .tmp extension, empty overrides - Use SIGINT instead of SIGKILL in VideoRecordingSession.Dispose so simctl can flush and write the video file trailer (prevents corrupt output files). Falls back to Process.Kill if SIGINT fails or times out. - Drain redirected stdout/stderr with async readers in StartRecording to prevent pipe buffer deadlocks on longer recordings. - Use .json extension for temporary push payload files instead of .tmp to satisfy simctl push file extension requirements. - Guard StatusBarOverrides.Override against all-null overrides that would produce an invalid simctl invocation. - Fix NUnit2009 analyzer errors in ReturnsSameInstance tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Xamarin.MacDev/SimulatorScreenCapture.cs | 19 +++++++++++++++++-- Xamarin.MacDev/SimulatorServiceExtras.cs | 2 +- Xamarin.MacDev/SimulatorStatusBar.cs | 6 ++++++ tests/SimulatorServiceExtrasTests.cs | 19 +++++++++++++++---- 4 files changed, 39 insertions(+), 7 deletions(-) diff --git a/Xamarin.MacDev/SimulatorScreenCapture.cs b/Xamarin.MacDev/SimulatorScreenCapture.cs index 9d9a63a..3f63608 100644 --- a/Xamarin.MacDev/SimulatorScreenCapture.cs +++ b/Xamarin.MacDev/SimulatorScreenCapture.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Runtime.InteropServices; #nullable enable @@ -89,7 +90,11 @@ public bool Screenshot (string udidOrName, string outputPath, ScreenshotFormat f try { var process = new Process { StartInfo = psi }; + process.OutputDataReceived += (_, _) => { }; + process.ErrorDataReceived += (_, _) => { }; process.Start (); + process.BeginOutputReadLine (); + process.BeginErrorReadLine (); log.LogInfo ("simctl io recordVideo started for '{0}'.", udidOrName); return new VideoRecordingSession (process, log); } catch (System.ComponentModel.Win32Exception ex) { @@ -167,6 +172,8 @@ static string QuoteArguments (string [] arguments) /// sealed class VideoRecordingSession : IDisposable { + const int SIGINT = 2; + readonly Process process; readonly ICustomLogger log; bool disposed; @@ -186,8 +193,13 @@ public void Dispose () try { if (!process.HasExited) { - process.Kill (); - process.WaitForExit (5000); + // Send SIGINT for graceful shutdown so simctl can flush and write + // the video file trailer. Using Kill() sends SIGKILL, which + // terminates immediately and produces a corrupt output file. + if (kill (process.Id, SIGINT) != 0 || !process.WaitForExit (5000)) { + process.Kill (); + process.WaitForExit (5000); + } log.LogInfo ("simctl io recordVideo process stopped."); } } catch (InvalidOperationException) { @@ -198,5 +210,8 @@ public void Dispose () process.Dispose (); } } + + [DllImport ("/usr/lib/libc.dylib", SetLastError = true)] + static extern int kill (int pid, int sig); } } diff --git a/Xamarin.MacDev/SimulatorServiceExtras.cs b/Xamarin.MacDev/SimulatorServiceExtras.cs index 0d96e3c..10e117b 100644 --- a/Xamarin.MacDev/SimulatorServiceExtras.cs +++ b/Xamarin.MacDev/SimulatorServiceExtras.cs @@ -113,7 +113,7 @@ public bool Push (string udidOrName, string bundleIdentifier, string payloadJson var isInlineJson = payloadJsonOrPath.TrimStart ().StartsWith ("{", StringComparison.Ordinal); if (isInlineJson) { - var tempPath = Path.GetTempFileName (); + var tempPath = Path.Combine (Path.GetTempPath (), Path.GetRandomFileName () + ".json"); try { File.WriteAllText (tempPath, payloadJsonOrPath, Encoding.UTF8); return RunPush (udidOrName, bundleIdentifier, tempPath); diff --git a/Xamarin.MacDev/SimulatorStatusBar.cs b/Xamarin.MacDev/SimulatorStatusBar.cs index 581359e..ca27381 100644 --- a/Xamarin.MacDev/SimulatorStatusBar.cs +++ b/Xamarin.MacDev/SimulatorStatusBar.cs @@ -74,6 +74,12 @@ public bool Override (string udidOrName, StatusBarOverrides overrides) if (overrides is null) throw new ArgumentNullException (nameof (overrides)); + if (overrides.Time is null && !overrides.BatteryLevel.HasValue && + !overrides.BatteryState.HasValue && !overrides.DataNetwork.HasValue && + !overrides.CellularBars.HasValue && !overrides.WifiBars.HasValue && + overrides.OperatorName is null) + throw new ArgumentException ("At least one StatusBarOverrides field must be set.", nameof (overrides)); + var args = BuildOverrideArgs (udidOrName, overrides); var result = simctl.Run (args); var success = result is not null; diff --git a/tests/SimulatorServiceExtrasTests.cs b/tests/SimulatorServiceExtrasTests.cs index 4a804a6..edf3999 100644 --- a/tests/SimulatorServiceExtrasTests.cs +++ b/tests/SimulatorServiceExtrasTests.cs @@ -36,7 +36,8 @@ public void Privacy_PropertyIsNotNull () public void Privacy_ReturnsSameInstance () { var svc = new SimulatorService (ConsoleLogger.Instance); - Assert.That (svc.Privacy, Is.SameAs (svc.Privacy)); + var first = svc.Privacy; + Assert.That (first, Is.SameAs (svc.Privacy)); } [Test] @@ -95,7 +96,8 @@ public void StatusBar_PropertyIsNotNull () public void StatusBar_ReturnsSameInstance () { var svc = new SimulatorService (ConsoleLogger.Instance); - Assert.That (svc.StatusBar, Is.SameAs (svc.StatusBar)); + var first = svc.StatusBar; + Assert.That (first, Is.SameAs (svc.StatusBar)); } [Test] @@ -114,6 +116,13 @@ public void StatusBar_Override_ThrowsOnNullOverrides () Assert.Throws (() => sb.Override ("booted", null!)); } + [Test] + public void StatusBar_Override_ThrowsOnEmptyOverrides () + { + var sb = new SimulatorService (ConsoleLogger.Instance).StatusBar; + Assert.Throws (() => sb.Override ("booted", new StatusBarOverrides ())); + } + [Test] public void StatusBar_Clear_ThrowsOnNullOrEmptyUdid () { @@ -158,7 +167,8 @@ public void Location_PropertyIsNotNull () public void Location_ReturnsSameInstance () { var svc = new SimulatorService (ConsoleLogger.Instance); - Assert.That (svc.Location, Is.SameAs (svc.Location)); + var first = svc.Location; + Assert.That (first, Is.SameAs (svc.Location)); } [Test] @@ -200,7 +210,8 @@ public void ScreenCapture_PropertyIsNotNull () public void ScreenCapture_ReturnsSameInstance () { var svc = new SimulatorService (ConsoleLogger.Instance); - Assert.That (svc.ScreenCapture, Is.SameAs (svc.ScreenCapture)); + var first = svc.ScreenCapture; + Assert.That (first, Is.SameAs (svc.ScreenCapture)); } [Test] From f0a0994c7209b65f9a216c4e6cd9a4de3f27115d Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Wed, 13 May 2026 20:22:07 +0100 Subject: [PATCH 4/4] Address PR review feedback - Lowercase battery state values for simctl compatibility (rolfbjarne/Copilot) - Log stderr output from recordVideo instead of swallowing it (rolfbjarne) - Stop redirecting stdout (not needed), only redirect stderr for diagnostics - Fix process leak if BeginErrorReadLine throws after Start (Copilot) - Use IsNullOrWhiteSpace for bundleIdentifier in RunPrivacy (Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Xamarin.MacDev/SimulatorPrivacy.cs | 2 +- Xamarin.MacDev/SimulatorScreenCapture.cs | 14 +++++++++----- Xamarin.MacDev/SimulatorStatusBar.cs | 6 +++--- tests/SimulatorServiceExtrasTests.cs | 6 +++--- 4 files changed, 16 insertions(+), 12 deletions(-) diff --git a/Xamarin.MacDev/SimulatorPrivacy.cs b/Xamarin.MacDev/SimulatorPrivacy.cs index 8462c3f..12aef8f 100644 --- a/Xamarin.MacDev/SimulatorPrivacy.cs +++ b/Xamarin.MacDev/SimulatorPrivacy.cs @@ -57,7 +57,7 @@ bool RunPrivacy (string action, string udidOrName, PrivacyPermission permission, var service = ToSimctlServiceName (permission); string? result; - if (!string.IsNullOrEmpty (bundleIdentifier)) + if (!string.IsNullOrWhiteSpace (bundleIdentifier)) result = simctl.Run ("privacy", udidOrName, action, service, bundleIdentifier!); else result = simctl.Run ("privacy", udidOrName, action, service); diff --git a/Xamarin.MacDev/SimulatorScreenCapture.cs b/Xamarin.MacDev/SimulatorScreenCapture.cs index 3f63608..19a6361 100644 --- a/Xamarin.MacDev/SimulatorScreenCapture.cs +++ b/Xamarin.MacDev/SimulatorScreenCapture.cs @@ -77,7 +77,7 @@ public bool Screenshot (string udidOrName, string outputPath, ScreenshotFormat f var psi = new ProcessStartInfo (SimCtl.XcrunPath) { CreateNoWindow = true, UseShellExecute = false, - RedirectStandardOutput = true, + RedirectStandardOutput = false, RedirectStandardError = true, }; @@ -88,20 +88,24 @@ public bool Screenshot (string udidOrName, string outputPath, ScreenshotFormat f psi.ArgumentList.Add (arg); #endif + Process? process = null; try { - var process = new Process { StartInfo = psi }; - process.OutputDataReceived += (_, _) => { }; - process.ErrorDataReceived += (_, _) => { }; + process = new Process { StartInfo = psi }; + process.ErrorDataReceived += (_, e) => { + if (e.Data is not null) + log.LogInfo ("simctl io recordVideo stderr: {0}", e.Data); + }; process.Start (); - process.BeginOutputReadLine (); process.BeginErrorReadLine (); log.LogInfo ("simctl io recordVideo started for '{0}'.", udidOrName); return new VideoRecordingSession (process, log); } catch (System.ComponentModel.Win32Exception ex) { log.LogInfo ("Could not start xcrun simctl io recordVideo: {0}", ex.Message); + process?.Dispose (); return null; } catch (InvalidOperationException ex) { log.LogInfo ("Could not start xcrun simctl io recordVideo: {0}", ex.Message); + process?.Dispose (); return null; } } diff --git a/Xamarin.MacDev/SimulatorStatusBar.cs b/Xamarin.MacDev/SimulatorStatusBar.cs index ca27381..729c810 100644 --- a/Xamarin.MacDev/SimulatorStatusBar.cs +++ b/Xamarin.MacDev/SimulatorStatusBar.cs @@ -151,9 +151,9 @@ static string [] BuildOverrideArgs (string udidOrName, StatusBarOverrides overri public static string ToSimctlBatteryState (SimulatorBatteryState state) { return state switch { - SimulatorBatteryState.Charging => "Charging", - SimulatorBatteryState.Charged => "Charged", - SimulatorBatteryState.Discharging => "Discharging", + SimulatorBatteryState.Charging => "charging", + SimulatorBatteryState.Charged => "charged", + SimulatorBatteryState.Discharging => "discharging", _ => throw new ArgumentOutOfRangeException (nameof (state), state, null), }; } diff --git a/tests/SimulatorServiceExtrasTests.cs b/tests/SimulatorServiceExtrasTests.cs index edf3999..3a0f49b 100644 --- a/tests/SimulatorServiceExtrasTests.cs +++ b/tests/SimulatorServiceExtrasTests.cs @@ -134,9 +134,9 @@ public void StatusBar_Clear_ThrowsOnNullOrEmptyUdid () [Test] public void StatusBar_BatteryState_ConvertsAllValues () { - Assert.That (SimulatorStatusBar.ToSimctlBatteryState (SimulatorBatteryState.Charging), Is.EqualTo ("Charging")); - Assert.That (SimulatorStatusBar.ToSimctlBatteryState (SimulatorBatteryState.Charged), Is.EqualTo ("Charged")); - Assert.That (SimulatorStatusBar.ToSimctlBatteryState (SimulatorBatteryState.Discharging), Is.EqualTo ("Discharging")); + Assert.That (SimulatorStatusBar.ToSimctlBatteryState (SimulatorBatteryState.Charging), Is.EqualTo ("charging")); + Assert.That (SimulatorStatusBar.ToSimctlBatteryState (SimulatorBatteryState.Charged), Is.EqualTo ("charged")); + Assert.That (SimulatorStatusBar.ToSimctlBatteryState (SimulatorBatteryState.Discharging), Is.EqualTo ("discharging")); } [Test]