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
2 changes: 2 additions & 0 deletions src/OpenIPC.Viewer.App/Services/Localizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ private static LangCode DetectSystem()

["Settings.Advanced.VerboseLogging"] = "Verbose logging (debug level)",
["Settings.Advanced.RawConfigEditor"] = "Allow risky device tools (raw config editing, file manager)",
["Settings.Advanced.SingleInstance"] = "Single instance only (a repeat launch focuses the running window)",
["Settings.Advanced.OpenAppData"] = "Open app data folder",

["Settings.About.Repository"] = "GitHub repository →",
Expand Down Expand Up @@ -728,6 +729,7 @@ private static LangCode DetectSystem()

["Settings.Advanced.VerboseLogging"] = "Подробные логи (debug)",
["Settings.Advanced.RawConfigEditor"] = "Разрешить рискованные инструменты (raw-конфиг, файловый менеджер)",
["Settings.Advanced.SingleInstance"] = "Запрещать запуск второй копии (повторный запуск откроет это окно)",
["Settings.Advanced.OpenAppData"] = "Открыть папку данных",

["Settings.About.Repository"] = "Репозиторий на GitHub →",
Expand Down
6 changes: 5 additions & 1 deletion src/OpenIPC.Viewer.App/Services/UserSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,11 @@ public sealed record UserSettings(
int GridStillsIntervalSeconds = 10,
// Desktop only: closing the main window hides it to the tray icon instead
// of quitting (live streams are released while hidden). Off = close quits.
bool CloseToTray = false)
bool CloseToTray = false,
// Desktop only: refuse to start a second copy of the app — a repeat launch
// brings the already-running window to the foreground instead (see
// Desktop/SingleInstanceGuard). Off = any number of copies may run.
bool SingleInstance = false)
{
public static UserSettings Default => new();
}
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 @@ -46,6 +46,7 @@ public sealed partial class SettingsPageViewModel : ViewModelBase
[ObservableProperty] private string _language = "system";
[ObservableProperty] private bool _showSplash = true;
[ObservableProperty] private bool _closeToTray;
[ObservableProperty] private bool _singleInstance;

// Gates the desktop-only toggles (tray) off the shared settings page.
public bool IsDesktopPlatform { get; } =
Expand Down Expand Up @@ -216,6 +217,7 @@ private void Load()
Language = s.Language;
ShowSplash = s.ShowSplash;
CloseToTray = s.CloseToTray;
SingleInstance = s.SingleInstance;
SshStrictHostKey = s.SshStrictHostKey;
SshDefaultPort = s.SshDefaultPort;
SshTerminalFontSize = s.SshTerminalFontSize;
Expand Down Expand Up @@ -248,6 +250,7 @@ private void Load()
partial void OnLanguageChanged(string value) => Persist();
partial void OnShowSplashChanged(bool value) => Persist();
partial void OnCloseToTrayChanged(bool value) => Persist();
partial void OnSingleInstanceChanged(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 @@ -279,6 +282,7 @@ private void Persist()
Language = Language,
ShowSplash = ShowSplash,
CloseToTray = CloseToTray,
SingleInstance = SingleInstance,
SshStrictHostKey = SshStrictHostKey,
SshDefaultPort = SshDefaultPort,
SshTerminalFontSize = SshTerminalFontSize,
Expand Down
5 changes: 5 additions & 0 deletions src/OpenIPC.Viewer.App/Views/Pages/SettingsPage.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,11 @@
Content="{Binding [Settings.Advanced.RawConfigEditor], Source={x:Static svc:Localizer.Instance}}"
Foreground="{StaticResource TextPrimaryBrush}" />

<CheckBox IsChecked="{Binding SingleInstance}"
IsVisible="{Binding IsDesktopPlatform}"
Content="{Binding [Settings.Advanced.SingleInstance], Source={x:Static svc:Localizer.Instance}}"
Foreground="{StaticResource TextPrimaryBrush}" />

<StackPanel Spacing="6">
<Button Classes="outlined"
Content="{Binding [Settings.Advanced.OpenAppData], Source={x:Static svc:Localizer.Instance}}"
Expand Down
16 changes: 16 additions & 0 deletions src/OpenIPC.Viewer.App/Views/Pages/SettingsPage.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.ComponentModel;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Threading;
using OpenIPC.Viewer.App.ViewModels;

namespace OpenIPC.Viewer.App.Views.Pages;
Expand All @@ -14,6 +15,7 @@ public sealed partial class SettingsPage : UserControl
private const double WideThreshold = 700;

private SettingsPageViewModel? _vm;
private bool _relayoutQueued;

public SettingsPage()
{
Expand Down Expand Up @@ -57,6 +59,20 @@ private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
_vm.IsWide = nextWide; // triggers ShowList/ShowDetail change → ApplyLayout
else
ApplyLayout();

// During an interactive resize Avalonia can render with a stale
// arrange pass and (rarely, seen on Windows) miss the final one,
// leaving the detail pane clipped at the window edge. Queue a single
// low-priority re-apply + re-measure so a definitive layout pass runs
// after the resize storm settles. Coalesced: one pending post at most.
if (_relayoutQueued) return;
_relayoutQueued = true;
Dispatcher.UIThread.Post(() =>
{
_relayoutQueued = false;
ApplyLayout();
RootGrid.InvalidateMeasure();
}, DispatcherPriority.Background);
}

private void ApplyLayout()
Expand Down
9 changes: 9 additions & 0 deletions src/OpenIPC.Viewer.Desktop/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ public static int Main(string[] args)
System.Diagnostics.Trace.Listeners.Add(new System.Diagnostics.ConsoleTraceListener());
#endif

// Opt-in single-instance mode: when another copy already runs and the
// user enabled the guard, hand focus to it and bail out before any
// services (logs, db) spin up.
if (!SingleInstanceGuard.TryBecomePrimary() && SingleInstanceGuard.IsEnabledInSettings())
{
SingleInstanceGuard.ActivatePrimary();
return 0;
}

var services = Composition.Build();
App.App.Services = services;

Expand Down
118 changes: 118 additions & 0 deletions src/OpenIPC.Viewer.Desktop/SingleInstanceGuard.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
using System;
using System.IO;
using System.IO.Pipes;
using System.Text.Json;
using System.Threading;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Threading;
using OpenIPC.Viewer.App;

namespace OpenIPC.Viewer.Desktop;

// Opt-in single-instance mode (Settings → Advanced). The first process always
// takes the named mutex and serves the activation pipe, even with the setting
// off — that way enabling the toggle later protects against copies launched
// afterwards without restarting the app that's already running. A losing
// process consults the setting itself (read straight from usersettings.json,
// before DI or Avalonia spin up) and, when enforcement is on, pings the pipe
// so the existing window comes to the foreground, then exits.
internal static class SingleInstanceGuard
{
// User-scoped names so two OS sessions each get their own slot. "Local\"
// confines the mutex to the current session on Windows; other platforms
// treat the prefix as part of the name, which is equally fine.
private static readonly string MutexName =
@"Local\OpenIPC.Viewer.instance." + Uri.EscapeDataString(Environment.UserName);
private static readonly string PipeName =
"OpenIPC.Viewer.activate." + Uri.EscapeDataString(Environment.UserName);

private static Mutex? _mutex; // held for the process lifetime on purpose

// True → this process is the primary: it owns the mutex and answers
// activation pings. False → another copy already runs.
public static bool TryBecomePrimary()
{
var mutex = new Mutex(initiallyOwned: true, MutexName, out var createdNew);
if (!createdNew)
{
mutex.Dispose();
return false;
}
_mutex = mutex;
StartActivationListener();
return true;
}

// The user's toggle, read without the service stack: Main needs the answer
// before Composition.Build() opens logs/db. Property casing matches what
// UserSettingsService writes (PascalCase, no naming policy).
public static bool IsEnabledInSettings()
{
try
{
var path = Path.Combine(AppPaths.AppDataDir.FullName, "usersettings.json");
if (!File.Exists(path)) return false;
using var doc = JsonDocument.Parse(File.ReadAllBytes(path));
return doc.RootElement.TryGetProperty("SingleInstance", out var v)
&& v.ValueKind == JsonValueKind.True;
}
catch
{
return false; // corrupt/unreadable settings must never block startup
}
}

// Second-instance side: ask the primary to come to the foreground.
// Best-effort — if the primary is mid-startup or wedged, we just exit.
public static void ActivatePrimary()
{
try
{
using var client = new NamedPipeClientStream(".", PipeName, PipeDirection.Out);
client.Connect(timeout: 2000);
client.WriteByte(1);
client.Flush();
}
catch
{
// Nothing useful to do: the user still sees the primary window
// (possibly minimized), and this process is about to exit anyway.
}
}

private static void StartActivationListener()
{
var thread = new Thread(() =>
{
while (true)
{
try
{
using var server = new NamedPipeServerStream(PipeName, PipeDirection.In, maxNumberOfServerInstances: 1);
server.WaitForConnection();
server.ReadByte(); // any payload = "bring yourself to front"
Dispatcher.UIThread.Post(ActivateMainWindow);
}
catch
{
// Client vanished mid-handshake or the dispatcher isn't up
// yet — drop this ping and keep serving the next one.
}
}
})
{ IsBackground = true, Name = "single-instance-activate" };
thread.Start();
}

private static void ActivateMainWindow()
{
if (Avalonia.Application.Current?.ApplicationLifetime
is not IClassicDesktopStyleApplicationLifetime { MainWindow: { } window })
return;
window.Show(); // un-hides a close-to-tray window
if (window.WindowState == WindowState.Minimized)
window.WindowState = WindowState.Normal;
window.Activate();
}
}
Loading