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 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 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 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().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(); } }