diff --git a/App/App.csproj b/App/App.csproj
index 2a15166..982612f 100644
--- a/App/App.csproj
+++ b/App/App.csproj
@@ -56,15 +56,19 @@
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
-
+
+
+
+
+
+
diff --git a/App/App.xaml.cs b/App/App.xaml.cs
index 4a35a0f..ba6fa67 100644
--- a/App/App.xaml.cs
+++ b/App/App.xaml.cs
@@ -1,18 +1,27 @@
using System;
+using System.Collections.Generic;
using System.Diagnostics;
+using System.IO;
using System.Threading;
using System.Threading.Tasks;
+using Windows.ApplicationModel.Activation;
using Coder.Desktop.App.Models;
using Coder.Desktop.App.Services;
using Coder.Desktop.App.ViewModels;
using Coder.Desktop.App.Views;
using Coder.Desktop.App.Views.Pages;
+using Coder.Desktop.CoderSdk.Agent;
using Coder.Desktop.Vpn;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
using Microsoft.UI.Xaml;
using Microsoft.Win32;
+using Microsoft.Windows.AppLifecycle;
+using Serilog;
+using LaunchActivatedEventArgs = Microsoft.UI.Xaml.LaunchActivatedEventArgs;
+using Microsoft.Windows.AppNotifications;
namespace Coder.Desktop.App;
@@ -21,28 +30,51 @@ public partial class App : Application
private readonly IServiceProvider _services;
private bool _handleWindowClosed = true;
+ private const string MutagenControllerConfigSection = "MutagenController";
#if !DEBUG
- private const string MutagenControllerConfigSection = "AppMutagenController";
+ private const string ConfigSubKey = @"SOFTWARE\Coder Desktop\App";
+ private const string logFilename = "app.log";
#else
- private const string MutagenControllerConfigSection = "DebugAppMutagenController";
+ private const string ConfigSubKey = @"SOFTWARE\Coder Desktop\DebugApp";
+ private const string logFilename = "debug-app.log";
#endif
+ private readonly ILogger _logger;
+ private readonly IUriHandler _uriHandler;
+
public App()
{
var builder = Host.CreateApplicationBuilder();
+ var configBuilder = builder.Configuration as IConfigurationBuilder;
- (builder.Configuration as IConfigurationBuilder).Add(
- new RegistryConfigurationSource(Registry.LocalMachine, @"SOFTWARE\Coder Desktop"));
+ // Add config in increasing order of precedence: first builtin defaults, then HKLM, finally HKCU
+ // so that the user's settings in the registry take precedence.
+ AddDefaultConfig(configBuilder);
+ configBuilder.Add(
+ new RegistryConfigurationSource(Registry.LocalMachine, ConfigSubKey));
+ configBuilder.Add(
+ new RegistryConfigurationSource(Registry.CurrentUser, ConfigSubKey));
var services = builder.Services;
+ // Logging
+ builder.Services.AddSerilog((_, loggerConfig) =>
+ {
+ loggerConfig.ReadFrom.Configuration(builder.Configuration);
+ });
+
+ services.AddSingleton();
+
services.AddSingleton();
services.AddSingleton();
services.AddOptions()
.Bind(builder.Configuration.GetSection(MutagenControllerConfigSection));
services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
// SignInWindow views and view models
services.AddTransient();
@@ -53,6 +85,8 @@ public App()
// FileSyncListMainPage is created by FileSyncListWindow.
services.AddTransient();
+ // DirectoryPickerWindow views and view models are created by FileSyncListViewModel.
+
// TrayWindow views and view models
services.AddTransient();
services.AddTransient();
@@ -66,12 +100,15 @@ public App()
services.AddTransient();
_services = services.BuildServiceProvider();
+ _logger = (ILogger)_services.GetService(typeof(ILogger))!;
+ _uriHandler = (IUriHandler)_services.GetService(typeof(IUriHandler))!;
InitializeComponent();
}
public async Task ExitApplication()
{
+ _logger.LogDebug("exiting app");
_handleWindowClosed = false;
Exit();
var syncController = _services.GetRequiredService();
@@ -84,36 +121,39 @@ public async Task ExitApplication()
protected override void OnLaunched(LaunchActivatedEventArgs args)
{
+ _logger.LogInformation("new instance launched");
// Start connecting to the manager in the background.
var rpcController = _services.GetRequiredService();
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.
- // TODO: log
- _ = rpcController.Reconnect(CancellationToken.None).ContinueWith(t =>
+ _logger.LogDebug("reconnecting with VPN service");
+ _ = rpcController.Reconnect(CancellationToken.None).ContinueWith(t =>
+ {
+ if (t.Exception != null)
{
+ _logger.LogError(t.Exception, "failed to connect to VPN service");
#if DEBUG
- if (t.Exception != null)
- {
- Debug.WriteLine(t.Exception);
- Debugger.Break();
- }
+ Debug.WriteLine(t.Exception);
+ Debugger.Break();
#endif
- });
+ }
+ });
// Load the credentials in the background.
var credentialManagerCts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
var credentialManager = _services.GetRequiredService();
_ = credentialManager.LoadCredentials(credentialManagerCts.Token).ContinueWith(t =>
{
- // TODO: log
-#if DEBUG
if (t.Exception != null)
{
+ _logger.LogError(t.Exception, "failed to load credentials");
+#if DEBUG
Debug.WriteLine(t.Exception);
Debugger.Break();
- }
#endif
+ }
+
credentialManagerCts.Dispose();
}, CancellationToken.None);
@@ -122,10 +162,14 @@ protected override void OnLaunched(LaunchActivatedEventArgs args)
var syncSessionController = _services.GetRequiredService();
_ = syncSessionController.RefreshState(syncSessionCts.Token).ContinueWith(t =>
{
- // TODO: log
+ if (t.IsCanceled || t.Exception != null)
+ {
+ _logger.LogError(t.Exception, "failed to refresh sync state (canceled = {canceled})", t.IsCanceled);
#if DEBUG
- if (t.IsCanceled || t.Exception != null) Debugger.Break();
+ Debugger.Break();
#endif
+ }
+
syncSessionCts.Dispose();
}, CancellationToken.None);
@@ -138,4 +182,67 @@ protected override void OnLaunched(LaunchActivatedEventArgs args)
trayWindow.AppWindow.Hide();
};
}
+
+ public void OnActivated(object? sender, AppActivationArguments args)
+ {
+ switch (args.Kind)
+ {
+ case ExtendedActivationKind.Protocol:
+ var protoArgs = args.Data as IProtocolActivatedEventArgs;
+ if (protoArgs == null)
+ {
+ _logger.LogWarning("URI activation with null data");
+ return;
+ }
+
+ // don't need to wait for it to complete.
+ _uriHandler.HandleUri(protoArgs.Uri).ContinueWith(t =>
+ {
+ if (t.Exception != null)
+ {
+ // don't log query params, as they contain secrets.
+ _logger.LogError(t.Exception,
+ "unhandled exception while processing URI coder://{authority}{path}",
+ protoArgs.Uri.Authority, protoArgs.Uri.AbsolutePath);
+ }
+ });
+
+ break;
+
+ case ExtendedActivationKind.AppNotification:
+ var notificationArgs = (args.Data as AppNotificationActivatedEventArgs)!;
+ HandleNotification(null, notificationArgs);
+ break;
+
+ default:
+ _logger.LogWarning("activation for {kind}, which is unhandled", args.Kind);
+ break;
+ }
+ }
+
+ public void HandleNotification(AppNotificationManager? sender, AppNotificationActivatedEventArgs args)
+ {
+ // right now, we don't do anything other than log
+ _logger.LogInformation("handled notification activation");
+ }
+
+ private static void AddDefaultConfig(IConfigurationBuilder builder)
+ {
+ var logPath = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "CoderDesktop",
+ logFilename);
+ builder.AddInMemoryCollection(new Dictionary
+ {
+ [MutagenControllerConfigSection + ":MutagenExecutablePath"] = @"C:\mutagen.exe",
+ ["Serilog:Using:0"] = "Serilog.Sinks.File",
+ ["Serilog:MinimumLevel"] = "Information",
+ ["Serilog:Enrich:0"] = "FromLogContext",
+ ["Serilog:WriteTo:0:Name"] = "File",
+ ["Serilog:WriteTo:0:Args:path"] = logPath,
+ ["Serilog:WriteTo:0:Args:outputTemplate"] =
+ "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext} - {Message:lj}{NewLine}{Exception}",
+ ["Serilog:WriteTo:0:Args:rollingInterval"] = "Day",
+ });
+ }
}
diff --git a/App/Converters/DependencyObjectSelector.cs b/App/Converters/DependencyObjectSelector.cs
index 8c1570f..a31c33b 100644
--- a/App/Converters/DependencyObjectSelector.cs
+++ b/App/Converters/DependencyObjectSelector.cs
@@ -186,3 +186,7 @@ private static void SelectedKeyPropertyChanged(DependencyObject obj, DependencyP
public sealed class StringToBrushSelectorItem : DependencyObjectSelectorItem;
public sealed class StringToBrushSelector : DependencyObjectSelector;
+
+public sealed class StringToStringSelectorItem : DependencyObjectSelectorItem;
+
+public sealed class StringToStringSelector : DependencyObjectSelector;
diff --git a/App/Package.appxmanifest b/App/Package.appxmanifest
deleted file mode 100644
index e3ad480..0000000
--- a/App/Package.appxmanifest
+++ /dev/null
@@ -1,52 +0,0 @@
-
-
-
-
-
-
-
-
-
- Coder Desktop (Package)
- Coder Technologies Inc.
- Images\StoreLogo.png
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/App/Program.cs b/App/Program.cs
index 2918caa..3749c3b 100644
--- a/App/Program.cs
+++ b/App/Program.cs
@@ -1,9 +1,11 @@
using System;
+using System.Reflection;
using System.Runtime.InteropServices;
using System.Threading;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.Windows.AppLifecycle;
+using Microsoft.Windows.AppNotifications;
using WinRT;
namespace Coder.Desktop.App;
@@ -26,7 +28,23 @@ private static void Main(string[] args)
try
{
ComWrappersSupport.InitializeComWrappers();
- if (!CheckSingleInstance()) return;
+ var mainInstance = GetMainInstance();
+ var activationArgs = AppInstance.GetCurrent().GetActivatedEventArgs();
+ if (!mainInstance.IsCurrent)
+ {
+ mainInstance.RedirectActivationToAsync(activationArgs).AsTask().Wait();
+ return;
+ }
+
+ // Register for URI handling (known as "protocol activation")
+#if DEBUG
+ const string scheme = "coder-debug";
+#else
+ const string scheme = "coder";
+#endif
+ var thisBin = Assembly.GetExecutingAssembly().Location;
+ ActivationRegistrationManager.RegisterForProtocolActivation(scheme, thisBin + ",1", "Coder Desktop", "");
+
Application.Start(p =>
{
var context = new DispatcherQueueSynchronizationContext(DispatcherQueue.GetForCurrentThread());
@@ -38,6 +56,18 @@ private static void Main(string[] args)
e.Handled = true;
ShowExceptionAndCrash(e.Exception);
};
+
+ // redirections via RedirectActivationToAsync above get routed to the App
+ mainInstance.Activated += app.OnActivated;
+ var notificationManager = AppNotificationManager.Default;
+ notificationManager.NotificationInvoked += app.HandleNotification;
+ notificationManager.Register();
+ if (activationArgs.Kind != ExtendedActivationKind.Launch)
+ {
+ // this means we were activated without having already launched, so handle
+ // the activation as well.
+ app.OnActivated(null, activationArgs);
+ }
});
}
catch (Exception e)
@@ -46,8 +76,7 @@ private static void Main(string[] args)
}
}
- [STAThread]
- private static bool CheckSingleInstance()
+ private static AppInstance GetMainInstance()
{
#if !DEBUG
const string appInstanceName = "Coder.Desktop.App";
@@ -55,11 +84,9 @@ private static bool CheckSingleInstance()
const string appInstanceName = "Coder.Desktop.App.Debug";
#endif
- var instance = AppInstance.FindOrRegisterForKey(appInstanceName);
- return instance.IsCurrent;
+ return AppInstance.FindOrRegisterForKey(appInstanceName);
}
- [STAThread]
private static void ShowExceptionAndCrash(Exception e)
{
const string title = "Coder Desktop Fatal Error";
diff --git a/App/Services/CredentialManager.cs b/App/Services/CredentialManager.cs
index 41a8dc7..280169c 100644
--- a/App/Services/CredentialManager.cs
+++ b/App/Services/CredentialManager.cs
@@ -7,6 +7,7 @@
using System.Threading.Tasks;
using Coder.Desktop.App.Models;
using Coder.Desktop.CoderSdk;
+using Coder.Desktop.CoderSdk.Coder;
using Coder.Desktop.Vpn.Utilities;
namespace Coder.Desktop.App.Services;
@@ -306,7 +307,7 @@ public WindowsCredentialBackend(string credentialsTargetName)
public Task ReadCredentials(CancellationToken ct = default)
{
- var raw = NativeApi.ReadCredentials(_credentialsTargetName);
+ var raw = Wincred.ReadCredentials(_credentialsTargetName);
if (raw == null) return Task.FromResult(null);
RawCredentials? credentials;
@@ -325,115 +326,179 @@ public WindowsCredentialBackend(string credentialsTargetName)
public Task WriteCredentials(RawCredentials credentials, CancellationToken ct = default)
{
var raw = JsonSerializer.Serialize(credentials, RawCredentialsJsonContext.Default.RawCredentials);
- NativeApi.WriteCredentials(_credentialsTargetName, raw);
+ Wincred.WriteCredentials(_credentialsTargetName, raw);
return Task.CompletedTask;
}
public Task DeleteCredentials(CancellationToken ct = default)
{
- NativeApi.DeleteCredentials(_credentialsTargetName);
+ Wincred.DeleteCredentials(_credentialsTargetName);
return Task.CompletedTask;
}
- private static class NativeApi
+}
+
+///
+/// Wincred provides relatively low level wrapped calls to the Wincred.h native API.
+///
+internal static class Wincred
+{
+ private const int CredentialTypeGeneric = 1;
+ private const int CredentialTypeDomainPassword = 2;
+ private const int PersistenceTypeLocalComputer = 2;
+ private const int ErrorNotFound = 1168;
+ private const int CredMaxCredentialBlobSize = 5 * 512;
+ private const string PackageNTLM = "NTLM";
+
+ public static string? ReadCredentials(string targetName)
{
- private const int CredentialTypeGeneric = 1;
- private const int PersistenceTypeLocalComputer = 2;
- private const int ErrorNotFound = 1168;
- private const int CredMaxCredentialBlobSize = 5 * 512;
+ if (!CredReadW(targetName, CredentialTypeGeneric, 0, out var credentialPtr))
+ {
+ var error = Marshal.GetLastWin32Error();
+ if (error == ErrorNotFound) return null;
+ throw new InvalidOperationException($"Failed to read credentials (Error {error})");
+ }
- public static string? ReadCredentials(string targetName)
+ try
{
- if (!CredReadW(targetName, CredentialTypeGeneric, 0, out var credentialPtr))
- {
- var error = Marshal.GetLastWin32Error();
- if (error == ErrorNotFound) return null;
- throw new InvalidOperationException($"Failed to read credentials (Error {error})");
- }
+ var cred = Marshal.PtrToStructure(credentialPtr);
+ return Marshal.PtrToStringUni(cred.CredentialBlob, cred.CredentialBlobSize / sizeof(char));
+ }
+ finally
+ {
+ CredFree(credentialPtr);
+ }
+ }
- try
- {
- var cred = Marshal.PtrToStructure(credentialPtr);
- return Marshal.PtrToStringUni(cred.CredentialBlob, cred.CredentialBlobSize / sizeof(char));
- }
- finally
+ public static void WriteCredentials(string targetName, string secret)
+ {
+ var byteCount = Encoding.Unicode.GetByteCount(secret);
+ if (byteCount > CredMaxCredentialBlobSize)
+ throw new ArgumentOutOfRangeException(nameof(secret),
+ $"The secret is greater than {CredMaxCredentialBlobSize} bytes");
+
+ var credentialBlob = Marshal.StringToHGlobalUni(secret);
+ var cred = new CREDENTIALW
+ {
+ Type = CredentialTypeGeneric,
+ TargetName = targetName,
+ CredentialBlobSize = byteCount,
+ CredentialBlob = credentialBlob,
+ Persist = PersistenceTypeLocalComputer,
+ };
+ try
+ {
+ if (!CredWriteW(ref cred, 0))
{
- CredFree(credentialPtr);
+ var error = Marshal.GetLastWin32Error();
+ throw new InvalidOperationException($"Failed to write credentials (Error {error})");
}
}
-
- public static void WriteCredentials(string targetName, string secret)
+ finally
{
- var byteCount = Encoding.Unicode.GetByteCount(secret);
- if (byteCount > CredMaxCredentialBlobSize)
- throw new ArgumentOutOfRangeException(nameof(secret),
- $"The secret is greater than {CredMaxCredentialBlobSize} bytes");
+ Marshal.FreeHGlobal(credentialBlob);
+ }
+ }
- var credentialBlob = Marshal.StringToHGlobalUni(secret);
- var cred = new CREDENTIAL
- {
- Type = CredentialTypeGeneric,
- TargetName = targetName,
- CredentialBlobSize = byteCount,
- CredentialBlob = credentialBlob,
- Persist = PersistenceTypeLocalComputer,
- };
- try
- {
- if (!CredWriteW(ref cred, 0))
- {
- var error = Marshal.GetLastWin32Error();
- throw new InvalidOperationException($"Failed to write credentials (Error {error})");
- }
- }
- finally
- {
- Marshal.FreeHGlobal(credentialBlob);
- }
+ public static void DeleteCredentials(string targetName)
+ {
+ if (!CredDeleteW(targetName, CredentialTypeGeneric, 0))
+ {
+ var error = Marshal.GetLastWin32Error();
+ if (error == ErrorNotFound) return;
+ throw new InvalidOperationException($"Failed to delete credentials (Error {error})");
}
+ }
+
+ public static void WriteDomainCredentials(string domainName, string serverName, string username, string password)
+ {
+ var targetName = $"{domainName}/{serverName}";
+ var targetInfo = new CREDENTIAL_TARGET_INFORMATIONW
+ {
+ TargetName = targetName,
+ DnsServerName = serverName,
+ DnsDomainName = domainName,
+ PackageName = PackageNTLM,
+ };
+ var byteCount = Encoding.Unicode.GetByteCount(password);
+ if (byteCount > CredMaxCredentialBlobSize)
+ throw new ArgumentOutOfRangeException(nameof(password),
+ $"The secret is greater than {CredMaxCredentialBlobSize} bytes");
- public static void DeleteCredentials(string targetName)
+ var credentialBlob = Marshal.StringToHGlobalUni(password);
+ var cred = new CREDENTIALW
{
- if (!CredDeleteW(targetName, CredentialTypeGeneric, 0))
+ Type = CredentialTypeDomainPassword,
+ TargetName = targetName,
+ CredentialBlobSize = byteCount,
+ CredentialBlob = credentialBlob,
+ Persist = PersistenceTypeLocalComputer,
+ UserName = username,
+ };
+ try
+ {
+ if (!CredWriteDomainCredentialsW(ref targetInfo, ref cred, 0))
{
var error = Marshal.GetLastWin32Error();
- if (error == ErrorNotFound) return;
- throw new InvalidOperationException($"Failed to delete credentials (Error {error})");
+ throw new InvalidOperationException($"Failed to write credentials (Error {error})");
}
}
+ finally
+ {
+ Marshal.FreeHGlobal(credentialBlob);
+ }
+ }
- [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
- private static extern bool CredReadW(string target, int type, int reservedFlag, out IntPtr credentialPtr);
+ [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
+ private static extern bool CredReadW(string target, int type, int reservedFlag, out IntPtr credentialPtr);
- [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
- private static extern bool CredWriteW([In] ref CREDENTIAL userCredential, [In] uint flags);
+ [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
+ private static extern bool CredWriteW([In] ref CREDENTIALW userCredential, [In] uint flags);
- [DllImport("Advapi32.dll", SetLastError = true)]
- private static extern void CredFree([In] IntPtr cred);
+ [DllImport("Advapi32.dll", SetLastError = true)]
+ private static extern void CredFree([In] IntPtr cred);
- [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
- private static extern bool CredDeleteW(string target, int type, int flags);
+ [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
+ private static extern bool CredDeleteW(string target, int type, int flags);
- [StructLayout(LayoutKind.Sequential)]
- private struct CREDENTIAL
- {
- public int Flags;
- public int Type;
+ [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
+ private static extern bool CredWriteDomainCredentialsW([In] ref CREDENTIAL_TARGET_INFORMATIONW target, [In] ref CREDENTIALW userCredential, [In] uint flags);
- [MarshalAs(UnmanagedType.LPWStr)] public string TargetName;
+ [StructLayout(LayoutKind.Sequential)]
+ private struct CREDENTIALW
+ {
+ public int Flags;
+ public int Type;
- [MarshalAs(UnmanagedType.LPWStr)] public string Comment;
+ [MarshalAs(UnmanagedType.LPWStr)] public string TargetName;
- public long LastWritten;
- public int CredentialBlobSize;
- public IntPtr CredentialBlob;
- public int Persist;
- public int AttributeCount;
- public IntPtr Attributes;
+ [MarshalAs(UnmanagedType.LPWStr)] public string Comment;
- [MarshalAs(UnmanagedType.LPWStr)] public string TargetAlias;
+ public long LastWritten;
+ public int CredentialBlobSize;
+ public IntPtr CredentialBlob;
+ public int Persist;
+ public int AttributeCount;
+ public IntPtr Attributes;
- [MarshalAs(UnmanagedType.LPWStr)] public string UserName;
- }
+ [MarshalAs(UnmanagedType.LPWStr)] public string TargetAlias;
+
+ [MarshalAs(UnmanagedType.LPWStr)] public string UserName;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct CREDENTIAL_TARGET_INFORMATIONW
+ {
+ [MarshalAs(UnmanagedType.LPWStr)] public string TargetName;
+ [MarshalAs(UnmanagedType.LPWStr)] public string NetbiosServerName;
+ [MarshalAs(UnmanagedType.LPWStr)] public string DnsServerName;
+ [MarshalAs(UnmanagedType.LPWStr)] public string NetbiosDomainName;
+ [MarshalAs(UnmanagedType.LPWStr)] public string DnsDomainName;
+ [MarshalAs(UnmanagedType.LPWStr)] public string DnsTreeName;
+ [MarshalAs(UnmanagedType.LPWStr)] public string PackageName;
+
+ public uint Flags;
+ public uint CredTypeCount;
+ public IntPtr CredTypes;
}
}
diff --git a/App/Services/MutagenController.cs b/App/Services/MutagenController.cs
index dd489df..3931b66 100644
--- a/App/Services/MutagenController.cs
+++ b/App/Services/MutagenController.cs
@@ -12,10 +12,14 @@
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.Synchronization.Core.Ignore;
using Coder.Desktop.MutagenSdk.Proto.Url;
using Coder.Desktop.Vpn.Utilities;
using Grpc.Core;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
+using Serilog;
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;
@@ -85,7 +89,9 @@ public interface ISyncSessionController : IAsyncDisposable
///
Task RefreshState(CancellationToken ct = default);
- Task CreateSyncSession(CreateSyncSessionRequest req, CancellationToken ct = default);
+ Task CreateSyncSession(CreateSyncSessionRequest req, Action progressCallback,
+ CancellationToken ct = default);
+
Task PauseSyncSession(string identifier, CancellationToken ct = default);
Task ResumeSyncSession(string identifier, CancellationToken ct = default);
Task TerminateSyncSession(string identifier, CancellationToken ct = default);
@@ -110,6 +116,8 @@ public sealed class MutagenController : ISyncSessionController
// Protects all private non-readonly class members.
private readonly RaiiSemaphoreSlim _lock = new(1, 1);
+ private readonly ILogger _logger;
+
private readonly CancellationTokenSource _stateUpdateCts = new();
private Task? _stateUpdateTask;
@@ -139,15 +147,19 @@ public sealed class MutagenController : ISyncSessionController
private string MutagenDaemonLog => Path.Combine(_mutagenDataDirectory, "daemon.log");
- public MutagenController(IOptions config)
+ public MutagenController(IOptions config, ILogger logger)
{
_mutagenExecutablePath = config.Value.MutagenExecutablePath;
+ _logger = logger;
}
public MutagenController(string executablePath, string dataDirectory)
{
_mutagenExecutablePath = executablePath;
_mutagenDataDirectory = dataDirectory;
+ var builder = Host.CreateApplicationBuilder();
+ builder.Services.AddSerilog();
+ _logger = (ILogger)builder.Build().Services.GetService(typeof(ILogger))!;
}
public event EventHandler? StateChanged;
@@ -200,12 +212,16 @@ public async Task RefreshState(CancellationToke
return state;
}
- public async Task CreateSyncSession(CreateSyncSessionRequest req, CancellationToken ct = default)
+ public async Task CreateSyncSession(CreateSyncSessionRequest req,
+ Action? progressCallback = null, CancellationToken ct = default)
{
using var _ = await _lock.LockAsync(ct);
var client = await EnsureDaemon(ct);
await using var prompter = await Prompter.Create(client, true, ct);
+ if (progressCallback != null)
+ prompter.OnProgress += (_, progress) => progressCallback(progress);
+
var createRes = await client.Synchronization.CreateAsync(new CreateRequest
{
Prompter = prompter.Identifier,
@@ -213,8 +229,11 @@ public async Task CreateSyncSession(CreateSyncSessionRequest r
{
Alpha = req.Alpha.MutagenUrl,
Beta = req.Beta.MutagenUrl,
- // TODO: probably should set these at some point
- Configuration = new Configuration(),
+ // TODO: probably should add a configuration page for these at some point
+ Configuration = new Configuration
+ {
+ IgnoreVCSMode = IgnoreVCSMode.Ignore,
+ },
ConfigurationAlpha = new Configuration(),
ConfigurationBeta = new Configuration(),
},
@@ -437,9 +456,9 @@ private async Task EnsureDaemon(CancellationToken ct)
{
await StopDaemon(cts.Token);
}
- catch
+ catch (Exception stopEx)
{
- // ignored
+ _logger.LogError(stopEx, "failed to stop daemon");
}
ReplaceState(new SyncSessionControllerStateModel
@@ -491,6 +510,8 @@ private async Task StartDaemon(CancellationToken ct)
}
catch (Exception e) when (e is not OperationCanceledException)
{
+ _logger.LogWarning(e, "failed to start daemon process, attempt {attempt} of {maxAttempts}", attempts,
+ maxAttempts);
if (attempts == maxAttempts)
throw;
// back off a little and try again.
@@ -535,21 +556,62 @@ private void StartDaemonProcess()
var logPath = Path.Combine(_mutagenDataDirectory, "daemon.log");
var logStream = new StreamWriter(logPath, true);
- _daemonProcess = new Process();
- _daemonProcess.StartInfo.FileName = _mutagenExecutablePath;
- _daemonProcess.StartInfo.Arguments = "daemon run";
- _daemonProcess.StartInfo.Environment.Add("MUTAGEN_DATA_DIRECTORY", _mutagenDataDirectory);
+ _logger.LogInformation("starting mutagen daemon process with executable path '{path}'", _mutagenExecutablePath);
+ _logger.LogInformation("mutagen data directory '{path}'", _mutagenDataDirectory);
+ _logger.LogInformation("mutagen daemon log path '{path}'", logPath);
+
+ var daemonProcess = new Process();
+ daemonProcess.StartInfo.FileName = _mutagenExecutablePath;
+ daemonProcess.StartInfo.Arguments = "daemon run";
+ daemonProcess.StartInfo.Environment.Add("MUTAGEN_DATA_DIRECTORY", _mutagenDataDirectory);
+ daemonProcess.StartInfo.Environment.Add("MUTAGEN_SSH_CONFIG_PATH", "none"); // do not use ~/.ssh/config
// hide the console window
- _daemonProcess.StartInfo.CreateNoWindow = true;
+ 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;
- // TODO: log exited process
- // _daemonProcess.Exited += ...
- if (!_daemonProcess.Start())
- throw new InvalidOperationException("Failed to start mutagen daemon process, Start returned false");
+ daemonProcess.StartInfo.UseShellExecute = false;
+ daemonProcess.StartInfo.RedirectStandardError = true;
+ daemonProcess.EnableRaisingEvents = true;
+ daemonProcess.Exited += (_, _) =>
+ {
+ var exitCode = -1;
+ try
+ {
+ // ReSharper disable once AccessToDisposedClosure
+ exitCode = daemonProcess.ExitCode;
+ }
+ catch
+ {
+ // ignored
+ }
+
+ _logger.LogInformation("mutagen daemon exited with code {exitCode}", exitCode);
+ };
+ try
+ {
+ if (!daemonProcess.Start())
+ throw new InvalidOperationException("Failed to start mutagen daemon process, Start returned false");
+ }
+ catch (Exception e)
+ {
+ _logger.LogWarning(e, "mutagen daemon failed to start");
+
+ logStream.Dispose();
+ try
+ {
+ daemonProcess.Kill();
+ }
+ catch
+ {
+ // ignored, the process likely doesn't exist
+ }
+
+ daemonProcess.Dispose();
+ throw;
+ }
+
+ _daemonProcess = daemonProcess;
var writer = new LogWriter(_daemonProcess.StandardError, logStream);
Task.Run(() => { _ = writer.Run(); });
_logWriter = writer;
@@ -561,6 +623,7 @@ private void StartDaemonProcess()
///
private async Task StopDaemon(CancellationToken ct)
{
+ _logger.LogDebug("stopping mutagen daemon");
var process = _daemonProcess;
var client = _mutagenClient;
var writer = _logWriter;
@@ -573,17 +636,21 @@ private async Task StopDaemon(CancellationToken ct)
if (client == null)
{
if (process == null) return;
+ _logger.LogDebug("no client; killing daemon process");
process.Kill(true);
}
else
{
try
{
+ _logger.LogDebug("sending DaemonTerminateRequest");
await client.Daemon.TerminateAsync(new DaemonTerminateRequest(), cancellationToken: ct);
}
- catch
+ catch (Exception e)
{
+ _logger.LogError(e, "failed to gracefully terminate agent");
if (process == null) return;
+ _logger.LogDebug("killing daemon process after failed graceful termination");
process.Kill(true);
}
}
@@ -591,10 +658,12 @@ private async Task StopDaemon(CancellationToken ct)
if (process == null) return;
var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(5));
+ _logger.LogDebug("waiting for process to exit");
await process.WaitForExitAsync(cts.Token);
}
finally
{
+ _logger.LogDebug("cleaning up daemon process objects");
client?.Dispose();
process?.Dispose();
writer?.Dispose();
@@ -603,6 +672,8 @@ private async Task StopDaemon(CancellationToken ct)
private class Prompter : IAsyncDisposable
{
+ public event EventHandler? OnProgress;
+
private readonly AsyncDuplexStreamingCall _dup;
private readonly CancellationTokenSource _cts;
private readonly Task _handleRequestsTask;
@@ -684,6 +755,9 @@ private async Task HandleRequests(CancellationToken ct)
if (response.Message == null)
throw new InvalidOperationException("Prompting.Host response stream returned a null message");
+ if (!response.IsPrompt)
+ OnProgress?.Invoke(this, response.Message);
+
// Currently we only reply to SSH fingerprint messages with
// "yes" and send an empty reply for everything else.
var reply = "";
diff --git a/App/Services/RdpConnector.cs b/App/Services/RdpConnector.cs
new file mode 100644
index 0000000..a48d0ac
--- /dev/null
+++ b/App/Services/RdpConnector.cs
@@ -0,0 +1,76 @@
+using System;
+using System.Diagnostics;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+
+namespace Coder.Desktop.App.Services;
+
+public struct RdpCredentials(string username, string password)
+{
+ public readonly string Username = username;
+ public readonly string Password = password;
+}
+
+public interface IRdpConnector
+{
+ public const int DefaultPort = 3389;
+
+ public void WriteCredentials(string fqdn, RdpCredentials credentials);
+
+ public Task Connect(string fqdn, int port = DefaultPort, CancellationToken ct = default);
+}
+
+public class RdpConnector(ILogger logger) : IRdpConnector
+{
+ // Remote Desktop always uses TERMSRV as the domain; RDP is a part of Windows "Terminal Services".
+ private const string RdpDomain = "TERMSRV";
+
+ public void WriteCredentials(string fqdn, RdpCredentials credentials)
+ {
+ // writing credentials is idempotent for the same domain and server name.
+ Wincred.WriteDomainCredentials(RdpDomain, fqdn, credentials.Username, credentials.Password);
+ logger.LogDebug("wrote domain credential for {serverName} with username {username}", fqdn,
+ credentials.Username);
+ return;
+ }
+
+ public Task Connect(string fqdn, int port = IRdpConnector.DefaultPort, CancellationToken ct = default)
+ {
+ // use mstsc to launch the RDP connection
+ var mstscProc = new Process();
+ mstscProc.StartInfo.FileName = "mstsc";
+ var args = $"/v {fqdn}";
+ if (port != IRdpConnector.DefaultPort)
+ {
+ args = $"/v {fqdn}:{port}";
+ }
+
+ mstscProc.StartInfo.Arguments = args;
+ mstscProc.StartInfo.CreateNoWindow = true;
+ mstscProc.StartInfo.UseShellExecute = false;
+ try
+ {
+ if (!mstscProc.Start())
+ throw new InvalidOperationException("Failed to start mstsc, Start returned false");
+ }
+ catch (Exception e)
+ {
+ logger.LogWarning(e, "mstsc failed to start");
+
+ try
+ {
+ mstscProc.Kill();
+ }
+ catch
+ {
+ // ignored, the process likely doesn't exist
+ }
+
+ mstscProc.Dispose();
+ throw;
+ }
+
+ return mstscProc.WaitForExitAsync(ct);
+ }
+}
diff --git a/App/Services/UriHandler.cs b/App/Services/UriHandler.cs
new file mode 100644
index 0000000..b0b0a9a
--- /dev/null
+++ b/App/Services/UriHandler.cs
@@ -0,0 +1,152 @@
+using System;
+using System.Collections.Specialized;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Web;
+using Coder.Desktop.App.Models;
+using Coder.Desktop.Vpn.Proto;
+using Microsoft.Extensions.Logging;
+
+
+namespace Coder.Desktop.App.Services;
+
+public interface IUriHandler
+{
+ public Task HandleUri(Uri uri, CancellationToken ct = default);
+}
+
+public class UriHandler(
+ ILogger logger,
+ IRpcController rpcController,
+ IUserNotifier userNotifier,
+ IRdpConnector rdpConnector) : IUriHandler
+{
+ private const string OpenWorkspacePrefix = "/v0/open/ws/";
+
+ internal class UriException : Exception
+ {
+ internal readonly string Title;
+ internal readonly string Detail;
+
+ internal UriException(string title, string detail) : base($"{title}: {detail}")
+ {
+ Title = title;
+ Detail = detail;
+ }
+ }
+
+ public async Task HandleUri(Uri uri, CancellationToken ct = default)
+ {
+ try
+ {
+ await HandleUriThrowingErrors(uri, ct);
+ }
+ catch (UriException e)
+ {
+ await userNotifier.ShowErrorNotification(e.Title, e.Detail, ct);
+ }
+ }
+
+ private async Task HandleUriThrowingErrors(Uri uri, CancellationToken ct = default)
+ {
+ if (uri.AbsolutePath.StartsWith(OpenWorkspacePrefix))
+ {
+ await HandleOpenWorkspaceApp(uri, ct);
+ return;
+ }
+
+ logger.LogWarning("unhandled URI path {path}", uri.AbsolutePath);
+ throw new UriException("URI handling error",
+ $"URI with path '{uri.AbsolutePath}' is unsupported or malformed");
+ }
+
+ public async Task HandleOpenWorkspaceApp(Uri uri, CancellationToken ct = default)
+ {
+ const string errTitle = "Open Workspace Application Error";
+ var subpath = uri.AbsolutePath[OpenWorkspacePrefix.Length..];
+ var components = subpath.Split("/");
+ if (components.Length != 4 || components[1] != "agent")
+ {
+ logger.LogWarning("unsupported open workspace app format in URI {path}", uri.AbsolutePath);
+ throw new UriException(errTitle, $"Failed to open '{uri.AbsolutePath}' because the format is unsupported.");
+ }
+
+ var workspaceName = components[0];
+ var agentName = components[2];
+ var appName = components[3];
+
+ var state = rpcController.GetState();
+ if (state.VpnLifecycle != VpnLifecycle.Started)
+ {
+ logger.LogDebug("got URI to open workspace '{workspace}', but Coder Connect is not started", workspaceName);
+ throw new UriException(errTitle,
+ $"Failed to open application on '{workspaceName}' because Coder Connect is not started.");
+ }
+
+ var workspace = state.Workspaces.FirstOrDefault(w => w.Name == workspaceName);
+ if (workspace == null)
+ {
+ logger.LogDebug("got URI to open workspace '{workspace}', but the workspace doesn't exist", workspaceName);
+ throw new UriException(errTitle,
+ $"Failed to open application on workspace '{workspaceName}' because it doesn't exist");
+ }
+
+ var agent = state.Agents.FirstOrDefault(a => a.WorkspaceId == workspace.Id && a.Name == agentName);
+ if (agent == null)
+ {
+ logger.LogDebug(
+ "got URI to open workspace/agent '{workspaceName}/{agentName}', but the agent doesn't exist",
+ workspaceName, agentName);
+ // If the workspace isn't running, that is almost certainly why we can't find the agent, so report that
+ // to the user.
+ if (workspace.Status != Workspace.Types.Status.Running)
+ {
+ throw new UriException(errTitle,
+ $"Failed to open application on workspace '{workspaceName}', because the workspace is not running.");
+ }
+
+ throw new UriException(errTitle,
+ $"Failed to open application on workspace '{workspaceName}', because agent '{agentName}' doesn't exist.");
+ }
+
+ if (appName != "rdp")
+ {
+ logger.LogWarning("unsupported agent application type {app}", appName);
+ throw new UriException(errTitle,
+ $"Failed to open agent in URI '{uri.AbsolutePath}' because application '{appName}' is unsupported");
+ }
+
+ await OpenRDP(agent.Fqdn.First(), uri.Query, ct);
+ }
+
+ public async Task OpenRDP(string domainName, string queryString, CancellationToken ct = default)
+ {
+ const string errTitle = "Workspace Remote Desktop Error";
+ NameValueCollection query;
+ try
+ {
+ query = HttpUtility.ParseQueryString(queryString);
+ }
+ catch (Exception ex)
+ {
+ // unfortunately, we can't safely write they query string to logs because it might contain
+ // sensitive info like a password. This is also why we don't log the exception directly
+ var trace = new System.Diagnostics.StackTrace(ex, false);
+ logger.LogWarning("failed to parse open RDP query string: {classMethod}",
+ trace?.GetFrame(0)?.GetMethod()?.ReflectedType?.FullName);
+ throw new UriException(errTitle,
+ "Failed to open remote desktop on a workspace because the URI was malformed");
+ }
+
+ var username = query.Get("username");
+ var password = query.Get("password");
+ if (!string.IsNullOrEmpty(username))
+ {
+ password ??= string.Empty;
+ rdpConnector.WriteCredentials(domainName, new RdpCredentials(username, password));
+ }
+
+ await rdpConnector.Connect(domainName, ct: ct);
+ }
+}
diff --git a/App/Services/UserNotifier.cs b/App/Services/UserNotifier.cs
new file mode 100644
index 0000000..9150f47
--- /dev/null
+++ b/App/Services/UserNotifier.cs
@@ -0,0 +1,30 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Windows.AppNotifications;
+using Microsoft.Windows.AppNotifications.Builder;
+
+namespace Coder.Desktop.App.Services;
+
+public interface IUserNotifier : IAsyncDisposable
+{
+ public Task ShowErrorNotification(string title, string message, CancellationToken ct = default);
+}
+
+public class UserNotifier : IUserNotifier
+{
+ private readonly AppNotificationManager _notificationManager = AppNotificationManager.Default;
+
+ public ValueTask DisposeAsync()
+ {
+ return ValueTask.CompletedTask;
+ }
+
+ public Task ShowErrorNotification(string title, string message, CancellationToken ct = default)
+ {
+ var builder = new AppNotificationBuilder().AddText(title).AddText(message);
+ _notificationManager.Show(builder.BuildNotification());
+ return Task.CompletedTask;
+ }
+}
+
diff --git a/App/Utils/TitleBarIcon.cs b/App/Utils/TitleBarIcon.cs
new file mode 100644
index 0000000..3efc81d
--- /dev/null
+++ b/App/Utils/TitleBarIcon.cs
@@ -0,0 +1,19 @@
+using System;
+using Microsoft.UI;
+using Microsoft.UI.Windowing;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls.Primitives;
+using WinRT.Interop;
+
+namespace Coder.Desktop.App.Utils
+{
+ public static class TitleBarIcon
+ {
+ public static void SetTitlebarIcon(Window window)
+ {
+ var hwnd = WindowNative.GetWindowHandle(window);
+ var windowId = Win32Interop.GetWindowIdFromWindow(hwnd);
+ AppWindow.GetFromWindowId(windowId).SetIcon("coder.ico");
+ }
+ }
+}
diff --git a/App/ViewModels/DirectoryPickerViewModel.cs b/App/ViewModels/DirectoryPickerViewModel.cs
new file mode 100644
index 0000000..131934f
--- /dev/null
+++ b/App/ViewModels/DirectoryPickerViewModel.cs
@@ -0,0 +1,263 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Coder.Desktop.CoderSdk.Agent;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+
+namespace Coder.Desktop.App.ViewModels;
+
+public class DirectoryPickerBreadcrumb
+{
+ // HACK: you cannot access the parent context when inside an ItemsRepeater.
+ public required DirectoryPickerViewModel ViewModel;
+
+ public required string Name { get; init; }
+
+ public required IReadOnlyList AbsolutePathSegments { get; init; }
+
+ // HACK: we need to know which one is first so we don't prepend an arrow
+ // icon. You can't get the index of the current ItemsRepeater item in XAML.
+ public required bool IsFirst { get; init; }
+}
+
+public enum DirectoryPickerItemKind
+{
+ ParentDirectory, // aka. ".."
+ Directory,
+ File, // includes everything else
+}
+
+public class DirectoryPickerItem
+{
+ // HACK: you cannot access the parent context when inside an ItemsRepeater.
+ public required DirectoryPickerViewModel ViewModel;
+
+ public required DirectoryPickerItemKind Kind { get; init; }
+ public required string Name { get; init; }
+ public required IReadOnlyList AbsolutePathSegments { get; init; }
+
+ public bool Selectable => Kind is DirectoryPickerItemKind.ParentDirectory or DirectoryPickerItemKind.Directory;
+}
+
+public partial class DirectoryPickerViewModel : ObservableObject
+{
+ // PathSelected will be called ONCE when the user either cancels or selects
+ // a directory. If the user cancelled, the path will be null.
+ public event EventHandler? PathSelected;
+
+ private const int RequestTimeoutMilliseconds = 15_000;
+
+ private readonly IAgentApiClient _client;
+
+ private Window? _window;
+ private DispatcherQueue? _dispatcherQueue;
+
+ public readonly string AgentFqdn;
+
+ // The initial loading screen is differentiated from subsequent loading
+ // screens because:
+ // 1. We don't want to show a broken state while the page is loading.
+ // 2. An error dialog allows the user to get to a broken state with no
+ // breadcrumbs, no items, etc. with no chance to reload.
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(ShowLoadingScreen))]
+ [NotifyPropertyChangedFor(nameof(ShowListScreen))]
+ public partial bool InitialLoading { get; set; } = true;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(ShowLoadingScreen))]
+ [NotifyPropertyChangedFor(nameof(ShowErrorScreen))]
+ [NotifyPropertyChangedFor(nameof(ShowListScreen))]
+ public partial string? InitialLoadError { get; set; } = null;
+
+ [ObservableProperty] public partial bool NavigatingLoading { get; set; } = false;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsSelectable))]
+ public partial string CurrentDirectory { get; set; } = "";
+
+ [ObservableProperty] public partial IReadOnlyList Breadcrumbs { get; set; } = [];
+
+ [ObservableProperty] public partial IReadOnlyList Items { get; set; } = [];
+
+ public bool ShowLoadingScreen => InitialLoadError == null && InitialLoading;
+ public bool ShowErrorScreen => InitialLoadError != null;
+ public bool ShowListScreen => InitialLoadError == null && !InitialLoading;
+
+ // The "root" directory on Windows isn't a real thing, but in our model
+ // it's a drive listing. We don't allow users to select the fake drive
+ // listing directory.
+ //
+ // On Linux, this will never be empty since the highest you can go is "/".
+ public bool IsSelectable => CurrentDirectory != "";
+
+ public DirectoryPickerViewModel(IAgentApiClientFactory clientFactory, string agentFqdn)
+ {
+ _client = clientFactory.Create(agentFqdn);
+ AgentFqdn = agentFqdn;
+ }
+
+ 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");
+
+ InitialLoading = true;
+ InitialLoadError = null;
+ // Initial load is in the home directory.
+ _ = BackgroundLoad(ListDirectoryRelativity.Home, []).ContinueWith(ContinueInitialLoad);
+ }
+
+ [RelayCommand]
+ private void RetryLoad()
+ {
+ InitialLoading = true;
+ InitialLoadError = null;
+ // Subsequent loads after the initial failure are always in the root
+ // directory in case there's a permanent issue preventing listing the
+ // home directory.
+ _ = BackgroundLoad(ListDirectoryRelativity.Root, []).ContinueWith(ContinueInitialLoad);
+ }
+
+ private async Task BackgroundLoad(ListDirectoryRelativity relativity, List path)
+ {
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
+ return await _client.ListDirectory(new ListDirectoryRequest
+ {
+ Path = path,
+ Relativity = relativity,
+ }, cts.Token);
+ }
+
+ private void ContinueInitialLoad(Task task)
+ {
+ // Ensure we're on the UI thread.
+ if (_dispatcherQueue == null) return;
+ if (!_dispatcherQueue.HasThreadAccess)
+ {
+ _dispatcherQueue.TryEnqueue(() => ContinueInitialLoad(task));
+ return;
+ }
+
+ if (task.IsCompletedSuccessfully)
+ {
+ ProcessResponse(task.Result);
+ return;
+ }
+
+ InitialLoadError = "Could not list home directory in workspace: ";
+ if (task.IsCanceled) InitialLoadError += new TaskCanceledException();
+ else if (task.IsFaulted) InitialLoadError += task.Exception;
+ else InitialLoadError += "no successful result or error";
+ InitialLoading = false;
+ }
+
+ [RelayCommand]
+ public async Task ListPath(IReadOnlyList path)
+ {
+ if (_window is null || NavigatingLoading) return;
+ NavigatingLoading = true;
+
+ using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(RequestTimeoutMilliseconds));
+ try
+ {
+ var res = await _client.ListDirectory(new ListDirectoryRequest
+ {
+ Path = path.ToList(),
+ Relativity = ListDirectoryRelativity.Root,
+ }, cts.Token);
+ ProcessResponse(res);
+ }
+ catch (Exception e)
+ {
+ // Subsequent listing errors are just shown as dialog boxes.
+ var dialog = new ContentDialog
+ {
+ Title = "Failed to list remote directory",
+ Content = $"{e}",
+ CloseButtonText = "Ok",
+ XamlRoot = _window.Content.XamlRoot,
+ };
+ _ = await dialog.ShowAsync();
+ }
+ finally
+ {
+ NavigatingLoading = false;
+ }
+ }
+
+ [RelayCommand]
+ public void Cancel()
+ {
+ PathSelected?.Invoke(this, null);
+ _window?.Close();
+ }
+
+ [RelayCommand]
+ public void Select()
+ {
+ if (CurrentDirectory == "") return;
+ PathSelected?.Invoke(this, CurrentDirectory);
+ _window?.Close();
+ }
+
+ private void ProcessResponse(ListDirectoryResponse res)
+ {
+ InitialLoading = false;
+ InitialLoadError = null;
+ NavigatingLoading = false;
+
+ var breadcrumbs = new List(res.AbsolutePath.Count + 1)
+ {
+ new()
+ {
+ Name = "🖥️",
+ AbsolutePathSegments = [],
+ IsFirst = true,
+ ViewModel = this,
+ },
+ };
+ for (var i = 0; i < res.AbsolutePath.Count; i++)
+ breadcrumbs.Add(new DirectoryPickerBreadcrumb
+ {
+ Name = res.AbsolutePath[i],
+ AbsolutePathSegments = res.AbsolutePath[..(i + 1)],
+ IsFirst = false,
+ ViewModel = this,
+ });
+
+ var items = new List(res.Contents.Count + 1);
+ if (res.AbsolutePath.Count != 0)
+ items.Add(new DirectoryPickerItem
+ {
+ Kind = DirectoryPickerItemKind.ParentDirectory,
+ Name = "..",
+ AbsolutePathSegments = res.AbsolutePath[..^1],
+ ViewModel = this,
+ });
+
+ foreach (var item in res.Contents)
+ {
+ if (item.Name.StartsWith(".")) continue;
+ items.Add(new DirectoryPickerItem
+ {
+ Kind = item.IsDir ? DirectoryPickerItemKind.Directory : DirectoryPickerItemKind.File,
+ Name = item.Name,
+ AbsolutePathSegments = res.AbsolutePath.Append(item.Name).ToList(),
+ ViewModel = this,
+ });
+ }
+
+ CurrentDirectory = res.AbsolutePathString;
+ Breadcrumbs = breadcrumbs;
+ Items = items;
+ }
+}
diff --git a/App/ViewModels/FileSyncListViewModel.cs b/App/ViewModels/FileSyncListViewModel.cs
index 7fdd881..9235141 100644
--- a/App/ViewModels/FileSyncListViewModel.cs
+++ b/App/ViewModels/FileSyncListViewModel.cs
@@ -6,6 +6,8 @@
using Windows.Storage.Pickers;
using Coder.Desktop.App.Models;
using Coder.Desktop.App.Services;
+using Coder.Desktop.App.Views;
+using Coder.Desktop.CoderSdk.Agent;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.UI.Dispatching;
@@ -19,10 +21,12 @@ public partial class FileSyncListViewModel : ObservableObject
{
private Window? _window;
private DispatcherQueue? _dispatcherQueue;
+ private DirectoryPickerWindow? _remotePickerWindow;
private readonly ISyncSessionController _syncSessionController;
private readonly IRpcController _rpcController;
private readonly ICredentialManager _credentialManager;
+ private readonly IAgentApiClientFactory _agentApiClientFactory;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowUnavailable))]
@@ -46,7 +50,7 @@ public partial class FileSyncListViewModel : ObservableObject
[ObservableProperty] public partial bool OperationInProgress { get; set; } = false;
- [ObservableProperty] public partial List Sessions { get; set; } = [];
+ [ObservableProperty] public partial IReadOnlyList Sessions { get; set; } = [];
[ObservableProperty] public partial bool CreatingNewSession { get; set; } = false;
@@ -58,14 +62,30 @@ public partial class FileSyncListViewModel : ObservableObject
[NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))]
public partial bool NewSessionLocalPathDialogOpen { get; set; } = false;
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(NewSessionRemoteHostEnabled))]
+ public partial IReadOnlyList AvailableHosts { get; set; } = [];
+
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))]
- public partial string NewSessionRemoteHost { get; set; } = "";
+ [NotifyPropertyChangedFor(nameof(NewSessionRemotePathDialogEnabled))]
+ public partial string? NewSessionRemoteHost { get; set; } = null;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))]
public partial string NewSessionRemotePath { get; set; } = "";
- // TODO: NewSessionRemotePathDialogOpen for remote path
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))]
+ [NotifyPropertyChangedFor(nameof(NewSessionRemotePathDialogEnabled))]
+ public partial bool NewSessionRemotePathDialogOpen { get; set; } = false;
+
+ public bool NewSessionRemoteHostEnabled => AvailableHosts.Count > 0;
+
+ public bool NewSessionRemotePathDialogEnabled =>
+ !string.IsNullOrWhiteSpace(NewSessionRemoteHost) && !NewSessionRemotePathDialogOpen;
+
+ [ObservableProperty] public partial string NewSessionStatus { get; set; } = "";
public bool NewSessionCreateEnabled
{
@@ -75,6 +95,7 @@ public bool NewSessionCreateEnabled
if (NewSessionLocalPathDialogOpen) return false;
if (string.IsNullOrWhiteSpace(NewSessionRemoteHost)) return false;
if (string.IsNullOrWhiteSpace(NewSessionRemotePath)) return false;
+ if (NewSessionRemotePathDialogOpen) return false;
return true;
}
}
@@ -86,11 +107,12 @@ public bool NewSessionCreateEnabled
public bool ShowSessions => !Loading && UnavailableMessage == null && Error == null;
public FileSyncListViewModel(ISyncSessionController syncSessionController, IRpcController rpcController,
- ICredentialManager credentialManager)
+ ICredentialManager credentialManager, IAgentApiClientFactory agentApiClientFactory)
{
_syncSessionController = syncSessionController;
_rpcController = rpcController;
_credentialManager = credentialManager;
+ _agentApiClientFactory = agentApiClientFactory;
}
public void Initialize(Window window, DispatcherQueue dispatcherQueue)
@@ -103,6 +125,14 @@ public void Initialize(Window window, DispatcherQueue dispatcherQueue)
_rpcController.StateChanged += RpcControllerStateChanged;
_credentialManager.CredentialsChanged += CredentialManagerCredentialsChanged;
_syncSessionController.StateChanged += SyncSessionStateChanged;
+ _window.Closed += (_, _) =>
+ {
+ _remotePickerWindow?.Close();
+
+ _rpcController.StateChanged -= RpcControllerStateChanged;
+ _credentialManager.CredentialsChanged -= CredentialManagerCredentialsChanged;
+ _syncSessionController.StateChanged -= SyncSessionStateChanged;
+ };
var rpcModel = _rpcController.GetState();
var credentialModel = _credentialManager.GetCachedCredentials();
@@ -171,8 +201,13 @@ private void MaybeSetUnavailableMessage(RpcModel rpcModel, CredentialModel crede
else
{
UnavailableMessage = null;
+ // Reload if we transitioned from unavailable to available.
if (oldMessage != null) ReloadSessions();
}
+
+ // When transitioning from available to unavailable:
+ if (oldMessage == null && UnavailableMessage != null)
+ ClearNewForm();
}
private void UpdateSyncSessionState(SyncSessionControllerStateModel syncSessionState)
@@ -187,6 +222,8 @@ private void ClearNewForm()
NewSessionLocalPath = "";
NewSessionRemoteHost = "";
NewSessionRemotePath = "";
+ NewSessionStatus = "";
+ _remotePickerWindow?.Close();
}
[RelayCommand]
@@ -223,21 +260,50 @@ private void HandleRefresh(Task t)
Loading = false;
}
+ // Overriding AvailableHosts seems to make the ComboBox clear its value, so
+ // we only do this while the create form is not open.
+ // Must be called in UI thread.
+ private void SetAvailableHostsFromRpcModel(RpcModel rpcModel)
+ {
+ var hosts = new List(rpcModel.Agents.Count);
+ // Agents will only contain started agents.
+ foreach (var agent in rpcModel.Agents)
+ {
+ var fqdn = agent.Fqdn
+ .Select(a => a.Trim('.'))
+ .Where(a => !string.IsNullOrWhiteSpace(a))
+ .Aggregate((a, b) => a.Count(c => c == '.') < b.Count(c => c == '.') ? a : b);
+ if (string.IsNullOrWhiteSpace(fqdn))
+ continue;
+ hosts.Add(fqdn);
+ }
+
+ NewSessionRemoteHost = null;
+ AvailableHosts = hosts;
+ }
+
[RelayCommand]
private void StartCreatingNewSession()
{
ClearNewForm();
+ // Ensure we have a fresh hosts list before we open the form. We don't
+ // bind directly to the list on RPC state updates as updating the list
+ // while in use seems to break it.
+ SetAvailableHostsFromRpcModel(_rpcController.GetState());
CreatingNewSession = true;
}
- public async Task OpenLocalPathSelectDialog(Window window)
+ [RelayCommand]
+ public async Task OpenLocalPathSelectDialog()
{
+ if (_window is null) return;
+
var picker = new FolderPicker
{
SuggestedStartLocation = PickerLocationId.ComputerFolder,
};
- var hwnd = WindowNative.GetWindowHandle(window);
+ var hwnd = WindowNative.GetWindowHandle(_window);
InitializeWithWindow.Initialize(picker, hwnd);
NewSessionLocalPathDialogOpen = true;
@@ -257,19 +323,66 @@ public async Task OpenLocalPathSelectDialog(Window window)
}
}
+ [RelayCommand]
+ public void OpenRemotePathSelectDialog()
+ {
+ if (string.IsNullOrWhiteSpace(NewSessionRemoteHost))
+ return;
+ if (_remotePickerWindow is not null)
+ {
+ _remotePickerWindow.Activate();
+ return;
+ }
+
+ NewSessionRemotePathDialogOpen = true;
+ var pickerViewModel = new DirectoryPickerViewModel(_agentApiClientFactory, NewSessionRemoteHost);
+ pickerViewModel.PathSelected += OnRemotePathSelected;
+
+ _remotePickerWindow = new DirectoryPickerWindow(pickerViewModel);
+ _remotePickerWindow.SetParent(_window);
+ _remotePickerWindow.Closed += (_, _) =>
+ {
+ _remotePickerWindow = null;
+ NewSessionRemotePathDialogOpen = false;
+ };
+ _remotePickerWindow.Activate();
+ }
+
+ private void OnRemotePathSelected(object? sender, string? path)
+ {
+ if (sender is not DirectoryPickerViewModel pickerViewModel) return;
+ pickerViewModel.PathSelected -= OnRemotePathSelected;
+
+ if (path == null) return;
+ NewSessionRemotePath = path;
+ }
+
[RelayCommand]
private void CancelNewSession()
{
ClearNewForm();
}
+ private void OnCreateSessionProgress(string message)
+ {
+ // Ensure we're on the UI thread.
+ if (_dispatcherQueue == null) return;
+ if (!_dispatcherQueue.HasThreadAccess)
+ {
+ _dispatcherQueue.TryEnqueue(() => OnCreateSessionProgress(message));
+ return;
+ }
+
+ NewSessionStatus = message;
+ }
+
[RelayCommand]
private async Task ConfirmNewSession()
{
if (OperationInProgress || !NewSessionCreateEnabled) return;
OperationInProgress = true;
- using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120));
try
{
// The controller will send us a state changed event.
@@ -283,10 +396,10 @@ await _syncSessionController.CreateSyncSession(new CreateSyncSessionRequest
Beta = new CreateSyncSessionRequest.Endpoint
{
Protocol = CreateSyncSessionRequest.Endpoint.ProtocolKind.Ssh,
- Host = NewSessionRemoteHost,
+ Host = NewSessionRemoteHost!,
Path = NewSessionRemotePath,
},
- }, cts.Token);
+ }, OnCreateSessionProgress, cts.Token);
ClearNewForm();
}
@@ -304,6 +417,7 @@ await _syncSessionController.CreateSyncSession(new CreateSyncSessionRequest
finally
{
OperationInProgress = false;
+ NewSessionStatus = "";
}
}
diff --git a/App/ViewModels/TrayWindowViewModel.cs b/App/ViewModels/TrayWindowViewModel.cs
index 532bfe4..f845521 100644
--- a/App/ViewModels/TrayWindowViewModel.cs
+++ b/App/ViewModels/TrayWindowViewModel.cs
@@ -178,6 +178,7 @@ private void UpdateFromRpcModel(RpcModel rpcModel)
{
// We just assume that it's a single-agent workspace.
Hostname = workspace.Name,
+ // TODO: this needs to get the suffix from the server
HostnameSuffix = ".coder",
ConnectionStatus = AgentConnectionStatus.Gray,
DashboardUrl = WorkspaceUri(coderUri, workspace.Name),
diff --git a/App/Views/DirectoryPickerWindow.xaml b/App/Views/DirectoryPickerWindow.xaml
new file mode 100644
index 0000000..8a107cb
--- /dev/null
+++ b/App/Views/DirectoryPickerWindow.xaml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/App/Views/DirectoryPickerWindow.xaml.cs b/App/Views/DirectoryPickerWindow.xaml.cs
new file mode 100644
index 0000000..2409d4b
--- /dev/null
+++ b/App/Views/DirectoryPickerWindow.xaml.cs
@@ -0,0 +1,96 @@
+using System;
+using System.Runtime.InteropServices;
+using Windows.Graphics;
+using Coder.Desktop.App.ViewModels;
+using Coder.Desktop.App.Views.Pages;
+using Microsoft.UI.Windowing;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Media;
+using WinRT.Interop;
+using WinUIEx;
+using Coder.Desktop.App.Utils;
+
+namespace Coder.Desktop.App.Views;
+
+public sealed partial class DirectoryPickerWindow : WindowEx
+{
+ public DirectoryPickerWindow(DirectoryPickerViewModel viewModel)
+ {
+ InitializeComponent();
+ TitleBarIcon.SetTitlebarIcon(this);
+
+ SystemBackdrop = new DesktopAcrylicBackdrop();
+
+ viewModel.Initialize(this, DispatcherQueue);
+ RootFrame.Content = new DirectoryPickerMainPage(viewModel);
+
+ // This will be moved to the center of the parent window in SetParent.
+ this.CenterOnScreen();
+ }
+
+ public void SetParent(Window parentWindow)
+ {
+ // Move the window to the center of the parent window.
+ var scale = DisplayScale.WindowScale(parentWindow);
+ var windowPos = new PointInt32(
+ parentWindow.AppWindow.Position.X + parentWindow.AppWindow.Size.Width / 2 - AppWindow.Size.Width / 2,
+ parentWindow.AppWindow.Position.Y + parentWindow.AppWindow.Size.Height / 2 - AppWindow.Size.Height / 2
+ );
+
+ // Ensure we stay within the display.
+ var workArea = DisplayArea.GetFromPoint(parentWindow.AppWindow.Position, DisplayAreaFallback.Primary).WorkArea;
+ if (windowPos.X + AppWindow.Size.Width > workArea.X + workArea.Width) // right edge
+ windowPos.X = workArea.X + workArea.Width - AppWindow.Size.Width;
+ if (windowPos.Y + AppWindow.Size.Height > workArea.Y + workArea.Height) // bottom edge
+ windowPos.Y = workArea.Y + workArea.Height - AppWindow.Size.Height;
+ if (windowPos.X < workArea.X) // left edge
+ windowPos.X = workArea.X;
+ if (windowPos.Y < workArea.Y) // top edge
+ windowPos.Y = workArea.Y;
+
+ AppWindow.Move(windowPos);
+
+ var parentHandle = WindowNative.GetWindowHandle(parentWindow);
+ var thisHandle = WindowNative.GetWindowHandle(this);
+
+ // Set the parent window in win API.
+ NativeApi.SetWindowParent(thisHandle, parentHandle);
+
+ // Override the presenter, which allows us to enable modal-like
+ // behavior for this window:
+ // - Disables the parent window
+ // - Any activations of the parent window will play a bell sound and
+ // focus the modal window
+ //
+ // This behavior is very similar to the native file/directory picker on
+ // Windows.
+ var presenter = OverlappedPresenter.CreateForDialog();
+ presenter.IsModal = true;
+ AppWindow.SetPresenter(presenter);
+ AppWindow.Show();
+
+ // Cascade close events.
+ parentWindow.Closed += OnParentWindowClosed;
+ Closed += (_, _) =>
+ {
+ parentWindow.Closed -= OnParentWindowClosed;
+ parentWindow.Activate();
+ };
+ }
+
+ private void OnParentWindowClosed(object? sender, WindowEventArgs e)
+ {
+ Close();
+ }
+
+ private static class NativeApi
+ {
+ [DllImport("user32.dll")]
+ private static extern IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong);
+
+ public static void SetWindowParent(IntPtr window, IntPtr parent)
+ {
+ SetWindowLongPtr(window, -8, parent);
+ }
+ }
+}
diff --git a/App/Views/FileSyncListWindow.xaml.cs b/App/Views/FileSyncListWindow.xaml.cs
index 8a409d7..fb899cc 100644
--- a/App/Views/FileSyncListWindow.xaml.cs
+++ b/App/Views/FileSyncListWindow.xaml.cs
@@ -2,6 +2,7 @@
using Coder.Desktop.App.Views.Pages;
using Microsoft.UI.Xaml.Media;
using WinUIEx;
+using Coder.Desktop.App.Utils;
namespace Coder.Desktop.App.Views;
@@ -13,11 +14,14 @@ public FileSyncListWindow(FileSyncListViewModel viewModel)
{
ViewModel = viewModel;
InitializeComponent();
+ TitleBarIcon.SetTitlebarIcon(this);
+
SystemBackdrop = new DesktopAcrylicBackdrop();
ViewModel.Initialize(this, DispatcherQueue);
- RootFrame.Content = new FileSyncListMainPage(ViewModel, this);
+ RootFrame.Content = new FileSyncListMainPage(ViewModel);
this.CenterOnScreen();
}
+
}
diff --git a/App/Views/Pages/DirectoryPickerMainPage.xaml b/App/Views/Pages/DirectoryPickerMainPage.xaml
new file mode 100644
index 0000000..dd08c46
--- /dev/null
+++ b/App/Views/Pages/DirectoryPickerMainPage.xaml
@@ -0,0 +1,179 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/App/Views/Pages/DirectoryPickerMainPage.xaml.cs b/App/Views/Pages/DirectoryPickerMainPage.xaml.cs
new file mode 100644
index 0000000..4e26200
--- /dev/null
+++ b/App/Views/Pages/DirectoryPickerMainPage.xaml.cs
@@ -0,0 +1,27 @@
+using Coder.Desktop.App.ViewModels;
+using Microsoft.UI.Xaml.Controls;
+
+namespace Coder.Desktop.App.Views.Pages;
+
+public sealed partial class DirectoryPickerMainPage : Page
+{
+ public readonly DirectoryPickerViewModel ViewModel;
+
+ public DirectoryPickerMainPage(DirectoryPickerViewModel viewModel)
+ {
+ ViewModel = viewModel;
+ InitializeComponent();
+ }
+
+ private void TooltipText_IsTextTrimmedChanged(TextBlock sender, IsTextTrimmedChangedEventArgs e)
+ {
+ ToolTipService.SetToolTip(sender, null);
+ if (!sender.IsTextTrimmed) return;
+
+ var toolTip = new ToolTip
+ {
+ Content = sender.Text,
+ };
+ ToolTipService.SetToolTip(sender, toolTip);
+ }
+}
diff --git a/App/Views/Pages/FileSyncListMainPage.xaml b/App/Views/Pages/FileSyncListMainPage.xaml
index d38bc29..cb9f2bb 100644
--- a/App/Views/Pages/FileSyncListMainPage.xaml
+++ b/App/Views/Pages/FileSyncListMainPage.xaml
@@ -38,21 +38,27 @@
-
-
+
+
+
+
+
+
+
+
-
+
@@ -132,7 +138,7 @@
@@ -266,7 +272,7 @@
@@ -274,8 +280,11 @@
-
-
+
+
@@ -314,7 +323,7 @@
@@ -322,23 +331,43 @@
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/App/Views/Pages/FileSyncListMainPage.xaml.cs b/App/Views/Pages/FileSyncListMainPage.xaml.cs
index c54c29e..a677522 100644
--- a/App/Views/Pages/FileSyncListMainPage.xaml.cs
+++ b/App/Views/Pages/FileSyncListMainPage.xaml.cs
@@ -1,7 +1,4 @@
-using System.Threading.Tasks;
using Coder.Desktop.App.ViewModels;
-using CommunityToolkit.Mvvm.Input;
-using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace Coder.Desktop.App.Views.Pages;
@@ -10,12 +7,9 @@ public sealed partial class FileSyncListMainPage : Page
{
public FileSyncListViewModel ViewModel;
- private readonly Window _window;
-
- public FileSyncListMainPage(FileSyncListViewModel viewModel, Window window)
+ public FileSyncListMainPage(FileSyncListViewModel viewModel)
{
ViewModel = viewModel; // already initialized
- _window = window;
InitializeComponent();
}
@@ -31,10 +25,4 @@ private void TooltipText_IsTextTrimmedChanged(TextBlock sender, IsTextTrimmedCha
};
ToolTipService.SetToolTip(sender, toolTip);
}
-
- [RelayCommand]
- public async Task OpenLocalPathSelectDialog()
- {
- await ViewModel.OpenLocalPathSelectDialog(_window);
- }
}
diff --git a/App/Views/Pages/SignInTokenPage.xaml b/App/Views/Pages/SignInTokenPage.xaml
index 8613f19..e21b46b 100644
--- a/App/Views/Pages/SignInTokenPage.xaml
+++ b/App/Views/Pages/SignInTokenPage.xaml
@@ -62,8 +62,9 @@
Grid.Row="2"
HorizontalAlignment="Stretch"
PlaceholderText="Paste your token here"
+ KeyDown="PasswordBox_KeyDown"
LostFocus="{x:Bind ViewModel.ApiToken_FocusLost, Mode=OneWay}"
- Password="{x:Bind ViewModel.ApiToken, Mode=TwoWay}" />
+ Password="{x:Bind ViewModel.ApiToken, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
+ Text="{x:Bind ViewModel.CoderUrl, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
+ KeyDown="TextBox_KeyDown"/>
SendRequestNoBodyAsync(HttpMethod method, string path,
+ CancellationToken ct = default)
+ {
+ return await SendRequestAsync