Files
QSafe/windows/QSafe.Desktop/MainWindow.xaml.cs
T

401 lines
33 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.IO;
using System.Security.Cryptography;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Threading;
using Microsoft.Win32;
using QSafe.Core;
namespace QSafe.Desktop;
public partial class MainWindow : Window
{
private LocalStore store;
private readonly string originalDirectory;
private VaultKey? key;
private VaultData? data;
private Entry? selected;
private string filter = "all";
private bool busy, deferredLock, closed;
private DateTime lastInput = DateTime.UtcNow;
private DateTime lastAutoSync = DateTime.UtcNow;
private readonly DispatcherTimer timer = new() { Interval = TimeSpan.FromSeconds(1) };
private readonly DispatcherTimer clipboardTimer = new() { Interval = TimeSpan.FromSeconds(30) };
private readonly CancellationTokenSource lifetime = new();
private string? clipboardMarker;
private readonly Dictionary<string, Button> navButtons = [];
public bool IsDemo { get; private set; }
public MainWindow(LocalStore localStore, bool demo)
{
store = localStore; originalDirectory = demo ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Kusoft", "QSafe") : localStore.DirectoryPath; IsDemo = demo;
InitializeComponent(); NativeWindow.Style(this);
System.Windows.Automation.AutomationProperties.SetName(Search, "Поиск карточек");
InputManager.Current.PreProcessInput += Activity;
PreviewKeyDown += Shortcut;
StateChanged += (_, _) => { if (WindowState == WindowState.Minimized) Lock(); };
SystemEvents.SessionSwitch += SessionChanged;
SystemEvents.PowerModeChanged += PowerChanged;
timer.Tick += (_, _) =>
{
if (key != null && DateTime.UtcNow - lastInput > TimeSpan.FromMinutes(2)) Lock();
if (key != null && !busy && OwnedWindows.Count == 0 && store.Connected && store.Config.AutoSync && DateTime.UtcNow - lastAutoSync > TimeSpan.FromMinutes(1)) _ = Synchronize(false);
};
clipboardTimer.Tick += (_, _) => ClearClipboard(); timer.Start();
Closing += (_, e) =>
{
if (busy) { e.Cancel = true; Lock(); SetStatus("Дождитесь завершения операции перед закрытием."); return; }
closed = true; lifetime.Cancel(); timer.Stop(); Lock(); ClearClipboard();
InputManager.Current.PreProcessInput -= Activity; SystemEvents.SessionSwitch -= SessionChanged; SystemEvents.PowerModeChanged -= PowerChanged;
};
if (demo) StartDemo(); else ShowWelcome();
}
private void Activity(object sender, PreProcessInputEventArgs e)
{
if (e.StagingItem.Input is KeyEventArgs or MouseButtonEventArgs or MouseWheelEventArgs || e.StagingItem.Input is MouseEventArgs { RoutedEvent: var ev } && ev == Mouse.MouseMoveEvent)
lastInput = DateTime.UtcNow;
}
private void SessionChanged(object sender, SessionSwitchEventArgs e) { if (e.Reason is SessionSwitchReason.SessionLock or SessionSwitchReason.SessionLogoff or SessionSwitchReason.RemoteDisconnect) Dispatcher.BeginInvoke(Lock); }
private void PowerChanged(object sender, PowerModeChangedEventArgs e) { if (e.Mode == PowerModes.Suspend) Dispatcher.Invoke(Lock); }
private void Shortcut(object sender, KeyEventArgs e)
{
if (Keyboard.Modifiers != ModifierKeys.Control) return;
if (e.Key == Key.L) { Lock(); e.Handled = true; return; }
if (key == null || busy || OwnedWindows.Count > 0) return;
switch (e.Key)
{
case Key.F: Search.Focus(); Search.SelectAll(); break;
case Key.N: AddEntry(); break;
case Key.G: ShowGenerator(); break;
default: return;
}
e.Handled = true;
}
private void SetStatus(string text, bool error = false)
{
Status.Text = text; Status.Foreground = Ui.Brush(error ? "#FFADAD" : "#A2ADAA");
}
private async Task Work(string message, Func<Task> action)
{
if (busy) return;
busy = true; BusyText.Text = message; BusyOverlay.Visibility = Visibility.Visible; LockButton.IsEnabled = false;
try { await action(); }
catch (OperationCanceledException) { SetStatus("Операция отменена."); }
catch (CryptographicException) { SetStatus("Неверный мастер-пароль или повреждённый файл. Данные не изменены.", true); }
catch (Exception ex) when (ex is IOException or InvalidDataException or System.Text.Json.JsonException or System.Xml.XmlException or System.Net.Http.HttpRequestException or ArgumentException or System.ComponentModel.Win32Exception or UnauthorizedAccessException)
{
SetStatus(ex is System.Net.Http.HttpRequestException ? "Не удалось подключиться к серверу. Проверьте интернет и адрес в настройках." : ex.Message, true);
}
finally
{
busy = false; BusyOverlay.Visibility = Visibility.Collapsed; LockButton.IsEnabled = true;
if (deferredLock) { deferredLock = false; Lock(); }
}
}
private void ShowWelcome(string mode = "unlock", byte[]? import = null)
{
NativeWindow.SetProtection(this, !IsDemo);
Welcome.Children.Clear(); Welcome.Visibility = Visibility.Visible; Workspace.Visibility = Visibility.Collapsed; LockButton.Visibility = Visibility.Collapsed;
DemoBadge.Visibility = IsDemo ? Visibility.Visible : Visibility.Collapsed;
var layout = new Grid { MaxWidth = 1030, Margin = new Thickness(50, 36, 50, 36), VerticalAlignment = VerticalAlignment.Center };
layout.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
layout.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(430) });
var hero = new StackPanel { VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 58, 0) };
var eyebrow = Ui.Text("ЛИЧНОЕ ЦИФРОВОЕ ПРОСТРАНСТВО", 10, "#A5E4BC", FontWeights.SemiBold); hero.Children.Add(eyebrow);
var heading = Ui.Text("Всё важное.\nПод вашим\nконтролем.", 49, "#F0F2F6", FontWeights.SemiBold); heading.LineHeight = 56; heading.Margin = new Thickness(0, 20, 0, 22); hero.Children.Add(heading);
hero.Children.Add(Ui.Text("Пароли, карты и заметки — в одном\nзашифрованном хранилище на вашем ПК.", 16, "#969EAD"));
foreach (var (glyph, text) in new[] { ("\uE72E", "Один мастер-пароль для всех секретов"), ("\uE8B7", "Общая база с QSafe на Android"), ("\uE753", "Синхронизация с вашим сервером") })
{
var t = Ui.Text(text, 13, "#B6BECA"); t.Margin = new Thickness(12, 0, 0, 0);
var row = Ui.Row(Ui.Glyph(glyph, 16, "#A5E4BC"), t); row.Margin = new Thickness(0, 21, 0, 0); hero.Children.Add(row);
}
layout.Children.Add(hero);
var form = new StackPanel();
bool existing = store.Exists;
bool create = mode == "create", remote = mode == "remote", restore = import != null;
form.Children.Add(Ui.Tile("\uE72E", "#A5E4BC", 52));
var title = Ui.Text(create ? "Новое хранилище" : restore ? "Открыть резервную копию" : remote ? "Подключить хранилище" : existing ? "С возвращением" : "Добро пожаловать", 25, "#F0F2F6", FontWeights.SemiBold); title.Margin = new Thickness(0, 23, 0, 10); form.Children.Add(title);
form.Children.Add(Ui.Text(create ? "Придумайте мастер-пароль от 12 символов. Он понадобится на всех устройствах." : restore || remote ? "Введите мастер-пароль существующей базы." : existing ? "Введите мастер-пароль, чтобы открыть свои карточки." : "Начните с чистого листа или подключите существующую базу QSafe.", 13, "#969EAD"));
if (existing || create || restore || remote)
{
Ui.Label(form, "Мастер-пароль");
var password = new PasswordBox(); form.Children.Add(password); System.Windows.Automation.AutomationProperties.SetName(password, "Мастер-пароль");
PasswordBox? confirm = null;
if (create) { Ui.Label(form, "Повторите мастер-пароль"); confirm = new PasswordBox(); form.Children.Add(confirm); System.Windows.Automation.AutomationProperties.SetName(confirm, "Повторите мастер-пароль"); }
var caps = Ui.Text("", 11, "#F3D684"); caps.Margin = new Thickness(0, 7, 0, 0); form.Children.Add(caps);
password.PreviewKeyUp += (_, _) => caps.Text = Keyboard.IsKeyToggled(Key.CapsLock) ? "Включён Caps Lock" : "";
async void Open()
{
var pass = password.Password.ToCharArray(); var repeated = confirm?.Password;
password.Clear(); confirm?.Clear();
try
{
if (create && (pass.Length < 12 || new string(pass) != repeated)) { SetStatus("Нужен пароль от 12 символов и совпадающее подтверждение.", true); return; }
await Work(remote ? "Загружаем зашифрованную базу…" : "Открываем хранилище…", async () =>
{
byte[]? blob = import;
if (remote) { using var client = new SyncClient(store.Config, HomeWifiNetwork.Instance); blob = await client.Download(lifetime.Token); }
VaultKey? nextKey = null;
try
{
var result = await Task.Run(() =>
{
nextKey = create ? VaultCrypto.Create(pass) : VaultCrypto.Unlock(blob ?? store.Read(), pass);
return create ? new VaultData() : Vault.Read(blob ?? store.Read(), nextKey);
});
if (deferredLock) return;
if (create) store.Save(result, nextKey!);
else if (blob != null) { store.SaveEnvelope(blob); if (remote) store.MarkSynced(blob); else store.ResetSync(); }
key = nextKey; nextKey = null; data = result; filter = "all"; selected = null; ShowVault(); SetStatus("Хранилище открыто. Ctrl+F — поиск · Ctrl+N — новая карточка · Ctrl+L — блокировка");
}
finally { nextKey?.Dispose(); }
});
if (key != null && store.Connected && store.Config.AutoSync && !remote) await Synchronize(false);
}
finally { Array.Clear(pass); }
}
var open = Ui.Button(create ? "Создать хранилище →" : "Разблокировать →", Open, "Primary"); open.Margin = new Thickness(0, 12, 0, 0); form.Children.Add(open);
password.KeyDown += (_, e) => { if (e.Key == Key.Enter) Open(); }; if (confirm != null) confirm.KeyDown += (_, e) => { if (e.Key == Key.Enter) Open(); };
if (!existing || restore || remote) { var back = Ui.Button("Назад", () => ShowWelcome(), "Quiet"); back.Margin = new Thickness(0, 9, 0, 0); form.Children.Add(back); }
Dispatcher.BeginInvoke(() => password.Focus());
}
else
{
var createButton = Ui.Button("Создать хранилище →", () => ShowWelcome("create"), "Primary"); createButton.Margin = new Thickness(0, 25, 0, 10); form.Children.Add(createButton);
var connect = Ui.Button("Подключить Pi и синхронизировать", () =>
{
ShowSettings(); if (store.Connected) ShowWelcome("remote");
}); connect.Margin = new Thickness(0, 0, 0, 10); form.Children.Add(connect);
form.Children.Add(Ui.Button("Открыть файл .qsafe", ImportVault, "Quiet"));
}
if (!existing && mode == "unlock" && import == null)
{
form.Children.Add(Ui.Rule()); form.Children.Add(Ui.Button("Посмотреть демо", StartDemo, "Quiet"));
}
var note = Ui.Text("Мастер-пароль остаётся только у вас.\nВосстановить забытый пароль невозможно.", 11, "#788493"); note.TextAlignment = TextAlignment.Center; note.Margin = new Thickness(0, 22, 0, 0); form.Children.Add(note);
var box = Ui.Box(form, "#181C22", 20, 32); Grid.SetColumn(box, 1); layout.Children.Add(box); Welcome.Children.Add(new ScrollViewer { Content = layout });
}
private void StartDemo()
{
IsDemo = true;
if (!store.DirectoryPath.Contains("QSafe-Demo", StringComparison.Ordinal)) store = new LocalStore(Path.Combine(Path.GetTempPath(), "QSafe-Demo", Guid.NewGuid().ToString("N")));
key?.Dispose(); key = VaultCrypto.Create("QSafe public demo password 2026"); data = new VaultData(); filter = "all";
foreach (var (title, kind, login, url, tags, star) in new[]
{
("GitHub", "login", "andrey@example.test", "https://github.com", "Работа", true),
("Яндекс Почта", "email", "andrey@example.test", "https://mail.yandex.ru", "Личное", true),
("Домашний Wi-Fi", "wifi", "Home Network", "", "Дом", false),
("Банковская карта", "bank", "АНДРЕЙ", "", "Личное", true),
("Домашний сервер", "server", "andrey", "", "Дом", false),
("Figma", "login", "andrey@example.test", "https://figma.com", "Работа", false),
("Важная заметка", "note", "", "", "Личное", false)
})
{
var e = Templates.Create(kind); e.Title = title; e.Star = star; e.Tags = [tags];
foreach (var f in e.Fields) f.Value = f.Type switch { "password" => "Demo-Only-Password-2026!", "website" => url, "login" or "email" => login, "card" => "4000 0000 0000 0002", "pin" => "123", "date" => "12/29", _ => login };
if (kind == "server") e.Fields[0].Value = "raspberrypi.local";
e.Notes = kind == "note" ? "Здесь можно хранить идеи, инструкции и другую важную информацию. Эта карточка демонстрационная." : "Демонстрационная карточка. Здесь нет настоящих паролей.";
data.Entries.Add(e);
}
selected = data.Entries[0]; store.Save(data, key); ShowVault(); SetStatus("Демо: все карточки вымышлены. Изменения сохраняются во временной папке.");
}
private void ShowVault()
{
NativeWindow.SetProtection(this, !IsDemo);
Welcome.Children.Clear(); Welcome.Visibility = Visibility.Collapsed; Workspace.Visibility = Visibility.Visible; LockButton.Visibility = Visibility.Visible;
DemoBadge.Visibility = IsDemo ? Visibility.Visible : Visibility.Collapsed;
BuildNavigation(); RefreshCards();
}
private void BuildNavigation()
{
Navigation.Children.Clear(); Tools.Children.Clear(); navButtons.Clear();
void Nav(string id, string glyph, string title, int count)
{
var button = Ui.Button(title, () => { filter = id; selected = null; BuildNavigation(); RefreshCards(); }, "Quiet");
button.Padding = new Thickness(12, 11, 10, 11); button.Margin = new Thickness(0, 0, 0, 3); button.HorizontalContentAlignment = HorizontalAlignment.Stretch;
if (filter == id) { button.Background = Ui.Brush("#293A30"); button.BorderBrush = Ui.Brush("#344B3D"); }
var content = new DockPanel(); var icon = Ui.Glyph(glyph, 16, filter == id ? "#A5E4BC" : "#8E99A9"); icon.Margin = new Thickness(0, 0, 12, 0); DockPanel.SetDock(icon, Dock.Left); content.Children.Add(icon);
var number = Ui.Text(count.ToString(), 11, "#8E99A9"); number.Margin = new Thickness(8, 0, 0, 0); number.VerticalAlignment = VerticalAlignment.Center; DockPanel.SetDock(number, Dock.Right); content.Children.Add(number);
var label = Ui.Text(title, 11.5, filter == id ? "#C5EBD2" : "#B7C0CE"); label.TextWrapping = TextWrapping.NoWrap; label.TextTrimming = TextTrimming.CharacterEllipsis; label.VerticalAlignment = VerticalAlignment.Center; content.Children.Add(label); button.Content = content; button.ToolTip = title; Navigation.Children.Add(button); navButtons[id] = button;
}
var visible = data!.Entries.Where(e => !e.Deleted).ToList();
Nav("all", "\uE8A9", "Все карточки", visible.Count); Nav("stars", "\uE734", "Избранное", visible.Count(e => e.Star));
var caption = Ui.Text("КАТЕГОРИИ", 10, "#788493", FontWeights.SemiBold); caption.Margin = new Thickness(12, 23, 0, 12); Navigation.Children.Add(caption);
for (int i = 0; i < Templates.Kinds.Length; i++)
{
var kind = Templates.Kinds[i]; if (kind == "custom" && visible.All(e => e.Category != "custom")) continue;
Nav(kind, Templates.Glyphs[i], Templates.Titles[i], visible.Count(e => e.Category == kind));
}
Nav("trash", "\uE74D", "Корзина", data.Entries.Count(e => e.Deleted));
var gen = Ui.Button("Генератор паролей", () => ShowGenerator(), "Quiet"); gen.FontSize = 12; gen.HorizontalContentAlignment = HorizontalAlignment.Left; gen.ToolTip = "Ctrl+G"; Tools.Children.Add(gen);
var settings = Ui.Button("Настройки и синхронизация", ShowSettings, "Quiet"); settings.FontSize = 12; settings.HorizontalContentAlignment = HorizontalAlignment.Left; Tools.Children.Add(settings);
if (IsDemo) { var exit = Ui.Button("Выйти из демо", () => { Lock(); store = new LocalStore(originalDirectory); IsDemo = false; ShowWelcome(); }, "Quiet"); exit.FontSize = 12; Tools.Children.Add(exit); }
}
private void RefreshCards()
{
if (data == null) return;
string query = Search.Text.Trim(); SearchHint.Visibility = query.Length == 0 ? Visibility.Visible : Visibility.Collapsed;
PageTitle.Text = filter switch { "all" => "Все карточки", "stars" => "Избранное", "trash" => "Корзина", _ => Templates.Titles[Templates.Index(filter)] };
var entries = data.Entries.Where(e => filter == "trash" ? e.Deleted : !e.Deleted)
.Where(e => filter is "all" or "trash" || filter == "stars" && e.Star || e.Category == filter)
.Where(e => string.Join(' ', new[] { e.Title, string.Join(' ', e.Tags) }.Concat(e.Fields.Where(f => !f.Secret).Select(f => f.Value))).Contains(query, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(e => e.Star).ThenBy(e => e.Title, StringComparer.CurrentCultureIgnoreCase).ToList();
PageSubtitle.Text = $"{entries.Count} карточек · Только вы имеете доступ к содержимому";
var selectedId = selected?.Id; Cards.Items.Clear();
foreach (var entry in entries)
{
int i = Templates.Index(entry.Category);
var grid = new Grid(); grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); grid.ColumnDefinitions.Add(new ColumnDefinition()); grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
grid.Children.Add(Ui.Tile(Templates.Glyphs[i], Templates.Colors[i], 40));
var texts = new StackPanel { Margin = new Thickness(12, 0, 6, 0), VerticalAlignment = VerticalAlignment.Center };
var title = Ui.Text(entry.Title, 14, "#E7EBF2", FontWeights.SemiBold); title.TextWrapping = TextWrapping.NoWrap; title.TextTrimming = TextTrimming.CharacterEllipsis; texts.Children.Add(title);
var sub = Ui.Text(entry.Subtitle, 11, "#8996A8"); sub.Margin = new Thickness(0, 5, 0, 0); sub.TextWrapping = TextWrapping.NoWrap; sub.TextTrimming = TextTrimming.CharacterEllipsis; texts.Children.Add(sub); Grid.SetColumn(texts, 1); grid.Children.Add(texts);
if (entry.Star) { var star = Ui.Glyph("\uE735", 12, "#D6C27D"); Grid.SetColumn(star, 2); grid.Children.Add(star); }
var item = new ListBoxItem { Content = grid, Tag = entry }; System.Windows.Automation.AutomationProperties.SetName(item, entry.Title); Cards.Items.Add(item);
if (entry.Id == selectedId) Cards.SelectedItem = item;
}
if (Cards.SelectedItem == null && Cards.Items.Count > 0) Cards.SelectedIndex = 0;
ListEmpty.Visibility = entries.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
if (entries.Count == 0) { selected = null; RenderDetail(); }
}
private void RenderDetail()
{
Details.Children.Clear(); var entry = selected;
if (entry == null)
{
var empty = new StackPanel { Margin = new Thickness(15, 100, 15, 0), HorizontalAlignment = HorizontalAlignment.Center };
empty.Children.Add(Ui.Tile("\uE8A5", "#A5E4BC", 64)); var heading = Ui.Text(data!.Entries.All(e => e.Deleted) ? "Ваше личное хранилище" : "Выберите карточку", 23, "#E7EBF2", FontWeights.SemiBold); heading.Margin = new Thickness(0, 24, 0, 12); heading.TextAlignment = TextAlignment.Center; empty.Children.Add(heading);
var hint = Ui.Text("Сохраните первый пароль, карту или заметку.\nВсё содержимое будет зашифровано на этом устройстве.", 13, "#969EAD"); hint.TextAlignment = TextAlignment.Center; empty.Children.Add(hint);
var add = Ui.Button("+ Добавить карточку", AddEntry, "Primary"); add.Margin = new Thickness(0, 24, 0, 0); empty.Children.Add(add); Details.Children.Add(empty); return;
}
int index = Templates.Index(entry.Category);
var category = Ui.Text(Templates.Titles[index].ToUpperInvariant(), 10, "#829186", FontWeights.SemiBold); category.Margin = new Thickness(0, 0, 0, 22); Details.Children.Add(category);
var header = new DockPanel(); var tile = Ui.Tile(Templates.Glyphs[index], Templates.Colors[index], 58); tile.Margin = new Thickness(0, 0, 16, 0); DockPanel.SetDock(tile, Dock.Left); header.Children.Add(tile);
var actions = Ui.Row(Ui.IconButton(entry.Star ? "\uE735" : "\uE734", "В избранное", () => ChangeEntry(e => e.Star = !e.Star)));
if (!entry.Deleted) { var edit = Ui.Button("Изменить", () => EditEntry(entry)); edit.Margin = new Thickness(6, 0, 0, 0); edit.FontSize = 12; edit.Padding = new Thickness(12, 8, 12, 8); actions.Children.Add(edit); }
DockPanel.SetDock(actions, Dock.Right); header.Children.Add(actions);
var titles = new StackPanel { VerticalAlignment = VerticalAlignment.Center }; var title = Ui.Text(entry.Title, 25, "#F0F2F6", FontWeights.SemiBold); title.TextTrimming = TextTrimming.CharacterEllipsis; title.TextWrapping = TextWrapping.NoWrap; titles.Children.Add(title);
var subtitle = Ui.Text(entry.Subtitle, 12, "#969EAD"); subtitle.Margin = new Thickness(0, 7, 0, 0); titles.Children.Add(subtitle); header.Children.Add(titles); Details.Children.Add(header);
if (entry.Tags.Count > 0)
{
var tags = new WrapPanel { Margin = new Thickness(0, 22, 0, 3) };
foreach (var tag in entry.Tags) { var chip = Ui.Box(Ui.Text(tag, 11, "#B0BFC3"), "#20272D", 5, 5); chip.Padding = new Thickness(9, 4, 9, 4); chip.Margin = new Thickness(0, 0, 7, 5); tags.Children.Add(chip); } Details.Children.Add(tags);
}
Details.Children.Add(Ui.Rule());
if (entry.Deleted)
{
var restore = Ui.Button("Восстановить из корзины", () => { ChangeEntry(e => e.Deleted = false); SetStatus("Карточка восстановлена."); }, "Primary"); restore.Margin = new Thickness(0, 0, 0, 20); Details.Children.Add(restore);
}
foreach (var f in entry.Fields)
{
var row = new Grid(); row.ColumnDefinitions.Add(new ColumnDefinition()); row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
var text = new StackPanel { Margin = new Thickness(0, 0, 12, 0) }; text.Children.Add(Ui.Text(f.Name, 11, "#8F9CAB"));
var value = Ui.Text(f.Secret && f.Value.Length > 0 ? "••••••••••••••••" : f.Value.Length == 0 ? "Не заполнено" : f.Value, 15, f.Secret ? "#D7E5DC" : "#E1E6EE"); value.FontFamily = new FontFamily(f.Secret ? "Cascadia Mono, Consolas" : "Segoe UI"); value.Margin = new Thickness(0, 9, 0, 0); text.Children.Add(value); row.Children.Add(text);
var buttons = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center };
if (f.Secret)
{
bool visible = false; var reveal = Ui.IconButton("\uE890", "Показать / скрыть «" + f.Name + "»", () => { visible = !visible; value.Text = visible ? f.Value : "••••••••••••••••"; }); buttons.Children.Add(reveal);
}
var copy = Ui.IconButton("\uE8C8", "Скопировать «" + f.Name + "»", () => CopySecret(f.Value)); copy.IsEnabled = f.Value.Length > 0; buttons.Children.Add(copy); Grid.SetColumn(buttons, 1); row.Children.Add(buttons);
var box = Ui.Box(row, "#191E25", 10, 16); box.Margin = new Thickness(0, 0, 0, 10); Details.Children.Add(box);
}
if (entry.Notes.Length > 0)
{
Ui.Label(Details, "ЗАМЕТКА"); var note = Ui.Text(entry.Notes, 13, "#AFB9C7"); note.LineHeight = 21; Details.Children.Add(note);
}
if (entry.Extra?.ContainsKey("attachments") == true) { var hint = Ui.Text("Вложения сохранены в зашифрованной базе. Их просмотр пока недоступен.", 11, "#788493"); hint.Margin = new Thickness(0, 18, 0, 0); Details.Children.Add(hint); }
Details.Children.Add(Ui.Rule());
string modified; try { modified = DateTimeOffset.FromUnixTimeMilliseconds(entry.Modified).LocalDateTime.ToString("dd MMMM yyyy, HH:mm"); } catch (ArgumentOutOfRangeException) { modified = "дата неизвестна"; }
var footer = new DockPanel();
if (!entry.Deleted) { var delete = Ui.IconButton("\uE74D", "В корзину", () => { if (Confirm("Переместить карточку в корзину?", "Её можно будет восстановить в разделе «Корзина».")) ChangeEntry(e => e.Deleted = true); }); DockPanel.SetDock(delete, Dock.Right); footer.Children.Add(delete); }
var time = Ui.Text("Изменено " + modified, 10, "#788493"); time.VerticalAlignment = VerticalAlignment.Center; footer.Children.Add(time); Details.Children.Add(footer);
}
private void ChangeEntry(Action<Entry> change)
{
if (selected == null || key == null || busy) return;
try
{
var next = Vault.Copy(data!); var entry = Vault.Copy(selected); change(entry); Vault.Update(next, entry); store.Save(next, key); data = next; selected = entry; BuildNavigation(); RefreshCards(); SetStatus("Изменения сохранены и зашифрованы.");
}
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or CryptographicException) { SetStatus("Не удалось сохранить изменения: " + ex.Message, true); }
}
public void CopySecret(string value)
{
try
{
clipboardMarker = Guid.NewGuid().ToString("N"); var clip = new DataObject(); clip.SetText(value); clip.SetData("QSafe.Clipboard.Owner", clipboardMarker);
clip.SetData("CanIncludeInClipboardHistory", new MemoryStream(BitConverter.GetBytes(0)));
clip.SetData("CanUploadToCloudClipboard", new MemoryStream(BitConverter.GetBytes(0)));
Clipboard.SetDataObject(clip, true); clipboardTimer.Stop(); clipboardTimer.Interval = TimeSpan.FromSeconds(30); clipboardTimer.Start(); SetStatus("Скопировано. Буфер обмена будет очищен через 30 секунд.");
}
catch (System.Runtime.InteropServices.COMException) { SetStatus("Буфер обмена занят другим приложением. Повторите копирование.", true); }
}
private void ClearClipboard()
{
clipboardTimer.Stop();
try { if (clipboardMarker != null && Clipboard.GetData("QSafe.Clipboard.Owner") as string == clipboardMarker) Clipboard.Clear(); }
catch (System.Runtime.InteropServices.COMException) { clipboardTimer.Interval = TimeSpan.FromSeconds(1); clipboardTimer.Start(); return; }
clipboardMarker = null;
}
private void Lock()
{
foreach (Window window in OwnedWindows.Cast<Window>().ToArray()) { if (window is AppDialog dialog) dialog.ForceClose(); else window.Close(); }
ClearClipboard(); data = null; Details.Children.Clear(); Cards.Items.Clear(); Welcome.Children.Clear(); Search.Clear(); selected = null;
Workspace.Visibility = Visibility.Collapsed; LockButton.Visibility = Visibility.Collapsed;
if (busy) { deferredLock = true; data = null; Welcome.Visibility = Visibility.Collapsed; BusyText.Text = "Блокируем хранилище…"; return; }
key?.Dispose(); key = null; data = null;
if (!closed) { ShowWelcome(); SetStatus("Хранилище заблокировано."); }
}
private async Task Synchronize(bool manual = true)
{
if (key == null || busy || IsDemo) { if (IsDemo) SetStatus("Синхронизация недоступна в демо."); return; }
if (!store.Connected) { if (manual) ShowSettings(); return; }
lastAutoSync = DateTime.UtcNow;
await Work("Синхронизируем зашифрованную базу…", async () =>
{
var sessionKey = key!;
using var client = new SyncClient(store.Config, HomeWifiNetwork.Instance);
var result = await Task.Run(() => client.Sync(store.Read(), store.ReadBase(), sessionKey, lifetime.Token));
store.SaveEnvelope(result.Envelope); store.MarkSynced(result.Envelope);
if (!deferredLock) { data = result.Data; BuildNavigation(); RefreshCards(); }
SetStatus(result.Conflicts > 0 ? $"Синхронизировано. Конфликтов: {result.Conflicts}; обе версии сохранены отдельными карточками." : "Все изменения синхронизированы · " + DateTime.Now.ToString("HH:mm"));
});
}
private void ImportVault()
{
var file = new OpenFileDialog { Title = "Открыть зашифрованную базу QSafe", Filter = "Хранилище QSafe (*.qsafe)|*.qsafe", CheckFileExists = true };
if (file.ShowDialog(this) != true) return;
try
{
var blob = LocalStore.ReadBounded(file.FileName); VaultCrypto.Validate(blob);
if (store.Exists && !Confirm("Восстановить другую базу?", "Текущая зашифрованная база останется в локальной папке backups. Подключение к Pi нужно проверить перед синхронизацией.")) return;
Lock(); ShowWelcome("restore", blob);
}
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException) { SetStatus(ex.Message, true); }
}
private void ExportVault()
{
var file = new SaveFileDialog { Title = "Экспорт зашифрованной базы", Filter = "Хранилище QSafe (*.qsafe)|*.qsafe", FileName = "QSafe-" + DateTime.Now.ToString("yyyy-MM-dd") + ".qsafe" };
if (file.ShowDialog(this) != true) return;
try { LocalStore.AtomicWrite(file.FileName, store.Read()); SetStatus("Зашифрованная резервная копия сохранена. Для открытия нужен мастер-пароль."); }
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException) { SetStatus(ex.Message, true); }
}
private void SearchChanged(object sender, TextChangedEventArgs e) { if (data != null) RefreshCards(); }
private void CardSelected(object sender, SelectionChangedEventArgs e) { if (Cards.SelectedItem is ListBoxItem { Tag: Entry entry }) { selected = entry; RenderDetail(); } }
private void LockClick(object sender, RoutedEventArgs e) => Lock();
private void AddClick(object sender, RoutedEventArgs e) => AddEntry();
private void ClearSearchClick(object sender, RoutedEventArgs e) { Search.Clear(); Search.Focus(); }
}