diff --git a/Xamarin.MacDev/NullableAttributes.cs b/Xamarin.MacDev/NullableAttributes.cs
index 66091d9..74a83ba 100644
--- a/Xamarin.MacDev/NullableAttributes.cs
+++ b/Xamarin.MacDev/NullableAttributes.cs
@@ -42,3 +42,12 @@ internal sealed class NotNullIfNotNullAttribute : Attribute {
}
}
#endif // !NET
+
+#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 { }
+}
+#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..12aef8f
--- /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.IsNullOrWhiteSpace (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..19a6361
--- /dev/null
+++ b/Xamarin.MacDev/SimulatorScreenCapture.cs
@@ -0,0 +1,221 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Runtime.InteropServices;
+
+#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 = false,
+ RedirectStandardError = true,
+ };
+
+#if NETSTANDARD2_0
+ psi.Arguments = QuoteArguments (allArgs);
+#else
+ foreach (var arg in allArgs)
+ psi.ArgumentList.Add (arg);
+#endif
+
+ Process? process = null;
+ try {
+ 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.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;
+ }
+ }
+
+ 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 ("\\", "\\\\").Replace ("\"", "\\\""));
+ sb.Append ('"');
+ }
+ }
+ return sb.ToString ();
+ }
+#endif
+
+ ///
+ /// Disposable handle that terminates a running simctl io recordVideo process
+ /// when disposed.
+ ///
+ sealed class VideoRecordingSession : IDisposable {
+
+ const int SIGINT = 2;
+
+ 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) {
+ // 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) {
+ // Process already exited
+ } catch (System.ComponentModel.Win32Exception) {
+ // Cannot kill process (e.g. access denied)
+ } finally {
+ process.Dispose ();
+ }
+ }
+
+ [DllImport ("/usr/lib/libc.dylib", SetLastError = true)]
+ static extern int kill (int pid, int sig);
+ }
+}
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..10e117b
--- /dev/null
+++ b/Xamarin.MacDev/SimulatorServiceExtras.cs
@@ -0,0 +1,168 @@
+// 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.Combine (Path.GetTempPath (), Path.GetRandomFileName () + ".json");
+ try {
+ File.WriteAllText (tempPath, payloadJsonOrPath, Encoding.UTF8);
+ return RunPush (udidOrName, bundleIdentifier, tempPath);
+ } finally {
+ 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);
+ }
+ }
+ }
+
+ 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..729c810
--- /dev/null
+++ b/Xamarin.MacDev/SimulatorStatusBar.cs
@@ -0,0 +1,177 @@
+// 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));
+
+ 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;
+ 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..3a0f49b
--- /dev/null
+++ b/tests/SimulatorServiceExtrasTests.cs
@@ -0,0 +1,388 @@
+// 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);
+ var first = svc.Privacy;
+ Assert.That (first, 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);
+ var first = svc.StatusBar;
+ Assert.That (first, 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_Override_ThrowsOnEmptyOverrides ()
+ {
+ var sb = new SimulatorService (ConsoleLogger.Instance).StatusBar;
+ Assert.Throws (() => sb.Override ("booted", new StatusBarOverrides ()));
+ }
+
+ [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);
+ var first = svc.Location;
+ Assert.That (first, 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);
+ var first = svc.ScreenCapture;
+ Assert.That (first, 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"));
+ }
+}