From a8393546adf6fe1689b5f08a106d2a38ae94f49b Mon Sep 17 00:00:00 2001 From: keyldev Date: Fri, 19 Jun 2026 16:14:15 +0300 Subject: [PATCH 01/10] feat(ssh): phase 13 SSH foundation + open-in-browser - ISshSession/ISshShell/ISshSessionFactory + SshEndpoint in Core (no deps) - SSH.NET 2025.1.0 impl: exec/list/scp/shell, host-key TOFU via secrets store - busybox ls -la parser + root-delete guard (+unit tests) - 13.2 "Open web": library button -> LaunchUriAsync, Camera.WebInterfaceUrl --- Directory.Packages.props | 4 + .../Services/DialogService.cs | 26 +++ .../Services/IDialogService.cs | 4 + src/OpenIPC.Viewer.App/Services/Localizer.cs | 2 + .../ViewModels/CameraLibraryPageViewModel.cs | 11 + .../Views/Pages/CameraLibraryPage.axaml | 9 + .../SharedComposition.cs | 4 + src/OpenIPC.Viewer.Core/Entities/Camera.cs | 10 +- src/OpenIPC.Viewer.Core/Ssh/CommandResult.cs | 7 + src/OpenIPC.Viewer.Core/Ssh/ISshSession.cs | 46 ++++ src/OpenIPC.Viewer.Core/Ssh/ISshShell.cs | 27 +++ src/OpenIPC.Viewer.Core/Ssh/LsParser.cs | 103 +++++++++ src/OpenIPC.Viewer.Core/Ssh/RemoteEntry.cs | 22 ++ .../Ssh/RemotePathGuard.cs | 35 ++++ src/OpenIPC.Viewer.Core/Ssh/SshEndpoint.cs | 20 ++ .../OpenIPC.Viewer.Infrastructure.csproj | 1 + .../Ssh/SshNetSession.cs | 198 ++++++++++++++++++ .../Ssh/SshNetSessionFactory.cs | 21 ++ .../Ssh/SshNetShell.cs | 49 +++++ .../Ssh/SshParsingTests.cs | 79 +++++++ 20 files changed, 677 insertions(+), 1 deletion(-) create mode 100644 src/OpenIPC.Viewer.Core/Ssh/CommandResult.cs create mode 100644 src/OpenIPC.Viewer.Core/Ssh/ISshSession.cs create mode 100644 src/OpenIPC.Viewer.Core/Ssh/ISshShell.cs create mode 100644 src/OpenIPC.Viewer.Core/Ssh/LsParser.cs create mode 100644 src/OpenIPC.Viewer.Core/Ssh/RemoteEntry.cs create mode 100644 src/OpenIPC.Viewer.Core/Ssh/RemotePathGuard.cs create mode 100644 src/OpenIPC.Viewer.Core/Ssh/SshEndpoint.cs create mode 100644 src/OpenIPC.Viewer.Infrastructure/Ssh/SshNetSession.cs create mode 100644 src/OpenIPC.Viewer.Infrastructure/Ssh/SshNetSessionFactory.cs create mode 100644 src/OpenIPC.Viewer.Infrastructure/Ssh/SshNetShell.cs create mode 100644 tests/OpenIPC.Viewer.Core.Tests/Ssh/SshParsingTests.cs 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/src/OpenIPC.Viewer.App/Services/DialogService.cs b/src/OpenIPC.Viewer.App/Services/DialogService.cs index 8bc7844..98ed3fd 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; @@ -187,6 +188,31 @@ public async Task ShowManageGroupsAsync(ManageGroupsViewModel viewModel) return dlg.ShowDialog(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/IDialogService.cs b/src/OpenIPC.Viewer.App/Services/IDialogService.cs index b1a5c60..04efd73 100644 --- a/src/OpenIPC.Viewer.App/Services/IDialogService.cs +++ b/src/OpenIPC.Viewer.App/Services/IDialogService.cs @@ -25,4 +25,8 @@ public interface IDialogService // 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..1852a35 100644 --- a/src/OpenIPC.Viewer.App/Services/Localizer.cs +++ b/src/OpenIPC.Viewer.App/Services/Localizer.cs @@ -87,6 +87,7 @@ private static LangCode DetectSystem() ["Library.Title"] = "Cameras", ["Library.EmptyTitle"] = "No cameras yet", + ["Library.RowOpenWeb"] = "Open web", ["Library.RowEdit"] = "Edit", ["Library.RowDelete"] = "Delete", ["Library.RowShowInGrid"] = "Show in grid", @@ -297,6 +298,7 @@ private static LangCode DetectSystem() ["Library.Title"] = "Камеры", ["Library.EmptyTitle"] = "Камер пока нет", + ["Library.RowOpenWeb"] = "Веб-интерфейс", ["Library.RowEdit"] = "Изменить", ["Library.RowDelete"] = "Удалить", ["Library.RowShowInGrid"] = "В гриде", diff --git a/src/OpenIPC.Viewer.App/ViewModels/CameraLibraryPageViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/CameraLibraryPageViewModel.cs index f142ada..0a4986a 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/CameraLibraryPageViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/CameraLibraryPageViewModel.cs @@ -371,6 +371,17 @@ 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 EditCameraAsync(CameraRowViewModel? row) { diff --git a/src/OpenIPC.Viewer.App/Views/Pages/CameraLibraryPage.axaml b/src/OpenIPC.Viewer.App/Views/Pages/CameraLibraryPage.axaml index 9121a6c..6e53829 100644 --- a/src/OpenIPC.Viewer.App/Views/Pages/CameraLibraryPage.axaml +++ b/src/OpenIPC.Viewer.App/Views/Pages/CameraLibraryPage.axaml @@ -163,6 +163,15 @@ Foreground="{StaticResource TextSecondaryBrush}" FontSize="{StaticResource FontSizeSm}" VerticalAlignment="Center" /> +