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
3 changes: 2 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,8 @@ way to draw (`WindowsRendering`): `dcomp` (skip WinUI composition), `redirection
surface) or `software` (no GPU); unset or `auto` keeps the defaults. Filee reads it at start. On ARM64, Avalonia's
Adreno blocklist draws on the CPU through the Microsoft Basic Render Driver (issue #236), and three values try the GPU
there again: `gpu` (Direct3D on the first adapter that isn't the Basic Render Driver), `vulkan` and `wgl` (OpenGL), the
last two falling back to software when they can't start.
last two falling back to software when they can't start. On Linux (X11) only `software` applies: the GPU's driver stays
in memory for as long as Filee runs, also with no window open, so this is the way to a smaller tray app (issue #957).

## Explorer integration

Expand Down
5 changes: 5 additions & 0 deletions src/Filee.App/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ private void Start(IClassicDesktopStyleApplicationLifetime desktop)
log.LogInformation("{Rendering}", WindowsRendering.Describe(Environment.GetEnvironmentVariable(WindowsRendering.Variable)));
TransparentWindows.Watch(log);
}
else if (Program.LinuxDrawsInSoftware)
log.LogInformation("Rendering: software ({Variable})", WindowsRendering.Variable);
// macOS: ask for Accessibility with the system's own alert when the user opened Filee (not at sign-in) and
// a drag gesture is on, once ever (see ShouldAskForPermission). The home page keeps a reminder until it is
// given. Asked once the main window is up: asked earlier, the alert ends up behind it.
Expand Down Expand Up @@ -213,6 +215,9 @@ private void Start(IClassicDesktopStyleApplicationLifetime desktop)
});
};
WatchForUpdates(store);
// Started to the tray (at sign-in): no job or window would ask for a trim, however long Filee waits there (#957).
if (AppHost.Get<WindowService>().Main is null)
AppHost.Get<MemoryTrimmer>().Request();
// Filee runs as administrator while Explorer doesn't (an installer started "as administrator" started it):
// say so once, next to the tray, and on the Home page for as long as it lasts.
if (AppHost.Get<ElevationService>().ShellCannotReachFilee)
Expand Down
104 changes: 104 additions & 0 deletions src/Filee.App/FileeFontCollection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// The embedded Noto fonts (build/fetch-fonts.ps1), each family read when text first asks for it (issue #957). Avalonia's
// EmbeddedFontCollection opens every file at the start, and SkiaSharp copies a font it opens from a stream into native
// memory for good. So all 18 files (37 MB) stayed in memory on top of the pages of Filee.dll that the copying read, also
// in a Filee started to the tray that has drawn no text yet. Since a letter is only matched in the family asked for, the
// families after the one that has it are never asked for: Noto Sans SC stays unread until a chain reaches it.

using System.Collections;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using Avalonia.Media;
using Avalonia.Media.Fonts;
using Avalonia.Platform;

namespace Filee.App;

/// <summary>
/// Embedded Noto fonts: <c>fonts:Filee#Noto Sans</c>, <c>#Noto Sans JP</c>, <c>#Noto Sans KR</c>,
/// <c>#Noto Sans SC</c>, <c>#Noto Sans Devanagari</c>, <c>#Noto Sans Arabic</c>, <c>#Noto Sans Thai</c>.
/// <para>
/// A letter is only matched in the family asked for, so the order of SoftPalette's font chain decides (#1095). Avalonia's
/// own collection, asked for Noto Sans about a Han character, answers with the first family of its own that has it,
/// Noto Sans JP, before the font manager reaches the chain's next family: every language drew Han in Japanese shapes,
/// up to the first character that font lacks. Text has no culture to go by either, as TextBlock passes none. It takes
/// this wrapper rather than a subclass: the font manager skips the virtual method on Avalonia's collections.
/// </para>
/// </summary>
internal sealed class FileeFontCollection : IFontCollection
{
internal static readonly Uri Source = new("avares://Filee/Assets/Fonts", UriKind.Absolute);

private readonly ReadFonts _fonts;
private readonly Lock _lock = new();
private readonly HashSet<string> _asked = new(StringComparer.OrdinalIgnoreCase);

internal FileeFontCollection(string key = "fonts:Filee") => _fonts = new ReadFonts(new Uri(key, UriKind.Absolute));

public Uri Key => _fonts.Key;

/// <summary>The families read so far.</summary>
public int Count => _fonts.Count;

public FontFamily this[int index] => _fonts[index];

public bool TryMatchCharacter(int codepoint, FontStyle fontStyle, FontWeight fontWeight, FontStretch fontStretch,
string? familyName, CultureInfo? culture, out Typeface typeface)
{
typeface = default;
// No culture from here on: the family is settled, and one would send it looking elsewhere again.
return familyName is not null
&& TryGetGlyphTypeface(familyName, fontStyle, fontWeight, fontStretch, out var face)
&& face.CharacterToGlyphMap.TryGetGlyph(codepoint, out _)
&& _fonts.TryMatchCharacter(codepoint, fontStyle, fontWeight, fontStretch, familyName, null, out typeface);
}

public bool TryGetGlyphTypeface(string familyName, FontStyle style, FontWeight weight, FontStretch stretch,
[NotNullWhen(true)] out GlyphTypeface? glyphTypeface)
{
ReadFamily(familyName);
return _fonts.TryGetGlyphTypeface(familyName, style, weight, stretch, out glyphTypeface);
}

public bool TryGetFamilyTypefaces(string familyName, [NotNullWhen(true)] out IReadOnlyList<Typeface>? familyTypefaces)
{
ReadFamily(familyName);
return _fonts.TryGetFamilyTypefaces(familyName, out familyTypefaces);
}

public bool TryCreateSyntheticGlyphTypeface(GlyphTypeface glyphTypeface, FontStyle style, FontWeight weight, FontStretch stretch,
[NotNullWhen(true)] out GlyphTypeface? syntheticGlyphTypeface) =>
_fonts.TryCreateSyntheticGlyphTypeface(glyphTypeface, style, weight, stretch, out syntheticGlyphTypeface);

public bool TryGetNearestMatch(string familyName, FontStyle style, FontWeight weight, FontStretch stretch,
[NotNullWhen(true)] out GlyphTypeface? glyphTypeface)
{
ReadFamily(familyName);
return _fonts.TryGetNearestMatch(familyName, style, weight, stretch, out glyphTypeface);
}

public IEnumerator<FontFamily> GetEnumerator() => _fonts.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
public void Dispose() => ((IDisposable)_fonts).Dispose();

/// <summary>"NotoSansKR-Bold.otf" belongs to "Noto Sans KR", and not to "Noto Sans".</summary>
internal static bool IsFileOf(string familyName, string fileName) =>
fileName.StartsWith(familyName.Replace(" ", "", StringComparison.Ordinal) + "-", StringComparison.OrdinalIgnoreCase);

private void ReadFamily(string familyName)
{
lock (_lock)
{
if (!_asked.Add(familyName))
return;
// None when the fonts weren't fetched: the family isn't found, and the chain goes on to the system's fonts.
foreach (var asset in AssetLoader.GetAssets(Source, null))
if (IsFileOf(familyName, Path.GetFileName(asset.AbsolutePath)))
_fonts.TryAddFontSource(asset);
}
}

private sealed class ReadFonts(Uri key) : FontCollectionBase
{
public override Uri Key => key;
}
}
62 changes: 7 additions & 55 deletions src/Filee.App/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,9 @@
// 3. Start Avalonia. The app lives in the tray, so it only exits via the tray "Quit" command (or --quit).

using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Media.Fonts;
using Filee.App.Services;
using Filee.Core.Platform;
using Filee.Core.Settings;
Expand Down Expand Up @@ -293,6 +290,10 @@ public static AppBuilder BuildAvaloniaApp()
WindowsRendering.Normalize(Environment.GetEnvironmentVariable(WindowsRendering.Variable)),
adapters => Log.LogInformation("Graphics adapters: {Adapters}", adapters)));

// The GPU's driver stays in memory for as long as Filee runs, also with no window open (#957).
if (LinuxDrawsInSoftware)
builder = builder.With(new X11PlatformOptions { RenderingMode = [X11RenderingMode.Software] });

builder = builder
.ConfigureFonts(fonts => fonts.AddFontCollection(new FileeFontCollection()))
#if DEBUG
Expand All @@ -303,57 +304,8 @@ public static AppBuilder BuildAvaloniaApp()
AvaloniaLogForwarder.Install(Log);
return builder;
}
}

/// <summary>
/// Embedded Noto fonts: <c>fonts:Filee#Noto Sans</c>, <c>#Noto Sans JP</c>, <c>#Noto Sans KR</c>,
/// <c>#Noto Sans SC</c>, <c>#Noto Sans Devanagari</c>, <c>#Noto Sans Arabic</c>, <c>#Noto Sans Thai</c>.
/// <para>
/// A letter is only matched in the family asked for, so the order of SoftPalette's font chain decides (#1095). Avalonia's
/// own collection, asked for Noto Sans about a Han character, answers with the first family of its own that has it,
/// Noto Sans JP, before the font manager reaches the chain's next family: every language drew Han in Japanese shapes,
/// up to the first character that font lacks. Text has no culture to go by either, as TextBlock passes none. It takes
/// this wrapper rather than a subclass: the font manager skips the virtual method on Avalonia's collections.
/// </para>
/// </summary>
internal sealed class FileeFontCollection : IFontCollection
{
private readonly EmbeddedFontCollection _fonts = new(
new Uri("fonts:Filee", UriKind.Absolute),
new Uri("avares://Filee/Assets/Fonts", UriKind.Absolute));

public Uri Key => _fonts.Key;
public int Count => _fonts.Count;
public FontFamily this[int index] => _fonts[index];

public bool TryMatchCharacter(int codepoint, FontStyle fontStyle, FontWeight fontWeight, FontStretch fontStretch,
string? familyName, CultureInfo? culture, out Typeface typeface)
{
typeface = default;
// No culture from here on: the family is settled, and one would send it looking elsewhere again.
return familyName is not null
&& _fonts.TryGetGlyphTypeface(familyName, fontStyle, fontWeight, fontStretch, out var face)
&& face.CharacterToGlyphMap.TryGetGlyph(codepoint, out _)
&& _fonts.TryMatchCharacter(codepoint, fontStyle, fontWeight, fontStretch, familyName, null, out typeface);
}

public bool TryGetGlyphTypeface(string familyName, FontStyle style, FontWeight weight, FontStretch stretch,
[NotNullWhen(true)] out GlyphTypeface? glyphTypeface) =>
_fonts.TryGetGlyphTypeface(familyName, style, weight, stretch, out glyphTypeface);

public bool TryGetFamilyTypefaces(string familyName,
[NotNullWhen(true)] out IReadOnlyList<Typeface>? familyTypefaces) =>
_fonts.TryGetFamilyTypefaces(familyName, out familyTypefaces);

public bool TryCreateSyntheticGlyphTypeface(GlyphTypeface glyphTypeface, FontStyle style, FontWeight weight, FontStretch stretch,
[NotNullWhen(true)] out GlyphTypeface? syntheticGlyphTypeface) =>
_fonts.TryCreateSyntheticGlyphTypeface(glyphTypeface, style, weight, stretch, out syntheticGlyphTypeface);

public bool TryGetNearestMatch(string familyName, FontStyle style, FontWeight weight, FontStretch stretch,
[NotNullWhen(true)] out GlyphTypeface? glyphTypeface) =>
_fonts.TryGetNearestMatch(familyName, style, weight, stretch, out glyphTypeface);

public IEnumerator<FontFamily> GetEnumerator() => _fonts.GetEnumerator();
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
public void Dispose() => ((IDisposable)_fonts).Dispose();
/// <summary><c>FILEE_RENDERING=software</c> on Linux.</summary>
internal static bool LinuxDrawsInSoftware => OperatingSystem.IsLinux()
&& WindowsRendering.Normalize(Environment.GetEnvironmentVariable(WindowsRendering.Variable)) == "software";
}
46 changes: 34 additions & 12 deletions src/Filee.App/Services/MemoryTrimmer.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
// Gives memory back while Filee waits in the tray (issue #119): a while after the last conversion finished, and after
// the main window was released. Large images grow the managed heap and the engines' caches, and the garbage collector
// Gives memory back while Filee waits in the tray (issues #119, #957): a while after the last conversion finished, after
// the main window was released, and after a start without a window. Large images grow the managed heap and the engines' caches, and the garbage collector
// keeps that memory committed for the next allocation, so Filee stayed hundreds of MB above its idle size.

using System.Diagnostics;
using Filee.Core.Conversion;
using Filee.Engines.Infrastructure;
using Filee.Platform.Linux;
using Filee.Platform.Windows;
using Microsoft.Extensions.Logging;

Expand Down Expand Up @@ -74,36 +75,57 @@ private void OnQuiet()
Interlocked.Increment(ref _trims);
}

/// <summary>Drops the engines' caches, collects every generation, then trims the working set (Windows).</summary>
/// <summary>
/// Drops the engines' caches, collects every generation, then trims the working set (Windows) or gives malloc's free
/// memory back (Linux).
/// </summary>
internal static void Trim(ILogger log)
{
try
{
var watch = Stopwatch.StartNew();
var before = PrivateMegabytes();
var linuxBefore = ProcessMemoryUsage.Current();
EngineCaches.Release();
// Without compacting: garbage is freed and empty heap regions are decommitted all the same. Compaction was
// once blamed for a CI crash in Skia (sk_pngencoder_encode) after a trim; the cause was tests that let a
// captured frame be collected while Avalonia encoded it (WriteableBitmap.Save encodes a snapshot that
// doesn't own the pixels, and the finalizers this trim runs freed them). Nothing in Filee leaves native
// code with a pointer into managed memory: NativeMemoryStressTests converts under constant compaction.
GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, blocking: true, compacting: false);
GC.WaitForPendingFinalizers();
GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, blocking: true, compacting: false);
// Aggressive: a forced collection frees the garbage but keeps the empty heap regions committed for the next
// allocation (hundreds of MB after a large conversion), and only this one decommits them all. It compacts.
// Compaction was once blamed for a CI crash in Skia (sk_pngencoder_encode) after a trim; the cause was tests
// that let a captured frame be collected while Avalonia encoded it (WriteableBitmap.Save encodes a snapshot
// that doesn't own the pixels, and the finalizers this trim runs freed them). Nothing in Filee leaves native
// code with a pointer into managed memory: NativeMemoryStressTests converts under constant compaction.
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, blocking: true, compacting: true);
ProcessMemory.TrimWorkingSet();
log.LogInformation("Trimmed memory: private {Before} MB → {After} MB (managed heap {Heap} MB) in {Elapsed} ms",
before, PrivateMegabytes(), GC.GetTotalMemory(false) / (1024 * 1024), watch.ElapsedMilliseconds);
NativeHeap.Trim();
var committed = GC.GetGCMemoryInfo().TotalCommittedBytes / Megabyte;
if (linuxBefore is { } own && ProcessMemoryUsage.Current() is { } now)
{
// Windows' "private" means nothing here (see ProcessMemoryUsage). The split is what a report needs: what
// is the runtime's, what is malloc's, and what is left (mapped files, stacks, JIT code).
var malloc = NativeHeap.Usage();
log.LogInformation("Trimmed memory: own {Before} MB → {After} MB, resident {Resident} MB, anonymous {Anonymous} MB " +
"(managed heap {Heap} MB of {Committed} MB committed, malloc {MallocUsed} MB used, {MallocFree} MB free) in {Elapsed} ms",
own.OwnBytes / Megabyte, now.OwnBytes / Megabyte, now.ResidentBytes / Megabyte, now.AnonymousBytes / Megabyte,
GC.GetTotalMemory(false) / Megabyte, committed,
malloc?.InUseBytes / Megabyte, malloc?.FreeBytes / Megabyte, watch.ElapsedMilliseconds);
return;
}
log.LogInformation("Trimmed memory: private {Before} MB → {After} MB (managed heap {Heap} MB of {Committed} MB committed) in {Elapsed} ms",
before, PrivateMegabytes(), GC.GetTotalMemory(false) / Megabyte, committed, watch.ElapsedMilliseconds);
}
catch (Exception ex) when (ex is not OutOfMemoryException)
{
log.LogWarning(ex, "Could not trim memory");
}
}

private const long Megabyte = 1024 * 1024;

private static long PrivateMegabytes()
{
using var process = Process.GetCurrentProcess();
return process.PrivateMemorySize64 / (1024 * 1024);
return process.PrivateMemorySize64 / Megabyte;
}

public void Dispose()
Expand Down
3 changes: 3 additions & 0 deletions src/Filee.App/Services/WindowService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@ public void ShowImageEditWindow(IReadOnlyList<string> files, Preset preset)
{
if (_imageEdit == win)
_imageEdit = null;
trimmer.Request();
};
_imageEdit = win;
win.Show();
Expand Down Expand Up @@ -408,6 +409,7 @@ public void ShowCropOverlay(IReadOnlyList<string> files, Preset preset, PixelPoi
{
if (_cropOverlay == win)
_cropOverlay = null;
trimmer.Request();
};
_cropOverlay = win;
win.Show();
Expand All @@ -429,6 +431,7 @@ public void ShowPdfPageEditor(IReadOnlyList<string> files, Preset preset, bool a
{
if (_pdfPageEditor == win)
_pdfPageEditor = null;
trimmer.Request();
};
if (addPages)
win.Opened += async (_, _) => await win.PickAndAddAsync();
Expand Down
Loading
Loading