| File: src\Aspire.Tray\Common\TrayStartupRegistration.cs | Web Access |
| Project: src\tests\Aspire.Tray.Tests\Aspire.Tray.Tests.csproj (Aspire.Tray.Tests) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Text; namespace Aspire.Tray; /// <summary> /// An explicitly selected registration location with optimistic external-edit protection. /// </summary> internal interface ITrayStartupRegistrationStore { string? Read(); void Write(string? expected, string? value); } /// <summary> /// Derives startup state from an owned registration, never from a preference flag. /// </summary> internal abstract class RegisteredTrayStartupSettings(ITrayStartupRegistrationStore store) : ITrayStartupSettings { private const string PolicyDetail = "Applies at the next sign-in only. Moving or removing the CLI installation breaks startup. Operating-system startup policy may override this registration."; private bool _read; private string? _observed; protected abstract string? UnavailableReason { get; } protected abstract bool IsOwned(string registration); protected abstract string CreateRegistration(); protected virtual void PrepareRegistration() { } public TrayStartupState Read() { var current = ReadOwned(); _observed = current; _read = true; return State(current); } public TrayStartupState SetEnabled(bool enabled) { if (!_read) { Read(); } var current = ReadOwned(); if (!string.Equals(current, _observed, StringComparison.Ordinal)) { throw new InvalidOperationException("The startup registration changed outside Aspire. Reopen Settings before changing it."); } string? desired = null; if (enabled) { if (UnavailableReason is { } reason) { throw new InvalidOperationException(reason); } desired = CreateRegistration(); try { PrepareRegistration(); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { throw new InvalidOperationException("Aspire could not install its sign-in startup helper. The startup registration was not changed.", ex); } } if (!string.Equals(current, desired, StringComparison.Ordinal)) { try { store.Write(current, desired); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { throw new InvalidOperationException("Aspire could not update the sign-in startup registration.", ex); } } _observed = desired; return State(desired); } private string? ReadOwned() { string? current; try { current = store.Read(); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { throw new InvalidOperationException("Aspire could not read the sign-in startup registration.", ex); } if (current is not null && !IsOwned(current)) { throw new InvalidOperationException("An unrelated or modified registration occupies Aspire's startup location. It was left unchanged."); } return current; } private TrayStartupState State(string? registration) { var reason = UnavailableReason; return new(registration is not null, reason is null, reason is null ? PolicyDetail : $"{reason} Existing registration can still be disabled. {PolicyDetail}"); } } /// <summary> /// Atomically updates a single owned file without changing shared directory permissions. /// </summary> internal sealed class FileTrayStartupRegistrationStore(string path) : ITrayStartupRegistrationStore { public string? Read() { RejectLinks(path); try { using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); if (stream.Length > 64 * 1024) { throw new InvalidOperationException("The startup registration is unexpectedly large and was left unchanged."); } using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); return reader.ReadToEnd(); } catch (FileNotFoundException) { return null; } catch (DirectoryNotFoundException) { return null; } } public void Write(string? expected, string? value) { if (!string.Equals(Read(), expected, StringComparison.Ordinal)) { throw new InvalidOperationException("The startup registration changed outside Aspire. It was left unchanged."); } if (value is null) { if (expected is not null) { File.Delete(path); } return; } var directory = Path.GetDirectoryName(path) ?? throw new InvalidOperationException("An absolute startup registration directory is required."); // LaunchAgents is shared by other applications. Only our file receives mode 0600; // never chmod the user's existing LaunchAgents directory. Directory.CreateDirectory(directory); RejectLinks(path); var temporary = Path.Combine(directory, $".aspire-startup-{Guid.NewGuid():N}.tmp"); try { var options = new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None }; if (!OperatingSystem.IsWindows()) { options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; } using (var stream = new FileStream(temporary, options)) { stream.Write(Encoding.UTF8.GetBytes(value)); stream.Flush(flushToDisk: true); } if (!string.Equals(Read(), expected, StringComparison.Ordinal)) { throw new InvalidOperationException("The startup registration changed outside Aspire. It was left unchanged."); } File.Move(temporary, path, overwrite: expected is not null); } finally { File.Delete(temporary); } } internal static void RejectLinks(string path) { if (!Path.IsPathFullyQualified(path)) { throw new InvalidOperationException("An absolute startup registration path is required."); } foreach (FileSystemInfo entry in new FileSystemInfo[] { new FileInfo(path), new DirectoryInfo(Path.GetDirectoryName(path)!) }) { if (entry.LinkTarget is not null || (entry.Exists && (entry.Attributes & FileAttributes.ReparsePoint) != 0)) { throw new InvalidOperationException("The startup registration must not use symbolic links or reparse points."); } } } }