BASE changed interface
This commit is contained in:
commit
256bb084c5
30 files changed
+2836
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)}";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user