From 2dcb9a3753848770556fdfb97d853e49458b345f Mon Sep 17 00:00:00 2001 From: whimsical-c4lic0 Date: Thu, 19 Feb 2026 18:52:15 -0600 Subject: [PATCH] Update web link handling to copy links to clipboard when click inside docker --- AGENTS.md | 1 + OF DL.Core/Helpers/Constants.cs | 4 ++ OF DL.Gui/Helpers/WebLinkHelper.cs | 92 ++++++++++++++++++++++++++++ OF DL.Gui/Views/AboutWindow.axaml.cs | 46 +++++++++----- OF DL.Gui/Views/FaqWindow.axaml.cs | 36 ++++------- OF DL.Gui/Views/MainWindow.axaml | 30 +++++++++ OF DL.Gui/Views/MainWindow.axaml.cs | 81 +++++++++++++++++++----- docs/running-the-program.md | 46 +++++++++----- 8 files changed, 266 insertions(+), 70 deletions(-) create mode 100644 OF DL.Gui/Helpers/WebLinkHelper.cs diff --git a/AGENTS.md b/AGENTS.md index 6e21e46..d0e9a0a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ most important change points. - `OF DL/Program.cs` is the single entrypoint and routes between GUI (default) and CLI (`--cli`). - `OF DL/CLI/` contains Spectre.Console UI helpers and progress reporting (CLI-only). - `OF DL.Gui/` contains the Avalonia desktop UI (`App`, `MainWindow`, `AboutWindow`, `FaqWindow`, MVVM view models, and GUI event handlers). +- `OF DL.Gui/Helpers/` contains GUI-specific utility helpers (for example, Docker-aware web-link behavior shared across windows). - `OF DL.Core/Services/` contains application services (API, auth, download, config, DB, startup, logging, filenames). - `OF DL.Core/Models/` holds configuration, auth, API request/response models, downloads/startup results, DTOs, entities, and mapping helpers. diff --git a/OF DL.Core/Helpers/Constants.cs b/OF DL.Core/Helpers/Constants.cs index abe5439..40123a6 100644 --- a/OF DL.Core/Helpers/Constants.cs +++ b/OF DL.Core/Helpers/Constants.cs @@ -2,6 +2,10 @@ namespace OF_DL.Helpers; public static class Constants { + public const string DiscordInviteUrl = "https://discord.com/invite/6bUW8EJ53j"; + + public const string DocumentationUrl = "https://docs.ofdl.tools/"; + public const string ApiUrl = "https://onlyfans.com/api2/v2"; public const int ApiPageSize = 50; diff --git a/OF DL.Gui/Helpers/WebLinkHelper.cs b/OF DL.Gui/Helpers/WebLinkHelper.cs new file mode 100644 index 0000000..87d5d6e --- /dev/null +++ b/OF DL.Gui/Helpers/WebLinkHelper.cs @@ -0,0 +1,92 @@ +using System.Diagnostics; +using Avalonia.Controls; +using OF_DL.Helpers; +using Serilog; + +namespace OF_DL.Gui.Helpers; + +public static class WebLinkHelper +{ + private const string CopiedToClipboardMessage = "Copied to clipboard"; + + public static async Task OpenOrCopyAsync( + Window owner, + string url, + Control? toolTipTarget = null, + Func? dockerFeedbackAsync = null) + { + try + { + if (EnvironmentHelper.IsRunningInDocker()) + { + TopLevel? topLevel = TopLevel.GetTopLevel(owner); + if (topLevel?.Clipboard != null) + { + try + { + await topLevel.Clipboard.SetTextAsync(url); + } + catch + { + return; + } + } + + if (dockerFeedbackAsync != null) + { + await dockerFeedbackAsync(CopiedToClipboardMessage); + return; + } + + if (toolTipTarget != null) + { + await ShowTemporaryTooltipAsync(toolTipTarget, CopiedToClipboardMessage); + } + + return; + } + } + catch (Exception e) + { + Log.Error("Failed to copy URL to clipboard. {ErrorMessage}", e.Message); + } + + try + { + OpenExternalUrl(url); + } + catch (Exception e) + { + Log.Error(e, "Failed to open external URL. {ErrorMessage}", e.Message); + } + } + + private static async Task ShowTemporaryTooltipAsync( + Control target, + string message, + int durationMilliseconds = 1500) + { + object? originalTip = ToolTip.GetTip(target); + + ToolTip.SetTip(target, message); + ToolTip.SetIsOpen(target, true); + + await Task.Delay(durationMilliseconds); + + ToolTip.SetIsOpen(target, false); + ToolTip.SetTip(target, originalTip); + } + + private static void OpenExternalUrl(string url) + { + try + { + ProcessStartInfo processStartInfo = new(url) { UseShellExecute = true }; + Process.Start(processStartInfo); + } + catch + { + // Ignore browser launch failures to preserve prior behavior. + } + } +} diff --git a/OF DL.Gui/Views/AboutWindow.axaml.cs b/OF DL.Gui/Views/AboutWindow.axaml.cs index 9d4a604..7269ac7 100644 --- a/OF DL.Gui/Views/AboutWindow.axaml.cs +++ b/OF DL.Gui/Views/AboutWindow.axaml.cs @@ -1,7 +1,8 @@ -using System.Diagnostics; using Avalonia.Controls; using Avalonia.Interactivity; using Avalonia.Platform; +using OF_DL.Gui.Helpers; +using Serilog; namespace OF_DL.Gui.Views; @@ -37,26 +38,39 @@ public partial class AboutWindow : Window DataContext = this; } - private async void OnOpenSourceCodeClick(object? sender, RoutedEventArgs e) => - await OpenExternalUrlAsync(SourceCodeUrl); - - private async void OnOpenFfmpegLicenseClick(object? sender, RoutedEventArgs e) => - await OpenExternalUrlAsync(FfmpegLicenseUrl); - - private async void OnOpenFfprobeLicenseClick(object? sender, RoutedEventArgs e) => - await OpenExternalUrlAsync(FfprobeLicenseUrl); - - private async Task OpenExternalUrlAsync(string url) + private async void OnOpenSourceCodeClick(object? sender, RoutedEventArgs e) { try { - ProcessStartInfo processStartInfo = new(url) { UseShellExecute = true }; - - Process.Start(processStartInfo); + await WebLinkHelper.OpenOrCopyAsync(this, SourceCodeUrl, sender as Control); } - catch + catch (Exception exception) { - await Task.CompletedTask; + Log.Error(exception, "Failed to handle link click event. {ErrorMessage}", exception.Message); + } + } + + private async void OnOpenFfmpegLicenseClick(object? sender, RoutedEventArgs e) + { + try + { + await WebLinkHelper.OpenOrCopyAsync(this, FfmpegLicenseUrl, sender as Control); + } + catch (Exception exception) + { + Log.Error(exception, "Failed to handle link click event. {ErrorMessage}", exception.Message); + } + } + + private async void OnOpenFfprobeLicenseClick(object? sender, RoutedEventArgs e) + { + try + { + await WebLinkHelper.OpenOrCopyAsync(this, FfprobeLicenseUrl, sender as Control); + } + catch (Exception exception) + { + Log.Error(exception, "Failed to handle link click event. {ErrorMessage}", exception.Message); } } } diff --git a/OF DL.Gui/Views/FaqWindow.axaml.cs b/OF DL.Gui/Views/FaqWindow.axaml.cs index 5696c75..a4b9164 100644 --- a/OF DL.Gui/Views/FaqWindow.axaml.cs +++ b/OF DL.Gui/Views/FaqWindow.axaml.cs @@ -1,23 +1,18 @@ using System.Collections.ObjectModel; -using System.Diagnostics; using Avalonia.Controls; using Avalonia.Interactivity; using Avalonia.Platform; +using OF_DL.Gui.Helpers; using OF_DL.Helpers; +using Serilog; namespace OF_DL.Gui.Views; -public class FaqLink +public class FaqLink(string label, string url) { - public FaqLink(string label, string url) - { - Label = label; - Url = url; - } + public string Label { get; } = label; - public string Label { get; } - - public string Url { get; } + public string Url { get; } = url; } public class FaqEntry @@ -136,26 +131,19 @@ public partial class FaqWindow : Window } private async void OnLinkClick(object? sender, RoutedEventArgs e) - { - if (sender is not Button { CommandParameter: string url } || string.IsNullOrWhiteSpace(url)) - { - return; - } - - await OpenExternalUrlAsync(url); - } - - private async Task OpenExternalUrlAsync(string url) { try { - ProcessStartInfo processStartInfo = new(url) { UseShellExecute = true }; + if (sender is not Button { CommandParameter: string url } || string.IsNullOrWhiteSpace(url)) + { + return; + } - Process.Start(processStartInfo); + await WebLinkHelper.OpenOrCopyAsync(this, url, sender as Control); } - catch + catch (Exception exception) { - await Task.CompletedTask; + Log.Error(exception, "Failed to handle link click event. {ErrorMessage}", exception.Message); } } } diff --git a/OF DL.Gui/Views/MainWindow.axaml b/OF DL.Gui/Views/MainWindow.axaml index 1a4e212..5550a63 100644 --- a/OF DL.Gui/Views/MainWindow.axaml +++ b/OF DL.Gui/Views/MainWindow.axaml @@ -144,6 +144,21 @@ + + @@ -1491,5 +1506,20 @@ + + + + diff --git a/OF DL.Gui/Views/MainWindow.axaml.cs b/OF DL.Gui/Views/MainWindow.axaml.cs index f51b0a4..4be4993 100644 --- a/OF DL.Gui/Views/MainWindow.axaml.cs +++ b/OF DL.Gui/Views/MainWindow.axaml.cs @@ -1,4 +1,3 @@ -using System.Diagnostics; using System.Collections.Specialized; using Avalonia.Controls; using Avalonia.Input; @@ -8,19 +7,20 @@ using Avalonia.Platform.Storage; using Avalonia.VisualTree; using Avalonia.Threading; using OF_DL.Helpers; +using OF_DL.Gui.Helpers; using OF_DL.Gui.ViewModels; +using Serilog; namespace OF_DL.Gui.Views; public partial class MainWindow : Window { - private const string DiscordInviteUrl = "https://discord.com/invite/6bUW8EJ53j"; - private const string DocumentationUrl = "https://docs.ofdl.tools/"; private bool _hasInitialized; private bool _activityLogAutoScroll = true; private bool _activityLogUserInteracted; private bool _isActivityLogProgrammaticScroll; private ScrollViewer? _activityLogScrollViewer; + private CancellationTokenSource? _copyToastCancellationTokenSource; public MainWindowViewModel? ViewModel => DataContext as MainWindowViewModel; public MainWindow() @@ -68,6 +68,10 @@ public partial class MainWindow : Window ActivityLogListBox.PointerWheelChanged -= OnActivityLogPointerInteracted; ActivityLogListBox.PointerPressed -= OnActivityLogPointerInteracted; + + _copyToastCancellationTokenSource?.Cancel(); + _copyToastCancellationTokenSource?.Dispose(); + _copyToastCancellationTokenSource = null; } private async void OnBrowseFfmpegPathClick(object? sender, RoutedEventArgs e) @@ -166,11 +170,31 @@ public partial class MainWindow : Window vm.SetDownloadPath(selectedFolder.Name); } - private async void OnJoinDiscordClick(object? sender, RoutedEventArgs e) => - await OpenExternalUrlAsync(DiscordInviteUrl); + private async void OnJoinDiscordClick(object? sender, RoutedEventArgs e) + { + try + { + await WebLinkHelper.OpenOrCopyAsync(this, Constants.DiscordInviteUrl, + dockerFeedbackAsync: ShowCopyToastAsync); + } + catch (Exception exception) + { + Log.Error(exception, "Failed to handle link click event. {ErrorMessage}", exception.Message); + } + } - private async void OnDocumentationClick(object? sender, RoutedEventArgs e) => - await OpenExternalUrlAsync(DocumentationUrl); + private async void OnDocumentationClick(object? sender, RoutedEventArgs e) + { + try + { + await WebLinkHelper.OpenOrCopyAsync(this, Constants.DocumentationUrl, + dockerFeedbackAsync: ShowCopyToastAsync); + } + catch (Exception exception) + { + Log.Error(exception, "Failed to handle link click event. {ErrorMessage}", exception.Message); + } + } private void OnFaqClick(object? sender, RoutedEventArgs e) { @@ -192,17 +216,45 @@ public partial class MainWindow : Window aboutWindow.Show(this); } - private async Task OpenExternalUrlAsync(string url) + private async Task ShowCopyToastAsync(string message) { + if (_copyToastCancellationTokenSource != null) + { + await _copyToastCancellationTokenSource.CancelAsync(); + _copyToastCancellationTokenSource.Dispose(); + } + + CancellationTokenSource cancellationTokenSource = new(); + _copyToastCancellationTokenSource = cancellationTokenSource; + + CopyToastTextBlock.Text = message; + CopyToastBorder.Opacity = 0; + CopyToastBorder.IsVisible = true; + CopyToastBorder.Opacity = 1; + try { - ProcessStartInfo processStartInfo = new(url) { UseShellExecute = true }; - - Process.Start(processStartInfo); + await Task.Delay(TimeSpan.FromSeconds(2), cancellationTokenSource.Token); } - catch + catch (OperationCanceledException) { - await Task.CompletedTask; + return; + } + + CopyToastBorder.Opacity = 0; + + try + { + await Task.Delay(TimeSpan.FromMilliseconds(180), cancellationTokenSource.Token); + } + catch (OperationCanceledException) + { + return; + } + + if (!cancellationTokenSource.IsCancellationRequested) + { + CopyToastBorder.IsVisible = false; } } @@ -255,7 +307,8 @@ public partial class MainWindow : Window Dispatcher.UIThread.Post(ScrollActivityLogToBottom); } - private void OnActivityLogPointerInteracted(object? sender, PointerEventArgs e) => _activityLogUserInteracted = true; + private void OnActivityLogPointerInteracted(object? sender, PointerEventArgs e) => + _activityLogUserInteracted = true; private void OnActivityLogScrollChanged(object? sender, ScrollChangedEventArgs e) { diff --git a/docs/running-the-program.md b/docs/running-the-program.md index 6afd9e3..b4dbe3d 100644 --- a/docs/running-the-program.md +++ b/docs/running-the-program.md @@ -1,23 +1,27 @@ # Running the Program -Once you are happy you have filled everything in [auth.json](/config/auth) correctly, you can double click OF-DL.exe and you should see a command prompt window appear, it should look something like this: +Once you are happy you have filled everything in [auth.json](/config/auth) correctly, you can double click OF-DL.exe and +you should see a command prompt window appear, it should look something like this: ![CLI welcome banner](/img/welcome_banner.png) It should locate `config.conf`, `rules.json`, FFmpeg, and FFprobe successfully. If anything doesn't get located successfully, then make sure the files exist or the path is correct. -OF-DL will open a new window, if needed, to allow you to log into your OnlyFans account. The window will automatically close once +OF-DL will open a new window, if needed, to allow you to log into your OnlyFans account. The window will automatically +close once the authorization process has finished. If the auth info is correct then you should see a message in green text `Logged In successfully as {Your Username} {Your User Id}`. However, if the authorization has failed, -then a message in red text will appear `Auth failed, please check the values in auth.json are correct, press any key to exit.` -This means you need to go back and fill in the `auth.json` file again, this will usually indicate that your `user-agent` has changed or you need to re-copy your `sess` value. +then a message in red text will appear +`Auth failed, please check the values in auth.json are correct, press any key to exit.` +This means you need to go back and fill in the `auth.json` file again, this will usually indicate that your `user-agent` +has changed or you need to re-copy your `sess` value. In GUI mode, the **Help** menu includes: -- **Join Discord** (opens the OF-DL Discord invite) +- **Join Discord** (opens the OF-DL Discord invite; in Docker, copies the link to clipboard) - **FAQ** (opens the FAQ window; content coming soon) -- **Documentation** (opens https://docs.ofdl.tools/) +- **Documentation** (opens https://docs.ofdl.tools/; in Docker, copies the link to clipboard) - **About** (shows version details and project/license links) In GUI mode, the main download screen includes: @@ -33,30 +37,40 @@ For **Download Single Post/Message** in GUI: find the message, click `...`, and choose **Copy link to message**. - Other message types cannot be downloaded individually by URL; scrape all messages for that creator instead. -If you're logged in successfully then you will be greeted with a selection prompt. To navigate the menu the can use the ↑ & ↓ arrows and press `enter` to choose that option. +If you're logged in successfully then you will be greeted with a selection prompt. To navigate the menu the can use +the ↑ & ↓ arrows and press `enter` to choose that option. ![CLI main menu](/img/cli_menu.png) -The `Select All` option will go through every account you are currently subscribed to and grab all of the media from the users. +The `Select All` option will go through every account you are currently subscribed to and grab all of the media from the +users. -The `List` option will show you all the lists you have created on OnlyFans and you can then select 1 or more lists to download the content of the users within those lists. +The `List` option will show you all the lists you have created on OnlyFans and you can then select 1 or more lists to +download the content of the users within those lists. -The `Custom` option allows you to select 1 or more accounts you want to scrape media from so if you only want to get media from a select number of accounts then you can do that. -To navigate the menu the can use the ↑ & ↓ arrows. You can also press keys A-Z on the keyboard whilst in the menu to easily navigate the menu and for example -pressing the letter 'c' on the keyboard will highlight the first user in the list whose username starts with the letter 'c'. To select/deselect an account, +The `Custom` option allows you to select 1 or more accounts you want to scrape media from so if you only want to get +media from a select number of accounts then you can do that. +To navigate the menu the can use the ↑ & ↓ arrows. You can also press keys A-Z on the keyboard whilst in the menu to +easily navigate the menu and for example +pressing the letter 'c' on the keyboard will highlight the first user in the list whose username starts with the +letter 'c'. To select/deselect an account, press the space key, and after you are happy with your selection(s), press the enter key to start downloading. -The `Download Single Post` allows you to download a post from a URL, to get this URL go to any post and press the 3 dots, Copy link to post. +The `Download Single Post` allows you to download a post from a URL, to get this URL go to any post and press the 3 +dots, Copy link to post. -The `Download Single Message` allows you to download a message from a URL, to get this URL go to any message in the **purchased tab** and press the 3 dots, Copy link to message. +The `Download Single Message` allows you to download a message from a URL, to get this URL go to any message in the * +*purchased tab** and press the 3 dots, Copy link to message. The `Download Purchased Tab` option will download all the media from the purchased tab in OnlyFans. The `Edit config.json` option allows you to change the config from within the program. -The `Change logging level` option allows you to change the logging level that the program uses when writing logs to files in the `logs` folder. +The `Change logging level` option allows you to change the logging level that the program uses when writing logs to +files in the `logs` folder. -The `Logout and Exit` option allows you to remove your authentication from OF-DL. This is useful if you use multiple OnlyFans accounts. +The `Logout and Exit` option allows you to remove your authentication from OF-DL. This is useful if you use multiple +OnlyFans accounts. After you have made your selection the content should start downloading. Content is downloaded in this order: