first commit

This commit is contained in:
2026-09-09 00:02:16 -04:00
commit 8b8795a475
44 changed files with 7456 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
# Node.js
node_modules/
# Stream Deck files
*.sdPlugin/*
!*.sdPlugin/.appignore
!*.sdPlugin/app*.json
!*.sdPlugin/manifest.json
+20
View File
@@ -0,0 +1,20 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Attach to Plugin",
"type": "node",
"request": "attach",
"processId": "${command:PickProcess}",
"outFiles": [
"${workspaceFolder}/*.sdPlugin/bin/**/*.js"
],
"resolveSourceMapLocations": [
"${workspaceFolder}/**"
]
}
]
}
+17
View File
@@ -0,0 +1,17 @@
{
/* JSON schemas */
"json.schemas": [
{
"fileMatch": [
"**/manifest.json"
],
"url": "https://schemas.elgato.com/streamdeck/plugins/manifest.json"
},
{
"fileMatch": [
"**/layouts/*.json"
],
"url": "https://schemas.elgato.com/streamdeck/plugins/layout.json"
}
]
}
+40
View File
@@ -0,0 +1,40 @@
# AGENTS.md
Context for OpenCode sessions working on the **Dynamic Deck** Stream Deck plugin (`com.ryan-kegel.dynamic-deck`).
## What this is
An Elgato Stream Deck plugin (SDK v3, Node.js 24) built with TypeScript + Rollup. Some actions are **Windows-only** because they shell out to PowerShell and use Win32 P/Invoke via the `koffi` FFI (`user32.dll`, `kernel32.dll`). Other actions are platform-agnostic.
## Commands
- `npm run build` — rollup production build to `com.ryan-kegel.dynamic-deck.sdPlugin/bin/plugin.js` (terser-minified, no sourcemap).
- `npm run watch` — rollup watch with sourcemaps; on every rebuild triggers `streamdeck restart com.ryan-kegel.dynamic-deck`.
- `node generate-assets.cjs` — writes 1x1 placeholder PNGs into `imgs/plugin/` and `imgs/actions/`. Run once after cloning if those images are missing.
- There is **no test runner, linter, or typecheck script**. The only verification is `npm run build` (rollup runs the TS plugin, so type errors will surface here).
## Source layout
- `src/plugin.ts` — entrypoint. Registers actions, wires `AppMonitor` (1s polling for foreground-window changes) and `AppTrackerService` (persisted to `app-stats.json` in cwd), handles SIGINT/SIGTERM to flush stats, then `streamDeck.connect()`s.
- `src/actions/<name>.ts` — one `SingletonAction` subclass per Stream Deck action, each `@action({ UUID: "com.ryan-kegel.dynamic-deck.<name>" })`.
- `src/services/` — `app-monitor.ts` (Win32 FFI foreground tracking), `app-tracker.ts` (launch stats / ranking), `blacklist-manager.ts`.
- `src/utils/app-launcher.ts` — Start Menu `.lnk` scanning + 256x256 icon extraction via PowerShell + C# `PrivateExtractIcons` P/Invoke.
- `src/ui/<action>.html` + `*.js` — Property Inspector assets, copied as-is to the `.sdPlugin` bundle.
- `com.ryan-kegel.dynamic-deck.sdPlugin/` — the built plugin bundle (output). `manifest.json`, `bin/plugin.js`, copied `ui/`, `imgs/`, `scripts/`, `services/`, `utils/`. This is what Stream Deck loads.
## Conventions and gotchas
- **Bundle shape is non-obvious.** `rollup.config.mjs` copies `src/{ui,imgs,scripts,services,utils}` into the `.sdPlugin/` folder alongside the built `bin/plugin.js`. Edits to those files in the source tree won't appear until a rebuild copies them. The TS files copied under `services/` and `utils/` are **not** the ones Rollup bundles — they are inert copies; the bundled code is what ends up in `bin/plugin.js`.
- `RollupWatchPlugin` emits `{"type":"module"}` into `bin/package.json` so the host can `import()` the bundle. Don't hand-edit that file.
- `manifest.json` is treated as a watched file; changes trigger a rebuild.
- Several actions are currently commented out in `plugin.ts` (e.g. `IncrementCounter`, `EcobeeModeAction`, `EcobeeAction`). To re-enable one, uncomment both the import and the `registerAction` line.
- The `app-stats.json` tracker file is written next to `process.cwd()` — i.e. inside the `.sdPlugin/` folder at runtime. Don't delete it without expecting reset ranking data.
- `launch-app.ts` uses `ev.action.setTitle()` to write to Stream Deck's built-in Title field — do **not** add a custom title `<input>` in `launch-app.html`, you'll get two fields (see README §2).
- `node` engine is pinned to v24 via `manifest.json` (`"Debug": "enabled"`); debug by attaching VS Code to the running plugin process (the "Attach to Plugin" launch config in `.vscode/launch.json`).
- `tsconfig.json` extends `@tsconfig/node20` and uses `customConditions: ["node"]` + `moduleResolution: "Bundler"` to match Rollup's resolution mode.
## Things explicitly NOT to do
- Don't run `tsc` directly — there's no script and no outDir. The TS plugin runs as part of Rollup.
- Don't add a `test` / `lint` script "just in case" — verify with the user first; none have been configured and the user may not want them.
- Don't assume macOS support exists for app launching features — `app-launcher.ts` and `app-monitor.ts` use Windows-specific APIs.
+123
View File
@@ -0,0 +1,123 @@
# Dynamic Deck - Stream Deck Plugin Architecture & Development Context
**Plugin ID:** `com.ryan-kegel.dynamic-deck`
**Author:** Ryan Kegel
**Node.js Version:** 24
**SDK Version:** 3 (Elgato Stream Deck Node.js SDK `@elgato/streamdeck`)
---
## Overview
`Dynamic Deck` is a custom modular Stream Deck plugin built using TypeScript and Node.js. It integrates desktop application switching, smart home controls, API balance monitoring, and utility actions into a unified plugin structure.
---
## Action Inventory
### 1. Fast App Launcher (`com.ryan-kegel.dynamic-deck.launch-app`)
Scans the host system for installed applications, populates a custom dropdown menu in the Property Inspector, automatically extracts application icons, updates button text, and either launches or focuses the selected application.
- **Property Inspector UI:** Custom HTML/JS interface with styled select elements, dropdown options menu, and options checkboxes.
- **Key Features:**
- **App Discovery:** Scans Windows Start Menu shortcuts (`.lnk` files) via PowerShell scripts and extracts executable paths.
- **High-Resolution Icon Extraction:** Uses Win32 P/Invoke (`PrivateExtractIcons` from `user32.dll`) via C# wrapped inside PowerShell to retrieve 256x256 PNG icons, falling back to standard `ExtractAssociatedIcon`.
- **Title Auto-Generation:** Shortens application executable/shortcut names to a maximum of 2 words (e.g., "Visual Studio Code" -> "Visual Studio") and formats button labels automatically.
- **Process Focus / Bring-to-Front:** Checks running system processes for existing window handles (`MainWindowHandle`). If found, it invokes Win32 `ShowWindow` (`SW_RESTORE = 9`) and `SetForegroundWindow` to bring active windows to the front instead of spawning duplicate app instances.
- **Native UI Title Sync:** Eliminates duplicate input fields in the Property Inspector by leveraging Stream Deck's native built-in Title input and updating it via `ev.action.setTitle()`.
### 2. DeepSeek Balance (`com.ryan-kegel.dynamic-deck.deepseek-balance`)
Monitors API key balance for DeepSeek services and opens the developer usage dashboard on key press.
- **Property Inspector UI:** Modern `sdpi-components` form layout for secure API key configuration.
### 3. Ecobee Smart Home Controls
- **Temperature Control (`com.ryan-kegel.dynamic-deck.ecobee`):** Adjusts thermostat target temperature and displays current indoor ambient temperature readings.
- **HVAC Mode (`com.ryan-kegel.dynamic-deck.ecobee-mode`):** Toggles system operating modes (Heat, Cool, Auto, Off).
### 4. Counter (`com.ryan-kegel.dynamic-deck.increment`)
A basic numeric state incrementer action.
---
## Key Architectural Decisions & Engineering Solutions
### 1. High-Resolution Icon Extraction Engine
Standard Win32 shell calls (`ExtractAssociatedIcon`) extract default `32x32` thumbnail icons, resulting in blurry, pixelated icons on Stream Deck keys.
**Solution:** In `launch-app.ts`, icon extraction was upgraded by embedding a custom C# helper class (`HighResIconExtractor`) compiled dynamically in PowerShell using `Add-Type`. It requests explicit `256x256` pixel icon groups directly from executable headers:
```csharp
[DllImport("user32.dll", CharSet = CharSet.Auto)]
private static extern uint PrivateExtractIcons(
string szFileName, int nIconIndex, int cxIcon, int cyIcon,
IntPtr[] phicon, uint[] piconid, uint nIcons, uint flags);
```
The high-resolution bitmap is saved directly to an in-memory PNG stream and returned as base64 data to setImage().
### 2. Native Property Inspector Title Integration
Adding a custom `<input type="text" id="titleInput">` field to launch-app.html resulted in two Title fields appearing in the settings UI because Stream Deck automatically injects its native Title field at the top of every Property Inspector.
**Solution:**
1. The custom HTML row for Title in launch-app.html was removed.
2. When an app is selected in the Property Inspector or picked via OpenFileDialog, the backend (launch-app.ts) calculates the shortened title name and invokes await ev.action.setTitle(title).
3. Stream Deck automatically synchronizes this title to both the physical button face and the native Property Inspector Title input field.
### 3. Resolving Settings Race Conditions
Calling `saveSettings()` (`setSettings`) inside the Property Inspector WebSocket script (`launch-app.js`) concurrently with sending message payloads (`sendToPlugin`) created race conditions where stored setting objects were overwritten before backend logic could resolve.
**Solution:**
Settings management was centralized into the backend action handler (launch-app.ts). The Property Inspector (launch-app.js) sends intent payloads (e.g., { event: "appSelected", app: fullAppObject }) to the backend plugin, which executes icon/title processing and calls await ev.action.setSettings(updatedSettings) in a single atomic transaction.
### 4. Windows Process Focus Logic
Standard child_process.execFile calls trigger new executable instances rather than bringing active windows to focus.
**Solution:**
The plugin passes executable paths to an encoded PowerShell process focus routine before falling back to Start-Process:
```PowerShell
$proc = Get-Process -Name $exeName -ErrorAction SilentlyContinue \vert{} Where-Object {$_.MainWindowHandle -ne 0 } | Select-Object -First 1;
if ($proc) {
$type::ShowWindow($proc.MainWindowHandle, 9); # SW_RESTORE
$type::SetForegroundWindow($proc.MainWindowHandle);
} else {
Start-Process -FilePath $targetPath;
}
```
## Data & Event Flow Sequence
```
┌─────────────────────────────────┐ ┌─────────────────────────────────┐
│ Property Inspector (Client UI) │ │ Plugin Backend (TypeScript) │
└────────────────┬────────────────┘ └────────────────┬────────────────┘
│ │
│────── sendToPlugin: { event: "getApps" } ────────>│
│ │─── Scans Start Menu
│ │─── Extracts 256x256 Base64 Icons
│<───── sendToPropertyInspector: apps [] ───────────│
│ │
│────── sendToPlugin: { event: "appSelected" } ────>│
│ │─── Formats/Shortens Title
│ │─── ev.action.setTitle(...)
│ │─── ev.action.setImage(...)
│ │─── ev.action.setSettings(...)
│<───── sendToPropertyInspector: { title } ─────────│
```
## File Structure Map
- manifest.json: Plugin metadata, minimum SDK/OS targets, action declarations, and entry points.
- src/actions/launch-app.ts: Fast App Launcher backend logic (PowerShell app discovery, high-res P/Invoke icon extraction, window activation, settings persistence).
- ui/launch-app.html: Property Inspector structure for App Launcher options and dropdown.
- ui/launch-app.js: Property Inspector logic handling WebSocket initialization, dropdown UI rendering, and message dispatch.ui/deepseek-balance.html: Property Inspector form using <sdpi-components> for API key entry.
@@ -0,0 +1,10 @@
# App Ignore Blacklist
(?i)\\windows\\
(?i)powershell\.exe$
(?i)cmd\.exe$
(?i)explorer\.exe$
(?i)applicationframehost\.exe$
(?i)searchhost\.exe$
(?i)streamdeck\.exe$
(?i)whatsapp\.root\.exe$
(?i)steamwebhelper\.exe$
@@ -0,0 +1,15 @@
{
"exact": {
"cmd.exe": "Command Prompt",
"peak.exe": "Peak",
"code.exe": "VS Code",
"chrome.exe": "Google\nChrome",
"PEAK.url": "Peak"
},
"patterns": [
{
"regex": "(?i)^godot_v(\\d+)\\..*",
"replacement": "Godot $1"
}
]
}
@@ -0,0 +1,154 @@
{
"c:\\portableapps\\visual studio code\\code.exe": {
"name": "Visual Studio Code",
"path": "c:\\portableapps\\visual studio code\\code.exe",
"launchCount": 12,
"focusCount": 186,
"activeDurationSeconds": 205450,
"lastUsedTimestamp": 1788926512701
},
"c:\\program files\\google\\chrome\\application\\chrome.exe": {
"name": "Google Chrome",
"path": "c:\\program files\\google\\chrome\\application\\chrome.exe",
"launchCount": 34,
"focusCount": 253,
"activeDurationSeconds": 208334,
"lastUsedTimestamp": 1788926520418
},
"c:\\portableapps\\blender\\blender.exe": {
"name": "Blender",
"path": "c:\\portableapps\\blender\\blender.exe",
"launchCount": 2,
"focusCount": 2,
"activeDurationSeconds": 6,
"lastUsedTimestamp": 1788410154017
},
"c:\\godot4\\godot_v4.7.1-stable_win64.exe": {
"name": "Godot Engine",
"path": "c:\\godot4\\godot_v4.7.1-stable_win64.exe",
"launchCount": 115,
"focusCount": 83,
"activeDurationSeconds": 49481,
"lastUsedTimestamp": 1788919885095
},
"c:\\program files\\nomachine\\bin\\nxplayer.bin": {
"name": "NoMachine Player Process",
"path": "c:\\program files\\nomachine\\bin\\nxplayer.bin",
"launchCount": 5,
"focusCount": 26,
"activeDurationSeconds": 3607,
"lastUsedTimestamp": 1788900860130
},
"c:\\users\\ryan\\appdata\\local\\programs\\obsidian\\obsidian.exe": {
"name": "Obsidian",
"path": "c:\\users\\ryan\\appdata\\local\\programs\\obsidian\\obsidian.exe",
"launchCount": 3,
"focusCount": 28,
"activeDurationSeconds": 2708,
"lastUsedTimestamp": 1788924951152
},
"c:\\program files\\windowsapps\\microsoft.windowsterminal_1.24.11911.0_x64__8wekyb3d8bbwe\\windowsterminal.exe": {
"name": "Windows Terminal Host",
"path": "c:\\program files\\windowsapps\\microsoft.windowsterminal_1.24.11911.0_x64__8wekyb3d8bbwe\\windowsterminal.exe",
"launchCount": 3,
"focusCount": 78,
"activeDurationSeconds": 5848,
"lastUsedTimestamp": 1788925695681
},
"h:\\games\\the darkest files\\the darkest files.exe": {
"name": "the darkest files",
"path": "h:\\games\\the darkest files\\the darkest files.exe",
"launchCount": 2,
"focusCount": 4,
"activeDurationSeconds": 2381,
"lastUsedTimestamp": 1788838622190
},
"c:\\portableapps\\gimpportable\\app\\gimp\\bin\\gimp-2.10.exe": {
"name": "GNU Image Manipulation Program",
"path": "c:\\portableapps\\gimpportable\\app\\gimp\\bin\\gimp-2.10.exe",
"launchCount": 1,
"focusCount": 1,
"activeDurationSeconds": 3,
"lastUsedTimestamp": 1788575227396
},
"c:\\program files\\bambu studio\\bambu-studio.exe": {
"name": "BambuStudio",
"path": "c:\\program files\\bambu studio\\bambu-studio.exe",
"launchCount": 2,
"focusCount": 9,
"activeDurationSeconds": 27027,
"lastUsedTimestamp": 1788923561849
},
"c:\\portableapps\\freecad\\bin\\freecad.exe": {
"name": "freecad",
"path": "c:\\portableapps\\freecad\\bin\\freecad.exe",
"launchCount": 3,
"focusCount": 17,
"activeDurationSeconds": 4871,
"lastUsedTimestamp": 1788923536820
},
"c:\\portableapps\\irfanview\\app\\irfanview64\\i_view64.exe": {
"name": "IrfanView 64-bit",
"path": "c:\\portableapps\\irfanview\\app\\irfanview64\\i_view64.exe",
"launchCount": 3,
"focusCount": 1,
"activeDurationSeconds": 95,
"lastUsedTimestamp": 1788586171205
},
"h:\\steam\\steamapps\\common\\content warning\\content warning.exe": {
"name": "content warning",
"path": "h:\\steam\\steamapps\\common\\content warning\\content warning.exe",
"launchCount": 2,
"focusCount": 10,
"activeDurationSeconds": 3812,
"lastUsedTimestamp": 1788754135308
},
"c:\\program files\\7-zip\\7zfm.exe": {
"name": "7-Zip File Manager",
"path": "c:\\program files\\7-zip\\7zfm.exe",
"launchCount": 1,
"focusCount": 3,
"activeDurationSeconds": 35,
"lastUsedTimestamp": 1788751440759
},
"c:\\program files\\cheat engine 7.5\\cheatengine-x86_64-sse4-avx2.exe": {
"name": "Cheat Engine",
"path": "c:\\program files\\cheat engine 7.5\\cheatengine-x86_64-sse4-avx2.exe",
"launchCount": 1,
"focusCount": 7,
"activeDurationSeconds": 32,
"lastUsedTimestamp": 1788754937452
},
"c:\\program files (x86)\\steam\\steam.exe": {
"name": "Steam",
"path": "c:\\program files (x86)\\steam\\steam.exe",
"launchCount": 1,
"focusCount": 1,
"activeDurationSeconds": 174,
"lastUsedTimestamp": 1788838072451
},
"h:\\steam\\steamapps\\common\\peak\\peak.exe": {
"name": "peak",
"path": "h:\\steam\\steamapps\\common\\peak\\peak.exe",
"launchCount": 1,
"focusCount": 1,
"activeDurationSeconds": 22,
"lastUsedTimestamp": 1788838292184
},
"d:\\python utilities\\icue\\app\\dist\\icue assigner\\icue assigner.exe": {
"name": "icue assigner",
"path": "d:\\python utilities\\icue\\app\\dist\\icue assigner\\icue assigner.exe",
"launchCount": 1,
"focusCount": 1,
"activeDurationSeconds": 8,
"lastUsedTimestamp": 1788923506351
},
"c:\\portableapps\\dimmer\\dimmer.exe": {
"name": "Dimmer - Brightness control software",
"path": "c:\\portableapps\\dimmer\\dimmer.exe",
"launchCount": 1,
"focusCount": 1,
"activeDurationSeconds": 6,
"lastUsedTimestamp": 1788924847522
}
}
@@ -0,0 +1,113 @@
{
"Name": "Dynamic Deck",
"Version": "0.1.0.0",
"Author": "Ryan Kegel",
"Actions": [
{
"Name": "Counter",
"UUID": "com.ryan-kegel.dynamic-deck.increment",
"Icon": "imgs/actions/counter/icon",
"Tooltip": "Displays a count, which increments by one on press.",
"PropertyInspectorPath": "ui/increment-counter.html",
"Controllers": ["Keypad"],
"States": [
{
"Image": "imgs/actions/counter/key",
"TitleAlignment": "middle"
}
]
},
{
"Name": "DeepSeek Balance",
"UUID": "com.ryan-kegel.dynamic-deck.deepseek-balance",
"Icon": "imgs/actions/deepseek/icon",
"Tooltip": "Displays current DeepSeek API balance and opens usage dashboard on press.",
"PropertyInspectorPath": "ui/deepseek-balance.html",
"Controllers": ["Keypad"],
"States": [
{
"Image": "imgs/actions/deepseek/key",
"TitleAlignment": "middle",
"FontSize": "12"
}
]
},
{
"Name": "Ecobee Temperature Control",
"UUID": "com.ryan-kegel.dynamic-deck.ecobee",
"Icon": "imgs/actions/ecobee",
"PropertyInspectorPath": "ui/ecobee.html",
"States": [
{
"Image": "imgs/actions/key",
"TitleAlignment": "middle",
"FontSize": 10
}
]
},
{
"Name": "Ecobee HVAC Mode",
"UUID": "com.ryan-kegel.dynamic-deck.ecobee-mode",
"Icon": "imgs/actions/ecobee-mode",
"PropertyInspectorPath": "ui/ecobee.html",
"States": [
{
"Image": "imgs/actions/key",
"TitleAlignment": "middle",
"FontSize": 10
}
]
},
{
"Name": "Fast App Launcher",
"UUID": "com.ryan-kegel.dynamic-deck.launch-app",
"Icon": "imgs/actions/launcher",
"PropertyInspectorPath": "ui/launch-app.html",
"States": [
{
"Image": "imgs/actions/key",
"TitleAlignment": "middle",
"FontSize": 12
}
]
},
{
"Name": "Dynamic App Slot",
"UUID": "com.ryan-kegel.dynamic-deck.app-slot",
"Icon": "imgs/actions/app-slot",
"PropertyInspectorPath": "ui/app-slot.html",
"States": [
{
"Image": "imgs/actions/app-slot-state",
"TitleAlignment": "middle",
"FontSize": "12"
}
],
"PropertyInspectorPath": "ui/app-slot.html"
}
],
"Category": "Dynamic Deck",
"CategoryIcon": "imgs/plugin/category-icon",
"CodePath": "bin/plugin.js",
"Description": "Just learning",
"Icon": "imgs/plugin/marketplace",
"SDKVersion": 3,
"Software": {
"MinimumVersion": "7.1"
},
"OS": [
{
"Platform": "mac",
"MinimumVersion": "12"
},
{
"Platform": "windows",
"MinimumVersion": "10"
}
],
"Nodejs": {
"Version": "24",
"Debug": "enabled"
},
"UUID": "com.ryan-kegel.dynamic-deck"
}
+30
View File
@@ -0,0 +1,30 @@
// generate-assets.js
const fs = require("fs");
const path = require("path");
// Minimal valid PNG buffer (1x1 solid block)
const minimalPngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
const pngBuffer = Buffer.from(minimalPngBase64, "base64");
const assetPaths = [
"imgs/plugin/category.png",
"imgs/plugin/category@2x.png",
"imgs/actions/app-slot.png",
"imgs/actions/app-slot@2x.png",
"imgs/actions/app-slot-state.png",
"imgs/actions/app-slot-state@2x.png"
];
for (const relativePath of assetPaths) {
const fullPath = path.join(process.cwd(), relativePath);
const dir = path.dirname(fullPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(fullPath, pngBuffer);
console.log(`Created: ${relativePath}`);
}
console.log("\nPlaceholder assets created successfully.");
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"></path></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"></path></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

+2806
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"scripts": {
"build": "rollup -c",
"watch": "rollup -c -w --watch.onEnd=\"streamdeck restart com.ryan-kegel.dynamic-deck\""
},
"type": "module",
"devDependencies": {
"@elgato/cli": "^1.9.0",
"@rollup/plugin-commonjs": "^29.0.2",
"@rollup/plugin-node-resolve": "^15.2.2",
"@rollup/plugin-terser": "^1.0.0",
"@rollup/plugin-typescript": "^12.1.0",
"@tsconfig/node20": "^20.1.2",
"@types/node": "~24.1.0",
"rollup": "^4.0.2",
"rollup-plugin-copy": "^3.5.0",
"tslib": "^2.6.2",
"typescript": "^5.2.2"
},
"dependencies": {
"@elgato/streamdeck": "^2.0.0",
"file-icon": "^6.0.0",
"koffi": "^3.1.6"
}
}
+65
View File
@@ -0,0 +1,65 @@
import commonjs from "@rollup/plugin-commonjs";
import nodeResolve from "@rollup/plugin-node-resolve";
import terser from "@rollup/plugin-terser";
import copy from "rollup-plugin-copy";
import typescript from "@rollup/plugin-typescript";
import path from "node:path";
import url from "node:url";
const isWatching = !!process.env.ROLLUP_WATCH;
const sdPlugin = "com.ryan-kegel.dynamic-deck.sdPlugin";
/**
* @type {import('rollup').RollupOptions}
*/
const config = {
input: "src/plugin.ts",
output: {
file: `${sdPlugin}/bin/plugin.js`,
sourcemap: isWatching,
sourcemapPathTransform: (relativeSourcePath, sourcemapPath) => {
return url.pathToFileURL(
path.resolve(path.dirname(sourcemapPath), relativeSourcePath),
).href;
},
},
plugins: [
copy({
targets: [
{ src: "src/ui", dest: "com.ryan-kegel.dynamic-deck.sdPlugin" },
{ src: "src/imgs", dest: "com.ryan-kegel.dynamic-deck.sdPlugin" },
{ src: "src/scripts", dest: "com.ryan-kegel.dynamic-deck.sdPlugin" },
{ src: "src/services", dest: "com.ryan-kegel.dynamic-deck.sdPlugin" },
{ src: "src/utils", dest: "com.ryan-kegel.dynamic-deck.sdPlugin" }
],
}),
{
name: "watch-externals",
buildStart: function () {
this.addWatchFile(`${sdPlugin}/manifest.json`);
},
},
typescript({
mapRoot: isWatching ? "./" : undefined,
}),
nodeResolve({
browser: false,
exportConditions: ["node"],
preferBuiltins: true,
}),
commonjs(),
!isWatching && terser(),
{
name: "emit-module-package-file",
generateBundle() {
this.emitFile({
fileName: "package.json",
source: `{ "type": "module" }`,
type: "asset",
});
},
},
],
};
export default config;
@@ -0,0 +1,85 @@
// src/actions/app-slot-action.ts
import streamDeck, {
action,
Action,
DidReceiveSettingsEvent,
KeyDownEvent,
SendToPluginEvent,
SingletonAction,
WillAppearEvent,
WillDisappearEvent
} from "@elgato/streamdeck";
import { AppLauncher } from "../utils/app-launcher";
import type { AppTrackerService, TrackedMetric } from "../services/app-tracker";
export type AppSlotSettings = {
slotIndex?: number;
metric?: TrackedMetric;
[key: string]: any;
};
@action({ UUID: "com.ryan-kegel.dynamic-deck.app-slot" })
export class AppSlotAction extends SingletonAction<AppSlotSettings> {
static appPathMap = new Map<string, string>();
static activeActions = new Map<string, Action<AppSlotSettings>>();
static settingsMap = new Map<string, AppSlotSettings>();
public static trackerService?: AppTrackerService;
// Static method called by plugin.ts on app switch
public static refreshAllSlots(): void {
AppSlotAction.trackerService?.queueUpdateSlots();
}
// Store visible key instances
private static instances = new Set<AppSlotAction>();
override async onWillAppear(ev: WillAppearEvent<AppSlotSettings>): Promise<void> {
AppSlotAction.instances.add(this);
AppSlotAction.activeActions.set(ev.action.id, ev.action);
AppSlotAction.settingsMap.set(ev.action.id, ev.payload.settings);
AppSlotAction.trackerService?.queueUpdateSlots();
}
override async onWillDisappear(ev: WillDisappearEvent<any>): Promise<void> {
AppSlotAction.instances.delete(this);
AppSlotAction.activeActions.delete(ev.action.id);
AppSlotAction.settingsMap.delete(ev.action.id);
AppSlotAction.appPathMap.delete(ev.action.id);
AppSlotAction.trackerService?.clearSlotCache(ev.action.id);
}
override async onDidReceiveSettings(ev: DidReceiveSettingsEvent<AppSlotSettings>): Promise<void> {
AppSlotAction.settingsMap.set(ev.action.id, ev.payload.settings);
AppSlotAction.trackerService?.queueUpdateSlots();
}
override async onSendToPlugin(ev: SendToPluginEvent<any, AppSlotSettings>): Promise<void> {
const payload = ev.payload as { command?: string };
if (payload?.command === "resetStats") {
streamDeck.logger.info("[AppSlotAction] Received resetStats command from Property Inspector");
await AppSlotAction.trackerService?.resetStats();
AppSlotAction.trackerService?.queueUpdateSlots();
}
}
override async onKeyDown(ev: KeyDownEvent<AppSlotSettings>): Promise<void> {
const targetPath = AppSlotAction.appPathMap.get(ev.action.id);
if (!targetPath) {
streamDeck.logger.warn(`[AppSlotAction] Key pressed on unassigned slot (Action ID: ${ev.action.id})`);
await ev.action.showAlert();
return;
}
try {
await AppLauncher.activateOrLaunchApp(targetPath, true);
} catch (err) {
streamDeck.logger.error(`[AppSlotAction] Launch failed for path "${targetPath}":`, err);
await ev.action.showAlert();
}
}
}
@@ -0,0 +1,140 @@
import {
action,
KeyDownEvent,
SingletonAction,
WillAppearEvent,
WillDisappearEvent,
DidReceiveSettingsEvent,
streamDeck,
} from "@elgato/streamdeck";
type DeepSeekSettings = {
apiKey?: string;
};
@action({ UUID: "com.ryan-kegel.dynamic-deck.deepseek-balance" })
export class DeepSeekBalanceAction extends SingletonAction<DeepSeekSettings> {
private apiIntervals = new Map<string, NodeJS.Timeout>();
private displayIntervals = new Map<string, NodeJS.Timeout>();
private cachedBalances = new Map<string, string>();
override async onWillAppear(
ev: WillAppearEvent<DeepSeekSettings>,
): Promise<void> {
const actionId = ev.action.id;
// Fetch initial balance
await this.fetchBalance(ev.action, ev.payload.settings);
// Poll API every 15 minutes
const apiInterval = setInterval(
() => {
this.fetchBalance(ev.action, ev.payload.settings);
},
15 * 60 * 1000,
);
this.apiIntervals.set(actionId, apiInterval);
// Refresh display every 1 minute (to update Peak/Off-Peak indicator accurately)
const displayInterval = setInterval(() => {
this.updateDisplay(ev.action);
}, 60 * 1000);
this.displayIntervals.set(actionId, displayInterval);
}
override async onWillDisappear(
ev: WillDisappearEvent<DeepSeekSettings>,
): Promise<void> {
const actionId = ev.action.id;
if (this.apiIntervals.has(actionId)) {
clearInterval(this.apiIntervals.get(actionId)!);
this.apiIntervals.delete(actionId);
}
if (this.displayIntervals.has(actionId)) {
clearInterval(this.displayIntervals.get(actionId)!);
this.displayIntervals.delete(actionId);
}
}
override async onDidReceiveSettings(
ev: DidReceiveSettingsEvent<DeepSeekSettings>,
): Promise<void> {
await this.fetchBalance(ev.action, ev.payload.settings);
}
override async onKeyDown(ev: KeyDownEvent<DeepSeekSettings>): Promise<void> {
await streamDeck.system.openUrl("https://platform.deepseek.com/usage");
}
/**
* Checks if current UTC time falls within DeepSeek Peak hours:
* Peak = Monday-Friday, 01:00-04:00 UTC or 06:00-10:00 UTC
*/
private isPeakTime(): boolean {
const now = new Date();
const day = now.getUTCDay(); // 0 = Sunday, 1 = Monday, ..., 5 = Friday, 6 = Saturday
if (day >= 1 && day <= 5) {
const hour = now.getUTCHours();
if ((hour >= 1 && hour < 4) || (hour >= 6 && hour < 10)) {
return true;
}
}
return false;
}
private updateDisplay(action: any): void {
const balance = this.cachedBalances.get(action.id);
if (!balance) return;
const isPeak = this.isPeakTime();
const statusBadge = isPeak ? "🔴" : "🟢";
// Display format:
// $12.34
// 🟢 OFF or 🔴 PEAK
action.setTitle(`DeepSeek\n${statusBadge}${balance}`);
}
private async fetchBalance(
action: any,
settings: DeepSeekSettings,
): Promise<void> {
if (!settings.apiKey) {
await action.setTitle("Set API\nKey");
return;
}
try {
const res = await fetch("https://api.deepseek.com/user/balance", {
headers: {
Authorization: `Bearer ${settings.apiKey}`,
},
});
if (!res.ok) {
await action.setTitle("Error\n" + res.status);
return;
}
const data = (await res.json()) as {
balance_infos?: Array<{ currency: string; total_balance: string }>;
};
const info = data.balance_infos?.[0];
if (info) {
const symbol = info.currency === "USD" ? "$" : "¥";
const amount = parseFloat(info.total_balance).toFixed(2);
// Cache current formatted balance string
this.cachedBalances.set(action.id, `${symbol}${amount}`);
this.updateDisplay(action);
} else {
await action.setTitle("No Data");
}
} catch {
await action.setTitle("Conn\nErr");
}
}
}
+202
View File
@@ -0,0 +1,202 @@
import streamDeck, {
action,
KeyDownEvent,
SingletonAction,
WillAppearEvent,
WillDisappearEvent,
DidReceiveSettingsEvent,
KeyAction,
DialAction
} from "@elgato/streamdeck";
type EcobeeModeSettings = {
haUrl?: string;
accessToken?: string;
entityId?: string;
hvacMode?: string;
};
type EcobeeModeActionInstance = KeyAction<EcobeeModeSettings> | DialAction<EcobeeModeSettings>;
const MODE_ORDER = ["off", "heat", "cool", "heat_cool"];
@action({ UUID: "com.ryan-kegel.dynamic-deck.ecobee-mode" })
export class EcobeeModeAction extends SingletonAction<EcobeeModeSettings> {
private pollIntervals = new Map<string, NodeJS.Timeout>();
private activeActions = new Map<string, EcobeeModeActionInstance>();
private async getResolvedSettings(local: EcobeeModeSettings): Promise<EcobeeModeSettings> {
const global = await streamDeck.settings.getGlobalSettings<EcobeeModeSettings>();
return {
...local,
haUrl: local.haUrl || global.haUrl,
accessToken: local.accessToken || global.accessToken,
entityId: local.entityId || global.entityId
};
}
override async onWillAppear(ev: WillAppearEvent<EcobeeModeSettings>): Promise<void> {
const actionId = ev.action.id;
this.activeActions.set(actionId, ev.action);
const s = await this.getResolvedSettings(ev.payload.settings);
await this.fetchStatus(ev.action, s);
const interval = setInterval(async () => {
const currentSettings = await this.getResolvedSettings(await ev.action.getSettings<EcobeeModeSettings>());
this.fetchStatus(ev.action, currentSettings);
}, 60 * 1000);
this.pollIntervals.set(actionId, interval);
}
override async onWillDisappear(ev: WillDisappearEvent<EcobeeModeSettings>): Promise<void> {
const actionId = ev.action.id;
this.activeActions.delete(actionId);
const interval = this.pollIntervals.get(actionId);
if (interval) {
clearInterval(interval);
this.pollIntervals.delete(actionId);
}
}
override async onDidReceiveSettings(ev: DidReceiveSettingsEvent<EcobeeModeSettings>): Promise<void> {
const s = await this.getResolvedSettings(ev.payload.settings);
await this.fetchStatus(ev.action, s);
}
override async onKeyDown(ev: KeyDownEvent<EcobeeModeSettings>): Promise<void> {
const s = await this.getResolvedSettings(ev.payload.settings);
if (!s.haUrl || !s.accessToken || !s.entityId) {
await ev.action.showAlert();
return;
}
try {
const baseUrl = s.haUrl.replace(/\/$/, "");
let targetMode = s.hvacMode || "cycle";
if (targetMode === "cycle") {
const stateRes = await fetch(`${baseUrl}/api/states/${s.entityId}`, {
headers: {
"Authorization": `Bearer ${s.accessToken}`,
"Content-Type": "application/json"
}
});
if (!stateRes.ok) {
await ev.action.showAlert();
return;
}
const stateData = (await stateRes.json()) as any;
const currentMode = stateData.state;
const currentIndex = MODE_ORDER.indexOf(currentMode);
const nextIndex = (currentIndex + 1) % MODE_ORDER.length;
targetMode = MODE_ORDER[nextIndex];
}
const res = await fetch(`${baseUrl}/api/services/climate/set_hvac_mode`, {
method: "POST",
headers: {
"Authorization": `Bearer ${s.accessToken}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
entity_id: s.entityId,
hvac_mode: targetMode
})
});
if (res.ok) {
// Immediately broadcast & re-render SVG without showing the green checkmark overlay
await this.broadcastStatusUpdate();
} else {
await ev.action.showAlert();
}
} catch {
await ev.action.showAlert();
}
}
private async broadcastStatusUpdate(): Promise<void> {
for (const action of this.activeActions.values()) {
const local = await action.getSettings();
const resolved = await this.getResolvedSettings(local);
await this.fetchStatus(action, resolved);
}
}
private async fetchStatus(action: EcobeeModeActionInstance, settings: EcobeeModeSettings): Promise<void> {
if (!settings.haUrl || !settings.accessToken || !settings.entityId) {
await action.setTitle("HA\nNo Token");
return;
}
try {
const baseUrl = settings.haUrl.replace(/\/$/, "");
const res = await fetch(`${baseUrl}/api/states/${settings.entityId}`, {
headers: {
"Authorization": `Bearer ${settings.accessToken}`,
"Content-Type": "application/json"
}
});
if (!res.ok) {
await action.setTitle("HA\nErr");
return;
}
const data = (await res.json()) as any;
const currentMode = data.state;
const hvacAction = data.attributes.hvac_action || currentMode;
const currentTemp = Math.round(data.attributes.current_temperature);
await action.setTitle("");
const svgDataUri = this.generateModeCardSvg(currentMode, hvacAction, currentTemp, settings.hvacMode);
await action.setImage(svgDataUri);
} catch {
await action.setTitle("Conn\nErr");
}
}
private generateModeCardSvg(currentMode: string, hvacAction: string, currentTemp: number, configuredMode?: string): string {
let headerText = "MODE";
let headerColor = "#2c3e50";
const activeType = configuredMode && configuredMode !== "cycle" ? configuredMode : currentMode;
switch (activeType) {
case "cool":
headerText = "❄ COOL";
headerColor = "#2980b9";
break;
case "heat":
headerText = "🔥 HEAT";
headerColor = "#e74c3c";
break;
case "heat_cool":
headerText = "🔄 AUTO";
headerColor = "#8e44ad";
break;
case "off":
headerText = "⭕ OFF";
headerColor = "#7f8c8d";
break;
}
const actionLabel = hvacAction.toUpperCase();
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="144" height="144" viewBox="0 0 144 144">
<rect width="144" height="144" rx="20" fill="#141414" />
<path d="M 0,20 A 20,20 0 0,1 20,0 L 124,0 A 20,20 0 0,1 144,20 L 144,38 L 0,38 Z" fill="${headerColor}" />
<text x="72" y="25" fill="#ffffff" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="15" font-weight="800" text-anchor="middle" letter-spacing="1">${headerText}</text>
<text x="72" y="80" fill="#ffffff" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="22" font-weight="700" text-anchor="middle">${actionLabel}</text>
<text x="72" y="118" fill="#888888" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="16" font-weight="600" text-anchor="middle">ROOM: ${currentTemp}°</text>
</svg>`.trim();
return `data:image/svg+xml;charset=utf8,${encodeURIComponent(svg)}`;
}
}
+245
View File
@@ -0,0 +1,245 @@
import streamDeck, {
action,
KeyDownEvent,
SingletonAction,
WillAppearEvent,
WillDisappearEvent,
DidReceiveSettingsEvent,
KeyAction,
DialAction
} from "@elgato/streamdeck";
type EcobeeSettings = {
haUrl?: string;
accessToken?: string;
entityId?: string;
step?: string;
targetTemp?: string;
targetCoolTemp?: string;
targetHeatTemp?: string;
};
type EcobeeActionInstance = KeyAction<EcobeeSettings> | DialAction<EcobeeSettings>;
@action({ UUID: "com.ryan-kegel.dynamic-deck.ecobee" })
export class EcobeeAction extends SingletonAction<EcobeeSettings> {
private pollIntervals = new Map<string, NodeJS.Timeout>();
private activeActions = new Map<string, EcobeeActionInstance>();
private async getResolvedSettings(local: EcobeeSettings): Promise<EcobeeSettings> {
const global = await streamDeck.settings.getGlobalSettings<EcobeeSettings>();
return {
...local,
haUrl: local.haUrl || global.haUrl,
accessToken: local.accessToken || global.accessToken,
entityId: local.entityId || global.entityId
};
}
override async onWillAppear(ev: WillAppearEvent<EcobeeSettings>): Promise<void> {
const actionId = ev.action.id;
this.activeActions.set(actionId, ev.action);
const s = await this.getResolvedSettings(ev.payload.settings);
await this.fetchStatus(ev.action, s);
const interval = setInterval(async () => {
const currentSettings = await this.getResolvedSettings(await ev.action.getSettings<EcobeeSettings>());
this.fetchStatus(ev.action, currentSettings);
}, 60 * 1000);
this.pollIntervals.set(actionId, interval);
}
override async onWillDisappear(ev: WillDisappearEvent<EcobeeSettings>): Promise<void> {
const actionId = ev.action.id;
this.activeActions.delete(actionId);
const interval = this.pollIntervals.get(actionId);
if (interval) {
clearInterval(interval);
this.pollIntervals.delete(actionId);
}
}
override async onDidReceiveSettings(ev: DidReceiveSettingsEvent<EcobeeSettings>): Promise<void> {
const s = await this.getResolvedSettings(ev.payload.settings);
await this.fetchStatus(ev.action, s);
}
override async onKeyDown(ev: KeyDownEvent<EcobeeSettings>): Promise<void> {
const s = await this.getResolvedSettings(ev.payload.settings);
if (!s.haUrl || !s.accessToken || !s.entityId) {
await ev.action.showAlert();
return;
}
try {
const baseUrl = s.haUrl.replace(/\/$/, "");
// --- Stepper Mode (+1 / -1) ---
if (s.step) {
const stepVal = parseFloat(s.step);
if (isNaN(stepVal)) {
await ev.action.showAlert();
return;
}
const stateRes = await fetch(`${baseUrl}/api/states/${s.entityId}`, {
headers: {
"Authorization": `Bearer ${s.accessToken}`,
"Content-Type": "application/json"
}
});
if (!stateRes.ok) {
await ev.action.showAlert();
return;
}
const stateData = (await stateRes.json()) as any;
let activeTarget =
stateData.attributes.temperature ??
stateData.attributes.target_temp_high ??
stateData.attributes.current_temperature ??
72;
activeTarget += stepVal;
const res = await fetch(`${baseUrl}/api/services/climate/set_temperature`, {
method: "POST",
headers: {
"Authorization": `Bearer ${s.accessToken}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
entity_id: s.entityId,
temperature: activeTarget
})
});
if (res.ok) {
await this.broadcastStatusUpdate();
} else {
await ev.action.showAlert();
}
return;
}
// --- Static / Dual Setpoint Fallback ---
const payload: Record<string, any> = { entity_id: s.entityId };
const temp = s.targetTemp ? parseFloat(s.targetTemp) : null;
const cool = s.targetCoolTemp ? parseFloat(s.targetCoolTemp) : null;
const heat = s.targetHeatTemp ? parseFloat(s.targetHeatTemp) : null;
if (temp !== null) {
payload.temperature = temp;
} else if (cool !== null && heat !== null) {
payload.target_temp_high = cool;
payload.target_temp_low = heat;
} else if (cool !== null) {
payload.temperature = cool;
payload.target_temp_high = cool;
} else if (heat !== null) {
payload.temperature = heat;
payload.target_temp_low = heat;
} else {
await ev.action.showAlert();
return;
}
const res = await fetch(`${baseUrl}/api/services/climate/set_temperature`, {
method: "POST",
headers: {
"Authorization": `Bearer ${s.accessToken}`,
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
if (res.ok) {
await this.broadcastStatusUpdate();
} else {
await ev.action.showAlert();
}
} catch {
await ev.action.showAlert();
}
}
private async broadcastStatusUpdate(): Promise<void> {
for (const action of this.activeActions.values()) {
const local = await action.getSettings();
const resolved = await this.getResolvedSettings(local);
await this.fetchStatus(action, resolved);
}
}
private async fetchStatus(action: EcobeeActionInstance, settings: EcobeeSettings): Promise<void> {
if (!settings.haUrl || !settings.accessToken || !settings.entityId) {
await action.setTitle("HA\nNo Token");
return;
}
try {
const baseUrl = settings.haUrl.replace(/\/$/, "");
const res = await fetch(`${baseUrl}/api/states/${settings.entityId}`, {
headers: {
"Authorization": `Bearer ${settings.accessToken}`,
"Content-Type": "application/json"
}
});
if (!res.ok) {
await action.setTitle("HA\nErr");
return;
}
const data = (await res.json()) as any;
const currentTemp = Math.round(data.attributes.current_temperature);
const setTemp =
data.attributes.temperature ??
(data.attributes.target_temp_high
? `${data.attributes.target_temp_low}-${data.attributes.target_temp_high}`
: null) ??
settings.targetTemp ??
"--";
await action.setTitle("");
const svgDataUri = this.generateCardSvg(currentTemp, setTemp.toString(), settings.step);
await action.setImage(svgDataUri);
} catch {
await action.setTitle("Conn\nErr");
}
}
private generateCardSvg(currentTemp: number, setTemp: string, step?: string): string {
let headerText = "ECOBEE";
let headerColor = "#2c3e50";
if (step) {
const num = parseFloat(step);
if (num > 0) {
headerText = `▲ +${num}° UP`;
headerColor = "#e74c3c";
} else if (num < 0) {
headerText = `▼ ${num}° DOWN`;
headerColor = "#2980b9";
}
}
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="144" height="144" viewBox="0 0 144 144">
<rect width="144" height="144" rx="20" fill="#141414" />
<path d="M 0,20 A 20,20 0 0,1 20,0 L 124,0 A 20,20 0 0,1 144,20 L 144,38 L 0,38 Z" fill="${headerColor}" />
<text x="72" y="25" fill="#ffffff" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="15" font-weight="800" text-anchor="middle" letter-spacing="1">${headerText}</text>
<text x="72" y="88" fill="#ffffff" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="44" font-weight="700" text-anchor="middle">${currentTemp}°</text>
<text x="72" y="122" fill="#888888" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="16" font-weight="600" text-anchor="middle">SET: ${setTemp}°</text>
</svg>`.trim();
return `data:image/svg+xml;charset=utf8,${encodeURIComponent(svg)}`;
}
}
@@ -0,0 +1,49 @@
import {
action,
KeyDownEvent,
SingletonAction,
WillAppearEvent,
} from "@elgato/streamdeck";
/**
* An example action class that displays a count that increments by one each time the button is pressed.
*/
@action({ UUID: "com.ryan-kegel.dynamic-deck.increment" })
export class IncrementCounter extends SingletonAction<CounterSettings> {
/**
* The {@link SingletonAction.onWillAppear} event is useful for setting the visual representation of an action when it becomes visible. This could be due to the Stream Deck first
* starting up, or the user navigating between pages / folders etc.. There is also an inverse of this event in the form of {@link streamDeck.client.onWillDisappear}. In this example,
* we're setting the title to the "count" that is incremented in {@link IncrementCounter.onKeyDown}.
*/
override onWillAppear(
ev: WillAppearEvent<CounterSettings>,
): void | Promise<void> {
return ev.action.setTitle(`${ev.payload.settings.count ?? 0}`);
}
/**
* Listens for the {@link SingletonAction.onKeyDown} event which is emitted by Stream Deck when an action is pressed. Stream Deck provides various events for tracking interaction
* with devices including key down/up, dial rotations, and device connectivity, etc. When triggered, {@link ev} object contains information about the event including any payloads
* and action information where applicable. In this example, our action will display a counter that increments by one each press. We track the current count on the action's persisted
* settings using `setSettings` and `getSettings`.
*/
override async onKeyDown(ev: KeyDownEvent<CounterSettings>): Promise<void> {
// Update the count from the settings.
const { settings } = ev.payload;
settings.incrementBy ??= 1;
settings.count = (settings.count ?? 0) + settings.incrementBy;
// Update the current count in the action's settings, and change the title.
await ev.action.setSettings(settings);
await ev.action.setTitle(`${settings.count}`);
await ev.action.showAlert();
}
}
/**
* Settings for {@link IncrementCounter}.
*/
type CounterSettings = {
count?: number;
incrementBy?: number;
};
+143
View File
@@ -0,0 +1,143 @@
// src/actions/launch-app-action.ts
import streamDeck, {
action,
DidReceiveSettingsEvent,
KeyDownEvent,
SendToPluginEvent,
SingletonAction,
WillAppearEvent
} from "@elgato/streamdeck";
import { AppTrackerService } from "../services/app-tracker";
import { AppLauncher, InstalledApp } from "../utils/app-launcher";
const logger = streamDeck.logger.createScope("AppLauncherButton");
export interface LaunchAppSettings {
appPreset?: string;
customApp?: InstalledApp;
bringToFront?: boolean;
title?: string;
[key: string]: any;
}
@action({ UUID: "com.ryan-kegel.dynamic-deck.launch-app" })
export class LaunchAppAction extends SingletonAction<LaunchAppSettings> {
public static trackerService?: AppTrackerService;
override async onWillAppear(ev: WillAppearEvent<LaunchAppSettings>): Promise<void> {
await this.updateButtonVisuals(ev.action, ev.payload.settings);
}
override async onDidReceiveSettings(ev: DidReceiveSettingsEvent<LaunchAppSettings>): Promise<void> {
await this.updateButtonVisuals(ev.action, ev.payload.settings);
}
override async onSendToPlugin(ev: SendToPluginEvent<Record<string, any>, LaunchAppSettings>): Promise<void> {
if (ev.payload.event === "getApps") {
try {
const apps = await AppLauncher.getInstalledApps();
await streamDeck.ui.sendToPropertyInspector({
event: "getAppsResponse",
apps: apps
});
} catch {
await streamDeck.ui.sendToPropertyInspector({
event: "getAppsResponse",
apps: []
});
}
} else if (ev.payload.event === "pickFile") {
const result = await AppLauncher.openExecutablePicker();
if (result) {
const title = AppLauncher.shortenTitle(result.name);
const updatedSettings: LaunchAppSettings = {
...ev.payload.settings,
appPreset: result.path,
customApp: result,
title: title
};
await ev.action.setSettings(updatedSettings);
await this.updateButtonVisuals(ev.action, updatedSettings);
await streamDeck.ui.sendToPropertyInspector({
event: "filePickedResponse",
app: result,
title: title
});
}
} else if (ev.payload.event === "appSelected" && ev.payload.app) {
const app = ev.payload.app as InstalledApp;
const title = AppLauncher.shortenTitle(app.name);
const updatedSettings: LaunchAppSettings = {
...ev.payload.settings,
appPreset: app.path,
customApp: app,
title: title
};
await ev.action.setSettings(updatedSettings);
await this.updateButtonVisuals(ev.action, updatedSettings);
await streamDeck.ui.sendToPropertyInspector({
event: "appSelectedResponse",
title: title
});
}
}
override async onKeyDown(ev: KeyDownEvent<LaunchAppSettings>): Promise<void> {
const targetPath = ev.payload.settings.appPreset;
const bringToFront = ev.payload.settings.bringToFront !== false;
if (!targetPath || targetPath === "other") {
await ev.action.showAlert();
return;
}
try {
logger.info(`[LaunchAppAction] Activating or launching app at path "${targetPath}"`);
await AppLauncher.activateOrLaunchApp(targetPath, bringToFront);
} catch {
await ev.action.showAlert();
}
}
private async updateButtonVisuals(actionContext: any, settings: LaunchAppSettings): Promise<void> {
const targetPath = settings.customApp?.path || settings.appPreset;
if (!targetPath || targetPath === "other") {
await actionContext.setImage(null);
await actionContext.setTitle("");
return;
}
// Restore Title
let titleToSet = settings.title;
if (!titleToSet && settings.customApp?.name) {
titleToSet = AppLauncher.shortenTitle(settings.customApp.name);
}
if (titleToSet !== undefined) {
await actionContext.setTitle(titleToSet);
}
// Restore High-Res Image
try {
const highResIcon = await AppLauncher.getAppIcon(targetPath);
if (highResIcon) {
await actionContext.setImage(this.formatIconUri(highResIcon));
} else {
await actionContext.setImage(null);
}
} catch (err) {
logger.error(`[LaunchAppAction] Failed to update button icon for "${targetPath}":`, err);
await actionContext.setImage(null);
}
}
private formatIconUri(icon: string): string {
return icon.startsWith("data:") ? icon : `data:image/png;base64,${icon}`;
}
}
+64
View File
@@ -0,0 +1,64 @@
import streamDeck from "@elgato/streamdeck";
import path from "path";
import { AppSlotAction } from "./actions/app-slot-action";
import { DeepSeekBalanceAction } from "./actions/deepseek-balance";
import { EcobeeAction } from "./actions/ecobee";
import { EcobeeModeAction } from "./actions/ecobee-mode";
import { LaunchAppAction } from "./actions/launch-app";
import { AppMonitor, type AppSwitchEvent } from "./services/app-monitor";
import { AppTrackerService } from "./services/app-tracker";
const logger = streamDeck.logger.createScope("Plugin");
const statsPath = path.join(process.cwd(), "app-stats.json");
const tracker = new AppTrackerService(statsPath);
AppSlotAction.trackerService = tracker;
LaunchAppAction.trackerService = tracker;
// Register all Stream Deck actions
streamDeck.actions.registerAction(new DeepSeekBalanceAction());
streamDeck.actions.registerAction(new EcobeeModeAction());
streamDeck.actions.registerAction(new EcobeeAction());
streamDeck.actions.registerAction(new LaunchAppAction());
streamDeck.actions.registerAction(new AppSlotAction());
// Initialize monitor with a 1000ms polling interval
const monitor = new AppMonitor(1000);
// Handle app switch events
monitor.on("appSwitched", (event: AppSwitchEvent) => {
const normalizedPath = path.normalize(event.path).toLowerCase();
const normalizedEvent: AppSwitchEvent = { ...event, path: normalizedPath };
const appName = path.basename(normalizedPath);
logger.info(`App Focused: ${appName}`);
tracker.handleAppSwitch(normalizedEvent);
AppSlotAction.refreshAllSlots();
const topByLaunch = tracker.getRankedApps("launchCount").slice(0, 3);
logger.info("Top 3 by Launch Count: " + topByLaunch.map((a) => `${a.name} (${a.launchCount})`).join(", "));
});
monitor.start();
// Save stats gracefully on process exit
const saveAndExit = () => {
logger.info("Cleaning up and saving tracker stats before exit...");
try {
tracker.destroy();
} catch (err) {
logger.error("Error saving stats on exit:", err);
}
process.exit(0);
};
process.on("SIGINT", saveAndExit);
process.on("SIGTERM", saveAndExit);
// Connect to Stream Deck
streamDeck.connect();
+110
View File
@@ -0,0 +1,110 @@
// src/services/app-monitor.ts
import EventEmitter from "events";
import koffi from "koffi";
import streamDeck from "@elgato/streamdeck";
const logger = streamDeck.logger.createScope("AppMonitor");
// Load Windows API DLLs
const user32 = koffi.load("user32.dll");
const kernel32 = koffi.load("kernel32.dll");
// Define Win32 Functions
const GetForegroundWindow = user32.func("void* GetForegroundWindow()");
const GetWindowThreadProcessId = user32.func("uint32 GetWindowThreadProcessId(void* hWnd, _Out_ uint32* lpdwProcessId)");
const OpenProcess = kernel32.func("void* OpenProcess(uint32 dwDesiredAccess, int bInheritHandle, uint32 dwProcessId)");
const QueryFullProcessImageNameW = kernel32.func("int QueryFullProcessImageNameW(void* hProcess, uint32 dwFlags, _Out_ uint16* lpExeName, _Inout_ uint32* lpdwSize)");
const CloseHandle = kernel32.func("int CloseHandle(void* hObject)");
const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000;
export interface AppSwitchEvent {
path: string;
pid?: number;
timestamp: number;
}
export interface ActiveWindowInfo {
path: string;
pid: number;
}
export class AppMonitor extends EventEmitter {
private monitorTimer: NodeJS.Timeout | null = null;
private lastActivePath: string | null = null;
private lastActivePid: number | null = null;
constructor(private intervalMs: number = 1000) {
super();
}
public start(): void {
if (this.monitorTimer) return;
logger.info(`Starting native AppMonitor with ${this.intervalMs}ms polling interval.`);
this.monitorTimer = setInterval(() => this.checkActiveWindow(), this.intervalMs);
}
public stop(): void {
if (this.monitorTimer) {
clearInterval(this.monitorTimer);
this.monitorTimer = null;
}
}
private checkActiveWindow(): void {
const activeWindow = this.getActiveWindowInfo();
if (!activeWindow) return;
// Skip if both path and PID have not changed
if (activeWindow.path === this.lastActivePath && activeWindow.pid === this.lastActivePid) {
return;
}
this.lastActivePath = activeWindow.path;
this.lastActivePid = activeWindow.pid;
const event: AppSwitchEvent = {
path: activeWindow.path,
pid: activeWindow.pid,
timestamp: Date.now()
};
logger.info(`App Focused: ${event.path} (PID: ${event.pid})`);
this.emit("appSwitched", event);
}
private getActiveWindowInfo(): ActiveWindowInfo | null {
const hwnd = GetForegroundWindow();
if (!hwnd) return null;
const pidBuffer = [0];
GetWindowThreadProcessId(hwnd, pidBuffer);
const pid = pidBuffer[0];
if (!pid) return null;
const hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
if (!hProcess) return null;
try {
const bufSize = 1024;
const buffer = new Uint16Array(bufSize);
const sizeRef = [bufSize];
const success = QueryFullProcessImageNameW(hProcess, 0, buffer, sizeRef);
if (success) {
const charCount = sizeRef[0];
const rawPath = String.fromCharCode(...buffer.slice(0, charCount));
const normalizedPath = rawPath.toLowerCase().replace(/\//g, "\\");
return {
path: normalizedPath,
pid: pid
};
}
} finally {
CloseHandle(hProcess);
}
return null;
}
}
+385
View File
@@ -0,0 +1,385 @@
import streamDeck from "@elgato/streamdeck";
import * as fs from "fs";
import * as path from "path";
import { AppSlotAction } from "../actions/app-slot-action";
import { AppLauncher } from "../utils/app-launcher";
import { AppSwitchEvent } from "./app-monitor";
import { AppMetadata } from "../utils/app-metadata";
import { AppNameMapper } from "../utils/app-name-mapper";
const logger = streamDeck.logger.createScope("AppTrackerService");
export interface AppStat {
name: string;
path: string;
launchCount: number;
focusCount: number;
activeDurationSeconds: number;
lastUsedTimestamp: number;
}
export type TrackedMetric = "launchCount" | "focusCount" | "lastUsed" | "activeDuration";
interface RenderedSlotState {
appPath: string;
title: string;
}
export class AppTrackerService {
private nameMapper: AppNameMapper = new AppNameMapper();
private stats: Map<string, AppStat> = new Map();
private activePath: string | null = null;
private activeStartTime: number = Date.now();
private knownPids: Set<number> = new Set();
private readonly MAX_TRACKED_PIDS = 500;
private ignorePatterns: RegExp[] = [];
private renderedSlots: Map<string, RenderedSlotState> = new Map();
// Timers
private durationTimer: NodeJS.Timeout | null = null;
private updateTimer: NodeJS.Timeout | null = null;
private saveTimer: NodeJS.Timeout | null = null;
constructor(
private storageFilePath: string,
ignoreFilePath: string = path.join(process.cwd(), ".appignore")
) {
this.loadIgnorePatterns(ignoreFilePath);
this.loadStats();
// Tick active window duration every 5 seconds
this.durationTimer = setInterval(() => this.tickActiveDuration(), 5000);
}
/**
* Checks if a given application path matches any pattern in .appignore
*/
public isIgnored(appPath: string): boolean {
if (!appPath || this.ignorePatterns.length === 0) return false;
return this.ignorePatterns.some((pattern) => pattern.test(appPath));
}
/**
* Handles OS focus switches between window applications.
*/
public async handleAppSwitch(event: AppSwitchEvent): Promise<void> {
// Exclude ignored applications from focus tracking and launches
if (this.isIgnored(event.path)) {
return;
}
const now = event.timestamp || Date.now();
// Register launch if a new PID is encountered
if (event.pid && this.registerPidLaunch(event.pid)) {
logger.info(`New process detected via PID ${event.pid}`);
this.recordAppLaunch(event.path);
}
if (this.activePath === event.path) {
return;
}
// Conclude duration calculation for the app that just lost focus
this.flushActiveDuration(now);
// Update focus stats for newly focused application
const stat = this.getOrCreateStat(event.path);
stat.focusCount = (stat.focusCount || 0) + 1;
this.activePath = event.path;
this.activeStartTime = now;
this.queueSaveStats();
this.queueUpdateSlots();
}
/**
* Records an explicit application launch event.
*/
public recordAppLaunch(appPath: string): void {
if (this.isIgnored(appPath)) {
return;
}
const stat = this.getOrCreateStat(appPath);
stat.launchCount = (stat.launchCount || 0) + 1;
stat.lastUsedTimestamp = Date.now();
this.queueSaveStats();
this.queueUpdateSlots();
}
/**
* Parses the .appignore file and converts lines into RegExp objects.
*/
private loadIgnorePatterns(ignoreFilePath: string): void {
try {
if (!fs.existsSync(ignoreFilePath)) {
logger.info(`.appignore file not found at "${ignoreFilePath}". Skipping ignore filtering.`);
return;
}
const content = fs.readFileSync(ignoreFilePath, "utf-8");
this.ignorePatterns = content
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith("#"))
.map((pattern) => {
let flags = "";
let cleanPattern = pattern;
// Convert inline (?i) syntax to JavaScript RegExp 'i' flag
if (cleanPattern.startsWith("(?i)")) {
flags += "i";
cleanPattern = cleanPattern.substring(4);
}
return new RegExp(cleanPattern, flags);
});
logger.info(`Loaded ${this.ignorePatterns.length} ignore patterns from .appignore.`);
} catch (err) {
logger.error("Failed to load .appignore file:", err);
this.ignorePatterns = [];
}
}
/**
* Synchronizes active Stream Deck AppSlotAction keys with ranked application stats.
*/
public async updateSlots(): Promise<void> {
const activeActions = Array.from(AppSlotAction.activeActions.values());
if (activeActions.length === 0) return;
logger.info(`Updating Stream Deck slots for ${activeActions.length} active actions.`);
for (const action of activeActions) {
if (!action.isKey()) continue;
const settings = AppSlotAction.settingsMap.get(action.id) || {};
const slotIndex = (settings.slotIndex ?? 1) - 1;
const metric = settings.metric || "focusCount";
const rankedApps = this.getRankedApps(metric);
logger.info(`Ranked apps by ${metric} for slot index ${slotIndex}: ${rankedApps.map(app => app.name).join(", ")}`);
const app = rankedApps[slotIndex];
if (app) {
await this.renderSlotWithApp(action.id, action, app);
} else {
await this.renderEmptySlot(action.id, action, slotIndex);
}
}
}
public queueUpdateSlots(delayMs: number = 50): void {
if (this.updateTimer) clearTimeout(this.updateTimer);
this.updateTimer = setTimeout(() => {
void this.updateSlots();
}, delayMs);
}
public clearSlotCache(actionId: string): void {
this.renderedSlots.delete(actionId);
}
public getRankedApps(metric: TrackedMetric): AppStat[] {
let list = Array.from(this.stats.values());
if (metric === "lastUsed") {
if (this.activePath) {
list = list.filter((app) => app.path !== this.activePath);
}
return list.sort((a, b) => b.lastUsedTimestamp - a.lastUsedTimestamp);
}
const metricKeys: Record<Exclude<TrackedMetric, "lastUsed">, keyof AppStat> = {
launchCount: "launchCount",
focusCount: "focusCount",
activeDuration: "activeDurationSeconds"
};
const key = metricKeys[metric as keyof typeof metricKeys] || "focusCount";
return list.sort((a, b) => ((b[key] as number) || 0) - ((a[key] as number) || 0));
}
public applyDecay(decayFactor: number = 0.9): void {
logger.info(`Applying statistic decay factor (${decayFactor})...`);
for (const stat of this.stats.values()) {
stat.launchCount = Math.floor((stat.launchCount || 0) * decayFactor);
stat.focusCount = Math.floor((stat.focusCount || 0) * decayFactor);
stat.activeDurationSeconds = Math.floor(stat.activeDurationSeconds * decayFactor);
}
this.saveStatsImmediately();
this.queueUpdateSlots();
}
public resetStats(): void {
logger.info("Resetting all application tracking data...");
this.stats.clear();
this.activePath = null;
this.renderedSlots.clear();
this.saveStatsImmediately();
this.queueUpdateSlots();
}
public destroy(): void {
if (this.durationTimer) clearInterval(this.durationTimer);
if (this.updateTimer) clearTimeout(this.updateTimer);
if (this.saveTimer) clearTimeout(this.saveTimer);
this.flushActiveDuration();
this.saveStatsImmediately();
}
// --- Helper Methods ---
private getOrCreateStat(appPath: string): AppStat {
let stat = this.stats.get(appPath);
if (!stat) {
const baseExeName = path.basename(appPath, path.extname(appPath));
// Baseline object initialized immediately
stat = {
name: baseExeName, // Temporary fallback
path: appPath,
launchCount: 0,
focusCount: 0,
activeDurationSeconds: 0,
lastUsedTimestamp: 0
};
this.stats.set(appPath, stat);
// Fetch Windows FileDescription metadata asynchronously
void AppMetadata.getFileDescription(appPath).then((description) => {
if (description && stat) {
stat.name = description; // Updates app-stats.json entry with real title
this.queueSaveStats();
this.queueUpdateSlots();
}
});
logger.info(`Started tracking new app: ${baseExeName}`);
}
return stat;
}
private flushActiveDuration(now: number = Date.now()): void {
if (this.activePath && this.stats.has(this.activePath)) {
const elapsedSeconds = Math.max(0, Math.floor((now - this.activeStartTime) / 1000));
if (elapsedSeconds > 0) {
const stat = this.stats.get(this.activePath)!;
stat.activeDurationSeconds += elapsedSeconds;
stat.lastUsedTimestamp = now;
this.activeStartTime = now;
}
}
}
private tickActiveDuration(): void {
if (this.activePath) {
this.flushActiveDuration();
this.queueSaveStats();
}
}
private registerPidLaunch(pid: number): boolean {
if (this.knownPids.has(pid)) return false;
if (this.knownPids.size >= this.MAX_TRACKED_PIDS) {
const oldestPid = this.knownPids.values().next().value;
if (oldestPid !== undefined) {
this.knownPids.delete(oldestPid);
}
}
this.knownPids.add(pid);
return true;
}
private async renderSlotWithApp(actionId: string, action: any, app: AppStat): Promise<void> {
// Step 1: Check AppNameMapper overrides first -> fallback to app.name (Metadata)
const rawTitle = this.nameMapper.resolveDisplayName(app.path, app.name);
// Step 2: Apply line-wrapping / title shortening
const displayTitle = AppLauncher.shortenTitle(rawTitle);
const currentRendered = this.renderedSlots.get(actionId);
if (currentRendered && currentRendered.appPath === app.path && currentRendered.title === displayTitle) {
return;
}
const iconBase64 = await AppLauncher.getAppIcon(app.path);
AppSlotAction.appPathMap.set(actionId, app.path);
await action.setTitle(displayTitle);
await action.setImage(iconBase64 ? `data:image/png;base64,${iconBase64}` : "");
this.renderedSlots.set(actionId, { appPath: app.path, title: displayTitle });
}
private async renderEmptySlot(actionId: string, action: any, slotIndex: number): Promise<void> {
const emptyTitle = `Slot ${slotIndex + 1}\n(Empty)`;
const currentRendered = this.renderedSlots.get(actionId);
if (currentRendered && currentRendered.appPath === "" && currentRendered.title === emptyTitle) {
return;
}
AppSlotAction.appPathMap.delete(actionId);
await action.setTitle(emptyTitle);
await action.setImage("");
this.renderedSlots.set(actionId, { appPath: "", title: emptyTitle });
}
private queueSaveStats(delayMs: number = 1000): void {
if (this.saveTimer) return;
this.saveTimer = setTimeout(() => {
this.saveTimer = null;
this.saveStatsImmediately();
}, delayMs);
}
public saveStatsImmediately(): void {
try {
const dir = path.dirname(this.storageFilePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const rawObj = Object.fromEntries(this.stats);
fs.writeFileSync(this.storageFilePath, JSON.stringify(rawObj, null, 2), "utf-8");
} catch (err) {
logger.error("Failed to save app stats file:", err);
}
}
private loadStats(): void {
try {
if (fs.existsSync(this.storageFilePath)) {
const content = fs.readFileSync(this.storageFilePath, "utf-8");
const rawObj: Record<string, AppStat> = JSON.parse(content);
this.stats = new Map();
for (const [key, val] of Object.entries(rawObj)) {
// Filter out any ignored entries stored historically
if (this.isIgnored(key)) continue;
this.stats.set(key, {
...val,
launchCount: val.launchCount ?? 0,
focusCount: val.focusCount ?? 0
});
}
logger.info(`Loaded tracking data for ${this.stats.size} apps.`);
}
} catch (err) {
logger.error("Failed to load app stats file:", err);
this.stats = new Map();
}
}
}
@@ -0,0 +1,71 @@
// src/services/blacklist-manager.ts
import streamDeck from "@elgato/streamdeck";
import * as fs from "fs";
import * as path from "path";
const logger = streamDeck.logger.createScope("BlacklistManager");
export class BlacklistManager {
private ignorePatterns: RegExp[] = [];
constructor(private ignoreFilePath: string) {
this.loadRules();
this.watchFile();
}
private loadRules(): void {
try {
if (!fs.existsSync(this.ignoreFilePath)) {
const defaults = [
"# App Ignore Blacklist",
"\\\\windows\\\\",
"powershell\\.exe$",
"cmd\\.exe$",
"explorer\\.exe$",
"applicationframehost\\.exe$",
"searchhost\\.exe$",
"streamdeck\\.exe$"
].join("\n");
const dir = path.dirname(this.ignoreFilePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(this.ignoreFilePath, defaults, "utf-8");
}
const content = fs.readFileSync(this.ignoreFilePath, "utf-8");
this.ignorePatterns = content
.split("\n")
.map((line) => line.trim())
.filter((line) => line && !line.startsWith("#"))
.map((pattern) => {
// Clean inline (?i) flags if present from PowerShell syntax
const cleanedPattern = pattern.replace(/^\(\?i\)/i, "");
return new RegExp(cleanedPattern, "i");
});
logger.info(`Loaded ${this.ignorePatterns.length} blacklist rules.`);
} catch (err) {
logger.error("Failed to load ignore rules:", err);
}
}
private watchFile(): void {
try {
fs.watch(this.ignoreFilePath, (eventType) => {
if (eventType === "change") {
logger.info("Blacklist file modified. Reloading rules...");
this.loadRules();
}
});
} catch (err) {
logger.error("Failed to attach file watcher:", err);
}
}
public isIgnored(appPath: string): boolean {
if (!appPath) return true;
return this.ignorePatterns.some((pattern) => pattern.test(appPath));
}
}
+117
View File
@@ -0,0 +1,117 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Dynamic App Slot Settings</title>
<link rel="stylesheet" href="sdpi.css" />
</head>
<body>
<div class="sdpi-item">
<div class="sdpi-item-label">Slot Position</div>
<div class="sdpi-item-value">
<input type="number" id="slotIndex" min="1" max="15" value="1" onchange="saveSettings()" />
</div>
</div>
<div class="sdpi-item">
<div class="sdpi-item-label">Metric</div>
<div class="sdpi-item-value">
<select id="metric" onchange="saveSettings()">
<option value="launchCount">Most Launched</option>
<option value="focusCount">Most Focused</option>
<option value="activeDuration">Most Active Time</option>
<option value="lastUsed">Recently Used</option>
</select>
</div>
</div>
<div class="sdpi-item">
<div class="sdpi-item-label">Reset Data</div>
<div class="sdpi-item-value">
<button class="sdpi-item-button" onclick="resetStats()">Reset All Stats</button>
</div>
</div>
<script>
let websocket = null;
let pluginUUID = null;
let actionInfo = null;
// Called automatically by the Stream Deck application on launch
function connectElgatoStreamDeckSocket(inPort, inPluginUUID, inRegisterEvent, inInfo, inActionInfo) {
pluginUUID = inPluginUUID;
actionInfo = JSON.parse(inActionInfo);
const settings = actionInfo.payload.settings;
websocket = new WebSocket("ws://127.0.0.1:" + inPort);
websocket.onopen = function () {
// Register Property Inspector context with Stream Deck
websocket.send(JSON.stringify({
event: inRegisterEvent,
uuid: inPluginUUID
}));
// Populate form fields from saved settings
if (settings.slotIndex !== undefined) {
document.getElementById("slotIndex").value = settings.slotIndex;
}
if (settings.metric !== undefined) {
document.getElementById("metric").value = settings.metric;
}
};
websocket.onmessage = function (evt) {
const jsonObj = JSON.parse(evt.data);
if (jsonObj.event === "didReceiveSettings") {
const settings = jsonObj.payload.settings;
if (settings.slotIndex !== undefined) {
document.getElementById("slotIndex").value = settings.slotIndex;
}
if (settings.metric !== undefined) {
document.getElementById("metric").value = settings.metric;
}
}
};
}
// Send settings back to your AppSlotAction on change
function saveSettings() {
if (!websocket || websocket.readyState !== WebSocket.OPEN) return;
const slotValue = Number(document.getElementById("slotIndex").value);
const metricValue = document.getElementById("metric").value;
const payload = {
event: "setSettings",
context: pluginUUID,
payload: {
slotIndex: isNaN(slotValue) ? 1 : slotValue,
metric: metricValue
}
};
websocket.send(JSON.stringify(payload));
}
// Trigger reset command to plugin backend
function resetStats() {
if (!websocket || websocket.readyState !== WebSocket.OPEN) return;
const confirmed = confirm("Are you sure you want to reset all tracked app statistics?");
if (confirmed) {
const payload = {
event: "sendToPlugin",
action: actionInfo.action,
context: pluginUUID,
payload: {
command: "resetStats"
}
};
websocket.send(JSON.stringify(payload));
}
}
</script>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>DeepSeek Balance Settings</title>
<script src="https://sdpi-components.dev/releases/v4/sdpi-components.js"></script>
</head>
<body>
<sdpi-item label="API Key">
<sdpi-textfield setting="apiKey" password placeholder="sk-..."></sdpi-textfield>
</sdpi-item>
</body>
</html>
+47
View File
@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Ecobee Settings</title>
<script src="https://sdpi-components.dev/releases/v4/sdpi-components.js"></script>
</head>
<body>
<sdpi-item label="HA URL">
<sdpi-textfield setting="haUrl" global placeholder="http://192.168.1.100:8123"></sdpi-textfield>
</sdpi-item>
<sdpi-item label="Access Token">
<sdpi-textfield setting="accessToken" global password placeholder="Long-lived token"></sdpi-textfield>
</sdpi-item>
<sdpi-item label="Entity ID">
<sdpi-textfield setting="entityId" global placeholder="climate.upstairs"></sdpi-textfield>
</sdpi-item>
<sdpi-item label="HVAC Mode">
<sdpi-select setting="hvacMode">
<option value="cycle">Cycle Modes (Off → Heat → Cool → Auto)</option>
<option value="cool">Set Cool</option>
<option value="heat">Set Heat</option>
<option value="heat_cool">Set Auto (Heat/Cool)</option>
<option value="off">Set Off</option>
</sdpi-select>
</sdpi-item>
<sdpi-item label="Temp Step (+/-)">
<sdpi-textfield setting="step" placeholder="+1 or -1"></sdpi-textfield>
</sdpi-item>
<sdpi-item label="Target Temp (°F)">
<sdpi-textfield setting="targetTemp" placeholder="72"></sdpi-textfield>
</sdpi-item>
<sdpi-item label="Cool Temp (°F)">
<sdpi-textfield setting="targetCoolTemp" placeholder="75"></sdpi-textfield>
</sdpi-item>
<sdpi-item label="Heat Temp (°F)">
<sdpi-textfield setting="targetHeatTemp" placeholder="68"></sdpi-textfield>
</sdpi-item>
</body>
</html>
+150
View File
@@ -0,0 +1,150 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Launch App Settings</title>
<link rel="stylesheet" href="sdpi.css" />
<style>
html, body {
margin: 0;
padding: 0;
width: 100%;
overflow-x: hidden;
}
body {
padding: 12px 10px;
box-sizing: border-box;
}
/* SDPI Webkit Scrollbar Styling */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #1e1e1e;
border-radius: 3px;
}
::-webkit-scrollbar-thumb {
background: #4a4a4a;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #666666;
}
.custom-select {
width: 100%;
height: 28px;
background-color: #383838;
border: 1px solid #1e1e1e;
border-radius: 3px;
color: #e2e2e2;
padding: 0 8px;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
box-sizing: border-box;
}
.custom-select:hover { background-color: #424242; }
.selected-item {
display: flex;
align-items: center;
gap: 8px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.selected-item img, .option-item img {
width: 16px;
height: 16px;
object-fit: contain;
flex-shrink: 0;
}
.icon-placeholder {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.options-menu {
position: absolute;
top: 30px;
left: 0;
right: 0;
max-height: 180px;
overflow-y: auto;
background-color: #232323;
border: 1px solid #1e1e1e;
border-radius: 3px;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.6);
z-index: 9999;
display: none;
}
.options-menu.open { display: block; }
.option-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
cursor: pointer;
font-size: 11px;
color: #d8d8d8;
}
.option-item:hover { background-color: #0078d4; color: #ffffff; }
.dropdown-separator {
height: 1px;
background-color: #1e1e1e;
margin: 4px 0;
}
.checkbox-container {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
color: #e2e2e2;
cursor: pointer;
user-select: none;
}
.checkbox-container input {
cursor: pointer;
}
</style>
</head>
<body>
<div class="sdpi-item">
<div class="sdpi-item-label">App:</div>
<div class="sdpi-item-value">
<div id="selectTrigger" class="custom-select">
<div id="selectedDisplay" class="selected-item">
<span class="icon-placeholder"></span>
<span>Scanning apps...</span>
</div>
<span style="font-size: 9px; color: #a0a0a0;">▼</span>
</div>
<div id="optionsMenu" class="options-menu"></div>
</div>
</div>
<div class="sdpi-item">
<div class="sdpi-item-label">Option:</div>
<div class="sdpi-item-value">
<label class="checkbox-container">
<input type="checkbox" id="bringToFront" checked />
Bring to front if already running
</label>
</div>
</div>
<script src="launch-app.js"></script>
</body>
</html>
+243
View File
@@ -0,0 +1,243 @@
let websocket = null;
let pluginUUID = null;
let actionUUID = null;
let currentSettings = {};
let appList = [];
function connectElgatoStreamDeckSocket(inPort, inUUID, inRegisterEvent, inInfo, inActionInfo) {
pluginUUID = inUUID;
const actionData = JSON.parse(inActionInfo);
actionUUID = actionData.action;
currentSettings = actionData.payload.settings || {};
if (currentSettings.bringToFront === undefined) {
currentSettings.bringToFront = true;
}
updateInitialDisplay();
websocket = new WebSocket("ws://127.0.0.1:" + inPort);
websocket.onopen = function () {
websocket.send(JSON.stringify({
event: inRegisterEvent,
uuid: inUUID
}));
sendToPlugin({ event: "getApps" });
};
websocket.onmessage = function (evt) {
try {
const jsonObj = JSON.parse(evt.data);
const payload = jsonObj.payload || jsonObj;
if (payload.event === "getAppsResponse") {
appList = Array.isArray(payload.apps) ? payload.apps : [];
renderDropdown();
} else if (payload.event === "filePickedResponse" && payload.app) {
const customApp = payload.app;
currentSettings.appPreset = customApp.path;
currentSettings.customApp = customApp;
if (payload.title) {
currentSettings.title = payload.title;
}
saveSettings();
renderDropdown();
} else if (payload.event === "appSelectedResponse" && payload.title) {
currentSettings.title = payload.title;
saveSettings();
}
} catch (err) {
console.error("Error parsing WebSocket message:", err);
}
};
}
function formatIconSrc(icon) {
if (!icon) return "";
return icon.startsWith("data:") ? icon : "data:image/png;base64," + icon;
}
function updateInitialDisplay() {
if (currentSettings.customApp && currentSettings.customApp.name) {
updateSelectedDisplay(currentSettings.customApp.name, currentSettings.customApp.icon);
} else if (currentSettings.appPreset) {
const parts = currentSettings.appPreset.split(/[\\/]/);
const fileName = parts[parts.length - 1] || currentSettings.appPreset;
updateSelectedDisplay(fileName, "");
} else {
updateSelectedDisplay("Select an App...", "");
}
}
function sendToPlugin(payload) {
if (websocket && websocket.readyState === WebSocket.OPEN) {
websocket.send(JSON.stringify({
action: actionUUID,
event: "sendToPlugin",
context: pluginUUID,
payload: payload
}));
}
}
function saveSettings() {
if (websocket && websocket.readyState === WebSocket.OPEN) {
websocket.send(JSON.stringify({
event: "setSettings",
context: pluginUUID,
payload: currentSettings
}));
}
}
function renderDropdown() {
const menu = document.getElementById("optionsMenu");
const display = document.getElementById("selectedDisplay");
if (!menu || !display) return;
menu.innerHTML = "";
let foundSelected = false;
appList.forEach(app => {
const opt = createOptionElement(app.name, app.path, app.icon, app);
menu.appendChild(opt);
if (currentSettings.appPreset === app.path) {
updateSelectedDisplay(app.name, app.icon);
foundSelected = true;
}
});
if (currentSettings.customApp && currentSettings.customApp.path) {
const customApp = currentSettings.customApp;
const inSystemList = appList.some(a => a.path.toLowerCase() === customApp.path.toLowerCase());
if (!inSystemList) {
const customOpt = createOptionElement(customApp.name, customApp.path, customApp.icon, customApp);
menu.appendChild(customOpt);
if (currentSettings.appPreset === customApp.path) {
updateSelectedDisplay(customApp.name, customApp.icon);
foundSelected = true;
}
}
}
const separator = document.createElement("div");
separator.className = "dropdown-separator";
menu.appendChild(separator);
const otherOption = createOptionElement("Other...", "other", "", null);
menu.appendChild(otherOption);
if (!foundSelected) {
if (currentSettings.appPreset === "other" || !currentSettings.appPreset) {
updateSelectedDisplay("Select an App...", "");
}
}
}
function createOptionElement(name, value, iconBase64, fullAppObject) {
const div = document.createElement("div");
div.className = "option-item";
if (iconBase64) {
const img = document.createElement("img");
img.src = formatIconSrc(iconBase64);
div.appendChild(img);
} else {
const placeholder = document.createElement("div");
placeholder.className = "icon-placeholder";
div.appendChild(placeholder);
}
const text = document.createElement("span");
text.textContent = name;
div.appendChild(text);
div.addEventListener("click", (e) => {
e.stopPropagation();
toggleMenu(false);
if (value === "other") {
sendToPlugin({ event: "pickFile", settings: currentSettings });
} else {
currentSettings.appPreset = value;
if (fullAppObject) {
currentSettings.customApp = fullAppObject;
}
updateSelectedDisplay(name, iconBase64);
saveSettings();
if (fullAppObject) {
sendToPlugin({
event: "appSelected",
app: fullAppObject,
settings: currentSettings
});
}
}
});
return div;
}
function updateSelectedDisplay(name, iconBase64) {
const display = document.getElementById("selectedDisplay");
if (!display) return;
display.innerHTML = "";
if (iconBase64) {
const img = document.createElement("img");
img.src = formatIconSrc(iconBase64);
display.appendChild(img);
} else {
const placeholder = document.createElement("div");
placeholder.className = "icon-placeholder";
display.appendChild(placeholder);
}
const text = document.createElement("span");
text.textContent = name;
display.appendChild(text);
}
function toggleMenu(open) {
const menu = document.getElementById("optionsMenu");
if (!menu) return;
if (open === undefined) {
menu.classList.toggle("open");
} else if (open) {
menu.classList.add("open");
} else {
menu.classList.remove("open");
}
}
document.addEventListener("DOMContentLoaded", () => {
const trigger = document.getElementById("selectTrigger");
const bringToFrontInput = document.getElementById("bringToFront");
if (trigger) {
trigger.addEventListener("click", () => toggleMenu());
}
if (bringToFrontInput) {
bringToFrontInput.checked = currentSettings.bringToFront !== false;
bringToFrontInput.addEventListener("change", (e) => {
currentSettings.bringToFront = e.target.checked;
saveSettings();
});
}
document.addEventListener("click", (e) => {
if (!e.target.closest(".sdpi-item-value")) {
toggleMenu(false);
}
});
});
+162
View File
@@ -0,0 +1,162 @@
/* ui/sdpi.css - Official Elgato Property Inspector Baseline */
:root {
--sdpi-font-family: "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, sans-serif;
--sdpi-font-size: 9pt;
--sdpi-bg-color: #2d2d2d;
--sdpi-text-color: #d8d8d8;
--sdpi-control-bg: #383838;
--sdpi-border-color: #1e1e1e;
--sdpi-active-color: #0078d4;
--sdpi-hover-bg: #444444;
}
*, *::before, *::after {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow-x: hidden;
}
body {
font-family: var(--sdpi-font-family);
font-size: var(--sdpi-font-size);
color: var(--sdpi-text-color);
background-color: var(--sdpi-bg-color);
padding: 16px 12px;
user-select: none;
}
/* Custom SDPI Webkit Scrollbar Styling */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #1e1e1e;
border-radius: 3px;
}
::-webkit-scrollbar-thumb {
background: #4a4a4a;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #666666;
}
.sdpi-item {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.sdpi-item-label {
width: 90px;
text-align: right;
padding-right: 12px;
color: #9e9e9e;
font-size: 8.5pt;
flex-shrink: 0;
}
.sdpi-item-value {
flex: 1;
position: relative;
min-width: 0;
}
.sdpi-item-value input[type="text"],
.sdpi-item-value input[type="number"],
.sdpi-item-value select {
width: 100%;
height: 28px;
background-color: var(--sdpi-control-bg);
border: 1px solid var(--sdpi-border-color);
border-radius: 3px;
color: #e2e2e2;
padding: 0 8px;
outline: none;
font-family: inherit;
font-size: inherit;
}
.sdpi-item-value input:focus,
.sdpi-item-value select:focus {
border-color: var(--sdpi-active-color);
}
/* Custom Icon Dropdown Component */
.custom-dropdown-container {
position: relative;
width: 100%;
}
.dropdown-selected {
display: flex;
align-items: center;
gap: 8px;
height: 28px;
padding: 0 8px;
background-color: var(--sdpi-control-bg);
border: 1px solid var(--sdpi-border-color);
border-radius: 3px;
color: #e2e2e2;
cursor: pointer;
}
.dropdown-selected:hover {
border-color: #555555;
}
.dropdown-arrow {
margin-left: auto;
font-size: 8px;
color: #888888;
}
.dropdown-menu {
position: absolute;
top: calc(100% + 2px);
left: 0;
right: 0;
max-height: 180px;
overflow-y: auto;
background-color: #232323;
border: 1px solid var(--sdpi-border-color);
border-radius: 3px;
z-index: 1000;
display: none;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.5);
}
.dropdown-menu.open {
display: block;
}
.dropdown-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
cursor: pointer;
font-size: inherit;
color: var(--sdpi-text-color);
}
.dropdown-item:hover {
background-color: var(--sdpi-hover-bg);
}
.app-icon-small {
width: 16px;
height: 16px;
object-fit: contain;
flex-shrink: 0;
}
+674
View File
@@ -0,0 +1,674 @@
// src/utils/app-launcher.ts
import * as path from "path";
import * as fs from "fs";
import koffi from "koffi";
import { execFile } from "child_process";
import { promisify } from "util";
import streamDeck from "@elgato/streamdeck";
const execFileAsync = promisify(execFile);
export interface InstalledApp {
name: string;
path: string;
icon?: string;
}
const EXCLUDE_KEYWORDS = [
"uninstall", "un-install", "remove",
"documentation", "manual", "readme", "read me", "release notes", "help", "website", "visit", "license",
"reset", "restore", "unload", "cleanup", "clean up", "diagnostic", "troubleshoot", "fix",
"odbc", "data source", "component services", "computer management", "event viewer", "performance monitor",
"resource monitor", "system information", "task scheduler", "memory diagnostic", "defragment",
"configuration", "config", "setup", "wizard", "updater", "options"
];
// COM GUIDs for IShellLink & IPersistFile
const CLSID_ShellLink = {
Data1: 0x00021401,
Data2: 0x0000,
Data3: 0x0000,
Data4: [0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46]
};
const IID_IShellLinkW = {
Data1: 0x000214f9,
Data2: 0x0000,
Data3: 0x0000,
Data4: [0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46]
};
const IID_IPersistFile = {
Data1: 0x0000010b,
Data2: 0x0000,
Data3: 0x0000,
Data4: [0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46]
};
const logger = streamDeck.logger.createScope("AppLauncher");
const MAX_CACHE_SIZE = 100;
let win32: any = null;
if (process.platform === "win32") {
try {
const user32 = koffi.load("user32.dll");
const kernel32 = koffi.load("kernel32.dll");
const ole32 = koffi.load("ole32.dll");
const comdlg32 = koffi.load("comdlg32.dll");
const CLSID = koffi.struct("CLSID", {
Data1: "uint32",
Data2: "uint16",
Data3: "uint16",
Data4: koffi.array("uint8", 8)
});
const OPENFILENAMEW = koffi.struct("OPENFILENAMEW", {
lStructSize: "uint32",
hwndOwner: "intptr",
hInstance: "intptr",
lpstrFilter: "intptr",
lpstrCustomFilter: "intptr",
nMaxCustFilter: "uint32",
nFilterIndex: "uint32",
lpstrFile: "intptr",
nMaxFile: "uint32",
lpstrFileTitle: "intptr",
nMaxFileTitle: "uint32",
lpstrInitialDir: "intptr",
lpstrTitle: "intptr",
Flags: "uint32",
nFileOffset: "uint16",
nFileExtension: "uint16",
lpstrDefExt: "intptr",
lCustData: "intptr",
lpfnHook: "intptr",
lpTemplateName: "intptr",
pvReserved: "intptr",
dwReserved: "uint32",
FlagsEx: "uint32"
});
const EnumWindows = user32.func("bool __stdcall EnumWindows(intptr_t lpEnumFunc, intptr_t lParam)");
const GetWindowThreadProcessId = user32.func(
"uint32 __stdcall GetWindowThreadProcessId(intptr_t hWnd, _Out_ uint32_t *lpdwProcessId)"
);
const IsWindowVisible = user32.func("bool __stdcall IsWindowVisible(intptr_t hWnd)");
const ShowWindow = user32.func("bool __stdcall ShowWindow(intptr_t hWnd, int nCmdShow)");
const SetForegroundWindow = user32.func("bool __stdcall SetForegroundWindow(intptr_t hWnd)");
const OpenProcess = kernel32.func(
"intptr_t __stdcall OpenProcess(uint32_t dwDesiredAccess, bool bInheritHandle, uint32_t dwProcessId)"
);
const CloseHandle = kernel32.func("bool __stdcall CloseHandle(intptr_t hObject)");
const QueryFullProcessImageNameW = kernel32.func(
"bool __stdcall QueryFullProcessImageNameW(intptr_t hProcess, uint32_t dwFlags, _Out_ char16_t *lpExeName, _Inout_ uint32_t *lpdwSize)"
);
const CoInitialize = ole32.func("int32 __stdcall CoInitialize(intptr_t pvReserved)");
const CoUninitialize = ole32.func("void __stdcall CoUninitialize()");
const CoCreateInstance = ole32.func(
"int32 __stdcall CoCreateInstance(const CLSID *rclsid, intptr_t pUnkOuter, uint32_t dwClsContext, const CLSID *riid, _Out_ intptr_t *ppv)"
);
const GetOpenFileNameW = comdlg32.func("bool __stdcall GetOpenFileNameW(_Inout_ OPENFILENAMEW *lpofn)");
const EnumWindowsProc = koffi.proto("bool __stdcall EnumWindowsProc(intptr_t hWnd, intptr_t lParam)");
const ComReleaseProto = koffi.proto("uint32 __stdcall ComReleaseProto(intptr_t self)");
const QueryInterfaceProto = koffi.proto("int32 __stdcall QueryInterfaceProto(intptr_t self, const CLSID *riid, _Out_ intptr_t *ppv)");
const IPersistFileLoadProto = koffi.proto("int32 __stdcall IPersistFileLoadProto(intptr_t self, str16 pszFileName, uint32_t dwMode)");
const IShellLinkGetPathProto = koffi.proto("int32 __stdcall IShellLinkGetPathProto(intptr_t self, void *pszFile, int cch, intptr_t pfd, uint32_t fFlags)");
win32 = {
EnumWindows,
EnumWindowsProc,
GetWindowThreadProcessId,
IsWindowVisible,
ShowWindow,
SetForegroundWindow,
OpenProcess,
CloseHandle,
QueryFullProcessImageNameW,
CoInitialize,
CoUninitialize,
CoCreateInstance,
GetOpenFileNameW,
CLSID,
OPENFILENAMEW,
ComReleaseProto,
QueryInterfaceProto,
IPersistFileLoadProto,
IShellLinkGetPathProto
};
} catch (err) {
console.error("Failed to initialize Koffi Win32 bindings:", err);
}
}
let targetSearchPath = "";
let searchFoundHwnd: any = null;
let enumWindowsCallback: any = null;
function getEnumWindowsCallback() {
if (!enumWindowsCallback && win32) {
enumWindowsCallback = koffi.register((hWnd: any) => {
if (!win32.IsWindowVisible(hWnd)) return true;
const pidBuf = [0];
win32.GetWindowThreadProcessId(hWnd, pidBuf);
const pid = pidBuf[0];
if (!pid) return true;
const hProcess = win32.OpenProcess(0x1000, false, pid); // PROCESS_QUERY_LIMITED_INFORMATION
if (!hProcess) return true;
const pathBuf = new Uint16Array(260);
const sizeBuf = [260];
const success = win32.QueryFullProcessImageNameW(hProcess, 0, pathBuf, sizeBuf);
win32.CloseHandle(hProcess);
if (success) {
const procPath = Buffer.from(pathBuf.buffer, pathBuf.byteOffset, sizeBuf[0] * 2)
.toString("utf16le")
.toLowerCase();
const targetExe = targetSearchPath.split("\\").pop() || "";
if (procPath === targetSearchPath || procPath.endsWith("\\" + targetExe)) {
searchFoundHwnd = hWnd;
return false;
}
}
return true;
}, koffi.pointer(win32.EnumWindowsProc));
}
return enumWindowsCallback;
}
export class AppLauncher {
private static cachedApps: InstalledApp[] = [];
private static iconCache = new Map<string, string>();
private static failedPaths = new Set<string>();
static shortenTitle(name: string): string {
if (!name) return "";
const cleanName = name.replace(/\.exe$/i, "").trim();
const words = cleanName.split(/\s+/);
if (words.length > 2) {
return words.slice(0, 2).join(" ");
}
return cleanName;
}
private static releaseComObject(pUnk: number): void {
if (!pUnk || !win32) return;
try {
const vtableAddr = koffi.decode(pUnk, "intptr_t");
if (!vtableAddr) return;
const ptrSize = process.arch === "x64" ? 8n : 4n;
const releaseAddr = koffi.decode(BigInt(vtableAddr) + 2n * ptrSize, "intptr_t");
if (!releaseAddr) return;
const releaseFn = koffi.decode(releaseAddr, win32.ComReleaseProto);
releaseFn(pUnk);
} catch {}
}
private static resolveShortcutTarget(lnkPath: string): string {
if (!win32) return "";
let pShellLink = 0;
let pPersistFile = 0;
try {
const pShellLinkBuf = [0];
const hr = win32.CoCreateInstance(CLSID_ShellLink, 0, 1, IID_IShellLinkW, pShellLinkBuf);
if (hr < 0 || !pShellLinkBuf[0]) return "";
pShellLink = pShellLinkBuf[0];
const ptrSize = process.arch === "x64" ? 8n : 4n;
const vtableLinkAddr = koffi.decode(pShellLink, "intptr_t");
if (!vtableLinkAddr) return "";
const vtableLinkBigInt = BigInt(vtableLinkAddr);
const qiAddr = koffi.decode(vtableLinkBigInt, "intptr_t");
if (!qiAddr) return "";
const qiFn = koffi.decode(qiAddr, win32.QueryInterfaceProto);
const pPersistFileBuf = [0];
if (qiFn(pShellLink, IID_IPersistFile, pPersistFileBuf) < 0 || !pPersistFileBuf[0]) {
return "";
}
pPersistFile = pPersistFileBuf[0];
const vtablePersistAddr = koffi.decode(pPersistFile, "intptr_t");
if (!vtablePersistAddr) return "";
const vtablePersistBigInt = BigInt(vtablePersistAddr);
const loadAddr = koffi.decode(vtablePersistBigInt + 5n * ptrSize, "intptr_t");
if (!loadAddr) return "";
const loadFn = koffi.decode(loadAddr, win32.IPersistFileLoadProto);
if (loadFn(pPersistFile, lnkPath, 0) < 0) {
return "";
}
const getPathAddr = koffi.decode(vtableLinkBigInt + 3n * ptrSize, "intptr_t");
if (!getPathAddr) return "";
const getPathFn = koffi.decode(getPathAddr, win32.IShellLinkGetPathProto);
const targetBuf = Buffer.alloc(520);
if (getPathFn(pShellLink, targetBuf, 260, 0, 0) >= 0) {
return targetBuf.toString("utf16le").split("\0")[0].trim();
}
} catch (err) {
logger.error(`Failed to resolve shortcut ${lnkPath}:`, err);
} finally {
if (pPersistFile) this.releaseComObject(pPersistFile);
if (pShellLink) this.releaseComObject(pShellLink);
}
return "";
}
private static setCache(path: string, base64: string): void {
if (this.iconCache.size >= MAX_CACHE_SIZE) {
const firstKey = this.iconCache.keys().next().value;
if (firstKey) this.iconCache.delete(firstKey);
}
this.iconCache.set(path, base64);
}
static async activateOrLaunchApp(targetPath: string, bringToFront = true): Promise<void> {
if (process.platform !== "win32") return;
const cleanPath = targetPath.trim().replace(/^"+|"+$/g, "");
if (!cleanPath) return;
const isUrlShortcut = cleanPath.toLowerCase().endsWith(".url");
let activated = false;
// Window enumeration matching is skipped for .url files, as shortcut paths do not match process window paths
if (!isUrlShortcut && bringToFront && win32) {
const cb = getEnumWindowsCallback();
if (cb) {
targetSearchPath = cleanPath.toLowerCase().replace(/\//g, "\\");
searchFoundHwnd = null;
win32.EnumWindows(cb, 0);
if (searchFoundHwnd) {
win32.ShowWindow(searchFoundHwnd, 9); // SW_RESTORE
win32.SetForegroundWindow(searchFoundHwnd);
activated = true;
}
}
}
if (!activated) {
if (isUrlShortcut) {
logger.info(`[AppLauncher] Launching URL shortcut at path "${cleanPath}"`);
execFile("cmd.exe", ["/c", "start", "", cleanPath], (err: Error | null) => {
if (err) {
streamDeck.logger.error(`[AppLauncher] Failed to launch URL shortcut at path "${cleanPath}":`, err);
}
});
} else {
logger.info(`[AppLauncher] Launching executable at path "${cleanPath}"`);
execFile(cleanPath, (err: Error | null) => {
if (err) {
streamDeck.logger.error(`[AppLauncher] Failed to launch executable at path "${cleanPath}":`, err);
}
});
}
}
}
static async getAppIcon(targetPath: string): Promise<string> {
if (!targetPath || process.platform !== "win32") return "";
const cleanPath = path.normalize(targetPath.trim().replace(/^"+|"+$/g, ""));
if (this.iconCache.has(cleanPath)) return this.iconCache.get(cleanPath)!;
if (this.failedPaths.has(cleanPath)) return "";
if (!fs.existsSync(cleanPath)) {
this.failedPaths.add(cleanPath);
return "";
}
try {
logger.info(`[getAppIcon] Extracting 128x128 icon via PowerShell for: "${cleanPath}"`);
const escapedPath = cleanPath.replace(/'/g, "''");
const psScript = `
Add-Type -AssemblyName System.Drawing
$code = @'
using System;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;
public class IconHelper {
[DllImport("user32.dll", CharSet = CharSet.Auto)]
public static extern uint PrivateExtractIcons(
string lpszFile, int nIconIndex, int cxIcon, int cyIcon,
IntPtr[] phicon, uint[] piconid, uint nIcons, uint flags);
[DllImport("user32.dll", SetLastError = true)]
public static extern bool DestroyIcon(IntPtr hIcon);
public static Bitmap GetIcon128(string filePath) {
IntPtr[] phicon = new IntPtr[1];
uint[] piconid = new uint[1];
// Extract icon attempting 128x128 target size
uint count = PrivateExtractIcons(filePath, 0, 128, 128, phicon, piconid, 1, 0);
Bitmap bmp = null;
if (count > 0 && phicon[0] != IntPtr.Zero) {
try {
using (Icon ic = Icon.FromHandle(phicon[0])) {
bmp = ic.ToBitmap();
}
} finally {
DestroyIcon(phicon[0]);
}
}
// Fallback to standard associated icon if extraction returns null
if (bmp == null) {
using (Icon ic = Icon.ExtractAssociatedIcon(filePath)) {
if (ic != null) bmp = ic.ToBitmap();
}
}
if (bmp == null) return null;
// Ensure exact 128x128 canvas with high-quality resampling
if (bmp.Width == 128 && bmp.Height == 128) return bmp;
Bitmap resized = new Bitmap(128, 128);
using (Graphics g = Graphics.FromImage(resized)) {
g.InterpolationMode = InterpolationMode.HighQualityBicubic;
g.SmoothingMode = SmoothingMode.HighQuality;
g.PixelOffsetMode = PixelOffsetMode.HighQuality;
g.DrawImage(bmp, 0, 0, 128, 128);
}
bmp.Dispose();
return resized;
}
}
'@
if (-not ([System.Management.Automation.PSTypeName]'IconHelper').Type) {
Add-Type -TypeDefinition $code -ReferencedAssemblies System.Drawing
}
$bmp = [IconHelper]::GetIcon128('${escapedPath}')
if ($bmp) {
$ms = New-Object System.IO.MemoryStream
$bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
[System.Convert]::ToBase64String($ms.ToArray())
$bmp.Dispose()
$ms.Dispose()
}
`.trim();
const { stdout } = await execFileAsync("powershell.exe", [
"-NoProfile",
"-NonInteractive",
"-Command",
psScript
], { maxBuffer: 10 * 1024 * 1024 });
const base64 = stdout.trim();
if (base64) {
this.setCache(cleanPath, base64);
return base64;
} else {
this.failedPaths.add(cleanPath);
return "";
}
} catch (err) {
logger.error(`[getAppIcon] Exception extracting icon for "${cleanPath}":`, err);
this.failedPaths.add(cleanPath);
return "";
}
}
public static async getInstalledApps(): Promise<InstalledApp[]> {
if (this.cachedApps.length > 0) {
return this.cachedApps;
}
if (process.platform !== "win32") return [];
const psScript = `
Add-Type -AssemblyName System.Drawing
$code = @'
using System;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
using System.Runtime.InteropServices;
public class IconExtractor {
[DllImport("user32.dll", CharSet = CharSet.Auto)]
public static extern uint PrivateExtractIcons(
string lpszFile, int nIconIndex, int cxIcon, int cyIcon,
IntPtr[] phicon, uint[] piconid, uint nIcons, uint flags);
[DllImport("user32.dll", SetLastError = true)]
public static extern bool DestroyIcon(IntPtr hIcon);
public static string GetBase64Icon(string filePath) {
if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath)) return "";
string targetFile = filePath;
int iconIndex = 0;
// Parse .url files to extract the embedded IconFile and IconIndex
if (filePath.EndsWith(".url", StringComparison.OrdinalIgnoreCase)) {
try {
string[] lines = File.ReadAllLines(filePath);
foreach (string line in lines) {
if (line.StartsWith("IconFile=", StringComparison.OrdinalIgnoreCase)) {
targetFile = line.Substring(9).Trim();
}
if (line.StartsWith("IconIndex=", StringComparison.OrdinalIgnoreCase)) {
int.TryParse(line.Substring(10).Trim(), out iconIndex);
}
}
} catch {}
}
IntPtr[] phicon = new IntPtr[1];
uint[] piconid = new uint[1];
uint count = PrivateExtractIcons(targetFile, iconIndex, 32, 32, phicon, piconid, 1, 0);
Bitmap bmp = null;
if (count > 0 && phicon[0] != IntPtr.Zero) {
try {
using (Icon ic = Icon.FromHandle(phicon[0])) {
bmp = ic.ToBitmap();
}
} finally {
DestroyIcon(phicon[0]);
}
}
if (bmp == null) {
try {
using (Icon ic = Icon.ExtractAssociatedIcon(filePath)) {
if (ic != null) bmp = ic.ToBitmap();
}
} catch {}
}
if (bmp == null) return "";
using (MemoryStream ms = new MemoryStream()) {
bmp.Save(ms, ImageFormat.Png);
string base64 = Convert.ToBase64String(ms.ToArray());
bmp.Dispose();
return base64;
}
}
}
'@
if (-not ([System.Management.Automation.PSTypeName]'IconExtractor').Type) {
Add-Type -TypeDefinition $code -ReferencedAssemblies System.Drawing
}
$shell = New-Object -ComObject WScript.Shell
# Include Start Menu paths and Desktop locations (User & Public Desktop)
$searchPaths = @(
"$env:ProgramData\\Microsoft\\Windows\\Start Menu\\Programs",
"$env:AppData\\Microsoft\\Windows\\Start Menu\\Programs",
[System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::CommonDesktopDirectory),
[System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::DesktopDirectory)
)
$apps = @()
$seenPaths = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
# Scan for both .lnk and .url shortcuts
Get-ChildItem -Path $searchPaths -Recurse -Include *.lnk, *.url -ErrorAction SilentlyContinue | ForEach-Object {
try {
$file = $_
if ($file.Extension -eq ".url") {
$targetPath = $file.FullName
if ($seenPaths.Add($targetPath)) {
$iconBase64 = [IconExtractor]::GetBase64Icon($targetPath)
$apps += [PSCustomObject]@{
name = $file.BaseName
path = $targetPath
icon = $iconBase64
}
}
} else {
$shortcut = $shell.CreateShortcut($file.FullName)
$targetPath = $shortcut.TargetPath
if ($targetPath -and $targetPath.EndsWith(".exe") -and (Test-Path $targetPath)) {
if ($seenPaths.Add($targetPath)) {
$iconBase64 = [IconExtractor]::GetBase64Icon($targetPath)
$apps += [PSCustomObject]@{
name = $file.BaseName
path = $targetPath
icon = $iconBase64
}
}
}
}
} catch {}
}
$apps | Sort-Object name | ConvertTo-Json -Compress
`.trim();
try {
const { stdout } = await execFileAsync("powershell.exe", [
"-NoProfile",
"-NonInteractive",
"-Command",
psScript
], { maxBuffer: 20 * 1024 * 1024 });
if (!stdout.trim()) return [];
const parsed: InstalledApp[] = JSON.parse(stdout.trim());
const apps: InstalledApp[] = Array.isArray(parsed) ? parsed : [parsed];
const uniquePaths = new Set<string>();
const uniqueNames = new Set<string>();
const filteredApps: InstalledApp[] = [];
for (const app of apps) {
if (!app || !app.name || !app.path) continue;
const lowerName = app.name.toLowerCase().trim();
const lowerPath = app.path.toLowerCase().trim();
if (EXCLUDE_KEYWORDS.some((keyword) => lowerName.includes(keyword))) continue;
if (lowerPath.includes("\\windows\\system32\\") || lowerPath.includes("\\windows\\syswow64\\")) continue;
if (uniquePaths.has(lowerPath) || uniqueNames.has(lowerName)) continue;
uniquePaths.add(lowerPath);
uniqueNames.add(lowerName);
filteredApps.push(app);
}
this.cachedApps = filteredApps.sort((a, b) => a.name.localeCompare(b.name));
return this.cachedApps;
} catch (err) {
console.error("Failed to get installed apps with icons:", err);
return [];
}
}
static async openExecutablePicker(): Promise<InstalledApp | null> {
if (process.platform !== "win32" || !win32) return null;
try {
const filterStr = "Executable Files (*.exe)\0*.exe\0All Files (*.*)\0*.*\0\0";
const filterBuf = Buffer.from(filterStr, "ucs2");
const fileBuf = Buffer.alloc(520);
const titleStr = "Select Application Executable\0";
const titleBuf = Buffer.from(titleStr, "ucs2");
const ofn = {
lStructSize: koffi.sizeof(win32.OPENFILENAMEW),
hwndOwner: 0,
hInstance: 0,
lpstrFilter: koffi.address(filterBuf),
lpstrCustomFilter: 0,
nMaxCustFilter: 0,
nFilterIndex: 1,
lpstrFile: koffi.address(fileBuf),
nMaxFile: 260,
lpstrFileTitle: 0,
nMaxFileTitle: 0,
lpstrInitialDir: 0,
lpstrTitle: koffi.address(titleBuf),
Flags: 0x00000800 | 0x00000100 | 0x00000008,
nFileOffset: 0,
nFileExtension: 0,
lpstrDefExt: 0,
lCustData: 0,
lpfnHook: 0,
lpTemplateName: 0,
pvReserved: 0,
dwReserved: 0,
FlagsEx: 0
};
const success = win32.GetOpenFileNameW(ofn);
if (success) {
const targetPath = fileBuf.toString("utf16le").split("\0")[0].trim();
if (targetPath && fs.existsSync(targetPath)) {
const name = path.basename(targetPath);
const icon = await this.getAppIcon(targetPath);
return { path: targetPath, name, icon };
}
}
} catch (err) {
logger.error("Exception in openExecutablePicker:", err);
}
return null;
}
}
+31
View File
@@ -0,0 +1,31 @@
import { execFile } from "child_process";
import * as path from "path";
export class AppMetadata {
/**
* Reads the FileDescription or ProductName from Windows executable VersionInfo.
*/
public static async getFileDescription(exePath: string): Promise<string | null> {
return new Promise((resolve) => {
// Escaped command querying FileDescription, falling back to ProductName
const psScript = `
$info = (Get-Item -LiteralPath '${exePath.replace(/'/g, "''")}').VersionInfo;
if ($info.FileDescription) { $info.FileDescription } else { $info.ProductName }
`;
execFile(
"powershell.exe",
["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", psScript],
{ timeout: 3000 },
(error, stdout) => {
if (error || !stdout) {
resolve(null);
return;
}
const description = stdout.trim();
resolve(description.length > 0 ? description : null);
}
);
});
}
}
+74
View File
@@ -0,0 +1,74 @@
import * as fs from "fs";
import * as path from "path";
import streamDeck from "@elgato/streamdeck";
const logger = streamDeck.logger.createScope("AppNameMapper");
interface PatternRule {
pattern: RegExp;
replacement: string;
}
export class AppNameMapper {
private exactMap: Record<string, string> = {};
private patternRules: PatternRule[] = [];
constructor(private configPath: string = path.join(process.cwd(), "app-names.json")) {
this.loadConfig();
}
public loadConfig(): void {
if (!fs.existsSync(this.configPath)) return;
try {
const raw = fs.readFileSync(this.configPath, "utf-8");
const data = JSON.parse(raw);
this.exactMap = Object.fromEntries(
Object.entries(data.exact || {}).map(([k, v]) => [k.toLowerCase(), v as string])
);
this.patternRules = (data.patterns || []).map((p: any) => {
let flags = p.flags || "";
let regexStr = p.regex;
if (regexStr.startsWith("(?i)")) {
flags += "i";
regexStr = regexStr.substring(4);
}
return {
pattern: new RegExp(regexStr, flags),
replacement: p.replacement
};
});
} catch (err) {
console.error("Failed to parse app-names.json:", err);
}
}
/**
* Resolves display title:
* 1. Check exact json override
* 2. Check regex pattern override
* 3. Fallback to fallbackName (Metadata from app-stats.json)
*/
public resolveDisplayName(appPath: string, fallbackName: string): string {
const exeName = path.basename(appPath).toLowerCase();
// 1. Check exact match in JSON
if (this.exactMap[exeName]) {
return this.exactMap[exeName];
}
// 2. Check regex patterns in JSON
for (const rule of this.patternRules) {
logger.info(`checking pattern ${rule.pattern} against ${exeName}`);
if (rule.pattern.test(exeName)) {
return exeName.replace(rule.pattern, rule.replacement);
}
}
// 3. Fallback to stored metadata name
return fallbackName;
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"extends": "@tsconfig/node20/tsconfig.json",
"compilerOptions": {
"customConditions": [
"node"
],
"module": "ES2022",
"moduleResolution": "Bundler",
"noImplicitOverride": true
},
"include": [
"src/**/*.ts"
],
"exclude": [
"node_modules"
]
}