BASE changed interface

This commit is contained in:
AlastorFox committed 2026-10-01 01:46:18 +03:00
commit fd6bc3459f
33 files changed
+3047

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
.vs/
bin/
obj/
*.user
*.suo
+140
View File
@@ -0,0 +1,140 @@
УВАГА: Термінал агента не працює. Не виконуй жодних команд (dotnet build,
dotnet restore, dotnet run, встановлення NuGet-пакетів тощо) самостійно.
Якщо для задачі щось треба встановити, оновити чи запустити — напиши це
явним окремим пунктом "Потрібно виконати вручну:" в кінці відповіді для
цієї задачі, і не намагайся обійти це через bash/термінал.
Реалізуй дві задачі в FoxBrowser. Роби послідовно, кожну — окремим описом,
що змінено і як перевірити вручну (агент працює в Linux sandbox без
dotnet/WPF runtime — фінальний тест виконує користувач на Windows).
## 1. Діагностика й виправлення кнопки "Розширення"
Кнопка налаштувань розширень (`ExtensionsSettingsWindow`) працює
неправильно. Перевір по черзі всі гіпотези нижче, знайди реальну причину
в коді (не вигадуй, яка з них правильна, — прочитай файли й перевір),
і виправ саме те, що підтвердиться. Якщо підтвердиться кілька — виправ усі.
### 1a. Recovery-fallback environment без AreBrowserExtensionsEnabled
- Знайти в `MainWindow`/`TabManager` весь код навколо recovery-гілки з
`NEXT_AGENT_GUIDE`/інструкції ("Startup recovery fix — 0x8007139F") —
місце, де при HRESULT `0x8007139F` створюється запасний
`CoreWebView2Environment` з тимчасовою user-data folder.
- Перевірити: чи цей запасний environment створюється через ту саму
функцію/той самий `CoreWebView2EnvironmentOptions` з
`AreBrowserExtensionsEnabled = true`, що й основний, чи через окремий
шматок коду, де цей прапорець забули виставити.
- Якщо прапорець не потрапляє в recovery-гілку — винести створення
`CoreWebView2EnvironmentOptions` в один спільний метод/константу, яку
використовують і основний, і recovery шлях, щоб розбіжність стала
неможливою в принципі, а не просто додати прапорець ще раз у другому місці.
- Додати явний лог/статус (напр. поле `bool ExtensionsAvailable` на
TabManager чи MainWindow), який ExtensionsSettingsWindow може прочитати
при відкритті — і якщо `false` (бо стартанули в recovery-режимі),
показати користувачу зрозуміле повідомлення в самому вікні
("Розширення недоступні в цьому сеансі — застосунок запустився в
режимі відновлення профілю, перезапусти FoxBrowser"), а не порожній
список без пояснень.
### 1b. IsEnabled read-only і биндинг чекбокса
- Знайти XAML розмітку списку розширень в `ExtensionsSettingsWindow.xaml`.
- Перевірити: чи `IsChecked` чекбокса прив'язаний напряму двостороннім
біндингом до `Extension.IsEnabled` (read-only властивість), чи через
`Checked`/`Unchecked` event-обробник (або Command), що явно викликає
`await extension.EnableAsync(bool)`.
- Якщо це прямий `TwoWay`-біндинг на read-only властивість — замінити на
`OneWay`-біндинг для відображення поточного стану + окремий
`Click`/`Checked`/`Unchecked` обробник, який викликає `EnableAsync()`,
чекає результат, і тільки після успіху оновлює UI-стан (checkbox),
а при помилці — повертає checkbox у попередній стан і показує повідомлення.
### 1c. FolderBrowserDialog без owner-вікна
- Знайти виклик `FolderBrowserDialog`/`OpenFileDialog` для "Додати
розширення" в `ExtensionsSettingsWindow.xaml.cs`.
- Перевірити, чи передається owner-handle:
`new System.Windows.Interop.WindowInteropHelper(this).Handle`
через `IWin32Window`-обгортку (WinForms-діалог вимагає `IWin32Window`,
не голий `IntPtr` — якщо ще нема обгортки-хелпера, додати простий
`NativeWindowWrapper : IWin32Window`).
- Якщо owner не передається — додати, щоб діалог відкривався поверх
`ExtensionsSettingsWindow`, а не міг ховатись позаду.
### 1d. Options-сторінка розширення через спільний WebView2
- Знайти обробник кнопки "Налаштування" (per-extension), що відкриває
`chrome-extension://<id>/options.html`.
- Перевірити: чи ця навігація йде через ТОЙ САМИЙ спільний `CoreWebView2`,
яким керує `TabManager` для звичайних вкладок, чи через окремий
`CoreWebView2Controller`, створений безпосередньо в
`ExtensionsSettingsWindow`.
- Якщо використовується спільний контрол — це помилка архітектури: клік
по "Налаштування" в діалозі може непомітно перекидати активну вкладку
користувача в головному вікні. Виправити: створити в
`ExtensionsSettingsWindow` власний ізольований `CoreWebView2Controller`
(з того самого `CoreWebView2Environment`, щоб профіль/розширення
збігались) — тільки для options-сторінок, ніколи не займаючи головний
спільний WebView2.
Після кожного виправленого пункту (1a-1d) — короткий підсумок і ручний
тест-сценарій для Windows.
## 2. Звук WebView2 у Volume Mixer як "FoxBrowser"
Додати `Core/AudioSessionLabeler.cs`, який перейменовує WASAPI
audio-сесії власних дочірніх WebView2-процесів на "FoxBrowser" з іконкою
застосунку, БЕЗ ризику торкнутись чужих WebView2-сесій (Teams, Outlook,
системний Edge).
- Додати NuGet-пакет `NAudio` (зазнач це явно в "Потрібно виконати
вручну" — сам пакет не встановлюй, тільки допиши `<PackageReference>`
в `.csproj` вручну і попроси користувача зробити `dotnet restore`).
- Клас має:
1. Відстежувати список PID усіх дочірніх `msedgewebview2.exe`-процесів,
які належать саме FoxBrowser (ті самі, що вже прив'язані через
Job Object з попередньої задачі про дерево процесів —
переюзати ту логіку, не дублювати).
2. Через `NAudio.CoreAudioApi.MMDeviceEnumerator` отримати default
render endpoint (`eRender`, `eMultimedia`), і його `AudioSessionManager`.
3. Підписатись на подію нової сесії (`AudioSessionManager.SessionCreated`
в NAudio, або еквівалент `IAudioSessionNotification.OnSessionCreated`
якщо в NAudio такого немає в потрібній версії — перевір API
конкретної версії пакету, яку додаси).
4. У обробнику нової сесії: якщо `session.GetProcessID` входить у
список PID FoxBrowser — викликати
`session.DisplayName = "FoxBrowser"` і
`session.IconPath = Path.Combine(AppContext.BaseDirectory, "foxbrowser.ico")`
(або еквівалентні NAudio-методи `SetDisplayNameAsync`/властивості —
перевір актуальний API пакету).
5. **Критично:** ці властивості НЕ переживають життя об'єкта сесії
(задокументована поведінка WASAPI) — тому одноразового виклику при
старті недостатньо. Логіка з пункту 3-4 має спрацьовувати на КОЖНУ
нову сесію протягом усього часу роботи FoxBrowser, не тільки на старті.
6. НІКОЛИ не застосовувати `DisplayName`/`IconPath` до сесії, чий PID
не входить у явно відстежуваний список власних дочірніх процесів —
це і є захист від зачіпання чужих `msedgewebview2.exe` на машині
користувача.
- Покласти файл `foxbrowser.ico` в корінь проєкту поруч з іншими іконками
(якщо такого файлу ще нема в репозиторії — створи заглушку-примітивну
іконку і явно напиши користувачу в "Потрібно виконати вручну", що варто
замінити на нормальну).
- Викликати ініціалізацію `AudioSessionLabeler` один раз при старті
застосунку (напр. в `MainWindow` після успішної ініціалізації
`TabManager`, коли вже відомий перший дочірній PID).
Ручний тест Windows:
1. Запустити FoxBrowser, відкрити вкладку з YouTube, почати відтворення.
2. Відкрити Volume Mixer (правий клік на іконку гучності в треї Windows →
Open Volume mixer, або через Settings → System → Sound → Volume mixer).
3. Перевірити, що застосунок у списку показаний як "FoxBrowser" з іконкою
застосунку, а не як "Microsoft Edge WebView2"/msedgewebview2.exe.
4. Переключитись на іншу вкладку, знову увімкнути звук на новій —
перевірити, що лейбл лишається "FoxBrowser" і для нової audio-сесії
(не тільки для першої).
5. Паралельно запустити щось інше з WebView2 на цій же машині (напр.
Microsoft Teams, якщо є), перевірити, що ЙОГО лейбл у Volume Mixer
НЕ змінився і лишився своїм — це підтверджує, що фільтр по PID працює
правильно й не займає чужі процеси.
+142
View File
@@ -0,0 +1,142 @@
<Application x:Class="FoxBrowser.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
StartupUri="MainWindow.xaml">
<Application.Resources>
<!-- FoxBrowser visual system -->
<SolidColorBrush x:Key="WindowBg" Color="#0F1217" />
<SolidColorBrush x:Key="HeaderBg" Color="#171B22" />
<SolidColorBrush x:Key="HeaderBgElevated" Color="#1D222B" />
<SolidColorBrush x:Key="CardBg" Color="#202630" />
<SolidColorBrush x:Key="CardBgHover" Color="#28303C" />
<SolidColorBrush x:Key="BorderDark" Color="#29313C" />
<SolidColorBrush x:Key="BorderMedium" Color="#3B4655" />
<SolidColorBrush x:Key="AurBlue" Color="#78AFFF" />
<SolidColorBrush x:Key="AurBlueSoft" Color="#243B62" />
<SolidColorBrush x:Key="TextPrimary" Color="#F5F7FB" />
<SolidColorBrush x:Key="TextSecondary" Color="#B7C2D2" />
<SolidColorBrush x:Key="TextMuted" Color="#7E8A9C" />
<SolidColorBrush x:Key="DangerBg" Color="#4B252D" />
<SolidColorBrush x:Key="DangerText" Color="#FF9BA8" />
<Style TargetType="Button">
<Setter Property="Background" Value="#242B35" />
<Setter Property="Foreground" Value="{StaticResource TextPrimary}" />
<Setter Property="BorderBrush" Value="#35404E" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="10,6" />
<Setter Property="Margin" Value="2,0" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="FontFamily" Value="Segoe UI" />
<Setter Property="FontSize" Value="12" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="ButtonBorder"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="7"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center"
RecognizesAccessKey="True" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="ButtonBorder" Property="Background" Value="{StaticResource CardBgHover}" />
<Setter TargetName="ButtonBorder" Property="BorderBrush" Value="#536174" />
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="ButtonBorder" Property="Background" Value="#1B222C" />
<Setter TargetName="ButtonBorder" Property="BorderBrush" Value="{StaticResource AurBlue}" />
</Trigger>
<Trigger Property="IsKeyboardFocused" Value="True">
<Setter TargetName="ButtonBorder" Property="BorderBrush" Value="{StaticResource AurBlue}" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="ButtonBorder" Property="Opacity" Value="0.45" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="IconButton" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
<Setter Property="Width" Value="32" />
<Setter Property="Height" Value="32" />
<Setter Property="Padding" Value="0" />
<Setter Property="FontSize" Value="16" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="{StaticResource TextSecondary}" />
</Style>
<Style x:Key="PrimaryButton" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
<Setter Property="Background" Value="{StaticResource AurBlue}" />
<Setter Property="Foreground" Value="#0B1220" />
<Setter Property="BorderBrush" Value="{StaticResource AurBlue}" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Padding" Value="14,6" />
</Style>
<Style TargetType="TextBox">
<Setter Property="Background" Value="#12171E" />
<Setter Property="Foreground" Value="{StaticResource TextPrimary}" />
<Setter Property="BorderBrush" Value="#303A49" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="12,6" />
<Setter Property="FontFamily" Value="Segoe UI" />
<Setter Property="FontSize" Value="13" />
<Setter Property="CaretBrush" Value="{StaticResource AurBlue}" />
<Setter Property="SelectionBrush" Value="#5078AFFF" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TextBox">
<Border x:Name="TextBorder"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="8">
<ScrollViewer x:Name="PART_ContentHost"
Focusable="False"
HorizontalScrollBarVisibility="Hidden"
VerticalScrollBarVisibility="Hidden" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="TextBorder" Property="BorderBrush" Value="#536174" />
</Trigger>
<Trigger Property="IsFocused" Value="True">
<Setter TargetName="TextBorder" Property="BorderBrush" Value="{StaticResource AurBlue}" />
<Setter TargetName="TextBorder" Property="Background" Value="#161D27" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="ToolTip">
<Setter Property="Background" Value="#1D232C" />
<Setter Property="Foreground" Value="{StaticResource TextPrimary}" />
<Setter Property="BorderBrush" Value="#3B4655" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="9,5" />
<Setter Property="FontSize" Value="12" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ToolTip">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="6"
Padding="{TemplateBinding Padding}">
<ContentPresenter />
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Application.Resources>
</Application>
+12
View File
@@ -0,0 +1,12 @@
using System.Windows;
namespace FoxBrowser;
public partial class App : System.Windows.Application
{
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
ShutdownMode = ShutdownMode.OnMainWindowClose;
}
}
+161
View File
@@ -0,0 +1,161 @@
using System.IO;
using System.Runtime.InteropServices;
using NAudio.CoreAudioApi;
using NAudio.CoreAudioApi.Interfaces;
namespace FoxBrowser.Core;
/// <summary>
/// Labels only audio sessions owned by FoxBrowser's descendant WebView2 processes.
/// It never changes sessions belonging to unrelated WebView2 applications.
/// </summary>
public sealed class AudioSessionLabeler : IDisposable
{
private const uint SnapshotProcess = 0x00000002;
private readonly uint _rootProcessId;
private readonly string _iconPath;
private readonly MMDeviceEnumerator _devices = new();
private readonly AudioSessionManager _sessions;
private readonly System.Threading.Timer _refreshTimer;
private readonly object _gate = new();
private bool _disposed;
public AudioSessionLabeler(uint rootProcessId, string iconPath)
{
_rootProcessId = rootProcessId;
_iconPath = iconPath;
var device = _devices.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia);
_sessions = device.AudioSessionManager;
_sessions.OnSessionCreated += OnSessionCreated;
_refreshTimer = new System.Threading.Timer(_ => RefreshAndLabel(), null, TimeSpan.Zero, TimeSpan.FromSeconds(2));
}
private void OnSessionCreated(object sender, IAudioSessionControl nativeSession)
{
LabelSession(new AudioSessionControl(nativeSession), disposeAfter: true);
}
private void RefreshAndLabel()
{
if (_disposed) return;
try
{
var ownedPids = GetFoxBrowserWebViewPids();
var sessionCollection = _sessions.Sessions;
if (sessionCollection is null) return;
for (var index = 0; index < sessionCollection.Count; index++)
{
var session = sessionCollection[index];
if (session is null) continue;
try
{
if (ownedPids.Contains(session.GetProcessID))
LabelSession(session, ownedPids, disposeAfter: false);
}
finally { session.Dispose(); }
}
}
catch { /* Audio devices can disappear or be replaced while Windows is running. */ }
}
private void LabelSession(AudioSessionControl session, HashSet<uint>? knownOwnedPids = null, bool disposeAfter = false)
{
try
{
var ownedPids = knownOwnedPids ?? GetFoxBrowserWebViewPids();
if (!ownedPids.Contains(session.GetProcessID)) return;
session.DisplayName = "FoxBrowser";
if (File.Exists(_iconPath)) session.IconPath = _iconPath;
}
catch { /* A session may close between PID lookup and property update. */ }
finally { if (disposeAfter) session.Dispose(); }
}
private HashSet<uint> GetFoxBrowserWebViewPids()
{
var processes = ReadProcesses();
var children = new Dictionary<uint, List<ProcessRecord>>();
foreach (var process in processes)
{
if (!children.TryGetValue(process.ParentId, out var list))
children[process.ParentId] = list = new List<ProcessRecord>();
list.Add(process);
}
var result = new HashSet<uint>();
var pending = new Queue<uint>(new[] { _rootProcessId });
while (pending.Count > 0)
{
var parent = pending.Dequeue();
if (!children.TryGetValue(parent, out var descendants)) continue;
foreach (var child in descendants)
{
pending.Enqueue(child.ProcessId);
if (child.Name.Equals("msedgewebview2.exe", StringComparison.OrdinalIgnoreCase))
result.Add(child.ProcessId);
}
}
return result;
}
private static List<ProcessRecord> ReadProcesses()
{
var result = new List<ProcessRecord>();
var snapshot = CreateToolhelp32Snapshot(SnapshotProcess, 0);
if (snapshot == IntPtr.Zero || snapshot == new IntPtr(-1)) return result;
try
{
var entry = new ProcessEntry32 { Size = (uint)Marshal.SizeOf<ProcessEntry32>() };
if (!Process32First(snapshot, ref entry)) return result;
do
{
result.Add(new ProcessRecord(entry.ProcessId, entry.ParentProcessId, entry.ExeFile));
entry.Size = (uint)Marshal.SizeOf<ProcessEntry32>();
} while (Process32Next(snapshot, ref entry));
}
finally { CloseHandle(snapshot); }
return result;
}
public void Dispose()
{
lock (_gate)
{
if (_disposed) return;
_disposed = true;
_refreshTimer.Dispose();
_sessions.OnSessionCreated -= OnSessionCreated;
_sessions.Dispose();
_devices.Dispose();
}
}
private readonly record struct ProcessRecord(uint ProcessId, uint ParentId, string Name);
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct ProcessEntry32
{
public uint Size;
public uint Usage;
public uint ProcessId;
public IntPtr DefaultHeapId;
public uint ModuleId;
public uint Threads;
public uint ParentProcessId;
public int BasePriority;
public uint Flags;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string ExeFile;
}
[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr CreateToolhelp32Snapshot(uint flags, uint processId);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool Process32First(IntPtr snapshot, ref ProcessEntry32 entry);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool Process32Next(IntPtr snapshot, ref ProcessEntry32 entry);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool CloseHandle(IntPtr handle);
}
+193
View File
@@ -0,0 +1,193 @@
using System.Collections.ObjectModel;
using System.IO;
using System.Text.Json;
using Microsoft.Web.WebView2.Core;
namespace FoxBrowser.Core;
public sealed class PinnedExtensionItem
{
public required CoreWebView2BrowserExtension Extension { get; init; }
public required string Name { get; init; }
public string Glyph => string.IsNullOrWhiteSpace(Name) ? "🧩" : Name[..1].ToUpperInvariant();
public string? IconPath { get; init; }
public bool HasIcon => !string.IsNullOrWhiteSpace(IconPath) && File.Exists(IconPath);
public string? PopupPage { get; init; }
}
public sealed class ExtensionManager
{
private readonly CoreWebView2Profile _profile;
private readonly string _metadataPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "FoxBrowser", "extension-options.json");
private readonly string _uiMetadataPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "FoxBrowser", "extension-ui.json");
private readonly string _pinnedPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "FoxBrowser", "pinned-extensions.json");
private readonly Dictionary<string, string?> _optionsPages;
private readonly Dictionary<string, ExtensionUiMetadata> _uiMetadata;
private readonly HashSet<string> _pinnedIds;
public ExtensionManager(CoreWebView2Profile profile)
{
_profile = profile;
_optionsPages = LoadOptionsPages();
_uiMetadata = LoadUiMetadata();
_pinnedIds = LoadPinnedIds();
}
public ObservableCollection<CoreWebView2BrowserExtension> Extensions { get; } = new();
public ObservableCollection<PinnedExtensionItem> PinnedExtensions { get; } = new();
public event EventHandler? PinnedExtensionsChanged;
public async Task RefreshAsync()
{
Extensions.Clear();
PinnedExtensions.Clear();
var installed = await _profile.GetBrowserExtensionsAsync();
foreach (var extension in installed) Extensions.Add(extension);
RebuildPinned();
}
public async Task<CoreWebView2BrowserExtension> AddExtensionAsync(string folderPath)
{
if (!Directory.Exists(folderPath)) throw new DirectoryNotFoundException(folderPath);
var manifestPath = Path.Combine(folderPath, "manifest.json");
if (!File.Exists(manifestPath)) throw new InvalidDataException("У вибраній папці немає manifest.json.");
var manifest = await ReadManifestAsync(manifestPath);
var extension = await _profile.AddBrowserExtensionAsync(folderPath);
_optionsPages[extension.Id] = ReadOptionsPage(manifest);
_uiMetadata[extension.Id] = ReadUiMetadata(manifest, folderPath);
SaveOptionsPages();
SaveUiMetadata();
Extensions.Add(extension);
RebuildPinned();
return extension;
}
public string? GetOptionsPage(string extensionId) => _optionsPages.TryGetValue(extensionId, out var page) ? page : null;
public string? GetPopupPage(string extensionId) => _uiMetadata.TryGetValue(extensionId, out var metadata) ? metadata.PopupPage : null;
public string? GetIconPath(string extensionId) => _uiMetadata.TryGetValue(extensionId, out var metadata) ? metadata.IconPath : null;
public bool IsPinned(CoreWebView2BrowserExtension extension) => _pinnedIds.Contains(extension.Id);
public void SetPinned(CoreWebView2BrowserExtension extension, bool pinned)
{
if (pinned) _pinnedIds.Add(extension.Id); else _pinnedIds.Remove(extension.Id);
SavePinnedIds();
RebuildPinned();
}
public async Task SetEnabledAsync(CoreWebView2BrowserExtension extension, bool enabled)
{
await extension.EnableAsync(enabled);
RebuildPinned();
}
public async Task RemoveAsync(CoreWebView2BrowserExtension extension)
{
await extension.RemoveAsync();
Extensions.Remove(extension);
_optionsPages.Remove(extension.Id);
_uiMetadata.Remove(extension.Id);
_pinnedIds.Remove(extension.Id);
SaveOptionsPages();
SaveUiMetadata();
SavePinnedIds();
RebuildPinned();
}
private void RebuildPinned()
{
PinnedExtensions.Clear();
foreach (var extension in Extensions)
{
if (!_pinnedIds.Contains(extension.Id) || !extension.IsEnabled) continue;
_uiMetadata.TryGetValue(extension.Id, out var metadata);
PinnedExtensions.Add(new PinnedExtensionItem
{
Extension = extension,
Name = extension.Name,
IconPath = metadata?.IconPath,
PopupPage = metadata?.PopupPage
});
}
PinnedExtensionsChanged?.Invoke(this, EventArgs.Empty);
}
private static async Task<JsonElement> ReadManifestAsync(string path)
{
await using var stream = File.OpenRead(path);
using var document = await JsonDocument.ParseAsync(stream);
return document.RootElement.Clone();
}
private static string? ReadOptionsPage(JsonElement manifest)
{
if (manifest.TryGetProperty("options_ui", out var optionsUi) && optionsUi.ValueKind == JsonValueKind.Object && optionsUi.TryGetProperty("page", out var modernPage) && modernPage.ValueKind == JsonValueKind.String)
return NormalizeRelativePage(modernPage.GetString());
if (manifest.TryGetProperty("options_page", out var legacyPage) && legacyPage.ValueKind == JsonValueKind.String)
return NormalizeRelativePage(legacyPage.GetString());
return null;
}
private static ExtensionUiMetadata ReadUiMetadata(JsonElement manifest, string folderPath)
{
JsonElement action = default;
if (manifest.TryGetProperty("action", out var modernAction) && modernAction.ValueKind == JsonValueKind.Object) action = modernAction;
else if (manifest.TryGetProperty("browser_action", out var legacyAction) && legacyAction.ValueKind == JsonValueKind.Object) action = legacyAction;
string? popup = null;
if (action.ValueKind == JsonValueKind.Object && action.TryGetProperty("default_popup", out var popupElement) && popupElement.ValueKind == JsonValueKind.String)
popup = NormalizeRelativePage(popupElement.GetString());
string? icon = null;
if (action.ValueKind == JsonValueKind.Object && action.TryGetProperty("default_icon", out var iconElement))
{
var iconPage = iconElement.ValueKind == JsonValueKind.String
? iconElement.GetString()
: iconElement.ValueKind == JsonValueKind.Object
? iconElement.EnumerateObject().OrderByDescending(item => ParseIconSize(item.Name)).Select(item => item.Value.GetString()).FirstOrDefault()
: null;
var normalized = NormalizeRelativePage(iconPage);
if (normalized is not null)
{
var fullPath = Path.GetFullPath(Path.Combine(folderPath, normalized));
if (File.Exists(fullPath)) icon = fullPath;
}
}
return new ExtensionUiMetadata(popup, icon);
}
private static int ParseIconSize(string key) => int.TryParse(key, out var size) ? size : 0;
private static string? NormalizeRelativePage(string? page)
{
if (string.IsNullOrWhiteSpace(page)) return null;
var normalized = page.Trim().Replace('\\', '/').TrimStart('/');
return normalized.Contains("..", StringComparison.Ordinal) ? null : normalized;
}
private Dictionary<string, string?> LoadOptionsPages() => LoadJson(_metadataPath, new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase));
private Dictionary<string, ExtensionUiMetadata> LoadUiMetadata() => LoadJson(_uiMetadataPath, new Dictionary<string, ExtensionUiMetadata>(StringComparer.OrdinalIgnoreCase));
private HashSet<string> LoadPinnedIds() => LoadJson(_pinnedPath, new HashSet<string>(StringComparer.OrdinalIgnoreCase));
private static T LoadJson<T>(string path, T fallback)
{
try { return File.Exists(path) ? JsonSerializer.Deserialize<T>(File.ReadAllText(path)) ?? fallback : fallback; }
catch { return fallback; }
}
private static void SaveJson<T>(string path, T value)
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, JsonSerializer.Serialize(value, new JsonSerializerOptions { WriteIndented = true }));
}
catch { }
}
private void SaveOptionsPages() => SaveJson(_metadataPath, _optionsPages);
private void SaveUiMetadata() => SaveJson(_uiMetadataPath, _uiMetadata);
private void SavePinnedIds() => SaveJson(_pinnedPath, _pinnedIds);
public sealed record ExtensionUiMetadata(string? PopupPage, string? IconPath);
}
+96
View File
@@ -0,0 +1,96 @@
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace FoxBrowser.Core;
/// <summary>
/// Snapshot of a single tab persisted to disk.
/// All properties are nullable so old session files remain forward-compatible.
/// </summary>
public sealed class TabSnapshot
{
public string Address { get; init; } = "https://www.google.com";
public string Title { get; init; } = "Нова вкладка";
public bool IsAudioOnly { get; init; }
public double ScrollY { get; init; }
public double MediaPosition { get; init; }
public bool WasPlaying { get; init; }
}
public sealed class Session
{
public List<TabSnapshot> Tabs { get; init; } = new();
public int ActiveTabIndex { get; init; }
}
/// <summary>
/// Saves and loads the browser session (open tabs + per-tab state) to a JSON
/// file in LocalApplicationData\FoxBrowser\session.json.
///
/// Crash-recovery strategy: the file is re-written after every successful
/// NavigationCompleted so a hard crash never loses more than one page load.
/// </summary>
public static class SessionStore
{
private static readonly string SessionPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"FoxBrowser", "session.json");
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
};
public static async Task SaveAsync(IEnumerable<BrowserTab> tabs, int activeIndex)
{
try
{
var snapshots = tabs.Select(t => new TabSnapshot
{
Address = t.Address,
Title = t.Title,
IsAudioOnly = t.IsAudioOnly,
ScrollY = t.ScrollY,
MediaPosition = t.MediaPosition,
WasPlaying = t.WasPlaying,
}).ToList();
if (snapshots.Count == 0) return;
var session = new Session
{
Tabs = snapshots,
ActiveTabIndex = Math.Clamp(activeIndex, 0, snapshots.Count - 1),
};
Directory.CreateDirectory(Path.GetDirectoryName(SessionPath)!);
var json = JsonSerializer.Serialize(session, JsonOptions);
// Write to a temp file first, then replace atomically — avoids
// truncating the real file if the process is killed mid-write.
var tmp = SessionPath + ".tmp";
await File.WriteAllTextAsync(tmp, json);
File.Move(tmp, SessionPath, overwrite: true);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[SessionStore] Save failed: {ex.Message}");
}
}
public static async Task<Session?> LoadAsync()
{
if (!File.Exists(SessionPath)) return null;
try
{
var json = await File.ReadAllTextAsync(SessionPath);
return JsonSerializer.Deserialize<Session>(json);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[SessionStore] Load failed: {ex.Message}");
return null;
}
}
}
+311
View File
@@ -0,0 +1,311 @@
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.IO;
using System.Text.Json;
using System.Windows;
using System.Windows.Controls;
using Microsoft.Web.WebView2.Core;
using Microsoft.Web.WebView2.Wpf;
namespace FoxBrowser.Core;
public sealed class TabManager : IAsyncDisposable
{
private static readonly string[] BlockedHosts =
{
"doubleclick.net", "googlesyndication.com", "googleadservices.com", "adservice.google.com",
"googletagmanager.com", "google-analytics.com", "analytics.google.com", "facebook.net",
"connect.facebook.net", "hotjar.com", "segment.io", "amplitude.com", "scorecardresearch.com",
"adsrvr.org", "adnxs.com", "taboola.com", "outbrain.com"
};
private readonly Grid _host;
private readonly CoreWebView2Environment _environment;
private readonly WebView2 _view;
private bool _audioOnly;
private bool _minimizedMode;
private bool _navigationInProgress;
private bool _switchInProgress;
public TabManager(Grid host, CoreWebView2Environment environment, bool extensionsAvailable = true)
{
_host = host;
_environment = environment;
ExtensionsAvailable = extensionsAvailable;
_view = new WebView2 { Visibility = Visibility.Visible };
_host.Children.Add(_view);
}
public ObservableCollection<BrowserTab> Tabs { get; } = new();
public BrowserTab? ActiveTab { get; private set; }
public bool IsMinimizedMode => _minimizedMode;
public bool ExtensionsAvailable { get; private set; }
public CoreWebView2Environment Environment => _environment;
public WebView2 View => _view;
public ExtensionManager? ExtensionManager { get; private set; }
public event EventHandler? ActiveTabStateChanged;
public async Task InitializeAsync()
{
await _view.EnsureCoreWebView2Async(_environment);
try
{
ExtensionManager = new ExtensionManager(_view.CoreWebView2.Profile);
await ExtensionManager.RefreshAsync();
}
catch
{
ExtensionManager = null;
ExtensionsAvailable = false;
}
ConfigureResourceFirewall(_view.CoreWebView2);
_view.CoreWebView2.NavigationStarting += OnNavigationStarting;
_view.CoreWebView2.NavigationCompleted += OnNavigationCompleted;
_view.CoreWebView2.DocumentTitleChanged += OnDocumentTitleChanged;
_view.CoreWebView2.IsDocumentPlayingAudioChanged += OnAudioStateChanged;
_view.CoreWebView2.HistoryChanged += OnHistoryChanged;
await RestoreSessionOrDefaultAsync();
}
public async Task SaveSessionAsync()
{
var activeIndex = ActiveTab is not null ? Tabs.IndexOf(ActiveTab) : 0;
await SessionStore.SaveAsync(Tabs, activeIndex);
}
private async Task RestoreSessionOrDefaultAsync()
{
var session = await SessionStore.LoadAsync();
if (session is null || session.Tabs.Count == 0)
{
await AddTabAsync();
return;
}
BrowserTab? activeTab = null;
for (int i = 0; i < session.Tabs.Count; i++)
{
var snap = session.Tabs[i];
var tab = new BrowserTab(snap.Address)
{
Title = snap.Title,
IsAudioOnly = snap.IsAudioOnly,
ScrollY = snap.ScrollY,
MediaPosition = snap.MediaPosition,
WasPlaying = snap.WasPlaying,
};
Tabs.Add(tab);
if (i == session.ActiveTabIndex) activeTab = tab;
}
await SwitchToTabAsync(activeTab ?? Tabs[0]);
}
public async Task<BrowserTab> AddTabAsync(string address = "https://www.google.com")
{
var tab = new BrowserTab(NormalizeAddress(address));
Tabs.Add(tab);
await SwitchToTabAsync(tab);
return tab;
}
public async Task SwitchToTabAsync(BrowserTab? tab)
{
if (tab is null || !Tabs.Contains(tab)) return;
var core = _view.CoreWebView2;
if (core is null || ReferenceEquals(ActiveTab, tab) || _switchInProgress) return;
_switchInProgress = true;
try
{
if (ActiveTab is not null) await CaptureActiveStateAsync();
ActiveTab = tab;
_audioOnly = tab.IsAudioOnly;
ActiveTabStateChanged?.Invoke(this, EventArgs.Empty);
_navigationInProgress = true;
core.Navigate(tab.Address);
await Task.Yield();
}
finally { _switchInProgress = false; }
}
public async Task NavigateAsync(BrowserTab tab, string address)
{
if (!Tabs.Contains(tab)) return;
var core = _view.CoreWebView2;
if (core is null) return;
tab.Address = NormalizeAddress(address);
if (ActiveTab != tab) { await SwitchToTabAsync(tab); return; }
_navigationInProgress = true;
core.Navigate(tab.Address);
}
public Task OpenExtensionOptionsAsync(string extensionId)
{
var core = _view.CoreWebView2;
if (core is null || string.IsNullOrWhiteSpace(extensionId)) return Task.CompletedTask;
core.Navigate($"chrome-extension://{extensionId}/options.html");
return Task.CompletedTask;
}
public async Task OpenUrlAsync(string address)
{
if (ActiveTab is null) return;
if (address.StartsWith("chrome-extension://", StringComparison.OrdinalIgnoreCase))
{
var core = _view.CoreWebView2;
if (core is null) return;
ActiveTab.Address = address;
core.Navigate(address);
return;
}
await NavigateAsync(ActiveTab, address);
}
public async Task ToggleAudioOnlyAsync()
{
if (ActiveTab is null) return;
_audioOnly = !_audioOnly;
ActiveTab.IsAudioOnly = _audioOnly;
await ApplyLightweightPageModeAsync(_audioOnly || _minimizedMode);
}
public async Task SetMinimizedModeAsync(bool enabled)
{
_minimizedMode = enabled;
await ApplyLightweightPageModeAsync(enabled || _audioOnly);
}
public async Task SoftReloadIfNeededAsync(long thresholdBytes = 850L * 1024 * 1024)
{
using var process = Process.GetCurrentProcess();
if (process.WorkingSet64 < thresholdBytes || ActiveTab is null || _navigationInProgress) return;
await CaptureActiveStateAsync();
_navigationInProgress = true;
_view.CoreWebView2?.Navigate(ActiveTab.Address);
}
public void GoBack() { var core = _view.CoreWebView2; if (core?.CanGoBack == true) core.GoBack(); }
public void GoForward() { var core = _view.CoreWebView2; if (core?.CanGoForward == true) core.GoForward(); }
public void Reload() { var core = _view.CoreWebView2; if (core is null) return; _navigationInProgress = true; core.Reload(); }
public void CloseTab(BrowserTab tab)
{
var index = Tabs.IndexOf(tab);
Tabs.Remove(tab);
if (Tabs.Count == 0) { _ = AddTabAsync(); return; }
if (ActiveTab == tab) _ = SwitchToTabAsync(Tabs[Math.Min(index, Tabs.Count - 1)]);
_ = SaveSessionAsync();
}
public async ValueTask DisposeAsync()
{
if (ActiveTab is not null) await CaptureActiveStateAsync();
await SaveSessionAsync();
_view.Dispose();
}
private void OnNavigationStarting(object? sender, CoreWebView2NavigationStartingEventArgs e)
{
if (_minimizedMode && !_navigationInProgress) e.Cancel = true;
if (ActiveTab is not null) ActiveTab.IsLoading = true;
}
private async void OnNavigationCompleted(object? sender, CoreWebView2NavigationCompletedEventArgs e)
{
_navigationInProgress = false;
if (ActiveTab is null) return;
ActiveTab.IsLoading = false;
ActiveTab.Address = _view.Source?.ToString() ?? ActiveTab.Address;
ActiveTab.Title = string.IsNullOrWhiteSpace(_view.CoreWebView2.DocumentTitle) ? "Нова вкладка" : _view.CoreWebView2.DocumentTitle;
ActiveTabStateChanged?.Invoke(this, EventArgs.Empty);
await ApplyLightweightPageModeAsync(_audioOnly || _minimizedMode);
await RestoreActiveStateAsync();
await SaveSessionAsync();
}
private void OnDocumentTitleChanged(object? sender, object e)
{
if (ActiveTab is not null) ActiveTab.Title = string.IsNullOrWhiteSpace(_view.CoreWebView2.DocumentTitle) ? "Нова вкладка" : _view.CoreWebView2.DocumentTitle;
}
private void OnAudioStateChanged(object? sender, object e)
{
if (ActiveTab is not null) ActiveTab.IsPlayingAudio = _view.CoreWebView2.IsDocumentPlayingAudio;
}
private void OnHistoryChanged(object? sender, object e)
{
if (ActiveTab is null) return;
var current = _view.CoreWebView2.Source;
if (string.IsNullOrEmpty(current) || current == ActiveTab.Address) return;
ActiveTab.Address = current;
ActiveTabStateChanged?.Invoke(this, EventArgs.Empty);
_ = SaveSessionAsync();
}
private async Task CaptureActiveStateAsync()
{
if (ActiveTab is null || _view.CoreWebView2 is null) return;
try
{
var json = await _view.CoreWebView2.ExecuteScriptAsync("JSON.stringify({scrollY:window.scrollY, media:[...document.querySelectorAll('video,audio')].map(x=>({time:x.currentTime||0,playing:!x.paused}))})");
var payload = JsonSerializer.Deserialize<string>(json);
if (string.IsNullOrWhiteSpace(payload)) return;
using var doc = JsonDocument.Parse(payload);
if (doc.RootElement.TryGetProperty("scrollY", out var scroll)) ActiveTab.ScrollY = scroll.GetDouble();
if (doc.RootElement.TryGetProperty("media", out var media) && media.GetArrayLength() > 0)
{
var first = media[0];
if (first.TryGetProperty("time", out var time)) ActiveTab.MediaPosition = time.GetDouble();
if (first.TryGetProperty("playing", out var playing)) ActiveTab.WasPlaying = playing.GetBoolean();
}
}
catch (JsonException) { }
}
private async Task RestoreActiveStateAsync()
{
if (ActiveTab is null || _view.CoreWebView2 is null) return;
var scroll = ActiveTab.ScrollY.ToString(System.Globalization.CultureInfo.InvariantCulture);
var position = ActiveTab.MediaPosition.ToString(System.Globalization.CultureInfo.InvariantCulture);
var resume = ActiveTab.WasPlaying ? ".play().catch(()=>{});" : "";
var script = $"(() => {{ const restore=()=>{{ window.scrollTo(0,{scroll}); document.querySelectorAll('video,audio').forEach(x=>{{ try {{ x.currentTime={position}; {resume} }} catch {{}} }}); }}; restore(); setTimeout(restore,300); setTimeout(restore,1000); }})();";
await _view.CoreWebView2.ExecuteScriptAsync(script);
}
private void ConfigureResourceFirewall(CoreWebView2 core)
{
core.AddWebResourceRequestedFilter("*", CoreWebView2WebResourceContext.All);
core.WebResourceRequested += OnWebResourceRequested;
}
private void OnWebResourceRequested(object? sender, CoreWebView2WebResourceRequestedEventArgs e)
{
var uri = e.Request.Uri;
var host = Uri.TryCreate(uri, UriKind.Absolute, out var parsed) ? parsed.Host : string.Empty;
var blockedHost = BlockedHosts.Any(x => host.Equals(x, StringComparison.OrdinalIgnoreCase) || host.EndsWith("." + x, StringComparison.OrdinalIgnoreCase));
var visualWaste = _audioOnly || _minimizedMode;
var blockedContext = visualWaste && (e.ResourceContext is CoreWebView2WebResourceContext.Image or CoreWebView2WebResourceContext.Font);
var thirdPartyFrame = visualWaste && (uri.Contains("/embed/", StringComparison.OrdinalIgnoreCase) || host.Contains("livechat", StringComparison.OrdinalIgnoreCase));
if (!blockedHost && !blockedContext && !thirdPartyFrame) return;
e.Response = _view.CoreWebView2.Environment.CreateWebResourceResponse(new MemoryStream(Array.Empty<byte>()), 204, "No Content", "");
}
private async Task ApplyLightweightPageModeAsync(bool enabled)
{
if (_view.CoreWebView2 is null) return;
var script = enabled
? """
(()=>{let s=document.getElementById('fox-lightweight');if(!s){s=document.createElement('style');s.id='fox-lightweight';s.textContent='img,picture,svg,canvas{display:none!important}video{width:1px!important;height:1px!important;opacity:0!important;position:fixed!important;left:-10000px!important;top:-10000px!important}';document.documentElement.appendChild(s)};document.querySelectorAll('link[rel="preload"],link[rel="prefetch"],link[rel="next"]').forEach(x=>x.remove());})();
"""
: "document.getElementById('fox-lightweight')?.remove();";
await _view.CoreWebView2.ExecuteScriptAsync(script);
}
private static string NormalizeAddress(string value)
{
var text = value.Trim();
if (string.IsNullOrEmpty(text)) return "https://www.google.com";
if (Uri.TryCreate(text, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)) return text;
return $"https://www.google.com/search?q={Uri.EscapeDataString(text)}";
}
}
+167
View File
@@ -0,0 +1,167 @@
using Microsoft.Win32;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.IO;
namespace FoxBrowser.Core;
public enum VpnConnectionState
{
Disconnected,
Connecting,
Connected,
Error
}
public sealed class VpnProfile
{
public VpnProfile(string path)
{
FilePath = path;
Name = Path.GetFileNameWithoutExtension(path);
}
public string Name { get; }
public string FilePath { get; }
}
public sealed class VpnManager
{
private readonly string _profileDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"FoxBrowser", "VPN");
private string? _activeTunnel;
public VpnManager()
{
Directory.CreateDirectory(_profileDirectory);
Profiles = new ObservableCollection<VpnProfile>();
RefreshProfiles();
}
public ObservableCollection<VpnProfile> Profiles { get; }
public VpnConnectionState State { get; private set; } = VpnConnectionState.Disconnected;
public string? LastError { get; private set; }
public VpnProfile? ActiveProfile { get; private set; }
public bool IsConnected => State == VpnConnectionState.Connected;
public string? WireGuardPath => FindWireGuard();
public event EventHandler? StateChanged;
public void RefreshProfiles()
{
Profiles.Clear();
foreach (var path in Directory.EnumerateFiles(_profileDirectory, "*.conf"))
Profiles.Add(new VpnProfile(path));
}
public VpnProfile ImportTunnelConfig(string confFilePath)
{
if (!File.Exists(confFilePath)) throw new FileNotFoundException("Конфігурацію WireGuard не знайдено.", confFilePath);
if (!string.Equals(Path.GetExtension(confFilePath), ".conf", StringComparison.OrdinalIgnoreCase))
throw new InvalidDataException("Потрібен файл конфігурації з розширенням .conf.");
var safeName = Path.GetFileNameWithoutExtension(confFilePath);
foreach (var invalid in Path.GetInvalidFileNameChars()) safeName = safeName.Replace(invalid, '_');
var destination = Path.Combine(_profileDirectory, safeName + ".conf");
File.Copy(confFilePath, destination, true);
RefreshProfiles();
return Profiles.First(x => string.Equals(x.FilePath, destination, StringComparison.OrdinalIgnoreCase));
}
public async Task ConnectAsync(VpnProfile profile, CancellationToken cancellationToken = default)
{
if (State == VpnConnectionState.Connecting) return;
var wireGuard = FindWireGuard();
if (wireGuard is null)
{
State = VpnConnectionState.Error;
LastError = "WireGuard for Windows не встановлено. Завантажте його з https://www.wireguard.com/install/";
StateChanged?.Invoke(this, EventArgs.Empty);
throw new InvalidOperationException(LastError);
}
State = VpnConnectionState.Connecting;
LastError = null;
ActiveProfile = profile;
_activeTunnel = profile.Name;
StateChanged?.Invoke(this, EventArgs.Empty);
try
{
var result = await RunAsync(wireGuard, cancellationToken, "/installtunnelservice", profile.FilePath);
if (result.ExitCode != 0)
throw new InvalidOperationException(string.IsNullOrWhiteSpace(result.Error) ? result.Output : result.Error);
State = VpnConnectionState.Connected;
}
catch (Exception ex)
{
State = VpnConnectionState.Error;
LastError = ex.Message;
throw;
}
finally { StateChanged?.Invoke(this, EventArgs.Empty); }
}
public async Task DisconnectAsync(CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(_activeTunnel)) return;
var wireGuard = FindWireGuard();
if (wireGuard is null) throw new InvalidOperationException("WireGuard for Windows не встановлено.");
var result = await RunAsync(wireGuard, cancellationToken, "/uninstalltunnelservice", _activeTunnel);
if (result.ExitCode != 0)
{
State = VpnConnectionState.Error;
LastError = string.IsNullOrWhiteSpace(result.Error) ? result.Output : result.Error;
StateChanged?.Invoke(this, EventArgs.Empty);
throw new InvalidOperationException(LastError);
}
_activeTunnel = null;
ActiveProfile = null;
State = VpnConnectionState.Disconnected;
LastError = null;
StateChanged?.Invoke(this, EventArgs.Empty);
}
private static async Task<(int ExitCode, string Output, string Error)> RunAsync(string executable, CancellationToken cancellationToken, params object[] arguments)
{
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = executable,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
}
};
foreach (var argument in arguments) process.StartInfo.ArgumentList.Add(Convert.ToString(argument) ?? string.Empty);
process.Start();
var outputTask = process.StandardOutput.ReadToEndAsync();
var errorTask = process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync(cancellationToken);
return (process.ExitCode, await outputTask, await errorTask);
}
private static string? FindWireGuard()
{
var candidates = new[]
{
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "WireGuard", "wireguard.exe"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "WireGuard", "wireguard.exe"),
};
foreach (var candidate in candidates) if (File.Exists(candidate)) return candidate;
using var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\wireguard.exe");
var registered = key?.GetValue(null) as string;
if (!string.IsNullOrWhiteSpace(registered) && File.Exists(registered)) return registered;
foreach (var segment in (Environment.GetEnvironmentVariable("PATH") ?? string.Empty).Split(Path.PathSeparator))
{
var candidate = Path.Combine(segment.Trim(), "wireguard.exe");
try { if (File.Exists(candidate)) return candidate; }
catch { /* Ignore unreadable PATH entries. */ }
}
return null;
}
}
+190
View File
@@ -0,0 +1,190 @@
using System.IO;
using System.Security;
using Microsoft.Web.WebView2.Core;
namespace FoxBrowser.Core;
/// <summary>
/// Creates every WebView2 environment with one consistent set of options and
/// chooses the portable profile when the application directory is writable.
/// </summary>
public sealed class WebView2EnvironmentFactory
{
private const string ProductFolderName = "FoxBrowser";
private const string ProfileFolderName = "Profile";
private const string LegacyProfileFolderName = "WebView2";
private const string ProtectedFolderNoticeMarker = "portable-profile-fallback-notified";
private readonly string _applicationDataRoot = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), ProductFolderName);
private bool _shouldShowProtectedFolderNotice;
public string PortableProfilePath => Path.Combine(AppContext.BaseDirectory, ProfileFolderName);
public string LegacyProfilePath => Path.Combine(_applicationDataRoot, LegacyProfileFolderName);
public bool UsingLegacyProfile { get; private set; }
public string? MigrationError { get; private set; }
/// <summary>
/// Creates a WebView2 environment. An override is used only by startup
/// recovery, which must always receive its own temporary profile.
/// </summary>
public async Task<CoreWebView2Environment> CreateAsync(string? userDataFolderOverride = null)
{
var userDataFolder = userDataFolderOverride ?? SelectPersistentProfile();
Directory.CreateDirectory(userDataFolder);
return await CoreWebView2Environment.CreateAsync(
browserExecutableFolder: null,
userDataFolder: userDataFolder,
options: CreateWebViewOptions());
}
/// <summary>
/// Returns a startup notice once for the protected-installation fallback,
/// and returns a migration error when one occurred during this startup.
/// </summary>
public string? TakeStartupNotice()
{
if (_shouldShowProtectedFolderNotice)
{
_shouldShowProtectedFolderNotice = false;
return "FoxBrowser встановлено в захищену папку — профіль зберігається " +
"у стандартному місці Windows, а не поруч із застосунком. Для " +
"портативного профілю перенесіть FoxBrowser у папку з правами запису " +
"(наприклад, Робочий стіл або Документи).";
}
if (MigrationError is not null)
return $"Не вдалося перенести профіль FoxBrowser у папку поруч із застосунком: {MigrationError}\n\n" +
"Запуск продовжено зі стандартним профілем Windows.";
return null;
}
private string SelectPersistentProfile()
{
UsingLegacyProfile = false;
MigrationError = null;
if (!CanWriteToApplicationDirectory())
{
UsingLegacyProfile = true;
MarkProtectedFolderNotice();
return LegacyProfilePath;
}
if (!Directory.Exists(PortableProfilePath) && Directory.Exists(LegacyProfilePath))
{
try
{
CopyDirectoryContents(LegacyProfilePath, PortableProfilePath);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException)
{
MigrationError = ex.Message;
TryDeleteDirectory(PortableProfilePath);
UsingLegacyProfile = true;
return LegacyProfilePath;
}
}
return PortableProfilePath;
}
private static CoreWebView2EnvironmentOptions CreateWebViewOptions() => new()
{
AreBrowserExtensionsEnabled = true
};
private static bool CanWriteToApplicationDirectory()
{
var testFile = Path.Combine(AppContext.BaseDirectory, $".foxbrowser-write-test-{Guid.NewGuid():N}.tmp");
try
{
using (var stream = new FileStream(testFile, FileMode.CreateNew, FileAccess.Write, FileShare.None))
stream.WriteByte(0);
File.Delete(testFile);
return true;
}
catch (UnauthorizedAccessException)
{
TryDeleteFile(testFile);
return false;
}
catch (IOException)
{
TryDeleteFile(testFile);
return false;
}
catch (SecurityException)
{
TryDeleteFile(testFile);
return false;
}
}
private void MarkProtectedFolderNotice()
{
try
{
Directory.CreateDirectory(_applicationDataRoot);
var marker = ProtectedFolderNoticeMarkerPath();
if (!File.Exists(marker))
{
File.WriteAllText(marker, DateTimeOffset.UtcNow.ToString("O"));
_shouldShowProtectedFolderNotice = true;
}
}
catch
{
// The fallback profile remains valid even if the notice marker cannot be saved.
}
}
private string ProtectedFolderNoticeMarkerPath() => Path.Combine(_applicationDataRoot, ProtectedFolderNoticeMarker);
private static void CopyDirectoryContents(string sourceDirectory, string destinationDirectory)
{
Directory.CreateDirectory(destinationDirectory);
foreach (var directory in Directory.EnumerateDirectories(sourceDirectory, "*", SearchOption.AllDirectories))
{
var relativePath = Path.GetRelativePath(sourceDirectory, directory);
Directory.CreateDirectory(Path.Combine(destinationDirectory, relativePath));
}
foreach (var file in Directory.EnumerateFiles(sourceDirectory, "*", SearchOption.AllDirectories))
{
var relativePath = Path.GetRelativePath(sourceDirectory, file);
var destinationFile = Path.Combine(destinationDirectory, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(destinationFile)!);
File.Copy(file, destinationFile, overwrite: false);
}
}
private static void TryDeleteDirectory(string path)
{
try
{
if (Directory.Exists(path))
Directory.Delete(path, recursive: true);
}
catch
{
// A partial migration must not prevent fallback to the legacy profile.
}
}
private static void TryDeleteFile(string path)
{
try
{
if (File.Exists(path))
File.Delete(path);
}
catch
{
// Best effort cleanup only.
}
}
}
+96
View File
@@ -0,0 +1,96 @@
Реалізуй чотири незалежні покращення в FoxBrowser. Роби їх послідовно,
кожне — окремим комітом, з коротким тестовим сценарієм на кінці.
Не чіпай TabManager/watchdog-логіку, якщо це не потрібно напряму для задачі.
## 1. Підтримка розширень (Chrome-сумісні extensions)
WebView2 має нативну підтримку unpacked-розширень через CoreWebView2Profile:
- При створенні CoreWebView2Environment виставити
`CoreWebView2EnvironmentOptions.AreBrowserExtensionsEnabled = true`
(без цього прапорця AddBrowserExtensionAsync повертає ERROR_NOT_SUPPORTED).
- Додати `Core/ExtensionManager.cs`:
- `AddExtensionAsync(string folderPath)` → `profile.AddBrowserExtensionAsync(folderPath)`.
Важливо: приймається тільки шлях до **розпакованої** папки з manifest.json
(той самий формат, що й Chrome "Load unpacked" у режимі розробника) —
прямого встановлення з Chrome Web Store за URL немає в API, це нормально,
не намагайся емулювати завантаження з магазину.
- `GetInstalledExtensionsAsync()` → `profile.GetBrowserExtensionsAsync()`.
- Enable/disable через `CoreWebView2BrowserExtension.IsEnabled`, видалення — `.Remove()`.
- UI: нове вікно/секція в налаштуваннях `ExtensionsSettingsWindow.xaml` — список
встановлених розширень (назва, іконка якщо є, перемикач увімк/вимк, кнопка
видалити) + кнопка "Додати" з `System.Windows.Forms.FolderBrowserDialog` для
вибору папки розширення.
- Розширення персистяться в профілі (user data folder) автоматично —
не треба вручну зберігати список між запусками, просто перечитувати
GetBrowserExtensionsAsync() при старті.
- ⚠️ Це API позначене в документації Microsoft як таке, що ще може змінюватись
("prerelease"/experimental) — перевір актуальну версію WebView2 SDK
в csproj, і якщо AddBrowserExtensionAsync недоступний у поточній версії,
онови NuGet-пакет Microsoft.Web.WebView2 до версії, де метод є.
## 2. Темна смужка заголовка вікна (title bar)
Стандартний WPF Window малює системну (світлу) title bar незалежно від теми
застосунку. Потрібен P/Invoke до DWM:
- У `Native/Win32Interop.cs` додати сигнатуру:
`DwmSetWindowAttribute(IntPtr hwnd, int attribute, ref int value, int size)`.
- Атрибут `DWMWA_USE_IMMERSIVE_DARK_MODE` = 20 (Windows 10 build 19041+ і всі
Windows 11); для старіших білдів Windows 10 (до 19041) значення атрибута — 19.
Визнач білд через `Environment.OSVersion` і підстав правильне число, або
просто пробуй 20 і фолбек на 19, якщо виклик повернув помилку.
- Викликати з value=1 (true) у `MainWindow.xaml.cs` в обробнику
`SourceInitialized` (handle вікна вже створений на цьому етапі,
раніше — hwnd ще нульовий і виклик не подіє).
- Якщо застосунок надалі планує світлу/темну тему як перемикач — винести
виклик у окремий метод `ApplyDarkTitleBar(bool enabled)` і викликати його
і при старті, і при зміні теми в налаштуваннях, а не хардкодити один раз.
## 3. Прибрати кнопку "у трей" з головного вікна
- У `MainWindow.xaml` знайти і видалити XAML-кнопку, що ховає вікно в трей
(і відповідний Click-обробник у code-behind, якщо він не використовується
більше ніде).
- Функціональність згортання в трей НЕ прибирати — вона має лишитись
доступною через стандартну поведінку: клік на "згорнути" (мінімізувати)
вікна або на "закрити" (якщо зараз close перехоплюється і ховає замість
завершення застосунку — залиш як є). Перевір `TrayIconManager.cs` —
контекстне меню трею (Show/Hide, Exit) лишається без змін.
- Ціль — прибрати саме зайвий UI-елемент з панелі інструментів вікна,
не ламаючи існуючий цикл згортання/розгортання.
## 4. Виправити подвійний клік по іконці трею
Зараз: перший клік на іконку в треї лише "проявляє" застосунок на панелі
задач, і тільки другий клік по цій новій іконці на панелі задач реально
відкриває вікно. Це означає, що десь у `TrayIconManager.cs` логіка розбита
на два окремі кроки замість одного atomic-виклику, або що обробник висить
на `MouseDoubleClick` замість одинарного кліку лівою кнопкою, а проміжний
стан (ShowInTaskbar=true) відбувається окремо від Show()/Activate().
Виправлення:
- Знайти обробник кліку по NotifyIcon (ймовірно `MouseClick`/`MouseDoubleClick`
на `System.Windows.Forms.NotifyIcon` або аналог з бібліотеки трею, якщо
використовується інша, напр. Hardcodet.NotifyIcon.Wpf).
- Прибрати залежність на подвійний клік — підписатись на одинарний клік
лівою кнопкою (`MouseClick` з перевіркою `e.Button == MouseButtons.Left`,
або відповідний однократний eventArgs у використовуваній бібліотеці).
- В одному обробнику, синхронно й без проміжних станів, виконати весь ланцюжок:
```csharp
mainWindow.ShowInTaskbar = true;
mainWindow.Show();
mainWindow.WindowState = WindowState.Normal;
mainWindow.Activate();
```
Порядок важливий: ShowInTaskbar виставляється ДО Show(), інакше вікно
може з'явитись без іконки на панелі задач до наступного перемальовування.
- Протестувати сценарій: застосунок згорнутий у трей (ShowInTaskbar=false,
Hide()) → один клік лівою кнопкою по іконці трею → вікно одразу видиме,
активне, і з'являється на панелі задач за один крок, без другого кліка.
- Right-click (контекстне меню Show/Hide/Exit) не займати — воно вже працює
окремо і його логіку не чіпати.
Після кожного пункту напиши короткий підсумок, що саме змінено і як це
перевірити вручну на Windows-машині (агент у Linux sandbox без dotnet/WPF
runtime, тому фінальну компіляцію й ручний тест виконує користувач).
+29
View File
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<UseWindowsForms>true</UseWindowsForms>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>FoxBrowser</AssemblyName>
<RootNamespace>FoxBrowser</RootNamespace>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>FoxBrowser.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<Compile Remove="OCR Translate\**" />
<Page Remove="OCR Translate\**" />
<None Remove="OCR Translate\**" />
</ItemGroup>
<ItemGroup>
<Content Include="FoxBrowser.ico" />
</ItemGroup>
<ItemGroup>
<None Include="FoxBrowser.ico">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.2903.40" />
<PackageReference Include="NAudio" Version="2.2.1" />
</ItemGroup>
</Project>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

+18
View File
@@ -0,0 +1,18 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FoxBrowser", "FoxBrowser.csproj", "{9A7C5D50-9B70-4E4D-96D2-6F30E1A6C4BD}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{9A7C5D50-9B70-4E4D-96D2-6F30E1A6C4BD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9A7C5D50-9B70-4E4D-96D2-6F30E1A6C4BD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9A7C5D50-9B70-4E4D-96D2-6F30E1A6C4BD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9A7C5D50-9B70-4E4D-96D2-6F30E1A6C4BD}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
+177
View File
@@ -0,0 +1,177 @@
<Window x:Class="FoxBrowser.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="FoxBrowser" Width="1180" Height="760" MinWidth="760" MinHeight="480"
Background="{StaticResource WindowBg}" Foreground="{StaticResource TextPrimary}"
Closing="Window_Closing" StateChanged="Window_StateChanged"
TextOptions.TextFormattingMode="Display"
RenderOptions.ClearTypeHint="Enabled">
<Window.Resources>
<DataTemplate x:Key="TabTemplate">
<Grid MinWidth="126" MaxWidth="250" Height="34">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<Ellipse Width="6" Height="6" Fill="{StaticResource TextMuted}" Margin="0,0,8,0" />
<TextBlock Text="{Binding DisplayTitle}"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center"
FontSize="12"
FontWeight="SemiBold"
Foreground="{StaticResource TextSecondary}" />
</StackPanel>
<Button Grid.Column="1"
Content="×"
Tag="{Binding}"
Click="CloseTab_Click"
ToolTip="Закрити вкладку"
Width="22" Height="22"
Padding="0"
Margin="8,0,0,0"
FontSize="14"
FontWeight="Bold"
Background="Transparent"
BorderBrush="Transparent"
Foreground="{StaticResource TextMuted}">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border x:Name="CloseBorder" Background="{TemplateBinding Background}" CornerRadius="5">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" Margin="0,-1,0,0" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="CloseBorder" Property="Background" Value="{StaticResource DangerBg}" />
<Setter Property="Foreground" Value="{StaticResource DangerText}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
</Button>
</Grid>
</DataTemplate>
<Style TargetType="TabItem">
<Setter Property="Background" Value="#191F28" />
<Setter Property="BorderBrush" Value="{StaticResource BorderDark}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Foreground" Value="{StaticResource TextSecondary}" />
<Setter Property="Margin" Value="0,0,6,0" />
<Setter Property="Padding" Value="12,0" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TabItem">
<Border x:Name="TabBorder"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="8"
Padding="{TemplateBinding Padding}"
Margin="{TemplateBinding Margin}">
<ContentPresenter ContentSource="Header" HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="TabBorder" Property="Background" Value="#222A35" />
<Setter TargetName="TabBorder" Property="BorderBrush" Value="{StaticResource BorderMedium}" />
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="TabBorder" Property="Background" Value="#26354B" />
<Setter TargetName="TabBorder" Property="BorderBrush" Value="{StaticResource AurBlue}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Foreground" Value="{StaticResource TextPrimary}" />
<Setter Property="FontWeight" Value="SemiBold" />
</Trigger>
</Style.Triggers>
</Style>
</Window.Resources>
<DockPanel>
<!-- Tab strip -->
<Border DockPanel.Dock="Top" Background="{StaticResource HeaderBg}" Padding="12,10,12,8" BorderBrush="{StaticResource BorderDark}" BorderThickness="0,0,0,1">
<DockPanel>
<StackPanel DockPanel.Dock="Left" Orientation="Horizontal" VerticalAlignment="Center" Margin="2,0,18,0">
<Border Width="28" Height="28" CornerRadius="8" Background="{StaticResource AurBlueSoft}" Margin="0,0,9,0">
<TextBlock Text="🦊" FontSize="16" HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="FoxBrowser" Foreground="{StaticResource TextPrimary}" FontSize="13" FontWeight="Bold" />
<TextBlock Text="Focused browsing" Foreground="{StaticResource TextMuted}" FontSize="10" Margin="0,1,0,0" />
</StackPanel>
</StackPanel>
<Button DockPanel.Dock="Right" Content="+" ToolTip="Нова вкладка" Click="NewTab_Click" Style="{StaticResource IconButton}" Margin="8,0,0,0" />
<TabControl x:Name="TabStrip"
ItemsSource="{Binding Tabs}"
ItemTemplate="{StaticResource TabTemplate}"
SelectionChanged="TabStrip_SelectionChanged"
Background="Transparent"
BorderThickness="0"
Padding="0"
Margin="0,0,0,0">
<TabControl.ContentTemplate>
<DataTemplate><Grid Visibility="Collapsed" /></DataTemplate>
</TabControl.ContentTemplate>
</TabControl>
</DockPanel>
</Border>
<!-- Browser controls -->
<Border DockPanel.Dock="Top" Background="{StaticResource HeaderBgElevated}" Padding="12,9" BorderBrush="{StaticResource BorderDark}" BorderThickness="0,0,0,1">
<DockPanel LastChildFill="True">
<StackPanel DockPanel.Dock="Left" Orientation="Horizontal" VerticalAlignment="Center" Margin="0,0,10,0">
<Button Content="←" ToolTip="Назад" Click="Back_Click" Style="{StaticResource IconButton}" />
<Button Content="→" ToolTip="Вперед" Click="Forward_Click" Style="{StaticResource IconButton}" />
<Button Content="⟳" ToolTip="Оновити" Click="Reload_Click" Style="{StaticResource IconButton}" Margin="4,0,0,0" />
</StackPanel>
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" VerticalAlignment="Center" Margin="10,0,0,0">
<ItemsControl x:Name="PinnedExtensionStrip" Margin="0,0,5,0">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><StackPanel Orientation="Horizontal" /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Button Tag="{Binding}" Click="PinnedExtension_Click" Width="32" Height="32" Padding="0" Margin="0,0,4,0" ToolTip="{Binding Name}" Style="{StaticResource IconButton}">
<Grid>
<Image Source="{Binding IconPath}" Width="18" Height="18" Stretch="Uniform" />
<TextBlock Text="{Binding Glyph}" FontWeight="Bold" HorizontalAlignment="Center" VerticalAlignment="Center" />
</Grid>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Content="♫" ToolTip="Режим лише аудіо" Click="AudioOnly_Click" Height="32" Width="36" Padding="0" Margin="0,0,4,0" />
<Button Content="Розширення" ToolTip="Керування unpacked extensions" Click="Extensions_Click" Height="32" Padding="11,0" />
</StackPanel>
<Grid VerticalAlignment="Center">
<TextBox x:Name="AddressBox"
VerticalContentAlignment="Center"
Height="32"
Padding="12,0,86,0"
KeyDown="AddressBox_KeyDown" />
<Button Content="Перейти" ToolTip="Відкрити адресу" Click="Go_Click" Style="{StaticResource PrimaryButton}" Height="26" Padding="12,0" HorizontalAlignment="Right" Margin="0,0,3,0" />
</Grid>
</DockPanel>
</Border>
<!-- Web content -->
<Grid x:Name="WebViewHost" Background="#0B0E12">
<TextBlock x:Name="BrowserStatus"
Text="Ініціалізація WebView2…"
Foreground="{StaticResource TextSecondary}"
FontSize="13"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Visibility="Collapsed" />
</Grid>
</DockPanel>
</Window>
+214
View File
@@ -0,0 +1,214 @@
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Interop;
using System.Windows.Threading;
using Microsoft.Web.WebView2.Core;
using FoxBrowser.Core;
using FoxBrowser.Native;
using FoxBrowser.UI;
namespace FoxBrowser;
public partial class MainWindow : Window
{
private TabManager? _tabManager;
private TrayIconManager? _tray;
private bool _allowClose;
private readonly DispatcherTimer _watchdogTimer = new() { Interval = TimeSpan.FromSeconds(30) };
private AudioSessionLabeler? _audioLabeler;
public MainWindow()
{
InitializeComponent();
try
{
var icoPath = Path.Combine(AppContext.BaseDirectory, "FoxBrowser.ico");
if (File.Exists(icoPath))
Icon = new System.Windows.Media.Imaging.BitmapImage(new Uri(icoPath, UriKind.Absolute));
}
catch { }
SourceInitialized += (_, _) => ApplyDarkTitleBar(true);
Loaded += MainWindow_Loaded;
}
private async void MainWindow_Loaded(object sender, RoutedEventArgs e)
{
try
{
_tray = new TrayIconManager(this, ExitApplication);
var environmentFactory = new WebView2EnvironmentFactory();
var recoveryMode = false;
CoreWebView2Environment environment;
try
{
environment = await environmentFactory.CreateAsync();
}
catch (Exception ex) when ((uint)ex.HResult == 0x8007139Fu)
{
// Another FoxBrowser/WebView2 process may hold the persistent profile lock.
// Use a disposable recovery profile so the browser can still start.
recoveryMode = true;
var recoveryData = Path.Combine(
Path.GetTempPath(), $"FoxBrowser_Recovery_{Environment.ProcessId}_{Guid.NewGuid():N}");
Directory.CreateDirectory(recoveryData);
environment = await environmentFactory.CreateAsync(recoveryData);
}
var startupNotice = environmentFactory.TakeStartupNotice();
if (startupNotice is not null)
System.Windows.MessageBox.Show(this, startupNotice, "FoxBrowser", MessageBoxButton.OK, MessageBoxImage.Information);
_tabManager = new TabManager(WebViewHost, environment, !recoveryMode);
_tabManager.ActiveTabStateChanged += (_, _) => Dispatcher.Invoke(RefreshAddressBar);
DataContext = _tabManager;
await _tabManager.InitializeAsync();
var iconPath = Path.Combine(AppContext.BaseDirectory, "FoxBrowser.ico");
try
{
_audioLabeler = new AudioSessionLabeler((uint)Process.GetCurrentProcess().Id, iconPath);
}
catch
{
// Audio labeling is optional; a missing endpoint must not break browsing.
_audioLabeler = null;
}
PinnedExtensionStrip.ItemsSource = _tabManager.ExtensionManager?.PinnedExtensions;
_watchdogTimer.Tick += async (_, _) => await _tabManager.SoftReloadIfNeededAsync();
_watchdogTimer.Start();
BrowserStatus.Visibility = Visibility.Collapsed;
RefreshAddressBar();
}
catch (Exception ex)
{
BrowserStatus.Text = $"WebView2 не запустився: {ex.Message}";
BrowserStatus.Visibility = Visibility.Visible;
}
}
private async void NewTab_Click(object sender, RoutedEventArgs e)
{
if (_tabManager is not null) await _tabManager.AddTabAsync();
RefreshAddressBar();
}
private async void AddressBox_KeyDown(object sender, System.Windows.Input.KeyEventArgs e)
{
if (e.Key != Key.Enter) return;
await NavigateFromAddressBarAsync();
Keyboard.ClearFocus();
}
private async void Go_Click(object sender, RoutedEventArgs e) => await NavigateFromAddressBarAsync();
private async void AudioOnly_Click(object sender, RoutedEventArgs e)
{
if (_tabManager is null) return;
await _tabManager.ToggleAudioOnlyAsync();
TabStrip.Items.Refresh();
}
private async Task NavigateFromAddressBarAsync()
{
if (_tabManager?.ActiveTab is null) return;
await _tabManager.NavigateAsync(_tabManager.ActiveTab, AddressBox.Text);
AddressBox.Text = _tabManager.ActiveTab.Address;
}
private void Back_Click(object sender, RoutedEventArgs e) => _tabManager?.GoBack();
private void Forward_Click(object sender, RoutedEventArgs e) => _tabManager?.GoForward();
private void Reload_Click(object sender, RoutedEventArgs e) => _tabManager?.Reload();
private void Extensions_Click(object sender, RoutedEventArgs e)
{
if (_tabManager?.ExtensionManager is null) return;
var window = new ExtensionsSettingsWindow(
_tabManager.ExtensionManager,
_tabManager.Environment,
() => _tabManager.OpenUrlAsync("https://chromewebstore.google.com/category/extensions"),
_tabManager.ExtensionsAvailable)
{ Owner = this };
window.ShowDialog();
}
private void PinnedExtension_Click(object sender, RoutedEventArgs e)
{
if (_tabManager?.ExtensionManager is null || sender is not System.Windows.Controls.Button button || button.DataContext is not PinnedExtensionItem item)
return;
if (string.IsNullOrWhiteSpace(item.PopupPage))
{
Extensions_Click(sender, e);
return;
}
var screenPositionWpf = button.PointToScreen(new System.Windows.Point(0, button.ActualHeight));
var screenPosition = new System.Drawing.Point((int)screenPositionWpf.X, (int)screenPositionWpf.Y);
var popup = new ExtensionPopupWindow(_tabManager.Environment, item.Extension.Id, item.PopupPage, screenPosition)
{
Owner = this
};
popup.Show();
}
private async void TabStrip_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (e.Source != TabStrip || TabStrip.SelectedItem is not BrowserTab tab || _tabManager is null) return;
AddressBox.Text = tab.Address;
await _tabManager.SwitchToTabAsync(tab);
RefreshAddressBar();
}
private void CloseTab_Click(object sender, RoutedEventArgs e)
{
if ((sender as System.Windows.Controls.Button)?.Tag is BrowserTab tab) _tabManager?.CloseTab(tab);
RefreshAddressBar();
}
private async void Window_StateChanged(object? sender, EventArgs e)
{
if (_tabManager is null) return;
var minimized = WindowState == WindowState.Minimized;
if (minimized) _tray?.MinimizeToTray();
await _tabManager.SetMinimizedModeAsync(minimized);
}
private async void Window_Closing(object? sender, CancelEventArgs e)
{
if (!_allowClose)
{
e.Cancel = true;
_tray?.MinimizeToTray();
if (_tabManager is not null) await _tabManager.SetMinimizedModeAsync(true);
}
else
{
_watchdogTimer.Stop();
_tray?.Dispose();
_audioLabeler?.Dispose();
if (_tabManager is not null) await _tabManager.DisposeAsync();
}
}
private void ExitApplication()
{
_allowClose = true;
Close();
}
private void ApplyDarkTitleBar(bool enabled)
{
var hwnd = new WindowInteropHelper(this).Handle;
Win32Interop.ApplyDarkTitleBar(hwnd, enabled);
}
private void RefreshAddressBar()
{
if (_tabManager?.ActiveTab is not null)
{
if (!ReferenceEquals(TabStrip.SelectedItem, _tabManager.ActiveTab)) TabStrip.SelectedItem = _tabManager.ActiveTab;
AddressBox.Text = _tabManager.ActiveTab.Address;
}
}
}
+280
View File
@@ -0,0 +1,280 @@
# FoxBrowser — інструкція для наступного агента
## Мета проєкту
FoxBrowser — Windows-first легкий фоновий браузер для музики/відео. Мета — не зробити ще один Chromium-клон, а зменшити кількість одночасно живого web-коду, renderer-ів, мережевих ресурсів і візуального навантаження.
Ключовий сценарій: користувач відкриває багато логічних вкладок, але у фоні реально працює одна або кілька медіа-вкладок. Важливо не ламати звук і повертати стан вкладки після перемикання.
## Стек і запуск
- C# / .NET 8;
- WPF;
- Microsoft.Web.WebView2 `1.0.2903.40`;
- Windows 10 1809+ і Microsoft Edge WebView2 Runtime.
Поточний агент працює в Linux sandbox без `dotnet`, Windows SDK та WebView2 Runtime, тому перед релізом потрібно запускати:
```powershell
dotnet restore
dotnet build -c Debug
dotnet run --project .\FoxBrowser.csproj
```
## Що вже зроблено
### Shell і UI
- WPF shell, адресний рядок, назад/вперед/оновити;
- логічні вкладки;
- системний трей;
- кнопка `♫ Audio`;
- явна кнопка `Перейти`;
- address bar синхронізується з активною вкладкою і оновлюється після навігації по посиланнях;
- закриття вкладок виправлено на WPF `Button`.
### Один WebView2
`Core/TabManager.cs` використовує **один спільний WebView2**. `BrowserTab` у `Tab.cs` містить тільки metadata: URL, title, scroll position, media position, прапори Audio/playing/loading.
При перемиканні:
1. поточний URL/scroll/media state зберігається через JS;
2. активною стає інша логічна вкладка;
3. єдиний WebView2 переходить на її URL;
4. після `NavigationCompleted` відновлюються scroll position, currentTime і playback, якщо попереднє медіа грало.
### Resource firewall
Через `WebResourceRequested` блокуються відомі рекламні й аналітичні host-и. У lightweight/minimized режимі блокуються images і fonts. Окремого `CoreWebView2WebResourceContext.Iframe` **не існує**, тому iframe/embed фільтруються тільки консервативно за URL (`/embed/`, `livechat`). Не повертати enum `Iframe` у код.
### Audio / minimal YouTube
Кнопка Audio:
- приховує картинки, SVG, canvas і відеовізуалізацію;
- прибирає частину YouTube UI, рекомендації, comments, chat, Shorts/shelves;
- прибирає preload/prefetch/next;
- залишає media element для звуку.
CSS/JS не може звільнити базову пам’ять JavaScript runtime YouTube. Це оптимізація трафіку/DOM/GPU, а не повна заміна renderer.
### Minimized і watchdog
- при згортанні у трей встановлюється minimized mode;
- нові навігації блокуються, візуальний шар стискається, звук не зупиняється;
- watchdog кожні 30 секунд перевіряє Working Set;
- поріг soft-reload — 850 MB;
- перед reload зберігаються URL, scrollY, currentTime, playing;
- після reload стан відновлюється з повторними спробами через 300 і 1000 мс.
### Native audio boundary
`Core/NativeAudioSession.cs` містить `INativeAudioSession` і WPF `MediaPlayer` для **direct audio URL**. Автоматичного YouTube extractor немає. Це навмисно: extractor нестабільний через зміни YouTube, авторизацію, DRM та правила сервісу.
## Що ще не зроблено
1. Фінальна компіляція на Windows і тест WebView2 Runtime.
2. Реальний вимір RAM/CPU/GPU у старій і новій архітектурі.
3. Надійне збереження cookies/session state між переходами — профіль WebView2 існує, але стан вкладки поки metadata-only.
4. Повне відновлення плейлистів, form state, history та відкритих popup/window.
5. Відокремлений native YouTube audio backend із optional extractor.
6. Win32 always-on-bottom (`SetWinEventHook`) ще не реалізований.
7. Не можна блокувати всі iframe, scripts або media: YouTube зламається. Firewall має бути allow-by-default, deny тільки для перевірених ресурсів.
## Відомі ризики
- `ExecuteScriptAsync` повертає JSON-encoded string; для state parsing використовувати `JsonSerializer.Deserialize<string>(result)`, а не простий `Trim('"')`.
- Перемикання вкладки не повинно повторно запускати navigation через `SelectionChanged`; у `TabManager` є guard для вже активної вкладки.
- Після переходу посиланням address bar оновлюється через `ActiveTabStateChanged`.
- `WasPlaying` не гарантує autoplay: сайт може блокувати `play()` після navigation.
- Сховати `<video>` не означає гарантовано вимкнути video decode.
- SSD може зберігати metadata, але не може замінити RAM для живого JS/audio renderer.
## Що з власним браузерним engine
Повний власний engine HTML/CSS/JS не є практичним наступним кроком. Потрібні DOM/layout, CSS engine, JS engine, network/security sandbox, cookies/CORS, media codecs, WebRTC, DRM і web compatibility. YouTube-сумісний engine — це багаторічний проєкт рівня Chromium/WebKit.
Реалістичні альтернативи:
- власний **browser shell** над системним WebView2;
- один живий WebView2 + metadata tabs;
- власний resource firewall;
- окремий native audio backend для direct streams;
- у далекій перспективі — власний вузький media client, але не власний універсальний HTML engine.
Критерій переходу до іншої технології: спочатку виміряти shared WebView2. Якщо одна активна YouTube-вкладка все одно споживає неприйнятну RAM, не писати engine з нуля; робити окремий lightweight media client/native audio path.
## FEATURE_REQUEST_2026-09 — виконано
### 1. Chrome-сумісні unpacked extensions
У `Core/ExtensionManager.cs` додано роботу з `CoreWebView2Profile`: перечитування встановлених розширень, додавання папки з `manifest.json`, enable/disable через `EnableAsync(bool)` і видалення через `RemoveAsync()`. Важливо: у .NET WebView2 `IsEnabled` — read-only property; його не можна присвоювати напряму.
`MainWindow` створює WebView2 environment з `AreBrowserExtensionsEnabled = true`. У `UI/ExtensionsSettingsWindow.xaml` додано список розширень, перемикач стану, видалення і вибір папки через `FolderBrowserDialog`. WebView2 SDK `1.0.2903.40` містить потрібний API.
Ручний тест Windows:
1. Підготувати unpacked extension folder із `manifest.json`.
2. Запустити FoxBrowser і натиснути **Розширення**.
3. Натиснути **Додати розширення…**, вибрати папку.
4. Перевірити, що extension з’явився у списку.
5. Вимкнути/увімкнути checkbox і перевірити поведінку extension у новій навігації.
6. Видалити extension, перезапустити FoxBrowser і переконатися, що він не повернувся.
### 2. Темна title bar
У `Native/Win32Interop.cs` додано `DwmSetWindowAttribute` з атрибутом 20 і fallback на 19. `MainWindow` викликає `ApplyDarkTitleBar(true)` у `SourceInitialized`, коли HWND уже створений.
Ручний тест Windows:
1. Запустити FoxBrowser на Windows 10 19041+ або Windows 11.
2. Перевірити, що системна title bar темна, а не стандартна світла.
3. Перевірити, що вікно можна згортати, розгортати й закривати без зміни поведінки.
### 3. Прибрана кнопка «Трей» з головного вікна
Кнопка `Трей` видалена з `MainWindow.xaml`. Сам `TrayIconManager` і згортання через системні кнопки вікна залишені. Невикористаний `HideToTray_Click` прибраний із code-behind.
Ручний тест Windows:
1. Перевірити, що у toolbar немає кнопки `Трей`.
2. Натиснути стандартну кнопку minimize.
3. Переконатися, що застосунок ховається у tray.
4. Через tray відкрити його назад.
### 4. Одинарний клік по tray icon
`TrayIconManager` більше не використовує `DoubleClick`. Обробляється `MouseClick` з перевіркою `MouseButtons.Left`. В одному atomic-ланцюжку виставляються `ShowInTaskbar = true`, `Show()`, `WindowState = Normal`, `Activate()`. При приховуванні `ShowInTaskbar = false` виставляється до `Hide()`.
Ручний тест Windows:
1. Запустити FoxBrowser.
2. Згорнути його в tray.
3. Натиснути лівою кнопкою один раз на tray icon.
4. Перевірити, що вікно одразу видиме, активне і має taskbar entry.
5. Перевірити, що правий клік і меню `Показати / сховати` та `Вийти` працюють окремо.
Окремі git commits не створені через I/O-помилку файлової системи змонтованого каталогу; зміни залишені в робочих файлах і описані тут по незалежних етапах.
## Startup recovery fix — 0x8007139F
`MainWindow_Loaded` now retries WebView2 with a disposable recovery user-data folder when the persistent profile returns HRESULT `0x8007139F` (often caused by another WebView2/FoxBrowser process holding the profile lock or by a profile in an invalid state). The normal profile remains the first choice so extensions and cookies persist normally; recovery mode is only a startup fallback.
`TabManager.InitializeAsync` also treats extension-profile enumeration as optional: if extension APIs fail, normal browsing still starts and the extension manager is disabled for that session.
All navigation methods (`GoBack`, `GoForward`, `Reload`, `NavigateAsync`, `SwitchToTabAsync`) now return safely while `CoreWebView2` is not initialized. This prevents the previous `NullReferenceException` when a user clicked a tab or Reload while initialization had failed or was still in progress.
Manual test:
1. Close all FoxBrowser and WebView2 processes in Task Manager and start normally.
2. Start a second instance or leave an old WebView2 profile locked; the new instance should use recovery data instead of showing only an initialization error.
3. Click Reload, Back, Forward and tabs before/while startup completes; no `NullReferenceException` should appear.
4. After normal startup, close the recovery/duplicate process and start again to return to the persistent profile.
## FEATURE_REQUEST_2026-09-25 — частково виконано без запуску команд
### 1. WireGuard VPN — базова CLI-фаза
Додано `Core/VpnManager.cs` і `UI/VpnSettingsWindow.xaml(.cs)`. FoxBrowser:
- шукає зовнішній `wireguard.exe` у Program Files та App Paths registry;
- імпортує `.conf` у `%LOCALAPPDATA%\\FoxBrowser\\VPN`;
- запускає `/installtunnelservice <config>`;
- зупиняє `/uninstalltunnelservice <TunnelName>`;
- читає exit code, stdout/stderr і показує помилку;
- не встановлює WireGuard автоматично;
- показує статус VPN у головній панелі.
Це навмисно не вбудований VPN. Потрібен офіційний WireGuard for Windows.
Ручна перевірка:
1. Встановити WireGuard for Windows з https://www.wireguard.com/install/.
2. Запустити FoxBrowser і натиснути `VPN: Вимкнено`.
3. Імпортувати коректний `.conf`.
4. Вибрати профіль і натиснути Connect.
5. Перевірити статус `Connected` і мережевий маршрут.
6. Натиснути Disconnect і перевірити, що статус повернувся до `Disconnected`.
7. Повторити тест без WireGuard — має бути зрозуміле повідомлення, що зовнішній клієнт не встановлено.
### 2. Extensions settings
`ExtensionsSettingsWindow` уже існує і кнопка `Розширення` залишається в головному toolbar. Воно відкривається модально через `ShowDialog()`, так само як VPN settings window.
### 3a. Нативний плеєр прибрано з UI та code-behind
Кнопку `Native`, нижню панель плеєра, `NativeAudio_Click`, pause/stop handlers і поля `NativeAudioSession`/`yt-dlp` прибрано з `MainWindow`. JS-based Audio mode і state capture у `TabManager` не змінювалися.
`Core/NativeAudioSession.cs` і `Core/YtDlpExtractor.cs` залишені як невикористані source-файли, оскільки поточне обмеження задачі забороняє файлові shell-команди, а видалення не підтримується файловим patch API. Вони більше ніде не підключені й не впливають на runtime.
### 3b. «Тримати вкладки активними» — свідомо НЕ реалізовано напівмірою
Поточний `TabManager` має один shared `WebView2`; тому такий toggle потребує справжнього multi-WebView2 refactor. Простий bool був би брехливим: сторінки все одно перезавантажувалися б при перемиканні. Повний режим має винести resource firewall, navigation events, title/audio tracking, state capture і minimized-mode застосування з жорстко зашитого `_view` у per-tab controller abstraction.
Цей пункт зупинено до окремого рішення/етапу, як і вимагав feature request: не приховувати конфлікт архітектури під виглядом працюючого перемикача. Дефолтний shared режим і всі існуючі memory optimizations залишилися без змін.
Фінальну компіляцію та ручний тест потрібно виконати на Windows вручну.
## Extensions follow-up
Each extension row now has `Налаштування`. FoxBrowser opens the conventional `chrome-extension://<id>/options.html` page. This works only for extensions that actually declare an options page; extensions without one may show a blank/error page.
A `Магазин розширень` button opens the Chrome Web Store in the active FoxBrowser tab. WebView2's supported .NET API installs unpacked extensions from a local folder via `AddBrowserExtensionAsync`; it does not provide a Chrome Web Store/CRX installer API. Therefore the store button is a catalog/browser shortcut, not a direct one-click installer. The user must download/unpack an extension and use `Імпортувати розширення…`.
The WebView2 profile is `%LOCALAPPDATA%\\FoxBrowser\\WebView2`. The source files of an unpacked extension remain in the folder selected during import; WebView2 persists the registration in the profile but does not copy the extension source into the profile.
## Audio identity in Windows
Windows Volume Mixer usually attributes WebView2 page audio to the child process `msedgewebview2.exe`, because that renderer process owns the Chromium audio session. The parent executable name `FoxBrowser.exe` cannot reliably rename that session through ordinary WebView2 APIs. A native WASAPI session-label workaround would need to enumerate child audio sessions and repeatedly set display names; it is version-sensitive and may affect unrelated WebView2 sessions, so it was not added as a fake promise.
A guaranteed `FoxBrowser` audio identity would require a separate native audio pipeline that owns playback itself, which is exactly the native player path removed from the current product because it was not reliable for the intended YouTube scenario.
## 25.09.2026 — extension diagnostics and Windows audio labeler
### Extension diagnostics
The recovery branch now uses the shared `CreateWebViewOptions()` method, so both normal and recovery environments receive `AreBrowserExtensionsEnabled = true`. `TabManager.ExtensionsAvailable` is false when the app had to use the disposable recovery profile or when extension profile enumeration fails. The settings window displays an explicit recovery-mode message instead of silently showing an empty list.
The checkbox remains `Mode=OneWay`; `Toggle_Click` calls `EnableAsync(bool)` and restores the old visual state on failure. `FolderBrowserDialog` now receives an `IWin32Window` owner wrapper created from `WindowInteropHelper(this).Handle`.
Extension options no longer navigate the main shared WebView2. `ExtensionsSettingsWindow` creates its own lazy `WebView2` using the same `CoreWebView2Environment`, displays it inside the settings window, and navigates that isolated control to `chrome-extension://<id>/options.html`. The main tab is not replaced. The Web Store button remains a catalog shortcut; WebView2 still cannot install directly from the store.
### Audio session labeler
Added `Core/AudioSessionLabeler.cs` and the `NAudio 2.2.1` package reference. The labeler enumerates the process tree from the FoxBrowser PID, keeps only descendant `msedgewebview2.exe` PIDs, and changes `DisplayName`/`IconPath` only for matching WASAPI sessions. It subscribes to NAudio `OnSessionCreated` and also rescans every two seconds because session labels live only for the lifetime of each WASAPI session. It does not touch unrelated Edge, Teams, Outlook, or other WebView2 sessions.
The existing `FoxBrowser.ico` is already present and is used for the audio session icon. The labeler starts after WebView2 initialization and is disposed during application shutdown.
Final build and runtime verification remain Windows-only.
## Fix: extension options reported «file not found»
The previous implementation always navigated to `chrome-extension://<id>/options.html`. That is not valid for many extensions: Manifest V3 commonly declares `options_ui.page`, and older extensions may use `options_page`, with arbitrary relative paths.
`ExtensionManager` now reads `manifest.json` during import, extracts `options_ui.page` first and `options_page` second, normalizes the relative path, and persists the mapping in `%LOCALAPPDATA%\\FoxBrowser\\extension-options.json`. The isolated options WebView2 navigates to the stored manifest path instead of a hard-coded `options.html`.
Extensions imported before this fix have no stored manifest mapping. For those, remove the extension from FoxBrowser and import its folder again once. If the manifest has no `options_ui.page` or `options_page`, the extension simply has no options page and the button reports that clearly instead of showing a misleading file-not-found navigation.
## 26.09.2026 — extension UX and VPN removal
- Added `Назад до списку розширень` inside the isolated options WebView2 view. It hides the options view and restores the extension list without closing the settings window.
- Added persisted pinning via `ExtensionManager`: IDs are stored in `%LOCALAPPDATA%\\FoxBrowser\\pinned-extensions.json`, and `PinnedExtensions` updates live.
- Added a puzzle-piece icon strip near the address bar. Clicking a pinned icon opens the extensions window; the pin state itself is controlled per extension in the settings list.
- Removed the VPN button, field, startup subscription, and VPN handlers from the main browser code. The old VPN source files remain as unreferenced files because this agent session's file API does not support physical deletion; they are no longer used by the application. They can be deleted manually from the project in Visual Studio.
## 26.09.2026 — pinned extension popups and back-button layout
Pinned extension metadata now reads `action.default_popup` / `browser_action.default_popup` and `action.default_icon` / `browser_action.default_icon` from the unpacked extension manifest. It is persisted in `%LOCALAPPDATA%\\FoxBrowser\\extension-ui.json`. The pinned buttons are now on the right side of the address bar, immediately before the general `Розширення` button. An icon file is rendered when available; otherwise the first character of the extension name is used as a fallback glyph.
A pinned extension with `default_popup` opens `ExtensionPopupWindow`, a borderless 360x520 WebView2 window positioned below the clicked button, using the same WebView2 environment and closing on `Deactivated`. Extensions without a popup use the general extension list as the documented fallback. Disabling an extension immediately removes its icon from the pinned strip; re-enabling restores it.
The options view now uses a two-row Grid: a dedicated top row contains the back button and a separate bottom row contains the isolated WebView2. This removes the previous overlay/z-order ambiguity and keeps `Назад до списку розширень` visible whenever options are open.
+20
View File
@@ -0,0 +1,20 @@
using System.Runtime.InteropServices;
namespace FoxBrowser.Native;
internal static class Win32Interop
{
private const int DwmwaUseImmersiveDarkMode = 20;
private const int DwmwaUseImmersiveDarkModeLegacy = 19;
[DllImport("dwmapi.dll", PreserveSig = true)]
private static extern int DwmSetWindowAttribute(nint hwnd, int attribute, ref int value, int size);
public static void ApplyDarkTitleBar(nint hwnd, bool enabled)
{
if (hwnd == 0) return;
var value = enabled ? 1 : 0;
var result = DwmSetWindowAttribute(hwnd, DwmwaUseImmersiveDarkMode, ref value, sizeof(int));
if (result != 0) DwmSetWindowAttribute(hwnd, DwmwaUseImmersiveDarkModeLegacy, ref value, sizeof(int));
}
}
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
<Project>
<PropertyGroup>
<Configuration>Release</Configuration>
<Platform>Any CPU</Platform>
<PublishDir>bin\Release\net8.0-windows\publish\</PublishDir>
<PublishProtocol>FileSystem</PublishProtocol>
<_TargetId>Folder</_TargetId>
</PropertyGroup>
</Project>
+60
View File
@@ -0,0 +1,60 @@
# FoxBrowser
FoxBrowser — Windows-first браузерний shell на **C# / .NET 8 WPF / WebView2**, оптимізований під фонову роботу з мінімальною кількістю живих renderer-ів.
## Поточна архітектура пам'яті
FoxBrowser використовує **один WebView2 на весь застосунок**. Логічні вкладки є лише metadata-об'єктами: URL, назва, scroll position, позиція медіа і прапор Audio Mode.
```text
20 логічних вкладок = 1 живий WebView2 + 19 записів стану
```
При перемиканні вкладки поточний документ зберігає базовий стан, а єдиний WebView2 переходить на URL нової вкладки. Це свідомий компроміс: вкладка відновлюється через навігацію, зате не тримає окремий renderer у пам'яті.
## Реалізовані оптимізації
- один спільний `WebView2` замість WebView2 на кожну вкладку;
- ресурсний firewall через `WebResourceRequested`;
- блокування відомих рекламних і аналітичних доменів;
- Audio/Minimized режим блокує images і fonts;
- у Audio/Minimized режимі блокуються сторонні iframe;
- прибираються `preload`, `prefetch` і `next` посилання;
- невидимі зображення, canvas та відеовізуалізація прибираються CSS-ін'єкцією;
- при згортанні у трей нові навігації блокуються, а візуальний шар стискається;
- watchdog кожні 30 секунд перевіряє Working Set процесу;
- якщо процес перевищує 850 МБ, виконується soft-reload активної сторінки зі збереженням URL, scroll position і позиції медіа;
- стан активного відео/аудіо відновлюється після reload настільки, наскільки це дозволяє сам сайт.
## YouTube Minimal Mode
Кнопка **♫ Audio** вмикає режим, який додатково ховає непотрібний YouTube UI: рекомендації, коментарі, chat, Shorts, зайві полиці та engagement-панелі. Video element залишається для відтворення аудіо, але його візуальний шар стискається до невидимого розміру.
Це зменшує трафік, GPU і кількість зайвих DOM-вузлів, але не може видалити базовий JavaScript runtime YouTube або audio/video decoder. Для цього потрібен окремий аудіоплеєр.
## Native audio backend
Винесення YouTube у native player залишено окремим наступним модулем. WPF/Windows Media Foundation може відтворювати **прямий URL аудіопотоку**, але FoxBrowser не вбудовує нестабільний універсальний YouTube extractor у core браузера: YouTube змінює внутрішні протоколи, частина потоків має DRM, а авторизовані потоки потребують обережної роботи з cookies.
Реалістичний порядок для цього модуля:
1. додати `INativeAudioSession` для прямого audio URL;
2. підключити обраний користувачем extractor як окремий optional-компонент;
3. після отримання audio URL закривати YouTube WebView2;
4. відтворювати звук через Windows Media Foundation, залишаючи вкладку лише як metadata.
## Запуск у Windows
Потрібні Windows 10 версії 1809 або новіше, .NET 8 SDK та Microsoft Edge WebView2 Runtime Evergreen. У Visual Studio відкрийте `FoxBrowser.sln` і виконайте:
```powershell
dotnet restore
dotnet build -c Debug
dotnet run --project .\\FoxBrowser.csproj
```
Поточне Linux-середовище агента не має Windows/.NET SDK, тому фінальну компіляцію і вимірювання RAM потрібно виконати на Windows-машині.
## Як вимірювати результат
Порівнювати потрібно однаковий сценарій: однакові URL, однакова тривалість роботи, однаковий WebView2 Runtime. Зафіксуйте Working Set процесу FoxBrowser через 1, 5 і 20 хвилин у трьох режимах: звичайний, Audio Mode, Audio Mode зі згорнутим вікном. Окремо перевірте, чи не переривається звук після soft-reload.
+34
View File
@@ -0,0 +1,34 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace FoxBrowser;
public sealed class BrowserTab : INotifyPropertyChanged
{
private string _title;
private string _address;
private bool _isAudioOnly;
private bool _isPlayingAudio;
private bool _isLoading;
public BrowserTab(string address)
{
Id = Guid.NewGuid();
_address = address;
_title = "Нова вкладка";
}
public Guid Id { get; }
public string Address { get => _address; set { if (_address == value) return; _address = value; OnPropertyChanged(); } }
public string Title { get => _title; set { if (_title == value) return; _title = value; OnPropertyChanged(); OnPropertyChanged(nameof(DisplayTitle)); } }
public bool IsLoading { get => _isLoading; set { if (_isLoading == value) return; _isLoading = value; OnPropertyChanged(); } }
public bool IsAudioOnly { get => _isAudioOnly; set { if (_isAudioOnly == value) return; _isAudioOnly = value; OnPropertyChanged(); OnPropertyChanged(nameof(DisplayTitle)); } }
public bool IsPlayingAudio { get => _isPlayingAudio; set { if (_isPlayingAudio == value) return; _isPlayingAudio = value; OnPropertyChanged(); OnPropertyChanged(nameof(DisplayTitle)); } }
public double ScrollY { get; set; }
public double MediaPosition { get; set; }
public bool WasPlaying { get; set; }
public string DisplayTitle => $"{(IsPlayingAudio ? "♫ " : IsAudioOnly ? "◌ " : "")}{Title}";
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string? name = null) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
+10
View File
@@ -0,0 +1,10 @@
<Window x:Class="FoxBrowser.UI.ExtensionPopupWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
WindowStyle="None" ResizeMode="NoResize" ShowInTaskbar="False"
Width="360" Height="520" MaxHeight="600"
Background="#202328" AllowsTransparency="False">
<Border BorderBrush="#59616D" BorderThickness="1" Background="#202328">
<Grid x:Name="PopupHost" />
</Border>
</Window>
+39
View File
@@ -0,0 +1,39 @@
using System.Windows;
using Microsoft.Web.WebView2.Core;
using Microsoft.Web.WebView2.Wpf;
namespace FoxBrowser.UI;
public partial class ExtensionPopupWindow : Window
{
private readonly CoreWebView2Environment _environment;
private readonly string _extensionId;
private readonly string _popupPage;
public ExtensionPopupWindow(CoreWebView2Environment environment, string extensionId, string popupPage, System.Drawing.Point screenPosition)
{
InitializeComponent();
_environment = environment;
_extensionId = extensionId;
_popupPage = popupPage;
Left = screenPosition.X;
Top = screenPosition.Y;
Deactivated += (_, _) => Close();
Loaded += async (_, _) => await InitializeAsync();
}
private async Task InitializeAsync()
{
try
{
var view = new WebView2();
PopupHost.Children.Add(view);
await view.EnsureCoreWebView2Async(_environment);
view.CoreWebView2.Navigate($"chrome-extension://{_extensionId}/{_popupPage}");
}
catch
{
Close();
}
}
}
+44
View File
@@ -0,0 +1,44 @@
<Window x:Class="FoxBrowser.UI.ExtensionsSettingsWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Розширення" Width="860" Height="620" MinWidth="560" MinHeight="360"
Background="#15171A" Foreground="#F2F4F7">
<DockPanel Margin="14">
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
<Button Content="Магазин розширень" ToolTip="Відкрити Chrome Web Store; встановлення з магазину не підтримується API WebView2" Click="Store_Click" Padding="12,6" />
<Button Content="Імпортувати розширення…" Click="Add_Click" Padding="12,6" Margin="8,0,0,0" />
<Button Content="Закрити" Click="Close_Click" Padding="12,6" Margin="8,0,0,0" />
</StackPanel>
<TextBlock DockPanel.Dock="Top" Text="Chrome-сумісні unpacked extensions" FontSize="16" FontWeight="SemiBold" Margin="0,0,0,4" />
<TextBlock DockPanel.Dock="Top" Text="Кнопка «Налаштування» відкриває сторінку, вказану в manifest.json: options_ui.page або options_page." Foreground="#AEB4BE" TextWrapping="Wrap" Margin="0,0,0,4" />
<TextBlock DockPanel.Dock="Top" Text="Профіль WebView2: папка Profile поруч із FoxBrowser.exe (у захищеній папці Windows використовується стандартний профіль %LOCALAPPDATA%\FoxBrowser\WebView2). WebView2 зберігає реєстрацію розширення, а файли unpacked залишаються у вибраній тобою папці." Foreground="#AEB4BE" TextWrapping="Wrap" Margin="0,0,0,10" />
<TextBlock x:Name="ExtensionsStatus" DockPanel.Dock="Top" Foreground="#FFCC66" TextWrapping="Wrap" Margin="0,0,0,8" />
<Grid>
<ListBox x:Name="ExtensionList" Background="#202328" BorderBrush="#454C57" Foreground="#F2F4F7">
<ListBox.ItemTemplate>
<DataTemplate>
<Grid Margin="8">
<Grid.ColumnDefinitions><ColumnDefinition Width="*" /><ColumnDefinition Width="Auto" /><ColumnDefinition Width="Auto" /><ColumnDefinition Width="Auto" /><ColumnDefinition Width="Auto" /></Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" />
<TextBlock Text="{Binding Id}" FontSize="11" Foreground="#AEB4BE" />
</StackPanel>
<CheckBox Grid.Column="1" IsChecked="{Binding IsEnabled, Mode=OneWay}" Tag="{Binding}" Click="Toggle_Click" Content="Увімкнено" VerticalAlignment="Center" Margin="8,0" />
<CheckBox x:Name="PinCheckBox" Grid.Column="2" Content="Закріпити" Tag="{Binding}" Click="Pin_Click" VerticalAlignment="Center" Margin="8,0" />
<Button Grid.Column="3" Content="Налаштування" Tag="{Binding}" Click="Options_Click" Padding="8,4" Margin="0,0,6,0" />
<Button Grid.Column="4" Content="Видалити" Tag="{Binding}" Click="Remove_Click" Padding="8,4" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Grid x:Name="OptionsHost" Visibility="Collapsed" Background="#111317">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Button Grid.Row="0" Content="← Назад до списку розширень" Click="BackToList_Click" HorizontalAlignment="Left" Padding="10,6" Margin="8" Panel.ZIndex="2" />
<Grid x:Name="OptionsWebViewHost" Grid.Row="1" />
</Grid>
</Grid>
</DockPanel>
</Window>
+155
View File
@@ -0,0 +1,155 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Interop;
using System.Windows.Media;
using FoxBrowser.Core;
using Microsoft.Web.WebView2.Core;
using Microsoft.Web.WebView2.Wpf;
using Forms = System.Windows.Forms;
using WpfButton = System.Windows.Controls.Button;
using WpfCheckBox = System.Windows.Controls.CheckBox;
namespace FoxBrowser.UI;
public partial class ExtensionsSettingsWindow : Window
{
private readonly ExtensionManager _manager;
private readonly CoreWebView2Environment _environment;
private readonly Func<Task> _openStore;
private WebView2? _optionsView;
public ExtensionsSettingsWindow(ExtensionManager manager, CoreWebView2Environment environment, Func<Task> openStore, bool extensionsAvailable)
{
InitializeComponent();
_manager = manager;
_environment = environment;
_openStore = openStore;
ExtensionList.ItemsSource = _manager.Extensions;
if (!extensionsAvailable)
{
ExtensionsStatus.Text = "Розширення недоступні в цьому сеансі: FoxBrowser запустився у режимі відновлення профілю. Перезапусти FoxBrowser для повернення до звичайного профілю.";
ExtensionList.IsEnabled = false;
}
Loaded += async (_, _) =>
{
await _manager.RefreshAsync();
Dispatcher.BeginInvoke(RefreshPinCheckboxes);
};
}
private async void Add_Click(object sender, RoutedEventArgs e)
{
using var dialog = new Forms.FolderBrowserDialog { Description = "Виберіть розпаковану папку extension з manifest.json" };
var owner = new WindowInteropHelper(this).Handle;
if (dialog.ShowDialog(new NativeWindowWrapper(owner)) != Forms.DialogResult.OK) return;
try
{
await _manager.AddExtensionAsync(dialog.SelectedPath);
Dispatcher.BeginInvoke(RefreshPinCheckboxes);
}
catch (Exception ex) { ShowError(ex.Message); }
}
private async void Toggle_Click(object sender, RoutedEventArgs e)
{
if ((sender as WpfCheckBox)?.Tag is not CoreWebView2BrowserExtension extension) return;
var checkBox = (WpfCheckBox)sender;
try { await _manager.SetEnabledAsync(extension, checkBox.IsChecked == true); }
catch (Exception ex)
{
checkBox.IsChecked = extension.IsEnabled;
ShowError(ex.Message);
}
}
private void Pin_Click(object sender, RoutedEventArgs e)
{
if ((sender as WpfCheckBox)?.Tag is CoreWebView2BrowserExtension extension && sender is WpfCheckBox checkBox)
_manager.SetPinned(extension, checkBox.IsChecked == true);
}
private async void Options_Click(object sender, RoutedEventArgs e)
{
if ((sender as WpfButton)?.Tag is not CoreWebView2BrowserExtension extension) return;
var optionsPage = _manager.GetOptionsPage(extension.Id);
if (string.IsNullOrWhiteSpace(optionsPage))
{
ShowError("Для цього extension не знайдено options_ui.page або options_page. Видали його та імпортуй папку ще раз, щоб FoxBrowser прочитав manifest.json.");
return;
}
try
{
_optionsView ??= await CreateOptionsViewAsync();
ExtensionList.Visibility = Visibility.Collapsed;
OptionsHost.Visibility = Visibility.Visible;
_optionsView.CoreWebView2.Navigate($"chrome-extension://{extension.Id}/{optionsPage}");
}
catch (Exception ex) { ShowError($"Не вдалося відкрити налаштування extension: {ex.Message}"); }
}
private void BackToList_Click(object sender, RoutedEventArgs e)
{
OptionsHost.Visibility = Visibility.Collapsed;
ExtensionList.Visibility = Visibility.Visible;
}
private async Task<WebView2> CreateOptionsViewAsync()
{
var view = new WebView2 { Visibility = Visibility.Visible };
OptionsWebViewHost.Children.Add(view);
await view.EnsureCoreWebView2Async(_environment);
return view;
}
private async void Store_Click(object sender, RoutedEventArgs e)
{
Close();
await _openStore();
}
private async void Remove_Click(object sender, RoutedEventArgs e)
{
if ((sender as WpfButton)?.Tag is CoreWebView2BrowserExtension extension)
{
try { await _manager.RemoveAsync(extension); }
catch (Exception ex) { ShowError(ex.Message); }
}
}
private void RefreshPinCheckboxes()
{
foreach (var extension in _manager.Extensions)
{
if (ExtensionList.ItemContainerGenerator.ContainerFromItem(extension) is not ListBoxItem item) continue;
var pin = FindVisualChild<WpfCheckBox>(item, "PinCheckBox");
if (pin is not null) pin.IsChecked = _manager.IsPinned(extension);
}
}
private static T? FindVisualChild<T>(DependencyObject parent, string name) where T : FrameworkElement
{
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
{
var child = VisualTreeHelper.GetChild(parent, i);
if (child is T match && match.Name == name) return match;
var nested = FindVisualChild<T>(child, name);
if (nested is not null) return nested;
}
return null;
}
private void ShowError(string message) => System.Windows.MessageBox.Show(this, message, "Розширення", MessageBoxButton.OK, MessageBoxImage.Error);
private void Close_Click(object sender, RoutedEventArgs e) => Close();
protected override void OnClosed(EventArgs e)
{
_optionsView?.Dispose();
base.OnClosed(e);
}
private sealed class NativeWindowWrapper : System.Windows.Forms.IWin32Window
{
public NativeWindowWrapper(nint handle) => Handle = handle;
public nint Handle { get; }
}
}
+75
View File
@@ -0,0 +1,75 @@
using System.Drawing;
using System.Windows;
using Forms = System.Windows.Forms;
namespace FoxBrowser.UI;
public sealed class TrayIconManager : IDisposable
{
private readonly Forms.NotifyIcon _icon;
private readonly Window _window;
private readonly Action _exit;
public TrayIconManager(Window window, Action exit)
{
_window = window;
_exit = exit;
Icon? icon = null;
try
{
var icoPath = System.IO.Path.Combine(AppContext.BaseDirectory, "FoxBrowser.ico");
if (System.IO.File.Exists(icoPath)) icon = new Icon(icoPath);
}
catch { }
_icon = new Forms.NotifyIcon
{
Icon = icon ?? SystemIcons.Application,
Text = "FoxBrowser",
Visible = true,
ContextMenuStrip = BuildMenu()
};
_icon.MouseClick += OnMouseClick;
}
public void MinimizeToTray()
{
_window.ShowInTaskbar = false;
_window.Hide();
}
public void ToggleWindow()
{
if (_window.IsVisible && _window.WindowState != WindowState.Minimized)
{
MinimizeToTray();
return;
}
_window.ShowInTaskbar = true;
_window.Show();
_window.WindowState = WindowState.Normal;
_window.Activate();
}
public void Dispose()
{
_icon.MouseClick -= OnMouseClick;
_icon.Visible = false;
_icon.Dispose();
}
private void OnMouseClick(object? sender, Forms.MouseEventArgs e)
{
if (e.Button == Forms.MouseButtons.Left) ToggleWindow();
}
private Forms.ContextMenuStrip BuildMenu()
{
var menu = new Forms.ContextMenuStrip();
menu.Items.Add("Показати / сховати", null, (_, _) => ToggleWindow());
menu.Items.Add("Вийти", null, (_, _) => _exit());
return menu;
}
}
+22
View File
@@ -0,0 +1,22 @@
<Window x:Class="FoxBrowser.UI.VpnSettingsWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="VPN — WireGuard for Windows" Width="620" Height="430" MinWidth="500" MinHeight="320"
Background="#15171A" Foreground="#F2F4F7">
<DockPanel Margin="14">
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
<Button Content="Імпортувати .conf…" Click="Import_Click" Padding="12,6" />
<Button Content="Connect" Click="Connect_Click" Padding="12,6" Margin="8,0,0,0" />
<Button Content="Disconnect" Click="Disconnect_Click" Padding="12,6" Margin="8,0,0,0" />
<Button Content="Закрити" Click="Close_Click" Padding="12,6" Margin="8,0,0,0" />
</StackPanel>
<TextBlock DockPanel.Dock="Top" Text="VPN через зовнішній WireGuard for Windows" FontSize="16" FontWeight="SemiBold" Margin="0,0,0,5" />
<TextBlock DockPanel.Dock="Top" Text="FoxBrowser не вбудовує WireGuard: потрібна окрема офіційна інсталяція WireGuard for Windows." Foreground="#AEB4BE" TextWrapping="Wrap" Margin="0,0,0,12" />
<StackPanel DockPanel.Dock="Bottom" Margin="0,12,0,0">
<TextBlock Text="Статус" Foreground="#AEB4BE" />
<TextBlock x:Name="StatusText" Text="Disconnected" FontSize="18" FontWeight="SemiBold" Margin="0,2,0,0" />
<TextBlock x:Name="ErrorText" Foreground="#FF8C8C" TextWrapping="Wrap" Margin="0,6,0,0" />
</StackPanel>
<ListBox x:Name="ProfileList" Background="#202328" BorderBrush="#454C57" Foreground="#F2F4F7" DisplayMemberPath="Name" />
</DockPanel>
</Window>
+72
View File
@@ -0,0 +1,72 @@
using System.Windows;
using FoxBrowser.Core;
using Forms = System.Windows.Forms;
namespace FoxBrowser.UI;
public partial class VpnSettingsWindow : Window
{
private readonly VpnManager _vpn;
public VpnSettingsWindow(VpnManager vpn)
{
InitializeComponent();
_vpn = vpn;
ProfileList.ItemsSource = _vpn.Profiles;
_vpn.StateChanged += OnStateChanged;
RefreshState();
Closed += (_, _) => _vpn.StateChanged -= OnStateChanged;
}
private void Import_Click(object sender, RoutedEventArgs e)
{
using var dialog = new Forms.OpenFileDialog
{
Filter = "WireGuard config (*.conf)|*.conf|All files (*.*)|*.*",
Title = "Імпортувати WireGuard конфігурацію"
};
if (dialog.ShowDialog() != Forms.DialogResult.OK) return;
try
{
var profile = _vpn.ImportTunnelConfig(dialog.FileName);
ProfileList.SelectedItem = profile;
RefreshState();
}
catch (Exception ex) { ShowError(ex.Message); }
}
private async void Connect_Click(object sender, RoutedEventArgs e)
{
if (ProfileList.SelectedItem is not VpnProfile profile)
{
ShowError("Спочатку імпортуйте або виберіть профіль .conf.");
return;
}
try { await _vpn.ConnectAsync(profile); }
catch (Exception ex) { ShowError(ex.Message); }
finally { RefreshState(); }
}
private async void Disconnect_Click(object sender, RoutedEventArgs e)
{
try { await _vpn.DisconnectAsync(); }
catch (Exception ex) { ShowError(ex.Message); }
finally { RefreshState(); }
}
private void OnStateChanged(object? sender, EventArgs e) => Dispatcher.Invoke(RefreshState);
private void RefreshState()
{
StatusText.Text = _vpn.State.ToString();
ErrorText.Text = _vpn.LastError ?? string.Empty;
}
private void ShowError(string message)
{
ErrorText.Text = message;
System.Windows.MessageBox.Show(this, message, "VPN", MessageBoxButton.OK, MessageBoxImage.Error);
}
private void Close_Click(object sender, RoutedEventArgs e) => Close();
}
+70
View File
@@ -0,0 +1,70 @@
# FoxBrowser — гайд з переходу на Ultralight (замість/поруч з WebView2)
## Контекст і чому це взагалі розглядається
Поточна архітектура (`TabManager.cs`, `NativeAudioSession.cs`) побудована на Microsoft Edge WebView2 — тобто по суті на Chromium/Blink рушії, який вже стоїть у системі. Виграш там — тільки по диску (немає власної копії Chromium в інсталяторі) і по shell-архітектурі (один спільний WebView2, suspend вкладок, audio-only CSS/JS). Базове споживання RAM на сторінку залишається "chromium-рівня", бо рушій той самий.
Диск користувача не хвилює. Хвилює тільки RAM. Тому єдиний спосіб реально зменшити базове споживання пам'яті — це взяти **інший, не Chromium-похідний рушій**. Ultralight — головний кандидат: це форк WebKit (WebCore) зі своїм легким рендерером і JavaScriptCore замість V8, зроблений спеціально для embedding в apps/games, а не для повноцінного браузингу.
**Це не заміна архітектури "один shell — багато логічних вкладок" — це заміна рушія під капотом `TabManager`.** Wrapper-рівень (metadata tabs, watchdog, resource firewall) залишається концептуально тим самим.
## ⚠️ Критичне обмеження — прочитати перед тим, як писати хоч рядок коду
Офіційний FAQ Ultralight прямо каже: **рушій не підтримує WebGL, WebRTC, а підтримка HTML5 Video/Audio — "currently experimental"**. DRM (Widevine) не підтримується взагалі — це навіть не обговорюється в їхніх матеріалах, бо Ultralight не є Chromium-похідним і не має ліцензованого DRM-модуля.
Це напряму б'є по головному сценарію проєкту — YouTube/музичні сервіси:
- Звичайний `<video>`/`<audio>` на YouTube може просто не програватись, або програватися нестабільно, бо аудіо/відео декодування в рушії — експериментальна фіча, а не production-ready шлях.
- Будь-який контент з DRM (YouTube Premium, Spotify Web Player з захищеними треками тощо) гарантовано не запрацює.
- YouTube — важкий сучасний SPA (React-подібний фронтенд, WASM-компоненти). WebCore/JavaScriptCore може мати проблеми сумісності, яких в Blink просто немає, бо весь сучасний веб тестується проти Chromium.
**Практичний висновок:** якщо є ідея "просто підмінити WebView2 на Ultralight і воно так само програє YouTube-звук" — це, ймовірно, не спрацює з коробки. Реалістичний шлях — той, що вже закладений у проєкті через `NativeAudioSession.cs`: не покладатись на рушій узагалі для аудіо-декодування, а брати **direct audio URL** і програвати його нативним `MediaPlayer`/WASAPI в обхід HTML5 `<audio>` елемента. Ultralight в такому разі відповідає тільки за легкий рендер сторінки (UI, DOM, JS-логіка сайту), а не за сам звук.
## Крок 0 — Go/No-Go тест (зробити ДО будь-якого рефакторингу коду)
Не чіпати `TabManager.cs` одразу. Спочатку окремим ізольованим експериментом перевірити, чи Ultralight взагалі відкриває YouTube прийнятно:
1. Завантажити офіційний Ultralight SDK, знайти в ньому Sample #8 ("Browser") — це вже готовий мінімальний браузер на чистому Ultralight.
2. Відкрити в ньому youtube.com, спробувати програти будь-яке відео.
3. Зафіксувати чесно: чи взагалі вантажиться сторінка, чи є звук, чи є відео, наскільки стабільно (не падає/не висне при скролі списку рекомендацій).
4. Порівняти RAM одного відкритого таба в Sample #8 з тим, що зараз показує watchdog для WebView2 на тій самій сторінці.
Якщо на кроці 3 YouTube взагалі не вантажиться або аудіо не програється — не переходити до Кроку 1. У такому разі єдиний life-виправданий сценарій — Ultralight як рушій **тільки для легких некомплексних сторінок** (власний UI, налаштування, прості сайти), а YouTube/аудіо йде повністю через `NativeAudioSession` (direct URL, без рендеру самої сторінки взагалі, або з окремим легким HTML-плеєром власної розробки). Це суттєво звужує роль Ultralight у проєкті — задокументувати це чесно, а не підганяти висновок під бажаний результат.
## Стек і бібліотеки для .NET
Офіційний API Ultralight — тільки C/C++. Готових офіційних .NET-бібліотек від самої Ultralight Inc немає — є community-підтримувані байндинги:
- **UltralightNet** / **UltralightNet.Bundle** (NuGet) — керовані байндинги до C API Ultralight + AppCore (windowing, font loader, filesystem, logger), з нативними бінарниками для `win-x64` вже в пакеті.
```
dotnet add package UltralightNet.Bundle
```
- ⚠️ На момент цього гайду `UltralightNet.Bundle` таргетить **.NET 10**, а поточний проєкт — **.NET 8**. Перед стартом перевірити актуальну версію пакета на nuget.org і чи є білд під .NET 8, або чи є сенс піднімати проєкт до .NET 10 заради цього (окреме рішення, не робити мовчки за замовчуванням).
- Пакет — тонка обгортка над нативним C API, тобто код виглядає не по-WPF-івськи (`renderer.CreateView()`, ручний update/render loop), а не як `CoreWebView2` — буде помітно більше низькорівневого коду, ніж зараз з WebView2.
## Ліцензування — важливо для будь-якого комерційного релізу
Ultralight розповсюджується за "ULTRALIGHT FREE LICENSE AGREEMENT — V1":
- Free/Limited Commercial Distribution дозволена **тільки якщо** юрособа-ліцензіат не мала обороту понад **US$100,000** за останній фінансовий рік.
- Якщо оборот перевищує цей поріг у будь-якому наступному році — усі права на комерційну дистрибуцію автоматично анулюються, і потрібна окрема платна ліцензія напряму з Ultralight Inc.
- Для приватного/некомерційного use case (як зараз, судячи з проєкту) це не проблема, але задокументувати цей поріг, щоб не зʼясувати це post-factum при спробі щось випустити комерційно.
- Заборонено reverse engineering, декомпіляція, спроби дістатись сирців пропрієтарної частини (`UltralightCore`, `Ultralight` view/compositor) — сама `WebCore` (layout engine, форк WebKit) відкрита під LGPL/BSD.
## Архітектурні відмінності від WebView2, які треба врахувати в коді
- **Single process, без IPC** — на відміну від WebView2/Chromium, де кожен renderer — окремий процес (`msedgewebview2.exe` per tab/GPU), Ultralight працює в одному процесі без міжпроцесної ізоляції. Це означає: (а) менше overhead per-tab у теорії, (б) **немає** природної sandbox-ізоляції — крашнута/зависла вкладка може завалити весь застосунок, а не тільки один child-процес. Поточний watchdog (`MemoryWatchdog.cs`) писався в припущенні окремих WebView2-процесів — цю логіку треба переосмислити під single-process модель.
- **Рендер:** GPU-текстура або CPU pixel buffer, керується вручну через `renderer.Update()` / `renderer.Render()` у власному циклі — на відміну від WebView2, де WPF-хостинг рендер-циклу ховає це від вас. Треба буде інтегрувати цей update loop у WPF `CompositionTarget.Rendering` або окремий таймер, і подавати результат як `WriteableBitmap`/текстуру у WPF `Image`-контрол.
- **`WebResourceRequested`-подібного firewall** у тому вигляді, що є зараз (перехоплення мережевих запитів по host/URL) — треба перевірити, чи є еквівалент у Ultralight API (AppCore filesystem/network hooks), задокументована частина цього не гарантована так само детально, як у WebView2.
## Рекомендований план дій для агента
1. **Крок 0 (Go/No-Go тест вище) — обов'язково перший**, і чесно задокументувати результат у цьому файлі чи окремому `ULTRALIGHT_EVAL_RESULTS.md`, включно з цифрами RAM.
2. Якщо результат прийнятний — не переписувати `TabManager.cs` одразу поверх існуючого. Зробити паралельний `Core/UltralightTabManager.cs` (або аналог) як окрему гілку/фіче-флаг, щоб WebView2-шлях лишався робочим fallback, поки Ultralight-шлях не доведено стабільним на реальних сценаріях (не тільки YouTube — плейлисти, перемикання вкладок, тривала фонова робота годинами).
3. Портувати спочатку тільки non-media сторінки (власний UI, прості сайти) на Ultralight, лишаючи медіа-плеєр окремо через `NativeAudioSession` (direct URL) незалежно від того, який рушій рендерить решту.
4. Watchdog і suspend-логіку переписати під single-process модель Ultralight — старі пороги (850 MB soft-reload) писались під конкретну поведінку WebView2/Chromium і, ймовірно, невалідні напряму.
5. Повторно виміряти реальний виграш по RAM (не по відчуттю) на тому самому сценарії, що і в NEXT_AGENT_GUIDE.md — одна довгограюча медіа-вкладка годинами — і чесно порівняти з поточними цифрами WebView2-версії.
## Головний ризик, який не можна замовчати
Якщо Крок 0 покаже, що YouTube на Ultralight банально не працює (немає звуку, ламається UI, падає рушій) — це не привід "трохи допрацювати", а сигнал, що для головного use case проєкту (легкий фоновий плеєр для YouTube/музики) Ultralight як **основний** рушій не підходить, і чесний наступний крок — залишити WebView2 для медіа-сторінок, а Ultralight розглядати хіба що для легких допоміжних вікон (налаштування, non-media UI), де сумісність з сучасним вебом не критична.
+82
View File
@@ -0,0 +1,82 @@
# Результати тестування Ultralight (Крок 0 / Go-No-Go)
Тестування виконано згідно з вимогами `ULTRALIGHT_AGENT_GUIDE.md` на Windows 10 x64, .NET 8, Ultralight v1.3.0 (WebKit 610.4.3.1.4, JavaScriptCore).
---
## 1. Конфігурація середовища тестування
- **Платформа:** Windows 10 (10.0.19045), x64
- **Runtime:** .NET 8.0 (`net8.0-windows`)
- **Бібліотеки:** `UltralightNet` 1.3.0, `UltralightNet.AppCore` 1.3.0, `UltralightNet.Binaries` 1.3.0
- **Нативні ресурси:** `AppCore.dll`, `Ultralight.dll`, `UltralightCore.dll`, `WebCore.dll`, `icudt67l.dat`, `cacert.pem`
- **Тестові цілі:**
1. `about:blank` (перевірка наявності Web/Media API)
2. `https://www.youtube.com/` (головна сторінка YouTube SPA)
3. `https://www.youtube.com/watch?v=dQw4w9WgXcQ` (пряме відео)
---
## 2. Результати тестів
### 2.1. Перевірка медіа API (`canPlayType`, `HTMLVideoElement`, кодеки)
Спроба виклику `canPlayType` завершилася фатальною помилкою:
```
TypeError: document.createElement('video').canPlayType is not a function.
(In 'document.createElement('video').canPlayType(...)', 'document.createElement('video').canPlayType' is undefined)
```
- `HTMLVideoElement`: **відсутній** у global scope (`ReferenceError: Can't find variable: HTMLVideoElement`)
- `HTMLAudioElement`: **відсутній**
- `MediaSource` (MSE): **відсутній**
- `requestMediaKeySystemAccess` (EME / DRM / Widevine): **відсутній**
- `AudioContext` / Web Audio: відсутній
### 2.2. Завантаження головної сторінки YouTube (`https://www.youtube.com/`)
- **DOM:** завантажився частково (1224 елементи), контейнер `<ytd-app>` створений.
- **Помилки JS:** падіння скрипта `kevlar_base` на рядку 5444 через відсутність `HTMLVideoElement`:
```
ReferenceError: Can't find variable: HTMLVideoElement (kevlar_base_module:5444)
```
- **Відеокартки / прев'ю:** 0 знайдених рендерерів через збій у JS-ініціалізації YouTube.
- **Споживання RAM:** **~320 MB** (Single Process).
### 2.3. Завантаження сторінки з відео (`watch?v=dQw4w9WgXcQ`)
- **Заголовок сторінки:** розпізнано коректно:
`Rick Astley - Never Gonna Give You Up (Official Video) (4K Remaster) - YouTube`
- **Плеєр YouTube:** плеєр відображає системне повідомлення про несумісність:
> **«Воспроизвести видео в этом браузере нельзя. Подробнее…»**
- **Відтворення звуку:** **0 / відсутнє**. Звук не грає, потік не декодується.
- **Споживання RAM:** **441.8 MB** (для одного відкритого таба в одному процесі).
---
## 3. Порівняння: Ultralight vs WebView2
| Критерій | WebView2 (Chromium) | Ultralight v1.3.0 |
| :--- | :--- | :--- |
| **HTML5 Video / Audio** | Повноцінна підтримка | **Не працює** (`canPlayType is undefined`) |
| **YouTube HTML5 Player** | Працює ідеально | **«Не можна відтворити відео в цьому браузері»** |
| **DRM / Widevine** | Працює | **Не підтримується взагалі** |
| **Сучасні JS API** | Повна підтримка Blink | Падає на `HTMLVideoElement`, Trusted Types |
| **RAM (одна вкладка YouTube)** | ~350–450 MB (сумарно з renderer/GPU) | **441.8 MB** (один процес, навіть без відео) |
| **Виграш по RAM** | — | **0 MB** (споживання практично однакове) |
---
## 4. Висновок за правилом Go/No-Go (Крок 0)
Згідно з інструкцією `ULTRALIGHT_AGENT_GUIDE.md`:
> *«Якщо на кроці 3 YouTube взагалі не вантажиться або аудіо не програється — не переходити до Кроку 1. ...це не привід "трохи допрацювати", а сигнал, що для головного use case проєкту Ultralight як основний рушій не підходить.»*
### Вердикт: **NO-GO для заміни WebView2 на YouTube**
1. **Ultralight не здатен відтворювати YouTube усередині HTML:** рушій не має реалізації HTML5 Video/Audio кодеків, MSE та EME. YouTube видає плашку «Воспроизвести видео в этом браузере нельзя».
2. **Немає виграшу по RAM:** один процес Ultralight на сторінці YouTube зайняв **441.8 MB**, що відповідає або навіть перевищує оптимізований режим WebView2.
3. **Правильний шлях для проєкту:**
- Для веб-браузингу та навігації — **залишати WebView2**.
- Для ультралегкого звуку без навантаження на рендерер — використовувати вже створений **`NativeAudioSession` + `YtDlpExtractor`** (direct audio stream через WASAPI/MediaPlayer).
- Ultralight можна використовувати виключно для внутрішніх легких UI-віджетів/налаштувань (якщо виникне потреба у чистому локальному HTML-інтерфейсі).
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:asm.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>
+96
View File
@@ -0,0 +1,96 @@
# Промт для агента: FoxBrowser — легкий фоновий браузер
## Задум і чесна оцінка реалістичності
**Три цілі проєкту:**
1. Низьке споживання CPU/RAM порівняно зі звичайними браузерами.
2. Вікно завжди позаду всіх інших вікон (як "приліплений" віджет, а не звичайне вікно застосунку).
3. Не "роздувається" з часом, коли грає фонова музика/відео (напр. плейлист на YouTube з однією вкладкою).
4. (Другий етап) Вбудований VPN через WireGuard-тунель, без потреби в окремому клієнті.
**Важлива чесна засторога перед стартом:** писати власний рушій рендерингу HTML/CSS/JS (як Blink чи Gecko) — це багаторічна робота команд у сотні людей, нереалістично для одного проєкту. Правильний шлях — **не змагатися з Chromium рушієм**, а взяти вже готовий системний рушій (Microsoft Edge WebView2 на Windows) і виграти на рівні: відсутності власної копії Chromium в кожному інсталяторі (Electron тягне ~150+ МБ свого Chromium, WebView2 використовує вже встановлений у системі Edge Runtime), мінімалістичного "chrome" браузера без важкої екосистеми розширень, агресивного керування вкладками/пам'яттю, і унікальних UX-фіч (always-on-bottom, audio-only режим). Це дає реальний, чесний виграш у легкості — але базове споживання пам'яті на сторінку не буде на порядок меншим за сам Edge, бо рушій той самий.
## Рекомендований стек
- **Windows-first, C#/.NET 8 (WPF) + Microsoft.Web.WebView2** — обрано для консистентності з попереднім OCR-проєктом (той самий стек, ті самі патерни Win32-інтеропу для системних фіч), і тому що WebView2 — найлегший шлях отримати сучасний рушій без бандлингу власного Chromium.
- Альтернатива, якщо потрібна кросплатформність з нуля: Rust + Tauri v2 (теж використовує системний WebView, а не власний Chromium) — складніше по стеку, але менший бінарник і нативна швидкість shell-коду.
## Функціональні вимоги
### 1. Базовий shell браузера
- Мультивкладковість: кожна вкладка — окремий `CoreWebView2` контролер (WebView2 підтримує кілька `CoreWebView2Environment`/профілів у одному процесі-хості).
- Мінімалістичний UI: рядок вкладок, адресний рядок, кнопки назад/вперед/оновити — без важкої панелі закладок/історії з великою кількістю віджетів (легкість — і візуальна, і по ресурсах).
- Системний трей: згортання в трей замість закриття, іконка з контекстним меню (Show/Hide, Play/Pause якщо є активна медіа-вкладка, Exit).
### 2. Always-on-bottom (вікно завжди позаду)
- Реалізується через Win32 API, а не WPF-властивості: `SetWindowPos(hwnd, HWND_BOTTOM, ...)` замість звичного `HWND_TOPMOST`.
- **Ключова складність:** Windows постійно "підіймає" активне вікно наверх при фокусі/кліку, тому просто одноразовий `HWND_BOTTOM` не втримає вікно ззаду. Потрібен або: (а) хук на `WM_WINDOWPOSCHANGING`/`WM_ACTIVATE` для повторного примусового `SetWindowPos(HWND_BOTTOM)` щоразу, коли ОС намагається підняти вікно, або (б) `SetWinEventHook` на `EVENT_SYSTEM_FOREGROUND`, який відстежує зміну активного вікна і одразу опускає FoxBrowser назад вниз z-order. Задокументуй обраний підхід і протестуй, що вікно не "мигає" і не краде фокус у користувача під час гри музики.
- Клік по вкладці/елементу керування в самому FoxBrowser (напр. пауза музики) має працювати без переведення вікна на передній план — тобто вікно позаду, але клікабельне (`WS_EX_NOACTIVATE` для сценаріїв, де фокус не потрібен, або контрольоване тимчасове підняття тільки коли користувач свідомо клікає в вікно).
- Гаряча клавіша для "тимчасово підняти на передній план" (напр. коли треба щось вибрати в плеєрі), потім вікно саме повертається вниз.
### 3. Захист від "роздування" пам'яті з часом
- **Моніторинг**: фоновий таймер кожні N хвилин читає RAM/CPU процесу WebView2 (через `Process.WorkingSet64` для дочірніх процесів WebView2 — у них окремі `msedgewebview2.exe` процеси на renderer/GPU).
- **Watchdog для довгограючих вкладок**: якщо пам'ять конкретної вкладки перевищує поріг (наприклад, зросла вдвічі відносно початкового рівня після завантаження) — виконати "м'який" reload сторінки, попередньо зберігши стан програвання через JS injection (`ExecuteScriptAsync`): зчитати з `document` поточний час відео/треку, назву плейлиста/позицію, після reload — виконати JS, який перемотує на збережену позицію і продовжує відтворення. Користувач в ідеалі навіть не помітить короткого "заїкання" звуку.
- **Suspend неактивних вкладок**: вкладки, які не видно і не грають аудіо, вивантажувати (закривати `CoreWebView2Controller`, лишаючи тільки URL+заголовок+favicon у пам'яті), і відновлювати повний рендер лише при переключенні на них — аналогічно "sleeping tabs" в Edge/Chrome, тільки агресивніше за замовчуванням.
- **"Music Mode" (аудіо-only режим)** — головна фіча проти роздування на музичних плейлистах: для розпізнаних медіа-сайтів (YouTube, SoundCloud тощо) пропонувати режим, де відеодоріжка не рендериться взагалі (сторінка завантажується, але відеоелемент приховується/призупиняється у видимості, звук іде далі через Web Audio) — це прибирає найважчу частину навантаження (декодування й рендер відеокадрів), лишаючи тільки аудіодекодування, яке на порядки дешевше по CPU/GPU. Технічно: CSS/JS ін'єкція, яка ховає `<video>` елемент і зупиняє його рендер-цикл, не займаючи паузою сам плеєр (`display: none` на відео не завжди зупиняє декодування в Chromium — треба перевірити практично й, якщо потрібно, додатково `video.pause()` рендер-кадрів через `requestVideoFrameCallback` трюки або обмеження частоти кадрів).
### 4. Вбудований WireGuard VPN (другий етап)
- Не вимагати від користувача встановлювати окремий клієнт WireGuard. Варіанти реалізації, від простішого до "повністю вбудованого":
1. **Найпростіше:** якщо офіційний WireGuard for Windows встановлений, керувати тунелем через його CLI (`wireguard.exe /installtunnelservice config.conf`) з коду застосунку — швидко зробити, але вимагає зовнішньої залежності.
2. **Повністю вбудоване (рекомендовано для фінальної версії):** використати `wintun.dll` (офіційний легкий TUN-драйвер від творців WireGuard, розповсюджується вільно, не вимагає окремої інсталяції — просто кладеш dll поруч з .exe) у парі з **boringtun** (userspace-реалізація WireGuard-протоколу від Cloudflare на Rust, є FFI-бібліотека) або нативним P/Invoke до `wireguard-go`. Це дає тумблер "VPN: Увімкнено/Вимкнено" прямо в налаштуваннях FoxBrowser без жодних зовнішніх застосунків.
- UI: імпорт `.conf`-файлу конфігурації тунелю (стандартний формат WireGuard, який видають VPN-провайдери), список збережених профілів, індикатор стану підключення в тулбарі.
## Архітектура (орієнтовна структура)
```
/FoxBrowser
/Core
TabManager.cs // CoreWebView2 контролери, lifecycle вкладок
WindowStackManager.cs // Win32 always-on-bottom логіка
MemoryWatchdog.cs // моніторинг RAM/CPU, soft-reload довгограючих вкладок
TabSuspender.cs // вивантаження неактивних вкладок
MusicModeInjector.cs // CSS/JS ін'єкція для audio-only режиму
VpnManager.cs // обгортка над wintun/boringtun або wireguard.exe CLI
/UI
MainWindow.xaml(.cs)
TabBar.xaml(.cs)
TrayIconManager.cs
VpnSettingsWindow.xaml(.cs)
/Native
Win32Interop.cs // SetWindowPos, SetWinEventHook, P/Invoke сигнатури
App.xaml(.cs)
```
## Кроки реалізації для агента (фазами — не намагайся зробити все одразу)
**Фаза 1 — робочий shell:**
1. Ініціалізувати WPF-проєкт, додати `Microsoft.Web.WebView2` NuGet.
2. Реалізувати одну вкладку з WebView2, адресний рядок, навігацію.
3. Додати мультивкладковість (`TabManager`, кілька контролерів).
4. Системний трей + згортання замість закриття.
**Фаза 2 — always-on-bottom:**
5. Реалізувати `WindowStackManager` з `SetWinEventHook` на `EVENT_SYSTEM_FOREGROUND`, тестувати на різних сценаріях (Alt+Tab, клік по інших вікнах, повноекранні застосунки).
6. Гаряча клавіша для тимчасового підняття вікна.
**Фаза 3 — керування пам'яттю:**
7. `MemoryWatchdog` — логування споживання по вкладках, поки без автодій, просто щоб бачити цифри.
8. `TabSuspender` для неактивних вкладок.
9. Soft-reload з відновленням позиції відтворення для довгограючих вкладок.
**Фаза 4 — Music Mode:**
10. Детекція медіа-сайтів, UI-перемикач "Audio only", `MusicModeInjector`.
11. Виміряти реальний виграш по CPU/GPU до/після (профайлер Windows Performance Monitor або вбудований Task Manager по процесу `msedgewebview2.exe`) — це треба підтвердити цифрами, а не тільки відчуттями.
**Фаза 5 — VPN:**
12. Почати з найпростішого варіанту (керування зовнішнім `wireguard.exe`), переконатись, що весь флоу працює.
13. Якщо треба повна автономність без зовнішніх застосунків — інтегрувати wintun + boringtun.
## Нефункціональні вимоги
- Кожна фаза має бути окремим працюючим релізом, який можна потестувати — не розтягувати "великий бенг" на весь проєкт одразу.
- Вимірювати реальне споживання пам'яті/CPU до і після кожної оптимізації (Фаза 3-4) — легко обманути себе відчуттям "стало легше", коли насправді змінилось не так уже й багато.
- Задокументувати, які Windows-версії підтримуються (WebView2 Runtime вимагає Windows 10 1809+ або треба бандлити Evergreen-інсталятор для дуже старих систем).
---
Почни з Фази 1 (робочий shell з кількома вкладками і треєм) — саме на цьому етапі одразу стане видно, наскільки WebView2 справді легший за Chrome/Electron у твоєму конкретному сценарії використання, і чи варто взагалі рухатись далі за цим планом.
+11
View File
@@ -0,0 +1,11 @@
[Interface]
PrivateKey = +MRR5qw0gZ2fEpfNKxKDwM+AQUO+5eLSc/2tvmEer1Q=
ListenPort = 51844
Address = 10.66.66.2/32
DNS = 9.9.9.10
[Peer]
PublicKey = YiTmbVnaBH3JE+5YTWUAvynYaui/JCyMXY6lBymRCEg=
AllowedIPs = 0.0.0.0/1, 128.0.0.0/1
Endpoint = vpn.alastorfox.eu.org:51844
PersistentKeepalive = 2