Update web link handling to copy links to clipboard when click inside docker

This commit is contained in:
whimsical-c4lic0 2026-02-19 18:52:15 -06:00
parent 4d3ae0e19a
commit 2dcb9a3753
8 changed files with 266 additions and 70 deletions

View File

@ -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.

View File

@ -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;

View File

@ -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<string, Task>? 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.
}
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -144,6 +144,21 @@
<Setter Property="CornerRadius" Value="8" />
<Setter Property="BorderThickness" Value="1" />
</Style>
<Style Selector="Border.copyToast">
<Setter Property="Background" Value="{DynamicResource PrimaryButtonBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource SurfaceBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="Padding" Value="12,8" />
<Setter Property="Opacity" Value="0" />
<Setter Property="BoxShadow" Value="0 6 12 -4 #33000000, 0 2 4 -2 #26000000" />
<Setter Property="Transitions">
<Transitions>
<DoubleTransition Property="Opacity" Duration="0:0:0.18" />
</Transitions>
</Setter>
</Style>
</Window.Styles>
<Grid RowDefinitions="Auto,Auto,*">
@ -1491,5 +1506,20 @@
</StackPanel>
</Border>
</Grid>
<Border x:Name="CopyToastBorder"
Grid.Row="0"
Grid.RowSpan="3"
Classes="copyToast"
IsVisible="False"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Margin="0,0,20,20"
ZIndex="2000">
<TextBlock x:Name="CopyToastTextBlock"
Foreground="{DynamicResource PrimaryButtonForegroundBrush}"
FontWeight="SemiBold"
Text="Copied to clipboard" />
</Border>
</Grid>
</Window>

View File

@ -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)
{

View File

@ -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: