diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/linux/normal-disconnected.png b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/linux/normal-disconnected.png new file mode 100644 index 000000000..1c9813b2d Binary files /dev/null and b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/linux/normal-disconnected.png differ diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/macos/dark/normal-disconnected.png b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/macos/dark/normal-disconnected.png new file mode 100644 index 000000000..1f29949ed Binary files /dev/null and b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/macos/dark/normal-disconnected.png differ diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/macos/light/normal-disconnected.png b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/macos/light/normal-disconnected.png new file mode 100644 index 000000000..f74fbf15e Binary files /dev/null and b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/macos/light/normal-disconnected.png differ diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/windows/normal-disconnected.png b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/windows/normal-disconnected.png new file mode 100644 index 000000000..d2260393b Binary files /dev/null and b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/windows/normal-disconnected.png differ diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/AvaloniaRunner.cs b/Duplicati/GUI/Duplicati.GUI.TrayIcon/AvaloniaRunner.cs index b77cf1ba7..ae31a1828 100644 --- a/Duplicati/GUI/Duplicati.GUI.TrayIcon/AvaloniaRunner.cs +++ b/Duplicati/GUI/Duplicati.GUI.TrayIcon/AvaloniaRunner.cs @@ -19,6 +19,8 @@ // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. +#nullable enable + using System; using System.Collections.Generic; using System.Linq; @@ -40,7 +42,7 @@ namespace Duplicati.GUI.TrayIcon public class AvaloniaRunner : TrayIconBase { private static readonly string LOGTAG = Log.LogTagFromType(); - private AvaloniaApp application; + private AvaloniaApp? application; private ProcessBasedActionDelay actionDelayer = new ProcessBasedActionDelay(); private IEnumerable menuItems = Enumerable.Empty(); @@ -106,6 +108,8 @@ namespace Duplicati.GUI.TrayIcon #endif application = builder.Instance as AvaloniaApp; + if (application == null) + throw new InvalidOperationException("Failed to create Avalonia app"); application.SetMenu(menuItems); application.Configure(); @@ -114,7 +118,7 @@ namespace Duplicati.GUI.TrayIcon private async void CheckForAppInitialized(IClassicDesktopStyleApplicationLifetime lifetime) { - Exception lastEx = null; + Exception? lastEx = null; // Jump out of the UI thread, ot ensure that the check is done with posting to the UI thread, // as opposed to just calling the method directly @@ -164,7 +168,7 @@ namespace Duplicati.GUI.TrayIcon return new AvaloniaMenuItem(this, text, icon, callback, subitems); } - protected override void NotifyUser(string title, string message, NotificationType type) + public override void NotifyUser(string title, string message, NotificationType type) { //var icon = Win32NativeNotifyIcon.InfoFlags.NIIF_INFO; @@ -180,8 +184,6 @@ namespace Duplicati.GUI.TrayIcon //icon = Win32NativeNotifyIcon.InfoFlags.NIIF_ERROR; break; } - - //m_ntfIcon.ShowBalloonTip(title, message, icon); } protected override void Exit() @@ -209,7 +211,7 @@ namespace Duplicati.GUI.TrayIcon internal static string GetThemePath() { - var isDark = string.Equals(Application.Current.ActualThemeVariant.ToString(), "Dark", StringComparison.OrdinalIgnoreCase); + var isDark = string.Equals(Application.Current?.ActualThemeVariant.ToString(), "Dark", StringComparison.OrdinalIgnoreCase); if (OperatingSystem.IsMacOS()) return isDark ? "macos/dark" : "macos/light"; @@ -237,11 +239,12 @@ namespace Duplicati.GUI.TrayIcon public string Text { get; private set; } public Action Callback { get; private set; } - public IList SubItems { get; private set; } + public IList? SubItems { get; private set; } public bool Enabled { get; private set; } public MenuIcons Icon { get; private set; } public bool IsDefault { get; private set; } - private NativeMenuItem nativeMenuItem; + public bool Hidden { get; private set; } + private NativeMenuItem? nativeMenuItem; private readonly AvaloniaRunner parent; public AvaloniaMenuItem(AvaloniaRunner parent, string text, MenuIcons icon, Action callback, IList subitems) @@ -278,6 +281,9 @@ namespace Duplicati.GUI.TrayIcon /// private void UpdateIcon() { + if (nativeMenuItem == null) + return; + nativeMenuItem.Icon = this.Icon switch { MenuIcons.Status => AvaloniaRunner.LoadBitmap("context-menu-open.png"), @@ -301,6 +307,14 @@ namespace Duplicati.GUI.TrayIcon // Not currently supported by Avalonia, // used to set the menu bold on Windows to indicate the default entry } + + public void SetHidden(bool hidden) + { + this.Hidden = hidden; + if (nativeMenuItem != null) + parent.RunOnUIThread(() => nativeMenuItem.IsVisible = !this.Hidden); + + } #endregion public NativeMenuItem GetNativeItem() @@ -324,9 +338,9 @@ namespace Duplicati.GUI.TrayIcon public class AvaloniaApp : Application { - private Avalonia.Controls.TrayIcon trayIcon; + private Avalonia.Controls.TrayIcon? trayIcon; - private List menuItems; + private List? menuItems; public override void Initialize() { @@ -355,13 +369,17 @@ namespace Duplicati.GUI.TrayIcon default: this.trayIcon.Icon = AvaloniaRunner.LoadIcon("normal.png"); break; + case TrayIcons.Disconnected: + this.trayIcon.Icon = AvaloniaRunner.LoadIcon("normal-disconnected.png"); + break; } } public void Configure() { this.Name = Duplicati.Library.AutoUpdater.AutoUpdateSettings.AppName; - this.trayIcon.ToolTipText = this.Name; + if (this.trayIcon != null) + this.trayIcon.ToolTipText = this.Name; } public void SetMenu(IEnumerable menuItems) @@ -413,10 +431,10 @@ namespace Duplicati.GUI.TrayIcon public bool IsEnabled(LogEventLevel level, string area) => level >= _minLevel; - public void Log(LogEventLevel level, string area, object source, string messageTemplate) - => Log(level, area, source, messageTemplate, null); + public void Log(LogEventLevel level, string area, object? source, string messageTemplate) + => Log(level, area, source, messageTemplate, []); - public void Log(LogEventLevel level, string area, object source, string messageTemplate, params object[] propertyValues) + public void Log(LogEventLevel level, string area, object? source, string messageTemplate, params object?[] propertyValues) => Console.WriteLine($"Avalonia [{level}]: {source} {messageTemplate} {string.Join(" ", propertyValues)}"); } diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/HttpServerConnection.cs b/Duplicati/GUI/Duplicati.GUI.TrayIcon/HttpServerConnection.cs index cf610569a..481744afc 100644 --- a/Duplicati/GUI/Duplicati.GUI.TrayIcon/HttpServerConnection.cs +++ b/Duplicati/GUI/Duplicati.GUI.TrayIcon/HttpServerConnection.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; @@ -88,6 +88,7 @@ namespace Duplicati.GUI.TrayIcon private readonly string m_baseUri; private string m_password; private string m_accesstoken; + private bool m_isTryingWithPassword; public delegate void StatusUpdateDelegate(IServerStatus status); public event StatusUpdateDelegate OnStatusUpdated; @@ -161,10 +162,6 @@ namespace Duplicati.GUI.TrayIcon } }; - // TODO: Not nice to do in constructor - // Get a connection - UpdateStatus(false); - m_waitLock = new AutoResetEvent(false); m_requestThread = new Thread(ThreadRunner); m_pollThread = new Thread(LongPollRunner); @@ -176,11 +173,14 @@ namespace Duplicati.GUI.TrayIcon m_pollThread.Start(); } - private void UpdateStatus(bool longpoll) + public Task UpdateStatus() + => UpdateStatusAsync(false); + + private async Task UpdateStatusAsync(bool longpoll) { var query = longpoll ? $"?longpoll=true&lastEventId={m_lastEventId}&duration={LONGPOLL_TIMEOUT}" : ""; - m_status = PerformRequest("GET", $"/serverstate{query}", null, longpoll ? Library.Utility.Timeparser.ParseTimeSpan(LONGPOLL_TIMEOUT) : null); + m_status = await PerformRequestAsync("GET", $"/serverstate{query}", null, longpoll ? Library.Utility.Timeparser.ParseTimeSpan(LONGPOLL_TIMEOUT) : null); m_lastEventId = m_status.LastEventID; if (OnStatusUpdated != null) @@ -189,19 +189,19 @@ namespace Duplicati.GUI.TrayIcon if (m_lastNotificationId != m_status.LastNotificationUpdateID) { m_lastNotificationId = m_status.LastNotificationUpdateID; - UpdateNotifications(); + await UpdateNotificationsAsync().ConfigureAwait(false); } if (m_lastDataUpdateId != m_status.LastDataUpdateID) { m_lastDataUpdateId = m_status.LastDataUpdateID; - UpdateApplicationSettings(); + await UpdateApplicationSettingsAsync().ConfigureAwait(false); } } - private void UpdateNotifications() + private async Task UpdateNotificationsAsync() { - var notifications = PerformRequest("GET", "/notifications", null, null); + var notifications = await PerformRequestAsync("GET", "/notifications", null, null).ConfigureAwait(false); if (notifications != null) { foreach (var n in notifications.Where(x => x.Timestamp > m_firstNotificationTime)) @@ -213,9 +213,9 @@ namespace Duplicati.GUI.TrayIcon } } - private void UpdateApplicationSettings() + private async Task UpdateApplicationSettingsAsync() { - var settings = PerformRequest>("GET", "/serversettings", null, null); + var settings = await PerformRequestAsync>("GET", "/serversettings", null, null).ConfigureAwait(false); if (settings != null && settings.TryGetValue("disable-tray-icon-login", out var str)) m_disableTrayIconLogin = Library.Utility.Utility.ParseBool(str, false); } @@ -235,12 +235,15 @@ namespace Duplicati.GUI.TrayIcon if (waitTime.TotalSeconds > 0) Thread.Sleep(waitTime); started = DateTime.Now; - UpdateStatus(true); + UpdateStatusAsync(true).Await(); errorCount = 0; } catch (Exception ex) { errorCount++; + // More than 1 error, and we are disconnected + if (errorCount >= 2) + Task.Run(() => { OnStatusUpdated?.Invoke(new ServerStatusImpl(LiveControlState.Paused, SuggestedStatusIcon.Disconnected, m_lastEventId, m_lastDataUpdateId, m_lastNotificationId)); }).Await(); System.Diagnostics.Trace.WriteLine("Request error: " + ex.Message); Library.Logging.Log.WriteWarningMessage(LOGTAG, "TrayIconRequestError", ex, "Failed to get response"); } @@ -269,7 +272,7 @@ namespace Duplicati.GUI.TrayIcon if (req != null) { any = true; - PerformRequest(req.Method, req.Endpoint, req.Body, req.Timeout); + PerformRequestAsync(req.Method, req.Endpoint, req.Body, req.Timeout).Await(); } } while (req != null); @@ -300,18 +303,18 @@ namespace Duplicati.GUI.TrayIcon private sealed record SigninResponse(string AccessToken); - private T PerformRequest(string method, string urlfragment, string body, TimeSpan? timeout) + private async Task PerformRequestAsync(string method, string urlfragment, string body, TimeSpan? timeout) { if (string.IsNullOrWhiteSpace(m_accesstoken) && !urlfragment.StartsWith("/auth/")) - ObtainAccessToken(); + await ObtainAccessTokenAsync().ConfigureAwait(false); - var hasTriedPassword = false; + var hasTriedPassword = m_isTryingWithPassword; while (true) { try { - return PerformRequestInternal(method, urlfragment, body, timeout); + return await PerformRequestInternalAsync(method, urlfragment, body, timeout).ConfigureAwait(false); } catch (Exception ex) when (ex is HttpRequestException hex && (hex.StatusCode == HttpStatusCode.Unauthorized || hex.StatusCode == HttpStatusCode.Forbidden) @@ -321,20 +324,49 @@ namespace Duplicati.GUI.TrayIcon if (hasTriedPassword) throw; + // TODO: This error handling is error prone and can end up in infinite recursion + // Should rewrite the entire class and use websockets instead + // Only try once, and clear the token for the next try hasTriedPassword = true; m_accesstoken = null; - ObtainAccessToken(); + try + { + m_isTryingWithPassword = true; + await ObtainAccessTokenAsync().ConfigureAwait(false); + } + finally + { + m_isTryingWithPassword = false; + } } } } - private void ObtainAccessToken() + private async Task ObtainAccessTokenAsync() { - var token = ObtainSignInToken(); + // If we host the server, issue the access token from the service + if (FIXMEGlobal.IsServerStarted && m_passwordSource == Program.PasswordSource.HostedServer) + { + var provider = FIXMEGlobal.Provider.GetRequiredService(); + m_accesstoken = provider.CreateAccessToken("trayicon", provider.TemporaryFamilyId); + return; + } + + // If we know the password, issue a token from the API + if (!string.IsNullOrWhiteSpace(m_password)) + { + m_accesstoken = (await PerformRequestInternalAsync("POST", "/auth/login", JsonSerializer.Serialize(new { Password = m_password }), null).ConfigureAwait(false)).AccessToken; + return; + } + + // Otherwise, try to get a signin token + var token = await ObtainSignInTokenAsync().ConfigureAwait(false); if (string.IsNullOrWhiteSpace(token)) return; - m_accesstoken = PerformRequestInternal("POST", "/auth/signin", JsonSerializer.Serialize(new { SigninToken = token }), null).AccessToken; + + // Use the signin token to get an access token + m_accesstoken = (await PerformRequestInternalAsync("POST", "/auth/signin", JsonSerializer.Serialize(new { SigninToken = token }), null).ConfigureAwait(false)).AccessToken; } private async Task PerformRequestInternalAsync(string method, string endpoint, string body, TimeSpan? timeout) @@ -358,13 +390,10 @@ namespace Duplicati.GUI.TrayIcon if (typeof(T) == typeof(string)) return (T)(object)await response.Content.ReadAsStringAsync().ConfigureAwait(false); - using (var stream = await response.Content.ReadAsStreamAsync()) + using (var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false)) return await JsonSerializer.DeserializeAsync(stream, serializerOptions).ConfigureAwait(false); } - private T PerformRequestInternal(string method, string endpoint, string body, TimeSpan? timeout) - => PerformRequestInternalAsync(method, endpoint, body, timeout).Await(); - private void ExecuteAndNotify(string method, string urifragment, string body) { lock (m_lock) @@ -391,21 +420,29 @@ namespace Duplicati.GUI.TrayIcon } private sealed record SigninTokenResponse(string Token); - private string IssueSigninToken(string password) - => PerformRequest("POST", "/auth/issuesignintoken", JsonSerializer.Serialize(new { Password = password }), null).Token; + private async Task IssueSigninTokenAsync(string password) + => (await PerformRequestInternalAsync("POST", "/auth/issuesignintoken", JsonSerializer.Serialize(new { Password = password }), null).ConfigureAwait(false)).Token; - private string ObtainSignInToken() + private async Task ObtainSignInTokenAsync() { string signinjwt = null; // If we host the server, issue the token from the service if (FIXMEGlobal.IsServerStarted && m_passwordSource == Program.PasswordSource.HostedServer) + { + if (FIXMEGlobal.DataConnection.ApplicationSettings.DisableSigninTokens) + return null; + signinjwt = FIXMEGlobal.Provider.GetRequiredService().CreateSigninToken("trayicon"); + } // If we have database access, grab the issuer key from the db and issue a token if (string.IsNullOrWhiteSpace(signinjwt) && m_passwordSource == Program.PasswordSource.Database) { Program.databaseConnection.ApplicationSettings.ReloadSettings(); + if (Program.databaseConnection.ApplicationSettings.DisableSigninTokens) + return null; + var cfg = Program.databaseConnection.ApplicationSettings.JWTConfig; if (!string.IsNullOrWhiteSpace(cfg)) signinjwt = new JWTTokenProvider(JsonSerializer.Deserialize(cfg)).CreateSigninToken("trayicon"); @@ -413,20 +450,28 @@ namespace Duplicati.GUI.TrayIcon // If we know the password, issue a token from the API if (string.IsNullOrWhiteSpace(signinjwt) && !string.IsNullOrWhiteSpace(m_password)) - signinjwt = IssueSigninToken(m_password); + signinjwt = await IssueSigninTokenAsync(m_password); return signinjwt; } - public string StatusWindowURL + public async Task GetStatusWindowURLAsync() { - get + string signinjwt = null; + if (!m_disableTrayIconLogin) { - var signinjwt = m_disableTrayIconLogin ? null : ObtainSignInToken(); - return string.IsNullOrWhiteSpace(signinjwt) - ? m_baseUri + STATUS_WINDOW - : m_baseUri + SIGNIN_WINDOW + $"?token={signinjwt}"; + try + { + signinjwt = await ObtainSignInTokenAsync().ConfigureAwait(false); + } + catch + { + } } + + return string.IsNullOrWhiteSpace(signinjwt) + ? m_baseUri + STATUS_WINDOW + : m_baseUri + SIGNIN_WINDOW + $"?token={signinjwt}"; } } } diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/Program.cs b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Program.cs index 28cff9188..d9e924630 100644 --- a/Duplicati/GUI/Duplicati.GUI.TrayIcon/Program.cs +++ b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Program.cs @@ -24,6 +24,8 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; +using System.Net.Http; +using System.Threading; using Duplicati.Library.AutoUpdater; using Duplicati.Library.Interface; using Duplicati.Library.RestAPI; @@ -85,7 +87,7 @@ namespace Duplicati.GUI.TrayIcon if (OperatingSystem.IsWindows() && !Library.Utility.Utility.ParseBoolOption(options, DETACHED_PROCESS)) Library.Utility.Win32.AttachConsole(Library.Utility.Win32.ATTACH_PARENT_PROCESS); - + if (HelpOptionExtensions.IsArgumentAnyHelpString(args)) { Console.WriteLine("Supported commandline arguments:"); @@ -204,11 +206,19 @@ No password provided, unable to connect to server, exiting"); do { + if (reSpawn > 0) + Thread.Sleep(1000); + try { ServicePointManager.SecurityProtocol = SecurityProtocolType.SystemDefault; using (Connection = new HttpServerConnection(serverURL, password, passwordSource, disableTrayIconLogin, acceptedHostCertificate, options)) { + // Make sure we have the latest status + // This will throw an exception if the server is not running, + // or the password is incorrect + Connection.UpdateStatus().Await(); + using (var tk = RunTrayIcon()) { if (hosted != null && Server.Program.ApplicationInstance != null) @@ -219,16 +229,20 @@ No password provided, unable to connect to server, exiting"); // TODO: If we change to hosted browser this should be a callback if (openui) { - try + Connection.GetStatusWindowURLAsync().ContinueWith(t => { - tk.ShowUrlInWindow(Connection.StatusWindowURL); + if (t.IsFaulted) + { + Console.WriteLine("Failed to get status window URL: " + t.Exception.Message); + tk.NotifyUser("Failed to get status window URL", t.Exception.Message, NotificationType.Error); + return; + } + tk.ShowUrlInWindow(t.Result); Server.Program.IsFirstRun = false; Server.Program.ServerPortChanged = false; - } - catch - { - } + + }); } // If the server shuts down, shut down the tray-icon as well @@ -255,7 +269,7 @@ No password provided, unable to connect to server, exiting"); } } } - catch (WebException ex) + catch (HttpRequestException ex) { System.Diagnostics.Trace.WriteLine("Request error: " + ex); Console.WriteLine("Request error: " + ex); diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/TrayIconBase.cs b/Duplicati/GUI/Duplicati.GUI.TrayIcon/TrayIconBase.cs index 4906daf85..1b769718f 100644 --- a/Duplicati/GUI/Duplicati.GUI.TrayIcon/TrayIconBase.cs +++ b/Duplicati/GUI/Duplicati.GUI.TrayIcon/TrayIconBase.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; @@ -43,7 +43,8 @@ namespace Duplicati.GUI.TrayIcon IdleWarning, IdleError, PausedError, - RunningError + RunningError, + Disconnected } public enum WindowIcons @@ -64,11 +65,16 @@ namespace Duplicati.GUI.TrayIcon void SetDefault(bool isDefault); void SetIcon(MenuIcons icon); void SetText(string text); + void SetEnabled(bool enabled); + void SetHidden(bool hidden); } public abstract class TrayIconBase : IDisposable { + protected IMenuItem m_reconnectMenu; + protected IMenuItem m_openMenu; protected IMenuItem m_pauseMenu; + protected IMenuItem m_quitMenu; protected bool m_stateIsPaused; protected Action m_onSingleClick; protected Action m_onDoubleClick; @@ -105,7 +111,7 @@ namespace Duplicati.GUI.TrayIcon protected abstract void SetMenu(IEnumerable items); - protected abstract void NotifyUser(string title, string message, NotificationType type); + public abstract void NotifyUser(string title, string message, NotificationType type); protected virtual void RegisterStatusUpdateCallback() { @@ -149,23 +155,49 @@ namespace Duplicati.GUI.TrayIcon protected IEnumerable BuildMenu() { - var tmp = CreateMenuItem("Open", MenuIcons.Status, OnStatusClicked, null); - tmp.SetDefault(true); + m_reconnectMenu = CreateMenuItem("Reconnect", MenuIcons.Resume, Reconnect, null); + m_reconnectMenu.SetHidden(true); + m_openMenu = CreateMenuItem("Open", MenuIcons.Status, OnStatusClicked, null); + m_openMenu.SetDefault(true); return new IMenuItem[] { - tmp, + m_reconnectMenu, + m_openMenu, m_pauseMenu = CreateMenuItem("Pause", MenuIcons.Pause, OnPauseClicked, null ), - CreateMenuItem("Quit", MenuIcons.Quit, OnQuitClicked, null), + m_quitMenu = CreateMenuItem("Quit", MenuIcons.Quit, OnQuitClicked, null), }; } + private void Reconnect() + { + Program.Connection.UpdateStatus().ContinueWith(t => + { + if (t.IsFaulted) + { + Console.WriteLine("Failed to reconnect: " + t.Exception.Message); + NotifyUser("Failed to reconnect", t.Exception.Message, NotificationType.Error); + } + + }); + } + protected void ShowStatusWindow() { - var window = ShowUrlInWindow(Program.Connection.StatusWindowURL); - if (window != null) + Program.Connection.GetStatusWindowURLAsync().ContinueWith(t => { - window.SetIcon(WindowIcons.Regular); - window.SetTitle("Duplicati status"); - } + if (t.IsFaulted) + { + Console.WriteLine("Failed to get status window URL: " + t.Exception.Message); + NotifyUser("Failed to get status window URL", t.Exception.Message, NotificationType.Error); + return; + } + + var window = ShowUrlInWindow(t.Result); + if (window != null) + { + window.SetIcon(WindowIcons.Regular); + window.SetTitle("Duplicati status"); + } + }); } protected void OnStatusClicked() @@ -207,6 +239,9 @@ namespace Duplicati.GUI.TrayIcon case SuggestedStatusIcon.Paused: this.SetIcon(TrayIcons.Paused); break; + case SuggestedStatusIcon.Disconnected: + this.SetIcon(TrayIcons.Disconnected); + break; case SuggestedStatusIcon.Ready: default: this.SetIcon(TrayIcons.Idle); @@ -226,6 +261,10 @@ namespace Duplicati.GUI.TrayIcon m_pauseMenu.SetText("Resume"); m_stateIsPaused = true; } + + m_openMenu.SetHidden(status.SuggestedStatusIcon == SuggestedStatusIcon.Disconnected); + m_pauseMenu.SetHidden(status.SuggestedStatusIcon == SuggestedStatusIcon.Disconnected); + m_reconnectMenu.SetHidden(status.SuggestedStatusIcon != SuggestedStatusIcon.Disconnected); }); } diff --git a/Duplicati/Library/RestAPI/Scheduler.cs b/Duplicati/Library/RestAPI/Scheduler.cs index ba7bd40cf..281efc0fd 100644 --- a/Duplicati/Library/RestAPI/Scheduler.cs +++ b/Duplicati/Library/RestAPI/Scheduler.cs @@ -152,7 +152,7 @@ namespace Duplicati.Server /// public List WorkerQueue { - get { return (from t in m_worker.CurrentTasks where t != null select t).ToList(); } + get { return (from t in m_worker?.CurrentTasks where t != null select t)?.ToList() ?? []; } } /// diff --git a/Duplicati/Library/RestAPI/Strings.cs b/Duplicati/Library/RestAPI/Strings.cs index a7e7d3e0c..e7321da48 100644 --- a/Duplicati/Library/RestAPI/Strings.cs +++ b/Duplicati/Library/RestAPI/Strings.cs @@ -41,6 +41,7 @@ Error message: {0}", message); } public static string SkippingSourceArgumentsOnNonBackupOperation { get { return @"The --source argument was specified in the parameter file, but the current operation is not a backup operation, so the argument is ignored"; } } public static string LogfileCommandDescription { get { return LC.L(@"Output log information to the file given"); } } public static string LoglevelCommandDescription { get { return LC.L(@"Determine the amount of information written in the log file"); } } + public static string LogConsoleDescription { get { return LC.L(@"Output log information to the console"); } } public static string PortablemodeCommandDescription { get { return LC.L(@"Activate portable mode where the database is placed below the program executable"); } } public static string SeriousError(string message) { return LC.L(@"A serious error occurred in Duplicati: {0}", message); } public static string TearDownError(string message) { return LC.L(@"An error occurred on server tear down: {0}", message); } diff --git a/Duplicati/Library/Utility/Utility.cs b/Duplicati/Library/Utility/Utility.cs index 4d2d8e423..fdb2a6713 100644 --- a/Duplicati/Library/Utility/Utility.cs +++ b/Duplicati/Library/Utility/Utility.cs @@ -807,7 +807,7 @@ namespace Duplicati.Library.Utility return @default; var flags = 0; - foreach (var s in value.Split([','], StringSplitOptions.RemoveEmptyEntries)) + foreach (var s in value.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)) { var trimmed = s.Trim(); if (Enum.TryParse(trimmed, true, out T flag)) diff --git a/Duplicati/Server/Duplicati.Server.Serialization/Enums.cs b/Duplicati/Server/Duplicati.Server.Serialization/Enums.cs index 31c615634..714e450d3 100755 --- a/Duplicati/Server/Duplicati.Server.Serialization/Enums.cs +++ b/Duplicati/Server/Duplicati.Server.Serialization/Enums.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; @@ -53,7 +53,8 @@ namespace Duplicati.Server.Serialization ReadyError, Paused, Active, - ActivePaused + ActivePaused, + Disconnected } diff --git a/Duplicati/Server/Program.cs b/Duplicati/Server/Program.cs index 25b37a954..33aedb0bf 100644 --- a/Duplicati/Server/Program.cs +++ b/Duplicati/Server/Program.cs @@ -21,7 +21,6 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -52,6 +51,10 @@ namespace Duplicati.Server private const string REQUIRE_DB_ENCRYPTION_KEY_OPTION = "require-db-encryption-key"; private const string SETTINGS_ENCRYPTION_KEY_OPTION = "settings-encryption-key"; private const string DISABLE_UPDATE_CHECK_OPTION = "disable-update-check"; + private const string LOG_FILE_OPTION = "log-file"; + private const string LOG_LEVEL_OPTION = "log-level"; + private const string LOG_CONSOLE_OPTION = "log-console"; + #if DEBUG private const bool DEBUG_MODE = true; @@ -265,7 +268,7 @@ namespace Duplicati.Server DataConnection.LogError(null, "Error in updater", obj); }; - DuplicatiWebserver = StartWebServer(commandlineOptions, DataConnection).ConfigureAwait(false).GetAwaiter().GetResult(); + DuplicatiWebserver = StartWebServer(commandlineOptions, DataConnection).Await(); UpdatePoller.Init(Library.Utility.Utility.ParseBoolOption(commandlineOptions, DISABLE_UPDATE_CHECK_OPTION)); @@ -311,6 +314,24 @@ namespace Duplicati.Server ApplicationExitEvent.Set(); }); + var stopCounter = 0; + Console.CancelKeyPress += (sender, e) => + { + if (Interlocked.Increment(ref stopCounter) <= 1) + { + Log.WriteInformationMessage(LOGTAG, "CancelKeyPressed", "Cancel key pressed, stopping server"); + Task.Run(() => DuplicatiWebserver?.Stop()); + } + else + { + Log.WriteWarningMessage(LOGTAG, "CancelKeyPressed", null, "Cancel key pressed twice, terminating now"); + if (OperatingSystem.IsWindows()) + Environment.FailFast("Cancel key pressed twice, terminating now"); + else + Environment.Exit(0); + } + }; + ServerStartedEvent.Set(); ApplicationExitEvent.WaitOne(); } @@ -668,30 +689,45 @@ namespace Duplicati.Server private static async Task ApplySecretProvider(Dictionary commandlineOptions, CancellationToken cancellationToken) => FIXMEGlobal.SecretProvider = await SecretProviderHelper.ApplySecretProviderAsync([], [], commandlineOptions, TempFolder.SystemTempPath, FIXMEGlobal.SecretProvider, cancellationToken).ConfigureAwait(false); + private class ConsoleLogDestination(LogMessageType level) : ILogDestination + { + public void WriteMessage(LogEntry entry) + { + if (entry.Level >= level) + Console.WriteLine(entry.AsString(true)); + } + } + private static void ConfigureLogging(Dictionary commandlineOptions) { //Log various information in the logfile - if (DEBUG_MODE && !commandlineOptions.ContainsKey("log-file")) + if (DEBUG_MODE && !commandlineOptions.ContainsKey(LOG_FILE_OPTION)) { var prefix = System.Reflection.Assembly.GetEntryAssembly().GetName().Name.StartsWith("Duplicati.Server") ? "server" : "trayicon"; - commandlineOptions["log-file"] = System.IO.Path.Combine(StartupPath, $"Duplicati-{prefix}.debug.log"); - commandlineOptions["log-level"] = Duplicati.Library.Logging.LogMessageType.Profiling.ToString(); - if (System.IO.File.Exists(commandlineOptions["log-file"])) - { - System.IO.File.Delete(commandlineOptions["log-file"]); - } + commandlineOptions[LOG_FILE_OPTION] = System.IO.Path.Combine(StartupPath, $"Duplicati-{prefix}.debug.log"); + commandlineOptions[LOG_LEVEL_OPTION] = Duplicati.Library.Logging.LogMessageType.Profiling.ToString(); + if (System.IO.File.Exists(commandlineOptions[LOG_FILE_OPTION])) + System.IO.File.Delete(commandlineOptions[LOG_FILE_OPTION]); } - // Setup the log redirect - Library.Logging.Log.StartScope(LogHandler, null); + Log.StartScope(LogHandler, null); - if (commandlineOptions.ContainsKey("log-file")) + if (commandlineOptions.ContainsKey(LOG_FILE_OPTION)) { var loglevel = Library.Logging.LogMessageType.Warning; - if (commandlineOptions.ContainsKey("log-level")) - Enum.TryParse(commandlineOptions["log-level"], true, out loglevel); + if (commandlineOptions.ContainsKey(LOG_LEVEL_OPTION)) + Enum.TryParse(commandlineOptions[LOG_LEVEL_OPTION], true, out loglevel); - LogHandler.SetServerFile(commandlineOptions["log-file"], loglevel); + LogHandler.SetServerFile(commandlineOptions[LOG_FILE_OPTION], loglevel); + } + + if (Library.Utility.Utility.ParseBoolOption(commandlineOptions, LOG_CONSOLE_OPTION)) + { + var loglevel = Library.Logging.LogMessageType.Information; + if (commandlineOptions.ContainsKey(LOG_LEVEL_OPTION)) + Enum.TryParse(commandlineOptions[LOG_LEVEL_OPTION], true, out loglevel); + + LogHandler.AppendLogDestination(new ConsoleLogDestination(loglevel), loglevel); } if (commandlineOptions.TryGetValue(WINDOWS_EVENTLOG_OPTION, out var source) && !string.IsNullOrEmpty(source)) @@ -942,8 +978,9 @@ namespace Duplicati.Server new Duplicati.Library.Interface.CommandLineArgument("help", Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Boolean, Strings.Program.HelpCommandDescription, Strings.Program.HelpCommandDescription), new Duplicati.Library.Interface.CommandLineArgument("parameters-file", Library.Interface.CommandLineArgument.ArgumentType.Path, Strings.Program.ParametersFileOptionShort, Strings.Program.ParametersFileOptionLong2, "", ParameterFileOptionStrings), new Duplicati.Library.Interface.CommandLineArgument(DataFolderManager.PORTABLE_MODE_OPTION, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Boolean, Strings.Program.PortablemodeCommandDescription, Strings.Program.PortablemodeCommandDescription, DataFolderManager.PORTABLE_MODE.ToString().ToLowerInvariant()), - new Duplicati.Library.Interface.CommandLineArgument("log-file", Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Path, Strings.Program.LogfileCommandDescription, Strings.Program.LogfileCommandDescription), - new Duplicati.Library.Interface.CommandLineArgument("log-level", Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Enumeration, Strings.Program.LoglevelCommandDescription, Strings.Program.LoglevelCommandDescription, Library.Logging.LogMessageType.Warning.ToString(), null, Enum.GetNames(typeof(Duplicati.Library.Logging.LogMessageType))), + new Duplicati.Library.Interface.CommandLineArgument(LOG_FILE_OPTION, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Path, Strings.Program.LogfileCommandDescription, Strings.Program.LogfileCommandDescription), + new Duplicati.Library.Interface.CommandLineArgument(LOG_LEVEL_OPTION, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Enumeration, Strings.Program.LoglevelCommandDescription, Strings.Program.LoglevelCommandDescription, Library.Logging.LogMessageType.Warning.ToString(), null, Enum.GetNames(typeof(Duplicati.Library.Logging.LogMessageType))), + new Duplicati.Library.Interface.CommandLineArgument(LOG_CONSOLE_OPTION, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Boolean, Strings.Program.LogConsoleDescription, Strings.Program.LogConsoleDescription, false.ToString()), new Duplicati.Library.Interface.CommandLineArgument(WebServerLoader.OPTION_WEBROOT, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Path, Strings.Program.WebserverWebrootDescription, Strings.Program.WebserverWebrootDescription, WebServerLoader.DEFAULT_OPTION_WEBROOT), new Duplicati.Library.Interface.CommandLineArgument(WebServerLoader.OPTION_PORT, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.String, Strings.Program.WebserverPortDescription, Strings.Program.WebserverPortDescription, WebServerLoader.DEFAULT_OPTION_PORT.ToString()), new Duplicati.Library.Interface.CommandLineArgument(WebServerLoader.OPTION_WEBSERVICE_DISABLEHTTPS, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.String, Strings.Program.WebserverDisableHTTPSDescription, Strings.Program.WebserverDisableHTTPSDescription),