226 lines
19 KiB
C#
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;
|
|
});
|
|
}
|
|
}
|