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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OpenIPC.Viewer.App/Views/Dialogs/FileManagerContent.axaml.cs b/src/OpenIPC.Viewer.App/Views/Dialogs/FileManagerContent.axaml.cs
new file mode 100644
index 0000000..425c0c4
--- /dev/null
+++ b/src/OpenIPC.Viewer.App/Views/Dialogs/FileManagerContent.axaml.cs
@@ -0,0 +1,45 @@
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Controls;
+using OpenIPC.Viewer.App.ViewModels;
+
+namespace OpenIPC.Viewer.App.Views.Dialogs;
+
+public sealed partial class FileManagerContent : UserControl
+{
+ private readonly TaskCompletionSource _tcs = new();
+ private bool _started;
+
+ public Task Completion => _tcs.Task;
+
+ public FileManagerContent()
+ {
+ InitializeComponent();
+ this.FindControl
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OpenIPC.Viewer.App/Views/Pages/SingleCameraPage.axaml b/src/OpenIPC.Viewer.App/Views/Pages/SingleCameraPage.axaml
index 4790683..74bcb21 100644
--- a/src/OpenIPC.Viewer.App/Views/Pages/SingleCameraPage.axaml
+++ b/src/OpenIPC.Viewer.App/Views/Pages/SingleCameraPage.axaml
@@ -305,6 +305,15 @@
IsEnabled="{Binding !ApplyInProgress}"
Padding="14,4" FontSize="11"
Background="{StaticResource AccentBrush}" Foreground="White" CornerRadius="4" />
+
+
diff --git a/src/OpenIPC.Viewer.Composition/SharedComposition.cs b/src/OpenIPC.Viewer.Composition/SharedComposition.cs
index 594f1c2..0e2aa2b 100644
--- a/src/OpenIPC.Viewer.Composition/SharedComposition.cs
+++ b/src/OpenIPC.Viewer.Composition/SharedComposition.cs
@@ -10,6 +10,7 @@
using OpenIPC.Viewer.Core.Platform;
using OpenIPC.Viewer.Core.Recording;
using OpenIPC.Viewer.Core.Services;
+using OpenIPC.Viewer.Core.Ssh;
using OpenIPC.Viewer.Core.Video;
using OpenIPC.Viewer.Devices.Majestic;
using OpenIPC.Viewer.Devices.Onvif;
@@ -57,6 +58,13 @@ public static IServiceCollection AddSharedServices(this IServiceCollection servi
// Majestic HTTP
services.AddSingleton();
+ // SSH device suite (Phase 13): factory creates per-use sessions; the
+ // SSH transport for majestic.yaml is the fallback when HTTP is off.
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
// Recording lifecycle (IRecorder itself is registered by the platform
// host — FFmpeg subprocess on desktop, FFmpegKit on Android, etc).
services.AddSingleton();
@@ -72,6 +80,8 @@ public static IServiceCollection AddSharedServices(this IServiceCollection servi
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
// User-tweakable settings (Phase 11). Side-effects (e.g. live Serilog
// level switching) are wired by each platform composition after the
diff --git a/src/OpenIPC.Viewer.Core/Entities/Camera.cs b/src/OpenIPC.Viewer.Core/Entities/Camera.cs
index 12b486a..dd451cc 100644
--- a/src/OpenIPC.Viewer.Core/Entities/Camera.cs
+++ b/src/OpenIPC.Viewer.Core/Entities/Camera.cs
@@ -1,4 +1,5 @@
using System;
+using OpenIPC.Viewer.Core.Ssh;
using OpenIPC.Viewer.Core.Video;
namespace OpenIPC.Viewer.Core.Entities;
@@ -26,4 +27,18 @@ public sealed record Camera(
DateTime UpdatedAt,
// Per-camera SD/HD override (Phase 12.2). Trailing param with a default so
// existing constructor calls stay valid.
- StreamQualityOverride StreamQualityOverride = StreamQualityOverride.Auto);
+ StreamQualityOverride StreamQualityOverride = StreamQualityOverride.Auto,
+ // SSH port for the device suite (Phase 13). Null → default 22. Credentials
+ // live in the secrets store under cam:{id}:ssh:* keys, not on the entity.
+ int? SshPort = null)
+{
+ /// The SSH port to connect to, defaulting to 22 when unset.
+ public int SshPortOrDefault => SshPort ?? SshEndpoint.DefaultPort;
+
+ ///
+ /// The device's HTTP web interface URL. Omits the port when it's the
+ /// default 80 (Phase 13.2 "Open in browser").
+ ///
+ public string WebInterfaceUrl =>
+ HttpPort == 80 ? $"http://{Host}" : $"http://{Host}:{HttpPort}";
+}
diff --git a/src/OpenIPC.Viewer.Core/Entities/NewCameraRequest.cs b/src/OpenIPC.Viewer.Core/Entities/NewCameraRequest.cs
index 68b7403..2b99fd1 100644
--- a/src/OpenIPC.Viewer.Core/Entities/NewCameraRequest.cs
+++ b/src/OpenIPC.Viewer.Core/Entities/NewCameraRequest.cs
@@ -12,4 +12,9 @@ public sealed record NewCameraRequest(
Uri? RtspSubUri,
CameraCredentials? Credentials,
GroupId? GroupId = null,
- StreamQualityOverride StreamQualityOverride = StreamQualityOverride.Auto);
+ StreamQualityOverride StreamQualityOverride = StreamQualityOverride.Auto,
+ // SSH device suite (Phase 13). Separate from the RTSP/ONVIF credentials —
+ // the SSH login may differ. Null SshCredentials means "no SSH-specific
+ // login" (the resolver falls back to the main credentials).
+ CameraCredentials? SshCredentials = null,
+ int? SshPort = null);
diff --git a/src/OpenIPC.Viewer.Core/Entities/UpdateCameraRequest.cs b/src/OpenIPC.Viewer.Core/Entities/UpdateCameraRequest.cs
index 71f3747..a8b0383 100644
--- a/src/OpenIPC.Viewer.Core/Entities/UpdateCameraRequest.cs
+++ b/src/OpenIPC.Viewer.Core/Entities/UpdateCameraRequest.cs
@@ -12,4 +12,8 @@ public sealed record UpdateCameraRequest(
Uri? RtspSubUri,
CameraCredentials? Credentials,
GroupId? GroupId = null,
- StreamQualityOverride StreamQualityOverride = StreamQualityOverride.Auto);
+ StreamQualityOverride StreamQualityOverride = StreamQualityOverride.Auto,
+ // SSH device suite (Phase 13). Null SshCredentials keeps the stored SSH
+ // login untouched (mirrors how null Credentials keeps the main login).
+ CameraCredentials? SshCredentials = null,
+ int? SshPort = null);
diff --git a/src/OpenIPC.Viewer.Core/Majestic/IMajesticSshConfigClient.cs b/src/OpenIPC.Viewer.Core/Majestic/IMajesticSshConfigClient.cs
new file mode 100644
index 0000000..1396a37
--- /dev/null
+++ b/src/OpenIPC.Viewer.Core/Majestic/IMajesticSshConfigClient.cs
@@ -0,0 +1,29 @@
+using System.Threading;
+using System.Threading.Tasks;
+using OpenIPC.Viewer.Core.Ssh;
+
+namespace OpenIPC.Viewer.Core.Majestic;
+
+///
+/// SSH transport for the Majestic config file (Phase 13.5) — an alternative to
+/// for cameras whose HTTP API is disabled or
+/// unreachable. Reads/writes the raw majestic.yaml; the typed/HTTP path
+/// stays the default when it's available.
+///
+public interface IMajesticSshConfigClient
+{
+ /// Absolute path to the config file on the device.
+ string ConfigPath { get; }
+
+ /// True if the config file exists on the device.
+ Task ConfigExistsAsync(SshEndpoint endpoint, CancellationToken ct);
+
+ /// Reads the raw majestic.yaml contents.
+ Task ReadRawAsync(SshEndpoint endpoint, CancellationToken ct);
+
+ ///
+ /// Writes the raw config back (atomically via a temp file + move) and,
+ /// when is set, signals majestic to reload.
+ ///
+ Task WriteRawAsync(SshEndpoint endpoint, string rawYaml, bool restart, CancellationToken ct);
+}
diff --git a/src/OpenIPC.Viewer.Core/Services/CameraDirectoryService.cs b/src/OpenIPC.Viewer.Core/Services/CameraDirectoryService.cs
index 2e84ad4..e4d7edd 100644
--- a/src/OpenIPC.Viewer.Core/Services/CameraDirectoryService.cs
+++ b/src/OpenIPC.Viewer.Core/Services/CameraDirectoryService.cs
@@ -13,12 +13,18 @@ public sealed class CameraDirectoryService
private readonly ICameraRepository _cameras;
private readonly IGroupRepository _groups;
private readonly ISecretsStore _secrets;
+ private readonly Settings.IUserSettingsAccessor? _settings;
- public CameraDirectoryService(ICameraRepository cameras, IGroupRepository groups, ISecretsStore secrets)
+ public CameraDirectoryService(
+ ICameraRepository cameras,
+ IGroupRepository groups,
+ ISecretsStore secrets,
+ Settings.IUserSettingsAccessor? settings = null)
{
_cameras = cameras;
_groups = groups;
_secrets = secrets;
+ _settings = settings;
}
public Task> ListAsync(CancellationToken ct) =>
@@ -48,6 +54,7 @@ public async Task AddAsync(NewCameraRequest req, CancellationToken ct)
{
var id = CameraId.New();
var (usernameRef, passwordRef) = await StoreCredentialsAsync(id, req.Credentials, ct).ConfigureAwait(false);
+ await StoreSshCredentialsAsync(id, req.SshCredentials, ct).ConfigureAwait(false);
var now = DateTime.UtcNow;
var camera = new Camera(
@@ -71,7 +78,8 @@ public async Task AddAsync(NewCameraRequest req, CancellationToken ct)
SortOrder: 0,
CreatedAt: now,
UpdatedAt: now,
- StreamQualityOverride: req.StreamQualityOverride);
+ StreamQualityOverride: req.StreamQualityOverride,
+ SshPort: req.SshPort);
return await _cameras.AddAsync(camera, ct).ConfigureAwait(false);
}
@@ -85,6 +93,10 @@ public async Task UpdateAsync(CameraId id, UpdateCameraRequest req, Cancellation
? (existing.UsernameRef, existing.PasswordRef)
: await StoreCredentialsAsync(id, req.Credentials, ct).ConfigureAwait(false);
+ // Null SshCredentials = keep what's stored (mirrors main-credential handling).
+ if (req.SshCredentials is not null)
+ await StoreSshCredentialsAsync(id, req.SshCredentials, ct).ConfigureAwait(false);
+
var updated = existing with
{
Name = req.Name,
@@ -97,6 +109,7 @@ public async Task UpdateAsync(CameraId id, UpdateCameraRequest req, Cancellation
PasswordRef = passwordRef,
GroupId = req.GroupId,
StreamQualityOverride = req.StreamQualityOverride,
+ SshPort = req.SshPort,
UpdatedAt = DateTime.UtcNow,
};
@@ -151,6 +164,8 @@ public async Task RemoveAsync(CameraId id, CancellationToken ct)
{
await _secrets.RemoveAsync(UsernameKey(id), ct).ConfigureAwait(false);
await _secrets.RemoveAsync(PasswordKey(id), ct).ConfigureAwait(false);
+ await _secrets.RemoveAsync(SshUsernameKey(id), ct).ConfigureAwait(false);
+ await _secrets.RemoveAsync(SshPasswordKey(id), ct).ConfigureAwait(false);
await _cameras.RemoveAsync(id, ct).ConfigureAwait(false);
}
@@ -163,6 +178,50 @@ public async Task RemoveAsync(CameraId id, CancellationToken ct)
return new CameraCredentials(username, password);
}
+ ///
+ /// SSH-specific credentials, or null if none are stored. Does NOT fall back
+ /// to the main login — the editor uses this to decide whether to pre-fill
+ /// the SSH fields (blank = "reuse main login").
+ ///
+ public async Task GetSshCredentialsAsync(CameraId id, CancellationToken ct)
+ {
+ var username = await _secrets.GetAsync(SshUsernameKey(id), ct).ConfigureAwait(false);
+ var password = await _secrets.GetAsync(SshPasswordKey(id), ct).ConfigureAwait(false);
+ return username is not null && password is not null
+ ? new CameraCredentials(username, password)
+ : null;
+ }
+
+ ///
+ /// Builds the SSH connection target for a camera (Phase 13): SSH-specific
+ /// credentials if set, otherwise the main RTSP/ONVIF login (OpenIPC root
+ /// often matches). Returns null when no credentials exist at all — the
+ /// caller prompts rather than attempting an anonymous login.
+ ///
+ public async Task GetSshEndpointAsync(Camera camera, CancellationToken ct)
+ {
+ var creds = await GetSshCredentialsAsync(camera.Id, ct).ConfigureAwait(false)
+ ?? await GetCredentialsAsync(camera.Id, ct).ConfigureAwait(false);
+ if (creds is null)
+ return null;
+ // Per-camera port wins; otherwise the global default from settings, then 22.
+ var port = camera.SshPort ?? _settings?.SshDefaultPort ?? Ssh.SshEndpoint.DefaultPort;
+ return new Ssh.SshEndpoint(
+ camera.Host, port, creds.Username, new Ssh.SshAuth.Password(creds.Password));
+ }
+
+ private async Task StoreSshCredentialsAsync(CameraId id, CameraCredentials? credentials, CancellationToken ct)
+ {
+ if (credentials is null)
+ {
+ await _secrets.RemoveAsync(SshUsernameKey(id), ct).ConfigureAwait(false);
+ await _secrets.RemoveAsync(SshPasswordKey(id), ct).ConfigureAwait(false);
+ return;
+ }
+ await _secrets.SetAsync(SshUsernameKey(id), credentials.Username, ct).ConfigureAwait(false);
+ await _secrets.SetAsync(SshPasswordKey(id), credentials.Password, ct).ConfigureAwait(false);
+ }
+
private async Task<(string? UsernameRef, string? PasswordRef)> StoreCredentialsAsync(
CameraId id, CameraCredentials? credentials, CancellationToken ct)
{
@@ -182,4 +241,6 @@ public async Task RemoveAsync(CameraId id, CancellationToken ct)
private static string UsernameKey(CameraId id) => $"cam:{id}:username";
private static string PasswordKey(CameraId id) => $"cam:{id}:password";
+ private static string SshUsernameKey(CameraId id) => $"cam:{id}:ssh:username";
+ private static string SshPasswordKey(CameraId id) => $"cam:{id}:ssh:password";
}
diff --git a/src/OpenIPC.Viewer.Core/Settings/IUserSettingsAccessor.cs b/src/OpenIPC.Viewer.Core/Settings/IUserSettingsAccessor.cs
index 2cb3f9d..243278c 100644
--- a/src/OpenIPC.Viewer.Core/Settings/IUserSettingsAccessor.cs
+++ b/src/OpenIPC.Viewer.Core/Settings/IUserSettingsAccessor.cs
@@ -15,4 +15,11 @@ public interface IUserSettingsAccessor
// Local IPv4 to bind discovery/listeners to (Phase 12.6). Empty / null =
// auto-pick the best LAN interface (ignore VPN/virtual adapters).
string? PreferredNetworkInterface { get; }
+
+ // SSH device suite (Phase 13). Strict host-key checking (TOFU reject on
+ // change) when true; default SSH port for cameras without an explicit one;
+ // remote path of the Majestic config for the SSH transport.
+ bool SshStrictHostKey { get; }
+ int SshDefaultPort { get; }
+ string MajesticConfigPath { get; }
}
diff --git a/src/OpenIPC.Viewer.Core/Ssh/CommandResult.cs b/src/OpenIPC.Viewer.Core/Ssh/CommandResult.cs
new file mode 100644
index 0000000..1afd317
--- /dev/null
+++ b/src/OpenIPC.Viewer.Core/Ssh/CommandResult.cs
@@ -0,0 +1,7 @@
+namespace OpenIPC.Viewer.Core.Ssh;
+
+/// Outcome of a one-shot remote command ().
+public sealed record CommandResult(int ExitCode, string StandardOutput, string StandardError)
+{
+ public bool Success => ExitCode == 0;
+}
diff --git a/src/OpenIPC.Viewer.Core/Ssh/ISshHostKeyStore.cs b/src/OpenIPC.Viewer.Core/Ssh/ISshHostKeyStore.cs
new file mode 100644
index 0000000..a832aed
--- /dev/null
+++ b/src/OpenIPC.Viewer.Core/Ssh/ISshHostKeyStore.cs
@@ -0,0 +1,21 @@
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace OpenIPC.Viewer.Core.Ssh;
+
+///
+/// Pinned SSH host-key fingerprints (TOFU), kept apart from the secrets store —
+/// host keys aren't secrets, and a dedicated known-hosts store makes "forget
+/// all" (after a fleet reflash) a single operation (phase-13 §13.3 settings).
+///
+public interface ISshHostKeyStore
+{
+ /// Pinned SHA256 fingerprint for host:port, or null if none.
+ Task GetAsync(string host, int port, CancellationToken ct);
+
+ /// Pins (or re-pins) the fingerprint for host:port.
+ Task SetAsync(string host, int port, string fingerprint, CancellationToken ct);
+
+ /// Forgets every pinned host key.
+ Task ClearAsync(CancellationToken ct);
+}
diff --git a/src/OpenIPC.Viewer.Core/Ssh/ISshSession.cs b/src/OpenIPC.Viewer.Core/Ssh/ISshSession.cs
new file mode 100644
index 0000000..742ef95
--- /dev/null
+++ b/src/OpenIPC.Viewer.Core/Ssh/ISshSession.cs
@@ -0,0 +1,49 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace OpenIPC.Viewer.Core.Ssh;
+
+///
+/// A connected SSH session to one camera. One session backs the terminal, the
+/// file manager and the SSH Majestic transport. Connection-scoped — created per
+/// use via , not a DI singleton.
+///
+///
+/// File transfer uses SCP (not SFTP): OpenIPC's busybox ships scp far more
+/// reliably than an sftp-server (phase-13 §13.4). Listing is done by parsing
+/// ls -la output rather than SFTP for the same reason.
+///
+public interface ISshSession : IAsyncDisposable
+{
+ /// Opens the transport and authenticates. Verifies the host key (TOFU).
+ Task ConnectAsync(SshEndpoint endpoint, CancellationToken ct);
+
+ /// Opens an interactive shell (terminal) at the given window size.
+ Task OpenShellAsync(uint columns, uint rows, CancellationToken ct);
+
+ /// Lists a remote directory by parsing ls -la.
+ IAsyncEnumerable ListAsync(string path, CancellationToken ct);
+
+ /// Streams a remote file to a local path (progress in bytes).
+ Task DownloadAsync(string remotePath, string localPath, IProgress? progress, CancellationToken ct);
+
+ /// Streams a local file to a remote path (progress in bytes).
+ Task UploadAsync(string localPath, string remotePath, IProgress? progress, CancellationToken ct);
+
+ /// Deletes a remote file. Root-level paths are rejected (see ).
+ Task DeleteAsync(string remotePath, CancellationToken ct);
+
+ /// Creates a remote directory (mkdir -p).
+ Task CreateDirectoryAsync(string remotePath, CancellationToken ct);
+
+ /// Runs a one-shot command and captures its output and exit code.
+ Task ExecAsync(string command, CancellationToken ct);
+}
+
+/// Creates fresh instances. Registered as a DI singleton.
+public interface ISshSessionFactory
+{
+ ISshSession Create();
+}
diff --git a/src/OpenIPC.Viewer.Core/Ssh/ISshShell.cs b/src/OpenIPC.Viewer.Core/Ssh/ISshShell.cs
new file mode 100644
index 0000000..363391c
--- /dev/null
+++ b/src/OpenIPC.Viewer.Core/Ssh/ISshShell.cs
@@ -0,0 +1,27 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace OpenIPC.Viewer.Core.Ssh;
+
+///
+/// An interactive shell channel (PTY) over an SSH session. The terminal UI
+/// (Phase 13.3) reads bytes into its VT buffer and
+/// writes keystrokes via . Kept byte-oriented so the
+/// Core contract carries no SSH.NET types.
+///
+public interface ISshShell : IAsyncDisposable
+{
+ /// Raised on the SSH receive thread with a chunk of terminal output.
+ event EventHandler? DataReceived;
+
+ /// Sends raw input (typically keystrokes) to the remote shell.
+ Task SendAsync(string data, CancellationToken ct);
+
+ ///
+ /// Best-effort PTY resize. Not all servers/transports honour a mid-session
+ /// window change; the shell is created at the initial size passed to
+ /// .
+ ///
+ void Resize(uint columns, uint rows);
+}
diff --git a/src/OpenIPC.Viewer.Core/Ssh/LsParser.cs b/src/OpenIPC.Viewer.Core/Ssh/LsParser.cs
new file mode 100644
index 0000000..5d39303
--- /dev/null
+++ b/src/OpenIPC.Viewer.Core/Ssh/LsParser.cs
@@ -0,0 +1,103 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+
+namespace OpenIPC.Viewer.Core.Ssh;
+
+///
+/// Parses busybox ls -la output into rows.
+/// Tolerant by design — OpenIPC firmwares ship different busybox builds, so a
+/// line that doesn't match the expected layout is skipped, not fatal
+/// (phase-13 §13.6, risk "разнобой busybox").
+///
+public static class LsParser
+{
+ private static readonly string[] Months =
+ {
+ "jan", "feb", "mar", "apr", "may", "jun",
+ "jul", "aug", "sep", "oct", "nov", "dec",
+ };
+
+ ///
+ /// Parses listing text. The . and .. pseudo-entries and the
+ /// leading total N line are filtered out.
+ ///
+ public static IReadOnlyList Parse(string output)
+ {
+ var entries = new List();
+ if (string.IsNullOrEmpty(output))
+ return entries;
+
+ foreach (var raw in output.Split('\n'))
+ {
+ var line = raw.TrimEnd('\r', ' ', '\t');
+ if (line.Length == 0 || line.StartsWith("total ", StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ var entry = ParseLine(line);
+ if (entry is not null && entry.Name is not ("." or ".."))
+ entries.Add(entry);
+ }
+
+ return entries;
+ }
+
+ private static RemoteEntry? ParseLine(string line)
+ {
+ // perms links owner group size mon day time|year name[ -> target]
+ var tokens = line.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
+ if (tokens.Length < 9)
+ return null;
+
+ var perms = tokens[0];
+ var kind = perms[0] switch
+ {
+ 'd' => RemoteEntryKind.Directory,
+ 'l' => RemoteEntryKind.SymbolicLink,
+ '-' => RemoteEntryKind.File,
+ _ => RemoteEntryKind.Other,
+ };
+
+ _ = long.TryParse(tokens[4], NumberStyles.Integer, CultureInfo.InvariantCulture, out var size);
+ var modified = TryParseDate(tokens[5], tokens[6], tokens[7]);
+
+ // Name is everything after the 8 fixed columns; rejoin so names with
+ // spaces survive. Symlinks render as "name -> target" — keep the name.
+ var name = string.Join(' ', tokens, 8, tokens.Length - 8);
+ if (kind == RemoteEntryKind.SymbolicLink)
+ {
+ var arrow = name.IndexOf(" -> ", StringComparison.Ordinal);
+ if (arrow >= 0)
+ name = name[..arrow];
+ }
+
+ if (name.Length == 0)
+ return null;
+
+ return new RemoteEntry(name, kind, size, modified, perms);
+ }
+
+ // "Jan 1 2020" -> a date; "Jan 1 14:30" omits the year (recent file) — we
+ // don't guess it, so Modified stays null rather than reporting a wrong year.
+ private static DateTimeOffset? TryParseDate(string month, string day, string yearOrTime)
+ {
+ var m = Array.IndexOf(Months, month.ToLowerInvariant()) + 1;
+ if (m == 0 || !int.TryParse(day, NumberStyles.Integer, CultureInfo.InvariantCulture, out var d))
+ return null;
+
+ if (yearOrTime.Contains(':'))
+ return null;
+
+ if (!int.TryParse(yearOrTime, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year))
+ return null;
+
+ try
+ {
+ return new DateTimeOffset(year, m, d, 0, 0, 0, TimeSpan.Zero);
+ }
+ catch (ArgumentOutOfRangeException)
+ {
+ return null;
+ }
+ }
+}
diff --git a/src/OpenIPC.Viewer.Core/Ssh/RemoteEntry.cs b/src/OpenIPC.Viewer.Core/Ssh/RemoteEntry.cs
new file mode 100644
index 0000000..03cf5bc
--- /dev/null
+++ b/src/OpenIPC.Viewer.Core/Ssh/RemoteEntry.cs
@@ -0,0 +1,22 @@
+using System;
+
+namespace OpenIPC.Viewer.Core.Ssh;
+
+public enum RemoteEntryKind
+{
+ File,
+ Directory,
+ SymbolicLink,
+ Other,
+}
+
+/// One entry in a remote directory listing (parsed from ls -la).
+public sealed record RemoteEntry(
+ string Name,
+ RemoteEntryKind Kind,
+ long Size,
+ DateTimeOffset? Modified,
+ string? Permissions)
+{
+ public bool IsDirectory => Kind == RemoteEntryKind.Directory;
+}
diff --git a/src/OpenIPC.Viewer.Core/Ssh/RemotePath.cs b/src/OpenIPC.Viewer.Core/Ssh/RemotePath.cs
new file mode 100644
index 0000000..74406d9
--- /dev/null
+++ b/src/OpenIPC.Viewer.Core/Ssh/RemotePath.cs
@@ -0,0 +1,37 @@
+using System;
+
+namespace OpenIPC.Viewer.Core.Ssh;
+
+///
+/// Unix remote-path helpers for the file manager (Phase 13.4). Always uses
+/// forward slashes regardless of the host OS — these are paths on the camera.
+///
+public static class RemotePath
+{
+ public static string Combine(string directory, string name)
+ {
+ name = name.Trim('/');
+ if (string.IsNullOrEmpty(directory) || directory == "/")
+ return "/" + name;
+ return directory.TrimEnd('/') + "/" + name;
+ }
+
+ public static string Parent(string path)
+ {
+ var p = (path ?? "/").TrimEnd('/');
+ if (p.Length == 0)
+ return "/";
+ var idx = p.LastIndexOf('/');
+ return idx <= 0 ? "/" : p[..idx];
+ }
+
+ /// The last path segment (file/dir name); "/" for the root.
+ public static string Name(string path)
+ {
+ var p = (path ?? "/").TrimEnd('/');
+ if (p.Length == 0)
+ return "/";
+ var idx = p.LastIndexOf('/');
+ return idx < 0 ? p : p[(idx + 1)..];
+ }
+}
diff --git a/src/OpenIPC.Viewer.Core/Ssh/RemotePathGuard.cs b/src/OpenIPC.Viewer.Core/Ssh/RemotePathGuard.cs
new file mode 100644
index 0000000..1660cbc
--- /dev/null
+++ b/src/OpenIPC.Viewer.Core/Ssh/RemotePathGuard.cs
@@ -0,0 +1,35 @@
+using System;
+
+namespace OpenIPC.Viewer.Core.Ssh;
+
+///
+/// Guards against catastrophic remote deletes. Deleting the root or any
+/// top-level entry (/etc, /bin, …) would brick the camera, so the
+/// file manager refuses it (phase-13 §13.4, after dashboard v0.1.2). Paths
+/// deeper than the root level — /etc/majestic.yaml, /tmp/clip.mp4 —
+/// are allowed.
+///
+public static class RemotePathGuard
+{
+ public static bool IsProtected(string? path)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ return true;
+
+ var p = path.Trim().Replace('\\', '/');
+ while (p.Length > 1 && p.EndsWith("/", StringComparison.Ordinal))
+ p = p[..^1];
+
+ if (p is "/" or "." or "..")
+ return true;
+
+ // Relative paths are never root-level targets; the file manager always
+ // passes absolute paths, so this only relaxes the guard for callers that
+ // pass a bare name.
+ if (!p.StartsWith("/", StringComparison.Ordinal))
+ return false;
+
+ var segments = p.Split('/', StringSplitOptions.RemoveEmptyEntries);
+ return segments.Length <= 1;
+ }
+}
diff --git a/src/OpenIPC.Viewer.Core/Ssh/SshEndpoint.cs b/src/OpenIPC.Viewer.Core/Ssh/SshEndpoint.cs
new file mode 100644
index 0000000..bd9cec9
--- /dev/null
+++ b/src/OpenIPC.Viewer.Core/Ssh/SshEndpoint.cs
@@ -0,0 +1,20 @@
+namespace OpenIPC.Viewer.Core.Ssh;
+
+///
+/// Connection target for an SSH session. Credentials are passed in here rather
+/// than resolved inside the session so the Core contract stays free of the
+/// secrets store.
+///
+public sealed record SshEndpoint(string Host, int Port, string Username, SshAuth Auth)
+{
+ public const int DefaultPort = 22;
+}
+
+/// SSH authentication material — password or private key.
+public abstract record SshAuth
+{
+ public sealed record Password(string Value) : SshAuth;
+
+ /// Path to a private key file, with an optional passphrase.
+ public sealed record PrivateKey(string KeyPath, string? Passphrase) : SshAuth;
+}
diff --git a/src/OpenIPC.Viewer.Core/Ssh/Terminal/TerminalCell.cs b/src/OpenIPC.Viewer.Core/Ssh/Terminal/TerminalCell.cs
new file mode 100644
index 0000000..c38831b
--- /dev/null
+++ b/src/OpenIPC.Viewer.Core/Ssh/Terminal/TerminalCell.cs
@@ -0,0 +1,19 @@
+namespace OpenIPC.Viewer.Core.Ssh.Terminal;
+
+/// One character cell in the terminal grid.
+public readonly record struct TerminalCell(char Char, byte Foreground, byte Background, bool Bold)
+{
+ public static readonly TerminalCell Blank =
+ new(' ', TerminalPalette.DefaultForeground, TerminalPalette.DefaultBackground, false);
+}
+
+///
+/// Color indices used by . 0–7 are the standard ANSI
+/// colors, 8–15 their bright variants; two sentinels mark "use the theme
+/// default". The renderer maps these to brushes.
+///
+public static class TerminalPalette
+{
+ public const byte DefaultForeground = 0xFF;
+ public const byte DefaultBackground = 0xFE;
+}
diff --git a/src/OpenIPC.Viewer.Core/Ssh/Terminal/TerminalEmulator.cs b/src/OpenIPC.Viewer.Core/Ssh/Terminal/TerminalEmulator.cs
new file mode 100644
index 0000000..fa2d467
--- /dev/null
+++ b/src/OpenIPC.Viewer.Core/Ssh/Terminal/TerminalEmulator.cs
@@ -0,0 +1,365 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Text;
+
+namespace OpenIPC.Viewer.Core.Ssh.Terminal;
+
+///
+/// A deliberately small VT/ANSI terminal emulator (phase-13 §13.3 "basic VT" —
+/// no alt-screen, mouse, or full ncurses). Feeds UTF-8 bytes through a state
+/// machine that maintains a character grid: printable text, the common control
+/// codes, CSI cursor/erase moves, and SGR colors. Unknown escapes are consumed
+/// and ignored rather than corrupting the screen.
+///
+public sealed class TerminalEmulator
+{
+ private const char Esc = '\x1b';
+ private const char Bel = '\x07';
+
+ private enum State { Ground, Escape, Csi, Osc }
+
+ private TerminalCell[][] _screen = Array.Empty();
+ private readonly List _scrollback = new();
+ private const int MaxScrollback = 1000;
+
+ private int _cursorRow;
+ private int _cursorCol;
+ private int _savedRow;
+ private int _savedCol;
+
+ private byte _fg = TerminalPalette.DefaultForeground;
+ private byte _bg = TerminalPalette.DefaultBackground;
+ private bool _bold;
+
+ private State _state = State.Ground;
+ private readonly StringBuilder _params = new();
+ private bool _privateSeq;
+
+ private readonly Decoder _decoder = Encoding.UTF8.GetDecoder();
+ private char[] _charBuf = new char[1024];
+
+ public int Columns { get; private set; }
+ public int Rows { get; private set; }
+ public int CursorRow => _cursorRow;
+ public int CursorColumn => _cursorCol;
+
+ /// Raised after a batch mutates the grid.
+ public event Action? Updated;
+
+ public TerminalEmulator(int columns, int rows) => Resize(columns, rows);
+
+ public TerminalCell[] GetRow(int row) => _screen[row];
+
+ public void Resize(int columns, int rows)
+ {
+ columns = Math.Max(1, columns);
+ rows = Math.Max(1, rows);
+ if (columns == Columns && rows == Rows)
+ return;
+
+ var next = new TerminalCell[rows][];
+ for (var r = 0; r < rows; r++)
+ {
+ next[r] = NewBlankRow(columns);
+ if (r < Rows && _screen.Length > 0)
+ {
+ var old = _screen[r];
+ var copy = Math.Min(columns, old.Length);
+ Array.Copy(old, next[r], copy);
+ }
+ }
+
+ Columns = columns;
+ Rows = rows;
+ _screen = next;
+ _cursorRow = Math.Min(_cursorRow, rows - 1);
+ _cursorCol = Math.Min(_cursorCol, columns - 1);
+ Updated?.Invoke();
+ }
+
+ public void Feed(byte[] bytes) => Feed(bytes.AsSpan());
+
+ public void Feed(ReadOnlySpan bytes)
+ {
+ if (bytes.IsEmpty)
+ return;
+
+ var needed = _decoder.GetCharCount(bytes, flush: false);
+ if (_charBuf.Length < needed)
+ _charBuf = new char[needed];
+
+ var written = _decoder.GetChars(bytes, _charBuf, flush: false);
+ for (var i = 0; i < written; i++)
+ ProcessChar(_charBuf[i]);
+
+ Updated?.Invoke();
+ }
+
+ /// Convenience for tests and synthetic input.
+ public void Feed(string text) => Feed(Encoding.UTF8.GetBytes(text));
+
+ private void ProcessChar(char c)
+ {
+ switch (_state)
+ {
+ case State.Ground: ProcessGround(c); break;
+ case State.Escape: ProcessEscape(c); break;
+ case State.Csi: ProcessCsi(c); break;
+ case State.Osc:
+ // OS Command (e.g. window title) — swallow until BEL. Not rendered.
+ if (c == Bel) _state = State.Ground;
+ break;
+ }
+ }
+
+ private void ProcessGround(char c)
+ {
+ switch (c)
+ {
+ case Esc: _state = State.Escape; break;
+ case '\r': _cursorCol = 0; break;
+ case '\n': LineFeed(); break;
+ case '\b': if (_cursorCol > 0) _cursorCol--; break;
+ case '\t': _cursorCol = Math.Min(Columns - 1, (_cursorCol / 8 + 1) * 8); break;
+ case Bel: break;
+ default:
+ if (!char.IsControl(c))
+ PutChar(c);
+ break;
+ }
+ }
+
+ private void ProcessEscape(char c)
+ {
+ switch (c)
+ {
+ case '[':
+ _params.Clear();
+ _privateSeq = false;
+ _state = State.Csi;
+ break;
+ case ']':
+ _state = State.Osc;
+ break;
+ case 'c': // RIS — full reset
+ ResetScreen();
+ _state = State.Ground;
+ break;
+ default:
+ // Charset selection ESC( / ESC) and other two-char escapes —
+ // ignore the parameter byte and return to ground.
+ _state = State.Ground;
+ break;
+ }
+ }
+
+ private void ProcessCsi(char c)
+ {
+ if (c == '?')
+ {
+ _privateSeq = true;
+ return;
+ }
+ if ((c >= '0' && c <= '9') || c == ';')
+ {
+ _params.Append(c);
+ return;
+ }
+
+ // Final byte (0x40–0x7E) dispatches the command.
+ DispatchCsi(c, ParseParams());
+ _state = State.Ground;
+ }
+
+ private void DispatchCsi(char final, int[] ps)
+ {
+ // Private sequences (ESC[?…) are mode toggles like cursor visibility —
+ // we don't model them, just consume.
+ if (_privateSeq)
+ return;
+
+ switch (final)
+ {
+ case 'm': ApplySgr(ps); break;
+ case 'H' or 'f':
+ _cursorRow = ClampRow(Arg(ps, 0, 1) - 1);
+ _cursorCol = ClampCol(Arg(ps, 1, 1) - 1);
+ break;
+ case 'A': _cursorRow = ClampRow(_cursorRow - Arg(ps, 0, 1)); break;
+ case 'B': _cursorRow = ClampRow(_cursorRow + Arg(ps, 0, 1)); break;
+ case 'C': _cursorCol = ClampCol(_cursorCol + Arg(ps, 0, 1)); break;
+ case 'D': _cursorCol = ClampCol(_cursorCol - Arg(ps, 0, 1)); break;
+ case 'G': _cursorCol = ClampCol(Arg(ps, 0, 1) - 1); break;
+ case 'd': _cursorRow = ClampRow(Arg(ps, 0, 1) - 1); break;
+ case 'J': EraseInDisplay(Arg(ps, 0, 0)); break;
+ case 'K': EraseInLine(Arg(ps, 0, 0)); break;
+ case 's': _savedRow = _cursorRow; _savedCol = _cursorCol; break;
+ case 'u': _cursorRow = ClampRow(_savedRow); _cursorCol = ClampCol(_savedCol); break;
+ default: break; // unsupported — ignore
+ }
+ }
+
+ private void ApplySgr(int[] ps)
+ {
+ if (ps.Length == 0)
+ {
+ ResetAttributes();
+ return;
+ }
+
+ for (var i = 0; i < ps.Length; i++)
+ {
+ var p = ps[i];
+ switch (p)
+ {
+ case 0: ResetAttributes(); break;
+ case 1: _bold = true; break;
+ case 22: _bold = false; break;
+ case >= 30 and <= 37: _fg = (byte)(p - 30); break;
+ case 39: _fg = TerminalPalette.DefaultForeground; break;
+ case >= 40 and <= 47: _bg = (byte)(p - 40); break;
+ case 49: _bg = TerminalPalette.DefaultBackground; break;
+ case >= 90 and <= 97: _fg = (byte)(p - 90 + 8); break;
+ case >= 100 and <= 107: _bg = (byte)(p - 100 + 8); break;
+ case 38: i = ConsumeExtendedColor(ps, i, out _fg); break;
+ case 48: i = ConsumeExtendedColor(ps, i, out _bg); break;
+ default: break;
+ }
+ }
+ }
+
+ // 38/48 ; 5 ; n (256-color) or 38/48 ; 2 ; r ; g ; b (truecolor). We map
+ // a low index to its palette slot and fall back to default for the rest.
+ private static int ConsumeExtendedColor(int[] ps, int i, out byte color)
+ {
+ color = TerminalPalette.DefaultForeground;
+ if (i + 1 >= ps.Length)
+ return i;
+
+ var mode = ps[i + 1];
+ if (mode == 5 && i + 2 < ps.Length)
+ {
+ var n = ps[i + 2];
+ color = n < 16 ? (byte)n : TerminalPalette.DefaultForeground;
+ return i + 2;
+ }
+ if (mode == 2 && i + 4 < ps.Length)
+ return i + 4; // truecolor not represented — leave default
+
+ return i + 1;
+ }
+
+ private void PutChar(char c)
+ {
+ if (_cursorCol >= Columns)
+ {
+ _cursorCol = 0;
+ LineFeed();
+ }
+ _screen[_cursorRow][_cursorCol] = new TerminalCell(c, _fg, _bg, _bold);
+ _cursorCol++;
+ }
+
+ private void LineFeed()
+ {
+ if (_cursorRow >= Rows - 1)
+ ScrollUp();
+ else
+ _cursorRow++;
+ }
+
+ private void ScrollUp()
+ {
+ _scrollback.Add(_screen[0]);
+ if (_scrollback.Count > MaxScrollback)
+ _scrollback.RemoveAt(0);
+
+ for (var r = 1; r < Rows; r++)
+ _screen[r - 1] = _screen[r];
+ _screen[Rows - 1] = NewBlankRow(Columns);
+ }
+
+ private void EraseInLine(int mode)
+ {
+ var row = _screen[_cursorRow];
+ var (from, to) = mode switch
+ {
+ 1 => (0, _cursorCol),
+ 2 => (0, Columns - 1),
+ _ => (_cursorCol, Columns - 1),
+ };
+ for (var c = from; c <= to && c < Columns; c++)
+ row[c] = BlankCell();
+ }
+
+ private void EraseInDisplay(int mode)
+ {
+ switch (mode)
+ {
+ case 1:
+ for (var r = 0; r < _cursorRow; r++) ClearRow(r);
+ for (var c = 0; c <= _cursorCol && c < Columns; c++) _screen[_cursorRow][c] = BlankCell();
+ break;
+ case 2:
+ for (var r = 0; r < Rows; r++) ClearRow(r);
+ break;
+ default:
+ for (var c = _cursorCol; c < Columns; c++) _screen[_cursorRow][c] = BlankCell();
+ for (var r = _cursorRow + 1; r < Rows; r++) ClearRow(r);
+ break;
+ }
+ }
+
+ private void ClearRow(int row)
+ {
+ var r = _screen[row];
+ for (var c = 0; c < Columns; c++)
+ r[c] = BlankCell();
+ }
+
+ private void ResetScreen()
+ {
+ for (var r = 0; r < Rows; r++) ClearRow(r);
+ _cursorRow = 0;
+ _cursorCol = 0;
+ ResetAttributes();
+ }
+
+ private void ResetAttributes()
+ {
+ _fg = TerminalPalette.DefaultForeground;
+ _bg = TerminalPalette.DefaultBackground;
+ _bold = false;
+ }
+
+ // Erased cells keep the active background so colored fills survive a clear.
+ private TerminalCell BlankCell() =>
+ new(' ', TerminalPalette.DefaultForeground, _bg, false);
+
+ private int[] ParseParams()
+ {
+ if (_params.Length == 0)
+ return Array.Empty();
+
+ var parts = _params.ToString().Split(';');
+ var result = new int[parts.Length];
+ for (var i = 0; i < parts.Length; i++)
+ result[i] = int.TryParse(parts[i], NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) ? v : 0;
+ return result;
+ }
+
+ private static int Arg(int[] ps, int index, int fallback) =>
+ index < ps.Length && ps[index] > 0 ? ps[index] : fallback;
+
+ private int ClampRow(int r) => Math.Clamp(r, 0, Rows - 1);
+ private int ClampCol(int c) => Math.Clamp(c, 0, Columns - 1);
+
+ private static TerminalCell[] NewBlankRow(int columns)
+ {
+ var row = new TerminalCell[columns];
+ for (var c = 0; c < columns; c++)
+ row[c] = TerminalCell.Blank;
+ return row;
+ }
+}
diff --git a/src/OpenIPC.Viewer.Devices/Majestic/MajesticSshConfigClient.cs b/src/OpenIPC.Viewer.Devices/Majestic/MajesticSshConfigClient.cs
new file mode 100644
index 0000000..07b0f12
--- /dev/null
+++ b/src/OpenIPC.Viewer.Devices/Majestic/MajesticSshConfigClient.cs
@@ -0,0 +1,82 @@
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using OpenIPC.Viewer.Core.Majestic;
+using OpenIPC.Viewer.Core.Settings;
+using OpenIPC.Viewer.Core.Ssh;
+
+namespace OpenIPC.Viewer.Devices.Majestic;
+
+///
+/// SSH-based . Uses cat to read and
+/// an SCP upload + atomic mv to write, then killall -HUP majestic
+/// to make the daemon reload (phase-13 §13.5).
+///
+public sealed class MajesticSshConfigClient : IMajesticSshConfigClient
+{
+ private const string RemoteTempPath = "/tmp/.majestic.yaml.upload";
+ private const string RestartCommand = "killall -HUP majestic";
+
+ private readonly ISshSessionFactory _sessions;
+ private readonly IUserSettingsAccessor _settings;
+ private readonly ILogger _logger;
+
+ public MajesticSshConfigClient(ISshSessionFactory sessions, IUserSettingsAccessor settings, ILogger logger)
+ {
+ _sessions = sessions;
+ _settings = settings;
+ _logger = logger;
+ }
+
+ public string ConfigPath => _settings.MajesticConfigPath;
+
+ public async Task ConfigExistsAsync(SshEndpoint endpoint, CancellationToken ct)
+ {
+ await using var session = _sessions.Create();
+ await session.ConnectAsync(endpoint, ct).ConfigureAwait(false);
+ var result = await session.ExecAsync($"test -f {ConfigPath}", ct).ConfigureAwait(false);
+ return result.Success;
+ }
+
+ public async Task ReadRawAsync(SshEndpoint endpoint, CancellationToken ct)
+ {
+ await using var session = _sessions.Create();
+ await session.ConnectAsync(endpoint, ct).ConfigureAwait(false);
+ var result = await session.ExecAsync($"cat {ConfigPath}", ct).ConfigureAwait(false);
+ if (!result.Success)
+ throw new IOException($"Failed to read {ConfigPath}: {result.StandardError.Trim()}");
+ return result.StandardOutput;
+ }
+
+ public async Task WriteRawAsync(SshEndpoint endpoint, string rawYaml, bool restart, CancellationToken ct)
+ {
+ await using var session = _sessions.Create();
+ await session.ConnectAsync(endpoint, ct).ConfigureAwait(false);
+
+ // Stage to a temp file, then move into place — a half-written
+ // majestic.yaml (e.g. if the transfer is cut) would brick the streamer.
+ var local = Path.GetTempFileName();
+ try
+ {
+ await File.WriteAllTextAsync(local, rawYaml, ct).ConfigureAwait(false);
+ await session.UploadAsync(local, RemoteTempPath, progress: null, ct).ConfigureAwait(false);
+ }
+ finally
+ {
+ try { File.Delete(local); } catch (IOException) { /* best effort */ }
+ }
+
+ var move = await session.ExecAsync($"mv -f {RemoteTempPath} {ConfigPath}", ct).ConfigureAwait(false);
+ if (!move.Success)
+ throw new IOException($"Failed to write {ConfigPath}: {move.StandardError.Trim()}");
+
+ if (restart)
+ {
+ var reload = await session.ExecAsync(RestartCommand, ct).ConfigureAwait(false);
+ if (!reload.Success)
+ _logger.LogWarning("majestic reload returned {Exit}: {Err}",
+ reload.ExitCode, reload.StandardError.Trim());
+ }
+ }
+}
diff --git a/src/OpenIPC.Viewer.Devices/Onvif/Discovery/SystemNetworkInterfaceProvider.cs b/src/OpenIPC.Viewer.Devices/Onvif/Discovery/SystemNetworkInterfaceProvider.cs
index 3cc38a9..fafa604 100644
--- a/src/OpenIPC.Viewer.Devices/Onvif/Discovery/SystemNetworkInterfaceProvider.cs
+++ b/src/OpenIPC.Viewer.Devices/Onvif/Discovery/SystemNetworkInterfaceProvider.cs
@@ -32,8 +32,18 @@ public IReadOnlyList GetCandidates()
.ToList();
if (ipv4.Count == 0) continue;
- var hasGateway = props.GatewayAddresses
- .Any(g => g.Address.AddressFamily == AddressFamily.InterNetwork);
+ // GatewayAddresses is lazily evaluated and throws
+ // PlatformNotSupportedException on Android's BCL — guard it
+ // separately (GetIPProperties itself succeeds there). When the
+ // gateway is unknowable we just treat the NIC as gateway-less.
+ var hasGateway = false;
+ try
+ {
+ hasGateway = props.GatewayAddresses
+ .Any(g => g.Address.AddressFamily == AddressFamily.InterNetwork);
+ }
+ catch (PlatformNotSupportedException) { }
+ catch (NotImplementedException) { }
adapters.Add(new NicDescriptor(
Name: nic.Name,
diff --git a/src/OpenIPC.Viewer.Infrastructure/OpenIPC.Viewer.Infrastructure.csproj b/src/OpenIPC.Viewer.Infrastructure/OpenIPC.Viewer.Infrastructure.csproj
index 45a67be..acb74a2 100644
--- a/src/OpenIPC.Viewer.Infrastructure/OpenIPC.Viewer.Infrastructure.csproj
+++ b/src/OpenIPC.Viewer.Infrastructure/OpenIPC.Viewer.Infrastructure.csproj
@@ -9,6 +9,7 @@
+
diff --git a/src/OpenIPC.Viewer.Infrastructure/Persistence/Migrations/008_ssh_port.sql b/src/OpenIPC.Viewer.Infrastructure/Persistence/Migrations/008_ssh_port.sql
new file mode 100644
index 0000000..e8afd1e
--- /dev/null
+++ b/src/OpenIPC.Viewer.Infrastructure/Persistence/Migrations/008_ssh_port.sql
@@ -0,0 +1,4 @@
+-- Phase 13 — SSH device suite. Optional per-camera SSH port; NULL means the
+-- default 22. SSH credentials are not stored here — they live in the secrets
+-- store under cam:{id}:ssh:username / cam:{id}:ssh:password.
+ALTER TABLE Cameras ADD COLUMN SshPort INTEGER NULL;
diff --git a/src/OpenIPC.Viewer.Infrastructure/Persistence/SqliteCameraRepository.cs b/src/OpenIPC.Viewer.Infrastructure/Persistence/SqliteCameraRepository.cs
index d4993c2..8b015f4 100644
--- a/src/OpenIPC.Viewer.Infrastructure/Persistence/SqliteCameraRepository.cs
+++ b/src/OpenIPC.Viewer.Infrastructure/Persistence/SqliteCameraRepository.cs
@@ -47,13 +47,13 @@ INSERT INTO Cameras (
RtspMainUri, RtspSubUri, UsernameRef, PasswordRef,
OnvifEnabled, OnvifProfileToken, ChipModel, FirmwareVersion,
IncludedInGrid, HasPtz, IsMajestic, SortOrder, CreatedAt, UpdatedAt,
- StreamQualityOverride)
+ StreamQualityOverride, SshPort)
VALUES (
@Id, @GroupId, @Name, @Host, @OnvifPort, @HttpPort,
@RtspMainUri, @RtspSubUri, @UsernameRef, @PasswordRef,
@OnvifEnabled, @OnvifProfileToken, @ChipModel, @FirmwareVersion,
@IncludedInGrid, @HasPtz, @IsMajestic, @SortOrder, @CreatedAt, @UpdatedAt,
- @StreamQualityOverride);
+ @StreamQualityOverride, @SshPort);
""",
ToRow(camera), transaction: tx).ConfigureAwait(false);
await tx.CommitAsync(ct).ConfigureAwait(false);
@@ -85,7 +85,8 @@ UPDATE Cameras SET
IsMajestic = @IsMajestic,
SortOrder = @SortOrder,
UpdatedAt = @UpdatedAt,
- StreamQualityOverride = @StreamQualityOverride
+ StreamQualityOverride = @StreamQualityOverride,
+ SshPort = @SshPort
WHERE Id = @Id;
""",
ToRow(camera), transaction: tx).ConfigureAwait(false);
@@ -142,7 +143,8 @@ await conn.ExecuteAsync(
SortOrder: row.SortOrder,
CreatedAt: DateTime.Parse(row.CreatedAt, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind),
UpdatedAt: DateTime.Parse(row.UpdatedAt, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind),
- StreamQualityOverride: (StreamQualityOverride)row.StreamQualityOverride);
+ StreamQualityOverride: (StreamQualityOverride)row.StreamQualityOverride,
+ SshPort: row.SshPort is null ? null : (int)row.SshPort.Value);
private static object ToRow(Camera c) => new
{
@@ -167,6 +169,7 @@ await conn.ExecuteAsync(
CreatedAt = c.CreatedAt.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture),
UpdatedAt = c.UpdatedAt.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture),
StreamQualityOverride = (int)c.StreamQualityOverride,
+ c.SshPort,
};
private sealed class CameraRow
@@ -192,5 +195,6 @@ private sealed class CameraRow
public string CreatedAt { get; init; } = default!;
public string UpdatedAt { get; init; } = default!;
public int StreamQualityOverride { get; init; }
+ public long? SshPort { get; init; }
}
}
diff --git a/src/OpenIPC.Viewer.Infrastructure/Ssh/JsonFileHostKeyStore.cs b/src/OpenIPC.Viewer.Infrastructure/Ssh/JsonFileHostKeyStore.cs
new file mode 100644
index 0000000..3f1c4f5
--- /dev/null
+++ b/src/OpenIPC.Viewer.Infrastructure/Ssh/JsonFileHostKeyStore.cs
@@ -0,0 +1,97 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using OpenIPC.Viewer.Core.Platform;
+using OpenIPC.Viewer.Core.Ssh;
+
+namespace OpenIPC.Viewer.Infrastructure.Ssh;
+
+///
+/// Stores pinned host keys in ssh_known_hosts.json under the app data
+/// dir. Loaded once into memory; writes are serialized through a lock. Clearing
+/// just empties the map and rewrites the file.
+///
+public sealed class JsonFileHostKeyStore : ISshHostKeyStore
+{
+ private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = true };
+
+ private readonly string _path;
+ private readonly ILogger _logger;
+ private readonly object _gate = new();
+ private Dictionary? _map;
+
+ public JsonFileHostKeyStore(IFileSystem fs, ILogger logger)
+ {
+ _path = Path.Combine(fs.AppDataDir.FullName, "ssh_known_hosts.json");
+ _logger = logger;
+ }
+
+ public Task GetAsync(string host, int port, CancellationToken ct)
+ {
+ lock (_gate)
+ {
+ var map = Load();
+ return Task.FromResult(map.TryGetValue(Key(host, port), out var v) ? v : null);
+ }
+ }
+
+ public Task SetAsync(string host, int port, string fingerprint, CancellationToken ct)
+ {
+ lock (_gate)
+ {
+ var map = Load();
+ map[Key(host, port)] = fingerprint;
+ Save(map);
+ }
+ return Task.CompletedTask;
+ }
+
+ public Task ClearAsync(CancellationToken ct)
+ {
+ lock (_gate)
+ {
+ _map = new Dictionary();
+ Save(_map);
+ }
+ return Task.CompletedTask;
+ }
+
+ private Dictionary Load()
+ {
+ if (_map is not null)
+ return _map;
+ try
+ {
+ if (File.Exists(_path))
+ {
+ using var stream = File.OpenRead(_path);
+ _map = JsonSerializer.Deserialize>(stream, JsonOpts);
+ }
+ }
+ catch (System.Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to read ssh_known_hosts.json — starting empty");
+ }
+ return _map ??= new Dictionary();
+ }
+
+ private void Save(Dictionary map)
+ {
+ try
+ {
+ var tmp = _path + ".tmp";
+ using (var stream = File.Create(tmp))
+ JsonSerializer.Serialize(stream, map, JsonOpts);
+ File.Move(tmp, _path, overwrite: true);
+ }
+ catch (System.Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to write ssh_known_hosts.json");
+ }
+ }
+
+ private static string Key(string host, int port) => $"{host}:{port}";
+}
diff --git a/src/OpenIPC.Viewer.Infrastructure/Ssh/SshNetSession.cs b/src/OpenIPC.Viewer.Infrastructure/Ssh/SshNetSession.cs
new file mode 100644
index 0000000..7e19ed3
--- /dev/null
+++ b/src/OpenIPC.Viewer.Infrastructure/Ssh/SshNetSession.cs
@@ -0,0 +1,218 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using OpenIPC.Viewer.Core.Settings;
+using OpenIPC.Viewer.Core.Ssh;
+using Renci.SshNet;
+using Renci.SshNet.Common;
+
+namespace OpenIPC.Viewer.Infrastructure.Ssh;
+
+///
+/// SSH.NET-backed . Holds a shell/exec client and a
+/// separate SCP client over the same endpoint. Host keys are pinned on first
+/// use (TOFU) via the secrets store.
+///
+internal sealed class SshNetSession : ISshSession
+{
+ private readonly ISshHostKeyStore _hostKeys;
+ private readonly IUserSettingsAccessor _settings;
+ private readonly ILogger _logger;
+
+ private SshClient? _ssh;
+ private ScpClient? _scp;
+
+ private string? _knownFingerprint;
+ private bool _shouldStore;
+ private bool _mismatch;
+ private bool _strict = true;
+ private string _host = "";
+ private int _port;
+
+ public SshNetSession(ISshHostKeyStore hostKeys, IUserSettingsAccessor settings, ILogger logger)
+ {
+ _hostKeys = hostKeys;
+ _settings = settings;
+ _logger = logger;
+ }
+
+ public async Task ConnectAsync(SshEndpoint endpoint, CancellationToken ct)
+ {
+ _host = endpoint.Host;
+ _port = endpoint.Port;
+ _strict = _settings.SshStrictHostKey;
+ _knownFingerprint = await _hostKeys.GetAsync(endpoint.Host, endpoint.Port, ct).ConfigureAwait(false);
+
+ _ssh = new SshClient(BuildConnectionInfo(endpoint));
+ _scp = new ScpClient(BuildConnectionInfo(endpoint));
+ _ssh.HostKeyReceived += OnHostKeyReceived;
+ _scp.HostKeyReceived += OnHostKeyReceived;
+
+ await _ssh.ConnectAsync(ct).ConfigureAwait(false);
+ await _scp.ConnectAsync(ct).ConfigureAwait(false);
+
+ if (_mismatch)
+ {
+ await DisposeAsync().ConfigureAwait(false);
+ throw new SshConnectionException(
+ $"Host key for {endpoint.Host}:{endpoint.Port} changed — refusing to connect.");
+ }
+
+ if (_shouldStore && _knownFingerprint is { } fp)
+ {
+ await _hostKeys.SetAsync(endpoint.Host, endpoint.Port, fp, ct).ConfigureAwait(false);
+ _logger.LogInformation("Pinned SSH host key for {Host}:{Port} (SHA256 {Fingerprint})",
+ endpoint.Host, endpoint.Port, fp);
+ }
+ }
+
+ // Synchronous TOFU check: the captured fingerprint was loaded before connect
+ // (the event handler can't await). First use trusts and pins. A changed key
+ // is rejected under strict checking, or accepted and re-pinned when the user
+ // turned strict off (e.g. a camera was reflashed).
+ private void OnHostKeyReceived(object? sender, HostKeyEventArgs e)
+ {
+ var presented = e.FingerPrintSHA256;
+ if (_knownFingerprint is null)
+ {
+ _knownFingerprint = presented;
+ _shouldStore = true;
+ e.CanTrust = true;
+ }
+ else if (string.Equals(_knownFingerprint, presented, StringComparison.Ordinal))
+ {
+ e.CanTrust = true;
+ }
+ else if (_strict)
+ {
+ e.CanTrust = false;
+ _mismatch = true;
+ }
+ else
+ {
+ _knownFingerprint = presented;
+ _shouldStore = true;
+ e.CanTrust = true;
+ }
+ }
+
+ public async Task OpenShellAsync(uint columns, uint rows, CancellationToken ct)
+ {
+ ct.ThrowIfCancellationRequested();
+ var stream = Client.CreateShellStream("xterm", columns, rows, 0, 0, 4096);
+ return await Task.FromResult(new SshNetShell(stream, _logger)).ConfigureAwait(false);
+ }
+
+ public async IAsyncEnumerable ListAsync(
+ string path, [EnumeratorCancellation] CancellationToken ct)
+ {
+ var result = await ExecAsync($"ls -la {ShellQuote(path)}", ct).ConfigureAwait(false);
+ if (!result.Success)
+ throw new SshException($"ls failed for '{path}': {result.StandardError.Trim()}");
+
+ foreach (var entry in LsParser.Parse(result.StandardOutput))
+ {
+ ct.ThrowIfCancellationRequested();
+ yield return entry;
+ }
+ }
+
+ public Task DownloadAsync(string remotePath, string localPath, IProgress? progress, CancellationToken ct) =>
+ Task.Run(() =>
+ {
+ void OnDownloading(object? s, ScpDownloadEventArgs e) => progress?.Report(e.Downloaded);
+ Scp.Downloading += OnDownloading;
+ try
+ {
+ Scp.Download(remotePath, new FileInfo(localPath));
+ }
+ finally
+ {
+ Scp.Downloading -= OnDownloading;
+ }
+ }, ct);
+
+ public Task UploadAsync(string localPath, string remotePath, IProgress? progress, CancellationToken ct) =>
+ Task.Run(() =>
+ {
+ void OnUploading(object? s, ScpUploadEventArgs e) => progress?.Report(e.Uploaded);
+ Scp.Uploading += OnUploading;
+ try
+ {
+ Scp.Upload(new FileInfo(localPath), remotePath);
+ }
+ finally
+ {
+ Scp.Uploading -= OnUploading;
+ }
+ }, ct);
+
+ public async Task DeleteAsync(string remotePath, CancellationToken ct)
+ {
+ if (RemotePathGuard.IsProtected(remotePath))
+ throw new InvalidOperationException($"Refusing to delete root-level path '{remotePath}'.");
+
+ var result = await ExecAsync($"rm -rf {ShellQuote(remotePath)}", ct).ConfigureAwait(false);
+ if (!result.Success)
+ throw new SshException($"rm failed for '{remotePath}': {result.StandardError.Trim()}");
+ }
+
+ public async Task CreateDirectoryAsync(string remotePath, CancellationToken ct)
+ {
+ var result = await ExecAsync($"mkdir -p {ShellQuote(remotePath)}", ct).ConfigureAwait(false);
+ if (!result.Success)
+ throw new SshException($"mkdir failed for '{remotePath}': {result.StandardError.Trim()}");
+ }
+
+ public Task ExecAsync(string command, CancellationToken ct) =>
+ Task.Run(() =>
+ {
+ using var cmd = Client.CreateCommand(command);
+ cmd.Execute();
+ return new CommandResult(cmd.ExitStatus ?? -1, cmd.Result, cmd.Error);
+ }, ct);
+
+ private SshClient Client =>
+ _ssh ?? throw new InvalidOperationException("SSH session is not connected.");
+
+ private ScpClient Scp =>
+ _scp ?? throw new InvalidOperationException("SSH session is not connected.");
+
+ private static ConnectionInfo BuildConnectionInfo(SshEndpoint ep)
+ {
+ AuthenticationMethod auth = ep.Auth switch
+ {
+ SshAuth.Password p => new PasswordAuthenticationMethod(ep.Username, p.Value),
+ SshAuth.PrivateKey k => new PrivateKeyAuthenticationMethod(
+ ep.Username, new PrivateKeyFile(k.KeyPath, k.Passphrase)),
+ _ => throw new NotSupportedException($"Unsupported SSH auth: {ep.Auth.GetType().Name}"),
+ };
+ return new ConnectionInfo(ep.Host, ep.Port, ep.Username, auth);
+ }
+
+ // Wrap a path in single quotes for the remote shell, escaping embedded
+ // single quotes ('\'' is the standard sh trick).
+ private static string ShellQuote(string path) =>
+ "'" + path.Replace("'", "'\\''") + "'";
+
+ public ValueTask DisposeAsync()
+ {
+ if (_ssh is not null)
+ {
+ _ssh.HostKeyReceived -= OnHostKeyReceived;
+ _ssh.Dispose();
+ _ssh = null;
+ }
+ if (_scp is not null)
+ {
+ _scp.HostKeyReceived -= OnHostKeyReceived;
+ _scp.Dispose();
+ _scp = null;
+ }
+ return ValueTask.CompletedTask;
+ }
+}
diff --git a/src/OpenIPC.Viewer.Infrastructure/Ssh/SshNetSessionFactory.cs b/src/OpenIPC.Viewer.Infrastructure/Ssh/SshNetSessionFactory.cs
new file mode 100644
index 0000000..f25f4ee
--- /dev/null
+++ b/src/OpenIPC.Viewer.Infrastructure/Ssh/SshNetSessionFactory.cs
@@ -0,0 +1,23 @@
+using Microsoft.Extensions.Logging;
+using OpenIPC.Viewer.Core.Settings;
+using OpenIPC.Viewer.Core.Ssh;
+
+namespace OpenIPC.Viewer.Infrastructure.Ssh;
+
+/// Creates SSH.NET-backed sessions. Registered as a DI singleton.
+public sealed class SshNetSessionFactory : ISshSessionFactory
+{
+ private readonly ISshHostKeyStore _hostKeys;
+ private readonly IUserSettingsAccessor _settings;
+ private readonly ILoggerFactory _loggerFactory;
+
+ public SshNetSessionFactory(ISshHostKeyStore hostKeys, IUserSettingsAccessor settings, ILoggerFactory loggerFactory)
+ {
+ _hostKeys = hostKeys;
+ _settings = settings;
+ _loggerFactory = loggerFactory;
+ }
+
+ public ISshSession Create() =>
+ new SshNetSession(_hostKeys, _settings, _loggerFactory.CreateLogger());
+}
diff --git a/src/OpenIPC.Viewer.Infrastructure/Ssh/SshNetShell.cs b/src/OpenIPC.Viewer.Infrastructure/Ssh/SshNetShell.cs
new file mode 100644
index 0000000..43661bb
--- /dev/null
+++ b/src/OpenIPC.Viewer.Infrastructure/Ssh/SshNetShell.cs
@@ -0,0 +1,49 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using OpenIPC.Viewer.Core.Ssh;
+using Renci.SshNet;
+using Renci.SshNet.Common;
+
+namespace OpenIPC.Viewer.Infrastructure.Ssh;
+
+/// Wraps SSH.NET's behind .
+internal sealed class SshNetShell : ISshShell
+{
+ private readonly ShellStream _stream;
+ private readonly ILogger _logger;
+
+ public event EventHandler? DataReceived;
+
+ public SshNetShell(ShellStream stream, ILogger logger)
+ {
+ _stream = stream;
+ _logger = logger;
+ _stream.DataReceived += OnStreamData;
+ }
+
+ private void OnStreamData(object? sender, ShellDataEventArgs e) =>
+ DataReceived?.Invoke(this, e.Data);
+
+ public Task SendAsync(string data, CancellationToken ct)
+ {
+ ct.ThrowIfCancellationRequested();
+ _stream.Write(data);
+ _stream.Flush();
+ return Task.CompletedTask;
+ }
+
+ // SSH.NET's ShellStream exposes no mid-session window-change; the PTY keeps
+ // the size it was created with. Logged so the terminal UI knows the resize
+ // was a no-op rather than silently dropped (basic-VT scope, phase-13 §13.3).
+ public void Resize(uint columns, uint rows) =>
+ _logger.LogDebug("SSH shell resize to {Cols}x{Rows} ignored (PTY fixed at open)", columns, rows);
+
+ public ValueTask DisposeAsync()
+ {
+ _stream.DataReceived -= OnStreamData;
+ _stream.Dispose();
+ return ValueTask.CompletedTask;
+ }
+}
diff --git a/tests/OpenIPC.Viewer.Core.Tests/Ssh/SshParsingTests.cs b/tests/OpenIPC.Viewer.Core.Tests/Ssh/SshParsingTests.cs
new file mode 100644
index 0000000..20432be
--- /dev/null
+++ b/tests/OpenIPC.Viewer.Core.Tests/Ssh/SshParsingTests.cs
@@ -0,0 +1,106 @@
+using System.Linq;
+using OpenIPC.Viewer.Core.Ssh;
+using Xunit;
+
+namespace OpenIPC.Viewer.Core.Tests.Ssh;
+
+// Pure Phase 13 helpers — busybox `ls -la` parsing (13.4) and the root-delete
+// guard (13.4, after dashboard v0.1.2).
+public sealed class SshParsingTests
+{
+ private const string BusyboxListing =
+ "total 12\n" +
+ "drwxr-xr-x 3 root root 4096 Jan 1 00:00 .\n" +
+ "drwxr-xr-x 5 root root 4096 Jan 1 00:00 ..\n" +
+ "drwxr-xr-x 2 root root 4096 Feb 14 2024 recordings\n" +
+ "-rw-r--r-- 1 root root 220 Mar 3 2023 majestic.yaml\n" +
+ "lrwxrwxrwx 1 root root 11 Jan 1 00:00 sh -> busybox\n";
+
+ [Fact]
+ public void Parse_SkipsTotalAndDotEntries()
+ {
+ var entries = LsParser.Parse(BusyboxListing);
+ Assert.DoesNotContain(entries, e => e.Name is "." or "..");
+ Assert.Equal(3, entries.Count);
+ }
+
+ [Fact]
+ public void Parse_ReadsKindSizeAndName()
+ {
+ var entries = LsParser.Parse(BusyboxListing);
+
+ var dir = entries.Single(e => e.Name == "recordings");
+ Assert.Equal(RemoteEntryKind.Directory, dir.Kind);
+ Assert.True(dir.IsDirectory);
+
+ var file = entries.Single(e => e.Name == "majestic.yaml");
+ Assert.Equal(RemoteEntryKind.File, file.Kind);
+ Assert.Equal(220, file.Size);
+ }
+
+ [Fact]
+ public void Parse_StripsSymlinkTarget()
+ {
+ var link = LsParser.Parse(BusyboxListing).Single(e => e.Kind == RemoteEntryKind.SymbolicLink);
+ Assert.Equal("sh", link.Name);
+ }
+
+ [Fact]
+ public void Parse_YearFormGetsDate_TimeFormStaysNull()
+ {
+ var entries = LsParser.Parse(BusyboxListing);
+ // "Feb 14 2024" carries a year; "Jan 1 00:00" omits it.
+ Assert.NotNull(entries.Single(e => e.Name == "recordings").Modified);
+ }
+
+ [Fact]
+ public void Parse_ToleratesGarbageLines()
+ {
+ var entries = LsParser.Parse("not a real ls line\n-rw-r--r-- 1 a b 5 Jan 2 2020 ok.txt\n");
+ Assert.Single(entries);
+ Assert.Equal("ok.txt", entries[0].Name);
+ }
+
+ [Theory]
+ [InlineData(null, true)]
+ [InlineData("", true)]
+ [InlineData(" ", true)]
+ [InlineData("/", true)]
+ [InlineData("/etc", true)]
+ [InlineData("/etc/", true)]
+ [InlineData("/bin", true)]
+ [InlineData("/etc/majestic.yaml", false)]
+ [InlineData("/tmp/clip.mp4", false)]
+ [InlineData("/mnt/sd/recordings/x.mp4", false)]
+ public void RemotePathGuard_ProtectsRootLevel(string? path, bool expected)
+ {
+ Assert.Equal(expected, RemotePathGuard.IsProtected(path));
+ }
+
+ [Theory]
+ [InlineData("/", "etc", "/etc")]
+ [InlineData("/etc", "majestic.yaml", "/etc/majestic.yaml")]
+ [InlineData("/etc/", "/sub", "/etc/sub")]
+ public void RemotePath_Combine(string dir, string name, string expected)
+ {
+ Assert.Equal(expected, RemotePath.Combine(dir, name));
+ }
+
+ [Theory]
+ [InlineData("/etc/majestic.yaml", "/etc")]
+ [InlineData("/etc", "/")]
+ [InlineData("/", "/")]
+ public void RemotePath_Parent(string path, string expected)
+ {
+ Assert.Equal(expected, RemotePath.Parent(path));
+ }
+
+ [Theory]
+ [InlineData("/etc/majestic.yaml", "majestic.yaml")]
+ [InlineData("/etc", "etc")]
+ [InlineData("/", "/")]
+ public void RemotePath_Name(string path, string expected)
+ {
+ Assert.Equal(expected, RemotePath.Name(path));
+ }
+}
diff --git a/tests/OpenIPC.Viewer.Core.Tests/Ssh/TerminalEmulatorTests.cs b/tests/OpenIPC.Viewer.Core.Tests/Ssh/TerminalEmulatorTests.cs
new file mode 100644
index 0000000..4936ec7
--- /dev/null
+++ b/tests/OpenIPC.Viewer.Core.Tests/Ssh/TerminalEmulatorTests.cs
@@ -0,0 +1,91 @@
+using System.Linq;
+using System.Text;
+using OpenIPC.Viewer.Core.Ssh.Terminal;
+using Xunit;
+
+namespace OpenIPC.Viewer.Core.Tests.Ssh;
+
+// Basic-VT emulator (Phase 13.3): printable text, control codes, CSI moves,
+// SGR colors, scroll, and UTF-8 reassembly across feed boundaries.
+public sealed class TerminalEmulatorTests
+{
+ private static string RowText(TerminalEmulator t, int row) =>
+ new string(t.GetRow(row).Select(c => c.Char).ToArray()).TrimEnd();
+
+ [Fact]
+ public void PrintableText_LandsAtCursorAndAdvances()
+ {
+ var t = new TerminalEmulator(20, 5);
+ t.Feed("hello");
+ Assert.Equal("hello", RowText(t, 0));
+ Assert.Equal(5, t.CursorColumn);
+ Assert.Equal(0, t.CursorRow);
+ }
+
+ [Fact]
+ public void CrLf_WrapsToNextRow()
+ {
+ var t = new TerminalEmulator(20, 5);
+ t.Feed("a\r\nb");
+ Assert.Equal("a", RowText(t, 0));
+ Assert.Equal("b", RowText(t, 1));
+ Assert.Equal(1, t.CursorRow);
+ }
+
+ [Fact]
+ public void Sgr_SetsForegroundColorIndex()
+ {
+ var t = new TerminalEmulator(20, 5);
+ t.Feed("\x1b[31mX");
+ Assert.Equal(1, t.GetRow(0)[0].Foreground); // 31 -> index 1 (red)
+ // Reset returns to default.
+ t.Feed("\x1b[0mY");
+ Assert.Equal(TerminalPalette.DefaultForeground, t.GetRow(0)[1].Foreground);
+ }
+
+ [Fact]
+ public void CursorPosition_PlacesCharAtRowCol()
+ {
+ var t = new TerminalEmulator(20, 5);
+ t.Feed("\x1b[2;3HZ"); // 1-based row 2, col 3
+ Assert.Equal('Z', t.GetRow(1)[2].Char);
+ }
+
+ [Fact]
+ public void EraseDisplay_ClearsEverything()
+ {
+ var t = new TerminalEmulator(20, 5);
+ t.Feed("noise\r\nmore");
+ t.Feed("\x1b[2J");
+ Assert.Equal("", RowText(t, 0));
+ Assert.Equal("", RowText(t, 1));
+ }
+
+ [Fact]
+ public void Overflow_ScrollsContentUp()
+ {
+ var t = new TerminalEmulator(10, 3);
+ t.Feed("1\r\n2\r\n3\r\n4"); // 4 lines into a 3-row screen
+ Assert.Equal("2", RowText(t, 0));
+ Assert.Equal("3", RowText(t, 1));
+ Assert.Equal("4", RowText(t, 2));
+ }
+
+ [Fact]
+ public void Utf8_ReassemblesAcrossFeedBoundaries()
+ {
+ var t = new TerminalEmulator(20, 5);
+ var bytes = Encoding.UTF8.GetBytes("é"); // 2 bytes (0xC3 0xA9)
+ t.Feed(bytes[..1]); // partial — must not corrupt
+ t.Feed(bytes[1..]);
+ Assert.Equal('é', t.GetRow(0)[0].Char);
+ }
+
+ [Fact]
+ public void PrivateMode_IsConsumedNotPrinted()
+ {
+ var t = new TerminalEmulator(20, 5);
+ t.Feed("\x1b[?25lhi"); // hide-cursor private seq + "hi"
+ Assert.Equal("hi", RowText(t, 0));
+ }
+}
diff --git a/tests/OpenIPC.Viewer.Infrastructure.Tests/OpenIPC.Viewer.Infrastructure.Tests.csproj b/tests/OpenIPC.Viewer.Infrastructure.Tests/OpenIPC.Viewer.Infrastructure.Tests.csproj
new file mode 100644
index 0000000..1afedaa
--- /dev/null
+++ b/tests/OpenIPC.Viewer.Infrastructure.Tests/OpenIPC.Viewer.Infrastructure.Tests.csproj
@@ -0,0 +1,29 @@
+
+
+
+ net9.0
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/OpenIPC.Viewer.Infrastructure.Tests/SshSessionIntegrationTests.cs b/tests/OpenIPC.Viewer.Infrastructure.Tests/SshSessionIntegrationTests.cs
new file mode 100644
index 0000000..debbb0c
--- /dev/null
+++ b/tests/OpenIPC.Viewer.Infrastructure.Tests/SshSessionIntegrationTests.cs
@@ -0,0 +1,101 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Microsoft.Extensions.Logging.Abstractions;
+using OpenIPC.Viewer.Core.Ssh;
+using OpenIPC.Viewer.Infrastructure.Ssh;
+
+namespace OpenIPC.Viewer.Infrastructure.Tests;
+
+// Round-trips the SSH suite against a real sshd container (phase-13 §13.6).
+// Skips when the container isn't up — Windows CI without Docker, etc.
+public sealed class SshSessionIntegrationTests
+{
+ private static readonly ISshSessionFactory Factory =
+ new SshNetSessionFactory(new InMemoryHostKeyStore(), new FakeUserSettings(), NullLoggerFactory.Instance);
+
+ private static async Task ConnectAsync()
+ {
+ var session = Factory.Create();
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
+ await session.ConnectAsync(SshdFixture.Endpoint, cts.Token);
+ return session;
+ }
+
+ [SkippableFact]
+ public async Task Exec_CapturesStdoutAndExitCode()
+ {
+ Skip.IfNot(SshdFixture.IsReachable(), "sshd container not running — start tools/sshd/docker-compose.yml.");
+
+ await using var session = await ConnectAsync();
+ var result = await session.ExecAsync("echo openipc-it", CancellationToken.None);
+
+ Assert.True(result.Success);
+ Assert.Contains("openipc-it", result.StandardOutput);
+ }
+
+ [SkippableFact]
+ public async Task Upload_Download_RoundTrips()
+ {
+ Skip.IfNot(SshdFixture.IsReachable(), "sshd container not running.");
+
+ var name = $"openipc-it-{Guid.NewGuid():N}.txt";
+ var remote = $"/tmp/{name}";
+ var content = "hello from openipc viewer\nsecond line\n";
+
+ var localUp = Path.GetTempFileName();
+ var localDown = Path.GetTempFileName();
+ await File.WriteAllTextAsync(localUp, content);
+
+ try
+ {
+ await using var session = await ConnectAsync();
+ await session.UploadAsync(localUp, remote, progress: null, CancellationToken.None);
+ await session.DownloadAsync(remote, localDown, progress: null, CancellationToken.None);
+
+ Assert.Equal(content, await File.ReadAllTextAsync(localDown));
+
+ // Cleanup the remote artifact.
+ await session.DeleteAsync(remote, CancellationToken.None);
+ }
+ finally
+ {
+ File.Delete(localUp);
+ File.Delete(localDown);
+ }
+ }
+
+ [SkippableFact]
+ public async Task List_ReflectsCreateAndDelete()
+ {
+ Skip.IfNot(SshdFixture.IsReachable(), "sshd container not running.");
+
+ var dir = $"/tmp/openipc-itdir-{Guid.NewGuid():N}";
+ await using var session = await ConnectAsync();
+
+ await session.CreateDirectoryAsync(dir, CancellationToken.None);
+ var afterCreate = await CollectNames(session, "/tmp");
+ Assert.Contains(RemotePath.Name(dir), afterCreate);
+
+ await session.DeleteAsync(dir, CancellationToken.None);
+ var afterDelete = await CollectNames(session, "/tmp");
+ Assert.DoesNotContain(RemotePath.Name(dir), afterDelete);
+ }
+
+ [Fact]
+ public async Task Delete_RejectsRootLevel_WithoutNetwork()
+ {
+ // Guard is enforced before any I/O, so this needs no container.
+ await using var session = Factory.Create();
+ await Assert.ThrowsAsync(
+ () => session.DeleteAsync("/etc", CancellationToken.None));
+ }
+
+ private static async Task> CollectNames(ISshSession session, string path)
+ {
+ var names = new List();
+ await foreach (var entry in session.ListAsync(path, CancellationToken.None))
+ names.Add(entry.Name);
+ return names;
+ }
+}
diff --git a/tests/OpenIPC.Viewer.Infrastructure.Tests/SshdFixture.cs b/tests/OpenIPC.Viewer.Infrastructure.Tests/SshdFixture.cs
new file mode 100644
index 0000000..384dc1d
--- /dev/null
+++ b/tests/OpenIPC.Viewer.Infrastructure.Tests/SshdFixture.cs
@@ -0,0 +1,35 @@
+using System.Net.Sockets;
+using OpenIPC.Viewer.Core.Ssh;
+
+namespace OpenIPC.Viewer.Infrastructure.Tests;
+
+// Probes whether the test sshd container is reachable. SSH integration tests
+// skip themselves when it isn't (e.g. Windows CI without Docker).
+//
+// Usage:
+// docker compose -f tools/sshd/docker-compose.yml up -d
+// dotnet test tests/OpenIPC.Viewer.Infrastructure.Tests
+public static class SshdFixture
+{
+ public const string Host = "localhost";
+ public const int Port = 2222;
+ public const string Username = "tester";
+ public const string Password = "testpass";
+
+ public static SshEndpoint Endpoint =>
+ new(Host, Port, Username, new SshAuth.Password(Password));
+
+ public static bool IsReachable(int timeoutMs = 500)
+ {
+ try
+ {
+ using var client = new TcpClient();
+ var task = client.ConnectAsync(Host, Port);
+ return task.Wait(timeoutMs) && client.Connected;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+}
diff --git a/tests/OpenIPC.Viewer.Infrastructure.Tests/TestDoubles.cs b/tests/OpenIPC.Viewer.Infrastructure.Tests/TestDoubles.cs
new file mode 100644
index 0000000..c0c6fb9
--- /dev/null
+++ b/tests/OpenIPC.Viewer.Infrastructure.Tests/TestDoubles.cs
@@ -0,0 +1,35 @@
+using System.Collections.Concurrent;
+using OpenIPC.Viewer.Core.Settings;
+using OpenIPC.Viewer.Core.Ssh;
+
+namespace OpenIPC.Viewer.Infrastructure.Tests;
+
+internal sealed class InMemoryHostKeyStore : ISshHostKeyStore
+{
+ private readonly ConcurrentDictionary _keys = new();
+
+ public Task GetAsync(string host, int port, CancellationToken ct) =>
+ Task.FromResult(_keys.TryGetValue($"{host}:{port}", out var v) ? v : null);
+
+ public Task SetAsync(string host, int port, string fingerprint, CancellationToken ct)
+ {
+ _keys[$"{host}:{port}"] = fingerprint;
+ return Task.CompletedTask;
+ }
+
+ public Task ClearAsync(CancellationToken ct)
+ {
+ _keys.Clear();
+ return Task.CompletedTask;
+ }
+}
+
+internal sealed class FakeUserSettings : IUserSettingsAccessor
+{
+ public string? RecordingsDirectoryOverride => null;
+ public int MaxConcurrentGridSessions => 9;
+ public string? PreferredNetworkInterface => null;
+ public bool SshStrictHostKey => true;
+ public int SshDefaultPort => 22;
+ public string MajesticConfigPath => "/etc/majestic.yaml";
+}
diff --git a/tools/sshd/docker-compose.yml b/tools/sshd/docker-compose.yml
new file mode 100644
index 0000000..ae4cd8f
--- /dev/null
+++ b/tools/sshd/docker-compose.yml
@@ -0,0 +1,19 @@
+# Test sshd for the SSH device-suite integration tests (phase-13 §13.6).
+# docker compose -f tools/sshd/docker-compose.yml up -d
+# dotnet test tests/OpenIPC.Viewer.Infrastructure.Tests
+# docker compose -f tools/sshd/docker-compose.yml down
+#
+# Credentials/port are mirrored in SshdFixture. OpenSSH here ships scp + the
+# usual coreutils, enough to exercise exec/list/scp/mkdir/delete round-trips.
+services:
+ sshd:
+ image: lscr.io/linuxserver/openssh-server:latest
+ environment:
+ - PUID=1000
+ - PGID=1000
+ - PASSWORD_ACCESS=true
+ - USER_NAME=tester
+ - USER_PASSWORD=testpass
+ - SUDO_ACCESS=false
+ ports:
+ - "2222:2222"