diff --git a/CMakeLists.txt b/CMakeLists.txt index d3bdafa..a5138a2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -65,14 +65,20 @@ if(KEYFLOW_HAS_PLATFORM_BACKEND) add_executable(keyflow ${SOURCES}) - # Expose the CMake project version to the C++ source so `--version` can print - # the same number the release workflow tags with. - target_compile_definitions(keyflow PRIVATE - KEYFLOW_VERSION="${PROJECT_VERSION}" - ) - if(WIN32) target_link_libraries(keyflow PRIVATE interception) + + # Release builds: no console window. Process lives in the system tray. + # /ENTRY:mainCRTStartup lets us keep the standard `int main(argc, argv)` + # signature instead of switching to WinMain. + # Generator expressions are required: Visual Studio is multi-config, + # so CMAKE_BUILD_TYPE is empty at configure time. + if(MSVC) + target_link_options(keyflow PRIVATE + $<$:/SUBSYSTEM:WINDOWS> + $<$:/ENTRY:mainCRTStartup> + ) + endif() endif() # Output directory - organize by build type diff --git a/designer/app/screenshot/page.tsx b/designer/app/screenshot/page.tsx new file mode 100644 index 0000000..dbd9537 --- /dev/null +++ b/designer/app/screenshot/page.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + ExportKeyboard, + buildKeyContexts, + type LayerFilter, +} from "@/components/Export/ExportKeyboard"; +import type { KeyFlowConfig } from "@/lib/types"; + +// Renderer for documentation screenshots. Read by the Playwright capture spec +// (designer/tests/capture-images.spec.ts) which iterates over ?layer= values +// and snapshots the #screenshot-root element to docs/images/. +// +// Why fetch instead of import: Turbopack refuses cross-root imports, so the +// bundled config can't be `import`ed from ../../src/. A `predev` / `prebuild` +// npm hook copies src/config.json into designer/public/eduuh-dh-config.json +// (see scripts/copy-config.mjs), and this page fetches that static asset. +// +// Why window.location instead of useSearchParams: useSearchParams in the App +// Router requires a Suspense boundary in static-export mode, and without it +// the route silently bails out of prerender. Reading window.location.search +// in useEffect avoids the Suspense requirement entirely since the whole page +// only matters client-side anyway. +// +// URL params: +// ?layer=all → BASE + every layer mapping stacked per keycap (composite) +// ?layer=base → BASE remap only +// ?layer=0 → BASE + layer 0 (Numpad for Eduuh-DH) +// ?layer=1 → BASE + layer 1 (Arrows & Symbols) + +function parseLayerParam(raw: string | null): LayerFilter { + if (raw === "base") return "base"; + const idx = Number(raw); + if (Number.isInteger(idx) && idx >= 0) return idx; + return "all"; +} + +export default function ScreenshotPage() { + const [layerFilter, setLayerFilter] = useState("all"); + const [config, setConfig] = useState(null); + + useEffect(() => { + // Force dark theme so screenshots don't depend on the visitor's persisted + // ThemeProvider preference (which lives in localStorage). + const root = document.documentElement; + root.classList.remove("light"); + root.classList.add("dark"); + + setLayerFilter(parseLayerParam(new URLSearchParams(window.location.search).get("layer"))); + + // Fetch the bundled Eduuh-DH config. Absolute path because relative would + // resolve under /screenshot/ and 404 against the public asset at /. + // Note: this route is intended for local capture (Playwright + npm run + // capture-images), not for production viewing. On GitHub Pages with the + // /keyflow basePath, the absolute path would 404 — a separate concern. + fetch("/eduuh-dh-config.json") + .then((r) => r.json()) + .then((c) => setConfig(c as KeyFlowConfig)) + .catch((err) => { + console.error("Failed to load eduuh-dh-config.json", err); + }); + }, []); + + if (!config) { + // Render nothing until the config is loaded — keeps the + // [data-screenshot-ready] selector from matching before the keyboard is real. + return
; + } + + const keyContexts = buildKeyContexts(config); + + return ( +
+
+ false} + config={config} + scale={80} + layerFilter={layerFilter} + /> +
+
+ ); +} diff --git a/designer/components/Export/CategoryExport.tsx b/designer/components/Export/CategoryExport.tsx index d486ac5..02cbf3e 100644 --- a/designer/components/Export/CategoryExport.tsx +++ b/designer/components/Export/CategoryExport.tsx @@ -15,9 +15,12 @@ import { import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { cn } from "@/lib/utils"; import { ansiLayout } from "@/lib/keyboardLayout"; -import { getKeyType } from "@/lib/keyTypes"; +import { + ExportKeyboard, + buildKeyContexts, + type KeyContext, +} from "@/components/Export/ExportKeyboard"; // Category-filtered keyboard exports. // @@ -40,12 +43,6 @@ type ExportCategory = { match: (ctx: KeyContext) => boolean; }; -type KeyContext = { - physicalKey: string; - /** Every value this physical key can produce: base + layer outputs + shift outputs. */ - outputs: string[]; -}; - const CATEGORIES: ExportCategory[] = [ { id: "layout", label: "Full layout", match: () => true }, { @@ -107,20 +104,7 @@ export function CategoryExport({ isOpen, onClose }: CategoryExportProps) { const exportRef = useRef(null); // Build a context for every key in the layout so categories can be matched. - const keyContexts = new Map(); - ansiLayout.forEach((k) => { - const outputs: string[] = []; - const base = config.remapping?.[k.keyCode]; - if (base) outputs.push(base); - config.layers?.forEach((layer) => { - const m = layer.mappings?.[k.keyCode]; - if (m) outputs.push(m); - layer.shiftMappings?.forEach((sm) => { - if (sm.key === k.keyCode) outputs.push(sm.output); - }); - }); - keyContexts.set(k.keyCode, { physicalKey: k.keyCode, outputs }); - }); + const keyContexts = buildKeyContexts(config); const current = CATEGORIES.find((c) => c.id === activeCategory) ?? CATEGORIES[0]; @@ -249,150 +233,3 @@ export function CategoryExport({ isOpen, onClose }: CategoryExportProps) { ); } - -// ─── The render target for export ────────────────────────────────────────── - -type ExportKeyboardProps = { - keyContexts: Map; - isHighlighted: (keyCode: string) => boolean; - config: ReturnType["config"]; -}; - -function ExportKeyboard({ keyContexts, isHighlighted, config }: ExportKeyboardProps) { - const scale = 64; - const width = 15 * scale; - const height = 5 * scale; - - return ( -
- {ansiLayout.map((keyData) => { - const highlighted = isHighlighted(keyData.keyCode); - const ctx = keyContexts.get(keyData.keyCode); - const labels = buildLabels(keyData, config); - const keyType = getKeyType( - // Use the first layer output for type, or fall back to base or physical - ctx?.outputs[0] ?? keyData.keyCode, - ); - const _ = keyType; // keep type info available; visual is binary highlighted vs not - - return ( -
- {/* Top: layer outputs (if any) */} -
- - {labels.topLeft || ""} - - - {labels.topRight || ""} - -
- {/* Bottom: physical key + base output */} -
- - {labels.bottomLeft || ""} - - - {labels.bottomRight || ""} - -
-
- ); - })} -
- ); -} - -type KeyLabels = { - topLeft?: string; // First layer output (e.g. RAlt layer) - topRight?: string; // Second layer output (e.g. LAlt layer) - bottomLeft: string; // Base remap output (or physical label if no remap) - bottomRight?: string; // Shift mapping output (e.g. ⇧+) -}; - -function buildLabels( - keyData: (typeof ansiLayout)[number], - config: ReturnType["config"], -): KeyLabels { - const baseMapping = config.remapping?.[keyData.keyCode]; - const layers = config.layers ?? []; - - const layer0Output = layers[0]?.mappings?.[keyData.keyCode]; - const layer1Output = layers[1]?.mappings?.[keyData.keyCode]; - - // Show a shift mapping from any layer (prefer layer 1's, fall back to layer 0) - const shiftFromLayer1 = layers[1]?.shiftMappings?.find((m) => m.key === keyData.keyCode); - const shiftFromLayer0 = layers[0]?.shiftMappings?.find((m) => m.key === keyData.keyCode); - const shiftMapping = shiftFromLayer1 || shiftFromLayer0; - - return { - topLeft: layer0Output ? compactLabel(layer0Output) : undefined, - topRight: layer1Output - ? compactLabel(layer1Output) - : shiftMapping - ? `⇧${compactLabel(shiftMapping.output)}` - : undefined, - bottomLeft: baseMapping ? compactLabel(baseMapping) : compactLabel(keyData.label), - bottomRight: undefined, // Reserved - }; -} - -// Shrink long key names so they fit on a 60-px-wide keycap face. -function compactLabel(label: string): string { - const short: Record = { - LeftShift: "L⇧", - RightShift: "R⇧", - LeftCtrl: "L⌃", - RightCtrl: "R⌃", - LeftAlt: "L⌥", - RightAlt: "R⌥", - LeftWin: "L⊞", - RightWin: "R⊞", - CapsLock: "Caps", - Backspace: "⌫", - Enter: "↵", - Escape: "Esc", - PageUp: "PgUp", - PageDown: "PgDn", - Insert: "Ins", - Delete: "Del", - PrintScreen: "PrtSc", - Up: "↑", - Down: "↓", - Left: "←", - Right: "→", - Grave: "`", - Minus: "-", - Equals: "=", - LeftBracket: "[", - RightBracket: "]", - Backslash: "\\", - Semicolon: ";", - Apostrophe: "'", - Comma: ",", - Period: ".", - Slash: "/", - Space: "␣", - }; - return short[label] || label; -} diff --git a/designer/components/Export/ExportKeyboard.tsx b/designer/components/Export/ExportKeyboard.tsx new file mode 100644 index 0000000..c561889 --- /dev/null +++ b/designer/components/Export/ExportKeyboard.tsx @@ -0,0 +1,540 @@ +"use client"; + +import { ansiLayout } from "@/lib/keyboardLayout"; +import { useConfigStore } from "@/lib/store"; + +// Render target for keyboard exports. +// +// Used by: +// - CategoryExport dialog: highlights category-matching keys in cyan, shows +// base + every layer output stacked per keycap (layerFilter="all"). +// - The /screenshot route: produces documentation images for a specific +// layer (layerFilter="base" or a layer index) or the full composite. +// +// Visual design: +// - Each key is a tall keycap-style card with a colored face by role: +// modifiers = amber, letters/digits = slate, punctuation = teal, +// navigation = indigo, unchanged-from-physical = muted. Layer outputs in +// non-"all" views accent the active layer in violet. +// - Physical key reference (Q, W, E…) lives in the top-right corner so the +// reader can always tell which physical key produces which output. +// - Center label is the primary output for the current layer view. +// - Subtle drop shadow + top highlight gradient gives the cards a sense of +// elevation without going full skeuomorph. + +export type KeyContext = { + physicalKey: string; + /** Every value this physical key can produce: base + layer outputs + shift outputs. */ + outputs: string[]; +}; + +type Config = ReturnType["config"]; + +export type LayerFilter = "all" | "base" | number; + +/** Build the per-key context map used for category matching. */ +export function buildKeyContexts(config: Config): Map { + const map = new Map(); + ansiLayout.forEach((k) => { + const outputs: string[] = []; + const base = config.remapping?.[k.keyCode]; + if (base) outputs.push(base); + config.layers?.forEach((layer) => { + const m = layer.mappings?.[k.keyCode]; + if (m) outputs.push(m); + layer.shiftMappings?.forEach((sm) => { + if (sm.key === k.keyCode) outputs.push(sm.output); + }); + }); + config.noModCombos?.forEach((c) => { + if (c.key === k.keyCode) outputs.push(c.output); + }); + map.set(k.keyCode, { physicalKey: k.keyCode, outputs }); + }); + return map; +} + +export type ExportKeyboardProps = { + keyContexts: Map; + isHighlighted: (keyCode: string) => boolean; + config: Config; + /** Pixels per key unit. Default 64 matches the dialog; 80 is good for high-DPI export. */ + scale?: number; + /** Which layers to surface in the labels. Default "all". */ + layerFilter?: LayerFilter; +}; + +export function ExportKeyboard({ + keyContexts, + isHighlighted, + config, + scale = 64, + layerFilter = "all", +}: ExportKeyboardProps) { + const width = 15 * scale; + const height = 5 * scale; + + return ( +
+ {ansiLayout.map((keyData) => ( + + ))} +
+ ); +} + +// ─── Per-key card ────────────────────────────────────────────────────────── + +type KeycapProps = { + keyData: (typeof ansiLayout)[number]; + scale: number; + config: Config; + layerFilter: LayerFilter; + highlighted: boolean; +}; + +function Keycap({ keyData, scale, config, layerFilter, highlighted }: KeycapProps) { + const view = resolveKeyView(keyData, config, layerFilter); + const role = view.role; + const palette = ROLE_PALETTE[role]; + + // Highlighted state (category filter in dialog) shifts to a bright cyan ring. + const accent = highlighted ? "#22D3EE" : palette.accent; + const ringWidth = highlighted ? 2 : 1; + + const padding = 6; + const w = keyData.width * scale - 4; + const h = keyData.height * scale - 4; + + // Sizing scales with key size so a 2u Backspace doesn't dwarf its label. + // Long words like "Shift" / "PrtSc" get a smaller primary font so they fit. + const isWideLabel = view.primary.length >= 4 && /^[A-Za-z]+$/.test(view.primary); + const baseFont = Math.round(scale * 0.32); + const primaryFontSize = isWideLabel + ? Math.max(13, Math.round(scale * 0.22)) + : Math.max(14, baseFont); + const overlayFontSize = Math.max(10, Math.round(scale * 0.16)); + + return ( +
+ {/* Layer-0 overlay (top-left) — "all" view only. Surfaces the layer-0 + mapping next to the BASE output so you can see at a glance what each + layer adds without leaving the composite view. */} + {view.topLeft && ( +
+ {view.topLeft} +
+ )} + + {/* Center: the final output for the current view — what pressing this + key actually produces. Color comes from the role palette. */} +
+ + {view.primary} + +
+ + {/* Bottom-right overlay — "all" view only. The layer-1 mapping. */} + {view.bottomRight && ( +
+ {view.bottomRight} +
+ )} +
+ ); +} + +// ─── View model: what each key shows ─────────────────────────────────────── + +type KeyView = { + /** The big centered label — always the FINAL output for the current view. */ + primary: string; + primaryColor?: string; + /** Top-left overlay — "all" view only: layer-0 output. */ + topLeft?: string; + topLeftColor?: string; + /** Bottom-right overlay — "all" view only: layer-1 output. */ + bottomRight?: string; + bottomRightColor?: string; + /** Drives the card color. */ + role: KeyRole; +}; + +function resolveKeyView( + keyData: (typeof ansiLayout)[number], + config: Config, + filter: LayerFilter, +): KeyView { + const physicalLabel = keyData.label; + const baseOutput = baseOutputFor(keyData.keyCode, config); + const layers = config.layers ?? []; + + if (filter === "base") { + // BASE view: every key shows what it produces with no modifier held — + // including noModCombos so 1 shows "!", 2 shows "[", etc. + const display = baseOutput ?? physicalLabel; + return { + primary: compactLabel(display), + role: classifyRole(display, baseOutput != null), + }; + } + + if (typeof filter === "number") { + // Per-layer view: show the FINAL output for every key when this layer is + // active. Layer-mapped keys produce the layer's mapping; everything else + // falls through to the BASE output unchanged. Card color signals whether + // the layer affected this key (violet) or not (the key's natural role). + const layer = layers[filter]; + const mapping = layer?.mappings?.[keyData.keyCode]; + const shift = layer?.shiftMappings?.find((m) => m.key === keyData.keyCode); + + if (mapping) { + return { + primary: compactLabel(mapping), + primaryColor: ACCENT_LAYER, + role: "layerOutput", + }; + } + + if (shift) { + return { + primary: `⇧${compactLabel(shift.output)}`, + primaryColor: ACCENT_SHIFT, + role: "layerOutput", + }; + } + + // No layer activity on this key — pressing it with the layer trigger held + // still produces whatever BASE produces. Show that at full clarity. + const display = baseOutput ?? physicalLabel; + return { + primary: compactLabel(display), + role: classifyRole(display, baseOutput != null), + }; + } + + // "all" view: BASE output prominent, layer outputs in opposite corners. + const layer0 = layers[0]?.mappings?.[keyData.keyCode]; + const layer1 = layers[1]?.mappings?.[keyData.keyCode]; + const shift0 = layers[0]?.shiftMappings?.find((m) => m.key === keyData.keyCode); + const shift1 = layers[1]?.shiftMappings?.find((m) => m.key === keyData.keyCode); + + const topLeft = + layer0 != null + ? compactLabel(layer0) + : shift0 + ? `⇧${compactLabel(shift0.output)}` + : undefined; + const bottomRight = + layer1 != null + ? compactLabel(layer1) + : shift1 + ? `⇧${compactLabel(shift1.output)}` + : undefined; + + const display = baseOutput ?? physicalLabel; + + return { + primary: compactLabel(display), + topLeft, + topLeftColor: ACCENT_LAYER_DIM, + bottomRight, + bottomRightColor: ACCENT_LAYER_DIM, + role: classifyRole(display, baseOutput != null), + }; +} + +// ─── Data helpers ────────────────────────────────────────────────────────── + +/** What does pressing this key produce with no modifier held? + * Considers both explicit remapping AND noModCombos (which fire only when + * no modifier is held — they're effectively part of the BASE behavior). + * Shift-injected combos return the keycode prefixed with ⇧ so the doc shows + * the visible shifted character (e.g. 1 → ⇧1, then the consumer resolves + * ⇧1 to "!" via the QWERTY shift map). + */ +function baseOutputFor(keyCode: string, config: Config): string | null { + const remap = config.remapping?.[keyCode]; + if (remap) return remap; + const combo = config.noModCombos?.find((c) => c.key === keyCode); + if (combo) { + if (combo.shift) { + const shifted = US_SHIFT[combo.output]; + return shifted ?? `⇧${combo.output}`; + } + return combo.output; + } + return null; +} + +const US_SHIFT: Record = { + "1": "!", "2": "@", "3": "#", "4": "$", "5": "%", + "6": "^", "7": "&", "8": "*", "9": "(", "0": ")", + Grave: "~", + Minus: "_", + Equals: "+", + LeftBracket: "{", + RightBracket: "}", + Backslash: "|", + Semicolon: ":", + Apostrophe: '"', + Comma: "<", + Period: ">", + Slash: "?", +}; + +// ─── Role classification + palette ───────────────────────────────────────── + +type KeyRole = + | "modifier" // CapsLock/Shift/Ctrl/Alt/Win/Enter/etc. (or a remap that produces a modifier) + | "letter" // A-Z output + | "digit" // 0-9 output (kept distinct so we can color number-row differently if we ever want) + | "punctuation" // -, =, [, ], etc. and their shifted forms + | "navigation" // arrows, Home/End, PgUp/Dn + | "special" // Space, Tab, Backspace, Esc, Delete + | "layerOutput" // any key showing a layer-specific mapping (per-layer view only) + | "inactive" // per-layer view: key has no layer activity + | "unchanged"; // BASE/all view: key produces its physical label, nothing remapped + +const MODIFIER_OUTPUTS = new Set([ + "LeftShift", "RightShift", "LeftCtrl", "RightCtrl", "LeftAlt", "RightAlt", + "LeftWin", "RightWin", "CapsLock", "PrintScreen", +]); +const NAVIGATION_OUTPUTS = new Set([ + "Up", "Down", "Left", "Right", "Home", "End", "PageUp", "PageDown", "Insert", +]); +const SPECIAL_OUTPUTS = new Set([ + "Space", "Tab", "Backspace", "Enter", "Escape", "Delete", "Menu", +]); +const PUNCT_KEYCODES = new Set([ + "Grave", "Minus", "Equals", "LeftBracket", "RightBracket", "Backslash", + "Semicolon", "Apostrophe", "Comma", "Period", "Slash", +]); +const PUNCT_CHARS = new Set([ + "!", "@", "#", "$", "%", "^", "&", "*", "(", ")", + "~", "_", "+", "{", "}", "|", ":", '"', "<", ">", "?", + "`", "-", "=", "[", "]", "\\", ";", "'", ",", ".", "/", +]); + +function classifyRole(display: string, isRemapped: boolean): KeyRole { + if (MODIFIER_OUTPUTS.has(display)) return "modifier"; + if (NAVIGATION_OUTPUTS.has(display)) return "navigation"; + if (SPECIAL_OUTPUTS.has(display)) return "special"; + if (PUNCT_KEYCODES.has(display) || PUNCT_CHARS.has(display)) return "punctuation"; + if (/^\d$/.test(display)) return "digit"; + if (/^[A-Z]$/.test(display)) return "letter"; + if (!isRemapped) return "unchanged"; + return "letter"; +} + +type Palette = { + face: string; + faceTop: string; + faceBottom: string; + highlight: string; + accent: string; + text: string; + muted: string; +}; + +const ROLE_PALETTE: Record = { + modifier: { + face: "#7C5410", + faceTop: "#A06B14", + faceBottom: "#5A3D0B", + highlight: "rgba(255,210,140,0.35)", + accent: "#F59E0B", + text: "#FFF1D6", + muted: "rgba(255,241,214,0.55)", + }, + letter: { + face: "#2A3445", + faceTop: "#384355", + faceBottom: "#1F2735", + highlight: "rgba(180,200,230,0.18)", + accent: "#60A5FA", + text: "#F1F5F9", + muted: "rgba(241,245,249,0.5)", + }, + digit: { + face: "#2A3445", + faceTop: "#384355", + faceBottom: "#1F2735", + highlight: "rgba(180,200,230,0.18)", + accent: "#60A5FA", + text: "#F1F5F9", + muted: "rgba(241,245,249,0.5)", + }, + punctuation: { + face: "#0E5752", + faceTop: "#147068", + faceBottom: "#093E3A", + highlight: "rgba(180,255,240,0.22)", + accent: "#2DD4BF", + text: "#CCFBF1", + muted: "rgba(204,251,241,0.55)", + }, + navigation: { + face: "#2A2768", + faceTop: "#363288", + faceBottom: "#1E1C4B", + highlight: "rgba(190,200,255,0.22)", + accent: "#818CF8", + text: "#E0E7FF", + muted: "rgba(224,231,255,0.55)", + }, + special: { + face: "#293040", + faceTop: "#363E51", + faceBottom: "#1D222E", + highlight: "rgba(200,210,230,0.16)", + accent: "#94A3B8", + text: "#E2E8F0", + muted: "rgba(226,232,240,0.5)", + }, + layerOutput: { + face: "#3F1D7A", + faceTop: "#522799", + faceBottom: "#2A1455", + highlight: "rgba(220,200,255,0.25)", + accent: "#A78BFA", + text: "#F3E8FF", + muted: "rgba(243,232,255,0.55)", + }, + inactive: { + face: "#161B26", + faceTop: "#1B2230", + faceBottom: "#10141C", + highlight: "rgba(120,140,180,0.06)", + accent: "#1F2937", + text: "rgba(255,255,255,0.42)", + muted: "rgba(255,255,255,0.22)", + }, + unchanged: { + face: "#1A2030", + faceTop: "#222A3D", + faceBottom: "#131826", + highlight: "rgba(180,200,230,0.10)", + accent: "#334155", + text: "rgba(241,245,249,0.78)", + muted: "rgba(241,245,249,0.4)", + }, +}; + +const ACCENT_LAYER = "#C4B5FD"; // violet — primary text in per-layer view +const ACCENT_LAYER_DIM = "#A78BFA"; // softer violet — overlay layer outputs in "all" +const ACCENT_SHIFT = "#FCD34D"; // amber — shift-injected primary + +// Shrink long key names so they fit on a keycap face. Uses readable words +// (Shift, Ctrl, Alt, Win) instead of cryptic glyphs (⇧, ⌃, ⌥, ⊞). Drops the +// Left/Right prefix because the physical position on the keyboard already +// tells you which one — left-side keys show "Shift", right-side show "Shift", +// and the layout makes the distinction visually obvious. +function compactLabel(label: string): string { + const short: Record = { + LeftShift: "Shift", + RightShift: "Shift", + LeftCtrl: "Ctrl", + RightCtrl: "Ctrl", + LeftAlt: "Alt", + RightAlt: "Alt", + LeftWin: "Win", + RightWin: "Win", + CapsLock: "Caps", + Backspace: "⌫", + Enter: "↵", + Escape: "Esc", + PageUp: "PgUp", + PageDown: "PgDn", + Insert: "Ins", + Delete: "Del", + PrintScreen: "PrtSc", + Up: "↑", + Down: "↓", + Left: "←", + Right: "→", + Grave: "`", + Minus: "-", + Equals: "=", + LeftBracket: "[", + RightBracket: "]", + Backslash: "\\", + Semicolon: ";", + Apostrophe: "'", + Comma: ",", + Period: ".", + Slash: "/", + Space: "Space", + Tab: "Tab", + Menu: "Menu", + }; + return short[label] || label; +} diff --git a/designer/package.json b/designer/package.json index 5d79eed..073d5cd 100644 --- a/designer/package.json +++ b/designer/package.json @@ -3,12 +3,16 @@ "version": "1.0.0", "description": "Visual keyboard configuration designer for KeyFlow", "scripts": { + "copy-config": "node scripts/copy-config.mjs", + "predev": "npm run copy-config", + "prebuild": "npm run copy-config", "dev": "next dev", "build": "next build", "start": "next start", "lint": "next lint", "test": "playwright test", - "test:ui": "playwright test --ui" + "test:ui": "playwright test --ui", + "capture-images": "playwright test --project=capture --reporter=list" }, "keywords": [ "keyboard", diff --git a/designer/playwright.config.ts b/designer/playwright.config.ts index 7aa90c3..4f77686 100644 --- a/designer/playwright.config.ts +++ b/designer/playwright.config.ts @@ -12,18 +12,37 @@ export default defineConfig({ workers: 1, reporter: process.env.CI ? "github" : "list", use: { - baseURL: "http://localhost:3000", + // Port 3100 instead of the Next default 3000 — Windows/WSL2 routinely + // reserves 3000 invisibly (port appears free in netstat but binding + // fails), so we move out of the way. + baseURL: "http://localhost:3100", trace: "on-first-retry", }, projects: [ { name: "chromium", + // Capture spec is opt-in via the `capture` project (npm run capture-images). + // Keeping it out of `npm test` avoids accidentally regenerating doc PNGs + // on every CI run. + testIgnore: /capture-images\.spec\.ts/, use: { ...devices["Desktop Chrome"] }, }, + { + name: "capture", + testMatch: /capture-images\.spec\.ts/, + // Higher device scale + wider viewport → sharper, complete keyboard PNGs. + // ExportKeyboard at scale=80 renders 15 * 80 = 1200px wide; viewport 1400 + // gives enough room for the route's 24px padding. + use: { + ...devices["Desktop Chrome"], + deviceScaleFactor: 2, + viewport: { width: 1400, height: 600 }, + }, + }, ], webServer: { - command: "npm run dev", - url: "http://localhost:3000", + command: "npm run dev -- --port 3100", + url: "http://localhost:3100", reuseExistingServer: !process.env.CI, timeout: 120_000, }, diff --git a/designer/public/eduuh-dh-config.json b/designer/public/eduuh-dh-config.json new file mode 100644 index 0000000..3e4d87b --- /dev/null +++ b/designer/public/eduuh-dh-config.json @@ -0,0 +1,130 @@ +{ + "version": "1.0", + "name": "Eduuh-DH", + "disableCapsLock": true, + "remapping": { + "_comment": "Modifier remapping", + "CapsLock": "LeftShift", + "LeftShift": "LeftCtrl", + "LeftCtrl": "LeftCtrl", + "RightShift": "RightCtrl", + "Enter": "RightShift", + "RightCtrl": "PrintScreen", + "LeftWin": "LeftAlt", + + "_comment2": "Colemak-DH Top Row", + "E": "F", + "R": "P", + "T": "G", + "Y": "V", + "U": "J", + "I": "L", + "O": "U", + "P": "Y", + + "_comment3": "Colemak-DH Home Row", + "S": "R", + "D": "S", + "F": "T", + "G": "D", + "H": "Slash", + "J": "K", + "K": "N", + "L": "E", + "Semicolon": "I", + "Apostrophe": "O", + + "_comment4": "Colemak-DH Bottom Row", + "Z": "X", + "X": "C", + "C": "B", + "B": "Minus", + "N": "Z", + "Comma": "H", + "Period": "Comma", + "Slash": "Period", + + "_comment5": "Special mappings", + "LeftBracket": "Semicolon", + "Backslash": "Equals", + + "_comment6": "Essential keys for strict mode", + "Space": "Space", + "Backspace": "Backspace", + "Tab": "Tab", + "Delete": "Delete", + "Escape": "Escape" + }, + + "noModCombos": [ + {"key": "Grave", "output": "Slash", "shift": true, "description": "` → ?"}, + {"key": "1", "output": "1", "shift": true, "description": "1 → !"}, + {"key": "2", "output": "LeftBracket", "shift": false, "description": "2 → ["}, + {"key": "3", "output": "RightBracket", "shift": false, "description": "3 → ]"}, + {"key": "4", "output": "Apostrophe", "shift": true, "description": "4 → \""}, + {"key": "5", "output": "LeftBracket", "shift": true, "description": "5 → {"}, + {"key": "6", "output": "RightBracket", "shift": true, "description": "6 → }"}, + {"key": "7", "output": "8", "shift": true, "description": "7 → *"}, + {"key": "8", "output": "Slash", "shift": false, "description": "8 → /"}, + {"key": "9", "output": "Apostrophe", "shift": false, "description": "9 → '"}, + {"key": "0", "output": "0", "shift": true, "description": "0 → )"}, + {"key": "Minus", "output": "9", "shift": true, "description": "- → ("}, + {"key": "Equals", "output": "Backslash", "shift": false, "description": "= → \\"}, + {"key": "RightBracket", "output": "Semicolon", "shift": true, "description": "] → :"}, + {"key": "Tab", "output": "Tab", "shift": false, "description": "Shift+Tab passthrough"} + ], + + "layers": [ + { + "name": "Numpad Layer", + "trigger": "LALT", + "mappings": { + "9": "7", + "0": "8", + "Minus": "9", + "I": "4", + "O": "5", + "P": "6", + "K": "1", + "L": "2", + "Semicolon": "3", + "Apostrophe": "0", + "J": "Backslash" + }, + "shiftMappings": [ + {"key": "N", "output": "4", "shift": true, "description": "$ symbol"}, + {"key": "M", "output": "5", "shift": true, "description": "% symbol"}, + {"key": "Comma", "output": "3", "shift": true, "description": "# symbol"}, + {"key": "U", "output": "Equals", "shift": true, "description": "+ symbol (physical U = Colemak J, numpad operator)"} + ] + }, + { + "name": "Arrows and Symbols Layer", + "trigger": "RALT", + "mappings": { + "K": "Left", + "L": "Down", + "Semicolon": "Right", + "O": "Up", + "Apostrophe": "Enter", + "A": "Backslash", + "S": "Escape", + "D": "9", + "X": "Grave" + }, + "shiftMappings": [ + {"key": "E", "output": "Comma", "shift": true, "description": "< symbol"}, + {"key": "R", "output": "Period", "shift": true, "description": "> symbol"}, + {"key": "U", "output": "Equals", "shift": true, "description": "+ symbol (physical U = logical J in Colemak-DH)"}, + {"key": "Q", "output": "2", "shift": true, "description": "@ symbol"}, + {"key": "W", "output": "Grave", "shift": true, "description": "~ symbol"}, + {"key": "T", "output": "6", "shift": true, "description": "^ symbol"}, + {"key": "F", "output": "1", "shift": true, "description": "! symbol"}, + {"key": "G", "output": "Slash", "shift": true, "description": "? symbol"}, + {"key": "Z", "output": "8", "shift": true, "description": "* symbol"}, + {"key": "C", "output": "7", "shift": true, "description": "& symbol"}, + {"key": "V", "output": "Backslash", "shift": true, "description": "| symbol"} + ] + } + ] +} diff --git a/designer/scripts/copy-config.mjs b/designer/scripts/copy-config.mjs new file mode 100644 index 0000000..edb87e4 --- /dev/null +++ b/designer/scripts/copy-config.mjs @@ -0,0 +1,17 @@ +// Copies the bundled Keyflow config from the parent repo into designer/public/ +// so the screenshot route (and any future doc-rendering routes) can fetch it +// at runtime. Wired as `predev` / `prebuild` in package.json — Turbopack +// refuses to resolve imports outside the designer/ project root, hence the +// build-time sync instead of a direct ../../src import. + +import { copyFileSync, mkdirSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const src = resolve(__dirname, "../../src/config.json"); +const dest = resolve(__dirname, "../public/eduuh-dh-config.json"); + +mkdirSync(dirname(dest), { recursive: true }); +copyFileSync(src, dest); +console.log(`copy-config: ${src} → ${dest}`); diff --git a/designer/tests/capture-images.spec.ts b/designer/tests/capture-images.spec.ts new file mode 100644 index 0000000..76324dc --- /dev/null +++ b/designer/tests/capture-images.spec.ts @@ -0,0 +1,52 @@ +import { test } from "@playwright/test"; +import { mkdir } from "node:fs/promises"; +import { resolve } from "node:path"; + +// Generates documentation screenshots for the Eduuh-DH layout. +// +// Not run as part of `npm test` — gated to its own Playwright project +// (`--project=capture` via playwright.config.ts), invoked via +// `npm run capture-images`. Output is committed alongside the code change +// that motivated regenerating the images. +// +// The route under test (designer/app/screenshot/page.tsx) imports +// src/config.json directly, so screenshots always reflect the bundled +// Eduuh-DH config. + +// Playwright runs from the designer/ directory (that's where package.json +// lives). Use process.cwd() to dodge ESM/CJS ambiguity around import.meta. +const docsImagesDir = resolve(process.cwd(), "../docs/images"); + +const SHOTS = [ + { layer: "all", file: "eduuh-dh-all.png", label: "all layers composite" }, + { layer: "base", file: "eduuh-dh-base.png", label: "BASE remap only" }, + { layer: "0", file: "eduuh-dh-numpad.png", label: "Numpad layer (LAlt)" }, + { layer: "1", file: "eduuh-dh-arrows.png", label: "Arrows & Symbols layer (RAlt)" }, +]; + +test.beforeAll(async () => { + await mkdir(docsImagesDir, { recursive: true }); +}); + +test.beforeEach(async ({ page }) => { + // Clear the persisted theme so the screenshot route's dark-mode override + // doesn't race against a stale "light" class hydrated from localStorage. + await page.addInitScript(() => { + window.localStorage.removeItem("theme"); + }); +}); + +for (const shot of SHOTS) { + test(`capture ${shot.label}`, async ({ page }) => { + await page.goto(`/screenshot/?layer=${shot.layer}`); + await page.locator("[data-screenshot-ready]").waitFor(); + // Fonts settling avoids glyph FOUT artifacts in the snapshot. + await page.evaluate(() => document.fonts.ready); + await page + .locator("#screenshot-root") + .screenshot({ + path: resolve(docsImagesDir, shot.file), + animations: "disabled", + }); + }); +} diff --git a/docs/EDUUH_DH.md b/docs/EDUUH_DH.md index 311e73d..e33e9cc 100644 --- a/docs/EDUUH_DH.md +++ b/docs/EDUUH_DH.md @@ -2,6 +2,8 @@ A personal Colemak-inspired keyboard layout shipped as the default Keyflow config (`src/config.json`). +![Eduuh-DH all layers composite — every key shows its BASE remap plus its outputs in each layer (top: layer 0 / layer 1 or ⇧-injected)](images/eduuh-dh-all.png) + ## What it is Eduuh-DH is **not** Colemak-DH. It borrows ideas from old Colemak and Colemak Mod-DH, then makes three structural changes: @@ -37,6 +39,8 @@ Eduuh-DH: Q W F P G V J L U Y A R S T D / K N E I X C B V - Z ## Base remapping (physical → letter) +![Eduuh-DH base remap — only the BASE remapping shown on each keycap, layer outputs omitted](images/eduuh-dh-base.png) + ### Top row | Physical | Output | | Physical | Output | |----------|--------|---|----------|--------| @@ -74,9 +78,12 @@ Eduuh-DH: Q W F P G V J L U Y A R S T D / K N E I X C B V - Z | RightShift | RightCtrl | | Enter | RightShift | | RightCtrl | PrintScreen | +| LeftWin | LeftAlt | `CapsLock → Shift` is the foundational ergonomic move. `Enter → RightShift` puts a second shift under the right pinky, freeing both shift keys for use as Ctrl modifiers. CapsLock blocking (`"disableCapsLock": true`) prevents accidental caps-lock toggles. +`LeftWin → LeftAlt` repurposes the left Windows key as a second LeftAlt — useful on laptops where LWin sits where Alt should be ergonomically. Two consequences: (1) the Start menu no longer pops when LWin is pressed alone (reach it via Ctrl+Esc), and (2) LWin inherits LAlt's role as the Numpad layer trigger, so LWin + I produces `4` just like LAlt + I. + ## Number row (no-mod combos) These fire when no modifier is held — typing the number row produces programming punctuation by default. Hold Shift to get the original number/symbol. @@ -95,6 +102,8 @@ These fire when no modifier is held — typing the number row produces programmi ### Numpad Layer (LAlt) +![Numpad layer — BASE remap on the bottom-left of each keycap, LAlt-mapped output on the top-left, ⇧-injected outputs on the top-right](images/eduuh-dh-numpad.png) + Hold LeftAlt to turn the right side of the keyboard into a numeric keypad. Physical positions, not Colemak positions. ``` @@ -115,6 +124,8 @@ Shift-injected combos in the Numpad layer: ### Arrows & Symbols Layer (RAlt) +![Arrows and Symbols layer — BASE remap on the bottom-left, RAlt-mapped output on the top-left, ⇧-injected symbols on the top-right](images/eduuh-dh-arrows.png) + Hold RightAlt for navigation and the rest of the programming symbol set. | RAlt + key | Produces | | RAlt + key | Produces | diff --git a/docs/images/eduuh-dh-all.png b/docs/images/eduuh-dh-all.png new file mode 100644 index 0000000..8c48b74 Binary files /dev/null and b/docs/images/eduuh-dh-all.png differ diff --git a/docs/images/eduuh-dh-arrows.png b/docs/images/eduuh-dh-arrows.png new file mode 100644 index 0000000..1ec4459 Binary files /dev/null and b/docs/images/eduuh-dh-arrows.png differ diff --git a/docs/images/eduuh-dh-base.png b/docs/images/eduuh-dh-base.png new file mode 100644 index 0000000..6c47334 Binary files /dev/null and b/docs/images/eduuh-dh-base.png differ diff --git a/docs/images/eduuh-dh-numpad.png b/docs/images/eduuh-dh-numpad.png new file mode 100644 index 0000000..40710ce Binary files /dev/null and b/docs/images/eduuh-dh-numpad.png differ diff --git a/src/app/main.cpp b/src/app/main.cpp index dc459e3..0318f6f 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -1,6 +1,5 @@ #include "DebugLog.h" #include "app/Application.h" -#include "config/BehaviorEnumerator.h" #include "config/ConfigBuilder.h" #include "config/ConfigLoader.h" #include "hardware/Scancodes.h" @@ -10,6 +9,7 @@ #include #include #include +#include #include #include @@ -29,9 +29,6 @@ void shutdownCallback() { struct CliArgs { std::string configPath = "config.json"; - bool validateOnly = false; - bool listBindings = false; - bool printVersion = false; bool debug = false; bool verbose = false; }; @@ -40,13 +37,7 @@ CliArgs parseArgs(int argc, char* argv[]) { CliArgs args; for (int i = 1; i < argc; i++) { std::string_view arg = argv[i]; - if (arg == "--validate") { - args.validateOnly = true; - } else if (arg == "--list-bindings") { - args.listBindings = true; - } else if (arg == "--version" || arg == "-V") { - args.printVersion = true; - } else if (arg == "--debug" || arg == "-d") { + if (arg == "--debug" || arg == "-d") { args.debug = true; } else if (arg == "--verbose" || arg == "-v") { args.verbose = true; @@ -57,15 +48,7 @@ CliArgs parseArgs(int argc, char* argv[]) { return args; } -#ifndef KEYFLOW_VERSION -# define KEYFLOW_VERSION "unknown" -#endif - -void printVersion() { - std::cout << "keyflow " << KEYFLOW_VERSION << "\n"; -} - -void printValidationErrors(const ValidationResult& result) { +void printValidationErrors(IPlatformInit& platform, const ValidationResult& result) { std::cerr << "\n[Config] Validation failed:\n\n"; for (const auto& [field, message] : result.errors) { std::cerr << " - " << field << ": " << message << "\n"; @@ -73,9 +56,18 @@ void printValidationErrors(const ValidationResult& result) { std::cerr << "\nTips:\n" " - Check key names in docs/CONFIG_USAGE.md\n" " - Key names are case-sensitive (e.g., 'CapsLock' not 'capslock')\n\n"; + + // Build a concise MessageBox version for Explorer-launched users. + std::ostringstream dialog; + dialog << "Config validation failed:\n\n"; + for (const auto& [field, message] : result.errors) { + dialog << " - " << field << ": " << message << "\n"; + } + platform.showFatalError(dialog.str()); } -void printLoadFailure(const std::string& configPath, const std::exception& e) { +void printLoadFailure(IPlatformInit& platform, const std::string& configPath, + const std::exception& e) { std::cerr << "\n[Config] Failed to load: " << configPath << "\n\n" << "Error: " << e.what() << "\n\n"; @@ -89,89 +81,36 @@ void printLoadFailure(const std::string& configPath, const std::exception& e) { " \"remapping\": { \"CapsLock\": \"LeftCtrl\" }\n" " }\n\n" "See docs/EDUUH_DH.md for the bundled layout's full spec.\n\n"; + platform.showFatalError("config.json not found.\n\nMake sure config.json sits next to " + "keyflow.exe.\n\nPath checked: " + + configPath); } else { std::cerr << "Common issues:\n" " - Check JSON syntax (missing commas, brackets)\n" " - Ensure 'version' field exists\n" " - Validate at https://jsonlint.com\n\n"; + platform.showFatalError(std::string("Failed to load config: ") + configPath + "\n\n" + + e.what() + "\n\nCheck JSON syntax and the 'version' field."); } } -std::optional loadAndValidateConfig(const std::string& configPath) { +std::optional loadAndValidateConfig(IPlatformInit& platform, + const std::string& configPath) { try { DEBUG_LOG("[Config] Loading: " << configPath << "\n\n"); JsonConfig config = ConfigLoader::loadFromFile(configPath); auto validation = ConfigLoader::validate(config); if (!validation) { - printValidationErrors(validation); + printValidationErrors(platform, validation); return std::nullopt; } return config; } catch (const std::exception& e) { - printLoadFailure(configPath, e); + printLoadFailure(platform, configPath, e); return std::nullopt; } } -void printValidateSummary(const JsonConfig& config) { - std::cout << "[Config] Validation successful\n" - << "[Config] Config name: " << config.name << "\n" - << "[Config] Remappings: " << config.remapping.size() << "\n" - << "[Config] NoModCombos: " << config.noModCombos.size() << "\n" - << "[Config] Layers: " << config.layers.size() << "\n"; -} - -// Dumps every active binding from the loaded config, grouped by kind. Useful -// for "what does my LAlt+I actually do" without diving into JSON. Read-only — -// doesn't start the runtime. -void printBindings(const JsonConfig& config) { - auto behaviors = enumerateBehaviors(config, config.name); - - std::cout << "\nKeyflow bindings — " << config.name << " (" << behaviors.size() - << " behaviors)\n"; - std::cout << std::string(60, '=') << "\n\n"; - - auto printSection = [&](const char* title, Behavior::Kind kind) { - bool printedHeader = false; - for (const auto& b : behaviors) { - if (b.kind != kind) { - continue; - } - if (!printedHeader) { - std::cout << title << "\n" << std::string(strlen(title), '-') << "\n"; - printedHeader = true; - } - std::cout << " " << b.physicalKeyName; - if (b.triggerModifier) { - std::cout << " (with " << *b.triggerModifier << ")"; - } - std::cout << " → "; - if (b.expectedInjectShift) { - std::cout << "Shift+"; - } - if (b.kind == Behavior::Kind::CustomModifier) { - std::cout << "modifier:" << b.expectedOutputName; - if (b.blockOutput) { - std::cout << " (output blocked)"; - } - } else { - std::cout << b.expectedOutputName; - } - std::cout << "\n"; - } - if (printedHeader) { - std::cout << "\n"; - } - }; - - printSection("Modifier remaps", Behavior::Kind::ModifierRemap); - printSection("Key remaps", Behavior::Kind::Remap); - printSection("No-modifier combos", Behavior::Kind::NoModCombo); - printSection("Custom modifiers", Behavior::Kind::CustomModifier); - printSection("Layer mappings", Behavior::Kind::LayerMapping); - printSection("Layer shift-mappings (inject Shift)", Behavior::Kind::LayerShiftMapping); -} - // Send replacement key with shift injection. Tracker is updated so the safety // cleanup on the next key-down can recover from fast typing across shift injections. void sendReplaceWithShift(Application& app, ModifierTracker* modTracker, uint16_t outputScancode, @@ -264,35 +203,19 @@ void runEventLoop(Application& app, ModifierTracker* modTracker) { int main(int argc, char* argv[]) { auto platformInit = PlatformFactory::createPlatformInit(); - platformInit->hideConsoleIfRelease(); CliArgs args = parseArgs(argc, argv); - if (args.printVersion) { - printVersion(); - return 0; - } - if (args.verbose) { gVerboseLogging = true; std::cout << "[Main] Verbose logging enabled - writing to keyflow_debug.log\n"; } - auto config = loadAndValidateConfig(args.configPath); + auto config = loadAndValidateConfig(*platformInit, args.configPath); if (!config) { return 1; } - if (args.validateOnly) { - printValidateSummary(*config); - return 0; - } - - if (args.listBindings) { - printBindings(*config); - return 0; - } - Application app(PlatformFactory::createHardwareIO(), PlatformFactory::createSystemTray()); app.config().debugMode = args.debug; gApp = &app; @@ -301,8 +224,10 @@ int main(int argc, char* argv[]) { auto instanceLock = PlatformFactory::createSingleInstanceLock(); if (!instanceLock->acquire()) { - std::cerr << "[Main] Another instance of Keyflow is already running\n" - "[Main] Only one instance can run at a time\n"; + std::cerr << "[Main] Couldn't take over from existing Keyflow instance\n"; + platformInit->showFatalError( + "Couldn't take over from the existing Keyflow instance.\n\nThe other process may be " + "running as a different user or with elevated privileges. Try closing it manually."); gApp = nullptr; return 1; } @@ -318,6 +243,12 @@ int main(int argc, char* argv[]) { " 4. Re-run keyflow.exe\n\n" "If the driver is already installed, check that interception.dll\n" "is sitting next to keyflow.exe in the same folder.\n\n"; + platformInit->showFatalError( + "Hardware init failed — the Interception driver couldn't be opened.\n\n" + "Install it from:\n" + "https://github.com/oblitum/Interception/releases\n\n" + "Run install-interception.exe /install, reboot, then re-run keyflow.exe.\n\n" + "If already installed, check that interception.dll sits next to keyflow.exe."); gApp = nullptr; return 1; } @@ -325,6 +256,9 @@ int main(int argc, char* argv[]) { ModifierTracker* modTracker = nullptr; if (!ConfigBuilder::buildPipeline(*config, app.pipeline(), !args.debug, &modTracker)) { std::cerr << "[Main] Failed to build pipeline from config\n"; + platformInit->showFatalError( + "Failed to build pipeline from config.\n\nRun from PowerShell with --debug for " + "details."); gApp = nullptr; return 1; } diff --git a/src/config.json b/src/config.json index fc50cda..3e4d87b 100644 --- a/src/config.json +++ b/src/config.json @@ -10,6 +10,7 @@ "RightShift": "RightCtrl", "Enter": "RightShift", "RightCtrl": "PrintScreen", + "LeftWin": "LeftAlt", "_comment2": "Colemak-DH Top Row", "E": "F", diff --git a/src/platform/IPlatform.h b/src/platform/IPlatform.h index 4574b9e..817ba5c 100644 --- a/src/platform/IPlatform.h +++ b/src/platform/IPlatform.h @@ -38,7 +38,11 @@ class ISingleInstanceLock { class IPlatformInit { public: virtual ~IPlatformInit() = default; - virtual void hideConsoleIfRelease() noexcept = 0; + // Surface a fatal startup error to the user via the platform's modal UI + // (MessageBox on Windows). Needed because SUBSYSTEM:WINDOWS swallows + // stderr when launched from Explorer — without this, init failures look + // like the exe did nothing. + virtual void showFatalError(std::string_view message) noexcept = 0; using ShutdownCallback = void (*)(); virtual void installSignalHandlers(ShutdownCallback callback) noexcept = 0; }; diff --git a/src/platform/windows/WindowsPlatformInit.h b/src/platform/windows/WindowsPlatformInit.h index e17cb60..670c304 100644 --- a/src/platform/windows/WindowsPlatformInit.h +++ b/src/platform/windows/WindowsPlatformInit.h @@ -5,20 +5,39 @@ #include #include #include +#include +#include #include namespace keyflow { class WindowsPlatformInit final : public IPlatformInit { public: - void hideConsoleIfRelease() noexcept override { -#ifndef DEBUG_BUILD - HWND console = GetConsoleWindow(); - if (console) - ShowWindow(console, SW_HIDE); -#else - // In debug builds, keep console visible -#endif + WindowsPlatformInit() noexcept { + // Pin CWD to exe directory so config.json, keyboard-icon.ico, and + // keyflow_debug.log resolve regardless of how the exe was launched. + // Without this, `keyflow.exe` from any shell or shortcut whose "Start in" + // isn't set will fail to find config.json. + wchar_t path[MAX_PATH]; + DWORD len = GetModuleFileNameW(nullptr, path, MAX_PATH); + if (len == 0 || len >= MAX_PATH) { + return; + } + std::wstring dir(path, len); + auto slash = dir.find_last_of(L"\\/"); + if (slash == std::wstring::npos) { + return; + } + dir.resize(slash); + SetCurrentDirectoryW(dir.c_str()); + } + + void showFatalError(std::string_view message) noexcept override { + // string_view isn't null-terminated; copy to a std::string for the + // C API. MB_SETFOREGROUND brings the dialog above other windows so + // the user actually sees it when launched as a background process. + std::string text(message); + MessageBoxA(nullptr, text.c_str(), "Keyflow", MB_OK | MB_ICONERROR | MB_SETFOREGROUND); } void installSignalHandlers(ShutdownCallback callback) noexcept override { diff --git a/src/platform/windows/WindowsSingleInstanceLock.h b/src/platform/windows/WindowsSingleInstanceLock.h index bdcd6cc..f46e40f 100644 --- a/src/platform/windows/WindowsSingleInstanceLock.h +++ b/src/platform/windows/WindowsSingleInstanceLock.h @@ -2,7 +2,11 @@ #include "platform/IPlatform.h" +#include +// clang-format off #include +#include +// clang-format on namespace keyflow { @@ -18,32 +22,86 @@ class WindowsSingleInstanceLock final : public ISingleInstanceLock { WindowsSingleInstanceLock(WindowsSingleInstanceLock&&) = delete; WindowsSingleInstanceLock& operator=(WindowsSingleInstanceLock&&) = delete; + // Auto-takeover: if another instance holds the mutex, terminate it and retry. + // Failure means we couldn't take over (rare — permission issue or runaway + // mutex), in which case main() surfaces a fatal MessageBox. [[nodiscard]] bool acquire() noexcept override { - mutex_ = CreateMutexA(nullptr, TRUE, "Global\\KeyflowSingleInstanceMutex"); - - if (mutex_ == nullptr) { - return false; + if (tryCreateMutex()) { + return true; } + terminateOtherInstances(); + // Brief pause for the OS to release the abandoned mutex after TerminateProcess. + Sleep(200); + return tryCreateMutex(); + } - if (GetLastError() == ERROR_ALREADY_EXISTS) { + void release() noexcept override { + if (mutex_ != nullptr) { + ReleaseMutex(mutex_); CloseHandle(mutex_); mutex_ = nullptr; - return false; } - - return true; } - void release() noexcept override { + private: + HANDLE mutex_ = nullptr; + + // Returns true when we got fresh ownership of the mutex. Returns false when + // it already existed (another instance) — the caller decides whether to retry. + bool tryCreateMutex() noexcept { + // Guard against a double-call leaking the previously held handle. + // acquire() retries after takeover and would otherwise overwrite mutex_. if (mutex_ != nullptr) { ReleaseMutex(mutex_); CloseHandle(mutex_); mutex_ = nullptr; } + mutex_ = CreateMutexA(nullptr, TRUE, "Global\\KeyflowSingleInstanceMutex"); + if (mutex_ == nullptr) { + return false; + } + if (GetLastError() == ERROR_ALREADY_EXISTS) { + CloseHandle(mutex_); + mutex_ = nullptr; + return false; + } + return true; } - private: - HANDLE mutex_ = nullptr; + // Walk the process table and kill any process whose exe basename matches + // our own. Comparing by basename (not full path) means a renamed copy still + // takes precedence, and a portable build in a different folder still wins. + void terminateOtherInstances() noexcept { + wchar_t selfPath[MAX_PATH]; + DWORD len = GetModuleFileNameW(nullptr, selfPath, MAX_PATH); + if (len == 0 || len >= MAX_PATH) { + return; + } + std::wstring self(selfPath, len); + auto slash = self.find_last_of(L"\\/"); + std::wstring selfName = (slash != std::wstring::npos) ? self.substr(slash + 1) : self; + + DWORD selfPid = GetCurrentProcessId(); + HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snap == INVALID_HANDLE_VALUE) { + return; + } + + PROCESSENTRY32W pe{}; + pe.dwSize = sizeof(pe); + if (Process32FirstW(snap, &pe)) { + do { + if (pe.th32ProcessID != selfPid && _wcsicmp(pe.szExeFile, selfName.c_str()) == 0) { + HANDLE proc = OpenProcess(PROCESS_TERMINATE, FALSE, pe.th32ProcessID); + if (proc != nullptr) { + TerminateProcess(proc, 0); + CloseHandle(proc); + } + } + } while (Process32NextW(snap, &pe)); + } + CloseHandle(snap); + } }; } // namespace keyflow