Skip to content
Open
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
38 changes: 34 additions & 4 deletions src/TimeToKill.App/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Avalonia.Markup.Xaml;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using TimeToKill.App.Cli;
using TimeToKill.App.Helpers;
using TimeToKill.App.Services;
using TimeToKill.App.Themes;
Expand All @@ -27,7 +28,8 @@ public partial class App : Application
private TrayIcon _trayIcon;
private Bitmap _currentTrayBitmap;
private DispatcherTimer _flashTimer;

private CommandHandler _commandHandler;

private const string DefaultTheme = "default-dark";

public override void Initialize()
Expand All @@ -52,13 +54,21 @@ public override void OnFrameworkInitializationCompleted()

MainViewModel = new MainWindowViewModel(_presetRepository, _timerManager);
MainWindowInstance = new MainWindow { DataContext = MainViewModel };


// IPC: start pipe server and wire up command handling
_commandHandler = new CommandHandler(_presetRepository, MainViewModel);
if (Program.InstanceManager != null) {
Program.InstanceManager.CommandReceived += OnIpcCommandReceived;
Program.InstanceManager.StartPipeServer();
}
ProcessStartupCommands();

SetupTrayIcon();

// Timer events for tray updates
_timerManager.TimerTick += OnTimerTick;
_timerManager.TimerCompleted += OnTimerCompleted;

desktop.MainWindow = MainWindowInstance;
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
}
Expand Down Expand Up @@ -162,10 +172,30 @@ public static void ShowMainWindow()

public static void ExitApplication()
{
Program.InstanceManager?.Dispose();

if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) {
desktop.Shutdown();
}
}

private void OnIpcCommandReceived(object sender, IpcCommand command)
{
_commandHandler.Process(command);
}

private void ProcessStartupCommands()
{
var opts = Program.StartupOptions;
if (opts == null || !opts.HasCommands)
return;

Dispatcher.UIThread.Post(() => {
if (opts.StartTimers?.Any() == true) {
_commandHandler.Process(IpcCommand.StartTimer(opts.StartTimers));
}
});
}

private void DisableAvaloniaDataAnnotationValidation()
{
Expand Down
45 changes: 45 additions & 0 deletions src/TimeToKill.App/Cli/CliOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using System;
using System.Collections.Generic;
using CommandLine;
using CommandLine.Text;

namespace TimeToKill.App.Cli;

public class CliOptions
{
[Option("start-timer", Required = false, HelpText = "Start timer(s) by GUID or unique name. Repeatable.")]
public IEnumerable<string> StartTimers { get; set; }

// Future:
// [Option("stop-timer", Required = false, HelpText = "Stop timer(s) by GUID or unique name. Repeatable.")]
// public IEnumerable<string> StopTimers { get; set; }

// [Option("list", Required = false, Default = false, HelpText = "List all configured timer presets.")]
// public bool List { get; set; }

[Option("help", Default = false, HelpText = "Display this help text.")]
public bool Help { get; set; }

public bool HasCommands => StartTimers?.GetEnumerator().MoveNext() == true;

private static Parser CreateParser() => new Parser(s => {
s.AutoHelp = false;
s.AutoVersion = false;
});

public static CliOptions Parse(string[] args)
{
var parseResult = CreateParser().ParseArguments<CliOptions>(args);
if (parseResult.Tag == ParserResultType.NotParsed)
return null;
return parseResult.Value;
}

public static void PrintHelp()
{
var result = CreateParser().ParseArguments<CliOptions>(Array.Empty<string>());
var helpText = new HelpText("TimeToKill");
helpText.AddOptions(result);
Console.WriteLine(helpText);
}
}
64 changes: 64 additions & 0 deletions src/TimeToKill.App/Cli/CommandHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
using System;
using System.Linq;
using Avalonia.Threading;
using TimeToKill.App.Services;
using TimeToKill.App.ViewModels;

namespace TimeToKill.App.Cli;

public class CommandHandler
{
private readonly PresetRepository _presetRepository;
private readonly MainWindowViewModel _viewModel;

public CommandHandler(PresetRepository presetRepository, MainWindowViewModel viewModel)
{
_presetRepository = presetRepository;
_viewModel = viewModel;
}

public void Process(IpcCommand command)
{
switch (command.CommandType) {
case IpcCommandType.StartTimer:
HandleStartTimer(command);
break;
}
}

private void HandleStartTimer(IpcCommand command)
{
var presets = _presetRepository.LoadPresets();
var resolver = new IdentifierResolver(presets);

foreach (var identifier in command.Arguments) {
var (success, preset, error) = resolver.Resolve(identifier);

if (!success) {
Notify(error);
continue;
}

Dispatcher.UIThread.Post(() => {
var vm = _viewModel.Presets.FirstOrDefault(p => p.Id == preset.Id);
if (vm != null) {
vm.StartCommand.Execute(null);
var name = preset.DisplayLabel;
if (string.IsNullOrWhiteSpace(name))
name = preset.ProcessName;
Notify($"Started timer: {name}");
} else {
Notify($"Preset found but not loaded: {identifier}");
}
});
}
}

private void Notify(string message)
{
Dispatcher.UIThread.Post(() => {
_viewModel.LastNotification = message;
_viewModel.HasNotification = true;
});
}
}
63 changes: 63 additions & 0 deletions src/TimeToKill.App/Cli/IdentifierResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using System;
using System.Collections.Generic;
using System.Linq;
using TimeToKill.Models;
using TimeToKill.Tools;

namespace TimeToKill.App.Cli;

public class IdentifierResolver
{
private readonly List<TimerPreset> _presets;

public IdentifierResolver(List<TimerPreset> presets)
{
_presets = presets;
}

public (bool Success, TimerPreset Preset, string Error) Resolve(string identifier)
{
if (string.IsNullOrWhiteSpace(identifier))
return (false, null, "Identifier is empty");

// 1. Try Guid
if (Guid.TryParse(identifier, out var guid)) {
var preset = _presets.FirstOrDefault(p => p.Id == guid);
return preset != null
? (true, preset, null)
: (false, null, $"No preset with ID '{identifier}'");
}

// 2. DisplayLabel (case-insensitive, skip empty)
var labelMatches = _presets
.Where(p => !string.IsNullOrWhiteSpace(p.DisplayLabel) && p.DisplayLabel.Equals(identifier, StringComparison.OrdinalIgnoreCase))
.ToList();

if (labelMatches.Count == 1)
return (true, labelMatches[0], null);
if (labelMatches.Count > 1)
return (false, null, $"Ambiguous: {labelMatches.Count} presets match label '{identifier}'");

// 3. ProcessName base name without extension (e.g. "discord" matches "discord.exe")
var baseMatches = _presets
.Where(p => ProcessNameHelper.GetBaseNameWithoutExtension(p.ProcessName).Equals(identifier, StringComparison.OrdinalIgnoreCase))
.ToList();

if (baseMatches.Count == 1)
return (true, baseMatches[0], null);
if (baseMatches.Count > 1)
return (false, null, $"Ambiguous: {baseMatches.Count} presets match process '{identifier}'");

// 4. ProcessName exe name (e.g. "discord.exe" matches "discord.exe")
var exeMatches = _presets
.Where(p => ProcessNameHelper.GetExeName(p.ProcessName).Equals(identifier, StringComparison.OrdinalIgnoreCase))
.ToList();

if (exeMatches.Count == 1)
return (true, exeMatches[0], null);
if (exeMatches.Count > 1)
return (false, null, $"Ambiguous: {exeMatches.Count} presets match exe '{identifier}'");

return (false, null, $"No preset found matching '{identifier}'");
}
}
24 changes: 24 additions & 0 deletions src/TimeToKill.App/Cli/IpcProtocol.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System.Collections.Generic;
using System.Linq;

namespace TimeToKill.App.Cli;

public enum IpcCommandType
{
StartTimer = 1,
// Future: StopTimer = 2, ListTimers = 3
}

public class IpcCommand
{
public IpcCommandType CommandType { get; set; }
public List<string> Arguments { get; set; } = new();

public static IpcCommand StartTimer(IEnumerable<string> identifiers)
{
return new IpcCommand {
CommandType = IpcCommandType.StartTimer,
Arguments = identifiers.ToList()
};
}
}
99 changes: 99 additions & 0 deletions src/TimeToKill.App/Cli/SingleInstanceManager.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
using System;
using System.IO;
using System.IO.Pipes;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;

namespace TimeToKill.App.Cli;

public class SingleInstanceManager : IDisposable
{
private const string MutexName = "TimeToKill_SingleInstance";
private const string PipeName = "TimeToKill_Command";
private const int PipeConnectionTimeoutMs = 3000;

private Mutex _mutex;
private bool _isFirstInstance;
private CancellationTokenSource _pipeCancellation;
private Task _pipeServerTask;

public event EventHandler<IpcCommand> CommandReceived;

public bool IsFirstInstance => _isFirstInstance;

public bool TryAcquireInstance()
{
_mutex = new Mutex(true, MutexName, out _isFirstInstance);
return _isFirstInstance;
}

public void StartPipeServer()
{
if (!_isFirstInstance)
throw new InvalidOperationException("Cannot start pipe server on secondary instance.");

_pipeCancellation = new CancellationTokenSource();
_pipeServerTask = Task.Run(() => PipeServerLoop(_pipeCancellation.Token));
}

private async Task PipeServerLoop(CancellationToken cancellation)
{
while (!cancellation.IsCancellationRequested) {
try {
await using var server = new NamedPipeServerStream(
PipeName, PipeDirection.In, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous);
await server.WaitForConnectionAsync(cancellation);
await HandleClientConnection(server);
} catch (OperationCanceledException) {
break;
} catch {
// Connection-level error — backoff and re-listen
try { await Task.Delay(1000, cancellation); }
catch (OperationCanceledException) { break; }
}
}
}

private async Task HandleClientConnection(NamedPipeServerStream server)
{
try {
using var reader = new StreamReader(server, Encoding.UTF8);
var json = await reader.ReadToEndAsync();
var command = JsonSerializer.Deserialize<IpcCommand>(json);
if (command != null) {
CommandReceived?.Invoke(this, command);
}
} catch {
// Malformed message — ignore
}
}

public static async Task<bool> SendCommandToRunningInstance(IpcCommand command)
{
try {
await using var client = new NamedPipeClientStream(".", PipeName, PipeDirection.Out);
await client.ConnectAsync(PipeConnectionTimeoutMs);
var json = JsonSerializer.Serialize(command);
await using var writer = new StreamWriter(client, Encoding.UTF8);
await writer.WriteAsync(json);
await writer.FlushAsync();
return true;
} catch {
return false;
}
}

public void Dispose()
{
_pipeCancellation?.Cancel();
try { _pipeServerTask?.Wait(TimeSpan.FromSeconds(2)); } catch { }
_pipeCancellation?.Dispose();

if (_isFirstInstance) {
try { _mutex?.ReleaseMutex(); } catch { }
}
_mutex?.Dispose();
}
}
Loading