From 9233275e8c4edc4356a2bfb3a68f9f91625d0df4 Mon Sep 17 00:00:00 2001 From: Nathan Coad Date: Wed, 25 Feb 2026 08:27:37 +1100 Subject: [PATCH] Import current workspace --- README.md | 204 +++++ agent.md | 44 + build-caffeine-exe.ps1 | 101 ++ caffeine.ps1 | 1982 ++++++++++++++++++++++++++++++++++++++++ run-caffeine.bat | 13 + sign-caffeine-exe.ps1 | 378 ++++++++ tasks/todo.md | 34 + 7 files changed, 2756 insertions(+) create mode 100644 README.md create mode 100644 agent.md create mode 100644 build-caffeine-exe.ps1 create mode 100644 caffeine.ps1 create mode 100644 run-caffeine.bat create mode 100644 sign-caffeine-exe.ps1 create mode 100644 tasks/todo.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..d8fb781 --- /dev/null +++ b/README.md @@ -0,0 +1,204 @@ +# Portable Caffeine (PowerShell 5.1 + Embedded C#) + +Portable Windows implementation of the Zhorn Software Caffeine utility, built as a single `caffeine.ps1` script with embedded C# (`WinForms` + Win32 API calls). + +## What It Does + +Prevents sleep/idle by either: + +- Sending periodic key pulses (default `F15`) +- Using `SetThreadExecutionState` (`-stes`) + +It also includes a tray icon app, status dialog, timers, rule-based activation, and single-instance app control commands. + +## Files + +- `caffeine.ps1`: main portable app +- `run-caffeine.bat`: launcher that runs with `-ExecutionPolicy Bypass -STA` +- `build-caffeine-exe.ps1`: extracts embedded C# and compiles a standalone `.exe` +- `sign-caffeine-exe.ps1`: creates/reuses a self-signed code-signing cert, optionally trusts it, and signs the `.exe` + +## Requirements + +- Windows +- Windows PowerShell 5.1 +- Desktop session (uses `System.Windows.Forms` + tray icon) + +## Quick Start + +Run with the batch launcher: + +```bat +run-caffeine.bat -showdlg -notify +``` + +Or directly: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -STA -File .\caffeine.ps1 -showdlg -notify +``` + +## Execution Policy Note + +If PowerShell blocks script execution, use the launcher (`run-caffeine.bat`) or run: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -STA -File .\caffeine.ps1 +``` + +Optional: + +```powershell +Unblock-File .\caffeine.ps1 +``` + +## Supported Features / Switches + +Core: + +- `-on` / `-off` +- `-activefor:N` +- `-inactivefor:N` +- `-exitafter:N` +- `-lock` +- `-notify` +- `-showdlg` +- `-ontaskbar` +- `-replace` + +Single-instance app commands: + +- `-appexit` +- `-appon` +- `-appoff` +- `-apptoggle` +- `-apptoggleshowdlg` + +Activity methods: + +- Default F15 key pulse +- `-useshift` +- `-leftshift` +- `-key:NN` / `-keypress:NN` +- `-keyshift[:NN]` +- `-stes` +- `-allowss` + +Conditional activation: + +- `-watchwindow:TEXT` +- `-activeperiods:RANGES` +- `-activehours:RANGES` (alias) +- `-onac` +- `-cpu:N` + +Tray / icon behavior: + +- Custom coffee-cup tray icon (active/inactive variants) +- `-nohicon` keeps the same tray icon in both states +- Double-click tray icon toggles active/inactive + +Compatibility: + +- `-allowlocal` is recognized (compatibility flag) + +## Examples + +```bat +run-caffeine.bat -showdlg -notify +run-caffeine.bat -activefor:30 +run-caffeine.bat -watchwindow:Notepad -on +run-caffeine.bat -activeperiods:08:00-12:00,13:00-17:00 +run-caffeine.bat -stes -allowss +run-caffeine.bat -apptoggle +run-caffeine.bat -replace -off +``` + +## Compile To EXE (Windows) + +You can build a standalone executable from the embedded C# code in `caffeine.ps1`: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\build-caffeine-exe.ps1 +``` + +Output (default): + +- `dist\PortableCaffeine.exe` + +Useful options: + +```powershell +# Keep the extracted .cs file used for compilation +.\build-caffeine-exe.ps1 -KeepExtractedSource + +# Build a console-targeted EXE instead of WinExe +.\build-caffeine-exe.ps1 -ConsoleTarget + +# Custom output name/path +.\build-caffeine-exe.ps1 -OutputDir .\out -ExeName Caffeine.exe +``` + +Notes: + +- The build script looks for `.NET Framework` `csc.exe` on Windows (Framework64 first, then Framework). +- The compiled EXE uses the same tray UI, switches, and behavior as the PowerShell-hosted version. + +## Self-Signed Code Signing (Dev/Test) + +You can sign the compiled EXE with a locally generated self-signed code-signing certificate. + +Default behavior: + +- Reuses an existing cert matching the configured subject (if present) +- Otherwise creates a new self-signed code-signing cert +- Exports the public cert to `dist\PortableCaffeine-dev-signing.cer` +- Installs trust in `CurrentUser\Root` and `CurrentUser\TrustedPublisher` +- Signs `dist\PortableCaffeine.exe` with `Set-AuthenticodeSignature` + +Command: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\sign-caffeine-exe.ps1 +``` + +Machine-wide trust (all users, requires admin): + +```powershell +# Run PowerShell as Administrator +.\sign-caffeine-exe.ps1 -TrustScope LocalMachine +``` + +Notes on admin requirements: + +- `CurrentUser` trust/store operations do not require admin. +- `LocalMachine` trust (`Root` / `TrustedPublisher`) requires an elevated PowerShell session. +- Creating the private key in `LocalMachine\My` also requires admin (`-PrivateKeyStoreScope LocalMachine`). + +Useful options: + +```powershell +# Force creation of a new self-signed certificate instead of reusing an existing one +.\sign-caffeine-exe.ps1 -ForceNewCertificate + +# Sign without installing trust stores (signature will be present but likely untrusted) +.\sign-caffeine-exe.ps1 -TrustScope None + +# Export a PFX (password required) +.\sign-caffeine-exe.ps1 -PfxExportPath .\dist\PortableCaffeine-dev-signing.pfx -PfxPassword (Read-Host -AsSecureString) + +# Use a timestamp server (optional) +.\sign-caffeine-exe.ps1 -TimestampServer http://timestamp.digicert.com +``` + +Security note: + +- Trusting a self-signed certificate in `Root` makes your machine trust anything signed by that certificate. +- Use a dedicated dev/test certificate, protect the private key, and remove trust when no longer needed. +- This is suitable for local/internal testing, not a substitute for a public CA-issued code-signing certificate. + +## Notes + +- This project is intended to be portable (no install required). +- The implementation targets Windows PowerShell 5.1 and Windows desktop APIs. +- Exact visual/behavioral parity with the original Zhorn Caffeine may differ slightly in some edge cases. diff --git a/agent.md b/agent.md new file mode 100644 index 0000000..0e018af --- /dev/null +++ b/agent.md @@ -0,0 +1,44 @@ + +## Workflow Orchestration +### 1. Plan Node Default +- Enter plan mode for ANY non-trivial task (3+ steps or architectural decisions) +- If something goes sideways, STOP and re-plan immediately - don't keep pushing +- Use plan mode for verification steps, not just building +- Write detailed specs upfront to reduce ambiguity +### 2. Subagent Strategy +- Use subagents liberally to keep main context window clean +- Offload research, exploration, and parallel analysis to subagents +- For complex problems, throw more compute at it via subagents +- One tack per subagent for focused execution +### 3. Self-Improvement Loop +- After ANY correction from the user: update tasks/lessons.md +with the pattern +- Write rules for yourself that prevent the same mistake +- Ruthlessly iterate on these lessons until mistake rate drops +- Review lessons at session start for relevant project +### 4. Verification Before Done +- Never mark a task complete without proving it works +- Diff behavior between main and your changes when relevant +- Ask yourself: "Would a staff engineer approve this?" +- Run tests, check logs, demonstrate correctness +### 5. Demand Elegance (Balanced) +- For non-trivial changes: pause and ask "is there a more elegant way?" +- If a fix feels hacky: "Knowing everything I know now, implement the elegant solution" +- Skip this for simple, obvious fixes - don't over-engineer +- Challenge your own work before presenting it +### 6. Autonomous Bug Fizing +- When given a bug report: just fix it. Don't ask for hand-holding +- Point at logs, errors, failing tests - then resolve them +- Zero context switching required from the user +- Go fix failing CI tests without being told how +## Task Management +1. **Plan First**: Write plan to "tasks/todo.md with checkable items +2. **Verify Plan**: Check in before starting implementation +3. **Track Progress**: Mark items complete as you go +4. **Explain Changes**: High-level summary at each step +5. **Document Results**: Add review section to tasks/todo.md" +6. **Capture Lessons**: Update tasks/lessons. md after corrections +## Core Principles +- **Simplicity First**: Make every change as simple as possible. Impact minimal code. +- **No Laziness**: Find root causes. No temporary fixes, Senior developer standards. +- **Minimat Impact**: Changes should only touch what's necessary. Avoid introducing bugs. \ No newline at end of file diff --git a/build-caffeine-exe.ps1 b/build-caffeine-exe.ps1 new file mode 100644 index 0000000..f6838a8 --- /dev/null +++ b/build-caffeine-exe.ps1 @@ -0,0 +1,101 @@ +[CmdletBinding()] +param( + [string]$SourceScript = (Join-Path $PSScriptRoot 'caffeine.ps1'), + [string]$OutputDir = (Join-Path $PSScriptRoot 'dist'), + [string]$BuildDir = (Join-Path $PSScriptRoot 'build'), + [string]$ExeName = 'PortableCaffeine.exe', + [switch]$ConsoleTarget, + [switch]$KeepExtractedSource +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-CSharpFromCaffeineScript { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (-not (Test-Path -LiteralPath $Path)) { + throw "Source script not found: $Path" + } + + $raw = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 + + $pattern = '(?s)\$csharp\s*=\s*@''\r?\n(?.*?)\r?\n''@' + $match = [regex]::Match($raw, $pattern) + if (-not $match.Success) { + throw "Could not locate embedded C# here-string (`$csharp = @' ... '@) in $Path" + } + + return $match.Groups['code'].Value +} + +function Get-CscPath { + $candidates = @( + (Join-Path $env:WINDIR 'Microsoft.NET\Framework64\v4.0.30319\csc.exe'), + (Join-Path $env:WINDIR 'Microsoft.NET\Framework\v4.0.30319\csc.exe') + ) + + foreach ($candidate in $candidates) { + if ($candidate -and (Test-Path -LiteralPath $candidate)) { + return $candidate + } + } + + $cmd = Get-Command csc.exe -ErrorAction SilentlyContinue + if ($cmd) { + return $cmd.Source + } + + throw "csc.exe not found. Install .NET Framework build tools (or run on Windows with .NET Framework 4.x)." +} + +if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { + throw "This build script is intended for Windows because it compiles WinForms code against .NET Framework (csc.exe)." +} + +New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null +New-Item -ItemType Directory -Force -Path $BuildDir | Out-Null + +$csharpSource = Get-CSharpFromCaffeineScript -Path $SourceScript +$generatedCsPath = Join-Path $BuildDir 'PortableCaffeine.generated.cs' +$outExePath = Join-Path $OutputDir $ExeName +$targetKind = if ($ConsoleTarget) { 'exe' } else { 'winexe' } +$cscPath = Get-CscPath + +[System.IO.File]::WriteAllText($generatedCsPath, $csharpSource, (New-Object System.Text.UTF8Encoding($false))) + +$arguments = @( + '/nologo' + "/target:$targetKind" + '/optimize+' + '/platform:anycpu' + "/out:$outExePath" + '/r:System.dll' + '/r:System.Windows.Forms.dll' + '/r:System.Drawing.dll' + $generatedCsPath +) + +Write-Host "Compiling with: $cscPath" +Write-Host "Target: $targetKind" +Write-Host "Output: $outExePath" + +& $cscPath @arguments +$exitCode = $LASTEXITCODE +if ($exitCode -ne 0) { + throw "csc.exe failed with exit code $exitCode" +} + +if (-not $KeepExtractedSource) { + Remove-Item -LiteralPath $generatedCsPath -Force -ErrorAction SilentlyContinue +} + +Write-Host "" +Write-Host "Build succeeded." +Write-Host "EXE: $outExePath" +if ($KeepExtractedSource) { + Write-Host "Extracted source kept at: $generatedCsPath" +} diff --git a/caffeine.ps1 b/caffeine.ps1 new file mode 100644 index 0000000..f1ee5ee --- /dev/null +++ b/caffeine.ps1 @@ -0,0 +1,1982 @@ +$ErrorActionPreference = 'Stop' + +$csharp = @' +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Globalization; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Windows.Forms; + +namespace PortableCaffeine +{ + internal enum RemoteCommandKind + { + None, + AppExit, + AppOn, + AppOff, + AppToggle, + AppToggleShowDialog, + ShowDialog, + ExitForReplace + } + + internal enum ManualOverrideKind + { + None, + ForceActive, + ForceInactive + } + + internal enum EffectiveMode + { + Inactive, + ActiveKeyPulse, + ActiveStesSystemOnly, + ActiveStesSystemAndDisplay + } + + internal sealed class TimeRange + { + public TimeSpan Start; + public TimeSpan End; + + public bool Contains(TimeSpan time) + { + if (Start == End) + { + return true; + } + + if (Start < End) + { + return time >= Start && time < End; + } + + return time >= Start || time < End; + } + + public override string ToString() + { + return Start.ToString(@"hh\:mm") + "-" + End.ToString(@"hh\:mm"); + } + } + + internal sealed class Options + { + public bool ShowHelp; + public bool StartupEnabled = true; + public bool StartupModeExplicit; + public bool LockWorkstation; + public bool UseStes; + public bool AllowScreenSaver; + public bool Notify; + public bool ShowDialog; + public bool OnTaskbar; + public bool NoHIcon; + public bool ReplaceExisting; + public bool OnAcOnly; + public bool AllowLocalCompat; + public bool KeyWithShift; + public string WatchWindowContains; + public int? CpuThresholdPercent; + public double? ExitAfterMinutes; + public double? ActiveForMinutes; + public double? InactiveForMinutes; + public ushort VirtualKey = 0x7E; // F15 default + public List ActivePeriods = new List(); + public RemoteCommandKind AppCommand = RemoteCommandKind.None; + public List UnknownSwitches = new List(); + public List RawArgs = new List(); + + public bool HasAnyAppCommand + { + get { return AppCommand != RemoteCommandKind.None; } + } + } + + internal static class CommandLine + { + public static Options Parse(string[] args) + { + Options options = new Options(); + if (args == null) + { + return options; + } + + for (int i = 0; i < args.Length; i++) + { + string token = args[i]; + if (token == null) + { + continue; + } + + options.RawArgs.Add(token); + + if (!token.StartsWith("-") && !token.StartsWith("/")) + { + options.UnknownSwitches.Add(token); + continue; + } + + string body = token.Substring(1); + string name = body; + string value = null; + + int colon = body.IndexOf(':'); + int equals = body.IndexOf('='); + int sep = -1; + if (colon >= 0 && equals >= 0) + { + sep = Math.Min(colon, equals); + } + else if (colon >= 0) + { + sep = colon; + } + else if (equals >= 0) + { + sep = equals; + } + + if (sep >= 0) + { + name = body.Substring(0, sep); + value = body.Substring(sep + 1); + } + + name = name.Trim().ToLowerInvariant(); + if (value != null) + { + value = value.Trim(); + } + + switch (name) + { + case "?": + case "h": + case "help": + options.ShowHelp = true; + break; + case "on": + options.StartupEnabled = true; + options.StartupModeExplicit = true; + break; + case "off": + options.StartupEnabled = false; + options.StartupModeExplicit = true; + break; + case "lock": + options.LockWorkstation = true; + break; + case "showdlg": + options.ShowDialog = true; + break; + case "ontaskbar": + options.OnTaskbar = true; + break; + case "notify": + options.Notify = true; + break; + case "nohicon": + options.NoHIcon = true; + break; + case "replace": + options.ReplaceExisting = true; + break; + case "onac": + options.OnAcOnly = true; + break; + case "allowlocal": + options.AllowLocalCompat = true; + break; + case "stes": + options.UseStes = true; + break; + case "allowss": + options.AllowScreenSaver = true; + break; + case "watchwindow": + if (!string.IsNullOrEmpty(value)) + { + options.WatchWindowContains = value; + } + break; + case "cpu": + { + int cpu; + if (TryParseInt(value, out cpu)) + { + if (cpu < 0) cpu = 0; + if (cpu > 100) cpu = 100; + options.CpuThresholdPercent = cpu; + } + } + break; + case "exitafter": + options.ExitAfterMinutes = ParseMinutes(value, options.ExitAfterMinutes); + break; + case "activefor": + options.ActiveForMinutes = ParseMinutes(value, options.ActiveForMinutes); + break; + case "inactivefor": + options.InactiveForMinutes = ParseMinutes(value, options.InactiveForMinutes); + break; + case "activehours": + case "activeperiods": + if (!string.IsNullOrEmpty(value)) + { + List parsed = ParseTimeRanges(value); + if (parsed.Count > 0) + { + options.ActivePeriods = parsed; + } + } + break; + case "useshift": + options.VirtualKey = 0x10; // VK_SHIFT + break; + case "leftshift": + options.VirtualKey = 0xA0; // VK_LSHIFT + break; + case "key": + case "keypress": + { + ushort vk; + if (TryParseVirtualKey(value, out vk)) + { + options.VirtualKey = vk; + } + } + break; + case "keyshift": + options.KeyWithShift = true; + if (!string.IsNullOrEmpty(value)) + { + ushort vk; + if (TryParseVirtualKey(value, out vk)) + { + options.VirtualKey = vk; + } + } + break; + case "appexit": + options.AppCommand = RemoteCommandKind.AppExit; + break; + case "appon": + options.AppCommand = RemoteCommandKind.AppOn; + break; + case "appoff": + options.AppCommand = RemoteCommandKind.AppOff; + break; + case "apptoggle": + options.AppCommand = RemoteCommandKind.AppToggle; + break; + case "apptoggleshowdlg": + options.AppCommand = RemoteCommandKind.AppToggleShowDialog; + break; + default: + options.UnknownSwitches.Add(token); + break; + } + } + + return options; + } + + private static double? ParseMinutes(string value, double? fallback) + { + if (string.IsNullOrEmpty(value)) + { + return fallback; + } + + double minutes; + if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out minutes)) + { + return minutes; + } + + return fallback; + } + + private static bool TryParseInt(string value, out int number) + { + number = 0; + if (string.IsNullOrEmpty(value)) + { + return false; + } + + return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out number); + } + + private static bool TryParseVirtualKey(string value, out ushort vk) + { + vk = 0; + if (string.IsNullOrEmpty(value)) + { + return false; + } + + string v = value.Trim(); + try + { + if (v.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + { + vk = Convert.ToUInt16(v.Substring(2), 16); + return true; + } + + vk = Convert.ToUInt16(v, CultureInfo.InvariantCulture); + return true; + } + catch + { + return false; + } + } + + private static List ParseTimeRanges(string value) + { + List list = new List(); + string[] parts = value.Split(new char[] { ',', ';', '|' }, StringSplitOptions.RemoveEmptyEntries); + for (int i = 0; i < parts.Length; i++) + { + string p = parts[i].Trim(); + if (p.Length == 0) + { + continue; + } + + int dash = p.IndexOf('-'); + if (dash <= 0 || dash >= p.Length - 1) + { + continue; + } + + string left = p.Substring(0, dash).Trim(); + string right = p.Substring(dash + 1).Trim(); + + TimeSpan start; + TimeSpan end; + if (!TryParseTime(left, out start) || !TryParseTime(right, out end)) + { + continue; + } + + TimeRange range = new TimeRange(); + range.Start = start; + range.End = end; + list.Add(range); + } + + return list; + } + + private static bool TryParseTime(string text, out TimeSpan time) + { + time = TimeSpan.Zero; + if (string.IsNullOrEmpty(text)) + { + return false; + } + + DateTime dt; + string[] formats = new string[] + { + "H", + "HH", + "H:mm", + "HH:mm", + "H.mm", + "HH.mm", + "Hmm", + "HHmm" + }; + + if (DateTime.TryParseExact(text, formats, CultureInfo.InvariantCulture, DateTimeStyles.None, out dt)) + { + time = dt.TimeOfDay; + return true; + } + + int hour; + if (int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out hour) && hour >= 0 && hour <= 23) + { + time = new TimeSpan(hour, 0, 0); + return true; + } + + return false; + } + } + + internal static class NativeMethods + { + public const int WM_COPYDATA = 0x004A; + public const uint ES_SYSTEM_REQUIRED = 0x00000001; + public const uint ES_DISPLAY_REQUIRED = 0x00000002; + public const uint ES_CONTINUOUS = 0x80000000; + public const uint KEYEVENTF_KEYUP = 0x0002; + + [StructLayout(LayoutKind.Sequential)] + public struct COPYDATASTRUCT + { + public IntPtr dwData; + public int cbData; + public IntPtr lpData; + } + + [StructLayout(LayoutKind.Sequential)] + public struct SYSTEM_POWER_STATUS + { + public byte ACLineStatus; + public byte BatteryFlag; + public byte BatteryLifePercent; + public byte SystemStatusFlag; + public int BatteryLifeTime; + public int BatteryFullLifeTime; + } + + [DllImport("kernel32.dll")] + public static extern uint SetThreadExecutionState(uint esFlags); + + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern IntPtr FindWindow(string lpClassName, string lpWindowName); + + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern IntPtr SendMessage(IntPtr hWnd, int Msg, IntPtr wParam, ref COPYDATASTRUCT lParam); + + [DllImport("user32.dll")] + public static extern IntPtr GetForegroundWindow(); + + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount); + + [DllImport("user32.dll")] + public static extern int GetWindowTextLength(IntPtr hWnd); + + [DllImport("user32.dll")] + public static extern bool LockWorkStation(); + + [DllImport("kernel32.dll")] + public static extern bool GetSystemPowerStatus(out SYSTEM_POWER_STATUS sps); + + [DllImport("user32.dll")] + public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo); + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool DestroyIcon(IntPtr hIcon); + } + + internal static class TrayIconFactory + { + public static Icon CreateCaffeineTrayIcon(bool active) + { + Bitmap bmp = new Bitmap(16, 16); + try + { + using (Graphics g = Graphics.FromImage(bmp)) + { + g.SmoothingMode = SmoothingMode.AntiAlias; + g.Clear(Color.Transparent); + + Color cupFill = active ? Color.FromArgb(68, 118, 184) : Color.FromArgb(140, 145, 152); + Color cupOutline = active ? Color.FromArgb(35, 62, 102) : Color.FromArgb(84, 88, 94); + Color coffeeFill = active ? Color.FromArgb(111, 72, 41) : Color.FromArgb(108, 96, 88); + Color steamColor = active ? Color.FromArgb(245, 248, 252) : Color.FromArgb(188, 191, 196); + Color saucerColor = active ? Color.FromArgb(54, 77, 101) : Color.FromArgb(106, 110, 116); + + using (SolidBrush cupBrush = new SolidBrush(cupFill)) + using (SolidBrush coffeeBrush = new SolidBrush(coffeeFill)) + using (Pen outlinePen = new Pen(cupOutline, 1f)) + using (Pen steamPen = new Pen(steamColor, 1.1f)) + using (Pen saucerPen = new Pen(saucerColor, 1f)) + { + // Mug body + g.FillRectangle(cupBrush, 3, 7, 7, 5); + g.DrawRectangle(outlinePen, 3, 7, 7, 5); + + // Coffee surface + g.FillRectangle(coffeeBrush, 4, 8, 5, 2); + + // Mug handle + g.DrawArc(outlinePen, 8, 7, 4, 4, 285, 255); + + // Saucer + g.DrawLine(saucerPen, 2, 13, 12, 13); + + // Steam + g.DrawBezier(steamPen, 4, 7, 3, 5, 5, 4, 4, 2); + g.DrawBezier(steamPen, 7, 7, 6, 5, 8, 4, 7, 2); + } + + if (!active) + { + using (Pen slashPen = new Pen(Color.FromArgb(210, 186, 53, 53), 1.8f)) + { + g.DrawLine(slashPen, 3, 13, 13, 3); + } + } + } + + IntPtr hIcon = bmp.GetHicon(); + try + { + using (Icon tmp = Icon.FromHandle(hIcon)) + { + return (Icon)tmp.Clone(); + } + } + finally + { + NativeMethods.DestroyIcon(hIcon); + } + } + catch + { + return null; + } + finally + { + bmp.Dispose(); + } + } + } + + internal static class IpcTransport + { + public const string WindowTitle = "PortableCaffeine.HiddenIpcWindow"; + + public static bool Send(string payload) + { + IntPtr hwnd = NativeMethods.FindWindow(null, WindowTitle); + if (hwnd == IntPtr.Zero) + { + return false; + } + + IntPtr hGlobal = IntPtr.Zero; + try + { + string data = payload ?? string.Empty; + hGlobal = Marshal.StringToHGlobalUni(data); + NativeMethods.COPYDATASTRUCT cds = new NativeMethods.COPYDATASTRUCT(); + cds.dwData = IntPtr.Zero; + cds.cbData = (data.Length + 1) * 2; + cds.lpData = hGlobal; + NativeMethods.SendMessage(hwnd, NativeMethods.WM_COPYDATA, IntPtr.Zero, ref cds); + return true; + } + catch + { + return false; + } + finally + { + if (hGlobal != IntPtr.Zero) + { + Marshal.FreeHGlobal(hGlobal); + } + } + } + } + + internal sealed class IpcForm : Form + { + public delegate void PayloadReceivedHandler(object sender, string payload); + public event PayloadReceivedHandler PayloadReceived; + + public IpcForm() + { + this.Text = IpcTransport.WindowTitle; + this.ShowInTaskbar = false; + this.FormBorderStyle = FormBorderStyle.FixedToolWindow; + this.StartPosition = FormStartPosition.Manual; + this.Size = new Size(1, 1); + this.Location = new Point(-32000, -32000); + this.Opacity = 0; + } + + protected override void SetVisibleCore(bool value) + { + base.SetVisibleCore(false); + } + + protected override void OnShown(EventArgs e) + { + this.Hide(); + base.OnShown(e); + } + + protected override void WndProc(ref Message m) + { + if (m.Msg == NativeMethods.WM_COPYDATA) + { + try + { + NativeMethods.COPYDATASTRUCT cds = (NativeMethods.COPYDATASTRUCT)Marshal.PtrToStructure(m.LParam, typeof(NativeMethods.COPYDATASTRUCT)); + if (cds.lpData != IntPtr.Zero && cds.cbData > 0) + { + string payload = Marshal.PtrToStringUni(cds.lpData); + if (PayloadReceived != null) + { + PayloadReceived(this, payload); + } + } + } + catch + { + // Ignore malformed IPC messages. + } + } + + base.WndProc(ref m); + } + } + + internal sealed class StatusForm : Form + { + private Label _statusLabel; + private Label _detailLabel; + private Label _conditionLabel; + private Button _toggleButton; + private Button _revertButton; + private Button _closeButton; + + public event EventHandler ToggleRequested; + public event EventHandler RevertRequested; + + public StatusForm(bool showInTaskbar) + { + this.Text = "Portable Caffeine"; + this.FormBorderStyle = FormBorderStyle.FixedDialog; + this.MaximizeBox = false; + this.MinimizeBox = false; + this.StartPosition = FormStartPosition.CenterScreen; + this.ClientSize = new Size(470, 190); + this.ShowInTaskbar = showInTaskbar; + + _statusLabel = new Label(); + _statusLabel.AutoSize = false; + _statusLabel.Location = new Point(12, 12); + _statusLabel.Size = new Size(446, 28); + _statusLabel.Font = new Font(SystemFonts.MessageBoxFont.FontFamily, 11, FontStyle.Bold); + + _detailLabel = new Label(); + _detailLabel.AutoSize = false; + _detailLabel.Location = new Point(12, 48); + _detailLabel.Size = new Size(446, 44); + + _conditionLabel = new Label(); + _conditionLabel.AutoSize = false; + _conditionLabel.Location = new Point(12, 96); + _conditionLabel.Size = new Size(446, 42); + + _toggleButton = new Button(); + _toggleButton.Text = "Toggle"; + _toggleButton.Location = new Point(12, 148); + _toggleButton.Size = new Size(90, 28); + _toggleButton.Click += delegate(object sender, EventArgs e) + { + if (ToggleRequested != null) + { + ToggleRequested(this, EventArgs.Empty); + } + }; + + _revertButton = new Button(); + _revertButton.Text = "Revert"; + _revertButton.Location = new Point(108, 148); + _revertButton.Size = new Size(90, 28); + _revertButton.Click += delegate(object sender, EventArgs e) + { + if (RevertRequested != null) + { + RevertRequested(this, EventArgs.Empty); + } + }; + + _closeButton = new Button(); + _closeButton.Text = "Close"; + _closeButton.Location = new Point(368, 148); + _closeButton.Size = new Size(90, 28); + _closeButton.Click += delegate(object sender, EventArgs e) + { + this.Hide(); + }; + + this.Controls.Add(_statusLabel); + this.Controls.Add(_detailLabel); + this.Controls.Add(_conditionLabel); + this.Controls.Add(_toggleButton); + this.Controls.Add(_revertButton); + this.Controls.Add(_closeButton); + } + + public void UpdateView(string status, string detail, string condition) + { + _statusLabel.Text = status ?? string.Empty; + _detailLabel.Text = detail ?? string.Empty; + _conditionLabel.Text = condition ?? string.Empty; + } + } + + internal sealed class CaffeineAppContext : ApplicationContext + { + private readonly Options _options; + private readonly Mutex _instanceMutex; + private readonly NotifyIcon _tray; + private readonly Icon _trayIconActive; + private readonly Icon _trayIconInactive; + private readonly ContextMenuStrip _menu; + private readonly IpcForm _ipcForm; + private readonly System.Windows.Forms.Timer _timer; + private readonly DateTime _startedLocal; + private DateTime? _exitAtLocal; + private DateTime? _startupActiveUntilLocal; + private DateTime? _startupInactiveUntilLocal; + private DateTime? _manualOverrideUntilLocal; + private ManualOverrideKind _manualOverrideKind; + private bool _lastEffectiveActive; + private bool _hasLastEffectiveActive; + private bool _lastConditionsMatch; + private string _lastReason = "Initializing"; + private string _lastConditionSummary = "Initializing"; + private string _lastCpuSummary = "CPU condition not configured."; + private DateTime _lastPulseUtc = DateTime.MinValue; + private DateTime _lastStesRefreshUtc = DateTime.MinValue; + private PerformanceCounter _cpuCounter; + private bool _cpuCounterFailed; + private bool _cpuCounterPrimed; + private float _lastCpuPercent; + private StatusForm _statusForm; + private bool _lockPending; + private bool _disposed; + + // Menu references for state updates + private ToolStripMenuItem _miStatus; + private ToolStripMenuItem _miActiveNow; + private ToolStripMenuItem _miInactiveNow; + private ToolStripMenuItem _miRevert; + private ToolStripMenuItem _miShowHide; + private ToolStripMenuItem _miTimed5; + private ToolStripMenuItem _miTimed15; + private ToolStripMenuItem _miTimed30; + private ToolStripMenuItem _miTimed60; + private ToolStripMenuItem _miTimed120; + private ToolStripMenuItem _miTimedInactive5; + private ToolStripMenuItem _miTimedInactive15; + private ToolStripMenuItem _miTimedInactive30; + + public CaffeineAppContext(Options options, Mutex instanceMutex) + { + _options = options; + _instanceMutex = instanceMutex; + _startedLocal = DateTime.Now; + _lockPending = options.LockWorkstation; + + if (options.ExitAfterMinutes.HasValue) + { + _exitAtLocal = _startedLocal.AddMinutes(options.ExitAfterMinutes.Value); + } + if (options.ActiveForMinutes.HasValue) + { + _startupActiveUntilLocal = _startedLocal.AddMinutes(options.ActiveForMinutes.Value); + } + if (options.InactiveForMinutes.HasValue) + { + _startupInactiveUntilLocal = _startedLocal.AddMinutes(options.InactiveForMinutes.Value); + } + + _tray = new NotifyIcon(); + _trayIconActive = TrayIconFactory.CreateCaffeineTrayIcon(true); + _trayIconInactive = TrayIconFactory.CreateCaffeineTrayIcon(false); + _tray.Visible = true; + _tray.Text = "Portable Caffeine"; + _tray.DoubleClick += delegate(object sender, EventArgs e) + { + ToggleManual(); + }; + + _menu = new ContextMenuStrip(); + _tray.ContextMenuStrip = _menu; + BuildMenu(); + SetTrayIcon(false); + + _ipcForm = new IpcForm(); + _ipcForm.PayloadReceived += OnIpcPayload; + IntPtr dummyHandle = _ipcForm.Handle; + _ipcForm.Show(); + _ipcForm.Hide(); + + if (_options.CpuThresholdPercent.HasValue) + { + TryInitCpuCounter(); + } + + _timer = new System.Windows.Forms.Timer(); + _timer.Interval = 1000; + _timer.Tick += OnTick; + _timer.Start(); + + if (_options.ShowDialog) + { + EnsureStatusForm(); + ShowStatusForm(true); + } + + // Run one evaluation immediately so state is correct on launch. + TickCore(); + } + + private void BuildMenu() + { + _menu.Items.Clear(); + + _miStatus = new ToolStripMenuItem("Status: Initializing"); + _miStatus.Enabled = false; + + _miActiveNow = new ToolStripMenuItem("Active (indefinite)"); + _miActiveNow.Click += delegate { SetManualOverride(ManualOverrideKind.ForceActive, null); }; + + _miInactiveNow = new ToolStripMenuItem("Inactive (indefinite)"); + _miInactiveNow.Click += delegate { SetManualOverride(ManualOverrideKind.ForceInactive, null); }; + + _miTimed5 = new ToolStripMenuItem("Active for 5 minutes"); + _miTimed5.Click += delegate { SetManualOverride(ManualOverrideKind.ForceActive, 5); }; + _miTimed15 = new ToolStripMenuItem("Active for 15 minutes"); + _miTimed15.Click += delegate { SetManualOverride(ManualOverrideKind.ForceActive, 15); }; + _miTimed30 = new ToolStripMenuItem("Active for 30 minutes"); + _miTimed30.Click += delegate { SetManualOverride(ManualOverrideKind.ForceActive, 30); }; + _miTimed60 = new ToolStripMenuItem("Active for 60 minutes"); + _miTimed60.Click += delegate { SetManualOverride(ManualOverrideKind.ForceActive, 60); }; + _miTimed120 = new ToolStripMenuItem("Active for 120 minutes"); + _miTimed120.Click += delegate { SetManualOverride(ManualOverrideKind.ForceActive, 120); }; + + _miTimedInactive5 = new ToolStripMenuItem("Inactive for 5 minutes"); + _miTimedInactive5.Click += delegate { SetManualOverride(ManualOverrideKind.ForceInactive, 5); }; + _miTimedInactive15 = new ToolStripMenuItem("Inactive for 15 minutes"); + _miTimedInactive15.Click += delegate { SetManualOverride(ManualOverrideKind.ForceInactive, 15); }; + _miTimedInactive30 = new ToolStripMenuItem("Inactive for 30 minutes"); + _miTimedInactive30.Click += delegate { SetManualOverride(ManualOverrideKind.ForceInactive, 30); }; + + _miRevert = new ToolStripMenuItem("Revert To Parameters"); + _miRevert.Click += delegate { RevertToParameters(); }; + + _miShowHide = new ToolStripMenuItem("Show Status"); + _miShowHide.Click += delegate { ToggleStatusForm(); }; + + ToolStripMenuItem about = new ToolStripMenuItem("About"); + about.Click += delegate { ShowAbout(); }; + + ToolStripMenuItem exit = new ToolStripMenuItem("Exit"); + exit.Click += delegate { ExitApplication(); }; + + _menu.Items.Add(_miStatus); + _menu.Items.Add(new ToolStripSeparator()); + _menu.Items.Add(_miActiveNow); + _menu.Items.Add(_miInactiveNow); + _menu.Items.Add(new ToolStripSeparator()); + _menu.Items.Add(_miTimed5); + _menu.Items.Add(_miTimed15); + _menu.Items.Add(_miTimed30); + _menu.Items.Add(_miTimed60); + _menu.Items.Add(_miTimed120); + _menu.Items.Add(new ToolStripSeparator()); + _menu.Items.Add(_miTimedInactive5); + _menu.Items.Add(_miTimedInactive15); + _menu.Items.Add(_miTimedInactive30); + _menu.Items.Add(new ToolStripSeparator()); + _menu.Items.Add(_miRevert); + _menu.Items.Add(_miShowHide); + _menu.Items.Add(new ToolStripSeparator()); + _menu.Items.Add(about); + _menu.Items.Add(exit); + } + + private void EnsureStatusForm() + { + if (_statusForm != null && !_statusForm.IsDisposed) + { + return; + } + + _statusForm = new StatusForm(_options.OnTaskbar); + _statusForm.ToggleRequested += delegate { ToggleManual(); }; + _statusForm.RevertRequested += delegate { RevertToParameters(); }; + _statusForm.FormClosing += delegate(object sender, FormClosingEventArgs e) + { + if (e.CloseReason == CloseReason.UserClosing) + { + e.Cancel = true; + _statusForm.Hide(); + UpdateMenuState(); + } + }; + } + + private void ToggleStatusForm() + { + EnsureStatusForm(); + if (_statusForm.Visible) + { + _statusForm.Hide(); + } + else + { + ShowStatusForm(true); + } + + UpdateMenuState(); + } + + private void ShowStatusForm(bool activate) + { + EnsureStatusForm(); + if (!_statusForm.Visible) + { + _statusForm.Show(); + } + if (activate) + { + _statusForm.WindowState = FormWindowState.Normal; + _statusForm.BringToFront(); + _statusForm.Activate(); + } + UpdateMenuState(); + } + + private void ShowAbout() + { + StringBuilder sb = new StringBuilder(); + sb.AppendLine("Portable Caffeine (PowerShell + embedded C#)"); + sb.AppendLine("Zhorn Caffeine-compatible portable implementation."); + sb.AppendLine(); + sb.AppendLine("Method: " + GetMethodSummary()); + sb.AppendLine("Key: " + FormatVirtualKey(_options.VirtualKey) + (_options.KeyWithShift ? " (with Shift)" : string.Empty)); + if (_options.AllowLocalCompat) + { + sb.AppendLine("Compatibility flag: -allowlocal recognized."); + } + if (_options.UnknownSwitches.Count > 0) + { + sb.AppendLine("Unknown switches ignored: " + string.Join(", ", _options.UnknownSwitches.ToArray())); + } + MessageBox.Show(sb.ToString(), "About Portable Caffeine", MessageBoxButtons.OK, MessageBoxIcon.Information); + } + + private void OnTick(object sender, EventArgs e) + { + TickCore(); + } + + private void TickCore() + { + if (_disposed) + { + return; + } + + DateTime nowLocal = DateTime.Now; + DateTime nowUtc = DateTime.UtcNow; + + if (_lockPending) + { + _lockPending = false; + try + { + NativeMethods.LockWorkStation(); + } + catch + { + // Ignore lock failures. + } + } + + if (_exitAtLocal.HasValue && nowLocal >= _exitAtLocal.Value) + { + ExitApplication(); + return; + } + + ExpireManualOverrideIfNeeded(nowLocal); + + bool conditionsMatch; + string conditionSummary; + bool baseShouldBeActive = EvaluateBaseRules(nowLocal, out conditionsMatch, out conditionSummary); + + bool effectiveActive; + string reason; + EvaluateEffectiveState(nowLocal, baseShouldBeActive, out effectiveActive, out reason); + + _lastConditionsMatch = conditionsMatch; + _lastReason = reason; + _lastConditionSummary = conditionSummary; + + EffectiveMode mode = effectiveActive ? GetActiveMode() : EffectiveMode.Inactive; + ApplyActiveMode(mode, nowUtc); + + if (!_hasLastEffectiveActive || effectiveActive != _lastEffectiveActive) + { + _hasLastEffectiveActive = true; + _lastEffectiveActive = effectiveActive; + SetTrayIcon(effectiveActive); + if (_options.Notify) + { + ShowNotify(effectiveActive ? "Caffeine active" : "Caffeine inactive", reason); + } + } + + UpdateMenuState(); + UpdateStatusForm(); + UpdateTrayTooltip(effectiveActive, reason); + } + + private void ExpireManualOverrideIfNeeded(DateTime nowLocal) + { + if (_manualOverrideUntilLocal.HasValue && nowLocal >= _manualOverrideUntilLocal.Value) + { + _manualOverrideKind = ManualOverrideKind.None; + _manualOverrideUntilLocal = null; + } + } + + private bool EvaluateBaseRules(DateTime nowLocal, out bool conditionsMatch, out string conditionSummary) + { + List parts = new List(); + + bool baseMode = _options.StartupEnabled; + parts.Add("Base: " + (baseMode ? "On" : "Off")); + + if (_options.ActivePeriods != null && _options.ActivePeriods.Count > 0) + { + bool inPeriod = false; + TimeSpan current = nowLocal.TimeOfDay; + for (int i = 0; i < _options.ActivePeriods.Count; i++) + { + if (_options.ActivePeriods[i].Contains(current)) + { + inPeriod = true; + break; + } + } + parts.Add("Period: " + (inPeriod ? "matched" : "outside")); + if (!inPeriod) + { + conditionsMatch = false; + conditionSummary = string.Join(" | ", parts.ToArray()); + return false; + } + } + + if (!string.IsNullOrEmpty(_options.WatchWindowContains)) + { + bool match = ForegroundWindowMatches(_options.WatchWindowContains); + parts.Add("WatchWindow: " + (match ? "matched" : "not matched")); + if (!match) + { + conditionsMatch = false; + conditionSummary = string.Join(" | ", parts.ToArray()); + return false; + } + } + + if (_options.OnAcOnly) + { + bool onAc; + string powerSummary; + onAc = IsOnAc(out powerSummary); + parts.Add(powerSummary); + if (!onAc) + { + conditionsMatch = false; + conditionSummary = string.Join(" | ", parts.ToArray()); + return false; + } + } + + if (_options.CpuThresholdPercent.HasValue) + { + bool cpuOk; + string cpuSummary; + cpuOk = IsCpuAtOrAboveThreshold(_options.CpuThresholdPercent.Value, out cpuSummary); + _lastCpuSummary = cpuSummary; + parts.Add(cpuSummary); + if (!cpuOk) + { + conditionsMatch = false; + conditionSummary = string.Join(" | ", parts.ToArray()); + return false; + } + } + else + { + _lastCpuSummary = "CPU condition not configured."; + } + + conditionsMatch = true; + conditionSummary = string.Join(" | ", parts.ToArray()); + return baseMode; + } + + private void EvaluateEffectiveState(DateTime nowLocal, bool baseShouldBeActive, out bool effectiveActive, out string reason) + { + string timedSource; + ManualOverrideKind timedStartupOverride = GetStartupTimedOverride(nowLocal, out timedSource); + + if (_manualOverrideKind != ManualOverrideKind.None) + { + effectiveActive = _manualOverrideKind == ManualOverrideKind.ForceActive; + if (_manualOverrideUntilLocal.HasValue) + { + reason = "Manual " + (effectiveActive ? "active" : "inactive") + " until " + _manualOverrideUntilLocal.Value.ToString("HH:mm:ss"); + } + else + { + reason = "Manual " + (effectiveActive ? "active" : "inactive"); + } + return; + } + + if (timedStartupOverride != ManualOverrideKind.None) + { + effectiveActive = timedStartupOverride == ManualOverrideKind.ForceActive; + reason = timedSource; + return; + } + + effectiveActive = baseShouldBeActive; + reason = baseShouldBeActive ? "Parameters/rules allow active" : "Parameters/rules inactive"; + } + + private ManualOverrideKind GetStartupTimedOverride(DateTime nowLocal, out string source) + { + source = null; + + bool activeLive = _startupActiveUntilLocal.HasValue && nowLocal < _startupActiveUntilLocal.Value; + bool inactiveLive = _startupInactiveUntilLocal.HasValue && nowLocal < _startupInactiveUntilLocal.Value; + + if (!activeLive && !inactiveLive) + { + return ManualOverrideKind.None; + } + + // If both exist, later end time wins because it was most recently specified in typical use. + if (activeLive && inactiveLive) + { + if (_startupInactiveUntilLocal.Value >= _startupActiveUntilLocal.Value) + { + source = "Startup inactivefor until " + _startupInactiveUntilLocal.Value.ToString("HH:mm:ss"); + return ManualOverrideKind.ForceInactive; + } + source = "Startup activefor until " + _startupActiveUntilLocal.Value.ToString("HH:mm:ss"); + return ManualOverrideKind.ForceActive; + } + + if (activeLive) + { + source = "Startup activefor until " + _startupActiveUntilLocal.Value.ToString("HH:mm:ss"); + return ManualOverrideKind.ForceActive; + } + + source = "Startup inactivefor until " + _startupInactiveUntilLocal.Value.ToString("HH:mm:ss"); + return ManualOverrideKind.ForceInactive; + } + + private EffectiveMode GetActiveMode() + { + if (_options.AllowScreenSaver) + { + return EffectiveMode.ActiveStesSystemOnly; + } + + if (_options.UseStes) + { + return EffectiveMode.ActiveStesSystemAndDisplay; + } + + return EffectiveMode.ActiveKeyPulse; + } + + private void ApplyActiveMode(EffectiveMode mode, DateTime nowUtc) + { + switch (mode) + { + case EffectiveMode.Inactive: + ClearExecutionState(); + return; + case EffectiveMode.ActiveKeyPulse: + PulseKeyIfDue(nowUtc); + return; + case EffectiveMode.ActiveStesSystemOnly: + RefreshStesIfDue(nowUtc, NativeMethods.ES_CONTINUOUS | NativeMethods.ES_SYSTEM_REQUIRED); + return; + case EffectiveMode.ActiveStesSystemAndDisplay: + RefreshStesIfDue(nowUtc, NativeMethods.ES_CONTINUOUS | NativeMethods.ES_SYSTEM_REQUIRED | NativeMethods.ES_DISPLAY_REQUIRED); + return; + } + } + + private void ClearExecutionState() + { + try + { + NativeMethods.SetThreadExecutionState(NativeMethods.ES_CONTINUOUS); + } + catch + { + // Ignore. + } + } + + private void RefreshStesIfDue(DateTime nowUtc, uint flags) + { + if ((nowUtc - _lastStesRefreshUtc).TotalSeconds < 15) + { + return; + } + + _lastStesRefreshUtc = nowUtc; + try + { + NativeMethods.SetThreadExecutionState(flags); + } + catch + { + // Ignore. + } + } + + private void PulseKeyIfDue(DateTime nowUtc) + { + if ((nowUtc - _lastPulseUtc).TotalSeconds < 59) + { + return; + } + + _lastPulseUtc = nowUtc; + SendConfiguredKeyPulse(); + } + + private void SendConfiguredKeyPulse() + { + byte vk = (byte)_options.VirtualKey; + bool pressShiftModifier = _options.KeyWithShift; + + try + { + if (pressShiftModifier && vk != 0x10 && vk != 0xA0) + { + NativeMethods.keybd_event(0x10, 0, 0, UIntPtr.Zero); + } + + NativeMethods.keybd_event(vk, 0, 0, UIntPtr.Zero); + NativeMethods.keybd_event(vk, 0, NativeMethods.KEYEVENTF_KEYUP, UIntPtr.Zero); + + if (pressShiftModifier && vk != 0x10 && vk != 0xA0) + { + NativeMethods.keybd_event(0x10, 0, NativeMethods.KEYEVENTF_KEYUP, UIntPtr.Zero); + } + } + catch + { + // Ignore. + } + } + + private void SetManualOverride(ManualOverrideKind kind, double? minutes) + { + _manualOverrideKind = kind; + if (minutes.HasValue) + { + _manualOverrideUntilLocal = DateTime.Now.AddMinutes(minutes.Value); + } + else + { + _manualOverrideUntilLocal = null; + } + + TickCore(); + } + + private void ToggleManual() + { + bool currentEffective = _hasLastEffectiveActive && _lastEffectiveActive; + SetManualOverride(currentEffective ? ManualOverrideKind.ForceInactive : ManualOverrideKind.ForceActive, null); + } + + private void RevertToParameters() + { + _manualOverrideKind = ManualOverrideKind.None; + _manualOverrideUntilLocal = null; + TickCore(); + } + + private void UpdateMenuState() + { + bool manualActive = _manualOverrideKind == ManualOverrideKind.ForceActive; + bool manualInactive = _manualOverrideKind == ManualOverrideKind.ForceInactive; + bool dialogVisible = _statusForm != null && !_statusForm.IsDisposed && _statusForm.Visible; + + _miActiveNow.Checked = manualActive && !_manualOverrideUntilLocal.HasValue; + _miInactiveNow.Checked = manualInactive && !_manualOverrideUntilLocal.HasValue; + _miTimed5.Checked = manualActive && IsMinutesApproximately(5); + _miTimed15.Checked = manualActive && IsMinutesApproximately(15); + _miTimed30.Checked = manualActive && IsMinutesApproximately(30); + _miTimed60.Checked = manualActive && IsMinutesApproximately(60); + _miTimed120.Checked = manualActive && IsMinutesApproximately(120); + _miTimedInactive5.Checked = manualInactive && IsMinutesApproximately(5); + _miTimedInactive15.Checked = manualInactive && IsMinutesApproximately(15); + _miTimedInactive30.Checked = manualInactive && IsMinutesApproximately(30); + + _miRevert.Enabled = _manualOverrideKind != ManualOverrideKind.None; + _miShowHide.Text = dialogVisible ? "Hide Status" : "Show Status"; + + string stateText = (_hasLastEffectiveActive && _lastEffectiveActive) ? "Active" : "Inactive"; + _miStatus.Text = "Status: " + stateText + " | " + GetMethodSummary(); + } + + private bool IsMinutesApproximately(int minutes) + { + if (!_manualOverrideUntilLocal.HasValue) + { + return false; + } + + double diff = (_manualOverrideUntilLocal.Value - DateTime.Now).TotalMinutes; + return diff > minutes - 1.1 && diff <= minutes + 0.1; + } + + private void UpdateStatusForm() + { + if (_statusForm == null || _statusForm.IsDisposed) + { + return; + } + + string state = (_hasLastEffectiveActive && _lastEffectiveActive) ? "ACTIVE" : "INACTIVE"; + string detail = "Reason: " + _lastReason + Environment.NewLine + + "Mode: " + GetMethodSummary() + + " | Key: " + FormatVirtualKey(_options.VirtualKey) + (_options.KeyWithShift ? " + Shift" : string.Empty); + + if (_manualOverrideKind != ManualOverrideKind.None) + { + detail += Environment.NewLine + "Manual override: " + _manualOverrideKind.ToString() + + (_manualOverrideUntilLocal.HasValue ? (" until " + _manualOverrideUntilLocal.Value.ToString("HH:mm:ss")) : string.Empty); + } + + string condition = _lastConditionSummary; + if (_options.CpuThresholdPercent.HasValue) + { + condition += Environment.NewLine + _lastCpuSummary; + } + if (_options.UnknownSwitches.Count > 0) + { + condition += Environment.NewLine + "Unknown switches ignored: " + string.Join(", ", _options.UnknownSwitches.ToArray()); + } + if (_options.AllowLocalCompat) + { + condition += Environment.NewLine + "Compatibility flag -allowlocal is recognized (no special handling required for local IPC)."; + } + + _statusForm.UpdateView(state, detail, condition); + } + + private void UpdateTrayTooltip(bool active, string reason) + { + string text = "Portable Caffeine: " + (active ? "Active" : "Inactive"); + if (!string.IsNullOrEmpty(reason)) + { + text += " | " + reason; + } + if (text.Length > 63) + { + text = text.Substring(0, 63); + } + _tray.Text = text; + } + + private void SetTrayIcon(bool active) + { + if (active) + { + _tray.Icon = _trayIconActive ?? SystemIcons.Information; + } + else + { + _tray.Icon = _options.NoHIcon + ? (_trayIconActive ?? SystemIcons.Information) + : (_trayIconInactive ?? SystemIcons.Warning); + } + } + + private void ShowNotify(string title, string text) + { + try + { + _tray.BalloonTipTitle = title; + _tray.BalloonTipText = string.IsNullOrEmpty(text) ? string.Empty : text; + _tray.BalloonTipIcon = ToolTipIcon.Info; + _tray.ShowBalloonTip(1500); + } + catch + { + // Ignore notification failures. + } + } + + private void OnIpcPayload(object sender, string payload) + { + if (string.IsNullOrEmpty(payload)) + { + return; + } + + string cmd = payload.Trim(); + if (cmd.Length == 0) + { + return; + } + + if (string.Equals(cmd, "EXIT_REPLACE", StringComparison.Ordinal)) + { + ExitApplication(); + return; + } + + if (cmd.StartsWith("APPCMD:", StringComparison.Ordinal)) + { + string name = cmd.Substring("APPCMD:".Length); + ApplyRemoteCommand(name); + return; + } + + if (string.Equals(cmd, "SHOW", StringComparison.Ordinal)) + { + ShowStatusForm(true); + } + } + + private void ApplyRemoteCommand(string name) + { + if (string.IsNullOrEmpty(name)) + { + return; + } + + if (string.Equals(name, "AppExit", StringComparison.OrdinalIgnoreCase)) + { + ExitApplication(); + return; + } + + if (string.Equals(name, "AppOn", StringComparison.OrdinalIgnoreCase)) + { + SetManualOverride(ManualOverrideKind.ForceActive, null); + return; + } + + if (string.Equals(name, "AppOff", StringComparison.OrdinalIgnoreCase)) + { + SetManualOverride(ManualOverrideKind.ForceInactive, null); + return; + } + + if (string.Equals(name, "AppToggle", StringComparison.OrdinalIgnoreCase)) + { + ToggleManual(); + return; + } + + if (string.Equals(name, "AppToggleShowDialog", StringComparison.OrdinalIgnoreCase)) + { + ToggleManual(); + ShowStatusForm(true); + return; + } + + if (string.Equals(name, "ShowDialog", StringComparison.OrdinalIgnoreCase)) + { + ShowStatusForm(true); + } + } + + private void ExitApplication() + { + if (_disposed) + { + return; + } + + ExitThread(); + } + + protected override void ExitThreadCore() + { + if (_disposed) + { + base.ExitThreadCore(); + return; + } + + _disposed = true; + + try + { + ClearExecutionState(); + } + catch + { + } + + try + { + if (_timer != null) + { + _timer.Stop(); + _timer.Dispose(); + } + } + catch + { + } + + try + { + if (_tray != null) + { + _tray.Visible = false; + _tray.Dispose(); + } + } + catch + { + } + + try + { + if (_trayIconActive != null) + { + _trayIconActive.Dispose(); + } + if (_trayIconInactive != null) + { + _trayIconInactive.Dispose(); + } + } + catch + { + } + + try + { + if (_ipcForm != null && !_ipcForm.IsDisposed) + { + _ipcForm.Close(); + _ipcForm.Dispose(); + } + } + catch + { + } + + try + { + if (_statusForm != null && !_statusForm.IsDisposed) + { + _statusForm.Close(); + _statusForm.Dispose(); + } + } + catch + { + } + + try + { + if (_cpuCounter != null) + { + _cpuCounter.Dispose(); + } + } + catch + { + } + + try + { + if (_instanceMutex != null) + { + _instanceMutex.ReleaseMutex(); + _instanceMutex.Dispose(); + } + } + catch + { + } + + base.ExitThreadCore(); + } + + private void TryInitCpuCounter() + { + try + { + _cpuCounter = new PerformanceCounter("Processor", "% Processor Time", "_Total"); + _cpuCounter.NextValue(); + _cpuCounterPrimed = false; + _cpuCounterFailed = false; + } + catch + { + _cpuCounterFailed = true; + _cpuCounter = null; + } + } + + private bool IsCpuAtOrAboveThreshold(int threshold, out string summary) + { + summary = "CPU >= " + threshold.ToString(CultureInfo.InvariantCulture) + "% required"; + + if (_cpuCounterFailed) + { + summary = "CPU counter unavailable (condition not met)"; + return false; + } + + if (_cpuCounter == null) + { + TryInitCpuCounter(); + if (_cpuCounter == null) + { + summary = "CPU counter unavailable (condition not met)"; + return false; + } + } + + try + { + float next = _cpuCounter.NextValue(); + if (!_cpuCounterPrimed) + { + _cpuCounterPrimed = true; + summary = "CPU warmup sample..."; + return false; + } + _lastCpuPercent = next; + bool ok = next >= threshold; + summary = "CPU " + next.ToString("0.0", CultureInfo.InvariantCulture) + "% vs threshold " + threshold.ToString(CultureInfo.InvariantCulture) + "% (" + (ok ? "matched" : "below") + ")"; + return ok; + } + catch + { + _cpuCounterFailed = true; + summary = "CPU counter failed (condition not met)"; + return false; + } + } + + private bool ForegroundWindowMatches(string needle) + { + try + { + IntPtr hwnd = NativeMethods.GetForegroundWindow(); + if (hwnd == IntPtr.Zero) + { + return false; + } + + int length = NativeMethods.GetWindowTextLength(hwnd); + if (length <= 0) + { + return false; + } + + StringBuilder sb = new StringBuilder(length + 1); + NativeMethods.GetWindowText(hwnd, sb, sb.Capacity); + string title = sb.ToString(); + if (string.IsNullOrEmpty(title)) + { + return false; + } + + return title.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0; + } + catch + { + return false; + } + } + + private bool IsOnAc(out string summary) + { + summary = "Power: unknown"; + try + { + NativeMethods.SYSTEM_POWER_STATUS sps; + if (!NativeMethods.GetSystemPowerStatus(out sps)) + { + summary = "Power status unavailable"; + return false; + } + + if (sps.ACLineStatus == 1) + { + summary = "Power: on AC"; + return true; + } + + if (sps.ACLineStatus == 0) + { + summary = "Power: on battery"; + return false; + } + + summary = "Power: AC unknown (allowing)"; + return true; + } + catch + { + summary = "Power status error"; + return false; + } + } + + private string GetMethodSummary() + { + EffectiveMode mode = GetActiveMode(); + switch (mode) + { + case EffectiveMode.ActiveKeyPulse: + return "KeyPulse"; + case EffectiveMode.ActiveStesSystemOnly: + return "STES(System)"; + case EffectiveMode.ActiveStesSystemAndDisplay: + return "STES(System+Display)"; + default: + return "Inactive"; + } + } + + private static string FormatVirtualKey(ushort vk) + { + return "0x" + vk.ToString("X2", CultureInfo.InvariantCulture); + } + } + + public static class Program + { + private const string MutexName = @"Local\PortableCaffeine.ZhornCompat"; + + [STAThread] + public static int Main(string[] args) + { + return Run(args); + } + + public static int Run(string[] args) + { + Options options = CommandLine.Parse(args); + + if (options.ShowHelp) + { + Console.WriteLine(BuildUsageText()); + return 0; + } + + bool createdNew; + Mutex instanceMutex = null; + + try + { + instanceMutex = new Mutex(true, MutexName, out createdNew); + } + catch (Exception ex) + { + Console.Error.WriteLine("Failed to acquire single-instance mutex: " + ex.Message); + return 2; + } + + if (!createdNew) + { + try + { + if (options.ReplaceExisting) + { + IpcTransport.Send("EXIT_REPLACE"); + instanceMutex.Dispose(); + instanceMutex = null; + + DateTime deadline = DateTime.UtcNow.AddSeconds(8); + while (DateTime.UtcNow < deadline) + { + Thread.Sleep(200); + try + { + instanceMutex = new Mutex(true, MutexName, out createdNew); + if (createdNew) + { + break; + } + instanceMutex.Dispose(); + instanceMutex = null; + } + catch + { + // Keep retrying. + } + } + + if (!createdNew) + { + Console.Error.WriteLine("Could not replace existing instance (timeout)."); + return 3; + } + } + else + { + if (options.HasAnyAppCommand) + { + bool sent = IpcTransport.Send("APPCMD:" + options.AppCommand.ToString()); + return sent ? 0 : 1; + } + + bool showSent = IpcTransport.Send("SHOW"); + return showSent ? 0 : 1; + } + } + catch (Exception ex) + { + Console.Error.WriteLine("Failed to communicate with running instance: " + ex.Message); + return 1; + } + } + + if (createdNew && options.HasAnyAppCommand && !options.ReplaceExisting) + { + // App commands target an existing instance only; no-op if nothing is running. + try + { + instanceMutex.ReleaseMutex(); + } + catch + { + } + instanceMutex.Dispose(); + return 0; + } + + try + { + if (Thread.CurrentThread.GetApartmentState() != ApartmentState.STA) + { + Console.Error.WriteLine("This script must run in an STA PowerShell host (Windows PowerShell 5.1 console is typically STA)."); + return 4; + } + + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + CaffeineAppContext ctx = null; + try + { + ctx = new CaffeineAppContext(options, instanceMutex); + instanceMutex = null; // ownership transferred + Application.Run(ctx); + } + finally + { + if (ctx != null) + { + ctx.Dispose(); + } + } + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine(ex.ToString()); + return 5; + } + finally + { + if (instanceMutex != null) + { + try + { + instanceMutex.ReleaseMutex(); + } + catch + { + } + + try + { + instanceMutex.Dispose(); + } + catch + { + } + } + } + } + + private static string BuildUsageText() + { + StringBuilder sb = new StringBuilder(); + sb.AppendLine("Portable Caffeine (Zhorn-compatible, PowerShell + embedded C#)"); + sb.AppendLine(); + sb.AppendLine("Common switches:"); + sb.AppendLine(" -on / -off Start active or inactive (default active)"); + sb.AppendLine(" -activefor:N Force active for N minutes"); + sb.AppendLine(" -inactivefor:N Force inactive for N minutes"); + sb.AppendLine(" -exitafter:N Exit app after N minutes"); + sb.AppendLine(" -showdlg Show status dialog"); + sb.AppendLine(" -ontaskbar Show status dialog in taskbar"); + sb.AppendLine(" -notify Balloon notifications on state changes"); + sb.AppendLine(" -lock Lock workstation after startup"); + sb.AppendLine(" -replace Replace a running instance"); + sb.AppendLine(" -watchwindow:TEXT Only active when foreground window title contains TEXT"); + sb.AppendLine(" -activeperiods:RANGES Active only within local time ranges (e.g. 08:00-12:00,13:00-17:00)"); + sb.AppendLine(" -activehours:RANGES Alias of -activeperiods"); + sb.AppendLine(" -onac Only active when on AC power"); + sb.AppendLine(" -cpu:N Only active when total CPU >= N percent"); + sb.AppendLine(" -stes Use SetThreadExecutionState instead of key pulse"); + sb.AppendLine(" -allowss Allow screensaver (uses STES system-only mode)"); + sb.AppendLine(" -useshift / -leftshift Use Shift / Left Shift instead of F15"); + sb.AppendLine(" -key:NN or -keypress:NN Use custom virtual key (decimal or hex, e.g. 0x7E)"); + sb.AppendLine(" -keyshift[:NN] Press key with Shift modifier; optional custom key"); + sb.AppendLine(" -appexit Ask running instance to exit"); + sb.AppendLine(" -appon / -appoff Ask running instance to force active/inactive"); + sb.AppendLine(" -apptoggle Ask running instance to toggle"); + sb.AppendLine(" -apptoggleshowdlg Toggle and show running instance status dialog"); + sb.AppendLine(" -nohicon Use same tray icon while inactive"); + sb.AppendLine(" -allowlocal Compatibility flag (recognized)"); + return sb.ToString(); + } + } +} +'@ + +if (-not ('PortableCaffeine.Program' -as [type])) { + Add-Type -AssemblyName System.Windows.Forms + Add-Type -AssemblyName System.Drawing + Add-Type -TypeDefinition $csharp -ReferencedAssemblies @( + 'System.dll', + 'System.Windows.Forms.dll', + 'System.Drawing.dll' + ) +} + +$exitCode = [PortableCaffeine.Program]::Run([string[]]$args) +exit $exitCode diff --git a/run-caffeine.bat b/run-caffeine.bat new file mode 100644 index 0000000..51d3579 --- /dev/null +++ b/run-caffeine.bat @@ -0,0 +1,13 @@ +@echo off +setlocal + +set "SCRIPT_DIR=%~dp0" +set "PS_SCRIPT=%SCRIPT_DIR%caffeine.ps1" + +if not exist "%PS_SCRIPT%" ( + echo Could not find "%PS_SCRIPT%". + exit /b 1 +) + +powershell.exe -NoProfile -ExecutionPolicy Bypass -STA -File "%PS_SCRIPT%" %* +exit /b %ERRORLEVEL% diff --git a/sign-caffeine-exe.ps1 b/sign-caffeine-exe.ps1 new file mode 100644 index 0000000..b62ccf9 --- /dev/null +++ b/sign-caffeine-exe.ps1 @@ -0,0 +1,378 @@ +[CmdletBinding()] +param( + [string]$ExePath = (Join-Path $PSScriptRoot 'dist\PortableCaffeine.exe'), + [string]$Subject = 'CN=Portable Caffeine Dev Code Signing', + [string]$FriendlyName = 'Portable Caffeine Dev Code Signing (Self-Signed)', + [ValidateSet('None', 'CurrentUser', 'LocalMachine')] + [string]$TrustScope = 'CurrentUser', + [ValidateSet('CurrentUser', 'LocalMachine')] + [string]$PrivateKeyStoreScope = 'CurrentUser', + [int]$YearsValid = 5, + [switch]$ForceNewCertificate, + [string]$TimestampServer, + [string]$CerExportPath = (Join-Path $PSScriptRoot 'dist\PortableCaffeine-dev-signing.cer'), + [string]$PfxExportPath, + [securestring]$PfxPassword +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { + throw "This script is intended for Windows only." +} + +if (-not (Get-Command New-SelfSignedCertificate -ErrorAction SilentlyContinue)) { + throw "New-SelfSignedCertificate is not available in this PowerShell session." +} + +if (-not (Get-Command Set-AuthenticodeSignature -ErrorAction SilentlyContinue)) { + throw "Set-AuthenticodeSignature is not available in this PowerShell session." +} + +if (-not ('PortableCaffeineBuild.SigningHelpers' -as [type])) { + $helperCode = @" +using System; +using System.Security.Principal; +using System.Security.Cryptography.X509Certificates; + +namespace PortableCaffeineBuild +{ + public static class SigningHelpers + { + public static bool IsAdministrator() + { + WindowsIdentity identity = WindowsIdentity.GetCurrent(); + if (identity == null) + { + return false; + } + + WindowsPrincipal principal = new WindowsPrincipal(identity); + return principal.IsInRole(WindowsBuiltInRole.Administrator); + } + + public static bool StoreContainsThumbprint(string storeName, string storeLocation, string thumbprint) + { + if (string.IsNullOrEmpty(thumbprint)) + { + return false; + } + + StoreName sn = (StoreName)Enum.Parse(typeof(StoreName), storeName, true); + StoreLocation sl = (StoreLocation)Enum.Parse(typeof(StoreLocation), storeLocation, true); + + X509Store store = new X509Store(sn, sl); + try + { + store.Open(OpenFlags.ReadOnly); + string normalized = thumbprint.Replace(" ", string.Empty).ToUpperInvariant(); + foreach (X509Certificate2 cert in store.Certificates) + { + if (cert == null || string.IsNullOrEmpty(cert.Thumbprint)) + { + continue; + } + + if (cert.Thumbprint.Replace(" ", string.Empty).ToUpperInvariant() == normalized) + { + return true; + } + } + + return false; + } + finally + { + store.Close(); + } + } + + public static void AddPublicCertificateToStore(byte[] rawData, string storeName, string storeLocation) + { + if (rawData == null || rawData.Length == 0) + { + throw new ArgumentException("rawData"); + } + + X509Certificate2 publicCert = new X509Certificate2(rawData); + try + { + if (StoreContainsThumbprint(storeName, storeLocation, publicCert.Thumbprint)) + { + return; + } + + StoreName sn = (StoreName)Enum.Parse(typeof(StoreName), storeName, true); + StoreLocation sl = (StoreLocation)Enum.Parse(typeof(StoreLocation), storeLocation, true); + X509Store store = new X509Store(sn, sl); + try + { + store.Open(OpenFlags.ReadWrite); + store.Add(publicCert); + } + finally + { + store.Close(); + } + } + finally + { + publicCert.Reset(); + } + } + } +} +"@ + + Add-Type -TypeDefinition $helperCode -ReferencedAssemblies @( + 'System.dll' + ) +} + +function Test-IsAdministrator { + return [PortableCaffeineBuild.SigningHelpers]::IsAdministrator() +} + +function Get-CodeSigningCertificate { + param( + [Parameter(Mandatory = $true)] + [string]$SubjectName, + [Parameter(Mandatory = $true)] + [string]$StoreScope + ) + + $storePath = "Cert:\$StoreScope\My" + if (-not (Test-Path -LiteralPath $storePath)) { + return $null + } + + $now = Get-Date + $certs = Get-ChildItem -Path $storePath | + Where-Object { + $_.Subject -eq $SubjectName -and + $_.HasPrivateKey -and + $_.NotAfter -gt $now + } | + Sort-Object -Property NotAfter -Descending + + if ($certs) { + return @($certs)[0] + } + + return $null +} + +function New-CodeSigningCertificate { + param( + [Parameter(Mandatory = $true)] + [string]$SubjectName, + [Parameter(Mandatory = $true)] + [string]$Friendly, + [Parameter(Mandatory = $true)] + [string]$StoreScope, + [Parameter(Mandatory = $true)] + [int]$ValidYears + ) + + $certStoreLocation = "Cert:\$StoreScope\My" + $notAfter = (Get-Date).AddYears($ValidYears) + + Write-Host "Creating new self-signed code signing certificate..." + Write-Host "Subject: $SubjectName" + Write-Host "Store: $certStoreLocation" + + $newCert = New-SelfSignedCertificate ` + -Type CodeSigningCert ` + -Subject $SubjectName ` + -FriendlyName $Friendly ` + -CertStoreLocation $certStoreLocation ` + -KeyAlgorithm RSA ` + -KeyLength 2048 ` + -KeySpec Signature ` + -HashAlgorithm SHA256 ` + -KeyExportPolicy Exportable ` + -NotAfter $notAfter + + if (-not $newCert) { + throw "New-SelfSignedCertificate did not return a certificate." + } + + return $newCert +} + +function Install-CertificateTrust { + param( + [Parameter(Mandatory = $true)] + [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate, + [Parameter(Mandatory = $true)] + [ValidateSet('CurrentUser', 'LocalMachine')] + [string]$Scope + ) + + foreach ($storeName in @('Root', 'TrustedPublisher')) { + $alreadyTrusted = [PortableCaffeineBuild.SigningHelpers]::StoreContainsThumbprint($storeName, $Scope, $Certificate.Thumbprint) + if ($alreadyTrusted) { + Write-Host "Trusted store already contains cert: $Scope\$storeName ($($Certificate.Thumbprint))" + continue + } + + Write-Host "Adding cert to trusted store: $Scope\$storeName" + [PortableCaffeineBuild.SigningHelpers]::AddPublicCertificateToStore($Certificate.RawData, $storeName, $Scope) + } +} + +function Export-PublicCertificateIfRequested { + param( + [Parameter(Mandatory = $true)] + [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate, + [string]$Path + ) + + if ([string]::IsNullOrWhiteSpace($Path)) { + return + } + + $dir = Split-Path -Parent $Path + if ($dir) { + New-Item -ItemType Directory -Force -Path $dir | Out-Null + } + + $raw = $Certificate.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert) + [System.IO.File]::WriteAllBytes($Path, $raw) + Write-Host "Exported public certificate: $Path" +} + +function Export-PfxIfRequested { + param( + [Parameter(Mandatory = $true)] + [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate, + [string]$Path, + [securestring]$Password + ) + + if ([string]::IsNullOrWhiteSpace($Path)) { + return + } + + if (-not $Password) { + throw "PfxExportPath was provided but no -PfxPassword was supplied." + } + + if (-not (Get-Command Export-PfxCertificate -ErrorAction SilentlyContinue)) { + throw "Export-PfxCertificate is not available in this PowerShell session." + } + + $dir = Split-Path -Parent $Path + if ($dir) { + New-Item -ItemType Directory -Force -Path $dir | Out-Null + } + + Export-PfxCertificate -Cert $Certificate -FilePath $Path -Password $Password -Force | Out-Null + Write-Host "Exported PFX: $Path" +} + +function Sign-Executable { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate, + [string]$TimestampUrl + ) + + $params = @{ + FilePath = $Path + Certificate = $Certificate + HashAlgorithm = 'SHA256' + } + + if (-not [string]::IsNullOrWhiteSpace($TimestampUrl)) { + $params['TimestampServer'] = $TimestampUrl + } + + Write-Host "Signing file: $Path" + $result = Set-AuthenticodeSignature @params + + if (-not $result) { + throw "Set-AuthenticodeSignature did not return a result." + } + + Write-Host "Sign status: $($result.Status)" + if ($result.StatusMessage) { + Write-Host "Status msg: $($result.StatusMessage)" + } + + return $result +} + +if (-not (Test-Path -LiteralPath $ExePath)) { + throw "EXE not found: $ExePath`nBuild it first with .\build-caffeine-exe.ps1" +} + +if ($YearsValid -lt 1 -or $YearsValid -gt 50) { + throw "YearsValid must be between 1 and 50." +} + +$needsAdmin = $false +if ($TrustScope -eq 'LocalMachine' -or $PrivateKeyStoreScope -eq 'LocalMachine') { + $needsAdmin = $true +} + +if ($needsAdmin -and -not (Test-IsAdministrator)) { + throw "This operation requires an elevated PowerShell session (Run as Administrator) because LocalMachine certificate stores are being modified/used." +} + +$cert = $null +if (-not $ForceNewCertificate) { + $cert = Get-CodeSigningCertificate -SubjectName $Subject -StoreScope $PrivateKeyStoreScope +} + +if (-not $cert) { + $cert = New-CodeSigningCertificate -SubjectName $Subject -Friendly $FriendlyName -StoreScope $PrivateKeyStoreScope -ValidYears $YearsValid +} else { + Write-Host "Reusing existing certificate:" + Write-Host "Subject: $($cert.Subject)" + Write-Host "Thumbprint: $($cert.Thumbprint)" + Write-Host "Expires: $($cert.NotAfter)" + Write-Host "Store: Cert:\$PrivateKeyStoreScope\My" +} + +Export-PublicCertificateIfRequested -Certificate $cert -Path $CerExportPath +Export-PfxIfRequested -Certificate $cert -Path $PfxExportPath -Password $PfxPassword + +if ($TrustScope -ne 'None') { + Write-Host "" + Write-Host "Installing trust for self-signed certificate..." + Install-CertificateTrust -Certificate $cert -Scope $TrustScope +} else { + Write-Host "Skipping trust-store installation (TrustScope=None)." +} + +Write-Host "" +$signResult = Sign-Executable -Path $ExePath -Certificate $cert -TimestampUrl $TimestampServer +$verify = Get-AuthenticodeSignature -FilePath $ExePath + +Write-Host "" +Write-Host "Verification:" +Write-Host "Status: $($verify.Status)" +if ($verify.SignerCertificate) { + Write-Host "Thumbprint: $($verify.SignerCertificate.Thumbprint)" + Write-Host "Subject: $($verify.SignerCertificate.Subject)" +} +if ($verify.TimeStamperCertificate) { + Write-Host "Timestamped: Yes" +} else { + Write-Host "Timestamped: No" +} + +if ($verify.Status -ne 'Valid') { + Write-Warning "Signature was applied but verification is not 'Valid'. For self-signed certs, ensure trust was installed in Root + TrustedPublisher for the relevant scope." +} + +Write-Host "" +Write-Host "Done." +Write-Host "EXE: $ExePath" +if ($CerExportPath) { + Write-Host "CER: $CerExportPath" +} diff --git a/tasks/todo.md b/tasks/todo.md new file mode 100644 index 0000000..4f7a8ea --- /dev/null +++ b/tasks/todo.md @@ -0,0 +1,34 @@ +# Portable Caffeine Clone (PowerShell 5.1 + Embedded C#) + +## Plan + +- [x] Confirm feature parity target from https://www.zhornsoftware.co.uk/caffeine/ (current switches + tray behavior) +- [x] Define implementation architecture (single script, embedded C# helpers, tray UI, single-instance IPC) +- [x] Implement command-line parsing and normalized parameter model +- [x] Implement activity engine (F15/Shift/custom key, key up vs keypress, STES, allow screensaver) +- [x] Implement conditional activation rules (`-watchwindow`, `-activeperiods`/`-activehours`, `-onac`, `-cpu`) +- [x] Implement timers (`-exitafter`, `-activefor`, `-inactivefor`) and state transitions +- [x] Implement tray app UX (double-click toggle, active/inactive menu, timed menu, revert-to-parameters, about, exit) +- [x] Implement single-instance behavior and remote app commands (`-appexit`, `-appon`, `-appoff`, `-apptoggle`, `-apptoggleshowdlg`, `-replace`) +- [x] Implement notifications (`-notify`) and optional status dialog (`-showdlg`, `-ontaskbar`) +- [ ] Verify script loads/parses and document limitations from non-Windows validation environment + +## Progress Notes + +- Initial scope confirmed from Zhorn feature list (includes v1.98-era switches like `-activeperiods` and `-notify`). +- Implemented `caffeine.ps1` as a portable single-script app with embedded C# WinForms runtime. +- Added single-instance IPC via hidden window + `WM_COPYDATA` for `-app*` commands and `-replace`. +- Added conditional activation (`-watchwindow`, `-activeperiods`, `-activehours`, `-onac`, `-cpu`) and timers (`-activefor`, `-inactivefor`, `-exitafter`). +- Added activity methods (default F15 key pulse, Shift variants/custom keys, `-stes`, `-allowss`) and tray/status UX. +- Updated tray icons to custom coffee-cup active/inactive variants (with `-nohicon` preserving the same icon in both states). +- Added `build-caffeine-exe.ps1` to extract embedded C# from `caffeine.ps1` and compile `dist\PortableCaffeine.exe` with `csc.exe`. +- Added a `Main()` entry point to the embedded `PortableCaffeine.Program` class so the same source compiles directly as an EXE. +- Added `sign-caffeine-exe.ps1` (PowerShell + embedded C# helper) to create/reuse a self-signed code-signing cert, optionally trust it (`CurrentUser`/`LocalMachine`), and sign the compiled EXE. +- Static review patch: fixed ambiguous `Timer` type and prevented `-app*` switches from starting a new instance when no instance is running. + +## Review + +- Runtime verification is still pending because this environment is macOS and does not have `pwsh` installed, so I could not run a PowerShell parser check or Windows UI/API smoke test here. +- EXE build verification is also pending in this environment (no Windows `.NET Framework` `csc.exe` available here). +- Self-signed certificate creation/trust-store installation/signing verification is also pending in this environment (requires Windows cert stores and `Set-AuthenticodeSignature`). +- The implementation is intended for Windows PowerShell 5.1 (STA) and relies on `System.Windows.Forms`, `user32.dll`, and `kernel32.dll`.