System tray icon

Add code for creation of system tray icon, menu and handle menu events
System tray icon

- add code for creation of system tray icon, menu and handle menu events
- add option to preferences dialog
- add a kind of a single instance mode: only first launched instance creates system tray icon and does not quit
This commit is contained in:
Vladimir Eremeev 2025-01-13 09:44:40 +03:00
parent 620f411e99
commit 8930f1d614
13 changed files with 155 additions and 19 deletions

View file

@ -37,6 +37,7 @@ namespace SourceGit
} }
} }
public static readonly Command Unminimize = new Command(_ => ShowWindow());
public static readonly Command OpenPreferencesCommand = new Command(_ => OpenDialog(new Views.Preferences())); public static readonly Command OpenPreferencesCommand = new Command(_ => OpenDialog(new Views.Preferences()));
public static readonly Command OpenHotkeysCommand = new Command(_ => OpenDialog(new Views.Hotkeys())); public static readonly Command OpenHotkeysCommand = new Command(_ => OpenDialog(new Views.Hotkeys()));
public static readonly Command OpenAppDataDirCommand = new Command(_ => Native.OS.OpenInFileManager(Native.OS.DataDir)); public static readonly Command OpenAppDataDirCommand = new Command(_ => Native.OS.OpenInFileManager(Native.OS.DataDir));

View file

@ -14,6 +14,8 @@ using Avalonia.Data.Core.Plugins;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Media.Fonts; using Avalonia.Media.Fonts;
using Avalonia.Media.Imaging;
using Avalonia.Platform;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using Avalonia.Styling; using Avalonia.Styling;
using Avalonia.Threading; using Avalonia.Threading;
@ -167,6 +169,46 @@ namespace SourceGit
} }
} }
public void SetupTrayIcon(bool enable)
{
if (enable && Native.OS.EnsureSingleInstance())
{
var icons = new TrayIcons {
new TrayIcon {
Icon = new WindowIcon(new Bitmap(AssetLoader.Open(new Uri("avares://SourceGit/App.ico")))),
Menu = [
new NativeMenuItem(Text("Open")) {Command = Unminimize},
new NativeMenuItem(Text("Preferences")) {Command = OpenPreferencesCommand},
new NativeMenuItemSeparator(),
new NativeMenuItem(Text("Quit")) {Command = QuitCommand},
]
}
};
icons[0].Clicked += (_, _) => ToggleWindow();
TrayIcon.SetIcons(Current, icons);
_createdSystemTrayIcon = true;
}
}
private static void ToggleWindow() {
if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) {
if (desktop.MainWindow.IsVisible) {
desktop.MainWindow.Hide();
} else {
ShowWindow();
}
}
}
private static void ShowWindow()
{
if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) {
desktop.MainWindow.WindowState = WindowState.Normal;
desktop.MainWindow.Show();
desktop.MainWindow.BringIntoView();
desktop.MainWindow.Focus();
}
}
public static void SetFonts(string defaultFont, string monospaceFont, bool onlyUseMonospaceFontInEditor) public static void SetFonts(string defaultFont, string monospaceFont, bool onlyUseMonospaceFontInEditor)
{ {
var app = Current as App; var app = Current as App;
@ -320,6 +362,7 @@ namespace SourceGit
TryLaunchAsNormal(desktop); TryLaunchAsNormal(desktop);
} }
base.OnFrameworkInitializationCompleted();
} }
#endregion #endregion
@ -476,11 +519,17 @@ namespace SourceGit
if (desktop.Args != null && desktop.Args.Length == 1 && Directory.Exists(desktop.Args[0])) if (desktop.Args != null && desktop.Args.Length == 1 && Directory.Exists(desktop.Args[0]))
startupRepo = desktop.Args[0]; startupRepo = desktop.Args[0];
_launcher = new ViewModels.Launcher(startupRepo); var pref = ViewModels.Preferences.Instance;
SetupTrayIcon(pref.SystemTrayIcon);
if (_createdSystemTrayIcon) {
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
}
_launcher = new ViewModels.Launcher(startupRepo) { InterceptQuit = _createdSystemTrayIcon };
desktop.MainWindow = new Views.Launcher() { DataContext = _launcher }; desktop.MainWindow = new Views.Launcher() { DataContext = _launcher };
#if !DISABLE_UPDATE_DETECTION #if !DISABLE_UPDATE_DETECTION
var pref = ViewModels.Preferences.Instance;
if (pref.ShouldCheck4UpdateOnStartup()) if (pref.ShouldCheck4UpdateOnStartup())
Check4Update(); Check4Update();
#endif #endif
@ -543,5 +592,6 @@ namespace SourceGit
private ResourceDictionary _activeLocale = null; private ResourceDictionary _activeLocale = null;
private ResourceDictionary _themeOverrides = null; private ResourceDictionary _themeOverrides = null;
private ResourceDictionary _fontsOverrides = null; private ResourceDictionary _fontsOverrides = null;
private bool _createdSystemTrayIcon = false;
} }
} }

View file

@ -11,6 +11,7 @@ namespace SourceGit.Native
[SupportedOSPlatform("linux")] [SupportedOSPlatform("linux")]
internal class Linux : OS.IBackend internal class Linux : OS.IBackend
{ {
private FileStream _fs = null;
public void SetupApp(AppBuilder builder) public void SetupApp(AppBuilder builder)
{ {
builder.With(new X11PlatformOptions() { EnableIme = true }); builder.With(new X11PlatformOptions() { EnableIme = true });
@ -97,6 +98,26 @@ namespace SourceGit.Native
} }
} }
public bool EnsureSingleInstance()
{
var pidfile = Path.Combine(Path.GetTempPath(), "sourcegit.pid");
var pid = Process.GetCurrentProcess().Id.ToString();
Console.WriteLine("pid " + pid);
try
{
_fs = File.OpenWrite(pidfile);
_fs.Lock(0, 1000);
new StreamWriter(_fs).Write(pid);
return true;
}
catch (IOException)
{
Console.WriteLine("another SourceGit is running");
return false;
}
}
private string FindExecutable(string filename) private string FindExecutable(string filename)
{ {
var pathVariable = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; var pathVariable = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;

View file

@ -86,5 +86,7 @@ namespace SourceGit.Native
{ {
Process.Start("open", $"\"{file}\""); Process.Start("open", $"\"{file}\"");
} }
public bool EnsureSingleInstance() { return true; }
} }
} }

View file

@ -23,6 +23,8 @@ namespace SourceGit.Native
void OpenInFileManager(string path, bool select); void OpenInFileManager(string path, bool select);
void OpenBrowser(string url); void OpenBrowser(string url);
void OpenWithDefaultEditor(string file); void OpenWithDefaultEditor(string file);
bool EnsureSingleInstance();
} }
public static string DataDir { public static string DataDir {
@ -217,6 +219,11 @@ namespace SourceGit.Native
[GeneratedRegex(@"^git version[\s\w]*(\d+)\.(\d+)[\.\-](\d+).*$")] [GeneratedRegex(@"^git version[\s\w]*(\d+)\.(\d+)[\.\-](\d+).*$")]
private static partial Regex REG_GIT_VERSION(); private static partial Regex REG_GIT_VERSION();
public static bool EnsureSingleInstance()
{
return _backend.EnsureSingleInstance();
}
private static IBackend _backend = null; private static IBackend _backend = null;
private static string _gitExecutable = string.Empty; private static string _gitExecutable = string.Empty;
} }

View file

@ -14,6 +14,8 @@ namespace SourceGit.Native
[SupportedOSPlatform("windows")] [SupportedOSPlatform("windows")]
internal class Windows : OS.IBackend internal class Windows : OS.IBackend
{ {
private FileStream _fs = null;
[StructLayout(LayoutKind.Sequential)] [StructLayout(LayoutKind.Sequential)]
internal struct RTL_OSVERSIONINFOEX internal struct RTL_OSVERSIONINFOEX
{ {
@ -393,5 +395,25 @@ namespace SourceGit.Native
return null; return null;
} }
public bool EnsureSingleInstance()
{
var pidfile = Path.Combine(Path.GetTempPath(), "sourcegit.pid");
var pid = Process.GetCurrentProcess().Id.ToString();
Console.WriteLine("pid " + pid);
try
{
_fs = File.OpenWrite(pidfile);
_fs.Lock(0, 1000);
new StreamWriter(_fs).Write(pid);
return true;
}
catch (IOException)
{
Console.WriteLine("another SourceGit is running");
return false;
}
}
} }
} }

View file

@ -458,6 +458,7 @@
<x:String x:Key="Text.Preferences.Appearance.ThemeOverrides" xml:space="preserve">Theme Overrides</x:String> <x:String x:Key="Text.Preferences.Appearance.ThemeOverrides" xml:space="preserve">Theme Overrides</x:String>
<x:String x:Key="Text.Preferences.Appearance.UseFixedTabWidth" xml:space="preserve">Use fixed tab width in titlebar</x:String> <x:String x:Key="Text.Preferences.Appearance.UseFixedTabWidth" xml:space="preserve">Use fixed tab width in titlebar</x:String>
<x:String x:Key="Text.Preferences.Appearance.UseNativeWindowFrame" xml:space="preserve">Use native window frame</x:String> <x:String x:Key="Text.Preferences.Appearance.UseNativeWindowFrame" xml:space="preserve">Use native window frame</x:String>
<x:String x:Key="Text.Preferences.Appearance.SystemTrayIcon" xml:space="preserve">System tray icon (needs restart)</x:String>
<x:String x:Key="Text.Preferences.DiffMerge" xml:space="preserve">DIFF/MERGE TOOL</x:String> <x:String x:Key="Text.Preferences.DiffMerge" xml:space="preserve">DIFF/MERGE TOOL</x:String>
<x:String x:Key="Text.Preferences.DiffMerge.Path" xml:space="preserve">Install Path</x:String> <x:String x:Key="Text.Preferences.DiffMerge.Path" xml:space="preserve">Install Path</x:String>
<x:String x:Key="Text.Preferences.DiffMerge.Path.Placeholder" xml:space="preserve">Input path for diff/merge tool</x:String> <x:String x:Key="Text.Preferences.DiffMerge.Path.Placeholder" xml:space="preserve">Input path for diff/merge tool</x:String>

View file

@ -462,6 +462,7 @@
<x:String x:Key="Text.Preferences.Appearance.ThemeOverrides" xml:space="preserve">Переопределение темы</x:String> <x:String x:Key="Text.Preferences.Appearance.ThemeOverrides" xml:space="preserve">Переопределение темы</x:String>
<x:String x:Key="Text.Preferences.Appearance.UseFixedTabWidth" xml:space="preserve">Использовать фиксированную ширину табуляции в строке заголовка.</x:String> <x:String x:Key="Text.Preferences.Appearance.UseFixedTabWidth" xml:space="preserve">Использовать фиксированную ширину табуляции в строке заголовка.</x:String>
<x:String x:Key="Text.Preferences.Appearance.UseNativeWindowFrame" xml:space="preserve">Использовать системное окно</x:String> <x:String x:Key="Text.Preferences.Appearance.UseNativeWindowFrame" xml:space="preserve">Использовать системное окно</x:String>
<x:String x:Key="Text.Preferences.Appearance.SystemTrayIcon" xml:space="preserve">Иконка в системном лотке (нужен перезапуск)</x:String>
<x:String x:Key="Text.Preferences.DiffMerge" xml:space="preserve">ИНСТРУМЕНТ РАЗЛИЧИЙ/СЛИЯНИЯ</x:String> <x:String x:Key="Text.Preferences.DiffMerge" xml:space="preserve">ИНСТРУМЕНТ РАЗЛИЧИЙ/СЛИЯНИЯ</x:String>
<x:String x:Key="Text.Preferences.DiffMerge.Path" xml:space="preserve">Путь установки</x:String> <x:String x:Key="Text.Preferences.DiffMerge.Path" xml:space="preserve">Путь установки</x:String>
<x:String x:Key="Text.Preferences.DiffMerge.Path.Placeholder" xml:space="preserve">Введите путь для инструмента различия/слияния</x:String> <x:String x:Key="Text.Preferences.DiffMerge.Path.Placeholder" xml:space="preserve">Введите путь для инструмента различия/слияния</x:String>

View file

@ -29,6 +29,8 @@ namespace SourceGit.ViewModels
private set => SetProperty(ref _activeWorkspace, value); private set => SetProperty(ref _activeWorkspace, value);
} }
public bool InterceptQuit { get; set; } = false;
public LauncherPage ActivePage public LauncherPage ActivePage
{ {
get => _activePage; get => _activePage;
@ -47,7 +49,6 @@ namespace SourceGit.ViewModels
public Launcher(string startupRepo) public Launcher(string startupRepo)
{ {
_ignoreIndexChange = true; _ignoreIndexChange = true;
Pages = new AvaloniaList<LauncherPage>(); Pages = new AvaloniaList<LauncherPage>();
AddNewTab(); AddNewTab();

View file

@ -348,6 +348,12 @@ namespace SourceGit.ViewModels
set => SetProperty(ref _lastCheckUpdateTime, value); set => SetProperty(ref _lastCheckUpdateTime, value);
} }
public bool SystemTrayIcon
{
get => _systemTrayIcon;
set => SetProperty(ref _systemTrayIcon, value);
}
public bool IsGitConfigured() public bool IsGitConfigured()
{ {
var path = GitInstallPath; var path = GitInstallPath;
@ -682,5 +688,7 @@ namespace SourceGit.ViewModels
private string _externalMergeToolPath = string.Empty; private string _externalMergeToolPath = string.Empty;
private uint _statisticsSampleColor = 0xFF00FF00; private uint _statisticsSampleColor = 0xFF00FF00;
private bool _systemTrayIcon = false;
} }
} }

View file

@ -261,7 +261,13 @@ namespace SourceGit.Views
protected override void OnClosing(WindowClosingEventArgs e) protected override void OnClosing(WindowClosingEventArgs e)
{ {
(DataContext as ViewModels.Launcher)?.Quit(Width, Height); var launcher = DataContext as ViewModels.Launcher;
if (launcher is { InterceptQuit: true }) {
e.Cancel = true;
Hide();
} else {
launcher?.Quit(Width, Height);
}
base.OnClosing(e); base.OnClosing(e);
} }

View file

@ -148,7 +148,7 @@
<TabItem.Header> <TabItem.Header>
<TextBlock Classes="tab_header" Text="{DynamicResource Text.Preferences.Appearance}"/> <TextBlock Classes="tab_header" Text="{DynamicResource Text.Preferences.Appearance}"/>
</TabItem.Header> </TabItem.Header>
<Grid Margin="8" RowDefinitions="32,32,32,32,32,32,32,Auto" ColumnDefinitions="Auto,*"> <Grid Margin="8" RowDefinitions="32,32,32,32,32,Auto" ColumnDefinitions="Auto,*">
<TextBlock Grid.Row="0" Grid.Column="0" <TextBlock Grid.Row="0" Grid.Column="0"
Text="{DynamicResource Text.Preferences.Appearance.Theme}" Text="{DynamicResource Text.Preferences.Appearance.Theme}"
HorizontalAlignment="Right" HorizontalAlignment="Right"
@ -232,21 +232,26 @@
</TextBox.InnerRightContent> </TextBox.InnerRightContent>
</TextBox> </TextBox>
<CheckBox Grid.Row="5" Grid.Column="1" <StackPanel Grid.Row="5" Grid.Column="1">
Content="{DynamicResource Text.Preferences.Appearance.OnlyUseMonoFontInEditor}" <CheckBox Content="{DynamicResource Text.Preferences.Appearance.OnlyUseMonoFontInEditor}"
IsChecked="{Binding OnlyUseMonoFontInEditor, Mode=TwoWay}"/> IsChecked="{Binding OnlyUseMonoFontInEditor, Mode=TwoWay}"/>
<CheckBox Grid.Row="6" Grid.Column="1" <CheckBox Height="32"
Height="32" Content="{DynamicResource Text.Preferences.Appearance.UseFixedTabWidth}"
Content="{DynamicResource Text.Preferences.Appearance.UseFixedTabWidth}" IsChecked="{Binding Source={x:Static vm:Preferences.Instance}, Path=UseFixedTabWidth, Mode=TwoWay}"/>
IsChecked="{Binding Source={x:Static vm:Preferences.Instance}, Path=UseFixedTabWidth, Mode=TwoWay}"/>
<CheckBox Grid.Row="7" Grid.Column="1" <CheckBox Height="32"
Height="32" Content="{DynamicResource Text.Preferences.Appearance.UseNativeWindowFrame}"
Content="{DynamicResource Text.Preferences.Appearance.UseNativeWindowFrame}" IsChecked="{Binding Source={x:Static vm:Preferences.Instance}, Path=UseSystemWindowFrame, Mode=OneTime}"
IsChecked="{Binding Source={x:Static vm:Preferences.Instance}, Path=UseSystemWindowFrame, Mode=OneTime}" IsVisible="{OnPlatform False, Linux=True}"
IsVisible="{OnPlatform False, Linux=True}" IsCheckedChanged="OnUseNativeWindowFrameChanged"/>
IsCheckedChanged="OnUseNativeWindowFrameChanged"/>
<CheckBox Height="32"
Content="{DynamicResource Text.Preferences.Appearance.SystemTrayIcon}"
IsChecked="{Binding Path=SystemTrayIcon, Mode=OneTime}"
IsVisible="True"
IsCheckedChanged="OnSystemTrayIconCheckedChanged"/>
</StackPanel>
</Grid> </Grid>
</TabItem> </TabItem>

View file

@ -333,6 +333,17 @@ namespace SourceGit.Views
e.Handled = true; e.Handled = true;
} }
private void OnSystemTrayIconCheckedChanged(object sender, RoutedEventArgs e)
{
if (sender is CheckBox box)
{
ViewModels.Preferences.Instance.SystemTrayIcon = box.IsChecked == true;
var dialog = new ConfirmRestart();
App.OpenDialog(dialog);
}
e.Handled = true;
}
private void OnGitInstallPathChanged(object sender, TextChangedEventArgs e) private void OnGitInstallPathChanged(object sender, TextChangedEventArgs e)
{ {