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
111 changes: 86 additions & 25 deletions src/OpenIPC.Viewer.App/App.axaml.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -16,41 +19,99 @@ public sealed class App : Application
// AvaloniaMainActivity<App> 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<UserSettingsService>()?.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<StartupViewModel>();
var splash = new StartupWindow { DataContext = startup };

startup.Completed += () =>
{
var vm = Services.GetRequiredService<MainWindowViewModel>();
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<MainWindowViewModel>();
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<StartupViewModel>();

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<MainWindowViewModel>();
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<MainWindowViewModel>();
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);
}
}
83 changes: 83 additions & 0 deletions src/OpenIPC.Viewer.App/Controls/SplashAperture.axaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="OpenIPC.Viewer.App.Controls.SplashAperture">

<!-- Animated OpenIPC aperture mark (the splash "camera eye"). Extracted from
StartupWindow so the desktop splash and the mobile startup overlay share
one implementation. Scales to whatever Width/Height the consumer sets. -->

<UserControl.Styles>
<!-- Occasional "aperture blink": squash the whole mark vertically for a few
frames on a long loop, reading as a blink rather than a flicker. -->
<Style Selector="Canvas.mark">
<Setter Property="RenderTransformOrigin" Value="0.5,0.5" />
</Style>
<Style Selector="Canvas.mark">
<Style.Animations>
<Animation Duration="0:0:3.8" IterationCount="INFINITE">
<KeyFrame Cue="0%"><Setter Property="ScaleTransform.ScaleY" Value="1" /></KeyFrame>
<KeyFrame Cue="88%"><Setter Property="ScaleTransform.ScaleY" Value="1" /></KeyFrame>
<KeyFrame Cue="93%"><Setter Property="ScaleTransform.ScaleY" Value="0.08" /></KeyFrame>
<KeyFrame Cue="98%"><Setter Property="ScaleTransform.ScaleY" Value="1" /></KeyFrame>
<KeyFrame Cue="100%"><Setter Property="ScaleTransform.ScaleY" Value="1" /></KeyFrame>
</Animation>
</Style.Animations>
</Style>

<!-- The "pupil" looks left↔right, like a camera scanning the room. -->
<Style Selector="Ellipse.dot">
<Style.Animations>
<Animation Duration="0:0:4" IterationCount="INFINITE" Easing="SineEaseInOut">
<KeyFrame Cue="0%"><Setter Property="TranslateTransform.X" Value="0" /></KeyFrame>
<KeyFrame Cue="22%"><Setter Property="TranslateTransform.X" Value="-9" /></KeyFrame>
<KeyFrame Cue="50%"><Setter Property="TranslateTransform.X" Value="0" /></KeyFrame>
<KeyFrame Cue="72%"><Setter Property="TranslateTransform.X" Value="9" /></KeyFrame>
<KeyFrame Cue="100%"><Setter Property="TranslateTransform.X" Value="0" /></KeyFrame>
</Animation>
</Style.Animations>
</Style>

<!-- Accent glow ring brightens as the dot contracts (the "focus" snap). -->
<Style Selector="Ellipse.glow">
<Style.Animations>
<Animation Duration="0:0:1.8" IterationCount="INFINITE" Easing="SineEaseInOut">
<KeyFrame Cue="0%"><Setter Property="Opacity" Value="0.15" /></KeyFrame>
<KeyFrame Cue="50%"><Setter Property="Opacity" Value="0.55" /></KeyFrame>
<KeyFrame Cue="100%"><Setter Property="Opacity" Value="0.15" /></KeyFrame>
</Animation>
</Style.Animations>
</Style>
</UserControl.Styles>

<Viewbox Stretch="Uniform">
<Canvas Width="100" Height="100">
<Canvas Classes="mark" Width="100" Height="100">
<!-- Left chevron < -->
<Path Data="M33,29 L5,50 L33,71"
Stroke="{StaticResource TextPrimaryBrush}" StrokeThickness="9"
StrokeLineCap="Flat" StrokeJoin="Round" />
<!-- Right chevron > -->
<Path Data="M67,29 L95,50 L67,71"
Stroke="{StaticResource TextPrimaryBrush}" StrokeThickness="9"
StrokeLineCap="Flat" StrokeJoin="Round" />
<!-- Top cap arc ⌒ -->
<Path Data="M37,27 C43,12 57,12 63,27"
Stroke="{StaticResource TextPrimaryBrush}" StrokeThickness="9"
StrokeLineCap="Round" />
<!-- Bottom cap arc ⌣ -->
<Path Data="M37,73 C43,88 57,88 63,73"
Stroke="{StaticResource TextPrimaryBrush}" StrokeThickness="9"
StrokeLineCap="Round" />
<!-- Accent focus glow behind the dot -->
<Ellipse Classes="glow" Canvas.Left="32" Canvas.Top="32"
Width="36" Height="36"
Stroke="{StaticResource AccentBrush}" StrokeThickness="6" />
<!-- Center dot (focus pulse) -->
<Ellipse Classes="dot" Canvas.Left="37" Canvas.Top="37"
Width="26" Height="26"
Fill="{StaticResource TextPrimaryBrush}" />
</Canvas>
</Canvas>
</Viewbox>

</UserControl>
8 changes: 8 additions & 0 deletions src/OpenIPC.Viewer.App/Controls/SplashAperture.axaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using Avalonia.Controls;

namespace OpenIPC.Viewer.App.Controls;

public sealed partial class SplashAperture : UserControl
{
public SplashAperture() => InitializeComponent();
}
12 changes: 4 additions & 8 deletions src/OpenIPC.Viewer.App/Services/Localizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 →",
Expand Down Expand Up @@ -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}",
Expand Down Expand Up @@ -676,6 +674,7 @@ private static LangCode DetectSystem()
["Settings.Title"] = "Настройки",
["Settings.Appearance"] = "Внешний вид",
["Settings.Appearance.Language"] = "Язык",
["Settings.Appearance.ShowSplash"] = "Показывать анимированную заставку при запуске",
["Settings.Video"] = "Видео",
["Settings.Recording"] = "Запись",
["Settings.Discovery"] = "Поиск",
Expand Down Expand Up @@ -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 →",
Expand Down Expand Up @@ -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}",
Expand Down
10 changes: 8 additions & 2 deletions src/OpenIPC.Viewer.App/Services/UserSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 10 additions & 10 deletions src/OpenIPC.Viewer.App/ViewModels/CameraLibraryPageViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ public CameraLibraryPageViewModel(
_statusRegistry = statusRegistry;
_logger = logger;
WeakReferenceMessenger.Default.Register<ConfigImportedMessage>(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;
Expand Down Expand Up @@ -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);
}
Expand Down
4 changes: 4 additions & 0 deletions src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -263,6 +266,7 @@ private void Persist()
PreferredNetworkInterface = SelectedNetworkInterface?.Value ?? "",
RecordingsDirOverride = RecordingsDirOverride,
Language = Language,
ShowSplash = ShowSplash,
SshStrictHostKey = SshStrictHostKey,
SshDefaultPort = SshDefaultPort,
SshTerminalFontSize = SshTerminalFontSize,
Expand Down
Loading
Loading