diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..1bbb28f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,25 @@ +root = true + +[*] +charset = utf-8 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[src/windhawk/**/*.{c,cc,cpp,h,hpp,hxx,inl,rc}] +indent_style = space +indent_size = 4 + +[src/vscode-windhawk-ui/**/*.{ts,tsx,js,jsx,json,css,less}] +indent_style = space +indent_size = 2 + +[src/vscode-windhawk/**/*.{ts,tsx,js,jsx,json}] +indent_style = tab +tab_width = 4 + +[*.{yml,yaml}] +indent_style = space +indent_size = 2 diff --git a/.github/workflows/project-quality.yml b/.github/workflows/project-quality.yml new file mode 100644 index 0000000..c5c78bd --- /dev/null +++ b/.github/workflows/project-quality.yml @@ -0,0 +1,66 @@ +name: Project Quality + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + vscode-extension: + runs-on: windows-latest + defaults: + run: + working-directory: src/vscode-windhawk + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Install dependencies + run: npm install --ignore-scripts --no-package-lock + + - name: Typecheck + run: npx tsc -p . --noEmit + + - name: Lint + run: npm run lint + + webview-ui: + runs-on: windows-latest + defaults: + run: + working-directory: src/vscode-windhawk-ui + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Install dependencies + run: npm install --ignore-scripts --no-package-lock + + - name: Lint app + run: npx nx lint vscode-windhawk-ui + + - name: Lint e2e + run: npx nx lint vscode-windhawk-ui-e2e + + - name: Test + run: npx nx test vscode-windhawk-ui --runInBand + + - name: Typecheck + run: npx tsc -p apps/vscode-windhawk-ui/tsconfig.app.json --noEmit + + - name: Build + run: npx nx build vscode-windhawk-ui diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..98ba418 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.run/ +artifacts/*.exe +!artifacts/windhawk-custom-portable-installer.exe +artifacts/portable-build/ +artifacts/backups/ +artifacts/installer-build/*.log diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..214c23f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,70 @@ +# Changelog + +## 2026-03-18 + +### Updated + +* The README and contributor guide to document the exact portable packaging command, both emitted artifacts (`artifacts/windhawk-custom-portable-installer.exe` and `artifacts/portable-build/windhawk-custom-portable.zip`), the `-SkipBuild` repack path, and the Visual Studio developer-shell fallback for native builds. +* The New Mod Studio documentation to cover code-first and visual authoring modes, language-aware starter filtering, workflow bundles, CLI playbooks, and kickoff packets. +* The README and contributor guide to document the verified two-step release flow (`vcvars64.bat` + `build.bat Release`, then `build_custom_portable.ps1`) and the new recent-session relaunch behavior in New Mod Studio. + +### Verified + +* `build.bat Release` from `src\windhawk` after priming a Visual Studio developer shell. +* `powershell -ExecutionPolicy Bypass -File artifacts\installer-build\build_custom_portable.ps1` + +## 2026-03-17 + +### Added + +* A richer mod install decision modal with community, targeting, freshness, and reviewability signals, plus direct jumps to the details, source, and changelog tabs before install. +* A shared changelog viewer for Windhawk and mod release notes with release-summary cards and inline filtering. +* Browse-mode discovery insight chips in Explore so high-signal mods surface their strengths without requiring a search query. +* Guided Explore starting points that jump into fresh updates, community favorites, Taskbar, Explorer, Start menu, and Audio views. +* Research-backed Explore missions that turn common Windows goals into compare-and-verify flows, with copyable AI briefs and an active mission workbench for comparing top candidate mods. +* Additional Windows-surface discovery presets for Notifications, Window management, Input, and Appearance, backed by richer Windows shell search concepts. +* Changelog controls for release scoping, a latest-only toggle, and copy-to-clipboard support for the visible notes. +* A New Mod Studio flow with a real AI-ready starter template and AI prompt packs for ideation, scaffolding, review, and documentation. +* A structured core mod starter and a structure-planning prompt so new mods can begin from a cleaner settings/runtime/helpers/hooks layout instead of one growing source block. +* Additional mod creation templates for Explorer shell tweaks, window-behavior mods, and settings-first scaffolding. +* A Chromium browser starter so Chrome-related mods can be created directly from the mod studio with a compile-safe browser UI scaffold. +* Optional `.wh.py` Python mod authoring with a bundled `windhawk_py` helper module, generated `.wh.cpp` compatibility output, and mouse/keyboard automation helpers. +* An editor cockpit redesign with live mod metadata, a one-click recommended compile action, an evidence board, a verification pack, a dynamic iteration plan, safer compile guidance, and copyable AI helper prompts for scaffold, review, scope explanation, test planning, docs, and release notes. +* A Windows toolkit in the About page with OS/session diagnostics, Windows settings shortcuts, and Explorer actions for runtime paths. +* Strategy cards and a disabled-first install path in the install modal, backed by focused heuristics for scope, freshness, and reviewability. +* Local home quick-focus chips for local drafts, compile-needed mods, logging-enabled mods, and update-ready mods. +* A scrollable editor cockpit shell with a pinned exit action so long mod sessions no longer hide the last controls off-screen. +* Visible compile mode cards in the editor cockpit, replacing the hidden-first compile choice with explicit current and recommended states. +* A contextual Windows bridge in the editor cockpit that infers shell surfaces from target processes and opens the matching Windows settings pages directly. +* More Windows quick actions in the About page for Start, Notifications, Multitasking, and Colors. +* More Windows customization routes in Explore for Context menu, Desktop, Alt+Tab, Virtual desktops, and Widgets, plus new missions for context-menu cleanup, desktop polish, and app switching. +* More Windows quick actions in the About page for Background, Themes, Lock screen, and Clipboard. +* A new Performance and AI settings section with balanced, responsive, and efficient workspace profiles, plus an NPU-aware AI acceleration preference. +* Runtime-based local recommendation logic that can apply a suggested profile from the active machine's memory, NPU, and runtime-health signals. +* Runtime diagnostics that now include total memory and detected NPU hardware for About and Settings. +* More complex workflow settings for startup routing, Explore default sorting, editor assistance level, and Windows quick-action density, wired into Settings, About, Explore, and the editor cockpit. +* New research-driven editor features: prompt-less AI explainers for APIs, Windows terms, and usage examples; a challenge board with counterquestions; a best-practice audit prompt; and a validation-feedback compile-recovery prompt. +* A curated `force-process-accelerators` repository mod surfaced as a featured available install in the default online catalog. + +### Updated + +* The English locale with new mission-workbench, verification-pack, recommended-compile, and AI prompt-deck strings. +* Contributor guidance for AI-assisted mod authoring and the new editor cockpit workflow. +* The VS Code extension/editor bridge so compile, enable, and logging actions refresh the sidebar with current mod metadata. +* Runtime diagnostics so the extension exposes Windows version/session details and reusable shell actions to the webview. +* The README with the latest UI improvements and additional research references for code understanding, AI trust, and question-driven debugging. +* The installed-mods overview so "needs attention" also surfaces debug-logging and compile-needed states, not just updates. +* The editor workflow text so Shneiderman's overview-first guidance and the Whyline-style "what should I inspect next?" loop now show up directly in the sidebar rather than only in documentation. +* Local UI preferences so performance profile and AI acceleration settings persist with the rest of the webview workspace state. +* The README and contributor guidance to document the broader local workflow settings surface and the files that now depend on it. +* The mod studio copy and docs to include Chromium/Chrome-focused authoring support and a browser UI prompt pack. +* The mod studio flow so Python mode only offers starters that have real `.wh.py` implementations, avoiding template mismatches. +* Local authoring preferences so stored language and source-extension choices stay aligned even if older or malformed state is loaded. +* The research notes in the README to include newer work on AI challenge behavior, industrial code-review guidance, and validation-feedback repair loops. + +### Verified + +* `npx jest apps/vscode-windhawk-ui/src/app/utils.spec.ts apps/vscode-windhawk-ui/src/app/panel/changelogUtils.spec.ts apps/vscode-windhawk-ui/src/app/panel/modDiscovery.spec.ts apps/vscode-windhawk-ui/src/app/panel/aiModStudio.spec.ts apps/vscode-windhawk-ui/src/app/sidebar/editorModeUtils.spec.ts --runInBand` +* `npx tsc -p apps/vscode-windhawk-ui/tsconfig.app.json --noEmit` +* `npx tsc -p . --noEmit` +* `npx nx build vscode-windhawk-ui --configuration=development` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..47b1bbc --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,123 @@ +# Contributing + +## Repository layout + +- `src/windhawk`: Native Windows engine and app solution (`windhawk.sln`). +- `src/vscode-windhawk`: VS Code extension host integration. +- `src/vscode-windhawk-ui`: React/Nx webview UI used by the extension. + +## Quick verification + +These commands are the fastest checks currently verified in this repository. + +### VS Code extension + +```powershell +cd src\vscode-windhawk +npm install --ignore-scripts --no-package-lock +npx tsc -p . --noEmit +npm run lint +``` + +### Webview UI + +```powershell +cd src\vscode-windhawk-ui +npm install --ignore-scripts --no-package-lock +npx nx lint vscode-windhawk-ui +npx nx lint vscode-windhawk-ui-e2e +npx nx test vscode-windhawk-ui --runInBand +npx tsc -p apps\vscode-windhawk-ui\tsconfig.app.json --noEmit +npx nx build vscode-windhawk-ui +``` + +## Native build prerequisites + +The native solution is Windows-only and requires Visual Studio 2022 or the equivalent MSBuild + C++ build tools with a recent Windows SDK installed. + +Open `src/windhawk/windhawk.sln` in Visual Studio, or build it from a Visual Studio developer shell. + +### Native build shortcut + +If `src\windhawk\build.bat` doesn't find the expected Visual Studio path automatically, enter a Visual Studio developer command prompt first and then run: + +```powershell +cd src\windhawk +build.bat Release +``` + +That keeps the native build working even when the installed Visual Studio path differs from the hardcoded fallback inside `build.bat`. + +If you need to discover the current Visual Studio install path first, `vswhere` plus `vcvars64.bat` is the most reliable fallback: + +```powershell +$vsPath = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" ` + -latest -products * -property installationPath +cmd /c "call `"$vsPath\VC\Auxiliary\Build\vcvars64.bat`" && cd /d src\windhawk && build.bat Release" +``` + +## Notes + +- The extension package includes native runtime dependencies. For lint and typecheck-only verification, `--ignore-scripts` avoids unnecessary rebuild steps. +- If you add new automated checks, prefer commands that can run headlessly in CI. + +## Portable release packaging + +- Refresh the native binaries first. The packaging script does not rebuild `src\windhawk\Release` for you. +- A verified native-build fallback is: + +```powershell +$vsPath = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" ` + -latest -products * -property installationPath +cmd /c "call `"$vsPath\VC\Auxiliary\Build\vcvars64.bat`" && cd /d src\windhawk && build.bat Release" +``` + +- The supported packaging script is `artifacts/installer-build/build_custom_portable.ps1`. +- Run it from the repository root with `powershell -ExecutionPolicy Bypass -File artifacts\installer-build\build_custom_portable.ps1`. +- It expects a portable baseline install at `%LOCALAPPDATA%\Programs\Windhawk-Custom-Portable` and reads `windhawk.ini` from that location to determine the active engine path. +- The script rebuilds the webview and extension by default, overlays the latest native binaries from `src/windhawk/Release`, rewrites the portable runtime config, and emits both `artifacts/windhawk-custom-portable-installer.exe` and `artifacts/portable-build/windhawk-custom-portable.zip`. +- Use `-SkipBuild` only when you deliberately want to reuse the current webview and extension outputs while repackaging the portable payload. +- If you change installer behavior, payload layout, or runtime config rewriting, keep `artifacts/installer-build/InstallerStub.cs` and `artifacts/installer-build/build_custom_portable.ps1` aligned so the release artifact and the packaged payload stay in sync. + +## AI-assisted mod authoring + +- The webview now includes a `New Mod Studio` flow with code-first and visual modes, language-aware starter filtering, a structured core starter, a standard starter, an AI-ready starter, focused starters for Explorer shell work, Chromium browser work, window behavior, and settings-first scaffolding, plus copyable prompt packs for ideation, structure planning, scaffolding, browser UI work, review, and documentation. +- The same flow now exposes workflow bundles, recommended launch paths, CLI playbooks, and kickoff packets that combine the chosen starter, tools, prompts, and verification guidance into one copyable handoff. +- The AI-ready starter lives at `src/vscode-windhawk/files/mod_template_ai_ready.wh.cpp` and is intentionally still a normal Windhawk template, not a separate runtime path. +- Additional focused starters live beside it in `src/vscode-windhawk/files`. Keep them compile-safe, explanatory, and structurally legible: they should help contributors pick a mod shape quickly without pretending to be finished production mods. +- Python-backed authoring now lives in `src/vscode-windhawk/files/mod_template_python.wh.py`, with the renderer in `src/vscode-windhawk/files/python/render_mod.py` and the helper module in `src/vscode-windhawk/files/python/windhawk_py`. Keep the generated `.wh.cpp` output valid because it is still the compile-time contract with Windhawk. +- The create flow currently exposes the Python automation starter as the Python-backed template. If you add more `.wh.py` starters, update the modal starter filtering and tests so Python mode only offers templates that actually have a Python implementation. +- If you add or rename starters, workflow bundles, or CLI playbooks, keep `NewModStudioModal.tsx`, `aiModStudio.ts`, `aiModStudio.spec.ts`, and `translation.json` aligned so the copy, filtering, and packet generation stay consistent. +- Recent studio sessions are persisted in local UI settings and relaunch through stored editor launch context. If you change launch-context shape or recent-session behavior, keep `appUISettings.ts`, `appUISettings.spec.ts`, `NewModStudioModal.tsx`, and `translation.json` aligned. +- Treat AI output as a draft. Contributors are still expected to verify hook targets, failure handling, compatibility notes, and manual test steps before shipping a mod or template change. + +## Editor cockpit workflow + +- The editor sidebar now depends on live `setEditedModDetails` metadata from the extension host. If you add editor-side actions that change compile state, logging, versioning, or target processes, keep that payload in sync. +- The editor cockpit now uses an explicit scroll shell with a pinned footer exit action. Keep long-running or low-priority actions inside the scroll area and reserve the footer for persistent, high-value actions that must remain reachable. +- Compile presets, process summarization, inferred Windows surfaces, contextual Windows quick actions, the evidence board, the iteration plan, and AI helper prompt generation live in `src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/sidebar/EditorModeControls.tsx` and `src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/sidebar/editorModeUtils.ts`. Update the paired tests when you change those flows. +- The editor now also derives a recommended compile profile and a verification pack from the current draft state. If you retune those heuristics, update the utility tests rather than burying the behavior inside the component. +- The sidebar intentionally refreshes details after compile, enable, and logging actions so the authoring UI reflects the latest runtime state instead of stale local assumptions. +- The newer AI flows are intentionally heuristic-backed. If you change the prompt deck or evidence cards, keep `editorModeUtils.spec.ts` aligned so trust and verification guidance do not silently drift. +- The latest editor AI additions are research-driven: prompt-less explainers, a challenge board, best-practice audit prompts, and validation-feedback recovery prompts. Keep those grouped and legible so the cockpit stays actionable rather than turning into an undifferentiated prompt dump. + +## Windows runtime toolkit + +- `AppRuntimeDiagnostics` is shared between the extension host and the webview. If you add or rename Windows environment fields, update both `src/vscode-windhawk/src/webviewIPCMessages.ts` and `src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/webviewIPCMessages.ts` together. +- Runtime diagnostics now include memory and simple NPU detection in addition to Windows version/session data. Keep `RuntimeDiagnosticsUtils` fast and defensive because these values are fetched as part of normal settings/about flows. +- The About page and the editor cockpit now use `openExternal` and `openPath` IPC actions for Windows settings deep links and Explorer path launches. Reuse those actions instead of hardcoding shell behavior inside React components. +- Windows-surface discovery lives in `src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/modDiscovery.ts` and `ModsBrowserOnline.tsx`. When you add new Windows areas, update the concept vocabulary, preset cards, mission coverage, and `modDiscovery.spec.ts` together so search, browse insights, and preset counts stay aligned. +- Research missions also live off the discovery layer. If you add or retune a mission, keep the query, follow-up queries, workbench candidate summaries, verification checks, and mission-brief output coherent so Explore still encourages compare-and-verify behavior instead of one-click blind installs. + +## Local performance preferences + +- Local workspace behavior is coordinated through `src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/appUISettings.ts`. If you add new local UI switches, update the normalization, defaults, persistence tests, and any runtime-based recommendation logic together. +- The new Performance and AI settings section reads `runtimeDiagnostics` from `getAppSettings`. If you change those diagnostics, keep the recommendation copy and the About page summary aligned so the same machine state does not produce contradictory guidance. +- Workflow-level settings now also drive the startup route, Explore's empty-query sort, the editor cockpit assistance level, and Windows quick-action density in About/editor surfaces. Keep `Panel.tsx`, `ModsBrowserOnline.tsx`, `About.tsx`, and `EditorModeControls.tsx` aligned when you add or rename those controls. + +## Install and home heuristics + +- Install decision heuristics live in `src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/installDecisionUtils.ts`. If you change install guidance, keep the recommendation cards and checklist logic aligned so the modal does not suggest an action that contradicts its own signals. +- The install modal now supports a disabled-first install path through the existing install IPC. If you change the install request payload shape, make sure the not-installed and update flows still preserve the optional `disabled` flag. +- Curated repository mods are merged into the normal online catalog in the extension host. Keep their metadata, source URLs, and install expectations aligned so featured defaults such as `force-process-accelerators` behave like first-class available mods. +- Local home insights live in `src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/localModsInsights.ts`. Update the paired tests when you retune what counts as "needs attention", "needs compile", or "logging enabled" so quick-focus chips and overview counts remain intentional. diff --git a/README.md b/README.md index 6f9923b..4c0889c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Windhawk -![Screenshot](screenshot.png) +image +image +image Windhawk aims to make it easier to customize Windows programs. For more details, see [the official website](https://windhawk.net/) and [the announcement](https://ramensoftware.com/windhawk). @@ -10,12 +12,22 @@ You're also welcome to join [the Windhawk Discord channel](https://discord.com/s ## Technical details -High level architecture: +High-level architecture: -![High level architecture diagram](diagram.png) +image For technical details about the global injection and hooking method that is used, refer to the following blog post: [Implementing Global Injection and Hooking in Windows](https://m417z.com/Implementing-Global-Injection-and-Hooking-in-Windows/). +### Runtime storage contract + +The most important runtime invariant for a working build is that the app, the embedded extension, and the engine all resolve to the same storage backend. + +* `windhawk.ini` selects the install mode and points the app and extension to the active app-data root, engine folder, compiler folder, and UI runtime. +* `Engine\\engine.ini` must resolve to the matching engine app-data root and, for installed mode, the matching registry subtree. +* If those files disagree, the UI can still show mods as installed while the injected engine loads a different storage location and the mods never activate. + +The current fork now exposes runtime diagnostics in the About page, surfaces storage mismatches on the Home page, and includes a repair action that rewrites the engine config to match the active install. + ## Source code The Windhawk source code can be found in the `src` folder, which contains the following subfolders: @@ -28,6 +40,120 @@ The Windhawk source code can be found in the `src` folder, which contains the fo A simple way to get started is by extracting the portable version of Windhawk with the official installer, building the part of Windhawk that you want to modify, and then replacing the corresponding files in the portable version with the newly built files. +## Development + +Contributor setup, verified validation commands, and native build prerequisites are documented in [CONTRIBUTING.md](CONTRIBUTING.md). + +## Portable installer build + +This fork includes a portable packaging flow that bundles the rebuilt native binaries, the VS Code extension, and the React webview into a custom installer. + +Typical packaging flow: + +1. Refresh the native Release binaries: + +```powershell +$vsPath = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" ` + -latest -products * -property installationPath +cmd /c "call `"$vsPath\VC\Auxiliary\Build\vcvars64.bat`" && cd /d src\windhawk && build.bat Release" +``` + +2. Run the packaging script from the repository root. It rebuilds the webview and extension by default before staging the portable payload: + +```powershell +powershell -ExecutionPolicy Bypass -File artifacts\installer-build\build_custom_portable.ps1 +``` + +If `src\windhawk\build.bat` doesn't detect the local Visual Studio installation automatically, a Visual Studio developer shell or the explicit `vswhere` + `vcvars64.bat` sequence above is the supported fallback. + +The script produces `artifacts/windhawk-custom-portable-installer.exe` and `artifacts/portable-build/windhawk-custom-portable.zip`, refreshes the staged portable payload used by the installer stub, and expects a portable Windhawk baseline at `%LOCALAPPDATA%\Programs\Windhawk-Custom-Portable`. Use `-SkipBuild` only when you intentionally want to reuse the current webview and extension outputs. + +## UI preview + +The React webview lives in `src/vscode-windhawk-ui` and can be iterated on independently from the native C++ binaries. + +Typical local preview workflow: + +1. `cd src/vscode-windhawk-ui` +2. `npm install --ignore-scripts --no-package-lock` +3. `npx nx build vscode-windhawk-ui` +4. Serve `dist/apps/vscode-windhawk-ui` with a static file server, for example: + `python -m http.server 4200 --directory dist/apps/vscode-windhawk-ui` + +Then open `http://127.0.0.1:4200/`. + +## Recent UI improvements + +The webview UI now includes: + +* smarter mod discovery with typo recovery, query broadening, and refinement suggestions +* a redesigned settings experience with persistent local workspace controls for density, startup page, Explore default sorting, editor assistance level, Windows quick-action density, wide layout, reduced motion, and performance tuning +* an expanded About page with current workspace status, runtime diagnostics, path inspection, repair actions, and quicker access to key project resources +* a richer installed-mods home view with a fast overview strip and an early warning when the engine storage backend diverges from the UI backend +* a research-informed install decision modal with scope/freshness/community signals and one-click review actions for details, source, and changelog tabs +* strategy-based install guidance with a disabled-first path for broad-scope or lower-reviewability mods, plus a short pre-install checklist derived from scope and freshness +* a shared changelog explorer with release-summary cards and inline filtering in both the About page and per-mod changelog views +* browse-mode insight chips in Explore so fresh, popular, and focused mods stand out even before typing a query +* guided Explore starting points that jump straight into fresh updates, community favorites, or focused areas such as Taskbar, Explorer, Start menu, and Audio +* research-backed Explore missions that turn common Windows goals into compare-and-verify flows, with copyable AI comparison briefs and an active mission workbench for top-candidate comparison +* broader Windows-surface discovery presets for notifications, window management, input, and appearance so the catalog is easier to navigate by the Windows area you want to change +* more Windows customization entry points in Explore for context menus, the desktop surface, Alt+Tab, virtual desktops, and widgets so it is easier to start from the part of Windows you actually want to change +* richer changelog tooling with release scoping, a latest-only toggle, and copy-to-clipboard support for the currently visible notes +* a new mod studio that promotes AI-assisted authoring with code-first and visual creation modes, language-aware C++ and Python starter filtering, a structured core starter, an AI-ready starter template, focused starters for Explorer shell work, Chromium/Chrome browser mods, window behavior mods, settings-first experiments, and optional `.wh.py` Python automation authoring that renders back to compatible `.wh.cpp` +* workflow bundles inside the mod studio with recommended launch paths, copyable kickoff packets, and CLI playbooks that combine the chosen starter, prompt packs, and verification checklist into one handoff +* persistent recent studio sessions in New Mod Studio so starters, visual presets, and workflow bundles can be relaunched with the same launch brief, packet, authoring language, and studio mode +* a redesigned editor cockpit with a real scroll shell, a pinned exit action, live mod metadata, visible compile mode cards, a one-click recommended compile action, an evidence board, a verification pack, a dynamic iteration plan, and copyable AI helper prompts for scope analysis, test planning, release notes, and review +* contextual Windows integration inside the editor cockpit, including inferred shell-surface tags and one-click deep links into the Windows settings pages that match the current mod's target processes +* newer research-driven editor innovations including prompt-less AI explainers for APIs, Windows terms, and usage examples, a visible challenge board that pushes the draft with counterquestions instead of agreement, a best-practice audit prompt, and a validation-feedback recovery prompt for failed builds +* a Windows toolkit on the About page with live OS/session diagnostics, expanded quick links into key Windows settings surfaces such as Start, Notifications, Multitasking, Colors, Background, Themes, Lock screen, Clipboard, and one-click opening of Windhawk runtime paths in Explorer +* local home quick-focus chips for drafts, compile-needed mods, logging-enabled mods, and pending updates so maintenance work is easier to batch +* a new Performance and AI settings section with runtime-based profile recommendations, NPU-aware acceleration preferences, and coordinated local UI presets for balanced, responsive, or efficient workspaces +* richer runtime diagnostics that now surface system memory and detected NPU hardware so local recommendations and Windows troubleshooting are grounded in the active machine +* a curated `force-process-accelerators` repository mod surfaced as a featured available install so process CPU/GPU/NPU preference tuning is easier to discover from the default catalog + +## Research-informed UX improvements + +These interaction changes are intentionally grounded in a small set of papers that map well to Windhawk's mod-install and release-review workflows. + +* [Crying Wolf: An Empirical Study of SSL Warning Effectiveness](https://www.usenix.org/conference/usenixsecurity09/technical-sessions/presentation/crying-wolf-empirical-study-ssl) motivated a more concrete install warning with supporting context and clear review paths instead of a single generic caution block. +* [An Empirical Study of Release Note Production and Usage in Practice](https://www.microsoft.com/en-us/research/publication/an-empirical-study-of-release-note-production-and-usage-in-practice/) informed the release-summary cards and searchable changelog view so the most actionable updates are visible before reading the full Markdown stream. +* [The Eyes Have It: A Task by Data Type Taxonomy for Information Visualizations](https://www.cs.umd.edu/users/ben/papers/Shneiderman1996eyes.pdf) continues to inform the "overview first, zoom and filter, then details on demand" structure used across Explore, changelog, and the editor cockpit's visible mode cards and status surfaces. +* [Using an LLM to Help With Code Understanding](https://research.google/pubs/using-an-llm-to-help-with-code-understanding/) pushed the editor cockpit toward prompt-light, in-IDE requests such as scope explanation, API understanding, and test-plan generation instead of one generic AI action. +* [Identifying the Factors that Influence Trust in AI Code Completion](https://research.google/pubs/identifying-the-factors-that-influence-trust-in-ai-code-completion/) motivated the evidence board, visible compile mode states, and safer compile recommendations so AI assistance is paired with explicit trust signals and verification steps. +* [Source-level Debugging with the Whyline](https://faculty.washington.edu/ajko/papers/Ko2008SourceLevelDebugging.pdf) informed the new mission and editor flows that foreground "why this candidate?", "what Windows surface should I check?", and "what should I verify next?" instead of forcing users to build those questions manually. +* [AI-assisted Assessment of Coding Practices in Industrial Code Review](https://research.google/pubs/ai-assisted-assessment-of-coding-practices-in-industrial-code-review/) motivated the new best-practice audit prompt so contributors can ask for language-aware C++ and Windows review comments instead of only generic summaries. +* [AI Should Challenge, Not Obey](https://www.microsoft.com/en-us/research/publication/ai-should-challenge-not-obey/) directly informed the editor challenge board and counterexample-hunt prompts so the assistant can question assumptions rather than merely comply. +* [A Case Study of LLM for Automated Vulnerability Repair: Assessing Impact of Reasoning and Patch Validation Feedback](https://arxiv.org/abs/2405.15690) pushed the new compile-recovery prompt toward smaller, validation-driven iteration loops after build failures instead of broader speculative rewrites. + +## Research-informed reliability + +The new diagnostics and repair flow is based on a narrow, reliability-focused interpretation of configuration research rather than more invasive runtime behavior changes. + +* [PeerPressure: Using Peer Configuration to Troubleshoot Systems Automatically](https://www.usenix.org/legacy/events/osdi04/tech/full_papers/wang/wang_html/) motivated the idea of treating configuration mismatches as first-class failures instead of as vague runtime symptoms. +* [Strider: A Black-box, State-based Approach to Change and Configuration Management and Support](https://www.microsoft.com/en-us/research/publication/strider-a-black-box-state-based-approach-to-change-and-configuration-management-and-support/) informed the emphasis on comparing observed state with expected state before attempting repair. +* [Automatically Generating Predicates and Solutions for Configuration Troubleshooting](https://www.usenix.org/conference/atc10/automatically-generating-predicates-and-solutions-configuration-troubleshooting) reinforced the direction of pairing diagnostics with concrete, low-friction fixes instead of only presenting raw paths and flags. +* [The Eyes Have It: A Task by Data Type Taxonomy for Information Visualizations](https://www.cs.umd.edu/users/ben/papers/Shneiderman1996eyes.pdf) informed the UI structure: overview first, then diagnostic details on demand. + +## Advanced Research Features (2025-2026) + +This research fork of Windhawk implements state-of-the-art stealth and evasion techniques: + +### Injection & Stealth +* **Indirect Syscalls**: Bypassing EDR/AV hooks by dynamically resolving SSNs and using legitimate `syscall` instructions in `ntdll`. +* **Phantom Thread Pool Injection**: Hijacking existing Windows Thread Pool worker threads via APCs to avoid `NtCreateThreadEx` detection. +* **Module Stomping**: Hiding engine shellcode within the memory region of signed, file-backed Microsoft DLLs (e.g., `xpsprint.dll`). +* **ETW Evasion**: Surgical suppression of `EtwEventWrite` to blind telemetry during sensitive engine operations. + +### Hooking & Integrity +* **HWBP Hooking Engine**: Hardware Breakpoint-based hooking using CPU Debug Registers (DR0-DR3), ensuring zero bytes of target code are modified. +* **Injection Integrity Guard**: VEH-based `PAGE_GUARD` monitoring to detect and alert on unauthorized tampering of engine trampolines. +* **Call Stack Spoofing**: Synthetic ROP chain construction to hide the engine's origin during critical OS API calls. + +### Performance & API +* **Mod Sandbox**: Per-mod resource limits (CPU rate, Memory MB, Max Handles) via thread-level throttling and background priority. +* **Priority-Based Filtering**: Orchestrated injection flow with configurable priorities (`Deferred`, `Low`, `Normal`, `High`, `Critical`). +* **Extended Mods API**: Native support for `Wh_GetProcessInfo`, `Wh_RegisterCallback` (async events), and `Wh_GetSystemInfo`. + ## Additional resources -Code which demonstrates the global injection and hooking method that is used can be found in this repository: [global-inject-demo](https://github.com/m417z/global-inject-demo). +Code that demonstrates the global injection and hooking method that is used can be found in this repository: [global-inject-demo](https://github.com/m417z/global-inject-demo). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..034e848 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +# Security Policy + +## Supported Versions + +Use this section to tell people about which versions of your project are +currently being supported with security updates. + +| Version | Supported | +| ------- | ------------------ | +| 5.1.x | :white_check_mark: | +| 5.0.x | :x: | +| 4.0.x | :white_check_mark: | +| < 4.0 | :x: | + +## Reporting a Vulnerability + +Use this section to tell people how to report a vulnerability. + +Tell them where to go, how often they can expect to get an update on a +reported vulnerability, what to expect if the vulnerability is accepted or +declined, etc. diff --git a/artifacts/installer-build/InstallerStub.cs b/artifacts/installer-build/InstallerStub.cs new file mode 100644 index 0000000..66fa4a6 --- /dev/null +++ b/artifacts/installer-build/InstallerStub.cs @@ -0,0 +1,435 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Windows.Forms; + +internal static class Program +{ + private const string PayloadResourceName = "WindhawkPortablePayload.zip"; + private const string AppName = "Windhawk Custom Portable"; + private const string ShortcutName = "Windhawk Custom Portable"; + + [STAThread] + private static int Main(string[] args) + { + bool silent = args.Any(arg => string.Equals(arg, "/silent", StringComparison.OrdinalIgnoreCase)); + string targetDir = GetTargetDir(args) ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Programs", + "Windhawk-Custom-Portable"); + + try + { + if (!silent) + { + var result = MessageBox.Show( + "Install " + AppName + " to:\n\n" + targetDir, + AppName + " Installer", + MessageBoxButtons.OKCancel, + MessageBoxIcon.Information); + + if (result != DialogResult.OK) + { + return 1; + } + } + + Directory.CreateDirectory(targetDir); + InstallPayload(targetDir); + CreateShellShortcuts(targetDir); + + if (!silent) + { + var launchResult = MessageBox.Show( + AppName + " was installed successfully.\n\nLaunch it now?", + AppName + " Installer", + MessageBoxButtons.YesNo, + MessageBoxIcon.Information); + + if (launchResult == DialogResult.Yes) + { + Process.Start(new ProcessStartInfo + { + FileName = Path.Combine(targetDir, "windhawk.exe"), + WorkingDirectory = targetDir, + UseShellExecute = true, + }); + } + } + + return 0; + } + catch (Exception ex) + { + string message = AppName + " installation failed.\n\n" + ex.Message; + + if (silent) + { + Console.Error.WriteLine(message); + } + else + { + MessageBox.Show( + message, + AppName + " Installer", + MessageBoxButtons.OK, + MessageBoxIcon.Error); + } + + return 2; + } + } + + private static string GetTargetDir(string[] args) + { + const string Prefix = "/dir="; + + foreach (string arg in args) + { + if (!arg.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + string configuredPath = arg.Substring(Prefix.Length).Trim().Trim('"'); + if (string.IsNullOrWhiteSpace(configuredPath)) + { + throw new ArgumentException("The /dir argument must include a target path."); + } + + return Path.GetFullPath(Environment.ExpandEnvironmentVariables(configuredPath)); + } + + return null; + } + + private static void InstallPayload(string targetDir) + { + string tempZipPath = Path.Combine( + Path.GetTempPath(), + "windhawk-custom-portable-" + Guid.NewGuid().ToString("N") + ".zip"); + string tempExtractDir = Path.Combine( + Path.GetTempPath(), + "windhawk-custom-portable-extract-" + Guid.NewGuid().ToString("N")); + + try + { + using (Stream payloadStream = Assembly.GetExecutingAssembly() + .GetManifestResourceStream(PayloadResourceName)) + { + if (payloadStream == null) + { + throw new InvalidOperationException("Embedded payload not found."); + } + + using (FileStream fileStream = File.Create(tempZipPath)) + { + payloadStream.CopyTo(fileStream); + } + } + + Directory.CreateDirectory(tempExtractDir); + ZipFile.ExtractToDirectory(tempZipPath, tempExtractDir); + SynchronizeDirectory(tempExtractDir, targetDir); + } + finally + { + if (File.Exists(tempZipPath)) + { + File.Delete(tempZipPath); + } + + if (Directory.Exists(tempExtractDir)) + { + Directory.Delete(tempExtractDir, true); + } + } + } + + private static void SynchronizeDirectory(string sourceDir, string targetDir) + { + Directory.CreateDirectory(targetDir); + + foreach (string sourceSubdirectory in Directory.GetDirectories(sourceDir)) + { + string directoryName = Path.GetFileName(sourceSubdirectory); + if (string.IsNullOrEmpty(directoryName)) + { + continue; + } + + string targetSubdirectory = Path.Combine(targetDir, directoryName); + SynchronizeDirectory(sourceSubdirectory, targetSubdirectory); + } + + foreach (string sourceFile in Directory.GetFiles(sourceDir)) + { + string fileName = Path.GetFileName(sourceFile); + if (string.IsNullOrEmpty(fileName)) + { + continue; + } + + string targetFile = Path.Combine(targetDir, fileName); + if (File.Exists(targetFile)) + { + File.SetAttributes(targetFile, FileAttributes.Normal); + } + + File.Copy(sourceFile, targetFile, true); + } + + var sourceEntries = Directory.GetFileSystemEntries(sourceDir) + .Select(Path.GetFileName) + .Where(name => !string.IsNullOrEmpty(name)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (string targetEntry in Directory.GetFileSystemEntries(targetDir)) + { + string entryName = Path.GetFileName(targetEntry); + if (string.IsNullOrEmpty(entryName) || sourceEntries.Contains(entryName)) + { + continue; + } + + if (Directory.Exists(targetEntry)) + { + DeleteDirectory(targetEntry); + } + else if (File.Exists(targetEntry)) + { + File.SetAttributes(targetEntry, FileAttributes.Normal); + File.Delete(targetEntry); + } + } + } + + private static void DeleteDirectory(string path) + { + foreach (string filePath in Directory.GetFiles(path, "*", SearchOption.AllDirectories)) + { + File.SetAttributes(filePath, FileAttributes.Normal); + } + + Directory.Delete(path, true); + } + + private static void CreateShellShortcuts(string targetDir) + { + string exePath = Path.Combine(targetDir, "windhawk.exe"); + string desktopShortcutPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory), + ShortcutName + ".lnk"); + string startMenuShortcutPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.Programs), + ShortcutName + ".lnk"); + + CreateShortcut(desktopShortcutPath, exePath, targetDir); + CreateShortcut(startMenuShortcutPath, exePath, targetDir); + + // Windows doesn't reliably expose taskbar pinning to third-party + // installers, so only use it when the shell advertises a taskbar verb. + TryPinToTaskbar(startMenuShortcutPath); + } + + private static void CreateShortcut(string shortcutPath, + string targetPath, + string workingDirectory) + { + string shortcutDirectory = Path.GetDirectoryName(shortcutPath); + if (!string.IsNullOrEmpty(shortcutDirectory)) + { + Directory.CreateDirectory(shortcutDirectory); + } + + Type shellType = Type.GetTypeFromProgID("WScript.Shell"); + if (shellType == null) + { + throw new InvalidOperationException("WScript.Shell is not available."); + } + + object shellObject = Activator.CreateInstance(shellType); + + try + { + object shortcutObject = shellType.InvokeMember( + "CreateShortcut", + BindingFlags.InvokeMethod, + null, + shellObject, + new object[] { shortcutPath }); + + Type shortcutType = shortcutObject.GetType(); + shortcutType.InvokeMember("TargetPath", + BindingFlags.SetProperty, + null, + shortcutObject, + new object[] { targetPath }); + shortcutType.InvokeMember("WorkingDirectory", + BindingFlags.SetProperty, + null, + shortcutObject, + new object[] { workingDirectory }); + shortcutType.InvokeMember("IconLocation", + BindingFlags.SetProperty, + null, + shortcutObject, + new object[] { targetPath + ",0" }); + shortcutType.InvokeMember("Save", + BindingFlags.InvokeMethod, + null, + shortcutObject, + Array.Empty()); + + Marshal.FinalReleaseComObject(shortcutObject); + } + finally + { + Marshal.FinalReleaseComObject(shellObject); + } + } + + private static void TryPinToTaskbar(string shortcutPath) + { + try + { + Type shellType = Type.GetTypeFromProgID("Shell.Application"); + if (shellType == null) + { + return; + } + + object shellObject = Activator.CreateInstance(shellType); + + try + { + string folderPath = Path.GetDirectoryName(shortcutPath); + string shortcutName = Path.GetFileName(shortcutPath); + if (string.IsNullOrEmpty(folderPath) || string.IsNullOrEmpty(shortcutName)) + { + return; + } + + object folder = shellType.InvokeMember( + "NameSpace", + BindingFlags.InvokeMethod, + null, + shellObject, + new object[] { folderPath }); + if (folder == null) + { + return; + } + + try + { + Type folderType = folder.GetType(); + object item = folderType.InvokeMember( + "ParseName", + BindingFlags.InvokeMethod, + null, + folder, + new object[] { shortcutName }); + if (item == null) + { + return; + } + + try + { + Type itemType = item.GetType(); + object verbs = itemType.InvokeMember( + "Verbs", + BindingFlags.InvokeMethod, + null, + item, + Array.Empty()); + if (verbs == null) + { + return; + } + + try + { + Type verbsType = verbs.GetType(); + int count = (int)verbsType.InvokeMember( + "Count", + BindingFlags.GetProperty, + null, + verbs, + null); + + for (int i = 0; i < count; i++) + { + object verb = verbsType.InvokeMember( + "Item", + BindingFlags.InvokeMethod, + null, + verbs, + new object[] { i }); + if (verb == null) + { + continue; + } + + try + { + string name = (string)verb.GetType().InvokeMember( + "Name", + BindingFlags.GetProperty, + null, + verb, + null); + string normalizedName = + (name ?? string.Empty).Replace("&", string.Empty) + .Trim() + .ToLowerInvariant(); + + if (normalizedName.Contains("taskbar")) + { + verb.GetType().InvokeMember( + "DoIt", + BindingFlags.InvokeMethod, + null, + verb, + Array.Empty()); + return; + } + } + finally + { + Marshal.FinalReleaseComObject(verb); + } + } + } + finally + { + Marshal.FinalReleaseComObject(verbs); + } + } + finally + { + Marshal.FinalReleaseComObject(item); + } + } + finally + { + Marshal.FinalReleaseComObject(folder); + } + } + finally + { + Marshal.FinalReleaseComObject(shellObject); + } + } + catch + { + // Best-effort only. Current Windows builds may block taskbar pinning + // for third-party installers. + } + } +} diff --git a/artifacts/installer-build/build_custom_portable.ps1 b/artifacts/installer-build/build_custom_portable.ps1 new file mode 100644 index 0000000..4762d2b --- /dev/null +++ b/artifacts/installer-build/build_custom_portable.ps1 @@ -0,0 +1,348 @@ +param( + [string]$RepoRoot = "C:\Users\kai99\Desktop\New folder (12)\windhawk", + [string]$BasePortableRoot = "$env:LOCALAPPDATA\Programs\Windhawk-Custom-Portable", + [string]$OutputInstallerPath = (Join-Path "C:\Users\kai99\Desktop\New folder (12)\windhawk" "artifacts\windhawk-custom-portable-installer.exe"), + [switch]$SkipBuild +) + +$ErrorActionPreference = "Stop" + +Add-Type -AssemblyName System.IO.Compression.FileSystem + +function Invoke-Step { + param( + [Parameter(Mandatory = $true)] + [string]$Message, + + [Parameter(Mandatory = $true)] + [scriptblock]$Action + ) + + Write-Host "==> $Message" + & $Action +} + +function Invoke-RobocopyMirror { + param( + [Parameter(Mandatory = $true)] + [string]$Source, + + [Parameter(Mandatory = $true)] + [string]$Destination + ) + + if (-not (Test-Path $Source)) { + throw "Missing source path: $Source" + } + + New-Item -ItemType Directory -Path $Destination -Force | Out-Null + & robocopy $Source $Destination /MIR /COPY:DAT /R:2 /W:1 /NFL /NDL /NJH /NJS /NP | Out-Null + if ($LASTEXITCODE -ge 8) { + throw "robocopy mirror failed for $Source -> $Destination with exit code $LASTEXITCODE" + } +} + +function Get-IniValue { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + + [Parameter(Mandatory = $true)] + [string]$Section, + + [Parameter(Mandatory = $true)] + [string]$Key + ) + + $currentSection = $null + + foreach ($line in Get-Content -Path $Path) { + $trimmedLine = $line.Trim() + + if (-not $trimmedLine -or + $trimmedLine.StartsWith(';') -or + $trimmedLine.StartsWith('#')) { + continue + } + + if ($trimmedLine -match '^\[(.+)\]$') { + $currentSection = $matches[1] + continue + } + + if ($currentSection -and + $currentSection.Equals($Section, [System.StringComparison]::OrdinalIgnoreCase) -and + $trimmedLine -match '^(?[^=]+)=(?.*)$') { + if ($matches['key'].Trim().Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { + return $matches['value'].Trim() + } + } + } + + return $null +} + +function Set-PortableAppConfig { + param( + [Parameter(Mandatory = $true)] + [string]$WindhawkIniPath, + + [Parameter(Mandatory = $true)] + [string]$EngineRelativePath + ) + + @( + '[Storage]' + 'Portable=1' + 'CompilerPath=Compiler' + "EnginePath=$EngineRelativePath" + 'UIPath=UI' + 'AppDataPath=Data' + '' + ) | Set-Content -Path $WindhawkIniPath -Encoding ASCII +} + +function Set-PortableEngineConfig { + param( + [Parameter(Mandatory = $true)] + [string]$StagingRoot, + + [Parameter(Mandatory = $true)] + [string]$EngineRelativePath + ) + + $engineRootPath = Join-Path $StagingRoot $EngineRelativePath + $engineIniPath = Join-Path $engineRootPath "engine.ini" + $engineDataPath = Join-Path $StagingRoot "Data\Engine" + $relativeEngineDataPath = Get-RelativePath -FromPath $engineRootPath -ToPath $engineDataPath + + New-Item -ItemType Directory -Path $engineRootPath -Force | Out-Null + New-Item -ItemType Directory -Path $engineDataPath -Force | Out-Null + + @( + '[Storage]' + 'Portable=1' + "AppDataPath=$relativeEngineDataPath" + '' + ) | Set-Content -Path $engineIniPath -Encoding ASCII +} + +function Get-RelativePath { + param( + [Parameter(Mandatory = $true)] + [string]$FromPath, + + [Parameter(Mandatory = $true)] + [string]$ToPath + ) + + $normalizedFromPath = [System.IO.Path]::GetFullPath($FromPath).TrimEnd('\') + '\' + $normalizedToPath = [System.IO.Path]::GetFullPath($ToPath) + $fromUri = New-Object System.Uri($normalizedFromPath) + $toUri = New-Object System.Uri($normalizedToPath) + $relativeUri = $fromUri.MakeRelativeUri($toUri) + + return [System.Uri]::UnescapeDataString($relativeUri.ToString()).Replace('/', '\') +} + +function Get-CSharpCompilerPath { + $command = Get-Command csc.exe -ErrorAction SilentlyContinue + if ($command) { + return $command.Source + } + + $candidates = @( + (Join-Path $env:WINDIR 'Microsoft.NET\Framework64\v4.0.30319\csc.exe'), + (Join-Path $env:WINDIR 'Microsoft.NET\Framework\v4.0.30319\csc.exe') + ) + + foreach ($candidate in $candidates) { + if (Test-Path $candidate) { + return $candidate + } + } + + throw "csc.exe not found" +} + +function Copy-OptionalFile { + param( + [Parameter(Mandatory = $true)] + [string]$Source, + + [Parameter(Mandatory = $true)] + [string]$Destination + ) + + if (-not (Test-Path $Source)) { + return $false + } + + $destinationDirectory = Split-Path -Parent $Destination + if ($destinationDirectory) { + New-Item -ItemType Directory -Path $destinationDirectory -Force | Out-Null + } + + Copy-Item -Path $Source -Destination $Destination -Force + return $true +} + +$stagingRoot = Join-Path $RepoRoot "artifacts\portable-build\staging" +$payloadZipPath = Join-Path $RepoRoot "artifacts\portable-build\windhawk-custom-portable.zip" +$installerBuildRoot = Join-Path $RepoRoot "artifacts\portable-build" +$extensionRepoRoot = Join-Path $RepoRoot "src\vscode-windhawk" +$webviewRepoRoot = Join-Path $RepoRoot "src\vscode-windhawk-ui" +$portableAppConfigPath = Join-Path $BasePortableRoot "windhawk.ini" +$extensionTargetRoot = Join-Path $stagingRoot "UI\resources\app\extensions\windhawk" + +if (-not (Test-Path $BasePortableRoot)) { + throw "Portable baseline not found: $BasePortableRoot" +} + +if (-not (Test-Path $portableAppConfigPath)) { + throw "Portable baseline config not found: $portableAppConfigPath" +} + +$engineRelativePath = Get-IniValue -Path $portableAppConfigPath -Section 'Storage' -Key 'EnginePath' +if (-not $engineRelativePath) { + throw "Portable baseline is missing Storage/EnginePath: $portableAppConfigPath" +} + +if (Test-Path $installerBuildRoot) { + Remove-Item -Path $installerBuildRoot -Recurse -Force +} + +New-Item -ItemType Directory -Path $installerBuildRoot -Force | Out-Null + +if (-not $SkipBuild) { + Invoke-Step -Message "Build webview UI" -Action { + Push-Location $webviewRepoRoot + try { + & npx nx build vscode-windhawk-ui + if ($LASTEXITCODE -ne 0) { + throw "nx build failed with exit code $LASTEXITCODE" + } + + Invoke-RobocopyMirror ` + -Source (Join-Path $webviewRepoRoot "dist\apps\vscode-windhawk-ui") ` + -Destination (Join-Path $extensionRepoRoot "webview") + } finally { + Pop-Location + } + } + + Invoke-Step -Message "Bundle VS Code extension" -Action { + Push-Location $extensionRepoRoot + try { + & npx webpack --mode production + if ($LASTEXITCODE -ne 0) { + throw "webpack build failed with exit code $LASTEXITCODE" + } + } finally { + Pop-Location + } + } +} + +Invoke-Step -Message "Stage portable baseline" -Action { + Invoke-RobocopyMirror -Source $BasePortableRoot -Destination $stagingRoot +} + +Invoke-Step -Message "Overlay updated extension assets" -Action { + foreach ($directory in @('assets', 'dist', 'files', 'prebuilds', 'syntaxes', 'webview')) { + Invoke-RobocopyMirror ` + -Source (Join-Path $extensionRepoRoot $directory) ` + -Destination (Join-Path $extensionTargetRoot $directory) + } + + foreach ($file in @('package.json', 'package.nls.json', 'README.md')) { + $sourceFile = Join-Path $extensionRepoRoot $file + $destinationFile = Join-Path $extensionTargetRoot $file + Copy-OptionalFile -Source $sourceFile -Destination $destinationFile | Out-Null + } +} + +Invoke-Step -Message "Overlay built native binaries when available" -Action { + $nativeCopies = @( + @{ + Source = (Join-Path $RepoRoot 'src\windhawk\Release\windhawk.exe') + Destination = (Join-Path $stagingRoot 'windhawk.exe') + } + @{ + Source = (Join-Path $RepoRoot 'src\windhawk\Release\32\windhawk.dll') + Destination = (Join-Path $stagingRoot (Join-Path $engineRelativePath '32\windhawk.dll')) + } + @{ + Source = (Join-Path $RepoRoot 'src\windhawk\Release\32\windhawk.lib') + Destination = (Join-Path $stagingRoot (Join-Path $engineRelativePath '32\windhawk.lib')) + } + @{ + Source = (Join-Path $RepoRoot 'src\windhawk\Release\64\windhawk.dll') + Destination = (Join-Path $stagingRoot (Join-Path $engineRelativePath '64\windhawk.dll')) + } + @{ + Source = (Join-Path $RepoRoot 'src\windhawk\Release\64\windhawk.lib') + Destination = (Join-Path $stagingRoot (Join-Path $engineRelativePath '64\windhawk.lib')) + } + @{ + Source = (Join-Path $RepoRoot 'src\windhawk\Release\arm64\windhawk.dll') + Destination = (Join-Path $stagingRoot (Join-Path $engineRelativePath 'arm64\windhawk.dll')) + } + @{ + Source = (Join-Path $RepoRoot 'src\windhawk\Release\arm64\windhawk.lib') + Destination = (Join-Path $stagingRoot (Join-Path $engineRelativePath 'arm64\windhawk.lib')) + } + ) + + foreach ($nativeCopy in $nativeCopies) { + $copied = Copy-OptionalFile -Source $nativeCopy.Source -Destination $nativeCopy.Destination + if ($copied) { + Write-Host (" copied {0}" -f $nativeCopy.Source) + } + } +} + +Invoke-Step -Message "Rewrite portable runtime config" -Action { + Set-PortableAppConfig -WindhawkIniPath (Join-Path $stagingRoot "windhawk.ini") -EngineRelativePath $engineRelativePath + Set-PortableEngineConfig -StagingRoot $stagingRoot -EngineRelativePath $engineRelativePath +} + +Invoke-Step -Message "Create portable payload zip" -Action { + if (Test-Path $payloadZipPath) { + Remove-Item -Path $payloadZipPath -Force + } + + [System.IO.Compression.ZipFile]::CreateFromDirectory( + $stagingRoot, + $payloadZipPath, + [System.IO.Compression.CompressionLevel]::Optimal, + $false + ) +} + +Invoke-Step -Message "Compile installer stub" -Action { + $cscPath = Get-CSharpCompilerPath + $installerSourcePath = Join-Path $RepoRoot "artifacts\installer-build\InstallerStub.cs" + $outputDirectory = Split-Path -Parent $OutputInstallerPath + + if ($outputDirectory) { + New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null + } + + & $cscPath ` + /nologo ` + /target:winexe ` + /out:$OutputInstallerPath ` + /resource:"$payloadZipPath,WindhawkPortablePayload.zip" ` + /r:System.Windows.Forms.dll ` + /r:System.IO.Compression.dll ` + /r:System.IO.Compression.FileSystem.dll ` + $installerSourcePath + + if ($LASTEXITCODE -ne 0) { + throw "Installer stub compilation failed with exit code $LASTEXITCODE" + } +} + +Write-Host "Portable installer created:" +Write-Host $OutputInstallerPath diff --git a/artifacts/installer-build/install_staged_portable.ps1 b/artifacts/installer-build/install_staged_portable.ps1 new file mode 100644 index 0000000..cf38213 --- /dev/null +++ b/artifacts/installer-build/install_staged_portable.ps1 @@ -0,0 +1,74 @@ +param( + [Parameter(Mandatory = $true)] + [string]$StagingRoot, + + [Parameter(Mandatory = $true)] + [string]$TargetRoot, + + [string]$ShortcutName = 'Windhawk Custom Portable', + + [switch]$Move +) + +$ErrorActionPreference = 'Stop' + +function New-Shortcut { + param( + [Parameter(Mandatory = $true)] + [string]$ShortcutPath, + + [Parameter(Mandatory = $true)] + [string]$TargetPath, + + [Parameter(Mandatory = $true)] + [string]$WorkingDirectory + ) + + $shortcutDirectory = Split-Path -Parent $ShortcutPath + if ($shortcutDirectory) { + New-Item -ItemType Directory -Path $shortcutDirectory -Force | Out-Null + } + + $shell = New-Object -ComObject WScript.Shell + $shortcut = $shell.CreateShortcut($ShortcutPath) + $shortcut.TargetPath = $TargetPath + $shortcut.WorkingDirectory = $WorkingDirectory + $shortcut.IconLocation = "$TargetPath,0" + $shortcut.Save() +} + +if (-not (Test-Path $StagingRoot)) { + throw "Staging root not found: $StagingRoot" +} + +$resolvedStagingRoot = (Resolve-Path $StagingRoot).ProviderPath +$resolvedTargetRoot = [System.IO.Path]::GetFullPath($TargetRoot) + +if (Test-Path $resolvedTargetRoot) { + Remove-Item -Path $resolvedTargetRoot -Recurse -Force +} + +New-Item -ItemType Directory -Path (Split-Path -Parent $resolvedTargetRoot) -Force | Out-Null + +if ($Move) { + Move-Item -Path $resolvedStagingRoot -Destination $resolvedTargetRoot -Force +} else { + Copy-Item -Path $resolvedStagingRoot -Destination $resolvedTargetRoot -Recurse -Force +} + +$installedExePath = Join-Path $resolvedTargetRoot 'windhawk.exe' +if (-not (Test-Path $installedExePath)) { + throw "Installed executable not found: $installedExePath" +} + +$desktopShortcutPath = Join-Path ( + [Environment]::GetFolderPath([Environment+SpecialFolder]::DesktopDirectory) +) "$ShortcutName.lnk" +$startMenuShortcutPath = Join-Path ( + [Environment]::GetFolderPath([Environment+SpecialFolder]::Programs) +) "$ShortcutName.lnk" + +New-Shortcut -ShortcutPath $desktopShortcutPath -TargetPath $installedExePath -WorkingDirectory $resolvedTargetRoot +New-Shortcut -ShortcutPath $startMenuShortcutPath -TargetPath $installedExePath -WorkingDirectory $resolvedTargetRoot + +Write-Output "Installed staged portable build to $resolvedTargetRoot" diff --git a/artifacts/installer-build/replace_programfiles_windhawk.ps1 b/artifacts/installer-build/replace_programfiles_windhawk.ps1 new file mode 100644 index 0000000..9752fff --- /dev/null +++ b/artifacts/installer-build/replace_programfiles_windhawk.ps1 @@ -0,0 +1,157 @@ +$ErrorActionPreference = "Stop" + +$repoRoot = "C:\Users\kai99\Desktop\New folder (12)\windhawk" +$sourceRoot = "C:\Users\kai99\AppData\Local\Programs\Windhawk-Custom-Portable" +$targetRoot = "C:\Program Files\Windhawk" +$backupRoot = Join-Path $repoRoot ("artifacts\backups\programfiles-windhawk-" + (Get-Date -Format "yyyyMMdd-HHmmss")) +$logPath = Join-Path $repoRoot "artifacts\installer-build\replace_programfiles_windhawk.log" + +function Get-IniValue { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + + [Parameter(Mandatory = $true)] + [string]$Section, + + [Parameter(Mandatory = $true)] + [string]$Key + ) + + $currentSection = $null + + foreach ($line in Get-Content -Path $Path) { + $trimmedLine = $line.Trim() + + if (-not $trimmedLine -or + $trimmedLine.StartsWith(';') -or + $trimmedLine.StartsWith('#')) { + continue + } + + if ($trimmedLine -match '^\[(.+)\]$') { + $currentSection = $matches[1] + continue + } + + if ($currentSection -and + $currentSection.Equals($Section, [System.StringComparison]::OrdinalIgnoreCase) -and + $trimmedLine -match '^(?[^=]+)=(?.*)$') { + if ($matches['key'].Trim().Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { + return $matches['value'].Trim() + } + } + } + + return $null +} + +function Set-InstalledEngineConfig { + param( + [Parameter(Mandatory = $true)] + [string]$WindhawkIniPath, + + [Parameter(Mandatory = $true)] + [string]$EngineIniPath + ) + + $portableValue = Get-IniValue -Path $WindhawkIniPath -Section 'Storage' -Key 'Portable' + if ($portableValue -and $portableValue -ne '0') { + throw "Target windhawk.ini is configured for portable mode: $WindhawkIniPath" + } + + $appDataPath = Get-IniValue -Path $WindhawkIniPath -Section 'Storage' -Key 'AppDataPath' + $registryKey = Get-IniValue -Path $WindhawkIniPath -Section 'Storage' -Key 'RegistryKey' + + if (-not $appDataPath) { + throw "Target windhawk.ini is missing Storage/AppDataPath: $WindhawkIniPath" + } + + if (-not $registryKey) { + throw "Target windhawk.ini is missing Storage/RegistryKey: $WindhawkIniPath" + } + + $engineAppDataPath = $appDataPath.TrimEnd('\') + '\Engine' + $engineRegistryKey = $registryKey.TrimEnd('\') + '\Engine' + $engineIniDirectory = Split-Path -Parent $EngineIniPath + + if (-not (Test-Path $engineIniDirectory)) { + New-Item -ItemType Directory -Path $engineIniDirectory -Force | Out-Null + } + + @( + '[Storage]' + 'Portable=0' + "AppDataPath=$engineAppDataPath" + "RegistryKey=$engineRegistryKey" + '' + ) | Set-Content -Path $EngineIniPath -Encoding ASCII +} + +Start-Transcript -Path $logPath -Force | Out-Null + +try { + if (-not (Test-Path $sourceRoot)) { + throw "Source install not found: $sourceRoot" + } + + if (-not (Test-Path $targetRoot)) { + throw "Target install not found: $targetRoot" + } + + Get-Process windhawk -ErrorAction SilentlyContinue | ForEach-Object { + try { + Stop-Process -Id $_.Id -Force -ErrorAction Stop + } catch { + Write-Warning ("Could not stop PID {0}: {1}" -f $_.Id, $_.Exception.Message) + } + } + + New-Item -ItemType Directory -Path $backupRoot -Force | Out-Null + + & robocopy $targetRoot $backupRoot /E /COPY:DAT /R:2 /W:1 /NFL /NDL /NJH /NJS /NP | Out-Null + if ($LASTEXITCODE -ge 8) { + throw "Backup robocopy failed with exit code $LASTEXITCODE" + } + + foreach ($dir in @("Compiler", "Engine", "UI")) { + & robocopy (Join-Path $sourceRoot $dir) (Join-Path $targetRoot $dir) /MIR /COPY:DAT /R:2 /W:1 /NFL /NDL /NJH /NJS /NP | Out-Null + if ($LASTEXITCODE -ge 8) { + throw "Mirror robocopy failed for $dir with exit code $LASTEXITCODE" + } + } + + foreach ($file in @("command-line.txt", "windhawk-x64-helper.exe", "windhawk.exe")) { + $sourceFile = Join-Path $sourceRoot $file + if (Test-Path $sourceFile) { + Copy-Item $sourceFile (Join-Path $targetRoot $file) -Force + } + } + + $targetWindhawkIniPath = Join-Path $targetRoot "windhawk.ini" + if (-not (Test-Path $targetWindhawkIniPath)) { + throw "Installed target config not found after copy: $targetWindhawkIniPath" + } + + $engineIniPaths = @(Get-ChildItem -Path (Join-Path $targetRoot "Engine") -Filter "engine.ini" -Recurse -File -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty FullName) + + if ($engineIniPaths.Count -eq 0) { + $activeEnginePath = Get-IniValue -Path $targetWindhawkIniPath -Section 'Storage' -Key 'EnginePath' + if (-not $activeEnginePath) { + throw "Target windhawk.ini is missing Storage/EnginePath: $targetWindhawkIniPath" + } + + $engineIniPaths = @((Join-Path $targetRoot (Join-Path $activeEnginePath "engine.ini"))) + } + + foreach ($engineIniPath in $engineIniPaths) { + Set-InstalledEngineConfig -WindhawkIniPath $targetWindhawkIniPath -EngineIniPath $engineIniPath + } + + Start-Process -FilePath (Join-Path $targetRoot "windhawk.exe") -WorkingDirectory $targetRoot + Write-Host "Replacement completed successfully." + Write-Host "Backup: $backupRoot" +} finally { + Stop-Transcript | Out-Null +} diff --git a/diagram.svg b/diagram.svg new file mode 100644 index 0000000..f492403 --- /dev/null +++ b/diagram.svg @@ -0,0 +1,96 @@ + + Windhawk runtime architecture + Diagram showing how windhawk.ini, the embedded extension, engine.ini, and the active storage backend fit together. + + + + + + + + + + + + + + + + + + + + + + + + + Windhawk runtime architecture and storage alignment + A working build depends on the UI path resolver and the injected engine resolving the same storage backend. + + + Launcher and UI runtime + windhawk.exe + Bootstraps the embedded VSCodium runtime and the Windhawk extension. + + windhawk.ini + Controls: + Portable / installed mode + App data path, engine path, compiler path, UI path + + + Embedded extension + UI/resources/app/extensions/windhawk + Reads app config, installs mods, lists mods, and now computes runtime diagnostics. + + webview + extension.js + New surfaces: + Overview cards, mismatch alerts, diagnostics, repair action + + + Injected engine + Engine\<version>\engine.ini + windhawk.dll + Loads inside target processes and reads mod state from its own resolved storage backend. + + Portable? AppDataPath? RegistryKey? + If this file diverges from the app config, mods can appear installed but never load. + + + + + + Portable backend + App root relative paths + Data\settings.ini + Data\Engine\Mods, ModsWritable, Symbols + Both configs must point here for a portable install to work. + + + Installed backend + Machine-wide storage + %ProgramData%\Windhawk + %ProgramData%\Windhawk\Engine + HKLM\SOFTWARE\Windhawk and HKLM\SOFTWARE\Windhawk\Engine + Installed builds require both filesystem and registry alignment. + + + + + + Runtime diagnostics and repair + The extension now compares app config with engine config, exposes the mismatch in the UI, + and can rewrite the active engine.ini so the runtime backend matches the current install. + + diff --git a/screenshot.png b/screenshot.png index 42b6d0e..a472812 100644 Binary files a/screenshot.png and b/screenshot.png differ diff --git a/scripts/regression_test.py b/scripts/regression_test.py new file mode 100644 index 0000000..7918190 --- /dev/null +++ b/scripts/regression_test.py @@ -0,0 +1,419 @@ +from __future__ import annotations + +import argparse +import json +import os +import shutil +import stat +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run regression tests for the Windhawk control skill.") + parser.add_argument( + "--helper", + default=str(Path(__file__).with_name("windhawk_tool.py")), + help="Path to windhawk_tool.py. Defaults to the sibling helper script.", + ) + parser.add_argument( + "--real-root", + default=str(Path(os.environ.get("LOCALAPPDATA", str(Path.home() / "AppData" / "Local"))) / "Programs" / "Windhawk-Custom-Portable"), + help="Path to a real Windhawk portable root used for compiler/runtime assets.", + ) + parser.add_argument( + "--include-live", + action="store_true", + help="Also run live lifecycle checks against the auto-detected real Windhawk runtime.", + ) + parser.add_argument( + "--keep-sandboxes", + action="store_true", + help="Keep temporary sandboxes after the run.", + ) + return parser.parse_args() + + +@dataclass +class CommandResult: + returncode: int + payload: dict[str, Any] | None + stderr: str + + +class RegressionFailure(RuntimeError): + pass + + +class RegressionHarness: + def __init__(self, helper: Path, real_root: Path, include_live: bool, keep_sandboxes: bool) -> None: + self.helper = helper + self.real_root = real_root + self.include_live = include_live + self.keep_sandboxes = keep_sandboxes + self.results: list[dict[str, Any]] = [] + self.temp_dir = Path(tempfile.mkdtemp(prefix="windhawk-control-regression-")) + self.base = self.temp_dir / "base" + self.mismatch = self.temp_dir / "mismatch" + self.no_compiler = self.temp_dir / "no-compiler" + + def run(self) -> int: + try: + self._validate_inputs() + self._create_sandboxes() + self._run_sandbox_suite() + if self.include_live: + self._run_live_suite() + except Exception as exc: + self._record_failure("regression-suite", str(exc)) + print( + json.dumps( + { + "ok": False, + "helper": str(self.helper), + "real_root": str(self.real_root), + "results": self.results, + "sandbox_dir": str(self.temp_dir), + }, + indent=2, + ) + ) + return 1 + finally: + if not self.keep_sandboxes and not any(not result["ok"] for result in self.results): + self._cleanup() + + print( + json.dumps( + { + "ok": True, + "helper": str(self.helper), + "real_root": str(self.real_root), + "include_live": self.include_live, + "results": self.results, + "sandbox_dir": None if not self.keep_sandboxes else str(self.temp_dir), + }, + indent=2, + ) + ) + return 0 + + def _validate_inputs(self) -> None: + if not self.helper.exists(): + raise RegressionFailure(f"Helper not found: {self.helper}") + if not self.real_root.exists(): + raise RegressionFailure(f"Real Windhawk root not found: {self.real_root}") + for path in [ + self.real_root / "windhawk.exe", + self.real_root / "windhawk-x64-helper.exe", + self.real_root / "command-line.txt", + self.real_root / "Compiler", + self.real_root / "UI", + self.real_root / "Engine" / "1.7.3" / "32" / "windhawk.lib", + self.real_root / "Engine" / "1.7.3" / "64" / "windhawk.lib", + self.real_root / "Engine" / "1.7.3" / "arm64" / "windhawk.lib", + ]: + if not path.exists(): + raise RegressionFailure(f"Missing required real-root asset: {path}") + + def _create_sandboxes(self) -> None: + for root in [self.base, self.mismatch, self.no_compiler]: + self._build_sandbox(root) + + (self.mismatch / "Engine" / "1.7.3" / "engine.ini").write_text( + "[Storage]\nPortable=1\nAppDataPath=..\\..\\Data\\WrongEngine\n", + encoding="utf-8", + ) + (self.no_compiler / "windhawk.ini").write_text( + "[Storage]\n" + "Portable=1\n" + "CompilerPath=C:\\definitely-missing-windhawk-compiler\n" + "EnginePath=Engine\\1.7.3\n" + f"UIPath={self.real_root / 'UI'}\n" + "AppDataPath=Data\n", + encoding="utf-8", + ) + + def _build_sandbox(self, root: Path) -> None: + for rel in [ + Path("Engine/1.7.3/32"), + Path("Engine/1.7.3/64"), + Path("Engine/1.7.3/arm64"), + Path("Data/Engine/Mods/32"), + Path("Data/Engine/Mods/64"), + Path("Data/Engine/Mods/arm64"), + ]: + (root / rel).mkdir(parents=True, exist_ok=True) + + for name in ["windhawk.exe", "windhawk-x64-helper.exe", "command-line.txt"]: + shutil.copy2(self.real_root / name, root / name) + for arch in ["32", "64", "arm64"]: + shutil.copy2( + self.real_root / "Engine" / "1.7.3" / arch / "windhawk.lib", + root / "Engine" / "1.7.3" / arch / "windhawk.lib", + ) + + (root / "windhawk.ini").write_text( + "[Storage]\n" + "Portable=1\n" + f"CompilerPath={self.real_root / 'Compiler'}\n" + "EnginePath=Engine\\1.7.3\n" + f"UIPath={self.real_root / 'UI'}\n" + "AppDataPath=Data\n", + encoding="utf-8", + ) + (root / "Engine" / "1.7.3" / "engine.ini").write_text( + "[Storage]\nPortable=1\nAppDataPath=..\\..\\Data\\Engine\n", + encoding="utf-8", + ) + + def _run_sandbox_suite(self) -> None: + status = self._run_helper("baseline-status", self.base, "status") + self._expect(status.returncode == 0, "baseline status failed") + self._expect(status.payload["runtime"]["storage_mismatch"] is False, "baseline sandbox unexpectedly mismatched") + + valid_init = self._run_helper( + "positive-init", + self.base, + "init-mod", + "--mod-id", + "control-ok", + "--name", + "Control Ok", + "--description", + "Positive control mod", + "--include", + "notepad.exe", + "--sync-workspace", + "--force", + ) + self._expect(valid_init.returncode == 0, "positive init failed") + + workspace = self.base / "Data" / "EditorWorkspace" / "mod.wh.cpp" + workspace.write_text(workspace.read_text(encoding="utf-8") + "\n// positive-control\n", encoding="utf-8") + valid_compile = self._run_helper( + "positive-compile", + self.base, + "compile", + "--mod-id", + "control-ok", + "--from-workspace", + "--disabled", + "--enable-logging", + ) + self._expect(valid_compile.returncode == 0, "positive compile failed") + self._expect(valid_compile.payload["config"]["Disabled"] is True, "positive compile disabled flag mismatch") + self._expect(valid_compile.payload["config"]["LoggingEnabled"] is True, "positive compile logging flag mismatch") + + logs = self._run_helper("empty-logs", self.base, "logs", "--kind", "main", "--lines", "5") + self._expect(logs.returncode == 0, "logs failed") + self._expect(logs.payload["files"] == [], "fresh sandbox logs should be empty") + + delete_missing = self._run_helper("delete-missing-mod", self.base, "delete-mod", "--mod-id", "missing-mod") + self._expect(delete_missing.returncode == 0, "delete missing failed") + + missing_status = self._run_helper("status-missing-mod", self.base, "status", "--mod-id", "not-installed-anywhere") + self._expect(missing_status.returncode == 0, "missing mod status failed") + self._expect(missing_status.payload["mod"]["config"] is None, "missing mod config should be null") + + invalid_id = self._run_helper("invalid-mod-id", self.base, "init-mod", "--mod-id", "BadMod", "--name", "BadMod") + self._expect(invalid_id.returncode != 0, "invalid id should fail") + self._expect("lowercase letters" in invalid_id.payload["error"], "invalid id error mismatch") + + workspace_init = self._run_helper( + "workspace-id-mismatch-init", + self.base, + "init-mod", + "--mod-id", + "workspace-one", + "--name", + "Workspace One", + "--sync-workspace", + "--force", + ) + self._expect(workspace_init.returncode == 0, "workspace init failed") + text = workspace.read_text(encoding="utf-8") + workspace.write_text(text.replace("@id workspace-one", "@id workspace-two"), encoding="utf-8") + workspace_sync_fail = self._run_helper( + "workspace-id-mismatch", + self.base, + "sync-workspace", + "--mod-id", + "workspace-one", + "--direction", + "from-workspace", + ) + self._expect(workspace_sync_fail.returncode != 0, "workspace id mismatch should fail") + self._expect("contains mod id workspace-two" in workspace_sync_fail.payload["error"], "workspace mismatch error mismatch") + + (self.base / "Data" / "ModsSource" / "missing-meta.wh.cpp").write_text("int main() { return 0; }\n", encoding="utf-8") + missing_meta = self._run_helper("missing-metadata", self.base, "compile", "--mod-id", "missing-meta") + self._expect(missing_meta.returncode != 0, "missing metadata should fail") + self._expect("Couldn't find a metadata block" in missing_meta.payload["error"], "missing metadata error mismatch") + + bad_settings_init = self._run_helper( + "malformed-settings-init", + self.base, + "init-mod", + "--mod-id", + "bad-settings", + "--name", + "Bad Settings", + "--force", + ) + self._expect(bad_settings_init.returncode == 0, "bad settings init failed") + bad_settings_path = self.base / "Data" / "ModsSource" / "bad-settings.wh.cpp" + bad_settings_text = bad_settings_path.read_text(encoding="utf-8") + bad_settings_text = bad_settings_text.replace( + "- enabled: true\n $name: Enabled\n $description: Disable this if you want the hook to short-circuit without uninstalling the mod.\n", + "- enabled: [\n", + ) + bad_settings_path.write_text(bad_settings_text, encoding="utf-8") + bad_settings = self._run_helper("malformed-settings-yaml", self.base, "compile", "--mod-id", "bad-settings") + self._expect(bad_settings.returncode != 0, "malformed settings should fail") + self._expect("Failed to parse settings:" in bad_settings.payload["error"], "malformed settings error mismatch") + self._expect(bad_settings.stderr == "", "malformed settings should not print tracebacks") + + bad_arch_init = self._run_helper( + "unsupported-architecture-init", + self.base, + "init-mod", + "--mod-id", + "bad-arch", + "--name", + "Bad Arch", + "--force", + ) + self._expect(bad_arch_init.returncode == 0, "bad arch init failed") + bad_arch_path = self.base / "Data" / "ModsSource" / "bad-arch.wh.cpp" + bad_arch_text = bad_arch_path.read_text(encoding="utf-8").replace("// ==/WindhawkMod==", "// @architecture armv7\n// ==/WindhawkMod==") + bad_arch_path.write_text(bad_arch_text, encoding="utf-8") + bad_arch = self._run_helper("unsupported-architecture", self.base, "compile", "--mod-id", "bad-arch") + self._expect(bad_arch.returncode != 0, "unsupported architecture should fail") + self._expect("Unsupported architecture armv7" in bad_arch.payload["error"], "unsupported architecture error mismatch") + + syntax_init = self._run_helper( + "compile-syntax-error-init", + self.base, + "init-mod", + "--mod-id", + "syntax-bomb", + "--name", + "Syntax Bomb", + "--force", + ) + self._expect(syntax_init.returncode == 0, "syntax init failed") + syntax_path = self.base / "Data" / "ModsSource" / "syntax-bomb.wh.cpp" + syntax_path.write_text(syntax_path.read_text(encoding="utf-8") + "\nthis is not valid c++\n", encoding="utf-8") + syntax_fail = self._run_helper("compile-syntax-error", self.base, "compile", "--mod-id", "syntax-bomb") + self._expect(syntax_fail.returncode != 0, "syntax compile should fail") + self._expect(bool(syntax_fail.payload["stderr"]), "syntax compile should include stderr") + + no_compiler_init = self._run_helper( + "missing-compiler-init", + self.no_compiler, + "init-mod", + "--mod-id", + "no-compiler", + "--name", + "No Compiler", + "--force", + ) + self._expect(no_compiler_init.returncode == 0, "missing compiler init failed") + no_compiler = self._run_helper("missing-compiler-path", self.no_compiler, "compile", "--mod-id", "no-compiler") + self._expect(no_compiler.returncode != 0, "missing compiler should fail") + self._expect("Missing compiler dependency" in no_compiler.payload["error"], "missing compiler error mismatch") + + mismatch_status = self._run_helper("storage-mismatch-detection", self.mismatch, "status") + self._expect(mismatch_status.returncode == 0, "mismatch status failed") + self._expect(mismatch_status.payload["runtime"]["storage_mismatch"] is True, "mismatch should be flagged") + self._expect(len(mismatch_status.payload["runtime"]["storage_notes"]) > 0, "mismatch notes should be present") + + missing_enable = self._run_helper("enable-missing-mod", self.base, "enable", "--mod-id", "not-installed-anywhere") + self._expect(missing_enable.returncode != 0, "enable on missing mod should fail") + self._expect("Mod is not installed" in missing_enable.payload["error"], "missing enable error mismatch") + + cleanup = self._run_helper("cleanup-control-mod", self.base, "delete-mod", "--mod-id", "control-ok") + self._expect(cleanup.returncode == 0, "positive control cleanup failed") + + def _run_live_suite(self) -> None: + detect = self._run_helper("live-detect", None, "detect") + self._expect(detect.returncode == 0, "live detect failed") + + status_before = self._run_helper("live-status-before", None, "status") + self._expect(status_before.returncode == 0, "live status before failed") + + launched = False + try: + launch = self._run_helper("live-launch", None, "launch", "--tray-only") + self._expect(launch.returncode == 0, "live launch failed") + launched = True + + restart = self._run_helper("live-restart", None, "restart", "--tray-only") + self._expect(restart.returncode == 0, "live restart failed") + + status_running = self._run_helper("live-status-running", None, "status") + self._expect(status_running.returncode == 0, "live running status failed") + self._expect(status_running.payload["running"] is True, "Windhawk should be running during live suite") + finally: + if launched: + self._run_helper("live-exit", None, "exit", "--wait") + + status_after = self._run_helper("live-status-after", None, "status") + self._expect(status_after.returncode == 0, "live status after failed") + self._expect(status_after.payload["running"] is False, "Windhawk should be stopped after live suite") + + def _run_helper(self, name: str, root: Path | None, *args: str) -> CommandResult: + command = [sys.executable, str(self.helper)] + if root is not None: + command.extend(["--root", str(root)]) + command.extend(["--json", *args]) + + proc = subprocess.run(command, capture_output=True, text=True, timeout=180) + payload = json.loads(proc.stdout) if proc.stdout.strip() else None + result = CommandResult(proc.returncode, payload, proc.stderr.strip()) + self.results.append( + { + "name": name, + "ok": proc.returncode == 0, + "args": list(args) if root is None else [str(root), *args], + "returncode": proc.returncode, + "payload": payload, + "stderr": result.stderr, + } + ) + return result + + def _expect(self, condition: bool, message: str) -> None: + if not condition: + raise RegressionFailure(message) + self.results[-1]["ok"] = True + + def _record_failure(self, name: str, error: str) -> None: + self.results.append({"name": name, "ok": False, "error": error}) + + def _cleanup(self) -> None: + def onerror(func, path, _exc_info): + os.chmod(path, stat.S_IWRITE) + func(path) + + shutil.rmtree(self.temp_dir, onerror=onerror, ignore_errors=False) + + +def main() -> int: + args = parse_args() + harness = RegressionHarness( + helper=Path(args.helper).resolve(), + real_root=Path(args.real_root).resolve(), + include_live=args.include_live, + keep_sandboxes=args.keep_sandboxes, + ) + return harness.run() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/windhawk_tool.py b/scripts/windhawk_tool.py new file mode 100644 index 0000000..ec82f06 --- /dev/null +++ b/scripts/windhawk_tool.py @@ -0,0 +1,1509 @@ +from __future__ import annotations + +import argparse +import configparser +import json +import os +import random +import re +import shutil +import subprocess +import sys +import time +import winreg +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml + + +DEFAULT_TEMPLATE = """// ==WindhawkMod== +// @id {mod_id} +// @name {name} +// @description {description} +// @version {version} +// @author {author} +{github_line}{homepage_line}{include_lines}{compiler_options_line}{license_line}// ==/WindhawkMod== + +// ==WindhawkModReadme== +/* +# {name} +{description} + +Use the bundled Windhawk workflow to iterate on this mod: +- Edit `mod.wh.cpp` in `Data\\EditorWorkspace` or change the source in `Data\\ModsSource`. +- Compile it with `windhawk_tool.py compile --mod-id {mod_id}`. +- Restart Windhawk or the target process to validate the change. +*/ +// ==/WindhawkModReadme== + +// ==WindhawkModSettings== +/* +- enabled: true + $name: Enabled + $description: Disable this if you want the hook to short-circuit without uninstalling the mod. +*/ +// ==/WindhawkModSettings== + +#include + +BOOL Wh_ModInit() {{ + return TRUE; +}} + +void Wh_ModAfterInit() {{ +}} + +void Wh_ModBeforeUninit() {{ +}} + +void Wh_ModUninit() {{ +}} + +void Wh_ModSettingsChanged() {{ +}} +""" + +WORKSPACE_COMPILE_FLAGS = [ + "-x", + "c++", + "-std=c++23", + "-target", + "x86_64-w64-mingw32", + "-DUNICODE", + "-D_UNICODE", + "-DWINVER=0x0A00", + "-D_WIN32_WINNT=0x0A00", + "-D_WIN32_IE=0x0A00", + "-DNTDDI_VERSION=0x0A000008", + "-D__USE_MINGW_ANSI_STDIO=0", + "-DWH_MOD", + "-DWH_EDITING", + "-include", + "windhawk_api.h", + "-Wall", + "-Wextra", + "-Wno-unused-parameter", + "-Wno-missing-field-initializers", + "-Wno-cast-function-type-mismatch", +] + +DEFAULT_CLANG_FORMAT = [ + "# To override, create a .clang-format.windhawk file with the desired settings.", + "BasedOnStyle: Chromium", + "IndentWidth: 4", + "CommentPragmas: ^[ \\t]+@[a-zA-Z]+", +] + +COMMON_SYSTEM_MOD_TARGETS = { + "startmenuexperiencehost.exe", + "searchhost.exe", + "explorer.exe", + "shellexperiencehost.exe", + "shellhost.exe", + "dwm.exe", + "notepad.exe", + "regedit.exe", +} + +WINDOWS_VERSION_FLAG_EXCEPTIONS = { + ("classic-taskdlg-fix", "1.1.0"), +} + +BACKWARD_COMPATIBILITY_FLAGS = { + ("chrome-ui-tweaks", "1.0.0"): ["-include", "atomic", "-include", "optional"], + ("sib-plusplus-tweaker", "0.7.1"): ["-include", "atomic"], + ("classic-explorer-treeview", "1.1.3"): ["-include", "cmath"], + ("sysdm-general-tab", "1.1"): ["-include", "cmath"], + ("ce-disable-process-button-flashing", "1.0.1"): ["-include", "vector"], + ("windows-7-clock-spacing", "1.0.0"): ["-include", "vector"], +} + +METADATA_SINGLE_VALUE = { + "id", + "version", + "github", + "twitter", + "homepage", + "compilerOptions", + "license", + "donateUrl", +} +METADATA_LOCALIZABLE_SINGLE_VALUE = {"name", "description", "author"} +METADATA_MULTI_VALUE = {"include", "exclude", "architecture"} +SUPPORTED_ARCHITECTURES = {"x86", "x86-64", "amd64", "arm64"} +INT_PATTERN = re.compile(r"^-?\d+$") + + +class WindhawkError(RuntimeError): + pass + + +class CompileError(WindhawkError): + def __init__(self, target: str, result: subprocess.CompletedProcess[str]): + exit_code = result.returncode + message = "Compilation failed" + if exit_code == 1: + message += ", the mod might require a newer Windhawk version" + if target == "aarch64-w64-mingw32": + message += ", or the mod may not support ARM64 yet" + elif exit_code == 0xC0000135: + message += ", some files are missing; reinstall Windhawk or check antivirus exclusions" + else: + message += f", error code: 0x{exit_code & 0xFFFFFFFF:08X}" + super().__init__(message) + self.target = target + self.exit_code = exit_code + self.stdout = result.stdout + self.stderr = result.stderr + + +@dataclass +class RegistryRef: + root_name: str + subkey: str + + @property + def root(self) -> int: + roots = { + "HKLM": winreg.HKEY_LOCAL_MACHINE, + "HKCU": winreg.HKEY_CURRENT_USER, + "HKCR": winreg.HKEY_CLASSES_ROOT, + } + return roots[self.root_name] + + def with_suffix(self, suffix: str) -> "RegistryRef": + suffix = suffix.lstrip("\\") + return RegistryRef(self.root_name, f"{self.subkey}\\{suffix}" if suffix else self.subkey) + + def as_string(self) -> str: + return f"{self.root_name}\\{self.subkey}" + + +@dataclass +class WindhawkRuntime: + root: Path + portable: bool + exe_path: Path + ini_path: Path + compiler_path: Path + engine_path: Path + ui_path: Path + app_data_path: Path + engine_app_data_path: Path + app_registry_ref: RegistryRef | None + engine_registry_ref: RegistryRef | None + mods_source_path: Path + editor_workspace_path: Path + drafts_path: Path + engine_mods_path: Path + engine_mods_writable_path: Path + ui_logs_path: Path + arm64_enabled: bool + storage_notes: list[str] + + @property + def storage_mismatch(self) -> bool: + return bool(self.storage_notes) + + @property + def mod_registry_ref(self) -> RegistryRef | None: + if self.engine_registry_ref: + return self.engine_registry_ref.with_suffix("Mods") + if self.app_registry_ref: + return self.app_registry_ref.with_suffix("Engine\\Mods") + return None + + @property + def mod_registry_writable_ref(self) -> RegistryRef | None: + if self.engine_registry_ref: + return self.engine_registry_ref.with_suffix("ModsWritable") + if self.app_registry_ref: + return self.app_registry_ref.with_suffix("Engine\\ModsWritable") + return None + + def to_dict(self) -> dict[str, Any]: + return { + "root": str(self.root), + "portable": self.portable, + "exe_path": str(self.exe_path), + "compiler_path": str(self.compiler_path), + "engine_path": str(self.engine_path), + "ui_path": str(self.ui_path), + "app_data_path": str(self.app_data_path), + "engine_app_data_path": str(self.engine_app_data_path), + "mods_source_path": str(self.mods_source_path), + "editor_workspace_path": str(self.editor_workspace_path), + "engine_mods_path": str(self.engine_mods_path), + "ui_logs_path": str(self.ui_logs_path), + "arm64_enabled": self.arm64_enabled, + "storage_mismatch": self.storage_mismatch, + "storage_notes": self.storage_notes, + "app_registry_key": self.app_registry_ref.as_string() if self.app_registry_ref else None, + "engine_registry_key": self.engine_registry_ref.as_string() if self.engine_registry_ref else None, + } + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Control a local Windhawk install and automate the mod dev loop.") + parser.add_argument("--root", help="Optional Windhawk root. Overrides auto-detection.") + parser.add_argument("--json", action="store_true", help="Emit JSON for machine-readable output.") + + subparsers = parser.add_subparsers(dest="command", required=True) + + detect = subparsers.add_parser("detect", help="Detect the active Windhawk runtime.") + detect.add_argument("--all", action="store_true", help="List every detected install instead of only the preferred one.") + + status = subparsers.add_parser("status", help="Show runtime status, source mods, and installed mods.") + status.add_argument("--mod-id", help="Optional mod id to inspect in detail.") + + launch = subparsers.add_parser("launch", help="Launch Windhawk.") + launch.add_argument("--tray-only", action="store_true") + launch.add_argument("--safe-mode", action="store_true") + launch.add_argument("--wait", action="store_true") + + restart = subparsers.add_parser("restart", help="Restart Windhawk.") + restart.add_argument("--tray-only", action="store_true") + restart.add_argument("--wait", action="store_true") + + exit_cmd = subparsers.add_parser("exit", help="Exit Windhawk.") + exit_cmd.add_argument("--wait", action="store_true") + + init_mod = subparsers.add_parser("init-mod", help="Create a new .wh.cpp mod source in ModsSource.") + init_mod.add_argument("--mod-id", required=True) + init_mod.add_argument("--name", required=True) + init_mod.add_argument("--description", default="A new Windhawk mod.") + init_mod.add_argument("--author", default=os.environ.get("USERNAME", "You")) + init_mod.add_argument("--version", default="0.1.0") + init_mod.add_argument("--include", action="append", default=[]) + init_mod.add_argument("--github") + init_mod.add_argument("--homepage") + init_mod.add_argument("--compiler-options") + init_mod.add_argument("--license", default="MIT") + init_mod.add_argument("--force", action="store_true") + init_mod.add_argument("--sync-workspace", action="store_true") + + sync = subparsers.add_parser("sync-workspace", help="Copy a mod between ModsSource and EditorWorkspace.") + sync.add_argument("--mod-id", required=True) + sync.add_argument("--direction", choices=["to-workspace", "from-workspace"], default="to-workspace") + sync.add_argument("--force", action="store_true") + + compile_mod = subparsers.add_parser("compile", help="Compile a mod with Windhawk's bundled toolchain and update its config.") + compile_mod.add_argument("--mod-id", required=True) + compile_mod.add_argument("--from-workspace", action="store_true", help="Compile Data\\EditorWorkspace\\mod.wh.cpp and sync it back to ModsSource first.") + compile_mod.add_argument("--disabled", action="store_true", help="Install the compiled mod disabled.") + compile_mod.add_argument("--enable-logging", action="store_true", help="Enable Windhawk logging for the mod after compile.") + compile_mod.add_argument("--restart", action="store_true", help="Restart Windhawk after a successful compile.") + compile_mod.add_argument("--tray-only", action="store_true", help="Use -tray-only with --restart.") + + enable = subparsers.add_parser("enable", help="Enable an installed mod.") + enable.add_argument("--mod-id", required=True) + enable.add_argument("--restart", action="store_true") + enable.add_argument("--tray-only", action="store_true") + + disable = subparsers.add_parser("disable", help="Disable an installed mod.") + disable.add_argument("--mod-id", required=True) + disable.add_argument("--restart", action="store_true") + disable.add_argument("--tray-only", action="store_true") + + logging_cmd = subparsers.add_parser("logging", help="Toggle Windhawk logging for a mod.") + logging_cmd.add_argument("--mod-id", required=True) + logging_cmd.add_argument("--state", choices=["on", "off"], required=True) + + delete_mod = subparsers.add_parser("delete-mod", help="Remove a mod's source, config, and compiled binaries.") + delete_mod.add_argument("--mod-id", required=True) + delete_mod.add_argument("--keep-source", action="store_true") + + logs = subparsers.add_parser("logs", help="Show recent Windhawk UI logs from the latest log session.") + logs.add_argument("--kind", choices=["main", "all"], default="main") + logs.add_argument("--lines", type=int, default=80) + logs.add_argument("--contains", help="Only show lines containing this substring.") + + return parser.parse_args() + + +def parse_ini(path: Path) -> configparser.ConfigParser: + parser = configparser.ConfigParser(interpolation=None) + parser.optionxform = str + + if not path.exists(): + return parser + + raw = path.read_bytes() + last_error: UnicodeDecodeError | None = None + for encoding in ("utf-8-sig", "utf-16", "utf-16-le", "utf-16-be", "mbcs"): + try: + parser.read_string(raw.decode(encoding)) + return parser + except UnicodeDecodeError as exc: + last_error = exc + continue + if last_error: + raise last_error + return parser + + +def write_ini(path: Path, parser: configparser.ConfigParser) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8", newline="\n") as handle: + parser.write(handle, space_around_delimiters=False) + + +def resolve_config_path(base: Path, raw_value: str | None, default: str) -> Path: + value = raw_value or default + expanded = Path(os.path.expandvars(value)) + if expanded.is_absolute(): + return expanded.resolve() + return (base / expanded).resolve() + + +def normalize_path(value: Path) -> str: + return str(value.resolve()).lower() + + +def parse_registry_ref(value: str | None) -> RegistryRef | None: + if not value: + return None + match = re.match(r"^(HKLM|HKEY_LOCAL_MACHINE|HKCU|HKEY_CURRENT_USER|HKCR|HKEY_CLASSES_ROOT)\\(.+)$", value, re.IGNORECASE) + if not match: + raise WindhawkError(f"Unsupported registry key format: {value}") + root = match.group(1).upper() + root_map = { + "HKEY_LOCAL_MACHINE": "HKLM", + "HKEY_CURRENT_USER": "HKCU", + "HKEY_CLASSES_ROOT": "HKCR", + } + return RegistryRef(root_map.get(root, root), match.group(2)) + + +def load_runtime(root: Path) -> WindhawkRuntime: + root = root.resolve() + ini_path = root / "windhawk.ini" + exe_path = root / "windhawk.exe" + if not ini_path.exists() or not exe_path.exists(): + raise WindhawkError(f"{root} is not a Windhawk root") + + parser = parse_ini(ini_path) + storage = parser["Storage"] + portable = storage.get("Portable", "0").strip() == "1" + + compiler_path = resolve_config_path(root, storage.get("CompilerPath"), "Compiler") + engine_path = resolve_config_path(root, storage.get("EnginePath"), "Engine") + ui_path = resolve_config_path(root, storage.get("UIPath"), "UI") + app_data_path = resolve_config_path(root, storage.get("AppDataPath"), "Data") + app_registry_ref = parse_registry_ref(storage.get("RegistryKey")) + + engine_ini_path = engine_path / "engine.ini" + engine_app_data_path = (app_data_path / "Engine").resolve() + engine_registry_ref: RegistryRef | None = None + storage_notes: list[str] = [] + + if engine_ini_path.exists(): + engine_parser = parse_ini(engine_ini_path) + if engine_parser.has_section("Storage"): + engine_storage = engine_parser["Storage"] + engine_portable = engine_storage.get("Portable", "0").strip() == "1" + engine_app_data_path = resolve_config_path(engine_path, engine_storage.get("AppDataPath"), "..\\..\\Data\\Engine") + engine_registry_ref = parse_registry_ref(engine_storage.get("RegistryKey")) + + if engine_portable != portable: + storage_notes.append( + f"windhawk.ini portable={int(portable)} but engine.ini portable={int(engine_portable)}" + ) + if normalize_path(engine_app_data_path) != normalize_path(app_data_path / "Engine"): + storage_notes.append( + f"engine.ini app-data path is {engine_app_data_path} but windhawk.ini implies {(app_data_path / 'Engine').resolve()}" + ) + if app_registry_ref and engine_registry_ref: + expected_engine_key = app_registry_ref.with_suffix("Engine").as_string().lower() + actual_engine_key = engine_registry_ref.as_string().lower() + if expected_engine_key != actual_engine_key: + storage_notes.append( + f"engine.ini registry key is {engine_registry_ref.as_string()} but windhawk.ini implies {app_registry_ref.with_suffix('Engine').as_string()}" + ) + + engine_mods_path = (engine_app_data_path / "Mods").resolve() + engine_mods_writable_path = (engine_app_data_path / "ModsWritable").resolve() + arm64_enabled = (compiler_path / "aarch64-w64-mingw32" / "bin" / "libc++.dll").exists() or (engine_mods_path / "arm64").exists() + + return WindhawkRuntime( + root=root, + portable=portable, + exe_path=exe_path, + ini_path=ini_path, + compiler_path=compiler_path, + engine_path=engine_path, + ui_path=ui_path, + app_data_path=app_data_path, + engine_app_data_path=engine_app_data_path, + app_registry_ref=app_registry_ref, + engine_registry_ref=engine_registry_ref, + mods_source_path=(app_data_path / "ModsSource").resolve(), + editor_workspace_path=(app_data_path / "EditorWorkspace").resolve(), + drafts_path=(app_data_path / "EditorWorkspace" / "Drafts").resolve(), + engine_mods_path=engine_mods_path, + engine_mods_writable_path=engine_mods_writable_path, + ui_logs_path=(app_data_path / "UIData" / "user-data" / "logs").resolve(), + arm64_enabled=arm64_enabled, + storage_notes=storage_notes, + ) + + +def candidate_roots(root_hint: str | None) -> list[Path]: + ordered: list[Path] = [] + + def add(candidate: Path | None) -> None: + if not candidate: + return + candidate = candidate.resolve() + if candidate not in ordered: + ordered.append(candidate) + + if root_hint: + candidate = Path(root_hint) + if candidate.name.lower() == "windhawk.exe": + candidate = candidate.parent + add(candidate) + + env_root = os.environ.get("WINDHAWK_ROOT") + if env_root: + add(Path(env_root)) + + local_app_data = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) + local_programs = local_app_data / "Programs" + if local_programs.exists(): + for pattern in ("Windhawk*Portable", "Windhawk*"): + for candidate in sorted(local_programs.glob(pattern)): + if candidate.is_dir(): + add(candidate) + + add(Path(r"C:\Program Files\Windhawk")) + return ordered + + +def detect_runtimes(root_hint: str | None) -> list[WindhawkRuntime]: + runtimes: list[WindhawkRuntime] = [] + for candidate in candidate_roots(root_hint): + try: + runtimes.append(load_runtime(candidate)) + except WindhawkError: + continue + if not runtimes: + raise WindhawkError("No Windhawk install was detected. Pass --root or set WINDHAWK_ROOT.") + return runtimes + + +def resolve_runtime(root_hint: str | None) -> WindhawkRuntime: + runtimes = detect_runtimes(root_hint) + if root_hint: + return runtimes[0] + portable = [runtime for runtime in runtimes if runtime.portable and runtime.app_data_path.exists()] + if portable: + return portable[0] + return runtimes[0] + + +def is_windhawk_running() -> bool: + result = subprocess.run( + ["tasklist", "/FI", "IMAGENAME eq windhawk.exe", "/FO", "CSV", "/NH"], + capture_output=True, + text=True, + check=False, + ) + return "windhawk.exe" in result.stdout.lower() + + +def splitargs(value: str) -> list[str]: + single_quote_open = False + double_quote_open = False + token_buffer: list[str] = [] + tokens: list[str] = [] + + for char in value: + if char == "'" and not double_quote_open: + single_quote_open = not single_quote_open + continue + if char == '"' and not single_quote_open: + double_quote_open = not double_quote_open + continue + if char.isspace() and not single_quote_open and not double_quote_open: + if token_buffer: + tokens.append("".join(token_buffer)) + token_buffer = [] + continue + token_buffer.append(char) + + if token_buffer: + tokens.append("".join(token_buffer)) + return tokens + + +def get_best_language_match(match_language: str, candidates: list[dict[str, str | None]]) -> dict[str, str | None]: + languages = [(candidate["language"] or "").lower() or None for candidate in candidates] + iter_language = match_language.lower() + + while True: + if iter_language in languages: + return candidates[languages.index(iter_language)] + for index, language in enumerate(languages): + if language and language.startswith(iter_language): + return candidates[index] + if "-" not in iter_language: + break + iter_language = iter_language.rsplit("-", 1)[0] + + if None in languages: + return candidates[languages.index(None)] + return candidates[0] + + +def extract_metadata_raw(mod_source: str) -> dict[str, list[dict[str, str | None]]]: + block_match = re.search( + r"^//[ \t]+==WindhawkMod==[ \t]*$([\s\S]+?)^//[ \t]+==/WindhawkMod==[ \t]*$", + mod_source, + re.MULTILINE, + ) + if not block_match: + raise WindhawkError("Couldn't find a metadata block in the source code") + + result: dict[str, list[dict[str, str | None]]] = {} + for line in block_match.group(1).splitlines(): + line = line.rstrip() + if not line: + continue + match = re.match(r"^//[ \t]+@(_?[a-zA-Z]+)(?::([a-z]{2}(?:-[A-Z]{2})?))?[ \t]+(.*)$", line) + if not match: + truncated = line[:17] + "..." if len(line) > 20 else line + raise WindhawkError(f"Couldn't parse metadata line: {truncated}") + key = match.group(1) + value = {"language": match.group(2), "value": match.group(3)} + result.setdefault(key, []).append(value) + return result + + +def extract_metadata(mod_source: str, language: str = "en") -> dict[str, Any]: + metadata_raw = extract_metadata_raw(mod_source) + metadata: dict[str, Any] = {} + + for raw_key, entries in metadata_raw.items(): + key = raw_key.removeprefix("_") + if key in METADATA_LOCALIZABLE_SINGLE_VALUE: + seen_languages = set() + for entry in entries: + lang = entry["language"] + if lang in seen_languages: + raise WindhawkError(f"Duplicate metadata parameter: {key}" + (f":{lang}" if lang else "")) + seen_languages.add(lang) + metadata[key] = get_best_language_match(language, entries)["value"] + elif key in METADATA_MULTI_VALUE: + if any(entry["language"] is not None for entry in entries): + raise WindhawkError(f"Metadata parameter can't be localized: {key}") + metadata[key] = [entry["value"] for entry in entries] + elif key in METADATA_SINGLE_VALUE: + if any(entry["language"] is not None for entry in entries): + raise WindhawkError(f"Metadata parameter can't be localized: {key}") + if len(entries) > 1: + raise WindhawkError(f"Duplicate metadata parameter: {key}") + metadata[key] = entries[0]["value"] + elif raw_key.startswith("_"): + continue + else: + raise WindhawkError(f"Unsupported metadata parameter: {key}") + + mod_id = metadata.get("id") + if not mod_id: + raise WindhawkError("Mod id must be specified in the source code") + if not re.fullmatch(r"[0-9a-z-]+", mod_id): + raise WindhawkError("Mod id must only contain 0-9, a-z, and hyphens") + + for category in ("include", "exclude"): + for path_value in metadata.get(category, []) or []: + if re.search(r'[/"<>|]', path_value): + raise WindhawkError(f"Mod {category} path contains one of the forbidden characters: / \" < > |") + + for architecture in metadata.get("architecture", []) or []: + if architecture not in SUPPORTED_ARCHITECTURES: + raise WindhawkError( + f"Unsupported architecture {architecture}; expected one of {', '.join(sorted(SUPPORTED_ARCHITECTURES))}" + ) + + return metadata + + +def extract_initial_settings_for_engine(mod_source: str) -> dict[str, str | int] | None: + match = re.search( + r"^//[ \t]+==WindhawkModSettings==[ \t]*$\s*/\*\s*([\s\S]+?)\s*\*/\s*^//[ \t]+==/WindhawkModSettings==[ \t]*$", + mod_source, + re.MULTILINE, + ) + if not match: + return None + + try: + settings = yaml.safe_load(match.group(1)) + except yaml.YAMLError as exc: + raise WindhawkError(f"Failed to parse settings: {exc}") from exc + if not isinstance(settings, list): + raise WindhawkError("Failed to parse settings: expected a YAML list") + + parsed: dict[str, str | int] = {} + + def parse_settings(items: list[Any], key_prefix: str = "") -> None: + for item in items: + if not isinstance(item, dict): + raise WindhawkError("Failed to parse settings: expected a YAML object") + actual_keys = [key for key in item if not str(key).startswith("$")] + if len(actual_keys) != 1: + raise WindhawkError("Each settings item must contain exactly one non-$ key") + actual_key = actual_keys[0] + next_key = f"{key_prefix}.{actual_key}" if key_prefix else str(actual_key) + parse_settings_value(item[actual_key], next_key) + + def parse_settings_value(value: Any, key: str) -> None: + if isinstance(value, bool): + parsed[key] = 1 if value else 0 + return + if isinstance(value, (int, float)): + parsed[key] = int(value) + return + if isinstance(value, str): + parsed[key] = value + return + if not isinstance(value, list) or not value: + raise WindhawkError(f"Unsupported settings structure at {key}") + + first = value[0] + if isinstance(first, (bool, int, float, str)): + for index, item in enumerate(value): + parse_settings_value(item, f"{key}[{index}]") + return + if isinstance(first, list): + for index, item in enumerate(value): + if not isinstance(item, list): + raise WindhawkError(f"Mixed settings array types at {key}") + parse_settings(item, f"{key}[{index}]") + return + parse_settings(value, key) + + parse_settings(settings) + return parsed + + +def ensure_workspace_initialized(runtime: WindhawkRuntime) -> None: + runtime.editor_workspace_path.mkdir(parents=True, exist_ok=True) + (runtime.editor_workspace_path / "compile_flags.txt").write_text("\n".join(WORKSPACE_COMPILE_FLAGS) + "\n", encoding="utf-8") + + override_clang = runtime.editor_workspace_path / ".clang-format.windhawk" + target_clang = runtime.editor_workspace_path / ".clang-format" + if override_clang.exists(): + shutil.copy2(override_clang, target_clang) + else: + target_clang.write_text("\n".join(DEFAULT_CLANG_FORMAT) + "\n", encoding="utf-8") + + old_api = runtime.editor_workspace_path / "windhawk_api.h" + if old_api.exists(): + old_api.unlink() + + try: + subprocess.run(["git", "init"], cwd=runtime.editor_workspace_path, capture_output=True, text=True, check=False) + subprocess.run(["git", "add", "mod.wh.cpp"], cwd=runtime.editor_workspace_path, capture_output=True, text=True, check=False) + except OSError: + pass + + +def read_mod_source(runtime: WindhawkRuntime, mod_id: str) -> str: + path = runtime.mods_source_path / f"{mod_id}.wh.cpp" + if not path.exists(): + raise WindhawkError(f"Mod source not found: {path}") + return path.read_text(encoding="utf-8") + + +def write_mod_source(runtime: WindhawkRuntime, mod_id: str, mod_source: str) -> Path: + path = runtime.mods_source_path / f"{mod_id}.wh.cpp" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(mod_source, encoding="utf-8") + return path + + +def delete_mod_source(runtime: WindhawkRuntime, mod_id: str) -> None: + path = runtime.mods_source_path / f"{mod_id}.wh.cpp" + if path.exists(): + path.unlink() + + +def sync_workspace(runtime: WindhawkRuntime, mod_id: str, direction: str, force: bool = False) -> dict[str, str]: + ensure_workspace_initialized(runtime) + source_path = runtime.mods_source_path / f"{mod_id}.wh.cpp" + workspace_path = runtime.editor_workspace_path / "mod.wh.cpp" + + if direction == "to-workspace": + if not source_path.exists(): + raise WindhawkError(f"Mod source not found: {source_path}") + if workspace_path.exists() and not force: + current = workspace_path.read_text(encoding="utf-8", errors="ignore") + if current and current != source_path.read_text(encoding="utf-8"): + raise WindhawkError("EditorWorkspace/mod.wh.cpp already has different contents; use --force if you want to overwrite it") + shutil.copy2(source_path, workspace_path) + return {"from": str(source_path), "to": str(workspace_path)} + + if not workspace_path.exists(): + raise WindhawkError(f"Workspace file not found: {workspace_path}") + metadata = extract_metadata(workspace_path.read_text(encoding="utf-8")) + workspace_mod_id = metadata["id"] + if workspace_mod_id != mod_id and not force: + raise WindhawkError( + f"EditorWorkspace/mod.wh.cpp contains mod id {workspace_mod_id}, not {mod_id}; use --force to sync it anyway" + ) + write_mod_source(runtime, mod_id, workspace_path.read_text(encoding="utf-8")) + return {"from": str(workspace_path), "to": str(source_path)} + + +def create_mod_source(args: argparse.Namespace) -> str: + mod_id = args.mod_id + if not re.fullmatch(r"[0-9a-z-]+", mod_id): + raise WindhawkError("Mod id must only contain 0-9, lowercase letters, and hyphens") + include_values = args.include or ["notepad.exe"] + include_lines = "".join(f"// @include {item}\n" for item in include_values) + github_line = f"// @github {args.github}\n" if args.github else "" + homepage_line = f"// @homepage {args.homepage}\n" if args.homepage else "" + compiler_options_line = f"// @compilerOptions {args.compiler_options}\n" if args.compiler_options else "" + license_line = f"// @license {args.license}\n" if args.license else "" + return DEFAULT_TEMPLATE.format( + mod_id=mod_id, + name=args.name, + description=args.description, + version=args.version, + author=args.author, + github_line=github_line, + homepage_line=homepage_line, + include_lines=include_lines, + compiler_options_line=compiler_options_line, + license_line=license_line, + ) + + +def subfolder_from_target(target: str) -> str: + return { + "i686-w64-mingw32": "32", + "x86_64-w64-mingw32": "64", + "aarch64-w64-mingw32": "arm64", + }[target] + + +def targets_from_architecture(runtime: WindhawkRuntime, architectures: list[str], mod_targets: list[str]) -> list[str]: + if not architectures: + architectures = ["x86", "x86-64"] + + targets: list[str] = [] + for architecture in architectures: + if architecture == "x86": + targets.append("i686-w64-mingw32") + elif architecture == "x86-64": + if runtime.arm64_enabled: + targets.append("aarch64-w64-mingw32") + if not mod_targets or not all(target.lower() in COMMON_SYSTEM_MOD_TARGETS for target in mod_targets): + targets.append("x86_64-w64-mingw32") + else: + targets.append("x86_64-w64-mingw32") + elif architecture == "amd64": + targets.append("x86_64-w64-mingw32") + elif architecture == "arm64": + if runtime.arm64_enabled: + targets.append("aarch64-w64-mingw32") + else: + raise WindhawkError(f"Unsupported architecture: {architecture}") + + if not targets: + raise WindhawkError("The current architecture is not supported") + return targets + + +def subfolders_from_architecture(runtime: WindhawkRuntime, architectures: list[str]) -> set[str]: + if not architectures: + architectures = ["x86", "x86-64"] + subfolders: set[str] = set() + for architecture in architectures: + if architecture == "x86": + subfolders.add("32") + elif architecture == "x86-64": + if runtime.arm64_enabled: + subfolders.update({"64", "arm64"}) + else: + subfolders.add("64") + elif architecture == "amd64": + subfolders.add("64") + elif architecture == "arm64" and runtime.arm64_enabled: + subfolders.add("arm64") + return subfolders + + +def copy_compiler_libs(runtime: WindhawkRuntime, target: str) -> None: + libs_dir = runtime.compiler_path / target / "bin" + target_mods_dir = runtime.engine_mods_path / subfolder_from_target(target) + target_mods_dir.mkdir(parents=True, exist_ok=True) + + files_to_copy = [ + ("libc++.dll", "libc++.whl"), + ("libunwind.dll", "libunwind.whl"), + ("windhawk-mod-shim.dll", "windhawk-mod-shim.dll"), + ] + + if (target_mods_dir / "libc++.dll").exists(): + files_to_copy.append(("libc++.dll", "libc++.dll")) + if (target_mods_dir / "libunwind.dll").exists(): + files_to_copy.append(("libunwind.dll", "libunwind.dll")) + + for source_name, dest_name in files_to_copy: + source_path = libs_dir / source_name + dest_path = target_mods_dir / dest_name + if not source_path.exists(): + raise WindhawkError(f"Missing compiler dependency: {source_path}") + if dest_path.exists() and dest_path.stat().st_mtime_ns == source_path.stat().st_mtime_ns: + continue + if dest_path.exists(): + try: + temp_path = dest_path.with_name(f"{dest_path.stem}_temp{random.randint(1, 9999)}{dest_path.suffix}") + dest_path.rename(temp_path) + except OSError: + pass + shutil.copy2(source_path, dest_path) + + +def generate_target_dll_name(runtime: WindhawkRuntime, mod_id: str, version: str, architectures: list[str], mod_targets: list[str]) -> str: + targets = targets_from_architecture(runtime, architectures, mod_targets) + for _ in range(1000): + candidate = f"{mod_id}_{version}_{random.randint(100000, 999999)}.dll" + if all(not (runtime.engine_mods_path / subfolder_from_target(target) / candidate).exists() for target in targets): + return candidate + raise WindhawkError("Failed to generate a unique target DLL name") + + +def compile_for_target(runtime: WindhawkRuntime, metadata: dict[str, Any], mod_source: str, target: str, target_dll_name: str) -> str: + compiler_options = splitargs(metadata.get("compilerOptions", "")) + mod_id = metadata["id"] + version = metadata.get("version", "") + + engine_lib_path = runtime.engine_path / subfolder_from_target(target) / "windhawk.lib" + compiled_dll_path = runtime.engine_mods_path / subfolder_from_target(target) / target_dll_name + compiled_dll_path.parent.mkdir(parents=True, exist_ok=True) + + windows_version_flags: list[str] = [] + if (mod_id, version) not in WINDOWS_VERSION_FLAG_EXCEPTIONS: + windows_version_flags = [ + "-DWINVER=0x0A00", + "-D_WIN32_WINNT=0x0A00", + "-D_WIN32_IE=0x0A00", + "-DNTDDI_VERSION=0x0A000008", + ] + + args = [ + str(runtime.compiler_path / "bin" / "clang++.exe"), + "-std=c++23", + "-O2", + "-shared", + "-DUNICODE", + "-D_UNICODE", + *windows_version_flags, + "-D__USE_MINGW_ANSI_STDIO=0", + "-DWH_MOD", + f'-DWH_MOD_ID=L"{mod_id.replace(chr(34), r"\\\"")}"', + f'-DWH_MOD_VERSION=L"{version.replace(chr(34), r"\\\"")}"', + str(engine_lib_path), + "-x", + "c++", + "-", + "-include", + "windhawk_api.h", + "-target", + target, + "-Wl,--export-all-symbols", + "-o", + str(compiled_dll_path), + *compiler_options, + *BACKWARD_COMPATIBILITY_FLAGS.get((mod_id, version), []), + ] + + result = subprocess.run( + args, + cwd=runtime.compiler_path, + input=mod_source, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + raise CompileError(target, result) + return str(compiled_dll_path) + + +def get_mod_ini_path(runtime: WindhawkRuntime, mod_id: str) -> Path: + return runtime.engine_mods_path / f"{mod_id}.ini" + + +def read_registry_values(ref: RegistryRef, mod_id: str) -> dict[str, Any] | None: + try: + key = winreg.OpenKey(ref.root, f"{ref.subkey}\\{mod_id}", 0, winreg.KEY_READ | winreg.KEY_WOW64_64KEY) + except FileNotFoundError: + return None + values: dict[str, Any] = {} + index = 0 + try: + while True: + name, value, _kind = winreg.EnumValue(key, index) + values[name] = value + index += 1 + except OSError: + pass + finally: + winreg.CloseKey(key) + return values + + +def write_registry_values(ref: RegistryRef, mod_id: str, fields: dict[str, Any]) -> None: + key = winreg.CreateKeyEx(ref.root, f"{ref.subkey}\\{mod_id}", 0, winreg.KEY_SET_VALUE | winreg.KEY_WOW64_64KEY) + try: + for name, value in fields.items(): + if isinstance(value, int): + winreg.SetValueEx(key, name, 0, winreg.REG_DWORD, value) + else: + winreg.SetValueEx(key, name, 0, winreg.REG_SZ, str(value)) + finally: + winreg.CloseKey(key) + + +def delete_registry_tree(ref: RegistryRef, relative_path: str) -> None: + try: + key = winreg.OpenKey(ref.root, f"{ref.subkey}\\{relative_path}", 0, winreg.KEY_READ | winreg.KEY_WRITE | winreg.KEY_WOW64_64KEY) + except FileNotFoundError: + return + try: + while True: + try: + child = winreg.EnumKey(key, 0) + except OSError: + break + delete_registry_tree(ref, f"{relative_path}\\{child}") + finally: + winreg.CloseKey(key) + try: + winreg.DeleteKeyEx(ref.root, f"{ref.subkey}\\{relative_path}", access=winreg.KEY_WOW64_64KEY) + except FileNotFoundError: + pass + + +def parse_ini_scalar(value: str) -> str | int: + return int(value) if INT_PATTERN.fullmatch(value) else value + + +def get_mod_config(runtime: WindhawkRuntime, mod_id: str) -> dict[str, Any] | None: + field_types = { + "LibraryFileName": "string", + "Disabled": "bool", + "LoggingEnabled": "bool", + "DebugLoggingEnabled": "bool", + "Include": "string-array", + "Exclude": "string-array", + "IncludeCustom": "string-array", + "ExcludeCustom": "string-array", + "IncludeExcludeCustomOnly": "bool", + "PatternsMatchCriticalSystemProcesses": "bool", + "Architecture": "string-array", + "Version": "string", + } + + raw_fields: dict[str, Any] | None + if runtime.portable: + parser = parse_ini(get_mod_ini_path(runtime, mod_id)) + if not parser.has_section("Mod"): + return None + raw_fields = dict(parser["Mod"]) + else: + ref = runtime.mod_registry_ref + if not ref: + return None + raw_fields = read_registry_values(ref, mod_id) + if not raw_fields or not raw_fields.get("LibraryFileName"): + return None + + config: dict[str, Any] = {} + for field, field_type in field_types.items(): + raw_value = raw_fields.get(field, "") + if field_type == "string": + config[field] = str(raw_value) + elif field_type == "bool": + config[field] = bool(int(raw_value)) if str(raw_value) else False + else: + config[field] = [item for item in str(raw_value).split("|") if item] + return config + + +def get_installed_mod_ids(runtime: WindhawkRuntime) -> list[str]: + mod_ids: list[str] = [] + if runtime.portable: + if runtime.engine_mods_path.exists(): + for path in sorted(runtime.engine_mods_path.glob("*.ini")): + mod_id = path.stem + if get_mod_config(runtime, mod_id): + mod_ids.append(mod_id) + return mod_ids + + ref = runtime.mod_registry_ref + if not ref: + return mod_ids + try: + key = winreg.OpenKey(ref.root, ref.subkey, 0, winreg.KEY_READ | winreg.KEY_WOW64_64KEY) + except FileNotFoundError: + return mod_ids + try: + index = 0 + while True: + try: + mod_id = winreg.EnumKey(key, index) + except OSError: + break + if get_mod_config(runtime, mod_id): + mod_ids.append(mod_id) + index += 1 + finally: + winreg.CloseKey(key) + return sorted(mod_ids) + + +def get_mod_settings(runtime: WindhawkRuntime, mod_id: str) -> dict[str, str | int]: + if runtime.portable: + parser = parse_ini(get_mod_ini_path(runtime, mod_id)) + if not parser.has_section("Settings"): + return {} + return {key: parse_ini_scalar(value) for key, value in parser["Settings"].items()} + + ref = runtime.mod_registry_ref + if not ref: + return {} + try: + key = winreg.OpenKey(ref.root, f"{ref.subkey}\\{mod_id}\\Settings", 0, winreg.KEY_READ | winreg.KEY_WOW64_64KEY) + except FileNotFoundError: + return {} + settings: dict[str, str | int] = {} + index = 0 + try: + while True: + try: + name, value, _kind = winreg.EnumValue(key, index) + except OSError: + break + settings[name] = value + index += 1 + finally: + winreg.CloseKey(key) + return settings + + +def get_name_prefix(name: str) -> str: + return re.sub(r"\[\d+\].*$", "[0]", name) + + +def merge_mod_settings(existing_settings: dict[str, str | int], new_settings: dict[str, str | int]) -> dict[str, str | int]: + merged = dict(existing_settings) + existing_prefixes = {get_name_prefix(name) for name in existing_settings} + for name, value in new_settings.items(): + if get_name_prefix(name) not in existing_prefixes: + merged[name] = value + return merged + + +def write_mod_settings(runtime: WindhawkRuntime, mod_id: str, settings: dict[str, str | int]) -> None: + if runtime.portable: + parser = parse_ini(get_mod_ini_path(runtime, mod_id)) + if not parser.has_section("Settings"): + parser.add_section("Settings") + parser["Settings"] = {key: str(value) for key, value in settings.items()} + if not parser.has_section("Mod"): + parser.add_section("Mod") + parser["Mod"]["SettingsChangeTime"] = str(int(time.time()) & 0x7FFFFFFF) + write_ini(get_mod_ini_path(runtime, mod_id), parser) + return + + ref = runtime.mod_registry_ref + if not ref: + raise WindhawkError("Registry storage is not configured for this Windhawk install") + delete_registry_tree(ref, f"{mod_id}\\Settings") + settings_key = winreg.CreateKeyEx(ref.root, f"{ref.subkey}\\{mod_id}\\Settings", 0, winreg.KEY_SET_VALUE | winreg.KEY_WOW64_64KEY) + try: + for name, value in settings.items(): + if isinstance(value, int): + winreg.SetValueEx(settings_key, name, 0, winreg.REG_DWORD, value & 0xFFFFFFFF) + else: + winreg.SetValueEx(settings_key, name, 0, winreg.REG_SZ, str(value)) + finally: + winreg.CloseKey(settings_key) + write_registry_values(ref, mod_id, {"SettingsChangeTime": int(time.time()) & 0x7FFFFFFF}) + + +def write_mod_config(runtime: WindhawkRuntime, mod_id: str, fields: dict[str, Any], initial_settings: dict[str, str | int] | None = None) -> dict[str, Any]: + config_existed = get_mod_config(runtime, mod_id) is not None + + if runtime.portable: + parser = parse_ini(get_mod_ini_path(runtime, mod_id)) + if not parser.has_section("Mod"): + parser.add_section("Mod") + for name, value in fields.items(): + if isinstance(value, list): + parser["Mod"][name] = "|".join(value) + elif isinstance(value, bool): + parser["Mod"][name] = "1" if value else "0" + else: + parser["Mod"][name] = str(value) + write_ini(get_mod_ini_path(runtime, mod_id), parser) + else: + ref = runtime.mod_registry_ref + if not ref: + raise WindhawkError("Registry storage is not configured for this Windhawk install") + serialized: dict[str, Any] = {} + for name, value in fields.items(): + if isinstance(value, list): + serialized[name] = "|".join(value) + elif isinstance(value, bool): + serialized[name] = 1 if value else 0 + else: + serialized[name] = value + write_registry_values(ref, mod_id, serialized) + + if initial_settings: + merged_settings = initial_settings if not config_existed else merge_mod_settings(get_mod_settings(runtime, mod_id), initial_settings) + write_mod_settings(runtime, mod_id, merged_settings) + + config = get_mod_config(runtime, mod_id) + if config is None: + raise WindhawkError("Failed to read back the mod config after writing it") + return config + + +def set_mod_field(runtime: WindhawkRuntime, mod_id: str, field: str, value: bool) -> dict[str, Any]: + config = get_mod_config(runtime, mod_id) + if config is None: + raise WindhawkError(f"Mod is not installed: {mod_id}") + if runtime.portable: + parser = parse_ini(get_mod_ini_path(runtime, mod_id)) + if not parser.has_section("Mod"): + parser.add_section("Mod") + parser["Mod"][field] = "1" if value else "0" + write_ini(get_mod_ini_path(runtime, mod_id), parser) + else: + ref = runtime.mod_registry_ref + if not ref: + raise WindhawkError("Registry storage is not configured for this Windhawk install") + write_registry_values(ref, mod_id, {field: 1 if value else 0}) + updated = get_mod_config(runtime, mod_id) + if updated is None: + raise WindhawkError(f"Failed to update mod config: {mod_id}") + return updated + + +def delete_mod_storage(runtime: WindhawkRuntime, mod_id: str) -> None: + storage_path = runtime.engine_mods_writable_path / "mod-storage" / mod_id + shutil.rmtree(storage_path, ignore_errors=True) + + +def delete_mod_config(runtime: WindhawkRuntime, mod_id: str) -> None: + if runtime.portable: + for path in ( + get_mod_ini_path(runtime, mod_id), + runtime.engine_mods_writable_path / f"{mod_id}.ini", + ): + try: + path.unlink() + except FileNotFoundError: + pass + delete_mod_storage(runtime, mod_id) + return + + if runtime.mod_registry_ref: + delete_registry_tree(runtime.mod_registry_ref, mod_id) + if runtime.mod_registry_writable_ref: + delete_registry_tree(runtime.mod_registry_writable_ref, mod_id) + delete_mod_storage(runtime, mod_id) + + +def delete_old_mod_files(runtime: WindhawkRuntime, mod_id: str, architectures: list[str], current_dll_name: str | None = None) -> None: + for subfolder in subfolders_from_architecture(runtime, architectures): + compiled_mods_path = runtime.engine_mods_path / subfolder + if not compiled_mods_path.exists(): + continue + for path in compiled_mods_path.glob(f"{mod_id}_*.dll"): + if current_dll_name and path.name == current_dll_name: + continue + name_without_extension = path.stem + if not re.search(r"(^|_)\d+$", name_without_extension): + continue + try: + path.unlink() + except OSError: + pass + + +def find_latest_log_files(runtime: WindhawkRuntime, kind: str) -> list[Path]: + if not runtime.ui_logs_path.exists(): + return [] + sessions = [path for path in runtime.ui_logs_path.iterdir() if path.is_dir()] + if not sessions: + return [] + latest_session = max(sessions, key=lambda path: path.stat().st_mtime_ns) + if kind == "main": + main_log = latest_session / "main.log" + return [main_log] if main_log.exists() else [] + return sorted(path for path in latest_session.rglob("*.log") if path.is_file()) + + +def tail_lines(path: Path, limit: int, contains: str | None = None) -> list[str]: + lines = path.read_text(encoding="utf-8", errors="replace").splitlines() + if contains: + lines = [line for line in lines if contains in line] + return lines[-limit:] + + +def run_windhawk(runtime: WindhawkRuntime, *flags: str, wait: bool = False) -> dict[str, Any]: + args = [str(runtime.exe_path), *flags] + if wait: + result = subprocess.run(args, capture_output=True, text=True, check=False) + return {"args": args, "exit_code": result.returncode, "stdout": result.stdout, "stderr": result.stderr} + process = subprocess.Popen(args) + return {"args": args, "pid": process.pid} + + +def print_output(payload: Any, as_json: bool) -> None: + if as_json: + print(json.dumps(payload, indent=2)) + return + print(json.dumps(payload, indent=2)) + + +def command_detect(args: argparse.Namespace) -> Any: + runtimes = detect_runtimes(args.root) + if args.all: + return {"runtimes": [runtime.to_dict() for runtime in runtimes]} + return {"runtime": resolve_runtime(args.root).to_dict()} + + +def command_status(args: argparse.Namespace) -> Any: + runtime = resolve_runtime(args.root) + source_mods = sorted(path.name.removesuffix(".wh.cpp") for path in runtime.mods_source_path.glob("*.wh.cpp")) if runtime.mods_source_path.exists() else [] + installed_mods = get_installed_mod_ids(runtime) + payload: dict[str, Any] = { + "runtime": runtime.to_dict(), + "running": is_windhawk_running(), + "source_mods": source_mods, + "installed_mods": installed_mods, + } + + if args.mod_id: + mod_payload: dict[str, Any] = { + "mod_id": args.mod_id, + "source_path": str(runtime.mods_source_path / f"{args.mod_id}.wh.cpp"), + "workspace_path": str(runtime.editor_workspace_path / "mod.wh.cpp"), + "config": get_mod_config(runtime, args.mod_id), + "settings": get_mod_settings(runtime, args.mod_id), + "compiled_binaries": [], + } + source_path = runtime.mods_source_path / f"{args.mod_id}.wh.cpp" + if source_path.exists(): + source_text = source_path.read_text(encoding="utf-8") + mod_payload["metadata"] = extract_metadata(source_text) + for subfolder in ("32", "64", "arm64"): + compiled_dir = runtime.engine_mods_path / subfolder + if compiled_dir.exists(): + mod_payload["compiled_binaries"].extend(str(path) for path in sorted(compiled_dir.glob(f"{args.mod_id}_*.dll"))) + payload["mod"] = mod_payload + return payload + + +def command_launch(args: argparse.Namespace) -> Any: + runtime = resolve_runtime(args.root) + flags: list[str] = [] + if args.tray_only: + flags.append("-tray-only") + if args.safe_mode: + flags.append("-safe-mode") + return run_windhawk(runtime, *flags, wait=args.wait) + + +def command_restart(args: argparse.Namespace) -> Any: + runtime = resolve_runtime(args.root) + flags = ["-restart"] + if args.tray_only: + flags.append("-tray-only") + return run_windhawk(runtime, *flags, wait=args.wait) + + +def command_exit(args: argparse.Namespace) -> Any: + runtime = resolve_runtime(args.root) + flags = ["-exit"] + if args.wait: + flags.append("-wait") + return run_windhawk(runtime, *flags, wait=args.wait) + + +def command_init_mod(args: argparse.Namespace) -> Any: + runtime = resolve_runtime(args.root) + source_path = runtime.mods_source_path / f"{args.mod_id}.wh.cpp" + if source_path.exists() and not args.force: + raise WindhawkError(f"Mod source already exists: {source_path}") + mod_source = create_mod_source(args) + write_mod_source(runtime, args.mod_id, mod_source) + result: dict[str, Any] = {"mod_id": args.mod_id, "source_path": str(source_path)} + if args.sync_workspace: + result["workspace_sync"] = sync_workspace(runtime, args.mod_id, "to-workspace", force=True) + return result + + +def command_sync_workspace(args: argparse.Namespace) -> Any: + runtime = resolve_runtime(args.root) + return sync_workspace(runtime, args.mod_id, args.direction, force=args.force) + + +def command_compile(args: argparse.Namespace) -> Any: + runtime = resolve_runtime(args.root) + if args.from_workspace: + sync_workspace(runtime, args.mod_id, "from-workspace", force=False) + mod_source = read_mod_source(runtime, args.mod_id) + metadata = extract_metadata(mod_source) + if metadata["id"] != args.mod_id: + raise WindhawkError(f"Mod id in source is {metadata['id']}, expected {args.mod_id}") + + mod_targets = metadata.get("include", []) or [] + architectures = metadata.get("architecture", []) or [] + target_dll_name = generate_target_dll_name(runtime, args.mod_id, metadata.get("version", ""), architectures, mod_targets) + + compiled_paths: list[str] = [] + for target in targets_from_architecture(runtime, architectures, mod_targets): + copy_compiler_libs(runtime, target) + compiled_paths.append(compile_for_target(runtime, metadata, mod_source, target, target_dll_name)) + + initial_settings = extract_initial_settings_for_engine(mod_source) + config = write_mod_config( + runtime, + args.mod_id, + { + "LibraryFileName": target_dll_name, + "Disabled": 1 if args.disabled else 0, + "LoggingEnabled": 1 if args.enable_logging else 0, + "Include": metadata.get("include", []) or [], + "Exclude": metadata.get("exclude", []) or [], + "Architecture": metadata.get("architecture", []) or [], + "Version": metadata.get("version", "") or "", + }, + initial_settings=initial_settings, + ) + delete_old_mod_files(runtime, args.mod_id, architectures, current_dll_name=target_dll_name) + + restart_result = None + if args.restart: + flags = ["-restart"] + if args.tray_only: + flags.append("-tray-only") + restart_result = run_windhawk(runtime, *flags, wait=False) + + return { + "mod_id": args.mod_id, + "target_dll_name": target_dll_name, + "compiled_paths": compiled_paths, + "config": config, + "restart": restart_result, + } + + +def command_enable(args: argparse.Namespace) -> Any: + runtime = resolve_runtime(args.root) + config = set_mod_field(runtime, args.mod_id, "Disabled", False) + restart_result = None + if args.restart: + flags = ["-restart"] + if args.tray_only: + flags.append("-tray-only") + restart_result = run_windhawk(runtime, *flags, wait=False) + return {"mod_id": args.mod_id, "config": config, "restart": restart_result} + + +def command_disable(args: argparse.Namespace) -> Any: + runtime = resolve_runtime(args.root) + config = set_mod_field(runtime, args.mod_id, "Disabled", True) + restart_result = None + if args.restart: + flags = ["-restart"] + if args.tray_only: + flags.append("-tray-only") + restart_result = run_windhawk(runtime, *flags, wait=False) + return {"mod_id": args.mod_id, "config": config, "restart": restart_result} + + +def command_logging(args: argparse.Namespace) -> Any: + runtime = resolve_runtime(args.root) + config = set_mod_field(runtime, args.mod_id, "LoggingEnabled", args.state == "on") + return {"mod_id": args.mod_id, "config": config} + + +def command_delete_mod(args: argparse.Namespace) -> Any: + runtime = resolve_runtime(args.root) + config = get_mod_config(runtime, args.mod_id) + architectures = config.get("Architecture", []) if config else [] + delete_old_mod_files(runtime, args.mod_id, architectures) + delete_mod_config(runtime, args.mod_id) + if not args.keep_source: + delete_mod_source(runtime, args.mod_id) + return {"mod_id": args.mod_id, "source_deleted": not args.keep_source} + + +def command_logs(args: argparse.Namespace) -> Any: + runtime = resolve_runtime(args.root) + files = find_latest_log_files(runtime, args.kind) + return { + "files": [ + { + "path": str(path), + "lines": tail_lines(path, args.lines, args.contains), + } + for path in files + ] + } + + +def main() -> int: + args = parse_args() + handlers = { + "detect": command_detect, + "status": command_status, + "launch": command_launch, + "restart": command_restart, + "exit": command_exit, + "init-mod": command_init_mod, + "sync-workspace": command_sync_workspace, + "compile": command_compile, + "enable": command_enable, + "disable": command_disable, + "logging": command_logging, + "delete-mod": command_delete_mod, + "logs": command_logs, + } + + try: + payload = handlers[args.command](args) + except CompileError as exc: + error_payload = { + "error": str(exc), + "target": exc.target, + "exit_code": exc.exit_code, + "stdout": exc.stdout, + "stderr": exc.stderr, + } + print(json.dumps(error_payload, indent=2)) + return 1 + except (WindhawkError, FileNotFoundError) as exc: + print(json.dumps({"error": str(exc)}, indent=2)) + return 1 + + print_output(payload, args.json) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/jest.config.ts b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/jest.config.ts index e5f16f5..ffee888 100644 --- a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/jest.config.ts +++ b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/jest.config.ts @@ -6,6 +6,10 @@ export default { '^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': '@nrwl/react/plugins/jest', '^.+\\.[tj]sx?$': ['babel-jest', { presets: ['@nrwl/react/babel'] }], }, + moduleNameMapper: { + '^monaco-editor/esm/vs/editor/editor.api$': + '/src/test/monacoEditorApiMock.cjs', + }, moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], coverageDirectory: '../../coverage/apps/vscode-windhawk-ui', }; diff --git a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/app.css b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/app.css index c9f1c17..3bb24da 100644 --- a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/app.css +++ b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/app.css @@ -1,3 +1,4 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Outfit:wght@400;500;600;700&display=swap'); /* stylelint-disable at-rule-empty-line-before,at-rule-name-space-after,at-rule-no-unknown */ /* stylelint-disable no-duplicate-selectors */ /* stylelint-disable */ @@ -30,6 +31,7 @@ html { line-height: 1.15; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; + text-size-adjust: 100%; -ms-overflow-style: scrollbar; -webkit-tap-highlight-color: rgba(0, 0, 0, 0); } @@ -40,7 +42,7 @@ body { margin: 0; color: rgba(255, 255, 255, 0.85); font-size: 14px; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + font-family: 'Inter', 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; font-variant: tabular-nums; line-height: 1.5715; background-color: var(--app-background-color); @@ -139,7 +141,7 @@ a { outline: none; cursor: pointer; transition: color 0.3s; - -webkit-text-decoration-skip: objects; + text-decoration-skip-ink: auto; } a:hover { color: #165996; @@ -26835,6 +26837,15 @@ div.ant-typography-edit-content.ant-typography-rtl { } :root { --app-max-width: 1200px; + --app-horizontal-padding: 20px; + --app-section-gap: 24px; + --app-card-padding: 22px; + --app-surface-radius: 18px; + --app-surface-background: linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.025)); + --app-surface-border: rgba(255, 255, 255, 0.08); + --app-surface-shadow: 0 14px 40px rgba(0, 0, 0, 0.2); + --app-nav-button-height: 40px; + --app-status-pill-height: 34px; --app-background-color: var(--vscode-editor-background, #1e1e1e); --diff-background-color: #1e1e1e; --diff-text-color: #fafafa; @@ -26853,6 +26864,26 @@ div.ant-typography-edit-content.ant-typography-rtl { --diff-decoration-content-background-color: #222; --diff-decoration-content-color: #ababab; } +html[data-windhawk-layout='wide'] { + --app-max-width: 1440px; +} +html[data-windhawk-performance='responsive'] { + --app-max-width: 1480px; +} +html[data-windhawk-performance='efficient'] { + --app-section-gap: 16px; + --app-card-padding: 16px; + --app-surface-radius: 14px; + --app-surface-background: linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.018)); + --app-surface-shadow: none; +} +html[data-windhawk-density='compact'] { + --app-horizontal-padding: 16px; + --app-section-gap: 18px; + --app-card-padding: 18px; + --app-nav-button-height: 36px; + --app-status-pill-height: 30px; +} body[data-content="sidebar"] { --app-background-color: var(--vscode-sideBar-background); } @@ -26890,3 +26921,269 @@ body.windhawk-no-pointer-events .windhawk-popup-content-no-select { color: #fff; background-color: #177ddc; } +html[data-windhawk-reduce-motion='true'] *, +html[data-windhawk-reduce-motion='true'] *::before, +html[data-windhawk-reduce-motion='true'] *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; +} + +.app-boot { + position: relative; + min-height: 100vh; + overflow: hidden; + background: + radial-gradient(circle at top left, rgba(23, 125, 220, 0.24), transparent 28%), + radial-gradient(circle at bottom right, rgba(246, 173, 85, 0.16), transparent 24%), + linear-gradient(180deg, rgba(9, 15, 26, 0.98), rgba(12, 18, 30, 0.98)); +} + +.app-boot__halo { + position: absolute; + border-radius: 999px; + filter: blur(28px); + opacity: 0.7; + pointer-events: none; +} + +.app-boot__halo--one { + top: 8%; + left: -4%; + width: 340px; + height: 340px; + background: rgba(23, 125, 220, 0.22); + animation: appBootFloat 14s ease-in-out infinite; +} + +.app-boot__halo--two { + right: -6%; + bottom: 4%; + width: 300px; + height: 300px; + background: rgba(246, 173, 85, 0.16); + animation: appBootFloat 18s ease-in-out infinite reverse; +} + +.app-boot__grid { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(280px, 0.85fr); + gap: 24px; + align-items: center; + min-height: 100vh; + max-width: 1160px; + margin: 0 auto; + padding: 48px 32px; +} + +.app-boot--sidebar .app-boot__grid { + max-width: 900px; + grid-template-columns: minmax(0, 1fr); +} + +.app-boot__hero, +.app-boot__status { + position: relative; + padding: 28px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 28px; + background: + linear-gradient(160deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.02)), + rgba(9, 15, 26, 0.72); + box-shadow: + 0 24px 60px rgba(0, 0, 0, 0.32), + inset 0 1px 0 rgba(255, 255, 255, 0.05); + backdrop-filter: blur(12px); +} + +.app-boot__brand-row { + display: flex; + gap: 16px; + align-items: center; +} + +.app-boot__mark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 56px; + height: 56px; + border-radius: 18px; + background: + linear-gradient(135deg, rgba(23, 125, 220, 0.95), rgba(73, 180, 255, 0.72)); + color: #f7fbff; + font-size: 20px; + font-weight: 800; + letter-spacing: 0.08em; + box-shadow: 0 12px 32px rgba(23, 125, 220, 0.35); +} + +.app-boot__eyebrow { + color: rgba(255, 255, 255, 0.56); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.app-boot__brand { + margin-top: 4px; + color: rgba(255, 255, 255, 0.86); + font-size: 16px; + font-weight: 700; +} + +.app-boot__title { + margin: 22px 0 12px; + color: #f7fbff; + font-size: clamp(34px, 5vw, 56px); + line-height: 1.02; + letter-spacing: -0.04em; +} + +.app-boot__description { + max-width: 640px; + margin: 0; + color: rgba(255, 255, 255, 0.7); + font-size: 15px; + line-height: 1.6; +} + +.app-boot__chips { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 22px; +} + +.app-boot__chip { + display: inline-flex; + align-items: center; + min-height: 34px; + padding: 0 14px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 999px; + background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.88); + font-size: 12px; + font-weight: 600; +} + +.app-boot__status-title { + color: rgba(255, 255, 255, 0.92); + font-size: 14px; + font-weight: 700; +} + +.app-boot__phase-list { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 18px; +} + +.app-boot__phase { + display: flex; + gap: 14px; + align-items: center; + padding: 14px 16px; + border-radius: 18px; + border: 1px solid rgba(255, 255, 255, 0.06); + background: rgba(255, 255, 255, 0.03); +} + +.app-boot__phase--complete { + border-color: rgba(82, 196, 26, 0.2); + background: rgba(82, 196, 26, 0.07); +} + +.app-boot__phase--active { + border-color: rgba(23, 125, 220, 0.26); + background: rgba(23, 125, 220, 0.1); +} + +.app-boot__phase-index { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.92); + font-size: 13px; + font-weight: 700; +} + +.app-boot__phase--complete .app-boot__phase-index { + background: rgba(82, 196, 26, 0.2); +} + +.app-boot__phase--active .app-boot__phase-index { + background: rgba(23, 125, 220, 0.24); +} + +.app-boot__phase-copy { + min-width: 0; +} + +.app-boot__phase-label { + color: rgba(255, 255, 255, 0.92); + font-size: 14px; + font-weight: 700; +} + +.app-boot__phase-state { + margin-top: 4px; + color: rgba(255, 255, 255, 0.62); + font-size: 12px; + font-weight: 600; +} + +.app-boot__hint { + margin-top: 18px; + color: rgba(255, 255, 255, 0.56); + font-size: 13px; + line-height: 1.55; +} + +@keyframes appBootFloat { + 0%, + 100% { + transform: translate3d(0, 0, 0) scale(1); + } + + 50% { + transform: translate3d(0, -16px, 0) scale(1.04); + } +} + +[data-windhawk-reduce-motion='true'] .app-boot__halo { + animation: none; +} + +@media (max-width: 900px) { + .app-boot__grid { + grid-template-columns: minmax(0, 1fr); + padding: 32px 20px; + } + + .app-boot__hero, + .app-boot__status { + padding: 22px; + border-radius: 22px; + } + + .app-boot__title { + font-size: clamp(30px, 10vw, 42px); + } +} +.setup-assistant__actions { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 20px; +} diff --git a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/app.tsx b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/app.tsx index 2c7b20c..fa71224 100644 --- a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/app.tsx +++ b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/app.tsx @@ -1,4 +1,4 @@ -import { ConfigProvider } from 'antd'; +import { Button, ConfigProvider, Modal } from 'antd'; import 'prism-themes/themes/prism-vsc-dark-plus.css'; import { useCallback, useEffect, useMemo, useState } from 'react'; import 'react-diff-view/style/index.css'; @@ -7,23 +7,216 @@ import './App.css'; import { AppUISettingsContext, AppUISettingsContextType, + defaultLocalUISettings, + hasStoredLocalUISettings, + LocalUISettings, + mergeLocalUISettings, + readLocalUISettings, + writeLocalUISettings, } from './appUISettings'; import { setLanguage } from './i18n'; import { mockAppUISettings, useMockData } from './panel/mockData'; +import { AppUISettings } from './webviewIPCMessages'; import Panel from './panel/Panel'; import Sidebar from './sidebar/Sidebar'; import { useGetInitialAppSettings, useSetNewAppSettings } from './webviewIPC'; -function WhenTranslationIsReady( - props: React.PropsWithChildren> -) { - const { ready } = useTranslation(); - // https://stackoverflow.com/a/63898849 - // eslint-disable-next-line react/jsx-no-useless-fragment - return ready ? <>{props.children} : null; +type AppBootSplashProps = { + content: string | null; + extensionReady: boolean; + translationsReady: boolean; + localUISettings: LocalUISettings; + t: ReturnType['t']; +}; + +function AppBootSplash({ + content, + extensionReady, + translationsReady, + localUISettings, + t, +}: AppBootSplashProps) { + const panelMode = content === 'sidebar' ? 'sidebar' : 'panel'; + const startupPageLabel = + localUISettings.startupPage === 'explore' + ? t('settings.workflow.startupPage.options.explore', { + defaultValue: 'Explore', + }) + : localUISettings.startupPage === 'settings' + ? t('settings.workflow.startupPage.options.settings', { + defaultValue: 'Settings', + }) + : localUISettings.startupPage === 'about' + ? t('settings.workflow.startupPage.options.about', { + defaultValue: 'About', + }) + : t('settings.workflow.startupPage.options.home', { + defaultValue: 'Home', + }); + const performanceLabel = + localUISettings.performanceProfile === 'responsive' + ? t('settings.performance.profile.options.responsive', { + defaultValue: 'Responsive', + }) + : localUISettings.performanceProfile === 'efficient' + ? t('settings.performance.profile.options.efficient', { + defaultValue: 'Efficient', + }) + : t('settings.performance.profile.options.balanced', { + defaultValue: 'Balanced', + }); + const aiAccelerationLabel = + localUISettings.aiAccelerationPreference === 'prefer-npu' + ? t('settings.performance.aiAcceleration.options.preferNpu', { + defaultValue: 'Prefer NPU', + }) + : localUISettings.aiAccelerationPreference === 'off' + ? t('settings.performance.aiAcceleration.options.off', { + defaultValue: 'Off', + }) + : t('settings.performance.aiAcceleration.options.auto', { + defaultValue: 'Auto', + }); + + const phases = [ + { + key: 'profile', + label: t('splash.phases.profile', { + defaultValue: 'Workspace profile', + }), + state: 'complete' as const, + }, + { + key: 'bridge', + label: t('splash.phases.bridge', { defaultValue: 'Extension bridge' }), + state: extensionReady ? ('complete' as const) : ('active' as const), + }, + { + key: 'shell', + label: t('splash.phases.shell', { defaultValue: 'UI shell' }), + state: translationsReady + ? ('complete' as const) + : extensionReady + ? ('active' as const) + : ('pending' as const), + }, + ]; + + return ( +
+
+
+
+
+
+
WH
+
+
+ {t('splash.eyebrow', { defaultValue: 'Windhawk startup' })} +
+
+ {panelMode === 'sidebar' + ? t('splash.sidebarBrand', { + defaultValue: 'Editor cockpit', + }) + : t('splash.panelBrand', { defaultValue: 'Windhawk' })} +
+
+
+

+ {panelMode === 'sidebar' + ? t('splash.sidebarTitle', { + defaultValue: 'Loading the editor cockpit', + }) + : t('splash.panelTitle', { + defaultValue: 'Preparing your workspace', + })} +

+

+ {panelMode === 'sidebar' + ? t('splash.sidebarDescription', { + defaultValue: + 'Syncing the editor surface, compile controls, and cockpit helpers before the current mod session opens.', + }) + : t('splash.panelDescription', { + defaultValue: + 'Applying your startup route, local workspace profile, and webview shell before the control center appears.', + })} +

+
+ + {t('splash.startingIn', { + defaultValue: 'Starting in {{page}}', + page: startupPageLabel, + })} + + + {t('splash.profile', { + defaultValue: 'Profile: {{profile}}', + profile: performanceLabel, + })} + + + {t('splash.aiAcceleration', { + defaultValue: 'AI: {{mode}}', + mode: aiAccelerationLabel, + })} + + + {localUISettings.useWideLayout + ? t('splash.layoutWide', { + defaultValue: 'Wide workspace', + }) + : t('splash.layoutStandard', { + defaultValue: 'Standard width', + })} + +
+
+
+
+ {t('splash.statusTitle', { + defaultValue: 'Startup progress', + })} +
+
+ {phases.map((phase, index) => ( +
+
{index + 1}
+
+
{phase.label}
+
+ {phase.state === 'complete' + ? t('splash.phaseComplete', { defaultValue: 'Ready' }) + : phase.state === 'active' + ? t('splash.phaseActive', { + defaultValue: 'In progress', + }) + : t('splash.phasePending', { + defaultValue: 'Queued', + })} +
+
+
+ ))} +
+
+ {t('splash.hint', { + defaultValue: + 'The first frame now stays visible while the extension bridge and language shell finish warming up.', + })} +
+
+
+
+ ); } function App() { + const { t, ready } = useTranslation(); const content = useMemo( () => document.querySelector('body')?.getAttribute('data-content') ?? @@ -31,8 +224,12 @@ function App() { [] ); - const [appUISettings, setAppUISettings] = - useState(null); + const [extensionAppUISettings, setExtensionAppUISettings] = + useState | null>(null); + const [localUISettings, setLocalUISettingsState] = useState( + () => readLocalUISettings() + ); + const [setupAssistantOpen, setSetupAssistantOpen] = useState(false); const [direction, setDirection] = useState<'ltr' | 'rtl'>('ltr'); @@ -48,37 +245,123 @@ function App() { } }, []); + useEffect(() => { + applyNewLanguage(extensionAppUISettings?.language); + }, [applyNewLanguage, extensionAppUISettings?.language]); + + useEffect(() => { + const effectiveReduceMotion = + localUISettings.reduceMotion || + localUISettings.performanceProfile === 'efficient'; + + document.documentElement.setAttribute( + 'data-windhawk-density', + localUISettings.interfaceDensity + ); + document.documentElement.setAttribute( + 'data-windhawk-reduce-motion', + String(effectiveReduceMotion) + ); + document.documentElement.setAttribute( + 'data-windhawk-layout', + localUISettings.useWideLayout ? 'wide' : 'default' + ); + document.documentElement.setAttribute( + 'data-windhawk-performance', + localUISettings.performanceProfile + ); + document.documentElement.setAttribute( + 'data-windhawk-ai-acceleration', + localUISettings.aiAccelerationPreference + ); + }, [localUISettings]); + + const setLocalUISettings = useCallback( + (updates: Partial) => { + setLocalUISettingsState((current) => { + const next = mergeLocalUISettings(current, updates); + writeLocalUISettings(next); + return next; + }); + }, + [] + ); + + const resetLocalUISettings = useCallback(() => { + setLocalUISettingsState(defaultLocalUISettings); + writeLocalUISettings(defaultLocalUISettings); + }, []); + + const applySetupProfile = useCallback((settings: LocalUISettings) => { + setLocalUISettingsState(settings); + writeLocalUISettings(settings); + setSetupAssistantOpen(false); + }, []); + + const openSetupAssistant = useCallback(() => { + setSetupAssistantOpen(true); + }, []); + const { getInitialAppSettings } = useGetInitialAppSettings( useCallback((data) => { - applyNewLanguage(data.appUISettings?.language); - setAppUISettings(data.appUISettings || {}); - }, [applyNewLanguage]) + setExtensionAppUISettings(data.appUISettings || {}); + }, []) ); useEffect(() => { if (!useMockData) { getInitialAppSettings({}); } else { - applyNewLanguage(mockAppUISettings?.language); - setAppUISettings(mockAppUISettings || {}); + setExtensionAppUISettings(mockAppUISettings || {}); } - }, [applyNewLanguage, getInitialAppSettings]); + }, [getInitialAppSettings]); useSetNewAppSettings( useCallback((data) => { - applyNewLanguage(data.appUISettings?.language); - setAppUISettings(data.appUISettings || {}); - }, [applyNewLanguage]) + setExtensionAppUISettings((current) => ({ + ...(current ?? {}), + ...(data.appUISettings || {}), + })); + }, []) + ); + + const appUISettings = useMemo( + () => (extensionAppUISettings ? { + ...extensionAppUISettings, + localUISettings, + setLocalUISettings, + resetLocalUISettings, + openSetupAssistant, + } : null), + [ + extensionAppUISettings, + localUISettings, + openSetupAssistant, + setLocalUISettings, + resetLocalUISettings, + ] ); - if (!content || !appUISettings) { - return null; - } + useEffect(() => { + if (extensionAppUISettings && !hasStoredLocalUISettings()) { + setSetupAssistantOpen(true); + } + }, [extensionAppUISettings]); + + const isBooting = !content || !appUISettings || !ready; return ( - - - + + {isBooting ? ( + + ) : ( + {content === 'panel' ? ( ) : content === 'sidebar' ? ( @@ -86,9 +369,70 @@ function App() { ) : ( '' )} - - - + applySetupProfile(localUISettings)} + footer={null} + centered + > +

+ {t('setupAssistant.description', { + defaultValue: + 'Pick a starting profile now. You can change any of these options later in Settings.', + })} +

+
+ + + +
+
+ + )} + ); } diff --git a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/appUISettings.spec.ts b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/appUISettings.spec.ts new file mode 100644 index 0000000..31d30d5 --- /dev/null +++ b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/appUISettings.spec.ts @@ -0,0 +1,297 @@ +import { + defaultLocalUISettings, + getRecommendedLocalUISettings, + hasStoredLocalUISettings, + mergeLocalUISettings, + normalizeLocalUISettings, + readLocalUISettings, + recordRecentStudioLaunch, + writeLocalUISettings, +} from './appUISettings'; + +describe('appUISettings local preferences', () => { + it('merges valid updates without dropping existing values', () => { + expect( + mergeLocalUISettings(defaultLocalUISettings, { + interfaceDensity: 'compact', + useWideLayout: true, + }) + ).toEqual({ + interfaceDensity: 'compact', + reduceMotion: false, + useWideLayout: true, + performanceProfile: 'balanced', + aiAccelerationPreference: 'auto', + compileRecommendationMode: 'balanced', + startupPage: 'home', + exploreDefaultSort: 'smart-relevance', + editorAssistanceLevel: 'full', + windowsQuickActionDensity: 'expanded', + preferredAuthoringLanguage: 'cpp', + preferredSourceExtension: '.wh.cpp', + preferredStudioMode: 'code', + recentStudioLaunches: [], + }); + }); + + it('falls back to defaults when persisted data is malformed', () => { + const storage = { + getItem: jest.fn(() => '{'), + setItem: jest.fn(), + } as unknown as Storage; + + expect(readLocalUISettings(storage)).toEqual(defaultLocalUISettings); + }); + + it('keeps authoring language and source extension aligned', () => { + expect( + normalizeLocalUISettings({ + preferredAuthoringLanguage: 'python', + preferredSourceExtension: '.wh.cpp', + }) + ).toMatchObject({ + preferredAuthoringLanguage: 'python', + preferredSourceExtension: '.wh.py', + }); + + expect( + normalizeLocalUISettings({ + preferredSourceExtension: '.wh.py', + }) + ).toMatchObject({ + preferredAuthoringLanguage: 'python', + preferredSourceExtension: '.wh.py', + }); + }); + + it('normalizes recent studio launches and drops broken entries', () => { + expect( + normalizeLocalUISettings({ + recentStudioLaunches: [ + { + kind: 'workflow', + title: 'Browser workflow', + summary: 'Ship a browser-focused mod.', + templateKey: 'chromium-browser', + studioMode: 'visual', + authoringLanguage: 'cpp', + checklist: ['Inspect windows', 123, 'Verify shortcuts'], + tools: [ + { + key: 'status', + title: 'Runtime status', + command: 'python scripts/windhawk_tool.py --json detect', + }, + { + key: 42, + title: 'Broken resource', + }, + ], + prompts: [ + { + key: 'review', + title: 'Review prompt', + }, + ], + packet: 'Launch: Browser workflow', + }, + { + kind: 'workflow', + title: 'Broken launch', + summary: 'Missing template key so it cannot relaunch.', + }, + ], + }).recentStudioLaunches + ).toEqual([ + { + kind: 'workflow', + title: 'Browser workflow', + summary: 'Ship a browser-focused mod.', + templateKey: 'chromium-browser', + studioMode: 'visual', + authoringLanguage: 'cpp', + checklist: ['Inspect windows', 'Verify shortcuts'], + tools: [ + { + key: 'status', + title: 'Runtime status', + command: 'python scripts/windhawk_tool.py --json detect', + }, + ], + prompts: [ + { + key: 'review', + title: 'Review prompt', + }, + ], + packet: 'Launch: Browser workflow', + }, + ]); + }); + + it('keeps recent studio launches deduplicated and newest first', () => { + expect( + recordRecentStudioLaunch( + [ + { + kind: 'starter', + title: 'Structured core starter', + summary: 'Architecture-first scaffold', + templateKey: 'structured-core', + studioMode: 'code', + authoringLanguage: 'cpp', + }, + { + kind: 'workflow', + title: 'Shell workflow bundle', + summary: 'Explorer shell work', + templateKey: 'explorer-shell', + studioMode: 'visual', + authoringLanguage: 'cpp', + }, + ], + { + kind: 'starter', + title: 'Structured core starter', + summary: 'Updated launch packet', + templateKey: 'structured-core', + studioMode: 'code', + authoringLanguage: 'cpp', + packet: 'Launch: Structured core starter', + } + ) + ).toEqual([ + { + kind: 'starter', + title: 'Structured core starter', + summary: 'Updated launch packet', + templateKey: 'structured-core', + studioMode: 'code', + authoringLanguage: 'cpp', + packet: 'Launch: Structured core starter', + }, + { + kind: 'workflow', + title: 'Shell workflow bundle', + summary: 'Explorer shell work', + templateKey: 'explorer-shell', + studioMode: 'visual', + authoringLanguage: 'cpp', + }, + ]); + }); + + it('round-trips valid settings through storage', () => { + let storedValue: string | null = null; + const storage = { + getItem: jest.fn(() => storedValue), + setItem: jest.fn((key: string, value: string) => { + storedValue = value; + }), + } as unknown as Storage; + + writeLocalUISettings( + { + interfaceDensity: 'compact', + reduceMotion: true, + useWideLayout: true, + performanceProfile: 'responsive', + aiAccelerationPreference: 'prefer-npu', + compileRecommendationMode: 'fast-feedback', + startupPage: 'explore', + exploreDefaultSort: 'last-updated', + editorAssistanceLevel: 'full', + windowsQuickActionDensity: 'expanded', + preferredAuthoringLanguage: 'python', + preferredSourceExtension: '.wh.py', + preferredStudioMode: 'visual', + recentStudioLaunches: [ + { + kind: 'visual-preset', + title: 'Automation preset', + summary: 'Start from automation outcomes.', + templateKey: 'python-automation', + studioMode: 'visual', + authoringLanguage: 'python', + packet: 'Launch: Automation preset', + }, + ], + }, + storage + ); + + expect(readLocalUISettings(storage)).toEqual({ + interfaceDensity: 'compact', + reduceMotion: true, + useWideLayout: true, + performanceProfile: 'responsive', + aiAccelerationPreference: 'prefer-npu', + compileRecommendationMode: 'fast-feedback', + startupPage: 'explore', + exploreDefaultSort: 'last-updated', + editorAssistanceLevel: 'full', + windowsQuickActionDensity: 'expanded', + preferredAuthoringLanguage: 'python', + preferredSourceExtension: '.wh.py', + preferredStudioMode: 'visual', + recentStudioLaunches: [ + { + kind: 'visual-preset', + title: 'Automation preset', + summary: 'Start from automation outcomes.', + templateKey: 'python-automation', + studioMode: 'visual', + authoringLanguage: 'python', + packet: 'Launch: Automation preset', + }, + ], + }); + }); + + it('recommends responsive or efficient presets from runtime diagnostics', () => { + expect( + getRecommendedLocalUISettings({ + npuDetected: true, + totalMemoryGb: 16, + issueCode: 'none', + }) + ).toMatchObject({ + performanceProfile: 'responsive', + aiAccelerationPreference: 'prefer-npu', + useWideLayout: true, + compileRecommendationMode: 'fast-feedback', + startupPage: 'explore', + }); + + expect( + getRecommendedLocalUISettings({ + npuDetected: false, + totalMemoryGb: 8, + issueCode: 'none', + }) + ).toMatchObject({ + interfaceDensity: 'compact', + performanceProfile: 'efficient', + aiAccelerationPreference: 'off', + reduceMotion: true, + compileRecommendationMode: 'safe-first', + editorAssistanceLevel: 'guided', + }); + }); + + it('detects whether local settings were already persisted', () => { + let storedValue: string | null = null; + const storage = { + getItem: jest.fn(() => storedValue), + setItem: jest.fn((key: string, value: string) => { + storedValue = value; + }), + } as unknown as Storage; + + expect(hasStoredLocalUISettings(storage)).toBe(false); + + writeLocalUISettings(defaultLocalUISettings, storage); + + expect(hasStoredLocalUISettings(storage)).toBe(true); + }); +}); diff --git a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/appUISettings.ts b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/appUISettings.ts index 6b5bd2b..ca4276d 100644 --- a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/appUISettings.ts +++ b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/appUISettings.ts @@ -1,7 +1,533 @@ import React from 'react'; -import { AppUISettings } from './webviewIPCMessages'; +import { + AppRuntimeDiagnostics, + AppUISettings, + EditorLaunchContext, + EditorLaunchContextResource, +} from './webviewIPCMessages'; -export type AppUISettingsContextType = Partial; +export type InterfaceDensity = 'comfortable' | 'compact'; +export type PerformanceProfile = 'balanced' | 'responsive' | 'efficient'; +export type AIAccelerationPreference = 'auto' | 'prefer-npu' | 'off'; +export type CompileRecommendationMode = + | 'balanced' + | 'safe-first' + | 'fast-feedback'; +export type StartupPage = 'home' | 'explore' | 'settings' | 'about'; +export type ExploreDefaultSort = + | 'smart-relevance' + | 'last-updated' + | 'popular-top-rated'; +export type EditorAssistanceLevel = 'streamlined' | 'guided' | 'full'; +export type WindowsQuickActionDensity = 'focused' | 'expanded'; +export type PreferredAuthoringLanguage = 'cpp' | 'python'; +export type PreferredStudioMode = 'code' | 'visual'; + +export type LocalUISettings = { + interfaceDensity: InterfaceDensity; + reduceMotion: boolean; + useWideLayout: boolean; + performanceProfile: PerformanceProfile; + aiAccelerationPreference: AIAccelerationPreference; + compileRecommendationMode: CompileRecommendationMode; + startupPage: StartupPage; + exploreDefaultSort: ExploreDefaultSort; + editorAssistanceLevel: EditorAssistanceLevel; + windowsQuickActionDensity: WindowsQuickActionDensity; + preferredAuthoringLanguage: PreferredAuthoringLanguage; + preferredSourceExtension: '.wh.cpp' | '.wh.py'; + preferredStudioMode: PreferredStudioMode; + recentStudioLaunches: EditorLaunchContext[]; +}; + +export const defaultLocalUISettings: LocalUISettings = { + interfaceDensity: 'comfortable', + reduceMotion: false, + useWideLayout: false, + performanceProfile: 'balanced', + aiAccelerationPreference: 'auto', + compileRecommendationMode: 'balanced', + startupPage: 'home', + exploreDefaultSort: 'smart-relevance', + editorAssistanceLevel: 'full', + windowsQuickActionDensity: 'expanded', + preferredAuthoringLanguage: 'cpp', + preferredSourceExtension: '.wh.cpp', + preferredStudioMode: 'code', + recentStudioLaunches: [], +}; + +export const localUISettingsStorageKey = 'windhawk.local-ui-settings.v1'; + +const createNewModTemplateKeys = [ + 'default', + 'ai-ready', + 'structured-core', + 'explorer-shell', + 'chromium-browser', + 'window-behavior', + 'settings-lab', + 'python-automation', +] as const; + +const recentStudioLaunchLimit = 6; + +function getStorage(storage?: Storage | null) { + if (storage !== undefined) { + return storage ?? null; + } + + return typeof window !== 'undefined' ? window.localStorage : null; +} + +function isInterfaceDensity(value: unknown): value is InterfaceDensity { + return value === 'comfortable' || value === 'compact'; +} + +function isPerformanceProfile(value: unknown): value is PerformanceProfile { + return value === 'balanced' || value === 'responsive' || value === 'efficient'; +} + +function isAIAccelerationPreference( + value: unknown +): value is AIAccelerationPreference { + return value === 'auto' || value === 'prefer-npu' || value === 'off'; +} + +function isCompileRecommendationMode( + value: unknown +): value is CompileRecommendationMode { + return ( + value === 'balanced' || + value === 'safe-first' || + value === 'fast-feedback' + ); +} + +function isStartupPage(value: unknown): value is StartupPage { + return ( + value === 'home' || + value === 'explore' || + value === 'settings' || + value === 'about' + ); +} + +function isExploreDefaultSort(value: unknown): value is ExploreDefaultSort { + return ( + value === 'smart-relevance' || + value === 'last-updated' || + value === 'popular-top-rated' + ); +} + +function isEditorAssistanceLevel( + value: unknown +): value is EditorAssistanceLevel { + return value === 'streamlined' || value === 'guided' || value === 'full'; +} + +function isWindowsQuickActionDensity( + value: unknown +): value is WindowsQuickActionDensity { + return value === 'focused' || value === 'expanded'; +} + +function isPreferredAuthoringLanguage( + value: unknown +): value is PreferredAuthoringLanguage { + return value === 'cpp' || value === 'python'; +} + +function isPreferredStudioMode(value: unknown): value is PreferredStudioMode { + return value === 'code' || value === 'visual'; +} + +function isCreateNewModTemplateKey( + value: unknown +): value is (typeof createNewModTemplateKeys)[number] { + return createNewModTemplateKeys.includes( + value as (typeof createNewModTemplateKeys)[number] + ); +} + +function isEditorLaunchContextKind( + value: unknown +): value is EditorLaunchContext['kind'] { + return ( + value === 'starter' || + value === 'workflow' || + value === 'visual-preset' + ); +} + +function normalizeStringArray(value: unknown) { + if (!Array.isArray(value)) { + return []; + } + + return value.filter( + (item): item is string => typeof item === 'string' && item.trim().length > 0 + ); +} + +function normalizeLaunchResource( + value: unknown +): EditorLaunchContextResource | null { + if (!value || typeof value !== 'object') { + return null; + } + + const candidate = value as Partial>; + + if ( + typeof candidate.key !== 'string' || + !candidate.key.trim() || + typeof candidate.title !== 'string' || + !candidate.title.trim() + ) { + return null; + } + + return { + key: candidate.key, + title: candidate.title, + command: + typeof candidate.command === 'string' && candidate.command.trim() + ? candidate.command + : undefined, + }; +} + +function normalizeLaunchResources(value: unknown) { + if (!Array.isArray(value)) { + return []; + } + + return value + .map((item) => normalizeLaunchResource(item)) + .filter((item): item is EditorLaunchContextResource => !!item); +} + +export function normalizeEditorLaunchContext( + value: unknown +): EditorLaunchContext | null { + if (!value || typeof value !== 'object') { + return null; + } + + const candidate = value as Partial>; + + if ( + !isEditorLaunchContextKind(candidate.kind) || + typeof candidate.title !== 'string' || + !candidate.title.trim() || + typeof candidate.summary !== 'string' || + !candidate.summary.trim() + ) { + return null; + } + + const checklist = normalizeStringArray(candidate.checklist); + const tools = normalizeLaunchResources(candidate.tools); + const prompts = normalizeLaunchResources(candidate.prompts); + + return { + kind: candidate.kind, + title: candidate.title, + summary: candidate.summary, + templateKey: isCreateNewModTemplateKey(candidate.templateKey) + ? candidate.templateKey + : undefined, + studioMode: isPreferredStudioMode(candidate.studioMode) + ? candidate.studioMode + : undefined, + authoringLanguage: isPreferredAuthoringLanguage(candidate.authoringLanguage) + ? candidate.authoringLanguage + : undefined, + checklist: checklist.length > 0 ? checklist : undefined, + tools: tools.length > 0 ? tools : undefined, + prompts: prompts.length > 0 ? prompts : undefined, + packet: + typeof candidate.packet === 'string' && candidate.packet.trim() + ? candidate.packet + : undefined, + }; +} + +export function normalizeRecentStudioLaunches( + value: unknown +): EditorLaunchContext[] { + if (!Array.isArray(value)) { + return []; + } + + return value + .map((item) => normalizeEditorLaunchContext(item)) + .filter( + (item): item is EditorLaunchContext => + !!item && item.templateKey !== undefined + ) + .slice(0, recentStudioLaunchLimit); +} + +function getRecentStudioLaunchKey(launchContext: EditorLaunchContext) { + return [ + launchContext.kind, + launchContext.templateKey, + launchContext.title, + launchContext.authoringLanguage ?? '', + launchContext.studioMode ?? '', + ].join('::'); +} + +export function recordRecentStudioLaunch( + recentStudioLaunches: unknown, + launchContext: unknown +) { + const normalizedLaunchContext = normalizeEditorLaunchContext(launchContext); + const currentRecentStudioLaunches = + normalizeRecentStudioLaunches(recentStudioLaunches); + + if (!normalizedLaunchContext || !normalizedLaunchContext.templateKey) { + return currentRecentStudioLaunches; + } + + const launchKey = getRecentStudioLaunchKey(normalizedLaunchContext); + + return [ + normalizedLaunchContext, + ...currentRecentStudioLaunches.filter( + (item) => getRecentStudioLaunchKey(item) !== launchKey + ), + ].slice(0, recentStudioLaunchLimit); +} + +function normalizeAuthoringPreferences( + candidate: Partial> +) { + const preferredAuthoringLanguage = isPreferredAuthoringLanguage( + candidate.preferredAuthoringLanguage + ) + ? candidate.preferredAuthoringLanguage + : candidate.preferredSourceExtension === '.wh.py' + ? 'python' + : defaultLocalUISettings.preferredAuthoringLanguage; + + return { + preferredAuthoringLanguage, + preferredSourceExtension: + preferredAuthoringLanguage === 'python' ? '.wh.py' : '.wh.cpp', + } as Pick< + LocalUISettings, + 'preferredAuthoringLanguage' | 'preferredSourceExtension' + >; +} + +export function normalizeLocalUISettings(value: unknown): LocalUISettings { + if (!value || typeof value !== 'object') { + return defaultLocalUISettings; + } + + const candidate = value as Partial>; + const authoringPreferences = normalizeAuthoringPreferences(candidate); + + return { + interfaceDensity: isInterfaceDensity(candidate.interfaceDensity) + ? candidate.interfaceDensity + : defaultLocalUISettings.interfaceDensity, + reduceMotion: + typeof candidate.reduceMotion === 'boolean' + ? candidate.reduceMotion + : defaultLocalUISettings.reduceMotion, + useWideLayout: + typeof candidate.useWideLayout === 'boolean' + ? candidate.useWideLayout + : defaultLocalUISettings.useWideLayout, + performanceProfile: isPerformanceProfile(candidate.performanceProfile) + ? candidate.performanceProfile + : defaultLocalUISettings.performanceProfile, + aiAccelerationPreference: isAIAccelerationPreference( + candidate.aiAccelerationPreference + ) + ? candidate.aiAccelerationPreference + : defaultLocalUISettings.aiAccelerationPreference, + compileRecommendationMode: isCompileRecommendationMode( + candidate.compileRecommendationMode + ) + ? candidate.compileRecommendationMode + : defaultLocalUISettings.compileRecommendationMode, + startupPage: isStartupPage(candidate.startupPage) + ? candidate.startupPage + : defaultLocalUISettings.startupPage, + exploreDefaultSort: isExploreDefaultSort(candidate.exploreDefaultSort) + ? candidate.exploreDefaultSort + : defaultLocalUISettings.exploreDefaultSort, + editorAssistanceLevel: isEditorAssistanceLevel( + candidate.editorAssistanceLevel + ) + ? candidate.editorAssistanceLevel + : defaultLocalUISettings.editorAssistanceLevel, + windowsQuickActionDensity: isWindowsQuickActionDensity( + candidate.windowsQuickActionDensity + ) + ? candidate.windowsQuickActionDensity + : defaultLocalUISettings.windowsQuickActionDensity, + preferredAuthoringLanguage: + authoringPreferences.preferredAuthoringLanguage, + preferredSourceExtension: authoringPreferences.preferredSourceExtension, + preferredStudioMode: isPreferredStudioMode(candidate.preferredStudioMode) + ? candidate.preferredStudioMode + : defaultLocalUISettings.preferredStudioMode, + recentStudioLaunches: normalizeRecentStudioLaunches( + candidate.recentStudioLaunches + ), + }; +} + +export function getRecommendedLocalUISettings( + runtimeDiagnostics?: Partial | null +): LocalUISettings { + const recommendation = { + ...defaultLocalUISettings, + }; + + if (!runtimeDiagnostics) { + return recommendation; + } + + const totalMemoryGb = runtimeDiagnostics.totalMemoryGb ?? 0; + + if ( + runtimeDiagnostics.issueCode !== undefined && + runtimeDiagnostics.issueCode !== 'none' + ) { + return { + ...recommendation, + interfaceDensity: 'compact', + reduceMotion: true, + performanceProfile: 'efficient', + aiAccelerationPreference: runtimeDiagnostics.npuDetected + ? 'prefer-npu' + : 'auto', + compileRecommendationMode: 'safe-first', + startupPage: 'settings', + editorAssistanceLevel: 'guided', + windowsQuickActionDensity: 'focused', + }; + } + + if (runtimeDiagnostics.npuDetected) { + return { + ...recommendation, + useWideLayout: true, + performanceProfile: 'responsive', + aiAccelerationPreference: 'prefer-npu', + compileRecommendationMode: 'fast-feedback', + startupPage: 'explore', + }; + } + + if (totalMemoryGb > 0 && totalMemoryGb <= 8) { + return { + ...recommendation, + interfaceDensity: 'compact', + reduceMotion: true, + performanceProfile: 'efficient', + aiAccelerationPreference: 'off', + compileRecommendationMode: 'safe-first', + editorAssistanceLevel: 'guided', + windowsQuickActionDensity: 'focused', + }; + } + + if (totalMemoryGb >= 16) { + return { + ...recommendation, + useWideLayout: true, + performanceProfile: 'responsive', + aiAccelerationPreference: 'auto', + compileRecommendationMode: 'fast-feedback', + startupPage: 'explore', + }; + } + + return recommendation; +} + +export function mergeLocalUISettings( + current: LocalUISettings, + updates: Partial +) { + return normalizeLocalUISettings({ + ...current, + ...updates, + }); +} + +export function readLocalUISettings(storage?: Storage | null) { + const targetStorage = getStorage(storage); + + if (!targetStorage) { + return defaultLocalUISettings; + } + + try { + return normalizeLocalUISettings( + JSON.parse( + targetStorage.getItem(localUISettingsStorageKey) ?? 'null' + ) + ); + } catch { + return defaultLocalUISettings; + } +} + +export function hasStoredLocalUISettings(storage?: Storage | null) { + const targetStorage = getStorage(storage); + + if (!targetStorage) { + return false; + } + + try { + return targetStorage.getItem(localUISettingsStorageKey) !== null; + } catch { + return false; + } +} + +export function writeLocalUISettings( + settings: LocalUISettings, + storage?: Storage | null +) { + const targetStorage = getStorage(storage); + + if (!targetStorage) { + return; + } + + try { + targetStorage.setItem( + localUISettingsStorageKey, + JSON.stringify(settings) + ); + } catch { + // Ignore storage write errors so the UI remains usable in restricted hosts. + } +} + +export type AppUISettingsContextType = Partial & { + localUISettings: LocalUISettings; + setLocalUISettings: (updates: Partial) => void; + resetLocalUISettings: () => void; + openSetupAssistant: () => void; +}; export const AppUISettingsContext = - React.createContext({}); + React.createContext({ + localUISettings: defaultLocalUISettings, + setLocalUISettings: () => undefined, + resetLocalUISettings: () => undefined, + openSetupAssistant: () => undefined, + }); diff --git a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/About.tsx b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/About.tsx index 98e5d54..2e9324d 100644 --- a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/About.tsx +++ b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/About.tsx @@ -1,140 +1,1494 @@ -import { Alert, Button } from 'antd'; -import { useContext, useState } from 'react'; +import { Alert, Button, Card, message } from 'antd'; +import { useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import styled from 'styled-components'; import { AppUISettingsContext } from '../appUISettings'; +import { + useGetAppSettings, + useOpenExternal, + useOpenPath, + useRepairRuntimeConfig +} from '../webviewIPC'; +import { AppRuntimeDiagnostics, AppSettings } from '../webviewIPCMessages'; import { ChangelogModal } from './ChangelogModal'; +import { mockRuntimeDiagnostics, mockSettings } from './mockData'; import { UpdateModal } from './UpdateModal'; +type StatusTone = 'default' | 'success' | 'warning' | 'error'; + +type StatusItem = { + key: string; + text: string; + tone: StatusTone; +}; + +type SummaryItem = { + label: string; + value: string; +}; + +type PathItem = { + key: string; + label: string; + value: string; + openPath?: string | null; +}; + +type LinkItem = { + key: string; + label: string; + href: string; +}; + +type BuiltWithItem = { + key: string; + label?: string; + href?: string; + description: string; +}; + +type QuickActionItem = { + key: string; + title: string; + description: string; + kind: 'path' | 'uri'; + target: string; +}; + const AboutContainer = styled.div` + padding: 8px 0 32px; +`; + +const HeroCard = styled.section` + margin-bottom: var(--app-section-gap); + padding: calc(var(--app-card-padding) + 4px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 16px; + background: + radial-gradient(circle at top right, rgba(23, 125, 220, 0.25), transparent 45%), + radial-gradient(circle at bottom left, rgba(255, 255, 255, 0.12), transparent 40%), + rgba(20, 20, 20, 0.6); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + box-shadow: 0 8px 32px -8px rgba(0, 0, 0, 0.5); + transition: transform 0.4s cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 0.4s ease-out; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 12px 48px -12px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(255, 255, 255, 0.15) inset; + } +`; + +const HeroEyebrow = styled.div` + color: rgba(255, 255, 255, 0.58); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +`; + +const HeroTitle = styled.h1` + margin: 10px 0 8px; + font-size: 34px; + line-height: 1.05; +`; + +const HeroSubtitle = styled.p` + margin-bottom: 8px; + color: rgba(255, 255, 255, 0.78); + font-size: 18px; +`; + +const HeroDescription = styled.p` + max-width: 760px; + margin-bottom: 18px; + color: rgba(255, 255, 255, 0.64); +`; + +const HeroActionRow = styled.div` + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 18px; +`; + +const HeroAlert = styled(Alert)` + margin-top: 18px; +`; + +const AboutGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: var(--app-section-gap); +`; + +const SectionCard = styled(Card)` + /* Premium Glassmorphism */ + background: rgba(26, 26, 26, 0.4) !important; + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid rgba(255, 255, 255, 0.08) !important; + border-radius: 12px !important; + box-shadow: 0 4px 24px -6px rgba(0, 0, 0, 0.3) !important; + transition: transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 0.3s ease-out, border-color 0.3s ease-out !important; + + &:hover { + transform: translateY(-4px); + box-shadow: 0 12px 32px -8px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.1) inset !important; + border-color: rgba(255, 255, 255, 0.15) !important; + } + + .ant-card-body { + padding: var(--app-card-padding); + } +`; + +const SectionHeading = styled.div` + margin-bottom: 16px; +`; + +const SectionTitle = styled.h2` + margin: 0 0 6px; + font-size: 18px; +`; + +const SectionDescription = styled.p` + margin: 0; + color: rgba(255, 255, 255, 0.62); +`; + +const StatusRow = styled.div` + display: flex; + flex-wrap: wrap; + gap: 10px; +`; + +const StatusPill = styled.span<{ $tone: StatusTone }>` + position: relative; + display: inline-flex; + align-items: center; + min-height: var(--app-status-pill-height); + padding: 0 14px 0 30px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 999px; + background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.88); + font-size: 12px; + font-weight: 600; + + &::before { + content: ''; + position: absolute; + left: 12px; + width: 8px; + height: 8px; + border-radius: 999px; + background: ${({ $tone }) => { + switch ($tone) { + case 'success': + return '#73d13d'; + case 'error': + return '#ff7875'; + case 'warning': + return '#ffc53d'; + default: + return '#69c0ff'; + } + }}; + } +`; + +const SummaryList = styled.div` + display: flex; + flex-direction: column; +`; + +const SummaryRow = styled.div` + display: flex; + justify-content: space-between; + gap: 16px; + padding: 12px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + + &:last-child { + padding-bottom: 0; + border-bottom: 0; + } +`; + +const SummaryLabel = styled.div` + color: rgba(255, 255, 255, 0.62); +`; + +const SummaryValue = styled.div` + color: rgba(255, 255, 255, 0.92); + font-weight: 600; + text-align: right; +`; + +const ResourceList = styled.div` display: flex; flex-direction: column; - height: 100%; + gap: 12px; +`; + +const ResourceItem = styled.a` + display: flex; + justify-content: space-between; + gap: 16px; + padding: 14px 16px; + color: inherit; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 14px; + background: rgba(255, 255, 255, 0.04); + transition: border-color 0.2s ease, background-color 0.2s ease; + + &:hover { + color: inherit; + border-color: rgba(23, 125, 220, 0.35); + background: rgba(23, 125, 220, 0.08); + } +`; - // Without this the centered content looks too low. - padding-bottom: 10vh; +const ResourceLabel = styled.span` + font-weight: 600; `; -const AboutContent = styled.div` - margin: auto; - text-align: center; +const ResourceUrl = styled.span` + color: rgba(255, 255, 255, 0.5); + font-size: 12px; `; -const ContentSection = styled.div` - margin-bottom: 1.5em; +const BuiltWithList = styled.div` + display: flex; + flex-direction: column; + gap: 12px; +`; - h1, - h2, - h3, - h4, - h5, - h6 { - margin-bottom: 0; +const BuiltWithItemRow = styled.div` + padding-bottom: 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + + &:last-child { + padding-bottom: 0; + border-bottom: 0; } `; -const UpdateNoticeDescription = styled.div` +const BuiltWithLabel = styled.div` + margin-bottom: 4px; + font-weight: 600; +`; + +const DiagnosticsNotice = styled(Alert)` + margin-bottom: 16px; +`; + +const DiagnosticsPathList = styled.div` display: flex; flex-direction: column; - row-gap: 8px; + gap: 10px; + margin-top: 18px; +`; + +const DiagnosticsPathItem = styled.div` + padding: 12px 14px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 14px; + background: rgba(255, 255, 255, 0.04); +`; + +const DiagnosticsPathLabel = styled.div` + margin-bottom: 4px; + color: rgba(255, 255, 255, 0.6); + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; +`; + +const DiagnosticsPathValue = styled.div` + color: rgba(255, 255, 255, 0.9); + font-family: 'Cascadia Mono', Consolas, monospace; + font-size: 12px; + line-height: 1.5; + word-break: break-all; `; -const ButtonGroup = styled.div` +const DiagnosticsPathActions = styled.div` display: flex; + flex-wrap: wrap; gap: 8px; - justify-content: center; + margin-top: 12px; +`; + +const QuickActionsGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; +`; + +const QuickActionCard = styled.button` + padding: 14px 16px; + text-align: left; + color: inherit; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 14px; + background: rgba(255, 255, 255, 0.04); + cursor: pointer; + transition: border-color 0.2s ease, background-color 0.2s ease, + transform 0.2s ease; + + &:hover { + border-color: rgba(23, 125, 220, 0.35); + background: rgba(23, 125, 220, 0.08); + transform: translateY(-1px); + } + + &:disabled { + cursor: wait; + opacity: 0.7; + transform: none; + } `; +const QuickActionTitle = styled.div` + margin-bottom: 6px; + font-weight: 600; +`; + +const QuickActionDescription = styled.div` + color: rgba(255, 255, 255, 0.66); + line-height: 1.45; +`; + +function copyText(text: string) { + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.setAttribute('readonly', ''); + textArea.style.position = 'fixed'; + textArea.style.top = '0'; + textArea.style.left = '0'; + textArea.style.opacity = '0'; + + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + let successful = false; + + try { + successful = document.execCommand('copy'); + } finally { + document.body.removeChild(textArea); + } + + return successful; +} + function About() { const { t } = useTranslation(); const [changelogModalOpen, setChangelogModalOpen] = useState(false); const [updateModalOpen, setUpdateModalOpen] = useState(false); + const [appSettings, setAppSettings] = useState | null>( + mockSettings + ); + const [runtimeDiagnostics, setRuntimeDiagnostics] = + useState(mockRuntimeDiagnostics); + + const { + language, + devModeOptOut, + loggingEnabled, + localUISettings, + safeMode, + updateIsAvailable, + } = useContext(AppUISettingsContext); + + const { getAppSettings } = useGetAppSettings( + useCallback((data) => { + setAppSettings(data.appSettings); + setRuntimeDiagnostics(data.runtimeDiagnostics || null); + }, []) + ); + + const { repairRuntimeConfig, repairRuntimeConfigPending } = + useRepairRuntimeConfig( + useCallback( + (data) => { + if (data.succeeded) { + setRuntimeDiagnostics(data.runtimeDiagnostics || null); + message.success(t('about.runtime.actions.repairSuccess')); + return; + } - const { updateIsAvailable } = useContext(AppUISettingsContext); + message.error( + data.error || (t('about.runtime.actions.repairFailed') as string) + ); + }, + [t] + ) + ); + + const { openExternal, openExternalPending } = useOpenExternal( + useCallback( + (data) => { + if (!data.succeeded) { + message.error( + data.error || (t('about.actions.openError') as string) + ); + } + }, + [t] + ) + ); + + const { openPath, openPathPending } = useOpenPath( + useCallback( + (data) => { + if (!data.succeeded) { + message.error( + data.error || (t('about.actions.openError') as string) + ); + } + }, + [t] + ) + ); + + useEffect(() => { + getAppSettings({}); + }, [getAppSettings]); const currentVersion = ( process.env['REACT_APP_VERSION'] || 'unknown' ).replace(/^(\d+(?:\.\d+)+?)(\.0+)+$/, '$1'); + const performanceProfileLabel = useCallback( + (profile: 'balanced' | 'responsive' | 'efficient') => { + switch (profile) { + case 'responsive': + return t('settings.performance.profile.options.responsive'); + case 'efficient': + return t('settings.performance.profile.options.efficient'); + case 'balanced': + default: + return t('settings.performance.profile.options.balanced'); + } + }, + [t] + ); + + const aiAccelerationLabel = useCallback( + (preference: 'auto' | 'prefer-npu' | 'off') => { + switch (preference) { + case 'prefer-npu': + return t('settings.performance.aiAcceleration.options.preferNpu'); + case 'off': + return t('settings.performance.aiAcceleration.options.off'); + case 'auto': + default: + return t('settings.performance.aiAcceleration.options.auto'); + } + }, + [t] + ); + + const startupPageLabel = useCallback( + (startupPage: 'home' | 'explore' | 'settings' | 'about') => { + switch (startupPage) { + case 'explore': + return t('settings.workflow.startupPage.options.explore'); + case 'settings': + return t('settings.workflow.startupPage.options.settings'); + case 'about': + return t('settings.workflow.startupPage.options.about'); + case 'home': + default: + return t('settings.workflow.startupPage.options.home'); + } + }, + [t] + ); + + const exploreDefaultSortLabel = useCallback( + ( + sortPreference: + | 'smart-relevance' + | 'last-updated' + | 'popular-top-rated' + ) => { + switch (sortPreference) { + case 'last-updated': + return t('settings.workflow.exploreDefaultSort.options.lastUpdated'); + case 'popular-top-rated': + return t( + 'settings.workflow.exploreDefaultSort.options.popularTopRated' + ); + case 'smart-relevance': + default: + return t( + 'settings.workflow.exploreDefaultSort.options.smartRelevance' + ); + } + }, + [t] + ); + + const editorAssistanceLabel = useCallback( + (assistanceLevel: 'streamlined' | 'guided' | 'full') => { + switch (assistanceLevel) { + case 'streamlined': + return t('settings.workflow.editorAssistance.options.streamlined'); + case 'guided': + return t('settings.workflow.editorAssistance.options.guided'); + case 'full': + default: + return t('settings.workflow.editorAssistance.options.full'); + } + }, + [t] + ); + + const windowsQuickActionDensityLabel = useCallback( + (density: 'focused' | 'expanded') => { + switch (density) { + case 'focused': + return t('settings.workflow.windowsQuickActions.options.focused'); + case 'expanded': + default: + return t('settings.workflow.windowsQuickActions.options.expanded'); + } + }, + [t] + ); + + const workspaceItems = useMemo( + () => [ + { + label: t('about.workspace.language'), + value: language || appSettings?.language || 'en', + }, + { + label: t('about.workspace.updateChecks'), + value: appSettings?.disableUpdateCheck + ? t('about.values.disabled') + : t('about.values.enabled'), + }, + { + label: t('about.workspace.developerMode'), + value: devModeOptOut + ? t('about.values.hidden') + : t('about.values.visible'), + }, + { + label: t('about.workspace.compileLocally'), + value: appSettings?.alwaysCompileModsLocally + ? t('about.values.enabled') + : t('about.values.disabled'), + }, + { + label: t('about.workspace.trayIcon'), + value: appSettings?.hideTrayIcon + ? t('about.values.hidden') + : t('about.values.visible'), + }, + { + label: t('about.workspace.toolkitDialog'), + value: appSettings?.dontAutoShowToolkit + ? t('about.values.manual') + : t('about.values.automatic'), + }, + { + label: t('about.workspace.interfaceDensity'), + value: + localUISettings.interfaceDensity === 'compact' + ? t('settings.interface.layoutDensity.compact') + : t('settings.interface.layoutDensity.comfortable'), + }, + { + label: t('about.workspace.layoutWidth'), + value: localUISettings.useWideLayout + ? t('about.values.wide') + : t('about.values.standard'), + }, + { + label: t('about.workspace.motion'), + value: localUISettings.reduceMotion + ? t('about.values.reduced') + : t('about.values.standard'), + }, + { + label: t('about.workspace.performanceProfile'), + value: performanceProfileLabel(localUISettings.performanceProfile), + }, + { + label: t('about.workspace.aiAcceleration'), + value: aiAccelerationLabel(localUISettings.aiAccelerationPreference), + }, + { + label: t('about.workspace.startupPage'), + value: startupPageLabel(localUISettings.startupPage), + }, + { + label: t('about.workspace.exploreDefaultSort'), + value: exploreDefaultSortLabel(localUISettings.exploreDefaultSort), + }, + { + label: t('about.workspace.editorAssistance'), + value: editorAssistanceLabel(localUISettings.editorAssistanceLevel), + }, + { + label: t('about.workspace.windowsQuickActions'), + value: windowsQuickActionDensityLabel( + localUISettings.windowsQuickActionDensity + ), + }, + ], + [ + aiAccelerationLabel, + appSettings, + devModeOptOut, + editorAssistanceLabel, + exploreDefaultSortLabel, + language, + localUISettings, + performanceProfileLabel, + startupPageLabel, + t, + windowsQuickActionDensityLabel, + ] + ); + + const runtimeModeLabel = useCallback( + (portable: boolean | null | undefined) => { + if (portable === null || portable === undefined) { + return t('about.runtime.values.missing'); + } + + return portable + ? t('about.runtime.values.portable') + : t('about.runtime.values.installed'); + }, + [t] + ); + + const runtimeIssueText = useMemo(() => { + if (!runtimeDiagnostics) { + return null; + } + + switch (runtimeDiagnostics.issueCode) { + case 'engine-config-missing': + return t('about.runtime.issue.engineConfigMissing'); + case 'engine-storage-mismatch': + return t('about.runtime.issue.engineStorageMismatch'); + default: + return t('about.runtime.issue.none'); + } + }, [runtimeDiagnostics, t]); + + const statusItems = useMemo(() => { + const items: StatusItem[] = [ + { + key: 'update', + text: updateIsAvailable + ? t('about.status.updateAvailable') + : t('about.status.upToDate'), + tone: updateIsAvailable ? 'error' : 'success', + }, + { + key: 'safe-mode', + text: safeMode + ? t('about.status.safeModeOn') + : t('about.status.safeModeOff'), + tone: safeMode ? 'warning' : 'success', + }, + { + key: 'logging', + text: loggingEnabled + ? t('about.status.loggingOn') + : t('about.status.loggingOff'), + tone: loggingEnabled ? 'warning' : 'default', + }, + { + key: 'dev-mode', + text: devModeOptOut + ? t('about.status.devModeOff') + : t('about.status.devModeOn'), + tone: devModeOptOut ? 'default' : 'success', + }, + ]; + + if (runtimeDiagnostics) { + items.push({ + key: 'runtime-storage', + text: runtimeDiagnostics.engineConfigMatchesAppConfig + ? t('about.status.storageAligned') + : t('about.status.storageMismatch'), + tone: runtimeDiagnostics.engineConfigMatchesAppConfig + ? 'success' + : 'error', + }); + } + + return items; + }, [ + devModeOptOut, + loggingEnabled, + runtimeDiagnostics, + safeMode, + t, + updateIsAvailable, + ]); + + const runtimeSummaryItems = useMemo( + () => + runtimeDiagnostics + ? [ + { + label: t('about.runtime.modes.platform'), + value: runtimeDiagnostics.platformArch, + }, + { + label: t('about.runtime.modes.appMode'), + value: runtimeModeLabel(runtimeDiagnostics.portable), + }, + { + label: t('about.runtime.modes.engineMode'), + value: runtimeModeLabel(runtimeDiagnostics.enginePortable), + }, + { + label: t('about.runtime.modes.arm64'), + value: runtimeDiagnostics.arm64Enabled + ? t('about.values.enabled') + : t('about.values.disabled'), + }, + ] + : [], + [runtimeDiagnostics, runtimeModeLabel, t] + ); + + const windowsSummaryItems = useMemo( + () => + runtimeDiagnostics + ? [ + { + label: t('about.windows.summary.version'), + value: + runtimeDiagnostics.windowsProductName || + t('about.runtime.values.missing'), + }, + { + label: t('about.windows.summary.release'), + value: + runtimeDiagnostics.windowsDisplayVersion || + t('about.runtime.values.missing'), + }, + { + label: t('about.windows.summary.build'), + value: runtimeDiagnostics.windowsBuild, + }, + { + label: t('about.windows.summary.memory'), + value: `${runtimeDiagnostics.totalMemoryGb} GB`, + }, + { + label: t('about.windows.summary.npu'), + value: + runtimeDiagnostics.npuName || + (runtimeDiagnostics.npuDetected + ? t('about.windows.values.detected') + : t('about.windows.values.none')), + }, + { + label: t('about.windows.summary.installationType'), + value: + runtimeDiagnostics.windowsInstallationType || + t('about.runtime.values.missing'), + }, + { + label: t('about.windows.summary.session'), + value: + runtimeDiagnostics.isElevated === null + ? t('about.runtime.values.missing') + : runtimeDiagnostics.isElevated + ? t('about.windows.values.elevated') + : t('about.windows.values.standard'), + }, + { + label: t('about.windows.summary.host'), + value: runtimeDiagnostics.hostName, + }, + { + label: t('about.windows.summary.user'), + value: + runtimeDiagnostics.userName || + t('about.runtime.values.missing'), + }, + ] + : [], + [runtimeDiagnostics, t] + ); + + const runtimePathItems = useMemo( + () => + runtimeDiagnostics + ? [ + { + key: 'app-root', + label: t('about.runtime.paths.appRoot'), + value: runtimeDiagnostics.appRootPath, + openPath: runtimeDiagnostics.appRootPath, + }, + { + key: 'app-data', + label: t('about.runtime.paths.appData'), + value: runtimeDiagnostics.appDataPath, + openPath: runtimeDiagnostics.appDataPath, + }, + { + key: 'expected-engine-data', + label: t('about.runtime.paths.expectedEngineData'), + value: runtimeDiagnostics.expectedEngineAppDataPath, + openPath: runtimeDiagnostics.expectedEngineAppDataPath, + }, + { + key: 'actual-engine-data', + label: t('about.runtime.paths.actualEngineData'), + value: + runtimeDiagnostics.engineAppDataPath || + t('about.runtime.values.missing'), + openPath: runtimeDiagnostics.engineAppDataPath, + }, + { + key: 'engine', + label: t('about.runtime.paths.engine'), + value: runtimeDiagnostics.enginePath, + openPath: runtimeDiagnostics.enginePath, + }, + { + key: 'expected-engine-registry', + label: t('about.runtime.paths.expectedEngineRegistry'), + value: + runtimeDiagnostics.expectedEngineRegistryKey || + t('about.runtime.values.missing'), + }, + { + key: 'actual-engine-registry', + label: t('about.runtime.paths.actualEngineRegistry'), + value: + runtimeDiagnostics.engineRegistryKey || + t('about.runtime.values.missing'), + }, + { + key: 'compiler', + label: t('about.runtime.paths.compiler'), + value: runtimeDiagnostics.compilerPath, + openPath: runtimeDiagnostics.compilerPath, + }, + { + key: 'ui', + label: t('about.runtime.paths.ui'), + value: runtimeDiagnostics.uiPath, + openPath: runtimeDiagnostics.uiPath, + }, + ] + : [], + [runtimeDiagnostics, t] + ); + + const windowsPathItems = useMemo( + () => + runtimeDiagnostics + ? [ + { + key: 'windows-directory', + label: t('about.windows.paths.windowsDirectory'), + value: + runtimeDiagnostics.windowsDirectory || + t('about.runtime.values.missing'), + openPath: runtimeDiagnostics.windowsDirectory, + }, + { + key: 'temp-directory', + label: t('about.windows.paths.tempDirectory'), + value: runtimeDiagnostics.tempDirectory, + openPath: runtimeDiagnostics.tempDirectory, + }, + ] + : [], + [runtimeDiagnostics, t] + ); + + const supportSnapshot = useMemo( + () => + [ + `Windhawk ${currentVersion}`, + runtimeDiagnostics?.windowsProductName + ? `Windows: ${runtimeDiagnostics.windowsProductName}` + : null, + runtimeDiagnostics?.windowsDisplayVersion + ? `Windows release: ${runtimeDiagnostics.windowsDisplayVersion}` + : null, + runtimeDiagnostics ? `Windows build: ${runtimeDiagnostics.windowsBuild}` : null, + runtimeDiagnostics + ? `Session elevation: ${ + runtimeDiagnostics.isElevated === null + ? t('about.runtime.values.missing') + : runtimeDiagnostics.isElevated + ? t('about.windows.values.elevated') + : t('about.windows.values.standard') + }` + : null, + runtimeDiagnostics ? `Host: ${runtimeDiagnostics.hostName}` : null, + `Language: ${language || appSettings?.language || 'en'}`, + `Update available: ${ + updateIsAvailable + ? t('about.values.enabled') + : t('about.values.disabled') + }`, + `Update checks: ${ + appSettings?.disableUpdateCheck + ? t('about.values.disabled') + : t('about.values.enabled') + }`, + `Developer mode: ${ + devModeOptOut ? t('about.values.hidden') : t('about.values.visible') + }`, + `Safe mode: ${ + safeMode ? t('about.values.enabled') : t('about.values.disabled') + }`, + `Debug logging: ${ + loggingEnabled + ? t('about.values.enabled') + : t('about.values.disabled') + }`, + `Interface density: ${ + localUISettings.interfaceDensity === 'compact' + ? t('settings.interface.layoutDensity.compact') + : t('settings.interface.layoutDensity.comfortable') + }`, + `Layout width: ${ + localUISettings.useWideLayout + ? t('about.values.wide') + : t('about.values.standard') + }`, + `Motion: ${ + localUISettings.reduceMotion + ? t('about.values.reduced') + : t('about.values.standard') + }`, + `Startup page: ${startupPageLabel(localUISettings.startupPage)}`, + `Explore default sort: ${exploreDefaultSortLabel( + localUISettings.exploreDefaultSort + )}`, + `Editor assistance: ${editorAssistanceLabel( + localUISettings.editorAssistanceLevel + )}`, + `Windows quick actions: ${windowsQuickActionDensityLabel( + localUISettings.windowsQuickActionDensity + )}`, + runtimeDiagnostics + ? `Runtime storage: ${ + runtimeDiagnostics.engineConfigMatchesAppConfig + ? t('about.runtime.values.aligned') + : t('about.runtime.values.mismatched') + }` + : null, + runtimeDiagnostics + ? `Runtime platform: ${runtimeDiagnostics.platformArch}` + : null, + runtimeDiagnostics + ? `Runtime mode: ${runtimeModeLabel(runtimeDiagnostics.portable)}` + : null, + ].filter(Boolean).join('\n'), + [ + appSettings?.disableUpdateCheck, + appSettings?.language, + currentVersion, + devModeOptOut, + editorAssistanceLabel, + exploreDefaultSortLabel, + language, + localUISettings.editorAssistanceLevel, + localUISettings.exploreDefaultSort, + localUISettings.interfaceDensity, + localUISettings.reduceMotion, + localUISettings.startupPage, + localUISettings.useWideLayout, + localUISettings.windowsQuickActionDensity, + loggingEnabled, + runtimeDiagnostics, + runtimeModeLabel, + safeMode, + startupPageLabel, + t, + updateIsAvailable, + windowsQuickActionDensityLabel, + ] + ); + + const copySupportSnapshot = useCallback(() => { + if (copyText(supportSnapshot)) { + message.success(t('about.actions.copySuccess')); + } else { + message.error(t('about.actions.copyError')); + } + }, [supportSnapshot, t]); + + const copyTextWithFeedback = useCallback( + (text: string) => { + if (copyText(text)) { + message.success(t('about.actions.copyPathSuccess')); + } else { + message.error(t('about.actions.copyPathError')); + } + }, + [t] + ); + + const openPathInShell = useCallback( + (targetPath: string) => { + openPath({ + path: targetPath, + }); + }, + [openPath] + ); + + const openUri = useCallback( + (uri: string) => { + openExternal({ + uri, + }); + }, + [openExternal] + ); + + const windowsQuickActions = useMemo( + () => + runtimeDiagnostics + ? [ + { + key: 'windows-update', + title: t('about.windows.actions.windowsUpdate.title'), + description: t('about.windows.actions.windowsUpdate.description'), + kind: 'uri', + target: 'ms-settings:windowsupdate', + }, + { + key: 'taskbar-settings', + title: t('about.windows.actions.taskbar.title'), + description: t('about.windows.actions.taskbar.description'), + kind: 'uri', + target: 'ms-settings:personalization-taskbar', + }, + { + key: 'start-settings', + title: t('about.windows.actions.start.title'), + description: t('about.windows.actions.start.description'), + kind: 'uri', + target: 'ms-settings:personalization-start', + }, + { + key: 'notification-settings', + title: t('about.windows.actions.notifications.title'), + description: t('about.windows.actions.notifications.description'), + kind: 'uri', + target: 'ms-settings:notifications', + }, + { + key: 'multitasking-settings', + title: t('about.windows.actions.multitasking.title'), + description: t('about.windows.actions.multitasking.description'), + kind: 'uri', + target: 'ms-settings:multitasking', + }, + { + key: 'colors-settings', + title: t('about.windows.actions.colors.title'), + description: t('about.windows.actions.colors.description'), + kind: 'uri', + target: 'ms-settings:colors', + }, + { + key: 'background-settings', + title: t('about.windows.actions.background.title'), + description: t('about.windows.actions.background.description'), + kind: 'uri', + target: 'ms-settings:personalization-background', + }, + { + key: 'themes-settings', + title: t('about.windows.actions.themes.title'), + description: t('about.windows.actions.themes.description'), + kind: 'uri', + target: 'ms-settings:themes', + }, + { + key: 'lockscreen-settings', + title: t('about.windows.actions.lockScreen.title'), + description: t('about.windows.actions.lockScreen.description'), + kind: 'uri', + target: 'ms-settings:lockscreen', + }, + { + key: 'clipboard-settings', + title: t('about.windows.actions.clipboard.title'), + description: t('about.windows.actions.clipboard.description'), + kind: 'uri', + target: 'ms-settings:clipboard', + }, + { + key: 'startup-apps', + title: t('about.windows.actions.startupApps.title'), + description: t('about.windows.actions.startupApps.description'), + kind: 'uri', + target: 'ms-settings:startupapps', + }, + { + key: 'sound-settings', + title: t('about.windows.actions.sound.title'), + description: t('about.windows.actions.sound.description'), + kind: 'uri', + target: 'ms-settings:sound', + }, + { + key: 'app-data-folder', + title: t('about.windows.actions.appData.title'), + description: t('about.windows.actions.appData.description'), + kind: 'path', + target: runtimeDiagnostics.appDataPath, + }, + { + key: 'engine-folder', + title: t('about.windows.actions.engine.title'), + description: t('about.windows.actions.engine.description'), + kind: 'path', + target: runtimeDiagnostics.enginePath, + }, + ] + : [], + [runtimeDiagnostics, t] + ); + + const visibleWindowsQuickActions = useMemo(() => { + if (localUISettings.windowsQuickActionDensity === 'expanded') { + return windowsQuickActions; + } + + const focusedActionKeys = new Set([ + 'windows-update', + 'taskbar-settings', + 'start-settings', + 'notification-settings', + 'multitasking-settings', + 'colors-settings', + 'app-data-folder', + 'engine-folder', + ]); + + return windowsQuickActions.filter(({ key }) => focusedActionKeys.has(key)); + }, [ + localUISettings.windowsQuickActionDensity, + windowsQuickActions, + ]); + + const links = useMemo( + () => [ + { + key: 'homepage', + label: t('about.links.homepage'), + href: 'https://windhawk.net/', + }, + { + key: 'documentation', + label: t('about.links.documentation'), + href: 'https://github.com/ramensoftware/windhawk/wiki', + }, + { + key: 'github', + label: t('about.links.github'), + href: 'https://github.com/ramensoftware/windhawk', + }, + { + key: 'translations', + label: t('about.links.translations'), + href: 'https://github.com/ramensoftware/windhawk/wiki/translations', + }, + ], + [t] + ); + + const builtWithItems = useMemo( + () => [ + { + key: 'vscodium', + label: 'VSCodium', + href: 'https://github.com/VSCodium/vscodium', + description: t('about.builtWith.vscodium'), + }, + { + key: 'llvm-mingw', + label: 'LLVM MinGW', + href: 'https://github.com/mstorsjo/llvm-mingw', + description: t('about.builtWith.llvmMingw'), + }, + { + key: 'minhook', + label: 'MinHook-Detours', + href: 'https://github.com/m417z/minhook-detours', + description: t('about.builtWith.minHook'), + }, + { + key: 'others', + description: t('about.builtWith.others'), + }, + ], + [t] + ); + return ( - - -

- {t('about.title', { - // version: currentVersion + ' ' + t('about.beta'), - version: currentVersion, - })} -

-

{t('about.subtitle')}

-

- website]} - /> -

-
+ + {t('about.eyebrow')} + + {t('about.title', { + version: currentVersion, + })} + + {t('about.subtitle')} + {t('about.pageDescription')} +
+ website]} + /> +
+ + + + {updateIsAvailable && ( + + )} + {updateIsAvailable && ( - - {t('about.update.title')}} + {t('about.update.title')}} + description={t('about.update.subtitle')} + type="info" + showIcon + /> + )} +
+ + + + + {t('about.status.title')} + {t('about.status.description')} + + + {statusItems.map(({ key, text, tone }) => ( + + {text} + + ))} + + + + + + {t('about.workspace.title')} + {t('about.workspace.description')} + + + {workspaceItems.map(({ label, value }) => ( + + {label} + {value} + + ))} + + + + + + {t('about.runtime.title')} + {t('about.runtime.description')} + + {runtimeDiagnostics && runtimeIssueText && ( + {runtimeIssueText}} description={ - -
{t('about.update.subtitle')}
- - + + )} + + {runtimePathItems.map(({ key, label, value, openPath: targetPath }) => ( + + {label} + {value} + + {targetPath && ( + + )} + + + + ))} + +
+ + + + {t('about.windows.title')} + {t('about.windows.description')} + + + {windowsSummaryItems.map(({ label, value }) => ( + + {label} + {value} + + ))} + + + {windowsPathItems.map(({ key, label, value, openPath: targetPath }) => ( + + {label} + {value} + + {targetPath && ( - - - } - type="info" - /> - - )} - -

{t('about.links.title')}

- -
- -

{t('about.builtWith.title')}

-
-
- VSCodium - {' - '} - {t('about.builtWith.vscodium')} -
-
- LLVM MinGW - {' - '} - {t('about.builtWith.llvmMingw')} -
-
- MinHook-Detours - {' - '} - {t('about.builtWith.minHook')} -
-
{t('about.builtWith.others')}
-
-
-
+ )} + + + + ))} + + + + + + {t('about.windows.quickActionsTitle')} + + {t('about.windows.quickActionsDescription')} + + + + {visibleWindowsQuickActions.map(({ key, title, description, kind, target }) => ( + + kind === 'path' ? openPathInShell(target) : openUri(target) + } + > + {title} + {description} + + ))} + + + + + + {t('about.links.title')} + {t('about.links.description')} + + + {links.map(({ key, label, href }) => ( + + {label} + {href.replace(/^https?:\/\//, '')} + + ))} + + + + + + {t('about.builtWith.title')} + + {t('about.builtWith.description')} + + + + {builtWithItems.map(({ key, label, href, description }) => ( + + {label && ( + + {href ? {label} : label} + + )} +
{description}
+
+ ))} +
+
+ + setChangelogModalOpen(false)} diff --git a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/AppHeader.tsx b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/AppHeader.tsx index 43a607e..fa12f4f 100644 --- a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/AppHeader.tsx +++ b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/AppHeader.tsx @@ -15,42 +15,148 @@ import styled from 'styled-components'; import { AppUISettingsContext } from '../appUISettings'; import logo from './assets/logo-white.svg'; +type StatusTone = 'default' | 'warning' | 'error'; + +const HeaderShell = styled.div` + padding: 18px var(--app-horizontal-padding) 0; +`; + const Header = styled.header` display: flex; - align-items: center; - flex-wrap: wrap; - padding: 20px 20px 0; - column-gap: 20px; + flex-direction: column; + gap: 14px; + padding: var(--app-card-padding); margin: 0 auto; width: 100%; - max-width: var(--app-max-width); + max-width: calc(var(--app-max-width) + (var(--app-horizontal-padding) * 2)); + border: 1px solid var(--app-surface-border); + border-radius: var(--app-surface-radius); + background: + linear-gradient(140deg, rgba(23, 125, 220, 0.16), transparent 38%), + linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.03)); + box-shadow: var(--app-surface-shadow); +`; + +const HeaderTop = styled.div` + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 16px; `; -const HeaderLogo = styled.div` +const HeaderLogo = styled.button` + display: flex; + align-items: center; + gap: 12px; cursor: pointer; - margin-inline-end: auto; - font-size: 40px; + margin: 0 auto 0 0; + padding: 0; + color: inherit; + background: transparent; + border: 0; white-space: nowrap; - font-family: Oxanium; user-select: none; `; const LogoImage = styled.img` - height: 80px; - margin-inline-end: 6px; + height: 64px; +`; + +const LogoWordmark = styled.div` + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; +`; + +const LogoTitle = styled.div` + font-size: 38px; + line-height: 0.95; + font-family: Oxanium; +`; + +const LogoSubtitle = styled.div` + color: rgba(255, 255, 255, 0.58); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; `; const HeaderButtonsWrapper = styled.div` display: flex; flex-wrap: wrap; gap: 10px; - margin: 12px 0; `; const HeaderIcon = styled(FontAwesomeIcon)` margin-inline-end: 8px; `; +const NavButton = styled(Button)` + height: var(--app-nav-button-height); + padding-inline: 16px; + border-color: rgba(255, 255, 255, 0.12); + border-radius: 999px; + background: rgba(255, 255, 255, 0.04); + box-shadow: none; + + &.ant-btn:hover, + &.ant-btn:focus { + border-color: rgba(255, 255, 255, 0.22); + background: rgba(255, 255, 255, 0.08); + color: #fff; + } + + &.ant-btn-primary, + &.ant-btn-primary:hover, + &.ant-btn-primary:focus { + border-color: rgba(23, 125, 220, 0.45); + background: rgba(23, 125, 220, 0.18); + color: #fff; + } +`; + +const StatusRow = styled.div` + display: flex; + flex-wrap: wrap; + gap: 8px; +`; + +const StatusPill = styled.span<{ $tone: StatusTone }>` + position: relative; + display: inline-flex; + align-items: center; + min-height: var(--app-status-pill-height); + padding: 0 12px 0 28px; + color: rgba(255, 255, 255, 0.88); + font-size: 12px; + font-weight: 600; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 999px; + background: rgba(255, 255, 255, 0.04); + + &::before { + content: ''; + position: absolute; + left: 12px; + width: 8px; + height: 8px; + border-radius: 999px; + background: ${({ $tone }) => { + switch ($tone) { + case 'error': + return '#ff7875'; + case 'warning': + return '#ffc53d'; + default: + return '#69c0ff'; + } + }}; + box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.04); + } +`; + type HeaderButton = { text: string; route: string; @@ -61,6 +167,12 @@ type HeaderButton = { }; }; +type StatusItem = { + key: string; + text: string; + tone: StatusTone; +}; + function AppHeader() { const { t } = useTranslation(); @@ -68,7 +180,12 @@ function AppHeader() { const location = useLocation(); - const { loggingEnabled, updateIsAvailable } = useContext(AppUISettingsContext); + const { + loggingEnabled, + updateIsAvailable, + safeMode, + localUISettings, + } = useContext(AppUISettingsContext); const buttons: HeaderButton[] = [ { @@ -101,26 +218,80 @@ function AppHeader() { }, ]; + const statusItems: StatusItem[] = [ + updateIsAvailable + ? { key: 'update', text: t('appHeader.status.updateAvailable'), tone: 'error' as const } + : null, + safeMode + ? { key: 'safeMode', text: t('appHeader.status.safeMode'), tone: 'warning' as const } + : null, + loggingEnabled + ? { key: 'logging', text: t('appHeader.status.debugLogging'), tone: 'warning' as const } + : null, + localUISettings.interfaceDensity === 'compact' + ? { key: 'compact', text: t('appHeader.status.compactDensity'), tone: 'default' as const } + : null, + localUISettings.useWideLayout + ? { key: 'wide', text: t('appHeader.status.wideLayout'), tone: 'default' as const } + : null, + localUISettings.performanceProfile === 'responsive' + ? { + key: 'responsive-profile', + text: t('appHeader.status.responsiveProfile'), + tone: 'default' as const, + } + : localUISettings.performanceProfile === 'efficient' + ? { + key: 'efficient-profile', + text: t('appHeader.status.efficientProfile'), + tone: 'default' as const, + } + : null, + localUISettings.aiAccelerationPreference === 'prefer-npu' + ? { + key: 'npu-preferred', + text: t('appHeader.status.npuPreferred'), + tone: 'default' as const, + } + : null, + ].filter((item): item is StatusItem => item !== null); + return ( -
- navigate('/')}> - Windhawk - - - {buttons.map(({ text, route, icon, badge }) => ( - - - - ))} - -
+ +
+ + navigate('/')} type="button"> + + + Windhawk + {t('appHeader.tagline')} + + + + {buttons.map(({ text, route, icon, badge }) => ( + + navigate(route)} + > + + {text} + + + ))} + + + {statusItems.length > 0 && ( + + {statusItems.map(({ key, text, tone }) => ( + + {text} + + ))} + + )} +
+
); } diff --git a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/ChangelogModal.tsx b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/ChangelogModal.tsx index 9db7e80..d079e4b 100644 --- a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/ChangelogModal.tsx +++ b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/ChangelogModal.tsx @@ -1,9 +1,9 @@ -import { ConfigProvider, Modal, Result, Spin } from 'antd'; +import { Modal, Result, Spin } from 'antd'; import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import styled from 'styled-components'; -import ReactMarkdownCustom from '../components/ReactMarkdownCustom'; import { fetchText } from '../swrHelpers'; +import ChangelogViewer from './ChangelogViewer'; const CHANGELOG_URL = 'https://ramensoftware.com/downloads/windhawk_setup.exe?version&changelog'; @@ -81,13 +81,7 @@ export function ChangelogModal(props: Props) { /> )} {changelog && !loading && !hasError && ( - - - + )} diff --git a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/ChangelogViewer.tsx b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/ChangelogViewer.tsx new file mode 100644 index 0000000..1dcd542 --- /dev/null +++ b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/ChangelogViewer.tsx @@ -0,0 +1,216 @@ +import { Button, ConfigProvider, Empty, Select, Switch, Typography } from 'antd'; +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import styled from 'styled-components'; +import { InputWithContextMenu } from '../components/InputWithContextMenu'; +import { copyTextToClipboard } from '../utils'; +import ReactMarkdownCustom from '../components/ReactMarkdownCustom'; +import { + filterChangelogSections, + parseChangelogSections, + selectChangelogSections, +} from './changelogUtils'; + +const ViewerContainer = styled.div` + display: flex; + flex-direction: column; + gap: 16px; +`; + +const SummaryGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 12px; +`; + +const SummaryCard = styled.div` + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + padding: 12px 14px; + background: rgba(255, 255, 255, 0.02); +`; + +const SummaryLabel = styled(Typography.Text)` + display: block; + color: rgba(255, 255, 255, 0.6); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.04em; +`; + +const SummaryValue = styled.div` + margin-top: 6px; + color: rgba(255, 255, 255, 0.92); + font-size: 18px; + font-weight: 600; +`; + +const SearchInput = styled(InputWithContextMenu)` + max-width: 360px; +`; + +const ControlsRow = styled.div` + display: flex; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + align-items: center; +`; + +const ControlsCluster = styled.div` + display: flex; + gap: 12px; + flex-wrap: wrap; + align-items: center; +`; + +const SectionSelect = styled(Select)` + min-width: 220px; +`; + +const ControlLabel = styled(Typography.Text)` + color: rgba(255, 255, 255, 0.65); +`; + +interface Props { + markdown: string; + allowHtml?: boolean; +} + +function ChangelogViewer({ markdown, allowHtml = false }: Props) { + const { t } = useTranslation(); + const [filterText, setFilterText] = useState(''); + const [latestOnly, setLatestOnly] = useState(false); + const [selectedSectionIndex, setSelectedSectionIndex] = useState(null); + const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle'); + + const sections = useMemo( + () => parseChangelogSections(markdown), + [markdown] + ); + const scopedSections = useMemo( + () => selectChangelogSections(sections, { + latestOnly, + sectionIndex: latestOnly ? null : selectedSectionIndex, + }), + [latestOnly, sections, selectedSectionIndex] + ); + const visibleSections = useMemo( + () => filterChangelogSections(scopedSections, filterText), + [scopedSections, filterText] + ); + const sectionOptions = useMemo( + () => sections.map((section, index) => ({ + value: index, + label: section.heading || t('changelogViewer.controls.sectionFallback', { + index: index + 1, + }), + })), + [sections, t] + ); + + const latestHeading = sections[0]?.heading || t('changelogViewer.latestFallback'); + const totalHighlights = sections.reduce( + (sum, section) => sum + section.bulletCount, + 0 + ); + const hasScopedSelection = latestOnly || selectedSectionIndex !== null; + const visibleMarkdown = (filterText.trim() || hasScopedSelection) + ? visibleSections.map((section) => section.markdown).join('\n\n') + : markdown; + + useEffect(() => { + if ( + selectedSectionIndex !== null && + (selectedSectionIndex < 0 || selectedSectionIndex >= sections.length) + ) { + setSelectedSectionIndex(null); + } + }, [sections.length, selectedSectionIndex]); + + useEffect(() => { + if (copyState === 'idle') { + return undefined; + } + + const timeout = window.setTimeout(() => setCopyState('idle'), 1600); + return () => window.clearTimeout(timeout); + }, [copyState]); + + const handleCopyVisibleMarkdown = async () => { + try { + await copyTextToClipboard(visibleMarkdown); + setCopyState('copied'); + } catch (error) { + console.error('Failed to copy changelog:', error); + setCopyState('failed'); + } + }; + + return ( + + + + {t('changelogViewer.summary.latest')} + {latestHeading} + + + {t('changelogViewer.summary.sections')} + {sections.length} + + + {t('changelogViewer.summary.highlights')} + {totalHighlights} + + + + setFilterText(e.target.value)} + /> + + setSelectedSectionIndex( + typeof value === 'number' ? value : null + )} + /> + {t('changelogViewer.controls.latestOnly')} + + + + + {filterText.trim() && !visibleSections.length ? ( + + ) : ( + + + + )} + + ); +} + +export default ChangelogViewer; diff --git a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/CreateNewModButton.tsx b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/CreateNewModButton.tsx index 012c544..9a22f3c 100644 --- a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/CreateNewModButton.tsx +++ b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/CreateNewModButton.tsx @@ -1,10 +1,11 @@ import { faPen } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Button } from 'antd'; +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import styled from 'styled-components'; -import { createNewMod } from '../webviewIPC'; import DevModeAction from './DevModeAction'; +import NewModStudioModal from './NewModStudioModal'; const ButtonContainer = styled.div` position: fixed; @@ -31,19 +32,26 @@ const CreateButtonIcon = styled(FontAwesomeIcon)` function CreateNewModButton() { const { t } = useTranslation(); + const [studioOpen, setStudioOpen] = useState(false); return ( - - createNewMod()} - renderButton={(onClick) => ( - - {t('createNewModButton.title')} - - )} + <> + + setStudioOpen(true)} + renderButton={(onClick) => ( + + {t('createNewModButton.title')} + + )} + /> + + setStudioOpen(false)} /> - + ); } diff --git a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/ModCard.tsx b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/ModCard.tsx index faf3dc7..8dfd0b6 100644 --- a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/ModCard.tsx +++ b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/ModCard.tsx @@ -1,6 +1,6 @@ import { faUser } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { Badge, Button, Card, Divider, Rate, Switch, Tooltip } from 'antd'; +import { Badge, Button, Card, Divider, Rate, Switch, Tag, Tooltip } from 'antd'; import { useTranslation } from 'react-i18next'; import styled, { css } from 'styled-components'; import EllipsisText from '../components/EllipsisText'; @@ -28,6 +28,21 @@ const ModCardWrapperInner = styled(Card)` // Fill whole height and stick buttons to the bottom. height: 100%; + /* Premium Glassmorphism */ + background: rgba(26, 26, 26, 0.4) !important; + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid rgba(255, 255, 255, 0.08) !important; + border-radius: 12px !important; + box-shadow: 0 4px 24px -6px rgba(0, 0, 0, 0.3) !important; + transition: transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 0.3s ease-out, border-color 0.3s ease-out !important; + + &:hover { + transform: translateY(-4px); + box-shadow: 0 12px 32px -8px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.1) inset !important; + border-color: rgba(255, 255, 255, 0.15) !important; + } + > .ant-card-body { height: 100%; display: flex; @@ -137,6 +152,23 @@ const BreakdownCount = styled.span` white-space: nowrap; `; +const InsightsRow = styled.div` + display: flex; + gap: 6px; + flex-wrap: wrap; + margin-top: 12px; +`; + +const InsightTag = styled(Tag)` + margin-inline-end: 0; + border-radius: 999px; + background: linear-gradient(135deg, rgba(56, 142, 211, 0.15) 0%, rgba(56, 142, 211, 0.05) 100%); + border-color: rgba(56, 142, 211, 0.4); + color: rgba(255, 255, 255, 0.9); + padding: 0 10px; + box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.05); +`; + interface Props { ribbonText?: string; title: string; @@ -144,6 +176,7 @@ interface Props { description?: string; modMetadata?: ModMetadata; repositoryDetails?: RepositoryDetails; + insights?: string[]; buttons: { text: React.ReactNode; confirmText?: string; @@ -248,6 +281,13 @@ function ModCard(props: Props) { } description={props.description || {t('mod.noDescription')}} /> + {props.insights && props.insights.length > 0 && ( + + {props.insights.map((insight) => ( + {insight} + ))} + + )} {props.buttons.map((button, i) => { const buttonElement = button.confirmText ? ( diff --git a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/ModDetails.tsx b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/ModDetails.tsx index 1b3e551..84ac916 100644 --- a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/ModDetails.tsx +++ b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/ModDetails.tsx @@ -296,7 +296,7 @@ interface Props { repositoryModDetails?: RepositoryModDetails; loadRepositoryData?: boolean; goBack: () => void; - installMod?: (modSource: string) => void; + installMod?: (modSource: string, options?: { disabled?: boolean }) => void; updateMod?: (modSource: string, disabled: boolean) => void; forkModFromSource?: (modSource: string) => void; compileMod: () => void; @@ -597,6 +597,7 @@ function ModDetails(props: Props) { (modDetailsToShow === 'installed' && installedModDetails?.config) || undefined} + installSourceData={selectedModSourceData || undefined} modStatus={modStatus} updateAvailable={ !!( @@ -607,16 +608,14 @@ function ModDetails(props: Props) { installedVersionIsLatest={installedVersionIsLatest} isDowngrade={isDowngrade} userRating={installedModDetails?.userRating} - repositoryDetails={ - (modDetailsToShow === 'repository' - && repositoryModDetails?.details) - || undefined} + repositoryDetails={repositoryModDetails?.details || undefined} callbacks={{ goBack: props.goBack, installMod: props.installMod && selectedModSource - ? () => props.installMod?.(selectedModSource) + ? (options) => props.installMod?.(selectedModSource, options) : undefined, + openTab: (tab) => setActiveTab(tab), updateMod: props.updateMod && selectedModSource ? () => props.updateMod?.( diff --git a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/ModDetailsChangelog.tsx b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/ModDetailsChangelog.tsx index 00bc615..2fc95e9 100644 --- a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/ModDetailsChangelog.tsx +++ b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/ModDetailsChangelog.tsx @@ -1,9 +1,8 @@ -import { ConfigProvider } from 'antd'; import { Trans, useTranslation } from 'react-i18next'; import styled from 'styled-components'; import useSWR from 'swr'; -import ReactMarkdownCustom from '../components/ReactMarkdownCustom'; import { fetchText } from '../swrHelpers'; +import ChangelogViewer from './ChangelogViewer'; const ErrorMessage = styled.div` color: rgba(255, 255, 255, 0.45); @@ -39,11 +38,7 @@ function ModDetailsChangelog({ modId, loadingNode }: Props) { return loadingNode; } - return ( - - - - ); + return ; } export default ModDetailsChangelog; diff --git a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/ModDetailsHeader.tsx b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/ModDetailsHeader.tsx index c022cd8..815b60f 100644 --- a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/ModDetailsHeader.tsx +++ b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/ModDetailsHeader.tsx @@ -7,8 +7,8 @@ import { faUser, } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { Alert, Button, Card, ConfigProvider, Dropdown, Modal, Rate, Tooltip } from 'antd'; -import { useContext, useState } from 'react'; +import { Alert, Button, Card, ConfigProvider, Dropdown, Modal, Rate, Tooltip, Typography } from 'antd'; +import { useContext, useMemo, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import styled from 'styled-components'; import EllipsisText from '../components/EllipsisText'; @@ -16,12 +16,29 @@ import { PopconfirmModal } from '../components/InputWithContextMenu'; import { sanitizeUrl } from '../utils'; import { ModConfig, ModMetadata, RepositoryDetails } from '../webviewIPCMessages'; import DevModeAction from './DevModeAction'; +import { + buildInstallDecisionChecklist, + getInstallDecisionRecommendations, + InstallDecisionAction, + InstallSourceData, +} from './installDecisionUtils'; import ModMetadataLine from './ModMetadataLine'; const TextAsIconWrapper = styled.span` + position: relative; + display: inline-block; + width: 1ch; font-size: 18px; line-height: 18px; + color: transparent; user-select: none; + + &::before { + content: 'X'; + position: absolute; + inset: 0; + color: rgba(255, 255, 255, 0.88); + } `; const ModDetailsHeaderWrapper = styled.div` @@ -119,6 +136,118 @@ const ModInstallationDetailsVerified = styled.span` cursor: help; `; +const ModInstallationSection = styled.div` + display: flex; + flex-direction: column; + gap: 10px; +`; + +const ModInstallationSectionTitle = styled.div` + font-size: 15px; + font-weight: 600; +`; + +const ModInstallationSectionDescription = styled(Typography.Text)` + color: rgba(255, 255, 255, 0.65); +`; + +const ModInstallationSignalsGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 12px; +`; + +const ModInstallationSignalCard = styled.div<{ $tone: 'neutral' | 'positive' | 'caution' }>` + border-radius: 10px; + padding: 12px 14px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: ${({ $tone }) => ( + $tone === 'positive' + ? 'rgba(82, 196, 26, 0.08)' + : $tone === 'caution' + ? 'rgba(250, 173, 20, 0.08)' + : 'rgba(255, 255, 255, 0.02)' + )}; +`; + +const ModInstallationSignalLabel = styled(Typography.Text)` + display: block; + color: rgba(255, 255, 255, 0.58); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.04em; +`; + +const ModInstallationSignalValue = styled.div` + margin-top: 6px; + color: rgba(255, 255, 255, 0.92); + font-size: 16px; + font-weight: 600; + line-height: 1.35; +`; + +const ModInstallationReviewActions = styled.div` + display: flex; + flex-wrap: wrap; + gap: 8px; +`; + +const ModInstallationStrategyGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; +`; + +const ModInstallationStrategyCard = styled.div<{ $recommended: boolean }>` + border-radius: 10px; + padding: 12px 14px; + border: 1px solid ${({ $recommended }) => ( + $recommended ? 'rgba(24, 144, 255, 0.55)' : 'rgba(255, 255, 255, 0.08)' + )}; + background: ${({ $recommended }) => ( + $recommended ? 'rgba(24, 144, 255, 0.12)' : 'rgba(255, 255, 255, 0.02)' + )}; +`; + +const ModInstallationStrategyTitle = styled.div` + font-size: 14px; + font-weight: 700; +`; + +const ModInstallationStrategyDescription = styled.div` + margin-top: 6px; + color: rgba(255, 255, 255, 0.68); + line-height: 1.45; +`; + +const ModInstallationChecklist = styled.div` + display: flex; + flex-direction: column; + gap: 6px; +`; + +const ModInstallationChecklistItem = styled.div` + display: flex; + gap: 8px; + color: rgba(255, 255, 255, 0.72); + line-height: 1.45; + + &::before { + content: '•'; + color: rgba(255, 255, 255, 0.48); + } +`; + +type InstallReviewTab = 'details' | 'code' | 'changelog'; +type InstallSignalTone = 'neutral' | 'positive' | 'caution'; + +type InstallSignal = { + key: string; + label: string; + value: string; + tone: InstallSignalTone; +}; + export type ModStatus = | 'not-installed' | 'installed-not-compiled' @@ -208,11 +337,156 @@ function ModInstallationDetailsGrid(props: { modMetadata: ModMetadata }) { ); } +function formatRelativeUpdate(timestamp: number, locale: string): string { + const dayInMs = 24 * 60 * 60 * 1000; + const diffDays = Math.round((timestamp - Date.now()) / dayInMs); + const absDays = Math.abs(diffDays); + const formatter = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }); + + if (absDays < 45) { + return formatter.format(diffDays, 'day'); + } + + if (absDays < 540) { + return formatter.format(Math.round(diffDays / 30), 'month'); + } + + return formatter.format(Math.round(diffDays / 365), 'year'); +} + +function normalizeProcessName(process: string): string { + return process.includes('\\') + ? process.substring(process.lastIndexOf('\\') + 1) + : process; +} + +function getTargetingSummary( + t: ReturnType['t'], + modMetadata: ModMetadata +): { value: string; tone: InstallSignalTone } { + const include = (modMetadata.include || []).filter(Boolean); + if (!include.length) { + return { + value: t('installModal.values.metadataLimited') as string, + tone: 'neutral', + }; + } + + if (include.some((entry) => entry.includes('*') || entry.includes('?'))) { + return { + value: t('installModal.values.allProcesses') as string, + tone: 'caution', + }; + } + + const processes = Array.from( + new Set(include.map((entry) => normalizeProcessName(entry))) + ); + + if (processes.length === 1) { + return { + value: processes[0], + tone: 'positive', + }; + } + + if (processes.length <= 3) { + return { + value: processes.join(', '), + tone: 'neutral', + }; + } + + return { + value: t('installModal.values.processPlusMore', { + first: processes[0], + count: processes.length - 1, + }) as string, + tone: processes.length >= 6 ? 'caution' : 'neutral', + }; +} + +function buildReviewabilitySummary( + t: ReturnType['t'], + installSourceData?: InstallSourceData +): string { + const items: string[] = []; + + if (installSourceData?.source) { + items.push(t('installModal.values.sourceCode') as string); + } + + items.push(t('installModal.values.changelog') as string); + + if (installSourceData?.initialSettings?.length) { + items.push(t('installModal.values.settings') as string); + } + + if (installSourceData?.readme) { + items.push(t('installModal.values.readme') as string); + } + + return items.join(' | '); + + return items.join(' · '); +} + +function buildInstallSignals( + t: ReturnType['t'], + locale: string, + modMetadata: ModMetadata, + repositoryDetails: RepositoryDetails | undefined, + installSourceData: InstallSourceData | undefined +): InstallSignal[] { + const targeting = getTargetingSummary(t, modMetadata); + const reviewability = buildReviewabilitySummary(t, installSourceData); + + return [ + { + key: 'community', + label: t('installModal.signals.community') as string, + value: repositoryDetails + ? (t('installModal.values.communitySummary', { + users: repositoryDetails.users.toLocaleString(), + rating: (repositoryDetails.rating / 2).toFixed(1), + }) as string) + : (t('installModal.values.noCommunityData') as string), + tone: repositoryDetails && repositoryDetails.users >= 1000 ? 'positive' : 'neutral', + }, + { + key: 'targeting', + label: t('installModal.signals.targeting') as string, + value: targeting.value, + tone: targeting.tone, + }, + { + key: 'freshness', + label: t('installModal.signals.freshness') as string, + value: repositoryDetails + ? (t('installModal.values.updatedRelative', { + when: formatRelativeUpdate(repositoryDetails.updated, locale), + }) as string) + : (t('installModal.values.metadataLimited') as string), + tone: repositoryDetails && + (Date.now() - repositoryDetails.updated) / (24 * 60 * 60 * 1000) <= 90 + ? 'positive' + : 'neutral', + }, + { + key: 'reviewability', + label: t('installModal.signals.reviewability') as string, + value: reviewability, + tone: installSourceData?.source ? 'positive' : 'neutral', + }, + ]; +} + interface Props { topNode?: React.ReactNode; modId: string; modMetadata: ModMetadata; modConfig?: ModConfig; + installSourceData?: InstallSourceData; modStatus: ModStatus; updateAvailable: boolean; installedVersionIsLatest: boolean; @@ -221,7 +495,8 @@ interface Props { repositoryDetails?: RepositoryDetails; callbacks: { goBack: () => void; - installMod?: () => void; + installMod?: (options?: { disabled?: boolean }) => void; + openTab?: (tab: InstallReviewTab) => void; updateMod?: () => void; forkModFromSource?: () => void; compileMod: () => void; @@ -235,7 +510,7 @@ interface Props { } function ModDetailsHeader(props: Props) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const { modId, modMetadata, modConfig, modStatus, callbacks } = props; @@ -251,6 +526,62 @@ function ModDetailsHeader(props: Props) { const displayModName = modMetadata.name || displayModId; const [isInstallModalOpen, setIsInstallModalOpen] = useState(false); + const installSignals = useMemo( + () => buildInstallSignals( + t, + i18n.language, + modMetadata, + props.repositoryDetails, + props.installSourceData + ), + [i18n.language, modMetadata, props.installSourceData, props.repositoryDetails, t] + ); + const installRecommendations = useMemo( + () => getInstallDecisionRecommendations( + modMetadata, + props.repositoryDetails, + props.installSourceData + ), + [modMetadata, props.installSourceData, props.repositoryDetails] + ); + const installChecklist = useMemo( + () => buildInstallDecisionChecklist( + modMetadata, + props.repositoryDetails, + props.installSourceData + ), + [modMetadata, props.installSourceData, props.repositoryDetails] + ); + + const handleReviewTab = (tab: InstallReviewTab) => { + callbacks.openTab?.(tab); + setIsInstallModalOpen(false); + }; + const handleInstallDecision = (action: InstallDecisionAction) => { + if (action === 'install-disabled') { + callbacks.installMod?.({ disabled: true }); + setIsInstallModalOpen(false); + return; + } + + if (action === 'install-now') { + callbacks.installMod?.(); + setIsInstallModalOpen(false); + return; + } + + if (action === 'review-source') { + handleReviewTab('code'); + return; + } + + if (action === 'review-changelog') { + handleReviewTab('changelog'); + return; + } + + handleReviewTab('details'); + }; return ( @@ -457,19 +788,31 @@ function ModDetailsHeader(props: Props) { mod: displayModName, })} open={isInstallModalOpen} + width={760} centered={true} - onOk={() => { - callbacks.installMod?.(); - setIsInstallModalOpen(false); - }} onCancel={() => { setIsInstallModalOpen(false); }} - okText={t('installModal.acceptButton')} - okButtonProps={{ - disabled: !callbacks.installMod, - }} - cancelText={t('installModal.cancelButton')} + footer={[ + , + , + , + ]} > + + + {t('installModal.snapshotTitle')} + + + {t('installModal.snapshotDescription')} + + + {installSignals.map((signal) => ( + + + {signal.label} + + + {signal.value} + + + ))} + + + + + {t('installModal.strategyTitle')} + + + {t('installModal.strategyDescription')} + + + {installRecommendations.map((recommendation) => ( + + + {recommendation.title} + {recommendation.recommended + ? ` · ${t('installModal.recommended')}` + : ''} + + + {recommendation.description} + + + + ))} + + + + + {t('installModal.reviewTitle')} + + + {t('installModal.reviewDescription')} + + + + + + + + + + {t('installModal.checklistTitle')} + + + {t('installModal.checklistDescription')} + + + {installChecklist.map((item) => ( + + {item} + + ))} + + diff --git a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/ModsBrowserLocal.tsx b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/ModsBrowserLocal.tsx index ea44c28..9aa88ba 100644 --- a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/ModsBrowserLocal.tsx +++ b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/ModsBrowserLocal.tsx @@ -1,6 +1,6 @@ import { faCaretDown, faFilter, faGripVertical, faHdd, faList, faSearch, faStar } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { Badge, Button, Empty, Modal, Spin, Switch, Table, Tag, Tooltip } from 'antd'; +import { Alert, Badge, Button, Empty, Modal, Spin, Switch, Table, Tag, Tooltip } from 'antd'; import { ItemType } from 'antd/lib/menu/hooks/useItems'; import { produce } from 'immer'; import { useCallback, useContext, useEffect, useMemo, useState } from 'react'; @@ -16,6 +16,7 @@ import { useCompileMod, useDeleteMod, useEnableMod, + useGetAppSettings, useGetFeaturedMods, useGetInstalledMods, useInstallMod, @@ -24,14 +25,17 @@ import { useUpdateModRating, } from '../webviewIPC'; import { + AppRuntimeDiagnostics, ModConfig, ModMetadata, RepositoryDetails, } from '../webviewIPCMessages'; import localModIcon from './assets/local-mod-icon.svg'; +import { getLocalModsOverview, matchesLocalModFilters } from './localModsInsights'; import { mockModsBrowserLocalFeaturedMods, mockModsBrowserLocalInitialMods, + mockRuntimeDiagnostics, } from './mockData'; import ModCard from './ModCard'; import ModDetails from './ModDetails'; @@ -113,6 +117,54 @@ const ProgressSpin = styled(Spin)` font-size: 32px; `; +const OverviewGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 12px; + margin-bottom: 20px; +`; + +const OverviewCard = styled.div` + padding: 16px 18px; + border: 1px solid var(--app-surface-border); + border-radius: var(--app-surface-radius); + background: rgba(255, 255, 255, 0.04); + box-shadow: var(--app-surface-shadow); +`; + +const OverviewValue = styled.div` + margin-bottom: 4px; + color: rgba(255, 255, 255, 0.94); + font-size: 28px; + font-weight: 700; + line-height: 1; +`; + +const OverviewLabel = styled.div` + color: rgba(255, 255, 255, 0.62); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.08em; +`; + +const RuntimeAlert = styled(Alert)` + margin-bottom: 18px; +`; + +const QuickFocusRow = styled.div` + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 20px; +`; + +const QuickFocusButton = styled(Button)<{ $active: boolean }>` + ${({ $active }) => css` + border-color: ${$active ? 'rgba(24, 144, 255, 0.45)' : 'rgba(255, 255, 255, 0.12)'}; + background: ${$active ? 'rgba(24, 144, 255, 0.12)' : 'rgba(255, 255, 255, 0.03)'}; + `} +`; + type ModDetailsType = { metadata: ModMetadata | null; config: ModConfig | null; @@ -149,6 +201,8 @@ function ModsBrowserLocal({ ContentWrapper }: Props) { const [featuredMods, setFeaturedMods] = useState< Record | undefined | null >(mockModsBrowserLocalFeaturedMods || undefined); + const [runtimeDiagnostics, setRuntimeDiagnostics] = + useState(mockRuntimeDiagnostics); const [filterText, setFilterText] = useState(''); const [filterOptions, setFilterOptions] = useState>(new Set()); @@ -202,25 +256,7 @@ function ModsBrowserLocal({ ContentWrapper }: Props) { } // Use AND logic - mod must match ALL selected filters - if (filterOptions.has('enabled')) { - if (!mod.config || mod.config.disabled) { - return false; - } - } - - if (filterOptions.has('disabled')) { - if (mod.config && !mod.config.disabled) { - return false; - } - } - - if (filterOptions.has('update-available')) { - if (!mod.updateAvailable) { - return false; - } - } - - return true; + return matchesLocalModFilters(modId, mod, filterOptions); }) .sort((a, b) => { const [modIdA, modA] = a; @@ -300,10 +336,17 @@ function ModsBrowserLocal({ ContentWrapper }: Props) { }, []) ); + const { getAppSettings } = useGetAppSettings( + useCallback((data) => { + setRuntimeDiagnostics(data.runtimeDiagnostics || null); + }, []) + ); + useEffect(() => { getInstalledMods({}); getFeaturedMods({}); - }, [getInstalledMods, getFeaturedMods]); + getAppSettings({}); + }, [getAppSettings, getFeaturedMods, getInstalledMods]); useUpdateInstalledModsDetails( useCallback( @@ -488,6 +531,60 @@ function ModsBrowserLocal({ ContentWrapper }: Props) { return null; } + const runtimeIssueText = runtimeDiagnostics + ? runtimeDiagnostics.issueCode === 'engine-config-missing' + ? t('about.runtime.issue.engineConfigMissing') + : runtimeDiagnostics.issueCode === 'engine-storage-mismatch' + ? t('about.runtime.issue.engineStorageMismatch') + : null + : null; + const localModsOverview = getLocalModsOverview(installedMods); + const quickFocusItems = [ + { + key: 'local-drafts', + label: t('home.filter.localDrafts'), + count: localModsOverview.localDrafts, + }, + { + key: 'needs-compile', + label: t('home.filter.needsCompile'), + count: localModsOverview.needsCompile, + }, + { + key: 'logging-enabled', + label: t('home.filter.loggingEnabled'), + count: localModsOverview.loggingEnabled, + }, + { + key: 'update-available', + label: t('home.filter.updateAvailable'), + count: localModsOverview.updates, + }, + ]; + + const overviewItems = [ + { + key: 'total', + label: t('home.overview.totalInstalled'), + value: localModsOverview.totalInstalled, + }, + { + key: 'enabled', + label: t('home.overview.enabled'), + value: localModsOverview.enabled, + }, + { + key: 'updates', + label: t('home.overview.updates'), + value: localModsOverview.updates, + }, + { + key: 'attention', + label: t('home.overview.needsAttention'), + value: localModsOverview.needsAttention, + }, + ]; + const noInstalledMods = Object.keys(installedMods).length === 0; const noFilteredResults = installedModsFilteredAndSorted.length === 0 && !noInstalledMods; @@ -495,6 +592,42 @@ function ModsBrowserLocal({ ContentWrapper }: Props) { <> + {runtimeDiagnostics && + !runtimeDiagnostics.engineConfigMatchesAppConfig && + runtimeIssueText && ( + {t('home.runtimeIssue.title')}} + description={runtimeIssueText} + type="warning" + showIcon + action={ + + } + /> + )} + + {overviewItems.map(({ key, label, value }) => ( + + {value} + {label} + + ))} + + {!noInstalledMods && ( + + {quickFocusItems.map((item) => ( + handleFilterChange(item.key)} + > + {item.label} ({item.count}) + + ))} + + )}

{t('home.installedMods.title')} @@ -529,6 +662,18 @@ function ModsBrowserLocal({ ContentWrapper }: Props) { label: t('home.filter.updateAvailable'), key: 'update-available', }, + { + label: t('home.filter.localDrafts'), + key: 'local-drafts', + }, + { + label: t('home.filter.needsCompile'), + key: 'needs-compile', + }, + { + label: t('home.filter.loggingEnabled'), + key: 'logging-enabled', + }, { type: 'divider', }, @@ -959,8 +1104,12 @@ function ModsBrowserLocal({ ContentWrapper }: Props) { navigate('/'); } }} - installMod={(modSource) => - installMod({ modId: displayedModId, modSource: modSource }) + installMod={(modSource, options) => + installMod({ + modId: displayedModId, + modSource, + disabled: options?.disabled, + }) } updateMod={(modSource, disabled) => installMod( diff --git a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/ModsBrowserOnline.tsx b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/ModsBrowserOnline.tsx index 461d08d..e679241 100644 --- a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/ModsBrowserOnline.tsx +++ b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/ModsBrowserOnline.tsx @@ -1,6 +1,6 @@ import { faFilter, faSearch, faSort } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { Badge, Button, Empty, Modal, Result, Spin } from 'antd'; +import { Badge, Button, Empty, Modal, Result, Spin, Typography } from 'antd'; import { produce } from 'immer'; import { useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -28,6 +28,19 @@ import { import { mockModsBrowserOnlineRepositoryMods, useMockData } from './mockData'; import ModCard from './ModCard'; import ModDetails from './ModDetails'; +import { + buildDiscoveryMissionCandidates, + DiscoveryMission, + getDiscoveryMissions, + getDiscoveryMissionByQuery, + getSearchCorrection, + getSearchRecovery, + getRefinementSuggestions, + normalizeProcessName, + RankedMod, + rankMods, + SortingOrder, +} from './modDiscovery'; const CenteredContainer = styled.div` display: flex; @@ -37,15 +50,340 @@ const CenteredContainer = styled.div` const CenteredContent = styled.div` margin: auto; - - // Without this the centered content looks too low. padding-bottom: 10vh; `; +const BrowseHero = styled.div` + margin: -8px -8px 32px -8px; + padding: 48px 24px; + background: + radial-gradient(circle at 80% 20%, rgba(23, 125, 220, 0.15), transparent 40%), + radial-gradient(circle at 20% 80%, rgba(255, 255, 255, 0.05), transparent 40%), + rgba(255, 255, 255, 0.02); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + display: flex; + flex-direction: column; + gap: 12px; + position: relative; + overflow: hidden; + + &::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E"); + opacity: 0.015; + pointer-events: none; + } +`; + +const HeroBadge = styled.span` + background: rgba(23, 125, 220, 0.15); + color: #69c0ff; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; + padding: 4px 12px; + border-radius: 999px; + width: fit-content; + border: 1px solid rgba(23, 125, 220, 0.3); +`; + +const HeroTitle = styled.h1` + font-size: 32px; + font-weight: 800; + margin: 0; + background: linear-gradient(135deg, #fff 0%, rgba(255,255,255,0.7) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +`; + +const HeroDescription = styled.p` + font-size: 16px; + color: rgba(255, 255, 255, 0.6); + margin: 0; + max-width: 600px; +`; + const SearchFilterContainer = styled.div` display: flex; - gap: 10px; - margin: 20px 0; + gap: 12px; + padding: 16px 20px; + margin: 0 -20px 24px -20px; + background: rgba(20, 20, 20, 0.7); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + position: sticky; + top: 0; + z-index: 10; + border-radius: 0 0 12px 12px; +`; + +const SearchMetaRow = styled.div` + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 16px; +`; + +const SearchSuggestions = styled.div` + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +`; + +const SearchActions = styled.div` + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +`; + +const SearchMetaText = styled(Typography.Text)` + color: rgba(255, 255, 255, 0.65); +`; + +const DiscoveryPresetsSection = styled.div` + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 24px; + padding: 24px; + border-radius: 16px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + box-shadow: 0 4px 20px -5px rgba(0, 0, 0, 0.2); +`; + +const DiscoveryPresetsTitle = styled.div` + font-size: 16px; + font-weight: 600; +`; + +const DiscoveryPresetsDescription = styled(Typography.Text)` + color: rgba(255, 255, 255, 0.65); +`; + +const DiscoveryPresetsGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; +`; + +const DiscoveryPresetCard = styled.button<{ $active: boolean }>` + border: 1px solid ${({ $active }) => ( + $active ? 'rgba(23, 125, 220, 0.45)' : 'rgba(255, 255, 255, 0.08)' + )}; + border-radius: 12px; + padding: 16px; + text-align: left; + color: inherit; + background: ${({ $active }) => ( + $active ? 'rgba(23, 125, 220, 0.12)' : 'rgba(255, 255, 255, 0.04)' + )}; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.2, 0.8, 0.2, 1); + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + + &:hover { + border-color: rgba(23, 125, 220, 0.4); + background: rgba(23, 125, 220, 0.08); + transform: translateY(-2px); + box-shadow: 0 8px 16px rgba(0,0,0,0.25); + } +`; + +const DiscoveryPresetLabel = styled.div` + font-size: 15px; + font-weight: 600; +`; + +const DiscoveryPresetDescription = styled.div` + margin-top: 6px; + color: rgba(255, 255, 255, 0.7); + line-height: 1.45; +`; + +const DiscoveryPresetMeta = styled.div` + margin-top: 10px; + color: rgba(255, 255, 255, 0.58); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.04em; +`; + +const DiscoveryMissionsSection = styled(DiscoveryPresetsSection)` + margin-top: 16px; +`; + +const DiscoveryMissionsGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 14px; +`; + +const DiscoveryMissionCard = styled.div<{ $active: boolean }>` + display: flex; + flex-direction: column; + gap: 12px; + border: 1px solid ${({ $active }) => ( + $active ? 'rgba(23, 125, 220, 0.45)' : 'rgba(255, 255, 255, 0.08)' + )}; + border-radius: 16px; + padding: 20px; + background: ${({ $active }) => ( + $active ? 'rgba(23, 125, 220, 0.12)' : 'rgba(255, 255, 255, 0.04)' + )}; + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + transition: all 0.3s cubic-bezier(0.2, 0.8, 0.2, 1); + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + + &:hover { + transform: scale(1.01) translateY(-2px); + box-shadow: 0 12px 24px rgba(0,0,0,0.3); + border-color: rgba(23, 125, 220, 0.35); + } +`; + +const DiscoveryMissionTitle = styled.div` + font-size: 16px; + font-weight: 700; +`; + +const DiscoveryMissionDescription = styled.div` + margin-top: 6px; + color: rgba(255, 255, 255, 0.74); + line-height: 1.45; +`; + +const DiscoveryMissionCue = styled.div` + color: rgba(255, 255, 255, 0.62); + line-height: 1.45; +`; + +const DiscoveryMissionTokenRow = styled.div` + display: flex; + flex-wrap: wrap; + gap: 8px; +`; + +const DiscoveryMissionToken = styled.span` + border-radius: 999px; + padding: 4px 10px; + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.85); + font-size: 12px; +`; + +const DiscoveryMissionChecklist = styled.div` + display: flex; + flex-direction: column; + gap: 6px; + color: rgba(255, 255, 255, 0.72); + line-height: 1.45; +`; + +const DiscoveryMissionChecklistItem = styled.div` + display: flex; + gap: 8px; + + &::before { + content: '•'; + color: rgba(255, 255, 255, 0.5); + } +`; + +const DiscoveryMissionActions = styled.div` + display: flex; + flex-wrap: wrap; + gap: 8px; +`; + +const MissionWorkbenchSection = styled(DiscoveryPresetsSection)` + margin-bottom: 20px; +`; + +const MissionWorkbenchGrid = styled.div` + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(280px, 1fr); + gap: 16px; + + @media (max-width: 900px) { + grid-template-columns: 1fr; + } +`; + +const MissionWorkbenchColumn = styled.div` + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; +`; + +const MissionWorkbenchCard = styled.div` + border-radius: 12px; + padding: 14px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.03); +`; + +const MissionWorkbenchTitle = styled.div` + font-size: 16px; + font-weight: 700; +`; + +const MissionWorkbenchDescription = styled.div` + margin-top: 6px; + color: rgba(255, 255, 255, 0.72); + line-height: 1.45; +`; + +const MissionWorkbenchMeta = styled.div` + margin-top: 10px; + color: rgba(255, 255, 255, 0.58); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.04em; +`; + +const MissionWorkbenchCandidates = styled.div` + display: grid; + gap: 12px; +`; + +const MissionWorkbenchCandidate = styled.div` + border-radius: 12px; + padding: 14px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.03); +`; + +const MissionWorkbenchCandidateTitle = styled.div` + font-size: 15px; + font-weight: 700; +`; + +const MissionWorkbenchCandidateMeta = styled.div` + margin-top: 4px; + color: rgba(255, 255, 255, 0.6); + line-height: 1.4; +`; + +const MissionWorkbenchCandidateInsights = styled.div` + margin-top: 8px; + color: rgba(255, 255, 255, 0.76); + line-height: 1.45; `; const SearchFilterInput = styled(InputWithContextMenu)` @@ -70,6 +408,12 @@ const ResultsMessageWrapper = styled.div` margin-top: 85px; `; +const RecoveryContainer = styled.div` + display: flex; + flex-direction: column; + gap: 20px; +`; + const ModsGrid = styled.div` display: grid; grid-template-columns: repeat( @@ -131,10 +475,12 @@ type ModDetailsType = { }; }; -const normalizeProcessName = (process: string): string => { - return process.includes('\\') - ? process.substring(process.lastIndexOf('\\') + 1) - : process; +type DiscoveryPreset = { + key: string; + label: string; + description: string; + query: string; + sortingOrder: SortingOrder; }; const extractItemsWithCounts = ( @@ -238,6 +584,18 @@ const extractProcessesWithCounts = ( ); }; +const appendSearchRefinement = (currentQuery: string, refinement: string) => { + const trimmedQuery = currentQuery.trim(); + const normalizedQuery = trimmedQuery.toLowerCase(); + const normalizedRefinement = refinement.trim().toLowerCase(); + + if (!normalizedRefinement || normalizedQuery.includes(normalizedRefinement)) { + return trimmedQuery; + } + + return trimmedQuery ? `${trimmedQuery} ${refinement}` : refinement; +}; + const useFilterState = () => { const [filterText, setFilterText] = useState(''); const [filterOptions, setFilterOptions] = useState>(new Set()); @@ -307,7 +665,8 @@ function ModsBrowserOnline({ ContentWrapper }: Props) { ModDetailsType > | null>(mockModsBrowserOnlineRepositoryMods); - const [sortingOrder, setSortingOrder] = useState('popular-top-rated'); + const [sortingOrder, setSortingOrder] = + useState('smart-relevance'); // Filter state const { @@ -335,28 +694,9 @@ function ModsBrowserOnline({ ContentWrapper }: Props) { [repositoryMods] ); - const installedModsFilteredAndSorted = useMemo(() => { - const filterWords = filterText.toLowerCase().split(/\s+/) - .map(word => word.trim()) - .filter(word => word.length > 0); + const filteredMods = useMemo(() => { return Object.entries(repositoryMods || {}) - .filter(([modId, mod]) => { - // Apply text filter - if (filterWords.length > 0) { - const textMatch = filterWords.every((filterWord) => { - return ( - modId.toLowerCase().includes(filterWord) || - mod.repository.metadata.name?.toLowerCase().includes(filterWord) || - mod.repository.metadata.description - ?.toLowerCase() - .includes(filterWord) - ); - }); - if (!textMatch) { - return false; - } - } - + .filter(([, mod]) => { // Apply category filters - if none selected, show all if (filterOptions.size === 0) { return true; @@ -406,103 +746,142 @@ function ModsBrowserOnline({ ContentWrapper }: Props) { } return true; - }) - .sort((a, b) => { - const [modIdA, modA] = a; - const [modIdB, modB] = b; - - switch (sortingOrder) { - case 'popular-top-rated': - if ( - modB.repository.details.defaultSorting < - modA.repository.details.defaultSorting - ) { - return -1; - } else if ( - modB.repository.details.defaultSorting > - modA.repository.details.defaultSorting - ) { - return 1; - } - break; - - case 'popular': - if (modB.repository.details.users < modA.repository.details.users) { - return -1; - } else if ( - modB.repository.details.users > modA.repository.details.users - ) { - return 1; - } - break; - - case 'top-rated': - if ( - modB.repository.details.rating < modA.repository.details.rating - ) { - return -1; - } else if ( - modB.repository.details.rating > modA.repository.details.rating - ) { - return 1; - } - break; - - case 'newest': - if ( - modB.repository.details.published < - modA.repository.details.published - ) { - return -1; - } else if ( - modB.repository.details.published > - modA.repository.details.published - ) { - return 1; - } - break; - - case 'last-updated': - if ( - modB.repository.details.updated < modA.repository.details.updated - ) { - return -1; - } else if ( - modB.repository.details.updated > modA.repository.details.updated - ) { - return 1; - } - break; + }); + }, [repositoryMods, filterOptions]); - case 'alphabetical': - // Nothing to do. - break; - } + const rankedMods = useMemo( + () => rankMods(filteredMods, filterText, sortingOrder), + [filteredMods, filterText, sortingOrder] + ); - // Fallback sorting: Sort by name, then id. + const searchCorrection = useMemo( + () => getSearchCorrection(filteredMods, filterText), + [filteredMods, filterText] + ); - const modATitle = ( - modA.repository.metadata.name || modIdA - ).toLowerCase(); - const modBTitle = ( - modB.repository.metadata.name || modIdB - ).toLowerCase(); + const correctedRankedMods = useMemo( + () => searchCorrection + ? rankMods(filteredMods, searchCorrection.correctedQuery, 'smart-relevance') + : [], + [filteredMods, searchCorrection] + ); - if (modATitle < modBTitle) { - return -1; - } else if (modATitle > modBTitle) { - return 1; - } + const searchRecovery = useMemo( + () => rankedMods.length === 0 + ? getSearchRecovery(filteredMods, filterText) + : null, + [filteredMods, filterText, rankedMods.length] + ); - if (modIdA < modIdB) { - return -1; - } else if (modIdA > modIdB) { - return 1; - } + const refinementSuggestions = useMemo( + () => getRefinementSuggestions(rankedMods, filterText), + [rankedMods, filterText] + ); - return 0; - }); - }, [repositoryMods, sortingOrder, filterText, filterOptions]); + const discoveryPresets = useMemo( + () => [ + { + key: 'fresh', + label: t('explore.presets.items.fresh.title') as string, + description: t('explore.presets.items.fresh.description') as string, + query: '', + sortingOrder: 'last-updated', + }, + { + key: 'favorites', + label: t('explore.presets.items.favorites.title') as string, + description: t('explore.presets.items.favorites.description') as string, + query: '', + sortingOrder: 'popular-top-rated', + }, + { + key: 'taskbar', + label: t('explore.presets.items.taskbar.title') as string, + description: t('explore.presets.items.taskbar.description') as string, + query: 'taskbar', + sortingOrder: 'smart-relevance', + }, + { + key: 'explorer', + label: t('explore.presets.items.explorer.title') as string, + description: t('explore.presets.items.explorer.description') as string, + query: 'explorer', + sortingOrder: 'smart-relevance', + }, + { + key: 'start-menu', + label: t('explore.presets.items.startMenu.title') as string, + description: t('explore.presets.items.startMenu.description') as string, + query: 'start menu', + sortingOrder: 'smart-relevance', + }, + { + key: 'audio', + label: t('explore.presets.items.audio.title') as string, + description: t('explore.presets.items.audio.description') as string, + query: 'audio', + sortingOrder: 'smart-relevance', + }, + { + key: 'notifications', + label: t('explore.presets.items.notifications.title') as string, + description: t( + 'explore.presets.items.notifications.description' + ) as string, + query: 'notifications', + sortingOrder: 'smart-relevance', + }, + { + key: 'window-management', + label: t('explore.presets.items.windowManagement.title') as string, + description: t( + 'explore.presets.items.windowManagement.description' + ) as string, + query: 'window management', + sortingOrder: 'smart-relevance', + }, + { + key: 'input', + label: t('explore.presets.items.input.title') as string, + description: t('explore.presets.items.input.description') as string, + query: 'input', + sortingOrder: 'smart-relevance', + }, + { + key: 'appearance', + label: t('explore.presets.items.appearance.title') as string, + description: t('explore.presets.items.appearance.description') as string, + query: 'appearance', + sortingOrder: 'smart-relevance', + }, + ], + [t] + ); + const discoveryMissions = useMemo( + () => getDiscoveryMissions(), + [] + ); + + const discoveryPresetCounts = useMemo(() => { + const mods = Object.entries(repositoryMods || {}); + + return Object.fromEntries( + discoveryPresets.map((preset) => [ + preset.key, + rankMods(mods, preset.query, preset.sortingOrder).length, + ]) + ) as Record; + }, [discoveryPresets, repositoryMods]); + const activeDiscoveryMission = useMemo( + () => getDiscoveryMissionByQuery(filterText, sortingOrder), + [filterText, sortingOrder] + ); + const activeDiscoveryMissionCandidates = useMemo( + () => activeDiscoveryMission && filterOptions.size === 0 + ? buildDiscoveryMissionCandidates(rankedMods) + : [], + [activeDiscoveryMission, filterOptions.size, rankedMods] + ); const { devModeOptOut } = useContext(AppUISettingsContext); @@ -641,6 +1020,10 @@ function ModsBrowserOnline({ ContentWrapper }: Props) { useState(30); const resetInfiniteScrollLoadedItems = () => setInfiniteScrollLoadedItems(30); + const openModDetails = useCallback((modId: string) => { + setDetailsButtonClicked(true); + navigate('/mods-browser/' + modId); + }, [navigate]); const [detailsButtonClicked, setDetailsButtonClicked] = useState(false); @@ -684,6 +1067,31 @@ function ModsBrowserOnline({ ContentWrapper }: Props) { ); } + const renderModCard = ({ modId, mod, insights }: RankedMod) => ( + 0 ? insights : undefined} + buttons={[ + { + text: t('mod.details'), + onClick: () => openModDetails(modId), + }, + ]} + /> + ); + return ( <> + + {t('appHeader.explore')} + {t('explore.pageTitle') || "Discover Windhawk Mods"} + {t('explore.pageDescription') || "Enhance your Windows experience with community-driven customizations."} + } @@ -775,7 +1188,6 @@ function ModsBrowserOnline({ ContentWrapper }: Props) { } else { handleFilterChange(e.key); resetInfiniteScrollLoadedItems(); - // Keep dropdown open for filter changes } }, }} @@ -792,6 +1204,10 @@ function ModsBrowserOnline({ ContentWrapper }: Props) { arrow={true} menu={{ items: [ + { + label: t('explore.search.smartRelevance'), + key: 'smart-relevance', + }, { label: t('explore.search.popularAndTopRated'), key: 'popular-top-rated', @@ -812,7 +1228,7 @@ function ModsBrowserOnline({ ContentWrapper }: Props) { onClick: (e) => { dropdownModalDismissed(); resetInfiniteScrollLoadedItems(); - setSortingOrder(e.key); + setSortingOrder(e.key as SortingOrder); }, }} > @@ -821,12 +1237,243 @@ function ModsBrowserOnline({ ContentWrapper }: Props) { - {installedModsFilteredAndSorted.length === 0 ? ( + {!filterText.trim() && filterOptions.size === 0 && ( + <> + + + {t('explore.presets.title')} + + + {t('explore.presets.description')} + + + {discoveryPresets.map((preset) => ( + { + setFilterText(preset.query); + setSortingOrder(preset.sortingOrder); + handleClearFilters(); + }} + > + {preset.label} + + {preset.description} + + + {discoveryPresetCounts[preset.key] || 0}{' '} + {t('explore.presets.countSuffix') || "Results"} + + + ))} + + + + + {t('explore.missions.title')} + + + {t('explore.missions.description')} + + + {discoveryMissions.map((mission) => ( + + {mission.title} + + {mission.description} + + + {t('explore.missions.cueLabel') || "Research Cue"}: {mission.researchCue} + + + {mission.followUpQueries.map((query) => ( + + {query} + + ))} + + + + + + ))} + + + + )} + {activeDiscoveryMission && filterOptions.size === 0 && ( + + + {t('explore.missions.workbenchTitle') || "Mission Workbench"} + + + {t('explore.missions.workbenchDescription') || "Active mission targets and verification steps."} + + + + + + {activeDiscoveryMission.title} + + + {activeDiscoveryMission.description} + + + {t('explore.missions.checksLabel') || "Verification Checks"} + + + {activeDiscoveryMission.verificationChecks.map((check) => ( + + {check} + + ))} + + + + + + {activeDiscoveryMissionCandidates.map((candidate) => ( + + + {candidate.displayName} + + + {candidate.author} + + + {candidate.insightSummary} + + + + + + ))} + + + + + )} + {(filterText.trim() || filterOptions.size > 0) && ( + + + {filterText.trim() + ? sortingOrder === 'smart-relevance' + ? t('explore.discovery.smartResults', { + count: rankedMods.length, + }) + : t('explore.discovery.filteredResults', { + count: rankedMods.length, + }) + : t('explore.discovery.filteredOnly', { + count: rankedMods.length, + })} + + + {filterText.trim() && + sortingOrder === 'smart-relevance' && + searchCorrection && + correctedRankedMods.length > rankedMods.length && ( + + + {t('modSearch.didYouMean')} + + + + )} + {filterText.trim() && + sortingOrder === 'smart-relevance' && + refinementSuggestions.length > 0 && ( + + + {t('explore.discovery.refineWith')} + + {refinementSuggestions.map((suggestion) => ( + + ))} + + )} + + + )} + {rankedMods.length === 0 ? ( - + + + {searchRecovery && ( + <> + + + {searchRecovery.reason === 'correction' + ? t('modSearch.recoveryByCorrection') + : t('modSearch.recoveryByBroadening')} + + + + + {t('modSearch.closestMatches')} + + + {searchRecovery.results.map(renderModCard)} + + + )} + ) : ( - {installedModsFilteredAndSorted - .slice(0, infiniteScrollLoadedItems) - .map(([modId, mod]) => ( - { - setDetailsButtonClicked(true); - navigate('/mods-browser/' + modId); - }, - }, - ]} - /> - ))} + {rankedMods + .slice(0, infiniteScrollLoadedItems) + .map(renderModCard)} )} @@ -887,17 +1507,14 @@ function ModsBrowserOnline({ ContentWrapper }: Props) { installedModDetails={repositoryMods[displayedModId].installed} repositoryModDetails={repositoryMods[displayedModId].repository} goBack={() => { - // If we ever clicked on Details, go back. - // Otherwise, we probably arrived from a different location, - // go straight to the mods page. if (detailsButtonClicked) { navigate(-1); } else { navigate('/mods-browser'); } }} - installMod={(modSource) => - installMod({ modId: displayedModId, modSource }) + installMod={(modSource, options) => + installMod({ modId: displayedModId, modSource, disabled: options?.disabled }) } updateMod={(modSource, disabled) => installMod( diff --git a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/NewModStudioModal.tsx b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/NewModStudioModal.tsx new file mode 100644 index 0000000..7b46b49 --- /dev/null +++ b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/NewModStudioModal.tsx @@ -0,0 +1,915 @@ +import { Button, Modal, Tag, Typography, message } from 'antd'; +import { useContext, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import styled from 'styled-components'; +import { + AppUISettingsContext, + recordRecentStudioLaunch, +} from '../appUISettings'; +import { copyTextToClipboard } from '../utils'; +import { createNewMod } from '../webviewIPC'; +import { EditorLaunchContext } from '../webviewIPCMessages'; +import { + aiPromptPacks, + buildStarterLaunchContext, + buildStudioWorkflowPacket, + buildVisualPresetLaunchContext, + buildWorkflowLaunchContext, + cliPlaybooks, + getModSourceExtensionForAuthoringLanguage, + getModStudioStartersForAuthoringLanguage, + getStudioWorkflowRecipes, + getVisualStudioPresetsForAuthoringLanguage, + ModAuthoringLanguage, + modStudioStarters, +} from './aiModStudio'; + +const ModalBody = styled.div` + display: flex; + flex-direction: column; + gap: 32px; + max-height: 70vh; + overflow-y: auto; + padding-right: 8px; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 999px; + } +`; + +const Section = styled.section` + display: flex; + flex-direction: column; + gap: 12px; +`; + +const SectionTitle = styled.div` + font-size: 16px; + font-weight: 600; +`; + +const SectionDescription = styled(Typography.Text)` + color: rgba(255, 255, 255, 0.68); +`; + +const StudioControls = styled.div` + display: grid; + gap: 16px; + padding: 18px; + border-radius: 18px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: + radial-gradient(circle at top right, rgba(24, 144, 255, 0.18), transparent 42%), + linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.015)); + backdrop-filter: blur(14px); +`; + +const ControlGroup = styled.div` + display: flex; + flex-direction: column; + gap: 10px; +`; + +const ControlHeader = styled.div` + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; +`; + +const ControlTitle = styled.div` + font-size: 14px; + font-weight: 700; +`; + +const ControlDescription = styled(Typography.Text)` + color: rgba(255, 255, 255, 0.72); + line-height: 1.45; +`; + +const ControlOptions = styled.div` + display: flex; + flex-wrap: wrap; + gap: 10px; +`; + +const OptionButton = styled(Button)<{ $selected?: boolean }>` + min-width: 172px; + height: auto; + padding: 10px 16px; + border-radius: 999px; + display: block; + text-align: left; + white-space: normal; + border-color: ${({ $selected }) => + $selected ? 'rgba(24, 144, 255, 0.7)' : 'rgba(255, 255, 255, 0.14)'}; + background: ${({ $selected }) => + $selected + ? 'linear-gradient(135deg, rgba(24, 144, 255, 0.24), rgba(24, 144, 255, 0.08))' + : 'rgba(255, 255, 255, 0.04)'}; + color: #fff; + box-shadow: ${({ $selected }) => + $selected ? '0 0 18px rgba(24, 144, 255, 0.18)' : 'none'}; + + &:hover, + &:focus { + color: #fff; + border-color: rgba(24, 144, 255, 0.7); + background: linear-gradient( + 135deg, + rgba(24, 144, 255, 0.2), + rgba(24, 144, 255, 0.06) + ); + } +`; + +const OptionLabel = styled.div` + font-weight: 600; +`; + +const OptionMeta = styled.div` + margin-top: 4px; + font-size: 12px; + color: rgba(255, 255, 255, 0.72); + line-height: 1.4; +`; + +const StarterGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 14px; +`; + +const StarterCard = styled.div` + display: flex; + flex-direction: column; + gap: 12px; + padding: 20px; + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: linear-gradient( + 135deg, + rgba(255, 255, 255, 0.05), + rgba(255, 255, 255, 0.01) + ); + backdrop-filter: blur(12px); + box-shadow: + 0 4px 24px rgba(0, 0, 0, 0.1), + inset 0 1px 0 rgba(255, 255, 255, 0.05); + transition: all 0.3s cubic-bezier(0.2, 0.8, 0.2, 1); + cursor: pointer; + + &:hover { + transform: translateY(-4px); + box-shadow: + 0 12px 32px rgba(0, 0, 0, 0.3), + inset 0 1px 0 rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.2); + background: linear-gradient( + 135deg, + rgba(255, 255, 255, 0.08), + rgba(255, 255, 255, 0.02) + ); + } +`; + +const StarterHeader = styled.div` + display: flex; + justify-content: space-between; + gap: 8px; + align-items: flex-start; +`; + +const StarterTitle = styled.div` + font-size: 15px; + font-weight: 600; +`; + +const StarterHighlights = styled.ul` + margin: 0; + padding-inline-start: 18px; + color: rgba(255, 255, 255, 0.76); + + > li + li { + margin-top: 6px; + } +`; + +const PromptGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 14px; +`; + +const PromptCard = styled.div` + display: flex; + flex-direction: column; + gap: 12px; + padding: 20px; + border-radius: 16px; + border: 1px solid rgba(138, 43, 226, 0.2); + background: linear-gradient( + 135deg, + rgba(138, 43, 226, 0.08), + rgba(138, 43, 226, 0.02) + ); + backdrop-filter: blur(8px); + transition: all 0.3s ease; + + &:hover { + transform: translateY(-2px); + background: linear-gradient( + 135deg, + rgba(138, 43, 226, 0.12), + rgba(138, 43, 226, 0.04) + ); + border-color: rgba(138, 43, 226, 0.4); + box-shadow: 0 8px 24px rgba(138, 43, 226, 0.15); + } +`; + +const PromptTitle = styled.div` + font-size: 15px; + font-weight: 600; +`; + +const PromptPreview = styled.pre` + margin: 0; + padding: 12px; + border-radius: 10px; + background: rgba(0, 0, 0, 0.22); + color: rgba(255, 255, 255, 0.84); + white-space: pre-wrap; + word-break: break-word; + font-family: Consolas, Monaco, 'Courier New', monospace; + font-size: 12px; + line-height: 1.45; +`; + +const ToolsGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 14px; +`; + +const ToolCard = styled.div` + display: flex; + flex-direction: column; + gap: 12px; + padding: 20px; + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: linear-gradient( + 135deg, + rgba(255, 255, 255, 0.05), + rgba(255, 255, 255, 0.015) + ); + backdrop-filter: blur(10px); +`; + +const ToolTitle = styled.div` + font-size: 15px; + font-weight: 600; +`; + +const ToolPreview = styled.pre` + margin: 0; + padding: 12px; + border-radius: 12px; + background: rgba(0, 0, 0, 0.24); + color: rgba(255, 255, 255, 0.84); + white-space: pre-wrap; + word-break: break-word; + font-family: Consolas, Monaco, 'Courier New', monospace; + font-size: 12px; + line-height: 1.45; +`; + +const WorkflowBanner = styled.div` + display: flex; + flex-direction: column; + gap: 8px; + padding: 16px 18px; + border-radius: 16px; + border: 1px solid rgba(24, 144, 255, 0.26); + background: + radial-gradient(circle at top right, rgba(24, 144, 255, 0.14), transparent 40%), + rgba(24, 144, 255, 0.08); +`; + +const WorkflowBannerTitle = styled.div` + font-size: 15px; + font-weight: 700; +`; + +const WorkflowGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 14px; +`; + +const WorkflowCard = styled.div` + display: flex; + flex-direction: column; + gap: 12px; + padding: 20px; + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: linear-gradient( + 135deg, + rgba(255, 255, 255, 0.05), + rgba(255, 255, 255, 0.015) + ); + backdrop-filter: blur(10px); +`; + +const WorkflowTitle = styled.div` + font-size: 15px; + font-weight: 600; +`; + +const WorkflowChecklist = styled.div` + display: flex; + flex-direction: column; + gap: 8px; + color: rgba(255, 255, 255, 0.78); + line-height: 1.45; +`; + +const WorkflowChecklistItem = styled.div` + display: flex; + gap: 8px; + + &::before { + content: '-'; + color: rgba(255, 255, 255, 0.48); + } +`; + +const WorkflowMetaRow = styled.div` + display: flex; + flex-wrap: wrap; + gap: 8px; +`; + +const WorkflowActions = styled.div` + display: flex; + flex-wrap: wrap; + gap: 8px; +`; + +const InlineNote = styled.div` + border-radius: 12px; + padding: 12px 14px; + border: 1px solid rgba(24, 144, 255, 0.24); + background: rgba(24, 144, 255, 0.08); + color: rgba(255, 255, 255, 0.84); + line-height: 1.5; +`; + +const FooterNote = styled.div` + border-radius: 12px; + padding: 14px 16px; + border: 1px solid rgba(250, 173, 20, 0.28); + background: rgba(250, 173, 20, 0.08); + color: rgba(255, 255, 255, 0.86); + line-height: 1.5; +`; + +interface Props { + open: boolean; + onClose: () => void; +} + +function NewModStudioModal({ open, onClose }: Props) { + const { t } = useTranslation(); + const { localUISettings, setLocalUISettings } = useContext(AppUISettingsContext); + + const authoringLanguage = localUISettings.preferredAuthoringLanguage; + const studioMode = localUISettings.preferredStudioMode; + const starters = useMemo( + () => getModStudioStartersForAuthoringLanguage(authoringLanguage), + [authoringLanguage] + ); + const visualPresets = useMemo( + () => getVisualStudioPresetsForAuthoringLanguage(authoringLanguage), + [authoringLanguage] + ); + const workflowRecipes = useMemo( + () => getStudioWorkflowRecipes(authoringLanguage, studioMode), + [authoringLanguage, studioMode] + ); + const recommendedWorkflow = workflowRecipes[0] || null; + const recentStudioLaunches = useMemo( + () => + localUISettings.recentStudioLaunches.filter( + ( + launchContext + ): launchContext is EditorLaunchContext & { + templateKey: NonNullable; + } => launchContext.templateKey !== undefined + ), + [localUISettings.recentStudioLaunches] + ); + + const handleCreateStarter = ( + templateKey: (typeof modStudioStarters)[number]['key'], + selectedAuthoringLanguage: ModAuthoringLanguage = authoringLanguage, + launchContext?: EditorLaunchContext + ) => { + const nextLaunchContext = launchContext + ? { + ...launchContext, + templateKey, + authoringLanguage: + launchContext.authoringLanguage ?? selectedAuthoringLanguage, + studioMode: launchContext.studioMode ?? studioMode, + } + : undefined; + + setLocalUISettings( + nextLaunchContext + ? { + preferredAuthoringLanguage: + nextLaunchContext.authoringLanguage ?? selectedAuthoringLanguage, + preferredStudioMode: nextLaunchContext.studioMode ?? studioMode, + recentStudioLaunches: recordRecentStudioLaunch( + localUISettings.recentStudioLaunches, + nextLaunchContext + ), + } + : { + preferredAuthoringLanguage: selectedAuthoringLanguage, + preferredStudioMode: studioMode, + } + ); + + createNewMod({ + templateKey, + authoringLanguage: selectedAuthoringLanguage, + sourceExtension: + getModSourceExtensionForAuthoringLanguage(selectedAuthoringLanguage), + launchContext: nextLaunchContext, + }); + onClose(); + }; + + const getLaunchKindLabel = (launchContext: EditorLaunchContext) => { + switch (launchContext.kind) { + case 'workflow': + return t('newModStudio.recent.workflowKind'); + case 'visual-preset': + return t('newModStudio.recent.visualPresetKind'); + case 'starter': + default: + return t('newModStudio.recent.starterKind'); + } + }; + + const getTemplateTitle = (templateKey: string) => + modStudioStarters.find((starter) => starter.key === templateKey)?.title ?? + templateKey; + + const handleCopyText = async (title: string, text: string) => { + try { + await copyTextToClipboard(text); + message.success(t('newModStudio.copySuccess', { title })); + } catch (error) { + console.error('Failed to copy studio content:', error); + message.error(t('newModStudio.copyError')); + } + }; + + return ( + + +
+ {t('newModStudio.mode.title')} + + {t('newModStudio.mode.description')} + + + + + {t('newModStudio.mode.title')} + + {studioMode === 'visual' + ? t('newModStudio.mode.visual') + : t('newModStudio.mode.code')} + + + + + setLocalUISettings({ preferredStudioMode: 'code' }) + } + > + {t('newModStudio.mode.code')} + + {t('newModStudio.mode.codeDescription')} + + + + setLocalUISettings({ preferredStudioMode: 'visual' }) + } + > + {t('newModStudio.mode.visual')} + + {t('newModStudio.mode.visualDescription')} + + + + + + + {t('newModStudio.authoring.title')} + + {authoringLanguage === 'python' + ? t('newModStudio.authoring.python') + : t('newModStudio.authoring.cpp')} + + + + {t('newModStudio.authoring.description')} + + + + setLocalUISettings({ preferredAuthoringLanguage: 'cpp' }) + } + > + {t('newModStudio.authoring.cpp')} + + {t('newModStudio.authoring.cppDescription')} + + + + setLocalUISettings({ + preferredAuthoringLanguage: 'python', + }) + } + > + + {t('newModStudio.authoring.python')} + + + {t('newModStudio.authoring.pythonDescription')} + + + + + +
+ +
+ {t('newModStudio.recent.title')} + + {t('newModStudio.recent.description')} + + {recentStudioLaunches.length > 0 ? ( + + {recentStudioLaunches.map((launchContext, index) => ( + + + {index === 0 && ( + {t('newModStudio.recent.latest')} + )} + + {getLaunchKindLabel(launchContext)} + + + {t('newModStudio.recent.templateLabel', { + template: getTemplateTitle(launchContext.templateKey), + })} + + {launchContext.studioMode && ( + + {launchContext.studioMode === 'visual' + ? t('newModStudio.mode.visual') + : t('newModStudio.mode.code')} + + )} + {launchContext.authoringLanguage && ( + + {launchContext.authoringLanguage === 'python' + ? t('newModStudio.authoring.python') + : t('newModStudio.authoring.cpp')} + + )} + {launchContext.checklist?.length ? ( + + {t('newModStudio.recent.checklistLabel', { + count: launchContext.checklist.length, + })} + + ) : null} + {launchContext.tools?.length ? ( + + {t('newModStudio.recent.toolsLabel', { + count: launchContext.tools.length, + })} + + ) : null} + {launchContext.prompts?.length ? ( + + {t('newModStudio.recent.promptsLabel', { + count: launchContext.prompts.length, + })} + + ) : null} + + {launchContext.title} + {launchContext.summary} + {launchContext.checklist?.length ? ( + + {launchContext.checklist.slice(0, 3).map((item) => ( + + {item} + + ))} + + ) : null} + + + {launchContext.packet && ( + + )} + + + ))} + + ) : ( + {t('newModStudio.recent.empty')} + )} +
+ +
+ + {studioMode === 'visual' + ? t('newModStudio.visual.title') + : t('newModStudio.starters.title')} + + + {studioMode === 'visual' + ? t('newModStudio.visual.description') + : t('newModStudio.starters.description')} + + {studioMode === 'visual' ? ( + + {visualPresets.map((preset) => ( + + + {preset.title} + + {authoringLanguage === 'python' + ? t('newModStudio.authoring.python') + : t('newModStudio.authoring.cpp')} + + + {preset.description} + + + ))} + + ) : ( + <> + {authoringLanguage === 'python' && ( + {t('newModStudio.starters.pythonNote')} + )} + + {starters.map((starter) => ( + + + {starter.title} + {starter.recommended && ( + {t('newModStudio.recommended')} + )} + + {starter.description} + + {starter.highlights.map((highlight) => ( +
  • {highlight}
  • + ))} +
    + +
    + ))} +
    + + )} +
    + +
    + {t('newModStudio.workflows.title')} + + {t('newModStudio.workflows.description')} + + {recommendedWorkflow && ( + + + {t('newModStudio.workflows.recommended', { + title: recommendedWorkflow.title, + })} + + + {recommendedWorkflow.description} + + + )} + + {workflowRecipes.map((recipe) => ( + + {recipe.title} + {recipe.description} + + + {t('newModStudio.workflows.starterLabel', { + template: recipe.recommendedTemplateKey, + })} + + + {t('newModStudio.workflows.toolsLabel', { + count: recipe.suggestedPlaybookKeys.length, + })} + + + {t('newModStudio.workflows.promptsLabel', { + count: recipe.suggestedPromptPackKeys.length, + })} + + + + {recipe.checklist.map((item) => ( + + {item} + + ))} + + + + + + + ))} + +
    + +
    + {t('newModStudio.cli.title')} + + {t('newModStudio.cli.description')} + + + {cliPlaybooks.map((playbook) => ( + + {playbook.title} + {playbook.description} + {playbook.command} + + + ))} + +
    + +
    + {t('newModStudio.prompts.title')} + + {t('newModStudio.prompts.description')} + + + {aiPromptPacks.map((promptPack) => ( + + {promptPack.title} + {promptPack.description} + {promptPack.prompt} + + + ))} + +
    + {t('newModStudio.footerNote')} +
    +
    + ); +} + +export default NewModStudioModal; diff --git a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/Panel.tsx b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/Panel.tsx index ee25ee2..b454f32 100644 --- a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/Panel.tsx +++ b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/Panel.tsx @@ -1,6 +1,7 @@ import React, { useEffect } from 'react'; import { createHashRouter, Outlet, RouterProvider, useNavigate } from 'react-router-dom'; import styled, { css } from 'styled-components'; +import { readLocalUISettings } from '../appUISettings'; import About from './About'; import AppHeader from './AppHeader'; import CreateNewModButton from './CreateNewModButton'; @@ -15,6 +16,9 @@ const PanelContainer = styled.div` height: 100vh; overflow: hidden; flex-direction: column; + background: + radial-gradient(circle at top, rgba(23, 125, 220, 0.14), transparent 30%), + var(--app-background-color); `; const ContentContainerScroll = styled.div<{ $hidden?: boolean }>` @@ -31,7 +35,7 @@ const ContentContainer = styled.div` height: 100%; max-width: var(--app-max-width); margin: 0 auto; - padding: 0 20px; + padding: 0 var(--app-horizontal-padding) var(--app-section-gap); // Disable margin-collapsing: https://stackoverflow.com/a/47351270 display: flex; @@ -100,6 +104,23 @@ if (previewModId) { const url = new URL(window.location.href); url.hash = '#/mod-preview/' + previewModId; window.history.replaceState(null, '', url); +} else { + const startupPage = readLocalUISettings().startupPage; + const startupRouteMap = { + home: '#/', + explore: '#/mods-browser', + settings: '#/settings', + about: '#/about', + } as const; + + if (!window.location.hash || window.location.hash === '#' || window.location.hash === '#/') { + const preferredHash = startupRouteMap[startupPage]; + if (preferredHash !== '#/') { + const url = new URL(window.location.href); + url.hash = preferredHash; + window.history.replaceState(null, '', url); + } + } } const router = createHashRouter([ diff --git a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/Settings.tsx b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/Settings.tsx index 35c5bd9..4acf745 100644 --- a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/Settings.tsx +++ b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/Settings.tsx @@ -1,20 +1,148 @@ -import { Alert, Badge, Button, Checkbox, Collapse, List, Modal, Select, Space, Switch, Tooltip } from 'antd'; -import { useCallback, useContext, useEffect, useState } from 'react'; +import { + Alert, + Badge, + Button, + Card, + Checkbox, + Collapse, + List, + Modal, + Select, + Space, + Switch, + Tooltip, +} from 'antd'; +import { useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import styled from 'styled-components'; -import { AppUISettingsContext } from '../appUISettings'; -import { InputNumberWithContextMenu, SelectModal, TextAreaWithContextMenu } from '../components/InputWithContextMenu'; +import { + AppUISettingsContext, + getRecommendedLocalUISettings, +} from '../appUISettings'; +import { + InputWithContextMenu, + InputNumberWithContextMenu, + SelectModal, + TextAreaWithContextMenu, +} from '../components/InputWithContextMenu'; import { sanitizeUrl } from '../utils'; import { useGetAppSettings, useUpdateAppSettings } from '../webviewIPC'; -import { AppSettings } from '../webviewIPCMessages'; -import { mockSettings } from './mockData'; +import { AppRuntimeDiagnostics, AppSettings } from '../webviewIPCMessages'; +import { mockRuntimeDiagnostics, mockSettings } from './mockData'; const SettingsWrapper = styled.div` - padding-bottom: 20px; + padding: 8px 0 28px; +`; + +const SettingsHero = styled.section` + margin-bottom: var(--app-section-gap); + padding: calc(var(--app-card-padding) + 2px); + border: 1px solid var(--app-surface-border); + border-radius: var(--app-surface-radius); + background: + radial-gradient(circle at top right, rgba(23, 125, 220, 0.16), transparent 35%), + var(--app-surface-background); + box-shadow: var(--app-surface-shadow); +`; + +const SettingsEyebrow = styled.div` + color: rgba(255, 255, 255, 0.58); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +`; + +const SettingsPageTitle = styled.h1` + margin: 10px 0 8px; + font-size: 34px; + line-height: 1.05; +`; + +const SettingsPageDescription = styled.p` + max-width: 720px; + margin-bottom: 18px; + color: rgba(255, 255, 255, 0.7); + font-size: 15px; +`; + +const StatusPillRow = styled.div` + display: flex; + flex-wrap: wrap; + gap: 10px; +`; + +const StatusPill = styled.span<{ $tone: 'default' | 'warning' | 'error' }>` + position: relative; + display: inline-flex; + align-items: center; + min-height: var(--app-status-pill-height); + padding: 0 14px 0 30px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 999px; + background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.88); + font-size: 12px; + font-weight: 600; + + &::before { + content: ''; + position: absolute; + left: 12px; + width: 8px; + height: 8px; + border-radius: 999px; + background: ${({ $tone }) => { + switch ($tone) { + case 'error': + return '#ff7875'; + case 'warning': + return '#ffc53d'; + default: + return '#69c0ff'; + } + }}; + } +`; + +const SettingsGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: var(--app-section-gap); + align-items: start; +`; + +const SettingsSectionCard = styled(Card)` + border: 1px solid var(--app-surface-border); + border-radius: var(--app-surface-radius); + background: var(--app-surface-background); + box-shadow: var(--app-surface-shadow); + + .ant-card-body { + padding: var(--app-card-padding) var(--app-card-padding) 14px; + } +`; + +const AdvancedSettingsCard = styled(SettingsSectionCard)` + grid-column: 1 / -1; +`; + +const SectionHeading = styled.div` + margin-bottom: 16px; +`; + +const SectionTitle = styled.h2` + margin: 0 0 6px; + font-size: 18px; +`; + +const SectionDescription = styled.p` + margin: 0; + color: rgba(255, 255, 255, 0.62); `; const SettingsList = styled(List)` - margin-bottom: 20px; + margin-bottom: 0; `; const SettingsListItemMeta = styled(List.Item.Meta)` @@ -28,7 +156,7 @@ const SettingsListItemMeta = styled(List.Item.Meta)` `; const SettingsSelect = styled(SelectModal)` - width: 200px; + width: 220px; `; const SettingsNotice = styled.div` @@ -36,16 +164,28 @@ const SettingsNotice = styled.div` color: rgba(255, 255, 255, 0.45); `; +const SettingsActionRow = styled.div` + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + margin-top: 12px; +`; + const SettingInputNumber = styled(InputNumberWithContextMenu)` width: 100%; max-width: 130px; - // Remove default VSCode focus highlighting color. input:focus { outline: none !important; } `; +const SettingInput = styled(InputWithContextMenu)` + width: 100%; + max-width: 280px; +`; + const appLanguages = [ ['en', 'English'], ...Object.entries({ @@ -99,34 +239,55 @@ function Settings() { const { t, i18n } = useTranslation(); const appLanguage = i18n.resolvedLanguage; - const { loggingEnabled } = useContext(AppUISettingsContext); + const { + loggingEnabled, + safeMode, + localUISettings, + openSetupAssistant, + resetLocalUISettings, + setLocalUISettings, + } = useContext(AppUISettingsContext); const [appSettings, setAppSettings] = useState | null>( mockSettings ); + const [runtimeDiagnostics, setRuntimeDiagnostics] = + useState(mockRuntimeDiagnostics); - // More advanced settings. const [appLoggingVerbosity, setAppLoggingVerbosity] = useState(0); const [engineLoggingVerbosity, setEngineLoggingVerbosity] = useState(0); const [engineInclude, setEngineInclude] = useState(''); const [engineExclude, setEngineExclude] = useState(''); - const [engineInjectIntoCriticalProcesses, setEngineInjectIntoCriticalProcesses] = useState(false); - const [engineInjectIntoIncompatiblePrograms, setEngineInjectIntoIncompatiblePrograms] = useState(false); + const [engineInjectIntoCriticalProcesses, setEngineInjectIntoCriticalProcesses] = + useState(false); + const [engineInjectIntoIncompatiblePrograms, setEngineInjectIntoIncompatiblePrograms] = + useState(false); const [engineInjectIntoGames, setEngineInjectIntoGames] = useState(false); + const [engineUsePhantomInjection, setEngineUsePhantomInjection] = useState(false); + const [engineUseModuleStomping, setEngineUseModuleStomping] = useState(false); + const [engineUseIndirectSyscalls, setEngineUseIndirectSyscalls] = useState(true); const resetMoreAdvancedSettings = useCallback(() => { setAppLoggingVerbosity(appSettings?.loggingVerbosity ?? 0); setEngineLoggingVerbosity(appSettings?.engine?.loggingVerbosity ?? 0); setEngineInclude(engineArrayToProcessList(appSettings?.engine?.include ?? [])); setEngineExclude(engineArrayToProcessList(appSettings?.engine?.exclude ?? [])); - setEngineInjectIntoCriticalProcesses(appSettings?.engine?.injectIntoCriticalProcesses ?? false); - setEngineInjectIntoIncompatiblePrograms(appSettings?.engine?.injectIntoIncompatiblePrograms ?? false); + setEngineInjectIntoCriticalProcesses( + appSettings?.engine?.injectIntoCriticalProcesses ?? false + ); + setEngineInjectIntoIncompatiblePrograms( + appSettings?.engine?.injectIntoIncompatiblePrograms ?? false + ); setEngineInjectIntoGames(appSettings?.engine?.injectIntoGames ?? false); + setEngineUsePhantomInjection(appSettings?.engine?.usePhantomInjection ?? false); + setEngineUseModuleStomping(appSettings?.engine?.useModuleStomping ?? false); + setEngineUseIndirectSyscalls(appSettings?.engine?.useIndirectSyscalls ?? true); }, [appSettings]); const { getAppSettings } = useGetAppSettings( useCallback((data) => { setAppSettings(data.appSettings); + setRuntimeDiagnostics(data.runtimeDiagnostics || null); }, []) ); @@ -151,12 +312,371 @@ function Settings() { const [isMoreAdvancedSettingsModalOpen, setIsMoreAdvancedSettingsModalOpen] = useState(false); + const recommendedLocalUISettings = useMemo( + () => getRecommendedLocalUISettings(runtimeDiagnostics), + [runtimeDiagnostics] + ); + + const getPerformanceProfileLabel = useCallback( + (profile: 'balanced' | 'responsive' | 'efficient') => { + switch (profile) { + case 'responsive': + return t('settings.performance.profile.options.responsive'); + case 'efficient': + return t('settings.performance.profile.options.efficient'); + case 'balanced': + default: + return t('settings.performance.profile.options.balanced'); + } + }, + [t] + ); + + const getAIAccelerationLabel = useCallback( + (preference: 'auto' | 'prefer-npu' | 'off') => { + switch (preference) { + case 'prefer-npu': + return t('settings.performance.aiAcceleration.options.preferNpu'); + case 'off': + return t('settings.performance.aiAcceleration.options.off'); + case 'auto': + default: + return t('settings.performance.aiAcceleration.options.auto'); + } + }, + [t] + ); + + const getStartupPageLabel = useCallback( + (startupPage: 'home' | 'explore' | 'settings' | 'about') => { + switch (startupPage) { + case 'explore': + return t('settings.workflow.startupPage.options.explore'); + case 'settings': + return t('settings.workflow.startupPage.options.settings'); + case 'about': + return t('settings.workflow.startupPage.options.about'); + case 'home': + default: + return t('settings.workflow.startupPage.options.home'); + } + }, + [t] + ); + + const getExploreDefaultSortLabel = useCallback( + ( + sortPreference: + | 'smart-relevance' + | 'last-updated' + | 'popular-top-rated' + ) => { + switch (sortPreference) { + case 'last-updated': + return t('settings.workflow.exploreDefaultSort.options.lastUpdated'); + case 'popular-top-rated': + return t( + 'settings.workflow.exploreDefaultSort.options.popularTopRated' + ); + case 'smart-relevance': + default: + return t( + 'settings.workflow.exploreDefaultSort.options.smartRelevance' + ); + } + }, + [t] + ); + + const getEditorAssistanceLabel = useCallback( + (assistanceLevel: 'streamlined' | 'guided' | 'full') => { + switch (assistanceLevel) { + case 'streamlined': + return t('settings.workflow.editorAssistance.options.streamlined'); + case 'guided': + return t('settings.workflow.editorAssistance.options.guided'); + case 'full': + default: + return t('settings.workflow.editorAssistance.options.full'); + } + }, + [t] + ); + + const getWindowsQuickActionDensityLabel = useCallback( + (density: 'focused' | 'expanded') => { + switch (density) { + case 'focused': + return t('settings.workflow.windowsQuickActions.options.focused'); + case 'expanded': + default: + return t('settings.workflow.windowsQuickActions.options.expanded'); + } + }, + [t] + ); + + const getAuthoringLanguageLabel = useCallback( + (language: 'cpp' | 'python') => + language === 'python' + ? t('settings.authoring.language.options.python') + : t('settings.authoring.language.options.cpp'), + [t] + ); + + const getStudioModeLabel = useCallback( + (mode: 'code' | 'visual') => + mode === 'visual' + ? t('settings.authoring.studioMode.options.visual') + : t('settings.authoring.studioMode.options.code'), + [t] + ); + + const performanceRecommendationDescription = useMemo(() => { + if (!runtimeDiagnostics) { + return t('settings.performance.recommendationFallback'); + } + + if (runtimeDiagnostics.issueCode !== 'none') { + return t('settings.performance.recommendationIssue', { + profile: getPerformanceProfileLabel( + recommendedLocalUISettings.performanceProfile + ), + }); + } + + if (runtimeDiagnostics.npuDetected) { + return t('settings.performance.recommendationNpu', { + npu: + runtimeDiagnostics.npuName || + t('settings.performance.values.detected'), + }); + } + + if (runtimeDiagnostics.totalMemoryGb <= 8) { + return t('settings.performance.recommendationEfficient', { + memory: runtimeDiagnostics.totalMemoryGb, + }); + } + + if (runtimeDiagnostics.totalMemoryGb >= 16) { + return t('settings.performance.recommendationResponsive', { + memory: runtimeDiagnostics.totalMemoryGb, + }); + } + + return t('settings.performance.recommendationBalanced'); + }, [ + getPerformanceProfileLabel, + recommendedLocalUISettings.performanceProfile, + runtimeDiagnostics, + t, + ]); + + const recommendedSettingsAlreadyApplied = + localUISettings.performanceProfile === + recommendedLocalUISettings.performanceProfile && + localUISettings.aiAccelerationPreference === + recommendedLocalUISettings.aiAccelerationPreference && + localUISettings.reduceMotion === recommendedLocalUISettings.reduceMotion && + localUISettings.useWideLayout === recommendedLocalUISettings.useWideLayout; + + const workflowSummary = useMemo( + () => + t('settings.workflow.currentSummary', { + startup: getStartupPageLabel(localUISettings.startupPage), + explore: getExploreDefaultSortLabel(localUISettings.exploreDefaultSort), + editor: getEditorAssistanceLabel(localUISettings.editorAssistanceLevel), + windows: getWindowsQuickActionDensityLabel( + localUISettings.windowsQuickActionDensity + ), + }), + [ + getEditorAssistanceLabel, + getExploreDefaultSortLabel, + getStartupPageLabel, + getWindowsQuickActionDensityLabel, + localUISettings.editorAssistanceLevel, + localUISettings.exploreDefaultSort, + localUISettings.startupPage, + localUISettings.windowsQuickActionDensity, + t, + ] + ); + + const authoringSummary = useMemo( + () => + t('settings.authoring.currentSummary', { + language: getAuthoringLanguageLabel( + localUISettings.preferredAuthoringLanguage + ), + extension: localUISettings.preferredSourceExtension, + studio: getStudioModeLabel(localUISettings.preferredStudioMode), + }), + [ + getAuthoringLanguageLabel, + getStudioModeLabel, + localUISettings.preferredAuthoringLanguage, + localUISettings.preferredSourceExtension, + localUISettings.preferredStudioMode, + t, + ] + ); + + const statusItems = useMemo(() => { + const items = []; + + if (!appSettings?.disableUpdateCheck) { + items.push({ + key: 'updates', + text: t('settings.overview.updatesEnabled'), + tone: 'default' as const, + }); + } + + if (loggingEnabled) { + items.push({ + key: 'logging', + text: t('settings.overview.debugLogging'), + tone: 'warning' as const, + }); + } + + if (safeMode) { + items.push({ + key: 'safe-mode', + text: t('settings.overview.safeMode'), + tone: 'warning' as const, + }); + } + + if (!appSettings?.devModeOptOut) { + items.push({ + key: 'dev-mode', + text: t('settings.overview.devMode'), + tone: 'default' as const, + }); + } + + if (localUISettings.interfaceDensity === 'compact') { + items.push({ + key: 'compact-layout', + text: t('settings.overview.compactLayout'), + tone: 'default' as const, + }); + } + + if (localUISettings.useWideLayout) { + items.push({ + key: 'wide-layout', + text: t('settings.overview.wideLayout'), + tone: 'default' as const, + }); + } + + if (localUISettings.reduceMotion) { + items.push({ + key: 'reduce-motion', + text: t('settings.overview.reduceMotion'), + tone: 'default' as const, + }); + } + + if (localUISettings.performanceProfile === 'responsive') { + items.push({ + key: 'responsive-profile', + text: t('settings.overview.responsiveProfile'), + tone: 'default' as const, + }); + } else if (localUISettings.performanceProfile === 'efficient') { + items.push({ + key: 'efficient-profile', + text: t('settings.overview.efficientProfile'), + tone: 'default' as const, + }); + } + + if (localUISettings.aiAccelerationPreference === 'prefer-npu') { + items.push({ + key: 'prefer-npu', + text: t('settings.overview.npuPreferred'), + tone: 'default' as const, + }); + } + + if (localUISettings.startupPage === 'explore') { + items.push({ + key: 'startup-explore', + text: t('settings.overview.startupExplore'), + tone: 'default' as const, + }); + } else if (localUISettings.startupPage === 'settings') { + items.push({ + key: 'startup-settings', + text: t('settings.overview.startupSettings'), + tone: 'default' as const, + }); + } else if (localUISettings.startupPage === 'about') { + items.push({ + key: 'startup-about', + text: t('settings.overview.startupAbout'), + tone: 'default' as const, + }); + } + + if (localUISettings.exploreDefaultSort === 'last-updated') { + items.push({ + key: 'explore-fresh', + text: t('settings.overview.exploreFresh'), + tone: 'default' as const, + }); + } else if (localUISettings.exploreDefaultSort === 'popular-top-rated') { + items.push({ + key: 'explore-popular', + text: t('settings.overview.explorePopular'), + tone: 'default' as const, + }); + } + + if (localUISettings.editorAssistanceLevel === 'streamlined') { + items.push({ + key: 'editor-streamlined', + text: t('settings.overview.editorStreamlined'), + tone: 'default' as const, + }); + } else if (localUISettings.editorAssistanceLevel === 'guided') { + items.push({ + key: 'editor-guided', + text: t('settings.overview.editorGuided'), + tone: 'default' as const, + }); + } + + if (localUISettings.windowsQuickActionDensity === 'focused') { + items.push({ + key: 'windows-focused', + text: t('settings.overview.windowsFocused'), + tone: 'default' as const, + }); + } + + return items; + }, [ + appSettings?.devModeOptOut, + appSettings?.disableUpdateCheck, + localUISettings, + loggingEnabled, + safeMode, + t, + ]); + if (!appSettings) { return null; } const includeListEmpty = engineInclude.trim() === ''; - const excludeListEmpty = engineExclude.trim() === '' && + const excludeListEmpty = + engineExclude.trim() === '' && engineInjectIntoCriticalProcesses && engineInjectIntoIncompatiblePrograms && engineInjectIntoGames; @@ -164,117 +684,102 @@ function Settings() { return ( - - - -
    {t('settings.language.description')}
    -
    + + {t('appHeader.settings')} + {t('settings.pageTitle')} + + {t('settings.pageDescription')} + + + {statusItems.length > 0 ? ( + statusItems.map(({ key, text, tone }) => ( + + {text} + + )) + ) : ( + + {t('settings.overview.allClear')} + + )} + + + + + + + {t('settings.core.title')} + {t('settings.core.description')} + + + + +
    {t('settings.language.description')}
    +
    + + website + , + ]} + /> +
    + + } + /> + { + updateAppSettings({ + appSettings: { + language: typeof value === 'string' ? value : 'en', + }, + }); + }} + dropdownMatchSelectWidth={false} + > + {appLanguages.map(([languageId, languageDisplayName]) => ( + + {languageDisplayName} + + ))} + + {appLanguage !== 'en' && ( + + website , ]} /> -
    - - } - /> - { - updateAppSettings({ - appSettings: { - language: typeof value === 'string' ? value : 'en', - }, - }); - }} - dropdownMatchSelectWidth={false} - > - {appLanguages.map(([languageId, languageDisplayName]) => ( - - {languageDisplayName} - - ))} - - {appLanguage !== 'en' && ( - - - website - , - ]} - /> - - )} -
    - - - { - updateAppSettings({ - appSettings: { - disableUpdateCheck: !checked, - }, - }); - }} - /> - - - - { - updateAppSettings({ - appSettings: { - devModeOptOut: !checked, - }, - }); - }} - /> - -
    - - - {t('settings.advancedSettings')} - {' '} - {loggingEnabled && ( - - - - )} - - } key="1"> - + + )} + { updateAppSettings({ appSettings: { - hideTrayIcon: checked, + disableUpdateCheck: !checked, }, }); }} @@ -282,49 +787,420 @@ function Settings() { { updateAppSettings({ appSettings: { - alwaysCompileModsLocally: checked, + devModeOptOut: !checked, }, }); }} /> - {appSettings.disableRunUIScheduledTask !== null && ( - - - { - updateAppSettings({ - appSettings: { - disableRunUIScheduledTask: checked, - }, - }); - }} - /> - - )} + + + + + + {t('settings.interface.title')} + + {t('settings.interface.description')} + + + + + + { + setLocalUISettings({ + interfaceDensity: + value === 'compact' ? 'compact' : 'comfortable', + }); + }} + dropdownMatchSelectWidth={false} + > + + {t('settings.interface.layoutDensity.comfortable')} + + + {t('settings.interface.layoutDensity.compact')} + + + + + + { + setLocalUISettings({ + useWideLayout: checked, + }); + }} + /> + { + setLocalUISettings({ + reduceMotion: checked, + }); + }} + /> + + + + + + + + + + + + + {t('settings.performance.title')} + + {t('settings.performance.description')} + + + +
    {performanceRecommendationDescription}
    +
    + {t('settings.performance.hardwareSummary', { + memory: + runtimeDiagnostics?.totalMemoryGb ?? + t('settings.performance.values.unknown'), + npu: + runtimeDiagnostics?.npuName || + (runtimeDiagnostics?.npuDetected + ? t('settings.performance.values.detected') + : t('settings.performance.values.none')), + })} +
    + + + + + } + /> + + + + { + setLocalUISettings({ + performanceProfile: + value === 'responsive' + ? 'responsive' + : value === 'efficient' + ? 'efficient' + : 'balanced', + }); + }} + dropdownMatchSelectWidth={false} + > + + {t('settings.performance.profile.options.balanced')} + + + {t('settings.performance.profile.options.responsive')} + + + {t('settings.performance.profile.options.efficient')} + + + + + + { + setLocalUISettings({ + aiAccelerationPreference: + value === 'prefer-npu' + ? 'prefer-npu' + : value === 'off' + ? 'off' + : 'auto', + }); + }} + dropdownMatchSelectWidth={false} + > + + {t('settings.performance.aiAcceleration.options.auto')} + + + {t('settings.performance.aiAcceleration.options.preferNpu')} + + + {t('settings.performance.aiAcceleration.options.off')} + + + + {t('settings.performance.currentSummary', { + performance: getPerformanceProfileLabel( + localUISettings.performanceProfile + ), + acceleration: getAIAccelerationLabel( + localUISettings.aiAccelerationPreference + ), + })} + + + +
    + + + + {t('settings.workflow.title')} + {t('settings.workflow.description')} + + + + + { + setLocalUISettings({ + startupPage: + value === 'explore' + ? 'explore' + : value === 'settings' + ? 'settings' + : value === 'about' + ? 'about' + : 'home', + }); + }} + dropdownMatchSelectWidth={false} + > + + {t('settings.workflow.startupPage.options.home')} + + + {t('settings.workflow.startupPage.options.explore')} + + + {t('settings.workflow.startupPage.options.settings')} + + + {t('settings.workflow.startupPage.options.about')} + + + + + + { + setLocalUISettings({ + exploreDefaultSort: + value === 'last-updated' + ? 'last-updated' + : value === 'popular-top-rated' + ? 'popular-top-rated' + : 'smart-relevance', + }); + }} + dropdownMatchSelectWidth={false} + > + + {t('settings.workflow.exploreDefaultSort.options.smartRelevance')} + + + {t('settings.workflow.exploreDefaultSort.options.lastUpdated')} + + + {t( + 'settings.workflow.exploreDefaultSort.options.popularTopRated' + )} + + + + + + { + setLocalUISettings({ + editorAssistanceLevel: + value === 'streamlined' + ? 'streamlined' + : value === 'guided' + ? 'guided' + : 'full', + }); + }} + dropdownMatchSelectWidth={false} + > + + {t('settings.workflow.editorAssistance.options.streamlined')} + + + {t('settings.workflow.editorAssistance.options.guided')} + + + {t('settings.workflow.editorAssistance.options.full')} + + + + + + { + setLocalUISettings({ + windowsQuickActionDensity: + value === 'focused' ? 'focused' : 'expanded', + }); + }} + dropdownMatchSelectWidth={false} + > + + {t('settings.workflow.windowsQuickActions.options.focused')} + + + {t('settings.workflow.windowsQuickActions.options.expanded')} + + + {workflowSummary} + + + + + + + {t('settings.authoring.title')} + + {t('settings.authoring.description')} + + + + + + { + const nextLanguage = value === 'python' ? 'python' : 'cpp'; + setLocalUISettings({ + preferredAuthoringLanguage: nextLanguage, + preferredSourceExtension: + nextLanguage === 'python' ? '.wh.py' : '.wh.cpp', + }); + }} + dropdownMatchSelectWidth={false} + > + + {t('settings.authoring.language.options.cpp')} + + + {t('settings.authoring.language.options.python')} + + + + + + { + setLocalUISettings({ + preferredStudioMode: value === 'visual' ? 'visual' : 'code', + }); + }} + dropdownMatchSelectWidth={false} + > + + {t('settings.authoring.studioMode.options.code')} + + + {t('settings.authoring.studioMode.options.visual')} + + + + + + { updateAppSettings({ appSettings: { - dontAutoShowToolkit: checked, + pythonAuthoringCommand: e.target.value, }, }); }} @@ -332,44 +1208,225 @@ function Settings() { - { + { updateAppSettings({ appSettings: { - modTasksDialogDelay: parseIntLax(value) - 1000, + pythonAuthoringArgs: e.target.value, }, }); }} /> - - - + + {authoringSummary} -
    -
    -
    + + + + + + {t('settings.advancedSettings')} + {t('settings.advancedDescription')} + + + + {t('settings.advancedSettings')} + {' '} + {loggingEnabled && ( + + + + )} + + } + key="advanced" + > + + + + { + updateAppSettings({ + appSettings: { + hideTrayIcon: checked, + }, + }); + }} + /> + + + + { + updateAppSettings({ + appSettings: { + alwaysCompileModsLocally: checked, + }, + }); + }} + /> + + + + { + updateAppSettings({ + appSettings: { + parallelCompileTargets: checked, + }, + }); + }} + /> + + + + { + updateAppSettings({ + appSettings: { + preferPrecompiledHeaders: checked, + }, + }); + }} + /> + + {appSettings.disableRunUIScheduledTask !== null && ( + + + { + updateAppSettings({ + appSettings: { + disableRunUIScheduledTask: checked, + }, + }); + }} + /> + + )} + + + { + updateAppSettings({ + appSettings: { + dontAutoShowToolkit: checked, + }, + }); + }} + /> + + + + { + updateAppSettings({ + appSettings: { + modTasksDialogDelay: parseIntLax(value) - 1000, + }, + }); + }} + /> + + + + + + + + + + + + - + { - setEngineLoggingVerbosity( - typeof value === 'number' ? value : 0 - ); + setEngineLoggingVerbosity(typeof value === 'number' ? value : 0); }} dropdownMatchSelectWidth={false} > @@ -456,16 +1515,22 @@ function Settings() { -

    {t('settings.processList.descriptionExclusion')}

    -
    - wiki]} - /> -
    - } + description={ + <> +

    {t('settings.processList.descriptionExclusion')}

    +
    + + wiki + , + ]} + /> +
    + + } /> {engineExclude.match(/["/<>|]/) && ( |', - })} + description={t( + 'settings.processList.invalidCharactersWarning', + { + invalidCharacters: '" / < > |', + } + )} type="warning" showIcon /> @@ -517,6 +1585,32 @@ function Settings() {
    + + + + setEngineUsePhantomInjection(e.target.checked)} + > + Use Phantom Thread Pool Injection + + setEngineUseModuleStomping(e.target.checked)} + > + Use Module Stomping + + setEngineUseIndirectSyscalls(e.target.checked)} + > + Use Indirect Syscalls + + + {engineInclude.match(/["/<>|]/) && ( |', - })} + description={t( + 'settings.processList.invalidCharactersWarning', + { + invalidCharacters: '" / < > |', + } + )} type="warning" showIcon /> @@ -564,7 +1661,7 @@ function Settings() { /> )} -
    +
    ); diff --git a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/aiModStudio.spec.ts b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/aiModStudio.spec.ts new file mode 100644 index 0000000..d6095df --- /dev/null +++ b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/aiModStudio.spec.ts @@ -0,0 +1,191 @@ +import { + aiPromptPacks, + buildStarterLaunchContext, + buildVisualPresetLaunchContext, + buildWorkflowLaunchContext, + buildStudioWorkflowPacket, + cliPlaybooks, + getModSourceExtensionForAuthoringLanguage, + getModStudioStartersForAuthoringLanguage, + getStudioWorkflowRecipes, + getVisualStudioPresetsForAuthoringLanguage, + modStudioStarters, + studioWorkflowRecipes, + visualStudioPresets, +} from './aiModStudio'; + +describe('aiModStudio', () => { + it('includes focused starters for AI, shell, windows, and settings-first work', () => { + expect(modStudioStarters.map((starter) => starter.key)).toEqual( + expect.arrayContaining([ + 'structured-core', + 'ai-ready', + 'explorer-shell', + 'chromium-browser', + 'window-behavior', + 'settings-lab', + ]) + ); + }); + + it('only exposes the Python-backed starter in Python authoring mode', () => { + expect( + getModStudioStartersForAuthoringLanguage('python').map( + (starter) => starter.key + ) + ).toEqual(['python-automation']); + + expect( + getModStudioStartersForAuthoringLanguage('cpp').map((starter) => starter.key) + ).toEqual( + expect.arrayContaining([ + 'structured-core', + 'default', + 'ai-ready', + 'explorer-shell', + 'chromium-browser', + 'window-behavior', + 'settings-lab', + ]) + ); + }); + + it('ships prompt packs for ideation, browser scaffolding, review, and docs', () => { + expect(aiPromptPacks.map((promptPack) => promptPack.key)).toEqual( + expect.arrayContaining([ + 'ideate', + 'structure-plan', + 'scaffold', + 'browser-ui', + 'review', + 'docs', + ]) + ); + }); + + it('filters visual presets to the active authoring language', () => { + expect( + getVisualStudioPresetsForAuthoringLanguage('python').map( + (preset) => preset.key + ) + ).toEqual(['visual-automation']); + + expect( + getVisualStudioPresetsForAuthoringLanguage('cpp').map( + (preset) => preset.key + ) + ).toEqual( + expect.arrayContaining([ + 'visual-shell', + 'visual-windows', + 'visual-settings', + ]) + ); + }); + + it('maps authoring language to the expected source extension', () => { + expect(getModSourceExtensionForAuthoringLanguage('cpp')).toBe('.wh.cpp'); + expect(getModSourceExtensionForAuthoringLanguage('python')).toBe('.wh.py'); + }); + + it('includes visual presets and CLI playbooks for studio tooling', () => { + expect(visualStudioPresets.map((preset) => preset.key)).toEqual( + expect.arrayContaining([ + 'visual-automation', + 'visual-shell', + 'visual-windows', + 'visual-settings', + ]) + ); + + expect(cliPlaybooks.map((playbook) => playbook.key)).toEqual( + expect.arrayContaining([ + 'detect-runtime', + 'status', + 'launch-tray', + 'init-mod', + 'compile-restart', + 'tail-logs', + ]) + ); + }); + + it('filters workflow bundles by authoring language and studio mode', () => { + expect( + getStudioWorkflowRecipes('python', 'visual').map((recipe) => recipe.key) + ).toEqual(['automation-prototype']); + + expect( + getStudioWorkflowRecipes('cpp', 'code').map((recipe) => recipe.key) + ).toEqual( + expect.arrayContaining([ + 'shell-investigation', + 'browser-ui-lab', + 'window-behavior-audit', + 'settings-rollout', + ]) + ); + }); + + it('builds copy-ready workflow packets that summarize the starter, checklist, and tools', () => { + const recipe = studioWorkflowRecipes.find( + (candidate) => candidate.key === 'shell-investigation' + ); + + expect(recipe).toBeDefined(); + if (!recipe) { + throw new Error('Expected shell-investigation workflow recipe to exist'); + } + + const packet = buildStudioWorkflowPacket(recipe); + + expect(packet).toContain('Launch: Shell investigation sprint'); + expect(packet).toContain('Starter: Explorer shell starter'); + expect(packet).toContain('CLI playbooks:'); + expect(packet).toContain('Prompt packs:'); + }); + + it('builds launch context packets for starters, presets, and workflows', () => { + const starter = modStudioStarters.find( + (candidate) => candidate.key === 'structured-core' + ); + const preset = visualStudioPresets.find( + (candidate) => candidate.key === 'visual-shell' + ); + const workflow = studioWorkflowRecipes.find( + (candidate) => candidate.key === 'shell-investigation' + ); + + expect(starter).toBeDefined(); + expect(preset).toBeDefined(); + expect(workflow).toBeDefined(); + + if (!starter || !preset || !workflow) { + throw new Error('Expected starter, preset, and workflow fixtures'); + } + + const starterContext = buildStarterLaunchContext(starter, 'cpp', 'code'); + const presetContext = buildVisualPresetLaunchContext(preset, 'cpp'); + const workflowContext = buildWorkflowLaunchContext( + workflow, + 'cpp', + 'visual' + ); + + expect(starterContext.kind).toBe('starter'); + expect(starterContext.packet).toContain('Launch: Structured core starter'); + expect(starterContext.tools?.some((tool) => tool.key === 'compile-restart')).toBe( + true + ); + + expect(presetContext.kind).toBe('visual-preset'); + expect(presetContext.studioMode).toBe('visual'); + expect(presetContext.packet).toContain('Launch: Shell surfaces'); + + expect(workflowContext.kind).toBe('workflow'); + expect(workflowContext.packet).toContain('Launch: Shell investigation sprint'); + expect(workflowContext.tools?.some((tool) => tool.key === 'tail-logs')).toBe( + true + ); + }); +}); diff --git a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/aiModStudio.ts b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/aiModStudio.ts new file mode 100644 index 0000000..ba5cef6 --- /dev/null +++ b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/aiModStudio.ts @@ -0,0 +1,744 @@ +import { + CreateNewModTemplateKey, + EditorLaunchContext, + EditorLaunchContextResource, + ModSourceExtension, +} from '../webviewIPCMessages'; + +export type ModAuthoringLanguage = 'cpp' | 'python'; + +export type ModStudioStarter = { + key: CreateNewModTemplateKey; + title: string; + description: string; + highlights: string[]; + actionLabel: string; + supportedAuthoringLanguages: ModAuthoringLanguage[]; + recommended?: boolean; +}; + +export type AiPromptPack = { + key: string; + title: string; + description: string; + prompt: string; +}; + +export type VisualStudioPreset = { + key: string; + title: string; + description: string; + templateKey: CreateNewModTemplateKey; + recommendedLanguage: ModAuthoringLanguage; +}; + +export type CliPlaybook = { + key: string; + title: string; + description: string; + command: string; +}; + +export type StudioWorkflowRecipe = { + key: string; + title: string; + description: string; + supportedStudioModes: ('code' | 'visual')[]; + supportedAuthoringLanguages: ModAuthoringLanguage[]; + recommendedTemplateKey: CreateNewModTemplateKey; + suggestedPlaybookKeys: CliPlaybook['key'][]; + suggestedPromptPackKeys: AiPromptPack['key'][]; + checklist: string[]; +}; + +type StudioLaunchGuide = { + checklist: string[]; + suggestedPlaybookKeys: CliPlaybook['key'][]; + suggestedPromptPackKeys: AiPromptPack['key'][]; +}; + +export const modStudioStarters: ModStudioStarter[] = [ + { + key: 'structured-core', + title: 'Structured core starter', + description: 'An architecture-first scaffold with explicit sections for settings, runtime state, diagnostics, hook setup, and lifecycle callbacks.', + highlights: [ + 'Separates configuration, helpers, and hook setup from the start', + 'Starts in a dry-run shape so you can inspect the target before adding hooks', + 'Best default when you want a mod that stays readable as it grows', + ], + actionLabel: 'Use structured core starter', + supportedAuthoringLanguages: ['cpp'], + recommended: true, + }, + { + key: 'default', + title: 'Standard starter', + description: 'The classic Windhawk sample with a working hook example for authors who want a direct, minimal path.', + highlights: [ + 'Now organized into clearer settings, hook, and lifecycle sections', + 'Good when you already know the hook strategy', + 'Fastest path to a working sample with real behavior', + ], + actionLabel: 'Use standard starter', + supportedAuthoringLanguages: ['cpp'], + }, + { + key: 'ai-ready', + title: 'AI-ready starter', + description: 'A template that adds prompt scaffolding, review notes, and a verification checklist for AI-assisted work.', + highlights: [ + 'Includes an AI collaboration brief in the readme', + 'Adds a human verification checklist before shipping', + 'Keeps the code sample compatible with the standard build flow', + ], + actionLabel: 'Use AI-ready starter', + supportedAuthoringLanguages: ['cpp'], + }, + { + key: 'explorer-shell', + title: 'Explorer shell starter', + description: 'A Windows shell-focused scaffold for taskbar, tray, Start menu, or notification surface experiments.', + highlights: [ + 'Targets explorer.exe and common shell hosts', + 'Adds scope notes for taskbar, Start, and tray work', + 'Keeps the code minimal so you can choose the actual hook path', + ], + actionLabel: 'Use Explorer shell starter', + supportedAuthoringLanguages: ['cpp'], + }, + { + key: 'chromium-browser', + title: 'Chromium browser starter', + description: 'A browser-focused scaffold for Chrome-family window chrome, tab strip, shortcut, and UI-behavior experiments.', + highlights: [ + 'Starts with chrome.exe so Chrome-related mods are first-class in the create flow', + 'Logs browser process and foreground window details before you choose a hook', + 'Good for Chrome, Chromium, and other browser-family UI investigations', + ], + actionLabel: 'Use Chromium browser starter', + supportedAuthoringLanguages: ['cpp'], + }, + { + key: 'window-behavior', + title: 'Window behavior starter', + description: 'A focused app-window scaffold for mods that change captions, sizing, visibility, styles, or placement.', + highlights: [ + 'Starts with a single-app target for safer iteration', + 'Includes helpers for deciding which windows to affect', + 'Good for ShowWindow, SetWindowPos, and style-related experiments', + ], + actionLabel: 'Use window behavior starter', + supportedAuthoringLanguages: ['cpp'], + }, + { + key: 'settings-lab', + title: 'Settings lab starter', + description: 'A configuration-first scaffold for mods where settings design, defaults, and rollout safety come before hooks.', + highlights: [ + 'Shows nested settings and live reload structure', + 'Useful when you want to prototype config shape before hook work', + 'Good for feature flags, intensity values, and staged rollouts', + ], + actionLabel: 'Use settings lab starter', + supportedAuthoringLanguages: ['cpp'], + }, + { + key: 'python-automation', + title: 'Python automation starter', + description: 'A Python-authored scaffold that renders to .wh.cpp and ships with mouse and keyboard automation helpers.', + highlights: [ + 'Authors a mod in .wh.py while keeping generated .wh.cpp compatibility', + 'Includes SendInput-backed mouse and keyboard helpers out of the box', + 'Best fit for fast experimentation, automation ideas, and visual builder flows', + ], + actionLabel: 'Use Python automation starter', + supportedAuthoringLanguages: ['python'], + }, +]; + +export function getModStudioStartersForAuthoringLanguage( + authoringLanguage: ModAuthoringLanguage +) { + return modStudioStarters.filter((starter) => + starter.supportedAuthoringLanguages.includes(authoringLanguage) + ); +} + +export function getVisualStudioPresetsForAuthoringLanguage( + authoringLanguage: ModAuthoringLanguage +) { + const availableTemplateKeys = new Set( + getModStudioStartersForAuthoringLanguage(authoringLanguage).map( + (starter) => starter.key + ) + ); + + return visualStudioPresets.filter((preset) => + availableTemplateKeys.has(preset.templateKey) + ); +} + +export function getModSourceExtensionForAuthoringLanguage( + authoringLanguage: ModAuthoringLanguage +): ModSourceExtension { + return authoringLanguage === 'python' ? '.wh.py' : '.wh.cpp'; +} + +export const visualStudioPresets: VisualStudioPreset[] = [ + { + key: 'visual-automation', + title: 'Automation lab', + description: 'Start from mouse and keyboard automation with editable coordinates, shortcuts, and logging.', + templateKey: 'python-automation', + recommendedLanguage: 'python', + }, + { + key: 'visual-shell', + title: 'Shell surfaces', + description: 'Use an Explorer-focused starter for taskbar, Start, tray, and shell investigations.', + templateKey: 'explorer-shell', + recommendedLanguage: 'cpp', + }, + { + key: 'visual-windows', + title: 'Window behavior', + description: 'Focus on captions, sizing, visibility, and placement without starting from a blank file.', + templateKey: 'window-behavior', + recommendedLanguage: 'cpp', + }, + { + key: 'visual-settings', + title: 'Settings-first mod', + description: 'Sketch the config model visually first, then layer in hooks after the behavior is clear.', + templateKey: 'settings-lab', + recommendedLanguage: 'cpp', + }, +]; + +export const cliPlaybooks: CliPlaybook[] = [ + { + key: 'detect-runtime', + title: 'Detect runtime', + description: 'Confirm which Windhawk runtime and storage layout the Copilot helper will target.', + command: 'python scripts\\windhawk_tool.py --json detect', + }, + { + key: 'status', + title: 'Inspect status', + description: 'List runtime details, source mods, and installed mod state before editing or compiling.', + command: 'python scripts\\windhawk_tool.py --json status', + }, + { + key: 'launch-tray', + title: 'Launch in tray mode', + description: 'Bring Windhawk back up quietly when you want the runtime active without opening the main window.', + command: 'python scripts\\windhawk_tool.py launch --tray-only', + }, + { + key: 'init-mod', + title: 'Create scratch mod', + description: 'Generate a new mod source and sync it into the editor workspace in one step.', + command: + 'python scripts\\windhawk_tool.py init-mod --mod-id scratch-mod --name "Scratch Mod" --include explorer.exe --sync-workspace', + }, + { + key: 'compile-restart', + title: 'Compile and restart', + description: 'Compile the current workspace mod with the Windhawk toolchain contract and restart the runtime.', + command: + 'python scripts\\windhawk_tool.py compile --mod-id scratch-mod --from-workspace --restart', + }, + { + key: 'tail-logs', + title: 'Tail latest logs', + description: 'Read the newest Windhawk UI log session after a compile, restart, or runtime regression.', + command: 'python scripts\\windhawk_tool.py logs --kind main --lines 120', + }, +]; + +export const studioWorkflowRecipes: StudioWorkflowRecipe[] = [ + { + key: 'shell-investigation', + title: 'Shell investigation sprint', + description: + 'Best for taskbar, tray, Start menu, or notification work where you want a visible shell-surface checklist before choosing hooks.', + supportedStudioModes: ['code', 'visual'], + supportedAuthoringLanguages: ['cpp'], + recommendedTemplateKey: 'explorer-shell', + suggestedPlaybookKeys: [ + 'detect-runtime', + 'status', + 'compile-restart', + 'tail-logs', + ], + suggestedPromptPackKeys: ['ideate', 'review'], + checklist: [ + 'Confirm the active shell host processes before writing any hook code.', + 'Keep the first compile disabled or tightly scoped until the target surface is verified.', + 'Review adjacent shell flows so a taskbar or tray change does not spill into unrelated Windows surfaces.', + ], + }, + { + key: 'browser-ui-lab', + title: 'Browser UI lab', + description: + 'Use this when the draft targets Chrome-family chrome, tabs, shortcuts, or other browser-hosted UI surfaces.', + supportedStudioModes: ['code'], + supportedAuthoringLanguages: ['cpp'], + recommendedTemplateKey: 'chromium-browser', + suggestedPlaybookKeys: ['status', 'init-mod', 'compile-restart', 'tail-logs'], + suggestedPromptPackKeys: ['browser-ui', 'review'], + checklist: [ + 'Verify that the issue belongs to browser chrome rather than content rendering.', + 'Capture the weakest assumption in the current hook idea before compiling.', + 'Use logging on the first run so UI regressions are easier to localize.', + ], + }, + { + key: 'automation-prototype', + title: 'Automation prototype', + description: + 'Fastest path for keyboard, mouse, or repeatable workflow experiments where Python authoring lowers the iteration cost.', + supportedStudioModes: ['code', 'visual'], + supportedAuthoringLanguages: ['python'], + recommendedTemplateKey: 'python-automation', + suggestedPlaybookKeys: ['status', 'launch-tray', 'compile-restart', 'tail-logs'], + suggestedPromptPackKeys: ['ideate', 'docs'], + checklist: [ + 'Start with a narrow scenario and explicit timing assumptions.', + 'Keep the first script observable so failed automation steps are easy to inspect.', + 'Document shortcuts, coordinates, and foreground-window expectations before sharing the draft.', + ], + }, + { + key: 'window-behavior-audit', + title: 'Window behavior audit', + description: + 'Focuses on captions, visibility, placement, and sizing changes where scoping the affected windows matters more than raw hook volume.', + supportedStudioModes: ['code', 'visual'], + supportedAuthoringLanguages: ['cpp'], + recommendedTemplateKey: 'window-behavior', + suggestedPlaybookKeys: [ + 'detect-runtime', + 'status', + 'compile-restart', + 'tail-logs', + ], + suggestedPromptPackKeys: ['scaffold', 'review'], + checklist: [ + 'List which windows should stay unchanged before you touch styles or placement.', + 'Start with a single-app or narrow target so failures are easy to unwind.', + 'Check restore, minimize, and multi-monitor behavior in the first manual run.', + ], + }, + { + key: 'settings-rollout', + title: 'Settings-first rollout', + description: + 'Use this when the risk is mostly in configuration shape, staged rollout, or live setting updates rather than the initial hook itself.', + supportedStudioModes: ['code', 'visual'], + supportedAuthoringLanguages: ['cpp'], + recommendedTemplateKey: 'settings-lab', + suggestedPlaybookKeys: ['status', 'init-mod', 'compile-restart', 'tail-logs'], + suggestedPromptPackKeys: ['structure-plan', 'docs'], + checklist: [ + 'Decide which settings need safe defaults before adding any behavior.', + 'Design the readme and upgrade notes alongside the config model.', + 'Treat live reload and fallback behavior as part of the first implementation, not follow-up polish.', + ], + }, +]; + +const starterLaunchGuides: Partial< + Record +> = { + 'structured-core': { + checklist: [ + 'Name the first runtime state, helper, and hook sections before adding more logic.', + 'Keep the first compile narrow enough that you can explain every section in one review pass.', + 'Use the structure prompt or review prompt once the first scaffold lands.', + ], + suggestedPlaybookKeys: ['detect-runtime', 'status', 'compile-restart'], + suggestedPromptPackKeys: ['structure-plan', 'review'], + }, + default: { + checklist: [ + 'Verify the intended hook target before expanding the sample beyond the default path.', + 'Turn logging on if the first run touches more than one visible user flow.', + 'Write down the smallest manual smoke test before the next edit.', + ], + suggestedPlaybookKeys: ['status', 'compile-restart', 'tail-logs'], + suggestedPromptPackKeys: ['scaffold', 'review'], + }, + 'ai-ready': { + checklist: [ + 'Keep the AI prompt trail grounded in actual target processes and observed behavior.', + 'Use the review prompt before trusting the first clean compile.', + 'Refresh the docs or changelog packet as soon as the behavior stabilizes.', + ], + suggestedPlaybookKeys: ['status', 'compile-restart', 'tail-logs'], + suggestedPromptPackKeys: ['ideate', 'review', 'docs'], + }, + 'explorer-shell': { + checklist: [ + 'Confirm the active shell surface before writing the first hook.', + 'Compile disabled or with logging if the taskbar, Start, or tray are all in scope.', + 'Check an adjacent shell flow that should stay unchanged.', + ], + suggestedPlaybookKeys: ['detect-runtime', 'status', 'compile-restart', 'tail-logs'], + suggestedPromptPackKeys: ['ideate', 'review'], + }, + 'chromium-browser': { + checklist: [ + 'Make sure the issue belongs to browser chrome rather than page content.', + 'Name the least certain UI assumption before the first compile.', + 'Capture the first log evidence after launch so browser regressions are easier to localize.', + ], + suggestedPlaybookKeys: ['status', 'compile-restart', 'tail-logs'], + suggestedPromptPackKeys: ['browser-ui', 'review'], + }, + 'window-behavior': { + checklist: [ + 'List which windows must stay untouched before changing styles or placement.', + 'Start with one app or one window class where possible.', + 'Check restore, minimize, and multi-monitor behavior in the first pass.', + ], + suggestedPlaybookKeys: ['detect-runtime', 'status', 'compile-restart', 'tail-logs'], + suggestedPromptPackKeys: ['scaffold', 'review'], + }, + 'settings-lab': { + checklist: [ + 'Decide safe defaults before adding runtime behavior.', + 'Treat live-reload and rollback behavior as part of the first implementation.', + 'Draft the readme delta alongside the settings model instead of after it.', + ], + suggestedPlaybookKeys: ['status', 'init-mod', 'compile-restart'], + suggestedPromptPackKeys: ['structure-plan', 'docs'], + }, + 'python-automation': { + checklist: [ + 'Keep the first automation scenario narrow and observable.', + 'Write down timing, focus, and input assumptions before sharing the draft.', + 'Use logs or visible markers so failures are easy to replay.', + ], + suggestedPlaybookKeys: ['status', 'launch-tray', 'compile-restart', 'tail-logs'], + suggestedPromptPackKeys: ['ideate', 'docs'], + }, +}; + +export function getStudioWorkflowRecipes( + authoringLanguage: ModAuthoringLanguage, + studioMode: 'code' | 'visual' +) { + return studioWorkflowRecipes.filter( + (recipe) => + recipe.supportedAuthoringLanguages.includes(authoringLanguage) && + recipe.supportedStudioModes.includes(studioMode) + ); +} + +function getCliPlaybooksByKeys(keys: CliPlaybook['key'][]): CliPlaybook[] { + return cliPlaybooks.filter((playbook) => keys.includes(playbook.key)); +} + +function getAiPromptPacksByKeys(keys: AiPromptPack['key'][]): AiPromptPack[] { + return aiPromptPacks.filter((promptPack) => keys.includes(promptPack.key)); +} + +function toLaunchResources( + items: T[] +): EditorLaunchContextResource[] { + return items.map((item) => ({ + key: item.key, + title: item.title, + command: item.command, + })); +} + +function buildStudioLaunchPacket({ + title, + summary, + starterTitle, + authoringLanguage, + studioMode, + checklist, + playbooks, + prompts, +}: { + title: string; + summary: string; + starterTitle: string; + authoringLanguage: ModAuthoringLanguage; + studioMode: 'code' | 'visual'; + checklist: string[]; + playbooks: CliPlaybook[]; + prompts: AiPromptPack[]; +}) { + return [ + `Launch: ${title}`, + '', + summary, + '', + `Studio mode: ${studioMode}`, + `Authoring language: ${authoringLanguage}`, + `Starter: ${starterTitle}`, + '', + 'Checklist:', + ...checklist.map((item, index) => `${index + 1}. ${item}`), + '', + 'CLI playbooks:', + ...playbooks.map((playbook) => `- ${playbook.title}: ${playbook.command}`), + '', + 'Prompt packs:', + ...prompts.map((promptPack) => `- ${promptPack.title}`), + ].join('\n'); +} + +export function buildStudioWorkflowPacket(recipe: StudioWorkflowRecipe) { + const starter = modStudioStarters.find( + (candidate) => candidate.key === recipe.recommendedTemplateKey + ); + const playbooks = getCliPlaybooksByKeys(recipe.suggestedPlaybookKeys); + const prompts = getAiPromptPacksByKeys(recipe.suggestedPromptPackKeys); + + return buildStudioLaunchPacket({ + title: recipe.title, + summary: recipe.description, + starterTitle: starter?.title || recipe.recommendedTemplateKey, + authoringLanguage: recipe.supportedAuthoringLanguages[0] || 'cpp', + studioMode: recipe.supportedStudioModes[0] || 'code', + checklist: recipe.checklist, + playbooks, + prompts, + }); +} + +export function buildStarterLaunchContext( + starter: ModStudioStarter, + authoringLanguage: ModAuthoringLanguage, + studioMode: 'code' | 'visual' +): EditorLaunchContext { + const guide = starterLaunchGuides[starter.key] || { + checklist: starter.highlights, + suggestedPlaybookKeys: ['status', 'compile-restart'], + suggestedPromptPackKeys: ['review'], + }; + const playbooks = getCliPlaybooksByKeys(guide.suggestedPlaybookKeys); + const prompts = getAiPromptPacksByKeys(guide.suggestedPromptPackKeys); + + return { + kind: 'starter', + title: starter.title, + summary: starter.description, + templateKey: starter.key, + studioMode, + authoringLanguage, + checklist: guide.checklist, + tools: toLaunchResources(playbooks), + prompts: toLaunchResources(prompts), + packet: buildStudioLaunchPacket({ + title: starter.title, + summary: starter.description, + starterTitle: starter.title, + authoringLanguage, + studioMode, + checklist: guide.checklist, + playbooks, + prompts, + }), + }; +} + +export function buildVisualPresetLaunchContext( + preset: VisualStudioPreset, + authoringLanguage: ModAuthoringLanguage +): EditorLaunchContext { + const starter = modStudioStarters.find( + (candidate) => candidate.key === preset.templateKey + ); + const starterContext = buildStarterLaunchContext( + starter || { + key: preset.templateKey, + title: preset.title, + description: preset.description, + highlights: [], + actionLabel: preset.title, + supportedAuthoringLanguages: [authoringLanguage], + }, + authoringLanguage, + 'visual' + ); + + return { + ...starterContext, + kind: 'visual-preset', + title: preset.title, + summary: preset.description, + studioMode: 'visual', + packet: buildStudioLaunchPacket({ + title: preset.title, + summary: preset.description, + starterTitle: starter?.title || preset.templateKey, + authoringLanguage, + studioMode: 'visual', + checklist: starterContext.checklist || [], + playbooks: (starterContext.tools || []) + .map((tool) => cliPlaybooks.find((candidate) => candidate.key === tool.key)) + .filter((tool): tool is CliPlaybook => !!tool), + prompts: (starterContext.prompts || []) + .map((prompt) => + aiPromptPacks.find((candidate) => candidate.key === prompt.key) + ) + .filter((prompt): prompt is AiPromptPack => !!prompt), + }), + }; +} + +export function buildWorkflowLaunchContext( + recipe: StudioWorkflowRecipe, + authoringLanguage: ModAuthoringLanguage, + studioMode: 'code' | 'visual' +): EditorLaunchContext { + const playbooks = getCliPlaybooksByKeys(recipe.suggestedPlaybookKeys); + const prompts = getAiPromptPacksByKeys(recipe.suggestedPromptPackKeys); + + return { + kind: 'workflow', + title: recipe.title, + summary: recipe.description, + templateKey: recipe.recommendedTemplateKey, + studioMode, + authoringLanguage, + checklist: recipe.checklist, + tools: toLaunchResources(playbooks), + prompts: toLaunchResources(prompts), + packet: buildStudioLaunchPacket({ + title: recipe.title, + summary: recipe.description, + starterTitle: + modStudioStarters.find( + (candidate) => candidate.key === recipe.recommendedTemplateKey + )?.title || recipe.recommendedTemplateKey, + authoringLanguage, + studioMode, + checklist: recipe.checklist, + playbooks, + prompts, + }), + }; +} + +export const aiPromptPacks: AiPromptPack[] = [ + { + key: 'ideate', + title: 'Ideation prompt', + description: 'Turn a rough idea into a scoped Windhawk mod concept.', + prompt: `Help me design a Windhawk mod. +Target process: +User problem to solve: +Windows UI or API area involved: +Constraints: +- Prefer the smallest reliable hook surface. +- Avoid changing behavior outside the target scenario. +- Suggest optional settings only when they clearly help users. +Output: +1. Mod concept summary +2. Candidate hook points or APIs to inspect +3. Risks and failure modes +4. A minimal test plan`, + }, + { + key: 'structure-plan', + title: 'Structure prompt', + description: 'Ask AI to refactor or extend a mod without turning it into an unstructured blob.', + prompt: `Help me improve the structure of this Windhawk mod. +Goal: +Target process: +What already works: +What feels messy: +Constraints: +- Keep the metadata, readme, and settings blocks valid. +- Split the code into settings, runtime state, helpers, hook setup, and lifecycle callbacks. +- Prefer small named helpers over one long Wh_ModInit body. +- Preserve behavior unless I explicitly ask to change it. +Output: +1. Proposed section layout +2. Refactored source code +3. Why each section exists +4. Follow-up cleanup suggestions`, + }, + { + key: 'scaffold', + title: 'Scaffold prompt', + description: 'Ask AI to write or revise a Windhawk mod while preserving the expected metadata blocks.', + prompt: `Help me write a Windhawk mod in C++. +Goal: +Target process: +Known APIs or functions: +Requirements: +- Keep the Windhawk metadata, readme, and settings blocks valid. +- Explain why each hook target is appropriate. +- Keep logging in place for the first iteration. +- Avoid adding speculative code that cannot be justified from the goal. +Output: +1. Updated source code +2. Explanation of each hook +3. Manual verification steps`, + }, + { + key: 'browser-ui', + title: 'Browser UI prompt', + description: 'Scope a Chrome or Chromium-related mod before choosing a fragile browser-specific hook.', + prompt: `Help me design a Windhawk mod for a Chromium-based browser. +Browser process: +User problem to solve: +Window or browser UI surface involved: +Known candidate APIs, classes, or messages: +Constraints: +- Prefer the smallest reliable Win32 or browser-hosted surface. +- Avoid widening the scope beyond the browser chrome until the first run is proven. +- Call out what should stay unchanged in adjacent browser flows. +Output: +1. Candidate APIs, messages, or UI surfaces to inspect +2. The weakest assumption in the current idea +3. A low-risk first compile profile +4. A manual validation loop for Chrome-family behavior`, + }, + { + key: 'review', + title: 'Review prompt', + description: 'Use AI as a reviewer for safety, regressions, and missing tests.', + prompt: `Review this Windhawk mod like a cautious senior engineer. +Focus on: +- Crash risks +- Incorrect hook targets +- Missing error handling +- Unsafe assumptions about process lifetime or thread context +- User-facing regressions +- Missing manual tests +Output: +1. Findings ordered by severity +2. The most important tests to run before enabling the mod by default +3. Any metadata or readme changes that would reduce user confusion`, + }, + { + key: 'docs', + title: 'Docs and changelog prompt', + description: 'Generate a readme or release note update that stays grounded in actual behavior.', + prompt: `Draft documentation for this Windhawk mod update. +Include: +- What changed +- Which processes are affected +- New settings or behavior changes +- Upgrade risks or compatibility notes +Avoid: +- Marketing language +- Claims that are not verified +- Hiding limitations or known edge cases +Output: +1. Readme update +2. Short changelog entry +3. Manual test notes for contributors`, + }, +]; diff --git a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/changelogUtils.spec.ts b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/changelogUtils.spec.ts new file mode 100644 index 0000000..22d9173 --- /dev/null +++ b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/changelogUtils.spec.ts @@ -0,0 +1,79 @@ +import { + filterChangelogSections, + parseChangelogSections, + selectChangelogSections, +} from './changelogUtils'; + +describe('changelogUtils', () => { + it('splits markdown changelogs into heading-based sections', () => { + const sections = parseChangelogSections(` +# 1.7.3 + +- Added a better install flow + +## UI + +- Refreshed the changelog modal + +# 1.7.2 + +- Fixed a crash +`); + + expect(sections).toHaveLength(3); + expect(sections[0]).toMatchObject({ + heading: '1.7.3', + bulletCount: 1, + }); + expect(sections[1].heading).toBe('UI'); + expect(sections[2].heading).toBe('1.7.2'); + }); + + it('returns a single section when the changelog has no headings', () => { + const sections = parseChangelogSections(` +- Added better summaries +- Improved install review links +`); + + expect(sections).toEqual([ + expect.objectContaining({ + heading: '', + bulletCount: 2, + }), + ]); + }); + + it('filters sections by heading or body content', () => { + const sections = parseChangelogSections(` +# 1.7.3 + +- Added a search box + +# 1.7.2 + +- Fixed a crash in the compiler +`); + + expect(filterChangelogSections(sections, 'search')).toHaveLength(1); + expect(filterChangelogSections(sections, '1.7.2')[0].heading).toBe('1.7.2'); + }); + + it('can scope the changelog to the latest release or a selected section', () => { + const sections = parseChangelogSections(` +# 1.7.3 + +- Added a search box + +# 1.7.2 + +- Fixed a crash in the compiler +`); + + expect(selectChangelogSections(sections, { latestOnly: true })).toEqual([ + expect.objectContaining({ heading: '1.7.3' }), + ]); + expect(selectChangelogSections(sections, { sectionIndex: 1 })).toEqual([ + expect.objectContaining({ heading: '1.7.2' }), + ]); + }); +}); diff --git a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/changelogUtils.ts b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/changelogUtils.ts new file mode 100644 index 0000000..cb3242e --- /dev/null +++ b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/changelogUtils.ts @@ -0,0 +1,102 @@ +export type ChangelogSection = { + heading: string; + markdown: string; + body: string; + bulletCount: number; +}; + +function countBulletLines(markdown: string): number { + return markdown + .split(/\r?\n/) + .filter((line) => /^\s*[-*+]\s+/.test(line)) + .length; +} + +function finalizeSection(lines: string[]): ChangelogSection | null { + const markdown = lines.join('\n').trim(); + if (!markdown) { + return null; + } + + const [firstLine, ...restLines] = markdown.split(/\r?\n/); + const headingMatch = firstLine.match(/^(#{1,6})\s+(.*)$/); + const heading = headingMatch?.[2]?.trim() || ''; + const body = headingMatch ? restLines.join('\n').trim() : markdown; + + return { + heading, + markdown, + body, + bulletCount: countBulletLines(markdown), + }; +} + +export function parseChangelogSections(markdown: string): ChangelogSection[] { + const normalizedMarkdown = markdown.replace(/\r\n/g, '\n').trim(); + if (!normalizedMarkdown) { + return []; + } + + const lines = normalizedMarkdown.split('\n'); + const sections: ChangelogSection[] = []; + let currentLines: string[] = []; + + for (const line of lines) { + if (/^#{1,6}\s+/.test(line) && currentLines.length > 0) { + const section = finalizeSection(currentLines); + if (section) { + sections.push(section); + } + currentLines = [line]; + continue; + } + + currentLines.push(line); + } + + const lastSection = finalizeSection(currentLines); + if (lastSection) { + sections.push(lastSection); + } + + return sections; +} + +export function filterChangelogSections( + sections: ChangelogSection[], + query: string +): ChangelogSection[] { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) { + return sections; + } + + return sections.filter((section) => ( + `${section.heading}\n${section.body}`.toLowerCase().includes(normalizedQuery) + )); +} + +export function selectChangelogSections( + sections: ChangelogSection[], + options: { + latestOnly?: boolean; + sectionIndex?: number | null; + } +): ChangelogSection[] { + const { latestOnly = false, sectionIndex = null } = options; + + if (latestOnly) { + return sections.length > 0 ? [sections[0]] : []; + } + + if ( + sectionIndex !== null && + Number.isInteger(sectionIndex) && + sectionIndex >= 0 && + sectionIndex < sections.length + ) { + return [sections[sectionIndex]]; + } + + return sections; +} diff --git a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/installDecisionUtils.spec.ts b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/installDecisionUtils.spec.ts new file mode 100644 index 0000000..7bc69e7 --- /dev/null +++ b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/installDecisionUtils.spec.ts @@ -0,0 +1,49 @@ +import { buildInstallDecisionChecklist, getInstallDecisionRecommendations } from './installDecisionUtils'; + +describe('installDecisionUtils', () => { + it('recommends disabled-first installs for broad or low-review mods', () => { + const recommendations = getInstallDecisionRecommendations( + { + include: ['*'], + }, + { + users: 120, + rating: 6, + ratingBreakdown: [0, 0, 1, 2, 3], + defaultSorting: 10, + published: Date.now(), + updated: Date.now() - 240 * 24 * 60 * 60 * 1000, + }, + { + readme: null, + source: null, + } + ); + + expect( + recommendations.find((item) => item.key === 'install-disabled')?.recommended + ).toBe(true); + }); + + it('builds an install checklist that reacts to scope and freshness', () => { + const checklist = buildInstallDecisionChecklist( + { + include: ['explorer.exe', 'StartMenuExperienceHost.exe'], + }, + { + users: 4000, + rating: 9, + ratingBreakdown: [0, 0, 1, 8, 24], + defaultSorting: 90, + published: Date.now(), + updated: Date.now() - 220 * 24 * 60 * 60 * 1000, + }, + { + source: 'int main() {}', + } + ); + + expect(checklist.some((item) => item.includes('targeted process'))).toBe(true); + expect(checklist.some((item) => item.includes('not been updated recently'))).toBe(true); + }); +}); diff --git a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/installDecisionUtils.ts b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/installDecisionUtils.ts new file mode 100644 index 0000000..e710fd3 --- /dev/null +++ b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/installDecisionUtils.ts @@ -0,0 +1,150 @@ +import { ModMetadata, RepositoryDetails } from '../webviewIPCMessages'; + +export type InstallSourceData = Partial<{ + source: string | null; + readme: string | null; + initialSettings: unknown[] | null; +}>; + +export type InstallDecisionAction = + | 'install-now' + | 'install-disabled' + | 'review-source' + | 'review-details' + | 'review-changelog'; + +export type InstallDecisionRecommendation = { + key: InstallDecisionAction; + title: string; + description: string; + recommended: boolean; +}; + +function normalizeProcessName(process: string): string { + return process.includes('\\') + ? process.substring(process.lastIndexOf('\\') + 1) + : process; +} + +function getTargetSummary(modMetadata: ModMetadata) { + const include = (modMetadata.include || []).filter(Boolean); + const wildcardTargets = include.some( + (entry) => entry.includes('*') || entry.includes('?') + ); + const normalizedTargets = Array.from( + new Set(include.map((entry) => normalizeProcessName(entry))) + ); + + return { + wildcardTargets, + targetCount: wildcardTargets ? 999 : normalizedTargets.length, + }; +} + +export function getInstallDecisionRecommendations( + modMetadata: ModMetadata, + repositoryDetails: RepositoryDetails | undefined, + installSourceData: InstallSourceData | undefined +): InstallDecisionRecommendation[] { + const { wildcardTargets, targetCount } = getTargetSummary(modMetadata); + const hasSource = !!installSourceData?.source; + const hasReadme = !!installSourceData?.readme; + const hasSettings = !!installSourceData?.initialSettings?.length; + const strongCommunity = !!( + repositoryDetails && + repositoryDetails.users >= 2000 && + repositoryDetails.rating >= 8.5 + ); + const recentlyUpdated = !!( + repositoryDetails && + (Date.now() - repositoryDetails.updated) / (24 * 60 * 60 * 1000) <= 120 + ); + const staleUpdate = !!( + repositoryDetails && + (Date.now() - repositoryDetails.updated) / (24 * 60 * 60 * 1000) > 180 + ); + + let recommendedAction: InstallDecisionAction = 'review-source'; + + if (wildcardTargets || targetCount >= 4 || !hasSource) { + recommendedAction = 'install-disabled'; + } else if (targetCount === 0) { + recommendedAction = 'review-details'; + } else if (staleUpdate) { + recommendedAction = 'review-changelog'; + } else if (strongCommunity && recentlyUpdated && hasSource) { + recommendedAction = 'install-now'; + } + + return [ + { + key: 'install-now', + title: 'Install now', + description: strongCommunity && recentlyUpdated + ? 'Signals are strong enough for a direct install if you already trust the mod author.' + : 'Use when the scope is focused and you already reviewed enough evidence.', + recommended: recommendedAction === 'install-now', + }, + { + key: 'install-disabled', + title: 'Install disabled first', + description: wildcardTargets || targetCount >= 4 || !hasSource + ? 'Safer for broad scope, limited reviewability, or uncertain first runs.' + : 'Good for risky shell tweaks when you want the files installed before enabling.', + recommended: recommendedAction === 'install-disabled', + }, + { + key: 'review-source', + title: 'Review source first', + description: hasSource + ? 'Inspect hook targets and process scope before the first live run.' + : 'Source is not available in this view, so rely on details and changelog instead.', + recommended: recommendedAction === 'review-source', + }, + { + key: 'review-details', + title: 'Review details', + description: hasReadme || hasSettings + ? 'Use the readme and settings to confirm what the mod actually changes.' + : 'Metadata is limited, so confirm author, targeting, and purpose before installing.', + recommended: recommendedAction === 'review-details', + }, + { + key: 'review-changelog', + title: 'Review changelog', + description: 'Check recent compatibility notes and regressions before committing to the install.', + recommended: recommendedAction === 'review-changelog', + }, + ]; +} + +export function buildInstallDecisionChecklist( + modMetadata: ModMetadata, + repositoryDetails: RepositoryDetails | undefined, + installSourceData: InstallSourceData | undefined +): string[] { + const { wildcardTargets, targetCount } = getTargetSummary(modMetadata); + const checks = [ + 'Confirm which Windows surface you expect this mod to change before enabling it.', + 'Review at least one evidence source such as source, details, settings, or changelog.', + ]; + + if (wildcardTargets || targetCount >= 4) { + checks.push('Prefer a disabled-first install for broad process scope.'); + } else if (targetCount > 0) { + checks.push('Exercise the targeted process manually after install and before long-term use.'); + } + + if (!installSourceData?.source) { + checks.push('Treat limited reviewability as higher risk and verify behavior manually.'); + } + + if ( + repositoryDetails && + (Date.now() - repositoryDetails.updated) / (24 * 60 * 60 * 1000) > 180 + ) { + checks.push('Check changelog and Windows version compatibility because the mod has not been updated recently.'); + } + + return checks; +} diff --git a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/localModsInsights.spec.ts b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/localModsInsights.spec.ts new file mode 100644 index 0000000..a1b68f2 --- /dev/null +++ b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/localModsInsights.spec.ts @@ -0,0 +1,57 @@ +import { getLocalModsOverview, matchesLocalModFilters } from './localModsInsights'; + +describe('localModsInsights', () => { + const installedMods = { + 'local@taskbar-draft': { + metadata: { name: 'Taskbar Draft' }, + config: null, + updateAvailable: false, + }, + 'explorer-fix': { + metadata: { name: 'Explorer Fix' }, + config: { + disabled: false, + loggingEnabled: true, + debugLoggingEnabled: false, + include: [], + exclude: [], + includeCustom: [], + excludeCustom: [], + includeExcludeCustomOnly: false, + patternsMatchCriticalSystemProcesses: false, + architecture: [], + version: '1.0.0', + }, + updateAvailable: true, + }, + }; + + it('summarizes local drafts and logging-enabled mods', () => { + expect(getLocalModsOverview(installedMods)).toMatchObject({ + totalInstalled: 2, + updates: 1, + needsCompile: 1, + needsAttention: 2, + localDrafts: 1, + loggingEnabled: 1, + }); + }); + + it('matches extended local mod filters', () => { + expect( + matchesLocalModFilters( + 'local@taskbar-draft', + installedMods['local@taskbar-draft'], + new Set(['local-drafts', 'needs-compile']) + ) + ).toBe(true); + + expect( + matchesLocalModFilters( + 'explorer-fix', + installedMods['explorer-fix'], + new Set(['logging-enabled']) + ) + ).toBe(true); + }); +}); diff --git a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/localModsInsights.ts b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/localModsInsights.ts new file mode 100644 index 0000000..6547493 --- /dev/null +++ b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/localModsInsights.ts @@ -0,0 +1,75 @@ +import { ModConfig, ModMetadata } from '../webviewIPCMessages'; + +export type LocalModDetails = { + metadata: ModMetadata | null; + config: ModConfig | null; + updateAvailable: boolean; + userRating?: number; +}; + +export type LocalModsOverview = { + totalInstalled: number; + enabled: number; + updates: number; + needsAttention: number; + localDrafts: number; + needsCompile: number; + loggingEnabled: number; +}; + +function hasLoggingEnabled(config: ModConfig | null) { + return !!(config?.loggingEnabled || config?.debugLoggingEnabled); +} + +export function getLocalModsOverview( + installedMods: Record +): LocalModsOverview { + const values = Object.entries(installedMods); + const updates = values.filter(([, mod]) => mod.updateAvailable).length; + const needsCompile = values.filter(([, mod]) => !mod.config).length; + const loggingEnabled = values.filter(([, mod]) => hasLoggingEnabled(mod.config)).length; + + return { + totalInstalled: values.length, + enabled: values.filter(([, mod]) => mod.config && !mod.config.disabled).length, + updates, + needsAttention: values.filter(([, mod]) => + mod.updateAvailable || !mod.config || hasLoggingEnabled(mod.config) + ).length, + localDrafts: values.filter(([modId]) => modId.startsWith('local@')).length, + needsCompile, + loggingEnabled, + }; +} + +export function matchesLocalModFilters( + modId: string, + mod: LocalModDetails, + filterOptions: Set +): boolean { + if (filterOptions.has('enabled') && (!mod.config || mod.config.disabled)) { + return false; + } + + if (filterOptions.has('disabled') && mod.config && !mod.config.disabled) { + return false; + } + + if (filterOptions.has('update-available') && !mod.updateAvailable) { + return false; + } + + if (filterOptions.has('local-drafts') && !modId.startsWith('local@')) { + return false; + } + + if (filterOptions.has('needs-compile') && !!mod.config) { + return false; + } + + if (filterOptions.has('logging-enabled') && !hasLoggingEnabled(mod.config)) { + return false; + } + + return true; +} diff --git a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/mockData.ts b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/mockData.ts index 277485a..839e5e7 100644 --- a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/mockData.ts +++ b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/mockData.ts @@ -23,6 +23,12 @@ export const mockSettings = !useMockData devModeUsedAtLeastOnce: false, hideTrayIcon: false, alwaysCompileModsLocally: false, + parallelCompileTargets: true, + preferPrecompiledHeaders: true, + pythonAuthoringCommand: 'py', + pythonAuthoringArgs: '-3', + copilotCliCommand: 'python', + copilotCliArgs: 'scripts\\windhawk_tool.py', dontAutoShowToolkit: false, modTasksDialogDelay: 2000, safeMode: false, @@ -34,9 +40,46 @@ export const mockSettings = !useMockData injectIntoCriticalProcesses: false, injectIntoIncompatiblePrograms: false, injectIntoGames: false, + usePhantomInjection: false, + useModuleStomping: false, + useIndirectSyscalls: true, }, }; +export const mockRuntimeDiagnostics = !useMockData + ? null + : { + platformArch: 'arm64', + arm64Enabled: true, + portable: true, + totalMemoryGb: 32, + npuDetected: true, + npuName: 'Qualcomm Hexagon NPU', + windowsProductName: 'Windows 11 Pro', + windowsDisplayVersion: '24H2', + windowsBuild: '26100.2605', + windowsInstallationType: 'Client', + hostName: 'WORKSTATION-KAI', + userName: 'kai99', + isElevated: true, + windowsDirectory: 'C:\\Windows', + tempDirectory: 'C:\\Users\\kai99\\AppData\\Local\\Temp', + engineConfigExists: true, + enginePortable: false, + engineConfigMatchesAppConfig: false, + issueCode: 'engine-storage-mismatch' as const, + appRootPath: 'C:\\Users\\kai99\\AppData\\Local\\Programs\\Windhawk-Custom-Portable', + appDataPath: 'C:\\Users\\kai99\\AppData\\Local\\Programs\\Windhawk-Custom-Portable\\Data', + enginePath: 'C:\\Users\\kai99\\AppData\\Local\\Programs\\Windhawk-Custom-Portable\\Engine\\1.7.3', + compilerPath: 'C:\\Users\\kai99\\AppData\\Local\\Programs\\Windhawk-Custom-Portable\\Compiler', + uiPath: 'C:\\Users\\kai99\\AppData\\Local\\Programs\\Windhawk-Custom-Portable\\UI', + expectedEngineAppDataPath: 'C:\\Users\\kai99\\AppData\\Local\\Programs\\Windhawk-Custom-Portable\\Data\\Engine', + engineAppDataPath: 'C:\\ProgramData\\Windhawk\\Engine', + expectedEngineRegistryKey: null, + engineRegistryKey: 'HKLM\\SOFTWARE\\Windhawk\\Engine', + repairAvailable: true, + }; + const mockModMetadata = { id: 'custom-message-box', name: 'Custom Message Box', @@ -131,6 +174,7 @@ export const mockModsBrowserOnlineRepositoryMods = !useMockData installed: { metadata: mockModMetadata, config: mockModConfig, + userRating: 0, }, }, ...Object.fromEntries( diff --git a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/modDiscovery.spec.ts b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/modDiscovery.spec.ts new file mode 100644 index 0000000..92a5db7 --- /dev/null +++ b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/modDiscovery.spec.ts @@ -0,0 +1,399 @@ +import { + buildDiscoveryMissionCandidates, + buildDiscoveryMissionBrief, + getRefinementSuggestions, + getDiscoveryMissions, + getDiscoveryMissionByQuery, + getSearchCorrection, + getSearchRecovery, + rankMods, + RepositoryModEntry, +} from './modDiscovery'; + +const NOW = new Date('2026-03-16T00:00:00Z').valueOf(); + +function createMod(overrides: Partial & { + modId: string; + users?: number; + rating?: number; + updatedDaysAgo?: number; + defaultSorting?: number; + author?: string; + description?: string; + include?: string[]; + name?: string; +}): [string, RepositoryModEntry] { + const { + modId, + users = 1000, + rating = 8, + updatedDaysAgo = 30, + defaultSorting = 50, + author = 'Author', + description = '', + include = ['explorer.exe'], + name = modId, + ...metadata + } = overrides; + + return [ + modId, + { + repository: { + metadata: { + name, + description, + author, + include, + version: '1.0.0', + ...metadata, + }, + details: { + users, + rating, + ratingBreakdown: [0, 0, 2, 8, 20], + defaultSorting, + published: NOW - 120 * 24 * 60 * 60 * 1000, + updated: NOW - updatedDaysAgo * 24 * 60 * 60 * 1000, + }, + }, + }, + ]; +} + +describe('modDiscovery', () => { + beforeEach(() => { + jest.spyOn(Date, 'now').mockReturnValue(NOW); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('ranks typo-tolerant smart matches ahead of looser matches', () => { + const mods = [ + createMod({ + modId: 'taskbar-tweaks', + name: 'Taskbar Tweaks', + description: 'Tune the tray clock and taskbar behavior.', + author: 'Alice', + users: 4000, + rating: 9.2, + }), + createMod({ + modId: 'explorer-tabs', + name: 'Explorer Tabs', + description: 'Adds tabs to File Explorer.', + author: 'Bob', + users: 5000, + rating: 9.4, + }), + ]; + + const ranked = rankMods( + mods, + 'taskbr tray', + 'smart-relevance' + ); + + expect(ranked[0].modId).toBe('taskbar-tweaks'); + expect(ranked[0].insights).toContain('Fuzzy match'); + }); + + it('adds browse-mode insights even without a search query', () => { + const mods = [ + createMod({ + modId: 'fresh-explorer', + name: 'Fresh Explorer', + description: 'Explorer quality-of-life tweaks.', + users: 12000, + rating: 9.4, + updatedDaysAgo: 7, + }), + ]; + + const ranked = rankMods(mods, '', 'smart-relevance'); + + expect(ranked[0].insights).toEqual( + expect.arrayContaining(['Fresh update', 'Explorer']) + ); + }); + + it('connects notification and context-menu queries to Windows shell concepts', () => { + const mods = [ + createMod({ + modId: 'notification-center-plus', + name: 'Notification Center Plus', + description: 'Improve toast handling and quick settings flow.', + include: ['ShellExperienceHost.exe'], + }), + createMod({ + modId: 'context-menu-cleanup', + name: 'Context Menu Cleanup', + description: 'Tidy right-click and shell menu entries in Explorer.', + include: ['explorer.exe'], + }), + ]; + + const notificationResults = rankMods(mods, 'notifications', 'smart-relevance'); + const contextMenuResults = rankMods(mods, 'context menu', 'smart-relevance'); + + expect(notificationResults[0].modId).toBe('notification-center-plus'); + expect(notificationResults[0].insights).toContain('Notifications'); + expect(contextMenuResults[0].modId).toBe('context-menu-cleanup'); + expect(contextMenuResults[0].insights).toContain('Context menu'); + }); + + it('connects app switching and desktop queries to richer Windows customization concepts', () => { + const mods = [ + createMod({ + modId: 'alt-tab-tuner', + name: 'Alt+Tab Tuner', + description: 'Improve task switching and virtual desktop flow.', + include: ['dwm.exe', 'explorer.exe'], + }), + createMod({ + modId: 'desktop-polish', + name: 'Desktop Polish', + description: 'Adjust desktop icons, wallpaper behavior, and right-click flow.', + include: ['explorer.exe'], + }), + ]; + + const altTabResults = rankMods(mods, 'alt tab', 'smart-relevance'); + const virtualDesktopResults = rankMods( + mods, + 'virtual desktops', + 'smart-relevance' + ); + const desktopResults = rankMods(mods, 'desktop', 'smart-relevance'); + + expect(altTabResults[0].modId).toBe('alt-tab-tuner'); + expect(altTabResults[0].insights).toContain('Alt+Tab'); + expect(virtualDesktopResults[0].modId).toBe('alt-tab-tuner'); + expect(virtualDesktopResults[0].inferredConcepts).toContain( + 'Virtual desktops' + ); + expect(desktopResults[0].modId).toBe('desktop-polish'); + expect(desktopResults[0].insights).toContain('Desktop'); + }); + + it('diversifies the first results instead of stacking one author cluster', () => { + const mods = [ + createMod({ + modId: 'taskbar-clock', + name: 'Taskbar Clock', + description: 'Customize the taskbar clock.', + author: 'Alice', + users: 6000, + rating: 9.5, + }), + createMod({ + modId: 'taskbar-labels', + name: 'Taskbar Labels', + description: 'Bring back taskbar labels.', + author: 'Alice', + users: 5900, + rating: 9.4, + }), + createMod({ + modId: 'taskbar-alerts', + name: 'Taskbar Alerts', + description: 'Better taskbar notifications and tray alerts.', + author: 'Bob', + users: 5200, + rating: 9.0, + }), + ]; + + const ranked = rankMods(mods, 'taskbar', 'smart-relevance'); + + expect(ranked[0].mod.repository.metadata.author).not.toBe( + ranked[1].mod.repository.metadata.author + ); + }); + + it('keeps query filtering active when a non-smart sort order is selected', () => { + const mods = [ + createMod({ + modId: 'z-taskbar', + name: 'Z Taskbar', + description: 'Taskbar tweaks and tray customizations.', + }), + createMod({ + modId: 'a-desktop', + name: 'A Desktop', + description: 'Desktop icons and wallpaper tweaks.', + }), + createMod({ + modId: 'a-taskbar', + name: 'A Taskbar', + description: 'Another taskbar customization.', + }), + ]; + + const ranked = rankMods(mods, 'taskbar', 'alphabetical'); + + expect(ranked.map((item) => item.modId)).toEqual(['a-taskbar', 'z-taskbar']); + }); + + it('suggests related refinements from the top matching concepts', () => { + const mods = [ + createMod({ + modId: 'explorer-taskbar', + name: 'Explorer Taskbar Toolkit', + description: 'Explorer and taskbar tweaks in one mod.', + }), + createMod({ + modId: 'explorer-start-menu', + name: 'Explorer Start Menu Tweaks', + description: 'Explorer plus Start menu customization.', + include: ['explorer.exe', 'StartMenuExperienceHost.exe'], + }), + createMod({ + modId: 'explorer-icons', + name: 'Explorer Icons', + description: 'Desktop and Explorer icon options.', + }), + ]; + + const ranked = rankMods(mods, 'explorer', 'smart-relevance'); + const suggestions = getRefinementSuggestions(ranked, 'explorer'); + + const labels = suggestions.map((suggestion) => suggestion.label); + + expect( + labels.some((label) => + [ + 'Taskbar', + 'Start menu', + 'Desktop', + 'Context menu', + 'Window management', + 'Alt+Tab', + 'Virtual desktops', + ].includes(label) + ) + ).toBe(true); + expect(labels).not.toContain('Explorer'); + }); + + it('suggests a corrected query for likely misspellings', () => { + const mods = [ + createMod({ + modId: 'taskbar-tweaks', + name: 'Taskbar Tweaks', + description: 'Taskbar and tray improvements.', + }), + ]; + + const correction = getSearchCorrection(mods, 'taskbr'); + + expect(correction).toEqual({ + correctedQuery: 'taskbar', + correctedTokens: 1, + }); + }); + + it('recovers zero-result searches by broadening the corrected query', () => { + const mods = [ + createMod({ + modId: 'taskbar-tweaks', + name: 'Taskbar Tweaks', + description: 'Taskbar and tray improvements.', + author: 'Alice', + }), + createMod({ + modId: 'taskbar-clock', + name: 'Taskbar Clock', + description: 'Make the taskbar clock more useful.', + author: 'Bob', + }), + ]; + + const recovery = getSearchRecovery(mods, 'taskbr nonsense'); + + expect(recovery?.suggestedQuery).toBe('taskbar'); + expect(recovery?.reason).toBe('broadened'); + expect(recovery?.results[0].modId).toBe('taskbar-clock'); + }); + + it('provides research missions with copy-ready AI briefs', () => { + const mods = [ + createMod({ + modId: 'notification-center-plus', + name: 'Notification Center Plus', + description: 'Improve toast handling and quick settings flow.', + include: ['ShellExperienceHost.exe'], + }), + createMod({ + modId: 'quiet-notifications', + name: 'Quiet Notifications', + description: 'Reduce shell interruption cost and noisy alerts.', + include: ['explorer.exe'], + }), + ]; + + const mission = getDiscoveryMissions().find( + (candidate) => candidate.key === 'notification-calm' + ); + + expect(mission).toBeDefined(); + if (!mission) { + throw new Error('Expected notification-calm mission to exist'); + } + + const ranked = rankMods(mods, mission.query, mission.sortingOrder); + const brief = buildDiscoveryMissionBrief(mission, ranked); + + expect(brief).toContain('Calm notifications'); + expect(brief).toContain('Notification Center Plus'); + expect(brief).toContain('Top candidate mods'); + expect(brief).toContain('Manual verification priorities'); + }); + + it('matches an active mission and summarizes its top candidates', () => { + const mods = [ + createMod({ + modId: 'taskbar-focus', + name: 'Taskbar Focus', + description: 'Taskbar and tray cleanup for daily use.', + author: 'Alice', + users: 6000, + rating: 9.2, + }), + createMod({ + modId: 'taskbar-alerts', + name: 'Taskbar Alerts', + description: 'Taskbar notification and tray tweaks.', + author: 'Bob', + users: 5500, + rating: 9.0, + }), + ]; + + const mission = getDiscoveryMissionByQuery('taskbar', 'smart-relevance'); + const ranked = rankMods(mods, 'taskbar', 'smart-relevance'); + const candidates = buildDiscoveryMissionCandidates(ranked); + + expect(mission?.key).toBe('taskbar-flow'); + expect(candidates).toHaveLength(2); + expect( + candidates.some( + (candidate) => + candidate.displayName === 'Taskbar Focus' && candidate.author === 'Alice' + ) + ).toBe(true); + expect(candidates[0].communitySummary).toContain('users'); + expect(candidates[0].insightSummary.length).toBeGreaterThan(0); + }); + + it('matches new missions for context-menu and app-switching workflows', () => { + expect( + getDiscoveryMissionByQuery('context menu', 'smart-relevance')?.key + ).toBe('context-menu-cleanup'); + expect(getDiscoveryMissionByQuery('alt tab', 'smart-relevance')?.key).toBe( + 'app-switching' + ); + }); +}); diff --git a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/modDiscovery.ts b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/modDiscovery.ts new file mode 100644 index 0000000..e35b0ac --- /dev/null +++ b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/panel/modDiscovery.ts @@ -0,0 +1,1517 @@ +import { + ModConfig, + ModMetadata, + RepositoryDetails, +} from '../webviewIPCMessages'; + +export type RepositoryModEntry = { + repository: { + metadata: ModMetadata; + details: RepositoryDetails; + }; + installed?: { + metadata: ModMetadata | null; + config: ModConfig | null; + userRating?: number; + }; +}; + +export type SortingOrder = + | 'smart-relevance' + | 'popular-top-rated' + | 'popular' + | 'top-rated' + | 'newest' + | 'last-updated' + | 'alphabetical'; + +export type RankedMod = { + modId: string; + mod: RepositoryModEntry; + discoveryScore: number; + insights: string[]; + inferredConcepts: string[]; +}; + +export type RefinementSuggestion = { + key: string; + label: string; + queryText: string; +}; + +export type SearchCorrection = { + correctedQuery: string; + correctedTokens: number; +}; + +export type SearchRecovery = { + suggestedQuery: string; + reason: 'correction' | 'broadened'; + results: RankedMod[]; +}; + +export type DiscoveryMission = { + key: string; + title: string; + description: string; + researchCue: string; + query: string; + sortingOrder: SortingOrder; + followUpQueries: string[]; + verificationChecks: string[]; +}; + +export type DiscoveryMissionCandidate = { + modId: string; + displayName: string; + author: string; + insightSummary: string; + communitySummary: string; +}; + +type SearchConcept = { + key: string; + label: string; + queryText: string; + terms: string[]; + processes: string[]; +}; + +type SearchField = { + key: 'title' | 'id' | 'description' | 'author' | 'process'; + weight: number; + value: string; + tokens: string[]; +}; + +type VocabularyCandidate = { + token: string; + weight: number; +}; + +type QueryProfile = { + raw: string; + normalized: string; + tokens: string[]; + concepts: SearchConcept[]; + expandedTokens: string[]; +}; + +type ModProfile = { + title: string; + titleTokens: string[]; + id: string; + idTokens: string[]; + description: string; + descriptionTokens: string[]; + author: string; + authorTokens: string[]; + processes: string[]; + processTokens: string[]; + concepts: SearchConcept[]; + searchableText: string; + fields: SearchField[]; +}; + +const STOP_WORDS = new Set([ + 'a', + 'an', + 'and', + 'as', + 'at', + 'be', + 'by', + 'for', + 'from', + 'in', + 'into', + 'is', + 'it', + 'its', + 'mod', + 'mods', + 'of', + 'on', + 'or', + 'that', + 'the', + 'their', + 'this', + 'to', + 'with', + 'windows', +]); + +const SEARCH_CONCEPTS: SearchConcept[] = [ + { + key: 'taskbar', + label: 'Taskbar', + queryText: 'taskbar', + terms: [ + 'taskbar', + 'tray', + 'system tray', + 'notification area', + 'clock', + 'quick settings', + ], + processes: ['explorer.exe'], + }, + { + key: 'explorer', + label: 'Explorer', + queryText: 'explorer', + terms: [ + 'explorer', + 'file explorer', + 'folder', + 'folders', + 'files', + 'shell', + ], + processes: ['explorer.exe'], + }, + { + key: 'context-menu', + label: 'Context menu', + queryText: 'context menu', + terms: [ + 'context menu', + 'context menus', + 'right click', + 'right-click', + 'shell menu', + ], + processes: ['explorer.exe'], + }, + { + key: 'start-menu', + label: 'Start menu', + queryText: 'start menu', + terms: [ + 'start menu', + 'launcher', + 'windows search', + 'start button', + 'search panel', + ], + processes: ['explorer.exe', 'startmenuexperiencehost.exe', 'searchhost.exe'], + }, + { + key: 'notifications', + label: 'Notifications', + queryText: 'notifications', + terms: [ + 'notifications', + 'notification center', + 'action center', + 'toast', + 'toasts', + 'quick settings', + 'focus assist', + ], + processes: ['explorer.exe', 'shellexperiencehost.exe'], + }, + { + key: 'virtual-desktops', + label: 'Virtual desktops', + queryText: 'virtual desktops', + terms: [ + 'virtual desktop', + 'virtual desktops', + 'task view', + 'desktop switcher', + 'workspace', + 'workspaces', + ], + processes: ['dwm.exe', 'explorer.exe'], + }, + { + key: 'desktop', + label: 'Desktop', + queryText: 'desktop', + terms: ['desktop', 'icons', 'wallpaper', 'background'], + processes: ['explorer.exe'], + }, + { + key: 'window-management', + label: 'Window management', + queryText: 'window management', + terms: [ + 'window', + 'windows', + 'title bar', + 'titlebar', + 'caption', + 'resize', + 'snap', + 'maximize', + 'minimize', + ], + processes: ['dwm.exe', 'explorer.exe'], + }, + { + key: 'alt-tab', + label: 'Alt+Tab', + queryText: 'alt tab', + terms: [ + 'alt tab', + 'task switcher', + 'window switcher', + 'switcher', + 'switch between windows', + ], + processes: ['dwm.exe', 'explorer.exe'], + }, + { + key: 'widgets', + label: 'Widgets', + queryText: 'widgets', + terms: [ + 'widgets', + 'widget', + 'feed', + 'news feed', + 'dashboard', + 'board', + ], + processes: ['widgets.exe', 'explorer.exe'], + }, + { + key: 'appearance', + label: 'Appearance', + queryText: 'appearance', + terms: [ + 'theme', + 'style', + 'visual', + 'appearance', + 'dark mode', + 'light mode', + 'accent', + 'transparent', + 'transparency', + ], + processes: [], + }, + { + key: 'input', + label: 'Input', + queryText: 'input', + terms: ['keyboard', 'mouse', 'hotkey', 'shortcut', 'scroll', 'touchpad'], + processes: [], + }, + { + key: 'audio', + label: 'Audio', + queryText: 'audio', + terms: ['audio', 'sound', 'volume', 'speaker', 'microphone'], + processes: ['sndvol.exe'], + }, + { + key: 'performance', + label: 'Performance', + queryText: 'performance', + terms: ['performance', 'latency', 'fast', 'faster', 'memory', 'cpu'], + processes: [], + }, +]; + +const DISCOVERY_MISSIONS: DiscoveryMission[] = [ + { + key: 'taskbar-flow', + title: 'Sharpen taskbar flow', + description: 'Start from taskbar-focused mods, then branch into tray and clock refinements.', + researchCue: 'Compare a small set first, then refine instead of stacking unrelated tweaks.', + query: 'taskbar', + sortingOrder: 'smart-relevance', + followUpQueries: ['tray', 'clock', 'start menu'], + verificationChecks: [ + 'Check primary and secondary monitor behavior before keeping the change.', + 'Verify pinned apps, overflow area, and taskbar labels after Explorer reloads.', + 'Keep one rollback path in case explorer.exe behavior changes in your build.', + ], + }, + { + key: 'notification-calm', + title: 'Calm notifications', + description: 'Use notification-centered mods to reduce interruption cost and noisy shell surfaces.', + researchCue: 'Prefer focused interventions with explicit review steps over one broad shell change.', + query: 'notifications', + sortingOrder: 'smart-relevance', + followUpQueries: ['quick settings', 'toast', 'focus assist'], + verificationChecks: [ + 'Trigger a real toast and confirm the experience is quieter without losing critical alerts.', + 'Check quick settings and shell surfaces that share notification infrastructure.', + 'Review changelog notes for Windows build-specific shell regressions before enabling long term.', + ], + }, + { + key: 'explorer-focus', + title: 'Tighten Explorer workflow', + description: 'Begin with Explorer mods, then narrow toward context menu, desktop, or file-flow changes.', + researchCue: 'Keep the search wide enough to discover options, but validate one workflow at a time.', + query: 'explorer', + sortingOrder: 'smart-relevance', + followUpQueries: ['context menu', 'desktop', 'folders'], + verificationChecks: [ + 'Test the exact file and folder flow you want to improve, not just a screenshot path.', + 'Verify right-click menus and drag-drop behavior after any shell tweak.', + 'Check whether the mod targets only explorer.exe or reaches other shell processes too.', + ], + }, + { + key: 'window-flow', + title: 'Refine window movement', + description: 'Compare window-management mods, then drill into Alt+Tab, snapping, or title-bar behavior.', + researchCue: 'Use the first pass to shortlist candidates, then validate the risky interactions manually.', + query: 'window management', + sortingOrder: 'smart-relevance', + followUpQueries: ['alt tab', 'snap', 'title bar'], + verificationChecks: [ + 'Exercise snap, maximize, minimize, and virtual desktop flows before keeping the mod.', + 'Check for DWM or shell process scope when window chrome behavior changes.', + 'Keep logging available for the first live run if the mod adjusts window lifecycle events.', + ], + }, + { + key: 'context-menu-cleanup', + title: 'Clean up context menus', + description: 'Start with right-click and shell menu mods, then narrow toward desktop or file-flow cleanup.', + researchCue: 'Reduce menu noise in one workflow first instead of rewriting every shell interaction at once.', + query: 'context menu', + sortingOrder: 'smart-relevance', + followUpQueries: ['desktop', 'explorer', 'right click'], + verificationChecks: [ + 'Test file, folder, and desktop right-click flows separately before keeping the mod.', + 'Verify drag-drop and Open with behavior after any shell menu customization.', + 'Keep one unmodified path available in case the menu change hides a needed command.', + ], + }, + { + key: 'desktop-calm', + title: 'Polish the desktop surface', + description: 'Compare desktop-focused mods, then branch into icons, wallpapers, and right-click behavior.', + researchCue: 'Use one visible desktop workflow as the benchmark before stacking broader shell tweaks.', + query: 'desktop', + sortingOrder: 'smart-relevance', + followUpQueries: ['icons', 'wallpaper', 'context menu'], + verificationChecks: [ + 'Check empty-desktop, icon, and wallpaper behavior separately because they often use different hooks.', + 'Reload Explorer once before treating the visual result as stable.', + 'Confirm multi-monitor desktops still behave as expected after the change.', + ], + }, + { + key: 'app-switching', + title: 'Streamline app switching', + description: 'Start with Alt+Tab and task switching mods, then validate virtual desktops and snap flow together.', + researchCue: 'Treat switching, snapping, and desktop changes as one movement loop, but verify each step separately.', + query: 'alt tab', + sortingOrder: 'smart-relevance', + followUpQueries: ['virtual desktops', 'window management', 'snap'], + verificationChecks: [ + 'Exercise Alt+Tab, Win+Tab, and virtual desktop shortcuts before keeping the mod.', + 'Check whether DWM involvement makes the behavior build-sensitive on your Windows version.', + 'Verify app switching while full-screen and multi-monitor windows are open.', + ], + }, +]; + +export function normalizeProcessName(process: string): string { + return process.includes('\\') + ? process.substring(process.lastIndexOf('\\') + 1) + : process; +} + +function normalizeText(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9.]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function normalizeToken(token: string): string { + if (token.endsWith('.exe')) { + return token; + } + + if (token.length > 4) { + if (token.endsWith('ies')) { + return token.slice(0, -3) + 'y'; + } + + if (token.endsWith('s') && !token.endsWith('ss')) { + return token.slice(0, -1); + } + } + + return token; +} + +function tokenize(value: string): string[] { + const normalized = normalizeText(value); + if (!normalized) { + return []; + } + + return normalized + .split(' ') + .map((token) => normalizeToken(token)) + .filter((token) => token.length > 1 && !STOP_WORDS.has(token)); +} + +function unique(values: T[]): T[] { + return Array.from(new Set(values)); +} + +function levenshteinDistance(a: string, b: string): number { + if (a === b) { + return 0; + } + + if (a.length === 0) { + return b.length; + } + + if (b.length === 0) { + return a.length; + } + + const previous = new Array(b.length + 1).fill(0); + const current = new Array(b.length + 1).fill(0); + + for (let j = 0; j <= b.length; j++) { + previous[j] = j; + } + + for (let i = 1; i <= a.length; i++) { + current[0] = i; + + for (let j = 1; j <= b.length; j++) { + const substitutionCost = a[i - 1] === b[j - 1] ? 0 : 1; + current[j] = Math.min( + current[j - 1] + 1, + previous[j] + 1, + previous[j - 1] + substitutionCost + ); + } + + for (let j = 0; j <= b.length; j++) { + previous[j] = current[j]; + } + } + + return previous[b.length]; +} + +function fuzzySimilarity(queryToken: string, candidateToken: string): number { + if ( + queryToken.length < 4 || + candidateToken.length < 4 || + Math.abs(queryToken.length - candidateToken.length) > 2 + ) { + return 0; + } + + const distance = levenshteinDistance(queryToken, candidateToken); + const maxLength = Math.max(queryToken.length, candidateToken.length); + return 1 - distance / maxLength; +} + +function bestTokenMatchScore(queryToken: string, tokens: string[], value: string): number { + if (tokens.length === 0 && !value) { + return 0; + } + + if (tokens.includes(queryToken)) { + return 1; + } + + for (const token of tokens) { + if ( + token.startsWith(queryToken) || + (queryToken.startsWith(token) && token.length >= 4) + ) { + return 0.82; + } + } + + if (value.includes(queryToken) && queryToken.length >= 3) { + return 0.68; + } + + let bestFuzzyScore = 0; + for (const token of tokens) { + const similarity = fuzzySimilarity(queryToken, token); + if (similarity >= 0.85) { + bestFuzzyScore = Math.max(bestFuzzyScore, 0.62); + } else if (similarity >= 0.75) { + bestFuzzyScore = Math.max(bestFuzzyScore, 0.48); + } + } + + return bestFuzzyScore; +} + +function matchesConcept(queryValue: string, concept: SearchConcept): boolean { + if (!queryValue) { + return false; + } + + if (queryValue === concept.key || queryValue === normalizeText(concept.label)) { + return true; + } + + return concept.terms.some((term) => { + const normalizedTerm = normalizeText(term); + return queryValue.includes(normalizedTerm) || normalizedTerm.includes(queryValue); + }); +} + +function inferConcepts( + metadata: ModMetadata, + modId: string +): SearchConcept[] { + const title = metadata.name || ''; + const description = metadata.description || ''; + const author = metadata.author || ''; + const processes = unique( + (metadata.include || []) + .filter((process) => process && !process.includes('*') && !process.includes('?')) + .map((process) => normalizeProcessName(process).toLowerCase()) + ); + + const searchableText = normalizeText( + [title, description, author, modId, ...processes].join(' ') + ); + + return SEARCH_CONCEPTS.filter((concept) => { + const termMatch = concept.terms.some((term) => + searchableText.includes(normalizeText(term)) + ); + + const processMatch = concept.processes.some((process) => { + if (!processes.includes(process)) { + return false; + } + + // explorer.exe is too broad to imply every shell sub-domain on its own. + if ( + process === 'explorer.exe' && + [ + 'taskbar', + 'context-menu', + 'start-menu', + 'notifications', + 'desktop', + 'window-management', + ].includes(concept.key) + ) { + return termMatch; + } + + if ( + process === 'dwm.exe' && + ['window-management', 'alt-tab'].includes(concept.key) + ) { + return termMatch; + } + + return true; + }); + + return processMatch || termMatch; + }); +} + +function buildQueryProfile(query: string): QueryProfile { + const normalized = normalizeText(query); + const tokens = unique(tokenize(query)); + const concepts = SEARCH_CONCEPTS.filter((concept) => + matchesConcept(normalized, concept) || + tokens.some((token) => matchesConcept(token, concept)) + ); + + const expandedTokens = unique( + concepts.flatMap((concept) => [ + ...concept.terms.flatMap((term) => tokenize(term)), + ...concept.processes.flatMap((process) => tokenize(process)), + ]) + ).filter((token) => !tokens.includes(token)); + + return { + raw: query.trim(), + normalized, + tokens, + concepts, + expandedTokens, + }; +} + +function buildModProfile(modId: string, mod: RepositoryModEntry): ModProfile { + const metadata = mod.repository.metadata; + const title = normalizeText(metadata.name || modId); + const description = normalizeText(metadata.description || ''); + const author = normalizeText(metadata.author || ''); + const processes = unique( + (metadata.include || []) + .filter((process) => process && !process.includes('*') && !process.includes('?')) + .map((process) => normalizeProcessName(process).toLowerCase()) + ); + const concepts = inferConcepts(metadata, modId); + + return { + title, + titleTokens: unique(tokenize(title)), + id: normalizeText(modId), + idTokens: unique(tokenize(modId)), + description, + descriptionTokens: unique(tokenize(description)), + author, + authorTokens: unique(tokenize(author)), + processes, + processTokens: unique(processes.flatMap((process) => tokenize(process))), + concepts, + searchableText: normalizeText( + [ + metadata.name || modId, + metadata.description || '', + metadata.author || '', + modId, + ...processes, + ...concepts.map((concept) => concept.label), + ...concepts.flatMap((concept) => concept.terms), + ].join(' ') + ), + fields: [ + { + key: 'title', + weight: 7, + value: title, + tokens: unique(tokenize(title)), + }, + { + key: 'id', + weight: 6, + value: normalizeText(modId), + tokens: unique(tokenize(modId)), + }, + { + key: 'description', + weight: 4, + value: description, + tokens: unique(tokenize(description)), + }, + { + key: 'author', + weight: 2, + value: author, + tokens: unique(tokenize(author)), + }, + { + key: 'process', + weight: 4, + value: normalizeText(processes.join(' ')), + tokens: unique(processes.flatMap((process) => tokenize(process))), + }, + ], + }; +} + +function buildSearchVocabulary( + mods: [string, RepositoryModEntry][] +): VocabularyCandidate[] { + const vocabulary = new Map(); + + const addTokens = (tokens: string[], weight: number) => { + for (const token of tokens) { + vocabulary.set(token, (vocabulary.get(token) || 0) + weight); + } + }; + + addTokens( + SEARCH_CONCEPTS.flatMap((concept) => tokenize(concept.label)), + 2.2 + ); + addTokens( + SEARCH_CONCEPTS.flatMap((concept) => concept.terms.flatMap((term) => tokenize(term))), + 1.8 + ); + addTokens( + SEARCH_CONCEPTS.flatMap((concept) => concept.processes.flatMap((process) => tokenize(process))), + 2 + ); + + for (const [modId, mod] of mods) { + const profile = buildModProfile(modId, mod); + addTokens(profile.titleTokens, 3.2); + addTokens(profile.idTokens, 2.9); + addTokens(profile.descriptionTokens, 1.2); + addTokens(profile.authorTokens, 0.8); + addTokens(profile.processTokens, 2.4); + addTokens( + profile.concepts.flatMap((concept) => tokenize(concept.label)), + 1.7 + ); + } + + return Array.from(vocabulary.entries()) + .map(([token, weight]) => ({ token, weight })) + .sort((a, b) => b.weight - a.weight || a.token.localeCompare(b.token)); +} + +function getTokenCorrection( + token: string, + vocabulary: VocabularyCandidate[] +): { token: string; score: number } | null { + if (token.length < 4) { + return null; + } + + const exactMatch = vocabulary.find((candidate) => candidate.token === token); + if (exactMatch) { + return null; + } + + let bestCandidate: { token: string; score: number } | null = null; + + for (const candidate of vocabulary) { + if (Math.abs(candidate.token.length - token.length) > 2) { + continue; + } + + const similarity = fuzzySimilarity(token, candidate.token); + if (similarity < 0.75) { + continue; + } + + let score = similarity + Math.min(candidate.weight / 20, 0.18); + if ( + candidate.token.startsWith(token.slice(0, Math.min(3, token.length))) || + token.startsWith(candidate.token.slice(0, Math.min(3, candidate.token.length))) + ) { + score += 0.04; + } + + if (!bestCandidate || score > bestCandidate.score) { + bestCandidate = { + token: candidate.token, + score, + }; + } + } + + if (!bestCandidate || bestCandidate.score < 0.84) { + return null; + } + + return bestCandidate; +} + +function buildRelaxedQueries(query: string): string[] { + const tokens = tokenize(query); + if (tokens.length <= 1) { + return []; + } + + return tokens + .map((_, index) => tokens.filter((__, tokenIndex) => tokenIndex !== index).join(' ')) + .filter((candidate) => candidate.length > 0); +} + +function compareAlphabetical( + [modIdA, modA]: [string, RepositoryModEntry], + [modIdB, modB]: [string, RepositoryModEntry] +): number { + const modATitle = (modA.repository.metadata.name || modIdA).toLowerCase(); + const modBTitle = (modB.repository.metadata.name || modIdB).toLowerCase(); + + if (modATitle < modBTitle) { + return -1; + } + + if (modATitle > modBTitle) { + return 1; + } + + if (modIdA < modIdB) { + return -1; + } + + if (modIdA > modIdB) { + return 1; + } + + return 0; +} + +function compareBySortOrder( + a: [string, RepositoryModEntry], + b: [string, RepositoryModEntry], + sortingOrder: Exclude +): number { + const [, modA] = a; + const [, modB] = b; + + switch (sortingOrder) { + case 'popular-top-rated': + if (modB.repository.details.defaultSorting !== modA.repository.details.defaultSorting) { + return modB.repository.details.defaultSorting - modA.repository.details.defaultSorting; + } + break; + + case 'popular': + if (modB.repository.details.users !== modA.repository.details.users) { + return modB.repository.details.users - modA.repository.details.users; + } + break; + + case 'top-rated': + if (modB.repository.details.rating !== modA.repository.details.rating) { + return modB.repository.details.rating - modA.repository.details.rating; + } + break; + + case 'newest': + if (modB.repository.details.published !== modA.repository.details.published) { + return modB.repository.details.published - modA.repository.details.published; + } + break; + + case 'last-updated': + if (modB.repository.details.updated !== modA.repository.details.updated) { + return modB.repository.details.updated - modA.repository.details.updated; + } + break; + + case 'alphabetical': + break; + } + + return compareAlphabetical(a, b); +} + +function qualityScore(details: RepositoryDetails): number { + const popularity = Math.min(1, Math.log10(details.users + 10) / 5); + const rating = Math.min(1, details.rating / 10); + const recencyDays = Math.max( + 0, + (Date.now() - details.updated) / (1000 * 60 * 60 * 24) + ); + const recency = 1 / (1 + recencyDays / 180); + const defaultRanking = Math.min(1, details.defaultSorting / 100); + + return ( + popularity * 0.35 + + rating * 0.3 + + recency * 0.2 + + defaultRanking * 0.15 + ); +} + +function buildInsightLabel(fieldKey: SearchField['key'], mod: ModProfile): string { + switch (fieldKey) { + case 'title': + return 'Name match'; + case 'id': + return 'ID match'; + case 'description': + return 'Description match'; + case 'author': + return mod.author ? `Author: ${mod.author}` : 'Author match'; + case 'process': + return mod.processes[0] ? `Process: ${mod.processes[0]}` : 'Process match'; + } +} + +function buildBrowseInsights( + mod: RepositoryModEntry, + modProfile: ModProfile +): string[] { + const insightScores = new Map(); + const quality = qualityScore(mod.repository.details); + const updatedDays = + (Date.now() - mod.repository.details.updated) / (1000 * 60 * 60 * 24); + const includesWildcards = (mod.repository.metadata.include || []).some( + (entry) => entry.includes('*') || entry.includes('?') + ); + + if (quality >= 0.82) { + insightScores.set('Community favorite', 0.69); + } else if (quality >= 0.68) { + insightScores.set('Popular', 0.63); + } + + if (mod.repository.details.rating >= 8.5) { + insightScores.set('Highly rated', 1.1); + } + + if (updatedDays <= 45) { + insightScores.set('Fresh update', 1.05); + } else if (updatedDays <= 120) { + insightScores.set('Recently updated', 0.8); + } + + if (modProfile.concepts.length > 0) { + insightScores.set(modProfile.concepts[0].label, 0.76); + } + + if (includesWildcards) { + insightScores.set('Broad reach', 0.62); + } else if (modProfile.processes.length === 1) { + insightScores.set(`Targets ${modProfile.processes[0]}`, 0.58); + } + + if (mod.installed) { + insightScores.set('Installed already', 0.54); + } + + return Array.from(insightScores.entries()) + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .slice(0, 3) + .map(([label]) => label); +} + +function scoreModAgainstQuery( + modId: string, + mod: RepositoryModEntry, + query: QueryProfile +): RankedMod | null { + const modProfile = buildModProfile(modId, mod); + + if (!query.tokens.length && !query.normalized) { + return { + modId, + mod, + discoveryScore: qualityScore(mod.repository.details), + insights: buildBrowseInsights(mod, modProfile), + inferredConcepts: modProfile.concepts.map((concept) => concept.label), + }; + } + + let rawTokenScore = 0; + let matchedTokenWeight = 0; + const insightScores = new Map(); + let typoMatched = false; + + for (const token of query.tokens) { + let bestScore = 0; + let bestField: SearchField | null = null; + + for (const field of modProfile.fields) { + const matchScore = bestTokenMatchScore(token, field.tokens, field.value); + const weightedScore = matchScore * field.weight; + + if (weightedScore > bestScore) { + bestScore = weightedScore; + bestField = field; + } + + if (matchScore >= 0.48 && matchScore < 0.68) { + typoMatched = true; + } + } + + if (bestScore > 0) { + rawTokenScore += bestScore; + matchedTokenWeight += Math.min(1, bestScore / 5); + if (bestField) { + const label = buildInsightLabel(bestField.key, modProfile); + insightScores.set(label, (insightScores.get(label) || 0) + bestScore); + } + } + } + + let expansionScore = 0; + for (const token of query.expandedTokens) { + let bestExpandedScore = 0; + for (const field of modProfile.fields) { + bestExpandedScore = Math.max( + bestExpandedScore, + bestTokenMatchScore(token, field.tokens, field.value) * field.weight * 0.3 + ); + } + expansionScore += bestExpandedScore; + } + + const phraseMatch = query.normalized.length >= 3 && + modProfile.searchableText.includes(query.normalized); + if (phraseMatch) { + rawTokenScore += 3.2; + matchedTokenWeight += 1; + insightScores.set('Phrase match', (insightScores.get('Phrase match') || 0) + 3.2); + } + + const sharedConcepts = modProfile.concepts.filter((concept) => + query.concepts.some((queryConcept) => queryConcept.key === concept.key) + ); + + let conceptScore = 0; + for (const concept of sharedConcepts) { + conceptScore += 2.2; + insightScores.set(concept.label, (insightScores.get(concept.label) || 0) + 2.2); + } + + const coverageTarget = Math.max(1, query.tokens.length); + const coverage = matchedTokenWeight / coverageTarget; + const hasMeaningfulMatch = + phraseMatch || + coverage >= (query.tokens.length <= 1 ? 0.3 : 0.55) || + (sharedConcepts.length > 0 && coverage >= 0.25); + + if (!hasMeaningfulMatch) { + return null; + } + + const quality = qualityScore(mod.repository.details); + const finalScore = + rawTokenScore * 0.72 + + expansionScore * 0.1 + + conceptScore * 0.1 + + quality * 1.4 + + (mod.installed ? 0.2 : 0); + + if (quality > 0.72) { + insightScores.set('Popular', (insightScores.get('Popular') || 0) + quality * 0.8); + } + + if (mod.repository.details.rating >= 8) { + insightScores.set( + 'Highly rated', + (insightScores.get('Highly rated') || 0) + mod.repository.details.rating / 10 + ); + } + + const recentlyUpdatedDays = (Date.now() - mod.repository.details.updated) / (1000 * 60 * 60 * 24); + if (recentlyUpdatedDays <= 120) { + insightScores.set('Recently updated', (insightScores.get('Recently updated') || 0) + 0.8); + } + + if (typoMatched) { + insightScores.set('Fuzzy match', (insightScores.get('Fuzzy match') || 0) + 0.4); + } + + const insights = Array.from(insightScores.entries()) + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .slice(0, 3) + .map(([label]) => label); + + if (typoMatched && !insights.includes('Fuzzy match')) { + if (insights.length < 3) { + insights.push('Fuzzy match'); + } else { + insights[insights.length - 1] = 'Fuzzy match'; + } + } + + return { + modId, + mod, + discoveryScore: finalScore, + insights, + inferredConcepts: modProfile.concepts.map((concept) => concept.label), + }; +} + +function diversifyTopResults(results: RankedMod[]): RankedMod[] { + if (results.length <= 2) { + return results; + } + + const reranked: RankedMod[] = []; + const remaining = [...results]; + const seenAuthors = new Map(); + const seenProcesses = new Map(); + const seenConcepts = new Map(); + + // Apply a lightweight MMR-style penalty so the first screen is less dominated + // by one author or one process cluster. + while (remaining.length > 0 && reranked.length < Math.min(40, results.length)) { + let bestIndex = 0; + let bestScore = Number.NEGATIVE_INFINITY; + + for (let i = 0; i < remaining.length; i++) { + const candidate = remaining[i]; + const author = candidate.mod.repository.metadata.author?.toLowerCase() || ''; + const processes = (candidate.mod.repository.metadata.include || []) + .filter((process) => process && !process.includes('*') && !process.includes('?')) + .map((process) => normalizeProcessName(process).toLowerCase()); + + let penalty = 0; + if (author) { + penalty += (seenAuthors.get(author) || 0) * 0.55; + } + for (const process of processes) { + penalty += (seenProcesses.get(process) || 0) * 0.22; + } + for (const concept of candidate.inferredConcepts) { + penalty += (seenConcepts.get(concept) || 0) * 0.12; + } + + const adjustedScore = candidate.discoveryScore - penalty; + if (adjustedScore > bestScore) { + bestScore = adjustedScore; + bestIndex = i; + } + } + + const [selected] = remaining.splice(bestIndex, 1); + reranked.push(selected); + + const author = selected.mod.repository.metadata.author?.toLowerCase() || ''; + if (author) { + seenAuthors.set(author, (seenAuthors.get(author) || 0) + 1); + } + + for (const process of selected.mod.repository.metadata.include || []) { + if (!process || process.includes('*') || process.includes('?')) { + continue; + } + const normalizedProcess = normalizeProcessName(process).toLowerCase(); + seenProcesses.set( + normalizedProcess, + (seenProcesses.get(normalizedProcess) || 0) + 1 + ); + } + + for (const concept of selected.inferredConcepts) { + seenConcepts.set(concept, (seenConcepts.get(concept) || 0) + 1); + } + } + + if (remaining.length === 0) { + return reranked; + } + + return [ + ...reranked, + ...remaining.sort((a, b) => { + if (b.discoveryScore !== a.discoveryScore) { + return b.discoveryScore - a.discoveryScore; + } + return compareAlphabetical( + [a.modId, a.mod], + [b.modId, b.mod] + ); + }), + ]; +} + +export function getDiscoveryMissions(): DiscoveryMission[] { + return DISCOVERY_MISSIONS; +} + +export function getDiscoveryMissionByQuery( + query: string, + sortingOrder: SortingOrder +): DiscoveryMission | null { + const normalizedQuery = normalizeText(query); + if (!normalizedQuery) { + return null; + } + + return DISCOVERY_MISSIONS.find( + (mission) => + normalizeText(mission.query) === normalizedQuery && + mission.sortingOrder === sortingOrder + ) || null; +} + +export function buildDiscoveryMissionCandidates( + rankedMods: RankedMod[] +): DiscoveryMissionCandidate[] { + return rankedMods.slice(0, 3).map((candidate) => { + const metadata = candidate.mod.repository.metadata; + const details = candidate.mod.repository.details; + + return { + modId: candidate.modId, + displayName: metadata.name || candidate.modId, + author: metadata.author || 'Unknown author', + insightSummary: candidate.insights.length > 0 + ? candidate.insights.join(' | ') + : 'No extra signals yet', + communitySummary: `${details.users.toLocaleString()} users | ${(details.rating / 2).toFixed(1)}/5`, + }; + }); +} + +export function buildDiscoveryMissionBrief( + mission: DiscoveryMission, + rankedMods: RankedMod[] +): string { + const topCandidates = rankedMods.slice(0, 4); + const topCandidateLines = topCandidates.length > 0 + ? topCandidates.map((candidate, index) => { + const displayName = candidate.mod.repository.metadata.name || candidate.modId; + const insightSummary = candidate.insights.length > 0 + ? candidate.insights.join(', ') + : 'No extra signals'; + + return `${index + 1}. ${displayName} (${candidate.modId}) - ${insightSummary}`; + }) + : ['1. No ranked mods were available for this mission yet.']; + + return `Help me compare Windhawk mods for a Windows customization mission. +Mission: ${mission.title} +Goal: ${mission.description} +Starting query: ${mission.query} +Suggested follow-up queries: ${mission.followUpQueries.join(', ')} +Manual verification priorities: +- ${mission.verificationChecks.join('\n- ')} +Top candidate mods: +${topCandidateLines.join('\n')} +Output: +1. The best 1-2 mods to try first and why +2. Tradeoffs, process scope, and compatibility risks +3. A short manual validation plan before keeping the change`; +} + +export function rankMods( + mods: [string, RepositoryModEntry][], + query: string, + sortingOrder: SortingOrder +): RankedMod[] { + if (!query.trim()) { + const fallbackSortingOrder = + sortingOrder === 'smart-relevance' ? 'popular-top-rated' : sortingOrder; + + return [...mods] + .sort((a, b) => + compareBySortOrder( + a, + b, + fallbackSortingOrder as Exclude + ) + ) + .map(([modId, mod]) => { + const profile = buildModProfile(modId, mod); + + return { + modId, + mod, + discoveryScore: qualityScore(mod.repository.details), + insights: buildBrowseInsights(mod, profile), + inferredConcepts: profile.concepts.map((concept) => concept.label), + }; + }); + } + + const queryProfile = buildQueryProfile(query); + const matched = mods + .map(([modId, mod]) => scoreModAgainstQuery(modId, mod, queryProfile)) + .filter((item): item is RankedMod => item !== null); + + if (sortingOrder !== 'smart-relevance') { + return matched.sort((a, b) => + compareBySortOrder( + [a.modId, a.mod], + [b.modId, b.mod], + sortingOrder as Exclude + ) + ); + } + + const ranked = matched.sort((a, b) => { + if (b.discoveryScore !== a.discoveryScore) { + return b.discoveryScore - a.discoveryScore; + } + + return compareAlphabetical( + [a.modId, a.mod], + [b.modId, b.mod] + ); + }); + + return diversifyTopResults(ranked); +} + +export function getSearchCorrection( + mods: [string, RepositoryModEntry][], + query: string +): SearchCorrection | null { + const normalizedQuery = normalizeText(query); + if (!normalizedQuery) { + return null; + } + + const tokens = tokenize(query); + if (tokens.length === 0) { + return null; + } + + const vocabulary = buildSearchVocabulary(mods); + let correctedTokens = 0; + const correctedQuery = tokens + .map((token) => { + const correction = getTokenCorrection(token, vocabulary); + if (!correction) { + return token; + } + + correctedTokens++; + return correction.token; + }) + .join(' '); + + if (correctedTokens === 0 || correctedQuery === normalizedQuery) { + return null; + } + + return { + correctedQuery, + correctedTokens, + }; +} + +export function getSearchRecovery( + mods: [string, RepositoryModEntry][], + query: string +): SearchRecovery | null { + if (!query.trim()) { + return null; + } + + const rawResults = rankMods(mods, query, 'smart-relevance'); + if (rawResults.length > 0) { + return null; + } + + const correction = getSearchCorrection(mods, query); + const candidateQueries: { query: string; reason: SearchRecovery['reason'] }[] = []; + + if (correction) { + candidateQueries.push({ + query: correction.correctedQuery, + reason: 'correction', + }); + } + + for (const relaxedQuery of buildRelaxedQueries( + correction?.correctedQuery || query + )) { + candidateQueries.push({ + query: relaxedQuery, + reason: 'broadened', + }); + } + + const dedupedCandidates = candidateQueries.filter( + (candidate, index, candidates) => + normalizeText(candidate.query) !== normalizeText(query) && + candidates.findIndex( + (otherCandidate) => normalizeText(otherCandidate.query) === normalizeText(candidate.query) + ) === index + ); + + let bestRecovery: SearchRecovery | null = null; + let bestRecoveryScore = Number.NEGATIVE_INFINITY; + + for (const candidate of dedupedCandidates) { + const results = rankMods(mods, candidate.query, 'smart-relevance'); + if (results.length === 0) { + continue; + } + + const topScore = results[0]?.discoveryScore || 0; + const averageTopScore = + results + .slice(0, 3) + .reduce((sum, result) => sum + result.discoveryScore, 0) / + Math.min(3, results.length); + const recoveryScore = + topScore * 0.7 + + averageTopScore * 0.2 + + Math.min(6, results.length) * 0.45 + + (candidate.reason === 'correction' ? 0.35 : 0); + + if (recoveryScore > bestRecoveryScore) { + bestRecoveryScore = recoveryScore; + bestRecovery = { + suggestedQuery: candidate.query, + reason: candidate.reason, + results: results.slice(0, 6), + }; + } + } + + return bestRecovery; +} + +export function getRefinementSuggestions( + rankedMods: RankedMod[], + query: string +): RefinementSuggestion[] { + if (!query.trim() || rankedMods.length === 0) { + return []; + } + + const queryProfile = buildQueryProfile(query); + const queryConcepts = new Set(queryProfile.concepts.map((concept) => concept.label)); + const topResults = rankedMods.slice(0, 12); + + const conceptCounts = new Map(); + for (const result of topResults) { + for (const concept of result.inferredConcepts) { + if (queryConcepts.has(concept)) { + continue; + } + + const matchingConcept = SEARCH_CONCEPTS.find( + (searchConcept) => searchConcept.label === concept + ); + const queryTextValue = matchingConcept?.queryText || concept.toLowerCase(); + const existing = conceptCounts.get(concept); + conceptCounts.set(concept, { + count: (existing?.count || 0) + 1, + queryText: queryTextValue, + }); + } + } + + const processCounts = new Map(); + for (const result of topResults) { + for (const process of result.mod.repository.metadata.include || []) { + if (!process || process.includes('*') || process.includes('?')) { + continue; + } + + const normalizedProcess = normalizeProcessName(process).toLowerCase(); + if (queryProfile.normalized.includes(normalizedProcess)) { + continue; + } + + processCounts.set( + normalizedProcess, + (processCounts.get(normalizedProcess) || 0) + 1 + ); + } + } + + const conceptSuggestions = Array.from(conceptCounts.entries()) + .sort((a, b) => b[1].count - a[1].count || a[0].localeCompare(b[0])) + .slice(0, 3) + .map(([label, { queryText }]) => ({ + key: `concept:${label.toLowerCase()}`, + label, + queryText, + })); + + const processSuggestions = Array.from(processCounts.entries()) + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .slice(0, 2) + .map(([process]) => ({ + key: `process:${process}`, + label: process, + queryText: process, + })); + + return [...conceptSuggestions, ...processSuggestions] + .filter( + (suggestion, index, suggestions) => + suggestions.findIndex((candidate) => candidate.key === suggestion.key) === index + ) + .slice(0, 4); +} diff --git a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/sidebar/EditorModeControls.tsx b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/sidebar/EditorModeControls.tsx index 514d6d6..97c456f 100644 --- a/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/sidebar/EditorModeControls.tsx +++ b/src/vscode-windhawk-ui/apps/vscode-windhawk-ui/src/app/sidebar/EditorModeControls.tsx @@ -1,8 +1,10 @@ -import { Badge, Button, Dropdown, Switch, Tooltip } from 'antd'; -import { useCallback, useState } from 'react'; +import { Badge, Button, Switch, Tag, Tooltip, Typography, message } from 'antd'; +import { useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import styled from 'styled-components'; +import { AppUISettingsContext } from '../appUISettings'; import { PopconfirmModal } from '../components/InputWithContextMenu'; +import { copyTextToClipboard } from '../utils'; import { previewEditedMod, showLogOutput, @@ -13,82 +15,538 @@ import { useEnableEditedMod, useEnableEditedModLogging, useExitEditorMode, + useOpenExternal, useSetEditedModId, } from '../webviewIPC'; +import { EditorLaunchContext, ModMetadata } from '../webviewIPCMessages'; +import { + buildEditorAiPrompt, + buildEditorChallengeBrief, + buildEditorContextPacket, + buildEditorReleasePacket, + buildEditorVerificationChecklist, + getCurrentCompileProfileKey, + getEditorEvidenceCards, + getEditorIterationPlan, + getEditorProvocations, + getEditorVerificationPack, + getEditorWindowsActions, + getEditorWindowsSurfaceLabels, + getRecommendedCompileProfile, + summarizeTargetProcesses, +} from './editorModeUtils'; -const SidebarContainer = styled.div` - padding: 0 10px; - text-align: center; +const SidebarShell = styled.div` + height: 100vh; + max-height: 100vh; + display: flex; + flex-direction: column; + min-height: 0; + color: var(--vscode-foreground); + background: + radial-gradient(ellipse at top left, rgba(24, 144, 255, 0.15), transparent 60%), + radial-gradient(ellipse at bottom right, rgba(138, 43, 226, 0.1), transparent 50%), + var(--vscode-sideBar-background, var(--vscode-editor-background)); + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; `; -const SwitchesContainer = styled.div` - margin-bottom: 10px; +const SidebarScrollArea = styled.div` + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; + gap: 14px; + display: flex; + flex-direction: column; + padding: 12px; - > * { - width: 100%; - display: flex; - justify-content: space-between; - background-color: var(--vscode-editor-background); - border: 1px solid #303030; - padding: 4px 10px; + &::-webkit-scrollbar { + width: 10px; } - > *:not(:last-child) { - border-bottom: none; + &::-webkit-scrollbar-thumb { + border-radius: 999px; + background: rgba(255, 255, 255, 0.18); } +`; - > *:first-child { - border-top-left-radius: 2px; - border-top-right-radius: 2px; - } +const PanelCard = styled.section<{ $accent?: string }>` + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; + border-radius: 16px; + border: 1px solid var(--vscode-widget-border, rgba(255, 255, 255, 0.1)); + background: + linear-gradient( + 140deg, + ${({ $accent }) => $accent || 'rgba(255, 255, 255, 0.08)'}, + rgba(255, 255, 255, 0.02) 46% + ), + rgba(10, 10, 10, 0.4); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.06); + transition: transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 0.3s ease; - > *:last-child { - border-bottom-left-radius: 2px; - border-bottom-right-radius: 2px; + &:hover { + transform: translateY(-2px); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.08); } `; -const SwitchesContainerRow = styled.div` - // Fixes a button alignment bug. - > .ant-tooltip-disabled-compatible-wrapper { - font-size: 0; - } +const HeroEyebrow = styled(Typography.Text)` + color: var(--vscode-descriptionForeground, rgba(255, 255, 255, 0.65)); + text-transform: uppercase; + letter-spacing: 0.05em; + font-size: 11px; `; -const ButtonsContainer = styled.div` - > * { - margin-bottom: 10px; - } +const HeroTitle = styled.div` + font-size: 18px; + font-weight: 700; + line-height: 1.3; + overflow-wrap: anywhere; +`; + +const HeroDescription = styled(Typography.Text)` + color: var(--vscode-descriptionForeground, rgba(255, 255, 255, 0.7)); + line-height: 1.45; `; -const ModIdBox = styled.div` +const InlineActions = styled.div` + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +`; + +const TagRow = styled.div` + display: flex; + flex-wrap: wrap; + gap: 8px; +`; + +const MetaRow = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +`; + +const ModIdBox = styled.code` display: inline-block; - border-radius: 2px; - background: #444; - padding: 0 4px; + border-radius: 999px; + padding: 4px 10px; + background: rgba(255, 255, 255, 0.08); + color: var(--vscode-foreground); overflow-wrap: anywhere; - margin-bottom: 10px; +`; + +const SectionHeader = styled.div` + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; +`; + +const SectionKicker = styled(Typography.Text)` + color: var(--vscode-descriptionForeground, rgba(255, 255, 255, 0.58)); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; +`; + +const SectionTitle = styled.div` + font-size: 14px; + font-weight: 700; +`; + +const SectionDescription = styled(Typography.Text)` + color: var(--vscode-descriptionForeground, rgba(255, 255, 255, 0.7)); + line-height: 1.45; +`; + +const EvidenceGrid = styled.div` + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +`; + +const EvidenceCard = styled.div<{ + $tone: 'positive' | 'neutral' | 'caution'; +}>` + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + border-radius: 12px; + padding: 12px 14px; + background: ${({ $tone }) => + $tone === 'positive' + ? 'linear-gradient(135deg, rgba(82, 196, 26, 0.1), rgba(82, 196, 26, 0.02))' + : $tone === 'caution' + ? 'linear-gradient(135deg, rgba(250, 173, 20, 0.12), rgba(250, 173, 20, 0.03))' + : 'linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.01))'}; + border: 1px solid + ${({ $tone }) => + $tone === 'positive' + ? 'rgba(82, 196, 26, 0.24)' + : $tone === 'caution' + ? 'rgba(250, 173, 20, 0.26)' + : 'rgba(255, 255, 255, 0.08)'}; + backdrop-filter: blur(8px); +`; + +const EvidenceLabel = styled(Typography.Text)` + color: var(--vscode-descriptionForeground, rgba(255, 255, 255, 0.62)); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; +`; + +const EvidenceValue = styled.div` + font-size: 15px; + font-weight: 700; + line-height: 1.35; + overflow-wrap: anywhere; +`; + +const EvidenceDetail = styled.div` + color: var(--vscode-descriptionForeground, rgba(255, 255, 255, 0.72)); + line-height: 1.45; +`; + +const StatusGrid = styled.div` + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +`; + +const StatusCard = styled.div` + min-width: 0; + border-radius: 12px; + padding: 12px 14px; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.01)); + border: 1px solid rgba(255, 255, 255, 0.08); + backdrop-filter: blur(8px); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05); + transition: all 0.2s ease-in-out; + + &:hover { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.02)); + border-color: rgba(255, 255, 255, 0.15); + } +`; + +const StatusLabel = styled(Typography.Text)` + display: block; + color: var(--vscode-descriptionForeground, rgba(255, 255, 255, 0.62)); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; +`; + +const StatusValue = styled.div` + margin-top: 6px; + font-size: 14px; + font-weight: 600; + line-height: 1.35; + overflow-wrap: anywhere; +`; + +const SwitchField = styled.div` + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 12px 14px; + border-radius: 12px; + background: linear-gradient( + 135deg, + rgba(255, 255, 255, 0.05), + rgba(255, 255, 255, 0.01) + ); + border: 1px solid rgba(255, 255, 255, 0.08); + backdrop-filter: blur(8px); +`; + +const SwitchFieldText = styled.div` + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +`; + +const SwitchFieldTitle = styled.div` + font-size: 13px; + font-weight: 600; +`; + +const ActionColumn = styled.div` + display: flex; + flex-direction: column; + gap: 10px; +`; + +const ActionGroup = styled.div` + display: flex; + flex-direction: column; + gap: 10px; +`; + +const ActionGroupTitle = styled.div` + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--vscode-descriptionForeground, rgba(255, 255, 255, 0.66)); +`; + +const ActionGroupDescription = styled.div` + color: var(--vscode-descriptionForeground, rgba(255, 255, 255, 0.72)); + line-height: 1.45; +`; + +const ActionGrid = styled.div` + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; `; const CompileButtonBadge = styled(Badge)` display: block; cursor: default; - // Fixes badge z-index issue with dropdown button. > .ant-scroll-number { z-index: 3; } `; -const FullWidthDropdownButton = styled(Dropdown.Button)` - .ant-btn:not(.ant-dropdown-trigger) { - width: 100%; +const RecommendationStrip = styled.div` + display: flex; + flex-direction: column; + gap: 6px; + border-radius: 12px; + padding: 12px; + border: 1px solid rgba(0, 120, 212, 0.28); + background: rgba(0, 120, 212, 0.12); +`; + +const RecommendationLabel = styled(Typography.Text)` + color: rgba(180, 220, 255, 0.9); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.05em; +`; + +const RecommendationTitle = styled.div` + font-size: 15px; + font-weight: 700; +`; + +const ModeGrid = styled.div` + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +`; + +const ModeCard = styled.div<{ $recommended: boolean; $current: boolean }>` + display: flex; + flex-direction: column; + gap: 10px; + border-radius: 12px; + padding: 14px; + background: ${({ $recommended, $current }) => + $recommended + ? 'linear-gradient(135deg, rgba(0, 120, 212, 0.2), rgba(0, 120, 212, 0.05))' + : $current + ? 'linear-gradient(135deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.04))' + : 'linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.01))'}; + border: 1px solid + ${({ $recommended, $current }) => + $recommended + ? 'rgba(0, 120, 212, 0.5)' + : $current + ? 'rgba(255, 255, 255, 0.25)' + : 'rgba(255, 255, 255, 0.1)'}; + backdrop-filter: blur(8px); + box-shadow: ${({ $recommended, $current }) => + $recommended + ? '0 4px 16px rgba(0, 120, 212, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.1)' + : $current + ? '0 4px 12px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.1)' + : '0 2px 8px rgba(0, 0, 0, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.05)'}; + transition: all 0.25s cubic-bezier(0.2, 0.8, 0.2, 1); + cursor: pointer; + + &:hover { + transform: translateY(-2px); + box-shadow: ${({ $recommended, $current }) => + $recommended + ? '0 6px 20px rgba(0, 120, 212, 0.3)' + : '0 6px 16px rgba(0, 0, 0, 0.2)'}; + border-color: ${({ $recommended, $current }) => + $recommended + ? 'rgba(0, 120, 212, 0.7)' + : $current + ? 'rgba(255, 255, 255, 0.35)' + : 'rgba(255, 255, 255, 0.2)'}; + } +`; + +const ModeCardTitle = styled.div` + font-size: 13px; + font-weight: 600; +`; + +const ModeCardBody = styled.div` + color: var(--vscode-descriptionForeground, rgba(255, 255, 255, 0.72)); + line-height: 1.45; +`; + +const WorkflowList = styled.div` + display: flex; + flex-direction: column; + gap: 10px; +`; + +const WorkflowItem = styled.div` + border-radius: 12px; + padding: 12px 14px; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.01)); + border: 1px solid rgba(255, 255, 255, 0.08); + backdrop-filter: blur(8px); + transition: all 0.2s ease-in-out; + border-left: 3px solid rgba(255, 255, 255, 0.2); + + &:hover { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.02)); + border-left-color: rgba(24, 144, 255, 0.8); + transform: translateX(2px); + } +`; + +const WorkflowTitle = styled.div` + font-size: 13px; + font-weight: 600; +`; + +const WorkflowBody = styled.div` + margin-top: 4px; + color: var(--vscode-descriptionForeground, rgba(255, 255, 255, 0.72)); + line-height: 1.45; +`; + +const ProvocationList = styled(WorkflowList)``; + +const ProvocationItem = styled(WorkflowItem)` + background: linear-gradient(135deg, rgba(138, 43, 226, 0.1), rgba(138, 43, 226, 0.02)); + border-color: rgba(138, 43, 226, 0.2); + border-left-color: rgba(138, 43, 226, 0.6); + + &:hover { + background: linear-gradient(135deg, rgba(138, 43, 226, 0.15), rgba(138, 43, 226, 0.04)); + border-left-color: rgba(138, 43, 226, 0.9); + } +`; + +const ProvocationTitle = styled(WorkflowTitle)``; + +const ProvocationBody = styled(WorkflowBody)``; + +const VerificationList = styled.div` + display: flex; + flex-direction: column; + gap: 10px; +`; + +const VerificationItem = styled.div` + border-radius: 12px; + padding: 12px 14px; + background: linear-gradient(135deg, rgba(82, 196, 26, 0.08), rgba(82, 196, 26, 0.01)); + border: 1px solid rgba(82, 196, 26, 0.2); + backdrop-filter: blur(8px); + transition: all 0.2s ease-in-out; + border-left: 3px solid rgba(82, 196, 26, 0.5); + + &:hover { + background: linear-gradient(135deg, rgba(82, 196, 26, 0.12), rgba(82, 196, 26, 0.03)); + border-left-color: rgba(82, 196, 26, 0.9); + transform: translateX(2px); } `; +const VerificationTitle = styled.div` + font-size: 13px; + font-weight: 600; +`; + +const VerificationBody = styled.div` + margin-top: 4px; + color: var(--vscode-descriptionForeground, rgba(255, 255, 255, 0.72)); + line-height: 1.45; +`; + +const WindowsActionGrid = styled.div` + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +`; + +const WindowsActionCard = styled.div` + display: flex; + flex-direction: column; + gap: 10px; + border-radius: 12px; + padding: 14px; + background: linear-gradient(135deg, rgba(24, 144, 255, 0.1), rgba(24, 144, 255, 0.02)); + border: 1px solid rgba(24, 144, 255, 0.2); + backdrop-filter: blur(8px); + transition: all 0.25s cubic-bezier(0.2, 0.8, 0.2, 1); + cursor: pointer; + + &:hover { + transform: translateY(-2px); + background: linear-gradient(135deg, rgba(24, 144, 255, 0.15), rgba(24, 144, 255, 0.05)); + border-color: rgba(24, 144, 255, 0.4); + box-shadow: 0 4px 12px rgba(24, 144, 255, 0.15); + } +`; + +const WindowsActionTitle = styled.div` + font-size: 13px; + font-weight: 600; +`; + +const WindowsActionBody = styled.div` + color: var(--vscode-descriptionForeground, rgba(255, 255, 255, 0.72)); + line-height: 1.45; +`; + +const FooterBar = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px; + border-top: 1px solid var(--vscode-widget-border, rgba(255, 255, 255, 0.08)); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent), + var(--vscode-sideBar-background, var(--vscode-editor-background)); +`; + +const FooterText = styled(Typography.Text)` + color: var(--vscode-descriptionForeground, rgba(255, 255, 255, 0.72)); + line-height: 1.45; +`; + type ModDetailsCommon = { modId: string; modWasModified: boolean; + metadata?: ModMetadata | null; + launchContext?: EditorLaunchContext | null; }; type ModDetailsNotCompiled = ModDetailsCommon & { @@ -111,23 +569,37 @@ interface Props { function EditorModeControls({ initialModDetails, onExitEditorMode }: Props) { const { t } = useTranslation(); + const { localUISettings } = useContext(AppUISettingsContext); const [modId, setModId] = useState(initialModDetails.modId); + const [metadata, setMetadata] = useState(initialModDetails.metadata || null); + const [launchContext, setLaunchContext] = useState( + initialModDetails.launchContext || null + ); const [modWasModified, setModWasModified] = useState( initialModDetails.modWasModified ); - const [isModCompiled, setIsModCompiled] = useState( - initialModDetails.compiled - ); + const [isModCompiled, setIsModCompiled] = useState(initialModDetails.compiled); const [isModDisabled, setIsModDisabled] = useState( initialModDetails.compiled && initialModDetails.disabled ); const [isLoggingEnabled, setIsLoggingEnabled] = useState( initialModDetails.compiled && initialModDetails.loggingEnabled ); - const [compilationFailed, setCompilationFailed] = useState(false); + useEffect(() => { + setModId(initialModDetails.modId); + setMetadata(initialModDetails.metadata || null); + setLaunchContext(initialModDetails.launchContext || null); + setModWasModified(initialModDetails.modWasModified); + setIsModCompiled(initialModDetails.compiled); + setIsModDisabled(initialModDetails.compiled ? initialModDetails.disabled : false); + setIsLoggingEnabled( + initialModDetails.compiled ? initialModDetails.loggingEnabled : false + ); + }, [initialModDetails]); + useSetEditedModId( useCallback((data) => { setModId(data.modId); @@ -176,20 +648,46 @@ function EditorModeControls({ initialModDetails, onExitEditorMode }: Props) { ) ); - useCompileEditedModStart( - useCallback(() => { - if (!compileEditedModPending) { - compileEditedMod({ - disabled: isModDisabled, - loggingEnabled: isLoggingEnabled, - }); + const { openExternal, openExternalPending } = useOpenExternal( + useCallback( + (data) => { + if (!data.succeeded) { + message.error(data.error || (t('sidebar.openError') as string)); + } + }, + [t] + ) + ); + + const runCompile = useCallback( + (options?: { disabled?: boolean; loggingEnabled?: boolean }) => { + if (compileEditedModPending) { + return; } - }, [ + + const disabled = options?.disabled ?? isModDisabled; + const loggingEnabled = options?.loggingEnabled ?? isLoggingEnabled; + + setIsModDisabled(disabled); + setIsLoggingEnabled(loggingEnabled); + setCompilationFailed(false); + compileEditedMod({ + disabled, + loggingEnabled, + }); + }, + [ compileEditedMod, compileEditedModPending, isLoggingEnabled, isModDisabled, - ]) + ] + ); + + useCompileEditedModStart( + useCallback(() => { + runCompile(); + }, [runCompile]) ); useEditedModWasModified( @@ -199,95 +697,1018 @@ function EditorModeControls({ initialModDetails, onExitEditorMode }: Props) { }, []) ); + const displayName = metadata?.name || modId; + const scopeSummary = useMemo( + () => summarizeTargetProcesses(metadata?.include), + [metadata?.include] + ); + const launchToolCommands = useMemo( + () => + (launchContext?.tools || []) + .filter((tool) => !!tool.command) + .map((tool) => `- ${tool.title}: ${tool.command}`) + .join('\n'), + [launchContext] + ); + const launchPromptList = useMemo( + () => + (launchContext?.prompts || []) + .map((prompt) => `- ${prompt.title}`) + .join('\n'), + [launchContext] + ); + const editorSessionState = useMemo( + () => ({ + modWasModified, + isModCompiled, + isModDisabled, + isLoggingEnabled, + compilationFailed, + }), + [ + compilationFailed, + isLoggingEnabled, + isModCompiled, + isModDisabled, + modWasModified, + ] + ); + + const buildStatus = compileEditedModPending + ? t('sidebar.status.compiling') + : compilationFailed + ? t('sidebar.status.needsAttention') + : isModCompiled + ? t('sidebar.status.compiled') + : t('sidebar.status.notCompiled'); + const stateStatus = modWasModified + ? t('sidebar.status.modified') + : t('sidebar.status.synced'); + const runtimeStatus = isModDisabled + ? t('sidebar.status.disabled') + : t('sidebar.status.enabled'); + + const evidenceCards = useMemo( + () => getEditorEvidenceCards(metadata, editorSessionState), + [editorSessionState, metadata] + ); + const recommendedCompileProfile = useMemo( + () => getRecommendedCompileProfile(metadata, editorSessionState), + [editorSessionState, metadata] + ); + const workflowItems = useMemo( + () => getEditorIterationPlan(metadata, editorSessionState), + [editorSessionState, metadata] + ); + const provocations = useMemo( + () => getEditorProvocations(metadata, editorSessionState), + [editorSessionState, metadata] + ); + const verificationItems = useMemo( + () => getEditorVerificationPack(metadata, editorSessionState), + [editorSessionState, metadata] + ); + const contextPacket = useMemo( + () => buildEditorContextPacket(modId, metadata, editorSessionState), + [editorSessionState, metadata, modId] + ); + const currentCompileProfileKey = useMemo( + () => getCurrentCompileProfileKey(editorSessionState), + [editorSessionState] + ); + const windowsSurfaceLabels = useMemo( + () => getEditorWindowsSurfaceLabels(metadata), + [metadata] + ); + const windowsActionLimit = + localUISettings.windowsQuickActionDensity === 'expanded' + ? Number.POSITIVE_INFINITY + : 4; + const windowsActions = useMemo( + () => getEditorWindowsActions(metadata, windowsActionLimit), + [metadata, windowsActionLimit] + ); + const editorAssistanceLabel = useMemo(() => { + switch (localUISettings.editorAssistanceLevel) { + case 'streamlined': + return t('settings.workflow.editorAssistance.options.streamlined'); + case 'guided': + return t('settings.workflow.editorAssistance.options.guided'); + case 'full': + default: + return t('settings.workflow.editorAssistance.options.full'); + } + }, [localUISettings.editorAssistanceLevel, t]); + const showEvidenceSection = + localUISettings.editorAssistanceLevel !== 'streamlined'; + const showVerificationSection = + localUISettings.editorAssistanceLevel !== 'streamlined'; + const showWorkflowSection = + localUISettings.editorAssistanceLevel !== 'streamlined'; + const showProvocationSection = + localUISettings.editorAssistanceLevel !== 'streamlined'; + const showAiSection = localUISettings.editorAssistanceLevel === 'full'; + const showLaunchSection = !!launchContext; + + const copyTextWithFeedback = async ( + text: string, + successMessage: string + ) => { + try { + await copyTextToClipboard(text); + message.success(successMessage); + } catch (error) { + console.error('Failed to copy editor helper text:', error); + message.error(t('sidebar.copyError')); + } + }; + + const getCompileProfileLabel = useCallback( + (key: 'current' | 'disabled' | 'logging' | 'disabled-logging') => { + switch (key) { + case 'disabled': + return t('sidebar.compileMenu.disabled'); + case 'logging': + return t('sidebar.compileMenu.logging'); + case 'disabled-logging': + return t('sidebar.compileMenu.disabledLogging'); + case 'current': + default: + return t('sidebar.compileMenu.current'); + } + }, + [t] + ); + + const compileProfileMode = getCompileProfileLabel(currentCompileProfileKey); + const windowsSurfaceSummary = windowsSurfaceLabels.join(', '); + + const runCompileProfile = useCallback( + (profileKey: 'current' | 'disabled' | 'logging' | 'disabled-logging') => { + switch (profileKey) { + case 'disabled': + runCompile({ disabled: true, loggingEnabled: false }); + break; + case 'logging': + runCompile({ disabled: false, loggingEnabled: true }); + break; + case 'disabled-logging': + runCompile({ disabled: true, loggingEnabled: true }); + break; + case 'current': + default: + runCompile(); + break; + } + }, + [runCompile] + ); + + const runRecommendedCompile = useCallback(() => { + switch (recommendedCompileProfile.key) { + case 'disabled': + case 'logging': + case 'disabled-logging': + case 'current': + default: + runCompileProfile(recommendedCompileProfile.key); + break; + } + }, [recommendedCompileProfile.key, runCompileProfile]); + + const openWindowsSurface = useCallback( + (uri: string) => { + openExternal({ + uri, + }); + }, + [openExternal] + ); + + const compileModeCards = useMemo( + () => [ + { + key: 'current', + label: t('sidebar.compileMenu.current'), + description: t('sidebar.compileModes.currentDescription', { + mode: compileProfileMode, + }), + }, + { + key: 'disabled', + label: t('sidebar.compileMenu.disabled'), + description: t('sidebar.compileModes.disabledDescription'), + }, + { + key: 'logging', + label: t('sidebar.compileMenu.logging'), + description: t('sidebar.compileModes.loggingDescription'), + }, + { + key: 'disabled-logging', + label: t('sidebar.compileMenu.disabledLogging'), + description: t('sidebar.compileModes.disabledLoggingDescription'), + }, + ], + [compileProfileMode, t] + ); + return ( - - - {modId} - - - -
    {t('sidebar.enableMod')}
    + + + + {t('sidebar.editorTitle')} + {displayName} + + {t('sidebar.editorDescription')} + + + {stateStatus} + + {buildStatus} + + {runtimeStatus} + {isLoggingEnabled && {t('sidebar.loggingTag')}} + + {editorAssistanceLabel} + + + + + {modId} + + + + exitEditorMode({ saveToDrafts: false })} + > + + + + + + {windowsSurfaceLabels.map((surfaceLabel) => ( + {surfaceLabel} + ))} + + + + {showLaunchSection && launchContext && ( + + +
    + {t('sidebar.sectionKickers.launch')} + {t('sidebar.sections.launch')} +
    +
    + {t('sidebar.sections.launchDescription')} + + + {t('sidebar.launchBrief.kicker')} + + {launchContext.title} + {launchContext.summary} + + + {launchContext.templateKey && ( + + {t('sidebar.launchBrief.templateLabel', { + template: launchContext.templateKey, + })} + + )} + {launchContext.studioMode && ( + + {t('sidebar.launchBrief.modeLabel', { + mode: launchContext.studioMode, + })} + + )} + {launchContext.authoringLanguage && ( + + {t('sidebar.launchBrief.languageLabel', { + language: launchContext.authoringLanguage, + })} + + )} + {!!launchContext.tools?.length && ( + + {t('sidebar.launchBrief.toolsLabel', { + count: launchContext.tools.length, + })} + + )} + {!!launchContext.prompts?.length && ( + + {t('sidebar.launchBrief.promptsLabel', { + count: launchContext.prompts.length, + })} + + )} + + {!!launchContext.checklist?.length && ( + + {launchContext.checklist.map((item) => ( + + {item} + + ))} + + )} + {!!launchContext.tools?.length && ( + + {t('sidebar.launchBrief.toolsTitle')} + + {launchContext.tools.map((tool) => ( + + {tool.title} + {tool.command && {tool.command}} + + ))} + + + )} + {!!launchContext.prompts?.length && ( + + {t('sidebar.launchBrief.promptsTitle')} + + {launchContext.prompts.map((prompt) => ( + + {prompt.title} + + ))} + + + )} + + {!!launchContext.packet && ( + + )} + {!!launchToolCommands && ( + + )} + {!!launchPromptList && ( + + )} + +
    + )} + + + +
    + {t('sidebar.sectionKickers.status')} + {t('sidebar.sections.status')} +
    +
    + {t('sidebar.sections.statusDescription')} + + + {t('sidebar.cards.state')} + {stateStatus} + + + {t('sidebar.cards.build')} + {buildStatus} + + + {t('sidebar.cards.scope')} + {scopeSummary} + + + {t('sidebar.cards.version')} + {metadata?.version || t('sidebar.unknownValue')} + + + {t('sidebar.cards.surface')} + {windowsSurfaceSummary} + + + {t('sidebar.cards.nextCompile')} + {recommendedCompileProfile.label} + + +
    + + {showEvidenceSection && ( + + +
    + {t('sidebar.sectionKickers.evidence')} + {t('sidebar.sections.evidence')} +
    +
    + {t('sidebar.sections.evidenceDescription')} + + {evidenceCards.map((card) => ( + + {card.label} + {card.value} + {card.detail} + + ))} + +
    + )} + + + +
    + {t('sidebar.sectionKickers.controls')} + {t('sidebar.sections.controls')} +
    +
    + + {t('sidebar.sections.controlsDescription', { + mode: compileProfileMode, + })} + + + + {t('sidebar.recommendationLabel')} + + {recommendedCompileProfile.label} + {recommendedCompileProfile.rationale} + + + {compileEditedModPending ? ( + + ) : ( + + )} + + + {compileModeCards.map((modeCard) => ( + + {modeCard.label} + + {currentCompileProfileKey === modeCard.key && ( + {t('sidebar.compileModes.active')} + )} + {recommendedCompileProfile.key === modeCard.key && ( + {t('sidebar.compileModes.recommended')} + )} + + {modeCard.description} + + + ))} + + + + {t('sidebar.enableMod')} + {t('sidebar.descriptions.enableMod')} + enableEditedMod({ enable: checked })} /> -
    - -
    {t('sidebar.enableLogging')}
    + + + + {t('sidebar.enableLogging')} + {t('sidebar.descriptions.enableLogging')} + enableEditedModLogging({ enable: checked }) } /> -
    -
    - - - {compileEditedModPending ? ( - stopCompileEditedMod(), - }, - ], - }} + + + + + + + + + + + +
    + {t('sidebar.sectionKickers.windows')} + {t('sidebar.sections.windows')} +
    +
    + {t('sidebar.sections.windowsDescription')} + + {windowsActions.map((action) => ( + + {action.title} + {action.description} + + + ))} + +
    + + {showProvocationSection && ( + + +
    + {t('sidebar.sectionKickers.provocations')} + {t('sidebar.sections.provocations')} +
    +
    + + {t('sidebar.sections.provocationsDescription')} + + + {provocations.map((provocation) => ( + + {provocation.title} + {provocation.body} + + ))} + + {showAiSection && ( )} -
    - - + + )} + + {showVerificationSection && ( + + +
    + {t('sidebar.sectionKickers.verification')} + {t('sidebar.sections.verification')} +
    +
    + {t('sidebar.sections.verificationDescription')} + + {verificationItems.map((item) => ( + + {item.title} + {item.detail} + + ))} + + + + + +
    + )} + + {showAiSection && ( + + +
    + {t('sidebar.sectionKickers.ai')} + {t('sidebar.sections.ai')} +
    +
    + {t('sidebar.sections.aiDescription')} + + + + + {t('sidebar.ai.understandingTitle')} + + {t('sidebar.ai.understandingDescription')} + + + + + + + + + + {t('sidebar.ai.challengeTitle')} + + {t('sidebar.ai.challengeDescription')} + + + + + + + + + + {t('sidebar.ai.validationTitle')} + + {t('sidebar.ai.validationDescription')} + + + + + + + + + + {t('sidebar.ai.buildTitle')} + + {t('sidebar.ai.buildDescription')} + + + + + + +
    + )} + + {showWorkflowSection && ( + + +
    + {t('sidebar.sectionKickers.workflow')} + {t('sidebar.sections.workflow')} +
    +
    + {t('sidebar.sections.workflowDescription')} + + {workflowItems.map((item) => ( + + {item.title} + {item.body} + + ))} + +
    + )} + + + + {t('sidebar.footerNote')}