diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index ecb1dfac..00000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.env.example b/.env.example index 2ee7d739..465390f4 100644 --- a/.env.example +++ b/.env.example @@ -18,5 +18,11 @@ SENTRY_AUTH_TOKEN= SENTRY_ORG=your-org-slug SENTRY_PROJECT=your-project-slug +# Azure Blob Storage Configuration +AZURE_BLOB_BASE_URL=https://yourstorageaccount.blob.core.windows.net/yourcontainer + # Capture the URL for What's New content -PPTB_UPDATES_ORIGIN=https://www.powerplatformtoolbox.com \ No newline at end of file +PPTB_UPDATES_ORIGIN=https://www.powerplatformtoolbox.com + +# Enable mock updates for testing (set to "true" to enable) +MOCK_UPDATES=false \ No newline at end of file diff --git a/.github/plans/plan-inter-tool-launch-context.md b/.github/plans/plan-inter-tool-launch-context.md new file mode 100644 index 00000000..087f2e98 --- /dev/null +++ b/.github/plans/plan-inter-tool-launch-context.md @@ -0,0 +1,303 @@ +# Plan: Inter-Tool Launch Context + +## Request summary + +Enable tools to programmatically launch other installed tools, pass typed "prefill" data into them, +and receive a return value when the callee finishes. The feature follows the VS Code Extension Host +pattern already used for tool isolation, routing all communication through the Electron IPC bridge +so no tool ever has direct access to another tool's process. + +## Goals + +- Tool A can call `toolboxAPI.invocation.launchTool(targetToolId, prefillData?)` and receive back + whatever data the callee returns. +- Tool B reads caller-supplied data via `toolboxAPI.invocation.getLaunchContext()` and signals + completion via `toolboxAPI.invocation.returnData(result)`. +- If Tool B is closed before calling `returnData`, the caller receives `null` (no hang). +- Tools declare their invocation contract in an optional `pptb.config.json` (validated by + `pptb-validate` CLI) so callers know exactly what shape of data to pass and expect back. +- The existing `launchTool()` renderer entry-point is extended non-breakingly (new optional fields). + +## Non-goals + +- Cross-instance communication outside the launch/return lifecycle (use the Events API for that). +- Auto-closing the callee after `returnData` (the callee manages its own lifecycle). +- Schema-level runtime type checking of `prefillData` / `returnData` at IPC boundaries. + +## Assumptions / Open questions + +- Tools must be already installed; `launchTool` in the preload bridge looks up the tool manifest via + the existing `TOOL_CHANNELS.GET_TOOL` IPC channel. +- `callerInstanceId` is derived from the calling tool's own `toolContext.instanceId`; it is always + set when `toolboxAPI.invocation.launchTool` is called from a tool window. +- Connection IDs for the callee default to `null` when not supplied; the caller can override them + via `options.primaryConnectionId` / `options.secondaryConnectionId`. + +## Acceptance criteria + +- [ ] `toolboxAPI.invocation.launchTool("@scope/tool", { key: "value" })` opens the target tool, + passes the data, and returns the data supplied by `returnData()`. +- [ ] If the callee is closed without calling `returnData`, the caller's Promise resolves with `null`. +- [ ] `toolboxAPI.invocation.getLaunchContext()` returns `null` when no inter-tool context is + present (standalone tool launch). +- [ ] `toolboxAPI.invocation.returnData({...})` is a no-op when the tool was not launched by another + tool. +- [ ] `pptb-validate` validates `pptb.config.json` when present alongside `package.json`. +- [ ] `InvocationAPI` is exported from `packages/toolboxAPI.d.ts` with full JSDoc. +- [ ] `packages/README.md` has caller/callee examples and an API reference entry. +- [ ] `pnpm run typecheck`, `pnpm run lint`, and `pnpm run build` pass with 0 errors. + +## Triage + +Type: **High-risk** + +Rationale: + +- Changes span IPC channels (new handlers), the preload bridge (new surface exposed to tool + windows), and the main-process ToolWindowManager (new Promise-based invocation lifecycle). +- Any mistake in the preload bridge or IPC handler could expose unintended cross-tool data access. + +## Participants (mesh) + +- Product Manager (gateway) +- Data Architect (IPC schema, types) +- Tech Designer (preload bridge, ToolWindowManager lifecycle) +- App Developer (implementation) +- Code Reviewer +- Security Reviewer + +## Plan (drafted by agents) + +### Product Manager (Orchestrator) + +- Scope confirmed: 7 implementation parts, high-risk triage, requires explicit APPROVED before coding. +- Acceptance criteria locked above. + +### Data Architect + +**Part 1 — `pptb.config.json` schema & validation** +- Add `packages/pptbConfig.d.ts` — TypeScript types for `PPTBConfig`, `InvocationConfig`, + `JsonSchemaObject` and `JsonSchemaProperty`. +- Update `packages/lib/validate.js` — add `validatePPTBConfig(config)` function that checks: + - `invocation.version` is required and must be a valid semver string. + - `invocation.prefill` (optional) must be a `JsonSchemaObject` with valid `properties`. + - `invocation.returnTopic` (optional) must be a `JsonSchemaObject` with valid `properties`. + - Unknown root keys emit warnings (not errors). +- Update `packages/bin/pptb-validate.js` — auto-discover `pptb.config.json` in the same directory + as `package.json` and call `validatePPTBConfig` when found; report results in the same + human-readable / `--json` output format. + +**Part 2 — IPC Channels** +- Add to `TOOL_WINDOW_CHANNELS` in `src/common/ipc/channels.ts`: + - `LAUNCH_WITH_CONTEXT: "tool-window:launch-with-context"` + - `RETURN_INVOCATION_DATA: "tool-window:return-invocation-data"` + +### Tech Designer + +**Part 3 — Main Process: ToolWindowManager** + +New private field: +```ts +private pendingInvocations: Map< + string, // calleeInstanceId + { + callerInstanceId: string; + prefillData: Record; + resolve: (data: unknown) => void; + reject: (reason: unknown) => void; + } +> = new Map(); +``` + +New / updated methods in `src/main/managers/toolWindowManager.ts`: + +- `launchTool(instanceId, tool, primaryConnectionId, secondaryConnectionId, prefillData?)` — + optional 5th parameter; when `pendingInvocations` has an entry for `instanceId` the context + message (`toolbox:context`) includes `callerInstanceId` and `prefillData`. +- `launchToolWithContext(callerInstanceId, calleeInstanceId, tool, primaryConnectionId, + secondaryConnectionId, prefillData)` — stores pending invocation entry then delegates to + `launchTool`; returns a Promise resolved by `resolveInvocation`. +- `resolveInvocation(calleeInstanceId, returnData)` — pops the pending entry, sends + `toolbox:invocation-result` IPC push to the caller BrowserView (if still alive), resolves the + Promise. +- `closeTool(instanceId)` — if a pending invocation exists for this instance, resolve it with + `null` and send `toolbox:invocation-result` to the caller (prevents hang). + +IPC handlers to add in `setupIpcHandlers()` / `removeIpcHandlers()` / `destroy()`: +- `LAUNCH_WITH_CONTEXT` → `launchToolWithContext(...)` +- `RETURN_INVOCATION_DATA` → `resolveInvocation(...)` + +**Part 4 — Tool Preload Bridge** + +Import `TOOL_CHANNELS` and `TOOL_WINDOW_CHANNELS` in `src/main/toolPreloadBridge.ts`. + +New `toolboxAPI.invocation` namespace exposed via `contextBridge.exposeInMainWorld`: + +```ts +invocation: { + getLaunchContext(): Promise | null> + returnData(returnData: Record): Promise + launchTool( + targetToolId: string, + prefillData?: Record, + options?: { primaryConnectionId?: string | null; secondaryConnectionId?: string | null } + ): Promise +} +``` + +- `getLaunchContext` reads `toolContext.prefillData`; returns `null` when the field is absent, + `null`, `undefined`, or not a plain object. Uses explicit `=== null || === undefined` guard + (not truthiness check) to correctly handle empty-object `{}` prefill data. +- `returnData` invokes `RETURN_INVOCATION_DATA` with the caller's `instanceId` and the payload. +- `launchTool` fetches the target tool manifest via `TOOL_CHANNELS.GET_TOOL`, generates a callee + `instanceId` with `targetToolId-Date.now()-randomSuffix`, then calls `LAUNCH_WITH_CONTEXT`. + +**Part 5 — Renderer** + +`src/renderer/modules/toolManagement.ts`: +- Extend `LaunchToolOptions` with two new optional fields: + - `prefillData?: Record` + - `callerInstanceId?: string` +- In `launchTool()`, when `options.callerInstanceId` is set call + `window.toolboxAPI.launchToolWithContext(callerInstanceId, instanceId, tool, + primaryConnectionId, secondaryConnectionId ?? null, options.prefillData ?? {})` instead of the + standard `launchToolWindow` path. + +`src/main/preload.ts`: +- Expose `launchToolWithContext(callerInstanceId, calleeInstanceId, tool, primaryConnectionId, + secondaryConnectionId, prefillData)` → `ipcRenderer.invoke(LAUNCH_WITH_CONTEXT, ...)`. + +`src/common/types/api.ts`: +- Add `launchToolWithContext(...)` to the renderer `ToolboxAPI` type. + +### App Developer + +*(see Execution log below)* + +### Critic + +- Empty-object `{}` prefill data must not be rejected by `getLaunchContext` — use explicit + `=== null || === undefined` guard, not `!prefillData`. +- Extra blank lines between method definitions in `toolWindowManager.ts` should match the + surrounding code style. +- `.DS_Store` must remain in `.gitignore`. + +## Checkpoint + +Status: **APPROVED** + +- [x] Scope and acceptance criteria confirmed +- [x] Critic reviewed +- [x] User approved plan (required for high-risk) + +--- + +## Execution log (only after GO/APPROVED) + +### App Developer + +**Part 1 — pptb.config.json schema & validation** + +- `packages/pptbConfig.d.ts` — created; exports `JsonSchemaProperty`, `JsonSchemaObject`, + `InvocationConfig`, `PPTBConfig`. +- `packages/lib/validate.js` — `validatePPTBConfig(config)` added; validates `invocation.version` + (semver), `prefill` and `returnTopic` (JSON-schema objects). +- `packages/bin/pptb-validate.js` — auto-discovers `pptb.config.json` alongside `package.json`; + calls `validatePPTBConfig`; results included in human-readable and `--json` output. + +**Part 2 — IPC Channels** + +- `src/common/ipc/channels.ts` — `LAUNCH_WITH_CONTEXT` and `RETURN_INVOCATION_DATA` added to + `TOOL_WINDOW_CHANNELS`. + +**Part 3 — ToolWindowManager** + +- `pendingInvocations` private map added. +- `launchTool` signature extended with optional `prefillData` param; tool context message includes + `callerInstanceId` + `prefillData` when a pending invocation exists. +- `launchToolWithContext` method added (stores pending entry, delegates to `launchTool`, returns + Promise). +- `resolveInvocation` method added (pops pending entry, pushes `toolbox:invocation-result` to + caller BrowserView, resolves Promise). +- `closeTool` extended: if a pending invocation exists for the closing instance, resolve its + Promise with `null` and notify the caller BrowserView. +- `setupIpcHandlers` / `removeIpcHandlers` / `destroy` updated for both new channels. + +**Part 4 — Tool Preload Bridge** + +- `src/main/toolPreloadBridge.ts` imports `TOOL_CHANNELS` and `TOOL_WINDOW_CHANNELS`. +- `toolboxAPI.invocation` namespace added: `getLaunchContext`, `returnData`, `launchTool`. +- `getLaunchContext` uses explicit `=== null || === undefined` guard (Critic fix applied). + +**Part 5 — Renderer** + +- `LaunchToolOptions` extended with `prefillData` and `callerInstanceId`. +- `launchTool()` routes through `launchToolWithContext` when `callerInstanceId` is present. +- `preload.ts` exposes `launchToolWithContext` method. +- `src/common/types/api.ts` — `launchToolWithContext` added to renderer API type. + +**Part 6 — Public type definitions** + +- `packages/toolboxAPI.d.ts` — `InvocationAPI` interface added with full JSDoc including + caller/callee usage examples; `API` interface updated to include `invocation: InvocationAPI`. + +**Part 7 — Documentation** + +- `packages/README.md` — new **Inter-Tool Invocation** section added (table of contents, + caller/callee examples, `pptb.config.json` contract guidance); **Invocation** entry added to + the API Reference section. + +### Code Reviewer + +- Issue: `!prefillData` truthiness check rejects valid empty-object `{}`. + Fix: changed to `prefillData === null || prefillData === undefined`. +- Issue: extra blank line between `resolveInvocation` and `switchToTool` method definitions. + Fix: blank line removed. +- Issue: `.DS_Store` removed from `.gitignore` (pre-existing regression on the branch). + Fix: entry restored. +- All other review comments: no further issues raised. + +### Security Reviewer + +- CodeQL scan: 0 alerts. +- No new attack surface introduced: `prefillData` and `returnData` are plain JSON objects + serialised over Electron's existing IPC bridge; no new Node.js APIs are exposed to tool windows. + +--- + +## Files changed + +| File | Change | +|------|--------| +| `src/common/ipc/channels.ts` | Add `LAUNCH_WITH_CONTEXT`, `RETURN_INVOCATION_DATA` to `TOOL_WINDOW_CHANNELS` | +| `src/main/managers/toolWindowManager.ts` | `pendingInvocations` map; `launchToolWithContext`; `resolveInvocation`; `launchTool` prefill param; `closeTool` null-resolve on close | +| `src/main/toolPreloadBridge.ts` | `toolboxAPI.invocation` namespace; import `TOOL_CHANNELS`, `TOOL_WINDOW_CHANNELS` | +| `src/main/preload.ts` | Expose `launchToolWithContext` to renderer | +| `src/renderer/modules/toolManagement.ts` | Extend `LaunchToolOptions`; route through `launchToolWithContext` | +| `src/common/types/api.ts` | Add `launchToolWithContext` to renderer API type | +| `packages/toolboxAPI.d.ts` | Add `InvocationAPI` interface; update `API` interface | +| `packages/README.md` | Inter-tool invocation section + API reference entry | +| `packages/pptbConfig.d.ts` | New file — TypeScript types for `pptb.config.json` | +| `packages/lib/validate.js` | `validatePPTBConfig` function | +| `packages/bin/pptb-validate.js` | Auto-discover and validate `pptb.config.json` | +| `.gitignore` | Restored `.DS_Store` entry | + +## Validation steps + +- `pnpm run typecheck` — 0 errors ✅ +- `pnpm run lint` — 0 errors ✅ +- `pnpm run build` — succeeds ✅ +- CodeQL Security Scan — 0 alerts ✅ + +## Risks & rollback + +- **IPC handler conflicts**: new channels are added to both `setupIpcHandlers`/`removeIpcHandlers` + and `destroy`; duplicate-registration risk is mitigated by always calling `removeHandler` before + `handle`. +- **Hanging caller Promise**: mitigated by the `closeTool` null-resolve path. +- **Prefill data size**: no size limit is enforced at IPC level; very large objects could slow IPC. + Mitigated by documenting that `prefillData` should contain identifiers/configs, not large + payloads. +- **Rollback**: all changes are additive (new channels, new methods, new API namespace). The + existing `LAUNCH` channel and `launchToolWindow` path are untouched. Reverting this PR requires + only removing the new symbols; no migration is needed. diff --git a/.github/workflows/merge-prs.yml b/.github/workflows/merge-prs.yml new file mode 100644 index 00000000..ea429680 --- /dev/null +++ b/.github/workflows/merge-prs.yml @@ -0,0 +1,25 @@ +name: Combine PRs + +on: + workflow_dispatch: + +concurrency: + group: combine-prs + cancel-in-progress: false + +permissions: + contents: write + pull-requests: write + checks: read + +jobs: + combine-prs: + runs-on: ubuntu-latest + + steps: + - name: combine-prs + id: combine-prs + uses: github/combine-prs@v5.2.0 + with: + token: ${{ secrets.COMBINE_PRS_TOKEN }} + base: dev diff --git a/.github/workflows/nightly-release.yml b/.github/workflows/nightly-release.yml index f7426a46..746a5494 100644 --- a/.github/workflows/nightly-release.yml +++ b/.github/workflows/nightly-release.yml @@ -40,16 +40,20 @@ jobs: matrix: include: - os: ubuntu-latest - config: buildScripts/electron-builder-linux.json + config: buildScripts/electron-builder-linux-insider.json artifact_name: linux-build - os: windows-latest - config: buildScripts/electron-builder-win.json + config: buildScripts/electron-builder-win-insider.json artifact_name: windows-x64-build + win_arch: x64 + win_unpacked_dir: win-unpacked - os: windows-latest - config: buildScripts/electron-builder-win-arm64.json + config: buildScripts/electron-builder-win-arm64-insider.json artifact_name: windows-arm64-build + win_arch: arm64 + win_unpacked_dir: win-arm64-unpacked - os: macos-latest - config: buildScripts/electron-builder-mac.json + config: buildScripts/electron-builder-mac-insider.json artifact_name: macos-build steps: @@ -86,6 +90,7 @@ jobs: - name: Build application run: pnpm run build env: + PPTB_CHANNEL: insider SUPABASE_URL: ${{ secrets.SUPABASE_URL }} SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }} APPINSIGHTS_CONNECTION_STRING: ${{ secrets.APPINSIGHTS_CONNECTION_STRING }} @@ -98,6 +103,7 @@ jobs: if: matrix.os == 'ubuntu-latest' run: node ./buildScripts/package.js --config=${{ matrix.config }} env: + PPTB_CHANNEL: insider GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} SUPABASE_URL: ${{ secrets.SUPABASE_URL }} SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }} @@ -107,10 +113,11 @@ jobs: SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} - - name: Package application (Windows) + - name: Build application directory (Windows) if: matrix.os == 'windows-latest' - run: node ./buildScripts/package.js --config=${{ matrix.config }} + run: pnpm exec electron-builder --config ${{ matrix.config }} --dir --${{ matrix.win_arch }} env: + PPTB_CHANNEL: insider GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} SUPABASE_URL: ${{ secrets.SUPABASE_URL }} SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }} @@ -120,7 +127,7 @@ jobs: SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} - - name: Sign Windows artifacts with Azure Trusted Signing + - name: Sign application binaries with Azure Trusted Signing (Windows) if: matrix.os == 'windows-latest' uses: azure/artifact-signing-action@v1 with: @@ -130,47 +137,44 @@ jobs: endpoint: ${{ secrets.TRUSTED_SIGNING_ENDPOINT }} signing-account-name: ${{ secrets.TRUSTED_SIGNING_ACCOUNT_NAME }} certificate-profile-name: ${{ secrets.TRUSTED_SIGNING_CERTIFICATE_PROFILE }} - files-folder: ${{ github.workspace }}/build - files-folder-filter: exe,msi + files-folder: ${{ github.workspace }}/build/${{ matrix.win_unpacked_dir }} + files-folder-filter: exe,dll files-folder-recurse: true timestamp-rfc3161: http://timestamp.acs.microsoft.com timestamp-digest: SHA256 description: Power Platform ToolBox (Insider) description-url: https://github.com/PowerPlatformToolBox/desktop-app - - name: Repackage portable ZIP with signed EXE (Windows) + - name: Package installers from signed directory (Windows) if: matrix.os == 'windows-latest' - shell: powershell - run: | - $buildDir = "${{ github.workspace }}/build" - $zipFiles = @(Get-ChildItem "$buildDir/*.zip" -ErrorAction SilentlyContinue) - - if ($zipFiles.Count -eq 0) { - Write-Host "No ZIP artifacts found; skipping repack." - exit 0 - } - - foreach ($zip in $zipFiles) { - Write-Host "Repacking ZIP: $($zip.Name)" - $tempDir = Join-Path $env:RUNNER_TEMP ([Guid]::NewGuid().ToString()) - New-Item -ItemType Directory -Path $tempDir | Out-Null - - Expand-Archive -Path $zip.FullName -DestinationPath $tempDir -Force - - $zipExeFiles = @(Get-ChildItem $tempDir -Recurse -Filter *.exe -ErrorAction SilentlyContinue) - foreach ($zipExe in $zipExeFiles) { - $signedExe = Get-ChildItem $buildDir -Recurse -Filter $zipExe.Name -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($signedExe) { - Copy-Item $signedExe.FullName $zipExe.FullName -Force - Write-Host " Replaced $($zipExe.Name) with signed binary." - } else { - Write-Host " No signed match found for $($zipExe.Name)." - } - } + run: pnpm exec electron-builder --config ${{ matrix.config }} --prepackaged build/${{ matrix.win_unpacked_dir }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SUPABASE_URL: ${{ secrets.SUPABASE_URL }} + SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }} + APPINSIGHTS_CONNECTION_STRING: ${{ secrets.APPINSIGHTS_CONNECTION_STRING }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} - Remove-Item $zip.FullName -Force - Compress-Archive -Path (Join-Path $tempDir '*') -DestinationPath $zip.FullName -Force - } + - name: Sign Windows installers with Azure Trusted Signing + if: matrix.os == 'windows-latest' + uses: azure/artifact-signing-action@v1 + with: + azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} + azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} + azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }} + endpoint: ${{ secrets.TRUSTED_SIGNING_ENDPOINT }} + signing-account-name: ${{ secrets.TRUSTED_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ secrets.TRUSTED_SIGNING_CERTIFICATE_PROFILE }} + files-folder: ${{ github.workspace }}/build + files-folder-filter: exe,msi + files-folder-recurse: false + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + description: Power Platform ToolBox (Insider) + description-url: https://github.com/PowerPlatformToolBox/desktop-app - name: Regenerate latest.yml with correct SHA256 hashes (Windows) if: matrix.os == 'windows-latest' @@ -286,6 +290,7 @@ jobs: if: matrix.os == 'macos-latest' shell: bash env: + PPTB_CHANNEL: insider GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} SUPABASE_URL: ${{ secrets.SUPABASE_URL }} SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }} @@ -304,7 +309,7 @@ jobs: if: matrix.os == 'macos-latest' shell: bash run: | - APP_PATH="build/mac/Power Platform ToolBox.app" + APP_PATH="build/mac/Power Platform ToolBox Insider.app" echo "=== Verifying main app signature ===" codesign --verify --deep --strict --verbose=4 "$APP_PATH" 2>&1 @@ -354,7 +359,7 @@ jobs: APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | - node ./buildScripts/notarize.js submit --assets="build/*.dmg,build/*.zip,build/*.pkg" --app="build/mac/Power Platform ToolBox.app" --output="build/notarization-info.json" + node ./buildScripts/notarize.js submit --assets="build/*.dmg,build/*.zip,build/*.pkg" --app="build/mac/Power Platform ToolBox Insider.app" --output="build/notarization-info.json" - name: Cleanup macOS signing certificate if: ${{ always() && matrix.os == 'macos-latest' }} diff --git a/.github/workflows/prod-release.yml b/.github/workflows/prod-release.yml index 3113e1b8..df984c96 100644 --- a/.github/workflows/prod-release.yml +++ b/.github/workflows/prod-release.yml @@ -76,9 +76,13 @@ jobs: - os: windows-latest config: buildScripts/electron-builder-win.json artifact_name: windows-x64-release + win_arch: x64 + win_unpacked_dir: win-unpacked - os: windows-latest config: buildScripts/electron-builder-win-arm64.json artifact_name: windows-arm64-release + win_arch: arm64 + win_unpacked_dir: win-arm64-unpacked - os: macos-latest config: buildScripts/electron-builder-mac.json artifact_name: macos-release @@ -112,6 +116,7 @@ jobs: - name: Build application run: pnpm run build env: + PPTB_CHANNEL: stable SUPABASE_URL: ${{ secrets.SUPABASE_URL }} SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }} APPINSIGHTS_CONNECTION_STRING: ${{ secrets.APPINSIGHTS_CONNECTION_STRING }} @@ -124,6 +129,7 @@ jobs: if: matrix.os == 'ubuntu-latest' run: node ./buildScripts/package.js --config=${{ matrix.config }} env: + PPTB_CHANNEL: stable GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} SUPABASE_URL: ${{ secrets.SUPABASE_URL }} SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }} @@ -133,10 +139,11 @@ jobs: SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} - - name: Package application (Windows) + - name: Build application directory (Windows) if: matrix.os == 'windows-latest' - run: node ./buildScripts/package.js --config=${{ matrix.config }} + run: pnpm exec electron-builder --config ${{ matrix.config }} --dir --${{ matrix.win_arch }} env: + PPTB_CHANNEL: stable GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} SUPABASE_URL: ${{ secrets.SUPABASE_URL }} SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }} @@ -146,7 +153,7 @@ jobs: SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} - - name: Sign Windows artifacts with Azure Trusted Signing + - name: Sign application binaries with Azure Trusted Signing (Windows) if: matrix.os == 'windows-latest' uses: azure/artifact-signing-action@v1 with: @@ -156,47 +163,44 @@ jobs: endpoint: ${{ secrets.TRUSTED_SIGNING_ENDPOINT }} signing-account-name: ${{ secrets.TRUSTED_SIGNING_ACCOUNT_NAME }} certificate-profile-name: ${{ secrets.TRUSTED_SIGNING_CERTIFICATE_PROFILE }} - files-folder: ${{ github.workspace }}/build - files-folder-filter: exe,msi + files-folder: ${{ github.workspace }}/build/${{ matrix.win_unpacked_dir }} + files-folder-filter: exe,dll files-folder-recurse: true timestamp-rfc3161: http://timestamp.acs.microsoft.com timestamp-digest: SHA256 description: Power Platform ToolBox description-url: https://github.com/PowerPlatformToolBox/desktop-app - - name: Repackage portable ZIP with signed EXE (Windows) + - name: Package installers from signed directory (Windows) if: matrix.os == 'windows-latest' - shell: powershell - run: | - $buildDir = "${{ github.workspace }}/build" - $zipFiles = @(Get-ChildItem "$buildDir/*.zip" -ErrorAction SilentlyContinue) - - if ($zipFiles.Count -eq 0) { - Write-Host "No ZIP artifacts found; skipping repack." - exit 0 - } - - foreach ($zip in $zipFiles) { - Write-Host "Repacking ZIP: $($zip.Name)" - $tempDir = Join-Path $env:RUNNER_TEMP ([Guid]::NewGuid().ToString()) - New-Item -ItemType Directory -Path $tempDir | Out-Null - - Expand-Archive -Path $zip.FullName -DestinationPath $tempDir -Force - - $zipExeFiles = @(Get-ChildItem $tempDir -Recurse -Filter *.exe -ErrorAction SilentlyContinue) - foreach ($zipExe in $zipExeFiles) { - $signedExe = Get-ChildItem $buildDir -Recurse -Filter $zipExe.Name -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($signedExe) { - Copy-Item $signedExe.FullName $zipExe.FullName -Force - Write-Host " Replaced $($zipExe.Name) with signed binary." - } else { - Write-Host " No signed match found for $($zipExe.Name)." - } - } + run: pnpm exec electron-builder --config ${{ matrix.config }} --prepackaged build/${{ matrix.win_unpacked_dir }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SUPABASE_URL: ${{ secrets.SUPABASE_URL }} + SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }} + APPINSIGHTS_CONNECTION_STRING: ${{ secrets.APPINSIGHTS_CONNECTION_STRING }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} - Remove-Item $zip.FullName -Force - Compress-Archive -Path (Join-Path $tempDir '*') -DestinationPath $zip.FullName -Force - } + - name: Sign Windows installers with Azure Trusted Signing + if: matrix.os == 'windows-latest' + uses: azure/artifact-signing-action@v1 + with: + azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} + azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} + azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }} + endpoint: ${{ secrets.TRUSTED_SIGNING_ENDPOINT }} + signing-account-name: ${{ secrets.TRUSTED_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ secrets.TRUSTED_SIGNING_CERTIFICATE_PROFILE }} + files-folder: ${{ github.workspace }}/build + files-folder-filter: exe,msi + files-folder-recurse: false + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + description: Power Platform ToolBox + description-url: https://github.com/PowerPlatformToolBox/desktop-app - name: Regenerate latest.yml with correct SHA256 hashes (Windows) if: matrix.os == 'windows-latest' @@ -313,6 +317,7 @@ jobs: shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PPTB_CHANNEL: stable SUPABASE_URL: ${{ secrets.SUPABASE_URL }} SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }} APPINSIGHTS_CONNECTION_STRING: ${{ secrets.APPINSIGHTS_CONNECTION_STRING }} diff --git a/.gitignore b/.gitignore index d9f00490..d4529d60 100644 --- a/.gitignore +++ b/.gitignore @@ -155,3 +155,6 @@ buildScripts/*/latest-mac.yml # Any macOS Certificate files *.cer *.p12 + +# macOS Finder metadata files +.DS_Store diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 5a77acee..187dfa98 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,42 +1,46 @@ -# Power Platform ToolBox 1.2.1 +# Power Platform ToolBox 1.2.2 ## Highlights -- Import connections from XrmToolBox XML with a source selection step (XTB vs PPTB) -- Share and move connections via import/export connection files -- Review "What's New" after updates via in-app auto-update notifications -- Manage Settings as a dedicated tab, plus a Settings entry in the View menu -- Browse Community Resources with dynamic, Supabase-backed links -- Connect to US Government Dataverse environments (GCC High / DoD URL support) -- Customize connection list visuals with category/environment color border appearance settings -- Control startup behavior with an option to disable session restore +- Open external URLs using the active connection's browser profile for consistent session isolation +- Keep the app accessible on macOS with a system tray icon when the window is closed +- Sync zoom level between the main window and all open tool BrowserViews +- Allow tools to open `mailto:` links with explicit user consent and URL validation +- Improve protocol handler behavior for stable builds (single-instance lock and safer registration rules) +- Make marketplace filtering clearer with an active-filter indicator and one-click clear +- Improve tool update availability visibility with a clearer text-based indicator ## Fixes -- Auto-update: fixed force-close TypeError ("Object has been destroyed") during modal teardown -- Global search: fixed command palette rendering behind active tool BrowserViews -- Tools: improved dual-connection handling and corrected dual-connection tab color split -- UI: fixed BrowserView sizing and spurious connection prompts after force-reload -- Auto-update: loading overlay no longer blocks system dialogs (always-on-top conflicts removed) -- Protocol handler: fixed `pptb://` handling in development mode when explicitly enabled +- Zoom: correct zoom-in accelerator keys and re-fit BrowserView bounds after zooming +- Links: avoid lowercasing full URLs during `mailto:` scheme checks +- Links: improve parse-failure logging with scheme context while avoiding PII +- UI: fix notification layering/z-index issues in the renderer +- Tools sidebar: improve empty-state hint behavior for better guidance +- Marketplace: fix tool icon theming so icons adapt correctly in light/dark mode +- URLs: add support for URLs ending in `mcas.ms` where applicable +- macOS: ensure tray Quit fully exits the app (track `isQuitting` correctly) ## Developer & Build -- toolboxAPI: deprecated `showLoading`/`hideLoading` to reduce API surface and clarify usage -- DevTools: open in detached mode for main and tool windows -- Release automation: avoid draft release creation and switch nightly versioning to `dev` tags +- toolboxAPI: remove deprecated loading screen API and associated handlers +- `pptb-validate`: validate `pptb.config.json` invocation with semver enforcement +- Types: rename `PptbConfig` → `PPTBConfig` and `validatePptbConfig` → `validatePPTBConfig` for consistent casing +- CI/CD: separate stable vs insider release channels (including channel-specific icons) +- Windows packaging: sign app binaries before packaging into installers +- Workflows: tighten merge automation with explicit permissions and concurrency ## Install -- Windows: Power-Platform-ToolBox-1.2.1-Setup.exe -- macOS: Power-Platform-ToolBox-1.2.1.dmg (drag to Applications) -- Linux: Power-Platform-ToolBox-1.2.1.AppImage (chmod +x, then run) +- Windows: Power-Platform-ToolBox-1.2.2-Setup.exe +- macOS: Power-Platform-ToolBox-1.2.2.dmg (drag to Applications) +- Linux: Power-Platform-ToolBox-1.2.2.AppImage (chmod +x, then run) ## Notes - No manual migration needed; existing settings and connections continue to work. -- Tool developers: plan to remove `showLoading`/`hideLoading` usage and move to the newer loading UX patterns. +- Tool developers: `mailto:` opening may prompt for user consent; update config/type references to `PPTBConfig` if you used the older casing. ## Full Changelog -https://github.com/PowerPlatformToolBox/desktop-app/compare/v1.2.0...v1.2.1 +https://github.com/PowerPlatformToolBox/desktop-app/compare/v1.2.1...v1.2.2 diff --git a/assets/full-icon.png b/assets/full-icon.png deleted file mode 100644 index 535c630a..00000000 Binary files a/assets/full-icon.png and /dev/null differ diff --git a/buildScripts/electron-builder-base-insider.json b/buildScripts/electron-builder-base-insider.json new file mode 100644 index 00000000..45c3afa1 --- /dev/null +++ b/buildScripts/electron-builder-base-insider.json @@ -0,0 +1,12 @@ +{ + "extends": "buildScripts/electron-builder-base.json", + "appId": "com.powerplatform.toolbox.insider", + "productName": "Power Platform ToolBox Insider", + "artifactName": "Power-Platform-ToolBox-Insider-${version}-${arch}-${os}.${ext}", + "publish": { + "provider": "github", + "owner": "PowerPlatformToolBox", + "repo": "desktop-app", + "releaseType": "prerelease" + } +} diff --git a/buildScripts/electron-builder-base.json b/buildScripts/electron-builder-base.json index 8eca3a51..d001af2a 100644 --- a/buildScripts/electron-builder-base.json +++ b/buildScripts/electron-builder-base.json @@ -5,7 +5,7 @@ "directories": { "output": "build" }, - "files": ["dist/**/*", "assets/**/*", "package.json"], + "files": ["dist/**/*", "icons/**/*", "package.json"], "publish": { "provider": "github", "owner": "PowerPlatformToolBox", diff --git a/buildScripts/electron-builder-linux-insider.json b/buildScripts/electron-builder-linux-insider.json new file mode 100644 index 00000000..c9e855c8 --- /dev/null +++ b/buildScripts/electron-builder-linux-insider.json @@ -0,0 +1,15 @@ +{ + "extends": "buildScripts/electron-builder-linux.json", + "appId": "com.powerplatform.toolbox.insider", + "productName": "Power Platform ToolBox Insider", + "artifactName": "Power-Platform-ToolBox-Insider-${version}-${arch}-${os}.${ext}", + "linux": { + "icon": "icons/insider" + }, + "publish": { + "provider": "github", + "owner": "PowerPlatformToolBox", + "repo": "desktop-app", + "releaseType": "prerelease" + } +} diff --git a/buildScripts/electron-builder-mac-insider.json b/buildScripts/electron-builder-mac-insider.json new file mode 100644 index 00000000..796899ff --- /dev/null +++ b/buildScripts/electron-builder-mac-insider.json @@ -0,0 +1,15 @@ +{ + "extends": "buildScripts/electron-builder-mac.json", + "appId": "com.powerplatform.toolbox.insider", + "productName": "Power Platform ToolBox Insider", + "artifactName": "Power-Platform-ToolBox-Insider-${version}-${arch}-${os}.${ext}", + "mac": { + "icon": "icons/insider/icon.icns" + }, + "publish": { + "provider": "github", + "owner": "PowerPlatformToolBox", + "repo": "desktop-app", + "releaseType": "prerelease" + } +} diff --git a/buildScripts/electron-builder-win-arm64-insider.json b/buildScripts/electron-builder-win-arm64-insider.json new file mode 100644 index 00000000..b16282d7 --- /dev/null +++ b/buildScripts/electron-builder-win-arm64-insider.json @@ -0,0 +1,15 @@ +{ + "extends": "buildScripts/electron-builder-win-arm64.json", + "appId": "com.powerplatform.toolbox.insider", + "productName": "Power Platform ToolBox Insider", + "artifactName": "Power-Platform-ToolBox-Insider-${version}-${arch}-${os}.${ext}", + "win": { + "icon": "icons/insider/icon.ico" + }, + "publish": { + "provider": "github", + "owner": "PowerPlatformToolBox", + "repo": "desktop-app", + "releaseType": "prerelease" + } +} diff --git a/buildScripts/electron-builder-win-insider.json b/buildScripts/electron-builder-win-insider.json new file mode 100644 index 00000000..8462a93f --- /dev/null +++ b/buildScripts/electron-builder-win-insider.json @@ -0,0 +1,15 @@ +{ + "extends": "buildScripts/electron-builder-win.json", + "appId": "com.powerplatform.toolbox.insider", + "productName": "Power Platform ToolBox Insider", + "artifactName": "Power-Platform-ToolBox-Insider-${version}-${arch}-${os}.${ext}", + "win": { + "icon": "icons/insider/icon.ico" + }, + "publish": { + "provider": "github", + "owner": "PowerPlatformToolBox", + "repo": "desktop-app", + "releaseType": "prerelease" + } +} diff --git a/buildScripts/package.js b/buildScripts/package.js index e31bbcf3..dd615810 100644 --- a/buildScripts/package.js +++ b/buildScripts/package.js @@ -5,9 +5,9 @@ const os = require("os"); const fs = require("fs"); const path = require("path"); -function run(cmd) { +function run(cmd, env = {}) { console.log(`\n> ${cmd}\n`); - execSync(cmd, { stdio: "inherit" }); + execSync(cmd, { stdio: "inherit", env: { ...process.env, ...env } }); } const platform = os.platform(); @@ -17,6 +17,20 @@ const arch = os.arch(); const configArg = process.argv.find((arg) => arg.startsWith("--config=")); const configFile = configArg ? configArg.split("=")[1] : null; +// Detect insider channel flag +const isInsider = process.argv.includes("--insider"); +const channelEnv = isInsider ? { PPTB_CHANNEL: "insider" } : {}; + +if (isInsider) { + console.log("🔬 Building INSIDER channel"); +} + +// Run the Vite build (with the correct PPTB_CHANNEL injected cross-platform). +// This keeps all channel-specific env-var logic inside this script so the +// npm package:* scripts work on Windows (CMD/PowerShell) without cross-env. +console.log("⚙️ Building application..."); +run("pnpm run build", channelEnv); + if (configFile) { // Validate config file exists const configPath = path.resolve(process.cwd(), configFile); @@ -28,27 +42,47 @@ if (configFile) { // Build with specific config file console.log(`📦 Building with config: ${configFile}`); - run(`pnpm exec electron-builder --config ${configFile}`); + run(`pnpm exec electron-builder --config ${configFile}`, channelEnv); } else { - // Build with platform defaults + // Build with platform defaults, selecting insider configs when --insider is passed switch (platform) { case "darwin": // macOS console.log(`📦 Building for macOS (${arch})`); - run("pnpm exec electron-builder --config buildScripts/electron-builder-mac.json"); + run( + isInsider + ? "pnpm exec electron-builder --config buildScripts/electron-builder-mac-insider.json" + : "pnpm exec electron-builder --config buildScripts/electron-builder-mac.json", + channelEnv, + ); break; case "win32": // Windows console.log(`📦 Building for Windows (${arch})`); if (arch === "arm64") { - run("pnpm exec electron-builder --config buildScripts/electron-builder-win-arm64.json"); + run( + isInsider + ? "pnpm exec electron-builder --config buildScripts/electron-builder-win-arm64-insider.json" + : "pnpm exec electron-builder --config buildScripts/electron-builder-win-arm64.json", + channelEnv, + ); } else { - run("pnpm exec electron-builder --config buildScripts/electron-builder-win.json"); + run( + isInsider + ? "pnpm exec electron-builder --config buildScripts/electron-builder-win-insider.json" + : "pnpm exec electron-builder --config buildScripts/electron-builder-win.json", + channelEnv, + ); } break; case "linux": // Linux console.log(`📦 Building for Linux (${arch})`); - run("pnpm exec electron-builder --config buildScripts/electron-builder-linux.json"); + run( + isInsider + ? "pnpm exec electron-builder --config buildScripts/electron-builder-linux-insider.json" + : "pnpm exec electron-builder --config buildScripts/electron-builder-linux.json", + channelEnv, + ); break; default: diff --git a/docs/INTER_TOOL_INVOCATION.md b/docs/INTER_TOOL_INVOCATION.md new file mode 100644 index 00000000..f6dd64e1 --- /dev/null +++ b/docs/INTER_TOOL_INVOCATION.md @@ -0,0 +1,461 @@ +# Inter-Tool Invocation + +This document covers the **Inter-Tool Invocation** feature of Power Platform ToolBox (PPTB). It is split into two parts: + +- **[Part 1 – Callee](#part-1--callee-the-tool-that-accepts-invocations)** – For tool developers whose tool _receives_ a launch request from another tool. +- **[Part 2 – Caller](#part-2--caller-the-tool-that-launches-other-tools)** – For tool developers whose tool _initiates_ a launch request against another tool. + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Part 1 – Callee (the tool that accepts invocations)](#part-1--callee-the-tool-that-accepts-invocations) + - [1.1 Declaring the invocation contract (`pptb.config.json`)](#11-declaring-the-invocation-contract-pptbconfigjson) + - [1.2 Reading the launch context](#12-reading-the-launch-context) + - [1.3 Returning data to the caller](#13-returning-data-to-the-caller) + - [1.4 Handling standalone vs. invoked modes](#14-handling-standalone-vs-invoked-modes) + - [1.5 Complete callee example](#15-complete-callee-example) +3. [Part 2 – Caller (the tool that launches other tools)](#part-2--caller-the-tool-that-launches-other-tools) + - [2.1 Launching a tool with prefill data](#21-launching-a-tool-with-prefill-data) + - [2.2 Handling the return value](#22-handling-the-return-value) + - [2.3 Connection overrides](#23-connection-overrides) + - [2.4 Complete caller example](#24-complete-caller-example) +4. [Lifecycle and Behaviour](#lifecycle-and-behaviour) +5. [Validation and Tooling](#validation-and-tooling) +6. [Troubleshooting](#troubleshooting) + +--- + +## Overview + +Inter-Tool Invocation lets one installed PPTB tool **launch another installed tool**, pass structured data to pre-populate its state (_prefill data_), and optionally **receive a result** back when the launched tool finishes. + +``` +Tool A (Caller) Tool B (Callee) +────────────── ─────────────── +invocation.launchTool( invocation.getLaunchContext() + "@my-org/entity-picker", ─────► → { entityName: "account" } + { entityName: "account" } +) // user picks a record … + + ◄────────────────────────────── invocation.returnData( +result { selectedId: "a1b2c3", += { selectedId: "a1b2c3", selectedName: "Contoso" } + selectedName: "Contoso" } ) +``` + +Key properties of the feature: + +- **Promise-based**: `invocation.launchTool()` returns a `Promise` that resolves when the callee calls `returnData()`, or resolves to `null` if the callee closes without returning data. +- **Isolated windows**: the callee opens in its own BrowserView, just like a normally launched tool. +- **Optional contract**: the callee declares the shape of its prefill data and return value in `pptb.config.json`; this is validated by `pptb-validate` but is not enforced at runtime. +- **Graceful degradation**: both the prefill data and the return value are plain JSON objects (`Record`), so missing fields degrade gracefully. + +--- + +## Part 1 – Callee (the tool that accepts invocations) + +### 1.1 Declaring the invocation contract (`pptb.config.json`) + +Create a file named `pptb.config.json` in the **root of your tool package** (next to `package.json`). This file declares: + +| Field | Required | Description | +|-------|----------|-------------| +| `invocation.version` | **Yes** (when `invocation` is present) | Semantic version of your invocation contract (e.g. `"1.0.0"`). Bump this when the shape of `prefill` or `returnTopic` changes. | +| `invocation.prefill` | No | JSON-schema-style object describing the data a caller can pass in. | +| `invocation.prefill.properties` | No | Map of property names to `{ type?, enum?, items? }` descriptors. | +| `invocation.returnTopic` | No | JSON-schema-style object describing the data your tool returns to its caller. | +| `invocation.returnTopic.properties` | No | Map of property names to `{ type?, enum?, items? }` descriptors. | + +**Example `pptb.config.json`:** + +```json +{ + "invocation": { + "version": "1.0.0", + "prefill": { + "properties": { + "entityName": { "type": "string" }, + "allowMultiSelect": { "type": "boolean" } + } + }, + "returnTopic": { + "properties": { + "selectedId": { "type": "string" }, + "selectedName": { "type": "string" } + } + } + } +} +``` + +Supported `type` values: `"string"`, `"number"`, `"boolean"`, `"object"`, `"array"`. +Use `"enum"` to restrict a string property to a fixed set of values. +Use `"items"` to describe the element type of an array property. + +> **Tip:** Run `pptb-validate` in your tool directory to validate both `package.json` and `pptb.config.json` before publishing. + +--- + +### 1.2 Reading the launch context + +When your tool starts, call `toolboxAPI.invocation.getLaunchContext()` to find out whether it was launched by another tool and to read the prefill data the caller provided. + +```typescript +const ctx = await toolboxAPI.invocation.getLaunchContext(); + +if (ctx !== null) { + // Tool was launched via inter-tool invocation + const entityName = ctx.entityName as string; + // … use prefill data to set up your UI … +} else { + // Tool was opened normally by the user +} +``` + +**Signature:** + +```typescript +getLaunchContext(): Promise | null> +``` + +- Returns the prefill data object when the tool was invoked by another tool. +- Returns `null` when the tool was opened directly by the user (not via invocation). +- All values are `unknown`; cast or validate them before use. + +--- + +### 1.3 Returning data to the caller + +Once the user completes their task (e.g. selects a record, fills a form), call `toolboxAPI.invocation.returnData()` to send the result back to the caller tool. + +```typescript +await toolboxAPI.invocation.returnData({ + selectedId: "a1b2c3d4-...", + selectedName: "Contoso Ltd.", +}); +``` + +**Signature:** + +```typescript +returnData(returnData: Record): Promise +``` + +- Resolves the `Promise` that the caller is awaiting in `invocation.launchTool()`. +- If the tool was **not** launched by another tool, this call is a **no-op** – it is safe to call unconditionally. +- After calling `returnData`, it is your tool's responsibility to close its own window or update its UI as appropriate. + +--- + +### 1.4 Handling standalone vs. invoked modes + +A well-behaved callee works in both modes: + +| Mode | `getLaunchContext()` returns | Expected behaviour | +|------|-----------------------------|--------------------| +| Standalone (normal launch) | `null` | Show full UI, no pre-populated state | +| Invoked by another tool | `Record` | Pre-populate UI from the context, show a "confirm / return" action | + +```typescript +async function initTool() { + const ctx = await toolboxAPI.invocation.getLaunchContext(); + + if (ctx) { + // Invoked mode: pre-fill and show a compact picker UI + renderPickerUI({ + entityName: ctx.entityName as string, + allowMultiSelect: (ctx.allowMultiSelect as boolean) ?? false, + onConfirm: async (selection) => { + await toolboxAPI.invocation.returnData(selection); + }, + }); + } else { + // Standalone mode: show the full explorer UI + renderFullExplorerUI(); + } +} +``` + +--- + +### 1.5 Complete callee example + +The following is a minimal but complete callee implementation for an entity-picker tool. + +**`pptb.config.json`** + +```json +{ + "invocation": { + "version": "1.0.0", + "prefill": { + "properties": { + "entityName": { "type": "string" }, + "allowMultiSelect": { "type": "boolean" } + } + }, + "returnTopic": { + "properties": { + "selectedId": { "type": "string" }, + "selectedName": { "type": "string" } + } + } + } +} +``` + +**`index.ts`** + +```typescript +async function main() { + const ctx = await toolboxAPI.invocation.getLaunchContext(); + + if (ctx) { + // Invoked by another tool – show a targeted picker + const entityName = (ctx.entityName as string) ?? "account"; + const records = await loadRecords(entityName); + + renderPicker(records, async (selected) => { + // Send the selection back and let the caller handle closing / next steps + await toolboxAPI.invocation.returnData({ + selectedId: selected.id, + selectedName: selected.name, + }); + }); + } else { + // Standalone – show the full entity browser + renderFullBrowser(); + } +} + +main(); +``` + +--- + +## Part 2 – Caller (the tool that launches other tools) + +### 2.1 Launching a tool with prefill data + +Use `toolboxAPI.invocation.launchTool()` to open another installed tool and pass it prefill data. + +```typescript +const result = await toolboxAPI.invocation.launchTool( + "@my-org/entity-picker", // npm package name of the target tool + { entityName: "account" }, // prefill data (must match callee's prefill schema) +); +``` + +**Signature:** + +```typescript +launchTool( + targetToolId: string, + prefillData?: Record, + options?: { + primaryConnectionId?: string | null; + secondaryConnectionId?: string | null; + }, +): Promise +``` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `targetToolId` | `string` | The npm package name of the tool to launch (e.g. `"@my-org/entity-picker"`). Must be installed. | +| `prefillData` | `Record` | Optional data to pre-populate the callee's state. Shape should match the callee's `invocation.prefill` schema. | +| `options.primaryConnectionId` | `string | null` | Override the primary Dataverse connection for the callee. | +| `options.secondaryConnectionId` | `string | null` | Override the secondary Dataverse connection for the callee. | + +**Return value:** A `Promise` that resolves with the `Record` passed to `returnData()` by the callee, or `null` if the callee closes without returning data. + +> **Important:** The target tool must be **installed** in PPTB. If the tool is not found, `launchTool` throws an error. + +--- + +### 2.2 Handling the return value + +```typescript +const result = await toolboxAPI.invocation.launchTool( + "@my-org/entity-picker", + { entityName: "contact" }, +); + +if (result !== null) { + const { selectedId, selectedName } = result as { selectedId: string; selectedName: string }; + // Use the selection returned by the callee + populateField("regardingobjectid", selectedId, selectedName); +} else { + // User closed the picker without making a selection – no change needed +} +``` + +Always check for `null` before using the result. The `Promise` resolves to `null` in two scenarios: + +1. The user closes the callee tool window without calling `returnData`. +2. The callee explicitly calls `returnData({})` with an empty object (treat as cancelled if you expect specific fields). + +--- + +### 2.3 Connection overrides + +By default, the callee inherits no connection from the caller. Pass `options.primaryConnectionId` or `options.secondaryConnectionId` to forward a specific Dataverse connection: + +```typescript +const connections = await toolboxAPI.connections.getActiveConnection(); + +const result = await toolboxAPI.invocation.launchTool( + "@my-org/solution-importer", + { solutionName: "MySolution" }, + { primaryConnectionId: connections?.id ?? null }, +); +``` + +--- + +### 2.4 Complete caller example + +```typescript +async function openEntityPicker(entityName: string) { + let result: unknown; + + try { + result = await toolboxAPI.invocation.launchTool( + "@my-org/entity-picker", + { entityName, allowMultiSelect: false }, + ); + } catch (err) { + // Tool not installed or launch failed + await toolboxAPI.utils.showNotification({ + title: "Cannot open picker", + body: err instanceof Error ? err.message : String(err), + type: "error", + }); + return; + } + + if (result === null) { + // User dismissed the picker + return; + } + + const { selectedId, selectedName } = result as { selectedId: string; selectedName: string }; + setSelectedRecord(selectedId, selectedName); +} +``` + +--- + +## Lifecycle and Behaviour + +Understanding the full lifecycle helps when reasoning about edge cases: + +``` +Caller tool calls invocation.launchTool(...) + │ + ▼ +PPTB main process creates a new BrowserView for the callee + │ + ▼ +Callee loads, receives toolContext with: + • toolId, instanceId + • callerInstanceId ← present only for invocations + • prefillData ← the object passed by the caller + │ + ▼ +Callee calls getLaunchContext() → returns prefillData + │ + ▼ (user interacts with callee UI) + │ + ┌───┴────────────────────────┐ + │ │ + ▼ ▼ +Callee calls returnData(...) Callee window is closed + │ │ + ▼ ▼ +PPTB sends result to caller PPTB sends null to caller + │ │ + └───────────────┬────────────┘ + │ + ▼ + Caller's Promise resolves (returnData value OR null) +``` + +**Key points:** + +- The callee opens in its **own window** (BrowserView) and is visible as a separate tab in the PPTB tool panel. +- The caller's `launchTool()` Promise **never rejects** under normal operation – it always resolves (possibly with `null`). Rejections only occur if the target tool is not installed or if the launch itself fails due to a system error. +- It is the callee's responsibility to **close its own window** after calling `returnData`, if appropriate for the UX. PPTB does not close the callee automatically. +- A callee that never calls `returnData` will keep the caller's Promise pending until the callee window is closed by the user. + +--- + +## Validation and Tooling + +### `pptb-validate` + +Run `pptb-validate` from your tool directory to validate both `package.json` and `pptb.config.json`: + +```bash +npx pptb-validate +# or, if you have the @pptb/types package installed locally: +./node_modules/.bin/pptb-validate +``` + +The validator checks: + +- `invocation.version` is present and a valid semver string. +- `invocation.prefill.properties` values are valid JSON-schema property descriptors. +- `invocation.returnTopic.properties` values are valid JSON-schema property descriptors. + +### TypeScript types + +The `@pptb/types` package ships type definitions for the entire invocation API: + +```typescript +// The three methods live on toolboxAPI.invocation +toolboxAPI.invocation.getLaunchContext() // Promise | null> +toolboxAPI.invocation.returnData(data) // Promise +toolboxAPI.invocation.launchTool(...) // Promise +``` + +For the shape of `pptb.config.json`, import from the bundled declaration file: + +```typescript +import type { PPTBConfig, InvocationConfig } from "@pptb/types/pptbConfig"; +``` + +--- + +## Troubleshooting + +### `launchTool` throws "Tool not found" + +The target tool is not installed. Ask the user to install it from the PPTB Marketplace, or check that the `targetToolId` matches the exact npm package name (`name` field in the tool's `package.json`). + +### `getLaunchContext()` returns `null` when expecting prefill data + +The tool was opened by the user directly rather than via `launchTool`. Ensure the caller is using `toolboxAPI.invocation.launchTool()` and not the standard tool launch mechanism. + +### Caller `Promise` resolves with `null` unexpectedly + +The callee window was closed by the user (or programmatically) before `returnData()` was called. This is by design – always handle the `null` case in the caller. + +### Changes to `pptb.config.json` are not picked up + +Restart the tool or reload it in PPTB. The config file is read at install/load time; changes during development require a reload. + +### `returnData` appears to do nothing + +Confirm that `getLaunchContext()` returned a non-null value first. If `getLaunchContext()` returns `null`, `returnData` is a no-op because the tool was not launched by another tool. + +--- + +## References + +- [`packages/toolboxAPI.d.ts`](../packages/toolboxAPI.d.ts) – Full TypeScript type definitions for the invocation API (`InvocationAPI` interface) +- [`packages/pptbConfig.d.ts`](../packages/pptbConfig.d.ts) – Type definitions for `pptb.config.json` (`PPTBConfig`, `InvocationConfig`) +- [`packages/README.md`](../packages/README.md) – Developer guide for the `@pptb/types` package, including the API reference +- [`src/main/managers/toolWindowManager.ts`](../src/main/managers/toolWindowManager.ts) – Host-side implementation (`launchToolWithContext`, `resolveInvocation`) +- [`src/main/toolPreloadBridge.ts`](../src/main/toolPreloadBridge.ts) – Preload-side implementation of the `invocation` namespace diff --git a/docs/azure-trusted-signing.md b/docs/azure-trusted-signing.md index 0e620bce..82cc9c2f 100644 --- a/docs/azure-trusted-signing.md +++ b/docs/azure-trusted-signing.md @@ -25,16 +25,37 @@ This document explains how Windows artifacts generated by the production and nig ## Workflow behavior -- The jobs running on `windows-latest` in both `prod-release.yml` and `nightly-release.yml` execute the [`azure/artifact-signing-action@v1`](https://github.com/Azure/artifact-signing-action) step immediately after Electron Builder packages the installers. -- Every `.exe` and `.msi` produced under `build/` is signed in place. Portable `.zip` artifacts remain unsigned because the action does not yet support signing archives. The action handles hashing, timestamping, and certificate management by requesting a short-lived certificate from Azure Trusted Signing. -- The action fails if any secret is missing or Azure denies the signing request, which prevents unsigned installers from being uploaded or released. +The Windows signing process uses a **three-phase signing approach** to ensure every binary—both the application itself and the installer wrappers—carries a valid signature: + +### Phase 1 – Sign application binaries + +After building only the unpacked application directory (`build/win-unpacked/` for x64, `build/win-arm64-unpacked/` for arm64), the `azure/artifact-signing-action@v1` step signs all `.exe` and `.dll` files found recursively inside that directory. This includes: + +- `Power Platform ToolBox.exe` (the main application executable) +- All `.dll` files bundled with the application + +### Phase 2 – Package installers from signed binaries + +With the application binaries now signed, Electron Builder is invoked with `--prepackaged` pointing at the already-signed directory. This creates the NSIS installer (`.exe`), MSI (`.msi`), MSI-wrapped EXE (`msiWrapped`), and portable ZIP, all of which embed the **signed** application binaries. + +### Phase 3 – Sign installer packages + +A second `azure/artifact-signing-action@v1` step signs the top-level installer files in `build/` (non-recursive) so that the outer wrappers are also authenticated: + +- `Power-Platform-ToolBox-*-win.exe` (NSIS setup installer) +- `Power-Platform-ToolBox-*-win.msi` (MSI installer) +- `Power-Platform-ToolBox-*-Setup.exe` (msiWrapped EXE bootstrapper, x64 only) + +Portable `.zip` archives are not signed as packages (the action does not support archive signing), but they contain the signed application binaries from Phase 1 because the ZIP is assembled from the `--prepackaged` directory. + +The action fails if any secret is missing or Azure denies the signing request, which prevents unsigned installers from being uploaded or released. ## Testing the signing step 1. Configure the secrets above in the repository or organization settings. 2. Trigger the `Stable Release` or `Insider Pre-Release` workflow via the `workflow_dispatch` entry point (you can do this on a throwaway branch after updating the version and release notes). -3. Inspect the Windows job logs for the "Sign Windows artifacts with Azure Trusted Signing" step to verify that Azure returned a certificate and that all `.exe`/`.msi` files were processed. -4. Download the Windows artifacts and run `Get-AuthenticodeSignature` locally to validate the signature chain. +3. Inspect the Windows job logs for the "Sign application binaries with Azure Trusted Signing (Windows)" and "Sign Windows installers with Azure Trusted Signing" steps to verify that Azure returned a certificate and that all files were processed. +4. Download the Windows artifacts and run `Get-AuthenticodeSignature` locally to validate the signature chain on both the installer and the installed application executable. ## Operational guidance diff --git a/assets/icon.png b/icons/icon.png similarity index 100% rename from assets/icon.png rename to icons/icon.png diff --git a/icons/icon256x256.png b/icons/icon256x256.png deleted file mode 100644 index d8b8e54f..00000000 Binary files a/icons/icon256x256.png and /dev/null differ diff --git a/icons/insider/icon.icns b/icons/insider/icon.icns new file mode 100644 index 00000000..7d91d3d5 Binary files /dev/null and b/icons/insider/icon.icns differ diff --git a/icons/insider/icon.ico b/icons/insider/icon.ico new file mode 100644 index 00000000..df60408c Binary files /dev/null and b/icons/insider/icon.ico differ diff --git a/icons/insider/icon.png b/icons/insider/icon.png new file mode 100644 index 00000000..8f1904f5 Binary files /dev/null and b/icons/insider/icon.png differ diff --git a/package.json b/package.json index dc9b9fd0..77109911 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "powerplatform-toolbox", - "version": "1.2.1", + "version": "1.2.2", "description": "A universal desktop app that contains multiple tools to ease the customization and configuration of Power Platform", "main": "dist/main/index.js", "scripts": { @@ -11,11 +11,16 @@ "watch": "vite build --watch", "lint": "eslint src --ext .ts", "start": "electron .", - "package": "pnpm run build && node ./buildScripts/package.js", - "package:win": "pnpm run build && node ./buildScripts/package.js --config=buildScripts/electron-builder-win.json", - "package:win-arm64": "pnpm run build && node ./buildScripts/package.js --config=buildScripts/electron-builder-win-arm64.json", - "package:mac": "pnpm run build && node ./buildScripts/package.js --config=buildScripts/electron-builder-mac.json", - "package:linux": "pnpm run build && node ./buildScripts/package.js --config=buildScripts/electron-builder-linux.json", + "package": "node ./buildScripts/package.js", + "package:win": "node ./buildScripts/package.js --config=buildScripts/electron-builder-win.json", + "package:win-arm64": "node ./buildScripts/package.js --config=buildScripts/electron-builder-win-arm64.json", + "package:mac": "node ./buildScripts/package.js --config=buildScripts/electron-builder-mac.json", + "package:linux": "node ./buildScripts/package.js --config=buildScripts/electron-builder-linux.json", + "package:insider": "node ./buildScripts/package.js --insider", + "package:insider:win": "node ./buildScripts/package.js --config=buildScripts/electron-builder-win-insider.json --insider", + "package:insider:win-arm64": "node ./buildScripts/package.js --config=buildScripts/electron-builder-win-arm64-insider.json --insider", + "package:insider:mac": "node ./buildScripts/package.js --config=buildScripts/electron-builder-mac-insider.json --insider", + "package:insider:linux": "node ./buildScripts/package.js --config=buildScripts/electron-builder-linux-insider.json --insider", "version": "auto-changelog -p && git add CHANGELOG.md", "contributors:add": "all-contributors add", "contributors:generate": "all-contributors generate" diff --git a/packages/README.md b/packages/README.md index 152a18cd..5ad037ba 100644 --- a/packages/README.md +++ b/packages/README.md @@ -8,6 +8,7 @@ TypeScript type definitions for Power Platform ToolBox APIs, plus a built-in CLI - [Quick start](#quick-start) - [CLI options](#cli-options) - [What is validated](#what-is-validated) + - [pptb.config.json (optional)](#pptbconfigjson-optional) - [Overview](#overview) - [Usage](#usage) - [Include all type definitions](#include-all-type-definitions) @@ -17,6 +18,7 @@ TypeScript type definitions for Power Platform ToolBox APIs, plus a built-in CLI - [Utilities](#utilities) - [Terminal Operations](#terminal-operations) - [Events](#events) + - [Inter-Tool Invocation](#inter-tool-invocation) - [Dataverse API Examples](#dataverse-api-examples) - [CRUD Operations](#crud-operations) - [FetchXML Queries](#fetchxml-queries) @@ -29,6 +31,7 @@ TypeScript type definitions for Power Platform ToolBox APIs, plus a built-in CLI - [Utils](#utils) - [Terminal](#terminal) - [Events](#events-1) + - [Invocation](#invocation) - [Dataverse API (`window.dataverseAPI`)](#dataverse-api-windowdataverseapi) - [CRUD Operations](#crud-operations-1) - [Query Operations](#query-operations) @@ -109,6 +112,43 @@ The validator checks every field that the official review pipeline inspects: > \* Required only when the `features` object is present. +#### pptb.config.json (optional) + +In addition to `package.json`, the validator automatically checks a `pptb.config.json` file if one is present in the same directory. This file declares tool-to-tool communication contracts and other PPTB-specific metadata. + +| Field | Required | Rules | +| --------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------- | +| `invocation.version` | ✅\*\* | Must be a valid **semantic version** string (e.g. `"1.0.0"`). Tool developers own this version and bump it when the invocation contract changes. | +| `invocation.prefill` | ❌ | JSON-schema-style object describing data callers can pre-populate | +| `invocation.prefill.properties` | ❌ | Map of property names to `{ type?, enum?, items? }` descriptors | +| `invocation.returnTopic` | ❌ | JSON-schema-style object describing the data this tool returns to its caller | +| `invocation.returnTopic.properties` | ❌ | Map of property names to `{ type?, enum?, items? }` descriptors | + +> \*\* Required only when the `invocation` object is present. + +**Example `pptb.config.json`:** + +```json +{ + "invocation": { + "version": "1.0.0", + "prefill": { + "properties": { + "entityName": { "type": "string" }, + "attributes": { "type": "array", "items": { "type": "string" } } + } + }, + "returnTopic": { + "properties": { + "result": { "type": "object" }, + "status": { "type": "string", "enum": ["success", "cancelled", "error"] }, + "error": { "type": "string" } + } + } + } +} +``` + ## Overview The `@pptb/types` package provides TypeScript definitions for two main APIs: @@ -248,6 +288,67 @@ toolboxAPI.events.on((event, payload) => { const history = await toolboxAPI.events.getHistory(10); // Last 10 events ``` +### Inter-Tool Invocation + +Tools can launch one another and pass data between them using the `invocation` namespace. + +#### Caller: launching another tool with prefill data + +```typescript +// Tool A – launches the entity-picker tool and waits for a selection +const result = await toolboxAPI.invocation.launchTool( + "@my-org/entity-picker", + { entityName: "account", allowMultiSelect: false }, +); + +if (result) { + console.log("Selected record id:", (result as { selectedId: string }).selectedId); +} +``` + +#### Callee: reading prefill data and returning a result + +```typescript +// Tool B (@my-org/entity-picker) – reads the context provided by Tool A +const ctx = await toolboxAPI.invocation.getLaunchContext(); +if (ctx) { + const entityName = ctx.entityName as string; // "account" + // … show records from entityName … + + // When the user makes their selection: + await toolboxAPI.invocation.returnData({ selectedId: "a1b2c3...", selectedName: "Contoso" }); +} +``` + +> **Tip:** A tool that was *not* launched by another tool receives `null` from `getLaunchContext()`. +> Use this to show a standalone UI or redirect accordingly. + +#### Declaring your invocation contract + +Add a `pptb.config.json` alongside your `package.json` to tell callers what data you expect and return: + +```json +{ + "invocation": { + "version": "1.0.0", + "prefill": { + "properties": { + "entityName": { "type": "string" }, + "allowMultiSelect": { "type": "boolean" } + } + }, + "returnTopic": { + "properties": { + "selectedId": { "type": "string" }, + "selectedName": { "type": "string" } + } + } + } +} +``` + +Run `pptb-validate` to validate both `package.json` and `pptb.config.json` at once. + ## Dataverse API Examples The Dataverse API provides direct access to Microsoft Dataverse operations: @@ -495,6 +596,19 @@ Core platform features organized into namespaces: - **off(callback: (event: any, payload: ToolBoxEventPayload) => void)**: void - Removes a previously registered event listener +#### Invocation + +- **getLaunchContext()**: Promise\ | null\> + - Returns the prefill data passed by the tool that launched this tool, or `null` when not launched via inter-tool invocation + +- **returnData(returnData: Record\)**: Promise\ + - Sends data back to the caller tool and signals completion; no-op if not launched by another tool + +- **launchTool(targetToolId, prefillData?, options?)**: Promise\ + - Launches the specified tool, optionally with prefill data + - Returns a Promise that resolves with the data returned by the callee (or `null` if it closes without returning) + - `options.primaryConnectionId` / `options.secondaryConnectionId` – override connection for the callee + ### Dataverse API (`window.dataverseAPI`) Complete HTTP client for interacting with Microsoft Dataverse: diff --git a/packages/bin/pptb-validate.js b/packages/bin/pptb-validate.js old mode 100755 new mode 100644 index cea47887..110f447d --- a/packages/bin/pptb-validate.js +++ b/packages/bin/pptb-validate.js @@ -16,7 +16,7 @@ const fs = require("fs"); const path = require("path"); -const { validatePackageJson } = require("../lib/validate"); +const { validatePackageJson, validatePPTBConfig } = require("../lib/validate"); // ANSI colour helpers – gracefully degrade when colours are unsupported const NO_COLOR = !process.stdout.isTTY || process.env.NO_COLOR; @@ -37,7 +37,8 @@ ${c.bold("USAGE")} pptb-validate [options] [path/to/package.json] When no path is given the tool looks for ${c.cyan("package.json")} in the current - working directory. + working directory. If a ${c.cyan("pptb.config.json")} file exists in the same directory + it is automatically validated as well. ${c.bold("OPTIONS")} ${c.cyan("--skip-url-checks")} Skip URL reachability checks (faster, works offline) @@ -79,6 +80,10 @@ async function main() { packageJsonPath = path.resolve(process.cwd(), packageJsonPath); } + // Derive pptb.config.json path from the same directory as package.json + const toolDir = path.dirname(packageJsonPath); + const pptbConfigPath = path.join(toolDir, "pptb.config.json"); + // --- Load package.json --- if (!fs.existsSync(packageJsonPath)) { if (jsonOutput) { @@ -102,21 +107,35 @@ async function main() { process.exit(1); } + // --- Load pptb.config.json (optional) --- + let pptbConfig = null; + let pptbConfigParseError = null; + if (fs.existsSync(pptbConfigPath)) { + try { + pptbConfig = JSON.parse(fs.readFileSync(pptbConfigPath, "utf8")); + } catch (err) { + pptbConfigParseError = err instanceof Error ? err.message : String(err); + } + } + // --- Run validation --- if (!jsonOutput) { console.log(); console.log(c.bold("Power Platform ToolBox – Tool Validator")); console.log(c.dim("─".repeat(45))); console.log(c.dim(`File: ${packageJsonPath}`)); + if (pptbConfig !== null) { + console.log(c.dim(`Config: ${pptbConfigPath}`)); + } if (skipUrlChecks) { console.log(c.yellow("⚠ URL reachability checks are skipped")); } console.log(); } - let result; + let packageResult; try { - result = await validatePackageJson(packageJson, { skipUrlChecks }); + packageResult = await validatePackageJson(packageJson, { skipUrlChecks }); } catch (err) { const message = err instanceof Error ? err.message : String(err); if (jsonOutput) { @@ -127,27 +146,62 @@ async function main() { process.exit(1); } + // --- Validate pptb.config.json if present --- + let configResult = null; + if (pptbConfigParseError !== null) { + configResult = { valid: false, errors: [`Failed to parse pptb.config.json: ${pptbConfigParseError}`], warnings: [] }; + } else if (pptbConfig !== null) { + configResult = validatePPTBConfig(pptbConfig); + } + + // Merge results for overall pass/fail + const allErrors = [...packageResult.errors, ...(configResult ? configResult.errors : [])]; + const allWarnings = [...packageResult.warnings, ...(configResult ? configResult.warnings : [])]; + const overallValid = packageResult.valid && (configResult === null || configResult.valid); + // --- Output results --- if (jsonOutput) { - console.log(JSON.stringify(result, null, 2)); - process.exit(result.valid ? 0 : 1); + const output = { + valid: overallValid, + errors: allErrors, + warnings: allWarnings, + packageInfo: packageResult.packageInfo, + configInfo: configResult ? configResult.packageInfo : undefined, + }; + console.log(JSON.stringify(output, null, 2)); + process.exit(overallValid ? 0 : 1); } - // Human-readable output - if (result.errors.length > 0) { - console.log(c.bold(c.red(`Errors (${result.errors.length})`))); - result.errors.forEach((e) => console.log(` ${c.red("✖")} ${e}`)); + // Human-readable output – package.json section + if (packageResult.errors.length > 0) { + console.log(c.bold(c.red(`package.json Errors (${packageResult.errors.length})`))); + packageResult.errors.forEach((e) => console.log(` ${c.red("✖")} ${e}`)); console.log(); } - if (result.warnings.length > 0) { - console.log(c.bold(c.yellow(`Warnings (${result.warnings.length})`))); - result.warnings.forEach((w) => console.log(` ${c.yellow("⚠")} ${w}`)); + if (packageResult.warnings.length > 0) { + console.log(c.bold(c.yellow(`package.json Warnings (${packageResult.warnings.length})`))); + packageResult.warnings.forEach((w) => console.log(` ${c.yellow("⚠")} ${w}`)); console.log(); } - if (result.valid) { - const info = result.packageInfo; + // Human-readable output – pptb.config.json section + if (configResult !== null) { + if (configResult.errors.length > 0) { + console.log(c.bold(c.red(`pptb.config.json Errors (${configResult.errors.length})`))); + configResult.errors.forEach((e) => console.log(` ${c.red("✖")} ${e}`)); + console.log(); + } + + if (configResult.warnings.length > 0) { + console.log(c.bold(c.yellow(`pptb.config.json Warnings (${configResult.warnings.length})`))); + configResult.warnings.forEach((w) => console.log(` ${c.yellow("⚠")} ${w}`)); + console.log(); + } + } + + if (overallValid) { + const info = packageResult.packageInfo; console.log(c.green(c.bold("✔ Validation passed"))); console.log(); console.log(c.bold("Package summary")); @@ -157,13 +211,16 @@ async function main() { console.log(` Display name: ${info.displayName}`); console.log(` Description : ${info.description}`); console.log(` License : ${info.license}`); - console.log(` Contributors: ${info.contributors.map((c) => c.name).join(", ")}`); + console.log(` Contributors: ${info.contributors.map((contributor) => contributor.name).join(", ")}`); if (info.icon) { console.log(` Icon : ${info.icon}`); } if (info.features) { console.log(` Features : multiConnection=${info.features.multiConnection}${info.features.minAPI ? `, minAPI=${info.features.minAPI}` : ""}`); } + if (configResult !== null && configResult.packageInfo && configResult.packageInfo.invocation) { + console.log(` Invocation : version=${configResult.packageInfo.invocation.version}`); + } console.log(); } else { console.log(c.red(c.bold("✖ Validation failed"))); @@ -172,7 +229,7 @@ async function main() { console.log(); } - process.exit(result.valid ? 0 : 1); + process.exit(overallValid ? 0 : 1); } main().catch((err) => { diff --git a/packages/index.d.ts b/packages/index.d.ts index 2ed19414..74025a35 100644 --- a/packages/index.d.ts +++ b/packages/index.d.ts @@ -16,7 +16,9 @@ /// /// +/// // Re-export all namespaces for convenience export * from "./dataverseAPI"; export * from "./toolboxAPI"; +export * from "./pptbConfig"; diff --git a/packages/lib/validate.js b/packages/lib/validate.js index bed83327..79387e6d 100644 --- a/packages/lib/validate.js +++ b/packages/lib/validate.js @@ -33,6 +33,19 @@ * }} ValidationResult */ +/** + * @typedef {{ type?: string; enum?: string[]; items?: object }} JsonSchemaProperty + * @typedef {{ properties?: Record }} JsonSchemaObject + * @typedef {{ + * version: string; + * prefill?: JsonSchemaObject; + * returnTopic?: JsonSchemaObject; + * }} InvocationConfig + * @typedef {{ + * invocation?: InvocationConfig; + * }} PPTBConfig + */ + // List of approved open source licenses const APPROVED_LICENSES = ["MIT", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", "GPL-2.0", "GPL-3.0", "LGPL-3.0", "ISC", "AGPL-3.0-only"]; @@ -321,4 +334,110 @@ async function validatePackageJson(packageJson, options = {}) { }; } -module.exports = { validatePackageJson, isValidUrl, APPROVED_LICENSES }; +module.exports = { validatePackageJson, validatePPTBConfig, isValidUrl, APPROVED_LICENSES }; + +/** + * Validates a tool's pptb.config.json against the official review criteria. + * + * @param {PPTBConfig} config - The parsed pptb.config.json object. + * @returns {ValidationResult} + */ +function validatePPTBConfig(config) { + const errors = /** @type {string[]} */ ([]); + const warnings = /** @type {string[]} */ ([]); + + if (config === null || typeof config !== "object" || Array.isArray(config)) { + errors.push("pptb.config.json must be a JSON object"); + return { valid: false, errors, warnings }; + } + + const VALID_ROOT_KEYS = ["invocation"]; + const unknownRootKeys = Object.keys(config).filter((k) => !VALID_ROOT_KEYS.includes(k)); + if (unknownRootKeys.length > 0) { + warnings.push(`pptb.config.json contains unrecognised root keys: ${unknownRootKeys.join(", ")}`); + } + + // Invocation section + if (config.invocation !== undefined) { + const inv = config.invocation; + + if (inv === null || typeof inv !== "object" || Array.isArray(inv)) { + errors.push("invocation must be a non-array object"); + } else { + // invocation.version – required, must be a valid semver string + if (inv.version === undefined || inv.version === null) { + errors.push("invocation.version is required"); + } else if (typeof inv.version !== "string") { + errors.push("invocation.version must be a string"); + } else if (!SEMVER_REGEX.test(inv.version)) { + errors.push(`invocation.version "${inv.version}" is not a valid semantic version string (e.g. "1.0.0")`); + } + + // invocation.prefill – optional JSON-schema-like object + if (inv.prefill !== undefined) { + if (inv.prefill === null || typeof inv.prefill !== "object" || Array.isArray(inv.prefill)) { + errors.push("invocation.prefill must be a non-array object"); + } else if (inv.prefill.properties !== undefined) { + validateJsonSchemaProperties("invocation.prefill", inv.prefill.properties, errors); + } + } + + // invocation.returnTopic – optional JSON-schema-like object + if (inv.returnTopic !== undefined) { + if (inv.returnTopic === null || typeof inv.returnTopic !== "object" || Array.isArray(inv.returnTopic)) { + errors.push("invocation.returnTopic must be a non-array object"); + } else if (inv.returnTopic.properties !== undefined) { + validateJsonSchemaProperties("invocation.returnTopic", inv.returnTopic.properties, errors); + } + } + } + } + + const valid = errors.length === 0; + + return { + valid, + errors, + warnings, + packageInfo: valid + ? { + invocation: config.invocation, + } + : undefined, + }; +} + +/** + * Validates a JSON-schema-style `properties` map used inside invocation sections. + * Only performs basic structural validation; full JSON Schema validation is not required. + * + * @param {string} fieldName + * @param {unknown} properties + * @param {string[]} errors + */ +function validateJsonSchemaProperties(fieldName, properties, errors) { + if (properties === null || typeof properties !== "object" || Array.isArray(properties)) { + errors.push(`${fieldName}.properties must be a non-array object`); + return; + } + + const propsRecord = /** @type {Record} */ (properties); + + for (const [key, value] of Object.entries(propsRecord)) { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + errors.push(`${fieldName}.properties.${key} must be an object`); + continue; + } + const prop = /** @type {Record} */ (value); + if (prop.type !== undefined && typeof prop.type !== "string") { + errors.push(`${fieldName}.properties.${key}.type must be a string`); + } + if (prop.enum !== undefined) { + if (!Array.isArray(prop.enum)) { + errors.push(`${fieldName}.properties.${key}.enum must be an array`); + } else if (prop.enum.length === 0) { + errors.push(`${fieldName}.properties.${key}.enum must not be empty`); + } + } + } +} diff --git a/packages/package.json b/packages/package.json index 1d0e822c..446d86fd 100644 --- a/packages/package.json +++ b/packages/package.json @@ -1,6 +1,6 @@ { "name": "@pptb/types", - "version": "1.2.1-beta.0", + "version": "1.2.2", "description": "Type definitions for Power Platform ToolBox APIs and validity checks for tool packages", "main": "index.d.ts", "types": "index.d.ts", @@ -26,6 +26,7 @@ "index.d.ts", "toolboxAPI.d.ts", "dataverseAPI.d.ts", + "pptbConfig.d.ts", "bin/", "lib/", "README.md" @@ -36,4 +37,4 @@ "publish:stable": "pnpm publish --access public --tag latest --no-git-checks", "publish:beta": "pnpm publish --access public --tag beta --no-git-checks" } -} \ No newline at end of file +} diff --git a/packages/pptbConfig.d.ts b/packages/pptbConfig.d.ts new file mode 100644 index 00000000..ef8c85f9 --- /dev/null +++ b/packages/pptbConfig.d.ts @@ -0,0 +1,82 @@ +/** + * Type definitions for pptb.config.json – the optional configuration file that + * tools can place alongside their package.json to declare invocation contracts + * and other Power Platform ToolBox-specific metadata. + * + * Tool developers should place this file in the root of their tool package so + * that `pptb-validate` can automatically discover and validate it. + * + * Example pptb.config.json: + * ```json + * { + * "invocation": { + * "version": "1.0.0", + * "prefill": { + * "properties": { + * "entityName": { "type": "string" }, + * "attributes": { "type": "array", "items": { "type": "string" } } + * } + * }, + * "returnTopic": { + * "properties": { + * "result": { "type": "object" }, + * "status": { "type": "string", "enum": ["success", "cancelled", "error"] }, + * "error": { "type": "string" } + * } + * } + * } + * } + * ``` + */ + +/** A JSON-schema-style property descriptor used inside invocation definitions. */ +export interface JsonSchemaProperty { + /** The JSON type of the value (e.g. "string", "number", "boolean", "object", "array"). */ + type?: string; + /** Restricts the value to one of the listed literals. */ + enum?: string[]; + /** Describes the items of an array property. */ + items?: JsonSchemaProperty; +} + +/** A JSON-schema-style object definition: a map of named property descriptors. */ +export interface JsonSchemaObject { + properties?: Record; +} + +/** + * The invocation contract for a tool. + * + * - `version` controls which revision of this contract is in effect. It must + * follow **semantic versioning** (`MAJOR.MINOR.PATCH[-prerelease][+build]`). + * Tool developers own this version and should bump it whenever the shape of + * `prefill` or `returnTopic` changes in a meaningful way. + * - `prefill` describes the data that callers can pre-populate when opening + * this tool programmatically. + * - `returnTopic` describes the data this tool will resolve back to its caller + * when it finishes. + */ +export interface InvocationConfig { + /** + * Semantic version of this invocation contract (e.g. "1.0.0"). + * Tool developers control this version so they can evolve the prefill or + * return shape independently of the tool's npm package version. + */ + version: string; + /** Schema of the data the caller can pass in when invoking this tool. */ + prefill?: JsonSchemaObject; + /** Schema of the data this tool returns to its caller on completion. */ + returnTopic?: JsonSchemaObject; +} + +/** + * The shape of a tool's `pptb.config.json` file. + * + * This file lives alongside `package.json` in the tool's package root. + * All sections are optional; the file itself is optional. When present it + * is validated by `pptb-validate` in addition to `package.json`. + */ +export interface PPTBConfig { + /** Invocation contract – how this tool can be called by other tools. */ + invocation?: InvocationConfig; +} diff --git a/packages/toolboxAPI.d.ts b/packages/toolboxAPI.d.ts index 0987ca60..693568a8 100644 --- a/packages/toolboxAPI.d.ts +++ b/packages/toolboxAPI.d.ts @@ -189,17 +189,21 @@ declare namespace ToolBoxAPI { executeParallel: (...operations: Array | (() => Promise)>) => Promise; /** - * Show a loading screen in the tool's context - * @param message Optional message to display (default: "Loading...") - * @deprecated Use a tool-level loading pattern instead. Will be removed in a future version. - */ - showLoading: (message?: string) => Promise; - - /** - * Hide the loading screen in the tool's context - * @deprecated Use a tool-level loading pattern instead. Will be removed in a future version. + * Open a URL in the external browser associated with the tool's active connection. + * + * When the connection has a browser profile configured (e.g. a specific Chrome or + * Edge profile), the URL will be opened in that browser and profile so the user is + * already authenticated. Falls back to the system default browser when no profile + * is configured or the browser cannot be found. + * + * Only `https:` and `http:` URLs are allowed. + * + * @param url The URL to open (must use https: or http: protocol) + * @param connectionTarget Which connection's browser profile to use. + * Defaults to `"primary"`. Pass `"secondary"` for multi-connection tools that + * want to open the URL in the secondary connection's browser context. */ - hideLoading: () => Promise; + openInConnectionBrowser: (url: string, connectionTarget?: "primary" | "secondary") => Promise; } /** @@ -397,6 +401,14 @@ declare namespace ToolBoxAPI { */ events: EventsAPI; + /** + * Inter-tool launch context API. + * + * Allows one tool to launch another, pass prefill data to it, and receive + * a return value when the callee finishes. + */ + invocation: InvocationAPI; + /** * Get the current tool context * @internal Used internally by the framework @@ -404,6 +416,72 @@ declare namespace ToolBoxAPI { getToolContext: () => Promise; } + /** + * Inter-tool launch context API. + * + * Tools use this namespace to: + * 1. **Launch another tool** with prefill data (`invocation.launchTool`). + * 2. **Read their own launch context** when they were launched by another tool (`invocation.getLaunchContext`). + * 3. **Return data** back to the tool that launched them (`invocation.returnData`). + * + * @example Caller (Tool A) + * ```ts + * const result = await toolboxAPI.invocation.launchTool( + * "@my-org/entity-picker", + * { entityName: "account" }, + * ); + * console.log(result); // { selectedId: "...", selectedName: "..." } + * ``` + * + * @example Callee (Tool B) + * ```ts + * const ctx = await toolboxAPI.invocation.getLaunchContext(); + * if (ctx) { + * console.log(ctx.entityName); // "account" + * } + * + * // After user picks something... + * await toolboxAPI.invocation.returnData({ selectedId, selectedName }); + * ``` + */ + export interface InvocationAPI { + /** + * Returns the prefill data that was passed by the caller tool when it launched + * this tool, or `null` if this tool was not launched via an inter-tool invocation. + */ + getLaunchContext: () => Promise | null>; + + /** + * Returns data back to the caller tool that launched this tool. + * + * The value resolves the `Promise` returned by the caller's + * `invocation.launchTool()` call. After calling `returnData`, the PPTB host + * will notify the caller; it is the callee's responsibility to close itself (or + * update its UI) after the return. + * + * If this tool was not launched by another tool, the call is a no-op. + * + * @param returnData The data to pass back to the caller + */ + returnData: (returnData: Record) => Promise; + + /** + * Launch another tool from within this tool and (optionally) pass prefill data. + * + * Returns a Promise that resolves with the data the target tool sends via + * `invocation.returnData()`, or `null` if the target tool closes without + * returning any data. + * + * The target tool must be installed and its `pptb.config.json` must declare an + * `invocation.prefill` schema that matches the shape of `prefillData`. + * + * @param targetToolId The npm package name (toolId) of the tool to launch + * @param prefillData Data to pre-populate the target tool's state + * @param options Optional connection overrides for the target tool + */ + launchTool: (targetToolId: string, prefillData?: Record, options?: { primaryConnectionId?: string | null; secondaryConnectionId?: string | null }) => Promise; + } + /** * Auto-update event handlers */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7d10eaa..8f87e224 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -714,9 +714,10 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} - '@xmldom/xmldom@0.8.11': - resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} + '@xmldom/xmldom@0.8.12': + resolution: {integrity: sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} @@ -866,6 +867,9 @@ packages: brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@2.0.3: + resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -1374,11 +1378,12 @@ packages: glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-agent@3.0.0: resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} @@ -1410,8 +1415,8 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} - handlebars@4.7.8: - resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} + handlebars@4.7.9: + resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} engines: {node: '>=0.4.7'} hasBin: true @@ -1682,8 +1687,8 @@ packages: lodash.union@4.6.0: resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==} - lodash@4.17.21: - resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} lowercase-keys@2.0.0: resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} @@ -1753,6 +1758,10 @@ packages: resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} engines: {node: '>=10'} + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + minimatch@9.0.3: resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} engines: {node: '>=16 || 14 >=14.17'} @@ -2207,6 +2216,7 @@ packages: tar@6.2.1: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me temp-file@3.4.0: resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==} @@ -2675,7 +2685,7 @@ snapshots: dependencies: debug: 4.4.3 fs-extra: 9.1.0 - lodash: 4.17.21 + lodash: 4.18.1 tmp-promise: 3.0.3 transitivePeerDependencies: - supports-color @@ -3032,7 +3042,7 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@xmldom/xmldom@0.8.11': {} + '@xmldom/xmldom@0.8.12': {} acorn-jsx@5.3.2(acorn@8.15.0): dependencies: @@ -3076,7 +3086,7 @@ snapshots: didyoumean: 1.2.2 inquirer: 7.3.3 json-fixer: 1.6.15 - lodash: 4.17.21 + lodash: 4.18.1 node-fetch: 2.7.0 pify: 5.0.0 yargs: 15.4.1 @@ -3198,7 +3208,7 @@ snapshots: auto-changelog@2.5.0: dependencies: commander: 7.2.0 - handlebars: 4.7.8 + handlebars: 4.7.9 import-cwd: 3.0.0 node-fetch: 2.7.0 parse-github-url: 1.0.3 @@ -3234,6 +3244,10 @@ snapshots: dependencies: balanced-match: 1.0.2 + brace-expansion@2.0.3: + dependencies: + balanced-match: 1.0.2 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -3954,7 +3968,7 @@ snapshots: graphemer@1.4.0: {} - handlebars@4.7.8: + handlebars@4.7.9: dependencies: minimist: 1.2.8 neo-async: 2.6.2 @@ -4058,7 +4072,7 @@ snapshots: cli-width: 3.0.0 external-editor: 3.1.0 figures: 3.2.0 - lodash: 4.17.21 + lodash: 4.18.1 mute-stream: 0.0.8 run-async: 2.4.1 rxjs: 6.6.7 @@ -4225,7 +4239,7 @@ snapshots: lodash.union@4.6.0: {} - lodash@4.17.21: {} + lodash@4.18.1: {} lowercase-keys@2.0.0: {} @@ -4275,6 +4289,10 @@ snapshots: dependencies: brace-expansion: 2.0.2 + minimatch@5.1.9: + dependencies: + brace-expansion: 2.0.3 + minimatch@9.0.3: dependencies: brace-expansion: 2.0.2 @@ -4417,7 +4435,7 @@ snapshots: plist@3.1.0: dependencies: - '@xmldom/xmldom': 0.8.11 + '@xmldom/xmldom': 0.8.12 base64-js: 1.5.1 xmlbuilder: 15.1.1 @@ -4479,7 +4497,7 @@ snapshots: readdir-glob@1.1.3: dependencies: - minimatch: 5.1.6 + minimatch: 5.1.9 readdirp@4.1.2: {} diff --git a/src/common/ipc/channels.ts b/src/common/ipc/channels.ts index 0b36338c..2f83f788 100644 --- a/src/common/ipc/channels.ts +++ b/src/common/ipc/channels.ts @@ -82,6 +82,7 @@ export const TOOL_CHANNELS = { // Tool Window-related IPC channels export const TOOL_WINDOW_CHANNELS = { LAUNCH: "tool-window:launch", + LAUNCH_WITH_CONTEXT: "tool-window:launch-with-context", SWITCH: "tool-window:switch", CLOSE: "tool-window:close", GET_ACTIVE: "tool-window:get-active", @@ -89,6 +90,7 @@ export const TOOL_WINDOW_CHANNELS = { UPDATE_TOOL_CONNECTION: "tool-window:update-tool-connection", HIDE_ALL: "tool-window:hide-all", RENDERER_INITIALIZED: "tool-window:renderer-initialized", + RETURN_INVOCATION_DATA: "tool-window:return-invocation-data", } as const; // Terminal-related IPC channels @@ -108,8 +110,6 @@ export const UTIL_CHANNELS = { SHOW_CONTEXT_MENU: "show-context-menu", COPY_TO_CLIPBOARD: "copy-to-clipboard", GET_CURRENT_THEME: "get-current-theme", - SHOW_LOADING: "show-loading", - HIDE_LOADING: "hide-loading", OPEN_EXTERNAL: "open-external", GET_EVENT_HISTORY: "get-event-history", SHOW_MODAL_WINDOW: "show-modal-window", @@ -123,6 +123,7 @@ export const UTIL_CHANNELS = { CHECK_TOOL_DOWNLOAD: "check-tool-download", CHECK_INTERNET_CONNECTIVITY: "check-internet-connectivity", FETCH_FAVICON: "fetch-favicon", + OPEN_IN_CONNECTION_BROWSER: "open-in-connection-browser", } as const; // Filesystem-related IPC channels @@ -217,8 +218,6 @@ export const EVENT_CHANNELS = { CLOSE_DEVICE_CODE_DIALOG: "close-device-code-dialog", SHOW_AUTH_ERROR_DIALOG: "show-auth-error-dialog", TOKEN_EXPIRED: "token-expired", - SHOW_LOADING_SCREEN: "show-loading-screen", - HIDE_LOADING_SCREEN: "hide-loading-screen", MODAL_WINDOW_OPENED: "modal-window:opened", MODAL_WINDOW_CLOSED: "modal-window:closed", MODAL_WINDOW_MESSAGE: "modal-window:message", diff --git a/src/common/types/api.ts b/src/common/types/api.ts index a3a8f990..c0d0deb8 100644 --- a/src/common/types/api.ts +++ b/src/common/types/api.ts @@ -37,11 +37,6 @@ export interface UtilsAPI { copyToClipboard: (text: string) => Promise; getCurrentTheme: () => Promise; executeParallel: (...operations: Array | (() => Promise)>) => Promise; - // TODO: Remove showLoading and hideLoading - deprecated, use a tool-level loading pattern instead - /** @deprecated Use a tool-level loading pattern instead. Will be removed in a future version. */ - showLoading: (message?: string) => Promise; - /** @deprecated Use a tool-level loading pattern instead. Will be removed in a future version. */ - hideLoading: () => Promise; showModalWindow: (options: ModalWindowOptions) => Promise; closeModalWindow: () => Promise; sendModalMessage: (payload: ModalWindowMessagePayload) => Promise; @@ -152,6 +147,14 @@ export interface ToolboxAPI { // Tool Window Management launchToolWindow: (instanceId: string, tool: Tool, primaryConnectionId: string | null, secondaryConnectionId?: string | null) => Promise; + launchToolWithContext: ( + callerInstanceId: string, + calleeInstanceId: string, + tool: Tool, + primaryConnectionId: string | null, + secondaryConnectionId: string | null, + prefillData: Record, + ) => Promise; switchToolWindow: (toolId: string) => Promise; closeToolWindow: (toolId: string) => Promise; hideToolWindows: () => Promise; @@ -244,7 +247,20 @@ export interface ToolboxAPI { onProtocolInstallToolRequest: (callback: (params: { toolId: string; toolName: string }) => void) => void; // About dialog event - onShowAbout: (callback: (info: { appVersion: string; installId: string; locale: string; electronVersion: string; nodeVersion: string; chromeVersion: string; platform: string; arch: string; osVersion: string }) => void) => void; + onShowAbout: ( + callback: (info: { + appVersion: string; + installId: string; + locale: string; + electronVersion: string; + nodeVersion: string; + chromeVersion: string; + platform: string; + arch: string; + osVersion: string; + isInsider: boolean; + }) => void, + ) => void; // Dataverse namespace dataverse: DataverseAPI; diff --git a/src/common/types/common.ts b/src/common/types/common.ts index f0fa034b..dc030ae3 100644 --- a/src/common/types/common.ts +++ b/src/common/types/common.ts @@ -33,6 +33,11 @@ export function normalizeCspExceptionSource(source: CspExceptionSource): CspExce * CSP (Content Security Policy) exceptions for a tool * Allows tools to specify which external resources they need to access. * Each source can be a plain string (legacy) or a CspExceptionEntry object with an optional reason. + * + * Special directives: + * - `"mailto"`: Allows the tool to open `mailto:` links in the user's default email client. + * Sources should use the sentinel domain `"mailto:"` with an `exceptionReason` explaining why + * the tool needs to open email links (e.g. for pre-formatted support requests). */ export interface CspExceptions { "connect-src"?: CspExceptionSource[]; @@ -42,6 +47,8 @@ export interface CspExceptions { "font-src"?: CspExceptionSource[]; "frame-src"?: CspExceptionSource[]; "media-src"?: CspExceptionSource[]; + /** Allow the tool to open mailto: links in the user's default email client. */ + "mailto"?: CspExceptionSource[]; } /** diff --git a/src/main/index.ts b/src/main/index.ts index 688ffb36..be3157b0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -35,7 +35,6 @@ import { BrowserviewProtocolManager } from "./managers/browserviewProtocolManage import { ConnectionsManager } from "./managers/connectionsManager"; import { DataverseManager } from "./managers/dataverseManager"; import { InstallIdManager } from "./managers/installIdManager"; -import { LoadingOverlayWindowManager } from "./managers/loadingOverlayWindowManager"; import { ModalWindowManager } from "./managers/modalWindowManager"; import { NotificationWindowManager } from "./managers/notificationWindowManager"; import { ProtocolHandlerManager } from "./managers/protocolHandlerManager"; @@ -45,7 +44,9 @@ import { ToolBoxUtilityManager } from "./managers/toolboxUtilityManager"; import { ToolFileSystemAccessManager } from "./managers/toolFileSystemAccessManager"; import { ToolManager } from "./managers/toolsManager"; import { ToolWindowManager } from "./managers/toolWindowManager"; +import { TrayManager } from "./managers/trayManager"; import { VersionManager } from "./managers/versionManager"; +import { ActiveToolInfo, buildToolBoxFeedbackUrl, buildToolFeedbackUrl, getEnvironmentDiagnostics, resolveActiveToolInfo } from "./utilities"; // Constants const MENU_CREATION_DEBOUNCE_MS = 150; // Debounce delay for menu recreation during rapid tool switches @@ -54,6 +55,8 @@ const MENU_CREATION_DEBOUNCE_MS = 150; // Debounce delay for menu recreation dur const FAVICON_ALLOWED_HOSTS = new Set(["www.google.com"]); const FAVICON_ALLOWED_HOST_SUFFIXES = [".gstatic.com"]; const FAVICON_MAX_BYTES = 65536; // 64 KB — more than enough for any favicon +const OPEN_EXTERNAL_ALLOWED_PROTOCOLS = new Set(["https:", "http:", "mailto:"]); +const OPEN_IN_CONNECTION_BROWSER_ALLOWED_PROTOCOLS = new Set(["https:", "http:"]); const isFaviconAllowedHost = (hostname: string): boolean => { if (FAVICON_ALLOWED_HOSTS.has(hostname)) return true; @@ -70,8 +73,8 @@ class ToolBoxApp { private protocolHandlerManager: ProtocolHandlerManager; private toolWindowManager: ToolWindowManager | null = null; private notificationWindowManager: NotificationWindowManager | null = null; - private loadingOverlayWindowManager: LoadingOverlayWindowManager | null = null; private modalWindowManager: ModalWindowManager | null = null; + private trayManager: TrayManager | null = null; private api: ToolBoxUtilityManager; private autoUpdateManager: AutoUpdateManager; private browserManager: BrowserManager; @@ -82,6 +85,23 @@ class ToolBoxApp { private tokenExpiryCheckInterval: NodeJS.Timeout | null = null; private notifiedExpiredTokens: Set = new Set(); // Track notified expired tokens private menuCreationTimeout: NodeJS.Timeout | null = null; // Debounce timer for menu recreation + private isQuitting = false; // True once the user explicitly quits (e.g. tray "Quit" or Cmd+Q) + + /** + * Resolve the application icon for the current release channel. + * Returns the insider icon path when `PPTB_CHANNEL=insider` and the file + * exists; otherwise falls back to the standard stable icon. + */ + static resolveAppIcon(): string { + const channel = process.env.PPTB_CHANNEL ?? "stable"; + if (channel === "insider") { + const insiderPath = path.join(__dirname, "../../icons/insider/icon.png"); + if (fs.existsSync(insiderPath)) { + return insiderPath; + } + } + return path.join(__dirname, "../../icons/icon.png"); + } constructor() { logCheckpoint("ToolBoxApp constructor started"); @@ -108,6 +128,10 @@ class ToolBoxApp { this.terminalManager = new TerminalManager(); this.dataverseManager = new DataverseManager(this.connectionsManager, this.authManager); this.toolFilesystemAccessManager = new ToolFileSystemAccessManager(); + this.trayManager = new TrayManager( + () => this.mainWindow, + () => this.createWindow(), + ); this.setupEventListeners(); this.setupIpcHandlers(); @@ -315,12 +339,11 @@ class ToolBoxApp { ipcMain.removeHandler(UTIL_CHANNELS.CLOSE_MODAL_WINDOW); ipcMain.removeHandler(UTIL_CHANNELS.SEND_MODAL_MESSAGE); ipcMain.removeHandler(UTIL_CHANNELS.COPY_TO_CLIPBOARD); - ipcMain.removeHandler(UTIL_CHANNELS.SHOW_LOADING); - ipcMain.removeHandler(UTIL_CHANNELS.HIDE_LOADING); ipcMain.removeHandler(UTIL_CHANNELS.GET_CURRENT_THEME); ipcMain.removeHandler(UTIL_CHANNELS.GET_EVENT_HISTORY); ipcMain.removeHandler(UTIL_CHANNELS.FETCH_FAVICON); ipcMain.removeHandler(UTIL_CHANNELS.OPEN_EXTERNAL); + ipcMain.removeHandler(UTIL_CHANNELS.OPEN_IN_CONNECTION_BROWSER); // Filesystem handlers ipcMain.removeHandler(FILESYSTEM_CHANNELS.READ_TEXT); @@ -398,6 +421,28 @@ class ToolBoxApp { /** * Set up IPC handlers for communication with renderer */ + private parseAndValidateExternalUrl(url: unknown, allowedProtocols: Set, operation: "openExternal" | "openInConnectionBrowser"): URL | null { + if (typeof url !== "string") { + logWarn(`Blocked ${operation} call with non-string url`, { urlType: typeof url }); + return null; + } + + let parsedUrl: URL; + try { + parsedUrl = new URL(url); + } catch { + logWarn(`Blocked ${operation} call with invalid url`, { url }); + return null; + } + + if (!allowedProtocols.has(parsedUrl.protocol)) { + logWarn(`Blocked ${operation} call with disallowed protocol`, { url, protocol: parsedUrl.protocol }); + return null; + } + + return parsedUrl; + } + private setupIpcHandlers(): void { // Remove existing handlers first to prevent duplicate registration errors // This is necessary on macOS where the app doesn't quit when windows are closed @@ -775,6 +820,14 @@ class ToolBoxApp { // Check for tool updates ipcMain.handle(TOOL_CHANNELS.CHECK_TOOL_UPDATES, async (_, toolId) => { + // DEV MOCK: randomly flag ~50% of tools as having an update available + if (process.env.NODE_ENV === "development" && process.env.MOCK_UPDATES === "true") { + const hasMockUpdate = Math.random() < 0.5; + if (hasMockUpdate) { + return { hasUpdate: true, latestVersion: "99.0.0" }; + } + return { hasUpdate: false }; + } return await this.toolManager.checkForUpdates(toolId); }); @@ -1003,37 +1056,6 @@ class ToolBoxApp { this.api.copyToClipboard(text); }); - // Show loading handler (overlay window above tool panel area only) - ipcMain.handle(UTIL_CHANNELS.SHOW_LOADING, async (_, message: string) => { - if (this.loadingOverlayWindowManager && this.mainWindow) { - try { - // Get bounds from the active tool's BrowserView directly - const bounds = this.toolWindowManager?.getActiveToolBounds() || undefined; - - // Show overlay with tool panel bounds (or undefined for full window fallback) - this.loadingOverlayWindowManager.show(message || "Loading...", bounds); - } catch (error) { - // Capture bounds retrieval failure for diagnostics, then fall back to full window overlay - logError(error instanceof Error ? error : new Error(String(error))); - // On error, show without bounds (full window fallback) - this.loadingOverlayWindowManager.show(message || "Loading..."); - } - } else if (this.mainWindow) { - // Fallback to legacy in-DOM loading screen if manager not ready - this.mainWindow.webContents.send(EVENT_CHANNELS.SHOW_LOADING_SCREEN, message || "Loading..."); - } - }); - - // Hide loading handler - ipcMain.handle(UTIL_CHANNELS.HIDE_LOADING, () => { - if (this.loadingOverlayWindowManager) { - this.loadingOverlayWindowManager.hide(); - } else if (this.mainWindow) { - // Fallback legacy hide - this.mainWindow.webContents.send(EVENT_CHANNELS.HIDE_LOADING_SCREEN); - } - }); - // Get current theme handler ipcMain.handle(UTIL_CHANNELS.GET_CURRENT_THEME, () => { const settings = this.settingsManager.getUserSettings(); @@ -1185,26 +1207,40 @@ class ToolBoxApp { // Open external URL handler ipcMain.handle(UTIL_CHANNELS.OPEN_EXTERNAL, async (_, url: unknown) => { - if (typeof url !== "string") { - logWarn("Blocked openExternal call with non-string url", { urlType: typeof url }); + const parsedUrl = this.parseAndValidateExternalUrl(url, OPEN_EXTERNAL_ALLOWED_PROTOCOLS, "openExternal"); + if (!parsedUrl) { return; } - let parsedUrl: URL; - try { - parsedUrl = new URL(url); - } catch { - logWarn("Blocked openExternal call with invalid url", { url }); + await shell.openExternal(parsedUrl.toString()); + }); + + // Open URL in the browser/profile associated with the tool's connection + ipcMain.handle(UTIL_CHANNELS.OPEN_IN_CONNECTION_BROWSER, async (event, url: unknown, connectionTarget?: unknown) => { + const parsedUrl = this.parseAndValidateExternalUrl(url, OPEN_IN_CONNECTION_BROWSER_ALLOWED_PROTOCOLS, "openInConnectionBrowser"); + if (!parsedUrl) { return; } - const allowedProtocols = new Set(["https:", "http:", "mailto:"]); - if (!allowedProtocols.has(parsedUrl.protocol)) { - logWarn("Blocked openExternal call with disallowed protocol", { url, protocol: parsedUrl.protocol }); + if (connectionTarget !== undefined && connectionTarget !== "primary" && connectionTarget !== "secondary") { + logWarn("Blocked openInConnectionBrowser call with invalid connectionTarget", { connectionTarget }); return; } - await shell.openExternal(parsedUrl.toString()); + // Resolve the connection linked to the calling tool window + const isSecondary = connectionTarget === "secondary"; + const connectionId = isSecondary + ? this.toolWindowManager?.getSecondaryConnectionIdByWebContents(event.sender.id) + : this.toolWindowManager?.getConnectionIdByWebContents(event.sender.id); + + const connection = connectionId ? this.connectionsManager.getConnectionById(connectionId) : null; + + if (connection) { + await this.browserManager.openBrowserWithProfile(parsedUrl.toString(), connection); + } else { + // No connection found (e.g. called from main window or tool has no connection) — fall back to default browser + await shell.openExternal(parsedUrl.toString()); + } }); // Filesystem handlers with access control @@ -2216,9 +2252,32 @@ class ToolBoxApp { }, }, { type: "separator" }, - { role: "resetZoom" }, - { role: "zoomIn" }, - { role: "zoomOut" }, + { + label: "Actual Size", + accelerator: isMac ? "Command+0" : "Ctrl+0", + click: () => { + this.mainWindow?.webContents.setZoomLevel(0); + this.toolWindowManager?.applyZoomLevelToAllTools(0); + }, + }, + { + label: "Zoom In", + accelerator: isMac ? "Command+Plus" : "Ctrl+Plus", + click: () => { + const newLevel = (this.mainWindow?.webContents.getZoomLevel() ?? 0) + 0.5; + this.mainWindow?.webContents.setZoomLevel(newLevel); + this.toolWindowManager?.applyZoomLevelToAllTools(newLevel); + }, + }, + { + label: "Zoom Out", + accelerator: isMac ? "Command+-" : "Ctrl+-", + click: () => { + const newLevel = (this.mainWindow?.webContents.getZoomLevel() ?? 0) - 0.5; + this.mainWindow?.webContents.setZoomLevel(newLevel); + this.toolWindowManager?.applyZoomLevelToAllTools(newLevel); + }, + }, { type: "separator" }, { role: "togglefullscreen" }, ], @@ -2227,7 +2286,11 @@ class ToolBoxApp { // Window menu { label: "Window", - submenu: [{ role: "minimize" }, { role: "zoom" }, ...(isMac ? [{ type: "separator" }, { role: "front" }, { type: "separator" }, { role: "window" }] : [{ role: "close" }])], + submenu: [ + { role: "minimize" }, + { role: "zoom", label: "Maximize Window" }, + ...(isMac ? [{ type: "separator" }, { role: "front" }, { type: "separator" }, { role: "window" }] : [{ role: "close" }]), + ], }, // Help menu @@ -2274,7 +2337,7 @@ class ToolBoxApp { click: async () => { const repositoryUrl = this.toolWindowManager?.getActiveToolRepositoryUrl(); if (repositoryUrl) { - await shell.openExternal(repositoryUrl); + await shell.openExternal(buildToolFeedbackUrl(repositoryUrl, this.getActiveToolInfo())); } else { const result = await dialog.showMessageBox(this.mainWindow!, { type: "info", @@ -2315,7 +2378,7 @@ class ToolBoxApp { { label: "ToolBox Feedback", click: async () => { - await shell.openExternal("https://github.com/PowerPlatformToolBox/desktop-app"); + await shell.openExternal(buildToolBoxFeedbackUrl(this.getActiveToolInfo())); }, }, { @@ -2481,7 +2544,7 @@ class ToolBoxApp { // No longer need webviewTag - using BrowserView instead }, title: "Power Platform ToolBox", - icon: path.join(__dirname, "../../assets/icon.png"), + icon: ToolBoxApp.resolveAppIcon(), }); // Initialize ToolWindowManager for managing tool BrowserViews @@ -2502,8 +2565,6 @@ class ToolBoxApp { // Initialize NotificationWindowManager for overlay notifications this.notificationWindowManager = new NotificationWindowManager(this.mainWindow); - // Initialize LoadingOverlayWindowManager for full-screen loading spinner above BrowserViews - this.loadingOverlayWindowManager = new LoadingOverlayWindowManager(this.mainWindow); // Initialize BrowserWindow-based modal manager this.modalWindowManager = new ModalWindowManager(this.mainWindow); @@ -2530,7 +2591,6 @@ class ToolBoxApp { this.toolWindowManager?.destroy(); this.toolWindowManager = null; this.notificationWindowManager = null; - this.loadingOverlayWindowManager = null; this.modalWindowManager = null; this.mainWindow = null; }); @@ -2545,20 +2605,20 @@ class ToolBoxApp { return; } - const appVersion = app.getVersion(); + const diagnostics = getEnvironmentDiagnostics(); const installId = this.installIdManager.getInstallId(); - const locale = app.getLocale(); const payload = { - appVersion, + appVersion: diagnostics.appVersion, installId, - locale, - electronVersion: process.versions.electron, - nodeVersion: process.versions.node, - chromeVersion: process.versions.chrome, - platform: process.platform, - arch: process.arch, - osVersion: process.getSystemVersion(), + locale: diagnostics.locale, + electronVersion: diagnostics.electronVersion, + nodeVersion: diagnostics.nodeVersion, + chromeVersion: diagnostics.chromeVersion, + platform: diagnostics.platform, + arch: diagnostics.arch, + osVersion: diagnostics.osVersion, + isInsider: process.env.PPTB_CHANNEL === "insider", }; const webContents = this.mainWindow.webContents; @@ -2575,6 +2635,16 @@ class ToolBoxApp { } } + /** Resolve the currently-active tool's identity using the live manager instances. */ + private getActiveToolInfo(): ActiveToolInfo { + const instanceId = this.toolWindowManager?.getActiveToolId() ?? null; + return resolveActiveToolInfo( + instanceId, + (id) => this.toolManager.getTool(id), + (id) => this.toolManager.getInstalledManifestSync(id), + ); + } + /** * Show Troubleshooting modal * Displays a modal for diagnosing connectivity and configuration issues @@ -2608,7 +2678,6 @@ class ToolBoxApp { */ private async checkSupabaseConnectivity(): Promise<{ success: boolean; message?: string }> { try { - // Use the toolManager to check connectivity by fetching tools const tools = await this.toolManager.fetchAvailableTools(); if (tools && Array.isArray(tools)) { logInfo(`[Troubleshooting] Supabase connectivity check passed: ${tools.length} tools found`); @@ -2961,6 +3030,10 @@ class ToolBoxApp { this.createWindow(); logCheckpoint("Main window created"); + // Create the system tray icon so the app is accessible when its window is closed. + this.trayManager?.create(); + logCheckpoint("Tray icon created"); + // Set up deep link protocol handler callback after the main window exists. // The callback defers IPC delivery until the renderer has finished loading so // that protocol URLs captured during startup (buffered in pendingUrls) are @@ -3017,19 +3090,35 @@ class ToolBoxApp { this.connectionsManager.clearAllConnectionTokens(); app.on("activate", () => { - if (BrowserWindow.getAllWindows().length === 0) { + // On macOS the app stays alive after the window is closed. + // When the user clicks the Dock icon (or the tray "Open" item), + // restore the existing window if it still exists, otherwise create a new one. + if (this.mainWindow) { + if (this.mainWindow.isMinimized()) { + this.mainWindow.restore(); + } + this.mainWindow.show(); + this.mainWindow.focus(); + } else { this.createWindow(); } }); app.on("window-all-closed", () => { - if (process.platform !== "darwin") { + // On macOS the app intentionally stays alive after all windows are closed so + // background tool execution can continue. The exception is when the user + // explicitly quits (via the tray "Quit" item, Cmd+Q, or the app menu) — in + // that case `isQuitting` is already true and we allow the quit to proceed. + if (process.platform !== "darwin" || this.isQuitting) { app.quit(); } }); app.on("before-quit", () => { + this.isQuitting = true; logCheckpoint("Application shutting down"); + // Clean up tray icon before quitting + this.trayManager?.destroy(); // Clean up update checks this.autoUpdateManager.disableAutoUpdateChecks(); // Clean up token expiry checks diff --git a/src/main/managers/browserviewProtocolManager.ts b/src/main/managers/browserviewProtocolManager.ts index 3ac9c375..fc9f6ebc 100644 --- a/src/main/managers/browserviewProtocolManager.ts +++ b/src/main/managers/browserviewProtocolManager.ts @@ -284,6 +284,9 @@ export class BrowserviewProtocolManager { // Merge tool's CSP exceptions (only if consent was granted) for (const [directive, sources] of Object.entries(cspExceptions)) { + // Skip non-CSP permission directives (e.g. "mailto" is a permission flag handled + // at navigation time, not a browser CSP directive). + if (directive === "mailto") continue; if (Array.isArray(sources) && sources.length > 0) { if (!directives[directive]) { directives[directive] = ["'self'"]; diff --git a/src/main/managers/notificationWindowManager.ts b/src/main/managers/notificationWindowManager.ts index a1f87343..bcd7054c 100644 --- a/src/main/managers/notificationWindowManager.ts +++ b/src/main/managers/notificationWindowManager.ts @@ -44,7 +44,7 @@ export class NotificationWindowManager { height: this.calculateWindowHeight(), frame: false, transparent: true, - alwaysOnTop: false, + alwaysOnTop: true, skipTaskbar: true, resizable: false, movable: false, diff --git a/src/main/managers/protocolHandlerManager.ts b/src/main/managers/protocolHandlerManager.ts index 06b52677..fb0bfc8c 100644 --- a/src/main/managers/protocolHandlerManager.ts +++ b/src/main/managers/protocolHandlerManager.ts @@ -1,4 +1,4 @@ -import { app } from "electron"; +import { app, dialog } from "electron"; import { logError, logInfo, logWarn } from "../../common/logger"; /** @@ -62,6 +62,12 @@ export class ProtocolHandlerManager { return normalized === "1" || normalized === "true" || normalized === "yes"; } + // Insider (nightly) builds must not claim the OS-level pptb:// handler so they + // don't interfere with a stable installation on the same machine. + if (process.env.PPTB_CHANNEL === "insider") { + return false; + } + // Default behavior: only register/handle the OS-level pptb:// protocol when packaged. // This prevents local development runs from hijacking/claiming the protocol handler. return app.isPackaged; @@ -78,7 +84,7 @@ export class ProtocolHandlerManager { } if (!this.protocolEnabled) { - logInfo("[ProtocolHandler] Skipping pptb:// protocol registration (local/dev run)"); + logInfo("[ProtocolHandler] Skipping pptb:// protocol registration (local/dev run or insider build)"); return; } @@ -93,23 +99,29 @@ export class ProtocolHandlerManager { /** * Initialize early protocol listeners - must be called BEFORE app.whenReady(). - * Acquires the single-instance lock, registers the open-url and second-instance - * event handlers, and buffers any startup protocol URL from process.argv so that - * no deep link is lost before the main window exists. + * For stable packaged builds (protocolEnabled), acquires the single-instance lock + * to prevent duplicate stable instances and registers open-url / second-instance + * event handlers so no deep link is lost before the main window exists. + * + * Insider and dev builds skip both the single-instance lock and the protocol + * event listeners so they can run alongside a stable installation without + * interfering with it. */ initialize(): void { - // If the protocol is disabled (e.g. local/dev run), we skip all protocol-related setup to avoid any risk of - // accidentally hijacking the protocol handler on a developer's machine. + // Insider and dev builds must not acquire the single-instance lock so that + // they can run alongside a stable installation on the same machine. if (!this.protocolEnabled) { - logInfo("[ProtocolHandler] pptb:// protocol disabled for local/dev run; skipping protocol event listeners"); + logInfo("[ProtocolHandler] pptb:// protocol disabled (local/dev run or insider build); skipping single-instance lock and protocol event listeners"); return; } - // Acquire the single-instance lock as early as possible so a second launch - // forwards its command line to the first instance and then quits. + // Stable packaged builds: acquire the single-instance lock to prevent a + // second stable instance from starting and to receive protocol URLs forwarded + // via the second-instance event. const gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) { - logInfo("[ProtocolHandler] Another instance is already running, quitting this instance"); + logInfo("[ProtocolHandler] Another stable instance is already running, quitting this instance"); + dialog.showErrorBox("Power Platform ToolBox is already running", "Only one instance of Power Platform ToolBox can be open at a time.\n\nPlease switch to the existing window."); app.quit(); return; } diff --git a/src/main/managers/toolWindowManager.ts b/src/main/managers/toolWindowManager.ts index ae3067bc..f7c56d00 100644 --- a/src/main/managers/toolWindowManager.ts +++ b/src/main/managers/toolWindowManager.ts @@ -1,4 +1,4 @@ -import { BrowserView, BrowserWindow, ipcMain } from "electron"; +import { BrowserView, BrowserWindow, ipcMain, shell } from "electron"; import * as path from "path"; import { EVENT_CHANNELS, TOOL_WINDOW_CHANNELS } from "../../common/ipc/channels"; import { logError, logInfo, logWarn } from "../../common/logger"; @@ -47,6 +47,22 @@ export class ToolWindowManager { */ private toolViews: Map = new Map(); private toolConnectionInfo: Map = new Map(); // Maps instanceId -> connection info + /** + * Pending invocation contexts – created when one tool launches another with prefill data. + * The entry is keyed by the *callee* instanceId and holds: + * - the prefill data passed by the caller + * - the caller's instanceId so we can forward the return value + * - resolve/reject callbacks for the Promise returned to the caller tool + */ + private pendingInvocations: Map< + string, // calleeInstanceId + { + callerInstanceId: string; + prefillData: Record; + resolve: (data: unknown) => void; + reject: (reason: unknown) => void; + } + > = new Map(); // NOTE: Despite the name, this stores the active tool *instanceId* (not the toolId). // The property name is retained for backward compatibility; prefer `instanceId` terminology elsewhere. private activeToolId: string | null = null; @@ -80,11 +96,16 @@ export class ToolWindowManager { this.boundsResponseListener = (event, bounds) => { if (bounds && bounds.width > 0 && bounds.height > 0) { + // getBoundingClientRect() returns CSS pixels. When the main window is + // zoomed via setZoomLevel(), the CSS viewport shrinks so reported + // values are smaller than the logical pixels that setBounds() needs. + // Multiply by the current zoom factor to convert CSS px → logical px. + const zoomFactor = this.mainWindow.webContents.getZoomFactor(); this.applyToolViewBounds({ - x: Math.round(bounds.x), - y: Math.round(bounds.y), - width: Math.round(bounds.width), - height: Math.round(bounds.height), + x: Math.round(bounds.x * zoomFactor), + y: Math.round(bounds.y * zoomFactor), + width: Math.round(bounds.width * zoomFactor), + height: Math.round(bounds.height * zoomFactor), }); } else { this.boundsUpdatePending = false; @@ -121,12 +142,14 @@ export class ToolWindowManager { */ private removeIpcHandlers(): void { ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.LAUNCH); + ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.LAUNCH_WITH_CONTEXT); ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.SWITCH); ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.CLOSE); ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.GET_ACTIVE); ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.GET_OPEN_TOOLS); ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.UPDATE_TOOL_CONNECTION); ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.HIDE_ALL); + ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.RETURN_INVOCATION_DATA); } /** @@ -143,6 +166,29 @@ export class ToolWindowManager { return this.launchTool(instanceId, tool, primaryConnectionId, secondaryConnectionId); }); + // Launch a tool with inter-tool context (called by a tool's preload bridge) + // The caller passes its own instanceId, the target tool, connection IDs, and prefill data. + // Returns a Promise that resolves when the callee calls returnInvocationData. + ipcMain.handle( + TOOL_WINDOW_CHANNELS.LAUNCH_WITH_CONTEXT, + async ( + event, + callerInstanceId: string, + calleeInstanceId: string, + tool: Tool, + primaryConnectionId: string | null, + secondaryConnectionId: string | null, + prefillData: Record, + ) => { + return this.launchToolWithContext(callerInstanceId, calleeInstanceId, tool, primaryConnectionId, secondaryConnectionId, prefillData); + }, + ); + + // Handle data returned by a callee tool back to its caller + ipcMain.handle(TOOL_WINDOW_CHANNELS.RETURN_INVOCATION_DATA, async (event, calleeInstanceId: string, returnData: unknown) => { + return this.resolveInvocation(calleeInstanceId, returnData); + }); + // Switch to a different tool ipcMain.handle(TOOL_WINDOW_CHANNELS.SWITCH, async (event, instanceId: string) => { return this.switchToTool(instanceId); @@ -213,7 +259,7 @@ export class ToolWindowManager { * @param primaryConnectionId Primary connection ID for this instance (passed from frontend) * @param secondaryConnectionId Secondary connection ID for multi-connection tools (optional) */ - async launchTool(instanceId: string, tool: Tool, primaryConnectionId: string | null, secondaryConnectionId: string | null = null): Promise { + async launchTool(instanceId: string, tool: Tool, primaryConnectionId: string | null, secondaryConnectionId: string | null = null, prefillData?: Record): Promise { try { logInfo(`[ToolWindowManager] Launching tool instance: ${instanceId}`); @@ -247,9 +293,43 @@ export class ToolWindowManager { const toolUrl = this.browserviewProtocolManager.buildToolUrl(toolId); logInfo(`[ToolWindowManager] Loading tool from: ${toolUrl}`); + // Register event handlers BEFORE loading the tool URL so they are active + // from the very first navigation onward. + + // Intercept mailto: navigation attempts from the tool. + // Electron BrowserViews do not open mailto: links automatically; we must handle them here. + // Only open the link if the user has previously granted mailto consent for this tool. + toolView.webContents.on("will-navigate", (event, url) => { + if (url.length >= 7 && url.slice(0, 7).toLowerCase() === "mailto:") { + event.preventDefault(); + if (this.toolHasMailtoConsent(toolId)) { + this.openMailtoLink(url); + } else { + logWarn("[ToolWindowManager] Blocked mailto: navigation — tool has no mailto consent", { toolId }); + } + } + }); + + // Deny all new-window requests from tools. + // Handle mailto: links with a consent check (similar to the will-navigate handler above, + // but for window.open() calls rather than anchor-tag navigation). + toolView.webContents.setWindowOpenHandler(({ url }) => { + if (url.length >= 7 && url.slice(0, 7).toLowerCase() === "mailto:") { + if (this.toolHasMailtoConsent(toolId)) { + this.openMailtoLink(url); + } else { + logWarn("[ToolWindowManager] Blocked mailto: window.open — tool has no mailto consent", { toolId }); + } + } + return { action: "deny" }; + }); + // Load the tool await toolView.webContents.loadURL(toolUrl); + // Apply current zoom level so the new tool matches the main window zoom + toolView.webContents.setZoomLevel(this.mainWindow.webContents.getZoomLevel()); + // Store the view with instanceId as key this.toolViews.set(instanceId, toolView); @@ -295,6 +375,7 @@ export class ToolWindowManager { // Send tool context immediately (don't wait for did-finish-load) // The preload script will receive this before the tool code runs + const pending = this.pendingInvocations.get(instanceId); const toolContext = { toolId: tool.id, instanceId, @@ -304,6 +385,14 @@ export class ToolWindowManager { connectionId: primaryConnectionId, secondaryConnectionUrl: secondaryConnectionUrl, secondaryConnectionId: secondaryConnectionId, + // Inter-tool launch context (only present when launched by another tool) + ...(pending + ? { + callerInstanceId: pending.callerInstanceId, + prefillData: pending.prefillData, + } + : {}), + ...(prefillData && !pending ? { prefillData } : {}), }; toolView.webContents.send("toolbox:context", toolContext); logInfo(`[ToolWindowManager] Sent tool context for ${instanceId} with connection: ${connectionUrl ? "yes" : "no"}, secondary: ${secondaryConnectionUrl ? "yes" : "no"}`); @@ -339,9 +428,80 @@ export class ToolWindowManager { } /** - * Switch to a different tool (show its BrowserView) - * @param instanceId The instance identifier to switch to + * Launch a tool with inter-tool invocation context. + * + * Called when Tool A wants to launch Tool B with prefill data and (optionally) receive + * a return value when Tool B calls returnInvocationData(). + * + * @param callerInstanceId The instanceId of the tool initiating the launch + * @param calleeInstanceId The instanceId to use for the new tool window + * @param tool The tool manifest to launch + * @param primaryConnectionId Primary connection for the callee + * @param secondaryConnectionId Secondary connection for the callee (optional) + * @param prefillData Arbitrary data to pre-populate the callee's state + * @returns A Promise that resolves with the data returned by the callee, or null if the callee closes without returning data */ + async launchToolWithContext( + callerInstanceId: string, + calleeInstanceId: string, + tool: Tool, + primaryConnectionId: string | null, + secondaryConnectionId: string | null, + prefillData: Record, + ): Promise { + return new Promise((resolve, reject) => { + this.pendingInvocations.set(calleeInstanceId, { + callerInstanceId, + prefillData, + resolve, + reject, + }); + + this.launchTool(calleeInstanceId, tool, primaryConnectionId, secondaryConnectionId, prefillData) + .then((launched) => { + if (!launched) { + this.pendingInvocations.delete(calleeInstanceId); + reject(new Error(`Failed to launch tool instance ${calleeInstanceId}`)); + } + }) + .catch((error) => { + this.pendingInvocations.delete(calleeInstanceId); + reject(error as Error); + }); + }); + } + + /** + * Called by the callee tool's preload bridge when it is ready to return data to its caller. + * + * Resolves the pending Promise created in launchToolWithContext and notifies the + * caller tool via IPC so it can continue its workflow. + * + * @param calleeInstanceId The instanceId of the tool returning data + * @param returnData The data to hand back to the caller + */ + resolveInvocation(calleeInstanceId: string, returnData: unknown): void { + const pending = this.pendingInvocations.get(calleeInstanceId); + if (!pending) { + logWarn(`[ToolWindowManager] resolveInvocation: no pending invocation for ${calleeInstanceId}`); + return; + } + + this.pendingInvocations.delete(calleeInstanceId); + + // Notify the caller tool (if it is still open) via an IPC push + const callerView = this.toolViews.get(pending.callerInstanceId); + if (callerView && !callerView.webContents.isDestroyed()) { + callerView.webContents.send("toolbox:invocation-result", { + calleeInstanceId, + returnData, + }); + } + + // Resolve the JS Promise held by launchToolWithContext + pending.resolve(returnData); + } + async switchToTool(instanceId: string): Promise { try { const toolView = this.toolViews.get(instanceId); @@ -409,6 +569,23 @@ export class ToolWindowManager { this.toolViews.delete(instanceId); this.toolConnectionInfo.delete(instanceId); + // If the tool was launched by another tool (inter-tool invocation) and it closes + // without calling returnData, resolve the caller's Promise with null so the caller + // doesn't hang indefinitely. + const pending = this.pendingInvocations.get(instanceId); + if (pending) { + this.pendingInvocations.delete(instanceId); + // Notify the caller view (if still alive) + const callerView = this.toolViews.get(pending.callerInstanceId); + if (callerView && !callerView.webContents.isDestroyed()) { + callerView.webContents.send("toolbox:invocation-result", { + calleeInstanceId: instanceId, + returnData: null, + }); + } + pending.resolve(null); + } + // Dispose any terminals created by this tool instance this.terminalManager.closeToolInstanceTerminals(instanceId); @@ -490,6 +667,67 @@ export class ToolWindowManager { return instanceId.split("-").slice(0, -2).join("-"); } + /** + * Check whether a tool has been granted mailto consent. + * The sentinel domain "mailto:" must appear in the tool's stored required or optional consent domains. + */ + private toolHasMailtoConsent(toolId: string): boolean { + const approvedRequired = this.settingsManager.getApprovedRequiredDomains(toolId); + const approvedOptional = this.settingsManager.getApprovedOptionalDomains(toolId); + return approvedRequired.includes("mailto:") || approvedOptional.includes("mailto:"); + } + + /** + * Safely open a mailto: URL in the user's default email client. + * Validates the URL scheme and enforces a maximum length to prevent abuse. + */ + private openMailtoLink(url: string): void { + // Enforce a maximum URL length to prevent abuse with overly long mailto strings. + const MAX_MAILTO_LENGTH = 2000; + if (url.length > MAX_MAILTO_LENGTH) { + logWarn("[ToolWindowManager] Blocked mailto: link — URL exceeds maximum allowed length", { length: url.length }); + return; + } + + // Re-verify the scheme via URL parsing to guard against scheme-confusion attacks. + let parsed: URL; + try { + parsed = new URL(url); + } catch { + // At this point we know the URL starts with "mailto:" (checked by the caller) + // but URL parsing still failed (e.g. malformed recipient). Log the scheme only — not + // the full URL — to avoid capturing email addresses or body text as PII. + logWarn("[ToolWindowManager] Blocked mailto: link — URL failed to parse (scheme: mailto:)"); + return; + } + + if (parsed.protocol !== "mailto:") { + logWarn("[ToolWindowManager] Blocked link — unexpected protocol after mailto: check", { protocol: parsed.protocol }); + return; + } + + shell.openExternal(url).catch((err) => { + logError("[ToolWindowManager] Failed to open mailto link", err); + }); + } + + /** + * Apply the given zoom level to every open tool BrowserView. + * Called from the View menu zoom handlers so that all tool windows stay + * in sync with the main window zoom level. + * @param zoomLevel Electron zoom level (0 = 100%, 1 ≈ 120%, -1 ≈ 83%) + */ + applyZoomLevelToAllTools(zoomLevel: number): void { + for (const [, toolView] of this.toolViews) { + if (!toolView.webContents.isDestroyed()) { + toolView.webContents.setZoomLevel(zoomLevel); + } + } + // Re-query the renderer for tool panel bounds after zoom so the + // BrowserView is correctly positioned in the new CSS coordinate space. + this.scheduleBoundsUpdate(); + } + /** * Update the bounds of the active tool view to match the tool panel area * Bounds are calculated dynamically based on actual DOM element positions @@ -707,11 +945,13 @@ export class ToolWindowManager { */ destroy(): void { ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.LAUNCH); + ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.LAUNCH_WITH_CONTEXT); ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.SWITCH); ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.CLOSE); ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.GET_ACTIVE); ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.GET_OPEN_TOOLS); ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.UPDATE_TOOL_CONNECTION); + ipcMain.removeHandler(TOOL_WINDOW_CHANNELS.RETURN_INVOCATION_DATA); if (this.boundsResponseListener) ipcMain.removeListener("get-tool-panel-bounds-response", this.boundsResponseListener); if (this.terminalVisibilityListener) ipcMain.removeListener("terminal-visibility-changed", this.terminalVisibilityListener); diff --git a/src/main/managers/trayManager.ts b/src/main/managers/trayManager.ts new file mode 100644 index 00000000..e1a88868 --- /dev/null +++ b/src/main/managers/trayManager.ts @@ -0,0 +1,137 @@ +import { app, BrowserWindow, Menu, nativeImage, Tray } from "electron"; +import { existsSync } from "fs"; +import * as path from "path"; + +/** + * TrayManager + * + * Manages the system tray icon so the application remains accessible when its + * main window is closed. This is particularly important on macOS, where + * `window-all-closed` does not trigger `app.quit()` — the app intentionally + * stays alive to support background tool execution. Without a tray icon, + * users have no visible affordance that the app is still running. + * + * The tray icon provides: + * - A visual indicator that the app is running in the background + * - A context menu with "Open Power Platform ToolBox" and "Quit" actions + * - Double-click (macOS) / single-click (Windows/Linux) to restore the window + */ +export class TrayManager { + private tray: Tray | null = null; + private readonly getMainWindow: () => BrowserWindow | null; + private readonly openMainWindow: () => void; + + constructor(getMainWindow: () => BrowserWindow | null, openMainWindow: () => void) { + this.getMainWindow = getMainWindow; + this.openMainWindow = openMainWindow; + } + + /** + * Create and show the tray icon. Safe to call multiple times — subsequent + * calls are no-ops if the tray is already active. + */ + create(): void { + if (this.tray) { + return; + } + + const iconPath = this.resolveIconPath(); + let icon = nativeImage.createFromPath(iconPath); + + // Tray icons should be 16×16 (standard) or 22×22 pixels. + icon = icon.resize({ width: 16, height: 16 }); + + // On macOS, marking the image as a template lets the OS automatically + // adapt the icon color for both light and dark menu bars. + if (process.platform === "darwin") { + icon.setTemplateImage(true); + } + + this.tray = new Tray(icon); + this.tray.setToolTip(this.appName); + + this.updateContextMenu(); + + // macOS: double-click restores the window (single-click opens the context menu). + // Windows / Linux: single-click also restores the window. + if (process.platform === "darwin") { + this.tray.on("double-click", () => { + this.showMainWindow(); + }); + } else { + this.tray.on("click", () => { + this.showMainWindow(); + }); + } + } + + /** + * Destroy the tray icon. Should be called just before the application quits + * so the icon is removed from the system tray immediately. + */ + destroy(): void { + if (this.tray) { + this.tray.destroy(); + this.tray = null; + } + } + + /** + * Resolve the correct icon path for the current release channel. + * Uses `icons/insider/icon.png` for insider builds; falls back to + * `icons/icon.png` if the insider icon has not yet been placed. + */ + private resolveIconPath(): string { + const channel = process.env.PPTB_CHANNEL ?? "stable"; + if (channel === "insider") { + const insiderPath = path.join(__dirname, "../../icons/insider/icon.png"); + if (existsSync(insiderPath)) { + return insiderPath; + } + } + return path.join(__dirname, "../../icons/icon.png"); + } + + /** + * Returns the human-readable application name for the current channel. + */ + private get appName(): string { + return process.env.PPTB_CHANNEL === "insider" ? "Power Platform ToolBox Insider" : "Power Platform ToolBox"; + } + + // ─── Private helpers ────────────────────────────────────────────────────── + + private showMainWindow(): void { + const mainWindow = this.getMainWindow(); + if (mainWindow) { + if (mainWindow.isMinimized()) { + mainWindow.restore(); + } + mainWindow.show(); + mainWindow.focus(); + } else { + // Window was fully closed — re-create it. + this.openMainWindow(); + } + } + + private updateContextMenu(): void { + if (!this.tray) { + return; + } + + const contextMenu = Menu.buildFromTemplate([ + { + label: `Open ${this.appName}`, + click: () => this.showMainWindow(), + }, + { type: "separator" }, + { + label: "Quit", + click: () => app.quit(), + }, + ]); + + this.tray.setContextMenu(contextMenu); + } +} diff --git a/src/main/preload.ts b/src/main/preload.ts index 3d48c16e..0d383fa7 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -54,6 +54,14 @@ contextBridge.exposeInMainWorld("toolboxAPI", { // Tool Window Management (NEW - BrowserView based) launchToolWindow: (instanceId: string, tool: unknown, primaryConnectionId: string | null, secondaryConnectionId?: string | null) => ipcRenderer.invoke(TOOL_WINDOW_CHANNELS.LAUNCH, instanceId, tool, primaryConnectionId, secondaryConnectionId), + launchToolWithContext: ( + callerInstanceId: string, + calleeInstanceId: string, + tool: unknown, + primaryConnectionId: string | null, + secondaryConnectionId: string | null, + prefillData: Record, + ) => ipcRenderer.invoke(TOOL_WINDOW_CHANNELS.LAUNCH_WITH_CONTEXT, callerInstanceId, calleeInstanceId, tool, primaryConnectionId, secondaryConnectionId, prefillData), switchToolWindow: (instanceId: string) => ipcRenderer.invoke(TOOL_WINDOW_CHANNELS.SWITCH, instanceId), closeToolWindow: (instanceId: string) => ipcRenderer.invoke(TOOL_WINDOW_CHANNELS.CLOSE, instanceId), hideToolWindows: () => ipcRenderer.invoke(TOOL_WINDOW_CHANNELS.HIDE_ALL), @@ -124,11 +132,6 @@ contextBridge.exposeInMainWorld("toolboxAPI", { const promises = operations.map((op) => (typeof op === "function" ? op() : op)); return Promise.all(promises); }, - // TODO: Remove showLoading and hideLoading - deprecated - /** @deprecated */ - showLoading: (message?: string) => ipcRenderer.invoke(UTIL_CHANNELS.SHOW_LOADING, message), - /** @deprecated */ - hideLoading: () => ipcRenderer.invoke(UTIL_CHANNELS.HIDE_LOADING), showModalWindow: (options: unknown) => ipcRenderer.invoke(UTIL_CHANNELS.SHOW_MODAL_WINDOW, options), closeModalWindow: () => ipcRenderer.invoke(UTIL_CHANNELS.CLOSE_MODAL_WINDOW), sendModalMessage: (payload: unknown) => ipcRenderer.invoke(UTIL_CHANNELS.SEND_MODAL_MESSAGE, payload), @@ -251,7 +254,20 @@ contextBridge.exposeInMainWorld("toolboxAPI", { }, // About dialog event - onShowAbout: (callback: (info: { appVersion: string; installId: string; locale: string; electronVersion: string; nodeVersion: string; chromeVersion: string; platform: string; arch: string; osVersion: string }) => void) => { + onShowAbout: ( + callback: (info: { + appVersion: string; + installId: string; + locale: string; + electronVersion: string; + nodeVersion: string; + chromeVersion: string; + platform: string; + arch: string; + osVersion: string; + isInsider: boolean; + }) => void, + ) => { ipcRenderer.on(EVENT_CHANNELS.SHOW_ABOUT, (_, info) => callback(info)); }, diff --git a/src/main/toolPreloadBridge.ts b/src/main/toolPreloadBridge.ts index 0bdba1b8..ca665665 100644 --- a/src/main/toolPreloadBridge.ts +++ b/src/main/toolPreloadBridge.ts @@ -11,7 +11,17 @@ import { contextBridge, ipcRenderer } from "electron"; // Reverted to importing centralized channel definitions from single source file. // Ensure BrowserView preload can resolve this module (see ToolWindowManager sandbox setting). -import { CONNECTION_CHANNELS, DATAVERSE_CHANNELS, EVENT_CHANNELS, FILESYSTEM_CHANNELS, SETTINGS_CHANNELS, TERMINAL_CHANNELS, UTIL_CHANNELS } from "../common/ipc/channels"; +import { + CONNECTION_CHANNELS, + DATAVERSE_CHANNELS, + EVENT_CHANNELS, + FILESYSTEM_CHANNELS, + SETTINGS_CHANNELS, + TERMINAL_CHANNELS, + TOOL_CHANNELS, + TOOL_WINDOW_CHANNELS, + UTIL_CHANNELS, +} from "../common/ipc/channels"; import { logInfo } from "../common/logger"; import type { EntityRelatedMetadataPath, EntityRelatedMetadataResponse } from "../common/types"; @@ -256,14 +266,9 @@ contextBridge.exposeInMainWorld("toolboxAPI", { // Utils API utils: { showNotification: (options: Record) => ipcInvoke(UTIL_CHANNELS.SHOW_NOTIFICATION, options), - openExternal: (url: string) => ipcInvoke(UTIL_CHANNELS.OPEN_EXTERNAL, url), + openInConnectionBrowser: (url: string, connectionTarget?: "primary" | "secondary") => ipcInvoke(UTIL_CHANNELS.OPEN_IN_CONNECTION_BROWSER, url, connectionTarget), copyToClipboard: (text: string) => ipcInvoke(UTIL_CHANNELS.COPY_TO_CLIPBOARD, text), getCurrentTheme: () => ipcInvoke(UTIL_CHANNELS.GET_CURRENT_THEME), - // TODO: Remove showLoading and hideLoading - deprecated - /** @deprecated */ - showLoading: (message?: string) => ipcInvoke(UTIL_CHANNELS.SHOW_LOADING, message), - /** @deprecated */ - hideLoading: () => ipcInvoke(UTIL_CHANNELS.HIDE_LOADING), executeParallel: async (...operations: Array | (() => Promise)>) => { const promises = operations.map((op) => (typeof op === "function" ? op() : op)); return Promise.all(promises); @@ -331,6 +336,77 @@ contextBridge.exposeInMainWorld("toolboxAPI", { return ipcInvoke(SETTINGS_CHANNELS.TOOL_SETTINGS_SET_ALL, toolId, settings); }, }, + + // Invocation API (inter-tool launch context) + invocation: { + /** + * Returns the prefill data that was passed by the caller tool when it launched this tool, + * or null if this tool was not launched via an inter-tool invocation. + */ + getLaunchContext: async (): Promise | null> => { + await withTimeout(toolContextReady, TOOL_CONTEXT_TIMEOUT_MS, TOOL_CONTEXT_TIMEOUT_ERROR); + if (!toolContext) { + return null; + } + const prefillData = toolContext.prefillData; + if (prefillData === null || prefillData === undefined || typeof prefillData !== "object" || Array.isArray(prefillData)) { + return null; + } + return prefillData as Record; + }, + + /** + * Returns data to the caller tool that launched this tool. + * If this tool was not launched by another tool the call is a no-op. + * + * @param returnData The data to pass back to the caller + */ + returnData: async (returnData: Record): Promise => { + await withTimeout(toolContextReady, TOOL_CONTEXT_TIMEOUT_MS, TOOL_CONTEXT_TIMEOUT_ERROR); + if (!toolContext || typeof toolContext.callerInstanceId !== "string") { + return; + } + const { instanceId } = await getToolIdentifiers(); + if (!instanceId) { + return; + } + await ipcInvoke(TOOL_WINDOW_CHANNELS.RETURN_INVOCATION_DATA, instanceId, returnData); + }, + + /** + * Launch another tool from within this tool, optionally passing prefill data. + * Returns a Promise that resolves with the data the target tool returns via returnData(), + * or null if the target tool closes without returning data. + * + * @param targetToolId The npm package name (toolId) of the tool to launch + * @param prefillData Data to pre-populate the target tool's state + * @param options Connection overrides for the target tool + */ + launchTool: async ( + targetToolId: string, + prefillData: Record = {}, + options?: { primaryConnectionId?: string | null; secondaryConnectionId?: string | null }, + ): Promise => { + const { instanceId: callerInstanceId } = await getToolIdentifiers(); + if (!callerInstanceId) { + throw new Error("Cannot launch a tool from an uninitialized tool context"); + } + + // Get the target tool manifest + const tool = await ipcInvoke(TOOL_CHANNELS.GET_TOOL, targetToolId); + if (!tool) { + throw new Error(`Tool not found: ${targetToolId}`); + } + + // Generate a unique instanceId for the callee (mirrors the pattern used in the renderer) + const calleeInstanceId = `${targetToolId}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + + const primaryConnectionId = options?.primaryConnectionId !== undefined ? options.primaryConnectionId : null; + const secondaryConnectionId = options?.secondaryConnectionId !== undefined ? options.secondaryConnectionId : null; + + return ipcInvoke(TOOL_WINDOW_CHANNELS.LAUNCH_WITH_CONTEXT, callerInstanceId, calleeInstanceId, tool, primaryConnectionId, secondaryConnectionId, prefillData); + }, + }, }); // Also expose dataverseAPI as a direct alias (for tools that use it directly) diff --git a/src/main/utilities/feedback.ts b/src/main/utilities/feedback.ts new file mode 100644 index 00000000..23b0e48a --- /dev/null +++ b/src/main/utilities/feedback.ts @@ -0,0 +1,173 @@ +/** + * Feedback and environment diagnostics utility functions. + * + * These utilities are intentionally free of class dependencies so they can be + * called from any part of the main process (menu builder, IPC handlers, etc.). + */ + +import { app } from "electron"; +import { logError } from "../../common/logger"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface EnvironmentDiagnostics { + appVersion: string; + channel: string; + locale: string; + electronVersion: string; + nodeVersion: string; + chromeVersion: string; + platform: string; + arch: string; + osVersion: string; +} + +/** Resolved information about the currently-active tool instance. */ +export interface ActiveToolInfo { + instanceId: string | null; + toolId: string; + toolName: string; + toolVersion: string; +} + +// --------------------------------------------------------------------------- +// Environment diagnostics +// --------------------------------------------------------------------------- + +export function getEnvironmentDiagnostics(): EnvironmentDiagnostics { + return { + appVersion: app.getVersion(), + channel: process.env.PPTB_CHANNEL ?? "stable", + locale: app.getLocale(), + electronVersion: process.versions.electron, + nodeVersion: process.versions.node, + chromeVersion: process.versions.chrome, + platform: process.platform, + arch: process.arch, + osVersion: process.getSystemVersion(), + }; +} + +export function buildEnvironmentSummaryLines(extraLines: string[] = []): string[] { + const d = getEnvironmentDiagnostics(); + + return [ + `PPTB Version: ${d.appVersion}`, + `Channel: ${d.channel}`, + `Platform: ${d.platform}`, + `Architecture: ${d.arch}`, + `OS Version: ${d.osVersion}`, + `Locale: ${d.locale}`, + `Electron: ${d.electronVersion}`, + `Node: ${d.nodeVersion}`, + `Chrome: ${d.chromeVersion}`, + ...extraLines, + ]; +} + +// --------------------------------------------------------------------------- +// Active tool info +// --------------------------------------------------------------------------- + +/** + * Derive the active tool's identity from a raw tool-window instance ID. + * + * Instance IDs follow the pattern `--` (two trailing + * UUID-like segments appended at window creation time). The last two + * dash-separated segments are stripped to recover the base tool ID. + * + * Accepts lightweight getter callbacks so this function has no direct + * dependency on ToolManager or ToolWindowManager. + */ +export function resolveActiveToolInfo( + activeInstanceId: string | null, + getTool: (toolId: string) => { name?: string; version?: string } | undefined, + getInstalledManifestSync: (toolId: string) => { name?: string; version?: string } | null, +): ActiveToolInfo { + if (!activeInstanceId) { + return { instanceId: null, toolId: "none", toolName: "none", toolVersion: "none" }; + } + + const parsedToolId = activeInstanceId.split("-").slice(0, -2).join("-"); + const toolId = parsedToolId || "unknown"; + + const activeTool = getTool(toolId); + const installedManifest = activeTool ? null : getInstalledManifestSync(toolId); + const toolName = activeTool?.name ?? installedManifest?.name ?? toolId; + const toolVersion = activeTool?.version ?? installedManifest?.version ?? "unknown"; + + return { instanceId: activeInstanceId, toolId, toolName, toolVersion }; +} + +// --------------------------------------------------------------------------- +// Feedback URL builders +// --------------------------------------------------------------------------- + +/** + * Build a GitHub issues/new URL for a third-party tool, pre-filling the body + * with an environment summary that includes the tool's name and version. + * Non-GitHub URLs are returned as-is after the environment block is appended + * as a query parameter. + * + * Falls back to the original URL if construction fails. + */ +export function buildToolFeedbackUrl(repositoryUrl: string, activeToolInfo: ActiveToolInfo): string { + try { + const environmentSummary = [ + `[Write your comment/feedback/issue here]`, + ``, + ...buildEnvironmentSummaryLines([`Tool Name: ${activeToolInfo.toolName}`, `Tool Version: ${activeToolInfo.toolVersion}`]), + ].join("\n"); + + const url = new URL(repositoryUrl); + if (url.hostname === "github.com") { + // Strip trailing slashes and known suffixes so we always end up at the repo root. + const cleanPath = url.pathname.replace(/\/(issues|pulls|discussions).*$/, "").replace(/\/+$/, ""); + url.pathname = `${cleanPath}/issues/new`; + } + + url.searchParams.set("body", environmentSummary); + return url.toString(); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + logError(err); + return repositoryUrl; + } +} + +/** + * Build a pre-filled GitHub bug-report URL for ToolBox itself. + * Accepts an {@link ActiveToolInfo} so the active-tool context can be + * injected from any call site without coupling this function to the managers. + */ +export function buildToolBoxFeedbackUrl(activeToolInfo: ActiveToolInfo): string { + const fallbackIssuesUrl = "https://github.com/PowerPlatformToolBox/desktop-app/issues/new?template=issue-form-bug.yml"; + + try { + const diagnostics = getEnvironmentDiagnostics(); + + const environmentSummary = buildEnvironmentSummaryLines([ + `Active Tool Instance ID: ${activeToolInfo.instanceId ?? "none"}`, + `Active Tool ID: ${activeToolInfo.toolId}`, + `Active Tool Name: ${activeToolInfo.toolName}`, + `Active Tool Version: ${activeToolInfo.toolVersion}`, + ]).join("\n"); + + const logsTemplate = ["Paste relevant logs here (if available).", "", "Environment (auto-filled):", environmentSummary].join("\n"); + + const params = new URLSearchParams({ + template: "issue-form-bug.yml", + title: "[Bug]: ", + version: diagnostics.appVersion, + logs: logsTemplate, + }); + + return `https://github.com/PowerPlatformToolBox/desktop-app/issues/new?${params.toString()}`; + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + logError(err); + return fallbackIssuesUrl; + } +} diff --git a/src/main/utilities/index.ts b/src/main/utilities/index.ts index 556c4f0e..4cbf7a89 100644 --- a/src/main/utilities/index.ts +++ b/src/main/utilities/index.ts @@ -3,5 +3,6 @@ */ export * from "./clipboard"; +export * from "./feedback"; export * from "./filesystem"; export * from "./theme"; diff --git a/src/renderer/constants/index.ts b/src/renderer/constants/index.ts index b1768bf0..ae0852a0 100644 --- a/src/renderer/constants/index.ts +++ b/src/renderer/constants/index.ts @@ -46,11 +46,6 @@ export const ACTIVITY_BAR_ICONS = [ */ export const MODAL_ANIMATION_DELAY = 300; -/** - * Loading screen fade out duration in milliseconds - */ -export const LOADING_SCREEN_FADE_DURATION = 200; - /** * Terminal panel resize constraints */ diff --git a/src/renderer/index.html b/src/renderer/index.html index 613331e8..4978a1f0 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -58,9 +58,21 @@ - +
+ + +
- - -
+ + +