diff --git a/Directory.Packages.props b/Directory.Packages.props index 2bd2406..55e58f7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -52,6 +52,10 @@ + + + diff --git a/OpenIPC.Viewer.slnx b/OpenIPC.Viewer.slnx index b5df9f3..4497995 100644 --- a/OpenIPC.Viewer.slnx +++ b/OpenIPC.Viewer.slnx @@ -14,6 +14,7 @@ + diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7178c52..407e97b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -44,3 +44,25 @@ Polish items still open before tagging the betas: > **Validation caveat.** Linux / macOS / Android / iOS code paths build and > link in CI but are not yet end-to-end tested on real devices for every > commit. Feedback is welcome — open an issue with OS version and steps. + +## Post-MVP — planned enhancements (Phases 12+) + +Enhancement phases distilled from a full review of competing-client release +notes, all designed cross-platform (Win / Lin / Mac / Android / iOS). Rationale +and scope live in the planning docs (`dashboard-ideas-roadmap-ru.md`). + +| # | Phase | Scope | Status | +|---|---|---|:---:| +| 12 | Streaming hardening | Smart-pause hidden tiles, auto SD/HD, watchdog + backoff, last-frame hold, error tile | ✅ Done | +| 13 | SSH device suite | SSH terminal, SCP file manager, open-in-browser, config push | ✅ Done | +| 14 | Snapshots & viewer | Always-HD snapshot, snapshot browser, built-in viewer + basic editor | 📋 Planned | +| 15 | Local AI analytics | ONNX object detection per camera, auto-record, control center, CPU fallback | 📋 Planned | +| 16 | Archive pro | Fragmented MP4, activity calendar, timeline zoom, clip export | 📋 Planned | +| 17 | Community & app-level | Tabbed layouts, config export/import, notifications, white-label, issue reporter, RBAC | 📋 Planned | +| 18 | Streq remote access | Cloud multistreaming across devices: LAN/overlay/relay routing, enrollment, WebRTC/HLS, cross-device sync | 📋 Planned | + +> **Phase 18** is the viewer side of our own **Streq** cloud (WireGuard/n3n +> overlay + go2rtc/MediaMTX media relay) for remote multistreaming across +> devices. The cloud/agent side lives in a separate `streq` repo with its own +> phasing; Phase 18 starts once the Streq coordinator (Stage I) is up, so it can +> run as a parallel track to Phases 12–17. diff --git a/src/OpenIPC.Viewer.App/Controls/TerminalView.cs b/src/OpenIPC.Viewer.App/Controls/TerminalView.cs new file mode 100644 index 0000000..981dc6f --- /dev/null +++ b/src/OpenIPC.Viewer.App/Controls/TerminalView.cs @@ -0,0 +1,243 @@ +using System; +using System.Globalization; +using System.Text; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; +using OpenIPC.Viewer.Core.Ssh.Terminal; + +namespace OpenIPC.Viewer.App.Controls; + +/// +/// Renders a grid and turns keyboard input into +/// the bytes a shell expects (phase-13 §13.3). Drawn entirely in +/// — monospace text per row, background fills per cell, +/// a block cursor — so there are no child controls to hit-test. +/// +public sealed class TerminalView : Control +{ + // Fixed 16-color ANSI palette (terminals don't follow the app theme). + private static readonly Color[] Palette = + { + Color.Parse("#1e1e1e"), Color.Parse("#cd3131"), Color.Parse("#0dbc79"), Color.Parse("#e5e510"), + Color.Parse("#2472c8"), Color.Parse("#bc3fbc"), Color.Parse("#11a8cd"), Color.Parse("#cccccc"), + Color.Parse("#666666"), Color.Parse("#f14c4c"), Color.Parse("#23d18b"), Color.Parse("#f5f543"), + Color.Parse("#3b8eea"), Color.Parse("#d670d6"), Color.Parse("#29b8db"), Color.Parse("#ffffff"), + }; + + private static readonly Color DefaultFg = Color.Parse("#d4d4d4"); + private static readonly Color DefaultBg = Color.Parse("#0c0f14"); + private static readonly IBrush DefaultFgBrush = new SolidColorBrush(DefaultFg); + private static readonly IBrush CursorBrush = new SolidColorBrush(Color.Parse("#d4d4d4")); + + public static readonly StyledProperty EmulatorProperty = + AvaloniaProperty.Register(nameof(Emulator)); + + public TerminalEmulator? Emulator + { + get => GetValue(EmulatorProperty); + set => SetValue(EmulatorProperty, value); + } + + public static readonly StyledProperty TerminalFontSizeProperty = + AvaloniaProperty.Register(nameof(TerminalFontSize), 14); + + public double TerminalFontSize + { + get => GetValue(TerminalFontSizeProperty); + set => SetValue(TerminalFontSizeProperty, value); + } + + /// Raised with raw text/control bytes the user typed. + public event EventHandler? Input; + + /// Raised when the available size maps to a new column/row count. + public event EventHandler<(int Columns, int Rows)>? GridResized; + + private readonly Typeface _typeface = new(new FontFamily("Cascadia Mono,Consolas,Menlo,monospace")); + private readonly Typeface _boldTypeface = + new(new FontFamily("Cascadia Mono,Consolas,Menlo,monospace"), weight: FontWeight.Bold); + + private double _cellWidth; + private double _cellHeight; + private TerminalEmulator? _subscribed; + private int _lastCols = -1; + private int _lastRows = -1; + + public TerminalView() + { + Focusable = true; + EnsureMetrics(); + this.GetObservable(BoundsProperty).Subscribe(_ => RecomputeGrid()); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == TerminalFontSizeProperty) + { + EnsureMetrics(); + RecomputeGrid(); + InvalidateVisual(); + return; + } + + if (change.Property != EmulatorProperty) + return; + + if (_subscribed is not null) + _subscribed.Updated -= OnEmulatorUpdated; + _subscribed = Emulator; + if (_subscribed is not null) + _subscribed.Updated += OnEmulatorUpdated; + RecomputeGrid(); + InvalidateVisual(); + } + + // Updated fires on the UI thread (the VM marshals shell data), so a direct + // invalidate is safe. + private void OnEmulatorUpdated() => InvalidateVisual(); + + private void EnsureMetrics() + { + var sample = new FormattedText("M", CultureInfo.InvariantCulture, FlowDirection.LeftToRight, + _typeface, TerminalFontSize, DefaultFgBrush); + _cellWidth = sample.Width; + _cellHeight = sample.Height; + } + + private void RecomputeGrid() + { + if (_cellWidth <= 0 || _cellHeight <= 0) + return; + + var cols = Math.Max(1, (int)(Bounds.Width / _cellWidth)); + var rows = Math.Max(1, (int)(Bounds.Height / _cellHeight)); + if (cols == _lastCols && rows == _lastRows) + return; + + _lastCols = cols; + _lastRows = rows; + GridResized?.Invoke(this, (cols, rows)); + } + + public override void Render(DrawingContext context) + { + base.Render(context); + context.FillRectangle(new SolidColorBrush(DefaultBg), new Rect(Bounds.Size)); + + var emu = Emulator; + if (emu is null) + return; + + for (var row = 0; row < emu.Rows; row++) + { + var cells = emu.GetRow(row); + var y = row * _cellHeight; + DrawRow(context, cells, y); + } + + DrawCursor(context, emu); + } + + private void DrawRow(DrawingContext context, TerminalCell[] cells, double y) + { + // Background fills first (only non-default cells). + for (var c = 0; c < cells.Length; c++) + { + var cell = cells[c]; + if (cell.Background == TerminalPalette.DefaultBackground) + continue; + context.FillRectangle( + new SolidColorBrush(Palette[cell.Background & 0x0F]), + new Rect(c * _cellWidth, y, _cellWidth, _cellHeight)); + } + + // Text in runs of equal foreground/bold to cut FormattedText churn. + var run = new StringBuilder(); + var runStart = 0; + byte runFg = cells.Length > 0 ? cells[0].Foreground : TerminalPalette.DefaultForeground; + var runBold = cells.Length > 0 && cells[0].Bold; + + void Flush(int end) + { + if (run.Length == 0) return; + var brush = runFg == TerminalPalette.DefaultForeground + ? DefaultFgBrush + : new SolidColorBrush(Palette[runFg & 0x0F]); + var ft = new FormattedText(run.ToString(), CultureInfo.InvariantCulture, FlowDirection.LeftToRight, + runBold ? _boldTypeface : _typeface, TerminalFontSize, brush); + context.DrawText(ft, new Point(runStart * _cellWidth, y)); + run.Clear(); + } + + for (var c = 0; c < cells.Length; c++) + { + var cell = cells[c]; + if (c == 0 || cell.Foreground != runFg || cell.Bold != runBold) + { + Flush(c); + runStart = c; + runFg = cell.Foreground; + runBold = cell.Bold; + } + run.Append(cell.Char); + } + Flush(cells.Length); + } + + private void DrawCursor(DrawingContext context, TerminalEmulator emu) + { + var x = emu.CursorColumn * _cellWidth; + var y = emu.CursorRow * _cellHeight; + // Hollow block so the character under the cursor stays readable. + context.DrawRectangle(null, new Pen(CursorBrush, 1), + new Rect(x, y, _cellWidth, _cellHeight)); + } + + protected override void OnTextInput(TextInputEventArgs e) + { + base.OnTextInput(e); + if (!string.IsNullOrEmpty(e.Text)) + { + Input?.Invoke(this, e.Text); + e.Handled = true; + } + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + + // Ctrl+letter -> control byte (Ctrl+C = 0x03, etc.). + if (e.KeyModifiers.HasFlag(KeyModifiers.Control) && e.Key is >= Key.A and <= Key.Z) + { + var b = (char)(e.Key - Key.A + 1); + Input?.Invoke(this, b.ToString()); + e.Handled = true; + return; + } + + var seq = e.Key switch + { + Key.Enter => "\r", + Key.Back => "\x7f", + Key.Tab => "\t", + Key.Escape => "\x1b", + Key.Up => "\x1b[A", + Key.Down => "\x1b[B", + Key.Right => "\x1b[C", + Key.Left => "\x1b[D", + Key.Home => "\x1b[H", + Key.End => "\x1b[F", + _ => null, + }; + if (seq is not null) + { + Input?.Invoke(this, seq); + e.Handled = true; + } + } +} diff --git a/src/OpenIPC.Viewer.App/Services/CameraEditorFactory.cs b/src/OpenIPC.Viewer.App/Services/CameraEditorFactory.cs index 868512f..0a939d4 100644 --- a/src/OpenIPC.Viewer.App/Services/CameraEditorFactory.cs +++ b/src/OpenIPC.Viewer.App/Services/CameraEditorFactory.cs @@ -24,6 +24,6 @@ public CameraEditorFactory(IVideoEngine engine, CameraDirectoryService directory public CameraEditorViewModel CreateForNew() => new(_engine, _directory, _userSettings, _loggerFactory.CreateLogger()); - public CameraEditorViewModel CreateForEdit(Camera existing, CameraCredentials? credentials) => - new(existing, credentials, _engine, _directory, _userSettings, _loggerFactory.CreateLogger()); + public CameraEditorViewModel CreateForEdit(Camera existing, CameraCredentials? credentials, CameraCredentials? sshCredentials) => + new(existing, credentials, sshCredentials, _engine, _directory, _userSettings, _loggerFactory.CreateLogger()); } diff --git a/src/OpenIPC.Viewer.App/Services/DialogService.cs b/src/OpenIPC.Viewer.App/Services/DialogService.cs index 8bc7844..e3ffec0 100644 --- a/src/OpenIPC.Viewer.App/Services/DialogService.cs +++ b/src/OpenIPC.Viewer.App/Services/DialogService.cs @@ -1,3 +1,4 @@ +using System; using System.IO; using System.Linq; using System.Threading.Tasks; @@ -65,6 +66,22 @@ public async Task ConfirmAsync(string title, string message, string confir return result == true; } + public async Task PromptAsync(string title, string initial, string okLabel, string cancelLabel) + { + if (OverlayDialogPresenter.IsMobile) + { + var content = new PromptDialogContent(); + content.Configure(title, initial, okLabel, cancelLabel); + return await OverlayDialogPresenter.ShowAsync(content, content.Completion).ConfigureAwait(true); + } + + var owner = ResolveOwner(); + if (owner is null) return null; + var dlg = new PromptDialog(); + dlg.Configure(title, initial, okLabel, cancelLabel); + return await dlg.ShowDialog(owner).ConfigureAwait(true); + } + public Task ShowWelcomeAsync() { if (OverlayDialogPresenter.IsMobile) @@ -115,6 +132,32 @@ public Task ShowWelcomeAsync() return files.FirstOrDefault()?.TryGetLocalPath(); } + public async Task PickAnyFileAsync(string title) + { + var owner = ResolveTopLevel(); + if (owner is null) return null; + + var files = await owner.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + { + Title = title, + AllowMultiple = false, + }); + return files.FirstOrDefault()?.TryGetLocalPath(); + } + + public async Task PickSaveTargetAsync(string suggestedName, string title) + { + var owner = ResolveTopLevel(); + if (owner is null) return null; + + var file = await owner.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions + { + Title = title, + SuggestedFileName = suggestedName, + }); + return file?.TryGetLocalPath(); + } + public async Task PickSaveFileAsync(string suggestedName, string title, string extension) { var owner = ResolveOwner(); @@ -187,6 +230,62 @@ public async Task ShowManageGroupsAsync(ManageGroupsViewModel viewModel) return dlg.ShowDialog(owner); } + public async Task OpenSshTerminalAsync(ViewModels.SshTerminalViewModel viewModel) + { + if (OverlayDialogPresenter.IsMobile) + { + var content = new SshTerminalContent { DataContext = viewModel }; + await OverlayDialogPresenter.ShowAsync(content, content.Completion, fullScreen: true).ConfigureAwait(true); + return; + } + + var owner = ResolveOwner(); + // Non-modal: the user keeps the live view usable while a terminal is open. + var window = new SshTerminalWindow { DataContext = viewModel }; + if (owner is null) window.Show(); + else window.Show(owner); + } + + public async Task OpenFileManagerAsync(ViewModels.FileManagerViewModel viewModel) + { + if (OverlayDialogPresenter.IsMobile) + { + var content = new FileManagerContent { DataContext = viewModel }; + await OverlayDialogPresenter.ShowAsync(content, content.Completion, fullScreen: true).ConfigureAwait(true); + return; + } + + var owner = ResolveOwner(); + var window = new FileManagerWindow { DataContext = viewModel }; + if (owner is null) window.Show(); + else window.Show(owner); + } + + public async Task OpenUrlAsync(string url) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + return false; + + var top = ResolveTopLevel(); + if (top?.Launcher is null) + return false; + + return await top.Launcher.LaunchUriAsync(uri).ConfigureAwait(true); + } + private static Window? ResolveOwner() => (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow; + + // Resolves the active TopLevel on both heads: the desktop MainWindow, or + // the single-view MainView on mobile (where there is no Window). + private static TopLevel? ResolveTopLevel() + { + Control? root = Application.Current?.ApplicationLifetime switch + { + IClassicDesktopStyleApplicationLifetime desk => desk.MainWindow, + ISingleViewApplicationLifetime sv => sv.MainView, + _ => null, + }; + return root is null ? null : TopLevel.GetTopLevel(root); + } } diff --git a/src/OpenIPC.Viewer.App/Services/FileManagerFactory.cs b/src/OpenIPC.Viewer.App/Services/FileManagerFactory.cs new file mode 100644 index 0000000..c441096 --- /dev/null +++ b/src/OpenIPC.Viewer.App/Services/FileManagerFactory.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.Logging; +using OpenIPC.Viewer.App.ViewModels; +using OpenIPC.Viewer.Core.Entities; +using OpenIPC.Viewer.Core.Services; +using OpenIPC.Viewer.Core.Ssh; + +namespace OpenIPC.Viewer.App.Services; + +public sealed class FileManagerFactory +{ + private readonly CameraDirectoryService _directory; + private readonly ISshSessionFactory _sessions; + private readonly IDialogService _dialogs; + private readonly ILoggerFactory _loggerFactory; + + public FileManagerFactory(CameraDirectoryService directory, ISshSessionFactory sessions, IDialogService dialogs, ILoggerFactory loggerFactory) + { + _directory = directory; + _sessions = sessions; + _dialogs = dialogs; + _loggerFactory = loggerFactory; + } + + public FileManagerViewModel Create(Camera camera) => + new(camera, _directory, _sessions, _dialogs, _loggerFactory.CreateLogger()); +} diff --git a/src/OpenIPC.Viewer.App/Services/IDialogService.cs b/src/OpenIPC.Viewer.App/Services/IDialogService.cs index b1a5c60..58494ad 100644 --- a/src/OpenIPC.Viewer.App/Services/IDialogService.cs +++ b/src/OpenIPC.Viewer.App/Services/IDialogService.cs @@ -1,16 +1,28 @@ using System.Threading.Tasks; +using OpenIPC.Viewer.App.ViewModels; using OpenIPC.Viewer.App.ViewModels.Dialogs; namespace OpenIPC.Viewer.App.Services; public interface IDialogService { + // Opens an interactive SSH terminal — a non-modal window on desktop, a + // full-screen overlay on mobile (Phase 13.3). + Task OpenSshTerminalAsync(SshTerminalViewModel viewModel); + + // Opens the remote file manager (Phase 13.4) — window on desktop, overlay on mobile. + Task OpenFileManagerAsync(FileManagerViewModel viewModel); + Task ShowCameraEditorAsync(CameraEditorViewModel viewModel); Task ShowDiscoveryDialogAsync(DiscoveryDialogViewModel viewModel); Task ConfirmAsync(string title, string message, string confirmLabel = "Delete", string cancelLabel = "Cancel"); + // Single-line text prompt. Returns the trimmed text, or null if cancelled + // or left empty (Phase 13.4 — new remote folder name). + Task PromptAsync(string title, string initial, string okLabel, string cancelLabel); + Task ShowWelcomeAsync(); Task PickFolderAsync(string? title = null); @@ -19,10 +31,20 @@ public interface IDialogService Task PickImageFileAsync(string title); + // File manager (Phase 13.4): pick any local file to upload, or a save + // target for a download. Cross-platform via StorageProvider. + Task PickAnyFileAsync(string title); + + Task PickSaveTargetAsync(string suggestedName, string title); + Task CopyFileToClipboardAsync(string path); Task ShowManageGroupsAsync(ManageGroupsViewModel viewModel); // Returns the edited JSON if the user clicked Apply, null if cancelled. Task ShowRawConfigEditorAsync(string initialJson); + + // Opens a URI in the system browser via the platform launcher. Returns + // false if no TopLevel is available or the launch was rejected. + Task OpenUrlAsync(string url); } diff --git a/src/OpenIPC.Viewer.App/Services/Localizer.cs b/src/OpenIPC.Viewer.App/Services/Localizer.cs index af8a74f..b187c29 100644 --- a/src/OpenIPC.Viewer.App/Services/Localizer.cs +++ b/src/OpenIPC.Viewer.App/Services/Localizer.cs @@ -87,6 +87,9 @@ private static LangCode DetectSystem() ["Library.Title"] = "Cameras", ["Library.EmptyTitle"] = "No cameras yet", + ["Library.RowOpenWeb"] = "Open web", + ["Library.RowSsh"] = "SSH", + ["Library.RowFiles"] = "Files", ["Library.RowEdit"] = "Edit", ["Library.RowDelete"] = "Delete", ["Library.RowShowInGrid"] = "Show in grid", @@ -135,8 +138,18 @@ private static LangCode DetectSystem() ["Settings.Video"] = "Video", ["Settings.Recording"] = "Recording", ["Settings.Discovery"] = "Discovery", + ["Settings.Ssh"] = "SSH", ["Settings.Advanced"] = "Advanced", ["Settings.About"] = "About", + ["Settings.Ssh.StrictHostKey"] = "Strict host-key checking", + ["Settings.Ssh.DefaultPort"] = "Default SSH port", + ["Settings.Ssh.TerminalFontSize"] = "Terminal font size", + ["Settings.Ssh.MajesticPath"] = "majestic.yaml path", + ["Settings.Ssh.ResetHostKeys"] = "Forget all host keys", + ["Settings.Ssh.HostKeysCleared"] = "All pinned host keys were forgotten.", + ["Settings.Ssh.ResetTitle"] = "Forget host keys", + ["Settings.Ssh.ResetMessage"] = "Forget all pinned SSH host keys? They will be re-pinned on next connect.", + ["Settings.Ssh.ResetConfirm"] = "Forget", ["Settings.Video.TelemetryOverlay"] = "Show telemetry overlay on live view", ["Settings.Video.AutoSdHd"] = "Auto SD/HD (sub in grid, main when full-screen)", @@ -174,6 +187,7 @@ private static LangCode DetectSystem() ["CameraEditor.Label.Username"] = "Username", ["CameraEditor.Label.Password"] = "Password", ["CameraEditor.Label.Group"] = "Group", + ["CameraEditor.Label.SshSection"] = "SSH (optional)", ["CameraEditor.Label.StreamQuality"] = "Grid stream quality", ["CameraEditor.Quality.Auto"] = "Auto (SD/HD)", ["CameraEditor.Quality.AlwaysHd"] = "Always HD (main)", @@ -184,6 +198,9 @@ private static LangCode DetectSystem() ["CameraEditor.Placeholder.RtspMain"] = "rtsp://192.168.1.10/", ["CameraEditor.Placeholder.RtspSub"] = "rtsp://192.168.1.10/stream1", ["CameraEditor.Placeholder.Username"] = "admin", + ["CameraEditor.Placeholder.SshUser"] = "SSH user (reuse main if blank)", + ["CameraEditor.Placeholder.SshPassword"] = "SSH password (reuse main if blank)", + ["CameraEditor.Placeholder.SshPort"] = "22", ["CameraEditor.NoGroup"] = "(no group)", ["CameraEditor.Button.AutoDeriveRtsp"] = "Auto from host", ["CameraEditor.Button.TestConnection"] = "Test connection", @@ -197,6 +214,29 @@ private static LangCode DetectSystem() ["CameraEditor.Error.RtspSubInvalid"] = "RTSP sub URI is not a valid absolute URI.", ["CameraEditor.Error.OnvifPortInvalid"] = "ONVIF port must be between 1 and 65535.", ["CameraEditor.Error.HttpPortInvalid"] = "HTTP port must be between 1 and 65535.", + ["CameraEditor.Error.SshPortInvalid"] = "SSH port must be between 1 and 65535.", + ["CameraEditor.Error.SshCredsIncomplete"] = "Enter both SSH username and password, or leave both blank.", + ["Terminal.Connecting"] = "Connecting…", + ["Terminal.NoCreds"] = "No SSH credentials set for this camera.", + ["Terminal.FailedFormat"] = "SSH error: {0}", + ["FileManager.Title"] = "Files", + ["FileManager.Connecting"] = "Connecting…", + ["FileManager.NoCreds"] = "No SSH credentials set for this camera.", + ["FileManager.FailedFormat"] = "Error: {0}", + ["FileManager.Refresh"] = "Refresh", + ["FileManager.NewFolder"] = "New folder", + ["FileManager.Upload"] = "Upload", + ["FileManager.Download"] = "Download", + ["FileManager.SaveTitle"] = "Save file as", + ["FileManager.UploadTitle"] = "Pick a file to upload", + ["FileManager.NewFolderTitle"] = "New folder name", + ["FileManager.Downloading"] = "Downloading…", + ["FileManager.Uploading"] = "Uploading…", + ["FileManager.TransferDone"] = "Done.", + ["FileManager.DeleteProtected"] = "Refusing to delete a root-level path.", + ["FileManager.DeleteTitle"] = "Delete", + ["FileManager.DeleteMessage"] = "Delete \"{0}\" from the camera?", + ["FileManager.Hint"] = "Tap a folder to open, a file to download.", ["Discovery.Title"] = "Discover cameras", ["Discovery.Header"] = "Discover ONVIF cameras", @@ -227,6 +267,8 @@ private static LangCode DetectSystem() ["CameraPage.Majestic.NightPrefix"] = "night:", ["CameraPage.Majestic.ViewRaw"] = "View raw", ["CameraPage.Majestic.EditRaw"] = "Edit raw", + ["CameraPage.Majestic.EditRawSsh"] = "Edit via SSH", + ["CameraPage.SshNoCreds"] = "No SSH credentials set for this camera.", ["CameraPage.NightMode.Day"] = "Day", ["CameraPage.NightMode.Night"] = "Night", ["CameraPage.NightMode.Auto"] = "Auto", @@ -297,6 +339,9 @@ private static LangCode DetectSystem() ["Library.Title"] = "Камеры", ["Library.EmptyTitle"] = "Камер пока нет", + ["Library.RowOpenWeb"] = "Веб-интерфейс", + ["Library.RowSsh"] = "SSH", + ["Library.RowFiles"] = "Файлы", ["Library.RowEdit"] = "Изменить", ["Library.RowDelete"] = "Удалить", ["Library.RowShowInGrid"] = "В гриде", @@ -345,7 +390,17 @@ private static LangCode DetectSystem() ["Settings.Video"] = "Видео", ["Settings.Recording"] = "Запись", ["Settings.Discovery"] = "Поиск", + ["Settings.Ssh"] = "SSH", ["Settings.Advanced"] = "Дополнительно", + ["Settings.Ssh.StrictHostKey"] = "Строгая проверка host-key", + ["Settings.Ssh.DefaultPort"] = "SSH-порт по умолчанию", + ["Settings.Ssh.TerminalFontSize"] = "Размер шрифта терминала", + ["Settings.Ssh.MajesticPath"] = "Путь к majestic.yaml", + ["Settings.Ssh.ResetHostKeys"] = "Забыть все host-ключи", + ["Settings.Ssh.HostKeysCleared"] = "Все запомненные host-ключи забыты.", + ["Settings.Ssh.ResetTitle"] = "Забыть host-ключи", + ["Settings.Ssh.ResetMessage"] = "Забыть все запомненные SSH host-ключи? Они будут запомнены заново при следующем подключении.", + ["Settings.Ssh.ResetConfirm"] = "Забыть", ["Settings.About"] = "О приложении", ["Settings.Video.TelemetryOverlay"] = "Показывать телеметрию на видео", @@ -384,6 +439,7 @@ private static LangCode DetectSystem() ["CameraEditor.Label.Username"] = "Логин", ["CameraEditor.Label.Password"] = "Пароль", ["CameraEditor.Label.Group"] = "Группа", + ["CameraEditor.Label.SshSection"] = "SSH (необязательно)", ["CameraEditor.Label.StreamQuality"] = "Качество в гриде", ["CameraEditor.Quality.Auto"] = "Авто (SD/HD)", ["CameraEditor.Quality.AlwaysHd"] = "Всегда HD (main)", @@ -394,6 +450,9 @@ private static LangCode DetectSystem() ["CameraEditor.Placeholder.RtspMain"] = "rtsp://192.168.1.10/", ["CameraEditor.Placeholder.RtspSub"] = "rtsp://192.168.1.10/stream1", ["CameraEditor.Placeholder.Username"] = "admin", + ["CameraEditor.Placeholder.SshUser"] = "SSH-логин (пусто — как основной)", + ["CameraEditor.Placeholder.SshPassword"] = "SSH-пароль (пусто — как основной)", + ["CameraEditor.Placeholder.SshPort"] = "22", ["CameraEditor.NoGroup"] = "(без группы)", ["CameraEditor.Button.AutoDeriveRtsp"] = "Подставить из хоста", ["CameraEditor.Button.TestConnection"] = "Проверить", @@ -407,6 +466,29 @@ private static LangCode DetectSystem() ["CameraEditor.Error.RtspSubInvalid"] = "RTSP sub URI должен быть абсолютным URI.", ["CameraEditor.Error.OnvifPortInvalid"] = "ONVIF-порт должен быть от 1 до 65535.", ["CameraEditor.Error.HttpPortInvalid"] = "HTTP-порт должен быть от 1 до 65535.", + ["CameraEditor.Error.SshPortInvalid"] = "SSH-порт должен быть от 1 до 65535.", + ["CameraEditor.Error.SshCredsIncomplete"] = "Укажите и SSH-логин, и SSH-пароль, либо оставьте оба пустыми.", + ["Terminal.Connecting"] = "Подключение…", + ["Terminal.NoCreds"] = "Для камеры не заданы SSH-учётные данные.", + ["Terminal.FailedFormat"] = "Ошибка SSH: {0}", + ["FileManager.Title"] = "Файлы", + ["FileManager.Connecting"] = "Подключение…", + ["FileManager.NoCreds"] = "Для камеры не заданы SSH-учётные данные.", + ["FileManager.FailedFormat"] = "Ошибка: {0}", + ["FileManager.Refresh"] = "Обновить", + ["FileManager.NewFolder"] = "Новая папка", + ["FileManager.Upload"] = "Загрузить", + ["FileManager.Download"] = "Скачать", + ["FileManager.SaveTitle"] = "Сохранить файл как", + ["FileManager.UploadTitle"] = "Выберите файл для загрузки", + ["FileManager.NewFolderTitle"] = "Имя новой папки", + ["FileManager.Downloading"] = "Скачивание…", + ["FileManager.Uploading"] = "Загрузка…", + ["FileManager.TransferDone"] = "Готово.", + ["FileManager.DeleteProtected"] = "Удаление пути корневого уровня запрещено.", + ["FileManager.DeleteTitle"] = "Удалить", + ["FileManager.DeleteMessage"] = "Удалить «{0}» с камеры?", + ["FileManager.Hint"] = "Папка — открыть, файл — скачать.", ["Discovery.Title"] = "Поиск камер", ["Discovery.Header"] = "Поиск ONVIF-камер", @@ -437,6 +519,8 @@ private static LangCode DetectSystem() ["CameraPage.Majestic.NightPrefix"] = "ночь:", ["CameraPage.Majestic.ViewRaw"] = "Смотреть raw", ["CameraPage.Majestic.EditRaw"] = "Правка raw", + ["CameraPage.Majestic.EditRawSsh"] = "Правка по SSH", + ["CameraPage.SshNoCreds"] = "Для камеры не заданы SSH-учётные данные.", ["CameraPage.NightMode.Day"] = "День", ["CameraPage.NightMode.Night"] = "Ночь", ["CameraPage.NightMode.Auto"] = "Авто", diff --git a/src/OpenIPC.Viewer.App/Services/OverlayDialogPresenter.cs b/src/OpenIPC.Viewer.App/Services/OverlayDialogPresenter.cs index 47825ad..5dd5a66 100644 --- a/src/OpenIPC.Viewer.App/Services/OverlayDialogPresenter.cs +++ b/src/OpenIPC.Viewer.App/Services/OverlayDialogPresenter.cs @@ -54,7 +54,10 @@ public static class OverlayDialogPresenter /// Raised on the UI thread whenever may have changed. public static event Action? ActiveChanged; - public static async Task ShowAsync(Control content, Task completion) + // fullScreen → fill the whole TopLevel (no bottom-sheet card / scroll wrapper). + // Used for the SSH terminal and file manager, which are full-screen pages on + // mobile, not sheets (phase-13 §13.3). + public static async Task ShowAsync(Control content, Task completion, bool fullScreen = false) { var overlay = GetOverlayLayer(); if (overlay is null) return default!; @@ -69,30 +72,31 @@ public static async Task ShowAsync(Control content, Task ShowAsync(Control content, Task 0 ? safeTop : 28, 0, 0); + } + var dim = new Border { Background = new SolidColorBrush(Color.FromArgb(0xB0, 0, 0, 0)), @@ -137,7 +151,9 @@ void ApplySize(Size s) if (s.Width <= 0 || s.Height <= 0) return; dim.Width = s.Width; dim.Height = s.Height; - card.MaxHeight = Math.Max(0, s.Height - TopPeek); + // Sheet leaves a top peek of the page; a full-screen page fills all. + if (!fullScreen) + card.MaxHeight = Math.Max(0, s.Height - TopPeek); } IDisposable? sizeSub = null; @@ -156,7 +172,8 @@ void ApplySize(Size s) Dispatcher.UIThread.Post(() => { dim.Opacity = 1; - card.RenderTransform = TransformOperations.Parse("translateY(0)"); + if (!fullScreen) + card.RenderTransform = TransformOperations.Parse("translateY(0)"); }, DispatcherPriority.Background); try diff --git a/src/OpenIPC.Viewer.App/Services/SingleCameraPageFactory.cs b/src/OpenIPC.Viewer.App/Services/SingleCameraPageFactory.cs index 1384904..2c97d52 100644 --- a/src/OpenIPC.Viewer.App/Services/SingleCameraPageFactory.cs +++ b/src/OpenIPC.Viewer.App/Services/SingleCameraPageFactory.cs @@ -16,6 +16,7 @@ public sealed class SingleCameraPageFactory private readonly CameraDirectoryService _directory; private readonly IOnvifClient _onvif; private readonly IMajesticClient _majestic; + private readonly IMajesticSshConfigClient _majesticSsh; private readonly RecordingService _recordings; private readonly IFileSystem _fs; private readonly UserSettingsService _userSettings; @@ -27,6 +28,7 @@ public SingleCameraPageFactory( CameraDirectoryService directory, IOnvifClient onvif, IMajesticClient majestic, + IMajesticSshConfigClient majesticSsh, RecordingService recordings, IFileSystem fs, UserSettingsService userSettings, @@ -37,6 +39,7 @@ public SingleCameraPageFactory( _directory = directory; _onvif = onvif; _majestic = majestic; + _majesticSsh = majesticSsh; _recordings = recordings; _fs = fs; _userSettings = userSettings; @@ -45,5 +48,5 @@ public SingleCameraPageFactory( } public SingleCameraPageViewModel Create(Camera camera) => - new(camera, _coordinator, _directory, _onvif, _majestic, _recordings, _fs, _userSettings, _dialogs, _loggerFactory.CreateLogger()); + new(camera, _coordinator, _directory, _onvif, _majestic, _majesticSsh, _recordings, _fs, _userSettings, _dialogs, _loggerFactory.CreateLogger()); } diff --git a/src/OpenIPC.Viewer.App/Services/SshTerminalFactory.cs b/src/OpenIPC.Viewer.App/Services/SshTerminalFactory.cs new file mode 100644 index 0000000..11bf742 --- /dev/null +++ b/src/OpenIPC.Viewer.App/Services/SshTerminalFactory.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.Logging; +using OpenIPC.Viewer.App.ViewModels; +using OpenIPC.Viewer.Core.Entities; +using OpenIPC.Viewer.Core.Services; +using OpenIPC.Viewer.Core.Ssh; + +namespace OpenIPC.Viewer.App.Services; + +public sealed class SshTerminalFactory +{ + private readonly CameraDirectoryService _directory; + private readonly ISshSessionFactory _sessions; + private readonly UserSettingsService _settings; + private readonly ILoggerFactory _loggerFactory; + + public SshTerminalFactory(CameraDirectoryService directory, ISshSessionFactory sessions, UserSettingsService settings, ILoggerFactory loggerFactory) + { + _directory = directory; + _sessions = sessions; + _settings = settings; + _loggerFactory = loggerFactory; + } + + public SshTerminalViewModel Create(Camera camera) => + new(camera, _directory, _sessions, _settings.Current.SshTerminalFontSize, _loggerFactory.CreateLogger()); +} diff --git a/src/OpenIPC.Viewer.App/Services/UserSettings.cs b/src/OpenIPC.Viewer.App/Services/UserSettings.cs index 17c4e95..99fdf89 100644 --- a/src/OpenIPC.Viewer.App/Services/UserSettings.cs +++ b/src/OpenIPC.Viewer.App/Services/UserSettings.cs @@ -24,7 +24,15 @@ public sealed record UserSettings( bool WelcomeShown = false, // Unlocks the "Edit raw" button in the Phase 5 Majestic panel. Off by // default — a typo here can leave the camera in a non-bootable state. - bool RawConfigEditorEnabled = false) + bool RawConfigEditorEnabled = false, + // SSH device suite (Phase 13). StrictHostKey on → a changed host key is + // refused (TOFU); off → the new key is accepted and re-pinned (e.g. after + // a camera reflash). DefaultPort is used when a camera has no per-camera + // SSH port. TerminalFontSize is the monospace size in the SSH terminal. + bool SshStrictHostKey = true, + int SshDefaultPort = 22, + int SshTerminalFontSize = 14, + string MajesticConfigPath = "/etc/majestic.yaml") { public static UserSettings Default => new(); } diff --git a/src/OpenIPC.Viewer.App/Services/UserSettingsService.cs b/src/OpenIPC.Viewer.App/Services/UserSettingsService.cs index b766724..7bcfeba 100644 --- a/src/OpenIPC.Viewer.App/Services/UserSettingsService.cs +++ b/src/OpenIPC.Viewer.App/Services/UserSettingsService.cs @@ -24,6 +24,10 @@ public sealed class UserSettingsService : IUserSettingsAccessor public int MaxConcurrentGridSessions => Current.MaxConcurrentGridSessions; public string? PreferredNetworkInterface => string.IsNullOrWhiteSpace(Current.PreferredNetworkInterface) ? null : Current.PreferredNetworkInterface; + public bool SshStrictHostKey => Current.SshStrictHostKey; + public int SshDefaultPort => Current.SshDefaultPort < 1 ? 22 : Current.SshDefaultPort; + public string MajesticConfigPath => + string.IsNullOrWhiteSpace(Current.MajesticConfigPath) ? "/etc/majestic.yaml" : Current.MajesticConfigPath; private static readonly JsonSerializerOptions JsonOpts = new() { diff --git a/src/OpenIPC.Viewer.App/Themes/Theme.axaml b/src/OpenIPC.Viewer.App/Themes/Theme.axaml index 1c42da2..77a0385 100644 --- a/src/OpenIPC.Viewer.App/Themes/Theme.axaml +++ b/src/OpenIPC.Viewer.App/Themes/Theme.axaml @@ -78,6 +78,8 @@ M10,5 H3 M12,19 H3 M14,3 V7 M16,17 V21 M21,12 H12 M21,19 H16 M21,5 H14 M8,10 V14 M8,12 H3 M2,12 A10,10 0 1 0 22,12 A10,10 0 1 0 2,12 Z M12,16 v-4 M12,8 h0.01 + + M4,17 L10,11 L4,5 M12,19 H20 M19,5 L22,2 M2,22 L5,19 M6.3,20.3 a2.4,2.4 0 0 0 3.4,0 L12,18 L6,12 L3.7,14.3 a2.4,2.4 0 0 0 0,3.4 Z M7.5,13.5 L10,11 M10.5,16.5 L13,14 M12,6 L18,12 L20.3,9.7 a2.4,2.4 0 0 0 0,-3.4 L17.7,3.7 a2.4,2.4 0 0 0 -3.4,0 Z diff --git a/src/OpenIPC.Viewer.App/ViewModels/CameraLibraryPageViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/CameraLibraryPageViewModel.cs index f142ada..802854c 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/CameraLibraryPageViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/CameraLibraryPageViewModel.cs @@ -21,6 +21,8 @@ public sealed partial class CameraLibraryPageViewModel : ViewModelBase private readonly IDialogService _dialogs; private readonly CameraEditorFactory _editorFactory; private readonly DiscoveryDialogFactory _discoveryFactory; + private readonly SshTerminalFactory _terminalFactory; + private readonly FileManagerFactory _fileManagerFactory; private readonly ILogger _logger; public string Title => Localizer.Instance["Library.Title"]; @@ -59,6 +61,8 @@ public CameraLibraryPageViewModel( CameraEditorFactory editorFactory, DiscoveryDialogFactory discoveryFactory, ManageGroupsDialogFactory manageGroupsFactory, + SshTerminalFactory terminalFactory, + FileManagerFactory fileManagerFactory, UserSettingsService userSettings, IDiscoveryService discovery, IReachabilityProbe reachability, @@ -68,6 +72,8 @@ public CameraLibraryPageViewModel( _dialogs = dialogs; _editorFactory = editorFactory; _discoveryFactory = discoveryFactory; + _terminalFactory = terminalFactory; + _fileManagerFactory = fileManagerFactory; _manageGroupsFactory = manageGroupsFactory; _userSettings = userSettings; _discovery = discovery; @@ -371,6 +377,35 @@ await _dialogs.ConfirmAsync( } } + [RelayCommand] + private async Task OpenWebInterfaceAsync(CameraRowViewModel? row) + { + if (row is null) + return; + + var url = row.Camera.WebInterfaceUrl; + if (!await _dialogs.OpenUrlAsync(url).ConfigureAwait(true)) + _logger.LogWarning("Failed to open web interface for {CameraId} at {Url}", row.Camera.Id, url); + } + + [RelayCommand] + private async Task OpenSshTerminalAsync(CameraRowViewModel? row) + { + if (row is null) + return; + var vm = _terminalFactory.Create(row.Camera); + await _dialogs.OpenSshTerminalAsync(vm).ConfigureAwait(true); + } + + [RelayCommand] + private async Task OpenFileManagerAsync(CameraRowViewModel? row) + { + if (row is null) + return; + var vm = _fileManagerFactory.Create(row.Camera); + await _dialogs.OpenFileManagerAsync(vm).ConfigureAwait(true); + } + [RelayCommand] private async Task EditCameraAsync(CameraRowViewModel? row) { @@ -378,7 +413,8 @@ private async Task EditCameraAsync(CameraRowViewModel? row) return; var creds = await _directory.GetCredentialsAsync(row.Camera.Id, CancellationToken.None).ConfigureAwait(true); - var editor = _editorFactory.CreateForEdit(row.Camera, creds); + var sshCreds = await _directory.GetSshCredentialsAsync(row.Camera.Id, CancellationToken.None).ConfigureAwait(true); + var editor = _editorFactory.CreateForEdit(row.Camera, creds, sshCreds); var result = await _dialogs.ShowCameraEditorAsync(editor).ConfigureAwait(true); if (result?.UpdateRequest is not { } req) return; diff --git a/src/OpenIPC.Viewer.App/ViewModels/Dialogs/CameraEditorViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/Dialogs/CameraEditorViewModel.cs index 64971cb..c63352b 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/Dialogs/CameraEditorViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/Dialogs/CameraEditorViewModel.cs @@ -37,6 +37,12 @@ public sealed partial class CameraEditorViewModel : ViewModelBase [ObservableProperty] private string _password = ""; [ObservableProperty] private CameraGroup? _selectedGroup; + // SSH device suite (Phase 13). Blank SSH fields = reuse the main login; + // blank port = default 22. + [ObservableProperty] private string _sshUsername = ""; + [ObservableProperty] private string _sshPassword = ""; + [ObservableProperty] private string _sshPortText = ""; + // Per-camera SD/HD override (Phase 12.2). public IReadOnlyList StreamQualityOptions { get; } = new[] { @@ -70,7 +76,7 @@ public CameraEditorViewModel(IVideoEngine engine, CameraDirectoryService directo SelectedStreamQuality = StreamQualityOptions[0]; // Auto } - public CameraEditorViewModel(Camera existing, CameraCredentials? credentials, IVideoEngine engine, CameraDirectoryService directory, UserSettingsService userSettings, ILogger logger) + public CameraEditorViewModel(Camera existing, CameraCredentials? credentials, CameraCredentials? sshCredentials, IVideoEngine engine, CameraDirectoryService directory, UserSettingsService userSettings, ILogger logger) : this(engine, directory, userSettings, logger) { EditingId = existing.Id; @@ -82,6 +88,9 @@ public CameraEditorViewModel(Camera existing, CameraCredentials? credentials, IV RtspSubText = existing.RtspSubUri?.ToString() ?? ""; Username = credentials?.Username ?? ""; Password = credentials?.Password ?? ""; + SshUsername = sshCredentials?.Username ?? ""; + SshPassword = sshCredentials?.Password ?? ""; + SshPortText = existing.SshPort?.ToString() ?? ""; _pendingGroupId = existing.GroupId; SelectedStreamQuality = StreamQualityOptions.FirstOrDefault(o => o.Value == existing.StreamQualityOverride) ?? StreamQualityOptions[0]; @@ -116,7 +125,7 @@ private async Task TestConnectionAsync() { if (_engine is null) return; - if (!TryValidate(out _, out var rtspMain, out _, out _)) + if (!TryValidate(out _, out var rtspMain, out _, out _, out _)) { TestStatus = ErrorMessage; return; @@ -164,13 +173,19 @@ public bool TryBuildRequest(out NewCameraRequest? newRequest, out UpdateCameraRe newRequest = null; updateRequest = null; - if (!TryValidate(out var ok, out var rtspMain, out var rtspSub, out var onvifPort)) + if (!TryValidate(out var ok, out var rtspMain, out var rtspSub, out var onvifPort, out var sshPort)) return false; var credentials = string.IsNullOrEmpty(Username) && string.IsNullOrEmpty(Password) ? null : new CameraCredentials(Username, Password); + // Built only when both SSH fields are set; the both-or-neither rule is + // enforced in TryValidate so we never store a half credential. + var sshCredentials = string.IsNullOrEmpty(SshUsername) && string.IsNullOrEmpty(SshPassword) + ? null + : new CameraCredentials(SshUsername, SshPassword); + var quality = SelectedStreamQuality?.Value ?? StreamQualityOverride.Auto; if (EditingId is null) @@ -184,7 +199,9 @@ public bool TryBuildRequest(out NewCameraRequest? newRequest, out UpdateCameraRe RtspSubUri: rtspSub, Credentials: credentials, GroupId: SelectedGroup?.Id, - StreamQualityOverride: quality); + StreamQualityOverride: quality, + SshCredentials: sshCredentials, + SshPort: sshPort); } else { @@ -197,18 +214,21 @@ public bool TryBuildRequest(out NewCameraRequest? newRequest, out UpdateCameraRe RtspSubUri: rtspSub, Credentials: credentials, GroupId: SelectedGroup?.Id, - StreamQualityOverride: quality); + StreamQualityOverride: quality, + SshCredentials: sshCredentials, + SshPort: sshPort); } return ok; } - private bool TryValidate(out bool ok, out Uri rtspMain, out Uri? rtspSub, out int? onvifPort) + private bool TryValidate(out bool ok, out Uri rtspMain, out Uri? rtspSub, out int? onvifPort, out int? sshPort) { ok = false; rtspMain = default!; rtspSub = null; onvifPort = null; + sshPort = null; ErrorMessage = null; if (string.IsNullOrWhiteSpace(Name)) @@ -257,6 +277,23 @@ private bool TryValidate(out bool ok, out Uri rtspMain, out Uri? rtspSub, out in return false; } + if (!string.IsNullOrWhiteSpace(SshPortText)) + { + if (!int.TryParse(SshPortText.Trim(), out var sp) || sp < 1 || sp > 65535) + { + ErrorMessage = Localizer.Instance["CameraEditor.Error.SshPortInvalid"]; + return false; + } + sshPort = sp; + } + + // SSH login is password-based here — both fields, or neither. + if (string.IsNullOrEmpty(SshUsername) != string.IsNullOrEmpty(SshPassword)) + { + ErrorMessage = Localizer.Instance["CameraEditor.Error.SshCredsIncomplete"]; + return false; + } + ok = true; return true; } diff --git a/src/OpenIPC.Viewer.App/ViewModels/FileManagerViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/FileManagerViewModel.cs new file mode 100644 index 0000000..697dde0 --- /dev/null +++ b/src/OpenIPC.Viewer.App/ViewModels/FileManagerViewModel.cs @@ -0,0 +1,296 @@ +using System; +using System.Collections.ObjectModel; +using System.Globalization; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.Logging; +using OpenIPC.Viewer.App.Services; +using OpenIPC.Viewer.Core.Entities; +using OpenIPC.Viewer.Core.Services; +using OpenIPC.Viewer.Core.Ssh; +using CoreRemotePath = OpenIPC.Viewer.Core.Ssh.RemotePath; + +namespace OpenIPC.Viewer.App.ViewModels; + +/// +/// Remote (camera) file browser over SCP/SSH (phase-13 §13.4). Cross-platform: +/// the remote panel and operations work on every head; local I/O goes through +/// the OS file pickers (no local FS panel) so there are no desktop-only paths +/// or mobile sandbox issues. Root-level deletes are refused. +/// +public sealed partial class FileManagerViewModel : ViewModelBase, IAsyncDisposable +{ + private readonly Camera _camera; + private readonly CameraDirectoryService _directory; + private readonly ISshSessionFactory _sessions; + private readonly IDialogService _dialogs; + private readonly ILogger _logger; + + private ISshSession? _session; + + public ObservableCollection Entries { get; } = new(); + public string Title => $"{Localizer.Instance["FileManager.Title"]} — {_camera.Name}"; + + [ObservableProperty] private string _remotePath = "/"; + [ObservableProperty] private bool _isConnected; + [ObservableProperty] private bool _isBusy; + [ObservableProperty] private bool _isTransferring; + [ObservableProperty] private double _transferPercent; + [ObservableProperty] private string _statusText = ""; + + public FileManagerViewModel( + Camera camera, + CameraDirectoryService directory, + ISshSessionFactory sessions, + IDialogService dialogs, + ILogger logger) + { + _camera = camera; + _directory = directory; + _sessions = sessions; + _dialogs = dialogs; + _logger = logger; + } + + public async Task ConnectAsync() + { + StatusText = Localizer.Instance["FileManager.Connecting"]; + try + { + var endpoint = await _directory.GetSshEndpointAsync(_camera, CancellationToken.None).ConfigureAwait(true); + if (endpoint is null) + { + StatusText = Localizer.Instance["FileManager.NoCreds"]; + return; + } + + var session = _sessions.Create(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + await session.ConnectAsync(endpoint, cts.Token).ConfigureAwait(true); + _session = session; + IsConnected = true; + StatusText = ""; + await LoadEntriesAsync().ConfigureAwait(true); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "File manager connect failed for {CameraId}", _camera.Id); + StatusText = string.Format(Localizer.Instance["FileManager.FailedFormat"], ex.Message); + await DisposeAsync().ConfigureAwait(true); + } + } + + [RelayCommand] + private Task RefreshAsync() => LoadEntriesAsync(); + + [RelayCommand] + private Task NavigateUpAsync() + { + RemotePath = CoreRemotePath.Parent(RemotePath); + return LoadEntriesAsync(); + } + + [RelayCommand] + private async Task OpenEntryAsync(RemoteEntryViewModel? entry) + { + if (entry is null) + return; + if (entry.IsDirectory) + { + RemotePath = CoreRemotePath.Combine(RemotePath, entry.Name); + await LoadEntriesAsync().ConfigureAwait(true); + } + else + { + await DownloadAsync(entry).ConfigureAwait(true); + } + } + + [RelayCommand] + private async Task DownloadAsync(RemoteEntryViewModel? entry) + { + if (_session is null || entry is null || entry.IsDirectory) + return; + + var target = await _dialogs.PickSaveTargetAsync(entry.Name, Localizer.Instance["FileManager.SaveTitle"]).ConfigureAwait(true); + if (string.IsNullOrEmpty(target)) + return; + + var remote = CoreRemotePath.Combine(RemotePath, entry.Name); + await RunTransferAsync( + p => _session.DownloadAsync(remote, target, p, CancellationToken.None), + entry.SizeBytes, + Localizer.Instance["FileManager.Downloading"]).ConfigureAwait(true); + } + + [RelayCommand] + private async Task UploadAsync() + { + if (_session is null) + return; + + var local = await _dialogs.PickAnyFileAsync(Localizer.Instance["FileManager.UploadTitle"]).ConfigureAwait(true); + if (string.IsNullOrEmpty(local)) + return; + + var name = Path.GetFileName(local); + var remote = CoreRemotePath.Combine(RemotePath, name); + long? total = null; + try { total = new FileInfo(local).Length; } catch (IOException) { /* unknown size */ } + + await RunTransferAsync( + p => _session.UploadAsync(local, remote, p, CancellationToken.None), + total, + Localizer.Instance["FileManager.Uploading"]).ConfigureAwait(true); + await LoadEntriesAsync().ConfigureAwait(true); + } + + [RelayCommand] + private async Task NewFolderAsync() + { + if (_session is null) + return; + + var name = await _dialogs.PromptAsync( + Localizer.Instance["FileManager.NewFolderTitle"], "", + Localizer.Instance["Common.Save"], Localizer.Instance["Common.Cancel"]).ConfigureAwait(true); + if (string.IsNullOrEmpty(name)) + return; + + try + { + await _session.CreateDirectoryAsync(CoreRemotePath.Combine(RemotePath, name), CancellationToken.None).ConfigureAwait(true); + await LoadEntriesAsync().ConfigureAwait(true); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "mkdir failed"); + StatusText = string.Format(Localizer.Instance["FileManager.FailedFormat"], ex.Message); + } + } + + [RelayCommand] + private async Task DeleteAsync(RemoteEntryViewModel? entry) + { + if (_session is null || entry is null) + return; + + var full = CoreRemotePath.Combine(RemotePath, entry.Name); + if (RemotePathGuard.IsProtected(full)) + { + StatusText = Localizer.Instance["FileManager.DeleteProtected"]; + return; + } + + var confirmed = await _dialogs.ConfirmAsync( + Localizer.Instance["FileManager.DeleteTitle"], + string.Format(Localizer.Instance["FileManager.DeleteMessage"], entry.Name), + Localizer.Instance["Common.Delete"], + Localizer.Instance["Common.Cancel"]).ConfigureAwait(true); + if (!confirmed) + return; + + try + { + await _session.DeleteAsync(full, CancellationToken.None).ConfigureAwait(true); + await LoadEntriesAsync().ConfigureAwait(true); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Remote delete failed for {Path}", full); + StatusText = string.Format(Localizer.Instance["FileManager.FailedFormat"], ex.Message); + } + } + + private async Task LoadEntriesAsync() + { + if (_session is null) + return; + + IsBusy = true; + try + { + Entries.Clear(); + await foreach (var entry in _session.ListAsync(RemotePath, CancellationToken.None).ConfigureAwait(true)) + Entries.Add(new RemoteEntryViewModel(entry)); + StatusText = ""; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "List failed for {Path}", RemotePath); + StatusText = string.Format(Localizer.Instance["FileManager.FailedFormat"], ex.Message); + } + finally + { + IsBusy = false; + } + } + + // Progress captures the UI sync context (this method runs on the UI + // thread), so the percent updates marshal back automatically. + private async Task RunTransferAsync(Func, Task> op, long? total, string label) + { + IsTransferring = true; + TransferPercent = 0; + StatusText = label; + var progress = new Progress(bytes => + TransferPercent = total is > 0 ? Math.Clamp((double)bytes / total.Value, 0, 1) : 0); + try + { + await op(progress).ConfigureAwait(true); + StatusText = Localizer.Instance["FileManager.TransferDone"]; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Transfer failed"); + StatusText = string.Format(Localizer.Instance["FileManager.FailedFormat"], ex.Message); + } + finally + { + IsTransferring = false; + } + } + + public async ValueTask DisposeAsync() + { + IsConnected = false; + if (_session is not null) + { + await _session.DisposeAsync().ConfigureAwait(false); + _session = null; + } + } +} + +public sealed class RemoteEntryViewModel +{ + public RemoteEntryViewModel(RemoteEntry entry) + { + Name = entry.Name; + IsDirectory = entry.IsDirectory; + SizeBytes = entry.Size; + SizeText = entry.IsDirectory ? "" : FormatSize(entry.Size); + } + + public string Name { get; } + public bool IsDirectory { get; } + public long SizeBytes { get; } + public string SizeText { get; } + public string Display => IsDirectory ? Name + "/" : Name; + + private static string FormatSize(long bytes) + { + string[] units = { "B", "KB", "MB", "GB", "TB" }; + double size = bytes; + var unit = 0; + while (size >= 1024 && unit < units.Length - 1) + { + size /= 1024; + unit++; + } + return string.Format(CultureInfo.InvariantCulture, unit == 0 ? "{0:0} {1}" : "{0:0.0} {1}", size, units[unit]); + } +} diff --git a/src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs index 71814e0..bdff6dc 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs @@ -9,6 +9,7 @@ using OpenIPC.Viewer.App.Services; using OpenIPC.Viewer.Core.Onvif.Discovery; using OpenIPC.Viewer.Core.Platform; +using OpenIPC.Viewer.Core.Ssh; namespace OpenIPC.Viewer.App.ViewModels; @@ -17,6 +18,7 @@ public sealed partial class SettingsPageViewModel : ViewModelBase private readonly UserSettingsService _settings; private readonly IFileSystem _fs; private readonly IDialogService _dialogs; + private readonly ISshHostKeyStore _hostKeys; private bool _suppressSave; public string Title => Localizer.Instance["Settings.Title"]; @@ -32,6 +34,13 @@ public sealed partial class SettingsPageViewModel : ViewModelBase [ObservableProperty] private NetworkInterfaceOption? _selectedNetworkInterface; [ObservableProperty] private string _language = "system"; + // SSH section (Phase 13). + [ObservableProperty] private bool _sshStrictHostKey = true; + [ObservableProperty] private int _sshDefaultPort = 22; + [ObservableProperty] private int _sshTerminalFontSize = 14; + [ObservableProperty] private string _majesticConfigPath = "/etc/majestic.yaml"; + [ObservableProperty] private bool _hostKeysJustCleared; + [ObservableProperty] [NotifyPropertyChangedFor(nameof(EffectiveRecordingsDirectory))] [NotifyPropertyChangedFor(nameof(IsRecordingsDirOverridden))] @@ -47,6 +56,7 @@ public sealed partial class SettingsPageViewModel : ViewModelBase [NotifyPropertyChangedFor(nameof(IsVideo))] [NotifyPropertyChangedFor(nameof(IsRecording))] [NotifyPropertyChangedFor(nameof(IsDiscovery))] + [NotifyPropertyChangedFor(nameof(IsSsh))] [NotifyPropertyChangedFor(nameof(IsAdvanced))] [NotifyPropertyChangedFor(nameof(IsAbout))] [NotifyPropertyChangedFor(nameof(ShowList))] @@ -64,8 +74,9 @@ public sealed partial class SettingsPageViewModel : ViewModelBase public bool IsVideo => SelectedSectionIndex == 1; public bool IsRecording => SelectedSectionIndex == 2; public bool IsDiscovery => SelectedSectionIndex == 3; - public bool IsAdvanced => SelectedSectionIndex == 4; - public bool IsAbout => SelectedSectionIndex == 5; + public bool IsSsh => SelectedSectionIndex == 4; + public bool IsAdvanced => SelectedSectionIndex == 5; + public bool IsAbout => SelectedSectionIndex == 6; public bool ShowList => IsWide || SelectedSectionIndex < 0; public bool ShowDetail => IsWide || SelectedSectionIndex >= 0; @@ -105,11 +116,13 @@ public SettingsPageViewModel( UserSettingsService settings, IFileSystem fs, IDialogService dialogs, - INetworkInterfaceProvider nics) + INetworkInterfaceProvider nics, + ISshHostKeyStore hostKeys) { _settings = settings; _fs = fs; _dialogs = dialogs; + _hostKeys = hostKeys; var options = new List { @@ -142,6 +155,10 @@ private void Load() ?? NetworkInterfaceOptions[0]; RecordingsDirOverride = s.RecordingsDirOverride; Language = s.Language; + SshStrictHostKey = s.SshStrictHostKey; + SshDefaultPort = s.SshDefaultPort; + SshTerminalFontSize = s.SshTerminalFontSize; + MajesticConfigPath = s.MajesticConfigPath; } finally { _suppressSave = false; } } @@ -156,6 +173,10 @@ private void Load() partial void OnSelectedNetworkInterfaceChanged(NetworkInterfaceOption? value) => Persist(); partial void OnRecordingsDirOverrideChanged(string value) => Persist(); partial void OnLanguageChanged(string value) => Persist(); + partial void OnSshStrictHostKeyChanged(bool value) => Persist(); + partial void OnSshDefaultPortChanged(int value) => Persist(); + partial void OnSshTerminalFontSizeChanged(int value) => Persist(); + partial void OnMajesticConfigPathChanged(string value) => Persist(); private void Persist() { @@ -172,12 +193,31 @@ private void Persist() PreferredNetworkInterface = SelectedNetworkInterface?.Value ?? "", RecordingsDirOverride = RecordingsDirOverride, Language = Language, + SshStrictHostKey = SshStrictHostKey, + SshDefaultPort = SshDefaultPort, + SshTerminalFontSize = SshTerminalFontSize, + MajesticConfigPath = MajesticConfigPath, }; // Fire-and-forget; binding setters are synchronous and any save // error is logged inside UpdateAsync. _ = _settings.UpdateAsync(next, CancellationToken.None); } + [RelayCommand] + private async Task ResetHostKeysAsync() + { + var confirmed = await _dialogs.ConfirmAsync( + Localizer.Instance["Settings.Ssh.ResetTitle"], + Localizer.Instance["Settings.Ssh.ResetMessage"], + Localizer.Instance["Settings.Ssh.ResetConfirm"], + Localizer.Instance["Common.Cancel"]).ConfigureAwait(true); + if (!confirmed) + return; + + await _hostKeys.ClearAsync(CancellationToken.None).ConfigureAwait(true); + HostKeysJustCleared = true; + } + [RelayCommand] private async Task PickRecordingsDirectoryAsync() { diff --git a/src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs index 261b3c2..3c1c220 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs @@ -27,6 +27,7 @@ public sealed partial class SingleCameraPageViewModel : ViewModelBase, IAsyncDis private readonly CameraDirectoryService _directory; private readonly IOnvifClient _onvif; private readonly IMajesticClient _majestic; + private readonly IMajesticSshConfigClient _majesticSsh; private readonly RecordingService _recordings; private readonly IFileSystem _fs; private readonly UserSettingsService _userSettings; @@ -141,6 +142,7 @@ public SingleCameraPageViewModel( CameraDirectoryService directory, IOnvifClient onvif, IMajesticClient majestic, + IMajesticSshConfigClient majesticSsh, RecordingService recordings, IFileSystem fs, UserSettingsService userSettings, @@ -152,6 +154,7 @@ public SingleCameraPageViewModel( _directory = directory; _onvif = onvif; _majestic = majestic; + _majesticSsh = majesticSsh; _recordings = recordings; _fs = fs; _userSettings = userSettings; @@ -500,6 +503,59 @@ private async Task EditRawConfigAsync() } } + // SSH fallback transport for majestic.yaml (Phase 13.5) — used when the + // HTTP API is disabled/unreachable. Edits the raw YAML through the same + // raw-config editor dialog, then reloads the stream like Apply does. + [RelayCommand] + private async Task EditMajesticOverSshAsync() + { + var endpoint = await _directory.GetSshEndpointAsync(_camera, CancellationToken.None).ConfigureAwait(true); + if (endpoint is null) + { + ApplyStatus = Localizer.Instance["CameraPage.SshNoCreds"]; + return; + } + + string initial; + try + { + using var readCts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + initial = await _majesticSsh.ReadRawAsync(endpoint, readCts.Token).ConfigureAwait(true); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Read majestic.yaml over SSH failed"); + ApplyStatus = string.Format(CultureInfo.CurrentCulture, + Localizer.Instance["CameraPage.ApplyFailedFormat"], ex.Message); + return; + } + + var edited = await _dialogs.ShowRawConfigEditorAsync(initial).ConfigureAwait(true); + if (edited is null || edited == initial) return; + + ApplyInProgress = true; + ApplyStatus = Localizer.Instance["CameraPage.ApplyingRawStatus"]; + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + await _majesticSsh.WriteRawAsync(endpoint, edited, restart: true, cts.Token).ConfigureAwait(true); + + ApplyStatus = Localizer.Instance["CameraPage.AppliedRestarting"]; + await ReloadStreamAsync().ConfigureAwait(true); + ApplyStatus = Localizer.Instance["CameraPage.ApplyDone"]; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Write majestic.yaml over SSH failed"); + ApplyStatus = string.Format(CultureInfo.CurrentCulture, + Localizer.Instance["CameraPage.ApplyFailedFormat"], ex.Message); + } + finally + { + ApplyInProgress = false; + } + } + private async Task<(MajesticConfig config, MajesticInfo info)> GetMajesticStateAsync(MajesticEndpoint ep, CancellationToken ct) { var cfgTask = _majestic.GetConfigAsync(ep, ct); diff --git a/src/OpenIPC.Viewer.App/ViewModels/SshTerminalViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/SshTerminalViewModel.cs new file mode 100644 index 0000000..4a8ea6c --- /dev/null +++ b/src/OpenIPC.Viewer.App/ViewModels/SshTerminalViewModel.cs @@ -0,0 +1,129 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.Extensions.Logging; +using OpenIPC.Viewer.App.Services; +using OpenIPC.Viewer.Core.Entities; +using OpenIPC.Viewer.Core.Services; +using OpenIPC.Viewer.Core.Ssh; +using OpenIPC.Viewer.Core.Ssh.Terminal; + +namespace OpenIPC.Viewer.App.ViewModels; + +/// +/// Drives an interactive SSH shell over a camera (phase-13 §13.3). Owns the +/// session/shell lifecycle and pumps received bytes into a +/// on the UI thread; the view renders the grid +/// and forwards keystrokes back via . +/// +public sealed partial class SshTerminalViewModel : ViewModelBase, IAsyncDisposable +{ + private readonly Camera _camera; + private readonly CameraDirectoryService _directory; + private readonly ISshSessionFactory _sessions; + private readonly ILogger _logger; + + private ISshSession? _session; + private ISshShell? _shell; + private int _columns = 80; + private int _rows = 24; + + public TerminalEmulator Emulator { get; } = new(80, 24); + public string Title => $"SSH — {_camera.Name}"; + public double FontSize { get; } + + [ObservableProperty] private string _statusText = ""; + [ObservableProperty] private bool _isConnected; + + public SshTerminalViewModel( + Camera camera, + CameraDirectoryService directory, + ISshSessionFactory sessions, + double fontSize, + ILogger logger) + { + _camera = camera; + _directory = directory; + _sessions = sessions; + FontSize = fontSize >= 8 ? fontSize : 14; + _logger = logger; + } + + public async Task ConnectAsync() + { + StatusText = Localizer.Instance["Terminal.Connecting"]; + try + { + var endpoint = await _directory.GetSshEndpointAsync(_camera, CancellationToken.None).ConfigureAwait(true); + if (endpoint is null) + { + StatusText = Localizer.Instance["Terminal.NoCreds"]; + return; + } + + var session = _sessions.Create(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + await session.ConnectAsync(endpoint, cts.Token).ConfigureAwait(true); + var shell = await session.OpenShellAsync((uint)_columns, (uint)_rows, cts.Token).ConfigureAwait(true); + + _session = session; + _shell = shell; + shell.DataReceived += OnShellData; + + IsConnected = true; + StatusText = ""; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "SSH terminal connect failed for {CameraId}", _camera.Id); + StatusText = string.Format(Localizer.Instance["Terminal.FailedFormat"], ex.Message); + await DisposeAsync().ConfigureAwait(true); + } + } + + // Fires on the SSH receive thread — marshal onto the UI thread before + // touching the emulator grid the renderer reads. + private void OnShellData(object? sender, byte[] data) => + Dispatcher.UIThread.Post(() => Emulator.Feed(data)); + + public async Task SendAsync(string text) + { + if (_shell is null) + return; + try + { + await _shell.SendAsync(text, CancellationToken.None).ConfigureAwait(true); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "SSH terminal send failed"); + } + } + + public Task ResizeAsync(int columns, int rows) + { + _columns = columns; + _rows = rows; + Emulator.Resize(columns, rows); + _shell?.Resize((uint)columns, (uint)rows); + return Task.CompletedTask; + } + + public async ValueTask DisposeAsync() + { + IsConnected = false; + if (_shell is not null) + { + _shell.DataReceived -= OnShellData; + await _shell.DisposeAsync().ConfigureAwait(false); + _shell = null; + } + if (_session is not null) + { + await _session.DisposeAsync().ConfigureAwait(false); + _session = null; + } + } +} diff --git a/src/OpenIPC.Viewer.App/Views/Dialogs/CameraEditorContent.axaml b/src/OpenIPC.Viewer.App/Views/Dialogs/CameraEditorContent.axaml index de62aab..4322f47 100644 --- a/src/OpenIPC.Viewer.App/Views/Dialogs/CameraEditorContent.axaml +++ b/src/OpenIPC.Viewer.App/Views/Dialogs/CameraEditorContent.axaml @@ -89,6 +89,17 @@ + + + + + + + + + + + + + + + + +