Files
FoxBrowser/Core/ExtensionManager.cs
T
2026-10-01 02:41:50 +03:00

267 lines
12 KiB
C#

using System.Collections.ObjectModel;
using System.IO.Compression;
using System.IO;
using System.Text.Json;
using Microsoft.Web.WebView2.Core;
namespace FoxBrowser.Core;
public sealed class PinnedExtensionItem
{
public required CoreWebView2BrowserExtension Extension { get; init; }
public required string Name { get; init; }
public string Glyph => string.IsNullOrWhiteSpace(Name) ? "🧩" : Name[..1].ToUpperInvariant();
public string? IconPath { get; init; }
public bool HasIcon => !string.IsNullOrWhiteSpace(IconPath) && File.Exists(IconPath);
public string? PopupPage { get; init; }
}
public sealed class ExtensionManager
{
private readonly CoreWebView2Profile _profile;
private readonly string _metadataPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "FoxBrowser", "extension-options.json");
private readonly string _uiMetadataPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "FoxBrowser", "extension-ui.json");
private readonly string _pinnedPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "FoxBrowser", "pinned-extensions.json");
private readonly Dictionary<string, string?> _optionsPages;
private readonly Dictionary<string, ExtensionUiMetadata> _uiMetadata;
private readonly HashSet<string> _pinnedIds;
public static string ExtensionsRootPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "FoxBrowser", "Extensions");
public ExtensionManager(CoreWebView2Profile profile)
{
_profile = profile;
_optionsPages = LoadOptionsPages();
_uiMetadata = LoadUiMetadata();
_pinnedIds = LoadPinnedIds();
}
public ObservableCollection<CoreWebView2BrowserExtension> Extensions { get; } = new();
public ObservableCollection<PinnedExtensionItem> PinnedExtensions { get; } = new();
public event EventHandler? PinnedExtensionsChanged;
public async Task RefreshAsync()
{
Extensions.Clear();
PinnedExtensions.Clear();
var installed = await _profile.GetBrowserExtensionsAsync();
foreach (var extension in installed) Extensions.Add(extension);
RebuildPinned();
}
public async Task<CoreWebView2BrowserExtension> AddExtensionAsync(string folderPath)
{
if (!Directory.Exists(folderPath)) throw new DirectoryNotFoundException(folderPath);
var manifestPath = Path.Combine(folderPath, "manifest.json");
if (!File.Exists(manifestPath)) throw new InvalidDataException("У вибраній папці немає manifest.json.");
var manifest = await ReadManifestAsync(manifestPath);
var extension = await _profile.AddBrowserExtensionAsync(folderPath);
_optionsPages[extension.Id] = ReadOptionsPage(manifest);
_uiMetadata[extension.Id] = ReadUiMetadata(manifest, folderPath);
SaveOptionsPages();
SaveUiMetadata();
Extensions.Add(extension);
RebuildPinned();
return extension;
}
public 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;
public bool IsPinned(CoreWebView2BrowserExtension extension) => _pinnedIds.Contains(extension.Id);
public void SetPinned(CoreWebView2BrowserExtension extension, bool pinned)
{
if (pinned) _pinnedIds.Add(extension.Id); else _pinnedIds.Remove(extension.Id);
SavePinnedIds();
RebuildPinned();
}
public async Task SetEnabledAsync(CoreWebView2BrowserExtension extension, bool enabled)
{
await extension.EnableAsync(enabled);
RebuildPinned();
}
public async Task RemoveAsync(CoreWebView2BrowserExtension extension)
{
await extension.RemoveAsync();
Extensions.Remove(extension);
_optionsPages.Remove(extension.Id);
_uiMetadata.Remove(extension.Id);
_pinnedIds.Remove(extension.Id);
SaveOptionsPages();
SaveUiMetadata();
SavePinnedIds();
RebuildPinned();
}
private void RebuildPinned()
{
PinnedExtensions.Clear();
foreach (var extension in Extensions)
{
if (!_pinnedIds.Contains(extension.Id) || !extension.IsEnabled) continue;
_uiMetadata.TryGetValue(extension.Id, out var metadata);
PinnedExtensions.Add(new PinnedExtensionItem
{
Extension = extension,
Name = extension.Name,
IconPath = metadata?.IconPath,
PopupPage = metadata?.PopupPage
});
}
PinnedExtensionsChanged?.Invoke(this, EventArgs.Empty);
}
private static async Task<JsonElement> ReadManifestAsync(string path)
{
await using var stream = File.OpenRead(path);
using var document = await JsonDocument.ParseAsync(stream);
return document.RootElement.Clone();
}
private static string? ReadOptionsPage(JsonElement manifest)
{
if (manifest.TryGetProperty("options_ui", out var optionsUi) && optionsUi.ValueKind == JsonValueKind.Object && optionsUi.TryGetProperty("page", out var modernPage) && modernPage.ValueKind == JsonValueKind.String)
return NormalizeRelativePage(modernPage.GetString());
if (manifest.TryGetProperty("options_page", out var legacyPage) && legacyPage.ValueKind == JsonValueKind.String)
return NormalizeRelativePage(legacyPage.GetString());
return null;
}
private static ExtensionUiMetadata ReadUiMetadata(JsonElement manifest, string folderPath)
{
JsonElement action = default;
if (manifest.TryGetProperty("action", out var modernAction) && modernAction.ValueKind == JsonValueKind.Object) action = modernAction;
else if (manifest.TryGetProperty("browser_action", out var legacyAction) && legacyAction.ValueKind == JsonValueKind.Object) action = legacyAction;
string? popup = null;
if (action.ValueKind == JsonValueKind.Object && action.TryGetProperty("default_popup", out var popupElement) && popupElement.ValueKind == JsonValueKind.String)
popup = NormalizeRelativePage(popupElement.GetString());
string? icon = null;
if (action.ValueKind == JsonValueKind.Object && action.TryGetProperty("default_icon", out var iconElement))
{
var iconPage = iconElement.ValueKind == JsonValueKind.String
? iconElement.GetString()
: iconElement.ValueKind == JsonValueKind.Object
? iconElement.EnumerateObject().OrderByDescending(item => ParseIconSize(item.Name)).Select(item => item.Value.GetString()).FirstOrDefault()
: null;
var normalized = NormalizeRelativePage(iconPage);
if (normalized is not null)
{
var fullPath = Path.GetFullPath(Path.Combine(folderPath, normalized));
if (File.Exists(fullPath)) icon = fullPath;
}
}
return new ExtensionUiMetadata(popup, icon);
}
private static int ParseIconSize(string key) => int.TryParse(key, out var size) ? size : 0;
private static string? NormalizeRelativePage(string? page)
{
if (string.IsNullOrWhiteSpace(page)) return null;
var normalized = page.Trim().Replace('\\', '/').TrimStart('/');
return normalized.Contains("..", StringComparison.Ordinal) ? null : normalized;
}
private Dictionary<string, string?> LoadOptionsPages() => LoadJson(_metadataPath, new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase));
private Dictionary<string, ExtensionUiMetadata> LoadUiMetadata() => LoadJson(_uiMetadataPath, new Dictionary<string, ExtensionUiMetadata>(StringComparer.OrdinalIgnoreCase));
private HashSet<string> LoadPinnedIds() => LoadJson(_pinnedPath, new HashSet<string>(StringComparer.OrdinalIgnoreCase));
private static T LoadJson<T>(string path, T fallback)
{
try { return File.Exists(path) ? JsonSerializer.Deserialize<T>(File.ReadAllText(path)) ?? fallback : fallback; }
catch { return fallback; }
}
private static void SaveJson<T>(string path, T value)
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, JsonSerializer.Serialize(value, new JsonSerializerOptions { WriteIndented = true }));
}
catch { }
}
private void SaveOptionsPages() => SaveJson(_metadataPath, _optionsPages);
private void SaveUiMetadata() => SaveJson(_uiMetadataPath, _uiMetadata);
private void SavePinnedIds() => SaveJson(_pinnedPath, _pinnedIds);
public sealed record ExtensionUiMetadata(string? PopupPage, string? IconPath);
}