Compare commits
3 Commits
1c8f184994
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c5a8b3b4c | |||
| 3fb8e98eed | |||
| 9233275e8c |
85
README.md
85
README.md
@@ -15,6 +15,8 @@ It also includes a tray icon app, status dialog, timers, rule-based activation,
|
|||||||
|
|
||||||
- `caffeine.ps1`: main portable app
|
- `caffeine.ps1`: main portable app
|
||||||
- `run-caffeine.bat`: launcher that runs with `-ExecutionPolicy Bypass -STA`
|
- `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
|
## Requirements
|
||||||
|
|
||||||
@@ -112,6 +114,89 @@ run-caffeine.bat -apptoggle
|
|||||||
run-caffeine.bat -replace -off
|
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
|
## Notes
|
||||||
|
|
||||||
- This project is intended to be portable (no install required).
|
- This project is intended to be portable (no install required).
|
||||||
|
|||||||
44
agent.md
Normal file
44
agent.md
Normal file
@@ -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.
|
||||||
121
build-caffeine-exe.ps1
Normal file
121
build-caffeine-exe.ps1
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$SourceScript,
|
||||||
|
[string]$OutputDir,
|
||||||
|
[string]$BuildDir,
|
||||||
|
[string]$ExeName = 'PortableCaffeine.exe',
|
||||||
|
[switch]$ConsoleTarget,
|
||||||
|
[switch]$KeepExtractedSource
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
# In Windows PowerShell 5.1, $PSScriptRoot may be empty during param default evaluation.
|
||||||
|
# Resolve script-relative defaults after the param block for compatibility.
|
||||||
|
$scriptRoot = if (-not [string]::IsNullOrWhiteSpace($PSScriptRoot)) {
|
||||||
|
$PSScriptRoot
|
||||||
|
} elseif ($MyInvocation.MyCommand.Path) {
|
||||||
|
Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
} else {
|
||||||
|
(Get-Location).Path
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($SourceScript)) {
|
||||||
|
$SourceScript = Join-Path $scriptRoot 'caffeine.ps1'
|
||||||
|
}
|
||||||
|
if ([string]::IsNullOrWhiteSpace($OutputDir)) {
|
||||||
|
$OutputDir = Join-Path $scriptRoot 'dist'
|
||||||
|
}
|
||||||
|
if ([string]::IsNullOrWhiteSpace($BuildDir)) {
|
||||||
|
$BuildDir = Join-Path $scriptRoot 'build'
|
||||||
|
}
|
||||||
|
|
||||||
|
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(?<code>.*?)\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"
|
||||||
|
}
|
||||||
@@ -1779,6 +1779,12 @@ namespace PortableCaffeine
|
|||||||
{
|
{
|
||||||
private const string MutexName = @"Local\PortableCaffeine.ZhornCompat";
|
private const string MutexName = @"Local\PortableCaffeine.ZhornCompat";
|
||||||
|
|
||||||
|
[STAThread]
|
||||||
|
public static int Main(string[] args)
|
||||||
|
{
|
||||||
|
return Run(args);
|
||||||
|
}
|
||||||
|
|
||||||
public static int Run(string[] args)
|
public static int Run(string[] args)
|
||||||
{
|
{
|
||||||
Options options = CommandLine.Parse(args);
|
Options options = CommandLine.Parse(args);
|
||||||
|
|||||||
395
sign-caffeine-exe.ps1
Normal file
395
sign-caffeine-exe.ps1
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$ExePath,
|
||||||
|
[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,
|
||||||
|
[string]$PfxExportPath,
|
||||||
|
[securestring]$PfxPassword
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
# In Windows PowerShell 5.1, $PSScriptRoot may be empty during param default evaluation.
|
||||||
|
# Resolve script-relative defaults after the param block for compatibility.
|
||||||
|
$scriptRoot = if (-not [string]::IsNullOrWhiteSpace($PSScriptRoot)) {
|
||||||
|
$PSScriptRoot
|
||||||
|
} elseif ($MyInvocation.MyCommand.Path) {
|
||||||
|
Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
} else {
|
||||||
|
(Get-Location).Path
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($ExePath)) {
|
||||||
|
$ExePath = Join-Path $scriptRoot 'dist\PortableCaffeine.exe'
|
||||||
|
}
|
||||||
|
if ([string]::IsNullOrWhiteSpace($CerExportPath)) {
|
||||||
|
$CerExportPath = Join-Path $scriptRoot 'dist\PortableCaffeine-dev-signing.cer'
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
5
tasks/lessons.md
Normal file
5
tasks/lessons.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Lessons
|
||||||
|
|
||||||
|
## 2026-02-24
|
||||||
|
|
||||||
|
- PowerShell 5.1 compatibility: do not use `$PSScriptRoot` (or other script-location variables) inside `param()` default expressions. Resolve script-relative defaults after the `param` block instead.
|
||||||
34
tasks/todo.md
Normal file
34
tasks/todo.md
Normal file
@@ -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`.
|
||||||
Reference in New Issue
Block a user