Files

226 lines
19 KiB
C#

using System.IO;
using System.Text.Json;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using Microsoft.Win32;
using QSafe.Core;
namespace QSafe.Desktop;
public class AppDialog : Window
{
public StackPanel Body { get; } = new();
public StackPanel Actions { get; } = new() { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right };
public bool Forced { get; private set; }
public bool Accepted { get; private set; }
public AppDialog(Window owner, string title, string subtitle = "", double width = 600)
{
Owner = owner; Style = (Style)Application.Current.FindResource(typeof(Window)); Title = title + " — QSafe"; Width = width; SizeToContent = SizeToContent.Height;
MaxHeight = Math.Max(600, owner.ActualHeight - 40); MinHeight = 240; WindowStartupLocation = WindowStartupLocation.CenterOwner; ResizeMode = ResizeMode.NoResize; ShowInTaskbar = false;
NativeWindow.Style(this);
var root = new Grid { Margin = new Thickness(28) };
root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
var heading = new StackPanel(); heading.Children.Add(Ui.Text(title, 25, "#F0F2F6", FontWeights.SemiBold));
if (subtitle.Length > 0) { var hint = Ui.Text(subtitle, 13, "#969EAD"); hint.Margin = new Thickness(0, 10, 0, 0); heading.Children.Add(hint); }
heading.Margin = new Thickness(0, 0, 0, 12); root.Children.Add(heading);
var scroll = new ScrollViewer { Content = Body, MaxHeight = Math.Max(310, owner.ActualHeight - 260), Padding = new Thickness(0, 0, 12, 3) }; Grid.SetRow(scroll, 1); root.Children.Add(scroll);
Actions.Margin = new Thickness(0, 23, 0, 0); Grid.SetRow(Actions, 2); root.Children.Add(Actions); Content = root;
PreviewKeyDown += (_, e) => { if (e.Key == Key.Escape) { Close(); e.Handled = true; } };
}
public void Accept() { Accepted = true; DialogResult = true; }
public void ForceClose() { Forced = true; Close(); }
public void AddCancel(string title = "Отмена") { var button = Ui.Button(title, Close, "Quiet"); button.Margin = new Thickness(0, 0, 10, 0); Actions.Children.Add(button); }
}
public sealed class SecretInput : Grid
{
private readonly PasswordBox password;
private readonly TextBox text;
private bool revealed;
public string Value => revealed ? text.Text : password.Password;
public SecretInput(string value, string label)
{
ColumnDefinitions.Add(new ColumnDefinition()); ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
password = new PasswordBox { Password = value }; text = new TextBox { Visibility = Visibility.Collapsed };
System.Windows.Automation.AutomationProperties.SetName(password, label); System.Windows.Automation.AutomationProperties.SetName(text, label + " (показано)");
Children.Add(password); Children.Add(text);
var reveal = Ui.IconButton("\uE890", "Показать / скрыть «" + label + "»", () =>
{
if (revealed) { password.Password = text.Text; text.Clear(); } else { text.Text = password.Password; password.Clear(); }
revealed = !revealed; text.Visibility = revealed ? Visibility.Visible : Visibility.Collapsed; password.Visibility = revealed ? Visibility.Collapsed : Visibility.Visible;
}); Grid.SetColumn(reveal, 1); Children.Add(reveal);
}
public void Set(string value) { if (revealed) text.Text = value; else password.Password = value; }
}
public partial class MainWindow
{
private bool Confirm(string title, string explanation)
{
var dialog = new AppDialog(this, title, explanation, 530); dialog.AddCancel(); dialog.Actions.Children.Add(Ui.Button("Продолжить", dialog.Accept, "Primary"));
return dialog.ShowDialog() == true;
}
private void AddEntry()
{
if (key == null || busy) return;
var dialog = new AppDialog(this, "Новая карточка", "Выберите, что хотите сохранить.", 630);
var grid = new Grid(); grid.ColumnDefinitions.Add(new ColumnDefinition()); grid.ColumnDefinitions.Add(new ColumnDefinition()); string? kind = null;
for (int i = 0; i < Templates.Kinds.Length; i++)
{
int index = i; if (i % 2 == 0) grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
var button = Ui.Button(Templates.Titles[i], () => { kind = Templates.Kinds[index]; dialog.Accept(); }); button.Margin = new Thickness(0, 7, 10, 3); button.Padding = new Thickness(13, 16, 13, 16); button.HorizontalContentAlignment = HorizontalAlignment.Left;
var label = Ui.Text(Templates.Titles[i], 13); label.Margin = new Thickness(12, 0, 0, 0); label.VerticalAlignment = VerticalAlignment.Center;
button.Content = Ui.Row(Ui.Tile(Templates.Glyphs[i], Templates.Colors[i], 34), label); Grid.SetColumn(button, i % 2); Grid.SetRow(button, i / 2); grid.Children.Add(button);
}
dialog.Body.Children.Add(grid); dialog.AddCancel();
if (dialog.ShowDialog() == true && kind != null && key != null) EditEntry(Templates.Create(kind), true);
}
private void EditEntry(Entry original, bool isNew = false)
{
if (key == null || busy) return;
var draft = Vault.Copy(original);
var dialog = new AppDialog(this, isNew ? "Добавить карточку" : "Изменить карточку", Templates.Titles[Templates.Index(draft.Category)], 650);
var title = Ui.Input(dialog.Body, "Название", draft.Title); title.MaxLength = 500;
var star = new CheckBox { Content = "В избранном", IsChecked = draft.Star, Margin = new Thickness(0, 15, 0, 0) }; dialog.Body.Children.Add(star);
var fieldPanel = new StackPanel(); dialog.Body.Children.Add(fieldPanel);
var readers = new List<Func<Field>>();
void RenderFields()
{
fieldPanel.Children.Clear(); readers.Clear();
for (int i = 0; i < draft.Fields.Count; i++)
{
int index = i; var field = draft.Fields[i]; var group = new StackPanel();
var line = new DockPanel(); var delete = Ui.IconButton("\uE74D", "Удалить поле «" + field.Name + "»", () => { CaptureFields(); draft.Fields.RemoveAt(index); RenderFields(); }); DockPanel.SetDock(delete, Dock.Right); line.Children.Add(delete);
var name = new TextBox { Text = field.Name, FontSize = 12, Padding = new Thickness(9, 7, 9, 7), Margin = new Thickness(0, 0, 8, 0) }; System.Windows.Automation.AutomationProperties.SetName(name, "Название поля " + (i + 1)); line.Children.Add(name); group.Children.Add(line);
FrameworkElement editor; Func<string> read;
if (field.Secret)
{
var secret = new SecretInput(field.Value, field.Name); editor = secret; read = () => secret.Value;
if (field.Type == "password")
{
var generate = Ui.Button("Сгенерировать пароль", () => ShowGenerator(secret.Set), "Quiet"); generate.FontSize = 11; generate.HorizontalAlignment = HorizontalAlignment.Left; generate.Padding = new Thickness(0, 8, 10, 0); generate.Margin = new Thickness(0, 0, 0, 4);
editor.Margin = new Thickness(0, 7, 0, 0); group.Children.Add(editor); group.Children.Add(generate);
}
else { editor.Margin = new Thickness(0, 7, 0, 0); group.Children.Add(editor); }
}
else
{
var input = new TextBox { Text = field.Value, Margin = new Thickness(0, 7, 0, 0) }; System.Windows.Automation.AutomationProperties.SetName(input, field.Name); editor = input; read = () => input.Text; group.Children.Add(input);
}
readers.Add(() => { var f = Vault.Copy(field); f.Name = name.Text.Trim(); f.Value = read(); return f; });
var card = Ui.Box(group, "#181D23", 9, 12); card.Margin = new Thickness(0, 15, 0, 0); fieldPanel.Children.Add(card);
}
}
void CaptureFields() => draft.Fields = readers.Select(read => read()).ToList();
RenderFields();
var extras = Ui.Row(Ui.Button("+ Текстовое поле", () => { CaptureFields(); draft.Fields.Add(new Field { Name = "Новое поле" }); RenderFields(); }, "Quiet"), Ui.Button("+ Секретное поле", () => { CaptureFields(); draft.Fields.Add(new Field { Name = "Секретное поле", Type = "secret" }); RenderFields(); }, "Quiet")); extras.Margin = new Thickness(0, 9, 0, 0); dialog.Body.Children.Add(extras);
var tags = Ui.Input(dialog.Body, "Метки через запятую", string.Join(", ", draft.Tags));
var notes = Ui.Input(dialog.Body, "Заметка", draft.Notes, true);
var error = Ui.Text("", 12, "#FFADAD"); error.Margin = new Thickness(0, 10, 0, 0); dialog.Body.Children.Add(error);
void Capture()
{
CaptureFields(); draft.Title = title.Text.Trim(); draft.Star = star.IsChecked == true; draft.Notes = notes.Text;
draft.Tags = tags.Text.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Distinct().ToList();
}
void Save()
{
if (key == null || dialog.Forced) return;
Capture();
if (draft.Title.Length == 0) { error.Text = "Укажите название карточки."; title.Focus(); return; }
if (draft.Fields.Any(f => f.Name.Length == 0)) { error.Text = "Укажите название каждого поля."; return; }
try
{
var next = Vault.Copy(data!); var current = next.Entries.Find(e => e.Id == draft.Id);
if (current != null && current.Rev != draft.Rev) { draft.Id = Guid.NewGuid().ToString(); draft.Title += " · моя версия (конфликт)"; }
Vault.Update(next, draft); store.Save(next, key); data = next; selected = draft; dialog.Accept(); filter = "all"; Search.Clear(); BuildNavigation(); RefreshCards(); SetStatus("Карточка сохранена и зашифрована.");
}
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or System.Security.Cryptography.CryptographicException) { error.Text = "Не удалось сохранить: " + ex.Message; }
}
dialog.Closing += (_, e) =>
{
if (dialog.Accepted || dialog.Forced || key == null) return;
Capture();
if (JsonSerializer.Serialize(draft, Vault.Json) != JsonSerializer.Serialize(original, Vault.Json) && !Confirm("Закрыть без сохранения?", "В карточке есть несохранённые изменения.")) e.Cancel = true;
};
dialog.AddCancel(); dialog.Actions.Children.Add(Ui.Button("Сохранить карточку", Save, "Primary"));
dialog.PreviewKeyDown += (_, e) => { if (e.Key == Key.S && Keyboard.Modifiers == ModifierKeys.Control) { Save(); e.Handled = true; } };
dialog.Loaded += (_, _) => title.Focus(); dialog.ShowDialog();
}
private void ShowGenerator(Action<string>? use = null)
{
var owner = OwnedWindows.Cast<Window>().LastOrDefault(w => w.IsVisible) ?? this;
new PasswordGeneratorDialog(owner, use ?? CopySecret, use != null).ShowDialog();
}
private void ShowSettings()
{
if (busy) return;
var dialog = new AppDialog(this, "Настройки", "Подключение, резервные копии и защита хранилища.", 640);
dialog.Body.Children.Add(Ui.Text("СИНХРОНИЗАЦИЯ С PI", 10, "#A5E4BC", FontWeights.SemiBold));
dialog.Body.Children.Add(Ui.Text("База доступна только по локальному IP Pi и только в указанном домашнем Wi-Fi. Вне дома приложение работает автономно.", 12, "#969EAD"));
var url = Ui.Input(dialog.Body, "Локальный HTTPS-адрес Pi", store.Config.Url);
var homeSsid = Ui.Input(dialog.Body, "Домашний Wi-Fi (точное имя SSID)", store.Config.HomeWifiSsid);
var certificate = Ui.Input(dialog.Body, "SHA-256 локального сертификата Pi", store.Config.CertificateSha256);
Ui.Label(dialog.Body, "Ключ подключения"); var token = new SecretInput(store.Config.Token, "Ключ подключения"); dialog.Body.Children.Add(token);
var auto = new CheckBox { Content = "Синхронизировать открытое хранилище раз в минуту", IsChecked = store.Config.AutoSync, Margin = new Thickness(0, 15, 0, 10) }; dialog.Body.Children.Add(auto);
var error = Ui.Text("", 12, "#FFADAD"); dialog.Body.Children.Add(error);
var connection = Ui.Button("Выбрать файл connection.qsafe", () =>
{
var file = new OpenFileDialog { Title = "Личный файл подключения к Pi", Filter = "Файл подключения QSafe (*.qsafe)|*.qsafe" };
if (file.ShowDialog(dialog) != true) return;
try { var config = Connection.Parse(LocalStore.ReadBounded(file.FileName, 65536)); url.Text = config.Url; homeSsid.Text = config.HomeWifiSsid; certificate.Text = config.CertificateSha256; token.Set(config.Token); error.Text = "Локальное подключение прочитано. Нажмите «Сохранить»."; }
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or JsonException or ArgumentException) { error.Text = ex.Message; }
}); connection.HorizontalAlignment = HorizontalAlignment.Left; connection.Margin = new Thickness(0, 12, 0, 12); dialog.Body.Children.Add(connection);
string syncText = store.Config.LastSync is { } last ? "Последняя синхронизация: " + last.ToLocalTime().ToString("dd.MM.yyyy HH:mm") : "Синхронизация ещё не выполнялась";
dialog.Body.Children.Add(Ui.Text(syncText, 12, "#969EAD"));
Action? after = null;
if (key != null)
{
var sync = Ui.Button("Синхронизировать сейчас", () => { if (!SaveSettings()) return; after = () => _ = Synchronize(); dialog.Accept(); }); sync.IsEnabled = !IsDemo; sync.HorizontalAlignment = HorizontalAlignment.Left; sync.Margin = new Thickness(0, 14, 0, 0); dialog.Body.Children.Add(sync);
dialog.Body.Children.Add(Ui.Rule()); dialog.Body.Children.Add(Ui.Text("ДАННЫЕ", 10, "#A5E4BC", FontWeights.SemiBold));
foreach (var (label, action) in new (string, Action)[] { ("Экспорт зашифрованной базы .qsafe", ExportVault), ("Восстановить из файла .qsafe", ImportVault), ("Импортировать SafeInCloud XML", ImportXml) })
{
var button = Ui.Button(label, () => { after = action; dialog.Accept(); }, "Quiet"); button.HorizontalAlignment = HorizontalAlignment.Left; button.Margin = new Thickness(0, 6, 0, 0); dialog.Body.Children.Add(button);
}
}
dialog.Body.Children.Add(Ui.Rule()); dialog.Body.Children.Add(Ui.Text("ЗАЩИТА", 10, "#A5E4BC", FontWeights.SemiBold));
var protections = Ui.Text("Автоблокировка через 2 минуты бездействия, при сворачивании и блокировке Windows. Буфер обмена очищается через 30 секунд. Ключ подключения защищён учётной записью Windows. Мастер-пароль не сохраняется.", 12, "#969EAD"); protections.Margin = new Thickness(0, 12, 0, 0); protections.LineHeight = 20; dialog.Body.Children.Add(protections);
dialog.Body.Children.Add(Ui.Rule()); dialog.Body.Children.Add(Ui.Text("QSafe для Windows · 1.0.4\nЛокальные резервные копии: последние 10 сохранений.\nWindows Hello, TOTP и автозаполнение браузеров пока недоступны.", 12, "#788493"));
bool SaveSettings()
{
if (IsDemo) { error.Text = "Подключение к серверу недоступно в демо."; return false; }
try
{
if (token.Value.Length > 0)
{
var next = Vault.Copy(store.Config); next.Url = url.Text.Trim(); next.HomeWifiSsid = homeSsid.Text; next.CertificateSha256 = certificate.Text.Trim(); next.Token = token.Value; next.AutoSync = auto.IsChecked == true; store.Configure(next);
}
else if (store.Connected) throw new InvalidDataException("Для изменения подключения нужен ключ. Текущее подключение сохранено.");
else { store.Config.AutoSync = auto.IsChecked == true; store.SaveConfig(); }
SetStatus("Настройки сохранены."); return true;
}
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or System.ComponentModel.Win32Exception or ArgumentException) { error.Text = ex.Message; return false; }
}
dialog.AddCancel("Закрыть"); dialog.Actions.Children.Add(Ui.Button("Сохранить", () => { if (SaveSettings()) dialog.Accept(); }, "Primary"));
if (dialog.ShowDialog() == true && !dialog.Forced) after?.Invoke();
}
private async void ImportXml()
{
if (key == null || busy) return;
var file = new OpenFileDialog { Title = "XML-экспорт SafeInCloud", Filter = "XML SafeInCloud (*.xml)|*.xml" };
if (file.ShowDialog(this) != true) return;
ImportPreview? preview = null;
await Work("Читаем XML локально…", async () => { var current = data!; preview = await Task.Run(() => XmlImport.Preview(LocalStore.ReadBounded(file.FileName, 24 * 1024 * 1024), current)); });
if (preview == null || key == null) return;
if (!Confirm("Импорт из SafeInCloud", $"Добавится карточек: {preview.Added}. Дубликатов: {preview.Duplicates}. Пропущено: {preview.Skipped}. Вложений сохранится: {preview.Attachments}.\n\nXML содержит открытые пароли. После проверки переноса удалите экспорт с устройства.")) return;
await Work("Сохраняем импортированные карточки…", () =>
{
store.Save(preview.Data, key); data = preview.Data; filter = "all"; Search.Clear(); BuildNavigation(); RefreshCards(); SetStatus($"Импортировано карточек: {preview.Added}. Вложения сохранены в зашифрованной базе."); return Task.CompletedTask;
});
}
}