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"
|
||||
}
|
||||
}
|
||||