This commit is contained in:
2025-10-26 10:40:21 -05:00
commit 45b9d70c90
14 changed files with 1043 additions and 0 deletions

125
.gitignore vendored Normal file
View File

@@ -0,0 +1,125 @@
## Build results
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
TestResults/
artifacts/
*.VisualState.xml
*.userprefs
## Rider / VS Code
.idea/
.vscode/
*.suo
*.user
*.userosscache
*.sln.docstates
## VS Working / Cache directories
.vs/
_ReSharper*/
_ReSharper.Caches/
*.DotSettings.user
## Build Artifacts
*.dll
*.exe
*.appx
*.appxbundle
*.msix
*.msixbundle
*.log
*.cache
*.pdb
*.mdb
## NuGet
*.nupkg
*.snupkg
*.nuspec
.nuget/
packages/
*.nuget.props
*.nuget.targets
## Others
*.dbmdl
*.bak
*.swp
*.tmp
*.temp
*.pidb
*.svclog
*.scc
*.orig
*.rej
*.dif
*.patch
## ASP.NET and Blazor
node_modules/
wwwroot/dist/
wwwroot/lib/
wwwroot/**/*.map
## Generated code
Generated_Code/
ServiceFabricBackup/
ClientBin/
## Publish artifacts
publish/
dist/
out/
*.publishsettings
app.publish/
## Azure
*.azurePubxml
PublishProfiles/
App_Data/
*.Publish.xml
## Windows
Thumbs.db
ehthumbs.db
Desktop.ini
$RECYCLE.BIN/
*.stackdump
## macOS
.DS_Store
.AppleDouble
.LSOverride
## Logs
*.log
*.tlog
## Coverage
*.coverage
*.coveragexml
*.opencover.xml
coverage*.json
coverage*.xml
coverage*.info
## Temporary files
~$*
*.tmp
*.bak
*.swp
*.swo
*.DS_Store
## Git
*.orig
*.rej
*.diff
*.patch
## Environment files
.env
.env.local
.env.*.local

8
App.xaml Normal file
View File

@@ -0,0 +1,8 @@
<Application x:Class="Bedtimer.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
ShutdownMode="OnExplicitShutdown"
Startup="OnStartup"
Exit="OnExit">
<Application.Resources/>
</Application>

55
App.xaml.cs Normal file
View File

@@ -0,0 +1,55 @@
using System;
using System.Linq;
using System.Threading;
using System.Windows;
namespace Bedtimer
{
public partial class App : Application
{
private Mutex? _single;
private TrayHost? _tray;
private void OnStartup(object sender, StartupEventArgs e)
{
var args = Environment.GetCommandLineArgs();
// 1) Elevated branches (exit immediately after doing the thing)
if (args.Length > 1 && args[1] == "--save-admin")
{
Config.SaveFromArgs(args);
Shutdown();
return;
}
// Handle elevated autostart toggle: Bedtimer.exe --autostart-admin on|off
AutoStart.HandleAdminArgs(args); // returns normally when not matching
// 2) Single-instance guard
_single = new Mutex(true, @"Global\Bedtimer_Singleton", out bool isNew);
if (!isNew)
{
Shutdown();
return;
}
// 3) Optional bootstrap: enable autostart only if current process is admin
// (Non-admin users won't be prompted on first run; they can toggle from UI later.)
if (!AutoStart.IsEnabled() && Config.IsProcessElevated())
{
// Will set HKLM\...\Run directly since were already elevated
AutoStart.SetEnabled(true);
}
// 4) Start tray + overlay
_tray = new TrayHost();
_tray.Init();
}
private void OnExit(object sender, ExitEventArgs e)
{
_tray?.Dispose();
_single?.Dispose();
}
}
}

85
AutoStart.cs Normal file
View File

@@ -0,0 +1,85 @@
using Microsoft.Win32;
using System;
using System.Diagnostics;
namespace Bedtimer
{
public static class AutoStart
{
private const string RunKey = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Run";
private const string ValueName = "Bedtimer";
// Public API used by UI
public static bool IsEnabled() => ExistsHKLM();
public static void SetEnabled(bool enabled)
{
try
{
if (enabled) EnsureHKLM();
else DisableHKLM();
}
catch (UnauthorizedAccessException)
{
// Relaunch self elevated to write HKLM
var exe = Environment.ProcessPath!;
var args = enabled ? "--autostart-admin on" : "--autostart-admin off";
Process.Start(new ProcessStartInfo(exe, args)
{
UseShellExecute = true,
Verb = "runas"
});
}
}
// Branch the app handles on startup (see App.xaml.cs patch below)
public static void HandleAdminArgs(string[] args)
{
// args: ["--autostart-admin", "on"|"off"]
if (args.Length >= 3 && args[1] == "--autostart-admin")
{
if (string.Equals(args[2], "on", StringComparison.OrdinalIgnoreCase)) EnsureHKLM();
else DisableHKLM();
Environment.Exit(0);
}
}
// -------- Internal HKLM helpers (explicit registry view) --------
private static RegistryKey OpenHKLM()
{
var view = Environment.Is64BitOperatingSystem ? RegistryView.Registry64 : RegistryView.Registry32;
return RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, view);
}
private static bool ExistsHKLM()
{
using var root = OpenHKLM();
using var key = root.OpenSubKey(RunKey, false);
return key?.GetValue(ValueName) is string;
}
private static void EnsureHKLM()
{
using var root = OpenHKLM();
using var key = root.CreateSubKey(RunKey, true);
var exe = Environment.ProcessPath!;
var target = $"\"{exe}\""; // no args; single EXE handles all
key.SetValue(ValueName, target, RegistryValueKind.String);
// (Optional) Clean up any old per-user Run entries so we don't double-start
try
{
using var hkcu = Registry.CurrentUser.CreateSubKey(RunKey, true);
hkcu.DeleteValue(ValueName, false);
}
catch { /* ignore */ }
}
private static void DisableHKLM()
{
using var root = OpenHKLM();
using var key = root.CreateSubKey(RunKey, true);
key.DeleteValue(ValueName, false);
}
}
}

12
Bedtimer.csproj Normal file
View File

@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<Platforms>AnyCPU;x64</Platforms>
</PropertyGroup>
</Project>

31
Bedtimer.sln Normal file
View File

@@ -0,0 +1,31 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.14.36518.9
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bedtimer", "Bedtimer.csproj", "{B2F2781D-A9DC-4225-BD3F-43CF485CC5BC}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{B2F2781D-A9DC-4225-BD3F-43CF485CC5BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B2F2781D-A9DC-4225-BD3F-43CF485CC5BC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B2F2781D-A9DC-4225-BD3F-43CF485CC5BC}.Debug|x64.ActiveCfg = Debug|x64
{B2F2781D-A9DC-4225-BD3F-43CF485CC5BC}.Debug|x64.Build.0 = Debug|x64
{B2F2781D-A9DC-4225-BD3F-43CF485CC5BC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B2F2781D-A9DC-4225-BD3F-43CF485CC5BC}.Release|Any CPU.Build.0 = Release|Any CPU
{B2F2781D-A9DC-4225-BD3F-43CF485CC5BC}.Release|x64.ActiveCfg = Release|x64
{B2F2781D-A9DC-4225-BD3F-43CF485CC5BC}.Release|x64.Build.0 = Release|x64
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {F77F2FC4-235E-4350-9B69-EA23D2113489}
EndGlobalSection
EndGlobal

121
Config.cs Normal file
View File

@@ -0,0 +1,121 @@
using Microsoft.Win32;
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Security.Principal;
using System.Threading;
namespace Bedtimer
{
public sealed class Config
{
public int BedMinutes { get; set; } = 22 * 60 + 30; // 10:30 PM
public int WarnLead { get; set; } = 60;
public int FlashLead { get; set; } = 20;
public int LockDelay { get; set; } = 5;
private const string RegPath = @"SOFTWARE\Whitlocktech\Bedtimer";
// --- Public API -------------------------------------------------------
public static Config LoadOrCreate()
{
// Read only; do NOT write defaults here.
using var k = OpenHKLM().OpenSubKey(RegPath, writable: false);
if (k == null) return new Config(); // defaults in memory only
int Get(string n, int d) => (k.GetValue(n) is int v) ? v : d;
return new Config
{
BedMinutes = Get(nameof(BedMinutes), 22 * 60 + 30),
WarnLead = Get(nameof(WarnLead), 60),
FlashLead = Get(nameof(FlashLead), 20),
LockDelay = Get(nameof(LockDelay), 5),
};
}
public void Save()
{
try
{
using var root = OpenHKLM();
using var k = root.CreateSubKey(RegPath, writable: true);
k.SetValue(nameof(BedMinutes), BedMinutes, RegistryValueKind.DWord);
k.SetValue(nameof(WarnLead), WarnLead, RegistryValueKind.DWord);
k.SetValue(nameof(FlashLead), FlashLead, RegistryValueKind.DWord);
k.SetValue(nameof(LockDelay), LockDelay, RegistryValueKind.DWord);
Signals.RaiseConfigChanged();
}
catch (UnauthorizedAccessException)
{
// Relaunch self elevated to write HKLM in the SAME registry view
var exe = Environment.ProcessPath!;
var args = $"--save-admin {BedMinutes} {WarnLead} {FlashLead} {LockDelay}";
Process.Start(new ProcessStartInfo(exe, args)
{
UseShellExecute = true,
Verb = "runas"
});
}
}
public static void SaveFromArgs(string[] args)
{
// Accept: Bedtimer.exe --save-admin <bed> <warn> <flash> <delay>
// Find the flag and read the 4 numbers after it.
int i = Array.FindIndex(args, a => string.Equals(a, "--save-admin", StringComparison.OrdinalIgnoreCase));
if (i < 0 || args.Length < i + 5) return;
var cfg = new Config
{
BedMinutes = int.Parse(args[i + 1]),
WarnLead = int.Parse(args[i + 2]),
FlashLead = int.Parse(args[i + 3]),
LockDelay = int.Parse(args[i + 4]),
};
using var root = OpenHKLM();
using var k = root.CreateSubKey(RegPath, writable: true);
k.SetValue(nameof(BedMinutes), cfg.BedMinutes, RegistryValueKind.DWord);
k.SetValue(nameof(WarnLead), cfg.WarnLead, RegistryValueKind.DWord);
k.SetValue(nameof(FlashLead), cfg.FlashLead, RegistryValueKind.DWord);
k.SetValue(nameof(LockDelay), cfg.LockDelay, RegistryValueKind.DWord);
Signals.RaiseConfigChanged();
}
// Keep %AppData% for state/logs
public static string Dir => System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"Whitlocktech", "Bedtimer");
// --- Helpers ----------------------------------------------------------
private static RegistryKey OpenHKLM()
{
// Always open the correct view explicitly to avoid WOW6432 redirection issues
var view = Environment.Is64BitOperatingSystem ? RegistryView.Registry64 : RegistryView.Registry32;
return RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, view);
}
public static bool IsProcessElevated()
{
using var id = WindowsIdentity.GetCurrent();
var p = new WindowsPrincipal(id);
return p.IsInRole(WindowsBuiltInRole.Administrator);
}
}
public static class Signals
{
public const string ConfigChangedName = @"Local\Bedtimer_Config_Changed";
public static void RaiseConfigChanged()
{
using var ev = new EventWaitHandle(false, EventResetMode.AutoReset, ConfigChangedName);
ev.Set();
}
public static EventWaitHandle SubscribeConfigChanged()
=> new EventWaitHandle(false, EventResetMode.AutoReset, ConfigChangedName);
}
}

43
MainWindow.xaml Normal file
View File

@@ -0,0 +1,43 @@
<Window x:Class="Bedtimer.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Bedtimer Settings" Width="380" Height="260" ResizeMode="NoResize"
WindowStartupLocation="CenterScreen">
<Grid Margin="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<TextBlock Text="Bedtime:" VerticalAlignment="Center" Margin="0,0,8,0"/>
<ComboBox x:Name="BedHour" Width="60"/>
<TextBlock Text=":" VerticalAlignment="Center" Margin="4,0"/>
<ComboBox x:Name="BedMinute" Width="60"/>
<ComboBox x:Name="AmPm" Width="70" Margin="8,0,0,0"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Grid.Row="1" Margin="0,0,0,8">
<TextBlock Text="Warn lead (min):" VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBox x:Name="WarnLead" Width="80"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Grid.Row="2" Margin="0,0,0,8">
<TextBlock Text="Flash lead (min):" VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBox x:Name="FlashLead" Width="80"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Grid.Row="3" Margin="0,0,0,8">
<TextBlock Text="Lock delay (min after bedtime):" VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBox x:Name="LockDelay" Width="80"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Grid.Row="4" HorizontalAlignment="Right">
<Button Content="Save" Width="100" Margin="0,0,8,0" Click="Save_Click"/>
<Button Content="Close" Width="100" Click="Close_Click"/>
</StackPanel>
</Grid>
</Window>

64
MainWindow.xaml.cs Normal file
View File

@@ -0,0 +1,64 @@
using System;
using System.Linq;
using System.Windows;
namespace Bedtimer
{
public partial class MainWindow : Window
{
private Config _cfg = null!;
public MainWindow()
{
InitializeComponent();
_cfg = Config.LoadOrCreate();
BedHour.ItemsSource = Enumerable.Range(1, 12);
BedMinute.ItemsSource = new[] { "00", "05", "10", "15", "20", "25", "30", "35", "40", "45", "50", "55" };
AmPm.ItemsSource = new[] { "AM", "PM" };
// load values
var (h, m, am) = ToClock(_cfg.BedMinutes);
BedHour.SelectedItem = h;
BedMinute.SelectedItem = m.ToString("00");
AmPm.SelectedItem = am ? "AM" : "PM";
WarnLead.Text = _cfg.WarnLead.ToString();
FlashLead.Text = _cfg.FlashLead.ToString();
LockDelay.Text = _cfg.LockDelay.ToString();
}
private static (int hour, int minute, bool am) ToClock(int minutes)
{
int hh24 = minutes / 60;
int mm = minutes % 60;
bool am = hh24 < 12;
int hh12 = hh24 % 12; if (hh12 == 0) hh12 = 12;
return (hh12, mm, am);
}
private static int ToMinutes(int hour12, int minute, bool am)
{
int hh24 = (hour12 % 12) + (am ? 0 : 12);
return hh24 * 60 + minute;
}
private void Save_Click(object sender, RoutedEventArgs e)
{
int hour = (int)(BedHour.SelectedItem ?? 10);
int minute = int.Parse((string?)BedMinute.SelectedItem ?? "30");
bool am = ((string?)AmPm.SelectedItem ?? "PM") == "AM";
_cfg.BedMinutes = ToMinutes(hour, minute, am);
_cfg.WarnLead = int.TryParse(WarnLead.Text, out var w) ? Math.Max(1, w) : 60;
_cfg.FlashLead = int.TryParse(FlashLead.Text, out var f) ? Math.Max(1, f) : 20;
_cfg.LockDelay = int.TryParse(LockDelay.Text, out var d) ? Math.Max(0, d) : 5;
_cfg.Save(); // non-admin -> triggers UAC; admin -> writes directly
_cfg = Config.LoadOrCreate();
MessageBox.Show(this, "Saved.", "Bedtimer", MessageBoxButton.OK, MessageBoxImage.Information);
}
private void Close_Click(object sender, RoutedEventArgs e) => Close();
}
}

73
OverlayManager.cs Normal file
View File

@@ -0,0 +1,73 @@
using Microsoft.Win32;
using System;
using System.Threading;
using System.Windows;
namespace Bedtimer
{
public sealed class OverlayManager : IDisposable
{
private Config _cfg = null!;
private OverlayWindow? _overlay;
private Thread? _cfgThread;
private string _cycleId = "";
public void Start()
{
_cfg = Config.LoadOrCreate();
BuildOverlay();
StartConfigListener();
// React to system time changes / DST
SystemEvents.TimeChanged += (_, __) => Application.Current.Dispatcher.Invoke(RecomputeSchedule);
}
private void BuildOverlay()
{
Application.Current.Dispatcher.Invoke(() =>
{
_overlay ??= new OverlayWindow();
_overlay.Show(); // safe: collapsed until T-60
RecomputeSchedule();
});
}
private void StartConfigListener()
{
_cfgThread = new Thread(() =>
{
using var ev = Signals.SubscribeConfigChanged();
while (true)
{
ev.WaitOne();
Application.Current.Dispatcher.Invoke(RecomputeSchedule);
}
});
_cfgThread.IsBackground = true;
_cfgThread.Start();
}
private void RecomputeSchedule()
{
_cfg = Config.LoadOrCreate();
var now = DateTime.Now;
var bedToday = now.Date.AddMinutes(_cfg.BedMinutes);
var bedT = (now <= bedToday) ? bedToday : bedToday.AddDays(1);
var warnT = bedT.AddMinutes(-_cfg.WarnLead);
var flashT = bedT.AddMinutes(-_cfg.FlashLead);
var lockT = bedT.AddMinutes(+_cfg.LockDelay);
// one lock per calendar day (next bedtimes date)
_cycleId = bedT.ToString("yyyy-MM-dd");
_overlay!.SetSchedule(_cycleId, warnT, flashT, bedT, lockT);
}
public void Dispose()
{
// overlay lives/dies with app; nothing extra
}
}
}

15
OverlayWindow.xaml Normal file
View File

@@ -0,0 +1,15 @@
<Window x:Class="Bedtimer.OverlayWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
AllowsTransparency="True" Background="Transparent"
WindowStyle="None" Topmost="True" ShowInTaskbar="False"
Width="360" Height="96">
<Grid>
<Border x:Name="HudPanel" CornerRadius="10" Padding="12" Background="#66000000" Visibility="Collapsed">
<TextBlock x:Name="HudText" FontSize="24" FontWeight="SemiBold" Foreground="White"/>
</Border>
<Border x:Name="BedLabel" CornerRadius="10" Padding="12" Background="#99000000" Visibility="Collapsed">
<TextBlock Text="Its bedtime." FontSize="26" FontWeight="Bold" Foreground="White"/>
</Border>
</Grid>
</Window>

155
OverlayWindow.xaml.cs Normal file
View File

@@ -0,0 +1,155 @@
using System;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Interop;
using System.Windows.Threading;
namespace Bedtimer
{
public partial class OverlayWindow : Window
{
private readonly DispatcherTimer _ui;
private readonly DateTime _procStart = DateTime.Now;
private string _cycleId = "";
private DateTime WarnT, FlashT, BedT, LockT;
public OverlayWindow()
{
InitializeComponent();
Width = 360; Height = 96;
Background = System.Windows.Media.Brushes.Transparent;
WindowStyle = WindowStyle.None;
AllowsTransparency = true;
ShowInTaskbar = false;
Topmost = true;
Loaded += (_, __) =>
{
PositionTopRight(12);
MakeClickThrough(true);
};
_ui = new DispatcherTimer(TimeSpan.FromSeconds(1), DispatcherPriority.Render, (_, __) => Tick(), Dispatcher);
_ui.Start();
}
// unified signature: includes cycleId
public void SetSchedule(string cycleId, DateTime warnT, DateTime flashT, DateTime bedT, DateTime lockT)
{
_cycleId = cycleId;
WarnT = warnT; FlashT = flashT; BedT = bedT; LockT = lockT;
}
private void Tick()
{
var now = DateTime.Now;
// Prevent instant re-lock right after login/app start
bool startupGrace = (now - _procStart) < TimeSpan.FromSeconds(90);
// If we already locked for this cycle, stay hidden until next schedule is computed
if (!string.IsNullOrEmpty(_cycleId) && State.IsLockedForCycle(_cycleId))
{
Visibility = Visibility.Collapsed;
return;
}
if (now < WarnT)
{
Visibility = Visibility.Collapsed;
return;
}
if (now >= WarnT && now < FlashT)
{
Visibility = Visibility.Visible;
MakeClickThrough(true);
ShowClock(now, withCountdown: false);
return;
}
if (now >= FlashT && now < BedT)
{
Visibility = Visibility.Visible;
MakeClickThrough(true);
FlashBriefly(now);
ShowClock(now, withCountdown: true);
return;
}
if (now >= BedT && now < LockT)
{
Visibility = Visibility.Visible;
MakeClickThrough(true);
ShowBedtime();
return;
}
if (now >= LockT)
{
if (!startupGrace && !string.IsNullOrEmpty(_cycleId) && !State.IsLockedForCycle(_cycleId))
{
TryLock();
State.MarkLocked(_cycleId);
}
Visibility = Visibility.Collapsed;
}
}
private void ShowClock(DateTime now, bool withCountdown)
{
BedLabel.Visibility = Visibility.Collapsed;
HudPanel.Visibility = Visibility.Visible;
var t = now.ToString("h:mm tt");
HudText.Text = withCountdown ? $"{t} • Bed in {Pretty(BedT - now)}" : t;
}
private void ShowBedtime()
{
HudPanel.Visibility = Visibility.Collapsed;
BedLabel.Visibility = Visibility.Visible;
}
private static string Pretty(TimeSpan ts)
{
if (ts.TotalMinutes < 1) return $"{ts.Seconds}s";
if (ts.TotalHours < 1) return $"{(int)ts.TotalMinutes}m {ts.Seconds}s";
return $"{(int)ts.TotalHours}h {ts.Minutes}m";
}
private void FlashBriefly(DateTime now)
{
if (now.Second == 0)
{
var orig = Opacity; Opacity = 0.2;
_ = Task.Delay(1000).ContinueWith(_ => Dispatcher.Invoke(() => Opacity = orig));
}
}
[DllImport("user32.dll")] private static extern bool LockWorkStation();
private static void TryLock() { try { LockWorkStation(); } catch { } }
private void PositionTopRight(int margin)
{
var r = System.Windows.SystemParameters.WorkArea;
Left = r.Right - Width - margin;
Top = r.Top + margin;
}
private const int GWL_EXSTYLE = -20, WS_EX_TRANSPARENT = 0x20, WS_EX_TOOLWINDOW = 0x80, WS_EX_NOACTIVATE = 0x08000000;
[DllImport("user32.dll")] private static extern int GetWindowLong(IntPtr hWnd, int nIndex);
[DllImport("user32.dll")] private static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);
private void MakeClickThrough(bool enable)
{
var hwnd = new WindowInteropHelper(this).Handle;
int ex = GetWindowLong(hwnd, GWL_EXSTYLE);
ex |= WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE;
ex = enable ? (ex | WS_EX_TRANSPARENT) : (ex & ~WS_EX_TRANSPARENT);
SetWindowLong(hwnd, GWL_EXSTYLE, ex);
}
}
}

49
State.cs Normal file
View File

@@ -0,0 +1,49 @@
using System;
using System.IO;
using System.Text.Json;
namespace Bedtimer
{
public static class State
{
private sealed class Model
{
public string? LastLockCycleId { get; set; }
public DateTime LastLockUtc { get; set; }
}
private static string PathJson => System.IO.Path.Combine(Config.Dir, "state.json");
private static readonly object _gate = new();
private static Model Load()
{
lock (_gate)
{
Directory.CreateDirectory(Path.GetDirectoryName(PathJson)!);
if (!File.Exists(PathJson)) return new Model();
try { return JsonSerializer.Deserialize<Model>(File.ReadAllText(PathJson)) ?? new Model(); }
catch { return new Model(); }
}
}
private static void Save(Model m)
{
lock (_gate)
{
Directory.CreateDirectory(Path.GetDirectoryName(PathJson)!);
File.WriteAllText(PathJson, JsonSerializer.Serialize(m, new JsonSerializerOptions { WriteIndented = true }));
}
}
public static bool IsLockedForCycle(string cycleId)
{
var m = Load();
return string.Equals(m.LastLockCycleId, cycleId, StringComparison.Ordinal);
}
public static void MarkLocked(string cycleId)
{
Save(new Model { LastLockCycleId = cycleId, LastLockUtc = DateTime.UtcNow });
}
}
}

207
TrayHost.cs Normal file
View File

@@ -0,0 +1,207 @@
using System;
using System.ComponentModel;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Controls; // ContextMenu, MenuItem
using System.Windows.Controls.Primitives; // PlacementMode
using System.Windows.Interop;
namespace Bedtimer
{
public sealed class TrayHost : IDisposable
{
// === Win32 interop ===
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct NOTIFYICONDATA
{
public int cbSize;
public IntPtr hWnd;
public int uID;
public int uFlags;
public int uCallbackMessage;
public IntPtr hIcon;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
public string szTip;
public int dwState;
public int dwStateMask;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
public string szInfo;
public int uTimeoutOrVersion;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)]
public string szInfoTitle;
public int dwInfoFlags;
public Guid guidItem;
public IntPtr hBalloonIcon;
}
private const int NIM_ADD = 0x00000000;
private const int NIM_MODIFY = 0x00000001;
private const int NIM_DELETE = 0x00000002;
private const int NIF_MESSAGE = 0x00000001;
private const int NIF_ICON = 0x00000002;
private const int NIF_TIP = 0x00000004;
private const int WM_APP = 0x8000;
private const int WM_USER = 0x0400;
private const int WM_LBUTTONUP = 0x0202;
private const int WM_LBUTTONDBLCLK = 0x0203;
private const int WM_RBUTTONUP = 0x0205;
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
private static extern bool Shell_NotifyIcon(int dwMessage, ref NOTIFYICONDATA lpdata);
[DllImport("user32.dll")] private static extern bool GetCursorPos(out POINT lpPoint);
[DllImport("user32.dll")] private static extern IntPtr LoadIcon(IntPtr hInstance, IntPtr lpIconName);
private static readonly IntPtr IDI_INFORMATION = (IntPtr)0x7F04; // stock info icon
[StructLayout(LayoutKind.Sequential)]
private struct POINT { public int X; public int Y; }
// === Fields ===
private HwndSource? _src;
private IntPtr _hWnd = IntPtr.Zero;
private IntPtr _hIcon = IntPtr.Zero;
private int _iconId = 1;
private readonly int _callbackMsg = WM_APP + 1;
private ContextMenu? _menu;
private OverlayManager _overlay = null!;
public void Init()
{
// Hidden message window for tray callbacks
var param = new HwndSourceParameters("Bedtimer.TrayHostWindow")
{
Width = 0,
Height = 0,
WindowStyle = unchecked((int)0x80000000), // WS_POPUP
UsesPerPixelOpacity = false
};
_src = new HwndSource(param);
_src.AddHook(WndProc);
_hWnd = _src.Handle;
// Icon (stock system icon to avoid resources)
_hIcon = LoadIcon(IntPtr.Zero, IDI_INFORMATION);
// Add tray icon
var nid = new NOTIFYICONDATA
{
cbSize = Marshal.SizeOf<NOTIFYICONDATA>(),
hWnd = _hWnd,
uID = _iconId,
uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP,
uCallbackMessage = _callbackMsg,
hIcon = _hIcon,
szTip = "Bedtimer"
};
if (!Shell_NotifyIcon(NIM_ADD, ref nid))
throw new Win32Exception("Shell_NotifyIcon(NIM_ADD) failed.");
// Build WPF context menu
_menu = BuildMenu();
// Start overlay manager
_overlay = new OverlayManager();
_overlay.Start();
}
private ContextMenu BuildMenu()
{
var menu = new ContextMenu();
var open = new MenuItem { Header = "Open Settings..." };
open.Click += (_, __) => ShowSettings();
menu.Items.Add(open);
var autostart = new MenuItem { Header = "Start with Windows", IsCheckable = true, IsChecked = AutoStart.IsEnabled() };
autostart.Click += (_, __) =>
{
var next = !AutoStart.IsEnabled();
AutoStart.SetEnabled(next); // may UAC prompt
autostart.IsChecked = AutoStart.IsEnabled(); // refresh state after hop
};
menu.Items.Add(autostart);
menu.Items.Add(new Separator());
var exit = new MenuItem { Header = "Exit" };
exit.Click += (_, __) => Application.Current.Shutdown();
menu.Items.Add(exit);
return menu;
}
private void ShowSettings()
{
// Bring up settings window (single instance)
foreach (Window w in Application.Current.Windows)
if (w is MainWindow mw) { mw.Activate(); return; }
var win = new MainWindow();
win.Show();
win.Activate();
}
// Show WPF ContextMenu at the current cursor position
private void ShowMenuAtCursor()
{
if (_menu == null) return;
if (GetCursorPos(out var pt))
{
_menu.Placement = PlacementMode.AbsolutePoint;
_menu.HorizontalOffset = pt.X;
_menu.VerticalOffset = pt.Y;
_menu.IsOpen = true;
_menu.Focus();
}
}
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
if (msg == _callbackMsg)
{
int code = lParam.ToInt32();
switch (code)
{
case WM_LBUTTONUP:
ShowSettings();
handled = true;
break;
case WM_LBUTTONDBLCLK:
ShowSettings();
handled = true;
break;
case WM_RBUTTONUP:
ShowMenuAtCursor();
handled = true;
break;
}
}
return IntPtr.Zero;
}
public void Dispose()
{
try
{
var nid = new NOTIFYICONDATA
{
cbSize = Marshal.SizeOf<NOTIFYICONDATA>(),
hWnd = _hWnd,
uID = _iconId
};
Shell_NotifyIcon(NIM_DELETE, ref nid);
}
catch { /* ignore */ }
_overlay?.Dispose();
_src?.RemoveHook(WndProc);
_src?.Dispose();
_src = null;
}
}
}