commit fd6bc3459f2c55d551df0d0f6bf9d23a4eb84af6 Author: AlastorFox MAC Date: Thu Oct 1 01:46:18 2026 +0300 BASE changed interface diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1a354a3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.vs/ +bin/ +obj/ +*.user +*.suo diff --git a/25.09.2026.md b/25.09.2026.md new file mode 100644 index 0000000..c157aa7 --- /dev/null +++ b/25.09.2026.md @@ -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:///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` (зазнач це явно в "Потрібно виконати + вручну" — сам пакет не встановлюй, тільки допиши `` + в `.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 працює + правильно й не займає чужі процеси. \ No newline at end of file diff --git a/App.xaml b/App.xaml new file mode 100644 index 0000000..38b2985 --- /dev/null +++ b/App.xaml @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/App.xaml.cs b/App.xaml.cs new file mode 100644 index 0000000..a347296 --- /dev/null +++ b/App.xaml.cs @@ -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; + } +} diff --git a/Core/AudioSessionLabeler.cs b/Core/AudioSessionLabeler.cs new file mode 100644 index 0000000..1d914aa --- /dev/null +++ b/Core/AudioSessionLabeler.cs @@ -0,0 +1,161 @@ +using System.IO; +using System.Runtime.InteropServices; +using NAudio.CoreAudioApi; +using NAudio.CoreAudioApi.Interfaces; + +namespace FoxBrowser.Core; + +/// +/// Labels only audio sessions owned by FoxBrowser's descendant WebView2 processes. +/// It never changes sessions belonging to unrelated WebView2 applications. +/// +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? 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 GetFoxBrowserWebViewPids() + { + var processes = ReadProcesses(); + var children = new Dictionary>(); + foreach (var process in processes) + { + if (!children.TryGetValue(process.ParentId, out var list)) + children[process.ParentId] = list = new List(); + list.Add(process); + } + + var result = new HashSet(); + var pending = new Queue(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 ReadProcesses() + { + var result = new List(); + var snapshot = CreateToolhelp32Snapshot(SnapshotProcess, 0); + if (snapshot == IntPtr.Zero || snapshot == new IntPtr(-1)) return result; + try + { + var entry = new ProcessEntry32 { Size = (uint)Marshal.SizeOf() }; + if (!Process32First(snapshot, ref entry)) return result; + do + { + result.Add(new ProcessRecord(entry.ProcessId, entry.ParentProcessId, entry.ExeFile)); + entry.Size = (uint)Marshal.SizeOf(); + } 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); +} diff --git a/Core/ExtensionManager.cs b/Core/ExtensionManager.cs new file mode 100644 index 0000000..8e2f456 --- /dev/null +++ b/Core/ExtensionManager.cs @@ -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 _optionsPages; + private readonly Dictionary _uiMetadata; + private readonly HashSet _pinnedIds; + + public ExtensionManager(CoreWebView2Profile profile) + { + _profile = profile; + _optionsPages = LoadOptionsPages(); + _uiMetadata = LoadUiMetadata(); + _pinnedIds = LoadPinnedIds(); + } + + public ObservableCollection Extensions { get; } = new(); + public ObservableCollection 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 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 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 LoadOptionsPages() => LoadJson(_metadataPath, new Dictionary(StringComparer.OrdinalIgnoreCase)); + private Dictionary LoadUiMetadata() => LoadJson(_uiMetadataPath, new Dictionary(StringComparer.OrdinalIgnoreCase)); + private HashSet LoadPinnedIds() => LoadJson(_pinnedPath, new HashSet(StringComparer.OrdinalIgnoreCase)); + + private static T LoadJson(string path, T fallback) + { + try { return File.Exists(path) ? JsonSerializer.Deserialize(File.ReadAllText(path)) ?? fallback : fallback; } + catch { return fallback; } + } + + private static void SaveJson(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); +} diff --git a/Core/SessionStore.cs b/Core/SessionStore.cs new file mode 100644 index 0000000..33503dc --- /dev/null +++ b/Core/SessionStore.cs @@ -0,0 +1,96 @@ +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace FoxBrowser.Core; + +/// +/// Snapshot of a single tab persisted to disk. +/// All properties are nullable so old session files remain forward-compatible. +/// +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 Tabs { get; init; } = new(); + public int ActiveTabIndex { get; init; } +} + +/// +/// 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. +/// +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 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 LoadAsync() + { + if (!File.Exists(SessionPath)) return null; + try + { + var json = await File.ReadAllTextAsync(SessionPath); + return JsonSerializer.Deserialize(json); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"[SessionStore] Load failed: {ex.Message}"); + return null; + } + } +} diff --git a/Core/TabManager.cs b/Core/TabManager.cs new file mode 100644 index 0000000..e92474d --- /dev/null +++ b/Core/TabManager.cs @@ -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 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 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(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()), 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)}"; + } +} diff --git a/Core/VpnManager.cs b/Core/VpnManager.cs new file mode 100644 index 0000000..509d656 --- /dev/null +++ b/Core/VpnManager.cs @@ -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(); + RefreshProfiles(); + } + + public ObservableCollection 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; + } +} diff --git a/Core/WebView2EnvironmentFactory.cs b/Core/WebView2EnvironmentFactory.cs new file mode 100644 index 0000000..804ddce --- /dev/null +++ b/Core/WebView2EnvironmentFactory.cs @@ -0,0 +1,190 @@ +using System.IO; +using System.Security; +using Microsoft.Web.WebView2.Core; + +namespace FoxBrowser.Core; + +/// +/// Creates every WebView2 environment with one consistent set of options and +/// chooses the portable profile when the application directory is writable. +/// +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; } + + /// + /// Creates a WebView2 environment. An override is used only by startup + /// recovery, which must always receive its own temporary profile. + /// + public async Task CreateAsync(string? userDataFolderOverride = null) + { + var userDataFolder = userDataFolderOverride ?? SelectPersistentProfile(); + Directory.CreateDirectory(userDataFolder); + + return await CoreWebView2Environment.CreateAsync( + browserExecutableFolder: null, + userDataFolder: userDataFolder, + options: CreateWebViewOptions()); + } + + /// + /// Returns a startup notice once for the protected-installation fallback, + /// and returns a migration error when one occurred during this startup. + /// + 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. + } + } +} diff --git a/FEATURE_REQUEST_2026-09.md b/FEATURE_REQUEST_2026-09.md new file mode 100644 index 0000000..86d9c27 --- /dev/null +++ b/FEATURE_REQUEST_2026-09.md @@ -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, тому фінальну компіляцію й ручний тест виконує користувач). \ No newline at end of file diff --git a/FoxBrowser.csproj b/FoxBrowser.csproj new file mode 100644 index 0000000..478b810 --- /dev/null +++ b/FoxBrowser.csproj @@ -0,0 +1,29 @@ + + + WinExe + net8.0-windows + true + true + enable + enable + FoxBrowser + FoxBrowser + app.manifest + FoxBrowser.ico + + + + + + + + + + + + PreserveNewest + + + + + diff --git a/FoxBrowser.ico b/FoxBrowser.ico new file mode 100644 index 0000000..5b62deb Binary files /dev/null and b/FoxBrowser.ico differ diff --git a/FoxBrowser.sln b/FoxBrowser.sln new file mode 100644 index 0000000..a281007 --- /dev/null +++ b/FoxBrowser.sln @@ -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 diff --git a/MainWindow.xaml b/MainWindow.xaml new file mode 100644 index 0000000..41bdfb1 --- /dev/null +++ b/MainWindow.xaml @@ -0,0 +1,177 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +