Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions PersonalResourceWorkspace.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@
<Project Path="tests/PersonalResourceWorkspace.Application.Tests/PersonalResourceWorkspace.Application.Tests.csproj" />
<Project Path="tests/PersonalResourceWorkspace.Domain.Tests/PersonalResourceWorkspace.Domain.Tests.csproj" />
<Project Path="tests/PersonalResourceWorkspace.Infrastructure.Tests/PersonalResourceWorkspace.Infrastructure.Tests.csproj" />
<Project Path="tests/PersonalResourceWorkspace.Windows.Tests/PersonalResourceWorkspace.Windows.Tests.csproj" />
</Folder>
</Solution>
2 changes: 1 addition & 1 deletion docs/adr/0002-sqlite-and-filesystem-storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@ The implemented schema stores resource visual mode, resolved kind, origin, sourc

Database and asset changes spanning both stores will require an explicit transaction/compensation strategy. Integration tests use isolated temporary roots. Backup and export can later include one database plus its asset tree without introducing cloud dependencies.

Remote web discovery is an optional, bounded enhancement: it uses no browser cookies, does not execute page scripts, and falls back to the type glyph when metadata, network access, or image decoding fails. User-selected assets always take precedence over automatic refresh.
Remote web discovery is an optional, bounded enhancement. The screenshot path opens the page in a temporary headless Edge profile and executes page scripts; metadata and favicon fallback uses HTTP without browser cookies. Capture, network, or image decoding failures fall back to another available visual or the type glyph. User-selected assets always take precedence over automatic refresh.
2 changes: 2 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,6 @@

Новая схема хранит ресурсы, тэги, связи ресурса с тэгами, сохранённые поиски и их обязательные тэги в отдельных таблицах с внешними ключами. При обнаружении старой схемы создаётся проверенная резервная копия `workspace.collections-backup-*.db`, затем новая пустая база. Переноса записей коллекций нет. / The new schema separates resources, tags, resource tags, saved searches, and required search tags. A legacy database is backed up before a fresh empty database is created; collection rows are not imported.

Для новой веб-ссылки автоматический визуал сначала пытается получить снимок страницы через установленный Microsoft Edge в headless режиме. Снимок ограничен областью 480×300 пикселей, сохраняется как JPEG размером не более 512 КБ и выполняется в фоне с ограничением времени. Если Edge отсутствует или снимок не получен, используются `og:image`, иконка сайта и затем значок типа. Уже сохранённый автоматический визуал остаётся в кэше до ручного обновления в редакторе. / For a new web link, the automatic visual first tries a headless Microsoft Edge screenshot. The 480×300 viewport is stored as a JPEG capped at 512 KB. If capture fails, the existing Open Graph image, site icon, and type-icon fallbacks apply. Previously cached visuals remain until manually refreshed in the editor.

Подробнее / Further reading: [project specification](../PROJECT.md), [portable storage decision](adr/0003-portable-data-and-settings.md), [architecture review](../architecture-review.md).
11 changes: 10 additions & 1 deletion src/PersonalResourceWorkspace.App/MainPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,12 @@
<Image Margin="{x:Bind VisualImageMargin, Mode=OneWay}" AutomationProperties.AccessibilityView="Raw" Source="{x:Bind VisualImage, Mode=OneWay}" Stretch="{x:Bind VisualStretch, Mode=OneWay}" Visibility="{x:Bind VisualImageVisibility, Mode=OneWay}" />
<TextBlock Margin="14" VerticalAlignment="Center" FontSize="13" Foreground="{ThemeResource TextOnAccentFillColorPrimaryBrush}" MaxLines="3" Text="{x:Bind VisualPreviewText, Mode=OneWay}" TextTrimming="CharacterEllipsis" TextWrapping="Wrap" Visibility="{x:Bind GeneratedPreviewVisibility, Mode=OneWay}" />
<FontIcon FontSize="34" Foreground="{ThemeResource TextOnAccentFillColorPrimaryBrush}" Glyph="{x:Bind Glyph}" Visibility="{x:Bind FallbackVisibility, Mode=OneWay}" />
<Border HorizontalAlignment="Center" VerticalAlignment="Bottom" Margin="8" Padding="8,5" Background="{ThemeResource CardBackgroundFillColorDefaultBrush}" CornerRadius="6" Visibility="{x:Bind VisualLoadingVisibility, Mode=OneWay}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ProgressRing Width="16" Height="16" IsActive="{x:Bind IsVisualLoading, Mode=OneWay}" />
<TextBlock VerticalAlignment="Center" FontSize="12" Text="Получаем превью…" />
</StackPanel>
</Border>
<Button HorizontalAlignment="Right" VerticalAlignment="Top" Margin="4" Click="DeleteResourceButton_Click" Tag="{Binding}" ToolTipService.ToolTip="Удалить ресурс" Visibility="{x:Bind CardDeleteVisibility, Mode=OneWay}">
<SymbolIcon Symbol="Delete" />
</Button>
Expand Down Expand Up @@ -248,7 +254,10 @@
<FontIcon FontSize="18" Foreground="{ThemeResource TextOnAccentFillColorPrimaryBrush}" Glyph="{x:Bind Glyph}" Visibility="{x:Bind FallbackVisibility, Mode=OneWay}" />
</Grid>
</Border>
<TextBlock Grid.Column="1" VerticalAlignment="Center" FontWeight="SemiBold" MaxLines="1" Text="{x:Bind Title}" TextTrimming="CharacterEllipsis" />
<StackPanel Grid.Column="1" VerticalAlignment="Center" Spacing="2">
<TextBlock FontWeight="SemiBold" MaxLines="1" Text="{x:Bind Title}" TextTrimming="CharacterEllipsis" />
<TextBlock FontSize="11" Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="Получаем превью…" Visibility="{x:Bind VisualLoadingVisibility, Mode=OneWay}" />
</StackPanel>
<TextBlock Grid.Column="2" VerticalAlignment="Center" Foreground="{ThemeResource TextFillColorSecondaryBrush}" MaxLines="1" Text="{x:Bind SourceOrType}" TextTrimming="CharacterEllipsis" Visibility="{x:Bind SourceColumnVisibility, Mode=OneWay}" />
<TextBlock Grid.Column="3" VerticalAlignment="Center" Foreground="{ThemeResource TextFillColorSecondaryBrush}" MaxLines="1" Text="{x:Bind TagsDisplay}" TextTrimming="CharacterEllipsis" Visibility="{x:Bind TagsColumnVisibility, Mode=OneWay}" />
<TextBlock Grid.Column="4" VerticalAlignment="Center" Foreground="{ThemeResource TextFillColorTertiaryBrush}" MaxLines="1" Text="{x:Bind Modified}" TextTrimming="CharacterEllipsis" Visibility="{x:Bind DateColumnVisibility, Mode=OneWay}" />
Expand Down
24 changes: 24 additions & 0 deletions src/PersonalResourceWorkspace.App/MainPage.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1201,6 +1201,7 @@ private async Task ResolveVisualsAsync(IEnumerable<WorkspaceResource> resources,

private async Task ResolveVisualAsync(WorkspaceResource resource, bool forceRefresh)
{
resource.IsVisualLoading = true;
try
{
var previousDescriptor = resource.Visual;
Expand Down Expand Up @@ -1236,6 +1237,10 @@ private async Task ResolveVisualAsync(WorkspaceResource resource, bool forceRefr
"Значок типа"),
null);
}
finally
{
resource.IsVisualLoading = false;
}
}

private static async Task<ImageSource> LoadImageSourceAsync(string path, CancellationToken cancellationToken)
Expand Down Expand Up @@ -1538,6 +1543,7 @@ public WorkspaceResource(

private ResourceVisualDescriptor _visual;
private ImageSource? _visualImage;
private bool _isVisualLoading;
private string _visualPreviewText = string.Empty;
private string _visualSourceLabel = "Значок типа";

Expand Down Expand Up @@ -1591,6 +1597,24 @@ private set
}
}

public bool IsVisualLoading
{
get => _isVisualLoading;
set
{
if (_isVisualLoading == value)
{
return;
}

_isVisualLoading = value;
OnPropertyChanged();
OnPropertyChanged(nameof(VisualLoadingVisibility));
}
}

public Visibility VisualLoadingVisibility => IsVisualLoading ? Visibility.Visible : Visibility.Collapsed;

public string VisualPreviewText
{
get => _visualPreviewText;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public enum ResourceVisualOrigin
RemoteMetadata,
Generated,
Fallback,
WebsiteScreenshot,
}

public sealed record ResourceVisualDescriptor(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
using System.Diagnostics;
using System.Net;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using PersonalResourceWorkspace.Application.Abstractions;
Expand All @@ -19,6 +20,8 @@ internal sealed class WindowsResourceVisualService : IResourceVisualService, IDi
private const long MaximumUserAssetBytes = 20 * 1024 * 1024;
private const int MaximumRemoteAssetBytes = 10 * 1024 * 1024;
private const int MaximumHtmlBytes = 512 * 1024;
private const int MaximumScreenshotBytes = 512 * 1024;
private static readonly SemaphoreSlim ScreenshotGate = new(2, 2);
private const string ShellVisualCacheVersion = "shell-visual-v3";
private const byte VisibleIconAlphaThreshold = 80;
private static readonly string[] SupportedExtensions =
Expand Down Expand Up @@ -69,7 +72,8 @@ public async Task<ResourceVisualResolution> ResolveAsync(
request.Descriptor.Mode == ResourceVisualMode.Auto &&
string.Equals(request.Descriptor.SourceFingerprint, fingerprint, StringComparison.Ordinal) &&
cachedPath is not null &&
File.Exists(cachedPath))
File.Exists(cachedPath) &&
await IsDecodableImageAsync(cachedPath, cancellationToken).ConfigureAwait(false))
{
return new ResourceVisualResolution(request.Descriptor, cachedPath, null, SourceLabel(request.Descriptor.Origin));
}
Expand Down Expand Up @@ -182,6 +186,11 @@ public async Task<ResourceVisualDescriptor> ImportUserAssetAsync(

var extension = Path.GetExtension(address).ToLowerInvariant();
var destination = CachePath(fingerprint, extension);
if (File.Exists(destination) &&
!await IsDecodableImageAsync(destination, cancellationToken).ConfigureAwait(false))
{
File.Delete(destination);
}
await CopyIfMissingAsync(address, destination, cancellationToken).ConfigureAwait(false);
return Resolution(destination, ResourceVisualKind.Image, ResourceVisualOrigin.Payload, fingerprint, "Изображение ресурса");
}
Expand Down Expand Up @@ -345,6 +354,12 @@ pageUri.Scheme is not ("http" or "https"))
}

var candidates = new List<(Uri Uri, bool IsCover)>();
var screenshot = await ResolveWebScreenshotAsync(pageUri, fingerprint, cancellationToken).ConfigureAwait(false);
if (screenshot is not null)
{
return screenshot;
}

try
{
var html = await DownloadHtmlAsync(pageUri, cancellationToken).ConfigureAwait(false);
Expand Down Expand Up @@ -395,6 +410,154 @@ pageUri.Scheme is not ("http" or "https"))
return null;
}

private async Task<ResourceVisualResolution?> ResolveWebScreenshotAsync(
Uri pageUri,
string fingerprint,
CancellationToken cancellationToken)
{
await ScreenshotGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var edgePath = FindEdgePath();
if (edgePath is null)
{
return null;
}

var temporaryRoot = Path.Combine(GetVisualRoot(), "browser-temp", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(temporaryRoot);
try
{
var screenshotPath = Path.Combine(temporaryRoot, "screenshot.png");
var profilePath = Path.Combine(temporaryRoot, "profile");
var startInfo = new ProcessStartInfo(edgePath)
{
UseShellExecute = false,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden,
WorkingDirectory = temporaryRoot,
};
startInfo.ArgumentList.Add("--headless");
startInfo.ArgumentList.Add("--disable-gpu");
startInfo.ArgumentList.Add("--no-first-run");
startInfo.ArgumentList.Add("--hide-scrollbars");
startInfo.ArgumentList.Add("--window-size=480,300");
startInfo.ArgumentList.Add("--timeout=8000");
startInfo.ArgumentList.Add($"--user-data-dir={profilePath}");
startInfo.ArgumentList.Add($"--screenshot={screenshotPath}");
startInfo.ArgumentList.Add(pageUri.AbsoluteUri);

using var process = Process.Start(startInfo);
if (process is null)
{
return null;
}

try
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(18));
await process.WaitForExitAsync(timeout.Token).ConfigureAwait(false);
}
finally
{
if (!process.HasExited)
{
process.Kill(entireProcessTree: true);
await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false);
}
}

if (process.ExitCode != 0 || !File.Exists(screenshotPath))
{
return null;
}

var bytes = await EncodeSmallJpegAsync(screenshotPath, cancellationToken).ConfigureAwait(false);
if (bytes.Length is 0 or > MaximumScreenshotBytes)
{
return null;
}

var destination = CachePath(fingerprint, ".jpg");
await WriteValidatedImageAsync(destination, bytes, cancellationToken).ConfigureAwait(false);
return Resolution(destination, ResourceVisualKind.Image, ResourceVisualOrigin.WebsiteScreenshot,
fingerprint, "Скриншот сайта");
}
finally
{
try { Directory.Delete(temporaryRoot, recursive: true); }
catch (IOException) { }
catch (UnauthorizedAccessException) { }
}
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return null;
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or COMException or InvalidDataException or InvalidOperationException or System.ComponentModel.Win32Exception)
{
return null;
}
finally
{
ScreenshotGate.Release();
}
}

private static string? FindEdgePath()
{
string?[] candidates =
[
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "Microsoft", "Edge", "Application", "msedge.exe"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Microsoft", "Edge", "Application", "msedge.exe"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Microsoft", "Edge", "Application", "msedge.exe"),
];
return candidates.FirstOrDefault(static path => path is not null && File.Exists(path));
}

private static async Task<byte[]> EncodeSmallJpegAsync(string screenshotPath, CancellationToken cancellationToken)
{
if (new FileInfo(screenshotPath).Length > 5 * 1024 * 1024)
{
return [];
}

var file = await StorageFile.GetFileFromPathAsync(screenshotPath);
using var input = await file.OpenReadAsync();
var decoder = await BitmapDecoder.CreateAsync(input);
if (decoder.PixelWidth > 1600 || decoder.PixelHeight > 1000)
{
return [];
}

var scale = Math.Min(1d, Math.Min(480d / decoder.PixelWidth, 300d / decoder.PixelHeight));
var width = (uint)Math.Max(1, Math.Round(decoder.PixelWidth * scale));
var height = (uint)Math.Max(1, Math.Round(decoder.PixelHeight * scale));
var pixels = await decoder.GetPixelDataAsync(
BitmapPixelFormat.Bgra8,
BitmapAlphaMode.Ignore,
new BitmapTransform { ScaledWidth = width, ScaledHeight = height },
ExifOrientationMode.IgnoreExifOrientation,
ColorManagementMode.ColorManageToSRgb);
cancellationToken.ThrowIfCancellationRequested();
using var output = new InMemoryRandomAccessStream();
var encoder = await BitmapEncoder.CreateAsync(BitmapEncoder.JpegEncoderId, output);
encoder.SetPixelData(BitmapPixelFormat.Bgra8, BitmapAlphaMode.Ignore, width, height,
decoder.DpiX, decoder.DpiY, pixels.DetachPixelData());
await encoder.FlushAsync();
if (output.Size > MaximumScreenshotBytes)
{
return [];
}

using var reader = new DataReader(output.GetInputStreamAt(0));
var loaded = await reader.LoadAsync((uint)output.Size);
var bytes = new byte[loaded];
reader.ReadBytes(bytes);
return bytes;
}

private async Task<string?> DownloadHtmlAsync(Uri uri, CancellationToken cancellationToken)
{
using var response = await _httpClient.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
Expand Down Expand Up @@ -484,7 +647,7 @@ candidate.Scheme is not ("http" or "https"))
private static async Task WriteValidatedImageAsync(string destination, byte[] bytes, CancellationToken cancellationToken)
{
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
var temporary = destination + $".{Guid.NewGuid():N}.tmp";
var temporary = Path.Combine(Path.GetDirectoryName(destination)!, $"{Guid.NewGuid():N}.tmp");
try
{
await File.WriteAllBytesAsync(temporary, bytes, cancellationToken).ConfigureAwait(false);
Expand Down Expand Up @@ -680,6 +843,7 @@ private static string HashText(string value) =>
ResourceVisualOrigin.Payload => "Изображение ресурса",
ResourceVisualOrigin.WindowsShell => "Значок Windows",
ResourceVisualOrigin.RemoteMetadata => "Изображение сайта",
ResourceVisualOrigin.WebsiteScreenshot => "Скриншот сайта",
ResourceVisualOrigin.Generated => "Предпросмотр",
_ => "Значок типа",
};
Expand Down
Loading
Loading