From 6ea16d6fc86478daf9712a5d811c2fe7a3b401f4 Mon Sep 17 00:00:00 2001 From: keyldev Date: Thu, 2 Jul 2026 00:24:17 +0300 Subject: [PATCH 1/2] feat(mobile): SSH soft keyboard, risky-tools gate, animated splash on mobile - SSH terminal: invisible IME proxy + special-keys bar (Esc/Tab/Ctrl/arrows) so Android/iOS get a soft keyboard; desktop path unchanged - one shared "risky device tools" toggle now also gates the library Files button; per-open risk confirm dropped (the opt-in is the consent) - splash aperture extracted into a reusable control; single-view (mobile) shows it as a fading startup overlay; new "show splash" setting gates it on desktop and mobile --- src/OpenIPC.Viewer.App/App.axaml.cs | 111 ++++++++++--- .../Controls/SplashAperture.axaml | 83 ++++++++++ .../Controls/SplashAperture.axaml.cs | 8 + src/OpenIPC.Viewer.App/Services/Localizer.cs | 12 +- .../Services/UserSettings.cs | 10 +- .../ViewModels/CameraLibraryPageViewModel.cs | 20 +-- .../ViewModels/SettingsPageViewModel.cs | 4 + .../Views/Dialogs/SshTerminalContent.axaml | 64 +++++++- .../Views/Dialogs/SshTerminalContent.axaml.cs | 151 +++++++++++++++++- .../Views/MobileSplashView.axaml | 35 ++++ .../Views/MobileSplashView.axaml.cs | 8 + .../Views/Pages/CameraLibraryPage.axaml | 3 + .../Views/Pages/SettingsPage.axaml | 4 + .../Views/StartupWindow.axaml | 76 +-------- 14 files changed, 465 insertions(+), 124 deletions(-) create mode 100644 src/OpenIPC.Viewer.App/Controls/SplashAperture.axaml create mode 100644 src/OpenIPC.Viewer.App/Controls/SplashAperture.axaml.cs create mode 100644 src/OpenIPC.Viewer.App/Views/MobileSplashView.axaml create mode 100644 src/OpenIPC.Viewer.App/Views/MobileSplashView.axaml.cs diff --git a/src/OpenIPC.Viewer.App/App.axaml.cs b/src/OpenIPC.Viewer.App/App.axaml.cs index a0cd4f6..44ab1d0 100644 --- a/src/OpenIPC.Viewer.App/App.axaml.cs +++ b/src/OpenIPC.Viewer.App/App.axaml.cs @@ -1,8 +1,11 @@ using System; using Avalonia; +using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; +using Avalonia.Threading; using Microsoft.Extensions.DependencyInjection; +using OpenIPC.Viewer.App.Services; using OpenIPC.Viewer.App.ViewModels; using OpenIPC.Viewer.App.Views; @@ -16,41 +19,99 @@ public sealed class App : Application // AvaloniaMainActivity requires on Android. public static IServiceProvider? Services { get; set; } + // How long the mobile splash overlay stays before fading. The Android boot + // is fast, so without a minimum beat the splash would just flicker. + private static readonly TimeSpan MobileSplashMinDuration = TimeSpan.FromSeconds(1.6); + private static readonly TimeSpan MobileSplashFadeDuration = TimeSpan.FromMilliseconds(400); + public override void Initialize() => AvaloniaXamlLoader.Load(this); public override void OnFrameworkInitializationCompleted() { if (Services is not null) { + // Settings → Appearance: the animated splash is our own feature (not + // the OS launch screen), so the user can turn it off entirely. + var showSplash = Services.GetService()?.Current.ShowSplash ?? true; + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) - { - // Desktop shows a splash that runs migrations + event ingestion - // with visible progress, then swaps in the main window. (Android - // runs that bootstrap in MainApplication and uses the single-view - // branch below.) - var startup = Services.GetRequiredService(); - var splash = new StartupWindow { DataContext = startup }; - - startup.Completed += () => - { - var vm = Services.GetRequiredService(); - var main = new MainWindow { DataContext = vm }; - desktop.MainWindow = main; - main.Show(); - splash.Close(); - }; - - desktop.MainWindow = splash; - // Start init only once the splash is actually on screen. - splash.Opened += (_, _) => _ = startup.RunAsync(); - } + StartDesktop(desktop, showSplash); else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView) - { - var vm = Services.GetRequiredService(); - singleView.MainView = new MainView { DataContext = vm }; - } + StartSingleView(singleView, showSplash); } base.OnFrameworkInitializationCompleted(); } + + // Desktop shows a splash that runs migrations + event ingestion with visible + // progress, then swaps in the main window. With the splash disabled the same + // init runs window-less and the shell opens straight away when it finishes — + // but a failure still needs the retry/exit UI, so the StartupWindow is + // created lazily in that case instead of being lost. + private static void StartDesktop(IClassicDesktopStyleApplicationLifetime desktop, bool showSplash) + { + var startup = Services!.GetRequiredService(); + + if (showSplash) + { + var splash = new StartupWindow { DataContext = startup }; + startup.Completed += () => ShowMainWindow(desktop, splash); + desktop.MainWindow = splash; + // Start init only once the splash is actually on screen. + splash.Opened += (_, _) => _ = startup.RunAsync(); + return; + } + + StartupWindow? errorWindow = null; + startup.Completed += () => ShowMainWindow(desktop, errorWindow); + startup.PropertyChanged += (_, e) => + { + if (e.PropertyName != nameof(StartupViewModel.HasError) || + !startup.HasError || errorWindow is not null) + return; + errorWindow = new StartupWindow { DataContext = startup }; + desktop.MainWindow = errorWindow; + errorWindow.Show(); + }; + _ = startup.RunAsync(); + } + + private static void ShowMainWindow(IClassicDesktopStyleApplicationLifetime desktop, Window? toClose) + { + var vm = Services!.GetRequiredService(); + var main = new MainWindow { DataContext = vm }; + desktop.MainWindow = main; + main.Show(); + toClose?.Close(); + } + + // Android/iOS: migrations already ran in the platform host, so the splash is + // a purely visual overlay — MainView initializes underneath, and after a + // minimum beat the overlay fades out and leaves the tree. + private static void StartSingleView(ISingleViewApplicationLifetime singleView, bool showSplash) + { + var vm = Services!.GetRequiredService(); + var main = new MainView { DataContext = vm }; + + if (!showSplash) + { + singleView.MainView = main; + return; + } + + var splash = new MobileSplashView(); + var host = new Panel(); + host.Children.Add(main); + host.Children.Add(splash); + singleView.MainView = host; + + // Count the minimum beat from the splash's first layout pass, not from + // here — Avalonia's first Android frame lands well after lifetime setup, + // and a timer started now can remove the splash before it's ever drawn. + splash.Loaded += (_, _) => DispatcherTimer.RunOnce(() => + { + splash.Opacity = 0; // MobileSplashView carries the Opacity transition + DispatcherTimer.RunOnce(() => host.Children.Remove(splash), MobileSplashFadeDuration); + }, MobileSplashMinDuration); + } } diff --git a/src/OpenIPC.Viewer.App/Controls/SplashAperture.axaml b/src/OpenIPC.Viewer.App/Controls/SplashAperture.axaml new file mode 100644 index 0000000..a2cd7db --- /dev/null +++ b/src/OpenIPC.Viewer.App/Controls/SplashAperture.axaml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/OpenIPC.Viewer.App/Controls/SplashAperture.axaml.cs b/src/OpenIPC.Viewer.App/Controls/SplashAperture.axaml.cs new file mode 100644 index 0000000..a8146dd --- /dev/null +++ b/src/OpenIPC.Viewer.App/Controls/SplashAperture.axaml.cs @@ -0,0 +1,8 @@ +using Avalonia.Controls; + +namespace OpenIPC.Viewer.App.Controls; + +public sealed partial class SplashAperture : UserControl +{ + public SplashAperture() => InitializeComponent(); +} diff --git a/src/OpenIPC.Viewer.App/Services/Localizer.cs b/src/OpenIPC.Viewer.App/Services/Localizer.cs index 5297666..2836175 100644 --- a/src/OpenIPC.Viewer.App/Services/Localizer.cs +++ b/src/OpenIPC.Viewer.App/Services/Localizer.cs @@ -211,6 +211,7 @@ private static LangCode DetectSystem() ["Settings.Title"] = "Settings", ["Settings.Appearance"] = "Appearance", ["Settings.Appearance.Language"] = "Language", + ["Settings.Appearance.ShowSplash"] = "Show animated splash screen on launch", ["Settings.Video"] = "Video", ["Settings.Recording"] = "Recording", ["Settings.Discovery"] = "Discovery", @@ -247,7 +248,7 @@ private static LangCode DetectSystem() ["Settings.Discovery.AutoScan"] = "Run WS-Discovery on launch", ["Settings.Advanced.VerboseLogging"] = "Verbose logging (debug level)", - ["Settings.Advanced.RawConfigEditor"] = "Allow raw Majestic config editing (advanced)", + ["Settings.Advanced.RawConfigEditor"] = "Allow risky device tools (raw config editing, file manager)", ["Settings.Advanced.OpenAppData"] = "Open app data folder", ["Settings.About.Repository"] = "GitHub repository →", @@ -353,9 +354,6 @@ private static LangCode DetectSystem() ["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}", @@ -676,6 +674,7 @@ private static LangCode DetectSystem() ["Settings.Title"] = "Настройки", ["Settings.Appearance"] = "Внешний вид", ["Settings.Appearance.Language"] = "Язык", + ["Settings.Appearance.ShowSplash"] = "Показывать анимированную заставку при запуске", ["Settings.Video"] = "Видео", ["Settings.Recording"] = "Запись", ["Settings.Discovery"] = "Поиск", @@ -712,7 +711,7 @@ private static LangCode DetectSystem() ["Settings.Discovery.AutoScan"] = "Запускать WS-Discovery при старте", ["Settings.Advanced.VerboseLogging"] = "Подробные логи (debug)", - ["Settings.Advanced.RawConfigEditor"] = "Разрешить правку raw-конфига Majestic (продвинутое)", + ["Settings.Advanced.RawConfigEditor"] = "Разрешить рискованные инструменты (raw-конфиг, файловый менеджер)", ["Settings.Advanced.OpenAppData"] = "Открыть папку данных", ["Settings.About.Repository"] = "Репозиторий на GitHub →", @@ -818,9 +817,6 @@ private static LangCode DetectSystem() ["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}", diff --git a/src/OpenIPC.Viewer.App/Services/UserSettings.cs b/src/OpenIPC.Viewer.App/Services/UserSettings.cs index 89b7dac..ee029c3 100644 --- a/src/OpenIPC.Viewer.App/Services/UserSettings.cs +++ b/src/OpenIPC.Viewer.App/Services/UserSettings.cs @@ -28,8 +28,14 @@ public sealed record UserSettings( // "system" follows CurrentUICulture; "en"/"ru" force a specific locale. string Language = "system", 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. + // The animated startup splash (our in-app one, not the OS launch screen). + // Applies to both the desktop StartupWindow and the mobile overlay. + bool ShowSplash = true, + // Shared "risky device tools" gate: unlocks the raw Majestic config editors + // (HTTP + SSH) on the camera page AND the SSH file manager in the library. + // Off by default — a typo in the config or a deleted system file can leave + // the camera in a non-bootable state. The toggle itself is the consent, so + // the gated tools open without an extra per-use warning. 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 diff --git a/src/OpenIPC.Viewer.App/ViewModels/CameraLibraryPageViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/CameraLibraryPageViewModel.cs index c6792c9..71f93ba 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/CameraLibraryPageViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/CameraLibraryPageViewModel.cs @@ -89,6 +89,10 @@ public CameraLibraryPageViewModel( _statusRegistry = statusRegistry; _logger = logger; WeakReferenceMessenger.Default.Register(this); + // Toggling "risky device tools" in Settings shows/hides the Files button + // without reopening the page. The update can arrive off the UI thread. + _userSettings.Changed += (_, _) => Avalonia.Threading.Dispatcher.UIThread.Post( + () => OnPropertyChanged(nameof(IsFileManagerEnabled))); // Live status: a grid session's verdict (incl. Attention) flows here so a // library row reflects it, not just its own probe. _statusRegistry.Changed += OnStatusRegistryChanged; @@ -445,21 +449,17 @@ private async Task OpenSshTerminalAsync(CameraRowViewModel? row) await _dialogs.OpenSshTerminalAsync(vm).ConfigureAwait(true); } + // The file manager browses/edits the camera's live root filesystem over + // SSH — deleting or overwriting the wrong file can brick the device. The + // button only shows once the shared "risky device tools" toggle (Settings → + // Advanced) is on; that opt-in is the consent, so no per-open warning here. + public bool IsFileManagerEnabled => _userSettings.Current.RawConfigEditorEnabled; + [RelayCommand] 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); } diff --git a/src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs index 69a8dff..97a87d5 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs @@ -44,6 +44,7 @@ public sealed partial class SettingsPageViewModel : ViewModelBase [ObservableProperty] private NetworkInterfaceOption? _selectedNetworkInterface; [ObservableProperty] private string _language = "system"; + [ObservableProperty] private bool _showSplash = true; // SSH section (Phase 13). [ObservableProperty] private bool _sshStrictHostKey = true; @@ -204,6 +205,7 @@ private void Load() ?? NetworkInterfaceOptions[0]; RecordingsDirOverride = s.RecordingsDirOverride; Language = s.Language; + ShowSplash = s.ShowSplash; SshStrictHostKey = s.SshStrictHostKey; SshDefaultPort = s.SshDefaultPort; SshTerminalFontSize = s.SshTerminalFontSize; @@ -234,6 +236,7 @@ private void Load() partial void OnSelectedNetworkInterfaceChanged(NetworkInterfaceOption? value) => Persist(); partial void OnRecordingsDirOverrideChanged(string value) => Persist(); partial void OnLanguageChanged(string value) => Persist(); + partial void OnShowSplashChanged(bool value) => Persist(); partial void OnSshStrictHostKeyChanged(bool value) => Persist(); partial void OnSshDefaultPortChanged(int value) => Persist(); partial void OnSshTerminalFontSizeChanged(int value) => Persist(); @@ -263,6 +266,7 @@ private void Persist() PreferredNetworkInterface = SelectedNetworkInterface?.Value ?? "", RecordingsDirOverride = RecordingsDirOverride, Language = Language, + ShowSplash = ShowSplash, SshStrictHostKey = SshStrictHostKey, SshDefaultPort = SshDefaultPort, SshTerminalFontSize = SshTerminalFontSize, diff --git a/src/OpenIPC.Viewer.App/Views/Dialogs/SshTerminalContent.axaml b/src/OpenIPC.Viewer.App/Views/Dialogs/SshTerminalContent.axaml index 3b09316..a8db576 100644 --- a/src/OpenIPC.Viewer.App/Views/Dialogs/SshTerminalContent.axaml +++ b/src/OpenIPC.Viewer.App/Views/Dialogs/SshTerminalContent.axaml @@ -8,7 +8,27 @@ MinWidth="280" MinHeight="320" Background="#0c0f14"> - + + + + + + + - + + + + +