diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 459579c..ac57947 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -21,7 +21,7 @@ jobs: cache: true cache-dependency-path: '**/packages.lock.json' - name: dotnet restore - run: dotnet restore --locked-mode + run: dotnet restore --locked-mode /p:BuildWithNetFrameworkHostedCompiler=true - name: dotnet format run: dotnet format --verify-no-changes --no-restore @@ -75,7 +75,7 @@ jobs: cache: true cache-dependency-path: '**/packages.lock.json' - name: dotnet restore - run: dotnet restore --locked-mode + run: dotnet restore --locked-mode /p:BuildWithNetFrameworkHostedCompiler=true # This doesn't call `dotnet publish` on the entire solution, just the # projects we care about building. Doing a full publish includes test # libraries and stuff which is pointless. diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index e6849aa..9ad6c16 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -18,6 +18,8 @@ permissions: jobs: release: runs-on: ${{ github.repository_owner == 'coder' && 'windows-latest-16-cores' || 'windows-latest' }} + outputs: + version: ${{ steps.version.outputs.VERSION }} timeout-minutes: 15 steps: @@ -117,3 +119,78 @@ jobs: ${{ steps.release.outputs.ARM64_OUTPUT_PATH }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + winget: + runs-on: depot-windows-latest + needs: release + steps: + - name: Sync fork + run: gh repo sync cdrci/winget-pkgs -b master + env: + GH_TOKEN: ${{ secrets.CDRCI_GITHUB_TOKEN }} + + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + + # If the event that triggered the build was an annotated tag (which our + # tags are supposed to be), actions/checkout has a bug where the tag in + # question is only a lightweight tag and not a full annotated tag. This + # command seems to fix it. + # https://github.com/actions/checkout/issues/290 + - name: Fetch git tags + run: git fetch --tags --force + + - name: Install wingetcreate + run: | + Invoke-WebRequest https://aka.ms/wingetcreate/latest -OutFile wingetcreate.exe + + - name: Submit updated manifest to winget-pkgs + run: | + $version = "${{ needs.release.outputs.version }}" + + $release_assets = gh release view --repo coder/coder-desktop-windows "v${version}" --json assets | ` + ConvertFrom-Json + # Get the installer URLs from the release assets. + $amd64_installer_url = $release_assets.assets | ` + Where-Object name -Match ".*-x64.exe$" | ` + Select -ExpandProperty url + $arm64_installer_url = $release_assets.assets | ` + Where-Object name -Match ".*-arm64.exe$" | ` + Select -ExpandProperty url + + echo "amd64 Installer URL: ${amd64_installer_url}" + echo "arm64 Installer URL: ${arm64_installer_url}" + echo "Package version: ${version}" + + .\wingetcreate.exe update Coder.CoderDesktop ` + --submit ` + --version "${version}" ` + --urls "${amd64_installer_url}" "${arm64_installer_url}" ` + --token "$env:WINGET_GH_TOKEN" + + env: + # For gh CLI: + GH_TOKEN: ${{ github.token }} + # For wingetcreate. We need a real token since we're pushing a commit + # to GitHub and then making a PR in a different repo. + WINGET_GH_TOKEN: ${{ secrets.CDRCI_GITHUB_TOKEN }} + + + - name: Comment on PR + run: | + # wait 30 seconds + Start-Sleep -Seconds 30.0 + # Find the PR that wingetcreate just made. + $version = "${{ needs.release.outputs.version }}" + $pr_list = gh pr list --repo microsoft/winget-pkgs --search "author:cdrci Coder.CoderDesktop version ${version}" --limit 1 --json number | ` + ConvertFrom-Json + $pr_number = $pr_list[0].number + + gh pr comment --repo microsoft/winget-pkgs "${pr_number}" --body "🤖 cc: @deansheather @matifali" + + env: + # For gh CLI. We need a real token since we're commenting on a PR in a + # different repo. + GH_TOKEN: ${{ secrets.CDRCI_GITHUB_TOKEN }} \ No newline at end of file diff --git a/App/App.xaml.cs b/App/App.xaml.cs index 5b82ced..06ab676 100644 --- a/App/App.xaml.cs +++ b/App/App.xaml.cs @@ -165,20 +165,22 @@ protected override void OnLaunched(LaunchActivatedEventArgs args) }, CancellationToken.None); // Initialize file sync. - var syncSessionCts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - var syncSessionController = _services.GetRequiredService(); - _ = syncSessionController.RefreshState(syncSessionCts.Token).ContinueWith(t => - { - if (t.IsCanceled || t.Exception != null) - { - _logger.LogError(t.Exception, "failed to refresh sync state (canceled = {canceled})", t.IsCanceled); -#if DEBUG - Debugger.Break(); -#endif - } + // We're adding a 5s delay here to avoid race conditions when loading the mutagen binary. - syncSessionCts.Dispose(); - }, CancellationToken.None); + _ = Task.Delay(5000).ContinueWith((_) => + { + var syncSessionCts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var syncSessionController = _services.GetRequiredService(); + syncSessionController.RefreshState(syncSessionCts.Token).ContinueWith( + t => + { + if (t.IsCanceled || t.Exception != null) + { + _logger.LogError(t.Exception, "failed to refresh sync state (canceled = {canceled})", t.IsCanceled); + } + syncSessionCts.Dispose(); + }, CancellationToken.None); + }); // Prevent the TrayWindow from closing, just hide it. var trayWindow = _services.GetRequiredService(); diff --git a/App/Controls/ExpandContent.xaml b/App/Controls/ExpandContent.xaml index d36170d..2cc0eb4 100644 --- a/App/Controls/ExpandContent.xaml +++ b/App/Controls/ExpandContent.xaml @@ -9,42 +9,43 @@ xmlns:toolkit="using:CommunityToolkit.WinUI" mc:Ignorable="d"> - + - + - - - + + + + - - - - + + + + + diff --git a/App/Controls/ExpandContent.xaml.cs b/App/Controls/ExpandContent.xaml.cs index 1cd5d2f..926af9a 100644 --- a/App/Controls/ExpandContent.xaml.cs +++ b/App/Controls/ExpandContent.xaml.cs @@ -2,38 +2,60 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Markup; +using System; +using System.Threading.Tasks; namespace Coder.Desktop.App.Controls; + [ContentProperty(Name = nameof(Children))] [DependencyProperty("IsOpen", DefaultValue = false)] public sealed partial class ExpandContent : UserControl { public UIElementCollection Children => CollapsiblePanel.Children; + private readonly string _expandedState = "ExpandedState"; + private readonly string _collapsedState = "CollapsedState"; + public ExpandContent() { InitializeComponent(); - } + Loaded += (_, __) => + { + // When we load the control for the first time (after panel swapping) + // we need to set the initial state based on IsOpen. + VisualStateManager.GoToState( + this, + IsOpen ? _expandedState : _collapsedState, + useTransitions: false); // NO animation yet - public void CollapseAnimation_Completed(object? sender, object args) - { - // Hide the panel completely when the collapse animation is done. This - // cannot be done with keyframes for some reason. - // - // Without this, the space will still be reserved for the panel. - CollapsiblePanel.Visibility = Visibility.Collapsed; + // If IsOpen was already true we must also show the panel + if (IsOpen) + { + CollapsiblePanel.Visibility = Visibility.Visible; + // This makes the panel expand to its full height + CollapsiblePanel.ClearValue(FrameworkElement.MaxHeightProperty); + } + }; } partial void OnIsOpenChanged(bool oldValue, bool newValue) { - var newState = newValue ? "ExpandedState" : "CollapsedState"; - - // The animation can't set visibility when starting or ending the - // animation. + var newState = newValue ? _expandedState : _collapsedState; if (newValue) + { CollapsiblePanel.Visibility = Visibility.Visible; + // We use BeginTime to ensure other panels are collapsed first. + // If the user clicks the expand button quickly, we want to avoid + // the panel expanding to its full height before the collapse animation completes. + CollapseSb.SkipToFill(); + } VisualStateManager.GoToState(this, newState, true); } + + private void CollapseStoryboard_Completed(object sender, object e) + { + CollapsiblePanel.Visibility = Visibility.Collapsed; + } } diff --git a/App/Services/RpcController.cs b/App/Services/RpcController.cs index 70dfe9f..7461ba8 100644 --- a/App/Services/RpcController.cs +++ b/App/Services/RpcController.cs @@ -234,6 +234,8 @@ public async Task StopVpn(CancellationToken ct = default) MutateState(state => { state.VpnLifecycle = VpnLifecycle.Unknown; }); throw new VpnLifecycleException($"Failed to stop VPN. Service reported failure: {reply.Stop.ErrorMessage}"); } + + MutateState(state => { state.VpnLifecycle = VpnLifecycle.Stopped; }); } public async ValueTask DisposeAsync() @@ -311,7 +313,7 @@ private void SpeakerOnError(Exception e) Debug.WriteLine($"Error: {e}"); try { - Reconnect(CancellationToken.None).Wait(); + using var _ = Reconnect(CancellationToken.None); } catch { diff --git a/App/ViewModels/AgentViewModel.cs b/App/ViewModels/AgentViewModel.cs index 34b01d7..cd5907b 100644 --- a/App/ViewModels/AgentViewModel.cs +++ b/App/ViewModels/AgentViewModel.cs @@ -237,12 +237,20 @@ public AgentViewModel(ILogger logger, ICoderApiClientFactory cod Id = id; - PropertyChanged += (_, args) => + PropertyChanging += (x, args) => { if (args.PropertyName == nameof(IsExpanded)) { - _expanderHost.HandleAgentExpanded(Id, IsExpanded); + var value = !IsExpanded; + if (value) + _expanderHost.HandleAgentExpanded(Id, value); + } + }; + PropertyChanged += (x, args) => + { + if (args.PropertyName == nameof(IsExpanded)) + { // Every time the drawer is expanded, re-fetch all apps. if (IsExpanded && !FetchingApps) FetchApps(); diff --git a/App/ViewModels/FileSyncListViewModel.cs b/App/ViewModels/FileSyncListViewModel.cs index da40e5c..cb84f56 100644 --- a/App/ViewModels/FileSyncListViewModel.cs +++ b/App/ViewModels/FileSyncListViewModel.cs @@ -48,7 +48,11 @@ public partial class FileSyncListViewModel : ObservableObject [NotifyPropertyChangedFor(nameof(ShowSessions))] public partial string? Error { get; set; } = null; - [ObservableProperty] public partial bool OperationInProgress { get; set; } = false; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanOpenLocalPath))] + [NotifyPropertyChangedFor(nameof(NewSessionRemoteHostEnabled))] + [NotifyPropertyChangedFor(nameof(NewSessionRemotePathDialogEnabled))] + public partial bool OperationInProgress { get; set; } = false; [ObservableProperty] public partial IReadOnlyList Sessions { get; set; } = []; @@ -60,6 +64,7 @@ public partial class FileSyncListViewModel : ObservableObject [ObservableProperty] [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))] + [NotifyPropertyChangedFor(nameof(CanOpenLocalPath))] public partial bool NewSessionLocalPathDialogOpen { get; set; } = false; [ObservableProperty] @@ -80,10 +85,12 @@ public partial class FileSyncListViewModel : ObservableObject [NotifyPropertyChangedFor(nameof(NewSessionRemotePathDialogEnabled))] public partial bool NewSessionRemotePathDialogOpen { get; set; } = false; - public bool NewSessionRemoteHostEnabled => AvailableHosts.Count > 0; + public bool CanOpenLocalPath => !NewSessionLocalPathDialogOpen && !OperationInProgress; + + public bool NewSessionRemoteHostEnabled => AvailableHosts.Count > 0 && !OperationInProgress; public bool NewSessionRemotePathDialogEnabled => - !string.IsNullOrWhiteSpace(NewSessionRemoteHost) && !NewSessionRemotePathDialogOpen; + !string.IsNullOrWhiteSpace(NewSessionRemoteHost) && !NewSessionRemotePathDialogOpen && !OperationInProgress; [ObservableProperty] public partial string NewSessionStatus { get; set; } = ""; @@ -136,9 +143,9 @@ public void Initialize(Window window, DispatcherQueue dispatcherQueue) var rpcModel = _rpcController.GetState(); var credentialModel = _credentialManager.GetCachedCredentials(); - MaybeSetUnavailableMessage(rpcModel, credentialModel); var syncSessionState = _syncSessionController.GetState(); UpdateSyncSessionState(syncSessionState); + MaybeSetUnavailableMessage(rpcModel, credentialModel, syncSessionState); } private void RpcControllerStateChanged(object? sender, RpcModel rpcModel) @@ -152,7 +159,8 @@ private void RpcControllerStateChanged(object? sender, RpcModel rpcModel) } var credentialModel = _credentialManager.GetCachedCredentials(); - MaybeSetUnavailableMessage(rpcModel, credentialModel); + var syncSessionState = _syncSessionController.GetState(); + MaybeSetUnavailableMessage(rpcModel, credentialModel, syncSessionState); } private void CredentialManagerCredentialsChanged(object? sender, CredentialModel credentialModel) @@ -166,7 +174,8 @@ private void CredentialManagerCredentialsChanged(object? sender, CredentialModel } var rpcModel = _rpcController.GetState(); - MaybeSetUnavailableMessage(rpcModel, credentialModel); + var syncSessionState = _syncSessionController.GetState(); + MaybeSetUnavailableMessage(rpcModel, credentialModel, syncSessionState); } private void SyncSessionStateChanged(object? sender, SyncSessionControllerStateModel syncSessionState) @@ -182,7 +191,7 @@ private void SyncSessionStateChanged(object? sender, SyncSessionControllerStateM UpdateSyncSessionState(syncSessionState); } - private void MaybeSetUnavailableMessage(RpcModel rpcModel, CredentialModel credentialModel) + private void MaybeSetUnavailableMessage(RpcModel rpcModel, CredentialModel credentialModel, SyncSessionControllerStateModel syncSessionState) { var oldMessage = UnavailableMessage; if (rpcModel.RpcLifecycle != RpcLifecycle.Connected) @@ -198,6 +207,10 @@ private void MaybeSetUnavailableMessage(RpcModel rpcModel, CredentialModel crede { UnavailableMessage = "Please start Coder Connect from the tray window to access file sync."; } + else if (syncSessionState.Lifecycle == SyncSessionControllerLifecycle.Uninitialized) + { + UnavailableMessage = "Sync session controller is not initialized. Please wait..."; + } else { UnavailableMessage = null; @@ -212,6 +225,13 @@ private void MaybeSetUnavailableMessage(RpcModel rpcModel, CredentialModel crede private void UpdateSyncSessionState(SyncSessionControllerStateModel syncSessionState) { + // This should never happen. + if (syncSessionState == null) + return; + if (syncSessionState.Lifecycle == SyncSessionControllerLifecycle.Uninitialized) + { + MaybeSetUnavailableMessage(_rpcController.GetState(), _credentialManager.GetCachedCredentials(), syncSessionState); + } Error = syncSessionState.DaemonError; Sessions = syncSessionState.SyncSessions.Select(s => new SyncSessionViewModel(this, s)).ToList(); } diff --git a/App/ViewModels/TrayWindowDisconnectedViewModel.cs b/App/ViewModels/TrayWindowDisconnectedViewModel.cs index 5fe16a2..ce6582c 100644 --- a/App/ViewModels/TrayWindowDisconnectedViewModel.cs +++ b/App/ViewModels/TrayWindowDisconnectedViewModel.cs @@ -1,8 +1,9 @@ -using System.Threading.Tasks; using Coder.Desktop.App.Models; using Coder.Desktop.App.Services; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using System; +using System.Threading.Tasks; namespace Coder.Desktop.App.ViewModels; @@ -11,6 +12,8 @@ public partial class TrayWindowDisconnectedViewModel : ObservableObject private readonly IRpcController _rpcController; [ObservableProperty] public partial bool ReconnectButtonEnabled { get; set; } = true; + [ObservableProperty] public partial string ErrorMessage { get; set; } = string.Empty; + [ObservableProperty] public partial bool ReconnectFailed { get; set; } = false; public TrayWindowDisconnectedViewModel(IRpcController rpcController) { @@ -26,6 +29,16 @@ private void UpdateFromRpcModel(RpcModel rpcModel) [RelayCommand] public async Task Reconnect() { - await _rpcController.Reconnect(); + try + { + ReconnectFailed = false; + ErrorMessage = string.Empty; + await _rpcController.Reconnect(); + } + catch (Exception ex) + { + ErrorMessage = ex.Message; + ReconnectFailed = true; + } } } diff --git a/App/ViewModels/TrayWindowLoginRequiredViewModel.cs b/App/ViewModels/TrayWindowLoginRequiredViewModel.cs index 628be72..abc1257 100644 --- a/App/ViewModels/TrayWindowLoginRequiredViewModel.cs +++ b/App/ViewModels/TrayWindowLoginRequiredViewModel.cs @@ -2,6 +2,7 @@ using Coder.Desktop.App.Views; using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.DependencyInjection; +using Microsoft.UI.Xaml; namespace Coder.Desktop.App.ViewModels; @@ -31,4 +32,10 @@ public void Login() _signInWindow.Closed += (_, _) => _signInWindow = null; _signInWindow.Activate(); } + + [RelayCommand] + public void Exit() + { + _ = ((App)Application.Current).ExitApplication(); + } } diff --git a/App/ViewModels/TrayWindowViewModel.cs b/App/ViewModels/TrayWindowViewModel.cs index cfa5163..d8b3182 100644 --- a/App/ViewModels/TrayWindowViewModel.cs +++ b/App/ViewModels/TrayWindowViewModel.cs @@ -126,7 +126,7 @@ public void HandleAgentExpanded(Uuid id, bool expanded) if (!expanded) return; _hasExpandedAgent = true; // Collapse every other agent. - foreach (var otherAgent in Agents.Where(a => a.Id != id)) + foreach (var otherAgent in Agents.Where(a => a.Id != id && a.IsExpanded == true)) otherAgent.SetExpanded(false); } @@ -207,7 +207,7 @@ private void UpdateFromRpcModel(RpcModel rpcModel) // For every stopped workspace that doesn't have any agents, add a // dummy agent row. foreach (var workspace in rpcModel.Workspaces.Where(w => - w.Status == Workspace.Types.Status.Stopped && !workspacesWithAgents.Contains(w.Id))) + ShouldShowDummy(w) && !workspacesWithAgents.Contains(w.Id))) { if (!Uuid.TryFrom(workspace.Id.Span, out var uuid)) continue; @@ -360,11 +360,10 @@ private void ShowFileSyncListWindow() } [RelayCommand] - private void SignOut() + private async Task SignOut() { - if (VpnLifecycle is not VpnLifecycle.Stopped) - return; - _credentialManager.ClearCredentials(); + await _rpcController.StopVpn(); + await _credentialManager.ClearCredentials(); } [RelayCommand] @@ -372,4 +371,21 @@ public void Exit() { _ = ((App)Application.Current).ExitApplication(); } + + private static bool ShouldShowDummy(Workspace workspace) + { + switch (workspace.Status) + { + case Workspace.Types.Status.Unknown: + case Workspace.Types.Status.Pending: + case Workspace.Types.Status.Starting: + case Workspace.Types.Status.Stopping: + case Workspace.Types.Status.Stopped: + return true; + // TODO: should we include and show a different color than Gray for workspaces that are + // failed, canceled or deleting? + default: + return false; + } + } } diff --git a/App/Views/Pages/FileSyncListMainPage.xaml b/App/Views/Pages/FileSyncListMainPage.xaml index cb9f2bb..0872c1a 100644 --- a/App/Views/Pages/FileSyncListMainPage.xaml +++ b/App/Views/Pages/FileSyncListMainPage.xaml @@ -318,11 +318,12 @@ Grid.Column="0" Margin="0,0,5,0" VerticalAlignment="Stretch" + IsEnabled="{x:Bind ViewModel.OperationInProgress,Converter={StaticResource InverseBoolConverter}, Mode=OneWay}" Text="{x:Bind ViewModel.NewSessionLocalPath, Mode=TwoWay}" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #(loc.FailureHyperlinkLogText) + + + + + + +