Compare commits

...
10 Commits
Author SHA1 Message Date
AlastorFox e3445ab15d BASE 1.2.2.1 Fix 2026-10-12 00:05:53 +03:00
AlastorFox 4b80e75a0e BASE 1.2.2 2026-10-11 23:56:50 +03:00
AlastorFox d1337b4d8f BASE Temp 1.2.2 2026-10-11 23:49:55 +03:00
AlastorFox fc96f5a927 BASE 1.2.1 2026-10-02 00:44:14 +03:00
AlastorFox 4d916207a4 Changed how the built-in extensions work and fixed tab loading 2026-10-01 22:36:17 +03:00
AlastorFox 79c1d88b58 Change icon and name 2026-10-01 22:18:11 +03:00
AlastorFox 81d1ed8379 Fix BASE 1.1 2026-10-01 18:31:02 +03:00
AlastorFox 9e684ec067 BASE 1.1 2026-10-01 04:38:11 +03:00
AlastorFox c3e1117b97 Finally install extension on local folder 2026-10-01 02:53:02 +03:00
AlastorFox a602512ca3 Temp install extension 2026-10-01 02:41:50 +03:00
25 changed files with 1678 additions and 243 deletions

No files matched your search

+59
View File
@@ -138,5 +138,64 @@
</Setter.Value>
</Setter>
</Style>
<Style x:Key="FoxContextMenu" TargetType="ContextMenu">
<Setter Property="Background" Value="#171B22" />
<Setter Property="Foreground" Value="{StaticResource TextPrimary}" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="8" />
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="Focusable" Value="False" />
<Setter Property="IsTabStop" Value="False" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ContextMenu">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="8"
Padding="{TemplateBinding Padding}">
<ItemsPresenter />
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="FoxMenuButton" TargetType="Button">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{StaticResource TextPrimary}" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="Padding" Value="9,7" />
<Setter Property="Margin" Value="0,1" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="Focusable" Value="False" />
<Setter Property="IsTabStop" Value="False" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="MenuButtonBorder"
Background="{TemplateBinding Background}"
CornerRadius="6"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="Center" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="MenuButtonBorder" Property="Background" Value="{StaticResource CardBgHover}" />
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="MenuButtonBorder" Property="Background" Value="{StaticResource AurBlueSoft}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Application.Resources>
</Application>
+49
View File
@@ -0,0 +1,49 @@
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace FoxBrowser.Core;
public sealed class AppSettings
{
public string AppDisplayName { get; set; } = "FoxBrowser";
public string? AppIconPath { get; set; }
public Dictionary<string, string> Shortcuts { get; set; } = new();
}
public static class AppSettingsStore
{
public static string SettingsPath => Path.Combine(AppContext.BaseDirectory, "settings.json");
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.Never
};
public static AppSettings Load()
{
try
{
if (!File.Exists(SettingsPath)) return new AppSettings();
var settings = JsonSerializer.Deserialize<AppSettings>(File.ReadAllText(SettingsPath), JsonOptions);
if (settings is null) return new AppSettings();
if (string.IsNullOrWhiteSpace(settings.AppDisplayName)) settings.AppDisplayName = "FoxBrowser";
if (!string.IsNullOrWhiteSpace(settings.AppIconPath) && !File.Exists(settings.AppIconPath))
settings.AppIconPath = null;
return settings;
}
catch
{
return new AppSettings();
}
}
public static void Save(AppSettings settings)
{
Directory.CreateDirectory(AppContext.BaseDirectory);
var tempPath = SettingsPath + ".tmp";
File.WriteAllText(tempPath, JsonSerializer.Serialize(settings, JsonOptions));
File.Move(tempPath, SettingsPath, overwrite: true);
}
}
+12 -3
View File
@@ -13,23 +13,32 @@ public sealed class AudioSessionLabeler : IDisposable
{
private const uint SnapshotProcess = 0x00000002;
private readonly uint _rootProcessId;
private readonly string _iconPath;
private string _displayName;
private 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)
public AudioSessionLabeler(uint rootProcessId, string iconPath, string displayName = "FoxBrowser")
{
_rootProcessId = rootProcessId;
_iconPath = iconPath;
_displayName = string.IsNullOrWhiteSpace(displayName) ? "FoxBrowser" : displayName;
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));
}
public void UpdateIdentity(string displayName, string iconPath)
{
_displayName = string.IsNullOrWhiteSpace(displayName) ? "FoxBrowser" : displayName;
_iconPath = iconPath;
RefreshAndLabel();
}
private void OnSessionCreated(object sender, IAudioSessionControl nativeSession)
{
LabelSession(new AudioSessionControl(nativeSession), disposeAfter: true);
@@ -64,7 +73,7 @@ public sealed class AudioSessionLabeler : IDisposable
{
var ownedPids = knownOwnedPids ?? GetFoxBrowserWebViewPids();
if (!ownedPids.Contains(session.GetProcessID)) return;
session.DisplayName = "FoxBrowser";
session.DisplayName = _displayName;
if (File.Exists(_iconPath)) session.IconPath = _iconPath;
}
catch { /* A session may close between PID lookup and property update. */ }
+75 -3
View File
@@ -1,4 +1,5 @@
using System.Collections.ObjectModel;
using System.IO.Compression;
using System.IO;
using System.Text.Json;
using Microsoft.Web.WebView2.Core;
@@ -18,13 +19,15 @@ public sealed class PinnedExtensionItem
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 string _metadataPath = Path.Combine(AppContext.BaseDirectory, "extension-options.json");
private readonly string _uiMetadataPath = Path.Combine(AppContext.BaseDirectory, "extension-ui.json");
private readonly string _pinnedPath = Path.Combine(AppContext.BaseDirectory, "pinned-extensions.json");
private readonly Dictionary<string, string?> _optionsPages;
private readonly Dictionary<string, ExtensionUiMetadata> _uiMetadata;
private readonly HashSet<string> _pinnedIds;
public static string ExtensionsRootPath => WebView2EnvironmentFactory.PortableExtensionsPath;
public ExtensionManager(CoreWebView2Profile profile)
{
_profile = profile;
@@ -63,6 +66,75 @@ public sealed class ExtensionManager
return extension;
}
public async Task<CoreWebView2BrowserExtension> AddArchiveAsync(string archivePath)
{
if (!File.Exists(archivePath)) throw new FileNotFoundException("Файл extension не знайдено.", archivePath);
var extensionName = Path.GetFileNameWithoutExtension(archivePath);
var safeName = string.Concat(extensionName.Select(ch => char.IsLetterOrDigit(ch) || ch is '-' or '_' ? ch : '_'));
if (string.IsNullOrWhiteSpace(safeName)) safeName = "extension";
var destination = Path.Combine(ExtensionsRootPath, $"{safeName}_{Guid.NewGuid():N}");
Directory.CreateDirectory(destination);
try
{
await using var input = File.OpenRead(archivePath);
using var archive = await OpenZipPayloadAsync(input, archivePath);
archive.ExtractToDirectory(destination);
var manifestFolder = FindManifestFolder(destination);
return await AddExtensionAsync(manifestFolder);
}
catch
{
try { Directory.Delete(destination, recursive: true); } catch { }
throw;
}
}
public static void OpenExtensionsFolder()
{
Directory.CreateDirectory(ExtensionsRootPath);
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = "explorer.exe",
Arguments = $"\"{ExtensionsRootPath}\"",
UseShellExecute = true
});
}
private static string FindManifestFolder(string destination)
{
var rootManifest = Path.Combine(destination, "manifest.json");
if (File.Exists(rootManifest)) return destination;
var nested = Directory.EnumerateFiles(destination, "manifest.json", SearchOption.AllDirectories).FirstOrDefault();
if (nested is null) throw new InvalidDataException("В архіві не знайдено manifest.json.");
return Path.GetDirectoryName(nested)!;
}
private static async Task<ZipArchive> OpenZipPayloadAsync(FileStream input, string archivePath)
{
if (!archivePath.EndsWith(".crx", StringComparison.OrdinalIgnoreCase))
return new ZipArchive(input, ZipArchiveMode.Read, leaveOpen: false);
var header = new byte[16];
var read = await input.ReadAsync(header);
if (read < 16 || header[0] != (byte)'C' || header[1] != (byte)'r' || header[2] != (byte)'2' || header[3] != (byte)'4')
throw new InvalidDataException("Файл CRX має некоректний заголовок.");
var version = BitConverter.ToInt32(header, 4);
var zipOffset = version switch
{
2 => 16 + BitConverter.ToInt32(header, 8) + BitConverter.ToInt32(header, 12),
3 => 12 + BitConverter.ToInt32(header, 8),
_ => throw new InvalidDataException($"Непідтримувана версія CRX: {version}.")
};
input.Position = zipOffset;
var payload = new MemoryStream();
await input.CopyToAsync(payload);
payload.Position = 0;
return new ZipArchive(payload, ZipArchiveMode.Read, leaveOpen: false);
}
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;
+16 -4
View File
@@ -13,6 +13,10 @@ public sealed class TabSnapshot
public string Address { get; init; } = "https://www.google.com";
public string Title { get; init; } = "Нова вкладка";
public bool IsAudioOnly { get; init; }
public bool IsDarkMode { get; init; }
public double DarkBrightness { get; init; } = 0.72;
public double DarkContrast { get; init; } = 1.08;
public double DarkSaturation { get; init; } = 0.88;
public double ScrollY { get; init; }
public double MediaPosition { get; init; }
public bool WasPlaying { get; init; }
@@ -26,16 +30,15 @@ public sealed class Session
/// <summary>
/// Saves and loads the browser session (open tabs + per-tab state) to a JSON
/// file in LocalApplicationData\FoxBrowser\session.json.
/// file in the portable FoxBrowser folder next to the executable.
///
/// 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 string SessionPath = Path.Combine(AppContext.BaseDirectory, "session.json");
private static readonly SemaphoreSlim SaveGate = new(1, 1);
private static readonly JsonSerializerOptions JsonOptions = new()
{
@@ -45,6 +48,7 @@ public static class SessionStore
public static async Task SaveAsync(IEnumerable<BrowserTab> tabs, int activeIndex)
{
await SaveGate.WaitAsync();
try
{
var snapshots = tabs.Select(t => new TabSnapshot
@@ -52,6 +56,10 @@ public static class SessionStore
Address = t.Address,
Title = t.Title,
IsAudioOnly = t.IsAudioOnly,
IsDarkMode = t.IsDarkMode,
DarkBrightness = t.DarkBrightness,
DarkContrast = t.DarkContrast,
DarkSaturation = t.DarkSaturation,
ScrollY = t.ScrollY,
MediaPosition = t.MediaPosition,
WasPlaying = t.WasPlaying,
@@ -77,6 +85,10 @@ public static class SessionStore
{
System.Diagnostics.Debug.WriteLine($"[SessionStore] Save failed: {ex.Message}");
}
finally
{
SaveGate.Release();
}
}
public static async Task<Session?> LoadAsync()
+130
View File
@@ -0,0 +1,130 @@
using System.Collections.Generic;
using System.Linq;
using System.Windows.Input;
namespace FoxBrowser.Core;
public sealed class ShortcutManager
{
public const string CloseTab = "CloseTab";
public const string NewTab = "NewTab";
public const string ToggleTopPanel = "ToggleTopPanel";
private static readonly IReadOnlyDictionary<string, KeyGesture> Defaults =
new Dictionary<string, KeyGesture>
{
[CloseTab] = new(Key.W, ModifierKeys.Control),
[NewTab] = new(Key.N, ModifierKeys.Control),
[ToggleTopPanel] = new(Key.F, ModifierKeys.Control | ModifierKeys.Shift),
};
private static readonly Lazy<ShortcutManager> LazyInstance = new(() => new ShortcutManager());
private readonly Dictionary<string, KeyGesture> _shortcuts = new();
private ShortcutManager()
{
ResetToDefaults(save: false);
Load(AppSettingsStore.Load());
}
public static ShortcutManager Instance => LazyInstance.Value;
public IReadOnlyDictionary<string, KeyGesture> Shortcuts => _shortcuts;
public event EventHandler? Changed;
public KeyGesture Get(string action) => _shortcuts[action];
public string? FindAction(Key key, ModifierKeys modifiers)
{
return _shortcuts.FirstOrDefault(pair => pair.Value.Key == key && pair.Value.Modifiers == modifiers).Key;
}
public bool TrySet(string action, KeyGesture gesture, out string? conflictAction)
{
conflictAction = _shortcuts.FirstOrDefault(pair => pair.Key != action && GesturesEqual(pair.Value, gesture)).Key;
if (!string.IsNullOrEmpty(conflictAction)) return false;
_shortcuts[action] = gesture;
Save();
Changed?.Invoke(this, EventArgs.Empty);
return true;
}
public void ResetToDefaults()
{
ResetToDefaults(save: true);
}
public static string GetActionLabel(string action) => action switch
{
CloseTab => "Закрити вкладку",
NewTab => "Нова вкладка",
ToggleTopPanel => "Показати/сховати верхню панель",
_ => action,
};
public static string Format(KeyGesture gesture)
{
var parts = new List<string>();
if (gesture.Modifiers.HasFlag(ModifierKeys.Control)) parts.Add("Ctrl");
if (gesture.Modifiers.HasFlag(ModifierKeys.Shift)) parts.Add("Shift");
if (gesture.Modifiers.HasFlag(ModifierKeys.Alt)) parts.Add("Alt");
if (gesture.Modifiers.HasFlag(ModifierKeys.Windows)) parts.Add("Win");
parts.Add(gesture.Key.ToString());
return string.Join("+", parts);
}
public static bool TryCreateGesture(Key key, ModifierKeys modifiers, out KeyGesture? gesture)
{
gesture = null;
if (key is Key.LeftCtrl or Key.RightCtrl or Key.LeftShift or Key.RightShift or Key.LeftAlt or Key.RightAlt or Key.LWin or Key.RWin)
return false;
if (key == Key.None || modifiers == ModifierKeys.None)
return false;
gesture = new KeyGesture(key, modifiers);
return true;
}
private void Load(AppSettings settings)
{
if (settings.Shortcuts is null) return;
foreach (var pair in settings.Shortcuts)
{
if (!Defaults.ContainsKey(pair.Key)) continue;
if (TryParse(pair.Value, out var gesture)) _shortcuts[pair.Key] = gesture!;
}
}
private void Save()
{
var settings = AppSettingsStore.Load();
settings.Shortcuts = _shortcuts.ToDictionary(pair => pair.Key, pair => Format(pair.Value));
AppSettingsStore.Save(settings);
}
private void ResetToDefaults(bool save)
{
_shortcuts.Clear();
foreach (var pair in Defaults) _shortcuts[pair.Key] = pair.Value;
if (save) Save();
}
private static bool GesturesEqual(KeyGesture left, KeyGesture right) => left.Key == right.Key && left.Modifiers == right.Modifiers;
private static bool TryParse(string? value, out KeyGesture? gesture)
{
gesture = null;
if (string.IsNullOrWhiteSpace(value)) return false;
var parts = value.Split('+', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length < 2 || !Enum.TryParse(parts[^1], true, out Key key)) return false;
var modifiers = ModifierKeys.None;
foreach (var part in parts[..^1])
{
if (part.Equals("Ctrl", StringComparison.OrdinalIgnoreCase) || part.Equals("Control", StringComparison.OrdinalIgnoreCase)) modifiers |= ModifierKeys.Control;
else if (part.Equals("Shift", StringComparison.OrdinalIgnoreCase)) modifiers |= ModifierKeys.Shift;
else if (part.Equals("Alt", StringComparison.OrdinalIgnoreCase)) modifiers |= ModifierKeys.Alt;
else if (part.Equals("Win", StringComparison.OrdinalIgnoreCase) || part.Equals("Windows", StringComparison.OrdinalIgnoreCase)) modifiers |= ModifierKeys.Windows;
else return false;
}
if (!TryCreateGesture(key, modifiers, out gesture)) return false;
return true;
}
}
+274 -5
View File
@@ -26,6 +26,9 @@ public sealed class TabManager : IAsyncDisposable
private bool _minimizedMode;
private bool _navigationInProgress;
private bool _switchInProgress;
private bool _restoringSession;
private readonly string _blockedElementsPath = Path.Combine(AppContext.BaseDirectory, "blocked-elements.json");
private readonly Dictionary<string, List<string>> _blockedElements;
public TabManager(Grid host, CoreWebView2Environment environment, bool extensionsAvailable = true)
{
@@ -34,12 +37,15 @@ public sealed class TabManager : IAsyncDisposable
ExtensionsAvailable = extensionsAvailable;
_view = new WebView2 { Visibility = Visibility.Visible };
_host.Children.Add(_view);
_blockedElements = LoadBlockedElements();
}
public ObservableCollection<BrowserTab> Tabs { get; } = new();
public BrowserTab? ActiveTab { get; private set; }
public bool IsMinimizedMode => _minimizedMode;
public bool ExtensionsAvailable { get; private set; }
public bool SessionReady { get; private set; }
public bool IsElementPickerActive { get; private set; }
public CoreWebView2Environment Environment => _environment;
public WebView2 View => _view;
public ExtensionManager? ExtensionManager { get; private set; }
@@ -64,7 +70,18 @@ public sealed class TabManager : IAsyncDisposable
_view.CoreWebView2.DocumentTitleChanged += OnDocumentTitleChanged;
_view.CoreWebView2.IsDocumentPlayingAudioChanged += OnAudioStateChanged;
_view.CoreWebView2.HistoryChanged += OnHistoryChanged;
await RestoreSessionOrDefaultAsync();
_view.CoreWebView2.WebMessageReceived += OnWebMessageReceived;
_restoringSession = true;
try
{
await RestoreSessionOrDefaultAsync();
}
finally
{
_restoringSession = false;
SessionReady = true;
}
await SaveSessionAsync();
}
public async Task SaveSessionAsync()
@@ -90,6 +107,10 @@ public sealed class TabManager : IAsyncDisposable
{
Title = snap.Title,
IsAudioOnly = snap.IsAudioOnly,
IsDarkMode = snap.IsDarkMode,
DarkBrightness = snap.DarkBrightness,
DarkContrast = snap.DarkContrast,
DarkSaturation = snap.DarkSaturation,
ScrollY = snap.ScrollY,
MediaPosition = snap.MediaPosition,
WasPlaying = snap.WasPlaying,
@@ -117,11 +138,14 @@ public sealed class TabManager : IAsyncDisposable
try
{
if (ActiveTab is not null) await CaptureActiveStateAsync();
IsElementPickerActive = false;
ActiveTab = tab;
_audioOnly = tab.IsAudioOnly;
ActiveTabStateChanged?.Invoke(this, EventArgs.Empty);
_navigationInProgress = true;
await UpdateDocumentCreatedScriptAsync();
core.Navigate(tab.Address);
if (!_restoringSession) await SaveSessionAsync();
await Task.Yield();
}
finally { _switchInProgress = false; }
@@ -135,6 +159,7 @@ public sealed class TabManager : IAsyncDisposable
tab.Address = NormalizeAddress(address);
if (ActiveTab != tab) { await SwitchToTabAsync(tab); return; }
_navigationInProgress = true;
await UpdateDocumentCreatedScriptAsync();
core.Navigate(tab.Address);
}
@@ -168,6 +193,96 @@ public sealed class TabManager : IAsyncDisposable
await ApplyLightweightPageModeAsync(_audioOnly || _minimizedMode);
}
public async Task ToggleDarkModeAsync()
{
if (ActiveTab is null) return;
ActiveTab.IsDarkMode = !ActiveTab.IsDarkMode;
await ApplyDarkModeAsync(ActiveTab.IsDarkMode);
await SaveSessionAsync();
}
public async Task SetDarkModeParametersAsync(double brightness, double contrast, double saturation)
{
if (ActiveTab is null) return;
ActiveTab.DarkBrightness = Math.Clamp(brightness, 0.45, 1.0);
ActiveTab.DarkContrast = Math.Clamp(contrast, 0.8, 1.4);
ActiveTab.DarkSaturation = Math.Clamp(saturation, 0.0, 1.2);
if (ActiveTab.IsDarkMode) await ApplyDarkModeAsync(true);
await SaveSessionAsync();
}
public async Task StartElementPickerAsync()
{
if (_view.CoreWebView2 is null || ActiveTab is null) return;
if (IsElementPickerActive)
{
await _view.CoreWebView2.ExecuteScriptAsync("window.__foxElementPicker?.();");
IsElementPickerActive = false;
ActiveTabStateChanged?.Invoke(this, EventArgs.Empty);
return;
}
IsElementPickerActive = true;
ActiveTabStateChanged?.Invoke(this, EventArgs.Empty);
await _view.CoreWebView2.ExecuteScriptAsync("""
(()=>{
if(window.__foxElementPicker){window.__foxElementPicker();return;}
const previousCursor=document.documentElement.style.cursor;
const getSelector=(el)=>{
if(el.id)return '#'+CSS.escape(el.id);
const parts=[];
while(el&&el.nodeType===1&&el!==document.body){
let part=el.tagName.toLowerCase();
if(el.classList.length)part+='.'+[...el.classList].slice(0,2).map(CSS.escape).join('.');
const siblings=[...el.parentElement.children].filter(x=>x.tagName===el.tagName);
if(siblings.length>1)part+=`:nth-of-type(${siblings.indexOf(el)+1})`;
parts.unshift(part);el=el.parentElement;
}
return parts.join(' > ');
};
let last=null;
const over=(event)=>{event.stopPropagation();if(last)last.style.outline='';last=event.target;last.style.outline='2px solid #78AFFF';};
const click=(event)=>{event.preventDefault();event.stopPropagation();const selector=getSelector(event.target);cleanup();window.chrome?.webview?.postMessage(JSON.stringify({type:'fox-block-element',selector}));};
const cleanup=()=>{document.removeEventListener('mouseover',over,true);document.removeEventListener('click',click,true);if(last)last.style.outline='';document.documentElement.style.cursor=previousCursor;window.__foxElementPicker=null;};
window.__foxElementPicker=cleanup;document.documentElement.style.cursor='crosshair';document.addEventListener('mouseover',over,true);document.addEventListener('click',click,true);
})();
""");
}
public string? CurrentHost => GetCurrentHost();
public IReadOnlyList<string> GetBlockedSelectors()
{
var host = GetCurrentHost();
return host is not null && _blockedElements.TryGetValue(host, out var selectors)
? selectors.ToArray()
: Array.Empty<string>();
}
public async Task RemoveBlockedSelectorAsync(string selector)
{
var host = GetCurrentHost();
if (host is null || !_blockedElements.TryGetValue(host, out var selectors)) return;
selectors.RemoveAll(x => string.Equals(x, selector, StringComparison.Ordinal));
SaveBlockedElements();
await ApplyBlockedElementsAsync();
}
public async Task ClearBlockedForCurrentHostAsync()
{
var host = GetCurrentHost();
if (host is null) return;
_blockedElements.Remove(host);
SaveBlockedElements();
await ApplyBlockedElementsAsync();
}
public async Task ClearAllBlockedAsync()
{
_blockedElements.Clear();
SaveBlockedElements();
await ApplyBlockedElementsAsync();
}
public async Task SetMinimizedModeAsync(bool enabled)
{
_minimizedMode = enabled;
@@ -187,13 +302,30 @@ public sealed class TabManager : IAsyncDisposable
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)
public async Task CloseTabAsync(BrowserTab tab)
{
var index = Tabs.IndexOf(tab);
if (index < 0) return;
Tabs.Remove(tab);
if (Tabs.Count == 0) { _ = AddTabAsync(); return; }
if (ActiveTab == tab) _ = SwitchToTabAsync(Tabs[Math.Min(index, Tabs.Count - 1)]);
_ = SaveSessionAsync();
if (Tabs.Count == 0)
{
await AddTabAsync();
return;
}
if (ActiveTab == tab)
await SwitchToTabAsync(Tabs[Math.Min(index, Tabs.Count - 1)]);
else
await SaveSessionAsync();
}
public async Task MoveTabAsync(BrowserTab tab, int targetIndex)
{
var sourceIndex = Tabs.IndexOf(tab);
if (sourceIndex < 0 || Tabs.Count < 2) return;
targetIndex = Math.Clamp(targetIndex, 0, Tabs.Count - 1);
if (sourceIndex == targetIndex) return;
Tabs.Move(sourceIndex, targetIndex);
await SaveSessionAsync();
}
public async ValueTask DisposeAsync()
@@ -218,6 +350,8 @@ public sealed class TabManager : IAsyncDisposable
ActiveTab.Title = string.IsNullOrWhiteSpace(_view.CoreWebView2.DocumentTitle) ? "Нова вкладка" : _view.CoreWebView2.DocumentTitle;
ActiveTabStateChanged?.Invoke(this, EventArgs.Empty);
await ApplyLightweightPageModeAsync(_audioOnly || _minimizedMode);
await ApplyDarkModeAsync(ActiveTab.IsDarkMode);
await ApplyBlockedElementsAsync();
await RestoreActiveStateAsync();
await SaveSessionAsync();
}
@@ -242,6 +376,112 @@ public sealed class TabManager : IAsyncDisposable
_ = SaveSessionAsync();
}
private void OnWebMessageReceived(object? sender, CoreWebView2WebMessageReceivedEventArgs e)
{
try
{
var payload = JsonSerializer.Deserialize<string>(e.WebMessageAsJson);
if (string.IsNullOrWhiteSpace(payload)) return;
using var document = JsonDocument.Parse(payload);
if (!document.RootElement.TryGetProperty("type", out var type) || type.GetString() != "fox-block-element") return;
var selector = document.RootElement.GetProperty("selector").GetString();
var host = GetCurrentHost();
if (string.IsNullOrWhiteSpace(selector) || string.IsNullOrWhiteSpace(host)) return;
if (!_blockedElements.TryGetValue(host, out var selectors))
_blockedElements[host] = selectors = new List<string>();
if (!selectors.Contains(selector, StringComparer.Ordinal)) selectors.Add(selector);
IsElementPickerActive = false;
SaveBlockedElements();
ActiveTabStateChanged?.Invoke(this, EventArgs.Empty);
_ = ApplyBlockedElementsAsync();
}
catch (JsonException) { }
}
private async Task ApplyBlockedElementsAsync()
{
if (_view.CoreWebView2 is null) return;
var host = GetCurrentHost();
var selectors = host is not null && _blockedElements.TryGetValue(host, out var savedSelectors)
? savedSelectors
: new List<string>();
var json = JsonSerializer.Serialize(selectors);
var script = """
(()=>{const selectors=__SELECTORS__;let s=document.getElementById('fox-blocked-elements');if(!selectors.length){s?.remove();return;}if(!s){s=document.createElement('style');s.id='fox-blocked-elements';(document.head||document.documentElement).appendChild(s);}s.textContent=selectors.map(x=>x+'{display:none!important;}').join('');})();
""".Replace("__SELECTORS__", json, StringComparison.Ordinal);
try { await _view.CoreWebView2.ExecuteScriptAsync(script); } catch { }
}
private async Task UpdateDocumentCreatedScriptAsync()
{
var core = _view.CoreWebView2;
if (core is null) return;
var host = GetCurrentHost();
var selectors = host is not null && _blockedElements.TryGetValue(host, out var savedSelectors)
? savedSelectors
: new List<string>();
var darkEnabled = ActiveTab?.IsDarkMode == true &&
Uri.TryCreate(ActiveTab.Address, UriKind.Absolute, out var address) &&
(address.Scheme == Uri.UriSchemeHttp || address.Scheme == Uri.UriSchemeHttps);
var brightness = (ActiveTab?.DarkBrightness ?? 0.72).ToString(System.Globalization.CultureInfo.InvariantCulture);
var contrast = (ActiveTab?.DarkContrast ?? 1.08).ToString(System.Globalization.CultureInfo.InvariantCulture);
var saturation = (ActiveTab?.DarkSaturation ?? 0.88).ToString(System.Globalization.CultureInfo.InvariantCulture);
var script = """
(()=>{
const selectors=__SELECTORS__, dark=__DARK__, brightness=__BRIGHTNESS__, contrast=__CONTRAST__, saturation=__SATURATION__;
const apply=()=>{
const root=document.documentElement;
if(!root){setTimeout(apply,0);return;}
let darkStyle=document.getElementById('fox-dark-mode');
if(!darkStyle){darkStyle=document.createElement('style');darkStyle.id='fox-dark-mode';root.appendChild(darkStyle);}
let blockedStyle=document.getElementById('fox-blocked-elements');
if(!blockedStyle){blockedStyle=document.createElement('style');blockedStyle.id='fox-blocked-elements';root.appendChild(blockedStyle);}
const darkCss=dark?'html{background:#111!important;color-scheme:dark!important}html.fox-dark-mode{background:#111!important;color-scheme:dark!important}html.fox-dark-mode body{background-color:#15171b!important;filter:brightness('+brightness+') contrast('+contrast+') saturate('+saturation+')!important}html.fox-dark-mode input,html.fox-dark-mode textarea,html.fox-dark-mode select,html.fox-dark-mode button{color-scheme:dark!important}':'';
darkStyle.textContent=darkCss;
blockedStyle.textContent=selectors.map(x=>x+'{display:none!important;}').join('');
if(dark) root.classList.add('fox-dark-mode'); else root.classList.remove('fox-dark-mode');
};
apply();
})();
"""
.Replace("__SELECTORS__", JsonSerializer.Serialize(selectors), StringComparison.Ordinal)
.Replace("__DARK__", darkEnabled ? "true" : "false", StringComparison.Ordinal)
.Replace("__BRIGHTNESS__", brightness, StringComparison.Ordinal)
.Replace("__CONTRAST__", contrast, StringComparison.Ordinal)
.Replace("__SATURATION__", saturation, StringComparison.Ordinal);
try { await core.AddScriptToExecuteOnDocumentCreatedAsync(script); } catch { }
}
private string? GetCurrentHost()
{
return Uri.TryCreate(ActiveTab?.Address, UriKind.Absolute, out var uri) &&
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)
? uri.Host.ToLowerInvariant()
: null;
}
private Dictionary<string, List<string>> LoadBlockedElements()
{
try
{
if (!File.Exists(_blockedElementsPath)) return new(StringComparer.OrdinalIgnoreCase);
return JsonSerializer.Deserialize<Dictionary<string, List<string>>>(File.ReadAllText(_blockedElementsPath))
?? new(StringComparer.OrdinalIgnoreCase);
}
catch { return new(StringComparer.OrdinalIgnoreCase); }
}
private void SaveBlockedElements()
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(_blockedElementsPath)!);
File.WriteAllText(_blockedElementsPath, JsonSerializer.Serialize(_blockedElements, new JsonSerializerOptions { WriteIndented = true }));
}
catch { }
}
private async Task CaptureActiveStateAsync()
{
if (ActiveTab is null || _view.CoreWebView2 is null) return;
@@ -301,6 +541,35 @@ public sealed class TabManager : IAsyncDisposable
await _view.CoreWebView2.ExecuteScriptAsync(script);
}
private async Task ApplyDarkModeAsync(bool enabled)
{
if (_view.CoreWebView2 is null || ActiveTab is null) return;
if (!Uri.TryCreate(ActiveTab.Address, UriKind.Absolute, out var uri) ||
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) return;
var brightness = ActiveTab.DarkBrightness.ToString(System.Globalization.CultureInfo.InvariantCulture);
var contrast = ActiveTab.DarkContrast.ToString(System.Globalization.CultureInfo.InvariantCulture);
var saturation = ActiveTab.DarkSaturation.ToString(System.Globalization.CultureInfo.InvariantCulture);
var script = enabled
? """
(()=>{
const root=document.documentElement;
if(!root)return;
root.classList.add('fox-dark-mode');
let s=document.getElementById('fox-dark-mode');
if(!s){s=document.createElement('style');s.id='fox-dark-mode';(document.head||root).appendChild(s);}
s.textContent=`html.fox-dark-mode{background:#111!important;color-scheme:dark!important}html.fox-dark-mode body{background-color:#15171b!important;filter:brightness(__BRIGHTNESS__) contrast(__CONTRAST__) saturate(__SATURATION__)!important}html.fox-dark-mode input,html.fox-dark-mode textarea,html.fox-dark-mode select,html.fox-dark-mode button{color-scheme:dark!important}`;
})();
"""
: """
(()=>{document.documentElement?.classList.remove('fox-dark-mode');document.getElementById('fox-dark-mode')?.remove();})();
""";
script = script.Replace("__BRIGHTNESS__", brightness, StringComparison.Ordinal)
.Replace("__CONTRAST__", contrast, StringComparison.Ordinal)
.Replace("__SATURATION__", saturation, StringComparison.Ordinal);
try { await _view.CoreWebView2.ExecuteScriptAsync(script); } catch { }
}
private static string NormalizeAddress(string value)
{
var text = value.Trim();
+1 -3
View File
@@ -27,9 +27,7 @@ public sealed class VpnProfile
public sealed class VpnManager
{
private readonly string _profileDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"FoxBrowser", "VPN");
private readonly string _profileDirectory = Path.Combine(AppContext.BaseDirectory, "VPN");
private string? _activeTunnel;
public VpnManager()
+8 -160
View File
@@ -1,36 +1,28 @@
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.
/// Creates WebView2 using only the portable profile next to FoxBrowser.exe.
/// No AppData/LocalAppData fallback is used.
/// </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;
private const string ExtensionsFolderName = "Extensions";
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; }
public static string PortableExtensionsPath => Path.Combine(AppContext.BaseDirectory, ExtensionsFolderName);
/// <summary>
/// Creates a WebView2 environment. An override is used only by startup
/// recovery, which must always receive its own temporary profile.
/// Kept for startup compatibility. Portable mode never switches to a user profile.
/// </summary>
public string? TakeStartupNotice() => null;
public async Task<CoreWebView2Environment> CreateAsync(string? userDataFolderOverride = null)
{
var userDataFolder = userDataFolderOverride ?? SelectPersistentProfile();
var userDataFolder = userDataFolderOverride ?? PortableProfilePath;
Directory.CreateDirectory(userDataFolder);
return await CoreWebView2Environment.CreateAsync(
@@ -39,152 +31,8 @@ public sealed class WebView2EnvironmentFactory
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.
}
}
}
+129 -19
View File
@@ -4,9 +4,11 @@
Title="FoxBrowser" Width="1180" Height="760" MinWidth="760" MinHeight="480"
Background="{StaticResource WindowBg}" Foreground="{StaticResource TextPrimary}"
Closing="Window_Closing" StateChanged="Window_StateChanged"
UseLayoutRounding="True" SnapsToDevicePixels="True"
TextOptions.TextFormattingMode="Display"
RenderOptions.ClearTypeHint="Enabled">
<Window.Resources>
<QuadraticEase x:Key="TabEase" EasingMode="EaseOut" />
<DataTemplate x:Key="TabTemplate">
<Grid MinWidth="126" MaxWidth="250" Height="34">
<Grid.ColumnDefinitions>
@@ -34,7 +36,33 @@
FontWeight="Bold"
Background="Transparent"
BorderBrush="Transparent"
Foreground="{StaticResource TextMuted}">
Foreground="{StaticResource TextMuted}"
Opacity="0">
<Button.Style>
<Style TargetType="Button">
<Setter Property="Opacity" Value="0" />
<Setter Property="IsHitTestVisible" Value="False" />
<Style.Triggers>
<DataTrigger Binding="{Binding RelativeSource={RelativeSource AncestorType=TabItem}, Path=IsMouseOver}" Value="True">
<Setter Property="IsHitTestVisible" Value="True" />
<DataTrigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetProperty="Opacity" To="1" Duration="0:0:0.12" EasingFunction="{StaticResource TabEase}" />
</Storyboard>
</BeginStoryboard>
</DataTrigger.EnterActions>
<DataTrigger.ExitActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetProperty="Opacity" To="0" Duration="0:0:0.12" EasingFunction="{StaticResource TabEase}" />
</Storyboard>
</BeginStoryboard>
</DataTrigger.ExitActions>
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
<Button.Template>
<ControlTemplate TargetType="Button">
<Border x:Name="CloseBorder" Background="{TemplateBinding Background}" CornerRadius="5">
@@ -57,29 +85,67 @@
<Setter Property="BorderBrush" Value="{StaticResource BorderDark}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Foreground" Value="{StaticResource TextSecondary}" />
<Setter Property="Margin" Value="0,0,6,0" />
<Setter Property="Margin" Value="0" />
<Setter Property="Padding" Value="12,0" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TabItem">
<Border x:Name="TabBorder"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
<Border x:Name="TabBorder"
Background="#191F28"
BorderBrush="#2B3441"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="8"
CornerRadius="8,8,0,0"
Padding="{TemplateBinding Padding}"
Margin="{TemplateBinding Margin}">
Margin="0,0,6,2"
RenderTransformOrigin="0.5,0.5">
<Border.RenderTransform>
<ScaleTransform ScaleX="1" ScaleY="1" />
</Border.RenderTransform>
<ContentPresenter ContentSource="Header" HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="TabBorder" Property="Background" Value="#222A35" />
<Setter TargetName="TabBorder" Property="BorderBrush" Value="{StaticResource BorderMedium}" />
<Trigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<ColorAnimation Storyboard.TargetName="TabBorder" Storyboard.TargetProperty="(Border.Background).(SolidColorBrush.Color)" To="#222A35" Duration="0:0:0.13" EasingFunction="{StaticResource TabEase}" />
<ColorAnimation Storyboard.TargetName="TabBorder" Storyboard.TargetProperty="(Border.BorderBrush).(SolidColorBrush.Color)" To="#46566C" Duration="0:0:0.13" EasingFunction="{StaticResource TabEase}" />
</Storyboard>
</BeginStoryboard>
</Trigger.EnterActions>
<Trigger.ExitActions>
<BeginStoryboard>
<Storyboard>
<ColorAnimation Storyboard.TargetName="TabBorder" Storyboard.TargetProperty="(Border.Background).(SolidColorBrush.Color)" To="#191F28" Duration="0:0:0.13" EasingFunction="{StaticResource TabEase}" />
<ColorAnimation Storyboard.TargetName="TabBorder" Storyboard.TargetProperty="(Border.BorderBrush).(SolidColorBrush.Color)" To="#2B3441" Duration="0:0:0.13" EasingFunction="{StaticResource TabEase}" />
</Storyboard>
</BeginStoryboard>
</Trigger.ExitActions>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="TabBorder" Property="Background" Value="#26354B" />
<Setter TargetName="TabBorder" Property="BorderBrush" Value="{StaticResource AurBlue}" />
<Setter TargetName="TabBorder" Property="Effect">
<Setter.Value>
<DropShadowEffect BlurRadius="7" ShadowDepth="1" Opacity="0.28" Color="#000000" />
</Setter.Value>
</Setter>
<Trigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<ColorAnimation Storyboard.TargetName="TabBorder" Storyboard.TargetProperty="(Border.Background).(SolidColorBrush.Color)" To="#26354B" Duration="0:0:0.14" EasingFunction="{StaticResource TabEase}" />
<ColorAnimation Storyboard.TargetName="TabBorder" Storyboard.TargetProperty="(Border.BorderBrush).(SolidColorBrush.Color)" To="#78AFFF" Duration="0:0:0.14" EasingFunction="{StaticResource TabEase}" />
<DoubleAnimation Storyboard.TargetName="TabBorder" Storyboard.TargetProperty="(UIElement.Opacity)" To="1" Duration="0:0:0.14" EasingFunction="{StaticResource TabEase}" />
</Storyboard>
</BeginStoryboard>
</Trigger.EnterActions>
<Trigger.ExitActions>
<BeginStoryboard>
<Storyboard>
<ColorAnimation Storyboard.TargetName="TabBorder" Storyboard.TargetProperty="(Border.Background).(SolidColorBrush.Color)" To="#191F28" Duration="0:0:0.14" EasingFunction="{StaticResource TabEase}" />
<ColorAnimation Storyboard.TargetName="TabBorder" Storyboard.TargetProperty="(Border.BorderBrush).(SolidColorBrush.Color)" To="#2B3441" Duration="0:0:0.14" EasingFunction="{StaticResource TabEase}" />
</Storyboard>
</BeginStoryboard>
</Trigger.ExitActions>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
@@ -95,23 +161,25 @@
</Window.Resources>
<DockPanel>
<StackPanel x:Name="TopPanel" DockPanel.Dock="Top">
<!-- Tab strip -->
<Border DockPanel.Dock="Top" Background="{StaticResource HeaderBg}" Padding="12,10,12,8" BorderBrush="{StaticResource BorderDark}" BorderThickness="0,0,0,1">
<Border Background="{StaticResource HeaderBg}" Padding="12,10,12,8" BorderBrush="{StaticResource BorderDark}" BorderThickness="0,0,0,1">
<DockPanel>
<StackPanel DockPanel.Dock="Left" Orientation="Horizontal" VerticalAlignment="Center" Margin="2,0,18,0">
<Border Width="28" Height="28" CornerRadius="8" Background="{StaticResource AurBlueSoft}" Margin="0,0,9,0">
<Border Width="32" Height="32" CornerRadius="9" Background="{StaticResource AurBlueSoft}">
<TextBlock Text="🦊" FontSize="16" HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="FoxBrowser" Foreground="{StaticResource TextPrimary}" FontSize="13" FontWeight="Bold" />
<TextBlock Text="Focused browsing" Foreground="{StaticResource TextMuted}" FontSize="10" Margin="0,1,0,0" />
</StackPanel>
</StackPanel>
<Button DockPanel.Dock="Right" Content="+" ToolTip="Нова вкладка" Click="NewTab_Click" Style="{StaticResource IconButton}" Margin="8,0,0,0" />
<TabControl x:Name="TabStrip"
ItemsSource="{Binding Tabs}"
ItemTemplate="{StaticResource TabTemplate}"
SelectionChanged="TabStrip_SelectionChanged"
AllowDrop="True"
PreviewMouseLeftButtonDown="TabStrip_PreviewMouseLeftButtonDown"
PreviewMouseMove="TabStrip_PreviewMouseMove"
DragOver="TabStrip_DragOver"
Drop="TabStrip_Drop"
Background="Transparent"
BorderThickness="0"
Padding="0"
@@ -124,7 +192,7 @@
</Border>
<!-- Browser controls -->
<Border DockPanel.Dock="Top" Background="{StaticResource HeaderBgElevated}" Padding="12,9" BorderBrush="{StaticResource BorderDark}" BorderThickness="0,0,0,1">
<Border Background="{StaticResource HeaderBgElevated}" Padding="12,9" BorderBrush="{StaticResource BorderDark}" BorderThickness="0,0,0,1">
<DockPanel LastChildFill="True">
<StackPanel DockPanel.Dock="Left" Orientation="Horizontal" VerticalAlignment="Center" Margin="0,0,10,0">
<Button Content="←" ToolTip="Назад" Click="Back_Click" Style="{StaticResource IconButton}" />
@@ -142,14 +210,55 @@
<Button Tag="{Binding}" Click="PinnedExtension_Click" Width="32" Height="32" Padding="0" Margin="0,0,4,0" ToolTip="{Binding Name}" Style="{StaticResource IconButton}">
<Grid>
<Image Source="{Binding IconPath}" Width="18" Height="18" Stretch="Uniform" />
<TextBlock Text="{Binding Glyph}" FontWeight="Bold" HorizontalAlignment="Center" VerticalAlignment="Center" />
<TextBlock Text="{Binding Glyph}"
FontWeight="Bold"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Visible" />
<Style.Triggers>
<DataTrigger Binding="{Binding HasIcon}" Value="True">
<Setter Property="Visibility" Value="Collapsed" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Grid>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button x:Name="DarkModeButton" Content="◐" ToolTip="ЛКМ: увімкнути Dark Mode · ПКМ: налаштування" Click="DarkMode_Click" PreviewMouseRightButtonUp="DarkMode_RightClick" Height="32" Width="36" Padding="0" Margin="0,0,4,0" />
<Popup x:Name="DarkModePopup" PlacementTarget="{Binding ElementName=DarkModeButton}" Placement="Bottom" StaysOpen="False" AllowsTransparency="True" PopupAnimation="Fade" Opened="DarkModeMenu_Opened">
<Border Background="#171B22" BorderBrush="#33404E" BorderThickness="1" CornerRadius="8" Padding="14" Width="250">
<StackPanel>
<TextBlock Text="Dark Mode" Foreground="{StaticResource TextPrimary}" FontSize="14" FontWeight="SemiBold" Margin="0,0,0,8" />
<TextBlock Text="Яскравість" Foreground="{StaticResource TextSecondary}" FontSize="11" />
<Slider x:Name="DarkBrightnessSlider" Minimum="0.45" Maximum="1.00" Value="0.72" TickFrequency="0.05" IsSnapToTickEnabled="False" ValueChanged="DarkModeParameter_Changed" Margin="0,2,0,6" />
<TextBlock Text="Контраст" Foreground="{StaticResource TextSecondary}" FontSize="11" />
<Slider x:Name="DarkContrastSlider" Minimum="0.80" Maximum="1.40" Value="1.08" TickFrequency="0.05" IsSnapToTickEnabled="False" ValueChanged="DarkModeParameter_Changed" Margin="0,2,0,6" />
<TextBlock Text="Насиченість" Foreground="{StaticResource TextSecondary}" FontSize="11" />
<Slider x:Name="DarkSaturationSlider" Minimum="0.00" Maximum="1.20" Value="0.88" TickFrequency="0.05" IsSnapToTickEnabled="False" ValueChanged="DarkModeParameter_Changed" Margin="0,2,0,8" />
<Button Content="Скинути параметри" Style="{StaticResource FoxMenuButton}" Click="ResetDarkMode_Click" />
</StackPanel>
</Border>
</Popup>
<Button x:Name="ElementPickerButton" Content="✂" ToolTip="ЛКМ: вибрати елемент · ПКМ: керування вирізаними елементами" Click="ElementPicker_Click" PreviewMouseRightButtonUp="ElementPicker_RightClick" Height="32" Width="36" Padding="0" Margin="0,0,4,0" />
<Popup x:Name="BlockedElementsPopup" PlacementTarget="{Binding ElementName=ElementPickerButton}" Placement="Bottom" StaysOpen="False" AllowsTransparency="True" PopupAnimation="Fade">
<Border Background="#171B22" BorderBrush="#33404E" BorderThickness="1" CornerRadius="8" Padding="14" Width="260">
<StackPanel>
<TextBlock Text="Вирізані елементи" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold" Margin="0,0,0,8" />
<Button Content="Керувати правилами сайту…" Style="{StaticResource FoxMenuButton}" Click="ManageBlockedElements_Click" />
<Button Content="Очистити правила цього сайту" Style="{StaticResource FoxMenuButton}" Click="ClearBlockedSite_Click" />
<Button Content="Очистити всі правила" Style="{StaticResource FoxMenuButton}" Click="ClearAllBlocked_Click" />
</StackPanel>
</Border>
</Popup>
<Button Content="♫" ToolTip="Режим лише аудіо" Click="AudioOnly_Click" Height="32" Width="36" Padding="0" Margin="0,0,4,0" />
<Button Content="Розширення" ToolTip="Керування unpacked extensions" Click="Extensions_Click" Height="32" Padding="11,0" />
<Button Content="⌨" ToolTip="Клавіатурні скорочення" Click="Shortcuts_Click" Height="32" Width="36" Padding="0" Margin="4,0,0,0" />
</StackPanel>
<Grid VerticalAlignment="Center">
@@ -162,6 +271,7 @@
</Grid>
</DockPanel>
</Border>
</StackPanel>
<!-- Web content -->
<Grid x:Name="WebViewHost" Background="#0B0E12">
+405 -19
View File
@@ -3,13 +3,22 @@ using System.Diagnostics;
using System.IO;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Input;
using System.Windows.Interop;
using System.Windows.Media.Animation;
using System.Windows.Threading;
using Microsoft.Web.WebView2.Core;
using FoxBrowser.Core;
using FoxBrowser.Native;
using FoxBrowser.UI;
using WpfDragDropEffects = System.Windows.DragDropEffects;
using WpfDragEventArgs = System.Windows.DragEventArgs;
using WpfKeyEventArgs = System.Windows.Input.KeyEventArgs;
using WpfMouseEventArgs = System.Windows.Input.MouseEventArgs;
using WpfPoint = System.Windows.Point;
using WpfScaleTransform = System.Windows.Media.ScaleTransform;
using WpfTranslateTransform = System.Windows.Media.TranslateTransform;
namespace FoxBrowser;
@@ -18,21 +27,124 @@ public partial class MainWindow : Window
private TabManager? _tabManager;
private TrayIconManager? _tray;
private bool _allowClose;
private bool _closingAfterSessionSave;
private readonly DispatcherTimer _watchdogTimer = new() { Interval = TimeSpan.FromSeconds(30) };
private AudioSessionLabeler? _audioLabeler;
private bool _updatingDarkModeMenu;
private AppSettings _appSettings;
private WpfPoint _tabDragStartPoint;
private BrowserTab? _tabDragItem;
private TabItem? _draggedTabContainer;
private TabItem? _dragPreviewTarget;
private bool _topPanelVisible = true;
private const int WmNcRButtonDown = 0x00A4;
private const int WmNcRButtonUp = 0x00A5;
private const int HtSysMenu = 3;
public MainWindow()
{
InitializeComponent();
_appSettings = AppSettingsStore.Load();
ApplyAppIdentity(_appSettings);
PreviewKeyDown += MainWindow_PreviewKeyDown;
SourceInitialized += MainWindow_SourceInitialized;
Loaded += MainWindow_Loaded;
}
private void MainWindow_SourceInitialized(object? sender, EventArgs e)
{
ApplyDarkTitleBar(true);
var source = HwndSource.FromHwnd(new WindowInteropHelper(this).Handle);
source?.AddHook(MainWindowWndProc);
}
private IntPtr MainWindowWndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
if ((msg == WmNcRButtonDown || msg == WmNcRButtonUp) && wParam.ToInt32() == HtSysMenu)
{
handled = true;
if (msg == WmNcRButtonUp)
Dispatcher.BeginInvoke(OpenAppIdentityMenu);
}
return IntPtr.Zero;
}
private void OpenAppIdentityMenu()
{
var popup = new Popup
{
Placement = PlacementMode.MousePoint,
StaysOpen = false,
AllowsTransparency = true,
PopupAnimation = PopupAnimation.Fade
};
var item = new System.Windows.Controls.Button
{
Content = "Змінити назву й іконку…",
Style = (Style)FindResource("FoxMenuButton")
};
item.Click += (_, _) =>
{
popup.IsOpen = false;
OpenAppSettings();
};
var shortcutsItem = new System.Windows.Controls.Button
{
Content = "Клавіатурні скорочення…",
Style = (Style)FindResource("FoxMenuButton")
};
shortcutsItem.Click += (_, _) =>
{
popup.IsOpen = false;
Shortcuts_Click(this, new RoutedEventArgs());
};
popup.Child = new Border
{
Background = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(23, 27, 34)),
BorderBrush = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(51, 64, 78)),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(8),
Child = new StackPanel
{
Children = { item, shortcutsItem }
}
};
popup.IsOpen = true;
}
private void OpenAppSettings()
{
var window = new AppSettingsWindow(_appSettings, ApplyAppIdentity) { Owner = this };
window.ShowDialog();
}
private void ApplyAppIdentity(AppSettings settings)
{
_appSettings = settings;
var iconPath = ResolveAppIconPath(settings);
Title = string.IsNullOrWhiteSpace(settings.AppDisplayName) ? "FoxBrowser" : settings.AppDisplayName;
try
{
var icoPath = Path.Combine(AppContext.BaseDirectory, "FoxBrowser.ico");
if (File.Exists(icoPath))
Icon = new System.Windows.Media.Imaging.BitmapImage(new Uri(icoPath, UriKind.Absolute));
var image = new System.Windows.Media.Imaging.BitmapImage();
image.BeginInit();
image.UriSource = new Uri(iconPath, UriKind.Absolute);
image.CacheOption = System.Windows.Media.Imaging.BitmapCacheOption.OnLoad;
image.EndInit();
image.Freeze();
Icon = image;
}
catch { }
SourceInitialized += (_, _) => ApplyDarkTitleBar(true);
Loaded += MainWindow_Loaded;
_tray?.UpdateIdentity(Title, iconPath);
_audioLabeler?.UpdateIdentity(Title, iconPath);
}
private static string ResolveAppIconPath(AppSettings settings)
{
if (!string.IsNullOrWhiteSpace(settings.AppIconPath) && File.Exists(settings.AppIconPath))
return settings.AppIconPath;
return Path.Combine(AppContext.BaseDirectory, "FoxBrowser.ico");
}
private async void MainWindow_Loaded(object sender, RoutedEventArgs e)
@@ -40,6 +152,7 @@ public partial class MainWindow : Window
try
{
_tray = new TrayIconManager(this, ExitApplication);
_tray.UpdateIdentity(Title, ResolveAppIconPath(_appSettings));
var environmentFactory = new WebView2EnvironmentFactory();
var recoveryMode = false;
CoreWebView2Environment environment;
@@ -53,7 +166,7 @@ public partial class MainWindow : Window
// Use a disposable recovery profile so the browser can still start.
recoveryMode = true;
var recoveryData = Path.Combine(
Path.GetTempPath(), $"FoxBrowser_Recovery_{Environment.ProcessId}_{Guid.NewGuid():N}");
AppContext.BaseDirectory, $"Profile_Recovery_{Environment.ProcessId}_{Guid.NewGuid():N}");
Directory.CreateDirectory(recoveryData);
environment = await environmentFactory.CreateAsync(recoveryData);
}
@@ -61,13 +174,19 @@ public partial class MainWindow : Window
if (startupNotice is not null)
System.Windows.MessageBox.Show(this, startupNotice, "FoxBrowser", MessageBoxButton.OK, MessageBoxImage.Information);
_tabManager = new TabManager(WebViewHost, environment, !recoveryMode);
_tabManager.ActiveTabStateChanged += (_, _) => Dispatcher.Invoke(RefreshAddressBar);
_tabManager.ActiveTabStateChanged += (_, _) => Dispatcher.Invoke(() =>
{
RefreshAddressBar();
RefreshModeIndicators();
});
DataContext = _tabManager;
await _tabManager.InitializeAsync();
var iconPath = Path.Combine(AppContext.BaseDirectory, "FoxBrowser.ico");
TabStrip.SelectedItem = _tabManager.ActiveTab;
RefreshModeIndicators();
var iconPath = ResolveAppIconPath(_appSettings);
try
{
_audioLabeler = new AudioSessionLabeler((uint)Process.GetCurrentProcess().Id, iconPath);
_audioLabeler = new AudioSessionLabeler((uint)Process.GetCurrentProcess().Id, iconPath, Title);
}
catch
{
@@ -109,28 +228,247 @@ public partial class MainWindow : Window
TabStrip.Items.Refresh();
}
private async void DarkMode_Click(object sender, RoutedEventArgs e)
{
DarkModePopup.IsOpen = false;
if (_tabManager is null) return;
await _tabManager.ToggleDarkModeAsync();
TabStrip.Items.Refresh();
RefreshModeIndicators();
}
private void DarkMode_RightClick(object sender, MouseButtonEventArgs e)
{
e.Handled = true;
DarkModePopup.IsOpen = true;
}
private async void ElementPicker_Click(object sender, RoutedEventArgs e)
{
BlockedElementsPopup.IsOpen = false;
if (_tabManager is null) return;
await _tabManager.StartElementPickerAsync();
RefreshModeIndicators();
}
private void ElementPicker_RightClick(object sender, MouseButtonEventArgs e)
{
e.Handled = true;
BlockedElementsPopup.IsOpen = true;
}
private void ManageBlockedElements_Click(object sender, RoutedEventArgs e)
{
BlockedElementsPopup.IsOpen = false;
if (_tabManager is null) return;
var window = new BlockedElementsWindow(_tabManager) { Owner = this };
window.ShowDialog();
}
private async void ClearBlockedSite_Click(object sender, RoutedEventArgs e)
{
BlockedElementsPopup.IsOpen = false;
if (_tabManager is not null) await _tabManager.ClearBlockedForCurrentHostAsync();
}
private async void ClearAllBlocked_Click(object sender, RoutedEventArgs e)
{
BlockedElementsPopup.IsOpen = false;
if (_tabManager is not null) await _tabManager.ClearAllBlockedAsync();
}
private void DarkModeMenu_Opened(object sender, EventArgs e)
{
if (_tabManager?.ActiveTab is not BrowserTab tab) return;
_updatingDarkModeMenu = true;
DarkBrightnessSlider.Value = tab.DarkBrightness;
DarkContrastSlider.Value = tab.DarkContrast;
DarkSaturationSlider.Value = tab.DarkSaturation;
_updatingDarkModeMenu = false;
}
private async void DarkModeParameter_Changed(object sender, RoutedPropertyChangedEventArgs<double> e)
{
if (_updatingDarkModeMenu || _tabManager is null) return;
await _tabManager.SetDarkModeParametersAsync(
DarkBrightnessSlider.Value,
DarkContrastSlider.Value,
DarkSaturationSlider.Value);
}
private async void ResetDarkMode_Click(object sender, RoutedEventArgs e)
{
_updatingDarkModeMenu = true;
DarkBrightnessSlider.Value = 0.72;
DarkContrastSlider.Value = 1.08;
DarkSaturationSlider.Value = 0.88;
_updatingDarkModeMenu = false;
if (_tabManager is not null)
await _tabManager.SetDarkModeParametersAsync(0.72, 1.08, 0.88);
}
private async Task NavigateFromAddressBarAsync()
{
if (_tabManager?.ActiveTab is null) return;
await _tabManager.NavigateAsync(_tabManager.ActiveTab, AddressBox.Text);
AddressBox.Text = _tabManager.ActiveTab.Address;
AnimateWebViewTransition();
}
private void Back_Click(object sender, RoutedEventArgs e) => _tabManager?.GoBack();
private void Forward_Click(object sender, RoutedEventArgs e) => _tabManager?.GoForward();
private void Reload_Click(object sender, RoutedEventArgs e) => _tabManager?.Reload();
private void Back_Click(object sender, RoutedEventArgs e) { AnimateWebViewTransition(); _tabManager?.GoBack(); }
private void Forward_Click(object sender, RoutedEventArgs e) { AnimateWebViewTransition(); _tabManager?.GoForward(); }
private void Reload_Click(object sender, RoutedEventArgs e) { AnimateWebViewTransition(); _tabManager?.Reload(); }
private void Extensions_Click(object sender, RoutedEventArgs e)
{
if (_tabManager?.ExtensionManager is null) return;
var window = new ExtensionsSettingsWindow(
_tabManager.ExtensionManager,
_tabManager.Environment,
() => _tabManager.OpenUrlAsync("https://chromewebstore.google.com/category/extensions"),
address => _tabManager!.OpenUrlAsync(address),
_tabManager.ExtensionsAvailable)
{ Owner = this };
window.ShowDialog();
}
private void Shortcuts_Click(object sender, RoutedEventArgs e)
{
var window = new ShortcutSettingsWindow { Owner = this };
window.ShowDialog();
}
private async void MainWindow_PreviewKeyDown(object sender, WpfKeyEventArgs e)
{
var key = e.Key == Key.System ? e.SystemKey : e.Key;
var action = ShortcutManager.Instance.FindAction(key, Keyboard.Modifiers);
if (action is null) return;
e.Handled = true;
switch (action)
{
case ShortcutManager.CloseTab when _tabManager?.ActiveTab is not null:
await _tabManager.CloseTabAsync(_tabManager.ActiveTab);
RefreshAddressBar();
break;
case ShortcutManager.NewTab when _tabManager is not null:
await _tabManager.AddTabAsync();
RefreshAddressBar();
break;
case ShortcutManager.ToggleTopPanel:
_topPanelVisible = !_topPanelVisible;
TopPanel.Visibility = _topPanelVisible ? Visibility.Visible : Visibility.Collapsed;
break;
}
}
private void TabStrip_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
_tabDragStartPoint = e.GetPosition(TabStrip);
_tabDragItem = FindTabItem(e.OriginalSource as DependencyObject)?.DataContext as BrowserTab;
}
private void TabStrip_PreviewMouseMove(object sender, WpfMouseEventArgs e)
{
if (e.LeftButton != MouseButtonState.Pressed || _tabDragItem is null) return;
var position = e.GetPosition(TabStrip);
if (Math.Abs(position.X - _tabDragStartPoint.X) < SystemParameters.MinimumHorizontalDragDistance &&
Math.Abs(position.Y - _tabDragStartPoint.Y) < SystemParameters.MinimumVerticalDragDistance) return;
var item = _tabDragItem;
_tabDragItem = null;
_draggedTabContainer = FindTabItem(e.OriginalSource as DependencyObject);
if (_draggedTabContainer is not null)
{
_draggedTabContainer.Opacity = 0.7;
_draggedTabContainer.RenderTransformOrigin = new System.Windows.Point(0.5, 0.5);
_draggedTabContainer.RenderTransform = new System.Windows.Media.ScaleTransform(0.97, 0.97);
}
try
{
System.Windows.DragDrop.DoDragDrop(TabStrip, item, WpfDragDropEffects.Move);
}
finally
{
RestoreDraggedTabVisual();
ResetDragPreview();
}
}
private void TabStrip_DragOver(object sender, WpfDragEventArgs e)
{
e.Effects = e.Data.GetDataPresent(typeof(BrowserTab)) ? WpfDragDropEffects.Move : WpfDragDropEffects.None;
if (e.Data.GetDataPresent(typeof(BrowserTab)))
{
var target = FindTabItem(e.OriginalSource as DependencyObject);
if (target is not null && !ReferenceEquals(target, _draggedTabContainer) && !ReferenceEquals(target, _dragPreviewTarget))
{
ResetDragPreview();
_dragPreviewTarget = target;
var source = e.Data.GetData(typeof(BrowserTab)) as BrowserTab;
var targetTab = target.DataContext as BrowserTab;
var sourceIndex = source is null ? -1 : _tabManager?.Tabs.IndexOf(source) ?? -1;
var targetIndex = targetTab is null ? -1 : _tabManager?.Tabs.IndexOf(targetTab) ?? -1;
var offset = sourceIndex >= 0 && targetIndex >= 0 && sourceIndex < targetIndex ? -6 : 6;
var translation = new System.Windows.Media.TranslateTransform();
target.RenderTransform = translation;
translation.BeginAnimation(System.Windows.Media.TranslateTransform.XProperty,
new DoubleAnimation(offset, new Duration(TimeSpan.FromMilliseconds(120))) { EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } });
}
}
e.Handled = true;
}
private async void TabStrip_Drop(object sender, WpfDragEventArgs e)
{
if (_tabManager is null || !e.Data.GetDataPresent(typeof(BrowserTab))) return;
var source = e.Data.GetData(typeof(BrowserTab)) as BrowserTab;
var target = FindTabItem(e.OriginalSource as DependencyObject)?.DataContext as BrowserTab;
if (source is null || target is null || ReferenceEquals(source, target)) return;
var targetIndex = _tabManager.Tabs.IndexOf(target);
if (targetIndex >= 0) await _tabManager.MoveTabAsync(source, targetIndex);
e.Handled = true;
}
private void RestoreDraggedTabVisual()
{
if (_draggedTabContainer is null) return;
_draggedTabContainer.BeginAnimation(UIElement.OpacityProperty,
new DoubleAnimation(0.7, 1, new Duration(TimeSpan.FromMilliseconds(140))) { EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } });
if (_draggedTabContainer.RenderTransform is WpfScaleTransform scale)
{
scale.BeginAnimation(WpfScaleTransform.ScaleXProperty,
new DoubleAnimation(0.97, 1, new Duration(TimeSpan.FromMilliseconds(140))) { EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } });
scale.BeginAnimation(WpfScaleTransform.ScaleYProperty,
new DoubleAnimation(0.97, 1, new Duration(TimeSpan.FromMilliseconds(140))) { EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } });
}
_draggedTabContainer = null;
}
private void ResetDragPreview()
{
if (_dragPreviewTarget?.RenderTransform is WpfTranslateTransform translation)
{
translation.BeginAnimation(WpfTranslateTransform.XProperty,
new DoubleAnimation(translation.X, 0, new Duration(TimeSpan.FromMilliseconds(120))) { EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } });
}
_dragPreviewTarget = null;
}
private void AnimateWebViewTransition()
{
WebViewHost.BeginAnimation(UIElement.OpacityProperty, null);
WebViewHost.Opacity = 0.85;
WebViewHost.BeginAnimation(UIElement.OpacityProperty,
new DoubleAnimation(0.85, 1, new Duration(TimeSpan.FromMilliseconds(130))) { EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } });
}
private static TabItem? FindTabItem(DependencyObject? element)
{
while (element is not null)
{
if (element is TabItem tabItem) return tabItem;
element = System.Windows.Media.VisualTreeHelper.GetParent(element);
}
return null;
}
private void PinnedExtension_Click(object sender, RoutedEventArgs e)
{
if (_tabManager?.ExtensionManager is null || sender is not System.Windows.Controls.Button button || button.DataContext is not PinnedExtensionItem item)
@@ -138,14 +476,28 @@ public partial class MainWindow : Window
if (string.IsNullOrWhiteSpace(item.PopupPage))
{
System.Windows.MessageBox.Show(
this,
$"Розширення «{item.Name}» не має popup-вікна у manifest.json. Це нормально для частини extension (наприклад, Shazam). Відкриваю список розширень, де можна перевірити його налаштування.",
"Popup розширення",
MessageBoxButton.OK,
MessageBoxImage.Information);
Extensions_Click(sender, e);
return;
}
var screenPositionWpf = button.PointToScreen(new System.Windows.Point(0, button.ActualHeight));
var screenPosition = new System.Drawing.Point((int)screenPositionWpf.X, (int)screenPositionWpf.Y);
var screenPositionDevice = button.PointToScreen(new System.Windows.Point(0, button.ActualHeight));
var source = PresentationSource.FromVisual(button);
var fromDevice = source?.CompositionTarget?.TransformFromDevice ?? System.Windows.Media.Matrix.Identity;
var screenPosition = fromDevice.Transform(screenPositionDevice);
var popup = new ExtensionPopupWindow(_tabManager.Environment, item.Extension.Id, item.PopupPage, screenPosition)
var screen = System.Windows.Forms.Screen.FromPoint(new System.Drawing.Point(
(int)screenPositionDevice.X, (int)screenPositionDevice.Y));
var workAreaTopLeft = fromDevice.Transform(new System.Windows.Point(screen.WorkingArea.Left, screen.WorkingArea.Top));
var workAreaBottomRight = fromDevice.Transform(new System.Windows.Point(screen.WorkingArea.Right, screen.WorkingArea.Bottom));
var workingArea = new System.Windows.Rect(workAreaTopLeft, workAreaBottomRight);
var popup = new ExtensionPopupWindow(_tabManager.Environment, item.Extension.Id, item.PopupPage, screenPosition, workingArea)
{
Owner = this
};
@@ -154,15 +506,18 @@ public partial class MainWindow : Window
private async void TabStrip_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (e.Source != TabStrip || TabStrip.SelectedItem is not BrowserTab tab || _tabManager is null) return;
if (e.Source != TabStrip || TabStrip.SelectedItem is not BrowserTab tab || _tabManager is null || !_tabManager.SessionReady) return;
AddressBox.Text = tab.Address;
await _tabManager.SwitchToTabAsync(tab);
AnimateWebViewTransition();
RefreshAddressBar();
RefreshModeIndicators();
}
private void CloseTab_Click(object sender, RoutedEventArgs e)
private async void CloseTab_Click(object sender, RoutedEventArgs e)
{
if ((sender as System.Windows.Controls.Button)?.Tag is BrowserTab tab) _tabManager?.CloseTab(tab);
if ((sender as System.Windows.Controls.Button)?.Tag is BrowserTab tab && _tabManager is not null)
await _tabManager.CloseTabAsync(tab);
RefreshAddressBar();
}
@@ -184,6 +539,14 @@ public partial class MainWindow : Window
}
else
{
if (!_closingAfterSessionSave)
{
e.Cancel = true;
_closingAfterSessionSave = true;
if (_tabManager is not null) await _tabManager.SaveSessionAsync();
Close();
return;
}
_watchdogTimer.Stop();
_tray?.Dispose();
_audioLabeler?.Dispose();
@@ -211,4 +574,27 @@ public partial class MainWindow : Window
AddressBox.Text = _tabManager.ActiveTab.Address;
}
}
private void RefreshModeIndicators()
{
if (!IsInitialized) return;
var darkActive = _tabManager?.ActiveTab?.IsDarkMode == true;
var pickerActive = _tabManager?.IsElementPickerActive == true;
var activeBackground = (System.Windows.Media.Brush)FindResource("AurBlueSoft");
var activeBorder = (System.Windows.Media.Brush)FindResource("AurBlue");
var inactiveBackground = (System.Windows.Media.Brush)FindResource("CardBg");
var inactiveBorder = (System.Windows.Media.Brush)FindResource("BorderMedium");
DarkModeButton.Background = darkActive ? activeBackground : inactiveBackground;
DarkModeButton.BorderBrush = darkActive ? activeBorder : inactiveBorder;
DarkModeButton.Foreground = darkActive
? (System.Windows.Media.Brush)FindResource("TextPrimary")
: (System.Windows.Media.Brush)FindResource("TextSecondary");
ElementPickerButton.Background = pickerActive ? activeBackground : inactiveBackground;
ElementPickerButton.BorderBrush = pickerActive ? activeBorder : inactiveBorder;
ElementPickerButton.Foreground = pickerActive
? (System.Windows.Media.Brush)FindResource("TextPrimary")
: (System.Windows.Media.Brush)FindResource("TextSecondary");
}
}
+27
View File
@@ -10,6 +10,23 @@ internal static class Win32Interop
[DllImport("dwmapi.dll", PreserveSig = true)]
private static extern int DwmSetWindowAttribute(nint hwnd, int attribute, ref int value, int size);
private const int GwlStyle = -16;
private const nint WsBorder = 0x00800000;
private const nint WsDlgFrame = 0x00400000;
private const uint SwpNoMove = 0x0002;
private const uint SwpNoSize = 0x0001;
private const uint SwpNoZOrder = 0x0004;
private const uint SwpFrameChanged = 0x0020;
[DllImport("user32.dll", EntryPoint = "GetWindowLongPtrW", SetLastError = true)]
private static extern nint GetWindowLongPtr(nint hwnd, int index);
[DllImport("user32.dll", EntryPoint = "SetWindowLongPtrW", SetLastError = true)]
private static extern nint SetWindowLongPtr(nint hwnd, int index, nint value);
[DllImport("user32.dll", SetLastError = true)]
private static extern bool SetWindowPos(nint hwnd, nint insertAfter, int x, int y, int width, int height, uint flags);
public static void ApplyDarkTitleBar(nint hwnd, bool enabled)
{
if (hwnd == 0) return;
@@ -17,4 +34,14 @@ internal static class Win32Interop
var result = DwmSetWindowAttribute(hwnd, DwmwaUseImmersiveDarkMode, ref value, sizeof(int));
if (result != 0) DwmSetWindowAttribute(hwnd, DwmwaUseImmersiveDarkModeLegacy, ref value, sizeof(int));
}
public static void RemovePopupFrame(nint hwnd)
{
if (hwnd == 0) return;
var style = GetWindowLongPtr(hwnd, GwlStyle);
var withoutFrame = style & ~WsBorder & ~WsDlgFrame;
if (style == withoutFrame) return;
SetWindowLongPtr(hwnd, GwlStyle, withoutFrame);
SetWindowPos(hwnd, 0, 0, 0, 0, 0, SwpNoMove | SwpNoSize | SwpNoZOrder | SwpFrameChanged);
}
}
+2
View File
@@ -2,6 +2,8 @@
FoxBrowser — Windows-first браузерний shell на **C# / .NET 8 WPF / WebView2**, оптимізований під фонову роботу з мінімальною кількістю живих renderer-ів.
Правила прихованих елементів зберігаються у `blocked-elements.json` поруч із `FoxBrowser.exe`, щоб portable-версія не створювала для них окремі файли в профілі користувача.
## Поточна архітектура пам'яті
FoxBrowser використовує **один WebView2 на весь застосунок**. Логічні вкладки є лише metadata-об'єктами: URL, назва, scroll position, позиція медіа і прапор Audio Mode.
+5
View File
@@ -8,6 +8,7 @@ public sealed class BrowserTab : INotifyPropertyChanged
private string _title;
private string _address;
private bool _isAudioOnly;
private bool _isDarkMode;
private bool _isPlayingAudio;
private bool _isLoading;
@@ -23,6 +24,10 @@ public sealed class BrowserTab : INotifyPropertyChanged
public string Title { get => _title; set { if (_title == value) return; _title = value; OnPropertyChanged(); OnPropertyChanged(nameof(DisplayTitle)); } }
public bool IsLoading { get => _isLoading; set { if (_isLoading == value) return; _isLoading = value; OnPropertyChanged(); } }
public bool IsAudioOnly { get => _isAudioOnly; set { if (_isAudioOnly == value) return; _isAudioOnly = value; OnPropertyChanged(); OnPropertyChanged(nameof(DisplayTitle)); } }
public bool IsDarkMode { get => _isDarkMode; set { if (_isDarkMode == value) return; _isDarkMode = value; OnPropertyChanged(); } }
public double DarkBrightness { get; set; } = 0.72;
public double DarkContrast { get; set; } = 1.08;
public double DarkSaturation { get; set; } = 0.88;
public bool IsPlayingAudio { get => _isPlayingAudio; set { if (_isPlayingAudio == value) return; _isPlayingAudio = value; OnPropertyChanged(); OnPropertyChanged(nameof(DisplayTitle)); } }
public double ScrollY { get; set; }
public double MediaPosition { get; set; }
+34
View File
@@ -0,0 +1,34 @@
<Window x:Class="FoxBrowser.UI.AppSettingsWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Назва та іконка FoxBrowser" Width="540" Height="270"
MinWidth="480" Background="#15171A" Foreground="#F2F4F7"
WindowStartupLocation="CenterOwner" ResizeMode="NoResize">
<Grid Margin="20">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Text="Назва та іконка застосунку" FontSize="18" FontWeight="SemiBold" Margin="0,0,0,18" />
<Grid Grid.Row="1" Margin="0,0,0,12">
<Grid.ColumnDefinitions><ColumnDefinition Width="140" /><ColumnDefinition Width="*" /></Grid.ColumnDefinitions>
<TextBlock Text="Назва:" VerticalAlignment="Center" />
<TextBox x:Name="NameBox" Grid.Column="1" />
</Grid>
<Grid Grid.Row="2" Margin="0,0,0,8">
<Grid.ColumnDefinitions><ColumnDefinition Width="140" /><ColumnDefinition Width="*" /><ColumnDefinition Width="Auto" /></Grid.ColumnDefinitions>
<TextBlock Text="Іконка:" VerticalAlignment="Center" />
<TextBlock x:Name="IconPathText" Grid.Column="1" VerticalAlignment="Center" Foreground="#AEB4BE" TextTrimming="CharacterEllipsis" />
<Button Grid.Column="2" Content="Обрати іконку…" Click="ChooseIcon_Click" Padding="10,5" Margin="10,0,0,0" />
</Grid>
<TextBlock Grid.Row="3" Text="PNG і JPG автоматично конвертуються у custom_icon.ico поруч із FoxBrowser.exe." Foreground="#AEB4BE" TextWrapping="Wrap" Margin="0,4,0,0" />
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,18,0,0">
<Button Content="Скинути до стандартного" Click="Reset_Click" Padding="10,6" Margin="0,0,8,0" />
<Button Content="Скасувати" Click="Cancel_Click" Padding="10,6" Margin="0,0,8,0" />
<Button Content="Зберегти" Click="Save_Click" Padding="14,6" />
</StackPanel>
</Grid>
</Window>
+112
View File
@@ -0,0 +1,112 @@
using System.Drawing;
using System.IO;
using System.Runtime.InteropServices;
using System.Windows;
using FoxBrowser.Core;
namespace FoxBrowser.UI;
public partial class AppSettingsWindow : Window
{
private readonly Action<AppSettings> _apply;
private string? _iconPath;
public AppSettingsWindow(AppSettings current, Action<AppSettings> apply)
{
InitializeComponent();
_apply = apply;
NameBox.Text = current.AppDisplayName;
_iconPath = current.AppIconPath;
UpdateIconPathText();
}
private void ChooseIcon_Click(object sender, RoutedEventArgs e)
{
var dialog = new Microsoft.Win32.OpenFileDialog
{
Title = "Оберіть іконку застосунку",
Filter = "Зображення та іконки (*.ico;*.png;*.jpg;*.jpeg)|*.ico;*.png;*.jpg;*.jpeg|Усі файли (*.*)|*.*",
CheckFileExists = true
};
if (dialog.ShowDialog(this) != true) return;
try
{
_iconPath = AppIconConverter.CopyAsPortableIcon(dialog.FileName);
UpdateIconPathText();
}
catch (Exception ex)
{
System.Windows.MessageBox.Show(this, $"Не вдалося підготувати іконку: {ex.Message}", "Іконка", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void Save_Click(object sender, RoutedEventArgs e)
{
var name = NameBox.Text.Trim();
if (name.Length == 0) name = "FoxBrowser";
try
{
var settings = new AppSettings
{
AppDisplayName = name,
AppIconPath = _iconPath,
Shortcuts = ShortcutManager.Instance.Shortcuts.ToDictionary(pair => pair.Key, pair => ShortcutManager.Format(pair.Value))
};
AppSettingsStore.Save(settings);
_apply(settings);
DialogResult = true;
}
catch (Exception ex)
{
System.Windows.MessageBox.Show(this, $"Не вдалося зберегти налаштування: {ex.Message}", "Налаштування", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void Reset_Click(object sender, RoutedEventArgs e)
{
NameBox.Text = "FoxBrowser";
_iconPath = null;
try { File.Delete(Path.Combine(AppContext.BaseDirectory, "custom_icon.ico")); } catch { }
UpdateIconPathText();
}
private void Cancel_Click(object sender, RoutedEventArgs e) => Close();
private void UpdateIconPathText() => IconPathText.Text = string.IsNullOrWhiteSpace(_iconPath) ? "Вбудована іконка" : Path.GetFileName(_iconPath);
}
internal static class AppIconConverter
{
private const string PortableIconName = "custom_icon.ico";
public static string CopyAsPortableIcon(string sourcePath)
{
var destination = Path.Combine(AppContext.BaseDirectory, PortableIconName);
var extension = Path.GetExtension(sourcePath);
if (extension.Equals(".ico", StringComparison.OrdinalIgnoreCase))
{
if (string.Equals(Path.GetFullPath(sourcePath), Path.GetFullPath(destination), StringComparison.OrdinalIgnoreCase))
return destination;
File.Copy(sourcePath, destination, overwrite: true);
return destination;
}
using var bitmap = new Bitmap(sourcePath);
var handle = bitmap.GetHicon();
try
{
using var icon = Icon.FromHandle(handle);
using var stream = File.Create(destination);
icon.Save(stream);
}
finally
{
DestroyIcon(handle);
}
return destination;
}
[DllImport("user32.dll", SetLastError = true)]
private static extern bool DestroyIcon(nint handle);
}
+17
View File
@@ -0,0 +1,17 @@
<Window x:Class="FoxBrowser.UI.BlockedElementsWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Вирізані елементи" Width="620" Height="430" MinWidth="480" MinHeight="300"
WindowStartupLocation="CenterOwner"
Background="{StaticResource HeaderBg}" Foreground="{StaticResource TextPrimary}">
<DockPanel Margin="16">
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
<Button Content="Видалити вибране правило" Click="RemoveSelected_Click" Padding="10,6" />
<Button Content="Очистити сайт" Click="ClearSite_Click" Padding="10,6" Margin="8,0,0,0" />
<Button Content="Закрити" Click="Close_Click" Padding="10,6" Margin="8,0,0,0" />
</StackPanel>
<TextBlock DockPanel.Dock="Top" Text="Заблоковані елементи" FontSize="16" FontWeight="SemiBold" />
<TextBlock DockPanel.Dock="Top" Text="Вибери конкретне правило, щоб повернути один елемент. Правила діють лише для поточного сайту." Foreground="{StaticResource TextSecondary}" TextWrapping="Wrap" Margin="0,4,0,10" />
<ListBox x:Name="SelectorList" Background="{StaticResource CardBg}" BorderBrush="{StaticResource BorderMedium}" Foreground="{StaticResource TextPrimary}" />
</DockPanel>
</Window>
+36
View File
@@ -0,0 +1,36 @@
using System.Windows;
using FoxBrowser.Core;
namespace FoxBrowser.UI;
public partial class BlockedElementsWindow : Window
{
private readonly TabManager _tabManager;
public BlockedElementsWindow(TabManager tabManager)
{
InitializeComponent();
_tabManager = tabManager;
RefreshList();
}
private void RefreshList()
{
SelectorList.ItemsSource = _tabManager.GetBlockedSelectors();
}
private async void RemoveSelected_Click(object sender, RoutedEventArgs e)
{
if (SelectorList.SelectedItem is not string selector) return;
await _tabManager.RemoveBlockedSelectorAsync(selector);
RefreshList();
}
private async void ClearSite_Click(object sender, RoutedEventArgs e)
{
await _tabManager.ClearBlockedForCurrentHostAsync();
RefreshList();
}
private void Close_Click(object sender, RoutedEventArgs e) => Close();
}
+8
View File
@@ -1,9 +1,17 @@
<Window x:Class="FoxBrowser.UI.ExtensionPopupWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
WindowStyle="None" ResizeMode="NoResize" ShowInTaskbar="False"
Width="360" Height="520" MaxHeight="600"
Background="#202328" AllowsTransparency="False">
<shell:WindowChrome.WindowChrome>
<shell:WindowChrome
GlassFrameThickness="0"
CaptionHeight="0"
ResizeBorderThickness="0"
UseAeroCaptionButtons="False" />
</shell:WindowChrome.WindowChrome>
<Border BorderBrush="#59616D" BorderThickness="1" Background="#202328">
<Grid x:Name="PopupHost" />
</Border>
+36 -7
View File
@@ -1,6 +1,8 @@
using System.Windows;
using System.Windows.Interop;
using Microsoft.Web.WebView2.Core;
using Microsoft.Web.WebView2.Wpf;
using FoxBrowser.Native;
namespace FoxBrowser.UI;
@@ -9,15 +11,35 @@ public partial class ExtensionPopupWindow : Window
private readonly CoreWebView2Environment _environment;
private readonly string _extensionId;
private readonly string _popupPage;
private WebView2? _view;
public ExtensionPopupWindow(CoreWebView2Environment environment, string extensionId, string popupPage, System.Drawing.Point screenPosition)
public ExtensionPopupWindow(
CoreWebView2Environment environment,
string extensionId,
string popupPage,
System.Windows.Point screenPosition,
System.Windows.Rect workingArea)
{
InitializeComponent();
_environment = environment;
_extensionId = extensionId;
_popupPage = popupPage;
Left = screenPosition.X;
Top = screenPosition.Y;
WindowStartupLocation = WindowStartupLocation.Manual;
SourceInitialized += (_, _) => Win32Interop.RemovePopupFrame(new WindowInteropHelper(this).Handle);
Width = Math.Min(Width, Math.Max(220, workingArea.Width - 8));
Height = Math.Min(Height, Math.Max(160, workingArea.Height - 8));
// Keep the popup on the monitor where the extension button lives.
// If there is no room below the toolbar, open it above the button.
var left = Math.Min(screenPosition.X, workingArea.Right - Width - 4);
left = Math.Max(left, workingArea.Left + 4);
var top = screenPosition.Y + 4;
if (top + Height > workingArea.Bottom - 4)
top = screenPosition.Y - Height - 4;
top = Math.Max(top, workingArea.Top + 4);
Left = left;
Top = top;
Deactivated += (_, _) => Close();
Loaded += async (_, _) => await InitializeAsync();
}
@@ -26,14 +48,21 @@ public partial class ExtensionPopupWindow : Window
{
try
{
var view = new WebView2();
PopupHost.Children.Add(view);
await view.EnsureCoreWebView2Async(_environment);
view.CoreWebView2.Navigate($"chrome-extension://{_extensionId}/{_popupPage}");
_view = new WebView2();
PopupHost.Children.Add(_view);
await _view.EnsureCoreWebView2Async(_environment);
_view.CoreWebView2.Navigate($"chrome-extension://{_extensionId}/{_popupPage}");
}
catch
{
Close();
}
}
protected override void OnClosed(EventArgs e)
{
_view?.Dispose();
_view = null;
base.OnClosed(e);
}
}
+7 -3
View File
@@ -5,13 +5,17 @@
Background="#15171A" Foreground="#F2F4F7">
<DockPanel Margin="14">
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
<Button Content="Магазин розширень" ToolTip="Відкрити Chrome Web Store; встановлення з магазину не підтримується API WebView2" Click="Store_Click" Padding="12,6" />
<Button Content="Імпортувати розширення…" Click="Add_Click" Padding="12,6" Margin="8,0,0,0" />
<Button Content="Магазин у FoxBrowser" ToolTip="Відкрити Chrome Web Store у поточній вкладці" Click="Store_Click" Padding="12,6" />
<Button Content="CRX Downloader" ToolTip="Відкрити завантажувач CRX у FoxBrowser" Click="Downloader_Click" Padding="12,6" Margin="8,0,0,0" />
<Button Content="Папка розширень" ToolTip="Відкрити папку, куди можна розпакувати extension" Click="OpenFolder_Click" Padding="12,6" Margin="8,0,0,0" />
<Button Content="Імпортувати ZIP/CRX…" Click="AddArchive_Click" Padding="12,6" Margin="8,0,0,0" />
<Button Content="Імпортувати папку…" Click="Add_Click" Padding="12,6" Margin="8,0,0,0" />
<Button Content="Закрити" Click="Close_Click" Padding="12,6" Margin="8,0,0,0" />
</StackPanel>
<TextBlock DockPanel.Dock="Top" Text="Chrome-сумісні unpacked extensions" FontSize="16" FontWeight="SemiBold" Margin="0,0,0,4" />
<TextBlock DockPanel.Dock="Top" Text="Кнопка «Налаштування» відкриває сторінку, вказану в manifest.json: options_ui.page або options_page." Foreground="#AEB4BE" TextWrapping="Wrap" Margin="0,0,0,4" />
<TextBlock DockPanel.Dock="Top" Text="Профіль WebView2: папка Profile поруч із FoxBrowser.exe (у захищеній папці Windows використовується стандартний профіль %LOCALAPPDATA%\FoxBrowser\WebView2). WebView2 зберігає реєстрацію розширення, а файли unpacked залишаються у вибраній тобою папці." Foreground="#AEB4BE" TextWrapping="Wrap" Margin="0,0,0,10" />
<TextBlock DockPanel.Dock="Top" Text="WebView2 не підтримує Chrome-only встановлення кнопкою Install. Завантаж ZIP/CRX, імпортуй його тут — FoxBrowser сам розпакує файл у папку профілю та підключить extension." Foreground="#FFCC66" TextWrapping="Wrap" Margin="0,0,0,4" />
<TextBlock DockPanel.Dock="Top" Text="Імпортовані файли, профіль WebView2 і налаштування extension зберігаються тільки поруч із FoxBrowser.exe у portable-папках." Foreground="#AEB4BE" TextWrapping="Wrap" Margin="0,0,0,10" />
<TextBlock x:Name="ExtensionsStatus" DockPanel.Dock="Top" Foreground="#FFCC66" TextWrapping="Wrap" Margin="0,0,0,8" />
<Grid>
<ListBox x:Name="ExtensionList" Background="#202328" BorderBrush="#454C57" Foreground="#F2F4F7">
+56 -7
View File
@@ -15,15 +15,15 @@ public partial class ExtensionsSettingsWindow : Window
{
private readonly ExtensionManager _manager;
private readonly CoreWebView2Environment _environment;
private readonly Func<Task> _openStore;
private readonly Func<string, Task> _openUrl;
private WebView2? _optionsView;
public ExtensionsSettingsWindow(ExtensionManager manager, CoreWebView2Environment environment, Func<Task> openStore, bool extensionsAvailable)
public ExtensionsSettingsWindow(ExtensionManager manager, CoreWebView2Environment environment, Func<string, Task> openUrl, bool extensionsAvailable)
{
InitializeComponent();
_manager = manager;
_environment = environment;
_openStore = openStore;
_openUrl = openUrl;
ExtensionList.ItemsSource = _manager.Extensions;
if (!extensionsAvailable)
{
@@ -33,7 +33,7 @@ public partial class ExtensionsSettingsWindow : Window
Loaded += async (_, _) =>
{
await _manager.RefreshAsync();
Dispatcher.BeginInvoke(RefreshPinCheckboxes);
Dispatcher.Invoke(RefreshPinCheckboxes);
};
}
@@ -45,11 +45,40 @@ public partial class ExtensionsSettingsWindow : Window
try
{
await _manager.AddExtensionAsync(dialog.SelectedPath);
Dispatcher.BeginInvoke(RefreshPinCheckboxes);
Dispatcher.Invoke(RefreshPinCheckboxes);
}
catch (Exception ex) { ShowError(ex.Message); }
}
private async void AddArchive_Click(object sender, RoutedEventArgs e)
{
using var dialog = new Forms.OpenFileDialog
{
Title = "Виберіть архів extension",
Filter = "Extension archive (*.zip;*.crx)|*.zip;*.crx|Усі файли (*.*)|*.*",
CheckFileExists = true
};
var owner = new WindowInteropHelper(this).Handle;
if (dialog.ShowDialog(new NativeWindowWrapper(owner)) != Forms.DialogResult.OK) return;
try
{
await _manager.AddArchiveAsync(dialog.FileName);
ExtensionsStatus.Text = "Розширення розпаковано у профіль FoxBrowser та підключено.";
Dispatcher.Invoke(RefreshPinCheckboxes);
}
catch (Exception ex) { ShowError($"Не вдалося імпортувати архів: {ex.Message}"); }
}
private void OpenFolder_Click(object sender, RoutedEventArgs e)
{
try
{
ExtensionManager.OpenExtensionsFolder();
ExtensionsStatus.Text = $"Папка розширень: {ExtensionManager.ExtensionsRootPath}";
}
catch (Exception ex) { ShowError($"Не вдалося відкрити папку розширень: {ex.Message}"); }
}
private async void Toggle_Click(object sender, RoutedEventArgs e)
{
if ((sender as WpfCheckBox)?.Tag is not CoreWebView2BrowserExtension extension) return;
@@ -103,8 +132,28 @@ public partial class ExtensionsSettingsWindow : Window
private async void Store_Click(object sender, RoutedEventArgs e)
{
Close();
await _openStore();
try
{
Close();
await _openUrl("https://chromewebstore.google.com/category/extensions");
}
catch (Exception ex)
{
ShowError($"Не вдалося відкрити Chrome Web Store: {ex.Message}");
}
}
private async void Downloader_Click(object sender, RoutedEventArgs e)
{
try
{
Close();
await _openUrl("https://www.crx4chrome.com/crx-downloader/");
}
catch (Exception ex)
{
ShowError($"Не вдалося відкрити CRX Downloader: {ex.Message}");
}
}
private async void Remove_Click(object sender, RoutedEventArgs e)
+21
View File
@@ -0,0 +1,21 @@
<Window x:Class="FoxBrowser.UI.ShortcutSettingsWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Клавіатурні скорочення" Width="580" Height="330"
MinWidth="520" Background="#15171A" Foreground="#F2F4F7"
WindowStartupLocation="CenterOwner" ResizeMode="NoResize">
<Grid Margin="20">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Text="Клавіатурні скорочення" FontSize="18" FontWeight="SemiBold" Margin="0,0,0,8" />
<TextBlock Grid.Row="0" Text="Натисніть «Змінити», потім потрібну комбінацію Ctrl/Shift/Alt + клавіша." Foreground="#AEB4BE" Margin="0,30,0,12" />
<StackPanel x:Name="ShortcutList" Grid.Row="1" />
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,14,0,0">
<Button Content="Скинути до стандартних" Click="Reset_Click" Padding="10,6" Margin="0,0,8,0" />
<Button Content="Закрити" Click="Close_Click" Padding="14,6" />
</StackPanel>
</Grid>
</Window>
+138
View File
@@ -0,0 +1,138 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using FoxBrowser.Core;
using WpfButton = System.Windows.Controls.Button;
using WpfColor = System.Windows.Media.Color;
using WpfKeyEventArgs = System.Windows.Input.KeyEventArgs;
using WpfTextBox = System.Windows.Controls.TextBox;
namespace FoxBrowser.UI;
public partial class ShortcutSettingsWindow : Window
{
private readonly ShortcutManager _shortcuts = ShortcutManager.Instance;
private readonly Dictionary<string, WpfTextBox> _boxes = new();
private string? _capturingAction;
private KeyGesture? _pendingGesture;
public ShortcutSettingsWindow()
{
InitializeComponent();
_shortcuts.Changed += Shortcuts_Changed;
BuildRows();
}
private void BuildRows()
{
ShortcutList.Children.Clear();
_boxes.Clear();
foreach (var action in new[] { ShortcutManager.CloseTab, ShortcutManager.NewTab, ShortcutManager.ToggleTopPanel })
{
var box = new WpfTextBox
{
Text = ShortcutManager.Format(_shortcuts.Get(action)),
IsReadOnly = true,
IsTabStop = false,
Width = 150,
Padding = new Thickness(7, 4, 7, 4),
Background = new SolidColorBrush(WpfColor.FromRgb(32, 35, 40)),
Foreground = new SolidColorBrush(WpfColor.FromRgb(242, 244, 247)),
BorderBrush = new SolidColorBrush(WpfColor.FromRgb(69, 76, 87)),
HorizontalContentAlignment = System.Windows.HorizontalAlignment.Center,
Tag = action,
};
var change = new WpfButton { Content = "Змінити", Padding = new Thickness(10, 5, 10, 5), Tag = action };
change.Click += Change_Click;
var row = new Grid { Margin = new Thickness(0, 0, 0, 8) };
row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
row.Children.Add(new TextBlock { Text = ShortcutManager.GetActionLabel(action), VerticalAlignment = VerticalAlignment.Center });
Grid.SetColumn(box, 1);
Grid.SetColumn(change, 2);
change.Margin = new Thickness(8, 0, 0, 0);
row.Children.Add(box);
row.Children.Add(change);
ShortcutList.Children.Add(row);
_boxes[action] = box;
}
}
private void Change_Click(object sender, RoutedEventArgs e)
{
if (sender is not WpfButton button || button.Tag is not string action || !_boxes.TryGetValue(action, out var box)) return;
_capturingAction = action;
_pendingGesture = null;
box.Text = "Натисніть комбінацію…";
box.Focus();
box.PreviewKeyDown -= CaptureBox_PreviewKeyDown;
box.PreviewKeyDown += CaptureBox_PreviewKeyDown;
box.PreviewKeyUp -= CaptureBox_PreviewKeyUp;
box.PreviewKeyUp += CaptureBox_PreviewKeyUp;
}
private void CaptureBox_PreviewKeyDown(object sender, WpfKeyEventArgs e)
{
if (_capturingAction is null || sender is not WpfTextBox box) return;
var key = e.Key == Key.System ? e.SystemKey : e.Key;
if (key == Key.Escape)
{
CancelCapture(box);
e.Handled = true;
return;
}
var modifiers = Keyboard.Modifiers & (ModifierKeys.Control | ModifierKeys.Shift | ModifierKeys.Alt);
if (!ShortcutManager.TryCreateGesture(key, modifiers, out var gesture)) return;
_pendingGesture = gesture;
box.Text = ShortcutManager.Format(gesture);
e.Handled = true;
}
private void CaptureBox_PreviewKeyUp(object sender, WpfKeyEventArgs e)
{
if (_capturingAction is null || _pendingGesture is null || sender is not WpfTextBox box) return;
var action = _capturingAction;
if (!_shortcuts.TrySet(action, _pendingGesture, out var conflictAction))
{
System.Windows.MessageBox.Show(this, $"Комбінація вже призначена для «{ShortcutManager.GetActionLabel(conflictAction!)}». Оберіть іншу.", "Конфлікт шорткатів", MessageBoxButton.OK, MessageBoxImage.Warning);
box.Text = ShortcutManager.Format(_shortcuts.Get(action));
}
StopCapture(box);
}
private void CancelCapture(WpfTextBox box)
{
box.Text = ShortcutManager.Format(_shortcuts.Get(_capturingAction!));
StopCapture(box);
}
private void StopCapture(WpfTextBox box)
{
box.PreviewKeyDown -= CaptureBox_PreviewKeyDown;
box.PreviewKeyUp -= CaptureBox_PreviewKeyUp;
_capturingAction = null;
_pendingGesture = null;
Keyboard.ClearFocus();
}
private void Shortcuts_Changed(object? sender, EventArgs e)
{
if (!Dispatcher.CheckAccess())
{
Dispatcher.BeginInvoke(BuildRows);
return;
}
BuildRows();
}
private void Reset_Click(object sender, RoutedEventArgs e) => _shortcuts.ResetToDefaults();
private void Close_Click(object sender, RoutedEventArgs e) => Close();
protected override void OnClosed(EventArgs e)
{
_shortcuts.Changed -= Shortcuts_Changed;
base.OnClosed(e);
}
}
+21 -10
View File
@@ -9,28 +9,22 @@ public sealed class TrayIconManager : IDisposable
private readonly Forms.NotifyIcon _icon;
private readonly Window _window;
private readonly Action _exit;
private Icon? _ownedIcon;
public TrayIconManager(Window window, Action exit)
{
_window = window;
_exit = exit;
Icon? icon = null;
try
{
var icoPath = System.IO.Path.Combine(AppContext.BaseDirectory, "FoxBrowser.ico");
if (System.IO.File.Exists(icoPath)) icon = new Icon(icoPath);
}
catch { }
_icon = new Forms.NotifyIcon
{
Icon = icon ?? SystemIcons.Application,
Text = "FoxBrowser",
Icon = SystemIcons.Application,
Text = window.Title,
Visible = true,
ContextMenuStrip = BuildMenu()
};
_icon.MouseClick += OnMouseClick;
UpdateIdentity(window.Title, System.IO.Path.Combine(AppContext.BaseDirectory, "FoxBrowser.ico"));
}
public void MinimizeToTray()
@@ -58,6 +52,23 @@ public sealed class TrayIconManager : IDisposable
_icon.MouseClick -= OnMouseClick;
_icon.Visible = false;
_icon.Dispose();
_ownedIcon?.Dispose();
}
public void UpdateIdentity(string displayName, string iconPath)
{
try
{
Icon? next = null;
if (System.IO.File.Exists(iconPath)) next = new Icon(iconPath);
var old = _ownedIcon;
_ownedIcon = next;
_icon.Icon = next ?? SystemIcons.Application;
old?.Dispose();
}
catch { }
_icon.Text = string.IsNullOrWhiteSpace(displayName) ? "FoxBrowser" : displayName[..Math.Min(63, displayName.Length)];
}
private void OnMouseClick(object? sender, Forms.MouseEventArgs e)