From a80e415bd48beb3f33f4ed61160980ca039f54a7 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Fri, 27 Feb 2026 19:38:37 +0000 Subject: [PATCH 1/3] Add runtime management APIs Add RuntimeService for listing simulator runtimes and downloading platform runtimes via xcodebuild. Includes SimctlOutputParser for parsing xcrun simctl JSON output (shared with SimulatorService). Download pattern from ClientTools.Platform: xcodebuild -downloadPlatform. Runtime listing reuses SimctlOutputParser.ParseRuntimes with filtering by platform and availability. Adds System.Text.Json dependency for netstandard2.0 target. Closes #150 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Xamarin.MacDev/RuntimeService.cs | 151 +++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 Xamarin.MacDev/RuntimeService.cs diff --git a/Xamarin.MacDev/RuntimeService.cs b/Xamarin.MacDev/RuntimeService.cs new file mode 100644 index 0000000..a255056 --- /dev/null +++ b/Xamarin.MacDev/RuntimeService.cs @@ -0,0 +1,151 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +using Xamarin.MacDev.Models; + +#nullable enable + +namespace Xamarin.MacDev { + + /// + /// Manages simulator runtimes via xcrun simctl and xcodebuild. + /// Lists installed runtimes and supports downloading new platform runtimes. + /// Download approach from ClientTools.Platform: xcodebuild -downloadPlatform iOS. + /// + public class RuntimeService { + + static readonly string XcrunPath = "/usr/bin/xcrun"; + static readonly string XcodebuildRelativePath = "Contents/Developer/usr/bin/xcodebuild"; + + readonly ICustomLogger log; + + public RuntimeService (ICustomLogger log) + { + this.log = log ?? throw new ArgumentNullException (nameof (log)); + } + + /// + /// Lists installed simulator runtimes. Optionally filters by availability. + /// Uses xcrun simctl list runtimes --json. + /// + public List List (bool availableOnly = false) + { + var json = RunSimctl ("list", "runtimes", "--json"); + if (json is null) + return new List (); + + var runtimes = SimctlOutputParser.ParseRuntimes (json); + + if (availableOnly) + runtimes.RemoveAll (r => !r.IsAvailable); + + log.LogInfo ("Found {0} simulator runtime(s).", runtimes.Count); + return runtimes; + } + + /// + /// Lists runtimes for a specific platform (e.g. "iOS", "tvOS", "watchOS", "visionOS"). + /// + public List ListByPlatform (string platform, bool availableOnly = false) + { + var all = List (availableOnly); + return all.Where (r => string.Equals (r.Platform, platform, StringComparison.OrdinalIgnoreCase)).ToList (); + } + + /// + /// Downloads a platform runtime using xcodebuild -downloadPlatform. + /// Pattern from ClientTools.Platform RemoteSimulatorValidator. + /// + /// The platform to download (e.g. "iOS", "tvOS", "watchOS", "visionOS"). + /// The Xcode.app path. If null, looks for xcodebuild in PATH via xcrun. + /// True if the download command succeeded. + public bool DownloadPlatform (string platform, string? xcodePath = null) + { + if (string.IsNullOrEmpty (platform)) + throw new ArgumentException ("Platform must not be null or empty.", nameof (platform)); + + var xcodebuildPath = ResolveXcodebuild (xcodePath); + if (xcodebuildPath is null) { + log.LogInfo ("Cannot download platform: xcodebuild not found."); + return false; + } + + log.LogInfo ("Downloading {0} platform runtime via xcodebuild...", platform); + + try { + var (exitCode, stdout, stderr) = ProcessUtils.Exec (xcodebuildPath, "-downloadPlatform", platform); + if (exitCode != 0) { + log.LogInfo ("xcodebuild -downloadPlatform {0} failed (exit {1}): {2}", platform, exitCode, stderr.Trim ()); + return false; + } + + log.LogInfo ("Successfully downloaded {0} platform runtime.", platform); + return true; + } catch (System.ComponentModel.Win32Exception ex) { + log.LogInfo ("Could not run xcodebuild: {0}", ex.Message); + return false; + } + } + + /// + /// Resolves the path to xcodebuild. If xcodePath is given, looks inside the Xcode bundle. + /// Otherwise falls back to /usr/bin/xcrun xcodebuild. + /// + string? ResolveXcodebuild (string? xcodePath) + { + if (!string.IsNullOrEmpty (xcodePath)) { + var path = Path.Combine (xcodePath!, XcodebuildRelativePath); + if (File.Exists (path)) + return path; + } + + // Fall back to xcrun to find xcodebuild + if (File.Exists (XcrunPath)) { + try { + var (exitCode, stdout, _) = ProcessUtils.Exec (XcrunPath, "--find", "xcodebuild"); + if (exitCode == 0) { + var path = stdout.Trim (); + if (File.Exists (path)) + return path; + } + } catch (System.ComponentModel.Win32Exception) { + // fall through + } + } + + return null; + } + + /// + /// Runs a simctl subcommand and returns stdout, or null on failure. + /// + string? RunSimctl (params string [] args) + { + if (!File.Exists (XcrunPath)) { + log.LogInfo ("xcrun not found at '{0}'.", XcrunPath); + return null; + } + + var fullArgs = new string [args.Length + 1]; + fullArgs [0] = "simctl"; + Array.Copy (args, 0, fullArgs, 1, args.Length); + + try { + var (exitCode, stdout, stderr) = ProcessUtils.Exec (XcrunPath, fullArgs); + if (exitCode != 0) { + log.LogInfo ("simctl {0} failed (exit {1}): {2}", args [0], exitCode, stderr.Trim ()); + return null; + } + return stdout; + } catch (System.ComponentModel.Win32Exception ex) { + log.LogInfo ("Could not run xcrun: {0}", ex.Message); + return null; + } + } + } +} From 8b3c6c5fd4fc29a7faaa01b67abc84b3d03b4f48 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Tue, 3 Mar 2026 14:47:56 +0000 Subject: [PATCH 2/3] Address review feedback: xcrun xcodebuild, version param, tests - Use xcrun xcodebuild instead of resolving xcodebuild path inside Xcode bundle - Remove ResolveXcodebuild and XcodebuildRelativePath (rolfbjarne feedback) - Add version parameter to DownloadPlatform (-buildVersion flag) - Add input validation to ListByPlatform - Catch InvalidOperationException alongside Win32Exception - Add RuntimeServiceTests with smoke tests - Adopt file-scoped namespaces and flat usings - Sync SimctlOutputParser and tests.csproj from PR #158 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Xamarin.MacDev/RuntimeService.cs | 203 +++++++++++++------------------ tests/RuntimeServiceTests.cs | 52 ++++++++ 2 files changed, 139 insertions(+), 116 deletions(-) create mode 100644 tests/RuntimeServiceTests.cs diff --git a/Xamarin.MacDev/RuntimeService.cs b/Xamarin.MacDev/RuntimeService.cs index a255056..9e89b51 100644 --- a/Xamarin.MacDev/RuntimeService.cs +++ b/Xamarin.MacDev/RuntimeService.cs @@ -5,147 +5,118 @@ using System.Collections.Generic; using System.IO; using System.Linq; - using Xamarin.MacDev.Models; #nullable enable -namespace Xamarin.MacDev { - - /// - /// Manages simulator runtimes via xcrun simctl and xcodebuild. - /// Lists installed runtimes and supports downloading new platform runtimes. - /// Download approach from ClientTools.Platform: xcodebuild -downloadPlatform iOS. - /// - public class RuntimeService { - - static readonly string XcrunPath = "/usr/bin/xcrun"; - static readonly string XcodebuildRelativePath = "Contents/Developer/usr/bin/xcodebuild"; +namespace Xamarin.MacDev; - readonly ICustomLogger log; +/// +/// Manages simulator runtimes via xcrun simctl and xcrun xcodebuild. +/// +public class RuntimeService { - public RuntimeService (ICustomLogger log) - { - this.log = log ?? throw new ArgumentNullException (nameof (log)); - } + static readonly string XcrunPath = "/usr/bin/xcrun"; - /// - /// Lists installed simulator runtimes. Optionally filters by availability. - /// Uses xcrun simctl list runtimes --json. - /// - public List List (bool availableOnly = false) - { - var json = RunSimctl ("list", "runtimes", "--json"); - if (json is null) - return new List (); + readonly ICustomLogger log; - var runtimes = SimctlOutputParser.ParseRuntimes (json); + public RuntimeService (ICustomLogger log) + { + this.log = log ?? throw new ArgumentNullException (nameof (log)); + } - if (availableOnly) - runtimes.RemoveAll (r => !r.IsAvailable); + /// + /// Lists installed simulator runtimes. Optionally filters by availability. + /// + public List List (bool availableOnly = false) + { + var json = RunSimctl ("list", "runtimes", "--json"); + if (json is null) + return new List (); - log.LogInfo ("Found {0} simulator runtime(s).", runtimes.Count); - return runtimes; - } + var runtimes = SimctlOutputParser.ParseRuntimes (json); - /// - /// Lists runtimes for a specific platform (e.g. "iOS", "tvOS", "watchOS", "visionOS"). - /// - public List ListByPlatform (string platform, bool availableOnly = false) - { - var all = List (availableOnly); - return all.Where (r => string.Equals (r.Platform, platform, StringComparison.OrdinalIgnoreCase)).ToList (); - } + if (availableOnly) + runtimes.RemoveAll (r => !r.IsAvailable); - /// - /// Downloads a platform runtime using xcodebuild -downloadPlatform. - /// Pattern from ClientTools.Platform RemoteSimulatorValidator. - /// - /// The platform to download (e.g. "iOS", "tvOS", "watchOS", "visionOS"). - /// The Xcode.app path. If null, looks for xcodebuild in PATH via xcrun. - /// True if the download command succeeded. - public bool DownloadPlatform (string platform, string? xcodePath = null) - { - if (string.IsNullOrEmpty (platform)) - throw new ArgumentException ("Platform must not be null or empty.", nameof (platform)); - - var xcodebuildPath = ResolveXcodebuild (xcodePath); - if (xcodebuildPath is null) { - log.LogInfo ("Cannot download platform: xcodebuild not found."); - return false; - } + log.LogInfo ("Found {0} simulator runtime(s).", runtimes.Count); + return runtimes; + } - log.LogInfo ("Downloading {0} platform runtime via xcodebuild...", platform); + /// + /// Lists runtimes for a specific platform (e.g. "iOS", "tvOS", "watchOS", "visionOS"). + /// + public List ListByPlatform (string platform, bool availableOnly = false) + { + if (string.IsNullOrEmpty (platform)) + throw new ArgumentException ("Platform must not be null or empty.", nameof (platform)); - try { - var (exitCode, stdout, stderr) = ProcessUtils.Exec (xcodebuildPath, "-downloadPlatform", platform); - if (exitCode != 0) { - log.LogInfo ("xcodebuild -downloadPlatform {0} failed (exit {1}): {2}", platform, exitCode, stderr.Trim ()); - return false; - } + var all = List (availableOnly); + return all.Where (r => string.Equals (r.Platform, platform, StringComparison.OrdinalIgnoreCase)).ToList (); + } - log.LogInfo ("Successfully downloaded {0} platform runtime.", platform); - return true; - } catch (System.ComponentModel.Win32Exception ex) { - log.LogInfo ("Could not run xcodebuild: {0}", ex.Message); + /// + /// Downloads a platform runtime using xcrun xcodebuild -downloadPlatform. + /// + /// The platform to download (e.g. "iOS", "tvOS", "watchOS", "visionOS"). + /// Optional specific version to download (e.g. "17.5"). + /// True if the download command succeeded. + public bool DownloadPlatform (string platform, string? version = null) + { + if (string.IsNullOrEmpty (platform)) + throw new ArgumentException ("Platform must not be null or empty.", nameof (platform)); + + log.LogInfo ("Downloading {0} platform runtime via xcodebuild...", platform); + + try { + var args = string.IsNullOrEmpty (version) + ? new [] { "xcodebuild", "-downloadPlatform", platform } + : new [] { "xcodebuild", "-downloadPlatform", platform, "-buildVersion", version! }; + + var (exitCode, _, stderr) = ProcessUtils.Exec (XcrunPath, args); + if (exitCode != 0) { + log.LogInfo ("xcodebuild -downloadPlatform {0} failed (exit {1}): {2}", platform, exitCode, stderr.Trim ()); return false; } - } - - /// - /// Resolves the path to xcodebuild. If xcodePath is given, looks inside the Xcode bundle. - /// Otherwise falls back to /usr/bin/xcrun xcodebuild. - /// - string? ResolveXcodebuild (string? xcodePath) - { - if (!string.IsNullOrEmpty (xcodePath)) { - var path = Path.Combine (xcodePath!, XcodebuildRelativePath); - if (File.Exists (path)) - return path; - } - // Fall back to xcrun to find xcodebuild - if (File.Exists (XcrunPath)) { - try { - var (exitCode, stdout, _) = ProcessUtils.Exec (XcrunPath, "--find", "xcodebuild"); - if (exitCode == 0) { - var path = stdout.Trim (); - if (File.Exists (path)) - return path; - } - } catch (System.ComponentModel.Win32Exception) { - // fall through - } - } + log.LogInfo ("Successfully downloaded {0} platform runtime.", platform); + return true; + } catch (System.ComponentModel.Win32Exception ex) { + log.LogInfo ("Could not run xcodebuild: {0}", ex.Message); + return false; + } catch (InvalidOperationException ex) { + log.LogInfo ("Could not run xcodebuild: {0}", ex.Message); + return false; + } + } + /// + /// Runs a simctl subcommand and returns stdout, or null on failure. + /// + string? RunSimctl (params string [] args) + { + if (!File.Exists (XcrunPath)) { + log.LogInfo ("xcrun not found at '{0}'.", XcrunPath); return null; } - /// - /// Runs a simctl subcommand and returns stdout, or null on failure. - /// - string? RunSimctl (params string [] args) - { - if (!File.Exists (XcrunPath)) { - log.LogInfo ("xcrun not found at '{0}'.", XcrunPath); - return null; - } + var fullArgs = new string [args.Length + 1]; + fullArgs [0] = "simctl"; + Array.Copy (args, 0, fullArgs, 1, args.Length); - var fullArgs = new string [args.Length + 1]; - fullArgs [0] = "simctl"; - Array.Copy (args, 0, fullArgs, 1, args.Length); - - try { - var (exitCode, stdout, stderr) = ProcessUtils.Exec (XcrunPath, fullArgs); - if (exitCode != 0) { - log.LogInfo ("simctl {0} failed (exit {1}): {2}", args [0], exitCode, stderr.Trim ()); - return null; - } - return stdout; - } catch (System.ComponentModel.Win32Exception ex) { - log.LogInfo ("Could not run xcrun: {0}", ex.Message); + try { + var (exitCode, stdout, stderr) = ProcessUtils.Exec (XcrunPath, fullArgs); + if (exitCode != 0) { + log.LogInfo ("simctl {0} failed (exit {1}): {2}", args [0], exitCode, stderr.Trim ()); return null; } + return stdout; + } catch (System.ComponentModel.Win32Exception ex) { + log.LogInfo ("Could not run xcrun: {0}", ex.Message); + return null; + } catch (InvalidOperationException ex) { + log.LogInfo ("Could not run xcrun: {0}", ex.Message); + return null; } } } diff --git a/tests/RuntimeServiceTests.cs b/tests/RuntimeServiceTests.cs new file mode 100644 index 0000000..1e26988 --- /dev/null +++ b/tests/RuntimeServiceTests.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using NUnit.Framework; +using Xamarin.MacDev; + +#nullable enable + +namespace tests; + +[TestFixture] +public class RuntimeServiceTests { + + [Test] + public void Constructor_ThrowsOnNullLogger () + { + Assert.Throws (() => new RuntimeService (null!)); + } + + [Test] + public void ListByPlatform_ThrowsOnNullPlatform () + { + var svc = new RuntimeService (ConsoleLogger.Instance); + Assert.Throws (() => svc.ListByPlatform (null!)); + Assert.Throws (() => svc.ListByPlatform ("")); + } + + [Test] + public void DownloadPlatform_ThrowsOnNullPlatform () + { + var svc = new RuntimeService (ConsoleLogger.Instance); + Assert.Throws (() => svc.DownloadPlatform (null!)); + Assert.Throws (() => svc.DownloadPlatform ("")); + } + + [Test] + [Platform ("MacOsX")] + public void List_DoesNotThrow () + { + var svc = new RuntimeService (ConsoleLogger.Instance); + Assert.DoesNotThrow (() => svc.List ()); + } + + [Test] + [Platform ("MacOsX")] + public void ListByPlatform_DoesNotThrow () + { + var svc = new RuntimeService (ConsoleLogger.Instance); + Assert.DoesNotThrow (() => svc.ListByPlatform ("iOS")); + } +} From 57d3228eecdb788d6578de493295301cd39576e9 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Wed, 4 Mar 2026 10:14:25 +0000 Subject: [PATCH 3/3] Use shared SimCtl class and add exception logging to parsers - Refactor RuntimeService to use shared SimCtl class instead of inline RunSimctl - Add subprocess logging to DownloadPlatform (xcrun xcodebuild) - Add optional ICustomLogger to ParseDevices/ParseRuntimes for exception logging Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Xamarin.MacDev/RuntimeService.cs | 38 +++++--------------------------- 1 file changed, 5 insertions(+), 33 deletions(-) diff --git a/Xamarin.MacDev/RuntimeService.cs b/Xamarin.MacDev/RuntimeService.cs index 9e89b51..cf436d6 100644 --- a/Xamarin.MacDev/RuntimeService.cs +++ b/Xamarin.MacDev/RuntimeService.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.IO; using System.Linq; using Xamarin.MacDev.Models; @@ -19,10 +18,12 @@ public class RuntimeService { static readonly string XcrunPath = "/usr/bin/xcrun"; readonly ICustomLogger log; + readonly SimCtl simctl; public RuntimeService (ICustomLogger log) { this.log = log ?? throw new ArgumentNullException (nameof (log)); + simctl = new SimCtl (log); } /// @@ -30,11 +31,11 @@ public RuntimeService (ICustomLogger log) /// public List List (bool availableOnly = false) { - var json = RunSimctl ("list", "runtimes", "--json"); + var json = simctl.Run ("list", "runtimes", "--json"); if (json is null) return new List (); - var runtimes = SimctlOutputParser.ParseRuntimes (json); + var runtimes = SimctlOutputParser.ParseRuntimes (json, log); if (availableOnly) runtimes.RemoveAll (r => !r.IsAvailable); @@ -73,6 +74,7 @@ public bool DownloadPlatform (string platform, string? version = null) ? new [] { "xcodebuild", "-downloadPlatform", platform } : new [] { "xcodebuild", "-downloadPlatform", platform, "-buildVersion", version! }; + log.LogInfo ("Executing: {0} {1}", XcrunPath, string.Join (" ", args)); var (exitCode, _, stderr) = ProcessUtils.Exec (XcrunPath, args); if (exitCode != 0) { log.LogInfo ("xcodebuild -downloadPlatform {0} failed (exit {1}): {2}", platform, exitCode, stderr.Trim ()); @@ -89,34 +91,4 @@ public bool DownloadPlatform (string platform, string? version = null) return false; } } - - /// - /// Runs a simctl subcommand and returns stdout, or null on failure. - /// - string? RunSimctl (params string [] args) - { - if (!File.Exists (XcrunPath)) { - log.LogInfo ("xcrun not found at '{0}'.", XcrunPath); - return null; - } - - var fullArgs = new string [args.Length + 1]; - fullArgs [0] = "simctl"; - Array.Copy (args, 0, fullArgs, 1, args.Length); - - try { - var (exitCode, stdout, stderr) = ProcessUtils.Exec (XcrunPath, fullArgs); - if (exitCode != 0) { - log.LogInfo ("simctl {0} failed (exit {1}): {2}", args [0], exitCode, stderr.Trim ()); - return null; - } - return stdout; - } catch (System.ComponentModel.Win32Exception ex) { - log.LogInfo ("Could not run xcrun: {0}", ex.Message); - return null; - } catch (InvalidOperationException ex) { - log.LogInfo ("Could not run xcrun: {0}", ex.Message); - return null; - } - } }