first commit
@@ -0,0 +1 @@
|
|||||||
|
/node_modules
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Node.js
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Stream Deck files
|
||||||
|
*.sdPlugin/*
|
||||||
|
!*.sdPlugin/.appignore
|
||||||
|
!*.sdPlugin/app*.json
|
||||||
|
!*.sdPlugin/manifest.json
|
||||||
@@ -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}/**"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
After Width: | Height: | Size: 70 B |
|
After Width: | Height: | Size: 70 B |
|
After Width: | Height: | Size: 70 B |
|
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 |
|
After Width: | Height: | Size: 70 B |
|
After Width: | Height: | Size: 70 B |
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"extends": "@tsconfig/node20/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"customConditions": [
|
||||||
|
"node"
|
||||||
|
],
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"noImplicitOverride": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"devDependencies": {
|
||||||
|
"@elgato/cli": "^1.9.0"
|
||||||
|
}
|
||||||
|
}
|
||||||