diff --git a/App/App.xaml.cs b/App/App.xaml.cs index 0b159a9..4a35a0f 100644 --- a/App/App.xaml.cs +++ b/App/App.xaml.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; using System.Threading; using System.Threading.Tasks; using Coder.Desktop.App.Models; @@ -73,6 +74,8 @@ public async Task ExitApplication() { _handleWindowClosed = false; Exit(); + var syncController = _services.GetRequiredService(); + await syncController.DisposeAsync(); var rpcController = _services.GetRequiredService(); // TODO: send a StopRequest if we're connected??? await rpcController.DisposeAsync(); @@ -86,20 +89,52 @@ protected override void OnLaunched(LaunchActivatedEventArgs args) if (rpcController.GetState().RpcLifecycle == RpcLifecycle.Disconnected) // Passing in a CT with no cancellation is desired here, because // the named pipe open will block until the pipe comes up. - _ = rpcController.Reconnect(CancellationToken.None); + // TODO: log + _ = rpcController.Reconnect(CancellationToken.None).ContinueWith(t => + { +#if DEBUG + if (t.Exception != null) + { + Debug.WriteLine(t.Exception); + Debugger.Break(); + } +#endif + }); - // Load the credentials in the background. Even though we pass a CT - // with no cancellation, the method itself will impose a timeout on the - // HTTP portion. + // Load the credentials in the background. + var credentialManagerCts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); var credentialManager = _services.GetRequiredService(); - _ = credentialManager.LoadCredentials(CancellationToken.None); + _ = credentialManager.LoadCredentials(credentialManagerCts.Token).ContinueWith(t => + { + // TODO: log +#if DEBUG + if (t.Exception != null) + { + Debug.WriteLine(t.Exception); + Debugger.Break(); + } +#endif + credentialManagerCts.Dispose(); + }, CancellationToken.None); + + // Initialize file sync. + var syncSessionCts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var syncSessionController = _services.GetRequiredService(); + _ = syncSessionController.RefreshState(syncSessionCts.Token).ContinueWith(t => + { + // TODO: log +#if DEBUG + if (t.IsCanceled || t.Exception != null) Debugger.Break(); +#endif + syncSessionCts.Dispose(); + }, CancellationToken.None); // Prevent the TrayWindow from closing, just hide it. var trayWindow = _services.GetRequiredService(); - trayWindow.Closed += (sender, args) => + trayWindow.Closed += (_, closedArgs) => { if (!_handleWindowClosed) return; - args.Handled = true; + closedArgs.Handled = true; trayWindow.AppWindow.Hide(); }; } diff --git a/App/Models/RpcModel.cs b/App/Models/RpcModel.cs index dacef38..034f405 100644 --- a/App/Models/RpcModel.cs +++ b/App/Models/RpcModel.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.Linq; using Coder.Desktop.Vpn.Proto; namespace Coder.Desktop.App.Models; @@ -26,9 +25,9 @@ public class RpcModel public VpnLifecycle VpnLifecycle { get; set; } = VpnLifecycle.Unknown; - public List Workspaces { get; set; } = []; + public IReadOnlyList Workspaces { get; set; } = []; - public List Agents { get; set; } = []; + public IReadOnlyList Agents { get; set; } = []; public RpcModel Clone() { @@ -36,8 +35,8 @@ public RpcModel Clone() { RpcLifecycle = RpcLifecycle, VpnLifecycle = VpnLifecycle, - Workspaces = Workspaces.ToList(), - Agents = Agents.ToList(), + Workspaces = Workspaces, + Agents = Agents, }; } } diff --git a/App/Models/SyncSessionControllerStateModel.cs b/App/Models/SyncSessionControllerStateModel.cs new file mode 100644 index 0000000..524a858 --- /dev/null +++ b/App/Models/SyncSessionControllerStateModel.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; + +namespace Coder.Desktop.App.Models; + +public enum SyncSessionControllerLifecycle +{ + // Uninitialized means that the daemon has not been started yet. This can + // be resolved by calling RefreshState (or any other RPC method + // successfully). + Uninitialized, + + // Stopped means that the daemon is not running. This could be because: + // - It was never started (pre-Initialize) + // - It was stopped due to no sync sessions (post-Initialize, post-operation) + // - The last start attempt failed (DaemonError will be set) + // - The last daemon process crashed (DaemonError will be set) + Stopped, + + // Running is the normal state where the daemon is running and managing + // sync sessions. This is only set after a successful start (including + // being able to connect to the daemon). + Running, +} + +public class SyncSessionControllerStateModel +{ + public SyncSessionControllerLifecycle Lifecycle { get; init; } = SyncSessionControllerLifecycle.Stopped; + + /// + /// May be set when Lifecycle is Stopped to signify that the daemon failed + /// to start or unexpectedly crashed. + /// + public string? DaemonError { get; init; } + + public required string DaemonLogFilePath { get; init; } + + /// + /// This contains the last known state of all sync sessions. Sync sessions + /// are periodically refreshed if the daemon is running. This list is + /// sorted by creation time. + /// + public IReadOnlyList SyncSessions { get; init; } = []; +} diff --git a/App/Models/SyncSessionModel.cs b/App/Models/SyncSessionModel.cs index d8d261d..46137f5 100644 --- a/App/Models/SyncSessionModel.cs +++ b/App/Models/SyncSessionModel.cs @@ -1,6 +1,9 @@ using System; +using System.Collections.Generic; +using System.Linq; using Coder.Desktop.App.Converters; using Coder.Desktop.MutagenSdk.Proto.Synchronization; +using Coder.Desktop.MutagenSdk.Proto.Synchronization.Core; using Coder.Desktop.MutagenSdk.Proto.Url; namespace Coder.Desktop.App.Models; @@ -48,7 +51,7 @@ public string Description(string linePrefix = "") public class SyncSessionModel { public readonly string Identifier; - public readonly string Name; + public readonly DateTime CreatedAt; public readonly string AlphaName; public readonly string AlphaPath; @@ -62,14 +65,24 @@ public class SyncSessionModel public readonly SyncSessionModelEndpointSize AlphaSize; public readonly SyncSessionModelEndpointSize BetaSize; - public readonly string[] Errors = []; + public readonly IReadOnlyList Conflicts; // Conflict descriptions + public readonly ulong OmittedConflicts; + public readonly IReadOnlyList Errors; + + // If Paused is true, the session can be resumed. If false, the session can + // be paused. + public bool Paused => StatusCategory is SyncSessionStatusCategory.Paused or SyncSessionStatusCategory.Halted; public string StatusDetails { get { - var str = $"{StatusString} ({StatusCategory})\n\n{StatusDescription}"; - foreach (var err in Errors) str += $"\n\n{err}"; + var str = StatusString; + if (StatusCategory.ToString() != StatusString) str += $" ({StatusCategory})"; + str += $"\n\n{StatusDescription}"; + foreach (var err in Errors) str += $"\n\n-----\n\n{err}"; + foreach (var conflict in Conflicts) str += $"\n\n-----\n\n{conflict}"; + if (OmittedConflicts > 0) str += $"\n\n-----\n\n{OmittedConflicts:N0} conflicts omitted"; return str; } } @@ -84,41 +97,10 @@ public string SizeDetails } } - // TODO: remove once we process sessions from the mutagen RPC - public SyncSessionModel(string alphaPath, string betaName, string betaPath, - SyncSessionStatusCategory statusCategory, - string statusString, string statusDescription, string[] errors) - { - Identifier = "TODO"; - Name = "TODO"; - - AlphaName = "Local"; - AlphaPath = alphaPath; - BetaName = betaName; - BetaPath = betaPath; - StatusCategory = statusCategory; - StatusString = statusString; - StatusDescription = statusDescription; - AlphaSize = new SyncSessionModelEndpointSize - { - SizeBytes = (ulong)new Random().Next(0, 1000000000), - FileCount = (ulong)new Random().Next(0, 10000), - DirCount = (ulong)new Random().Next(0, 10000), - }; - BetaSize = new SyncSessionModelEndpointSize - { - SizeBytes = (ulong)new Random().Next(0, 1000000000), - FileCount = (ulong)new Random().Next(0, 10000), - DirCount = (ulong)new Random().Next(0, 10000), - }; - - Errors = errors; - } - public SyncSessionModel(State state) { Identifier = state.Session.Identifier; - Name = state.Session.Name; + CreatedAt = state.Session.CreationTime.ToDateTime(); (AlphaName, AlphaPath) = NameAndPathFromUrl(state.Session.Alpha); (BetaName, BetaPath) = NameAndPathFromUrl(state.Session.Beta); @@ -220,6 +202,9 @@ public SyncSessionModel(State state) StatusDescription = "The session has conflicts that need to be resolved."; } + Conflicts = state.Conflicts.Select(ConflictToString).ToList(); + OmittedConflicts = state.ExcludedConflicts; + AlphaSize = new SyncSessionModelEndpointSize { SizeBytes = state.AlphaState.TotalFileSize, @@ -235,9 +220,24 @@ public SyncSessionModel(State state) SymlinkCount = state.BetaState.SymbolicLinks, }; - // TODO: accumulate errors, there seems to be multiple fields they can - // come from - if (!string.IsNullOrWhiteSpace(state.LastError)) Errors = [state.LastError]; + List errors = []; + if (!string.IsNullOrWhiteSpace(state.LastError)) errors.Add($"Last error:\n {state.LastError}"); + // TODO: scan problems + transition problems + omissions should probably be fields + foreach (var scanProblem in state.AlphaState.ScanProblems) errors.Add($"Alpha scan problem: {scanProblem}"); + if (state.AlphaState.ExcludedScanProblems > 0) + errors.Add($"Alpha scan problems omitted: {state.AlphaState.ExcludedScanProblems}"); + foreach (var scanProblem in state.AlphaState.ScanProblems) errors.Add($"Beta scan problem: {scanProblem}"); + if (state.BetaState.ExcludedScanProblems > 0) + errors.Add($"Beta scan problems omitted: {state.BetaState.ExcludedScanProblems}"); + foreach (var transitionProblem in state.AlphaState.TransitionProblems) + errors.Add($"Alpha transition problem: {transitionProblem}"); + if (state.AlphaState.ExcludedTransitionProblems > 0) + errors.Add($"Alpha transition problems omitted: {state.AlphaState.ExcludedTransitionProblems}"); + foreach (var transitionProblem in state.AlphaState.TransitionProblems) + errors.Add($"Beta transition problem: {transitionProblem}"); + if (state.BetaState.ExcludedTransitionProblems > 0) + errors.Add($"Beta transition problems omitted: {state.BetaState.ExcludedTransitionProblems}"); + Errors = errors; } private static (string, string) NameAndPathFromUrl(URL url) @@ -251,4 +251,55 @@ private static (string, string) NameAndPathFromUrl(URL url) return (name, path); } + + private static string ConflictToString(Conflict conflict) + { + string? friendlyProblem = null; + if (conflict.AlphaChanges.Count == 1 && conflict.BetaChanges.Count == 1 && + conflict.AlphaChanges[0].Old == null && + conflict.BetaChanges[0].Old == null && + conflict.AlphaChanges[0].New != null && + conflict.BetaChanges[0].New != null) + friendlyProblem = + "An entry was created on both endpoints and they do not match. You can resolve this conflict by deleting one of the entries on either side."; + + var str = $"Conflict at path '{conflict.Root}':"; + foreach (var change in conflict.AlphaChanges) + str += $"\n (alpha) {ChangeToString(change)}"; + foreach (var change in conflict.BetaChanges) + str += $"\n (beta) {ChangeToString(change)}"; + if (friendlyProblem != null) + str += $"\n\n{friendlyProblem}"; + + return str; + } + + private static string ChangeToString(Change change) + { + return $"{change.Path} ({EntryToString(change.Old)} -> {EntryToString(change.New)})"; + } + + private static string EntryToString(Entry? entry) + { + if (entry == null) return ""; + var str = entry.Kind.ToString(); + switch (entry.Kind) + { + case EntryKind.Directory: + str += $" ({entry.Contents.Count} entries)"; + break; + case EntryKind.File: + var digest = BitConverter.ToString(entry.Digest.ToByteArray()).Replace("-", "").ToLower(); + str += $" ({digest}, executable: {entry.Executable})"; + break; + case EntryKind.SymbolicLink: + str += $" (target: {entry.Target})"; + break; + case EntryKind.Problematic: + str += $" ({entry.Problem})"; + break; + } + + return str; + } } diff --git a/App/Services/MutagenController.cs b/App/Services/MutagenController.cs index 4bd5688..dd489df 100644 --- a/App/Services/MutagenController.cs +++ b/App/Services/MutagenController.cs @@ -1,38 +1,94 @@ using System; -using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Diagnostics; using System.IO; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Coder.Desktop.App.Models; using Coder.Desktop.MutagenSdk; using Coder.Desktop.MutagenSdk.Proto.Selection; using Coder.Desktop.MutagenSdk.Proto.Service.Daemon; +using Coder.Desktop.MutagenSdk.Proto.Service.Prompting; using Coder.Desktop.MutagenSdk.Proto.Service.Synchronization; +using Coder.Desktop.MutagenSdk.Proto.Synchronization; +using Coder.Desktop.MutagenSdk.Proto.Url; using Coder.Desktop.Vpn.Utilities; +using Grpc.Core; using Microsoft.Extensions.Options; -using TerminateRequest = Coder.Desktop.MutagenSdk.Proto.Service.Daemon.TerminateRequest; +using DaemonTerminateRequest = Coder.Desktop.MutagenSdk.Proto.Service.Daemon.TerminateRequest; +using MutagenProtocol = Coder.Desktop.MutagenSdk.Proto.Url.Protocol; +using SynchronizationTerminateRequest = Coder.Desktop.MutagenSdk.Proto.Service.Synchronization.TerminateRequest; namespace Coder.Desktop.App.Services; public class CreateSyncSessionRequest { - // TODO: this -} + public required Endpoint Alpha { get; init; } + public required Endpoint Beta { get; init; } -public interface ISyncSessionController -{ - Task> ListSyncSessions(CancellationToken ct); - Task CreateSyncSession(CreateSyncSessionRequest req, CancellationToken ct); + public class Endpoint + { + public enum ProtocolKind + { + Local, + Ssh, + } + + public required ProtocolKind Protocol { get; init; } + public string User { get; init; } = ""; + public string Host { get; init; } = ""; + public uint Port { get; init; } = 0; + public string Path { get; init; } = ""; - Task TerminateSyncSession(string identifier, CancellationToken ct); + public URL MutagenUrl + { + get + { + var protocol = Protocol switch + { + ProtocolKind.Local => MutagenProtocol.Local, + ProtocolKind.Ssh => MutagenProtocol.Ssh, + _ => throw new ArgumentException($"Invalid protocol '{Protocol}'", nameof(Protocol)), + }; + + return new URL + { + Kind = Kind.Synchronization, + Protocol = protocol, + User = User, + Host = Host, + Port = Port, + Path = Path, + }; + } + } + } +} - // - // Initializes the controller; running the daemon if there are any saved sessions. Must be called and - // complete before other methods are allowed. - // - Task Initialize(CancellationToken ct); +public interface ISyncSessionController : IAsyncDisposable +{ + public event EventHandler StateChanged; + + /// + /// Gets the current state of the controller. + /// + SyncSessionControllerStateModel GetState(); + + // All the following methods will raise a StateChanged event *BEFORE* they return. + + /// + /// Starts the daemon (if it's not running) and fully refreshes the state of the controller. This should be + /// called at startup and after any unexpected daemon crashes to attempt to retry. + /// Additionally, the first call to RefreshState will start a background task to keep the state up-to-date while + /// the daemon is running. + /// + Task RefreshState(CancellationToken ct = default); + + Task CreateSyncSession(CreateSyncSessionRequest req, CancellationToken ct = default); + Task PauseSyncSession(string identifier, CancellationToken ct = default); + Task ResumeSyncSession(string identifier, CancellationToken ct = default); + Task TerminateSyncSession(string identifier, CancellationToken ct = default); } // These values are the config option names used in the registry. Any option @@ -42,48 +98,47 @@ public interface ISyncSessionController // If changed here, they should also be changed in the installer. public class MutagenControllerConfig { + // This is set to "[INSTALLFOLDER]\vpn\mutagen.exe" by the installer. [Required] public string MutagenExecutablePath { get; set; } = @"c:\mutagen.exe"; } -// -// A file synchronization controller based on the Mutagen Daemon. -// -public sealed class MutagenController : ISyncSessionController, IAsyncDisposable +/// +/// A file synchronization controller based on the Mutagen Daemon. +/// +public sealed class MutagenController : ISyncSessionController { - // Lock to protect all non-readonly class members. + // Protects all private non-readonly class members. private readonly RaiiSemaphoreSlim _lock = new(1, 1); - // daemonProcess is non-null while the daemon is running, starting, or + private readonly CancellationTokenSource _stateUpdateCts = new(); + private Task? _stateUpdateTask; + + // _state is the current state of the controller. It is updated + // continuously while the daemon is running and after most operations. + private SyncSessionControllerStateModel? _state; + + // _daemonProcess is non-null while the daemon is running, starting, or // in the process of stopping. private Process? _daemonProcess; private LogWriter? _logWriter; - // holds an in-progress task starting or stopping the daemon. If task is null, - // then we are not starting or stopping, and the _daemonProcess will be null if - // the daemon is currently stopped. If the task is not null, the daemon is - // starting or stopping. If stopping, the result is null. - private Task? _inProgressTransition; - // holds a client connected to the running mutagen daemon, if the daemon is running. private MutagenClient? _mutagenClient; - // holds a local count of SyncSessions, primarily so we can tell when to shut down - // the daemon because it is unneeded. - private int _sessionCount = -1; - // set to true if we are disposing the controller. Prevents the daemon from being // restarted. private bool _disposing; private readonly string _mutagenExecutablePath; - private readonly string _mutagenDataDirectory = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "CoderDesktop", "mutagen"); + private string MutagenDaemonLog => Path.Combine(_mutagenDataDirectory, "daemon.log"); + public MutagenController(IOptions config) { _mutagenExecutablePath = config.Value.MutagenExecutablePath; @@ -95,185 +150,344 @@ public MutagenController(string executablePath, string dataDirectory) _mutagenDataDirectory = dataDirectory; } + public event EventHandler? StateChanged; + public async ValueTask DisposeAsync() { - Task? transition = null; - using (_ = await _lock.LockAsync(CancellationToken.None)) - { - _disposing = true; - if (_inProgressTransition == null && _daemonProcess == null && _mutagenClient == null) return; - transition = _inProgressTransition; - } + using var _ = await _lock.LockAsync(CancellationToken.None); + _disposing = true; + + await _stateUpdateCts.CancelAsync(); + if (_stateUpdateTask != null) + try + { + await _stateUpdateTask; + } + catch + { + // ignored + } + + _stateUpdateCts.Dispose(); + + using var stopCts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await StopDaemon(stopCts.Token); - if (transition != null) await transition; - await StopDaemon(new CancellationTokenSource(TimeSpan.FromSeconds(5)).Token); GC.SuppressFinalize(this); } - - public async Task CreateSyncSession(CreateSyncSessionRequest req, CancellationToken ct) + public SyncSessionControllerStateModel GetState() { - // reads of _sessionCount are atomic, so don't bother locking for this quick check. - if (_sessionCount == -1) throw new InvalidOperationException("Controller must be Initialized first"); - var client = await EnsureDaemon(ct); - // TODO: implement - using (_ = await _lock.LockAsync(ct)) + // No lock required to read the reference. + var state = _state; + // No clone needed as the model is immutable. + if (state != null) return state; + return new SyncSessionControllerStateModel { - _sessionCount += 1; - } + Lifecycle = SyncSessionControllerLifecycle.Uninitialized, + DaemonError = null, + DaemonLogFilePath = MutagenDaemonLog, + SyncSessions = [], + }; + } - // TODO: implement this - return new SyncSessionModel(@"C:\path", "remote", "~/path", SyncSessionStatusCategory.Ok, "Watching", - "Description", []); + public async Task RefreshState(CancellationToken ct = default) + { + using var _ = await _lock.LockAsync(ct); + var client = await EnsureDaemon(ct); + var state = await UpdateState(client, ct); + _stateUpdateTask ??= UpdateLoop(_stateUpdateCts.Token); + return state; } - public async Task> ListSyncSessions(CancellationToken ct) + public async Task CreateSyncSession(CreateSyncSessionRequest req, CancellationToken ct = default) { - // reads of _sessionCount are atomic, so don't bother locking for this quick check. - switch (_sessionCount) + using var _ = await _lock.LockAsync(ct); + var client = await EnsureDaemon(ct); + + await using var prompter = await Prompter.Create(client, true, ct); + var createRes = await client.Synchronization.CreateAsync(new CreateRequest { - case < 0: - throw new InvalidOperationException("Controller must be Initialized first"); - case 0: - // If we already know there are no sessions, don't start up the daemon - // again. - return []; - } + Prompter = prompter.Identifier, + Specification = new CreationSpecification + { + Alpha = req.Alpha.MutagenUrl, + Beta = req.Beta.MutagenUrl, + // TODO: probably should set these at some point + Configuration = new Configuration(), + ConfigurationAlpha = new Configuration(), + ConfigurationBeta = new Configuration(), + }, + }, cancellationToken: ct); + if (createRes == null) throw new InvalidOperationException("CreateAsync returned null"); - // TODO: implement this - return []; + var session = await GetSyncSession(client, createRes.Session, ct); + await UpdateState(client, ct); + return session; } - public async Task Initialize(CancellationToken ct) + public async Task PauseSyncSession(string identifier, CancellationToken ct = default) { - using (_ = await _lock.LockAsync(ct)) + using var _ = await _lock.LockAsync(ct); + var client = await EnsureDaemon(ct); + + // Pausing sessions doesn't require prompting as seen in the mutagen CLI. + await using var prompter = await Prompter.Create(client, false, ct); + await client.Synchronization.PauseAsync(new PauseRequest { - if (_sessionCount != -1) throw new InvalidOperationException("Initialized more than once"); - _sessionCount = -2; // in progress - } + Prompter = prompter.Identifier, + Selection = new Selection + { + Specifications = { identifier }, + }, + }, cancellationToken: ct); + + var session = await GetSyncSession(client, identifier, ct); + await UpdateState(client, ct); + return session; + } + public async Task ResumeSyncSession(string identifier, CancellationToken ct = default) + { + using var _ = await _lock.LockAsync(ct); var client = await EnsureDaemon(ct); - var sessions = await client.Synchronization.ListAsync(new ListRequest + + await using var prompter = await Prompter.Create(client, true, ct); + await client.Synchronization.ResumeAsync(new ResumeRequest { + Prompter = prompter.Identifier, Selection = new Selection { - All = true, + Specifications = { identifier }, }, }, cancellationToken: ct); - using (_ = await _lock.LockAsync(ct)) - { - _sessionCount = sessions == null ? 0 : sessions.SessionStates.Count; - // check first that no other transition is happening - if (_sessionCount != 0 || _inProgressTransition != null) - return; - - // don't pass the CancellationToken; we're not going to wait for - // this Task anyway. - var transition = StopDaemon(new CancellationTokenSource(TimeSpan.FromSeconds(5)).Token); - _inProgressTransition = transition; - _ = transition.ContinueWith(RemoveTransition, CancellationToken.None); - // here we don't need to wait for the transition to complete - // before returning from Initialize(), since other operations - // will wait for the _inProgressTransition to complete before - // doing anything. - } + var session = await GetSyncSession(client, identifier, ct); + await UpdateState(client, ct); + return session; } - public async Task TerminateSyncSession(string identifier, CancellationToken ct) + public async Task TerminateSyncSession(string identifier, CancellationToken ct = default) { - if (_sessionCount < 0) throw new InvalidOperationException("Controller must be Initialized first"); + using var _ = await _lock.LockAsync(ct); var client = await EnsureDaemon(ct); - // TODO: implement - // here we don't use the Cancellation Token, since we want to decrement and possibly - // stop the daemon even if we were cancelled, since we already successfully terminated - // the session. - using (_ = await _lock.LockAsync(CancellationToken.None)) + // Terminating sessions doesn't require prompting as seen in the mutagen CLI. + await using var prompter = await Prompter.Create(client, true, ct); + + await client.Synchronization.TerminateAsync(new SynchronizationTerminateRequest + { + Prompter = prompter.Identifier, + Selection = new Selection + { + Specifications = { identifier }, + }, + }, cancellationToken: ct); + + await UpdateState(client, ct); + } + + private async Task UpdateLoop(CancellationToken ct) + { + while (!ct.IsCancellationRequested) { - _sessionCount -= 1; - if (_sessionCount == 0) - // check first that no other transition is happening - if (_inProgressTransition == null) + await Task.Delay(TimeSpan.FromSeconds(2), ct); // 2s matches macOS app + try + { + // We use a zero timeout here to avoid waiting. If another + // operation is holding the lock, it will update the state once + // it completes anyway. + var locker = await _lock.LockAsync(TimeSpan.Zero, ct); + if (locker == null) continue; + using (locker) { - var transition = StopDaemon(CancellationToken.None); - _inProgressTransition = transition; - _ = transition.ContinueWith(RemoveTransition, CancellationToken.None); - // here we don't need to wait for the transition to complete - // before returning, since other operations - // will wait for the _inProgressTransition to complete before - // doing anything. + if (_mutagenClient == null) continue; + await UpdateState(_mutagenClient, ct); } + } + catch + { + // ignore + } } } + private static async Task GetSyncSession(MutagenClient client, string identifier, + CancellationToken ct) + { + var listRes = await client.Synchronization.ListAsync(new ListRequest + { + Selection = new Selection + { + Specifications = { identifier }, + }, + }, cancellationToken: ct); + if (listRes == null) throw new InvalidOperationException("ListAsync returned null"); + if (listRes.SessionStates.Count != 1) + throw new InvalidOperationException("ListAsync returned wrong number of sessions"); + + return new SyncSessionModel(listRes.SessionStates[0]); + } - private async Task EnsureDaemon(CancellationToken ct) + private void ReplaceState(SyncSessionControllerStateModel state) { - while (true) + _state = state; + // Since the event handlers could block (or call back the + // SyncSessionController and deadlock), we run these in a new task. + var stateChanged = StateChanged; + if (stateChanged == null) return; + Task.Run(() => stateChanged.Invoke(this, state)); + } + + /// + /// Refreshes state and potentially stops the daemon if there are no sessions. The client must not be used after + /// this method is called. + /// Must be called AND awaited with the lock held. + /// + private async Task UpdateState(MutagenClient client, + CancellationToken ct = default) + { + ListResponse listResponse; + try { - ct.ThrowIfCancellationRequested(); - Task transition; - using (_ = await _lock.LockAsync(ct)) + listResponse = await client.Synchronization.ListAsync(new ListRequest { - if (_disposing) throw new ObjectDisposedException(ToString(), "async disposal underway"); - if (_mutagenClient != null && _inProgressTransition == null) return _mutagenClient; - if (_inProgressTransition != null) - { - transition = _inProgressTransition; - } - else - { - // no transition in progress, this implies the _mutagenClient - // must be null, and we are stopped. - _inProgressTransition = StartDaemon(ct); - transition = _inProgressTransition; - _ = transition.ContinueWith(RemoveTransition, ct); - } + Selection = new Selection { All = true }, + }, cancellationToken: ct); + if (listResponse == null) + throw new InvalidOperationException("ListAsync returned null"); + } + catch (Exception e) + { + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var error = $"Failed to UpdateState: ListAsync: {e}"; + try + { + await StopDaemon(cts.Token); } + catch (Exception e2) + { + error = $"Failed to UpdateState: StopDaemon failed after failed ListAsync call: {e2}"; + } + + ReplaceState(new SyncSessionControllerStateModel + { + Lifecycle = SyncSessionControllerLifecycle.Stopped, + DaemonError = error, + DaemonLogFilePath = MutagenDaemonLog, + SyncSessions = [], + }); + throw; + } - // wait for the transition without holding the lock. - var result = await transition; - if (result != null) return result; + var lifecycle = SyncSessionControllerLifecycle.Running; + if (listResponse.SessionStates.Count == 0) + { + lifecycle = SyncSessionControllerLifecycle.Stopped; + try + { + await StopDaemon(ct); + } + catch (Exception e) + { + ReplaceState(new SyncSessionControllerStateModel + { + Lifecycle = SyncSessionControllerLifecycle.Stopped, + DaemonError = $"Failed to stop daemon after no sessions: {e}", + DaemonLogFilePath = MutagenDaemonLog, + SyncSessions = [], + }); + throw new InvalidOperationException("Failed to stop daemon after no sessions", e); + } } + + var sessions = listResponse.SessionStates + .Select(s => new SyncSessionModel(s)) + .ToList(); + sessions.Sort((a, b) => a.CreatedAt < b.CreatedAt ? -1 : 1); + var state = new SyncSessionControllerStateModel + { + Lifecycle = lifecycle, + DaemonError = null, + DaemonLogFilePath = MutagenDaemonLog, + SyncSessions = sessions, + }; + ReplaceState(state); + return state; } - // - // Remove the completed transition from _inProgressTransition - // - private void RemoveTransition(Task transition) + /// + /// Starts the daemon if it's not running and returns a client to it. + /// Must be called AND awaited with the lock held. + /// + private async Task EnsureDaemon(CancellationToken ct) { - using var _ = _lock.Lock(); - if (_inProgressTransition == transition) _inProgressTransition = null; + ObjectDisposedException.ThrowIf(_disposing, typeof(MutagenController)); + if (_mutagenClient != null && _daemonProcess != null) + return _mutagenClient; + + try + { + return await StartDaemon(ct); + } + catch (Exception e) + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try + { + await StopDaemon(cts.Token); + } + catch + { + // ignored + } + + ReplaceState(new SyncSessionControllerStateModel + { + Lifecycle = SyncSessionControllerLifecycle.Stopped, + DaemonError = $"Failed to start daemon: {e}", + DaemonLogFilePath = MutagenDaemonLog, + SyncSessions = [], + }); + + throw; + } } - private async Task StartDaemon(CancellationToken ct) + /// + /// Starts the daemon and returns a client to it. + /// Must be called AND awaited with the lock held. + /// + private async Task StartDaemon(CancellationToken ct) { - // stop any orphaned daemon + // Stop the running daemon + if (_daemonProcess != null) await StopDaemon(ct); + + // Attempt to stop any orphaned daemon try { var client = new MutagenClient(_mutagenDataDirectory); - await client.Daemon.TerminateAsync(new TerminateRequest(), cancellationToken: ct); + await client.Daemon.TerminateAsync(new DaemonTerminateRequest(), cancellationToken: ct); } catch (FileNotFoundException) { // Mainline; no daemon running. } + catch (InvalidOperationException) + { + // Mainline; no daemon running. + } // If we get some failure while creating the log file or starting the process, we'll retry // it up to 5 times x 100ms. Those issues should resolve themselves quickly if they are // going to at all. const int maxAttempts = 5; - ListResponse? sessions = null; for (var attempts = 1; attempts <= maxAttempts; attempts++) { ct.ThrowIfCancellationRequested(); try { - using (_ = await _lock.LockAsync(ct)) - { - StartDaemonProcessLocked(); - } + StartDaemonProcess(); } catch (Exception e) when (e is not OperationCanceledException) { @@ -287,34 +501,16 @@ private void RemoveTransition(Task transition) break; } - return await WaitForDaemon(ct); - } - - private async Task WaitForDaemon(CancellationToken ct) - { + // Wait for the RPC to be available. while (true) { ct.ThrowIfCancellationRequested(); try { - MutagenClient? client; - using (_ = await _lock.LockAsync(ct)) - { - client = _mutagenClient ?? new MutagenClient(_mutagenDataDirectory); - } - + var client = new MutagenClient(_mutagenDataDirectory); _ = await client.Daemon.VersionAsync(new VersionRequest(), cancellationToken: ct); - - using (_ = await _lock.LockAsync(ct)) - { - if (_mutagenClient != null) - // Some concurrent process already wrote a client; unexpected - // since we should be ensuring only one transition is happening - // at a time. Start over with the new client. - continue; - _mutagenClient = client; - return _mutagenClient; - } + _mutagenClient = client; + return client; } catch (Exception e) when (e is not OperationCanceledException) // TODO: Are there other permanent errors we can detect? @@ -325,12 +521,17 @@ private void RemoveTransition(Task transition) } } - private void StartDaemonProcessLocked() + /// + /// Starts the daemon process. + /// Must be called AND awaited with the lock held. + /// + private void StartDaemonProcess() { if (_daemonProcess != null) - throw new InvalidOperationException("startDaemonLock called when daemonProcess already present"); + throw new InvalidOperationException("StartDaemonProcess called when _daemonProcess already present"); // create the log file first, so ensure we have permissions + Directory.CreateDirectory(_mutagenDataDirectory); var logPath = Path.Combine(_mutagenDataDirectory, "daemon.log"); var logStream = new StreamWriter(logPath, true); @@ -338,53 +539,56 @@ private void StartDaemonProcessLocked() _daemonProcess.StartInfo.FileName = _mutagenExecutablePath; _daemonProcess.StartInfo.Arguments = "daemon run"; _daemonProcess.StartInfo.Environment.Add("MUTAGEN_DATA_DIRECTORY", _mutagenDataDirectory); + // hide the console window + _daemonProcess.StartInfo.CreateNoWindow = true; // shell needs to be disabled since we set the environment // https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.processstartinfo.environment?view=net-8.0 _daemonProcess.StartInfo.UseShellExecute = false; _daemonProcess.StartInfo.RedirectStandardError = true; - _daemonProcess.Start(); + // TODO: log exited process + // _daemonProcess.Exited += ... + if (!_daemonProcess.Start()) + throw new InvalidOperationException("Failed to start mutagen daemon process, Start returned false"); var writer = new LogWriter(_daemonProcess.StandardError, logStream); Task.Run(() => { _ = writer.Run(); }); _logWriter = writer; } - private async Task StopDaemon(CancellationToken ct) + /// + /// Stops the daemon process. + /// Must be called AND awaited with the lock held. + /// + private async Task StopDaemon(CancellationToken ct) { - Process? process; - MutagenClient? client; - LogWriter? writer; - using (_ = await _lock.LockAsync(ct)) - { - process = _daemonProcess; - client = _mutagenClient; - writer = _logWriter; - _daemonProcess = null; - _mutagenClient = null; - _logWriter = null; - } + var process = _daemonProcess; + var client = _mutagenClient; + var writer = _logWriter; + _daemonProcess = null; + _mutagenClient = null; + _logWriter = null; try { if (client == null) { - if (process == null) return null; + if (process == null) return; process.Kill(true); } else { try { - await client.Daemon.TerminateAsync(new TerminateRequest(), cancellationToken: ct); + await client.Daemon.TerminateAsync(new DaemonTerminateRequest(), cancellationToken: ct); } catch { - if (process == null) return null; + if (process == null) return; process.Kill(true); } } - if (process == null) return null; + if (process == null) return; var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(TimeSpan.FromSeconds(5)); await process.WaitForExitAsync(cts.Token); @@ -395,34 +599,133 @@ private void StartDaemonProcessLocked() process?.Dispose(); writer?.Dispose(); } - - return null; } -} -public class LogWriter(StreamReader reader, StreamWriter writer) : IDisposable -{ - public void Dispose() + private class Prompter : IAsyncDisposable { - reader.Dispose(); - writer.Dispose(); - GC.SuppressFinalize(this); - } + private readonly AsyncDuplexStreamingCall _dup; + private readonly CancellationTokenSource _cts; + private readonly Task _handleRequestsTask; + public string Identifier { get; } - public async Task Run() - { - try + private Prompter(string identifier, AsyncDuplexStreamingCall dup, + CancellationToken ct) { - string? line; - while ((line = await reader.ReadLineAsync()) != null) await writer.WriteLineAsync(line); + Identifier = identifier; + _dup = dup; + + _cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + _handleRequestsTask = HandleRequests(_cts.Token); } - catch + + public async ValueTask DisposeAsync() { - // TODO: Log? + await _cts.CancelAsync(); + try + { + await _handleRequestsTask; + } + catch + { + // ignored + } + + _cts.Dispose(); + GC.SuppressFinalize(this); } - finally + + public static async Task Create(MutagenClient client, bool allowPrompts = false, + CancellationToken ct = default) { - Dispose(); + var dup = client.Prompting.Host(cancellationToken: ct); + if (dup == null) throw new InvalidOperationException("Prompting.Host returned null"); + + try + { + // Write first request. + await dup.RequestStream.WriteAsync(new HostRequest + { + AllowPrompts = allowPrompts, + }, ct); + + // Read initial response. + if (!await dup.ResponseStream.MoveNext(ct)) + throw new InvalidOperationException("Prompting.Host response stream ended early"); + var response = dup.ResponseStream.Current; + if (response == null) + throw new InvalidOperationException("Prompting.Host response stream returned null"); + if (string.IsNullOrEmpty(response.Identifier)) + throw new InvalidOperationException("Prompting.Host response stream returned empty identifier"); + + return new Prompter(response.Identifier, dup, ct); + } + catch + { + await dup.RequestStream.CompleteAsync(); + dup.Dispose(); + throw; + } + } + + private async Task HandleRequests(CancellationToken ct) + { + try + { + while (true) + { + ct.ThrowIfCancellationRequested(); + + // Read next request and validate it. + if (!await _dup.ResponseStream.MoveNext(ct)) + throw new InvalidOperationException("Prompting.Host response stream ended early"); + var response = _dup.ResponseStream.Current; + if (response == null) + throw new InvalidOperationException("Prompting.Host response stream returned null"); + if (response.Message == null) + throw new InvalidOperationException("Prompting.Host response stream returned a null message"); + + // Currently we only reply to SSH fingerprint messages with + // "yes" and send an empty reply for everything else. + var reply = ""; + if (response.IsPrompt && response.Message.Contains("yes/no/[fingerprint]")) reply = "yes"; + + await _dup.RequestStream.WriteAsync(new HostRequest + { + Response = reply, + }, ct); + } + } + catch + { + await _dup.RequestStream.CompleteAsync(); + // TODO: log? + } + } + } + + private class LogWriter(StreamReader reader, StreamWriter writer) : IDisposable + { + public void Dispose() + { + reader.Dispose(); + writer.Dispose(); + GC.SuppressFinalize(this); + } + + public async Task Run() + { + try + { + while (await reader.ReadLineAsync() is { } line) await writer.WriteLineAsync(line); + } + catch + { + // TODO: Log? + } + finally + { + Dispose(); + } } } } diff --git a/App/Services/RpcController.cs b/App/Services/RpcController.cs index 248a011..17d3ccb 100644 --- a/App/Services/RpcController.cs +++ b/App/Services/RpcController.cs @@ -96,8 +96,8 @@ public async Task Reconnect(CancellationToken ct = default) { state.RpcLifecycle = RpcLifecycle.Connecting; state.VpnLifecycle = VpnLifecycle.Stopped; - state.Workspaces.Clear(); - state.Agents.Clear(); + state.Workspaces = []; + state.Agents = []; }); if (_speaker != null) @@ -127,8 +127,8 @@ public async Task Reconnect(CancellationToken ct = default) { state.RpcLifecycle = RpcLifecycle.Disconnected; state.VpnLifecycle = VpnLifecycle.Unknown; - state.Workspaces.Clear(); - state.Agents.Clear(); + state.Workspaces = []; + state.Agents = []; }); throw new RpcOperationException("Failed to reconnect to the RPC server", e); } @@ -137,8 +137,8 @@ public async Task Reconnect(CancellationToken ct = default) { state.RpcLifecycle = RpcLifecycle.Connected; state.VpnLifecycle = VpnLifecycle.Unknown; - state.Workspaces.Clear(); - state.Agents.Clear(); + state.Workspaces = []; + state.Agents = []; }); var statusReply = await _speaker.SendRequestAwaitReply(new ClientMessage @@ -276,10 +276,8 @@ private void ApplyStatusUpdate(Status status) Status.Types.Lifecycle.Stopped => VpnLifecycle.Stopped, _ => VpnLifecycle.Stopped, }; - state.Workspaces.Clear(); - state.Workspaces.AddRange(status.PeerUpdate.UpsertedWorkspaces); - state.Agents.Clear(); - state.Agents.AddRange(status.PeerUpdate.UpsertedAgents); + state.Workspaces = status.PeerUpdate.UpsertedWorkspaces; + state.Agents = status.PeerUpdate.UpsertedAgents; }); } diff --git a/App/ViewModels/FileSyncListViewModel.cs b/App/ViewModels/FileSyncListViewModel.cs index 45ca318..7fdd881 100644 --- a/App/ViewModels/FileSyncListViewModel.cs +++ b/App/ViewModels/FileSyncListViewModel.cs @@ -10,12 +10,14 @@ using CommunityToolkit.Mvvm.Input; using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; using WinRT.Interop; namespace Coder.Desktop.App.ViewModels; public partial class FileSyncListViewModel : ObservableObject { + private Window? _window; private DispatcherQueue? _dispatcherQueue; private readonly ISyncSessionController _syncSessionController; @@ -29,10 +31,12 @@ public partial class FileSyncListViewModel : ObservableObject [NotifyPropertyChangedFor(nameof(ShowSessions))] public partial string? UnavailableMessage { get; set; } = null; + // Initially we use the current cached state, the loading screen is only + // shown when the user clicks "Reload" on the error screen. [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowLoading))] [NotifyPropertyChangedFor(nameof(ShowSessions))] - public partial bool Loading { get; set; } = true; + public partial bool Loading { get; set; } = false; [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowLoading))] @@ -40,7 +44,9 @@ public partial class FileSyncListViewModel : ObservableObject [NotifyPropertyChangedFor(nameof(ShowSessions))] public partial string? Error { get; set; } = null; - [ObservableProperty] public partial List Sessions { get; set; } = []; + [ObservableProperty] public partial bool OperationInProgress { get; set; } = false; + + [ObservableProperty] public partial List Sessions { get; set; } = []; [ObservableProperty] public partial bool CreatingNewSession { get; set; } = false; @@ -54,7 +60,7 @@ public partial class FileSyncListViewModel : ObservableObject [ObservableProperty] [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))] - public partial string NewSessionRemoteName { get; set; } = ""; + public partial string NewSessionRemoteHost { get; set; } = ""; [ObservableProperty] [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))] @@ -67,7 +73,7 @@ public bool NewSessionCreateEnabled { if (string.IsNullOrWhiteSpace(NewSessionLocalPath)) return false; if (NewSessionLocalPathDialogOpen) return false; - if (string.IsNullOrWhiteSpace(NewSessionRemoteName)) return false; + if (string.IsNullOrWhiteSpace(NewSessionRemoteHost)) return false; if (string.IsNullOrWhiteSpace(NewSessionRemotePath)) return false; return true; } @@ -85,42 +91,24 @@ public FileSyncListViewModel(ISyncSessionController syncSessionController, IRpcC _syncSessionController = syncSessionController; _rpcController = rpcController; _credentialManager = credentialManager; + } - Sessions = - [ - new SyncSessionModel(@"C:\Users\dean\git\coder-desktop-windows", "pog", "~/repos/coder-desktop-windows", - SyncSessionStatusCategory.Ok, "Watching", "Some description", []), - new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Paused, - "Paused", - "Some description", []), - new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Conflicts, - "Conflicts", "Some description", []), - new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Halted, - "Halted on root emptied", "Some description", []), - new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Error, - "Some error", "Some description", []), - new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Unknown, - "Unknown", "Some description", []), - new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Working, - "Reconciling", "Some description", []), - ]; - } - - public void Initialize(DispatcherQueue dispatcherQueue) + public void Initialize(Window window, DispatcherQueue dispatcherQueue) { + _window = window; _dispatcherQueue = dispatcherQueue; if (!_dispatcherQueue.HasThreadAccess) throw new InvalidOperationException("Initialize must be called from the UI thread"); _rpcController.StateChanged += RpcControllerStateChanged; _credentialManager.CredentialsChanged += CredentialManagerCredentialsChanged; + _syncSessionController.StateChanged += SyncSessionStateChanged; var rpcModel = _rpcController.GetState(); var credentialModel = _credentialManager.GetCachedCredentials(); MaybeSetUnavailableMessage(rpcModel, credentialModel); - - // TODO: Simulate loading until we have real data. - Task.Delay(TimeSpan.FromSeconds(3)).ContinueWith(_ => _dispatcherQueue.TryEnqueue(() => Loading = false)); + var syncSessionState = _syncSessionController.GetState(); + UpdateSyncSessionState(syncSessionState); } private void RpcControllerStateChanged(object? sender, RpcModel rpcModel) @@ -151,24 +139,53 @@ private void CredentialManagerCredentialsChanged(object? sender, CredentialModel MaybeSetUnavailableMessage(rpcModel, credentialModel); } + private void SyncSessionStateChanged(object? sender, SyncSessionControllerStateModel syncSessionState) + { + // Ensure we're on the UI thread. + if (_dispatcherQueue == null) return; + if (!_dispatcherQueue.HasThreadAccess) + { + _dispatcherQueue.TryEnqueue(() => SyncSessionStateChanged(sender, syncSessionState)); + return; + } + + UpdateSyncSessionState(syncSessionState); + } + private void MaybeSetUnavailableMessage(RpcModel rpcModel, CredentialModel credentialModel) { + var oldMessage = UnavailableMessage; if (rpcModel.RpcLifecycle != RpcLifecycle.Connected) + { UnavailableMessage = "Disconnected from the Windows service. Please see the tray window for more information."; + } else if (credentialModel.State != CredentialState.Valid) + { UnavailableMessage = "Please sign in to access file sync."; + } else if (rpcModel.VpnLifecycle != VpnLifecycle.Started) + { UnavailableMessage = "Please start Coder Connect from the tray window to access file sync."; + } else + { UnavailableMessage = null; + if (oldMessage != null) ReloadSessions(); + } + } + + private void UpdateSyncSessionState(SyncSessionControllerStateModel syncSessionState) + { + Error = syncSessionState.DaemonError; + Sessions = syncSessionState.SyncSessions.Select(s => new SyncSessionViewModel(this, s)).ToList(); } private void ClearNewForm() { CreatingNewSession = false; NewSessionLocalPath = ""; - NewSessionRemoteName = ""; + NewSessionRemoteHost = ""; NewSessionRemotePath = ""; } @@ -178,23 +195,24 @@ private void ReloadSessions() Loading = true; Error = null; var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); - _ = _syncSessionController.ListSyncSessions(cts.Token).ContinueWith(HandleList, cts.Token); + _syncSessionController.RefreshState(cts.Token).ContinueWith(HandleRefresh, CancellationToken.None); } - private void HandleList(Task> t) + private void HandleRefresh(Task t) { // Ensure we're on the UI thread. if (_dispatcherQueue == null) return; if (!_dispatcherQueue.HasThreadAccess) { - _dispatcherQueue.TryEnqueue(() => HandleList(t)); + _dispatcherQueue.TryEnqueue(() => HandleRefresh(t)); return; } if (t.IsCompletedSuccessfully) { - Sessions = t.Result.ToList(); + Sessions = t.Result.SyncSessions.Select(s => new SyncSessionViewModel(this, s)).ToList(); Loading = false; + Error = t.Result.DaemonError; return; } @@ -246,9 +264,131 @@ private void CancelNewSession() } [RelayCommand] - private void ConfirmNewSession() + private async Task ConfirmNewSession() { - // TODO: implement - ClearNewForm(); + if (OperationInProgress || !NewSessionCreateEnabled) return; + OperationInProgress = true; + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + try + { + // The controller will send us a state changed event. + await _syncSessionController.CreateSyncSession(new CreateSyncSessionRequest + { + Alpha = new CreateSyncSessionRequest.Endpoint + { + Protocol = CreateSyncSessionRequest.Endpoint.ProtocolKind.Local, + Path = NewSessionLocalPath, + }, + Beta = new CreateSyncSessionRequest.Endpoint + { + Protocol = CreateSyncSessionRequest.Endpoint.ProtocolKind.Ssh, + Host = NewSessionRemoteHost, + Path = NewSessionRemotePath, + }, + }, cts.Token); + + ClearNewForm(); + } + catch (Exception e) + { + var dialog = new ContentDialog + { + Title = "Failed to create sync session", + Content = $"{e}", + CloseButtonText = "Ok", + XamlRoot = _window?.Content.XamlRoot, + }; + _ = await dialog.ShowAsync(); + } + finally + { + OperationInProgress = false; + } + } + + public async Task PauseOrResumeSession(string identifier) + { + if (OperationInProgress) return; + OperationInProgress = true; + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var actionString = "resume/pause"; + try + { + if (Sessions.FirstOrDefault(s => s.Model.Identifier == identifier) is not { } session) + throw new InvalidOperationException("Session not found"); + + // The controller will send us a state changed event. + if (session.Model.Paused) + { + actionString = "resume"; + await _syncSessionController.ResumeSyncSession(session.Model.Identifier, cts.Token); + } + else + { + actionString = "pause"; + await _syncSessionController.PauseSyncSession(session.Model.Identifier, cts.Token); + } + } + catch (Exception e) + { + var dialog = new ContentDialog + { + Title = $"Failed to {actionString} sync session", + Content = $"Identifier: {identifier}\n{e}", + CloseButtonText = "Ok", + XamlRoot = _window?.Content.XamlRoot, + }; + _ = await dialog.ShowAsync(); + } + finally + { + OperationInProgress = false; + } + } + + public async Task TerminateSession(string identifier) + { + if (OperationInProgress) return; + OperationInProgress = true; + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + try + { + if (Sessions.FirstOrDefault(s => s.Model.Identifier == identifier) is not { } session) + throw new InvalidOperationException("Session not found"); + + var confirmDialog = new ContentDialog + { + Title = "Terminate sync session", + Content = "Are you sure you want to terminate this sync session?", + PrimaryButtonText = "Terminate", + CloseButtonText = "Cancel", + DefaultButton = ContentDialogButton.Close, + XamlRoot = _window?.Content.XamlRoot, + }; + var res = await confirmDialog.ShowAsync(); + if (res is not ContentDialogResult.Primary) + return; + + // The controller will send us a state changed event. + await _syncSessionController.TerminateSyncSession(session.Model.Identifier, cts.Token); + } + catch (Exception e) + { + var dialog = new ContentDialog + { + Title = "Failed to terminate sync session", + Content = $"Identifier: {identifier}\n{e}", + CloseButtonText = "Ok", + XamlRoot = _window?.Content.XamlRoot, + }; + _ = await dialog.ShowAsync(); + } + finally + { + OperationInProgress = false; + } } } diff --git a/App/ViewModels/SyncSessionViewModel.cs b/App/ViewModels/SyncSessionViewModel.cs new file mode 100644 index 0000000..7de6500 --- /dev/null +++ b/App/ViewModels/SyncSessionViewModel.cs @@ -0,0 +1,69 @@ +using System.Threading.Tasks; +using Coder.Desktop.App.Models; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Coder.Desktop.App.ViewModels; + +public partial class SyncSessionViewModel : ObservableObject +{ + public SyncSessionModel Model { get; } + + private FileSyncListViewModel Parent { get; } + + public string Icon => Model.Paused ? "\uE768" : "\uE769"; + + public SyncSessionViewModel(FileSyncListViewModel parent, SyncSessionModel model) + { + Parent = parent; + Model = model; + } + + [RelayCommand] + public async Task PauseOrResumeSession() + { + await Parent.PauseOrResumeSession(Model.Identifier); + } + + [RelayCommand] + public async Task TerminateSession() + { + await Parent.TerminateSession(Model.Identifier); + } + + // Check the comments in FileSyncListMainPage.xaml to see why this tooltip + // stuff is necessary. + private void SetToolTip(FrameworkElement element, string text) + { + // Get current tooltip and compare the text. Setting the tooltip with + // the same text causes it to dismiss itself. + var currentToolTip = ToolTipService.GetToolTip(element) as ToolTip; + if (currentToolTip?.Content as string == text) return; + + ToolTipService.SetToolTip(element, new ToolTip { Content = text }); + } + + public void OnStatusTextLoaded(object sender, RoutedEventArgs e) + { + if (sender is not FrameworkElement element) return; + SetToolTip(element, Model.StatusDetails); + } + + public void OnStatusTextDataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args) + { + SetToolTip(sender, Model.StatusDetails); + } + + public void OnSizeTextLoaded(object sender, RoutedEventArgs e) + { + if (sender is not FrameworkElement element) return; + SetToolTip(element, Model.SizeDetails); + } + + public void OnSizeTextDataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args) + { + SetToolTip(sender, Model.SizeDetails); + } +} diff --git a/App/Views/FileSyncListWindow.xaml.cs b/App/Views/FileSyncListWindow.xaml.cs index 27d386d..8a409d7 100644 --- a/App/Views/FileSyncListWindow.xaml.cs +++ b/App/Views/FileSyncListWindow.xaml.cs @@ -15,7 +15,7 @@ public FileSyncListWindow(FileSyncListViewModel viewModel) InitializeComponent(); SystemBackdrop = new DesktopAcrylicBackdrop(); - ViewModel.Initialize(DispatcherQueue); + ViewModel.Initialize(this, DispatcherQueue); RootFrame.Content = new FileSyncListMainPage(ViewModel, this); this.CenterOnScreen(); diff --git a/App/Views/Pages/FileSyncListMainPage.xaml b/App/Views/Pages/FileSyncListMainPage.xaml index 768e396..d38bc29 100644 --- a/App/Views/Pages/FileSyncListMainPage.xaml +++ b/App/Views/Pages/FileSyncListMainPage.xaml @@ -6,7 +6,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:models="using:Coder.Desktop.App.Models" + xmlns:viewmodels="using:Coder.Desktop.App.ViewModels" xmlns:converters="using:Coder.Desktop.App.Converters" mc:Ignorable="d" Background="{ThemeResource ApplicationPageBackgroundThemeBrush}"> @@ -126,8 +126,9 @@ - - + + +