Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/OpenIPC.Viewer.Android/MainActivity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Android.App;
using Android.Content.PM;
using Android.OS;
using Android.Views;
using Avalonia.Android;

namespace OpenIPC.Viewer.Android;
Expand All @@ -11,6 +12,13 @@ namespace OpenIPC.Viewer.Android;
Theme = "@style/MyTheme.NoActionBar",
Icon = "@mipmap/icon",
MainLauncher = true,
// AdjustResize shrinks the TopLevel client area when the soft keyboard opens
// (instead of the keyboard overlaying content). The overlay dialog presenter
// caps its bottom-sheet to ClientSize and wraps content in a ScrollViewer, so
// this lets a focused field (e.g. ONVIF login/password at the bottom of the
// add sheet) scroll into view above the keyboard. StateHidden keeps the
// keyboard down until the user taps a field.
WindowSoftInputMode = SoftInput.AdjustResize | SoftInput.StateHidden,
ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize
| ConfigChanges.UiMode | ConfigChanges.Density)]
public sealed class MainActivity : AvaloniaMainActivity
Expand Down
18 changes: 18 additions & 0 deletions src/OpenIPC.Viewer.Android/OpenIPC.Viewer.Android.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,24 @@
<NoWarn>$(NoWarn);NU1903;XA0141</NoWarn>
</PropertyGroup>

<!--
ONVIF SOAP on Android. The ONVIF client (Onvif.Core over WCF /
System.ServiceModel) serializes the generated WSDL contract types
(Onvif.Core.Client.Common.DeviceEntity et al.) with XmlSerializer, which
reflects over those types at runtime. Release builds default to
AndroidLinkMode=SdkOnly, which trims the framework serialization assemblies
(System.Private.Xml / System.Private.ServiceModel) and breaks that
reflection -> "XmlType reflection error" on the first device probe (the
discovery add-flow). Disable the linker in Release until we ship a surgical
ILLink descriptor that roots only the serialization + ONVIF metadata. Debug
already links None, so this only affects Release APK size / startup. The
add-flow also degrades to a guessed RTSP URL when the probe still fails, so
a device stays addable regardless of this switch.
-->
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<AndroidLinkMode>None</AndroidLinkMode>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Avalonia" />
<PackageReference Include="Avalonia.Android" />
Expand Down
4 changes: 4 additions & 0 deletions src/OpenIPC.Viewer.App/App.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@

<Application.Styles>
<FluentTheme />
<!-- AvaloniaEdit control theme. Without this the TextEditor in the Majestic
raw-config editor (RawConfigEditorContent) has no template and renders
as a blank dialog. -->
<StyleInclude Source="avares://AvaloniaEdit/Themes/Fluent/AvaloniaEdit.xaml" />
<StyleInclude Source="avares://OpenIPC.Viewer.App/Styles/Sidebar.axaml" />
<StyleInclude Source="avares://OpenIPC.Viewer.App/Styles/BottomNav.axaml" />
<StyleInclude Source="avares://OpenIPC.Viewer.App/Styles/Buttons.axaml" />
Expand Down
38 changes: 38 additions & 0 deletions src/OpenIPC.Viewer.App/Services/DialogSshHostKeyPrompt.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Threading;
using OpenIPC.Viewer.Core.Ssh;

namespace OpenIPC.Viewer.App.Services;

/// <summary>
/// Surfaces the SSH host-key-changed decision through the app's confirm dialog,
/// which the DialogService renders as a modal window on desktop and a bottom
/// sheet on mobile — so the same trust flow works on every platform. SSH.NET
/// raises the key event on a background thread, so we marshal to the UI thread.
/// </summary>
public sealed class DialogSshHostKeyPrompt : ISshHostKeyPrompt
{
private readonly IDialogService _dialogs;

public DialogSshHostKeyPrompt(IDialogService dialogs) => _dialogs = dialogs;

public Task<bool> ConfirmChangedKeyAsync(
string host, int port, string? knownFingerprint, string presentedFingerprint, CancellationToken ct)
{
var title = Localizer.Instance["Ssh.HostKeyChanged.Title"];
var message = string.Format(
CultureInfo.CurrentCulture,
Localizer.Instance["Ssh.HostKeyChanged.Message"],
$"{host}:{port}",
presentedFingerprint,
string.IsNullOrEmpty(knownFingerprint) ? "—" : knownFingerprint);

return Dispatcher.UIThread.InvokeAsync(() => _dialogs.ConfirmAsync(
title,
message,
Localizer.Instance["Ssh.HostKeyChanged.Trust"],
Localizer.Instance["Common.Cancel"]));
}
}
12 changes: 12 additions & 0 deletions src/OpenIPC.Viewer.App/Services/Localizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,13 @@ private static LangCode DetectSystem()
["Terminal.Connecting"] = "Connecting…",
["Terminal.NoCreds"] = "No SSH credentials set for this camera.",
["Terminal.FailedFormat"] = "SSH error: {0}",
["Ssh.HostKeyChanged.Title"] = "SSH host key changed",
["Ssh.HostKeyChanged.Message"] = "The SSH key presented by {0} does not match the one pinned earlier.\n\nNew: {1}\nPinned: {2}\n\nThis is expected if the camera was reflashed or replaced — but could also mean someone is intercepting the connection. Trust the new key and continue?",
["Ssh.HostKeyChanged.Trust"] = "Trust new key",
["FileManager.Title"] = "Files",
["FileManager.RiskTitle"] = "Browse camera files?",
["FileManager.RiskMessage"] = "This opens the camera's live filesystem over SSH. Deleting, moving or overwriting the wrong file can break the camera and require a reflash. Only continue if you know what you're doing.",
["FileManager.RiskConfirm"] = "Open anyway",
["FileManager.Connecting"] = "Connecting…",
["FileManager.NoCreds"] = "No SSH credentials set for this camera.",
["FileManager.FailedFormat"] = "Error: {0}",
Expand Down Expand Up @@ -807,7 +813,13 @@ private static LangCode DetectSystem()
["Terminal.Connecting"] = "Подключение…",
["Terminal.NoCreds"] = "Для камеры не заданы SSH-учётные данные.",
["Terminal.FailedFormat"] = "Ошибка SSH: {0}",
["Ssh.HostKeyChanged.Title"] = "SSH host-key изменился",
["Ssh.HostKeyChanged.Message"] = "SSH-ключ, который предъявил {0}, не совпадает с ранее сохранённым.\n\nНовый: {1}\nСохранённый: {2}\n\nЭто нормально, если камеру перепрошили или заменили, — но может означать и перехват соединения. Доверять новому ключу и продолжить?",
["Ssh.HostKeyChanged.Trust"] = "Доверять ключу",
["FileManager.Title"] = "Файлы",
["FileManager.RiskTitle"] = "Открыть файлы камеры?",
["FileManager.RiskMessage"] = "Откроется живая файловая система камеры по SSH. Удаление, перемещение или перезапись не того файла может сломать камеру вплоть до перепрошивки. Продолжайте, только если понимаете, что делаете.",
["FileManager.RiskConfirm"] = "Всё равно открыть",
["FileManager.Connecting"] = "Подключение…",
["FileManager.NoCreds"] = "Для камеры не заданы SSH-учётные данные.",
["FileManager.FailedFormat"] = "Ошибка: {0}",
Expand Down
10 changes: 10 additions & 0 deletions src/OpenIPC.Viewer.App/ViewModels/CameraLibraryPageViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,16 @@ private async Task OpenFileManagerAsync(CameraRowViewModel? row)
{
if (row is null)
return;
// The file manager browses/edits the camera's live root filesystem over
// SSH — deleting or overwriting the wrong file can brick the device. Gate
// it behind an explicit warning the user must accept.
var ok = await _dialogs.ConfirmAsync(
Localizer.Instance["FileManager.RiskTitle"],
Localizer.Instance["FileManager.RiskMessage"],
Localizer.Instance["FileManager.RiskConfirm"],
Localizer.Instance["Common.Cancel"]).ConfigureAwait(true);
if (!ok)
return;
var vm = _fileManagerFactory.Create(row.Camera);
await _dialogs.OpenFileManagerAsync(vm).ConfigureAwait(true);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,12 +164,22 @@ private void Upsert(DiscoveredDevice device)
StatusText = string.Format(Localizer.Instance["Discovery.Status.ProbeOkFormat"], probeResult.Manufacturer ?? "?", probeResult.Model ?? "").TrimEnd();
return new DiscoveryDialogResult(row.Device, probeResult.RtspMainUri, probeResult, creds);
}
catch (Exception ex)
catch (OperationCanceledException)
{
_logger.LogWarning(ex, "ONVIF probe failed for {Host}", row.HostPort);
StatusText = string.Format(Localizer.Instance["Discovery.Status.ProbeFailedFormat"], ex.Message);
StatusText = Localizer.Instance["Discovery.Status.Cancelled"];
return null;
}
catch (Exception ex)
{
// The ONVIF SOAP probe can't run on every platform — the WCF
// XmlSerializer stack fails to build on Android (XmlType reflection
// error over the generated contract types). Rather than dead-end the
// user, degrade to the same guessed-RTSP add we use for non-ONVIF
// finds: the camera is added and refined/tested in the editor.
_logger.LogWarning(ex, "ONVIF probe failed for {Host}; falling back to guessed RTSP", row.HostPort);
StatusText = Localizer.Instance["Discovery.Status.ManualAdd"];
return new DiscoveryDialogResult(row.Device, GuessRtspUri(row.Device), null, creds);
}
finally
{
AddInProgress = false;
Expand Down
27 changes: 26 additions & 1 deletion src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,13 @@ public sealed partial class SingleCameraPageViewModel : ViewModelBase, IAsyncDis
private bool _majesticReady;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowEnableAudioHint))]
[NotifyPropertyChangedFor(nameof(MajesticRawJsonPretty))]
private MajesticConfig? _majesticConfig;

// Camera returns config.json minified (one line). Pretty-print it for the
// read-only "View raw" panel and the raw editor so it's actually readable.
public string? MajesticRawJsonPretty =>
MajesticConfig is { RawJson: var raw } ? FormatJson(raw) : null;
[ObservableProperty] private MajesticInfo? _majesticInfo;
[ObservableProperty] private NightMode _currentNightMode = NightMode.Unknown;
[ObservableProperty] private string? _majesticError;
Expand Down Expand Up @@ -999,11 +1005,30 @@ private async Task RetryAsync()
// so toggling the checkbox while on this page flips the button live.
public bool IsRawConfigEditorEnabled => _userSettings.Current.RawConfigEditorEnabled;

private static readonly System.Text.Json.JsonSerializerOptions PrettyJsonOptions =
new() { WriteIndented = true };

// Indent config.json for display/editing. Falls back to the original text if
// it isn't valid JSON so we never hide the raw content behind a parse error.
private static string FormatJson(string json)
{
if (string.IsNullOrWhiteSpace(json)) return json;
try
{
using var doc = System.Text.Json.JsonDocument.Parse(json);
return System.Text.Json.JsonSerializer.Serialize(doc.RootElement, PrettyJsonOptions);
}
catch (System.Text.Json.JsonException)
{
return json;
}
}

[RelayCommand]
private async Task EditRawConfigAsync()
{
if (!IsMajestic || MajesticConfig is null) return;
var initial = MajesticConfig.RawJson;
var initial = FormatJson(MajesticConfig.RawJson);
var edited = await _dialogs.ShowRawConfigEditorAsync(initial).ConfigureAwait(true);
if (edited is null) return;
if (edited == initial) return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
FontSize="{StaticResource FontSizeBase}"
Foreground="{StaticResource TextPrimaryBrush}">

<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="24" MinWidth="460">
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="24">

<!-- Header -->
<StackPanel Grid.Row="0" Spacing="2" Margin="0,0,0,12">
Expand Down
24 changes: 21 additions & 3 deletions src/OpenIPC.Viewer.App/Views/Dialogs/SshTerminalContent.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public sealed partial class SshTerminalContent : UserControl
private readonly TaskCompletionSource<bool> _tcs = new();
private TerminalView? _term;
private bool _started;
private bool _connected;

public Task<bool> Completion => _tcs.Task;

Expand Down Expand Up @@ -42,12 +43,29 @@ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
_term.Emulator = vm.Emulator;
_term.TerminalFontSize = vm.FontSize;
_term.Input += (_, text) => _ = vm.SendAsync(text);
_term.GridResized += (_, g) => _ = vm.ResizeAsync(g.Columns, g.Rows);

_ = vm.ConnectAsync();
// Defer the connect until the terminal has been measured: SSH.NET's
// ShellStream can't be resized mid-session, so the PTY keeps whatever
// size we open it with. Opening at the default 80x24 before layout means
// a narrow (phone) view gets 80-column output that overflows off-screen.
// Wait for the first real grid size, then open the shell to match.
_term.GridResized += OnGridResized;
_term.Focus();
}

private void OnGridResized(object? sender, (int Columns, int Rows) grid)
{
if (DataContext is not SshTerminalViewModel vm)
return;
_ = vm.ResizeAsync(grid.Columns, grid.Rows);
// The pre-layout pass reports a 1x1 grid (Bounds still 0) — wait for a
// real measurement before opening the shell at that size.
if (!_connected && grid.Columns > 1 && grid.Rows > 1)
{
_connected = true;
_ = vm.ConnectAsync();
}
}

protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnDetachedFromVisualTree(e);
Expand Down
14 changes: 12 additions & 2 deletions src/OpenIPC.Viewer.App/Views/Pages/SingleCameraPage.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,16 @@
x:Class="OpenIPC.Viewer.App.Views.Pages.SingleCameraPage"
x:DataType="vm:SingleCameraPageViewModel">

<Grid RowDefinitions="Auto,*,Auto,Auto">
<!-- PageScroll: Disabled by default so on desktop/wide the Grid fills the
viewport and the video row (*) stretches — identical to a plain Grid.
On phone-width viewports the code-behind switches the video row to a
fixed 16:9 height and enables vertical scroll so the Majestic config
panel below the video is reachable (it otherwise runs off-screen). -->
<ScrollViewer Name="PageScroll"
VerticalScrollBarVisibility="Disabled"
HorizontalScrollBarVisibility="Disabled">
<Grid Name="RootGrid" RowDefinitions="Auto,*,Auto,Auto">


<!-- Header. Hidden in landscape fullscreen — only the video row stays. -->
<Grid Grid.Row="0" ColumnDefinitions="Auto,*,Auto" Margin="0,0,0,12"
Expand Down Expand Up @@ -361,7 +370,7 @@
CornerRadius="4"
MaxHeight="220">
<ScrollViewer>
<TextBox Text="{Binding MajesticConfig.RawJson, Mode=OneWay}"
<TextBox Text="{Binding MajesticRawJsonPretty, Mode=OneWay}"
IsReadOnly="True"
AcceptsReturn="True"
TextWrapping="NoWrap"
Expand Down Expand Up @@ -730,5 +739,6 @@
</WrapPanel>

</Grid>
</ScrollViewer>

</UserControl>
29 changes: 29 additions & 0 deletions src/OpenIPC.Viewer.App/Views/Pages/SingleCameraPage.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,17 @@ public sealed partial class SingleCameraPage : UserControl
private Point? _pressOrigin;
private DateTime _pressAt;

// Below this viewport width (phone) the page scrolls with a fixed 16:9 video
// preview; at/above it the video fills and the page doesn't scroll. Mirrors
// the app-wide 700px breakpoint.
private const double MobileBreakpoint = 700;

public SingleCameraPage()
{
InitializeComponent();
Loaded += OnLoaded;
Unloaded += OnUnloaded;
SizeChanged += OnPageSizeChanged;

// Holding defaults to touch-only; enabling mouse-hold lets us
// validate the PTZ-toggle gesture on desktop too.
Expand All @@ -46,6 +52,29 @@ public SingleCameraPage()
TalkButton.PointerExited += OnTalkReleased;
}

// Phone-width viewports can't fit the video plus the full Majestic config
// panel, and a Grid alone would just run the panel off the bottom. Switch the
// video row to a fixed 16:9 preview and let PageScroll scroll; on wider
// viewports keep the fill layout (star row, scrolling disabled).
private void OnPageSizeChanged(object? sender, SizeChangedEventArgs e)
{
var width = e.NewSize.Width;
if (width <= 0)
return;

var videoRow = RootGrid.RowDefinitions[1];
if (width < MobileBreakpoint)
{
videoRow.Height = new GridLength(System.Math.Round(width * 9.0 / 16.0));
PageScroll.VerticalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto;
}
else
{
videoRow.Height = new GridLength(1, GridUnitType.Star);
PageScroll.VerticalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Disabled;
}
}

private async void OnTalkPressed(object? sender, PointerPressedEventArgs e)
{
if (Vm is { } vm) await vm.BeginTalkAsync();
Expand Down
4 changes: 4 additions & 0 deletions src/OpenIPC.Viewer.Composition/SharedComposition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@ public static IServiceCollection AddSharedServices(this IServiceCollection servi
// SSH transport for majestic.yaml is the fallback when HTTP is off.
services.AddSingleton<OpenIPC.Viewer.Core.Ssh.ISshHostKeyStore,
OpenIPC.Viewer.Infrastructure.Ssh.JsonFileHostKeyStore>();
// Cross-platform "trust changed host key?" prompt so a re-pinned camera
// (reflash / swapped device) can be accepted from the UI instead of a
// dead-end error. Refuses by default when no UI is available.
services.AddSingleton<OpenIPC.Viewer.Core.Ssh.ISshHostKeyPrompt, DialogSshHostKeyPrompt>();
services.AddSingleton<ISshSessionFactory, OpenIPC.Viewer.Infrastructure.Ssh.SshNetSessionFactory>();
services.AddSingleton<IMajesticSshConfigClient, MajesticSshConfigClient>();
// Firmware-lite (reboot / time / logs) over the same SSH layer.
Expand Down
30 changes: 30 additions & 0 deletions src/OpenIPC.Viewer.Core/Ssh/ISshHostKeyPrompt.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using System.Threading;
using System.Threading.Tasks;

namespace OpenIPC.Viewer.Core.Ssh;

/// <summary>
/// Asks the user whether to trust a host key that differs from the pinned one
/// (strict TOFU). Implemented in the UI layer over the platform-independent
/// confirm dialog so the same flow covers desktop and mobile. When no prompt is
/// wired (headless), <see cref="NoopSshHostKeyPrompt"/> refuses — preserving the
/// strict default.
/// </summary>
public interface ISshHostKeyPrompt
{
/// <summary>
/// Returns true to trust <paramref name="presentedFingerprint"/> (the caller
/// then re-pins it and retries the connection), false to refuse. Safe to call
/// off the UI thread — implementations marshal internally.
/// </summary>
Task<bool> ConfirmChangedKeyAsync(
string host, int port, string? knownFingerprint, string presentedFingerprint, CancellationToken ct);
}

/// <summary>Refuses every changed key. Fallback when no UI prompt is available.</summary>
public sealed class NoopSshHostKeyPrompt : ISshHostKeyPrompt
{
public Task<bool> ConfirmChangedKeyAsync(
string host, int port, string? knownFingerprint, string presentedFingerprint, CancellationToken ct) =>
Task.FromResult(false);
}
Loading
Loading