From 40f4ffdaee28ad5e7ae3e118edf7b790f5cb1679 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 12:02:32 +0000 Subject: [PATCH 001/257] Initial plan From 190a9d67a7f22b9b83aaa41e63eb5aed4cee6f9a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 12:04:43 +0000 Subject: [PATCH 002/257] Fix macOS notarization failures with timestamps and signing improvements Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> --- .github/workflows/prod-release.yml | 56 ++++++++++++++++++++++++-- buildScripts/electron-builder-mac.json | 5 ++- buildScripts/entitlements.mac.plist | 2 + 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/.github/workflows/prod-release.yml b/.github/workflows/prod-release.yml index 09186332..74dfca35 100644 --- a/.github/workflows/prod-release.yml +++ b/.github/workflows/prod-release.yml @@ -170,6 +170,15 @@ jobs: echo "MACOS_CERT_PATH=$CERT_PATH" } >> "$GITHUB_ENV" + - name: Verify certificate details + if: matrix.os == 'macos-latest' + shell: bash + run: | + echo "=== Checking available signing identities ===" + security find-identity -v -p codesigning + echo "" + echo "Note: Should show 'Developer ID Application' not 'Mac App Distribution'" + - name: Package application (macOS) if: matrix.os == 'macos-latest' shell: bash @@ -183,6 +192,8 @@ jobs: SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} DEBUG: electron-builder + CSC_FOR_PULL_REQUEST: true + CSC_IDENTITY_AUTO_DISCOVERY: true run: | node ./buildScripts/package.js --config=${{ matrix.config }} @@ -191,10 +202,47 @@ jobs: shell: bash run: | APP_PATH="build/mac/Power Platform ToolBox.app" - echo "Verifying code signature for: $APP_PATH" - codesign --verify --deep --strict --verbose=2 "$APP_PATH" - spctl --assess --type exec --verbose=4 "$APP_PATH" - echo "✅ macOS code signing verification passed" + + echo "=== Verifying main app signature ===" + codesign --verify --deep --strict --verbose=4 "$APP_PATH" 2>&1 + + echo "" + echo "=== Checking for timestamps on main app ===" + codesign -dvvv "$APP_PATH" 2>&1 | grep -i timestamp || echo "❌ WARNING: NO TIMESTAMP FOUND ON MAIN APP!" + + echo "" + echo "=== Verifying critical nested binaries ===" + # Check helper apps + for helper in "$APP_PATH/Contents/Frameworks/"*.app; do + if [ -d "$helper" ]; then + echo "Checking helper: $(basename "$helper")" + codesign --verify --strict "$helper" 2>&1 || echo "❌ Failed: $helper" + codesign -dvvv "$helper" 2>&1 | grep -i timestamp || echo "❌ No timestamp: $helper" + fi + done + + # Check frameworks + echo "" + echo "=== Checking frameworks ===" + find "$APP_PATH/Contents/Frameworks" -name "*.framework" -type d -maxdepth 1 | while read framework; do + echo "Checking: $(basename "$framework")" + codesign --verify --strict "$framework" 2>&1 || echo "❌ Failed: $framework" + done + + # Check dylibs + echo "" + echo "=== Checking dynamic libraries ===" + find "$APP_PATH" -name "*.dylib" -type f | head -5 | while read dylib; do + echo "Checking: $(basename "$dylib")" + codesign --verify --strict "$dylib" 2>&1 || echo "❌ Failed: $dylib" + done + + echo "" + echo "=== Running spctl assessment ===" + spctl --assess --type exec --verbose=4 "$APP_PATH" 2>&1 + + echo "" + echo "✅ macOS code signing verification complete" - name: Submit macOS notarization request if: matrix.os == 'macos-latest' diff --git a/buildScripts/electron-builder-mac.json b/buildScripts/electron-builder-mac.json index 9af1a120..7eb052f9 100644 --- a/buildScripts/electron-builder-mac.json +++ b/buildScripts/electron-builder-mac.json @@ -21,12 +21,13 @@ "category": "public.app-category.developer-tools", "icon": "icons/icon.icns", "identity": "Developer ID Application", - "gatekeeperAssess": true, + "gatekeeperAssess": false, "hardenedRuntime": true, "entitlements": "./buildScripts/entitlements.mac.plist", "entitlementsInherit": "./buildScripts/entitlements.mac.plist", "type": "distribution", - "notarize": false + "notarize": false, + "signIgnore": [] }, "dmg": { "sign": true diff --git a/buildScripts/entitlements.mac.plist b/buildScripts/entitlements.mac.plist index 603f46df..08264f64 100644 --- a/buildScripts/entitlements.mac.plist +++ b/buildScripts/entitlements.mac.plist @@ -8,6 +8,8 @@ com.apple.security.cs.allow-unsigned-executable-memory + com.apple.security.cs.allow-dyld-environment-variables + com.apple.security.inherit com.apple.security.device.usb From e8401961d829bc0cb6a030eba68bd23333050b55 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 12:30:36 +0000 Subject: [PATCH 003/257] Apply macOS notarization fixes to nightly build workflow Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> --- .github/workflows/nightly-release.yml | 56 +++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/.github/workflows/nightly-release.yml b/.github/workflows/nightly-release.yml index c42daa80..a73b4d30 100644 --- a/.github/workflows/nightly-release.yml +++ b/.github/workflows/nightly-release.yml @@ -166,6 +166,15 @@ jobs: echo "MACOS_CERT_PATH=$CERT_PATH" } >> "$GITHUB_ENV" + - name: Verify certificate details + if: matrix.os == 'macos-latest' + shell: bash + run: | + echo "=== Checking available signing identities ===" + security find-identity -v -p codesigning + echo "" + echo "Note: Should show 'Developer ID Application' not 'Mac App Distribution'" + - name: Package application (macOS) if: matrix.os == 'macos-latest' shell: bash @@ -179,6 +188,8 @@ jobs: SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} DEBUG: electron-builder + CSC_FOR_PULL_REQUEST: true + CSC_IDENTITY_AUTO_DISCOVERY: true run: | node ./buildScripts/package.js --config=${{ matrix.config }} @@ -187,10 +198,47 @@ jobs: shell: bash run: | APP_PATH="build/mac/Power Platform ToolBox.app" - echo "Verifying code signature for: $APP_PATH" - codesign --verify --deep --strict --verbose=2 "$APP_PATH" - spctl --assess --type exec --verbose=4 "$APP_PATH" - echo "✅ macOS code signing verification passed" + + echo "=== Verifying main app signature ===" + codesign --verify --deep --strict --verbose=4 "$APP_PATH" 2>&1 + + echo "" + echo "=== Checking for timestamps on main app ===" + codesign -dvvv "$APP_PATH" 2>&1 | grep -i timestamp || echo "❌ WARNING: NO TIMESTAMP FOUND ON MAIN APP!" + + echo "" + echo "=== Verifying critical nested binaries ===" + # Check helper apps + for helper in "$APP_PATH/Contents/Frameworks/"*.app; do + if [ -d "$helper" ]; then + echo "Checking helper: $(basename "$helper")" + codesign --verify --strict "$helper" 2>&1 || echo "❌ Failed: $helper" + codesign -dvvv "$helper" 2>&1 | grep -i timestamp || echo "❌ No timestamp: $helper" + fi + done + + # Check frameworks + echo "" + echo "=== Checking frameworks ===" + find "$APP_PATH/Contents/Frameworks" -name "*.framework" -type d -maxdepth 1 | while read framework; do + echo "Checking: $(basename "$framework")" + codesign --verify --strict "$framework" 2>&1 || echo "❌ Failed: $framework" + done + + # Check dylibs + echo "" + echo "=== Checking dynamic libraries ===" + find "$APP_PATH" -name "*.dylib" -type f | head -5 | while read dylib; do + echo "Checking: $(basename "$dylib")" + codesign --verify --strict "$dylib" 2>&1 || echo "❌ Failed: $dylib" + done + + echo "" + echo "=== Running spctl assessment ===" + spctl --assess --type exec --verbose=4 "$APP_PATH" 2>&1 + + echo "" + echo "✅ macOS code signing verification complete" - name: Submit macOS notarization request if: matrix.os == 'macos-latest' From 69dd2919bb405f191774c991beaee393bddb5b19 Mon Sep 17 00:00:00 2001 From: Power-Maverick Date: Mon, 9 Feb 2026 10:03:30 -0500 Subject: [PATCH 004/257] chore: add macOS certificate files to .gitignore --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 054572ae..bd1e54d3 100644 --- a/.gitignore +++ b/.gitignore @@ -147,3 +147,7 @@ build/ dist/ package-lock.json .vscode/settings.json + +# Any macOS Certificate files +*.cer +*.p12 From bfd701d369223c4db5ab4e4e472a9aaa1e57f3bf Mon Sep 17 00:00:00 2001 From: Power-Maverick Date: Mon, 9 Feb 2026 10:25:26 -0500 Subject: [PATCH 005/257] fix: update macOS code signing verification to skip spctl assessment --- .github/workflows/nightly-release.yml | 19 +++++++++---------- .github/workflows/prod-release.yml | 19 +++++++++---------- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/.github/workflows/nightly-release.yml b/.github/workflows/nightly-release.yml index a73b4d30..77c5ef16 100644 --- a/.github/workflows/nightly-release.yml +++ b/.github/workflows/nightly-release.yml @@ -198,14 +198,14 @@ jobs: shell: bash run: | APP_PATH="build/mac/Power Platform ToolBox.app" - + echo "=== Verifying main app signature ===" codesign --verify --deep --strict --verbose=4 "$APP_PATH" 2>&1 - + echo "" echo "=== Checking for timestamps on main app ===" codesign -dvvv "$APP_PATH" 2>&1 | grep -i timestamp || echo "❌ WARNING: NO TIMESTAMP FOUND ON MAIN APP!" - + echo "" echo "=== Verifying critical nested binaries ===" # Check helper apps @@ -216,7 +216,7 @@ jobs: codesign -dvvv "$helper" 2>&1 | grep -i timestamp || echo "❌ No timestamp: $helper" fi done - + # Check frameworks echo "" echo "=== Checking frameworks ===" @@ -224,7 +224,7 @@ jobs: echo "Checking: $(basename "$framework")" codesign --verify --strict "$framework" 2>&1 || echo "❌ Failed: $framework" done - + # Check dylibs echo "" echo "=== Checking dynamic libraries ===" @@ -232,12 +232,11 @@ jobs: echo "Checking: $(basename "$dylib")" codesign --verify --strict "$dylib" 2>&1 || echo "❌ Failed: $dylib" done - - echo "" - echo "=== Running spctl assessment ===" - spctl --assess --type exec --verbose=4 "$APP_PATH" 2>&1 - + echo "" + echo "=== Note ===" + echo "Skipping 'spctl --assess' here because it typically reports 'source=Unnotarized Developer ID' until the notarization+stapling job completes." + echo "" echo "✅ macOS code signing verification complete" - name: Submit macOS notarization request diff --git a/.github/workflows/prod-release.yml b/.github/workflows/prod-release.yml index 74dfca35..63a612f2 100644 --- a/.github/workflows/prod-release.yml +++ b/.github/workflows/prod-release.yml @@ -202,14 +202,14 @@ jobs: shell: bash run: | APP_PATH="build/mac/Power Platform ToolBox.app" - + echo "=== Verifying main app signature ===" codesign --verify --deep --strict --verbose=4 "$APP_PATH" 2>&1 - + echo "" echo "=== Checking for timestamps on main app ===" codesign -dvvv "$APP_PATH" 2>&1 | grep -i timestamp || echo "❌ WARNING: NO TIMESTAMP FOUND ON MAIN APP!" - + echo "" echo "=== Verifying critical nested binaries ===" # Check helper apps @@ -220,7 +220,7 @@ jobs: codesign -dvvv "$helper" 2>&1 | grep -i timestamp || echo "❌ No timestamp: $helper" fi done - + # Check frameworks echo "" echo "=== Checking frameworks ===" @@ -228,7 +228,7 @@ jobs: echo "Checking: $(basename "$framework")" codesign --verify --strict "$framework" 2>&1 || echo "❌ Failed: $framework" done - + # Check dylibs echo "" echo "=== Checking dynamic libraries ===" @@ -236,12 +236,11 @@ jobs: echo "Checking: $(basename "$dylib")" codesign --verify --strict "$dylib" 2>&1 || echo "❌ Failed: $dylib" done - - echo "" - echo "=== Running spctl assessment ===" - spctl --assess --type exec --verbose=4 "$APP_PATH" 2>&1 - + echo "" + echo "=== Note ===" + echo "Skipping 'spctl --assess' here because it typically reports 'source=Unnotarized Developer ID' until the notarization+stapling job completes." + echo "" echo "✅ macOS code signing verification complete" - name: Submit macOS notarization request From af647d5069cfd59f0506b06fb92d517b4f1ec04b Mon Sep 17 00:00:00 2001 From: Power-Maverick Date: Mon, 9 Feb 2026 10:47:40 -0500 Subject: [PATCH 006/257] fix: enhance macOS notarization steps to support multiple artifacts and improve error handling --- .github/workflows/nightly-release.yml | 30 ++++++++++++++++----------- .github/workflows/prod-release.yml | 30 ++++++++++++++++----------- 2 files changed, 36 insertions(+), 24 deletions(-) diff --git a/.github/workflows/nightly-release.yml b/.github/workflows/nightly-release.yml index 77c5ef16..c6fdbf56 100644 --- a/.github/workflows/nightly-release.yml +++ b/.github/workflows/nightly-release.yml @@ -412,6 +412,7 @@ jobs: with: name: macos-build path: notarize + merge-multiple: true - name: Locate notarization info id: notarize-info @@ -438,24 +439,29 @@ jobs: - name: Staple macOS artifacts shell: bash run: | - shopt -s nullglob - for file in notarize/build/*.dmg notarize/build/*.pkg notarize/build/*.zip; do - if [[ -f "$file" ]]; then - echo "Stapling $(basename "$file")" - xcrun stapler staple "$file" - fi - done + found=0 + while IFS= read -r -d '' file; do + found=1 + echo "Stapling $(basename "$file")" + xcrun stapler staple "$file" + done < <(find notarize -type f \( -name "*.dmg" -o -name "*.pkg" -o -name "*.zip" \) -print0) + + if [[ "$found" -eq 0 ]]; then + echo "No macOS artifacts found to staple under 'notarize'." >&2 + find notarize -maxdepth 4 -type f || true + exit 1 + fi - name: Upload stapled macOS artifacts uses: actions/upload-artifact@v4 with: name: macos-build path: | - notarize/build/*.dmg - notarize/build/*.pkg - notarize/build/*.zip - notarize/build/*.yml - notarize/build/notarization-info.json + notarize/**/*.dmg + notarize/**/*.pkg + notarize/**/*.zip + notarize/**/*.yml + notarize/**/notarization-info.json retention-days: 30 overwrite: true diff --git a/.github/workflows/prod-release.yml b/.github/workflows/prod-release.yml index 63a612f2..2d55bf02 100644 --- a/.github/workflows/prod-release.yml +++ b/.github/workflows/prod-release.yml @@ -365,6 +365,7 @@ jobs: with: name: macos-release path: notarize + merge-multiple: true - name: Locate notarization info id: notarize-info @@ -391,24 +392,29 @@ jobs: - name: Staple macOS artifacts shell: bash run: | - shopt -s nullglob - for file in notarize/build/*.dmg notarize/build/*.pkg notarize/build/*.zip; do - if [[ -f "$file" ]]; then - echo "Stapling $(basename "$file")" - xcrun stapler staple "$file" - fi - done + found=0 + while IFS= read -r -d '' file; do + found=1 + echo "Stapling $(basename "$file")" + xcrun stapler staple "$file" + done < <(find notarize -type f \( -name "*.dmg" -o -name "*.pkg" -o -name "*.zip" \) -print0) + + if [[ "$found" -eq 0 ]]; then + echo "No macOS artifacts found to staple under 'notarize'." >&2 + find notarize -maxdepth 4 -type f || true + exit 1 + fi - name: Upload stapled macOS artifacts uses: actions/upload-artifact@v4 with: name: macos-release path: | - notarize/build/*.dmg - notarize/build/*.pkg - notarize/build/*.zip - notarize/build/*.yml - notarize/build/notarization-info.json + notarize/**/*.dmg + notarize/**/*.pkg + notarize/**/*.zip + notarize/**/*.yml + notarize/**/notarization-info.json retention-days: 90 overwrite: true From b4f1a178722ed70bc1bab0e7d365bc0738ac789e Mon Sep 17 00:00:00 2001 From: Power-Maverick Date: Mon, 9 Feb 2026 11:05:01 -0500 Subject: [PATCH 007/257] fix: update notarization scripts to support multiple asset types and improve error handling --- .github/workflows/nightly-release.yml | 4 +- .github/workflows/prod-release.yml | 4 +- buildScripts/notarize.js | 138 ++++++++++++++++++++------ 3 files changed, 110 insertions(+), 36 deletions(-) diff --git a/.github/workflows/nightly-release.yml b/.github/workflows/nightly-release.yml index c6fdbf56..4bc90156 100644 --- a/.github/workflows/nightly-release.yml +++ b/.github/workflows/nightly-release.yml @@ -247,7 +247,7 @@ jobs: APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | - node ./buildScripts/notarize.js submit --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.app" --output="build/notarization-info.json" - name: Cleanup macOS signing certificate if: ${{ always() && matrix.os == 'macos-latest' }} @@ -444,7 +444,7 @@ jobs: found=1 echo "Stapling $(basename "$file")" xcrun stapler staple "$file" - done < <(find notarize -type f \( -name "*.dmg" -o -name "*.pkg" -o -name "*.zip" \) -print0) + done < <(find notarize -type f \( -name "*.dmg" -o -name "*.pkg" \) -print0) if [[ "$found" -eq 0 ]]; then echo "No macOS artifacts found to staple under 'notarize'." >&2 diff --git a/.github/workflows/prod-release.yml b/.github/workflows/prod-release.yml index 2d55bf02..2635f252 100644 --- a/.github/workflows/prod-release.yml +++ b/.github/workflows/prod-release.yml @@ -251,7 +251,7 @@ jobs: APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | - node ./buildScripts/notarize.js submit --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.app" --output="build/notarization-info.json" - name: Cleanup macOS signing certificate if: ${{ always() && matrix.os == 'macos-latest' }} @@ -397,7 +397,7 @@ jobs: found=1 echo "Stapling $(basename "$file")" xcrun stapler staple "$file" - done < <(find notarize -type f \( -name "*.dmg" -o -name "*.pkg" -o -name "*.zip" \) -print0) + done < <(find notarize -type f \( -name "*.dmg" -o -name "*.pkg" \) -print0) if [[ "$found" -eq 0 ]]; then echo "No macOS artifacts found to staple under 'notarize'." >&2 diff --git a/buildScripts/notarize.js b/buildScripts/notarize.js index df27af36..2a2f8035 100644 --- a/buildScripts/notarize.js +++ b/buildScripts/notarize.js @@ -51,6 +51,42 @@ const getArg = (name, defaultValue) => { return defaultValue; }; +const splitListArg = (value) => { + if (!value) { + return []; + } + + return value + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +}; + +const escapeRegExp = (text) => { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +}; + +const expandWildcardPath = (inputPath) => { + const resolvedPath = path.resolve(inputPath); + + if (!resolvedPath.includes("*")) { + return [resolvedPath]; + } + + const dir = path.dirname(resolvedPath); + const base = path.basename(resolvedPath); + + if (!fs.existsSync(dir)) { + return []; + } + + const pattern = new RegExp(`^${base.split("*").map(escapeRegExp).join(".*")}$`); + return fs + .readdirSync(dir) + .filter((name) => pattern.test(name)) + .map((name) => path.join(dir, name)); +}; + const ensureAppleCreds = () => { const appleId = process.env.APPLE_ID; const applePassword = process.env.APPLE_APP_SPECIFIC_PASSWORD; @@ -104,37 +140,53 @@ const submit = () => { const defaultAppPath = path.resolve("build", "mac", "Power Platform ToolBox.app"); const appPath = path.resolve(getArg("--app", defaultAppPath)); + const assetArg = getArg("--assets", ""); + const assets = splitListArg(assetArg); + const outputPath = path.resolve(getArg("--output", path.resolve("build", "notarization-info.json"))); const bundleId = getArg("--bundle-id", "com.powerplatform.toolbox"); const { appleId, applePassword, teamId } = ensureAppleCreds(); - const { assetPath, cleanup, displayPath } = prepareSubmissionAsset(appPath); - process.stdout.write(`Submitting ${displayPath} for notarization (bundleId: ${bundleId}) without waiting...\n`); + const resolvedAssets = assets.length > 0 ? assets.flatMap(expandWildcardPath) : []; + const targets = resolvedAssets.length > 0 ? resolvedAssets : [appPath]; - let resultRaw; + const submissions = []; + for (const target of targets) { + const { assetPath, cleanup, displayPath } = prepareSubmissionAsset(target); + process.stdout.write(`Submitting ${displayPath} for notarization (bundleId: ${bundleId}) without waiting...\n`); - try { - resultRaw = runNotarytool(["submit", assetPath, "--apple-id", appleId, "--team-id", teamId, "--password", applePassword, "--no-wait", "--output-format", "json"]); - } finally { - if (cleanup) { - cleanup(); + try { + const resultRaw = runNotarytool(["submit", assetPath, "--apple-id", appleId, "--team-id", teamId, "--password", applePassword, "--no-wait", "--output-format", "json"]); + const parsed = JSON.parse(resultRaw); + submissions.push({ + submissionId: parsed.id, + status: parsed.status, + submittedAsset: assetPath, + displayPath, + submittedAt: new Date().toISOString(), + }); + process.stdout.write(`Submitted notarization request. Submission ID: ${parsed.id}\n`); + } finally { + if (cleanup) { + cleanup(); + } } } - const parsed = JSON.parse(resultRaw); - const submissionId = parsed.id; + if (submissions.length === 0) { + throw new Error(`No notarization assets found. assets='${assetArg}' app='${appPath}'`); + } + const info = { - submissionId, + submissionId: submissions[0].submissionId, + submissions, bundleId, - status: parsed.status, appPath, - submittedAsset: assetPath, submittedAt: new Date().toISOString(), }; fs.writeFileSync(outputPath, `${JSON.stringify(info, null, 2)}\n`); - process.stdout.write(`Submitted notarization request. Submission ID: ${submissionId}\n`); }; const loadInfo = (infoPath) => { @@ -145,13 +197,21 @@ const loadInfo = (infoPath) => { const contents = fs.readFileSync(infoPath, "utf8"); const info = JSON.parse(contents); - if (!info.submissionId) { - throw new Error(`Notarization info is missing submissionId: ${infoPath}`); + if (!info.submissionId && (!Array.isArray(info.submissions) || info.submissions.length === 0)) { + throw new Error(`Notarization info is missing submissionId/submissions: ${infoPath}`); } return info; }; +const getSubmissionIds = (info) => { + if (Array.isArray(info.submissions) && info.submissions.length > 0) { + return info.submissions.map((entry) => entry.submissionId).filter(Boolean); + } + + return info.submissionId ? [info.submissionId] : []; +}; + const waitForStatus = async () => { const infoPath = path.resolve(getArg("--info", path.resolve("build", "notarization-info.json"))); const timeoutHours = Number(getArg("--timeout-hours", "12")); @@ -163,33 +223,47 @@ const waitForStatus = async () => { const maxAttempts = Math.max(1, Math.ceil((timeoutHours * 60) / intervalMinutes)); const info = loadInfo(infoPath); + const submissionIds = getSubmissionIds(info); const { appleId, applePassword, teamId } = ensureAppleCreds(); - process.stdout.write(`Waiting for notarization ${info.submissionId} (max ${timeoutHours}h)...\n`); + if (submissionIds.length === 0) { + throw new Error(`No notarization submission IDs found in ${infoPath}`); + } + + process.stdout.write(`Waiting for notarization (${submissionIds.length} submission(s), max ${timeoutHours}h)...\n`); for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { try { - const output = runNotarytool(["log", info.submissionId, "--apple-id", appleId, "--team-id", teamId, "--password", applePassword, "--output-format", "json"]); + let acceptedCount = 0; - const parsed = JSON.parse(output); - const status = parsed.status; + for (const submissionId of submissionIds) { + const output = runNotarytool(["log", submissionId, "--apple-id", appleId, "--team-id", teamId, "--password", applePassword, "--output-format", "json"]); - if (status === "Accepted") { - process.stdout.write(`Notarization ${info.submissionId} accepted.\n`); - return; - } + const parsed = JSON.parse(output); + const status = parsed.status; + + if (status === "Accepted") { + acceptedCount += 1; + continue; + } - if (status === "Invalid") { - const issues = parsed.issues || []; - process.stderr.write(`Notarization ${info.submissionId} was rejected.\n`); - if (issues.length > 0) { - process.stderr.write(`${JSON.stringify(issues, null, 2)}\n`); + if (status === "Invalid") { + const issues = parsed.issues || []; + process.stderr.write(`Notarization ${submissionId} was rejected.\n`); + if (issues.length > 0) { + process.stderr.write(`${JSON.stringify(issues, null, 2)}\n`); + } + throw new Error("Apple rejected the notarization request."); } - throw new Error("Apple rejected the notarization request."); + } + + if (acceptedCount === submissionIds.length) { + process.stdout.write(`Notarization accepted for all submissions (${acceptedCount}/${submissionIds.length}).\n`); + return; } if (attempt === maxAttempts) { - throw new Error(`Timed out waiting for notarization ${info.submissionId} after ${timeoutHours} hours.`); + throw new Error(`Timed out waiting for notarization after ${timeoutHours} hours.`); } const nextDelayMs = intervalMinutes * 60 * 1000; @@ -198,7 +272,7 @@ const waitForStatus = async () => { } catch (error) { if (isLogUnavailableError(error)) { if (attempt === maxAttempts) { - throw new Error(`Timed out waiting for notarization ${info.submissionId} after ${timeoutHours} hours (submission log never became available).`); + throw new Error(`Timed out waiting for notarization after ${timeoutHours} hours (submission log never became available).`); } const nextDelayMs = intervalMinutes * 60 * 1000; From 0a17a657746b6502cb7d3e8b436dadfadac8f503 Mon Sep 17 00:00:00 2001 From: Power-Maverick Date: Mon, 9 Feb 2026 17:03:38 -0500 Subject: [PATCH 008/257] fix: update version to 1.1.3 in package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ce4e6c00..a16900c1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "powerplatform-toolbox", - "version": "1.1.2", + "version": "1.1.3", "description": "A universal desktop app that contains multiple tools to ease the customization and configuration of Power Platform", "main": "dist/main/index.js", "scripts": { From 47ffa9fd3a3121ebefdce5d0608e2efd08d2970c Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:27:10 -0500 Subject: [PATCH 009/257] feat: Associate browser profile with connections - visual tags and profile detection (#355) * Initial plan * Add browser profile support for interactive authentication - Add browserType and browserProfile fields to DataverseConnection interface - Implement browser-specific launch logic in AuthManager - Update add/edit connection modals with browser selection UI - Support Chrome, Edge, Firefox, and Brave browsers - Fallback to default browser if profile not found Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Address code review feedback - use helper functions consistently Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Refactor: Extract browser logic into separate BrowserManager - Create new BrowserManager class for browser detection and profile enumeration - Remove browser-related code from AuthManager - AuthManager now depends on BrowserManager via constructor injection - Add IPC channels for browser detection and profile listing - Update preload script to expose new browser APIs Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Implement dynamic browser profile detection in UI - Replace text input with dropdown for browser profiles - Dynamically detect browser installation on selection - Automatically load and populate browser profiles - Show warning when selected browser is not installed - Support both add and edit connection modals - Add modal-warning CSS for user feedback Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Fix: Browser profile detection in modals - Expose browser detection APIs in modalPreload.ts - Fix browser type change event to properly trigger profile loading - Set correct initial state for profile dropdown (No profile needed) - Remove premature loadBrowserProfiles() call that ran before DOM ready Fixes: - Profile selector now loads data when browser is selected - Browser detection APIs now available in modal context - Initial dropdown state shows correct message - Warning displays when browser not installed Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Enhance profile detection to show friendly names, support Chrome/Edge only - Read actual profile names from Local State file (e.g., "Person 1", "Work") - Fallback to reading individual Preferences files if Local State unavailable - Remove Firefox and Brave browser support (Chrome and Edge only) - UI now displays profile.name but stores profile.path for --profile-directory - Update both add and edit connection modals Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Move browser settings to separate section available for all auth types - Move browser type and profile selectors out of interactive-only section - Create new "Browser Settings (Optional)" section below Environment selector - Apply browser settings to all authentication types (interactive, clientSecret, usernamePassword, connectionString) - Browser profile used when tools need to open URLs with authentication - Updated both add and edit connection modals - Updated connectionManagement to apply browser settings to all auth types Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Fixed style issue and added logos * Add browser profile tag to connection cards in sidebar - Add browserProfileName field to DataverseConnection type - Display browser icon and profile name as tag on connection cards - Tag shows Chrome/Edge icon with friendly profile name (e.g., "My Cool Profile") - Tag positioned next to environment badge - Update form controllers to capture and store profile name - Add CSS styling for connection-browser-tag with browser icon - Browser icons loaded from renderer/icons/logos/ Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Fix modal hanging issue - improve browser profile name collection - Fix collectFormData to safely handle empty profile selections - Only capture browserProfileName when a profile is actually selected - Add null-safe checks for selectedOptions array access - Prevent attempting to read textContent from empty value options Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Revert "Fix modal hanging issue - improve browser profile name collection" This reverts commit 607fde9bdc171cdaf3039153d6457ff8a3c4d814. * Revert "Add browser profile tag to connection cards in sidebar" This reverts commit e1cab02dfd7401b5555de63bee6e664eea96e4d8. * feat: add browser profile name support in connection forms and UI * Update src/renderer/modules/connectionManagement.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/main/managers/browserManager.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/main/managers/browserManager.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/common/types/connection.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> Co-authored-by: Power-Maverick Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/common/ipc/channels.ts | 2 + src/common/types/connection.ts | 11 + src/main/index.ts | 16 +- src/main/managers/authManager.ts | 11 +- src/main/managers/browserManager.ts | 322 ++++++++++++++++++ src/main/modalPreload.ts | 10 +- src/main/preload.ts | 2 + src/renderer/icons/logos/chrome.png | Bin 0 -> 24560 bytes src/renderer/icons/logos/edge.png | Bin 0 -> 38070 bytes .../modals/addConnection/controller.ts | 100 ++++++ src/renderer/modals/addConnection/view.ts | 18 + .../modals/editConnection/controller.ts | 106 ++++++ src/renderer/modals/editConnection/view.ts | 18 + src/renderer/modals/sharedStyles.ts | 13 + src/renderer/modules/connectionManagement.ts | 76 ++++- src/renderer/styles.scss | 49 ++- vite.config.ts | 15 + 17 files changed, 761 insertions(+), 8 deletions(-) create mode 100644 src/main/managers/browserManager.ts create mode 100644 src/renderer/icons/logos/chrome.png create mode 100644 src/renderer/icons/logos/edge.png diff --git a/src/common/ipc/channels.ts b/src/common/ipc/channels.ts index e38d6b3c..c9e34dea 100644 --- a/src/common/ipc/channels.ts +++ b/src/common/ipc/channels.ts @@ -50,6 +50,8 @@ export const CONNECTION_CHANNELS = { TEST_CONNECTION: "test-connection", IS_TOKEN_EXPIRED: "is-connection-token-expired", REFRESH_TOKEN: "refresh-connection-token", + CHECK_BROWSER_INSTALLED: "check-browser-installed", + GET_BROWSER_PROFILES: "get-browser-profiles", } as const; // Tool-related IPC channels diff --git a/src/common/types/connection.ts b/src/common/types/connection.ts index 18b81d29..0547e504 100644 --- a/src/common/types/connection.ts +++ b/src/common/types/connection.ts @@ -7,6 +7,13 @@ */ export type AuthenticationType = "interactive" | "clientSecret" | "usernamePassword" | "connectionString"; +/** + * Browser type for interactive authentication + * + * Note: Firefox and Brave may be added here in the future when BrowserManager supports them. + */ +export type BrowserType = "default" | "chrome" | "edge"; + /** * Dataverse connection configuration * @@ -32,6 +39,10 @@ export interface DataverseConnection { tokenExpiry?: string; // MSAL account identifier for silent token acquisition (used with interactive auth) msalAccountId?: string; + // Browser profile settings for interactive authentication + browserType?: BrowserType; + browserProfile?: string; + browserProfileName?: string; } /** diff --git a/src/main/index.ts b/src/main/index.ts index 3ebaa2c6..d0c11ef1 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -77,6 +77,7 @@ import { import { EntityRelatedMetadataPath, LastUsedToolEntry, LastUsedToolUpdate, ModalWindowMessagePayload, ModalWindowOptions, ToolBoxEvent } from "../common/types"; import { AuthManager } from "./managers/authManager"; import { AutoUpdateManager } from "./managers/autoUpdateManager"; +import { BrowserManager } from "./managers/browserManager"; import { BrowserviewProtocolManager } from "./managers/browserviewProtocolManager"; import { ConnectionsManager } from "./managers/connectionsManager"; import { DataverseManager } from "./managers/dataverseManager"; @@ -106,6 +107,7 @@ class ToolBoxApp { private modalWindowManager: ModalWindowManager | null = null; private api: ToolBoxUtilityManager; private autoUpdateManager: AutoUpdateManager; + private browserManager: BrowserManager; private authManager: AuthManager; private terminalManager: TerminalManager; private dataverseManager: DataverseManager; @@ -133,7 +135,8 @@ class ToolBoxApp { this.toolManager = new ToolManager(path.join(app.getPath("userData"), "tools"), process.env.SUPABASE_URL, process.env.SUPABASE_ANON_KEY, this.installIdManager); this.browserviewProtocolManager = new BrowserviewProtocolManager(this.toolManager, this.settingsManager); this.autoUpdateManager = new AutoUpdateManager(); - this.authManager = new AuthManager(); + this.browserManager = new BrowserManager(); + this.authManager = new AuthManager(this.browserManager); this.terminalManager = new TerminalManager(); this.dataverseManager = new DataverseManager(this.connectionsManager, this.authManager); @@ -279,6 +282,8 @@ class ToolBoxApp { ipcMain.removeHandler(CONNECTION_CHANNELS.TEST_CONNECTION); ipcMain.removeHandler(CONNECTION_CHANNELS.IS_TOKEN_EXPIRED); ipcMain.removeHandler(CONNECTION_CHANNELS.REFRESH_TOKEN); + ipcMain.removeHandler(CONNECTION_CHANNELS.CHECK_BROWSER_INSTALLED); + ipcMain.removeHandler(CONNECTION_CHANNELS.GET_BROWSER_PROFILES); // Tool handlers ipcMain.removeHandler(TOOL_CHANNELS.GET_ALL_TOOLS); @@ -708,6 +713,15 @@ class ToolBoxApp { } }); + // Browser detection handlers + ipcMain.handle(CONNECTION_CHANNELS.CHECK_BROWSER_INSTALLED, (_, browserType: string) => { + return this.browserManager.isBrowserInstalled(browserType); + }); + + ipcMain.handle(CONNECTION_CHANNELS.GET_BROWSER_PROFILES, (_, browserType: string) => { + return this.browserManager.getBrowserProfiles(browserType); + }); + // Tool handlers ipcMain.handle(TOOL_CHANNELS.GET_ALL_TOOLS, () => { return this.toolManager.getAllTools(); diff --git a/src/main/managers/authManager.ts b/src/main/managers/authManager.ts index 8a93df35..080b3711 100644 --- a/src/main/managers/authManager.ts +++ b/src/main/managers/authManager.ts @@ -1,11 +1,12 @@ import { AccountInfo, ConfidentialClientApplication, LogLevel, PublicClientApplication } from "@azure/msal-node"; -import { BrowserWindow, shell } from "electron"; +import { BrowserWindow } from "electron"; import * as http from "http"; import * as https from "https"; import { EVENT_CHANNELS } from "../../common/ipc/channels"; import { captureMessage, logInfo, logWarn } from "../../common/sentryHelper"; import { DataverseConnection } from "../../common/types"; import { DATAVERSE_API_VERSION } from "../constants"; +import { BrowserManager } from "./browserManager"; /** * Manages authentication for Power Platform connections @@ -18,6 +19,7 @@ export class AuthManager { private activeServer: http.Server | null = null; private activeServerTimeout: NodeJS.Timeout | null = null; private activePort: number | null = null; + private browserManager: BrowserManager; // Authentication timeout duration (5 minutes) private static readonly AUTH_TIMEOUT_MS = 5 * 60 * 1000; @@ -31,7 +33,8 @@ export class AuthManager { "/": "/", }; - constructor() { + constructor(browserManager: BrowserManager) { + this.browserManager = browserManager; // MSAL will be initialized on-demand for interactive auth } @@ -382,8 +385,8 @@ export class AuthManager { server.listen(port, "localhost", () => { logInfo(`Listening for OAuth redirect on ${redirectUri}`); - // Server is ready, now open the browser - shell.openExternal(authCodeUrl).catch((err) => { + // Server is ready, now open the browser with profile support + this.browserManager.openBrowserWithProfile(authCodeUrl, connection).catch((err) => { cleanupAndReject(new Error(`Failed to open browser: ${err.message}`)); }); }); diff --git a/src/main/managers/browserManager.ts b/src/main/managers/browserManager.ts new file mode 100644 index 00000000..a4e82afd --- /dev/null +++ b/src/main/managers/browserManager.ts @@ -0,0 +1,322 @@ +import { spawn, execSync } from "child_process"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { shell } from "electron"; +import { logInfo, logWarn } from "../../common/sentryHelper"; +import { DataverseConnection } from "../../common/types"; + +/** + * Manages browser detection, profile enumeration, and browser launching + */ +export class BrowserManager { + /** + * Check if a specific browser is installed on the system + * @param browserType The type of browser to check (chrome or edge) + * @returns true if browser is installed, false otherwise + */ + public isBrowserInstalled(browserType: string): boolean { + if (!browserType || browserType === "default") { + return true; // Default browser is always available + } + + const platform = process.platform; + let possiblePaths: string[] = []; + + if (browserType === "chrome") { + if (platform === "win32") { + possiblePaths = [ + path.join(process.env.PROGRAMFILES || "C:\\Program Files", "Google\\Chrome\\Application\\chrome.exe"), + path.join(process.env["PROGRAMFILES(X86)"] || "C:\\Program Files (x86)", "Google\\Chrome\\Application\\chrome.exe"), + path.join(process.env.LOCALAPPDATA || "", "Google\\Chrome\\Application\\chrome.exe"), + ]; + } else if (platform === "darwin") { + possiblePaths = ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"]; + } else { + // For Linux, check if command exists + try { + execSync("which google-chrome", { stdio: "ignore" }); + return true; + } catch { + return false; + } + } + } else if (browserType === "edge") { + if (platform === "win32") { + possiblePaths = [ + path.join(process.env.PROGRAMFILES || "C:\\Program Files", "Microsoft\\Edge\\Application\\msedge.exe"), + path.join(process.env["PROGRAMFILES(X86)"] || "C:\\Program Files (x86)", "Microsoft\\Edge\\Application\\msedge.exe"), + ]; + } else if (platform === "darwin") { + possiblePaths = ["/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"]; + } else { + try { + execSync("which microsoft-edge", { stdio: "ignore" }); + return true; + } catch { + return false; + } + } + } + + // Check if any of the paths exist + return possiblePaths.some((p) => fs.existsSync(p)); + } + + /** + * Get list of browser profiles for a specific browser + * @param browserType The type of browser to get profiles for + * @returns Array of profile objects with name and path + */ + public getBrowserProfiles(browserType: string): Array<{ name: string; path: string }> { + if (!browserType || browserType === "default") { + return []; + } + + if (!this.isBrowserInstalled(browserType)) { + return []; + } + + const platform = process.platform; + + try { + if (browserType === "chrome" || browserType === "edge") { + return this.getChromiumProfiles(browserType, platform); + } + } catch (error) { + logWarn(`Failed to get profiles for ${browserType}: ${(error as Error).message}`); + return []; + } + + return []; + } + + /** + * Get Chromium-based browser profiles (Chrome, Edge) + * Returns objects with both display name and directory path + */ + private getChromiumProfiles(browserType: string, platform: string): Array<{ name: string; path: string }> { + let userDataPath = ""; + + if (browserType === "chrome") { + if (platform === "win32") { + userDataPath = path.join(process.env.LOCALAPPDATA || "", "Google\\Chrome\\User Data"); + } else if (platform === "darwin") { + userDataPath = path.join(os.homedir(), "Library/Application Support/Google/Chrome"); + } else { + userDataPath = path.join(os.homedir(), ".config/google-chrome"); + } + } else if (browserType === "edge") { + if (platform === "win32") { + userDataPath = path.join(process.env.LOCALAPPDATA || "", "Microsoft\\Edge\\User Data"); + } else if (platform === "darwin") { + userDataPath = path.join(os.homedir(), "Library/Application Support/Microsoft Edge"); + } else { + userDataPath = path.join(os.homedir(), ".config/microsoft-edge"); + } + } + + if (!fs.existsSync(userDataPath)) { + return []; + } + + const profiles: Array<{ name: string; path: string }> = []; + + try { + // Try to read Local State file to get profile names (preferred method) + const localStatePath = path.join(userDataPath, "Local State"); + if (fs.existsSync(localStatePath)) { + const localStateContent = fs.readFileSync(localStatePath, "utf8"); + const localState = JSON.parse(localStateContent); + + if (localState.profile && localState.profile.info_cache) { + const infoCache = localState.profile.info_cache; + + // Iterate through all profiles in info_cache + for (const profileDir in infoCache) { + if (Object.prototype.hasOwnProperty.call(infoCache, profileDir)) { + const profileInfo = infoCache[profileDir]; + const profileName = profileInfo.name || profileDir; + + // Include Default and Profile X directories + if (profileDir === "Default" || profileDir.startsWith("Profile ")) { + profiles.push({ + name: profileName, + path: profileDir, + }); + } + } + } + } + + // If we found profiles from Local State, return them + if (profiles.length > 0) { + return profiles; + } + } + } catch (error) { + logWarn(`Failed to read Local State file, falling back to directory scan: ${(error as Error).message}`); + } + + // Fallback: Scan directories and try to read individual Preferences files + try { + const entries = fs.readdirSync(userDataPath, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.isDirectory()) { + const dirName = entry.name; + + // Check for Default profile or Profile X directories + if (dirName === "Default" || dirName.startsWith("Profile ")) { + try { + // Try to read the profile name from Preferences file + const preferencesPath = path.join(userDataPath, dirName, "Preferences"); + if (fs.existsSync(preferencesPath)) { + const preferencesContent = fs.readFileSync(preferencesPath, "utf8"); + const preferences = JSON.parse(preferencesContent); + + const profileName = preferences.profile?.name || dirName; + profiles.push({ + name: profileName, + path: dirName, + }); + } else { + // If Preferences doesn't exist, use directory name + profiles.push({ + name: dirName, + path: dirName, + }); + } + } catch { + // If we can't read Preferences, just use directory name + profiles.push({ + name: dirName, + path: dirName, + }); + } + } + } + } + } catch (error) { + logWarn(`Failed to scan browser profile directories: ${(error as Error).message}`); + } + + return profiles; + } + + /** + * Get browser executable path and arguments for launching with a specific profile + * Returns null if browser is not found, which triggers fallback to default browser + */ + private getBrowserLaunchCommand(browserType: string, profileName: string | undefined): { executable: string; args: string[] } | null { + const platform = process.platform; + let executable = ""; + const args: string[] = []; + + // If no browser type specified or set to default, return null for fallback + if (!browserType || browserType === "default") { + return null; + } + + // Determine browser executable path based on platform and browser type + if (browserType === "chrome") { + if (platform === "win32") { + // Try multiple common Chrome installation paths on Windows + const chromePaths = [ + path.join(process.env.PROGRAMFILES || "C:\\Program Files", "Google\\Chrome\\Application\\chrome.exe"), + path.join(process.env["PROGRAMFILES(X86)"] || "C:\\Program Files (x86)", "Google\\Chrome\\Application\\chrome.exe"), + path.join(process.env.LOCALAPPDATA || "", "Google\\Chrome\\Application\\chrome.exe"), + ]; + for (const chromePath of chromePaths) { + if (fs.existsSync(chromePath)) { + executable = chromePath; + break; + } + } + } else if (platform === "darwin") { + executable = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; + } else { + // Linux + executable = "google-chrome"; + } + } else if (browserType === "edge") { + if (platform === "win32") { + const edgePaths = [ + path.join(process.env.PROGRAMFILES || "C:\\Program Files", "Microsoft\\Edge\\Application\\msedge.exe"), + path.join(process.env["PROGRAMFILES(X86)"] || "C:\\Program Files (x86)", "Microsoft\\Edge\\Application\\msedge.exe"), + ]; + for (const edgePath of edgePaths) { + if (fs.existsSync(edgePath)) { + executable = edgePath; + break; + } + } + } else if (platform === "darwin") { + executable = "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"; + } else { + // Linux + executable = "microsoft-edge"; + } + } + + // If executable not found or not set, return null for fallback + if (!executable) { + return null; + } + + // Verify executable exists (for absolute paths) + if (path.isAbsolute(executable) && !fs.existsSync(executable)) { + return null; + } + + // Add profile argument if specified + if (profileName) { + // Sanitize profile name to avoid problematic characters in CLI argument + const safeProfileName = profileName.replace(/[^\w\s-]/g, "_"); + // Chrome and Edge use --profile-directory flag + args.push(`--profile-directory=${safeProfileName}`); + } + + return { executable, args }; + } + + /** + * Open URL in browser with optional profile support + * Falls back to default browser if profile browser is not found + */ + public async openBrowserWithProfile(url: string, connection: DataverseConnection): Promise { + const browserType = connection.browserType || "default"; + const profileName = connection.browserProfile; + + // If default browser or no profile specified, use standard shell.openExternal + if (browserType === "default" || !profileName) { + return shell.openExternal(url); + } + + // Try to get browser launch command with profile + const browserCommand = this.getBrowserLaunchCommand(browserType, profileName); + + if (!browserCommand) { + // Browser not found, fallback to default browser + logInfo(`Browser ${browserType} not found, falling back to default browser`); + return shell.openExternal(url); + } + + try { + // Launch browser with profile + const { executable, args } = browserCommand; + const browserArgs = [...args, url]; + + logInfo(`Launching ${browserType} with profile ${profileName}: ${executable} ${browserArgs.join(" ")}`); + + spawn(executable, browserArgs, { + detached: true, + stdio: "ignore", + }).unref(); + } catch (error) { + // If browser launch fails, fallback to default browser + logWarn(`Failed to launch ${browserType} with profile, falling back to default: ${(error as Error).message}`); + return shell.openExternal(url); + } + } +} diff --git a/src/main/modalPreload.ts b/src/main/modalPreload.ts index 18141ec8..1e191702 100644 --- a/src/main/modalPreload.ts +++ b/src/main/modalPreload.ts @@ -1,5 +1,5 @@ import { contextBridge, ipcRenderer } from "electron"; -import { MODAL_WINDOW_CHANNELS } from "../common/ipc/channels"; +import { CONNECTION_CHANNELS, MODAL_WINDOW_CHANNELS } from "../common/ipc/channels"; type ModalMessageHandler = (payload: unknown) => void; const messageHandlers = new Set(); @@ -18,3 +18,11 @@ contextBridge.exposeInMainWorld("modalBridge", { messageHandlers.delete(handler); }, }); + +// Expose browser detection APIs for connection modals +contextBridge.exposeInMainWorld("toolboxAPI", { + connections: { + checkBrowserInstalled: (browserType: string) => ipcRenderer.invoke(CONNECTION_CHANNELS.CHECK_BROWSER_INSTALLED, browserType), + getBrowserProfiles: (browserType: string) => ipcRenderer.invoke(CONNECTION_CHANNELS.GET_BROWSER_PROFILES, browserType), + }, +}); diff --git a/src/main/preload.ts b/src/main/preload.ts index 7045e367..bf8b5a36 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -35,6 +35,8 @@ contextBridge.exposeInMainWorld("toolboxAPI", { isTokenExpired: (connectionId: string) => ipcRenderer.invoke(CONNECTION_CHANNELS.IS_TOKEN_EXPIRED, connectionId), refreshToken: (connectionId: string) => ipcRenderer.invoke(CONNECTION_CHANNELS.REFRESH_TOKEN, connectionId), authenticate: (connectionId: string) => ipcRenderer.invoke(CONNECTION_CHANNELS.SET_ACTIVE_CONNECTION, connectionId), + checkBrowserInstalled: (browserType: string) => ipcRenderer.invoke(CONNECTION_CHANNELS.CHECK_BROWSER_INSTALLED, browserType), + getBrowserProfiles: (browserType: string) => ipcRenderer.invoke(CONNECTION_CHANNELS.GET_BROWSER_PROFILES, browserType), }, // Tools - Only for PPTB UI diff --git a/src/renderer/icons/logos/chrome.png b/src/renderer/icons/logos/chrome.png new file mode 100644 index 0000000000000000000000000000000000000000..1d5437d428247c3713995a6698cef1c2eed0ba02 GIT binary patch literal 24560 zcmXtA1yodB*S-_PC=Jrxh_nKdLntXNA&rD|GaxkxQc9;%igb4hjD#R!&`1s?oznSV z-f#V^#TsVj+;jHX@jUzPvoBFv8cKw>Xm0@kK&Yaur~?2{@J}d!j|;vW_>P=`F9bm)b^tu*VgCEj(Ju>vtb+z?ES$fz2C=`m{-o?@Lxuu&8zpIB`#b9Wv5;$%HQ2atIOkcQ{2ZW6w-X7ftw-!YUTxSykB)bZ% zm+72@z@J_p$cO#(jwztz>fAsZug?J(MT--x(T9^fkpD25P%{a>;~`QDX{*}0R95+8 za4{))e&^DYMEfoN41|QE_DRxXJ2eF_6#XYI_7)m>uOuWfFaz;|T0@v`#rt-YT0%C{ z$j+IUT#%dO7&Bzhyt31s*lC>=$7zXxE3Wz$7>BBU)X_)zWVbO*ndUc$LhAS)D7Ozi z#ch^1zyUPA_@UGc5ioHpq+G|Ulw`sIW5m=@et0=&(xQuUQ#AVuM{_?O%QBk$UL&Ng zOpxLh4&laC6hafsRC5${0b?*j-h2}Q_2&$oUng@fDvWzFA2w`bBgq)Zfn0oN9$qzs z@gZAYa@T8;E_Ok6K*9Z@MWR@j82cP3g_I?B8ku_kHWINNEs6iz6iIdqyoj<7)N#A6 zFp}0PGybPI@-0k|0Ii8~^1Anj+cLlkCLN#^I0408LtXt;Qb9T{ ztgTS8f*snh}P;uKB3Z;32^(JXIu z^OFl9)P$HJ1}Yv>Xw@yU9XV{)2m8+=Co%bW-guAlZj%G5cb9bwajjQ>^WZK~ATo(8 z*a2OXN#1M1kf%ERID$(TCAaK&j!CtIEuqNlM$4fI8QixjWL9L%9T1H*-QjikZaan5 zM-mUqxzq0kZ`jhLG*nnN09IH}Fk~cV+51~hI0;wt_$|RX{kF20bgyWXjV^jnE zzW|n=*FZmT@*qO-8wrt3B1H%-@-zFqlu_o?Kkr#(<-JG*(3A2c zNiCXzhaYEY8F;xK8&T8-uQHauQcM3k_h#0xMH?lhXb$dV@;}EpU;lYW$Alv!3VWXT z*)auREPk+_LoIWi^lavfmd7F`<-PenCN+!1ZQsZR%w33|`7+BnkC~{V3%FXvgj99jC+EoTEXxmo zmW|j5ZlW#7k}N~-h1#`P+V%(ofgck&pi>gC(6mRgzy-7;FolwgxhGt7`GVP$qT9Sd zo_`!>2IEmekPqoe=n3)s1NyZ-^II0Lz}3hm{7Dv$a&f-@trjHhp(O{R-#r+{J%>dM z*(tLGA)>ytEVAs!$LL@yJNx~MPU^>LLCI$^aP(w(fP+dZ!8fK`ksE;5AOmqigflq_ zDnCEm7WJ;NL<1$5JOLVMWGpAZqu`ybIU$cL5MK()#qI%+9OFH4pTml!GJ?W$aDo;R zBDzp?|AZTVd=9&sdfC3e%>wGs3iK}LuOT_yE>B_m5ZF=q|GbG>l|UURdb*)0^Ke+b zz7lWA8ygd-xVMQ;-j97*J0E2BK)oEi^2f7>FY5%j#?P|hF1WvMOgCbS$K0L8^I)NU zTPrXw#fggyBxg=%EuZ#sR5au7HjNvN@Xvod=5qS{t+zpN<@Dq z#Bk44lV}f=QIg3DJgW<+-bQi<2|}0wOo7T`#AJLed`S@;kie?*GpV!b<6NiBhF66A znGi(T!?9gRMgS`=rV>>OF$6Wry4*?TJ3{5-vfj27D%}#w5||FK7pHtuIiwQRCnlG+0goT93!Y+!x8wBxbz1q4|D+MnBH z6uR)!!sU=JbfpZjP82?31;x5AllR7?$xE@LEnA+mASLNVtNkppp) zI-;z-M>s(O92F1?Fis-LVv{j*VZ42}+Y2MHp`ZMw8_O!@J*VeEL z?T**HW`hj)J(T1C@gMQ!Jb9k2a~Ai6ZNtbu$8P$QfN_eBqn|-D30h8qklo1rZ;J(T z(RcvGD!3b|jd&pFX`S0CTMO*t(Q{}#wjnuYi$eGZURXTUIyfLPEcxF(W3!$NI5{0T z8s1#T-WX3qklM1(KAksyG9cP-^HhwiN{Y4bF&|437edkO=VFeWPi11IKyZ8ov8!E3vdG?-z<|ci#ws$bH zd+qaIa4~zaasuuzF4vx)(G{HDyM02EfLHcnhjw zmbR%#uAFH`Spu>Zd5RH1xUUbz|D_;8q`-MKzs1DWTr(yg;O0csq?e? zCTNVc0cN~=s4S_rZVM*fHARz0+5i;( z6#{+xMnKhDc8utIH_Gj)CJ^60jY(3i9A!`z#*(4sJtpthUi@w_g1&OKWzEIM4>>=} z{(Mnr#YsO??R$qDcr=!rVsrANg^LAVSQ{Cc%d-34)Xr22+jd;y9kK989iaFRw)vJE z(bUDDi{-)_hmyq$f8{Rwd*dxUPzS7>X5Qtul~o16kg_)-_;piS(&|S4M>EW3op9#x zeoZDx>ViF7bj6MS_tT|gd46qPe~pdZZJmgHPzdrOf-tG~vNGO76wD>S%T>DZSgxPSG%tbP#V*VVXy45-^r(+t zfb7o2M=ImN$bxxt^gOb*kz{x9D!HA~#kbQ)nwX>>$tmU~0fLT;c|&unMC_!XTWOsx z4bg>>G_J*lf?*XOA3QBsz0}yUsC+-*RSfbp&pjBoo3Le(nKhSOC2H=_xbA`&{E|W) zm@EpgTFYTxs`T!aQ&i+jXuKm}vd254%?{;<)X9Mtin;G{v8uus!U>bUUTUK#qR=8h zId_iXnFzjL+O~LPBsY|-gWs&~LVw1xTZ9wfn-FYX;*wuroN*^1)m{q2{CY!qZv|!| zk`CPe1Uf4*^D-N@gOpS|HxarJ@#uEi*t^jbzyF$&C%IC^fU zNta5-`*uOL9s3KDvu`W?#Id+Ro=7WJxI#dw?^TUo3oMFD9f+;A?4Qt$(?5J}$;8U) zYEKd_)%xNpTFe`dOduVgueTpM`AC%62rS)zT4Zt&?%UyLD*j6?aAou{089wlJ24?v z2;IP~?$@Y8R#Lp}Qjdlhl%oAv;Q`_u~}X!T*BBAj63jCuiji^9WKDJ zzh9Wd;s0GO!7-ZTmP|+B#Yx`mPD&jDor&mj9lfR_VY`Va#R|!0mLEZn!3<@cNHHz% z3D7^sfFef!`%nMx@!huz`USSf8N&Hh-T_XTdN0e-P5LN$aNV@zlliaTIL0?(6(aH^ zahvwu4iyi@3xrTzl8_FP5)rt|)tFKw>mfjE@3BEvoB^5}2hVNNamI)@?{wzptP^$f zsU!hTJakh$PBgp_uTh5Eq@+FhKQzQmhJwG9z*=DL3Obt*Ly+QsHN3U^@+BuS^8L7y z;n+XN0>YyWqHaQ!1i*Ub*|8dy;y>#KkYnK2AKylJ_0Hq|;sTb*)hcj7;Ja}O%K+D| z?jfcPBKZfJJCF(S<^yY<rxFCzAmhI<2KmNaU+-0KWFLEYG8j!=bP zvY^#(fI(YFoW7wuGNe*qszfsWNO_e54H6$E!TB5(s|h0ew+5$E_1rn)_}>VmL8%KT zTIWA~Cniv*Wj&uMl6-rlJi&p!!6k7F8BlOTHQYQ8<*jrEbC=(MP9GDd4uDkNhcFI_ z0}epuAtmLg-?~D(`u;MQmClolSLRqELp$Rz0yi5Rs`Rg7Nsd>7k-OkpEOsZ-=wNwn z)*}s6fl?hBH(flOaaUyhNQVCP{V^4FYCUe*zLKJ%`=f2D@CSJ>uFlWBl!o*X^>B-m zGQ9tsW+8SJt9))YqpiqlNrv86$sfK+NI-=}zGr2(P1a--dxD~OP9aePlnSq8VgI8u zMLuy0Rf4tJ7pG=3A$6QAwgAERW8embwJ&(9a2UrJ6pgzBp}P|(jcZP3a$8j}k`rf7 z>A2!rwNNDGQ5ziSSASM)-upP5l2`6*PSoDblcZ;5%@rlZxP(f8)2rwi^Y{^#duLz%snQ_cfUliP=|Fcd4jy7uZFOm3QjryuRwWCiN`GN8#HpV!5APp6?i zd|)YK5eA1AjfC?FSz|Z8H>?va8M=$Xu>W3PM|0u^14aK37v72$-$}urdbJjs&Y70b zla?k`poTH)icmSAHh=u{q$GpmKFsAcDA^#*sp)IHD!9|jh z0zEy?y^dgMORU&r*o%KLvZsWi+5Ucb!t$}5eV~!8*0l`#7ee0X>w)aVZwn}5uB2J1 zFCZUSd%8b``reC=j3iU7;BiaZW=4yrL;3hz5r2oUWPTSL#_Tt7?Gl&-?K1^6D4S-cT@D2E{v@pZpvVBXZmjFYR4P1E?9_ zG&I;5`W7&W!O2Lq7f3KwPj-^k$45J|;n?}ofX)Pw=YN*w!j~41s?*bzNKmVHdECCg zw-3FPI2%g@tly0q$bWFXH&ylzF)f?<-y$Cmj+pvC%PaL(Fu^vDHtR;#9ct;37;BNm*JY_Y*}y?t;#}B1lls~+Co15_hf)!x%a6gNEmiIbuy&|$sP*~WK z!snII1nJ+wYq49BU+%2}Y>TUCrWf^2J^=FiZ@YcaDPAFIcv(nQDU1BMW!Ui-{GCwz z?bDqcXU=KGtr5bi3LL@Vaw5|VNZ;9cofJ!1IeNhBQ^!t4d3T(w%+g~6#eV$num0rI z4OB>59IhftHHFHyr7e9n%OvX-@e6SuFZ!g%WLUr(zYuJp|1=@onwTQEp%L@9k14xm zy=`_qoN+PeL-MsDEHR)-*!T4`!|`|Rdv7SWJN3in-8+X7qm9(<5CtrqAa!Tua+sE4 zUIIkYn|X62q&HwNoM!q~Z4kr2*BHSK+1I2p9|V+hPtsyO>^uX+S_j|piU2sZzrGVh z)j7wz9cIq*NNxyjY_+-gs8En5q!L~4YVXET!>;2=1tojNlLE_VKF$bk$h>~qcuW7X zmqep&J3eyZ2?xOeC0m+a$#!1~4Vpx!(%pX-4~=SJEG8GUG{~AVC4caJeU$1-`}sW6 zL)`YOP!{#tL#BQF-f68xWwgq{pp(F5n1NASsxO2MWaKyNu_M0# zDIY)8*x@Q8=rcy=mhrH?0p6*3l-GfVSJ>z$D(Ir79Ck_A(6n`fh>M?QKCsF`A7gON zIB-_q(#0}V3_bifYt`vLdRJ0g1{VK({95bQM`trn|M1s#=QfKqhY{Ydkt+S0(|33Z zpD1GUhQ(JKL!RGZP4^^v^>77)?-Eh@*Yu<$4THZm$UvJgwV$wX&VrVF>XYk(dlOxM z+Dl^MS3lO}=;ys=|J(iZqCRBIgNF2T|}3 zW2vXeZ`NEo5(lKAEAXe@9}b3j{Ey!9LZotQ;u<7eJs$)$W=h7Z5SBWk^3-K+^VlR= zTHCG>^(KXx;+P*=oe*~rvDK-e#1+>Od$Lv|3&A{&W>34~N&dwBG`wwru90E2KrU3D zwhE<#UJ@Pvi(uZ`yiLOW4xpYaapa;wlwOga@S2A|ZIwp}D&7qY*H#NhKsXvFdDH-5 zrnVD$g=oK$$+)k-qaRDPSWv+Q@bivHaG{@iS+VJ zT*QA6CBud0lgGP%TNZ5f8fGA@$Kdb0zQzV)E6w^8(VrD8%BbtDZOSs9*1TB5U$l$t z`oaX<)lrv{5LZG`Co@AixY%~KBjPXr(LFrj0(#dY!#f?dhPN2QL{z;a6e#G(R8?_$ z1LA+4jH7P-(0W0KPBv_0;FVEYSTjJ?XaJxu&rs3Kos(XKMywINp8QgBO%QS!97;1? zB1w_!6E7E5#+d7+t-jL`cS?Z1bIUYF97G)u`KLux{hnH>A|KmQ3bzM8*mvp33GKiO z(~bDh(0gwL^_ggBVStI_?tH{tyYS!guXXk31-LcCwT)2aNCutb_If<79V;O+s0FpM zC)AvaK*KA+gvYEBXl>*TfBWH})eF~GP$!$~@5m?kE(hgIkqJFp1ThlYa z4-wxlNpC2e7`w-mz1?d(m6)=7o)EN$iuXS^pXc4Kl%)5FVpt8GbV21-V}pS%@Ppij zq>~OqQL5VBxq;qxX^h*_q+!Dv?ifN@0}TCmzaNM9hLV=vVu|v$Td8cjaA!V})W>** zM+vzG)4nON5`{54`Q0IN0ue+a2LtPBe#mWvKdd;3Go0Z0FN^*H5!*rM(0X;+=Y-ZW zqfaTiB-jfzd~hcNa^Cm1V+)Yz&XZ27)H$F^on6*UleU?aTC2Gq>FDk=1*lR z@HL3~`jvknFMn(H3u6)uy6m9rP5cK?LE#fsfpW zJ|I-O9`{{x{Pt60En;%JmZV?&DT-T>@0(oo^(({s6iE*SAf*5PMY){kG2>Jo4PgEl z8*yRO!hS^@?3hH=)jr=pCrfj}D=NA(VUDv|4n2AbJ1>Ek(77E1L!T<)oj3F;Luz!; z0~ApM6wD$x+yjZL={R0}$*buc@i{E>J+S*j$_jyn8WQ^=~69H>#)$f^y4-Hw@hJ&d03@Eh|KZheCh6o>1^Y zG0T99lPlX`#>3&h?e-u#;Fq3?WfrXg_4O=yotS(m26bg)GaE5Jx87EVg z3}VReced!lAW!SM;6{1+mqBsIw`uHXa}Y`n zZR5T5f2=QB6MT3cHnE8+A~LL*#aujq`V(!$a>^_fs%1`^FO*p#=H3#$?#uF%9MCZJiWp+&2}v==$u@Vkm~ojIWKV5p zVE@U;@BZxiq|Ft?`A3sK=Q{D-J4o!=_j0Lg*-PGNy7p+t|ebx z4R-0>J`1&aDQpi+SuFL=;Uw!ocbrIk|H5FcU`aP=qGcH>^7Wy3v9f%(C96dkG5r&$ z4+!UpsRXP}-lQCFShHiJx9?h52GOYqp1?4l+i47wn0_2Fv~`YA2R|s2=@G(&hl5 zRxj_%rbQZZF}J5@wJ@Kf8oOt3WF1kE4&SdBzDnu|543Bl;ZzVw)1o)5BVY`3CT^^e zpwAD&9%<~H$oNOCg{Zrb$djbI|K?r6yV~g(>QPc<3NeA1xLQLlBrjVB%;VZiY=DTt zJ7Pa2`dLVDhrsUG%HFZpy3O;KDkgsK6)kNqE=uWqaE2PLZ$0+S0TJ>vjAhX1tr6Ma zOYd$GAe@+r`5Sn<*5sA%G(~X(vGi-LS4QNTl_W2gAf(Q(EJL``5EmCTR8<=2^RT(N%nX+ZSM-%y5Uo6>BaPPgP#5w| z>8i;JAGaocJ{2;gZQlMHe#fH zo{-;@of4Alm(E=GW^x1>Q*W^GYPa{kA+IZ1Scm_Y?k~@rS+w5Lm*E77jAQwniNXv`Q#bb8Zj~|;`x^^injrz_`V+1`91GK^!uvcP)QcZ@;PL7BW ziYf-I`Oa|y>?~n`5NB?D_#sEqmB&+~jISHuEJK1* zQu*=fV7&V1Xk>(q@Q`L%M7@iq#ftr+F>H&uW@+1A+=6xl{p=2*mA)>i!k z&J(3PSRmButOm#jeKP&xJHds+~x&fqF1wH-wdbvcRq29;5PCVOZCo*6R5t%6X#M<w=Ca=WztU^jgm6yBlb_720R9h{@v6-|6IoS$v8K${};+lap_r zJpu9r#V_Y@uNl*01cU=yx|F$Z61t;uHItpjfJ%&Oj+oXA_T@N*oda!1hPiN+d(OEE z^7$;m1W!-Jv6@P!poZ%qN4Y4g5C`WMUz0wPCKP-FUYD|adJ|yG>Zl!^2_N93OA2;a zHYl$&iWBlqfOdNaMoPJUJNOoL5b8XCi8(8(ye+Q>&U`#?{|5-~xyKymUrGng^7QQB zyM_10yhBbaZ^&GS_5QaU4Xr8siz^P8BvJEJ<>F3%KA@xcHtBmogRhPA8?g5Jo*dY1 zX$1>1DREU_7?$~}UmiVP_Zv3Z`iG3cxS9*P7yTS{;R&IyfCXLW&YuE^I&fuEqw7@!Mv4!=YLG+v2U`cE*qP*8F6XYgfE6gn0I7 zsesNew;k1}U&r_t{{kzDbKlYqx z{Yuiju^BNbrbB%XO&DBNBRsfDa$H56tZ9RAc1N??vx>3_6$0UzSrf>VpqM|fwR4VQ zW$RJV5tSYjJMEe78nXZbykbHw?Kv6gGCxTY1crqiyy@6@hVnF^F{~Y~-{w{Ub202| z;u*Cix_y3F{4_BUg~-E`lY@{afuFv?k+&W`c9#0Kya)Ma%*DK%ENm$4-#Rp2a0&et zfFXXOv-r&hxBs~1-<1bcnza9g&sr+C@O(iUf;dm#`G(nug%;;!KbOXjUyHPf{c z7Wnib?)arj7P#-m-$=nt4YJ?q!8FXiIru$_NzY%3wAR4wz|%mL#)G_4^7v<>H2UM) z6$ghq`M5p?Ekk!Wsu?2CT&b=1Z(}!JY2hAQ=@LXXa00~U%dp!3PlCEPG|ZmW`Msp( zHQmvz&qwwi7p;jw|76Hc0^-za0Y4GnM}j?_g3TTWh>hP|pKEIZBO;*6?i_jSwH)6g zt|@HRiMsBD>=kz2+syl%Q$;v^T>$CLXP8vfffDlzk@(D)Sb%K&@xWHoowWWb@OTV? zsuD|h=5c18j~B7b`6vrL;@Q5YD*LNkc=yq3UQ^OQn<)skhj_+MnE0>Rs;cagAJ;ut z<_S^i=WFx)XE`Ae(q>#3F<K`QMibqDeP5$y8 zQruabKIiOqLtkmP`-;l-x&2FP6c%Xg`pD)DP382>LMPYopta87bpf>){@G(qcZYL1 z@LpiF7Jz;n7^XOuo8`b`^e4lX!kKrfX=Z>TQ%t_?OcvG~3SAtf;`1BqdAX@L=)1;t zD5CfAASvZo&WxjGSH);t;QDB^bu=|`<@$l@k_1HmmrPa5;fu@HTU9|~CcGGVit_r_ zkq*r)q^5XKeTVCkWy9+EGEfgcAQupy9pizxqvxMa?lk?~_^%biHci5;z zVYY6R=-e?yD6@?#-^bquC6}Cx^r?lCZzFNO=9JW3DtKe?`p5O7te*d|4GFDxILEgs zGWTGMOVl7ope+R-goagd$^KE{bq>Fp6jX>@259DL4Ya z6Zp)NCI#Rhd7cJ3c*!t-E4aqg;+JKql8VTeC6x!4&lGh5+Hi9QFoX~PHah`$HK zp>1`z3Qum)V$2tsz#XmP=Wlg1CTLzKPt5Wev=Lxvfav~Ua$sJ$ex-@q;mu;5209_> zcI|UJo2>|Ei&1?}AbCP&f;E}Z()+@c`NxUb-8^x2Ybxw_4nV>u3l@jT1<{OHhs5^?JjEa3{XCi|0`}eU>=P=BXrkyT-UZsHj!8i7lB2=eQS7=zi6XxzdzD{DOLywc zxZ60m%ziO?(3b%gL=ARL9o}7iePIcow(ob2UG~4{9CG*$@pe|@Wg1e}9f$bEDYmmP zgH5h-3AbrMI-IG*I#HC&2-noV8opk7GDXqR$WW?ff4(F%^i2$)F*g{{&fwcl{rfTt zsQt76N6zb#C1^}h%sI0(=VY+5C}z(qgP|4DY|4ev6NgzyVt%Ur1meY_~ zW!Myz7>wQ5Hqkm|Ok)M8&q`nGboL1XMWq|66}m=TNb8a;D{Cmy%wY6GL!iBzeA=XH z0j&j(h320$+j%C~XxT8Gkpr0j;qpV`8;gVDByrOqML+KS1blxl!fLB|rH}lw5^|3! z+($K)6kB+5mW5~9Xp+g^_s{^u4)<}kX^KDeebU@A&eyPP*30$%a3;-v>aO9;Ar8YD zx%z1K)@W1*(vqaMG)8X~wQ;6!Gja##+}>U*5KYX4?AEm^^?*nKHZpCxUFIYz^yyDD zVS69e)7Sx@Z>;p|FlN4h`L!Crk`Z^EOy3#X(!fCuI7ouh&2)7=H{e1plOYY4bxF19 z5ykg>dpN-Fz5|$M17gow4FK#CAcCPg5j@d&zo=}iOkK`h@ssAY2y*S;FKY-Xux=$Y z79>ciDmZgE!2m9E>7OAO)93qB=3Ged5cb!SA_WKDBPwqw5T@7Mj$|aag~qeX+{L$$ z^WT6MWkLj-5b=qjn#)d9|C5M4$$t{m_c9sU9;lY8R;GwkhmqW!TQPxtXjmjb3g75E zXRDa}gV(um{8xF~C3l+WxkYFu0F;(bSYuK#W!xKZ@uyFPV*mOY@l4|(XH$!t^yB9BO1Hqnj@)bn zk}SGG16`(_0l49!KWlg=@@CTJZzUCu(8VWuK2}-X);_Ibn|{kqN5?`(HwuR{XQZfq zTLe8#UKAh_gXm<8(vZ9o0aRZ67b^YHCo6hCp%m602=wQ21p+nCfUK&~pT84UMrZ(W zSbSb?F*#PR^gNzN?_^$?f>^?~Sk2@0kG0_FurjecEE%$u8 zh4vAM+%TOYw^X&T(My*^swj!Bc-)nk#EE~8-f{o3_s0>kyEe0__OgH?84ZedrCq{C zz+qgYv}8XmXSCyG8UU9!A2iTynllmVa^Z^Tp5kwxps9*xlCFy`QKYkl74G|tJVdnU zq7eqXwL|R)ihG;04Vs+(J1#cv)X>$`?=k45C({2$&0qr{>qLgA!sJ0s2|8L&t=||I zK%3#HW^fG^*tIW60$`6aJH6ioz-|ZNpAX%;aJ1OCoqX3006s)45Ua47zPzj9(hNtE z3U#xmO7RKjnV&Q4EatPcN5C9jB^znduoxC_Kx;MO(kt8!Eps&b{TYrwQJIi4_G27{_d+G-#T6Eq%ORB|tA-kY;_z zmwSkiq$d&)5fWe*#*0OH>#GHQ*Hjc;GlzL=Fa61h3`JpZqe-@9?ijfA0Yt z4eo6mlUi164v*dN!hS?=Q&ex=Bu`)p@Zm3b=Awbzt&K}L^7kMGpSN%#kG@lRJzkm> z#TMt$4Q4gp;OJ+AdRb^}1QYj`rygfEVB$&BpH5v6OT_pKDdEyn_Lt6=;0EH6>GAO4H>- zxhhiZP=cV_AIoEYX*xE}QYW`%kFJ4$&x;6UjhS!Zl6aoDc zAU4STO@$k``?QS2b&~|-%cw&d1UzS0%`C6GotwUq2qV}oX&~0P*7fQx5V-bof_^x9 zh1w7M@h50N8r<{Z`W@ACa)6U)0n|{cN+|vExRG&vDaA1|o4wm$you2tX`@Rnz#;J^ zw08ti)#xneTR6$bZb#si%$Uy_%`psUY*dQcKX@f+0dMwxlRE_F;$Xl{>75ctd8lWh zzvl07ZwZJIQjbT}0?E{>=Ou0DOhCW`R}kx@)J3b|%*|j}p^UR7-8`V_FmX+VC zzMFg0Vv%=N7+m^nE0NCc8DTizpVMXY*cliz1iZCi^*HVW?~Q0v@4@pXAuml_qgrR%5v{6Z4z4zA+I2 zF_DU`1t@a(8SP4C&kT-*-u`CnX^Xd0ai9L-{B>{66VzGv)k8tL)?vO8KHsN)=3G=N z2mLGI$Y=rS+vp*g_47T3Aca7#CCi#qI5L1wlG)+SV>N6$sUJSSKe5KBWqu$6qP}tu z=y|86MvvbUcima>s#0U?N5+!-&?gZqDbsoi|FFK#B?c|>c)+~>RyOXN($*`36jxjdAjqvmuYFc^ zgcgF0)}8hhkk~cg)0(|~qq?@hX}_&S0mD1-Q0zV6k&R~qljqRg!6uL9{q{3dV*2m6 zS|HEh4-jS5vvLK&lo;K#Jb>%jGa*72vwvzdS(?fREtyFzJs>9d-gPyegUYYGerUvM zA%tL-)(ODaEYAFiBP$B{k`mO{m3OkcVJ=bK9Zka2N=D$PlQM^q9`gz7ALrfSl{H;3 zex8g{7|C0E&ajNSf}#It5!K3F?vVRshLAIEXQV>_koMD7qHC)B2H$|Cm>=3iFR4I- zPmhY2FPKnmU*G}PAFrE+i2X918^I=%6w6>J+V}~N1IS#-IbE~0#(w4nkc(9>M5rY6 z8%H=$k;QJxqwO5#^TnW19;`OF3rA)ikjI|p>{8AO1!_pUE$BhC*;7%w^PyrZO+yCm zN>Q~Dp5TJ+SISZC4AXucI7Bk}7kY#o5T3El-Bt7T44L&&|SOr(A@-baP_TV90@ zfAaImOpUnE6dTzqv)&_*-6R9;PpC8kuBQo1qR6q=Wdv=~6YGYk5v-aJD`yw=l(V zfnk5ZPq#B0uKUTIH=zE4x_~-?bO6x1!hUlGd_)hWbCpLjFq_0P4@m)lD%MLdk2eX3 zE+?ItnK~o}c<`RBBm5`rbS*MVg<-`<9B>onqw&toJ|M=7@;Cf9^`r)w9x zc@urV;+tHe7bi!D^)I=q9E=VktZe^`y8;(FwFor&%PCj%yPRZMbG)^mK7TXsW7F(Z zPMVm|li%#19TIA+lB4B}`AHrY4aQm89xskuj%>*RV>dSDo}ZFmr0oTq%s)2h?+TrH zC!bvLNXb0f@?0*CEluHS+zfVY9Bdk`zv4#Vf1a9deE$R}aU`mQx^*_o<-WMw{$%ID z8(Lp+i26$s#S{7tuH{ugl#(tr(e|a|u3kj@)3{wh-X0zNf{s5Cr+<%ui#=G8FM4Utj2i@zq|K|Cj9q^46nYA$n;xo5>JM~>@l z_)PF44E8+9O1(iUyfG8Vqjr7uI)wY{7}IQ2giOwkL(b_MS#Zt#4S-5XIUv|Pwblw`pYbI+?Wv!QTj{)HIB zZutXmCdTPk=VNW_FFPK+R!W7{n6ys6UU>Q#Xj%*Ct>16&J=>V1+RHl7G~G!{42zV5 zYNc3K!rcTpA*6vn0yoUR)LcF>(=FG_AtDQI`TaK=&7PWpd$A`^cPY^#dr~Q`75nOa z^XiMe>{~jyl<#=T%~s~l2rvf^MZ*j!lHw!Vzb25$8trit;cb}gv>G$+M%;F$*!4mV z@eCw4EgOrzq->f)JuPBH1)ZQSm@6kQfASdJqwJ}yf0O)!3jPP zxZbnHgw4rFeX%Afz;H4w#_)EnhaI!=_$a>V(Dr1sj0n@isidBoY@kGh13RGW%Wiz`~0_kM#TAIW6JM?>cIP3J0& zjH(f7?cQRPlsjLrddK{oxp}oJZ_RW@QmGnFlQdp^2lDj$s@)H3IxJlS-zXWR2oYkH z3e_%r6GPGY9B>_s9n>I)#eLNN4EH*aFkF#)z400jW^&$7bQX(?Vo^!Fy1nvnxE*0D#{lg%N4FXywGHfQ{`>0H7P*r?eEt<}Xz*?x`-wS` zC*zM)!SuhC2**)qajUhu`eod<767!}X1|j9@x^A51DbkO_`^D70oLxH7~0D3qoqg9 zbj{E(w%LW6Z96UnkC1;=E>_EWs4PAA?2Z#;*v?5MRTnyjSnxgYc)BuTB855=_|5lY zxuu*BJSLZ_mAClv3??k|mH8Jr@Rdp>!1dMJQVF8g4dQv@H#?ZvmRkU_fTh~uchL3) zm)QS0Bs`cCnW7ztMEs9>e!&)Ay$DJzl74AJVfE8qR;gF-301S$;y-CQ}Jtg4hx70;5iuQiqjvV}CED~jj;6t_k>_=q*L&U6oca)UiuN$hCN#@hqu zh8D|XzVz)2aP-ig0D8%hBBHM zaKNFNcS0Nn7PKZeOC%%Tq)vx$X*3hTYFgKcR^U)f)izmIi(2x|9p8Bc@B{^?bDDgN z{X3v~rfMRn^Yur?)nBKg$=7Og&BtM#PdU-DsM0ZV3J$K2qMavk5{)t*w3ta8<+g}g zp0(w(;l1Oeim{i@ZpI8HuvuPN4+rdNsyvKdl$A6MJ`VJ&y12-!)he)+7rMQYPpw1LK;}JsKfIouc zt(5opB|ts?efWL#vA z%#3SYab;ciBKvah@9q2h`@GKkbo%<<3V0OuF;#OwbpO>P}KEQ-KxE~UyzwR9GKj|8sm zQAjxM;pV2){A{#>oJ|CVBpUOT1gIjJ`meuQ37NPjflv)v%aBa3u&vKh&IVP0U z^oY!Y{mOeQ_=|C}G_{ZZxi3h@c=f)*NyZxWd3x3zS#<*3EO{$nglk9BjsBZVp!Sn9 zOb9aDEIG?dxu`!T67D*Y{QQ>&qG%#&%YJ<-p%Wwr16*8am+NatES|!LRh{ z(Q`^nC3(qK$QF4>33+Za$@dD)(jiJx6(iwSH?B@UP3M_hZ@X>u9{`qUr<)xrT6HqR6kVhbO$o=ZNp~|z1C3<5|5$Pa z)}Nt#U-y;Bv=M@+wm`WcU#b37#}K2ji;)iUssBNy67i`{vp^-iPH{o%$WL%*Ob;%| z6k+t-x%cCX;ohUUhMUiZWH`z=eSuSuQP`3X!x-V-VW;E7cI`A>7PKiQSTIi8 zPmStX#Ma&d%aH!9k}mDA3o9yD{54=Z6)Q%(Oii#<7%79HkAB&)f^T^VeeB#*-lWuzFLtTmdq?tHrHzW&Xa?j^b?1j^2LT zg-oD+hZM0OM2Ns#`pvpIPQd9X77qSdm*&Dyydw4JJlB^YP-esI%A@Np7DYy3>})~e z7LhG8BdRuxjc4`YeFUu-Ru=-^;L*BDSVvp6^GB`11e?{)QB+6DRaqM(ffoFwAibG13&8%{H|hp0YM?UORiuT7HB%qvQFFtfhzw*8tMH&tKySgN*E7$Lsq(xv~M z>JyRqdTl`{y#35pge~^;Ip&BYa2&BdJwn}SWEa}Yz~_!ylQ+IhQmw};B# z=m*C|ZF4Sn*r_v*G2EBJ_2r?QD-lC%gjfD~nhq!Qp->r=+V=;pXfsicMSJK+>oACz z79Tz9)nb;<8{7M!RjMsp|G0Y z+KAypxudx&eO%W@9<;9{#m;0I=;-RU4gtwi8v2~2+05mrU!!{)y>_$dI{QmC$s1fkM;!OW>*4Sk zOLI0^0s(qD>MbF|zE-FdkOuM#JV3G?) zlLOvUnn93UEfe{Q#nyrXc=1=A#d>cG9bajgl8ttuBd+++n>plzZ?V+ooz538y z>9a6Nqh887ELu6RJ%FRSTzujzoyE7%rWU95#(@)&JS5y4(3kMv+JbBuV#hQi@R1QY zutOTjZbHb2rO#r8u@yA$#w6(NpNI zm(EXhgbhdf(zB!oQ8RCC@g`p!EQTiMVOD;hVMCchB^Ocf5!Z&Z={L5kPmS4tCxijJ zC$lYYP^H1JJbsT9p1k{ALwdGqr$Vu1ti!V;U983upYjA~x@(U6jw2bA%v&d_GuBtx zeD79gK)nfJ>u}fpt>OzW`;x5{ysCnKU&7DS`W(WY7&1V`i|{+s4IA&QY{EK-9$`tG=0rLz9KcmwEwY*9g`evw`eMtB_-BoGh#4AlDp7#0XKcJVjQ<4hCKn3vLmot-9>n z32!bGI+oCc9juxj3!qdCQ#kUb$8GW~)ozztjKV<>>>!j9il)#D)ieqa(biJiki{=M z>(P;|nSXk;zudkQ2=u~>a)fB9lg)Hc3rqDnXUAx!lA0RbpN7wLqJ))1ztFZexE;RO zm?(-`IKuJW}7hQ0BzgO*(n#UhjR;rf8~$4h}C713Qb?X1a8*!Wwd01GZD`?F0KeJuF69 z5`Qw&7JYgkm|tnaBCpuzSyq0QQQ3!AIUnZlJ0(?{#KI=H_L4CfJmNZ;gxT2`G}gEiVB8-E7QC)>R$gyeTVs9l>nI zDbHEc+>muTs9;A90NalVyuIWoc{M&?VP2vCk3@`L=+Z8We~}!s`~v`!g-qgBCu^3_ zS6i~09%unUw_qQ8#rS2oxYiMD2lzgJ&XNAKdg;9{a)mGMx!!-Q!+-u=n|B}b$Zx5xDVdp6%;mhAS~QwD z3dY^YNIM2&*Q)3p+F5zIENvJin({SX1Zc_pNKJW0ti>@Q^>2^?$)-tpE4$Kmts9W& zNcJ}H8J}3OfuVmd7}VltW?6nr^wu$TBPITEaA$r!eyotHe*#xY#fk8E_@*W@m1;I_ zquXEUzGq@|@yA?6J*+9b?*q_jJ$m!@mzmBlAd&sPQ-Whq(gXZ`{Iffv1bxfP5jpHh zE-QeTq8H;tu!ZiqputVLTK-a(fC+el^Sve#fO~vaadesU(q;^lwK8|CZK=ye6pmGy z2@L=@P)%#^Li;Y~)Yzg@4;^5(qb_!8nS#?;Q2AU!-&m$7kCZ3O!cGn>zES?|eF+I7 zv*fKIIc`44Z@Cyz8_6ULhDf);GLO=z#j6d+jktM0*O58LmMP2V!$sb3Ho{}@-D^JQ z)x)eC=}vnnJ+cmxOIA}CVhr5eQO0Uiao{+HJ?CF?YHDB!!Npd-& zd*p`(b8L@?cdrBX`nSmZSv=j@Wo|FgqE`5j-F)y zpOdVq-p>IPlqFR-;9}Lb8-8%Ya(R zJxSL=-_6ECSnhZdOh{wU&NmMAakqX61^`&SXM|GdgU9Ah0&YxnG$h0i;Xq>D7zpaa z#+NP_I@BGH5c1%BrZ;9MWJeafWp`8xK7xUbbz89Vu0|qk&{VWQ9~kv>n=JDfuBq%Qh?dxdUR8j;JMs7S@PfI00)zb5 zL9J+L)_6rSF^Ru|qPD$pZdMhMj20F(3m6-(bBpgp<+YHh#c$jbz)i1+Z%#ZGls1kD zLfKPr(+aOYMvT#)-PQjCiP5?Na`|=7#*V_N_+}N`DphIR#WXy@tcW96I#O25_3q8) zYrb7@=0t8VZ%yz$5%el%CLW9d3H(u;P>Z-Q0{P2r@GA*s2IJ*(IR#zJ2E{SX3lph` zP&73y;E-Z}a$4G!ho*4H%7Y<#&u#6tEtHM0(W>xljSS#&voJ<|``6|>g$m87Xx<>L;gXnHHAIgD(o0@_4*YQdd`|KrDGN4@cIS2^h zBbkX95Q4i=-~I|vws_L|4S)bWYOXV@hsx8%&v1KxiTl{&uPMZI8>kUrQ*ex@5t2*|fjzbrrj{by9)-<7d7n!ETo{gV}ds zS8nm{sc)mGfx>Y~imZ=_&x!0(@R#rD&#)GBqkm4sysI_X z8lFsA1NM28pY~5*1c;z94=r;+Nzh!K*mq^lX_$o5P)g5`n7MenwC`rC5p>8@fNZ&- z!LbyZywHFCcJ%UDk)8XAJI%YAbnCCR%!{Kfb~k8LfE_Tuv4V0*8fpq;mn3yexCD9l z`fw!%9-5qoYC`}L*=0g8mtKb74Y_d;`_SSefe8A(rfEl;Ux?8DmPtC5jvZ%{CO+pZ z8!*Fy1_6g?bCdHm{V#m%?{b)l%b-QYCrG~FZEVxkRsP>gH%2v54{H1`0dGW7ENHl3 zGHuVcBw6f%eu=J(-j_qcfeWeaUS57DBT(rem9F^g?Jq`|Ts>E;F z+rys2zLY7sr3Zg=9%$AbYNKJ|y=UHA zl{lP8QVy8gEvb8*DyMC}Xeb`sY!{5|_^Zt1T76^&{Wx)9*)_gbrQo3*t8sq3veO?`0AfdCZe zLUB*~?!ih^DBV&~4-^UxV>A`zH`T8{`xjurkMR81(t8TO8hs2;lmD#G&b-nJ8}*yEM{ooqaBTqa~!Wy5dYMb0O(~Lqk%ROob$B zBtxbn+OU?xpCsUdSM$%@*5U$eauX8%>Pr{qPE}C#T;X;=L?XEK^n;JuRr&+3$V|hT zDaED;RJ}NDw1VQcEpO%_hIVw?TV|uaS6X{&9?P^c?C4JW;eWBOJJal3Cf)H z?UfJ5i&XW1;KzR}5fdex=5g3CXJ3M-`nt+V0mr_YR2PU<{;l-$yAavrbf zL)UocZ<&`Q6Gtp`tdMgkZjPeynnj(%*3Aq?}o zR!F9)u+Z-=e#0~zV+nN=0Qaq%*-#g`0$V`Jjb=Anlb}=Ip6j9Xa)aLBJaekCVn17B zKb;&Hh`zuvT+{1vS8@x-DUzbfH!U^l9fLxwoO||cj>3Y#t;@m|BpwX~PvOjK?ION* z!J7NcHgxHfqXVzM%bT;vEhBG9$F@;p$nUnR4F1(qkV5msh2y2)by*q} zAFZ2{F7RZv%x|SD;UKY0;CtCCs1@x$8)7XteoDDvsdD?_1^(aW39arOlEZ(Kh5h=s z(l_uddpcynS9H8a>PS)w`2U=+-yea0@i%-GpPD~^>uJ^jM`1#1{Av;Fv8lOpIqp8s z8*tho-Yac}eSExFDY@Gr!l<|VA&x=Dlaxog2E|u@H!2m+pYTP3K<+ACp2m5M-u(3V zK@*eU9)}PM7;AU!+?%ymnOcZ764mAivMBa=5FsH|WMfxAz8JXy+dmX9xk(7I0 z^A;Khq!Mm1n__Qgm3O=8P$6pZX`r?Fbu)8GZsf(Ei!LAs_=@1YIt6ALqgY3Quh6~C zgl^x@Ori(jnN!Ggv%+>dKIr-G{))ZvDaEfaWvnGt$xQ^9Bm>qIs>T@Qjs?YP)L8bk zl*a(>Q#)+zSNre(NLh3)puAr4O-~WLrv>wb_;kOUC!)KocLkKJY)qsdYQx3>?ziIa zPz8pR5l|W*%J?Cw&4pmcpuSfuW8tf^YCk{D%j<#nbrZDs!R7l?68~PS&UP31a*JVD zjO6%X?kv3Q(T|3AvW+udXYusUw3n}UR7&7!d!FlU7WrQ$?1J3qD5>JImZ0&|zXmL% zjPAb^1>W#H+rbf&uYBoRpfV6CDne^#&!zwOjYI{_{Xg*P?|D@4KA^W-C*k7OoWELf zK~G^sqGux_uz(YS;AdI_A{Z#aijqfD|G}HV<$#fFJ6pyO1&Vr=Vw_ooRC8VW8ZULp zQRDT#nAwo4IlqM+)7zZGDH%JDCb;91dZVW9tJ@<4Y;) z_3h>zT%E9=Kc+ey-!;G37B)|`$tO|adrUr@EnU_q=w%c+mO)VxVYdSANe7+(Hi1t02J8Nj?uJ$8$xD}O@s3*pk2K-80X4}o;(}g|!Lt-avh^oqY;>-2 zZo>E3SwD@~t-*Fiy_Yf1Ek*B4Cs}jE*B{u3+V#L@m=Rik?SUJ6_8kk_Pk-j+X=9XZ zX=J{;_v&Yrl9XBzMI)zFPVAM%K1bzw;ikI~vY^LlC|(53F4XD_VAwEp#OS~LM0%Q1!9oM~YZT&?u( z)o~+dCKo;;ioc+lCgy5lNRBuUP(p5$Rh*WxV^_V%vMC5kblvXVl+}zP@wM4aOD+Zf k&9id(sm>qpbRGr8XIr2+kp9!)YyE(>`V+N^|197C9}9h~m;e9( literal 0 HcmV?d00001 diff --git a/src/renderer/icons/logos/edge.png b/src/renderer/icons/logos/edge.png new file mode 100644 index 0000000000000000000000000000000000000000..feb98376c80861b80af4d215e40709107b5961fb GIT binary patch literal 38070 zcmY&=by(C<*X<0QLr6D*gdm_ucPI!72vX7#qrwnFmkcE!4I?gQRp1-3`(? z+#kO0_uYH{PiL9YG*S+ubj4r(M1U@Q1Wc&-9$M?5vzz&ES?GS65fQ54P5h=4SSme0FeX@}49e z2*d((#I3khj8Oeu}2j4?1M<24Y zce09-tvqgi?Yt-)L@fVH-P%AwI8GwYoWBc(ha~TQg_j1dfpDkiw0is;fl0q*+S7lz4w>vQBB(aElh6mCV1Al|@j>gGfnD{Q;B0 zueqVDZr~U~1fi@kh{1^ZAVo?c6w{eT53y*DP3Q@m_+9o#Q#!;H`+!E1PpK>lZ4_zW z%^XfnN*WbR3c|wLFh#d@vaNQI8ohjG97drF792@UG4HhbDg`ZlR0dBF^&yPNfw3z2 z#<4KVACd+KiRSG49?-C|WYDsL@bKPu>`ZyTOm!!Fz58&Xqgk6JQkq4BfD@XRXD;En zXmaS1+uUWLlk>roUz0EQK9!%L^7pc+&~!J9$E0MWgV}d>Z9ZJR*`aAnQ8o;X;>Y2` zbUy%t330W9V;w0dR&>vyozXc^DjFEJ7zy`Y<`dx{)rm*MU^!WSRxA*>0hgO+rf$WC zUH5e~zrvx)!XTeB+zOdWh2|FZqT;h633FkqVQ!gKcHsBE+9#>R0?&p4uRg|EllG!` zV;n+SB6mPA{qvAfY)s>vx@q_4!2p-rqjJOCFcum9Ux@f5YK^X6@7=D;0W@tFezHG2W{>g1j?|fq@0s(oB z2g0bu6`40&J$EdYeQt+!A<-Oig2|Vf5^_|vpgau;^x1-y*eSNpP0-mh$E35Y&KjcA zg7EM*1nv$k|7F@Drx;XEr|*ub*z|0;z`dorV_RRUcCcdiJ4cpT9fK-s$@4CW;B(rm z+`h%6?KTlo43$XWl}=3G`1hd&Zeo-dRykf$_R5)Rt;w;14vMrkXD*8-WobsQI*oIx zV^xm5pP$`?@yL)jI+ALr+#M>&Vi{NyY5YpN5u5uHu4vX%HNJ^f8Py2Uh8aw@AYbhB zM}|&A(3}~}>i~lJ^@Hg2qZty&JyH$wyARJKG%dZf*nPw@2Rj|3xxql&a~< zVv>RgZ(u#~N)eu|)!EwZa+2?33=H_4S$q85BaMh=K=q6XX_2wL8yWJ8=^k|=1h+dj zRr3?X%C@=pZ<*u0OliSjvAb~TSSlV1dJJ26#e&8Xl@YD^yt@a7uQ52Fj2bA)-0;qE zMmlco`_p$=S(@2^6w@GDE2+lfmIfV)o4)miGN~0~reJ-otTj?cP7vP493Nw58?m6N zUtdwhUvJ)EVY$fycRFkX;@M`~N9su}*iAp*Hgd76=zt$ni43rs5y4dc4b|z+RUbh> zbqIIcZ8tXatfDM}4KQ1?%pQ~E=O2}4_g9&HB_ViYyDw0g2kH$av4pqH&#sgZ%&&jk zuuF`N*uDW!m4Bd?NzgIF#(+r#z)`KfRw z?*lL2du%t>p?tx*P{dMz3t3rQdC3qRxC_L52{pm(DC(1L$PDOk8}EYO@NnG#4^QCk zB{OFITN5lRoU+Ewrg>*C^W9N-u}8Fevi9&79%kGYC#T>&Z{@!xZ?b(>vSfU|B0$8z zGg#zkpI<`sLuQPe^V?=O7XTsS3a)QW9MR9QRiu6U`inM*xH%Xz$KEGElw{dq=$&GIf_x-kAsdpCl;IKdSy*_ObXiSxz+C1wIJE zqys_?zG zHGD>kB?9vPm`N^*j~D3UfRv~?Ah||1)e+v>*vN#g2=35cL~5`N8UPn-2J;$<1~v%J z2bqFIC5=lDz}6`eB%BkzY8c{P+!pE+hM^MKuyk)6uc}-8sPv^Jbu>%8^d$Wq@-iOb zn1p`yAxTN&n|Y+h2V)uNimsR~SL55Q66jS4ZhT8ib1Z)Jn>sWlP zsFsGik)U2tJrhd}Lw?aD28g4)IXb#7IRVve&|0tBp5O{{ zD-hg-0}m^a z*6XqMg`IN0kS;`xkC`*N#YKr4Ew;dQzcis`t{;iT90O0iMtWaC+1zBPi39jaTm*oN z{)y5Quse~E^THOrj~R`2v4?N%F03q7c><-R+2tbinG)#|&zrNEE^HG>B(^vyD`-Qf z&)AV*Y-78brz|$kwE3YOM^4_qEW9&Ec6F;v8|>n#{3pLqX?i9?CmP@o7;uQsiD%SO zZ!;?dPYdbT70_G~+go`s5X1I@UsUV_Iq>dv*JA;=*Prmqu;_-VU==ssK{>$3B4(#a z<@r5!zV)rdW-o9G@2cEC5WceUv7SSeBjTl5!{e#aB-0QepVT^cgz_}vZt)L4 zR<%E%Ed>l!Z_IVAWf#=i_4mfvHM`ty4C|W(V$Q)MuQU; z0aLG#?V+b(GVWPYH#9fpZDwmFlFeI)h|?0QKBUEB?yz3a@q38}oca2*bysv!w|n*4 z;8dW`Le+`$(D=E91{bhk6y*^`SZeBwx- zmq4G%wMg-hiVwY{fJNa)3i`_EsM`Q;f7NV;4nJu|Q2s-lpWQfQw1$g?WHGUDi%9Wz@G{D zy1%m2bR`C^VZj1fw#^9f2X6TInjZ;HV*>180?j~DZN|NFzT58ze>@X`8ES~Z#y?@` zcMi>B^yz;vPxGLer_~x^>HF1BtUv~9X!dK*#d;3(cshMR*ucJm__Nq_|Ia)pa)KcS3=$CqD-Fg!t!Wpll=V94*r@P*0?dVV<$ ztjyY3Q31I9A3-Sm!$b#IDaM$KO54sA&Pe4U4;7!=xgh>1}T-2K_|oDj8U=qC^b z>F&4Y7qU}Q0;)1|McEjkP^lo855WyM66&=@zGmZ*8V0kaT%R> z58^>MIjv1lC5jPFvms`(FVlJCf?P@`geWxZ89q3~`6>A6_aOP^~UZB2p&sO89E;)f9YCgzS*9L#TB@iQy+|>`J_*R*E-}p=sAK zP8k5VAwpGF!q36&%nn1kO$xGBKwVbn)$_qJ&z5Q5UiWlf819a9Y9)7U!Z>wtu zfP$M8N}6qze}mqgaQ*hm1*4GkBj~VsMqBqaWmB&G`aE7LxL3XeW&kb;^m+22@We)f zC&-yBKJ*k?VWhm6o_&P;8N$^f6of%=%aG>!O`NWU=f{amR}4v7@^A)%CIHkdvR2f{ z)P4&t|6=>)lw_j%fWQAalfNaQQgNWqHwFif=Yc*{NCjul=TfQ=&OjeNQQl+tBdS;a zn)^=oKmExfY5a}gGG$-|9RyzXe8N<2#6G5;O&PYpYAiq%i9S84uO~ArfOai9b;tlt z$hb1HarP8O)fAquZ`OzBq0M0fAY1dlxOU29koR)=-~-=4!n{(SwO7h>Z=JLtvM1S3 zoZ(3f09C6s@~)39Kq>rse~lavPZ|YYIO|S`c#(7cl=`L@ia#ii2Qpm#4`@hXH5mb) z&S1yX;kpi;AO!fz9Tyvj-o`EF6jBJb!ZJSi|b-XNi(}%@KI4N86BV8hFwY9zyI=X z005q10lV3kxw|#=ZuK73w_>^zGf@Y1RB}>Nv~sg`&^yt1f{Tp_FHAr)zs<^ z28Mya7|4kCWpi9K!9nStz!vDOuwnN;U}bO_7_04}Qkct2z$|2ZMzDAe`~)6$cf z7%R3941hbr#fXkmn(o}uGBgflKtaZKnNEf=vLW?rzs!9lDQW*eyVDK7$q&W?g^j9D z-&6RtGoSrO;5AAwo?yJr*ezV&?d#T`9A8o%9O2u4G$n)^E*(3BROei;i}=0e5YL9v z@gP39SsidYVT&Fr(L0>8KB4fyWd)UX)N50-eRL%IdQBVKkI){rVSmN%NT%z%zWb+p z)qYM|H{{rR4zR9U#2)%^y+!%&J*u0bUaeqH0^q|fDJ}6V9X2YzYSRfGbOmr+2*O3j z1Q!5?6LCx==A~Oj??|&jsmYs!gOXmJL=JQsU3y!I%g_9dk@3)0!Sujk1qpOq3&pdo zsQW4aQBL2EEPX+>on!qORNT?ALWsXhl>n9Haw;cLZZYV@?TJT*K;wA zKU4YTL8g9UJSMUfvvMC&n;>4|Vy{rY86K4ZMor!5us`R!KH6NVV4}7xW&DYHbn*ml zk%Zs(ULuwW>7C2G@`%@CA2m$uBq9&W2zB?TX8Gv~77VpjkyMEx3)pf|V)*Q#HXmF% z<~RhEoZM^NI`y)Ft^#IM_l$Tb|0(&INWEZSP5~JPh!G$*R9z+r$+C^ygtolc^3tM? zyZ9_EB2HK9A9fi(f)ptJ2L9s6NNEIRf-by&P8B+Tgp2dJ4zQzg+l~-)h{{YQf40+DQ4mTGAolwj_fU<6S*mPv4 z==<7z4?qL}7#amLHD>o@2mIQAARRdBZ_s;647~SvqTX-8n)jluisorP572ms0__UJ z&m}(6PU_jOPUmp=gPzvcCxH23C`$HutD!m{ z!DlrEq!=Jds}jj*YSo!cwq=N~mR-8jCmv8IZY?eu_ z6B4H6UVs`oslUsQyq3kQ-gJA?GB~|zaT8Zy#oaTW0{~C@<`a=*$wux*Ci}mS7Up#? z*456gy9a6CUl#L{-a9nnon|yaOAhgUJp@w%c;@}H>WXzO3^>1?iE79-VV-|W4+sEZI{+2;xXflT|i zny@Z>vyw9-WBwPG;RXaXZBJN%9GW}1i~a!vYmU-@35J-fuqdbi$DYL^anSdtS!_Uk zswtQYTR3_q!Y1LAl7g*-LaH`kLH6$|!8_dWB^#)e9bo!P;5xb?J2P9SegwCM_U+-= zHO5DToAfn-Q<&a3&m}M#7(smo?n1%I0yN@Z06g*2m`#%#h>zv@D-;AoUJNXKY&Vb+ z?c97bo{CRVpTai&=}}r}H8B7-3xRlkPkIGh*TimV9?_F8hUJh7C=0jg}Bk`%uhU)^q@N8qEt zLg%?4mhAM>okxtv(V{{gJfH-TbKT~B%2^^u;0A#2T#%Kb^c#Z*#d+`K=4+tHOA!q|Mi0)rpYe8K1aK$46Heh>Y48B@U-Qlr7ecPX&?l4q zJ^u|u-kgJyuT8&H6UuHmEI1pUjfZ_4BJRRI!W~fTkwEwZNGhfhh5zahx)MBc1R!Z8 zK%TwEoq$)JT~rqUwc5LceDla5e*fL;GwbY>w$De_Ua(C=%;DjTD=28KL^MDdjknPp zm!c9Ac+%?Gh`gg2YIkS=Uf%`MD)E1rnpY7yH;w6ElyP*{8x<#4kOu$dmHNU3kj5nN zu7DKBl>~gTk_d2uHRsTBT9uZL- z#w-ww^bDX+RU1~5|4rp~v;1{$Zi~qET_6X6=w>Q$uPH#qa(nq)2ns6srQKG(j>y%# z0u$OL#K`MBbg}~pxUO#dssqlb>hf%c|04?j1!CkqPTeTjqsh}maudgcCKZeG!@N`& z4`fx(UQFw3?!9Tfy_3z!yZdV-X)oCK^Vrg_{9W2@PF|{R_2xdmriB5$BX3nv0~MrT zy_(}ph)pRb?Qh+#{#97EHH6Chx9qrk`s11sT3D5XME#oSB$Q}ZY*&!uYz)En!QkoU zi-2>Z{;Pe1O_0+(QFALa_ujLr;oW{2L8UKgcRZqZ`ZLfrHVgxziapSQhk1NsU~E#! z>9n4czQ+}HD5H0Hm6uEU4P%xXw1YY$Vd>|qrA04{bbpu@UQ_PXfmak2QMU~Nl=iI$ zt*FZ43h#+W)^^Sn z>)P?o)&Q4TFGHppp09_o;ba~Ruqj~>TyRaZ^DRX6#5)Iu%Aa9@&LC+-UgaBB)j{f< zP&0#2h0bnDQv-VG)RgZ47rAS*7ysjK3bgx@^3ceppk{rfrCkkQV1n4rByDdV@w%9XD75?)N=Y1!tD*R-xCs)G|2$IjoH{)%z42WCoOAl&`a zaonAU{@D9tWFO8D-tvm3Y|_Hdfo3A1O}2!TGad&hx6we7)=>Y09XGs>|FB=l{P^`# zNS@9GA3Wo0Bl2tblU6BzejI6iC&4nga<OzcPTl64y@Xa7 zYVY5fF!Km1z#R;t6r}~(?)berUR>Yh0yD}dzzJ2h9B>;ld-%|+UqNy~A1{ZJT@=TV z5wWVkEW_nA4L0h`Sz!|+Jd(T#r8c_xyi&maaicCNW`ZvI_1`JOMb*x!+Oo~slQb^-xWt` z62{1gLR%3BbTO=MLD>9`<9QpBS`Oj5fa`-GXJA>@qU|FVItu zWdj;9jR6E3U~1!%uO{f1{%WQxQ%FeAg8XHb&kSNlLi~uVc}E+nL8*$^d?xaM-lF%` z9O=zxz3r$!IodMlyIWdV4t-BKYtxh@ z*r=xkar3avr zwU9o%flsNO#@P^_xazqa@!%ke*{?Pl&g5F0dEI~x4>XOP5;e86P-oHeBHOb$sHD+R z$nIRBt@=bcBv%OLy2dta7DWw5ljv%&h+E@iP3L?{aa`ab#LYqRf;$cq5D!q<=X{BvyvBab6*#61PMwh+?Dm;3^*Q*c~?LOb@dO>Jx75yia>hb@G`9E zt}u+Ap78qI0HxDdV-I9{x?E1`Rk>khao4J3m*~auvK_oVU-x5^Q@@fk=mr!PlLy29 zh_Si$`Fa;J&KMKU543k=B2-RYLXrD|lvzDZRDU~~8|#VbGN;c5nunf=v@rrDl3eLG zQjPD6DX-s9exuYAoalh)w>P)uLtttAu<_|#yo{#Bv!QCKN^$h9%8iMScFCW6fB|Vm zpG@)<9t?nkPL}Tjjj!Sv>I}p5w+9YGM^QNZaW7TFz4HMI>^v?zvW5CFeg(Mh(4Q_y zY|CJU!V7PG-Tey{)Atmok*(+`R?Vw4g;TqBF_WipBUEozhfOvd@kVRtP0^NuWS1*FHrQZR!$(-M24pZW6 zjL{n*m8-j$@Z-d%{Bkj$`%fgfxVyeVy4wBnf0I4YrQ_BzVdBshf;rK+n;*3t{_Ac!TlBYc#X6I~IQ?bl9>gWwds8Kpjx$I;3Eeq!o+b8rpr1_QmyEYfk?KDGsmR{uT#!`-5K1`$i@8j zwbYVRvMzh?SY$Wg*Kgf5jBB~SS|3nx2aa3!P7;0ZT`jKsuvd;-SP}2~r?To1h@suM zo36zozIaKk&wTQ{0=sglg8=>hD(c$$F2Mh&<)9vaes0?4qSr*5Hm&0`#@8&US$6eQ zj4?4$TNL&%!P#Yp>4o-=x7{JXtLP1*L zIv9X#r6Md!SBl*8*fz*Hd9nLX zsmPm+D}NqnI!pY@&Ikphr1j`>G3=9Qex?-*U|GAMt<&?nLCrXi$znR4ALnHK5kqke zlvjTc(t!&)N)sj4_a@o^I*=*T$EL7Z?1HqwO8TvWEo{-}5UmSX&=dG81}cn*ZGXYM z=5DQw=}aKDo#E0Z$w#{@UN}@5nw;we12oe|`Xh@@nOw48w#9BcMZOazWuJpg_qO)I zHT6du)JduD*O_R%sBlD@({98ni*z*(cclN?`^`9kp)Y^nVg~7Obt1Iy=4%J)p{Tz? zB3pt~ryey2>fu5}%#IxKUTyARynL?HA4ZvS<>zSFM5bW~#MW3r)KY)zj)X$*tMIJaHXj?K z8(JR(c8_RW32Xd=I^8cHy8h%hPf>~%kmP>t>b@Z2Wf85(+0_)UDfEr@zcT81_Zm~# zopF8sQ)p@#T?ZCOlVA-Z9k$Ra5B$uw@EI8_p0ohYG{4NnrKHhlys3t7hOXJFsbeVU4|E%=A2-_K-&JdLeEWq-isF!}_x0mK!bdQr{C4igkjRIUNP;GZBLA zZXqzW#KG!|2q*IAYJB{hW~To$;T!ke&I34|Bmq00Ao+sS7t*5 zdZ_J)Cav--a>}jBo*I|akfki+dk0?l)LjW8I8Ut10x8h6_UxPb)DD)gCjY*wuVcGO zNbgfxK)jDuUrZ;+Zr)6BMO_vuUi<;g@D>EOsIOS@^L$n^-Tdh0#W-5;9rhy!FVN#c zeU;w+`t2d3ghI!C44#Rss z(wu(1YzcF8cq`G}iQ-};Q|s+WZ4PL%lthm-C+Ro&i1}0fk!U;M>IPG@KIFP{F(0HZ zysSXLWS;)ojSpF7+9q6m8-00%E`HXvr>dJj@o>Fus^GwaQuxin5?-2!l^A2e68{UF z{OolnI+67w+uA)wr#~%?C7aV!Oal^*uLABdbZ53rfam+=V*t>0{bNn`;M1<%J}IwP zAv&e0U~qTJJDy@IQEfG^n|thC^>eK2T7qpXLopn|FHUfRm9>>&pi6WwO?^Rvt5)26 zgyg$+G?f^TmHl!S{TS4>PS!j#b_R}Leb@Qi z818O)CTyt1ZupoWZ)~tXs_==J|BtECLKx9qDWAX>eacp1jur7E_68i<@m z11zE#P296_?Yi?BJFVko)*a#IUq3#uHGtg;f*f)8u`V9&yEu`( z&_fb@E85H0Lf7XVGTOgl+B?0wyn%hkPmAg_j+@meJY#VpCE@-s%RehGpBUmYyztOT zOo5&5a9yRy0)vS8y}ec$%F`#X6}L3cys`N8>wZw5u`^$nu1BSrU#h3+9QkBz`Np># z7K^h?4Sr1B9b3%eS?y|baYIQWXyl;Io3ZEGF+>_l>h81z{k6_wDWi(j3#EgO<4`BR z_@_=Ef6ssRr%gNLSV$HIW-*8_=K}6ywCr*iJQHpHku8&YLq#9t=B66hI)A3lx&qOM zty`#~Hq{UjmVvI*7o`-|UHs|2oE^B`G8`XUUIFVH!YEirMc7zxf5(2#=<=tV95oa~Qqw~BE53;CkMPhVWn2Cl8@`;P`MLQj2QbretD8vdM>Z-F+CIq6j#d`L^zd|7cY1%GAO z_S=VQed6Ir*Vr1-qvAZxj)m*DmeVd%Gv?aqk2m;HR||PTt>rIbKSOZ3G>d18f8_11 zNCKMIzX0g}xn4O?J$e)zybT+5(!v$&GZqa0SizLA8fb#cGv3-tDMo!+(=$$P;Z!Dc z&r@FEce^py=*UV)bomxPKD_u!^;zqKtHqtnzl`n{0})T=-*CpaxZJPu?-Mx#2Dtsg z?{S}hLwZy3y*){kFZJ!bR9~qz2%CeRX<=#PpSbM#dQy$e z|8@PsM_Uhb=jf1kN%wIv8J^0Z#BBidjaSWCD^x-I$${bS)$E-d-C*fBP$v;B7__1&9Pg{4x|3U_>x}i+xnbX zVZlNpp1_y6{S-msNTSx6XW#lwDsHuQG4E?2ZIFuSEhn)^y1^=Ehx8U$}eq2km;6%)QtmT2J3=yyL&?QX+?(j zpR1v!LTQ9lJEvX5aVC1IoAhRqb&3n^;5jGpson=iY+KG?*kE^>wtckwl(?7@Q0?N% zYK>wQDBepHq-m#f;|F+^hg73L87643z!*1_;Uig}5m97`Dyb%st=WOFM3YXUa3hhS z17v#T`VCx#lChX35osiR^K1<2bQ$_t%F^f8hG*is?Mtt=nVtydddVqI*i2gQk=5n=AH01)- zx>$z)?w9WPyAm@?pJMXXXNZ8*`I7M4nO-}C?XFXS52a9_!fOuOIfA?wj#XP`Utux{)`reXU@l z=*}F8>l|*f%YxN`4);$NYAH9K75Km&aY=@A4~XS9?S(uQLUUMQu>nuDpm;&rTTfy4)EH7Ubb{AS~6SLz^uQBKA~?BT}0-@R3_)vaV>T zY;yWo)Tu$aC+%_VAj@DBWO*Gl=(tXCL^83;BHhnXe?&ugpl`TV z`-g+g{{|#?0ZIrKiUFhFuC%w<6a3y9-6wwiOx({F12KJo`dhPii!`#I@E4GL&W*GG zf=tO@F}|KlIzvTbC+_Jen)$@X`1ReR&}cqP-=*|rwKE_$`G2DGMoh^2OSCzJ>O|_( zUffu^%3RMs7BG~`$Wp|$XoSJ|<;IH&^6K{>FhWKtb!6o+xoE&gpCsdM*1@+VET`-F z;Wa$zByp>W?r+}1(GLAjPa|n^x`L6!o_xwQg^#K=h{uH`iQyqjR1+*Z586)Nh;ykH z=PE?(g%3tO{*^ZdC9+64rp=rgT@&-b`(%#!hF+#WPB?+y3ZAHYt@>+rZH1vizk9dz z&pnh1=X(Cl+ldGMyPC!1Q{X7?JV!JMZmc3hALz|oVyc2KtZuP5kaz^W9Q-xq@Pq*u|K74i)l+tG8m&uiqZrO4fc8LKGGA5I|F%KWjri3KEerQR{y;p7dHBKo1_JkLjEC%88dW zEPJ|zLGA@6pI3@sG8EC!`KHT&>drpk{L3~Hx$YQ0V1|9dUkN-Dh2a*%oxWw>5K;1`d9TP0=33^S%oh<3y_`HA5*K&!;qqQ}KjcBiiaDh|rwLJ-)HU zadg(waD7_;M=84fl5O<;ckSLM)US&15_}|gQnR9j7#89lw$1^TIHzv^iN(7${jAthZUH)Te5B^zFUI5|+%XvS&qW}>0>?`R2d&%Lg$;8DT0#>Esl5y%Vn9b3Veu$(j-~tM zg2`)?Dln+VvU?to5Ru~h@@IZ#gPy~mBP}smo?_AU^{9f~D1T43rxq`F0^gFnN}~}g zKo7H2edOqyCdu1o=XnaGIS+mO^e$^0aXd zuv7u1q@kVUd$nj1kBz5kNam{S>LiM|+X@qh-P0!kJrJ;?m8{`5=CC+Rl6(wjd>pqQ zOz6QZZA&6}Gt{#4yDj=Gt=Aim#*y#>52yiAxPezUioSODu|aj}jTz=kN- z^-*Lnj|LXVssyaU4Z7O8y=fEh5mO!uq>#6u`e50m408%qbV`Nv5ffYbuN> z06idonDT!1^Of%C#HBjAGF;*54ZpUUE{Bd`kP<8Pwg!>=J6Gt!M{x`HPd#n4s8RN{ zDC^cltF_@xek#LX4{7(ptI$gb>%-E;3z#ScPGrDHRZar8==S)S^~X9~)U;YLJ8hG* z>6?|CJvYOw=?}t~8k8mF!cAMWZXWcPQ%NeL0z6wilqclVb3LmT&l+%wOS!ZN;?;Z} zi#7>2jvGl|g|0UI+T(u&4^>Ba-z@vMxsbzN@d8z>m-S62<202x!#8Adab1u#)5Hn6 zSS{6nc{dNlcJ{+1-hz-$?io=_TLGQR@xnsUn?FFq(zU)&JGUUF?*922cl7sz) zg?he!p+ic=qu+IZpNacB=eEfoQ7v22WFtv2&V-)_dqLVG+U)5H|IlSBai?g&zE2=D z`2uHFN$Wv+gVm}sAkw8V(f~JD{7jTh zcZ5~x?74*OXa$s>g-;q^Z4#`dMDOxV>}u4#(P%iV>95;6U>xY8)6nPpI$pkqx?=AM z7Y0SiQ{ifF63Mb}0&aO-6vA%SJ#kO!NXq5Z0JM%5kYz~3P*wL+#qD$z?W=7N6{D*b zgtKpQryA0!JQ<1pM{R9ozdr&E_HKMHWO1*Fe#E`l5;urEB^^BH6`*K!X4CC# z_zZQE^2{AqaL%u6(wn2V^ckwwaX9sw@F)}`5N0NCBh;CKCSQ;PJkmqnc?KZSKh|4a zbfi`*Uo3A>c9lnCzLGZK2PFu)J+}WEU8FrA{PKpAs5(}_2`kp?VO$ojU8d-0wtv+o zz{hABD6`De3z}$Vl7+SXt>Y$52CF;iV0OFwHTB(Q_EOGs*U=bE;+2+td=6_UC&+_t zptTQxqUIWxl-rz1XnlwvOVJ&9!h3_BSQ?9#&#eFf{qHnZ5XN7BdB2y13PW;XfZ#rUBYj~C${tlZkIQUxmJT8s@-n5efDTwf0 zw`FlWP3-QbOu4Xffn%q+a$WO)+ozhhLYj^-KrKSgCv1jT!h}AJ@es|ug-=6FM$!a> zN|rN-YsfnumE<`i^8eF}_S7}K2OK496Qz%45OpU!j{F^p8E7?ILyfLk2u{A_|I4ci z@Dx)D4W(V1g}i${-*G{`@n_|6Je3zBdx*;97q;4&TZa?({H$m~PWwNj-pY;^ouY#SCEBo&}xN0kA;7>Zr9z5+jr^fONp_T2Kl!DZLvg ziz1L$Q6!VBR&mHcm0vNXC24oxHQY53_@so1#9w5kiI#MmOQI_~yjDdfJ_uW2pel2u z4?|<~?OEft0$KRM#$OIc7SgQzbr?zV><|j4B0HlDJ185`{@ObNQxd7LK1oIMJyTJT zv8S2qRf!U-Si#?cV-U;ITi9XTKWx%GUF3g@v{0hPG{%+U@@)^^`8~II&7~j$`bfuDp2U7&+bFJdp z0RU7@p>!}sy;wAPYfACtlT_CT{jCbK^BSZdSaA3`vq3Y{Sc<+CcDh>F{a3?)L>o8s z3un2-?SAETF{Ubnci73fC;j>HTmY%vGdOM3UnZUvEc@MeqHyZz{P6>2O`A)KgbPd`0cnqgZ-hY`EIS(ZcS|=xEx=owr=w z3$spK7H7W=LzZTxkLwuOqS#}%F1v>ALY zv9R^P!W9l-_*xk&h;RG^-o9x`5cblKni^)iDA0pe014o~u8DHAGs5`L)OBIa#|bD9 zD%Gwudu}uzU&m;N8yShXRRxO@F{D_Y0q3T(=;rP;Qf8a;(9qW=PB;3jTJKNh$mtmq z&CComvdsr9w6W0Xu=R@49}jofA4mL^@rx}l+m#e}l1SP8zHWCKYrQ07ez6{98(2XH zqTSTKl_@LkxHvmsAWrI^Yfhsw>e`d=5LWI<-I)~bI{hPKzAR|6G{?U?uEi^cQ%nzT zB=m}poOL9Y|HRz^=FTnm8dFT+~nSwK6TICJy+VhUN?KC zkQ&5qBt@U_$E5!g?ZGor>O7u~<9Jw5>u0vb$&eE4+Aa{WXj0N@3onB)csAqJL15Y(%HW>a#)tM`oqk}hMw!dYcDw8W+ zoC?lt5Mj%1vYMk7-i~$#H@f|qW#M1tU$3Vqv^+y@=#F#Wsr0019jBw>B;^i zfUnCxd=yY2GBu=lIr8?`?N(kO0qE|J;A#92MTxVPauojEi8EzafC6j2;vrP0Yvx2D zEQLv*Miv%6OSlx7=(+sl)v^%|>&5+fbJNozB5gGJx#*zXs0or0C)uiXDBMnNEy^2c z*Vmg}#`|<71P)Cl?kUX_D%&4Fj}iwXXzF;KOAXL(`YQK_TTlT|c|MCXI!S!^>=XJ5 zA7;r)CL|0ei!}<9q5QCS1|E9onJ5+LYyBAkM#KAIcxeAz`4-WqhG&#{!dM{jfhyI6 zzamUkz|L#pW|uej$^lI?jbC@WG};>Br%L|9W!FCs`HDc~R%@3k$A`G&q`FmK2yS_E z{OFOPzKpjjp{@-_QTH=K-DJV1EAs968}@e{+yC3$zZ*K^=pef49sHzMgYk_opordg zL;B;A{Msk9o&S+t3$;mY@Q)$FXfKTSZnKZSH`z#IAepD@`KhGuN@kQZC(iRU7`@}< zxFS~0!eqz*M(6FDwc=Xbc2)Dp`6`0(N=Z*^M~x54(PVT_I8Id(X9-uWtN<=aG*J%I z{k^^Uy-9Ql;}4p=O{ju7%@{XXM3cER0IRY2m-@A{{WabB`AmVV7m4TxxQV8Z*r$7j zzw#HOUOcy9LAAq@oUJEy#B0J}5vwkvYxp!$S5>g4(8LJP!|YCnen$Gy$bX|QflqX{ z1EhuXOD@fiMiz=<9F}(ISKtn(-wb<umoIZ6Pq)8%t7ilps7Wz@(-u*5&)uSrdL7 zc>4M2x2yX+rH3nj&4IWIXP!8S^!0UJO}6=R`V~x5B~M0SUJ-5#^IjBf5(ny!rWF582bIspejiZ$}%nKeVTcZ9|3N$Rq%LFOH9u8U?mBdU0Y4W>P) zDL|9_l#Q(bu%Ev@NUB_jv zmwzTTZWEqRz*Q=VCbVb=_5LHj2Ye0-k%HR6H)hNOn#?L;!p3o;`60!>-pJiq<#6Nu74GW?s$vA18RbkzJB%19s26pnsM`O4m~es zn}|gB;^n4bHy^=&=SLH446Zf!3FBg9N9!Ss=57X4CvYw{Go)#XrUQ{Ly z!zg1*0{;Zb+S{U}xy_8)Yz#Pw=H>`bcx%4qncEjRrFc)3UEL=*d0CwIvuS4*QVecz4xKJyFsKI=?+0b zS^*`cJEiLY0@5HL(j_I`UD6Fo3Wt#H=Fogw-+S-#{p*AK%$`}ZX8mH;CQUyPa?k^L z3^Ujm=Hn?E2SO^{ngzgU*>JE>NqHH&`OK5jsaIrMm&PEhLUVF8BHntzd~N>?j#2-- z?VOgVPh{tN&5^!&=DFdz8AMNazx%d_HXoBlrmFC&Ai@r`m?(P|s-4!3FE@f-u0zDCxAS|5uOHKv1p zG80)G;`d}f0*!0uU-t$+F5R>!W8aHo6B+m14X-S%^*qHL^UrX}JD6DzpvZf%fQ7&T zC3&!=yNRSHxYzr;^!lsZ4RSIS7WAmw?N@kME!Or44^`gUNL(I<_QJdCXN-kVG#^;c z+IbY!ggeDh2zwbLC9N#9ho76q8p^|^dJ zkGT=uQel~2p=K`J*cU(uLkD$^FM+|Q>IxuB392uAPSRb|Wpy-ZLNi%#+@sid+ita# z$KHThVw?eVMD95m#wDDoj(TBHWI-BbN!48O6ZlpR7Ky@=_XXB|M?($Wbxhfh(1bnqbE9fMDe(3Q1Waf z92*sLtho%#)zuDpsc8LRzF0wgV`YG@UMv1cOCHSGV%qnsQASYVKl)-TS0%iK-${VF zT3l*lHOk(Gz;z&GsB6J#=bRLiD`{wsMZ6uU-mw=e$-{Mg`6(9~o;nYkkEG(WHDbDQ za|&Y=5CwOKDQbH z;&=Q4IqEedH;Lavw=5H3d$liDODUR^42lX}9zx+vNAF_e-|JoicC)@qQV>>0lZR+2 zUM-9PYQ)(nja3x9yeO4G6^d9$YV(`FFZn|%wq6VKQT#WK>M|)21WLp#bbmi3f0BiP zjJ+fnBOu^8v8qXJ<97Ka=FQH&KzUMm_pFUpP@BW|i@Aov_V3F}Fy6(gIo0PM1s=qZ z5tAhm{$v53o8M5lHKjM%U-yfBYnLuzVhBEH%xbKP2IS6F9xMrhw3sT+USJCzWr|PG zB|v+EZBlf@a)Ze%ZAOyler;y+f%^OZk94iLlOJ*i>wK8StMU32&bbE}*9*VGdq)f2 zL6CC^?ggq(Tf}|t!l%CbRmDP--onbQ^+4{NP!R;YWN)m}A-a_hbY|U(P{G4Z>H1Lf z`kBO;U01Z$4 zJy~ibfa@#ov$y1pElv2YrtXUY8~IZczUHZsacIeox~-r?O1zLuh^z!Z*TI8{ljDb* z;Tp9cL*^II8H!0uJ{HtONPz1qcU;nQJ1G~&h7p~eJ2A8RE@2+N+Ojksq^%>|+zWhZ z#+aQ!!QT&UHS_)c&6&8*1Cv}2&@U3<8@L48NiXduIAV_E8#1`m#Vyl6m zZptR?=MOtZNdAvQxq!~&THCMn0eyiP)NvrvApI*&CX4W3IHAxB z$!TGCdIFVRY9kVp15?csEx7EvDH&klI*M-`9z8(>46Ffj)wmtO4MVGY{{^KZzVNGvd0~*iY$D!$>y)#% zkA0|;_Pwi4X`j1xd5v0cbu#2_*0q{3qTGU`n+%aU16De}toivEq8OE!NdB3}_F_nW zX3RlBR_^!@DZ&^a01q3|Ia-ZDh1@ygx7M%s=tb2!js2>Ac6Z;E6|`7rirl_N%`mCh zpKp5;@Uk*_&gfm^<^IPF+_{b|Q1)r>l3ONolOs`QqiSC3fEGP_>%kQgKn`kf-8?)P z1E?;9uaE%6(iG&^;f(tfLmtPfYc`^GYx@G!?E*R_!6dv)*cCF?hTV~Wk8pDEDCOJd zzJ+&H-|ia0T@k}eeTRwPN)I0A1~La4mEe-Rg&7+A1%fSm^kP6WuQ(>*bLbmhLdJcn zpgBf12Ra9dM7l!u|AZ1*2~a3$sy84%iY|HJ6T}!kstACMQ_~oCMepeopd@0+KHv?$ zxsXyy{crvLx17O{?Z%SMcSiyi!YE=|h7Pr=N3_9`yKvk*5oUXw$k`Ix*1z_sEpB%9 zro-&T31Y)XNWNT8Ebtb*U=3#Nt&jjOBofFxy-v3ozOgyU#PLnm(O=l0hlG=0A~B8eHf6imM(Uh)R}?s zR(OJ`#eC((XJS$BOgr11r%o)0dC%IQRF%oDjy@Pnvye{~K~U;jN{4rgP}nCT@?`zE zl@-p$Pp-!W|EJjYo_ez(;!*b?8x`os{M+zaB?9)T(i~{EP ze@~+sP*ntB&yTf86EnWGt@cldN+|fSH_I&5&23 zZ+63YR5CP*1Hl>*27T7oIjSs3Tv(;)*Ezc~-o3p6Sqa4Qf_=lMOQ3hoR`5`)WwtdF z!Smdoj7F1oJO=+#4KwThfLNcOCo~b8)_W+gxea6|HCD4s7wB17XySEwt3*yXq2&A4 zo=I$G@XNnqM|t5J_bj@#r!DYBBb4OsOU_imQYR@~?EN!qx@@zePd@Rd@76|i=0p5Q z9+Je@92d1w6s)}=O|nB_OoDsh>CHdK|9!Q{K*UFd%W|w;hKtnF5tITpR=hE=$qCc` za=eD`vgX^-XCyN@5~yAMDA8CbZQz1sp)`?T0u%rJznl=CG4qyDErA0nlO?c@bs1GV zAVSev*Yts^mR={fF`pl6zDK9I+^vh#96JwXA4(}_mXMzbDFK=9RCm$*dUYw}CR9JBJ$>)EyW+QA-JD~b1?f2Dd z8xnn%Mi(O&kzPY~oyj^H%^97WGx*{oz&82hAsQ(WgZnCs8v3V6QzoT?Wx|~)M*V75 zrm^EB_6Do7JpM^iE`7`FdIaWIs=p{MG2Ye^o_eW2>n+~)UGn~jw)=HK#Ynfh^iCzk z*NySPq?Krd&Li(ft_y@#Sp{6dhqt)EWfTno#>EFOll4{+A5?vnFlw0PavjaB`>f9G zFe-bar7;J$Da1TTA6tLsSZLQizr3%$z|iFK2oyWOI-W>f>0HGtdq)ijRVXaZ=R|d6 z&qQhZ%cAM*AI8DN(y%|f+?}=8A(5-NisF^NAjQ~LLaS=_x=%oFEONjN3825_#%CP% zdWg-mUwnfLg!bPIizGRA5tsZtm~ZQjYTcgCu8aLqUN$JGFA@O#WJy(x{DHu%_D0$g z>`2j9PZ$u^b)AgWVF%-tM8vzO3rCPhf$4!-RFh%a&@6!p-ds%{4X_@LF~zP{%ci^SDDxzr1ORSNVw_2Q1Ko3OT&kL<0uj}6z<*z2Wr@?g6odYvWenc zD&~JS;$)cqohz_%QB{$5I)Bb-W8!ga}yg@6bd$V zsCxIX1e;TdAYg>7d});9tauFBmxS-P|H8P4YSO$J)+#YR;u;n7 z3Sulpm@5(f7GP8U*2Cn|(U!Xq$|%5t!9vf-psD)U9Y_JKz5YJ;ukiaQE1_Ht@~D61 zhCYuOh4)TPD20~igRRSASS*-qTDQ1&@AuY~PMZxh9Qn=updCjcr=KM-ui<>R1(@(K zF7}&1o2oS`3T?1UY?mKGtl^&p=^+c6eX(mF)f!rK>$JHQ6MwzZ0#Q9XF$RV+bjIJPfQhBP&5&&>^39-=gbtU|!<|YL8SJtANc(nLVR@$wCm_+@!^26FY*`gf0AO(x6RIHPY+8g1$Pede+0Yav_(XS zuXWN=e?IZyyc>Q}i8cU<{Od}#mL(Jb_Lp$dW^Lz& z@IQlXl1k#zCzs66!0r)dddgtUnQ~vagPe#U`;Znh=ZOtqE9ZpnFAW$9k?vX_2(G`Y z`<8j1jLv}Z_h&K2%Wk=GakT%o1gTF@B;}>bIj%^kiZ{?AEO}(+(LfR=3Xqb^S^B20 zsJfx&!s=n(D&$k2#C?nUi4D9mBP%Tvmu~pIR4=)^`FK3=01qvZ@^iq-kY4Rl#UZ7R z)fJrr%M|#!;OQl|Mrl6s0Qo3hwatV?1PzmNA{t294VJ|Gd+>qHphU@Aioz>)Fw{nX zeVdk!5S0zvp^$${1Q&=iCB%%i&}#yK9`xr8blJe`ytB0aHUi7LnUH`O`>^cn{VP%K zs}mf9)o==Es0XqST?=lAH-$K>O9AB1?~M)chrwBv2!GLf1?9hZd!=(4)!__YVQ}LM zO<{QKlFy+5&e2$1{J|97Az(woHZ*#p&m6{jSIO~DUJ8QO z(((>g&WEw-+Xvq^NSNkol0P~QV@dBxy~Byz45(7258RT@BlfixN54#Me4UE|o-@DG zbC}c!vy?`IFp-nh2dtcn-7%4~6w=<|R`}G4mpl|Zlj-q<`G>T5sdt^Bn< zmK<{jAN0&gA|X8Or!hMh(aHxA*N}s`nf0?#%4(ndbT@ZeQXY|Y8_~q~eK`kC!LQ0t z_J*rB0}$Az6QGKb4hqt$Wb+@;WnVcirSP5+zZJ$P=f=Zc(5^>}2)X0ig0)Y+wrxeB z`^}r8((Hn?GBF=Jys^3ecPsPht2vG>L3DntA~hB2%+Nv-X*9s@Yv{7AjqMtLqKrzw zyV^HF(9gz%T36qrO(cM#*Y%9cIyz&93BxHhj&Z}i^Lj45hg@Fn72P%Ea=tdNMJTVH zPmZDuU{|=Nx-nZkqI+}Mfsz5q19BpPrvH~3hh_V`cesF!34c;}+A?V4|7*&CWx_16 zsOs&DIv!^67trE-?mFCZ z%pLP2``M?SESCq)C-u7*Kz2oS%4BF8bs7w;=Qx|hZ6x{>j7^J;?rnR|inHvBbKjkPJt-u42+5LdqIcZ9TR0r~p&o$kv=VWE#;ctkWSdfr>1;Aen|!+$Yc21Vo&!v zu$XQsjpOy_QnBP8QnOSLk@u{6?ss{Zx$T3T^jR_yn0=3*;7?GAo~e8)c${}-P0wQI z+TiLwQ(m;6XUhNdgzJUVae+Q3{A%Zo_vJ9kU_O|PJD*%CI3mWBJjPFU)tf)2dW+Vl z`MZF5m+z9e~AOIcurJC`93AHRPGQ*i?Jsg%XN#5*!^?S@vi>u;! z%D0c$w$Hryq;r?`{aWWlOr^c(Ho|`2zFPVZ;63TP4vXq=rIQ-HZje}yIOu!=&{JkI zlc~BTd$;*c?`)?V1+{5&k?w5>xoo^CD)hmA1#&1-*xk1#jY_emUW0|IoXz+Un#y&* z3Rx%r`&fbwoh$=?uI?XSS|A?-%sS#J%Dd(;N5Y)M(3rfyj}=UyU}VmAuT$#gyY;Wwp!Cd#Kcj zzO=&Mt!$hifaZ`4(uWOvU8Hpc*2f@B2akt0b%^{x^(hg95?G0T!=K7r zO8gZxWg!Dx{l(T_maAF$t~!1(28wF^^*{eEja+3!{DTMG-QqVyNN2KPEoG0kpJm0~ zu|VVNE;*JdWl7L`JsZhaeCSM|L)2Ap!JAGUg3+yS@ZkvpmGSkh1>LpXzeRBb+k5<_ ziie`;bGKFWU4~VSP}n#F`o|HhM0^nYf7cK5Ia!H)13~>hYew7i|5#}^>h@mHgJV{9 zF?aJU3cG)9hv&sfYHk|d3XtcEB|PT1Y81ZA0(Jq-z-E=~L6-8GD-3VnlR-30*h&x*s$-V`zOVVFb(}I$Ln8{pT%&dkHg(`~uD|yCZ~Lz~Zr9F=PpUiv zLCpZ{oc8aGYsFja@sg^47^&a_hl|~331_<+IeZfaGBS>%eG=yI0`SH{p5}ZlmqNo6 zXL0VENU(F4JoKryzcHKXpO((o@Q{F~<5!doN%NpdTKXjJEJgHlwOJiykp)07QT(sw ze|5B2V&)6hV=gU%-R%Yxi_{-)OrcM~i`AV7KDGhfU`^TFdq5lRHH6HP_4Po@Vzw#(*c&4-1P-89*QV{ zwA+s~IA5}X)}eokCZo}KI)6~&$jL85 zzNhdT#edWJBZL9sk3tiy^&{u5ve4|P;arynHHwv`4N zNMJ#LO1jA2aA(PGfZ0fM2&>}DB^DmGJ4_aQ$-e7`drSOKVhB`UfY9I`}#?@ZvB(-f?WfHqS8wn5;k?vEl4z>A0i&KT^z>P4Kip=L13A9*u)YWm5?v z6vMA>@9*;vNIH(5qqVymD~7Z0rTKsMB7Nuu%kobFYl{ny%>yX4sDLJiqsvvn*h{a* zX&FnWpZOz)gz0{{-LckMHtt!|^R^anYwtX{N?YZK1Dr6TRbJ`0C&yXoSbr2dr*2}R zQYtF{tM{$u>i?h(ke+r3!;xuOnu!k}(zMF8R2fPIyiVX99SVI6^8ASK8dOU#r0VT~ z`2)nr){iBUZWfbeXjWZ8xU}*~v`}#+qvB46X0QXx2Vn$^z?Fcx>OJos>7QF3t};Hr zaq-y0HB=o+=k2syeUMyet>Zei9>yh?ph&Q;-QJCnp`7 zm_qS`bk)Fa!*e8jWL9M{@ZVm{f7`c3j;Kzo{;({j{MpApz)WbwNx?7 zGbl0cLc!-HIo`w!Z+3q)oLJxZUoU{H)x_dR9HQ#R@pVdeAwqo`0LeFOI6B0{{D4f| zQ~_(tu_xN&!YS1+gStdxNZ5lWjL!5H>jun)+3UBiYmW=@59X7_iPU< zi~zyzo5vO(+>kedyVos=N80->cOS%cJ20xPyY+dc`FXcl(K>AjW03sf?v2Tj>x-^t zoA=7;8thM!W2DTi)H}?!N6s`>Z)r`q0a3>w6ZM`S?Hc3d(mDjgjV&Hewhx!4M!FnT zGB_R`S4(_%azTy&J@Cv{%XoPI>g>dA&8Ug@BgJP0XNqo%f}qS#gqH}$EnkGB>wd?) zv1je*9y`kS-8sy-PjB`~@V%(H>mYyrxh5!6-r#H~MJ#7pr-7=dQuxdM0}L&DZM?zl z;2{J$57k~a+b&%Q&V0eTkKB2+YW=L@V|z>Nw1Py7jMn!>SlYT?!_QzpJK+rO$5xMt zgd)8Ur+V224*)p*I(}9lu*;B4Kh=Ni$g4G)1a#gq* z9!Fo)_7-WNH+e#gs_5PXZjnC~?Y%VLJ9F%UV`*F)53pOfZ@ESn`15?l_%iKYbAph! z>n)I-34g8oq)B7^*^I@|!J!`*4F&$?Om@1|Ym`7!_SE<~dtjim@ktIJhMmSqg^RO} z`Ay1tJ?F#ElQ4v`qi>@WU6pTXQ6z}9(xnE7#>KGLA6@#UvrD4$nqE>-iuDttayQ=b zir^&QDq3ypm~FbY%bwlnKP-O)rQ}n&pRn7eM92Nymd jYnUu(AVM9{5iXf zWim~u#3YJqhaK`*Mt4{yjqZ=KK&iYdk({MM`IX#`1VE8blBlzPwlCYHfvX>xvsM|a zyjO1_xy8;m+l$;0cVh?v&bNe14;r&AHFT_C4L#Xirn5-qeFK#6+LMZ*rsqURQ2&UJ zZgjU}-i+HRiIK{rd6}1s@rovSh8Z4k!-zI&N^Qz9_8I*MxT})vd{G%2SIpCvfNruk zXJ=&HV7Q+V`Z7fR)TuOqtzxCL32zU+mbHe&sjL0QopNDa2A9FcVE@2ZWfWoekbTh7 zJjXl_b$PCuHq4MA8MT~T^hiZVL4)&VuE@pN19r7#>cDzaXU1BTNB-N_OV?pr3q2oM z8h$t)XcMdL;V7zK_J^|OaEa4sSac$%Io-{Pv2-~84fJ>R)bF1tlHh#%D^NbN4H%qK zGs$L$juZ@13{|G!qDsWuXuJdN%+xU>NX_#M%Octgc(l&gKERp$DQCStQM*3tCU-vZ z2+CA2m>5EBLNzC9{jB_0x82M>VBd1i9n#)c`n==>E8{}tGP8{Vd__Wd#>hK~9m8+- z7)^Fl>`#n3VQqPOhV9W@(YkFp7Zv15yENy|Ovbx}%8?|OThB)~X%++hye0zFEoRX}!f8dhZyc5*wHGm;a-x6n^MRd-% zi+HfQYeRjskZONdq7a3kw-y}`gjtz8+U!uJwNtb^F z9l>p63)8qBSbLe?LMm5H6!K9}B*N$N-1_ULu`%sbgdULRPa>s@_#WxvX?|3u1YmnJ z_^)vRnsS2Ko~nYoZ|IHUy_#Tt(cpxQeZi*j-3|3O`^}B*O;E*LvZa0+JAdFzeBbQ5 zejT^qX*shnHb zuv=KCSEbGoj?^GM#0Mv9^mr#yX3OA1evi*@cyxdydL&nG$0AvfqF~_7Q`iEoaYsrl zibTpg6DEM*dr#ya&2*|TKd$!Y7SlspfAn)*1O@og`cooQAo$t&0H_lNtWIwE`m z%X|+i6t??c9zP8g$RgTO@pt%&4xNf~pXgmiaVmW}y<4v`>N}=mW>{uIn|ru4R-vq# z{pQP~!Q@?_9QQsgI0=zTZ>?+zG8{K4h&RXX>hO@KW9ZjUnBHX#RBmcurCiueB%o8A ziKV`^$@~D9+}!)X{&#@q+sQ`z2a9?e1f7T52O8lH0U;R#zP8_ z9$av3H;hCLb6oy}=?EFk_bzcU$CbB8@NImiD=BcDXza^oCeU+lD@(GwlsC3^!>G}N z9~H8&2!*N#3CZ|nZP>!ibkFMcMLPtx(L?ZEA)w{eH4>MCq%b?ek2JZ+9Zqx4#&wCK zN`8$3<&DTWr*hJm`1MHu6?STrC_tghMfr#XJmb#02*;8zX6d$g5Ww-+KO=NRkT!Lt z=$x$YEd7zI?NF6`*zSF8hTQpNmk+0HdnMZvT+)^eZ1u3b>!UBh8zh^)r*`IRMstG* z@Z5_RmlgkUpt14&FTsX1gR>k9zH4v?(s&f(;x!%br>$OWGwq zmp2)G5aO1%l#KlYvCN|rdm!KiOOL0evU|&)UmcEto21h5j8I06#m18mD5gJP5fFL? zYg@h+h3#gy9BJ;?`H|rR8gjtgqjO#$%Gqw0~s+I=KPfo z7pA&5mHiCerF}cp@9>sx8~r))8oR0o-$q7N_R(eL&|2DCQi)?Bqe8vtvyEGC9F6>@ z)KITKhI6&o4$$wz6(-Y0d;?d8+2j$+vDr-@y<>*Gm%g$CIDrPv_R~)6QS#E|7J8A(H8Ca z6g=(}A2Q)806tJI5@El2Y_=vOBgitHKHI)P3ll~m!=yqNJz`H4?1gE;jY9EQ2msAH zu>k~3>2W&P!4Z|Psn-Ny1V6U97wevXt!N)Cj*aLR!`Ia4*pK7VU3dQU?&By`_owq- z@vg*dW=)U%TljS6LFYlM_ilZhFwOZ%HZEdnI(3Q0SCuahusahK_G2}5ek)r$i>s(V z(VIcsi46#G-ad5LzCjt#xWlDo-^}jU8lDm#e3te&*ugRH+jAs}MlctM+pREOjbg5O z|D5th6BR?5a_$^EgasfMLS8rVnkXD%_~DwKph^71t=ejJE0>g=W>o`#S6)6esTEt9 z^7&w<;=e>*?s7bPKO7YBAmAvC1hvIsrtD=?@Afho@{-2tLfJDG zg^Rw~NtC{KxLZKlwNA_oGaofwo)6VYBEI|P3pp+kQj;szYZH0t;kZuvp{n6YLa5?n zrpJGeO!w4dkmh5ae%4tv`%L?ptB-*<68d(rTt}45WuNd501DzBoXe@Rgq`nsHn$2LJBcH(yRx7e5T{8L@5d+ydMLYrrtm8OYSG< z5$T=rbmgFJ(r}o`dj2i0--!`+D|TzpPww|m4RaM2D1X8f6NZpF6Ccw*YI~3`C|ALy z3Y~>xuYc>U^5O%RT*PR=K=yf+_(Pf(ipW(+Ym>nsr$ar_LGhr{vCXM6v{183ASzfWR@&>|6gpgv+g{eg!Skl)Rae@G2^%ccKQb4`pe*x(O z0gm~?L-9w{?0L{w(1O(Qmkv>^`t!5=sLoq1gPT|S6LD@M9Q|xlYdZUP96bFuhDn8= zcE1tH+H+ch%(mpBzgYo3Y^wIfs5GZx1kt@#*g%kG#a`5KcE(sJbBuOL{j;(~Q9g97 zAEta&JhaRf1W7g3ZH9BkeJ0h%LozBJ3yV@(3UvNfjyoQYuH!eC;l z8xk+Iy(I{8#HSoZ_N)1WGehXmPI8;Yq7&wKJoRpmvd3C(vm-!M%-ZpBAA*J!@`p)5 z-`jtJ5Fd$N^0f}2U%$M-Lc?wUde6jhOYf!<710r#CcmLAvw}#RMAct(>;NQJ+F-P3 zxB|iS18{Zbq)-dV|Y^p(aC4+ zVgk0ke%Vr`Djr6EpKmLwzjnA3>#M>5$o?27cb=!+Fp&Y*OZGxaS~JXY;^xuj7c4Eb zS&4>+k?ww7 zw2_bqGM;R^0(@U9s?kjS-=w^|^1$Jh3Aat@Z)>S=t7^csusKxUKZsL-zBeahtAc`G zx(d$xM29xnB?WQ1e&9xY1Z|NGX9CohO;;o-*R84QW`?;fLg*8WK7i~krAuEm!#IqbNX-a24gth=kkYWWH#QA8ZxizoqvNqUVALO<~H;qXVFbP z*NhUs0)b}QcyPr5MrbVfrnZSkgKsBhl(zKxPpHj~XEYrIpY|oa2Xp%dpCOw+kdC`3AZr=$%oJ!;Rp(1U;}CE#1AqpY*a*4dmJ zykHZoOC{alOxNBo?mzJqPA=^|Z~rXHvmFkOLR_FV1B#6ZQzhNifV|x!I2(sl!}Mru6CKp(aGa;E!-DO66=IN z!W@w9F*nb6jFb<^&h3z7&_aK^0|)w;rOtvZHFL@yMlj#&_6!j*@N+>D8%z8r(g8`& zDtJy$d1c1IsQay@2XUG{uIsF8Vx~75I{HH}eYliol-&AMcEB1+meU~f)Pnbv^cR_* z%yPEn6ExX_qI;1}d3cEow+9vOu%^FVi%%>#Ax3KWz=bNkCrpW?xLT5 z@~QH5a|k{}U5dpUK_^?cXSL(=FN7dG!XPnZ0IIG3aOY|jN{UM?!NTmH;!tNehHJyR zZ2MKb+g#ar71ISHJDqG&6j`0;8$k0khxyEESIS`$Tt04y5yorlXHv+%x_&P6~G6l`I=EHasBAhnG8Q_>3e_G-2~3cG}%#2`vl&8qb?3rPZPWsw!kv z$KQXy`)wzA*TvxZsDf&Q55x$b5CLgklo^eQ*U3wm-#)#-RYq{N4`>S8pvoI>x?gbl7i);dAq5j|&AJ3}fQKgJ_Mb8su1Y+o*o;FU|gY zq!LicsYI%`}Twis6?Ft`l1kq*oymN!qBi@A2d^ zUFW;#I2&F$F#uI2p~AR9*WY65T}sH(XykKghcj{b_gjF4Zk~!CI`oGet;`>r-xQ9k zJD4++NQ57M^H}_r9*9B}AfKwwkCs3O)JM!_t|nmC{Jsdl%1_IOz|9^`)bGa^60BDb z>w0ZehI+=P%;0htv?F(JGTR0&yl{E)*gB#rgCT2A=Gu+-;go7(S#I))M`hG_!Otwr zNGqBSk9pVga3>7tlp?ovZR+|kZ9RwQrpy3I<`OYv#p($H-D5cN4SiyB zOm{VIg3`P_3;3aw&q_}{-``f`)okTXSqXNqMoW>3V?ukM#nS5cz4CjS4vzA3Ko+L{ znjM}~6`Y7o0eLw_C^O_u$SfjQ`+*Pb>{8>FWp?=HvnjZlKSRz)faFN!w|2>LAxC5h zw2(yKG6N4PXII>mW7QT&zN{+*9PeBtj(>hZyiTqjZAmX-b&OdSRCDER|v*&Okj`?;}xPA`bv4kG-yK9(N}(T z8@^eUy2;PD7@(glEciqE zhJ6RZU6g9@L~{VQiq5 zshgihN=Bi$JYU|Hcqa$Rz80+MAOC<(LNIlgzkKswxsi~FW2B5W zfDXieOYehKGuuji#T;SrLUP~ZfA>^`$C2Obc`(Qag_cbHSM zYy@6#d<@ZOa4($g<2HS(m^VeH1p8#kzlsmA(~ErEq~&%)Ct{hz2gfVvMxeC(=J$){ z+laeYk!{rM$v*-N_dOZ@_x(4}p?6_v(h?}*XyJ-p>j;Inx|lNY;2v;Zd9ae87`0ka zpYt_u_hq01Y9AzP#&1fL<2u8?fVuQ)uw^o8R3codO6WM+q>KB!rgYI7YKMXkJhhVt zJYzMQ02VV5#!QrZB<(-erC*Z2W_?a0n*JPIAW&^E92h5#be_|RrjB?Y5PQP`5b>wD zS!|czIx3zcbza9}e`CEC%{(Myfwi@z;9f8Jl56`$pg{=)8GzRQqIQ6VY7Wf?vR$Zt zN2%N~kVjha>}!$n(4>$9uD7r*YMuyDFDW$BAll}H=0XeD-e4pjy0;`Yq!37y2XVjt z`Bhc6#XN3b+nmpF+4C-Lvuoz#8TL;A%X@J9`d~dGLt$4YE9UCl2@~A$uete;f}V1z zB6P;j?n~#s52y^y6GkJ$*(ZvD7gewW{orEyagF8Br-IRz%lv?qPV*Q<`^RLtw{HQ#sKKM3P&e%1N$Ri5G%5EKlL&szWX?HbUc7e8}wa%S~1~?e*rWb#0m$P~{bb908H)90*HYO1?-lisxqNqxfyQlV7bRUN&IK z?qo-D&Pvzi3ubkksJUpLlL-@9$lUOmZ zn790U`uN865ImB^d;Z~F>sCR8&i9S3z7my4S7=vc=V5WWp+9XaKijH%W23i)zH-Nw zn6_C)>MnZi(R{J&`JU*LxKKA>2>eKVR1)sN4VLzH`NcCEdvH*|CEf- zz#AjAwm*T?L*w=d`^1grQHCPTQiZM+N_&VkNJ^u zEUfLHHK(>M?muf^5U=1_=D9?+1v=*Y$8%og-x1lhJmGjSN~yyBrD)WS5k&YhJCENZ z?-*CcGs=1Wrl6^*ROVuv0l3IReBH07#wRs*PK?NJG9X`M)Cik z*TolFsWTZ>;OcEo{uO2r9*egK+nIqy`d4gkrVRo=5Z~{`(;^VsM<$pae3v=lOkYw-}dXKC#C#cRD*`7)w0LG@n^PQL{MnlYVhfC#i3%th04o{`= zK%gtG1}_6qH^DTWA;QW{-U6Mgc;$}~`tQou^xKEgr;vpsvu$<|KzPr{cE21Eu9Dae ze;w?J4nRxV>&f!l5sByb-xKNP#w4@8#H4qkEwT&Xl6v-t%@YV#}8BY8v zB00G;Yd<|!?7VsNti>nm)UEkVNsG|At=&h+Lh860IFcxq`+#R^UqPAU`%7_TKsvUt zAUkDYLl-<8L1pCnGD9Sg+FkIR6f9Vu+`Rpn;u@U!ye=HxP|OV$W!krxoeoRQd%i=* zznQgZ)j4jMKaP68x);@Bb0VC=+hCIQ239A(|_V|mw=px27Y zpRLKpT50M)war}Dlyjvh<1-Y|LSS$wqm|k%BDk$e}aAUO_(I$ zQwtcVyX+OYWR6l~@qgc6+b7SERZ6Y5UB3MRF?&lJM1w(eNKrFeygst)4yUF{cgR5J zo4g~y&9xWir!Tbo<~NLTL#rBt4ox<(F_O#CFClxS*ZVMq@EKt%XbUgovOkw<&# z2l&&w=5^)Hm|*b{13aXhO<(CJ%N9%P_1VmSgf5-{K>76z8^<0?ts%+yVf#$)Qr|4+ zNWGYBg9FVUaNtu>7mU@WjgO_~GHTyQrjo(8y%~T**Pz;#d7!>fa0tRp6y%Nx?i}O7 zE*ck*U7A9|?1g_7*tr0?U-n%v^WGhP0)a2;Tz%slu7ehk#_o8W@%kVsKm8HK|x=_D`5}1L*;9C5f$E@Ly0Tw zCC==n!LXjT7I@B_7`9_W!XnEHJfx1wiS(|0^s}JM7hl1= z-*Xh}jV6pfu#S_chh^{k$scnk+EzSSBuvGD_W58M}@tb6#kP-P`eV82NM6l z702cE3WG0?sWo;E55lldFAi`K>v{jKeh@oF@4UFvB`s)?AOlaazA4i~Kw{9Jhp~6b zVPJYT5tN<{PqCMfDa_=+O-Gz3qhfN|lLn4O7awB$<0!TXJ%1eYK+~V7+2@xpb~KL_ zsa${z9unv>4jB7L2|o4fOB9K~f#ahN?iJ-iAd1a?JHM0iswuR@Me+$Dtt-O$K04)$ zoL*W@^zL0OCXV2cdF$&a2GUqwyHS`43nG6o8HnNEs}I_i>-WJ-JpD&uhK{(rY+j>p zIG6Gq9?W}+)+OHktYtdVh}PaZ?)*r($S^Gig5Qwh<*TPkq>ImfY=Op;ibY@5ZvPK( z1q*8TV2|LBtrEyG$0H%)JgYaLK#IIhIK$pSd!|<_iCO!`nKx=wv;8k=7i!JJ~k7C;S3A_+eRvH&7?1- z)x3*3SZAIRr^5G85m88D(Sy{-YjZAMj!4|9f#Li86HtT_AnZ<~6b)(PZS7qeo1Gfj z@_oM9ENup4k*6<^HIgSd>YxYV=?FsIuw0p8l;`rfQD{&bUbm4vMgk+$nctL1ZHcr$ z4<#Qjhw|?}znt?^yS4$tL3whQhvw_u3vfW9r`GRO{G~Qo)s0ZvgqOg52#V;~5p7yf z8klP`1_@G*s>V@Xbr`s(*k#5UL2ywdI7`)nmvPUqoh%xAy#z~3{l`r@%)xvHg^U5a zCqh^RA4SdR*}Pa>$Gn)g!+VhN0srH$HK`m<34&3?$-P5c(6Hgse4H{aJzgf>LyAQI zM_f69Vlcf%62qIpSr!RuwD{o<%FV?+iPACwxYegx-}Np{G(>i)SME79`9VOon6j6O zK*ebFp$1&5q(*w5V%sh+cvi3l+w-8|+N&lSo}RocF7Fk`c2yy{jtk0_fXGfZb8aET zT|O=$#8VnM2b6UQg$oCiXBpA#%zRQCPSbC42;_mO2-ibO%*B}8mXb0e-!or!olew9 z<+)G5HXqmDVizA+GJVYj@L9QWxsHEXxW4&>27Py$;Dg(*35VQ^#=fko5MTcMw9+mG zBsHL1Ykv!HaPvzjm?Iby>oOmAio`aNVG=X=05*eDYqWB2!w_hvKI9paCR5l??S1%V z5O6opaHcv@SP?cCME5cAxmiuvi<1g$2Eb-ifSKc4AUAL}n!(0#IekZA_)^HgqN_q3 z&DuD*YoaYICkxE=W;E{tGEv9<#J0nyFd2ZLmEE;3C(ahnoZg&d_nVNtt_tIeY%@@e z_0JGFTTFO4XX;_KyGfr(hn6E@i8P%H(pFtUsN)#O7c zRdHxvR=M2~_tJwZP69CKGsgunVKpi^FK4sjh&TG1nNQO05u}l%$%MBu{Q_^HuokL{ zG?T2YJ&}*ma<KK{&@VK6eXBrc7uBx|;k(HP>|X<;m7 zL{X8lG+kqv8AOt7mtGN*Q1`XgPl@~09_T8xYW99x_n(dlbNT!n8)Z+xaykS_+rq24z7lOu5xiN;EIBSwP=9qn}fo#FWot<{iN zaPeN)Dt(ZDqVzXa6rqy+Uy0z#fJoTedOie$pf@?iR2EGhJ)i-o{%PBqnPVTjKce->2_R;+@SWH9<*rng`9FHkdkEwmz} zEIDh0;w>+0JD6e|KVZ9RqY#ks8acT^2D={)pH%a$BVtz)rd78-kf!Y zs?cm9YLWD!2W(BE_~wTVwtr?G;cbc}_1Mj3w(i^Mdei<{k+*Gy-buD(PcM@;FXUG= zm36}G^tL*JCDn?SIokYmt&I;z?uhvOrh&Dm=&bT!eH*Rn)*8u*YY2*B>t?_S9<~T~ zhD=}wb?56S9&kZ1f?Lk(pzmaE5#@3qvq8v?Q-8N~#5imcaQW*s`YicceC`GLab;A6 zdH(v540*NIlaT=RIPJxbXj2Ei`r(Y|&@aF|7tzL!c=f+4{q}zdDXmFTd%F@RSWAy@ z-jq@Vq(PX%GLi3nX12g|P*ZuOh--z`l~L~wJB{+SGrt!Fcb@tO=1r8u5k!+kdJX*& zuJGsR8uMO0#tT?aS>NGmB_oQ;0xn^DC$4xzz-cz1U5_Xg&O~!cf!2fXYcz|AL)M+9 zuD-gfMF(CVL+Jo}Q6I#aQ?hzCL-RVH)azdHN?+q!?Pa;}7wF>D34}ieMoMpHTOjbr zZ3`VKTGof?36mceRa;PE(6a4x1%F0Vh=JP(s7=QePKLNK&Sdr^x*%c5WQztU`^zGly?Y_@#QDClW*L{ObT ztxOD(T-%5H1F?5vX#`veYHJ^_qX@-Z?BKokb9Bk?b4aL9tLBE?7;_^w9DL>aQA!N5 zIGznyptB>x>p%1*PI?bH$QwLNcN96)|A8Ref32*ow{_#?MdP&}l4&r1_}x*&W>0giu` zc(a?A>X(&xI;|^=)XRD%v8lFi-LG3C{z8~bgJ!a*CuM&sh?|TUwmlZ%#-~-(LdN?XfE$h)PH!CcF zWPw!?LidA;jL&y+*XD11MSWN9k#YR z^gQq)xAUa_4XGFa2JGj@734$xVS`<&xcwy!)?FLgXxU|B-Z{60oVRY}*Tsv6(raIU z@=ddJia7KjdRr;NaIiD+Y%MJfdo~dTDG=Wb*BtVRLh#Te>Y-tf_l~Vy3F_TTtQ9n< zI==b!6x5d#=K>Y;aTOWYf3R}dIqNZ5N^m?%V8U+B)FxN_BWEhl!f9=&_>YRtC6Om1 znL=q(L`i_zFH4O$Kug~;_JHG~BloW9DsxlH76n0~h({gp9jMc_iV(_LY$_efRsw;k z&>2$SWA>al*wrhqL`D^%cL`2%uyVvNPe=qX>D7K$5xMm0MM)uwMT1`++^O<*OoSyNc;o!j%)Up-l}0W2gR8YAEmy9J zK{I$9qUJv#uZ{-^m?%sGV2!e0U%5}@c&R4A-awSsCwfd-lr^ zDN>NElQC^PH~Dt5rRME!l@zQwu#ofYmuUdh^Cw+NA6z0_!`PX$9XQ!0tTGBlkT z2o?AwcM%&9y6F?-J}M+w29;AYO?Sjg4ir9LZTyd6bvubp$NXUqq5eMHyUeicW=wuS zrOUw30y6DgAQ`h`n1r_a47d|TUqJO}ez@ek7hgBBsARlTbt*y)0zH006c+n4! z2FFaW_!xry)}V99%uUoYGpZI`fNv`}`tc&EFRlAWBqcB z8=r+vFp!X?-b%lED8mM9gyU^I#Jd#scao{^K;Xs0$-HGoBh-bF#Cs&O~F zW!P0Bu`RGXQD_fTsWmxN+Cb1J3fCo!o?}KL3p@+r!lGx!HtVqu0bVhG-Eq!diDT*M zaicGZn5wLpx|=yp2jjI|$gHHwrmuP1e08y-8pSO$o9o4^c=pkI*t5W1m@deb9Q)1# zggC90+a#a2f}g@s2dO472W>xR*!+{84dvUSHjOs)Q_4pJk8agD&>v@GTO@%mzyKe# ztn$?e^_j$!F(-%++@tXU4=z&lzCvF~8|PX-zLfZN+R+BDVKyV`6t-V&Q6Xd5B%rQs zcsDOer8_>@mAq=QwF>c5pe*wXf4hP5*(kj&$6uC*c#M*5<-J=UYXlNZ{Ay^^cRB71 zKSi?Z4|8ARQ)E4y& { + const select = browserProfileSelect instanceof HTMLSelectElement ? browserProfileSelect : null; + if (!select) { + return { value: "", name: "" }; + } + const value = (select.value || "").trim(); + if (!value) { + return { value: "", name: "" }; + } + const selectedOption = select.options[select.selectedIndex]; + const datasetName = selectedOption?.dataset?.profileName?.trim() || ""; + const fallbackName = selectedOption?.textContent?.trim() || ""; + return { value, name: datasetName || fallbackName }; + }; const updateAuthVisibility = () => { const authType = authTypeSelect?.value || "interactive"; @@ -39,6 +56,67 @@ export function getAddConnectionModalControllerScript(channels: AddConnectionMod if (testButton) testButton.style.display = (authType === "interactive" || authType === "connectionString") ? "none" : "inline-flex"; }; + const loadBrowserProfiles = async () => { + const browserType = browserTypeSelect?.value || "default"; + + // Reset warning + if (browserWarning) browserWarning.style.display = "none"; + + if (browserType === "default") { + // Reset profile dropdown for default browser + if (browserProfileSelect) { + browserProfileSelect.disabled = true; + browserProfileSelect.innerHTML = ''; + } + return; + } + + // Check if browser is installed + const isInstalled = await window.toolboxAPI.connections.checkBrowserInstalled(browserType); + + if (!isInstalled) { + // Show warning + if (browserWarning) browserWarning.style.display = "block"; + if (browserProfileSelect) { + browserProfileSelect.disabled = true; + browserProfileSelect.innerHTML = ''; + } + return; + } + + // Load profiles + if (browserProfileSelect) { + browserProfileSelect.disabled = true; + browserProfileSelect.innerHTML = ''; + } + + try { + const profiles = await window.toolboxAPI.connections.getBrowserProfiles(browserType); + + if (browserProfileSelect) { + if (profiles.length === 0) { + browserProfileSelect.innerHTML = ''; + browserProfileSelect.disabled = true; + } else { + browserProfileSelect.innerHTML = ''; + profiles.forEach(profile => { + const option = document.createElement("option"); + option.value = profile.path; // Use path as value for --profile-directory + option.textContent = profile.name; // Display the friendly name + option.dataset.profileName = profile.name; + browserProfileSelect.appendChild(option); + }); + browserProfileSelect.disabled = false; + } + } + } catch (error) { + if (browserProfileSelect) { + browserProfileSelect.innerHTML = ''; + browserProfileSelect.disabled = true; + } + } + }; + const updateTestFeedback = (message) => { if (!testFeedback) return; if (typeof message === "string" && message.trim().length > 0) { @@ -71,6 +149,14 @@ export function getAddConnectionModalControllerScript(channels: AddConnectionMod usernamePasswordClientId: getInputValue("connection-optional-client-id-up"), usernamePasswordTenantId: getInputValue("connection-tenant-id-up"), connectionString: getInputValue("connection-string-input"), + browserType: getInputValue("connection-browser-type") || "default", + ...(() => { + const selection = getBrowserProfileSelection(); + return { + browserProfile: selection.value, + browserProfileName: selection.name, + }; + })(), }); const setButtonState = (button, isLoading, loadingLabel, defaultLabel) => { @@ -100,6 +186,20 @@ export function getAddConnectionModalControllerScript(channels: AddConnectionMod authTypeSelect?.addEventListener("change", updateAuthVisibility); updateAuthVisibility(); + // Browser type change listener + browserTypeSelect?.addEventListener("change", () => { + loadBrowserProfiles(); + }); + + // Initial load - only load if default browser is selected (to set initial state) + // This ensures the dropdown shows proper initial state + if (browserTypeSelect?.value === "default") { + if (browserProfileSelect) { + browserProfileSelect.disabled = true; + browserProfileSelect.innerHTML = ''; + } + } + addButton?.addEventListener("click", () => { setButtonState(addButton, true, "Adding...", "Add"); modalBridge.send(CHANNELS.submit, collectFormData()); diff --git a/src/renderer/modals/addConnection/view.ts b/src/renderer/modals/addConnection/view.ts index 3a6930a7..045fab89 100644 --- a/src/renderer/modals/addConnection/view.ts +++ b/src/renderer/modals/addConnection/view.ts @@ -47,6 +47,24 @@ export function getAddConnectionModalView(isDarkTheme: boolean): ModalViewTempla +
+ Browser Settings (Optional) + + +

Choose which browser to use when opening URLs with authentication. Defaults to your system's default browser.

+
+ ⚠️ Selected browser is not installed. URLs will open using the system default browser. +
+ + +

Select a browser profile to use. Profiles will be loaded when you select a browser above.

+
Microsoft Login Options diff --git a/src/renderer/modals/editConnection/controller.ts b/src/renderer/modals/editConnection/controller.ts index 85d7eb24..44e372dc 100644 --- a/src/renderer/modals/editConnection/controller.ts +++ b/src/renderer/modals/editConnection/controller.ts @@ -30,6 +30,23 @@ export function getEditConnectionModalControllerScript(channels: EditConnectionM const testButton = document.getElementById("test-connection-btn"); const saveButton = document.getElementById("confirm-connection-btn"); const testFeedback = document.getElementById("connection-test-feedback"); + const browserTypeSelect = document.getElementById("connection-browser-type"); + const browserProfileSelect = document.getElementById("connection-browser-profile"); + const browserWarning = document.getElementById("browser-not-installed-warning"); + const getBrowserProfileSelection = () => { + const select = browserProfileSelect instanceof HTMLSelectElement ? browserProfileSelect : null; + if (!select) { + return { value: "", name: "" }; + } + const value = (select.value || "").trim(); + if (!value) { + return { value: "", name: "" }; + } + const selectedOption = select.options[select.selectedIndex]; + const datasetName = selectedOption?.dataset?.profileName?.trim() || ""; + const fallbackName = selectedOption?.textContent?.trim() || ""; + return { value, name: datasetName || fallbackName }; + }; // Store the original connection ID let connectionId = null; @@ -43,6 +60,73 @@ export function getEditConnectionModalControllerScript(channels: EditConnectionM if (testButton) testButton.style.display = (authType === "interactive" || authType === "connectionString") ? "none" : "inline-flex"; }; + const loadBrowserProfiles = async () => { + const browserType = browserTypeSelect?.value || "default"; + + // Reset warning + if (browserWarning) browserWarning.style.display = "none"; + + if (browserType === "default") { + // Reset profile dropdown for default browser + if (browserProfileSelect) { + browserProfileSelect.disabled = true; + browserProfileSelect.innerHTML = ''; + } + return; + } + + // Check if browser is installed + const isInstalled = await window.toolboxAPI.connections.checkBrowserInstalled(browserType); + + if (!isInstalled) { + // Show warning + if (browserWarning) browserWarning.style.display = "block"; + if (browserProfileSelect) { + browserProfileSelect.disabled = true; + browserProfileSelect.innerHTML = ''; + } + return; + } + + // Load profiles + if (browserProfileSelect) { + browserProfileSelect.disabled = true; + browserProfileSelect.innerHTML = ''; + } + + try { + const profiles = await window.toolboxAPI.connections.getBrowserProfiles(browserType); + + if (browserProfileSelect) { + if (profiles.length === 0) { + browserProfileSelect.innerHTML = ''; + browserProfileSelect.disabled = true; + } else { + // Store current value to restore after repopulating + const currentValue = browserProfileSelect.value; + browserProfileSelect.innerHTML = ''; + profiles.forEach(profile => { + const option = document.createElement("option"); + option.value = profile.path; // Use path as value for --profile-directory + option.textContent = profile.name; // Display the friendly name + option.dataset.profileName = profile.name; + browserProfileSelect.appendChild(option); + }); + // Restore previously selected value if it still exists + if (currentValue && profiles.some(p => p.path === currentValue)) { + browserProfileSelect.value = currentValue; + } + browserProfileSelect.disabled = false; + } + } + } catch (error) { + if (browserProfileSelect) { + browserProfileSelect.innerHTML = ''; + browserProfileSelect.disabled = true; + } + } + }; + const updateTestFeedback = (message) => { if (!testFeedback) return; if (typeof message === "string" && message.trim().length > 0) { @@ -83,6 +167,14 @@ export function getEditConnectionModalControllerScript(channels: EditConnectionM usernamePasswordClientId: getInputValue("connection-optional-client-id-up"), usernamePasswordTenantId: getInputValue("connection-tenant-id-up"), connectionString: getInputValue("connection-string-input"), + browserType: getInputValue("connection-browser-type") || "default", + ...(() => { + const selection = getBrowserProfileSelection(); + return { + browserProfile: selection.value, + browserProfileName: selection.name, + }; + })(), }); const populateFormData = (connection) => { @@ -97,6 +189,15 @@ export function getEditConnectionModalControllerScript(channels: EditConnectionM if (authTypeSelect) authTypeSelect.value = connection.authenticationType || "interactive"; + // Populate browser settings (applies to all auth types) + setInputValue("connection-browser-type", connection.browserType || "default"); + // Load profiles for the browser type, then set the profile value + loadBrowserProfiles().then(() => { + if (connection.browserProfile && browserProfileSelect) { + browserProfileSelect.value = connection.browserProfile; + } + }); + // Populate auth type specific fields if (connection.authenticationType === "clientSecret") { setInputValue("connection-client-id", connection.clientId); @@ -141,6 +242,11 @@ export function getEditConnectionModalControllerScript(channels: EditConnectionM authTypeSelect?.addEventListener("change", updateAuthVisibility); updateAuthVisibility(); + // Browser type change listener + browserTypeSelect?.addEventListener("change", () => { + loadBrowserProfiles(); + }); + saveButton?.addEventListener("click", () => { setButtonState(saveButton, true, "Saving...", "Save Changes"); modalBridge.send(CHANNELS.submit, collectFormData()); diff --git a/src/renderer/modals/editConnection/view.ts b/src/renderer/modals/editConnection/view.ts index 09505cdf..f7636809 100644 --- a/src/renderer/modals/editConnection/view.ts +++ b/src/renderer/modals/editConnection/view.ts @@ -47,6 +47,24 @@ export function getEditConnectionModalView(isDarkTheme: boolean): ModalViewTempl
+
+ Browser Settings (Optional) + + +

Choose which browser to use when opening URLs with authentication. Defaults to your system's default browser.

+
+ ⚠️ Selected browser is not installed. URLs will open using the system default browser. +
+ + +

Select a browser profile to use. Profiles will be loaded when you select a browser above.

+
Microsoft Login Options diff --git a/src/renderer/modals/sharedStyles.ts b/src/renderer/modals/sharedStyles.ts index fff69ad0..e7df9b4e 100644 --- a/src/renderer/modals/sharedStyles.ts +++ b/src/renderer/modals/sharedStyles.ts @@ -123,6 +123,7 @@ export function getModalStyles(isDarkTheme: boolean): string { border-radius: 12px; background: ${isDarkTheme ? "rgba(255, 255, 255, 0.03)" : "rgba(0, 0, 0, 0.03)"}; border: 1px solid ${isDarkTheme ? "rgba(255, 255, 255, 0.05)" : "rgba(0, 0, 0, 0.05)"}; + margin-bottom: 16px; } .password-wrapper { @@ -164,6 +165,18 @@ export function getModalStyles(isDarkTheme: boolean): string { line-height: 1.4; } + .modal-warning { + display: none; + padding: 10px 12px; + margin-bottom: 12px; + border-radius: 8px; + border: 1px solid rgba(255, 185, 0, 0.35); + background: rgba(255, 185, 0, 0.12); + color: ${isDarkTheme ? "#ffc83d" : "#8b6500"}; + font-size: 13px; + line-height: 1.4; + } + .fluent-button { border: none; border-radius: 8px; diff --git a/src/renderer/modules/connectionManagement.ts b/src/renderer/modules/connectionManagement.ts index ae81c881..8e9a17de 100644 --- a/src/renderer/modules/connectionManagement.ts +++ b/src/renderer/modules/connectionManagement.ts @@ -44,6 +44,9 @@ interface ConnectionFormPayload { usernamePasswordClientId?: string; usernamePasswordTenantId?: string; connectionString?: string; + browserType?: string; + browserProfile?: string; + browserProfileName?: string; } interface AuthenticateConnectionAction { @@ -1164,6 +1167,14 @@ function buildConnectionFromPayload(formPayload: ConnectionFormPayload, mode: "a if (parsed.username) connection.username = parsed.username; if (parsed.password) connection.password = parsed.password; + // Browser settings apply to all auth types (used for opening URLs with authentication) + const browserType = sanitizeInput(formPayload.browserType); + const browserProfile = sanitizeInput(formPayload.browserProfile); + const browserProfileName = sanitizeInput(formPayload.browserProfileName); + connection.browserType = (browserType || "default") as DataverseConnection["browserType"]; + connection.browserProfile = browserProfile || undefined; + connection.browserProfileName = browserProfileName || undefined; + return connection; } @@ -1178,6 +1189,14 @@ function buildConnectionFromPayload(formPayload: ConnectionFormPayload, mode: "a // Note: isActive is NOT part of DataverseConnection - it's a UI-level property }; + // Browser settings apply to all auth types (used for opening URLs with authentication) + const browserType = sanitizeInput(formPayload.browserType); + const browserProfile = sanitizeInput(formPayload.browserProfile); + const browserProfileName = sanitizeInput(formPayload.browserProfileName); + connection.browserType = (browserType || "default") as DataverseConnection["browserType"]; + connection.browserProfile = browserProfile || undefined; + connection.browserProfileName = browserProfileName || undefined; + if (authenticationType === "clientSecret") { connection.clientId = sanitizeInput(formPayload.clientId); connection.clientSecret = sanitizeInput(formPayload.clientSecret); @@ -1195,7 +1214,7 @@ function buildConnectionFromPayload(formPayload: ConnectionFormPayload, mode: "a connection.tenantId = usernamePasswordTenantId; } } else if (authenticationType === "interactive") { - // Interactive OAuth with optional username (login_hint), clientId, and tenantId + // Interactive OAuth with optional username (login_hint), clientId, tenantId const interactiveUsername = sanitizeInput(formPayload.interactiveUsername); const optionalClientId = sanitizeInput(formPayload.optionalClientId); const interactiveTenantId = sanitizeInput(formPayload.interactiveTenantId); @@ -1234,6 +1253,53 @@ function formatAuthType(authType: "interactive" | "clientSecret" | "usernamePass return labels[authType] || authType; } +function getBrowserBadgeMarkup(conn: DataverseConnection): string { + const browserType = conn.browserType; + if (!browserType || browserType === "default") { + return ""; + } + + const profileNameRaw = sanitizeInput(conn.browserProfileName || conn.browserProfile); + if (!profileNameRaw) { + return ""; + } + const profileName = profileNameRaw; + const safeProfileName = escapeHtml(profileName); + const browserLabel = formatBrowserType(browserType); + const safeTitle = escapeHtml(`${browserLabel} · ${profileName}`); + const iconPath = getBrowserIconPath(browserType); + const iconMarkup = iconPath + ? `${browserLabel} icon` + : `${browserLabel.charAt(0).toUpperCase()}`; + + return ` + + ${iconMarkup} + ${safeProfileName} + + `; +} + +function formatBrowserType(browserType: DataverseConnection["browserType"]): string { + const labels: Record = { + default: "Browser", + chrome: "Chrome", + edge: "Edge", + }; + return labels[browserType || "default"] || "Browser"; +} + +function getBrowserIconPath(browserType: DataverseConnection["browserType"]): string | null { + switch (browserType) { + case "chrome": + return "icons/logos/chrome.png"; + case "edge": + return "icons/logos/edge.png"; + default: + return null; + } +} + function normalizeAuthenticationType(value?: string): ConnectionAuthenticationType { if (value === "clientSecret" || value === "usernamePassword" || value === "connectionString") { return value; @@ -1241,6 +1307,10 @@ function normalizeAuthenticationType(value?: string): ConnectionAuthenticationTy return "interactive"; } +function escapeHtml(value: string): string { + return value.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); +} + async function signalAddConnectionSubmitReady(): Promise { await sendBrowserWindowModalMessage({ channel: ADD_CONNECTION_MODAL_CHANNELS.submitReady }); } @@ -1451,6 +1521,7 @@ export async function loadSidebarConnections(): Promise { .map((conn: DataverseConnection) => { const isDarkTheme = document.body.classList.contains("dark-theme"); const moreIconPath = isDarkTheme ? "icons/dark/more-icon.svg" : "icons/light/more-icon.svg"; + const browserBadgeMarkup = getBrowserBadgeMarkup(conn); return `
@@ -1472,6 +1543,9 @@ export async function loadSidebarConnections(): Promise { ${conn.environment} ${formatAuthType(conn.authenticationType)}
+
+ ${browserBadgeMarkup} +
`; diff --git a/src/renderer/styles.scss b/src/renderer/styles.scss index 1f1f17b5..49079a9f 100644 --- a/src/renderer/styles.scss +++ b/src/renderer/styles.scss @@ -2577,14 +2577,61 @@ body.light-theme .install-button:focus-visible img { .connection-item-footer-pptb { display: flex; + flex-direction: column; justify-content: space-between; - align-items: center; + // align-items: center; + gap: 6px; } .connection-item-meta-left { display: flex; gap: 8px; align-items: center; + margin-top: 2px; +} + +.browser-profile-badge { + display: inline-flex; + align-items: center; + gap: 6px; + // padding: 2px 8px; + // border-radius: 999px; + // border: 1px solid var(--border-color); + background: var(--card-background); + font-size: 11px; + color: var(--text-color); + line-height: 1; +} + +.browser-profile-icon { + width: 14px; + height: 14px; + object-fit: contain; +} + +.browser-profile-icon-fallback { + width: 16px; + height: 16px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.08); + color: var(--text-color); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; +} + +body.dark-theme .browser-profile-icon-fallback { + background: rgba(255, 255, 255, 0.15); +} + +.browser-profile-label { + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .connection-item-actions-pptb { diff --git a/vite.config.ts b/vite.config.ts index 7f5a40d4..d1ca98bf 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -142,6 +142,7 @@ export default defineConfig(({ mode }) => { mkdirSync("dist/renderer/icons", { recursive: true }); mkdirSync("dist/renderer/icons/light", { recursive: true }); mkdirSync("dist/renderer/icons/dark", { recursive: true }); + mkdirSync("dist/renderer/icons/logos", { recursive: true }); } catch (e) { // Directory already exists } @@ -177,6 +178,20 @@ export default defineConfig(({ mode }) => { } catch (e) { console.error(`Failed to copy icons directory:`, e); } + const iconsLogosSourceDir = "src/renderer/icons/logos"; + const iconsLogosTargetDir = "dist/renderer/icons/logos"; + try { + if (existsSync(iconsLogosSourceDir)) { + const iconFiles = readdirSync(iconsLogosSourceDir); + iconFiles.forEach((file: string) => { + const sourcePath = path.join(iconsLogosSourceDir, file); + const targetPath = path.join(iconsLogosTargetDir, file); + copyFileSync(sourcePath, targetPath); + }); + } + } catch (e) { + console.error(`Failed to copy icons directory:`, e); + } // Copy registry.json for fallback when Supabase is not configured const registrySource = "src/main/data/registry.json"; From 9c5f70c900f1b70cd36af37195b22ee3e3fe9ed5 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:56:24 -0500 Subject: [PATCH 010/257] Add optional file type filters to saveFile with extension-based auto-derivation (#354) * Initial plan * Add optional filters parameter to saveFile function Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Fix extension extraction to use path.extname for robust handling Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> Co-authored-by: Power-Maverick --- packages/toolboxAPI.d.ts | 15 ++++++++++++- src/common/types/api.ts | 4 ++-- src/main/index.ts | 4 ++-- src/main/preload.ts | 2 +- src/main/toolPreloadBridge.ts | 2 +- src/main/utilities/filesystem.ts | 38 +++++++++++++++++++++++++------- 6 files changed, 50 insertions(+), 15 deletions(-) diff --git a/packages/toolboxAPI.d.ts b/packages/toolboxAPI.d.ts index c9e2a258..ef328439 100644 --- a/packages/toolboxAPI.d.ts +++ b/packages/toolboxAPI.d.ts @@ -262,8 +262,21 @@ declare namespace ToolBoxAPI { /** * Open a save file dialog and write content + * @param defaultPath The suggested file name and path + * @param content The content to save (string or Buffer) + * @param filters Optional file type filters. If not provided, filters are derived from the file extension + * @example + * // Save with custom filters + * await toolboxAPI.fileSystem.saveFile( + * "react-export.json", + * JSON.stringify(data, null, 2), + * [{name: "JSON", extensions: ["json"]}, {name: "Text", extensions: ["txt"]}] + * ); + * + * // Save without filters (auto-derived from extension) + * await toolboxAPI.fileSystem.saveFile("config.xml", xmlContent); */ - saveFile: (defaultPath: string, content: any) => Promise; + saveFile: (defaultPath: string, content: any, filters?: FileDialogFilter[]) => Promise; /** * Open a native dialog to select either a file or a folder and return the chosen path diff --git a/src/common/types/api.ts b/src/common/types/api.ts index 5f535a64..0b332161 100644 --- a/src/common/types/api.ts +++ b/src/common/types/api.ts @@ -3,7 +3,7 @@ * These types define the structure of the toolboxAPI exposed to the renderer */ -import { ModalWindowMessagePayload, ModalWindowOptions, SelectPathOptions, Theme } from "./common"; +import { FileDialogFilter, ModalWindowMessagePayload, ModalWindowOptions, SelectPathOptions, Theme } from "./common"; import { DataverseConnection } from "./connection"; import { DataverseExecuteRequest } from "./dataverse"; import { LastUsedToolEntry, LastUsedToolUpdate, UserSettings } from "./settings"; @@ -51,7 +51,7 @@ export interface FileSystemAPI { readDirectory: (path: string) => Promise>; writeText: (path: string, content: string) => Promise; createDirectory: (path: string) => Promise; - saveFile: (defaultPath: string, content: string | Buffer) => Promise; + saveFile: (defaultPath: string, content: string | Buffer, filters?: FileDialogFilter[]) => Promise; selectPath: (options?: SelectPathOptions) => Promise; } diff --git a/src/main/index.ts b/src/main/index.ts index d0c11ef1..10a46742 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1056,9 +1056,9 @@ class ToolBoxApp { return await createDirectory(dirPath); }); - ipcMain.handle(FILESYSTEM_CHANNELS.SAVE_FILE, async (_, defaultPath: string, content: string | Buffer) => { + ipcMain.handle(FILESYSTEM_CHANNELS.SAVE_FILE, async (_, defaultPath: string, content: string | Buffer, filters?: Array<{ name: string; extensions: string[] }>) => { const { saveFile } = await import("./utilities/filesystem.js"); - return await saveFile(defaultPath, content); + return await saveFile(defaultPath, content, filters); }); ipcMain.handle(FILESYSTEM_CHANNELS.SELECT_PATH, async (_, options) => { diff --git a/src/main/preload.ts b/src/main/preload.ts index bf8b5a36..e4452ea7 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -146,7 +146,7 @@ contextBridge.exposeInMainWorld("toolboxAPI", { readDirectory: (path: string) => ipcRenderer.invoke(FILESYSTEM_CHANNELS.READ_DIRECTORY, path), writeText: (path: string, content: string) => ipcRenderer.invoke(FILESYSTEM_CHANNELS.WRITE_TEXT, path, content), createDirectory: (path: string) => ipcRenderer.invoke(FILESYSTEM_CHANNELS.CREATE_DIRECTORY, path), - saveFile: (defaultPath: string, content: unknown) => ipcRenderer.invoke(FILESYSTEM_CHANNELS.SAVE_FILE, defaultPath, content), + saveFile: (defaultPath: string, content: unknown, filters?: Array<{ name: string; extensions: string[] }>) => ipcRenderer.invoke(FILESYSTEM_CHANNELS.SAVE_FILE, defaultPath, content, filters), selectPath: (options?: unknown) => ipcRenderer.invoke(FILESYSTEM_CHANNELS.SELECT_PATH, options), }, diff --git a/src/main/toolPreloadBridge.ts b/src/main/toolPreloadBridge.ts index 611ae17d..626e32d9 100644 --- a/src/main/toolPreloadBridge.ts +++ b/src/main/toolPreloadBridge.ts @@ -221,7 +221,7 @@ contextBridge.exposeInMainWorld("toolboxAPI", { readDirectory: (path: string) => ipcInvoke(FILESYSTEM_CHANNELS.READ_DIRECTORY, path), writeText: (path: string, content: string) => ipcInvoke(FILESYSTEM_CHANNELS.WRITE_TEXT, path, content), createDirectory: (path: string) => ipcInvoke(FILESYSTEM_CHANNELS.CREATE_DIRECTORY, path), - saveFile: (defaultPath: string, content: unknown) => ipcInvoke(FILESYSTEM_CHANNELS.SAVE_FILE, defaultPath, content), + saveFile: (defaultPath: string, content: unknown, filters?: Array<{ name: string; extensions: string[] }>) => ipcInvoke(FILESYSTEM_CHANNELS.SAVE_FILE, defaultPath, content, filters), selectPath: (options?: Record) => ipcInvoke(FILESYSTEM_CHANNELS.SELECT_PATH, options), }, diff --git a/src/main/utilities/filesystem.ts b/src/main/utilities/filesystem.ts index 4876085e..fdf20610 100644 --- a/src/main/utilities/filesystem.ts +++ b/src/main/utilities/filesystem.ts @@ -194,16 +194,38 @@ export async function createDirectory(dirPath: string): Promise { * Save file dialog and write content * MOVED FROM utils namespace - no backward compatibility */ -export async function saveFile(defaultPath: string, content: string | Buffer): Promise { +export async function saveFile(defaultPath: string, content: string | Buffer, filters?: Array<{ name: string; extensions: string[] }>): Promise { + // Determine filters to use + let dialogFilters: Array<{ name: string; extensions: string[] }>; + + if (filters && filters.length > 0) { + // Use provided filters + dialogFilters = filters; + } else { + // Try to derive filter from filename extension using path.extname for robust extraction + const ext = path.extname(defaultPath).slice(1).toLowerCase(); // Remove leading dot + if (ext) { + // Create a filter based on the extension + const extensionName = ext.toUpperCase(); + dialogFilters = [ + { name: `${extensionName} Files`, extensions: [ext] }, + { name: "All Files", extensions: ["*"] }, + ]; + } else { + // No extension, use default filters + dialogFilters = [ + { name: "All Files", extensions: ["*"] }, + { name: "Text Files", extensions: ["txt"] }, + { name: "JSON Files", extensions: ["json"] }, + { name: "XML Files", extensions: ["xml"] }, + { name: "CSV Files", extensions: ["csv"] }, + ]; + } + } + const result = await dialog.showSaveDialog({ defaultPath, - filters: [ - { name: "All Files", extensions: ["*"] }, - { name: "Text Files", extensions: ["txt"] }, - { name: "JSON Files", extensions: ["json"] }, - { name: "XML Files", extensions: ["xml"] }, - { name: "CSV Files", extensions: ["csv"] }, - ], + filters: dialogFilters, }); if (result.canceled || !result.filePath) { From 2d65bf8263620708d23e3a6695c28d716ecb0d0d Mon Sep 17 00:00:00 2001 From: Danish Naglekar <36135520+Power-Maverick@users.noreply.github.com> Date: Tue, 10 Feb 2026 19:49:18 -0500 Subject: [PATCH 011/257] Signing executables for all platform (#377) * fix: enable recursive file search for notarization in release workflows * feat: add YAML regeneration steps for Windows, Linux, and macOS with correct SHA256 hashes * feat: add repackaging step for portable ZIP with signed EXE in Windows workflows --- .github/workflows/nightly-release.yml | 204 +++++++++++++++++++++++++- .github/workflows/prod-release.yml | 204 +++++++++++++++++++++++++- 2 files changed, 406 insertions(+), 2 deletions(-) diff --git a/.github/workflows/nightly-release.yml b/.github/workflows/nightly-release.yml index 4bc90156..fb240cd6 100644 --- a/.github/workflows/nightly-release.yml +++ b/.github/workflows/nightly-release.yml @@ -132,12 +132,117 @@ jobs: certificate-profile-name: ${{ secrets.TRUSTED_SIGNING_CERTIFICATE_PROFILE }} files-folder: ${{ github.workspace }}/build files-folder-filter: exe,msi - files-folder-recurse: false + 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) + 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)." + } + } + + Remove-Item $zip.FullName -Force + Compress-Archive -Path (Join-Path $tempDir '*') -DestinationPath $zip.FullName -Force + } + + - name: Regenerate latest.yml with correct SHA256 hashes (Windows) + if: matrix.os == 'windows-latest' + shell: powershell + run: | + $buildDir = "${{ github.workspace }}/build" + $latestYml = Join-Path $buildDir "latest.yml" + + if (Test-Path $latestYml) { + Write-Host "Regenerating latest.yml with correct hashes..." + + # Read the existing YAML to preserve version and other metadata + $ymlContent = Get-Content $latestYml -Raw + + # Extract version from existing YAML + $versionMatch = [regex]::Match($ymlContent, 'version:\s+([^\s]+)') + $version = if ($versionMatch.Success) { $versionMatch.Groups[1].Value } else { "unknown" } + + # Find the main EXE file (look for the installer) + $exeFiles = @(Get-ChildItem "$buildDir/*.exe" -ErrorAction SilentlyContinue) + + if ($exeFiles.Count -gt 0) { + # Sort to get the latest/main installer (NSIS or main app exe) + $mainExe = $exeFiles | Where-Object { $_.Name -match "(NSIS|Setup|Installer)" } | Select-Object -First 1 + if (-not $mainExe) { + $mainExe = $exeFiles[0] # Fallback to first exe + } + + Write-Host "Using EXE: $($mainExe.Name)" + + # Calculate SHA256 hash + $hash = (Get-FileHash -Path $mainExe.FullName -Algorithm SHA256).Hash.ToLower() + $size = $mainExe.Length + + Write-Host "SHA256: $hash" + Write-Host "Size: $size" + + # Create new YAML content with correct hashes + $releaseDate = (Get-Date -u -Format 'yyyy-MM-ddTHH:mm:ss.000Z') + $yml = @{ + version = $version + files = @( + @{ + url = $mainExe.Name + sha512 = $null + sha256 = $hash + size = $size + blockMapSize = $null + } + ) + releaseDate = $releaseDate + } + + $newYmlContent = "version: $version`n" + $newYmlContent += "files:`n" + $newYmlContent += " - url: $($mainExe.Name)`n" + $newYmlContent += " sha512: null`n" + $newYmlContent += " sha256: $hash`n" + $newYmlContent += " size: $size`n" + $newYmlContent += " blockMapSize: null`n" + $newYmlContent += "releaseDate: $releaseDate" + + # Write updated YAML + Set-Content -Path $latestYml -Value $newYmlContent + Write-Host "✅ latest.yml regenerated with correct hashes" + } else { + Write-Host "⚠️ No EXE files found, skipping YAML regeneration" + } + } else { + Write-Host "⚠️ latest.yml not found at $latestYml" + } + - name: Prepare macOS signing certificate if: matrix.os == 'macos-latest' shell: bash @@ -266,6 +371,56 @@ jobs: echo "✅ Quarantine attributes removed" shell: bash + - name: Regenerate latest-linux.yml with correct SHA256 hashes (Linux) + if: matrix.os == 'ubuntu-latest' + shell: bash + run: | + echo "🔄 Regenerating latest-linux.yml with correct artifact hashes..." + + BUILD_DIR="${{ github.workspace }}/build" + + # Find all YAML files + YML_FILES=$(find "$BUILD_DIR" -name "latest*.yml" -o -name "*-linux.yml") + + if [[ -z "$YML_FILES" ]]; then + echo "⚠️ No YAML files found, skipping regeneration" + exit 0 + fi + + for YML_FILE in $YML_FILES; do + echo "Processing: $YML_FILE" + + # Extract version from existing YAML + VERSION=$(grep -oP 'version:\s+\K[^\s]+' "$YML_FILE" || echo "unknown") + + # Find the main AppImage file + APP_IMAGE=$(find "$BUILD_DIR" -maxdepth 1 -type f -name "*.AppImage" | head -n 1) + + if [[ -n "$APP_IMAGE" && -f "$APP_IMAGE" ]]; then + echo " Found AppImage: $(basename "$APP_IMAGE")" + + # Calculate SHA256 hash + HASH=$(sha256sum "$APP_IMAGE" | awk '{print $1}') + SIZE=$(stat -c %s "$APP_IMAGE" 2>/dev/null || stat -f %z "$APP_IMAGE" 2>/dev/null) + + echo " SHA256: $HASH" + echo " Size: $SIZE" + + # Create new YAML with correct hashes using printf to avoid YAML parsing issues + printf "version: %s\nfiles:\n - url: %s\n sha512: null\n sha256: %s\n size: %s\n blockMapSize: null\nreleaseDate: %s\n" \ + "$VERSION" \ + "$(basename "$APP_IMAGE")" \ + "$HASH" \ + "$SIZE" \ + "$(date -u +'%Y-%m-%dT%H:%M:%S.000Z')" > "$YML_FILE" + echo " ✅ Updated $YML_FILE" + else + echo " ⚠️ No AppImage found in $BUILD_DIR" + fi + done + + echo "✅ Regeneration complete" + - name: Upload artifacts uses: actions/upload-artifact@v4 with: @@ -452,6 +607,53 @@ jobs: exit 1 fi + - name: Regenerate latest-mac.yml with correct SHA256 hashes + shell: bash + run: | + echo "🔄 Regenerating latest-mac.yml with stapled artifact hashes..." + + # Find all YAML files + YML_FILES=$(find notarize -name "latest*.yml" -o -name "*-mac.yml") + + if [[ -z "$YML_FILES" ]]; then + echo "⚠️ No YAML files found, skipping regeneration" + exit 0 + fi + + for YML_FILE in $YML_FILES; do + echo "Processing: $YML_FILE" + + # Extract version from existing YAML + VERSION=$(grep -oP 'version:\s+\K[^\s]+' "$YML_FILE" || echo "unknown") + + # Find the stapled DMG file in the same directory + DMG_FILE=$(find "$(dirname "$YML_FILE")" -maxdepth 1 -name "*.dmg" | head -n 1) + + if [[ -n "$DMG_FILE" && -f "$DMG_FILE" ]]; then + echo " Found DMG: $(basename "$DMG_FILE")" + + # Calculate SHA256 hash of the stapled DMG + HASH=$(shasum -a 256 "$DMG_FILE" | awk '{print $1}') + SIZE=$(stat -f '%z' "$DMG_FILE" 2>/dev/null || stat -c '%s' "$DMG_FILE" 2>/dev/null) + + echo " SHA256: $HASH" + echo " Size: $SIZE" + + # Create new YAML with correct hashes using printf to avoid YAML parsing issues + printf "version: %s\nfiles:\n - url: %s\n sha512: null\n sha256: %s\n size: %s\n blockMapSize: null\nreleaseDate: %s\n" \ + "$VERSION" \ + "$(basename "$DMG_FILE")" \ + "$HASH" \ + "$SIZE" \ + "$(date -u +'%Y-%m-%dT%H:%M:%S.000Z')" > "$YML_FILE" + echo " ✅ Updated $YML_FILE" + else + echo " ⚠️ No DMG found in $(dirname "$YML_FILE")" + fi + done + + echo "✅ Regeneration complete" + - name: Upload stapled macOS artifacts uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/prod-release.yml b/.github/workflows/prod-release.yml index 2635f252..7dd5ac97 100644 --- a/.github/workflows/prod-release.yml +++ b/.github/workflows/prod-release.yml @@ -136,12 +136,117 @@ jobs: certificate-profile-name: ${{ secrets.TRUSTED_SIGNING_CERTIFICATE_PROFILE }} files-folder: ${{ github.workspace }}/build files-folder-filter: exe,msi - files-folder-recurse: false + 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) + 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)." + } + } + + Remove-Item $zip.FullName -Force + Compress-Archive -Path (Join-Path $tempDir '*') -DestinationPath $zip.FullName -Force + } + + - name: Regenerate latest.yml with correct SHA256 hashes (Windows) + if: matrix.os == 'windows-latest' + shell: powershell + run: | + $buildDir = "${{ github.workspace }}/build" + $latestYml = Join-Path $buildDir "latest.yml" + + if (Test-Path $latestYml) { + Write-Host "Regenerating latest.yml with correct hashes..." + + # Read the existing YAML to preserve version and other metadata + $ymlContent = Get-Content $latestYml -Raw + + # Extract version from existing YAML + $versionMatch = [regex]::Match($ymlContent, 'version:\s+([^\s]+)') + $version = if ($versionMatch.Success) { $versionMatch.Groups[1].Value } else { "unknown" } + + # Find the main EXE file (look for the installer) + $exeFiles = @(Get-ChildItem "$buildDir/*.exe" -ErrorAction SilentlyContinue) + + if ($exeFiles.Count -gt 0) { + # Sort to get the latest/main installer (NSIS or main app exe) + $mainExe = $exeFiles | Where-Object { $_.Name -match "(NSIS|Setup|Installer)" } | Select-Object -First 1 + if (-not $mainExe) { + $mainExe = $exeFiles[0] # Fallback to first exe + } + + Write-Host "Using EXE: $($mainExe.Name)" + + # Calculate SHA256 hash + $hash = (Get-FileHash -Path $mainExe.FullName -Algorithm SHA256).Hash.ToLower() + $size = $mainExe.Length + + Write-Host "SHA256: $hash" + Write-Host "Size: $size" + + # Create new YAML content with correct hashes + $releaseDate = (Get-Date -u -Format 'yyyy-MM-ddTHH:mm:ss.000Z') + $yml = @{ + version = $version + files = @( + @{ + url = $mainExe.Name + sha512 = $null + sha256 = $hash + size = $size + blockMapSize = $null + } + ) + releaseDate = $releaseDate + } + + $newYmlContent = "version: $version`n" + $newYmlContent += "files:`n" + $newYmlContent += " - url: $($mainExe.Name)`n" + $newYmlContent += " sha512: null`n" + $newYmlContent += " sha256: $hash`n" + $newYmlContent += " size: $size`n" + $newYmlContent += " blockMapSize: null`n" + $newYmlContent += "releaseDate: $releaseDate" + + # Write updated YAML + Set-Content -Path $latestYml -Value $newYmlContent + Write-Host "✅ latest.yml regenerated with correct hashes" + } else { + Write-Host "⚠️ No EXE files found, skipping YAML regeneration" + } + } else { + Write-Host "⚠️ latest.yml not found at $latestYml" + } + - name: Prepare macOS signing certificate if: matrix.os == 'macos-latest' shell: bash @@ -270,6 +375,56 @@ jobs: echo "✅ Quarantine attributes removed" shell: bash + - name: Regenerate latest-linux.yml with correct SHA256 hashes (Linux) + if: matrix.os == 'ubuntu-latest' + shell: bash + run: | + echo "🔄 Regenerating latest-linux.yml with correct artifact hashes..." + + BUILD_DIR="${{ github.workspace }}/build" + + # Find all YAML files + YML_FILES=$(find "$BUILD_DIR" -name "latest*.yml" -o -name "*-linux.yml") + + if [[ -z "$YML_FILES" ]]; then + echo "⚠️ No YAML files found, skipping regeneration" + exit 0 + fi + + for YML_FILE in $YML_FILES; do + echo "Processing: $YML_FILE" + + # Extract version from existing YAML + VERSION=$(grep -oP 'version:\s+\K[^\s]+' "$YML_FILE" || echo "unknown") + + # Find the main AppImage file + APP_IMAGE=$(find "$BUILD_DIR" -maxdepth 1 -type f -name "*.AppImage" | head -n 1) + + if [[ -n "$APP_IMAGE" && -f "$APP_IMAGE" ]]; then + echo " Found AppImage: $(basename "$APP_IMAGE")" + + # Calculate SHA256 hash + HASH=$(sha256sum "$APP_IMAGE" | awk '{print $1}') + SIZE=$(stat -c %s "$APP_IMAGE" 2>/dev/null || stat -f %z "$APP_IMAGE" 2>/dev/null) + + echo " SHA256: $HASH" + echo " Size: $SIZE" + + # Create new YAML with correct hashes using printf to avoid YAML parsing issues + printf "version: %s\nfiles:\n - url: %s\n sha512: null\n sha256: %s\n size: %s\n blockMapSize: null\nreleaseDate: %s\n" \ + "$VERSION" \ + "$(basename "$APP_IMAGE")" \ + "$HASH" \ + "$SIZE" \ + "$(date -u +'%Y-%m-%dT%H:%M:%S.000Z')" > "$YML_FILE" + echo " ✅ Updated $YML_FILE" + else + echo " ⚠️ No AppImage found in $BUILD_DIR" + fi + done + + echo "✅ Regeneration complete" + - name: Upload artifacts (Linux) if: matrix.os == 'ubuntu-latest' uses: actions/upload-artifact@v4 @@ -405,6 +560,53 @@ jobs: exit 1 fi + - name: Regenerate latest-mac.yml with correct SHA256 hashes + shell: bash + run: | + echo "🔄 Regenerating latest-mac.yml with stapled artifact hashes..." + + # Find all YAML files + YML_FILES=$(find notarize -name "latest*.yml" -o -name "*-mac.yml") + + if [[ -z "$YML_FILES" ]]; then + echo "⚠️ No YAML files found, skipping regeneration" + exit 0 + fi + + for YML_FILE in $YML_FILES; do + echo "Processing: $YML_FILE" + + # Extract version from existing YAML + VERSION=$(grep -oP 'version:\s+\K[^\s]+' "$YML_FILE" || echo "unknown") + + # Find the stapled DMG file in the same directory + DMG_FILE=$(find "$(dirname "$YML_FILE")" -maxdepth 1 -name "*.dmg" | head -n 1) + + if [[ -n "$DMG_FILE" && -f "$DMG_FILE" ]]; then + echo " Found DMG: $(basename "$DMG_FILE")" + + # Calculate SHA256 hash of the stapled DMG + HASH=$(shasum -a 256 "$DMG_FILE" | awk '{print $1}') + SIZE=$(stat -f '%z' "$DMG_FILE" 2>/dev/null || stat -c '%s' "$DMG_FILE" 2>/dev/null) + + echo " SHA256: $HASH" + echo " Size: $SIZE" + + # Create new YAML with correct hashes using printf to avoid YAML parsing issues + printf "version: %s\nfiles:\n - url: %s\n sha512: null\n sha256: %s\n size: %s\n blockMapSize: null\nreleaseDate: %s\n" \ + "$VERSION" \ + "$(basename "$DMG_FILE")" \ + "$HASH" \ + "$SIZE" \ + "$(date -u +'%Y-%m-%dT%H:%M:%S.000Z')" > "$YML_FILE" + echo " ✅ Updated $YML_FILE" + else + echo " ⚠️ No DMG found in $(dirname "$YML_FILE")" + fi + done + + echo "✅ Regeneration complete" + - name: Upload stapled macOS artifacts uses: actions/upload-artifact@v4 with: From 83f2c0a585fe0a05135ee6a579f4b81572f9f6fe Mon Sep 17 00:00:00 2001 From: mohsinonxrm Date: Tue, 10 Feb 2026 20:48:14 -0800 Subject: [PATCH 012/257] feat: Added support for Metadata CRUD operations (#356) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Enhance Label structure compliance and add polymorphic lookup support This commit addresses documentation review findings and adds support for polymorphic lookup attributes (Customer/Regarding fields) to complete metadata CRUD operations implementation for issue #319. ## Type Definitions (src/common/types/dataverse.ts) - Added `IsManaged?: boolean` property to LocalizedLabel interface * Microsoft examples show IsManaged included in all attribute/relationship/optionset requests * Properly tracks whether label originates from managed solution - UserLocalizedLabel remains optional in Label interface * Entity creation docs note it as read-only * However, Microsoft's own examples for attributes, relationships, and optionsets include it in POST requests * Implementation follows Microsoft's published examples ## DataverseManager Updates (src/main/managers/dataverseManager.ts) - Updated buildLabel() helper method: * Now creates LocalizedLabel with IsManaged: false property * Includes UserLocalizedLabel property set to same LocalizedLabel instance * Matches Microsoft's published examples for attribute/relationship creation * Example output: { LocalizedLabels: [{ Label: "Text", LanguageCode: 1033, IsManaged: false }], UserLocalizedLabel: { Label: "Text", LanguageCode: 1033, IsManaged: false } } - Added createPolymorphicLookupAttribute() method: * Enables creation of lookup fields that reference multiple entity types (Customer, Regarding scenarios) * Validates presence of non-empty Targets array with entity logical names * Automatically sets AttributeType="Lookup" and AttributeTypeName={ Value: "LookupType" } * Delegates to createAttribute() with proper polymorphic configuration * Returns { AttributeId: string } matching API contract * Comprehensive JSDoc with Customer (account/contact) and custom Regarding examples - Enhanced createRelationship() JSDoc: * Added CascadeConfiguration example with all cascade behaviors (Assign, Delete, Merge, Reparent, Share, Unshare) * Shows proper RemoveLink delete behavior for lookup fields - Enhanced updateRelationship() JSDoc: * Added example demonstrating cascade configuration updates (RemoveLink → Cascade) * Illustrates retrieve-modify-PUT pattern for relationship updates - Added LocalizedLabel to imports from common types ## IPC Infrastructure (src/common/ipc/channels.ts, src/main/index.ts) - Added CREATE_POLYMORPHIC_LOOKUP_ATTRIBUTE channel constant * Value: "dataverse.createPolymorphicLookupAttribute" - Registered IPC handler in main process: * Supports both primary and secondary connection targets * Extracts connectionId from WebContents based on connectionTarget parameter * Wraps dataverseManager.createPolymorphicLookupAttribute() with error handling * Proper cleanup with removeHandler() on application quit ## Preload Bridge (src/main/toolPreloadBridge.ts) - Exposed createPolymorphicLookupAttribute in toolboxAPI.dataverse - Exposed createPolymorphicLookupAttribute in window.dataverseAPI - Both accept (entityLogicalName, attributeDefinition, options?, connectionTarget?) parameters - Properly wrapped with ipcInvoke using CREATE_POLYMORPHIC_LOOKUP_ATTRIBUTE channel ## Public API Types (packages/dataverseAPI.d.ts) - Added createPolymorphicLookupAttribute method signature to DataverseAPI interface - Comprehensive JSDoc documentation: * Customer lookup example (account/contact on new_order entity) * Multi-entity Regarding example (account/contact/custom entities on new_note) * Documents Targets array requirement * Notes metadata publish requirement * Shows buildLabel() usage in examples - Method signature: (entityLogicalName, attributeDefinition, options?, connectionTarget?) => Promise<{ AttributeId: string }> ## Validation - TypeScript compilation: ✅ No errors - ESLint: ✅ 0 errors (TypeScript version warning acceptable) - All 12 implementation tasks completed - Documentation compliance verified against 5 Microsoft Learn articles ## Microsoft Documentation References - Attribute creation: https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/create-update-column-definitions-using-web-api - Relationship creation: https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/create-update-entity-relationships-using-web-api - Option sets: https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/create-update-optionsets - Polymorphic lookups: https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/multitable-lookup - Entity creation: https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/create-update-entity-definitions-using-web-api Closes #319 metadata CRUD operations (enhancement for polymorphic lookups) * fix: improve metadata CRUD error handling and add comprehensive documentation FIXES (Code Review Issues): 1. **Metadata creation methods now throw descriptive errors on missing headers** - createEntityDefinition, createAttribute, createRelationship, createGlobalOptionSet - Previously returned empty strings silently when OData-EntityId header missing - Now throw explicit errors: "Failed to retrieve MetadataId from response. The OData-EntityId header was missing." - Guides users to identify real issues (network failures, API changes, etc.) 2. **GET_ATTRIBUTE_ODATA_TYPE IPC handler now enforces type safety** - Added runtime enum validation against AttributeMetadataType values - Removed unsafe `as any` cast that bypassed TypeScript's type system - Throws descriptive error listing all valid types when invalid value provided - Example: "Invalid attribute type: 'InvalidType'. Valid types are: String, Integer, Boolean, ..." TECHNICAL DISCOVERY: **Dataverse metadata operations return HTTP 204 No Content with NO response body**: - Standard operations (entity, attribute, relationship, global option set): 204 No Content - Only OData-EntityId header contains the created MetadataId - Empty response body → makeHttpRequest returns `response.data = {}` - Exception: CreateCustomerRelationships action returns 200 OK with JSON body containing AttributeId and RelationshipIds This discovery led to abandoning a proposed "fallback to response body" approach, as responseData["MetadataId"] would always be undefined for metadata operations. DOCUMENTATION IMPROVEMENTS: 1. **execute() method**: Added comprehensive JSDoc with examples for: - CreateCustomerRelationships action (customer lookup creation with 200 OK response) - InsertStatusValue action (status choice column values with StateCode) - UpdateStateValue action (state value metadata updates) - Bound vs unbound actions/functions 2. **queryData() method**: Added examples demonstrating: - Retrieving global option sets by name: `GlobalOptionSetDefinitions(Name='name')` - Retrieving all global option sets with filters - Retrieving by MetadataId 3. **createPolymorphicLookupAttribute()**: Added note about CreateCustomerRelationships alternative 4. **insertOptionValue()**: Added note about InsertStatusValue for status choice columns 5. **createGlobalOptionSet()**: Added retrieval example using queryData() BENEFITS: - **Fail-Fast Approach**: Clear errors replace silent failures, improving debuggability - **Type Safety**: Runtime enum validation prevents invalid API calls - **Complete Coverage**: All metadata operations from Microsoft documentation are supported via existing methods (execute, queryData) - **Developer Guidance**: JSDoc examples show how to use actions like CreateCustomerRelationships, InsertStatusValue, UpdateStateValue - **No New Methods Needed**: Generic execute() and queryData() methods handle all special cases VALIDATION: - ✅ TypeScript compilation passed (pnpm run typecheck) - ✅ Linting passed (pnpm run lint - 0 errors) - ✅ Application builds successfully (pnpm build) - ✅ All 6 file edits applied successfully * feat(dataverse): add whitelist-based header validation for metadata operations Implemented comprehensive security validation for custom headers in metadata operations to prevent header injection attacks and API compliance issues. SECURITY ENHANCEMENTS: - Added validateMetadataHeaders() method with whitelist-based validation - Validates all custom headers against allowed list from Microsoft documentation - Blocks attempts to override protected headers (Authorization, Content-Type, etc.) - Case-insensitive header matching per HTTP specification (RFC 2616) - Detailed error messages showing invalid headers and allowed alternatives ALLOWED CUSTOM HEADERS (per Microsoft Dataverse Web API docs): - MSCRM.SolutionUniqueName: Associates metadata changes with solutions - MSCRM.MergeLabels: Controls label merging behavior (true/false) - Consistency: Forces reading latest version (value: "Strong") - If-Match: Standard HTTP header for optimistic concurrency control - If-None-Match: Standard HTTP header for caching control PROTECTED HEADERS (never allowed in customHeaders): - Authorization, Accept, Content-Type, OData-MaxVersion, OData-Version, Prefer, Content-Length (all controlled by makeHttpRequest) IMPLEMENTATION: - Created ALLOWED_METADATA_HEADERS constant with whitelist (5 headers) - Created PROTECTED_HEADERS constant with blacklist (7 headers) - Integrated validation into buildMetadataHeaders() for automatic coverage - All metadata operations now automatically validated: * createEntityDefinition / updateEntityDefinition * createAttribute / updateAttribute * createRelationship / updateRelationship * createGlobalOptionSet / updateGlobalOptionSet * createPolymorphicLookup BENEFITS: - Prevents header injection attacks and accidental header overrides - Ensures API compliance with Microsoft's documented patterns - Provides clear developer feedback when invalid headers are used - Defense-in-depth validation even for type-safe inputs DOCUMENTATION REFERENCES: Based on thorough review of 6 Microsoft Learn articles covering all metadata operation types (entity, attribute, relationship, option set operations). Changes maintain backward compatibility - all existing code uses valid headers through typed MetadataOperationOptions interface. * fix(types): replace 'any' with MetadataOperationOptions in IPC handlers Improved type safety in metadata operation IPC handlers by replacing all occurrences of 'options?: any' with proper 'options?: MetadataOperationOptions' type annotation. CHANGES: - Added MetadataOperationOptions to type imports from ../common/types - Updated 9 IPC handler signatures to use MetadataOperationOptions: * CREATE_ENTITY_DEFINITION * UPDATE_ENTITY_DEFINITION * CREATE_ATTRIBUTE * UPDATE_ATTRIBUTE * CREATE_POLYMORPHIC_LOOKUP_ATTRIBUTE * CREATE_RELATIONSHIP * UPDATE_RELATIONSHIP * CREATE_GLOBAL_OPTION_SET * UPDATE_GLOBAL_OPTION_SET BENEFITS: - Eliminates use of 'any' type, maintaining TypeScript strict mode compliance - Provides IntelliSense and autocomplete for options parameter - Type safety ensures only valid options (solutionUniqueName, mergeLabels, consistencyStrong) can be passed through IPC layer - Consistency with DataverseManager method signatures - Compile-time validation of option properties This change maintains backward compatibility while adding proper type checking for all metadata operation options passed from tools via IPC. * chore: update version to 1.0.19 in package.json * feat(preload): sync metadata CRUD operations with toolPreloadBridge Synchronized preload.ts with toolPreloadBridge.ts to ensure both the main ToolBox UI and tool windows have identical metadata operation APIs available. This maintains API parity and enables future metadata manipulation from the main UI if needed. ADDED METADATA OPERATIONS (22 new methods in dataverse namespace): Metadata Helper Utilities: - buildLabel: Build localized Label objects for metadata operations - getAttributeODataType: Get OData type string for attribute type enums Entity (Table) Metadata CRUD: - createEntityDefinition: Create new entity/table definitions - updateEntityDefinition: Update existing entity definitions (PUT) - deleteEntityDefinition: Delete entity definitions Attribute (Column) Metadata CRUD: - createAttribute: Create new attributes/columns on entities - updateAttribute: Update existing attribute definitions (PUT) - deleteAttribute: Delete attributes from entities - createPolymorphicLookupAttribute: Create multi-table lookup attributes Relationship Metadata CRUD: - createRelationship: Create 1:N, N:N, or polymorphic relationships - updateRelationship: Update existing relationship definitions (PUT) - deleteRelationship: Delete relationships Global Option Set (Choice) Metadata CRUD: - createGlobalOptionSet: Create new global option sets/choices - updateGlobalOptionSet: Update existing global option sets (PUT) - deleteGlobalOptionSet: Delete global option sets Option Value Modification Actions (OData Actions): - insertOptionValue: Insert new option values into option sets - updateOptionValue: Update existing option values (labels, etc.) - deleteOptionValue: Delete option values from option sets - orderOption: Reorder option values within option sets BENEFITS: - API parity between main UI (preload.ts) and tool windows (toolPreloadBridge.ts) - Main ToolBox UI can now perform metadata operations if needed in future - Consistent API surface across all preload contexts - Future-proofing for internal metadata management UI features - All operations support primary/secondary connection targeting IMPLEMENTATION NOTES: - All methods maintain same signatures as toolPreloadBridge.ts - Options parameter uses Record for flexibility - Connection targeting via optional connectionTarget parameter - Validated header whitelisting enforced by DataverseManager layer This synchronization ensures both contexts stay in sync as the metadata API evolves and provides maximum flexibility for future UI enhancements. --------- Co-authored-by: Power-Maverick --- packages/dataverseAPI.d.ts | 640 +++++++++++++++++ packages/package.json | 4 +- src/common/ipc/channels.ts | 25 + src/common/types/dataverse.ts | 87 +++ src/main/index.ts | 347 +++++++++- src/main/managers/dataverseManager.ts | 944 +++++++++++++++++++++++++- src/main/preload.ts | 43 ++ src/main/toolPreloadBridge.ts | 70 ++ 8 files changed, 2155 insertions(+), 5 deletions(-) diff --git a/packages/dataverseAPI.d.ts b/packages/dataverseAPI.d.ts index 6ebfe6fd..26599f5d 100644 --- a/packages/dataverseAPI.d.ts +++ b/packages/dataverseAPI.d.ts @@ -224,6 +224,92 @@ declare namespace DataverseAPI { parameters?: Record; } + /** + * Localized label for metadata display names and descriptions + */ + export interface LocalizedLabel { + "@odata.type"?: "Microsoft.Dynamics.CRM.LocalizedLabel"; + Label: string; + LanguageCode: number; + } + + /** + * Label structure for metadata properties + */ + export interface Label { + "@odata.type"?: "Microsoft.Dynamics.CRM.Label"; + LocalizedLabels: LocalizedLabel[]; + UserLocalizedLabel?: LocalizedLabel; + } + + /** + * Attribute metadata types for Dataverse columns + * Used with getAttributeODataType() to generate full Microsoft.Dynamics.CRM.*AttributeMetadata type strings + */ + export enum AttributeMetadataType { + /** Single-line text field */ + String = "String", + /** Multi-line text field */ + Memo = "Memo", + /** Whole number */ + Integer = "Integer", + /** Big integer (large whole number) */ + BigInt = "BigInt", + /** Decimal number */ + Decimal = "Decimal", + /** Floating point number */ + Double = "Double", + /** Currency field */ + Money = "Money", + /** Yes/No (boolean) field */ + Boolean = "Boolean", + /** Date and time */ + DateTime = "DateTime", + /** Lookup (foreign key reference) */ + Lookup = "Lookup", + /** Choice (option set/picklist) */ + Picklist = "Picklist", + /** Multi-select choice */ + MultiSelectPicklist = "MultiSelectPicklist", + /** State field (active/inactive) */ + State = "State", + /** Status field (status reason) */ + Status = "Status", + /** Owner field */ + Owner = "Owner", + /** Customer field (Account or Contact lookup) */ + Customer = "Customer", + /** File attachment field */ + File = "File", + /** Image field */ + Image = "Image", + /** Unique identifier (GUID) */ + UniqueIdentifier = "UniqueIdentifier", + } + + /** + * Options for metadata CRUD operations + */ + export interface MetadataOperationOptions { + /** + * Associate metadata changes with a specific solution + * Uses MSCRM.SolutionUniqueName header + */ + solutionUniqueName?: string; + + /** + * Preserve existing localized labels during PUT operations + * Uses MSCRM.MergeLabels header (defaults to true for updates) + */ + mergeLabels?: boolean; + + /** + * Force fresh metadata read after create/update operations + * Uses Consistency: Strong header to bypass cache + */ + consistencyStrong?: boolean; + } + /** * Dataverse Web API for CRUD operations, queries, and metadata */ @@ -849,6 +935,560 @@ declare namespace DataverseAPI { * const status = await dataverseAPI.getImportJobStatus(importJobId, 'secondary'); */ getImportJobStatus: (importJobId: string, connectionTarget?: "primary" | "secondary") => Promise>; + + // ======================================== + // Metadata Helper Utilities + // ======================================== + + /** + * Build a Label structure for metadata display names and descriptions + * Helper utility to simplify creating localized labels for metadata operations + * + * @param text - Display text for the label + * @param languageCode - Optional language code (defaults to 1033 for English) + * @returns Label object with properly formatted LocalizedLabels array + * + * @example + * const label = dataverseAPI.buildLabel("Account Name"); + * // Returns: { LocalizedLabels: [{ Label: "Account Name", LanguageCode: 1033 }] } + * + * @example + * // Create label with specific language code + * const frenchLabel = dataverseAPI.buildLabel("Nom du compte", 1036); + */ + buildLabel: (text: string, languageCode?: number) => Label; + + /** + * Get the OData type string for an attribute metadata type + * Converts AttributeMetadataType enum to full Microsoft.Dynamics.CRM type path + * + * @param attributeType - Attribute metadata type enum value + * @returns Full OData type string (e.g., "Microsoft.Dynamics.CRM.StringAttributeMetadata") + * + * @example + * const odataType = dataverseAPI.getAttributeODataType(DataverseAPI.AttributeMetadataType.String); + * // Returns: "Microsoft.Dynamics.CRM.StringAttributeMetadata" + * + * @example + * // Use in attribute definition + * const attributeDef = { + * "@odata.type": dataverseAPI.getAttributeODataType(DataverseAPI.AttributeMetadataType.Integer), + * "SchemaName": "new_priority", + * "DisplayName": dataverseAPI.buildLabel("Priority") + * }; + */ + getAttributeODataType: (attributeType: AttributeMetadataType) => string; + + // ======================================== + // Entity (Table) Metadata CRUD Operations + // ======================================== + + /** + * Create a new entity (table) definition in Dataverse + * NOTE: Metadata changes require explicit publishCustomizations() call to become active + * + * @param entityDefinition - Entity metadata payload (must include SchemaName, DisplayName, OwnershipType, and at least one Attribute with IsPrimaryName=true) + * @param options - Optional metadata operation options (solution assignment, etc.) + * @param connectionTarget - Optional connection target for multi-connection tools ('primary' or 'secondary'). Defaults to 'primary'. + * @returns Object containing the created entity's MetadataId + * + * @example + * // Create a new custom table + * const result = await dataverseAPI.createEntityDefinition({ + * "@odata.type": "Microsoft.Dynamics.CRM.EntityMetadata", + * "SchemaName": "new_project", + * "DisplayName": dataverseAPI.buildLabel("Project"), + * "DisplayCollectionName": dataverseAPI.buildLabel("Projects"), + * "Description": dataverseAPI.buildLabel("Project tracking table"), + * "OwnershipType": "UserOwned", + * "HasActivities": true, + * "HasNotes": true, + * "Attributes": [{ + * "@odata.type": dataverseAPI.getAttributeODataType(DataverseAPI.AttributeMetadataType.String), + * "SchemaName": "new_name", + * "RequiredLevel": { "Value": "None" }, + * "MaxLength": 100, + * "FormatName": { "Value": "Text" }, + * "IsPrimaryName": true, + * "DisplayName": dataverseAPI.buildLabel("Project Name"), + * "Description": dataverseAPI.buildLabel("The name of the project") + * }] + * }, { + * solutionUniqueName: "MySolution" + * }); + * + * console.log("Created entity with MetadataId:", result.id); + * + * // IMPORTANT: Publish customizations to make changes active + * await dataverseAPI.publishCustomizations("new_project"); + */ + createEntityDefinition: (entityDefinition: Record, options?: MetadataOperationOptions, connectionTarget?: "primary" | "secondary") => Promise<{ id: string }>; + + /** + * Update an entity (table) definition + * NOTE: Uses PUT method which requires the FULL entity definition (retrieve-modify-PUT pattern) + * NOTE: Metadata changes require explicit publishCustomizations() call to become active + * + * @param entityIdentifier - Entity LogicalName or MetadataId + * @param entityDefinition - Complete entity metadata payload with all properties + * @param options - Optional metadata operation options (mergeLabels defaults to true to preserve translations) + * @param connectionTarget - Optional connection target for multi-connection tools ('primary' or 'secondary'). Defaults to 'primary'. + * + * @example + * // Retrieve-Modify-PUT Pattern for updating entity metadata + * + * // Step 1: Retrieve current entity definition + * const currentDef = await dataverseAPI.getEntityMetadata("new_project", true); + * + * // Step 2: Modify desired properties (must include ALL properties, not just changes) + * currentDef.DisplayName = dataverseAPI.buildLabel("Updated Project Name"); + * currentDef.Description = dataverseAPI.buildLabel("Updated description"); + * + * // Step 3: PUT the entire definition back (mergeLabels=true preserves other language translations) + * await dataverseAPI.updateEntityDefinition("new_project", currentDef, { + * mergeLabels: true, // Preserve existing translations + * solutionUniqueName: "MySolution" + * }); + * + * // Step 4: Publish customizations to activate changes + * await dataverseAPI.publishCustomizations("new_project"); + * + * @example + * // Update using MetadataId instead of LogicalName + * await dataverseAPI.updateEntityDefinition( + * "70816501-edb9-4740-a16c-6a5efbc05d84", + * updatedDefinition, + * { mergeLabels: true } + * ); + */ + updateEntityDefinition: (entityIdentifier: string, entityDefinition: Record, options?: MetadataOperationOptions, connectionTarget?: "primary" | "secondary") => Promise; + + /** + * Delete an entity (table) definition + * WARNING: This is a destructive operation that removes the table and all its data + * + * @param entityIdentifier - Entity LogicalName or MetadataId + * @param connectionTarget - Optional connection target for multi-connection tools ('primary' or 'secondary'). Defaults to 'primary'. + * + * @example + * // Delete a custom table (will fail if dependencies exist) + * await dataverseAPI.deleteEntityDefinition("new_project"); + * + * @example + * // Delete using MetadataId + * await dataverseAPI.deleteEntityDefinition("70816501-edb9-4740-a16c-6a5efbc05d84"); + */ + deleteEntityDefinition: (entityIdentifier: string, connectionTarget?: "primary" | "secondary") => Promise; + + // ======================================== + // Attribute (Column) Metadata CRUD Operations + // ======================================== + + /** + * Create a new attribute (column) on an existing entity + * NOTE: Metadata changes require explicit publishCustomizations() call to become active + * + * @param entityLogicalName - Logical name of the entity to add the attribute to + * @param attributeDefinition - Attribute metadata payload (must include @odata.type, SchemaName, DisplayName) + * @param options - Optional metadata operation options + * @param connectionTarget - Optional connection target for multi-connection tools ('primary' or 'secondary'). Defaults to 'primary'. + * @returns Object containing the created attribute's MetadataId + * + * @example + * // Create a text column + * const result = await dataverseAPI.createAttribute("new_project", { + * "@odata.type": dataverseAPI.getAttributeODataType(DataverseAPI.AttributeMetadataType.String), + * "SchemaName": "new_description", + * "DisplayName": dataverseAPI.buildLabel("Description"), + * "Description": dataverseAPI.buildLabel("Project description"), + * "RequiredLevel": { "Value": "None" }, + * "MaxLength": 500, + * "FormatName": { "Value": "Text" } + * }, { + * solutionUniqueName: "MySolution" + * }); + * + * console.log("Created attribute with MetadataId:", result.id); + * await dataverseAPI.publishCustomizations("new_project"); + * + * @example + * // Create a whole number column + * await dataverseAPI.createAttribute("new_project", { + * "@odata.type": dataverseAPI.getAttributeODataType(DataverseAPI.AttributeMetadataType.Integer), + * "SchemaName": "new_priority", + * "DisplayName": dataverseAPI.buildLabel("Priority"), + * "RequiredLevel": { "Value": "None" }, + * "MinValue": 1, + * "MaxValue": 100 + * }); + * await dataverseAPI.publishCustomizations("new_project"); + * + * @example + * // Create a choice (picklist) column + * await dataverseAPI.createAttribute("new_project", { + * "@odata.type": dataverseAPI.getAttributeODataType(DataverseAPI.AttributeMetadataType.Picklist), + * "SchemaName": "new_status", + * "DisplayName": dataverseAPI.buildLabel("Status"), + * "RequiredLevel": { "Value": "None" }, + * "OptionSet": { + * "@odata.type": "Microsoft.Dynamics.CRM.OptionSetMetadata", + * "OptionSetType": "Picklist", + * "Options": [ + * { "Value": 1, "Label": dataverseAPI.buildLabel("Active") }, + * { "Value": 2, "Label": dataverseAPI.buildLabel("On Hold") }, + * { "Value": 3, "Label": dataverseAPI.buildLabel("Completed") } + * ] + * } + * }); + * await dataverseAPI.publishCustomizations("new_project"); + */ + createAttribute: (entityLogicalName: string, attributeDefinition: Record, options?: MetadataOperationOptions, connectionTarget?: "primary" | "secondary") => Promise<{ id: string }>; + + /** + * Update an attribute (column) definition + * NOTE: Uses PUT method which requires the FULL attribute definition (retrieve-modify-PUT pattern) + * NOTE: Metadata changes require explicit publishCustomizations() call to become active + * + * @param entityLogicalName - Logical name of the entity + * @param attributeIdentifier - Attribute LogicalName or MetadataId + * @param attributeDefinition - Complete attribute metadata payload + * @param options - Optional metadata operation options (mergeLabels defaults to true) + * @param connectionTarget - Optional connection target for multi-connection tools ('primary' or 'secondary'). Defaults to 'primary'. + * + * @example + * // Retrieve-Modify-PUT Pattern for updating attribute metadata + * + * // Step 1: Retrieve current attribute definition + * const currentAttr = await dataverseAPI.getEntityRelatedMetadata( + * "new_project", + * "Attributes(LogicalName='new_description')" + * ); + * + * // Step 2: Modify desired properties + * currentAttr.DisplayName = dataverseAPI.buildLabel("Updated Description"); + * currentAttr.MaxLength = 1000; // Increase max length + * + * // Step 3: PUT entire definition back + * await dataverseAPI.updateAttribute( + * "new_project", + * "new_description", + * currentAttr, + * { mergeLabels: true } + * ); + * + * // Step 4: Publish customizations + * await dataverseAPI.publishCustomizations("new_project"); + */ + updateAttribute: (entityLogicalName: string, attributeIdentifier: string, attributeDefinition: Record, options?: MetadataOperationOptions, connectionTarget?: "primary" | "secondary") => Promise; + + /** + * Delete an attribute (column) from an entity + * WARNING: This is a destructive operation that removes the column and all its data + * + * @param entityLogicalName - Logical name of the entity + * @param attributeIdentifier - Attribute LogicalName or MetadataId + * @param connectionTarget - Optional connection target for multi-connection tools ('primary' or 'secondary'). Defaults to 'primary'. + * + * @example + * await dataverseAPI.deleteAttribute("new_project", "new_description"); + * + * @example + * // Delete using MetadataId + * await dataverseAPI.deleteAttribute("new_project", "00aa00aa-bb11-cc22-dd33-44ee44ee44ee"); + */ + deleteAttribute: (entityLogicalName: string, attributeIdentifier: string, connectionTarget?: "primary" | "secondary") => Promise; + + /** + * Create a polymorphic lookup attribute (Customer/Regarding field) + * Creates a lookup that can reference multiple entity types + * NOTE: Metadata changes require explicit publishCustomizations() call to become active + * + * @param entityLogicalName - Logical name of the entity to add the attribute to + * @param attributeDefinition - Lookup attribute metadata with Targets array + * @param options - Optional metadata operation options + * @returns Object containing the created attribute's MetadataId + * @param connectionTarget - Optional connection target ("primary" or "secondary") + * + * @example + * // Create a Customer lookup (Account or Contact) + * const result = await dataverseAPI.createPolymorphicLookupAttribute("new_order", { + * "@odata.type": "Microsoft.Dynamics.CRM.LookupAttributeMetadata", + * "SchemaName": "new_CustomerId", + * "LogicalName": "new_customerid", + * "DisplayName": buildLabel("Customer"), + * "Description": buildLabel("Customer for this order"), + * "RequiredLevel": { Value: "None", CanBeChanged: true, ManagedPropertyLogicalName: "canmodifyrequirementlevelsettings" }, + * "AttributeType": "Lookup", + * "AttributeTypeName": { Value: "LookupType" }, + * "Targets": ["account", "contact"] + * }); + * await dataverseAPI.publishCustomizations(); + */ + createPolymorphicLookupAttribute: ( + entityLogicalName: string, + attributeDefinition: Record, + options?: Record, + connectionTarget?: "primary" | "secondary", + ) => Promise<{ AttributeId: string }>; + + // ======================================== + // Relationship Metadata CRUD Operations + // ======================================== + + /** + * Create a new relationship (1:N or N:N) + * NOTE: Metadata changes require explicit publishCustomizations() call to become active + * + * @param relationshipDefinition - Relationship metadata payload (must include @odata.type for OneToManyRelationshipMetadata or ManyToManyRelationshipMetadata) + * @param options - Optional metadata operation options + * @param connectionTarget - Optional connection target for multi-connection tools ('primary' or 'secondary'). Defaults to 'primary'. + * @returns Object containing the created relationship's MetadataId + * + * @example + * // Create 1:N relationship (Project -> Tasks) + * const result = await dataverseAPI.createRelationship({ + * "@odata.type": "Microsoft.Dynamics.CRM.OneToManyRelationshipMetadata", + * "SchemaName": "new_project_tasks", + * "ReferencedEntity": "new_project", + * "ReferencedAttribute": "new_projectid", + * "ReferencingEntity": "task", + * "CascadeConfiguration": { + * "Assign": "NoCascade", + * "Delete": "RemoveLink", + * "Merge": "NoCascade", + * "Reparent": "NoCascade", + * "Share": "NoCascade", + * "Unshare": "NoCascade" + * }, + * "Lookup": { + * "@odata.type": dataverseAPI.getAttributeODataType(DataverseAPI.AttributeMetadataType.Lookup), + * "SchemaName": "new_projectid", + * "DisplayName": dataverseAPI.buildLabel("Project"), + * "RequiredLevel": { "Value": "None" } + * } + * }, { + * solutionUniqueName: "MySolution" + * }); + * + * await dataverseAPI.publishCustomizations(); + * + * @example + * // Create N:N relationship (Projects <-> Users) + * await dataverseAPI.createRelationship({ + * "@odata.type": "Microsoft.Dynamics.CRM.ManyToManyRelationshipMetadata", + * "SchemaName": "new_project_systemuser", + * "Entity1LogicalName": "new_project", + * "Entity2LogicalName": "systemuser", + * "IntersectEntityName": "new_project_systemuser" + * }); + * await dataverseAPI.publishCustomizations(); + */ + createRelationship: (relationshipDefinition: Record, options?: MetadataOperationOptions, connectionTarget?: "primary" | "secondary") => Promise<{ id: string }>; + + /** + * Update a relationship definition + * NOTE: Uses PUT method which requires the FULL relationship definition (retrieve-modify-PUT pattern) + * NOTE: Metadata changes require explicit publishCustomizations() call to become active + * + * @param relationshipIdentifier - Relationship SchemaName or MetadataId + * @param relationshipDefinition - Complete relationship metadata payload + * @param options - Optional metadata operation options (mergeLabels defaults to true) + * @param connectionTarget - Optional connection target for multi-connection tools ('primary' or 'secondary'). Defaults to 'primary'. + */ + updateRelationship: (relationshipIdentifier: string, relationshipDefinition: Record, options?: MetadataOperationOptions, connectionTarget?: "primary" | "secondary") => Promise; + + /** + * Delete a relationship + * WARNING: This removes the relationship and any associated lookup columns + * + * @param relationshipIdentifier - Relationship SchemaName or MetadataId + * @param connectionTarget - Optional connection target for multi-connection tools ('primary' or 'secondary'). Defaults to 'primary'. + * + * @example + * await dataverseAPI.deleteRelationship("new_project_tasks"); + */ + deleteRelationship: (relationshipIdentifier: string, connectionTarget?: "primary" | "secondary") => Promise; + + // ======================================== + // Global Option Set (Choice) CRUD Operations + // ======================================== + + /** + * Create a new global option set (global choice) + * NOTE: Metadata changes require explicit publishCustomizations() call to become active + * + * @param optionSetDefinition - Global option set metadata payload + * @param options - Optional metadata operation options + * @param connectionTarget - Optional connection target for multi-connection tools ('primary' or 'secondary'). Defaults to 'primary'. + * @returns Object containing the created option set's MetadataId + * + * @example + * const result = await dataverseAPI.createGlobalOptionSet({ + * "@odata.type": "Microsoft.Dynamics.CRM.OptionSetMetadata", + * "Name": "new_projectstatus", + * "DisplayName": dataverseAPI.buildLabel("Project Status"), + * "Description": dataverseAPI.buildLabel("Global choice for project status"), + * "OptionSetType": "Picklist", + * "IsGlobal": true, + * "Options": [ + * { "Value": 1, "Label": dataverseAPI.buildLabel("Active") }, + * { "Value": 2, "Label": dataverseAPI.buildLabel("On Hold") }, + * { "Value": 3, "Label": dataverseAPI.buildLabel("Completed") }, + * { "Value": 4, "Label": dataverseAPI.buildLabel("Cancelled") } + * ] + * }, { + * solutionUniqueName: "MySolution" + * }); + * + * await dataverseAPI.publishCustomizations(); + */ + createGlobalOptionSet: (optionSetDefinition: Record, options?: MetadataOperationOptions, connectionTarget?: "primary" | "secondary") => Promise<{ id: string }>; + + /** + * Update a global option set definition + * NOTE: Uses PUT method which requires the FULL option set definition (retrieve-modify-PUT pattern) + * NOTE: Metadata changes require explicit publishCustomizations() call to become active + * + * @param optionSetIdentifier - Option set Name or MetadataId + * @param optionSetDefinition - Complete option set metadata payload + * @param options - Optional metadata operation options (mergeLabels defaults to true) + * @param connectionTarget - Optional connection target for multi-connection tools ('primary' or 'secondary'). Defaults to 'primary'. + */ + updateGlobalOptionSet: (optionSetIdentifier: string, optionSetDefinition: Record, options?: MetadataOperationOptions, connectionTarget?: "primary" | "secondary") => Promise; + + /** + * Delete a global option set + * WARNING: This will fail if any attributes reference this global option set + * + * @param optionSetIdentifier - Option set Name or MetadataId + * @param connectionTarget - Optional connection target for multi-connection tools ('primary' or 'secondary'). Defaults to 'primary'. + * + * @example + * await dataverseAPI.deleteGlobalOptionSet("new_projectstatus"); + */ + deleteGlobalOptionSet: (optionSetIdentifier: string, connectionTarget?: "primary" | "secondary") => Promise; + + // ======================================== + // Option Value Modification Actions + // ======================================== + + /** + * Insert a new option value into a local or global option set + * NOTE: Works for both local option sets (specify EntityLogicalName + AttributeLogicalName) + * and global option sets (specify OptionSetName) + * NOTE: Metadata changes require explicit publishCustomizations() call to become active + * + * @param params - Parameters for inserting the option value + * @param connectionTarget - Optional connection target for multi-connection tools ('primary' or 'secondary'). Defaults to 'primary'. + * @returns Result of the insert operation + * + * @example + * // Insert into local option set on an entity + * await dataverseAPI.insertOptionValue({ + * EntityLogicalName: "new_project", + * AttributeLogicalName: "new_priority", + * Value: 4, + * Label: dataverseAPI.buildLabel("Critical"), + * Description: dataverseAPI.buildLabel("Highest priority level") + * }); + * await dataverseAPI.publishCustomizations("new_project"); + * + * @example + * // Insert into global option set + * await dataverseAPI.insertOptionValue({ + * OptionSetName: "new_projectstatus", + * Value: 5, + * Label: dataverseAPI.buildLabel("Archived"), + * SolutionUniqueName: "MySolution" + * }); + * await dataverseAPI.publishCustomizations(); + */ + insertOptionValue: (params: Record, connectionTarget?: "primary" | "secondary") => Promise>; + + /** + * Update an existing option value in a local or global option set + * NOTE: Metadata changes require explicit publishCustomizations() call to become active + * + * @param params - Parameters for updating the option value + * @param connectionTarget - Optional connection target for multi-connection tools ('primary' or 'secondary'). Defaults to 'primary'. + * @returns Result of the update operation + * + * @example + * // Update option label in local option set + * await dataverseAPI.updateOptionValue({ + * EntityLogicalName: "new_project", + * AttributeLogicalName: "new_priority", + * Value: 4, + * Label: dataverseAPI.buildLabel("High Priority"), + * MergeLabels: true // Preserve other language translations + * }); + * await dataverseAPI.publishCustomizations("new_project"); + * + * @example + * // Update option in global option set + * await dataverseAPI.updateOptionValue({ + * OptionSetName: "new_projectstatus", + * Value: 5, + * Label: dataverseAPI.buildLabel("Closed"), + * MergeLabels: true + * }); + * await dataverseAPI.publishCustomizations(); + */ + updateOptionValue: (params: Record, connectionTarget?: "primary" | "secondary") => Promise>; + + /** + * Delete an option value from a local or global option set + * NOTE: Metadata changes require explicit publishCustomizations() call to become active + * + * @param params - Parameters for deleting the option value + * @param connectionTarget - Optional connection target for multi-connection tools ('primary' or 'secondary'). Defaults to 'primary'. + * @returns Result of the delete operation + * + * @example + * // Delete option from local option set + * await dataverseAPI.deleteOptionValue({ + * EntityLogicalName: "new_project", + * AttributeLogicalName: "new_priority", + * Value: 4 + * }); + * await dataverseAPI.publishCustomizations("new_project"); + * + * @example + * // Delete option from global option set + * await dataverseAPI.deleteOptionValue({ + * OptionSetName: "new_projectstatus", + * Value: 5 + * }); + * await dataverseAPI.publishCustomizations(); + */ + deleteOptionValue: (params: Record, connectionTarget?: "primary" | "secondary") => Promise>; + + /** + * Reorder options in a local or global option set + * NOTE: Metadata changes require explicit publishCustomizations() call to become active + * + * @param params - Parameters for ordering options + * @param connectionTarget - Optional connection target for multi-connection tools ('primary' or 'secondary'). Defaults to 'primary'. + * @returns Result of the order operation + * + * @example + * // Reorder options in local option set + * await dataverseAPI.orderOption({ + * EntityLogicalName: "new_project", + * AttributeLogicalName: "new_priority", + * Values: [3, 1, 2, 4] // Reorder by option values + * }); + * await dataverseAPI.publishCustomizations("new_project"); + * + * @example + * // Reorder global option set + * await dataverseAPI.orderOption({ + * OptionSetName: "new_projectstatus", + * Values: [1, 2, 3, 5, 4] + * }); + * await dataverseAPI.publishCustomizations(); + */ + orderOption: (params: Record, connectionTarget?: "primary" | "secondary") => Promise>; } } diff --git a/packages/package.json b/packages/package.json index 6496427e..05edf8a6 100644 --- a/packages/package.json +++ b/packages/package.json @@ -1,6 +1,6 @@ { "name": "@pptb/types", - "version": "1.0.19-beta.3", + "version": "1.0.19", "description": "TypeScript type definitions for Power Platform ToolBox API", "main": "index.d.ts", "types": "index.d.ts", @@ -25,4 +25,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/src/common/ipc/channels.ts b/src/common/ipc/channels.ts index c9e34dea..3749a923 100644 --- a/src/common/ipc/channels.ts +++ b/src/common/ipc/channels.ts @@ -161,6 +161,31 @@ export const DATAVERSE_CHANNELS = { DISASSOCIATE: "dataverse.disassociate", DEPLOY_SOLUTION: "dataverse.deploySolution", GET_IMPORT_JOB_STATUS: "dataverse.getImportJobStatus", + // Metadata helper utilities + BUILD_LABEL: "dataverse.buildLabel", + GET_ATTRIBUTE_ODATA_TYPE: "dataverse.getAttributeODataType", + // Entity (Table) metadata operations + CREATE_ENTITY_DEFINITION: "dataverse.createEntityDefinition", + UPDATE_ENTITY_DEFINITION: "dataverse.updateEntityDefinition", + DELETE_ENTITY_DEFINITION: "dataverse.deleteEntityDefinition", + // Attribute (Column) metadata operations + CREATE_ATTRIBUTE: "dataverse.createAttribute", + UPDATE_ATTRIBUTE: "dataverse.updateAttribute", + DELETE_ATTRIBUTE: "dataverse.deleteAttribute", + CREATE_POLYMORPHIC_LOOKUP_ATTRIBUTE: "dataverse.createPolymorphicLookupAttribute", + // Relationship metadata operations + CREATE_RELATIONSHIP: "dataverse.createRelationship", + UPDATE_RELATIONSHIP: "dataverse.updateRelationship", + DELETE_RELATIONSHIP: "dataverse.deleteRelationship", + // Global option set (choice) metadata operations + CREATE_GLOBAL_OPTION_SET: "dataverse.createGlobalOptionSet", + UPDATE_GLOBAL_OPTION_SET: "dataverse.updateGlobalOptionSet", + DELETE_GLOBAL_OPTION_SET: "dataverse.deleteGlobalOptionSet", + // Option value modification actions + INSERT_OPTION_VALUE: "dataverse.insertOptionValue", + UPDATE_OPTION_VALUE: "dataverse.updateOptionValue", + DELETE_OPTION_VALUE: "dataverse.deleteOptionValue", + ORDER_OPTION: "dataverse.orderOption", } as const; // Event-related IPC channels (from main to renderer) diff --git a/src/common/types/dataverse.ts b/src/common/types/dataverse.ts index c45b6399..974f56f6 100644 --- a/src/common/types/dataverse.ts +++ b/src/common/types/dataverse.ts @@ -78,3 +78,90 @@ export const ENTITY_RELATED_METADATA_BASE_PATHS: ReadonlyArray = P extends EntityRelatedMetadataRecordPath ? Record : { value: Record[] }; + +/** + * Localized label for metadata display names and descriptions + */ +export interface LocalizedLabel { + "@odata.type"?: "Microsoft.Dynamics.CRM.LocalizedLabel"; + Label: string; + LanguageCode: number; + IsManaged?: boolean; +} + +/** + * Label structure for metadata properties + */ +export interface Label { + "@odata.type"?: "Microsoft.Dynamics.CRM.Label"; + LocalizedLabels: LocalizedLabel[]; + UserLocalizedLabel?: LocalizedLabel; +} + +/** + * Attribute metadata types for Dataverse columns + * Maps to Microsoft.Dynamics.CRM.*AttributeMetadata OData types + */ +export enum AttributeMetadataType { + /** Single-line text field */ + String = "String", + /** Multi-line text field */ + Memo = "Memo", + /** Whole number */ + Integer = "Integer", + /** Big integer (large whole number) */ + BigInt = "BigInt", + /** Decimal number */ + Decimal = "Decimal", + /** Floating point number */ + Double = "Double", + /** Currency field */ + Money = "Money", + /** Yes/No (boolean) field */ + Boolean = "Boolean", + /** Date and time */ + DateTime = "DateTime", + /** Lookup (foreign key reference) */ + Lookup = "Lookup", + /** Choice (option set/picklist) */ + Picklist = "Picklist", + /** Multi-select choice */ + MultiSelectPicklist = "MultiSelectPicklist", + /** State field (active/inactive) */ + State = "State", + /** Status field (status reason) */ + Status = "Status", + /** Owner field */ + Owner = "Owner", + /** Customer field (Account or Contact lookup) */ + Customer = "Customer", + /** File attachment field */ + File = "File", + /** Image field */ + Image = "Image", + /** Unique identifier (GUID) */ + UniqueIdentifier = "UniqueIdentifier", +} + +/** + * Options for metadata CRUD operations + */ +export interface MetadataOperationOptions { + /** + * Associate metadata changes with a specific solution + * Uses MSCRM.SolutionUniqueName header + */ + solutionUniqueName?: string; + + /** + * Preserve existing localized labels during PUT operations + * Uses MSCRM.MergeLabels header (defaults to true for updates) + */ + mergeLabels?: boolean; + + /** + * Force fresh metadata read after create/update operations + * Uses Consistency: Strong header to bypass cache + */ + consistencyStrong?: boolean; +} diff --git a/src/main/index.ts b/src/main/index.ts index 10a46742..c86379f2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -74,7 +74,7 @@ import { UPDATE_CHANNELS, UTIL_CHANNELS, } from "../common/ipc/channels"; -import { EntityRelatedMetadataPath, LastUsedToolEntry, LastUsedToolUpdate, ModalWindowMessagePayload, ModalWindowOptions, ToolBoxEvent } from "../common/types"; +import { AttributeMetadataType, EntityRelatedMetadataPath, LastUsedToolEntry, LastUsedToolUpdate, MetadataOperationOptions, ModalWindowMessagePayload, ModalWindowOptions, ToolBoxEvent } from "../common/types"; import { AuthManager } from "./managers/authManager"; import { AutoUpdateManager } from "./managers/autoUpdateManager"; import { BrowserManager } from "./managers/browserManager"; @@ -397,6 +397,26 @@ class ToolBoxApp { ipcMain.removeHandler(DATAVERSE_CHANNELS.DISASSOCIATE); ipcMain.removeHandler(DATAVERSE_CHANNELS.DEPLOY_SOLUTION); ipcMain.removeHandler(DATAVERSE_CHANNELS.GET_IMPORT_JOB_STATUS); + // Metadata operations + ipcMain.removeHandler(DATAVERSE_CHANNELS.BUILD_LABEL); + ipcMain.removeHandler(DATAVERSE_CHANNELS.GET_ATTRIBUTE_ODATA_TYPE); + ipcMain.removeHandler(DATAVERSE_CHANNELS.CREATE_ENTITY_DEFINITION); + ipcMain.removeHandler(DATAVERSE_CHANNELS.UPDATE_ENTITY_DEFINITION); + ipcMain.removeHandler(DATAVERSE_CHANNELS.DELETE_ENTITY_DEFINITION); + ipcMain.removeHandler(DATAVERSE_CHANNELS.CREATE_ATTRIBUTE); + ipcMain.removeHandler(DATAVERSE_CHANNELS.UPDATE_ATTRIBUTE); + ipcMain.removeHandler(DATAVERSE_CHANNELS.DELETE_ATTRIBUTE); + ipcMain.removeHandler(DATAVERSE_CHANNELS.CREATE_POLYMORPHIC_LOOKUP_ATTRIBUTE); + ipcMain.removeHandler(DATAVERSE_CHANNELS.CREATE_RELATIONSHIP); + ipcMain.removeHandler(DATAVERSE_CHANNELS.UPDATE_RELATIONSHIP); + ipcMain.removeHandler(DATAVERSE_CHANNELS.DELETE_RELATIONSHIP); + ipcMain.removeHandler(DATAVERSE_CHANNELS.CREATE_GLOBAL_OPTION_SET); + ipcMain.removeHandler(DATAVERSE_CHANNELS.UPDATE_GLOBAL_OPTION_SET); + ipcMain.removeHandler(DATAVERSE_CHANNELS.DELETE_GLOBAL_OPTION_SET); + ipcMain.removeHandler(DATAVERSE_CHANNELS.INSERT_OPTION_VALUE); + ipcMain.removeHandler(DATAVERSE_CHANNELS.UPDATE_OPTION_VALUE); + ipcMain.removeHandler(DATAVERSE_CHANNELS.DELETE_OPTION_VALUE); + ipcMain.removeHandler(DATAVERSE_CHANNELS.ORDER_OPTION); } /** @@ -1501,6 +1521,331 @@ class ToolBoxApp { throw new Error(`Dataverse getImportJobStatus failed: ${(error as Error).message}`); } }); + + // Dataverse Metadata Helper Utilities + ipcMain.handle(DATAVERSE_CHANNELS.BUILD_LABEL, async (event, text: string, languageCode?: number) => { + try { + return this.dataverseManager.buildLabel(text, languageCode); + } catch (error) { + throw new Error(`Build label failed: ${(error as Error).message}`); + } + }); + + ipcMain.handle(DATAVERSE_CHANNELS.GET_ATTRIBUTE_ODATA_TYPE, async (event, attributeType: string) => { + try { + // Validate attributeType is a valid enum value + const validTypes = Object.values(AttributeMetadataType); + if (!validTypes.includes(attributeType as AttributeMetadataType)) { + throw new Error(`Invalid attribute type: "${attributeType}". Valid types are: ${validTypes.join(", ")}`); + } + return this.dataverseManager.getAttributeODataType(attributeType as AttributeMetadataType); + } catch (error) { + throw new Error(`Get attribute OData type failed: ${(error as Error).message}`); + } + }); + + // Entity (Table) Metadata CRUD Operations + ipcMain.handle(DATAVERSE_CHANNELS.CREATE_ENTITY_DEFINITION, async (event, entityDefinition: Record, options?: MetadataOperationOptions, connectionTarget?: "primary" | "secondary") => { + try { + const connectionId = + connectionTarget === "secondary" + ? this.toolWindowManager?.getSecondaryConnectionIdByWebContents(event.sender.id) + : this.toolWindowManager?.getConnectionIdByWebContents(event.sender.id); + if (!connectionId) { + const targetMsg = connectionTarget === "secondary" ? "secondary connection" : "connection"; + throw new Error(`No ${targetMsg} found for this tool instance. Please ensure the tool is connected to an environment.`); + } + return await this.dataverseManager.createEntityDefinition(connectionId, entityDefinition, options); + } catch (error) { + throw new Error(`Create entity definition failed: ${(error as Error).message}`); + } + }); + + ipcMain.handle( + DATAVERSE_CHANNELS.UPDATE_ENTITY_DEFINITION, + async (event, entityIdentifier: string, entityDefinition: Record, options?: MetadataOperationOptions, connectionTarget?: "primary" | "secondary") => { + try { + const connectionId = + connectionTarget === "secondary" + ? this.toolWindowManager?.getSecondaryConnectionIdByWebContents(event.sender.id) + : this.toolWindowManager?.getConnectionIdByWebContents(event.sender.id); + if (!connectionId) { + const targetMsg = connectionTarget === "secondary" ? "secondary connection" : "connection"; + throw new Error(`No ${targetMsg} found for this tool instance. Please ensure the tool is connected to an environment.`); + } + await this.dataverseManager.updateEntityDefinition(connectionId, entityIdentifier, entityDefinition, options); + return { success: true }; + } catch (error) { + throw new Error(`Update entity definition failed: ${(error as Error).message}`); + } + }, + ); + + ipcMain.handle(DATAVERSE_CHANNELS.DELETE_ENTITY_DEFINITION, async (event, entityIdentifier: string, connectionTarget?: "primary" | "secondary") => { + try { + const connectionId = + connectionTarget === "secondary" + ? this.toolWindowManager?.getSecondaryConnectionIdByWebContents(event.sender.id) + : this.toolWindowManager?.getConnectionIdByWebContents(event.sender.id); + if (!connectionId) { + const targetMsg = connectionTarget === "secondary" ? "secondary connection" : "connection"; + throw new Error(`No ${targetMsg} found for this tool instance. Please ensure the tool is connected to an environment.`); + } + await this.dataverseManager.deleteEntityDefinition(connectionId, entityIdentifier); + return { success: true }; + } catch (error) { + throw new Error(`Delete entity definition failed: ${(error as Error).message}`); + } + }); + + // Attribute (Column) Metadata CRUD Operations + ipcMain.handle( + DATAVERSE_CHANNELS.CREATE_ATTRIBUTE, + async (event, entityLogicalName: string, attributeDefinition: Record, options?: MetadataOperationOptions, connectionTarget?: "primary" | "secondary") => { + try { + const connectionId = + connectionTarget === "secondary" + ? this.toolWindowManager?.getSecondaryConnectionIdByWebContents(event.sender.id) + : this.toolWindowManager?.getConnectionIdByWebContents(event.sender.id); + if (!connectionId) { + const targetMsg = connectionTarget === "secondary" ? "secondary connection" : "connection"; + throw new Error(`No ${targetMsg} found for this tool instance. Please ensure the tool is connected to an environment.`); + } + return await this.dataverseManager.createAttribute(connectionId, entityLogicalName, attributeDefinition, options); + } catch (error) { + throw new Error(`Create attribute failed: ${(error as Error).message}`); + } + }, + ); + + ipcMain.handle( + DATAVERSE_CHANNELS.UPDATE_ATTRIBUTE, + async (event, entityLogicalName: string, attributeIdentifier: string, attributeDefinition: Record, options?: MetadataOperationOptions, connectionTarget?: "primary" | "secondary") => { + try { + const connectionId = + connectionTarget === "secondary" + ? this.toolWindowManager?.getSecondaryConnectionIdByWebContents(event.sender.id) + : this.toolWindowManager?.getConnectionIdByWebContents(event.sender.id); + if (!connectionId) { + const targetMsg = connectionTarget === "secondary" ? "secondary connection" : "connection"; + throw new Error(`No ${targetMsg} found for this tool instance. Please ensure the tool is connected to an environment.`); + } + await this.dataverseManager.updateAttribute(connectionId, entityLogicalName, attributeIdentifier, attributeDefinition, options); + return { success: true }; + } catch (error) { + throw new Error(`Update attribute failed: ${(error as Error).message}`); + } + }, + ); + + ipcMain.handle(DATAVERSE_CHANNELS.DELETE_ATTRIBUTE, async (event, entityLogicalName: string, attributeIdentifier: string, connectionTarget?: "primary" | "secondary") => { + try { + const connectionId = + connectionTarget === "secondary" + ? this.toolWindowManager?.getSecondaryConnectionIdByWebContents(event.sender.id) + : this.toolWindowManager?.getConnectionIdByWebContents(event.sender.id); + if (!connectionId) { + const targetMsg = connectionTarget === "secondary" ? "secondary connection" : "connection"; + throw new Error(`No ${targetMsg} found for this tool instance. Please ensure the tool is connected to an environment.`); + } + await this.dataverseManager.deleteAttribute(connectionId, entityLogicalName, attributeIdentifier); + return { success: true }; + } catch (error) { + throw new Error(`Delete attribute failed: ${(error as Error).message}`); + } + }); + + ipcMain.handle( + DATAVERSE_CHANNELS.CREATE_POLYMORPHIC_LOOKUP_ATTRIBUTE, + async (event, entityLogicalName: string, attributeDefinition: Record, options?: MetadataOperationOptions, connectionTarget?: "primary" | "secondary") => { + try { + const connectionId = + connectionTarget === "secondary" + ? this.toolWindowManager?.getSecondaryConnectionIdByWebContents(event.sender.id) + : this.toolWindowManager?.getConnectionIdByWebContents(event.sender.id); + if (!connectionId) { + const targetMsg = connectionTarget === "secondary" ? "secondary connection" : "connection"; + throw new Error(`No ${targetMsg} found for this tool instance. Please ensure the tool is connected to an environment.`); + } + return await this.dataverseManager.createPolymorphicLookupAttribute(connectionId, entityLogicalName, attributeDefinition, options); + } catch (error) { + throw new Error(`Create polymorphic lookup attribute failed: ${(error as Error).message}`); + } + }, + ); + + // Relationship Metadata CRUD Operations + ipcMain.handle(DATAVERSE_CHANNELS.CREATE_RELATIONSHIP, async (event, relationshipDefinition: Record, options?: MetadataOperationOptions, connectionTarget?: "primary" | "secondary") => { + try { + const connectionId = + connectionTarget === "secondary" + ? this.toolWindowManager?.getSecondaryConnectionIdByWebContents(event.sender.id) + : this.toolWindowManager?.getConnectionIdByWebContents(event.sender.id); + if (!connectionId) { + const targetMsg = connectionTarget === "secondary" ? "secondary connection" : "connection"; + throw new Error(`No ${targetMsg} found for this tool instance. Please ensure the tool is connected to an environment.`); + } + return await this.dataverseManager.createRelationship(connectionId, relationshipDefinition, options); + } catch (error) { + throw new Error(`Create relationship failed: ${(error as Error).message}`); + } + }); + + ipcMain.handle( + DATAVERSE_CHANNELS.UPDATE_RELATIONSHIP, + async (event, relationshipIdentifier: string, relationshipDefinition: Record, options?: MetadataOperationOptions, connectionTarget?: "primary" | "secondary") => { + try { + const connectionId = + connectionTarget === "secondary" + ? this.toolWindowManager?.getSecondaryConnectionIdByWebContents(event.sender.id) + : this.toolWindowManager?.getConnectionIdByWebContents(event.sender.id); + if (!connectionId) { + const targetMsg = connectionTarget === "secondary" ? "secondary connection" : "connection"; + throw new Error(`No ${targetMsg} found for this tool instance. Please ensure the tool is connected to an environment.`); + } + await this.dataverseManager.updateRelationship(connectionId, relationshipIdentifier, relationshipDefinition, options); + return { success: true }; + } catch (error) { + throw new Error(`Update relationship failed: ${(error as Error).message}`); + } + }, + ); + + ipcMain.handle(DATAVERSE_CHANNELS.DELETE_RELATIONSHIP, async (event, relationshipIdentifier: string, connectionTarget?: "primary" | "secondary") => { + try { + const connectionId = + connectionTarget === "secondary" + ? this.toolWindowManager?.getSecondaryConnectionIdByWebContents(event.sender.id) + : this.toolWindowManager?.getConnectionIdByWebContents(event.sender.id); + if (!connectionId) { + const targetMsg = connectionTarget === "secondary" ? "secondary connection" : "connection"; + throw new Error(`No ${targetMsg} found for this tool instance. Please ensure the tool is connected to an environment.`); + } + await this.dataverseManager.deleteRelationship(connectionId, relationshipIdentifier); + return { success: true }; + } catch (error) { + throw new Error(`Delete relationship failed: ${(error as Error).message}`); + } + }); + + // Global Option Set (Choice) CRUD Operations + ipcMain.handle(DATAVERSE_CHANNELS.CREATE_GLOBAL_OPTION_SET, async (event, optionSetDefinition: Record, options?: MetadataOperationOptions, connectionTarget?: "primary" | "secondary") => { + try { + const connectionId = + connectionTarget === "secondary" + ? this.toolWindowManager?.getSecondaryConnectionIdByWebContents(event.sender.id) + : this.toolWindowManager?.getConnectionIdByWebContents(event.sender.id); + if (!connectionId) { + const targetMsg = connectionTarget === "secondary" ? "secondary connection" : "connection"; + throw new Error(`No ${targetMsg} found for this tool instance. Please ensure the tool is connected to an environment.`); + } + return await this.dataverseManager.createGlobalOptionSet(connectionId, optionSetDefinition, options); + } catch (error) { + throw new Error(`Create global option set failed: ${(error as Error).message}`); + } + }); + + ipcMain.handle( + DATAVERSE_CHANNELS.UPDATE_GLOBAL_OPTION_SET, + async (event, optionSetIdentifier: string, optionSetDefinition: Record, options?: MetadataOperationOptions, connectionTarget?: "primary" | "secondary") => { + try { + const connectionId = + connectionTarget === "secondary" + ? this.toolWindowManager?.getSecondaryConnectionIdByWebContents(event.sender.id) + : this.toolWindowManager?.getConnectionIdByWebContents(event.sender.id); + if (!connectionId) { + const targetMsg = connectionTarget === "secondary" ? "secondary connection" : "connection"; + throw new Error(`No ${targetMsg} found for this tool instance. Please ensure the tool is connected to an environment.`); + } + await this.dataverseManager.updateGlobalOptionSet(connectionId, optionSetIdentifier, optionSetDefinition, options); + return { success: true }; + } catch (error) { + throw new Error(`Update global option set failed: ${(error as Error).message}`); + } + }, + ); + + ipcMain.handle(DATAVERSE_CHANNELS.DELETE_GLOBAL_OPTION_SET, async (event, optionSetIdentifier: string, connectionTarget?: "primary" | "secondary") => { + try { + const connectionId = + connectionTarget === "secondary" + ? this.toolWindowManager?.getSecondaryConnectionIdByWebContents(event.sender.id) + : this.toolWindowManager?.getConnectionIdByWebContents(event.sender.id); + if (!connectionId) { + const targetMsg = connectionTarget === "secondary" ? "secondary connection" : "connection"; + throw new Error(`No ${targetMsg} found for this tool instance. Please ensure the tool is connected to an environment.`); + } + await this.dataverseManager.deleteGlobalOptionSet(connectionId, optionSetIdentifier); + return { success: true }; + } catch (error) { + throw new Error(`Delete global option set failed: ${(error as Error).message}`); + } + }); + + // Option Value Modification Actions + ipcMain.handle(DATAVERSE_CHANNELS.INSERT_OPTION_VALUE, async (event, params: Record, connectionTarget?: "primary" | "secondary") => { + try { + const connectionId = + connectionTarget === "secondary" + ? this.toolWindowManager?.getSecondaryConnectionIdByWebContents(event.sender.id) + : this.toolWindowManager?.getConnectionIdByWebContents(event.sender.id); + if (!connectionId) { + const targetMsg = connectionTarget === "secondary" ? "secondary connection" : "connection"; + throw new Error(`No ${targetMsg} found for this tool instance. Please ensure the tool is connected to an environment.`); + } + return await this.dataverseManager.insertOptionValue(connectionId, params); + } catch (error) { + throw new Error(`Insert option value failed: ${(error as Error).message}`); + } + }); + + ipcMain.handle(DATAVERSE_CHANNELS.UPDATE_OPTION_VALUE, async (event, params: Record, connectionTarget?: "primary" | "secondary") => { + try { + const connectionId = + connectionTarget === "secondary" + ? this.toolWindowManager?.getSecondaryConnectionIdByWebContents(event.sender.id) + : this.toolWindowManager?.getConnectionIdByWebContents(event.sender.id); + if (!connectionId) { + const targetMsg = connectionTarget === "secondary" ? "secondary connection" : "connection"; + throw new Error(`No ${targetMsg} found for this tool instance. Please ensure the tool is connected to an environment.`); + } + return await this.dataverseManager.updateOptionValue(connectionId, params); + } catch (error) { + throw new Error(`Update option value failed: ${(error as Error).message}`); + } + }); + + ipcMain.handle(DATAVERSE_CHANNELS.DELETE_OPTION_VALUE, async (event, params: Record, connectionTarget?: "primary" | "secondary") => { + try { + const connectionId = + connectionTarget === "secondary" + ? this.toolWindowManager?.getSecondaryConnectionIdByWebContents(event.sender.id) + : this.toolWindowManager?.getConnectionIdByWebContents(event.sender.id); + if (!connectionId) { + const targetMsg = connectionTarget === "secondary" ? "secondary connection" : "connection"; + throw new Error(`No ${targetMsg} found for this tool instance. Please ensure the tool is connected to an environment.`); + } + return await this.dataverseManager.deleteOptionValue(connectionId, params); + } catch (error) { + throw new Error(`Delete option value failed: ${(error as Error).message}`); + } + }); + + ipcMain.handle(DATAVERSE_CHANNELS.ORDER_OPTION, async (event, params: Record, connectionTarget?: "primary" | "secondary") => { + try { + const connectionId = + connectionTarget === "secondary" + ? this.toolWindowManager?.getSecondaryConnectionIdByWebContents(event.sender.id) + : this.toolWindowManager?.getConnectionIdByWebContents(event.sender.id); + if (!connectionId) { + const targetMsg = connectionTarget === "secondary" ? "secondary connection" : "connection"; + throw new Error(`No ${targetMsg} found for this tool instance. Please ensure the tool is connected to an environment.`); + } + return await this.dataverseManager.orderOption(connectionId, params); + } catch (error) { + throw new Error(`Order option failed: ${(error as Error).message}`); + } + }); } /** * Create application menu diff --git a/src/main/managers/dataverseManager.ts b/src/main/managers/dataverseManager.ts index 0f45e2b6..55db107b 100644 --- a/src/main/managers/dataverseManager.ts +++ b/src/main/managers/dataverseManager.ts @@ -1,5 +1,14 @@ import * as https from "https"; -import { DataverseConnection, ENTITY_RELATED_METADATA_BASE_PATHS, EntityRelatedMetadataPath, EntityRelatedMetadataResponse } from "../../common/types"; +import { + DataverseConnection, + ENTITY_RELATED_METADATA_BASE_PATHS, + EntityRelatedMetadataPath, + EntityRelatedMetadataResponse, + AttributeMetadataType, + Label, + LocalizedLabel, + MetadataOperationOptions, +} from "../../common/types"; import { captureMessage } from "../../common/sentryHelper"; import { DATAVERSE_API_VERSION } from "../constants"; import { AuthManager } from "./authManager"; @@ -58,6 +67,116 @@ export class DataverseManager { this.authManager = authManager; } + /** + * Allowed custom headers for metadata operations based on Microsoft Dataverse Web API documentation. + * These headers are validated before being passed to HTTP requests for metadata operations. + * + * Reference documentation: + * - https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/retrieve-metadata-name-metadataid + * - https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/create-update-entity-definitions-using-web-api + * - https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/create-update-column-definitions-using-web-api + * - https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/create-update-entity-relationships-using-web-api + * - https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/multitable-lookup + * - https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/create-update-optionsets + */ + private static readonly ALLOWED_METADATA_HEADERS: ReadonlySet = new Set([ + "mscrm.solutionuniquename", // Associates metadata changes with a specific solution (used in CREATE/UPDATE) + "mscrm.mergelabels", // Controls label merging: "true" (merge) or "false" (replace) in UPDATE operations + "consistency", // Forces reading latest version: "Strong" value (used in GET operations after changes) + "if-match", // Standard HTTP header for optimistic concurrency control + "if-none-match", // Standard HTTP header for caching control (commonly "null" in examples) + ]); + + /** + * Headers that must never be passed as custom headers because they are controlled by makeHttpRequest. + * Attempting to override these headers will result in validation errors. + */ + private static readonly PROTECTED_HEADERS: ReadonlySet = new Set([ + "authorization", + "accept", + "content-type", + "odata-maxversion", + "odata-version", + "prefer", + "content-length", + ]); + + /** + * Validates custom headers for metadata operations against the allowed headers list. + * Case-insensitive matching per HTTP specification (RFC 2616). + * + * @param customHeaders - The custom headers to validate + * @param operationName - Optional name of the operation for more descriptive error messages + * @returns Validated headers object + * @throws Error if any header is not in the allowed list or attempts to override protected headers + * + * @example + * ```typescript + * // Valid headers + * const headers = this.validateMetadataHeaders({ + * "MSCRM.SolutionUniqueName": "examplesolution", + * "MSCRM.MergeLabels": "true" + * }, "updateEntityDefinition"); + * + * // Invalid header - throws error + * this.validateMetadataHeaders({ + * "X-Custom-Header": "value" // Not in allowed list + * }); + * + * // Protected header - throws error + * this.validateMetadataHeaders({ + * "Authorization": "Bearer token" // Protected header + * }); + * ``` + */ + private validateMetadataHeaders(customHeaders: Record | undefined, operationName?: string): Record { + if (!customHeaders || Object.keys(customHeaders).length === 0) { + return {}; + } + + const validatedHeaders: Record = {}; + const invalidHeaders: string[] = []; + const protectedHeaders: string[] = []; + + for (const [headerName, headerValue] of Object.entries(customHeaders)) { + const normalizedHeaderName = headerName.toLowerCase(); + + // Check if attempting to override protected headers + if (DataverseManager.PROTECTED_HEADERS.has(normalizedHeaderName)) { + protectedHeaders.push(headerName); + continue; + } + + // Check if header is in allowed list + if (DataverseManager.ALLOWED_METADATA_HEADERS.has(normalizedHeaderName)) { + validatedHeaders[headerName] = headerValue; + } else { + invalidHeaders.push(headerName); + } + } + + // Build detailed error message if validation failed + if (protectedHeaders.length > 0 || invalidHeaders.length > 0) { + const errorParts: string[] = []; + const operation = operationName ? ` in ${operationName}` : ""; + + if (protectedHeaders.length > 0) { + errorParts.push(`Protected headers cannot be overridden: ${protectedHeaders.join(", ")}`); + } + + if (invalidHeaders.length > 0) { + errorParts.push( + `Invalid headers for metadata operations: ${invalidHeaders.join(", ")}. ` + + `Allowed headers: ${Array.from(DataverseManager.ALLOWED_METADATA_HEADERS).join(", ")}`, + ); + } + + throw new Error(`Header validation failed${operation}. ${errorParts.join(". ")}`); + } + + return validatedHeaders; + } + /** * Build a properly formatted API URL by combining base URL and path * Ensures no double slashes between base URL and path @@ -383,6 +502,94 @@ export class DataverseManager { /** * Execute a Dataverse Web API action or function + * + * This is a generic method that can execute any standard or custom action/function. + * Supports both bound operations (on specific entity records) and unbound operations. + * + * @param connectionId - Connection ID to use + * @param request - Operation request details + * @param request.operationName - Name of the action or function to execute + * @param request.operationType - "action" (POST) or "function" (GET) + * @param request.parameters - Parameters to pass to the operation + * @param request.entityName - (For bound operations) Entity logical name + * @param request.entityId - (For bound operations) Entity record ID + * @returns Response object from the operation + * + * @example + * // CreateCustomerRelationships - Create customer lookup attribute (returns HTTP 200 with body) + * const customerResult = await dataverseManager.execute(connectionId, { + * operationName: "CreateCustomerRelationships", + * operationType: "action", + * parameters: { + * Lookup: { + * "@odata.type": "Microsoft.Dynamics.CRM.LookupAttributeMetadata", + * SchemaName: "new_CustomerId", + * DisplayName: dataverseManager.buildLabel("Customer"), + * RequiredLevel: { Value: "None" }, + * Targets: ["account", "contact"] + * }, + * OneToManyRelationships: [ + * { + * "@odata.type": "Microsoft.Dynamics.CRM.OneToManyRelationshipMetadata", + * SchemaName: "new_order_customer_account", + * ReferencedEntity: "account", + * ReferencingEntity: "new_order" + * }, + * { + * "@odata.type": "Microsoft.Dynamics.CRM.OneToManyRelationshipMetadata", + * SchemaName: "new_order_customer_contact", + * ReferencedEntity: "contact", + * ReferencingEntity: "new_order" + * } + * ] + * } + * }); + * // Returns: { AttributeId: "guid", RelationshipIds: ["guid1", "guid2"] } + * + * @example + * // InsertStatusValue - Add status value to status choice column + * await dataverseManager.execute(connectionId, { + * operationName: "InsertStatusValue", + * operationType: "action", + * parameters: { + * EntityLogicalName: "new_project", + * AttributeLogicalName: "statuscode", + * Value: 100000000, + * Label: dataverseManager.buildLabel("Custom Status"), + * StateCode: 0 // Active state + * } + * }); + * + * @example + * // UpdateStateValue - Update state value metadata + * await dataverseManager.execute(connectionId, { + * operationName: "UpdateStateValue", + * operationType: "action", + * parameters: { + * EntityLogicalName: "new_project", + * AttributeLogicalName: "statecode", + * Value: 1, + * Label: dataverseManager.buildLabel("Inactive"), + * DefaultStatus: 2 + * } + * }); + * + * @example + * // Bound action - Execute on specific record + * await dataverseManager.execute(connectionId, { + * entityName: "account", + * entityId: "guid", + * operationName: "CustomAction", + * operationType: "action", + * parameters: { param1: "value" } + * }); + * + * @example + * // Function call - Uses GET with parameters in URL + * const result = await dataverseManager.execute(connectionId, { + * operationName: "WhoAmI", + * operationType: "function" + * }); */ async execute( connectionId: string, @@ -544,7 +751,37 @@ export class DataverseManager { /** * Query data from Dataverse using OData query parameters + * + * This method can query any Dataverse endpoint including entity data, metadata (EntityDefinitions, + * GlobalOptionSetDefinitions, etc.), and system entities. + * + * @param connectionId - Connection ID to use * @param odataQuery - OData query string with parameters like $select, $filter, $orderby, $top, $skip, $expand + * @returns Query result with value array + * + * @example + * // Query entity records + * const accounts = await dataverseManager.queryData(connectionId, + * "accounts?$select=name,accountnumber&$filter=statecode eq 0&$top=10" + * ); + * + * @example + * // Retrieve a global option set by name + * const optionSet = await dataverseManager.queryData(connectionId, + * "GlobalOptionSetDefinitions(Name='new_projectstatus')" + * ); + * + * @example + * // Retrieve all global option sets + * const allOptionSets = await dataverseManager.queryData(connectionId, + * "GlobalOptionSetDefinitions?$select=Name,DisplayName,OptionSetType" + * ); + * + * @example + * // Retrieve global option set by MetadataId + * const optionSetById = await dataverseManager.queryData(connectionId, + * "GlobalOptionSetDefinitions(guid)?$select=Name,Options" + * ); */ async queryData(connectionId: string, odataQuery: string): Promise<{ value: Record[] }> { if (!odataQuery || !odataQuery.trim()) { @@ -569,7 +806,14 @@ export class DataverseManager { /** * Make an HTTP request to Dataverse Web API */ - private makeHttpRequest(url: string, method: string, accessToken: string, body?: Record, preferOptions?: string[]): Promise<{ data: unknown; headers: Record }> { + private makeHttpRequest( + url: string, + method: string, + accessToken: string, + body?: Record, + preferOptions?: string[], + customHeaders?: Record, + ): Promise<{ data: unknown; headers: Record }> { return new Promise((resolve, reject) => { const urlObj = new URL(url); const bodyData = body ? JSON.stringify(body) : undefined; @@ -587,6 +831,8 @@ export class DataverseManager { path: urlObj.pathname + urlObj.search, method: method, headers: { + // Spread custom headers first, then override with required headers to prevent accidental overwrites + ...(customHeaders || {}), Authorization: `Bearer ${accessToken}`, Accept: "application/json", "OData-MaxVersion": "4.0", @@ -1010,4 +1256,698 @@ export class DataverseManager { await this.makeHttpRequest(url, "DELETE", accessToken); } + + // ======================================== + // Metadata Helper Utilities + // ======================================== + + /** + * Build a Label structure for metadata properties + * @param text - Display text for the label + * @param languageCode - Language code (defaults to 1033 for English) + * @returns Label object with LocalizedLabels array + * + * @example + * const label = dataverseManager.buildLabel("Account Name"); + * // Returns: { LocalizedLabels: [{ Label: "Account Name", LanguageCode: 1033, IsManaged: false }], UserLocalizedLabel: { Label: "Account Name", LanguageCode: 1033, IsManaged: false } } + */ + buildLabel(text: string, languageCode: number = 1033): Label { + const localizedLabel: LocalizedLabel = { + "@odata.type": "Microsoft.Dynamics.CRM.LocalizedLabel", + Label: text, + LanguageCode: languageCode, + IsManaged: false, + }; + + return { + "@odata.type": "Microsoft.Dynamics.CRM.Label", + LocalizedLabels: [localizedLabel], + UserLocalizedLabel: localizedLabel, + }; + } + + /** + * Get the OData type string for an attribute metadata type + * @param attributeType - Attribute metadata type enum value + * @returns Full OData type string (e.g., "Microsoft.Dynamics.CRM.StringAttributeMetadata") + * + * @example + * const odataType = dataverseManager.getAttributeODataType(AttributeMetadataType.String); + * // Returns: "Microsoft.Dynamics.CRM.StringAttributeMetadata" + */ + getAttributeODataType(attributeType: AttributeMetadataType): string { + return `Microsoft.Dynamics.CRM.${attributeType}AttributeMetadata`; + } + + /** + * Build custom headers for metadata operations + */ + private buildMetadataHeaders(options?: MetadataOperationOptions): Record { + const headers: Record = {}; + + if (options?.solutionUniqueName) { + headers["MSCRM.SolutionUniqueName"] = options.solutionUniqueName; + } + + if (options?.mergeLabels !== undefined) { + headers["MSCRM.MergeLabels"] = String(options.mergeLabels); + } + + if (options?.consistencyStrong) { + headers["Consistency"] = "Strong"; + } + + // Validate headers against allowed list (defensive programming - ensures type-safe options produce valid headers) + return this.validateMetadataHeaders(headers); + } + + /** + * Detect if a string is a GUID (MetadataId) or a logical name + */ + private isGuid(value: string): boolean { + const guidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + return guidRegex.test(value); + } + + // ======================================== + // Entity (Table) Metadata CRUD Operations + // ======================================== + + /** + * Create a new entity (table) definition + * @param connectionId - Connection ID to use + * @param entityDefinition - Entity metadata payload (must include SchemaName, DisplayName, OwnershipType, and at least one Attribute with IsPrimaryName=true) + * @param options - Optional metadata operation options + * @returns Object containing the created entity's MetadataId + * + * @example + * const result = await dataverseManager.createEntityDefinition(connectionId, { + * "@odata.type": "Microsoft.Dynamics.CRM.EntityMetadata", + * "SchemaName": "new_project", + * "DisplayName": dataverseManager.buildLabel("Project"), + * "OwnershipType": "UserOwned", + * "HasActivities": true, + * "Attributes": [{ + * "@odata.type": "Microsoft.Dynamics.CRM.StringAttributeMetadata", + * "SchemaName": "new_name", + * "IsPrimaryName": true, + * "MaxLength": 100, + * "DisplayName": dataverseManager.buildLabel("Project Name") + * }] + * }, { solutionUniqueName: "MySolution" }); + * + * // Remember to publish customizations after creating metadata + * await dataverseManager.publishCustomizations(connectionId, "new_project"); + */ + async createEntityDefinition(connectionId: string, entityDefinition: Record, options?: MetadataOperationOptions): Promise<{ id: string }> { + const { connection, accessToken } = await this.getConnectionWithToken(connectionId); + const url = this.buildApiUrl(connection, `api/data/${DATAVERSE_API_VERSION}/EntityDefinitions`); + const headers = this.buildMetadataHeaders(options); + + const response = await this.makeHttpRequest(url, "POST", accessToken, entityDefinition, undefined, headers); + + // Extract MetadataId from OData-EntityId header + // Metadata operations return 204 No Content with no body, header is the only source + const entityId = response.headers["odata-entityid"]; + if (!entityId) { + throw new Error("Failed to retrieve MetadataId from response. The OData-EntityId header was missing."); + } + return { + id: this.extractIdFromUrl(entityId), + }; + } + + /** + * Update an entity (table) definition + * NOTE: This uses PUT which requires the FULL entity definition (retrieve-modify-PUT pattern) + * @param connectionId - Connection ID to use + * @param entityIdentifier - Entity LogicalName or MetadataId + * @param entityDefinition - Complete entity metadata payload with all properties + * @param options - Optional metadata operation options (mergeLabels defaults to true) + * + * @example + * // Step 1: Retrieve current definition + * const currentDef = await dataverseManager.getEntityMetadata(connectionId, "new_project", true); + * + * // Step 2: Modify desired properties + * currentDef.DisplayName = dataverseManager.buildLabel("Updated Project Name"); + * + * // Step 3: PUT the entire definition back (mergeLabels preserves other language labels) + * await dataverseManager.updateEntityDefinition(connectionId, "new_project", currentDef, { mergeLabels: true }); + * + * // Step 4: Publish customizations + * await dataverseManager.publishCustomizations(connectionId, "new_project"); + */ + async updateEntityDefinition(connectionId: string, entityIdentifier: string, entityDefinition: Record, options?: MetadataOperationOptions): Promise { + const { connection, accessToken } = await this.getConnectionWithToken(connectionId); + + // Auto-detect MetadataId vs LogicalName + const isMetadataId = this.isGuid(entityIdentifier); + const identifier = isMetadataId ? entityIdentifier : `LogicalName='${encodeURIComponent(entityIdentifier)}'`; + + const url = this.buildApiUrl(connection, `api/data/${DATAVERSE_API_VERSION}/EntityDefinitions(${identifier})`); + + // Default mergeLabels to true for updates to preserve localized labels + const headers = this.buildMetadataHeaders({ + ...options, + mergeLabels: options?.mergeLabels !== undefined ? options.mergeLabels : true, + }); + + await this.makeHttpRequest(url, "PUT", accessToken, entityDefinition, undefined, headers); + } + + /** + * Delete an entity (table) definition + * @param connectionId - Connection ID to use + * @param entityIdentifier - Entity LogicalName or MetadataId + * + * @example + * await dataverseManager.deleteEntityDefinition(connectionId, "new_project"); + */ + async deleteEntityDefinition(connectionId: string, entityIdentifier: string): Promise { + const { connection, accessToken } = await this.getConnectionWithToken(connectionId); + + // Auto-detect MetadataId vs LogicalName + const isMetadataId = this.isGuid(entityIdentifier); + const identifier = isMetadataId ? entityIdentifier : `LogicalName='${encodeURIComponent(entityIdentifier)}'`; + + const url = this.buildApiUrl(connection, `api/data/${DATAVERSE_API_VERSION}/EntityDefinitions(${identifier})`); + + await this.makeHttpRequest(url, "DELETE", accessToken); + } + + // ======================================== + // Attribute (Column) Metadata CRUD Operations + // ======================================== + + /** + * Create a new attribute (column) on an existing entity + * @param connectionId - Connection ID to use + * @param entityLogicalName - Logical name of the entity to add the attribute to + * @param attributeDefinition - Attribute metadata payload (must include @odata.type, SchemaName, DisplayName) + * @param options - Optional metadata operation options + * @returns Object containing the created attribute's MetadataId + * + * @example + * const result = await dataverseManager.createAttribute(connectionId, "new_project", { + * "@odata.type": "Microsoft.Dynamics.CRM.StringAttributeMetadata", + * "SchemaName": "new_description", + * "DisplayName": dataverseManager.buildLabel("Description"), + * "MaxLength": 500, + * "FormatName": { "Value": "Text" } + * }, { solutionUniqueName: "MySolution" }); + * + * await dataverseManager.publishCustomizations(connectionId, "new_project"); + */ + async createAttribute(connectionId: string, entityLogicalName: string, attributeDefinition: Record, options?: MetadataOperationOptions): Promise<{ id: string }> { + const { connection, accessToken } = await this.getConnectionWithToken(connectionId); + const encodedLogicalName = encodeURIComponent(entityLogicalName); + const url = this.buildApiUrl(connection, `api/data/${DATAVERSE_API_VERSION}/EntityDefinitions(LogicalName='${encodedLogicalName}')/Attributes`); + const headers = this.buildMetadataHeaders(options); + + const response = await this.makeHttpRequest(url, "POST", accessToken, attributeDefinition, undefined, headers); + + // Extract MetadataId from OData-EntityId header + // Metadata operations return 204 No Content with no body, header is the only source + const entityId = response.headers["odata-entityid"]; + if (!entityId) { + throw new Error("Failed to retrieve attribute MetadataId from response. The OData-EntityId header was missing."); + } + return { + id: this.extractIdFromUrl(entityId), + }; + } + + /** + * Update an attribute (column) definition + * NOTE: This uses PUT which requires the FULL attribute definition (retrieve-modify-PUT pattern) + * @param connectionId - Connection ID to use + * @param entityLogicalName - Logical name of the entity + * @param attributeIdentifier - Attribute LogicalName or MetadataId + * @param attributeDefinition - Complete attribute metadata payload + * @param options - Optional metadata operation options (mergeLabels defaults to true) + * + * @example + * // Retrieve current attribute definition + * const currentAttr = await dataverseManager.getEntityRelatedMetadata( + * connectionId, "new_project", "Attributes(LogicalName='new_description')" + * ); + * + * // Modify properties + * currentAttr.DisplayName = dataverseManager.buildLabel("Updated Description"); + * + * // PUT entire definition back + * await dataverseManager.updateAttribute(connectionId, "new_project", "new_description", currentAttr, { mergeLabels: true }); + * await dataverseManager.publishCustomizations(connectionId, "new_project"); + */ + async updateAttribute( + connectionId: string, + entityLogicalName: string, + attributeIdentifier: string, + attributeDefinition: Record, + options?: MetadataOperationOptions, + ): Promise { + const { connection, accessToken } = await this.getConnectionWithToken(connectionId); + const encodedLogicalName = encodeURIComponent(entityLogicalName); + + // Auto-detect MetadataId vs LogicalName + const isMetadataId = this.isGuid(attributeIdentifier); + const identifier = isMetadataId ? attributeIdentifier : `LogicalName='${encodeURIComponent(attributeIdentifier)}'`; + + const url = this.buildApiUrl(connection, `api/data/${DATAVERSE_API_VERSION}/EntityDefinitions(LogicalName='${encodedLogicalName}')/Attributes(${identifier})`); + + // Default mergeLabels to true for updates + const headers = this.buildMetadataHeaders({ + ...options, + mergeLabels: options?.mergeLabels !== undefined ? options.mergeLabels : true, + }); + + await this.makeHttpRequest(url, "PUT", accessToken, attributeDefinition, undefined, headers); + } + + /** + * Delete an attribute (column) from an entity + * @param connectionId - Connection ID to use + * @param entityLogicalName - Logical name of the entity + * @param attributeIdentifier - Attribute LogicalName or MetadataId + * + * @example + * await dataverseManager.deleteAttribute(connectionId, "new_project", "new_description"); + */ + async deleteAttribute(connectionId: string, entityLogicalName: string, attributeIdentifier: string): Promise { + const { connection, accessToken } = await this.getConnectionWithToken(connectionId); + const encodedLogicalName = encodeURIComponent(entityLogicalName); + + // Auto-detect MetadataId vs LogicalName + const isMetadataId = this.isGuid(attributeIdentifier); + const identifier = isMetadataId ? attributeIdentifier : `LogicalName='${encodeURIComponent(attributeIdentifier)}'`; + + const url = this.buildApiUrl(connection, `api/data/${DATAVERSE_API_VERSION}/EntityDefinitions(LogicalName='${encodedLogicalName}')/Attributes(${identifier})`); + + await this.makeHttpRequest(url, "DELETE", accessToken); + } + + /** + * Create a polymorphic lookup attribute (Customer/Regarding field) + * Creates a lookup that can reference multiple entity types + * + * NOTE: For customer lookups specifically (account/contact), you can alternatively use the + * CreateCustomerRelationships action via execute() method, which creates both the lookup + * attribute and the relationships in a single operation and returns more detailed response. + * + * @param connectionId - Connection ID to use + * @param entityLogicalName - Logical name of the entity to add the attribute to + * @param attributeDefinition - Lookup attribute metadata with Targets array + * @param options - Optional metadata operation options + * @returns Object containing the created attribute's MetadataId + * + * @example + * // Create a Customer lookup (Account or Contact) + * const result = await dataverseManager.createPolymorphicLookupAttribute(connectionId, "new_order", { + * "@odata.type": "Microsoft.Dynamics.CRM.LookupAttributeMetadata", + * "SchemaName": "new_CustomerId", + * "LogicalName": "new_customerid", + * "DisplayName": dataverseManager.buildLabel("Customer"), + * "Description": dataverseManager.buildLabel("Customer for this order"), + * "RequiredLevel": { Value: "None", CanBeChanged: true, ManagedPropertyLogicalName: "canmodifyrequirementlevelsettings" }, + * "AttributeType": "Lookup", + * "AttributeTypeName": { Value: "LookupType" }, + * "Targets": ["account", "contact"] + * }); + * + * @example + * // Create a Regarding lookup (custom entities) + * const result = await dataverseManager.createPolymorphicLookupAttribute(connectionId, "new_note", { + * "@odata.type": "Microsoft.Dynamics.CRM.LookupAttributeMetadata", + * "SchemaName": "new_RegardingObjectId", + * "LogicalName": "new_regardingobjectid", + * "DisplayName": dataverseManager.buildLabel("Regarding"), + * "Description": dataverseManager.buildLabel("Item this note is about"), + * "RequiredLevel": { Value: "None", CanBeChanged: true, ManagedPropertyLogicalName: "canmodifyrequirementlevelsettings" }, + * "AttributeType": "Lookup", + * "AttributeTypeName": { Value: "LookupType" }, + * "Targets": ["account", "contact", "new_project", "new_task"] + * }, { solutionUniqueName: "MyCustomSolution" }); + */ + async createPolymorphicLookupAttribute( + connectionId: string, + entityLogicalName: string, + attributeDefinition: Record, + options?: MetadataOperationOptions, + ): Promise<{ AttributeId: string }> { + // Validate Targets array is present + if (!attributeDefinition.Targets || !Array.isArray(attributeDefinition.Targets) || attributeDefinition.Targets.length === 0) { + throw new Error("Polymorphic lookup attribute requires a non-empty Targets array with entity logical names"); + } + + // Ensure AttributeType and AttributeTypeName are set correctly + if (!attributeDefinition.AttributeType) { + attributeDefinition.AttributeType = "Lookup"; + } + if (!attributeDefinition.AttributeTypeName) { + attributeDefinition.AttributeTypeName = { Value: "LookupType" }; + } + + // Use the standard createAttribute method (it supports polymorphic lookups) + const result = await this.createAttribute(connectionId, entityLogicalName, attributeDefinition, options); + return { AttributeId: result.id }; + } + + // ======================================== + // Relationship Metadata CRUD Operations + // ======================================== + + /** + * Create a new relationship + * @param connectionId - Connection ID to use + * @param relationshipDefinition - Relationship metadata payload (must include @odata.type for OneToManyRelationshipMetadata or ManyToManyRelationshipMetadata) + * @param options - Optional metadata operation options + * @returns Object containing the created relationship's MetadataId + * + * @example + * // Create 1:N relationship with cascade configuration + * const result = await dataverseManager.createRelationship(connectionId, { + * "@odata.type": "Microsoft.Dynamics.CRM.OneToManyRelationshipMetadata", + * "SchemaName": "new_project_tasks", + * "ReferencedEntity": "new_project", + * "ReferencedAttribute": "new_projectid", + * "ReferencingEntity": "task", + * "CascadeConfiguration": { + * "Assign": "NoCascade", + * "Delete": "RemoveLink", + * "Merge": "NoCascade", + * "Reparent": "NoCascade", + * "Share": "NoCascade", + * "Unshare": "NoCascade" + * }, + * "Lookup": { + * "@odata.type": "Microsoft.Dynamics.CRM.LookupAttributeMetadata", + * "SchemaName": "new_projectid", + * "DisplayName": dataverseManager.buildLabel("Project") + * } + * }, { solutionUniqueName: "MySolution" }); + * + * await dataverseManager.publishCustomizations(connectionId); + */ + async createRelationship(connectionId: string, relationshipDefinition: Record, options?: MetadataOperationOptions): Promise<{ id: string }> { + const { connection, accessToken } = await this.getConnectionWithToken(connectionId); + const url = this.buildApiUrl(connection, `api/data/${DATAVERSE_API_VERSION}/RelationshipDefinitions`); + const headers = this.buildMetadataHeaders(options); + + const response = await this.makeHttpRequest(url, "POST", accessToken, relationshipDefinition, undefined, headers); + + // Extract MetadataId from OData-EntityId header + // Metadata operations return 204 No Content with no body, header is the only source + const entityId = response.headers["odata-entityid"]; + if (!entityId) { + throw new Error("Failed to retrieve relationship MetadataId from response. The OData-EntityId header was missing."); + } + return { + id: this.extractIdFromUrl(entityId), + }; + } + + /** + * Update a relationship definition + * NOTE: This uses PUT which requires the FULL relationship definition (retrieve-modify-PUT pattern) + * @param connectionId - Connection ID to use + * @param relationshipIdentifier - Relationship SchemaName or MetadataId + * @param relationshipDefinition - Complete relationship metadata payload + * @param options - Optional metadata operation options (mergeLabels defaults to true) + * + * @example + * // Update cascade configuration on existing relationship + * const existingRel = await dataverseManager.getRelationship(connectionId, "new_project_tasks"); + * existingRel.CascadeConfiguration = { + * "Assign": "NoCascade", + * "Delete": "Cascade", // Changed from RemoveLink to Cascade + * "Merge": "NoCascade", + * "Reparent": "NoCascade", + * "Share": "NoCascade", + * "Unshare": "NoCascade" + * }; + * await dataverseManager.updateRelationship(connectionId, "new_project_tasks", existingRel); + */ + async updateRelationship(connectionId: string, relationshipIdentifier: string, relationshipDefinition: Record, options?: MetadataOperationOptions): Promise { + const { connection, accessToken } = await this.getConnectionWithToken(connectionId); + + // Auto-detect MetadataId vs SchemaName + const isMetadataId = this.isGuid(relationshipIdentifier); + const identifier = isMetadataId ? relationshipIdentifier : `SchemaName='${encodeURIComponent(relationshipIdentifier)}'`; + + const url = this.buildApiUrl(connection, `api/data/${DATAVERSE_API_VERSION}/RelationshipDefinitions(${identifier})`); + + const headers = this.buildMetadataHeaders({ + ...options, + mergeLabels: options?.mergeLabels !== undefined ? options.mergeLabels : true, + }); + + await this.makeHttpRequest(url, "PUT", accessToken, relationshipDefinition, undefined, headers); + } + + /** + * Delete a relationship + * @param connectionId - Connection ID to use + * @param relationshipIdentifier - Relationship SchemaName or MetadataId + */ + async deleteRelationship(connectionId: string, relationshipIdentifier: string): Promise { + const { connection, accessToken } = await this.getConnectionWithToken(connectionId); + + // Auto-detect MetadataId vs SchemaName + const isMetadataId = this.isGuid(relationshipIdentifier); + const identifier = isMetadataId ? relationshipIdentifier : `SchemaName='${encodeURIComponent(relationshipIdentifier)}'`; + + const url = this.buildApiUrl(connection, `api/data/${DATAVERSE_API_VERSION}/RelationshipDefinitions(${identifier})`); + + await this.makeHttpRequest(url, "DELETE", accessToken); + } + + // ======================================== + // Global Option Set (Choice) CRUD Operations + // ======================================== + + /** + * Create a new global option set (choice) + * + * NOTE: To retrieve global option sets after creation, use queryData() method with + * "GlobalOptionSetDefinitions" endpoint or use getEntityRelatedMetadata() for options + * associated with specific entities. + * + * @param connectionId - Connection ID to use + * @param optionSetDefinition - Global option set metadata payload + * @param options - Optional metadata operation options + * @returns Object containing the created option set's MetadataId + * + * @example + * const result = await dataverseManager.createGlobalOptionSet(connectionId, { + * "@odata.type": "Microsoft.Dynamics.CRM.OptionSetMetadata", + * "Name": "new_projectstatus", + * "DisplayName": dataverseManager.buildLabel("Project Status"), + * "OptionSetType": "Picklist", + * "Options": [ + * { "Value": 1, "Label": dataverseManager.buildLabel("Active") }, + * { "Value": 2, "Label": dataverseManager.buildLabel("On Hold") }, + * { "Value": 3, "Label": dataverseManager.buildLabel("Completed") } + * ] + * }, { solutionUniqueName: "MySolution" }); + * + * await dataverseManager.publishCustomizations(connectionId); + * + * // Retrieve the created option set + * const optionSet = await dataverseManager.queryData(connectionId, + * "GlobalOptionSetDefinitions(Name='new_projectstatus')" + * ); + */ + async createGlobalOptionSet(connectionId: string, optionSetDefinition: Record, options?: MetadataOperationOptions): Promise<{ id: string }> { + const { connection, accessToken } = await this.getConnectionWithToken(connectionId); + const url = this.buildApiUrl(connection, `api/data/${DATAVERSE_API_VERSION}/GlobalOptionSetDefinitions`); + const headers = this.buildMetadataHeaders(options); + + const response = await this.makeHttpRequest(url, "POST", accessToken, optionSetDefinition, undefined, headers); + + // Extract MetadataId from OData-EntityId header + // Metadata operations return 204 No Content with no body, header is the only source + const entityId = response.headers["odata-entityid"]; + if (!entityId) { + throw new Error("Failed to retrieve global option set MetadataId from response. The OData-EntityId header was missing."); + } + return { + id: this.extractIdFromUrl(entityId), + }; + } + + /** + * Update a global option set definition + * NOTE: This uses PUT which requires the FULL option set definition (retrieve-modify-PUT pattern) + * @param connectionId - Connection ID to use + * @param optionSetIdentifier - Option set Name or MetadataId + * @param optionSetDefinition - Complete option set metadata payload + * @param options - Optional metadata operation options (mergeLabels defaults to true) + */ + async updateGlobalOptionSet(connectionId: string, optionSetIdentifier: string, optionSetDefinition: Record, options?: MetadataOperationOptions): Promise { + const { connection, accessToken } = await this.getConnectionWithToken(connectionId); + + // Auto-detect MetadataId vs Name + const isMetadataId = this.isGuid(optionSetIdentifier); + const identifier = isMetadataId ? optionSetIdentifier : `Name='${encodeURIComponent(optionSetIdentifier)}'`; + + const url = this.buildApiUrl(connection, `api/data/${DATAVERSE_API_VERSION}/GlobalOptionSetDefinitions(${identifier})`); + + const headers = this.buildMetadataHeaders({ + ...options, + mergeLabels: options?.mergeLabels !== undefined ? options.mergeLabels : true, + }); + + await this.makeHttpRequest(url, "PUT", accessToken, optionSetDefinition, undefined, headers); + } + + /** + * Delete a global option set + * @param connectionId - Connection ID to use + * @param optionSetIdentifier - Option set Name or MetadataId + */ + async deleteGlobalOptionSet(connectionId: string, optionSetIdentifier: string): Promise { + const { connection, accessToken } = await this.getConnectionWithToken(connectionId); + + // Auto-detect MetadataId vs Name + const isMetadataId = this.isGuid(optionSetIdentifier); + const identifier = isMetadataId ? optionSetIdentifier : `Name='${encodeURIComponent(optionSetIdentifier)}'`; + + const url = this.buildApiUrl(connection, `api/data/${DATAVERSE_API_VERSION}/GlobalOptionSetDefinitions(${identifier})`); + + await this.makeHttpRequest(url, "DELETE", accessToken); + } + + // ======================================== + // Option Value Modification Actions + // ======================================== + + /** + * Insert a new option value into a local or global option set + * + * NOTE: This method is for standard choice columns. For Status choice columns (statuscode), + * use the InsertStatusValue action via execute() method instead, which requires additional + * StateCode parameter to associate the status with a state. + * + * Works for both local option sets (specify EntityLogicalName + AttributeLogicalName) + * and global option sets (specify OptionSetName). + * + * @param connectionId - Connection ID to use + * @param params - Parameters for inserting the option value + * @param params.Value - Integer value for the option + * @param params.Label - Label for the option + * @param params.EntityLogicalName - (For local option sets) Entity logical name + * @param params.AttributeLogicalName - (For local option sets) Attribute logical name + * @param params.OptionSetName - (For global option sets) Option set name + * @param params.SolutionUniqueName - Optional solution unique name + * @returns Object containing the new option value + * + * @example + * // Insert into local option set + * await dataverseManager.insertOptionValue(connectionId, { + * EntityLogicalName: "new_project", + * AttributeLogicalName: "new_priority", + * Value: 4, + * Label: dataverseManager.buildLabel("Critical") + * }); + * await dataverseManager.publishCustomizations(connectionId, "new_project"); + * + * @example + * // Insert into global option set + * await dataverseManager.insertOptionValue(connectionId, { + * OptionSetName: "new_projectstatus", + * Value: 4, + * Label: dataverseManager.buildLabel("Cancelled") + * }); + * await dataverseManager.publishCustomizations(connectionId); + */ + async insertOptionValue(connectionId: string, params: Record): Promise> { + return await this.execute(connectionId, { + operationName: "InsertOptionValue", + operationType: "action", + parameters: params, + }); + } + + /** + * Update an existing option value in a local or global option set + * + * @param connectionId - Connection ID to use + * @param params - Parameters for updating the option value + * @param params.Value - Integer value of the option to update + * @param params.Label - New label for the option + * @param params.EntityLogicalName - (For local option sets) Entity logical name + * @param params.AttributeLogicalName - (For local option sets) Attribute logical name + * @param params.OptionSetName - (For global option sets) Option set name + * @param params.MergeLabels - Optional boolean to merge labels (defaults to false) + * + * @example + * await dataverseManager.updateOptionValue(connectionId, { + * EntityLogicalName: "new_project", + * AttributeLogicalName: "new_priority", + * Value: 4, + * Label: buildLabel("High Priority"), + * MergeLabels: true + * }); + * await dataverseManager.publishCustomizations(connectionId, "new_project"); + */ + async updateOptionValue(connectionId: string, params: Record): Promise> { + return await this.execute(connectionId, { + operationName: "UpdateOptionValue", + operationType: "action", + parameters: params, + }); + } + + /** + * Delete an option value from a local or global option set + * + * @param connectionId - Connection ID to use + * @param params - Parameters for deleting the option value + * @param params.Value - Integer value of the option to delete + * @param params.EntityLogicalName - (For local option sets) Entity logical name + * @param params.AttributeLogicalName - (For local option sets) Attribute logical name + * @param params.OptionSetName - (For global option sets) Option set name + * + * @example + * await dataverseManager.deleteOptionValue(connectionId, { + * EntityLogicalName: "new_project", + * AttributeLogicalName: "new_priority", + * Value: 4 + * }); + * await dataverseManager.publishCustomizations(connectionId, "new_project"); + */ + async deleteOptionValue(connectionId: string, params: Record): Promise> { + return await this.execute(connectionId, { + operationName: "DeleteOptionValue", + operationType: "action", + parameters: params, + }); + } + + /** + * Reorder options in a local or global option set + * + * @param connectionId - Connection ID to use + * @param params - Parameters for ordering options + * @param params.Values - Array of option values in desired order + * @param params.EntityLogicalName - (For local option sets) Entity logical name + * @param params.AttributeLogicalName - (For local option sets) Attribute logical name + * @param params.OptionSetName - (For global option sets) Option set name + * + * @example + * await dataverseManager.orderOption(connectionId, { + * EntityLogicalName: "new_project", + * AttributeLogicalName: "new_priority", + * Values: [3, 1, 2, 4] // Reorder options by value + * }); + * await dataverseManager.publishCustomizations(connectionId, "new_project"); + */ + async orderOption(connectionId: string, params: Record): Promise> { + return await this.execute(connectionId, { + operationName: "OrderOption", + operationType: "action", + parameters: params, + }); + } } diff --git a/src/main/preload.ts b/src/main/preload.ts index e4452ea7..1b17ac71 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -274,6 +274,49 @@ contextBridge.exposeInMainWorld("toolboxAPI", { connectionTarget?: "primary" | "secondary", ) => ipcRenderer.invoke(DATAVERSE_CHANNELS.DEPLOY_SOLUTION, base64SolutionContent, options, connectionTarget), getImportJobStatus: (importJobId: string, connectionTarget?: "primary" | "secondary") => ipcRenderer.invoke(DATAVERSE_CHANNELS.GET_IMPORT_JOB_STATUS, importJobId, connectionTarget), + // Metadata helper utilities + buildLabel: (text: string, languageCode?: number) => ipcRenderer.invoke(DATAVERSE_CHANNELS.BUILD_LABEL, text, languageCode), + getAttributeODataType: (attributeType: string) => ipcRenderer.invoke(DATAVERSE_CHANNELS.GET_ATTRIBUTE_ODATA_TYPE, attributeType), + // Entity (Table) metadata operations + createEntityDefinition: (entityDefinition: Record, options?: Record, connectionTarget?: "primary" | "secondary") => + ipcRenderer.invoke(DATAVERSE_CHANNELS.CREATE_ENTITY_DEFINITION, entityDefinition, options, connectionTarget), + updateEntityDefinition: (entityIdentifier: string, entityDefinition: Record, options?: Record, connectionTarget?: "primary" | "secondary") => + ipcRenderer.invoke(DATAVERSE_CHANNELS.UPDATE_ENTITY_DEFINITION, entityIdentifier, entityDefinition, options, connectionTarget), + deleteEntityDefinition: (entityIdentifier: string, connectionTarget?: "primary" | "secondary") => + ipcRenderer.invoke(DATAVERSE_CHANNELS.DELETE_ENTITY_DEFINITION, entityIdentifier, connectionTarget), + // Attribute (Column) metadata operations + createAttribute: (entityLogicalName: string, attributeDefinition: Record, options?: Record, connectionTarget?: "primary" | "secondary") => + ipcRenderer.invoke(DATAVERSE_CHANNELS.CREATE_ATTRIBUTE, entityLogicalName, attributeDefinition, options, connectionTarget), + updateAttribute: ( + entityLogicalName: string, + attributeIdentifier: string, + attributeDefinition: Record, + options?: Record, + connectionTarget?: "primary" | "secondary", + ) => ipcRenderer.invoke(DATAVERSE_CHANNELS.UPDATE_ATTRIBUTE, entityLogicalName, attributeIdentifier, attributeDefinition, options, connectionTarget), + deleteAttribute: (entityLogicalName: string, attributeIdentifier: string, connectionTarget?: "primary" | "secondary") => + ipcRenderer.invoke(DATAVERSE_CHANNELS.DELETE_ATTRIBUTE, entityLogicalName, attributeIdentifier, connectionTarget), + createPolymorphicLookupAttribute: (entityLogicalName: string, attributeDefinition: Record, options?: Record, connectionTarget?: "primary" | "secondary") => + ipcRenderer.invoke(DATAVERSE_CHANNELS.CREATE_POLYMORPHIC_LOOKUP_ATTRIBUTE, entityLogicalName, attributeDefinition, options, connectionTarget), + // Relationship metadata operations + createRelationship: (relationshipDefinition: Record, options?: Record, connectionTarget?: "primary" | "secondary") => + ipcRenderer.invoke(DATAVERSE_CHANNELS.CREATE_RELATIONSHIP, relationshipDefinition, options, connectionTarget), + updateRelationship: (relationshipIdentifier: string, relationshipDefinition: Record, options?: Record, connectionTarget?: "primary" | "secondary") => + ipcRenderer.invoke(DATAVERSE_CHANNELS.UPDATE_RELATIONSHIP, relationshipIdentifier, relationshipDefinition, options, connectionTarget), + deleteRelationship: (relationshipIdentifier: string, connectionTarget?: "primary" | "secondary") => + ipcRenderer.invoke(DATAVERSE_CHANNELS.DELETE_RELATIONSHIP, relationshipIdentifier, connectionTarget), + // Global option set (choice) metadata operations + createGlobalOptionSet: (optionSetDefinition: Record, options?: Record, connectionTarget?: "primary" | "secondary") => + ipcRenderer.invoke(DATAVERSE_CHANNELS.CREATE_GLOBAL_OPTION_SET, optionSetDefinition, options, connectionTarget), + updateGlobalOptionSet: (optionSetIdentifier: string, optionSetDefinition: Record, options?: Record, connectionTarget?: "primary" | "secondary") => + ipcRenderer.invoke(DATAVERSE_CHANNELS.UPDATE_GLOBAL_OPTION_SET, optionSetIdentifier, optionSetDefinition, options, connectionTarget), + deleteGlobalOptionSet: (optionSetIdentifier: string, connectionTarget?: "primary" | "secondary") => + ipcRenderer.invoke(DATAVERSE_CHANNELS.DELETE_GLOBAL_OPTION_SET, optionSetIdentifier, connectionTarget), + // Option value modification actions + insertOptionValue: (params: Record, connectionTarget?: "primary" | "secondary") => ipcRenderer.invoke(DATAVERSE_CHANNELS.INSERT_OPTION_VALUE, params, connectionTarget), + updateOptionValue: (params: Record, connectionTarget?: "primary" | "secondary") => ipcRenderer.invoke(DATAVERSE_CHANNELS.UPDATE_OPTION_VALUE, params, connectionTarget), + deleteOptionValue: (params: Record, connectionTarget?: "primary" | "secondary") => ipcRenderer.invoke(DATAVERSE_CHANNELS.DELETE_OPTION_VALUE, params, connectionTarget), + orderOption: (params: Record, connectionTarget?: "primary" | "secondary") => ipcRenderer.invoke(DATAVERSE_CHANNELS.ORDER_OPTION, params, connectionTarget), }, }); diff --git a/src/main/toolPreloadBridge.ts b/src/main/toolPreloadBridge.ts index 626e32d9..c121b602 100644 --- a/src/main/toolPreloadBridge.ts +++ b/src/main/toolPreloadBridge.ts @@ -196,6 +196,41 @@ contextBridge.exposeInMainWorld("toolboxAPI", { connectionTarget?: "primary" | "secondary", ) => ipcInvoke(DATAVERSE_CHANNELS.DEPLOY_SOLUTION, base64SolutionContent, options, connectionTarget), getImportJobStatus: (importJobId: string, connectionTarget?: "primary" | "secondary") => ipcInvoke(DATAVERSE_CHANNELS.GET_IMPORT_JOB_STATUS, importJobId, connectionTarget), + // Metadata helper utilities + buildLabel: (text: string, languageCode?: number) => ipcInvoke(DATAVERSE_CHANNELS.BUILD_LABEL, text, languageCode), + getAttributeODataType: (attributeType: string) => ipcInvoke(DATAVERSE_CHANNELS.GET_ATTRIBUTE_ODATA_TYPE, attributeType), + // Entity (Table) metadata operations + createEntityDefinition: (entityDefinition: Record, options?: Record, connectionTarget?: "primary" | "secondary") => + ipcInvoke(DATAVERSE_CHANNELS.CREATE_ENTITY_DEFINITION, entityDefinition, options, connectionTarget), + updateEntityDefinition: (entityIdentifier: string, entityDefinition: Record, options?: Record, connectionTarget?: "primary" | "secondary") => + ipcInvoke(DATAVERSE_CHANNELS.UPDATE_ENTITY_DEFINITION, entityIdentifier, entityDefinition, options, connectionTarget), + deleteEntityDefinition: (entityIdentifier: string, connectionTarget?: "primary" | "secondary") => ipcInvoke(DATAVERSE_CHANNELS.DELETE_ENTITY_DEFINITION, entityIdentifier, connectionTarget), + // Attribute (Column) metadata operations + createAttribute: (entityLogicalName: string, attributeDefinition: Record, options?: Record, connectionTarget?: "primary" | "secondary") => + ipcInvoke(DATAVERSE_CHANNELS.CREATE_ATTRIBUTE, entityLogicalName, attributeDefinition, options, connectionTarget), + updateAttribute: (entityLogicalName: string, attributeIdentifier: string, attributeDefinition: Record, options?: Record, connectionTarget?: "primary" | "secondary") => + ipcInvoke(DATAVERSE_CHANNELS.UPDATE_ATTRIBUTE, entityLogicalName, attributeIdentifier, attributeDefinition, options, connectionTarget), + deleteAttribute: (entityLogicalName: string, attributeIdentifier: string, connectionTarget?: "primary" | "secondary") => + ipcInvoke(DATAVERSE_CHANNELS.DELETE_ATTRIBUTE, entityLogicalName, attributeIdentifier, connectionTarget), + createPolymorphicLookupAttribute: (entityLogicalName: string, attributeDefinition: Record, options?: Record, connectionTarget?: "primary" | "secondary") => + ipcInvoke(DATAVERSE_CHANNELS.CREATE_POLYMORPHIC_LOOKUP_ATTRIBUTE, entityLogicalName, attributeDefinition, options, connectionTarget), + // Relationship metadata operations + createRelationship: (relationshipDefinition: Record, options?: Record, connectionTarget?: "primary" | "secondary") => + ipcInvoke(DATAVERSE_CHANNELS.CREATE_RELATIONSHIP, relationshipDefinition, options, connectionTarget), + updateRelationship: (relationshipIdentifier: string, relationshipDefinition: Record, options?: Record, connectionTarget?: "primary" | "secondary") => + ipcInvoke(DATAVERSE_CHANNELS.UPDATE_RELATIONSHIP, relationshipIdentifier, relationshipDefinition, options, connectionTarget), + deleteRelationship: (relationshipIdentifier: string, connectionTarget?: "primary" | "secondary") => ipcInvoke(DATAVERSE_CHANNELS.DELETE_RELATIONSHIP, relationshipIdentifier, connectionTarget), + // Global option set (choice) metadata operations + createGlobalOptionSet: (optionSetDefinition: Record, options?: Record, connectionTarget?: "primary" | "secondary") => + ipcInvoke(DATAVERSE_CHANNELS.CREATE_GLOBAL_OPTION_SET, optionSetDefinition, options, connectionTarget), + updateGlobalOptionSet: (optionSetIdentifier: string, optionSetDefinition: Record, options?: Record, connectionTarget?: "primary" | "secondary") => + ipcInvoke(DATAVERSE_CHANNELS.UPDATE_GLOBAL_OPTION_SET, optionSetIdentifier, optionSetDefinition, options, connectionTarget), + deleteGlobalOptionSet: (optionSetIdentifier: string, connectionTarget?: "primary" | "secondary") => ipcInvoke(DATAVERSE_CHANNELS.DELETE_GLOBAL_OPTION_SET, optionSetIdentifier, connectionTarget), + // Option value modification actions + insertOptionValue: (params: Record, connectionTarget?: "primary" | "secondary") => ipcInvoke(DATAVERSE_CHANNELS.INSERT_OPTION_VALUE, params, connectionTarget), + updateOptionValue: (params: Record, connectionTarget?: "primary" | "secondary") => ipcInvoke(DATAVERSE_CHANNELS.UPDATE_OPTION_VALUE, params, connectionTarget), + deleteOptionValue: (params: Record, connectionTarget?: "primary" | "secondary") => ipcInvoke(DATAVERSE_CHANNELS.DELETE_OPTION_VALUE, params, connectionTarget), + orderOption: (params: Record, connectionTarget?: "primary" | "secondary") => ipcInvoke(DATAVERSE_CHANNELS.ORDER_OPTION, params, connectionTarget), }, // Utils API @@ -317,6 +352,41 @@ contextBridge.exposeInMainWorld("dataverseAPI", { connectionTarget?: "primary" | "secondary", ) => ipcInvoke(DATAVERSE_CHANNELS.DEPLOY_SOLUTION, base64SolutionContent, options, connectionTarget), getImportJobStatus: (importJobId: string, connectionTarget?: "primary" | "secondary") => ipcInvoke(DATAVERSE_CHANNELS.GET_IMPORT_JOB_STATUS, importJobId, connectionTarget), + // Metadata helper utilities + buildLabel: (text: string, languageCode?: number) => ipcInvoke(DATAVERSE_CHANNELS.BUILD_LABEL, text, languageCode), + getAttributeODataType: (attributeType: string) => ipcInvoke(DATAVERSE_CHANNELS.GET_ATTRIBUTE_ODATA_TYPE, attributeType), + // Entity (Table) metadata operations + createEntityDefinition: (entityDefinition: Record, options?: Record, connectionTarget?: "primary" | "secondary") => + ipcInvoke(DATAVERSE_CHANNELS.CREATE_ENTITY_DEFINITION, entityDefinition, options, connectionTarget), + updateEntityDefinition: (entityIdentifier: string, entityDefinition: Record, options?: Record, connectionTarget?: "primary" | "secondary") => + ipcInvoke(DATAVERSE_CHANNELS.UPDATE_ENTITY_DEFINITION, entityIdentifier, entityDefinition, options, connectionTarget), + deleteEntityDefinition: (entityIdentifier: string, connectionTarget?: "primary" | "secondary") => ipcInvoke(DATAVERSE_CHANNELS.DELETE_ENTITY_DEFINITION, entityIdentifier, connectionTarget), + // Attribute (Column) metadata operations + createAttribute: (entityLogicalName: string, attributeDefinition: Record, options?: Record, connectionTarget?: "primary" | "secondary") => + ipcInvoke(DATAVERSE_CHANNELS.CREATE_ATTRIBUTE, entityLogicalName, attributeDefinition, options, connectionTarget), + updateAttribute: (entityLogicalName: string, attributeIdentifier: string, attributeDefinition: Record, options?: Record, connectionTarget?: "primary" | "secondary") => + ipcInvoke(DATAVERSE_CHANNELS.UPDATE_ATTRIBUTE, entityLogicalName, attributeIdentifier, attributeDefinition, options, connectionTarget), + deleteAttribute: (entityLogicalName: string, attributeIdentifier: string, connectionTarget?: "primary" | "secondary") => + ipcInvoke(DATAVERSE_CHANNELS.DELETE_ATTRIBUTE, entityLogicalName, attributeIdentifier, connectionTarget), + createPolymorphicLookupAttribute: (entityLogicalName: string, attributeDefinition: Record, options?: Record, connectionTarget?: "primary" | "secondary") => + ipcInvoke(DATAVERSE_CHANNELS.CREATE_POLYMORPHIC_LOOKUP_ATTRIBUTE, entityLogicalName, attributeDefinition, options, connectionTarget), + // Relationship metadata operations + createRelationship: (relationshipDefinition: Record, options?: Record, connectionTarget?: "primary" | "secondary") => + ipcInvoke(DATAVERSE_CHANNELS.CREATE_RELATIONSHIP, relationshipDefinition, options, connectionTarget), + updateRelationship: (relationshipIdentifier: string, relationshipDefinition: Record, options?: Record, connectionTarget?: "primary" | "secondary") => + ipcInvoke(DATAVERSE_CHANNELS.UPDATE_RELATIONSHIP, relationshipIdentifier, relationshipDefinition, options, connectionTarget), + deleteRelationship: (relationshipIdentifier: string, connectionTarget?: "primary" | "secondary") => ipcInvoke(DATAVERSE_CHANNELS.DELETE_RELATIONSHIP, relationshipIdentifier, connectionTarget), + // Global option set (choice) metadata operations + createGlobalOptionSet: (optionSetDefinition: Record, options?: Record, connectionTarget?: "primary" | "secondary") => + ipcInvoke(DATAVERSE_CHANNELS.CREATE_GLOBAL_OPTION_SET, optionSetDefinition, options, connectionTarget), + updateGlobalOptionSet: (optionSetIdentifier: string, optionSetDefinition: Record, options?: Record, connectionTarget?: "primary" | "secondary") => + ipcInvoke(DATAVERSE_CHANNELS.UPDATE_GLOBAL_OPTION_SET, optionSetIdentifier, optionSetDefinition, options, connectionTarget), + deleteGlobalOptionSet: (optionSetIdentifier: string, connectionTarget?: "primary" | "secondary") => ipcInvoke(DATAVERSE_CHANNELS.DELETE_GLOBAL_OPTION_SET, optionSetIdentifier, connectionTarget), + // Option value modification actions + insertOptionValue: (params: Record, connectionTarget?: "primary" | "secondary") => ipcInvoke(DATAVERSE_CHANNELS.INSERT_OPTION_VALUE, params, connectionTarget), + updateOptionValue: (params: Record, connectionTarget?: "primary" | "secondary") => ipcInvoke(DATAVERSE_CHANNELS.UPDATE_OPTION_VALUE, params, connectionTarget), + deleteOptionValue: (params: Record, connectionTarget?: "primary" | "secondary") => ipcInvoke(DATAVERSE_CHANNELS.DELETE_OPTION_VALUE, params, connectionTarget), + orderOption: (params: Record, connectionTarget?: "primary" | "secondary") => ipcInvoke(DATAVERSE_CHANNELS.ORDER_OPTION, params, connectionTarget), }); logInfo("[ToolPreloadBridge] Initialized - toolboxAPI and dataverseAPI exposed"); From e05dd2e309b8601b36af18ca6e7bc43ad2f75d53 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 22:02:40 -0500 Subject: [PATCH 013/257] Fix loading overlay positioning and add manual dismiss button (#379) * Initial plan * Fix loading screen to only block tool panel, not entire window Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Address code review: Fix listener leak and simplify null coalescing Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Fix IPC listener cleanup: use ipcMain.on instead of once Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Fix loading overlay bounds calculation and add close button - Get bounds directly from active tool BrowserView instead of renderer - Add getActiveToolBounds() method to ToolWindowManager - Remove rounded corners from overlay container - Add close button to manually dismiss loading overlay - Make overlay window focusable and closable Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Improve close button accessibility and contrast - Add aria-label for screen reader support - Increase background opacity for better contrast (0.1 -> 0.15) - Add visible focus indicator with blue border and shadow - Improve border visibility (0.2 -> 0.3 opacity) Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Fix overlay positioning and explicitly remove all rounded corners - Use content bounds (screen coordinates) instead of incorrectly adding window bounds to BrowserView bounds - Explicitly set border-radius:0 on overlay-container and close-button - BrowserView bounds are content-relative, overlay BrowserWindow needs screen coordinates Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Address code review feedback: fix shutdown handling, HTML escaping, and error telemetry - Add isMainWindowClosing flag to allow overlay to close during app shutdown - Escape HTML in loading message to prevent injection attacks - Improve error handling with captureException and full context (activeToolId, tags) - Properly destroy overlay window in cleanup - Add close listener on main window to set shutdown flag Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Refine code review fixes: use specific listener reference and clarify HTML escaping - Store closeHandler reference to remove specific listener (not all listeners) - Add comment explaining critical order of HTML entity replacements - Prevent potential issues with removeAllListeners affecting other code Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Polish destroy logic for better clarity and consistency - Move overlayWindow null assignment inside if block for logical consistency - Set closeHandler to null after cleanup for better flow - Clearer separation of window cleanup vs handler cleanup Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> --- src/main/index.ts | 29 ++++- .../managers/loadingOverlayWindowManager.ts | 113 +++++++++++++++--- src/main/managers/toolWindowManager.ts | 35 +++++- 3 files changed, 153 insertions(+), 24 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index c86379f2..fcc76400 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -964,10 +964,31 @@ class ToolBoxApp { this.api.copyToClipboard(text); }); - // Show loading handler (overlay window above BrowserViews) - ipcMain.handle(UTIL_CHANNELS.SHOW_LOADING, (_, message: string) => { - if (this.loadingOverlayWindowManager) { - this.loadingOverlayWindowManager.show(message || "Loading..."); + // 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 + captureException(error instanceof Error ? error : new Error(String(error)), { + extra: { + source: "UTIL_CHANNELS.SHOW_LOADING", + context: "Failed to compute active tool bounds for loading overlay; falling back to full-window overlay.", + hasLoadingOverlayWindowManager: !!this.loadingOverlayWindowManager, + hasMainWindow: !!this.mainWindow, + hasToolWindowManager: !!this.toolWindowManager, + activeToolId: this.toolWindowManager?.getActiveToolId() || null, + message, + }, + }); + // 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..."); diff --git a/src/main/managers/loadingOverlayWindowManager.ts b/src/main/managers/loadingOverlayWindowManager.ts index acfa3cb7..dd098dd8 100644 --- a/src/main/managers/loadingOverlayWindowManager.ts +++ b/src/main/managers/loadingOverlayWindowManager.ts @@ -4,14 +4,20 @@ import { BrowserWindow } from "electron"; * LoadingOverlayWindowManager * * Provides a frameless, transparent, always-on-top window that displays a centered - * loading spinner and message. This window sits above any BrowserView instances, - * solving the issue where an in-DOM loading screen would be obscured by the tool BrowserView. + * loading spinner and message. This window sits above the tool panel area (not the entire window), + * allowing users to still interact with the sidebar, toolbar, and close buttons. + * This solves two issues: + * 1. An in-DOM loading screen would be obscured by the tool BrowserView + * 2. A full-window overlay would block all app interaction, preventing users from closing tools or the app */ export class LoadingOverlayWindowManager { private overlayWindow: BrowserWindow | null = null; private mainWindow: BrowserWindow; private visible = false; private currentMessage = "Loading..."; + private currentBounds: { x: number; y: number; width: number; height: number } | null = null; + private isMainWindowClosing = false; + private closeHandler: ((e: Electron.Event) => void) | null = null; constructor(mainWindow: BrowserWindow) { this.mainWindow = mainWindow; @@ -34,8 +40,8 @@ export class LoadingOverlayWindowManager { movable: false, minimizable: false, maximizable: false, - closable: false, - focusable: false, + closable: true, + focusable: true, show: false, hasShadow: false, backgroundColor: "#00000000", @@ -46,21 +52,48 @@ export class LoadingOverlayWindowManager { }, }); this.overlayWindow.setParentWindow(this.mainWindow); + + // Handle close button click - hide the overlay instead of destroying it + // Allow close during app shutdown to prevent blocking quit + this.closeHandler = (e: Electron.Event) => { + if (!this.isMainWindowClosing) { + e.preventDefault(); + this.hide(); + } + // Otherwise allow close to proceed during shutdown + }; + this.overlayWindow.on("close", this.closeHandler); + this.reloadContent(); this.updateWindowBounds(); } - /** Resize & reposition to cover the main window client area */ + /** Resize & reposition to cover the tool panel area (or entire window as fallback) */ private updateWindowBounds(): void { if (!this.overlayWindow) return; - const bounds = this.mainWindow.getBounds(); - // Cover entire window - this.overlayWindow.setBounds({ - x: bounds.x, - y: bounds.y, - width: bounds.width, - height: bounds.height, - }); + + if (this.currentBounds) { + // BrowserView bounds are relative to window content area (x, y from top-left of content) + // We need to convert to screen coordinates for the overlay BrowserWindow + const contentBounds = this.mainWindow.getContentBounds(); + + // Position overlay in screen coordinates + this.overlayWindow.setBounds({ + x: contentBounds.x + this.currentBounds.x, + y: contentBounds.y + this.currentBounds.y, + width: this.currentBounds.width, + height: this.currentBounds.height, + }); + } else { + // Fallback: cover entire window (legacy behavior) + const bounds = this.mainWindow.getBounds(); + this.overlayWindow.setBounds({ + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + }); + } } /** Rebuild the HTML with current message */ @@ -72,26 +105,56 @@ export class LoadingOverlayWindowManager { /** Generate overlay HTML */ private generateHTML(message: string): string { + // Escape message to prevent HTML/script injection + const escapedMessage = this.escapeHtml(message); + return ` -
${message}
+
+ +
+
${escapedMessage}
+
`; } - /** Show overlay with optional message */ - show(message?: string): void { + /** + * Escape HTML special characters to prevent injection + * Note: Order of replacements is critical - ampersand must be first to avoid + * double-escaping the ampersands in entities like < and > + */ + private escapeHtml(text: string): string { + return text + .replace(/&/g, "&") // Must be first to avoid double-escaping + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + /** + * Show overlay with optional message. + * If bounds are provided, the overlay will only cover that area (typically the tool panel). + * If no bounds provided, it will cover the entire window (fallback for legacy compatibility). + */ + show(message?: string, bounds?: { x: number; y: number; width: number; height: number }): void { this.currentMessage = message || this.currentMessage || "Loading..."; + this.currentBounds = bounds || null; this.reloadContent(); this.updateWindowBounds(); if (this.overlayWindow && !this.visible) { @@ -124,12 +187,24 @@ body { display:flex; align-items:center; justify-content:center; } this.mainWindow.on("restore", () => { if (this.visible) this.show(); }); + this.mainWindow.on("close", () => { + // Mark that main window is closing so overlay can close too + this.isMainWindowClosing = true; + }); this.mainWindow.on("closed", () => this.destroy()); } /** Cleanup */ destroy(): void { - this.overlayWindow = null; + if (this.overlayWindow) { + // Remove the specific close listener to allow destruction + if (this.closeHandler) { + this.overlayWindow.removeListener("close", this.closeHandler); + } + this.overlayWindow.destroy(); + this.overlayWindow = null; + } + this.closeHandler = null; this.visible = false; } } diff --git a/src/main/managers/toolWindowManager.ts b/src/main/managers/toolWindowManager.ts index 8a8efcc5..de8ccb71 100644 --- a/src/main/managers/toolWindowManager.ts +++ b/src/main/managers/toolWindowManager.ts @@ -1,7 +1,7 @@ import { BrowserView, BrowserWindow, ipcMain } from "electron"; import * as path from "path"; import { EVENT_CHANNELS, TOOL_WINDOW_CHANNELS } from "../../common/ipc/channels"; -import { captureMessage, logInfo } from "../../common/sentryHelper"; +import { captureException, captureMessage, logInfo } from "../../common/sentryHelper"; import { LastUsedToolConnectionInfo, Tool } from "../../common/types"; import { ToolBoxEvent } from "../../common/types/events"; import { BrowserviewProtocolManager } from "./browserviewProtocolManager"; @@ -733,6 +733,39 @@ export class ToolWindowManager { return this.activeToolId; } + /** + * Get the bounds of the active tool's BrowserView + * @returns The bounds of the active tool's BrowserView, or null if no tool is active + */ + getActiveToolBounds(): { x: number; y: number; width: number; height: number } | null { + if (!this.activeToolId) { + return null; + } + + const toolView = this.toolViews.get(this.activeToolId); + if (!toolView) { + return null; + } + + try { + return toolView.getBounds(); + } catch (error) { + // Normalize error and capture with full context + const normalizedError = error instanceof Error ? error : new Error(String(error)); + captureException(normalizedError, { + tags: { + component: "ToolWindowManager", + method: "getActiveToolBounds", + }, + extra: { + activeToolId: this.activeToolId, + errorMessage: normalizedError.message, + }, + }); + return null; + } + } + /** * Get the active tool's repository URL * @returns The repository URL of the currently active tool, or null if no tool is active or no repository is defined From 3d6ec9cef8770b013a16cb29cd16175a605deff9 Mon Sep 17 00:00:00 2001 From: Danish Naglekar <36135520+Power-Maverick@users.noreply.github.com> Date: Wed, 11 Feb 2026 22:06:50 -0500 Subject: [PATCH 014/257] fix: update release date formatting in workflows for consistency (#383) --- .github/workflows/nightly-release.yml | 2 +- .github/workflows/prod-release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/nightly-release.yml b/.github/workflows/nightly-release.yml index fb240cd6..583f3b15 100644 --- a/.github/workflows/nightly-release.yml +++ b/.github/workflows/nightly-release.yml @@ -209,7 +209,7 @@ jobs: Write-Host "Size: $size" # Create new YAML content with correct hashes - $releaseDate = (Get-Date -u -Format 'yyyy-MM-ddTHH:mm:ss.000Z') + $releaseDate = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.000Z') $yml = @{ version = $version files = @( diff --git a/.github/workflows/prod-release.yml b/.github/workflows/prod-release.yml index 7dd5ac97..3f977381 100644 --- a/.github/workflows/prod-release.yml +++ b/.github/workflows/prod-release.yml @@ -213,7 +213,7 @@ jobs: Write-Host "Size: $size" # Create new YAML content with correct hashes - $releaseDate = (Get-Date -u -Format 'yyyy-MM-ddTHH:mm:ss.000Z') + $releaseDate = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.000Z') $yml = @{ version = $version files = @( From 79c6112f5ac52280ee38eae610165ecd38f79cf0 Mon Sep 17 00:00:00 2001 From: Danish Naglekar <36135520+Power-Maverick@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:13:08 -0500 Subject: [PATCH 015/257] fix: clean up toolboxAPI type definitions and improve connection handling (#387) --- packages/toolboxAPI.d.ts | 14 +---- src/main/toolPreloadBridge.ts | 97 ++++++++++++++++++++--------------- 2 files changed, 58 insertions(+), 53 deletions(-) diff --git a/packages/toolboxAPI.d.ts b/packages/toolboxAPI.d.ts index ef328439..68106d60 100644 --- a/packages/toolboxAPI.d.ts +++ b/packages/toolboxAPI.d.ts @@ -82,8 +82,6 @@ declare namespace ToolBoxAPI { name: string; url: string; environment: "Dev" | "Test" | "UAT" | "Production"; - clientId?: string; - tenantId?: string; createdAt: string; lastUsedAt?: string; /** @@ -155,16 +153,6 @@ declare namespace ToolBoxAPI { * Get the secondary connection for multi-connection tools */ getSecondaryConnection: () => Promise; - - /** - * Get the secondary connection URL for multi-connection tools - */ - getSecondaryConnectionUrl: () => Promise; - - /** - * Get the secondary connection ID for multi-connection tools - */ - getSecondaryConnectionId: () => Promise; } /** @@ -272,7 +260,7 @@ declare namespace ToolBoxAPI { * JSON.stringify(data, null, 2), * [{name: "JSON", extensions: ["json"]}, {name: "Text", extensions: ["txt"]}] * ); - * + * * // Save without filters (auto-derived from extension) * await toolboxAPI.fileSystem.saveFile("config.xml", xmlContent); */ diff --git a/src/main/toolPreloadBridge.ts b/src/main/toolPreloadBridge.ts index c121b602..3a11b082 100644 --- a/src/main/toolPreloadBridge.ts +++ b/src/main/toolPreloadBridge.ts @@ -95,6 +95,43 @@ function ipcInvoke(channel: string, ...args: unknown[]): Promise { return ipcRenderer.invoke(channel, ...args); } +type ToolSafeConnection = { + id: string; + name: string; + url: string; + environment: "Dev" | "Test" | "UAT" | "Production"; + createdAt?: string; + lastUsedAt?: string; + isActive?: boolean; +}; + +function toToolSafeConnection(connection: unknown): ToolSafeConnection | null { + if (!connection || typeof connection !== "object") { + return null; + } + + const source = connection as Record; + const environment = source.environment; + + if (typeof source.id !== "string" || typeof source.name !== "string" || typeof source.url !== "string") { + return null; + } + + if (environment !== "Dev" && environment !== "Test" && environment !== "UAT" && environment !== "Production") { + return null; + } + + return { + id: source.id, + name: source.name, + url: source.url, + environment, + createdAt: typeof source.createdAt === "string" ? source.createdAt : undefined, + lastUsedAt: typeof source.lastUsedAt === "string" ? source.lastUsedAt : undefined, + isActive: typeof source.isActive === "boolean" ? source.isActive : undefined, + }; +} + // Expose toolboxAPI to the tool window contextBridge.exposeInMainWorld("toolboxAPI", { // Tool Info @@ -106,52 +143,22 @@ contextBridge.exposeInMainWorld("toolboxAPI", { // Connections API connections: { // Get tool's primary connection from context - getConnection: async () => { - await withTimeout(toolContextReady, TOOL_CONTEXT_TIMEOUT_MS, TOOL_CONTEXT_TIMEOUT_ERROR); - if (!toolContext || typeof toolContext.connectionId !== "string") { - return null; - } - return ipcInvoke(CONNECTION_CHANNELS.GET_CONNECTION_BY_ID, toolContext.connectionId); - }, - getConnectionUrl: async () => { - await withTimeout(toolContextReady, TOOL_CONTEXT_TIMEOUT_MS, TOOL_CONTEXT_TIMEOUT_ERROR); - return toolContext?.connectionUrl || null; - }, - getConnectionId: async () => { - await withTimeout(toolContextReady, TOOL_CONTEXT_TIMEOUT_MS, TOOL_CONTEXT_TIMEOUT_ERROR); - return toolContext?.connectionId || null; - }, - // Backward compatibility: getActiveConnection is an alias for getConnection - // Tools call this expecting their own connection, not a global active connection getActiveConnection: async () => { await withTimeout(toolContextReady, TOOL_CONTEXT_TIMEOUT_MS, TOOL_CONTEXT_TIMEOUT_ERROR); if (!toolContext || typeof toolContext.connectionId !== "string") { return null; } - return ipcInvoke(CONNECTION_CHANNELS.GET_CONNECTION_BY_ID, toolContext.connectionId); + const connection = await ipcInvoke(CONNECTION_CHANNELS.GET_CONNECTION_BY_ID, toolContext.connectionId); + return toToolSafeConnection(connection); }, - getAll: () => ipcInvoke(CONNECTION_CHANNELS.GET_CONNECTIONS), - add: (connection: unknown) => ipcInvoke(CONNECTION_CHANNELS.ADD_CONNECTION, connection), - update: (id: string, updates: unknown) => ipcInvoke(CONNECTION_CHANNELS.UPDATE_CONNECTION, id, updates), - delete: (id: string) => ipcInvoke(CONNECTION_CHANNELS.DELETE_CONNECTION, id), - test: (connection: unknown) => ipcInvoke(CONNECTION_CHANNELS.TEST_CONNECTION, connection), - isTokenExpired: (connectionId: string) => ipcInvoke(CONNECTION_CHANNELS.IS_TOKEN_EXPIRED, connectionId), - refreshToken: (connectionId: string) => ipcInvoke(CONNECTION_CHANNELS.REFRESH_TOKEN, connectionId), // Secondary connection methods for multi-connection tools getSecondaryConnection: async () => { await withTimeout(toolContextReady, TOOL_CONTEXT_TIMEOUT_MS, TOOL_CONTEXT_TIMEOUT_ERROR); if (!toolContext || typeof toolContext.secondaryConnectionId !== "string") { return null; } - return ipcInvoke(CONNECTION_CHANNELS.GET_CONNECTION_BY_ID, toolContext.secondaryConnectionId); - }, - getSecondaryConnectionUrl: async () => { - await withTimeout(toolContextReady, TOOL_CONTEXT_TIMEOUT_MS, TOOL_CONTEXT_TIMEOUT_ERROR); - return toolContext?.secondaryConnectionUrl || null; - }, - getSecondaryConnectionId: async () => { - await withTimeout(toolContextReady, TOOL_CONTEXT_TIMEOUT_MS, TOOL_CONTEXT_TIMEOUT_ERROR); - return toolContext?.secondaryConnectionId || null; + const connection = await ipcInvoke(CONNECTION_CHANNELS.GET_CONNECTION_BY_ID, toolContext.secondaryConnectionId); + return toToolSafeConnection(connection); }, }, @@ -208,8 +215,13 @@ contextBridge.exposeInMainWorld("toolboxAPI", { // Attribute (Column) metadata operations createAttribute: (entityLogicalName: string, attributeDefinition: Record, options?: Record, connectionTarget?: "primary" | "secondary") => ipcInvoke(DATAVERSE_CHANNELS.CREATE_ATTRIBUTE, entityLogicalName, attributeDefinition, options, connectionTarget), - updateAttribute: (entityLogicalName: string, attributeIdentifier: string, attributeDefinition: Record, options?: Record, connectionTarget?: "primary" | "secondary") => - ipcInvoke(DATAVERSE_CHANNELS.UPDATE_ATTRIBUTE, entityLogicalName, attributeIdentifier, attributeDefinition, options, connectionTarget), + updateAttribute: ( + entityLogicalName: string, + attributeIdentifier: string, + attributeDefinition: Record, + options?: Record, + connectionTarget?: "primary" | "secondary", + ) => ipcInvoke(DATAVERSE_CHANNELS.UPDATE_ATTRIBUTE, entityLogicalName, attributeIdentifier, attributeDefinition, options, connectionTarget), deleteAttribute: (entityLogicalName: string, attributeIdentifier: string, connectionTarget?: "primary" | "secondary") => ipcInvoke(DATAVERSE_CHANNELS.DELETE_ATTRIBUTE, entityLogicalName, attributeIdentifier, connectionTarget), createPolymorphicLookupAttribute: (entityLogicalName: string, attributeDefinition: Record, options?: Record, connectionTarget?: "primary" | "secondary") => @@ -225,7 +237,8 @@ contextBridge.exposeInMainWorld("toolboxAPI", { ipcInvoke(DATAVERSE_CHANNELS.CREATE_GLOBAL_OPTION_SET, optionSetDefinition, options, connectionTarget), updateGlobalOptionSet: (optionSetIdentifier: string, optionSetDefinition: Record, options?: Record, connectionTarget?: "primary" | "secondary") => ipcInvoke(DATAVERSE_CHANNELS.UPDATE_GLOBAL_OPTION_SET, optionSetIdentifier, optionSetDefinition, options, connectionTarget), - deleteGlobalOptionSet: (optionSetIdentifier: string, connectionTarget?: "primary" | "secondary") => ipcInvoke(DATAVERSE_CHANNELS.DELETE_GLOBAL_OPTION_SET, optionSetIdentifier, connectionTarget), + deleteGlobalOptionSet: (optionSetIdentifier: string, connectionTarget?: "primary" | "secondary") => + ipcInvoke(DATAVERSE_CHANNELS.DELETE_GLOBAL_OPTION_SET, optionSetIdentifier, connectionTarget), // Option value modification actions insertOptionValue: (params: Record, connectionTarget?: "primary" | "secondary") => ipcInvoke(DATAVERSE_CHANNELS.INSERT_OPTION_VALUE, params, connectionTarget), updateOptionValue: (params: Record, connectionTarget?: "primary" | "secondary") => ipcInvoke(DATAVERSE_CHANNELS.UPDATE_OPTION_VALUE, params, connectionTarget), @@ -273,7 +286,6 @@ contextBridge.exposeInMainWorld("toolboxAPI", { const { toolId, instanceId } = await getToolIdentifiers(); return ipcInvoke(TERMINAL_CHANNELS.GET_TOOL_TERMINALS, toolId, instanceId); }, - listAll: () => ipcInvoke(TERMINAL_CHANNELS.GET_ALL_TERMINALS), setVisibility: (terminalId: string, visible: boolean) => ipcInvoke(TERMINAL_CHANNELS.SET_VISIBILITY, terminalId, visible), }, @@ -364,8 +376,13 @@ contextBridge.exposeInMainWorld("dataverseAPI", { // Attribute (Column) metadata operations createAttribute: (entityLogicalName: string, attributeDefinition: Record, options?: Record, connectionTarget?: "primary" | "secondary") => ipcInvoke(DATAVERSE_CHANNELS.CREATE_ATTRIBUTE, entityLogicalName, attributeDefinition, options, connectionTarget), - updateAttribute: (entityLogicalName: string, attributeIdentifier: string, attributeDefinition: Record, options?: Record, connectionTarget?: "primary" | "secondary") => - ipcInvoke(DATAVERSE_CHANNELS.UPDATE_ATTRIBUTE, entityLogicalName, attributeIdentifier, attributeDefinition, options, connectionTarget), + updateAttribute: ( + entityLogicalName: string, + attributeIdentifier: string, + attributeDefinition: Record, + options?: Record, + connectionTarget?: "primary" | "secondary", + ) => ipcInvoke(DATAVERSE_CHANNELS.UPDATE_ATTRIBUTE, entityLogicalName, attributeIdentifier, attributeDefinition, options, connectionTarget), deleteAttribute: (entityLogicalName: string, attributeIdentifier: string, connectionTarget?: "primary" | "secondary") => ipcInvoke(DATAVERSE_CHANNELS.DELETE_ATTRIBUTE, entityLogicalName, attributeIdentifier, connectionTarget), createPolymorphicLookupAttribute: (entityLogicalName: string, attributeDefinition: Record, options?: Record, connectionTarget?: "primary" | "secondary") => From 4b27beb8364a6efff118a2932175ed230b86718c Mon Sep 17 00:00:00 2001 From: mohsinonxrm Date: Sat, 14 Feb 2026 18:14:12 -0800 Subject: [PATCH 016/257] feat: add getCSDLDocument API for retrieving OData endpoint (#384) (#385) * feat: add getCSDLDocument API for retrieving OData endpoint (#384) Add new getCSDLDocument() method to DataverseAPI that retrieves the complete CSDL/EDMX metadata document from Dataverse's OData $metadata endpoint. Key Features: - Returns raw XML containing complete schema metadata (entities, attributes, relationships, actions, functions, complex types, enum types) - Supports gzip/deflate compression for optimal transfer (70-80% size reduction) - Automatic decompression handling using Node.js zlib module - Multi-connection support (primary/secondary) for advanced tools - Response size: 1-5MB typical, up to 10MB+ for complex environments Implementation: - Added GET_CSDL_DOCUMENT IPC channel to channels.ts - Implemented getCSDLDocument() in DataverseManager with: - Custom HTTPS request handler (avoids JSON parsing) - Accept-Encoding: gzip, deflate headers - Automatic decompression via zlib.gunzip/inflate - Buffer-based response handling for binary data - Registered IPC handler in index.ts with connection targeting support - Exposed method in toolPreloadBridge.ts and preload.ts - Added comprehensive TypeScript definitions in dataverseAPI.d.ts Use Cases: - Build Dataverse REST Builder (DRB) clone tools - Create intelligent query builders and code generators - Generate TypeScript interfaces from schema - Explore entity relationships and metadata - Validate action/function parameters Naming Rationale: Method named getCSDLDocument() (not getMetadata()) to avoid confusion with existing entity metadata operations like getEntityMetadata() and getAllEntitiesMetadata(). CSDL (Common Schema Definition Language) clearly indicates it returns the OData service document. Related: #384 * fix: expose getCSDLDocument on window.dataverseAPI for direct tool access Fixed issue where getCSDLDocument was only accessible via window.toolboxAPI.dataverse.getCSDLDocument but not directly on window.dataverseAPI.getCSDLDocument like other Dataverse operations. Changes: - Added getCSDLDocument to toolPreloadBridge.ts dataverseAPI namespace - Now consistent with other operations (getSolutions, queryData, etc.) - Tools can call dataverseAPI.getCSDLDocument() directly as expected This ensures getCSDLDocument follows the same exposure pattern as all other Dataverse API methods. * fix: decompress error responses in getCSDLDocument for readable error messages Previously, error responses (non-200 status codes) were converted directly from Buffer to UTF-8 string without checking for compression. Since the request includes 'Accept-Encoding: gzip, deflate' header, Dataverse may return compressed error responses, resulting in garbled error messages. Changes: - Added decompression logic to error response path - Check content-encoding header (gzip/deflate/none) - Decompress error body before converting to string - Added try-catch to handle decompression failures gracefully - Error messages now readable regardless of compression This mirrors the success path's decompression logic and ensures consistent handling of both success and error responses. --- packages/dataverseAPI.d.ts | 50 +++++++++- src/common/ipc/channels.ts | 1 + src/main/index.ts | 132 +++++++++++++++++--------- src/main/managers/dataverseManager.ts | 118 ++++++++++++++++++++--- src/main/preload.ts | 1 + src/main/toolPreloadBridge.ts | 2 + 6 files changed, 242 insertions(+), 62 deletions(-) diff --git a/packages/dataverseAPI.d.ts b/packages/dataverseAPI.d.ts index 26599f5d..17d90488 100644 --- a/packages/dataverseAPI.d.ts +++ b/packages/dataverseAPI.d.ts @@ -958,6 +958,27 @@ declare namespace DataverseAPI { */ buildLabel: (text: string, languageCode?: number) => Label; + /** + * Retrieve the CSDL/EDMX metadata document for the Dataverse environment + * + * Returns the complete OData service document as raw XML containing metadata for: + * - EntityType definitions (tables/entities) + * - Property elements (attributes/columns) + * - NavigationProperty elements (relationships) + * - ComplexType definitions (return types for actions/functions) + * - EnumType definitions (picklist/choice enumerations) + * - Action definitions (OData Actions - POST operations) + * - Function definitions (OData Functions - GET operations) + * - EntityContainer metadata + * + * The response is automatically compressed with gzip during transfer for optimal performance, + * then decompressed and returned as a raw XML string. + * + * @param connectionTarget - Optional connection target for multi-connection tools + * @returns Raw CSDL/EDMX XML document as string (typically 1-5MB) + */ + getCSDLDocument: (connectionTarget?: "primary" | "secondary") => Promise; + /** * Get the OData type string for an attribute metadata type * Converts AttributeMetadataType enum to full Microsoft.Dynamics.CRM type path @@ -1142,7 +1163,12 @@ declare namespace DataverseAPI { * }); * await dataverseAPI.publishCustomizations("new_project"); */ - createAttribute: (entityLogicalName: string, attributeDefinition: Record, options?: MetadataOperationOptions, connectionTarget?: "primary" | "secondary") => Promise<{ id: string }>; + createAttribute: ( + entityLogicalName: string, + attributeDefinition: Record, + options?: MetadataOperationOptions, + connectionTarget?: "primary" | "secondary", + ) => Promise<{ id: string }>; /** * Update an attribute (column) definition @@ -1179,7 +1205,13 @@ declare namespace DataverseAPI { * // Step 4: Publish customizations * await dataverseAPI.publishCustomizations("new_project"); */ - updateAttribute: (entityLogicalName: string, attributeIdentifier: string, attributeDefinition: Record, options?: MetadataOperationOptions, connectionTarget?: "primary" | "secondary") => Promise; + updateAttribute: ( + entityLogicalName: string, + attributeIdentifier: string, + attributeDefinition: Record, + options?: MetadataOperationOptions, + connectionTarget?: "primary" | "secondary", + ) => Promise; /** * Delete an attribute (column) from an entity @@ -1295,7 +1327,12 @@ declare namespace DataverseAPI { * @param options - Optional metadata operation options (mergeLabels defaults to true) * @param connectionTarget - Optional connection target for multi-connection tools ('primary' or 'secondary'). Defaults to 'primary'. */ - updateRelationship: (relationshipIdentifier: string, relationshipDefinition: Record, options?: MetadataOperationOptions, connectionTarget?: "primary" | "secondary") => Promise; + updateRelationship: ( + relationshipIdentifier: string, + relationshipDefinition: Record, + options?: MetadataOperationOptions, + connectionTarget?: "primary" | "secondary", + ) => Promise; /** * Delete a relationship @@ -1354,7 +1391,12 @@ declare namespace DataverseAPI { * @param options - Optional metadata operation options (mergeLabels defaults to true) * @param connectionTarget - Optional connection target for multi-connection tools ('primary' or 'secondary'). Defaults to 'primary'. */ - updateGlobalOptionSet: (optionSetIdentifier: string, optionSetDefinition: Record, options?: MetadataOperationOptions, connectionTarget?: "primary" | "secondary") => Promise; + updateGlobalOptionSet: ( + optionSetIdentifier: string, + optionSetDefinition: Record, + options?: MetadataOperationOptions, + connectionTarget?: "primary" | "secondary", + ) => Promise; /** * Delete a global option set diff --git a/src/common/ipc/channels.ts b/src/common/ipc/channels.ts index 3749a923..3e477f64 100644 --- a/src/common/ipc/channels.ts +++ b/src/common/ipc/channels.ts @@ -186,6 +186,7 @@ export const DATAVERSE_CHANNELS = { UPDATE_OPTION_VALUE: "dataverse.updateOptionValue", DELETE_OPTION_VALUE: "dataverse.deleteOptionValue", ORDER_OPTION: "dataverse.orderOption", + GET_CSDL_DOCUMENT: "dataverse.getCSDLDocument", } as const; // Event-related IPC channels (from main to renderer) diff --git a/src/main/index.ts b/src/main/index.ts index fcc76400..b967b1f0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -74,7 +74,16 @@ import { UPDATE_CHANNELS, UTIL_CHANNELS, } from "../common/ipc/channels"; -import { AttributeMetadataType, EntityRelatedMetadataPath, LastUsedToolEntry, LastUsedToolUpdate, MetadataOperationOptions, ModalWindowMessagePayload, ModalWindowOptions, ToolBoxEvent } from "../common/types"; +import { + AttributeMetadataType, + EntityRelatedMetadataPath, + LastUsedToolEntry, + LastUsedToolUpdate, + MetadataOperationOptions, + ModalWindowMessagePayload, + ModalWindowOptions, + ToolBoxEvent, +} from "../common/types"; import { AuthManager } from "./managers/authManager"; import { AutoUpdateManager } from "./managers/autoUpdateManager"; import { BrowserManager } from "./managers/browserManager"; @@ -970,7 +979,7 @@ class ToolBoxApp { 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) { @@ -1543,6 +1552,23 @@ class ToolBoxApp { } }); + // Get CSDL document endpoint + ipcMain.handle(DATAVERSE_CHANNELS.GET_CSDL_DOCUMENT, async (event, connectionTarget?: "primary" | "secondary") => { + try { + const connectionId = + connectionTarget === "secondary" + ? this.toolWindowManager?.getSecondaryConnectionIdByWebContents(event.sender.id) + : this.toolWindowManager?.getConnectionIdByWebContents(event.sender.id); + if (!connectionId) { + const targetMsg = connectionTarget === "secondary" ? "secondary connection" : "connection"; + throw new Error(`No ${targetMsg} found for this tool instance. Please ensure the tool is connected to an environment.`); + } + return await this.dataverseManager.getCSDLDocument(connectionId); + } catch (error) { + throw new Error(`Get CSDL document failed: ${(error as Error).message}`); + } + }); + // Dataverse Metadata Helper Utilities ipcMain.handle(DATAVERSE_CHANNELS.BUILD_LABEL, async (event, text: string, languageCode?: number) => { try { @@ -1566,21 +1592,24 @@ class ToolBoxApp { }); // Entity (Table) Metadata CRUD Operations - ipcMain.handle(DATAVERSE_CHANNELS.CREATE_ENTITY_DEFINITION, async (event, entityDefinition: Record, options?: MetadataOperationOptions, connectionTarget?: "primary" | "secondary") => { - try { - const connectionId = - connectionTarget === "secondary" - ? this.toolWindowManager?.getSecondaryConnectionIdByWebContents(event.sender.id) - : this.toolWindowManager?.getConnectionIdByWebContents(event.sender.id); - if (!connectionId) { - const targetMsg = connectionTarget === "secondary" ? "secondary connection" : "connection"; - throw new Error(`No ${targetMsg} found for this tool instance. Please ensure the tool is connected to an environment.`); + ipcMain.handle( + DATAVERSE_CHANNELS.CREATE_ENTITY_DEFINITION, + async (event, entityDefinition: Record, options?: MetadataOperationOptions, connectionTarget?: "primary" | "secondary") => { + try { + const connectionId = + connectionTarget === "secondary" + ? this.toolWindowManager?.getSecondaryConnectionIdByWebContents(event.sender.id) + : this.toolWindowManager?.getConnectionIdByWebContents(event.sender.id); + if (!connectionId) { + const targetMsg = connectionTarget === "secondary" ? "secondary connection" : "connection"; + throw new Error(`No ${targetMsg} found for this tool instance. Please ensure the tool is connected to an environment.`); + } + return await this.dataverseManager.createEntityDefinition(connectionId, entityDefinition, options); + } catch (error) { + throw new Error(`Create entity definition failed: ${(error as Error).message}`); } - return await this.dataverseManager.createEntityDefinition(connectionId, entityDefinition, options); - } catch (error) { - throw new Error(`Create entity definition failed: ${(error as Error).message}`); - } - }); + }, + ); ipcMain.handle( DATAVERSE_CHANNELS.UPDATE_ENTITY_DEFINITION, @@ -1641,7 +1670,14 @@ class ToolBoxApp { ipcMain.handle( DATAVERSE_CHANNELS.UPDATE_ATTRIBUTE, - async (event, entityLogicalName: string, attributeIdentifier: string, attributeDefinition: Record, options?: MetadataOperationOptions, connectionTarget?: "primary" | "secondary") => { + async ( + event, + entityLogicalName: string, + attributeIdentifier: string, + attributeDefinition: Record, + options?: MetadataOperationOptions, + connectionTarget?: "primary" | "secondary", + ) => { try { const connectionId = connectionTarget === "secondary" @@ -1696,21 +1732,24 @@ class ToolBoxApp { ); // Relationship Metadata CRUD Operations - ipcMain.handle(DATAVERSE_CHANNELS.CREATE_RELATIONSHIP, async (event, relationshipDefinition: Record, options?: MetadataOperationOptions, connectionTarget?: "primary" | "secondary") => { - try { - const connectionId = - connectionTarget === "secondary" - ? this.toolWindowManager?.getSecondaryConnectionIdByWebContents(event.sender.id) - : this.toolWindowManager?.getConnectionIdByWebContents(event.sender.id); - if (!connectionId) { - const targetMsg = connectionTarget === "secondary" ? "secondary connection" : "connection"; - throw new Error(`No ${targetMsg} found for this tool instance. Please ensure the tool is connected to an environment.`); + ipcMain.handle( + DATAVERSE_CHANNELS.CREATE_RELATIONSHIP, + async (event, relationshipDefinition: Record, options?: MetadataOperationOptions, connectionTarget?: "primary" | "secondary") => { + try { + const connectionId = + connectionTarget === "secondary" + ? this.toolWindowManager?.getSecondaryConnectionIdByWebContents(event.sender.id) + : this.toolWindowManager?.getConnectionIdByWebContents(event.sender.id); + if (!connectionId) { + const targetMsg = connectionTarget === "secondary" ? "secondary connection" : "connection"; + throw new Error(`No ${targetMsg} found for this tool instance. Please ensure the tool is connected to an environment.`); + } + return await this.dataverseManager.createRelationship(connectionId, relationshipDefinition, options); + } catch (error) { + throw new Error(`Create relationship failed: ${(error as Error).message}`); } - return await this.dataverseManager.createRelationship(connectionId, relationshipDefinition, options); - } catch (error) { - throw new Error(`Create relationship failed: ${(error as Error).message}`); - } - }); + }, + ); ipcMain.handle( DATAVERSE_CHANNELS.UPDATE_RELATIONSHIP, @@ -1750,21 +1789,24 @@ class ToolBoxApp { }); // Global Option Set (Choice) CRUD Operations - ipcMain.handle(DATAVERSE_CHANNELS.CREATE_GLOBAL_OPTION_SET, async (event, optionSetDefinition: Record, options?: MetadataOperationOptions, connectionTarget?: "primary" | "secondary") => { - try { - const connectionId = - connectionTarget === "secondary" - ? this.toolWindowManager?.getSecondaryConnectionIdByWebContents(event.sender.id) - : this.toolWindowManager?.getConnectionIdByWebContents(event.sender.id); - if (!connectionId) { - const targetMsg = connectionTarget === "secondary" ? "secondary connection" : "connection"; - throw new Error(`No ${targetMsg} found for this tool instance. Please ensure the tool is connected to an environment.`); + ipcMain.handle( + DATAVERSE_CHANNELS.CREATE_GLOBAL_OPTION_SET, + async (event, optionSetDefinition: Record, options?: MetadataOperationOptions, connectionTarget?: "primary" | "secondary") => { + try { + const connectionId = + connectionTarget === "secondary" + ? this.toolWindowManager?.getSecondaryConnectionIdByWebContents(event.sender.id) + : this.toolWindowManager?.getConnectionIdByWebContents(event.sender.id); + if (!connectionId) { + const targetMsg = connectionTarget === "secondary" ? "secondary connection" : "connection"; + throw new Error(`No ${targetMsg} found for this tool instance. Please ensure the tool is connected to an environment.`); + } + return await this.dataverseManager.createGlobalOptionSet(connectionId, optionSetDefinition, options); + } catch (error) { + throw new Error(`Create global option set failed: ${(error as Error).message}`); } - return await this.dataverseManager.createGlobalOptionSet(connectionId, optionSetDefinition, options); - } catch (error) { - throw new Error(`Create global option set failed: ${(error as Error).message}`); - } - }); + }, + ); ipcMain.handle( DATAVERSE_CHANNELS.UPDATE_GLOBAL_OPTION_SET, diff --git a/src/main/managers/dataverseManager.ts b/src/main/managers/dataverseManager.ts index 55db107b..5004127f 100644 --- a/src/main/managers/dataverseManager.ts +++ b/src/main/managers/dataverseManager.ts @@ -1,4 +1,6 @@ import * as https from "https"; +import * as zlib from "zlib"; +import { promisify } from "util"; import { DataverseConnection, ENTITY_RELATED_METADATA_BASE_PATHS, @@ -91,15 +93,7 @@ export class DataverseManager { * Headers that must never be passed as custom headers because they are controlled by makeHttpRequest. * Attempting to override these headers will result in validation errors. */ - private static readonly PROTECTED_HEADERS: ReadonlySet = new Set([ - "authorization", - "accept", - "content-type", - "odata-maxversion", - "odata-version", - "prefer", - "content-length", - ]); + private static readonly PROTECTED_HEADERS: ReadonlySet = new Set(["authorization", "accept", "content-type", "odata-maxversion", "odata-version", "prefer", "content-length"]); /** * Validates custom headers for metadata operations against the allowed headers list. @@ -165,10 +159,7 @@ export class DataverseManager { } if (invalidHeaders.length > 0) { - errorParts.push( - `Invalid headers for metadata operations: ${invalidHeaders.join(", ")}. ` + - `Allowed headers: ${Array.from(DataverseManager.ALLOWED_METADATA_HEADERS).join(", ")}`, - ); + errorParts.push(`Invalid headers for metadata operations: ${invalidHeaders.join(", ")}. ` + `Allowed headers: ${Array.from(DataverseManager.ALLOWED_METADATA_HEADERS).join(", ")}`); } throw new Error(`Header validation failed${operation}. ${errorParts.join(". ")}`); @@ -803,6 +794,107 @@ export class DataverseManager { return response.data as { value: Record[] }; } + /** + * Retrieve CSDL/EDMX metadata document for the Dataverse environment + * + * Returns the complete OData service document containing metadata for all: + * - EntityType definitions (tables/entities) + * - Property elements (attributes/columns) + * - NavigationProperty elements (relationships) + * - ComplexType definitions (return types for actions/functions) + * - EnumType definitions (picklist/choice enumerations) + * - Action definitions (OData Actions - POST operations) + * - Function definitions (OData Functions - GET operations) + * - EntityContainer metadata + * + * NOTE: Returns raw XML (1-5MB typical). Response is compressed with gzip for optimal transfer. + * The response is automatically decompressed and returned as a string. + * + * @param connectionId - Connection ID to use + * @returns Raw CSDL/EDMX XML document as string + * + * @throws Error if connection not found, token expired, or request fails + */ + async getCSDLDocument(connectionId: string): Promise { + const { connection, accessToken } = await this.getConnectionWithToken(connectionId); + const url = this.buildApiUrl(connection, `api/data/${DATAVERSE_API_VERSION}/$metadata`); + + const gunzipAsync = promisify(zlib.gunzip); + const inflateAsync = promisify(zlib.inflate); + + return new Promise((resolve, reject) => { + const urlObj = new URL(url); + + const options: https.RequestOptions = { + hostname: urlObj.hostname, + port: 443, + path: urlObj.pathname, + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/xml", + "Accept-Encoding": "gzip, deflate", + }, + }; + + const req = https.request(options, (res) => { + const chunks: Buffer[] = []; + + res.on("data", (chunk: Buffer) => { + chunks.push(chunk); + }); + + res.on("end", async () => { + if (res.statusCode === 200) { + try { + const buffer = Buffer.concat(chunks); + const encoding = res.headers["content-encoding"]; + + let decompressed: Buffer; + if (encoding === "gzip") { + decompressed = await gunzipAsync(buffer); + } else if (encoding === "deflate") { + decompressed = await inflateAsync(buffer); + } else { + decompressed = buffer; + } + + resolve(decompressed.toString("utf-8")); + } catch (error) { + reject(new Error(`Failed to decompress metadata response: ${(error as Error).message}`)); + } + } else { + // Error responses may also be compressed - decompress before reading body + try { + const buffer = Buffer.concat(chunks); + const encoding = res.headers["content-encoding"]; + let decompressed: Buffer; + + if (encoding === "gzip") { + decompressed = await gunzipAsync(buffer); + } else if (encoding === "deflate") { + decompressed = await inflateAsync(buffer); + } else { + decompressed = buffer; + } + + const body = decompressed.toString("utf-8"); + reject(new Error(`Failed to retrieve CSDL document. Status: ${res.statusCode}, Body: ${body}`)); + } catch (decompressError) { + reject(new Error(`Failed to process error response: ${(decompressError as Error).message}`)); + } + } + }); + }); + + req.on("error", (error) => { + reject(new Error(`Metadata request failed: ${error.message}`)); + }); + + req.end(); + }); + } + /** * Make an HTTP request to Dataverse Web API */ diff --git a/src/main/preload.ts b/src/main/preload.ts index 1b17ac71..163f1fe8 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -250,6 +250,7 @@ contextBridge.exposeInMainWorld("toolboxAPI", { getEntityRelatedMetadata:

(entityLogicalName: string, relatedPath: P, selectColumns?: string[], connectionTarget?: "primary" | "secondary") => ipcRenderer.invoke(DATAVERSE_CHANNELS.GET_ENTITY_RELATED_METADATA, entityLogicalName, relatedPath, selectColumns, connectionTarget) as Promise>, getSolutions: (selectColumns: string[], connectionTarget?: "primary" | "secondary") => ipcRenderer.invoke(DATAVERSE_CHANNELS.GET_SOLUTIONS, selectColumns, connectionTarget), + getCSDLDocument: (connectionTarget?: "primary" | "secondary") => ipcRenderer.invoke(DATAVERSE_CHANNELS.GET_CSDL_DOCUMENT, connectionTarget), queryData: (odataQuery: string, connectionTarget?: "primary" | "secondary") => ipcRenderer.invoke(DATAVERSE_CHANNELS.QUERY_DATA, odataQuery, connectionTarget), publishCustomizations: (tableLogicalName?: string, connectionTarget?: "primary" | "secondary") => ipcRenderer.invoke(DATAVERSE_CHANNELS.PUBLISH_CUSTOMIZATIONS, tableLogicalName, connectionTarget), diff --git a/src/main/toolPreloadBridge.ts b/src/main/toolPreloadBridge.ts index 3a11b082..2834e952 100644 --- a/src/main/toolPreloadBridge.ts +++ b/src/main/toolPreloadBridge.ts @@ -180,6 +180,7 @@ contextBridge.exposeInMainWorld("toolboxAPI", { getEntityRelatedMetadata:

(entityLogicalName: string, relatedPath: P, selectColumns?: string[], connectionTarget?: "primary" | "secondary") => ipcInvoke(DATAVERSE_CHANNELS.GET_ENTITY_RELATED_METADATA, entityLogicalName, relatedPath, selectColumns, connectionTarget) as Promise>, getSolutions: (selectColumns: string[], connectionTarget?: "primary" | "secondary") => ipcInvoke(DATAVERSE_CHANNELS.GET_SOLUTIONS, selectColumns, connectionTarget), + getCSDLDocument: (connectionTarget?: "primary" | "secondary") => ipcInvoke(DATAVERSE_CHANNELS.GET_CSDL_DOCUMENT, connectionTarget), queryData: (odataQuery: string, connectionTarget?: "primary" | "secondary") => ipcInvoke(DATAVERSE_CHANNELS.QUERY_DATA, odataQuery, connectionTarget), publishCustomizations: (tableLogicalName?: string, connectionTarget?: "primary" | "secondary") => ipcInvoke(DATAVERSE_CHANNELS.PUBLISH_CUSTOMIZATIONS, tableLogicalName, connectionTarget), createMultiple: (entityLogicalName: string, records: Record[], connectionTarget?: "primary" | "secondary") => @@ -341,6 +342,7 @@ contextBridge.exposeInMainWorld("dataverseAPI", { getEntityRelatedMetadata:

(entityLogicalName: string, relatedPath: P, selectColumns?: string[], connectionTarget?: "primary" | "secondary") => ipcInvoke(DATAVERSE_CHANNELS.GET_ENTITY_RELATED_METADATA, entityLogicalName, relatedPath, selectColumns, connectionTarget) as Promise>, getSolutions: (selectColumns: string[], connectionTarget?: "primary" | "secondary") => ipcInvoke(DATAVERSE_CHANNELS.GET_SOLUTIONS, selectColumns, connectionTarget), + getCSDLDocument: (connectionTarget?: "primary" | "secondary") => ipcInvoke(DATAVERSE_CHANNELS.GET_CSDL_DOCUMENT, connectionTarget), queryData: (odataQuery: string, connectionTarget?: "primary" | "secondary") => ipcInvoke(DATAVERSE_CHANNELS.QUERY_DATA, odataQuery, connectionTarget), publishCustomizations: (tableLogicalName?: string, connectionTarget?: "primary" | "secondary") => ipcInvoke(DATAVERSE_CHANNELS.PUBLISH_CUSTOMIZATIONS, tableLogicalName, connectionTarget), createMultiple: (entityLogicalName: string, records: Record[], connectionTarget?: "primary" | "secondary") => From badfdcb8ef6ffae5b2a45ad3ce929b14f6a6d714 Mon Sep 17 00:00:00 2001 From: Danish Naglekar <36135520+Power-Maverick@users.noreply.github.com> Date: Sat, 14 Feb 2026 21:28:54 -0500 Subject: [PATCH 017/257] File System cleanup (#389) * fix: clean up toolboxAPI type definitions and improve connection handling * fix: implement filesystem access management for tools with user consent model * Fix filesystem access tracking to use instanceId instead of toolId (#390) * Initial plan * fix: track filesystem access per instance instead of per tool This change fixes the issue where closing one instance of a tool would revoke filesystem access for all other instances of the same tool. Changes: - Updated ToolFileSystemAccessManager to use instanceId as key instead of toolId - Added getInstanceIdByWebContents() method to ToolWindowManager - Updated all filesystem IPC handlers to use instanceId - Updated closeTool() to revoke access only for the specific instance This ensures that each tool instance maintains its own filesystem permissions independently, allowing multiple instances of the same tool to run simultaneously without interfering with each other. Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * refactor: eliminate code duplication in toolWindowManager methods Refactor getToolIdByWebContents() to call getInstanceIdByWebContents() and extract the toolId from the result, eliminating duplicated loop logic. Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> --- src/main/index.ts | 99 ++++++++++++++--- .../managers/toolFileSystemAccessManager.ts | 104 ++++++++++++++++++ src/main/managers/toolWindowManager.ts | 39 +++++++ 3 files changed, 229 insertions(+), 13 deletions(-) create mode 100644 src/main/managers/toolFileSystemAccessManager.ts diff --git a/src/main/index.ts b/src/main/index.ts index b967b1f0..38e6ba17 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -97,6 +97,7 @@ import { NotificationWindowManager } from "./managers/notificationWindowManager" import { SettingsManager } from "./managers/settingsManager"; import { TerminalManager } from "./managers/terminalManager"; import { ToolBoxUtilityManager } from "./managers/toolboxUtilityManager"; +import { ToolFileSystemAccessManager } from "./managers/toolFileSystemAccessManager"; import { ToolManager } from "./managers/toolsManager"; import { ToolWindowManager } from "./managers/toolWindowManager"; @@ -120,6 +121,7 @@ class ToolBoxApp { private authManager: AuthManager; private terminalManager: TerminalManager; private dataverseManager: DataverseManager; + private toolFilesystemAccessManager: ToolFileSystemAccessManager; 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 @@ -148,6 +150,7 @@ class ToolBoxApp { this.authManager = new AuthManager(this.browserManager); this.terminalManager = new TerminalManager(); this.dataverseManager = new DataverseManager(this.connectionsManager, this.authManager); + this.toolFilesystemAccessManager = new ToolFileSystemAccessManager(); this.setupEventListeners(); this.setupIpcHandlers(); @@ -1070,50 +1073,112 @@ class ToolBoxApp { await shell.openExternal(url); }); - // Filesystem handlers - ipcMain.handle(FILESYSTEM_CHANNELS.READ_TEXT, async (_, filePath: string) => { + // Filesystem handlers with access control + ipcMain.handle(FILESYSTEM_CHANNELS.READ_TEXT, async (event, filePath: string) => { + // Validate access if caller is a tool (null instanceId means main window - allow all) + const instanceId = this.toolWindowManager?.getInstanceIdByWebContents(event.sender.id); + if (instanceId) { + this.toolFilesystemAccessManager.validateAccess(instanceId, filePath); + } + const { readText } = await import("./utilities/filesystem.js"); return await readText(filePath); }); - ipcMain.handle(FILESYSTEM_CHANNELS.READ_BINARY, async (_, filePath: string) => { + ipcMain.handle(FILESYSTEM_CHANNELS.READ_BINARY, async (event, filePath: string) => { + // Validate access if caller is a tool + const instanceId = this.toolWindowManager?.getInstanceIdByWebContents(event.sender.id); + if (instanceId) { + this.toolFilesystemAccessManager.validateAccess(instanceId, filePath); + } + const { readBinary } = await import("./utilities/filesystem.js"); return await readBinary(filePath); }); - ipcMain.handle(FILESYSTEM_CHANNELS.EXISTS, async (_, filePath: string) => { + ipcMain.handle(FILESYSTEM_CHANNELS.EXISTS, async (event, filePath: string) => { + // Validate access if caller is a tool + const instanceId = this.toolWindowManager?.getInstanceIdByWebContents(event.sender.id); + if (instanceId) { + this.toolFilesystemAccessManager.validateAccess(instanceId, filePath); + } + const { exists } = await import("./utilities/filesystem.js"); return await exists(filePath); }); - ipcMain.handle(FILESYSTEM_CHANNELS.STAT, async (_, filePath: string) => { + ipcMain.handle(FILESYSTEM_CHANNELS.STAT, async (event, filePath: string) => { + // Validate access if caller is a tool + const instanceId = this.toolWindowManager?.getInstanceIdByWebContents(event.sender.id); + if (instanceId) { + this.toolFilesystemAccessManager.validateAccess(instanceId, filePath); + } + const { stat } = await import("./utilities/filesystem.js"); return await stat(filePath); }); - ipcMain.handle(FILESYSTEM_CHANNELS.READ_DIRECTORY, async (_, dirPath: string) => { + ipcMain.handle(FILESYSTEM_CHANNELS.READ_DIRECTORY, async (event, dirPath: string) => { + // Validate access if caller is a tool + const instanceId = this.toolWindowManager?.getInstanceIdByWebContents(event.sender.id); + if (instanceId) { + this.toolFilesystemAccessManager.validateAccess(instanceId, dirPath); + } + const { readDirectory } = await import("./utilities/filesystem.js"); return await readDirectory(dirPath); }); - ipcMain.handle(FILESYSTEM_CHANNELS.WRITE_TEXT, async (_, filePath: string, content: string) => { + ipcMain.handle(FILESYSTEM_CHANNELS.WRITE_TEXT, async (event, filePath: string, content: string) => { + // Validate access if caller is a tool + const instanceId = this.toolWindowManager?.getInstanceIdByWebContents(event.sender.id); + if (instanceId) { + this.toolFilesystemAccessManager.validateAccess(instanceId, filePath); + } + const { writeText } = await import("./utilities/filesystem.js"); return await writeText(filePath, content); }); - ipcMain.handle(FILESYSTEM_CHANNELS.CREATE_DIRECTORY, async (_, dirPath: string) => { + ipcMain.handle(FILESYSTEM_CHANNELS.CREATE_DIRECTORY, async (event, dirPath: string) => { + // Validate access if caller is a tool + const instanceId = this.toolWindowManager?.getInstanceIdByWebContents(event.sender.id); + if (instanceId) { + this.toolFilesystemAccessManager.validateAccess(instanceId, dirPath); + } + const { createDirectory } = await import("./utilities/filesystem.js"); return await createDirectory(dirPath); }); - ipcMain.handle(FILESYSTEM_CHANNELS.SAVE_FILE, async (_, defaultPath: string, content: string | Buffer, filters?: Array<{ name: string; extensions: string[] }>) => { + ipcMain.handle(FILESYSTEM_CHANNELS.SAVE_FILE, async (event, defaultPath: string, content: string | Buffer, filters?: Array<{ name: string; extensions: string[] }>) => { const { saveFile } = await import("./utilities/filesystem.js"); - return await saveFile(defaultPath, content, filters); + const selectedPath = await saveFile(defaultPath, content, filters); + + // Grant access to the selected path if a tool called this and user selected a file + if (selectedPath) { + const instanceId = this.toolWindowManager?.getInstanceIdByWebContents(event.sender.id); + if (instanceId) { + this.toolFilesystemAccessManager.grantAccess(instanceId, selectedPath); + } + } + + return selectedPath; }); - ipcMain.handle(FILESYSTEM_CHANNELS.SELECT_PATH, async (_, options) => { + ipcMain.handle(FILESYSTEM_CHANNELS.SELECT_PATH, async (event, options) => { const { selectPath } = await import("./utilities/filesystem.js"); - return await selectPath(options); + const selectedPath = await selectPath(options); + + // Grant access to the selected path if a tool called this and user selected something + if (selectedPath) { + const instanceId = this.toolWindowManager?.getInstanceIdByWebContents(event.sender.id); + if (instanceId) { + this.toolFilesystemAccessManager.grantAccess(instanceId, selectedPath); + } + } + + return selectedPath; }); // Modal BrowserWindow internal channels (modal preload -> main) @@ -2236,7 +2301,15 @@ class ToolBoxApp { }); // Initialize ToolWindowManager for managing tool BrowserViews - this.toolWindowManager = new ToolWindowManager(this.mainWindow, this.browserviewProtocolManager, this.connectionsManager, this.settingsManager, this.toolManager, this.terminalManager); + this.toolWindowManager = new ToolWindowManager( + this.mainWindow, + this.browserviewProtocolManager, + this.connectionsManager, + this.settingsManager, + this.toolManager, + this.terminalManager, + this.toolFilesystemAccessManager, + ); // Set up callback to rebuild menu when active tool changes (debounced to prevent excessive recreation) this.toolWindowManager.setOnActiveToolChanged(() => { diff --git a/src/main/managers/toolFileSystemAccessManager.ts b/src/main/managers/toolFileSystemAccessManager.ts new file mode 100644 index 00000000..50bcddd9 --- /dev/null +++ b/src/main/managers/toolFileSystemAccessManager.ts @@ -0,0 +1,104 @@ +import * as path from "path"; +import { logInfo, logWarn } from "../../common/sentryHelper"; + +/** + * Manages filesystem access permissions for tools + * Implements a user-consent model where tools can only access paths explicitly selected by users + * Similar to VS Code Extension Host security model + * + * Access is tracked per tool instance (instanceId) to support multiple instances of the same tool + */ +export class ToolFileSystemAccessManager { + // Map: instanceId -> Set of allowed absolute paths + private allowedPaths: Map> = new Map(); + + /** + * Grant access to a path for a specific tool instance + * Called automatically when user selects a path via selectPath() or saveFile() + */ + grantAccess(instanceId: string, filePath: string): void { + const resolvedPath = path.resolve(filePath); + + if (!this.allowedPaths.has(instanceId)) { + this.allowedPaths.set(instanceId, new Set()); + } + + this.allowedPaths.get(instanceId)!.add(resolvedPath); + logInfo(`[ToolFilesystemAccess] Granted access to tool instance ${instanceId}: ${resolvedPath}`); + } + + /** + * Check if a tool instance has access to a specific path + * Access is granted if: + * 1. The exact path was user-selected + * 2. The path is a descendant of a user-selected directory + */ + canAccess(instanceId: string, targetPath: string): boolean { + const allowedSet = this.allowedPaths.get(instanceId); + if (!allowedSet || allowedSet.size === 0) { + return false; + } + + const resolvedTarget = path.resolve(targetPath); + + // Check if the target path matches or is within any allowed path + for (const allowedPath of allowedSet) { + // Exact match + if (resolvedTarget === allowedPath) { + return true; + } + + // Check if target is a descendant of allowed directory + // Use path separators to ensure we're checking directory boundaries + const relativePath = path.relative(allowedPath, resolvedTarget); + const isDescendant = !relativePath.startsWith("..") && !path.isAbsolute(relativePath); + + if (isDescendant) { + return true; + } + } + + return false; + } + + /** + * Validate access and throw if denied + */ + validateAccess(instanceId: string, targetPath: string): void { + if (!this.canAccess(instanceId, targetPath)) { + const resolvedPath = path.resolve(targetPath); + logWarn(`[ToolFilesystemAccess] Access denied for tool instance ${instanceId} to path: ${resolvedPath}`); + throw new Error( + `Access denied. This tool does not have permission to access "${resolvedPath}". ` + + `Please use toolboxAPI.fileSystem.selectPath() to grant access to a directory, ` + + `or use toolboxAPI.fileSystem.saveFile() to select where to save files.`, + ); + } + } + + /** + * Revoke all access for a tool instance (called when tool instance is closed) + */ + revokeAllAccess(instanceId: string): void { + const removed = this.allowedPaths.delete(instanceId); + if (removed) { + logInfo(`[ToolFilesystemAccess] Revoked all filesystem access for tool instance: ${instanceId}`); + } + } + + /** + * Get all allowed paths for a tool instance (for debugging/auditing) + */ + getAllowedPaths(instanceId: string): string[] { + const allowedSet = this.allowedPaths.get(instanceId); + return allowedSet ? Array.from(allowedSet) : []; + } + + /** + * Clear all permissions (for testing/cleanup) + */ + clearAll(): void { + this.allowedPaths.clear(); + logInfo("[ToolFilesystemAccess] Cleared all filesystem permissions"); + } +} diff --git a/src/main/managers/toolWindowManager.ts b/src/main/managers/toolWindowManager.ts index de8ccb71..6edaa521 100644 --- a/src/main/managers/toolWindowManager.ts +++ b/src/main/managers/toolWindowManager.ts @@ -8,6 +8,7 @@ import { BrowserviewProtocolManager } from "./browserviewProtocolManager"; import { ConnectionsManager } from "./connectionsManager"; import { SettingsManager } from "./settingsManager"; import { TerminalManager } from "./terminalManager"; +import { ToolFileSystemAccessManager } from "./toolFileSystemAccessManager"; import { ToolManager } from "./toolsManager"; /** @@ -30,6 +31,7 @@ export class ToolWindowManager { private settingsManager: SettingsManager; private toolManager: ToolManager; private terminalManager: TerminalManager; + private toolFilesystemAccessManager: ToolFileSystemAccessManager; /** * Maps tool instanceId (NOT toolId) to BrowserView. * @@ -65,6 +67,7 @@ export class ToolWindowManager { settingsManager: SettingsManager, toolManager: ToolManager, terminalManager: TerminalManager, + toolFilesystemAccessManager: ToolFileSystemAccessManager, ) { this.mainWindow = mainWindow; this.browserviewProtocolManager = browserviewProtocolManager; @@ -72,6 +75,7 @@ export class ToolWindowManager { this.settingsManager = settingsManager; this.toolManager = toolManager; this.terminalManager = terminalManager; + this.toolFilesystemAccessManager = toolFilesystemAccessManager; this.boundsResponseListener = (event, bounds) => { if (bounds && bounds.width > 0 && bounds.height > 0) { @@ -394,6 +398,9 @@ export class ToolWindowManager { // Dispose any terminals created by this tool instance this.terminalManager.closeToolInstanceTerminals(instanceId); + // Revoke filesystem access for this specific tool instance + this.toolFilesystemAccessManager.revokeAllAccess(instanceId); + logInfo(`[ToolWindowManager] Tool instance closed: ${instanceId}`); return true; } catch (error) { @@ -439,6 +446,38 @@ export class ToolWindowManager { return null; } + /** + * Get the instanceId for a tool instance by its WebContents + * This is used for per-instance operations like filesystem access control + * @param webContentsId The ID of the WebContents making the request + * @returns The instanceId or null if not found (null means it's from main window, not a tool) + */ + getInstanceIdByWebContents(webContentsId: number): string | null { + // Find the instance that owns this WebContents + for (const [instanceId, toolView] of this.toolViews.entries()) { + if (toolView.webContents.id === webContentsId) { + return instanceId; + } + } + // Not a tool window - likely the main window + return null; + } + + /** + * Get the toolId for a tool instance by its WebContents + * This is used for tool-scoped operations + * @param webContentsId The ID of the WebContents making the request + * @returns The toolId or null if not found (null means it's from main window, not a tool) + */ + getToolIdByWebContents(webContentsId: number): string | null { + const instanceId = this.getInstanceIdByWebContents(webContentsId); + if (!instanceId) { + return null; + } + // Extract toolId from instanceId (format: toolId-timestamp-random) + return instanceId.split("-").slice(0, -2).join("-"); + } + /** * Update the bounds of the active tool view to match the tool panel area * Bounds are calculated dynamically based on actual DOM element positions From 5d402871a9f5cce98e378daf375668ae265615b7 Mon Sep 17 00:00:00 2001 From: Power-Maverick Date: Sun, 15 Feb 2026 22:48:15 -0500 Subject: [PATCH 018/257] fix: update force check-in timestamp in README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e854ea9b..c9101d10 100644 --- a/README.md +++ b/README.md @@ -258,4 +258,4 @@ Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for gui This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! - + From aa7fc24d9b424d102e4f2ca5b8f6d0e41416dfa3 Mon Sep 17 00:00:00 2001 From: Power-Maverick Date: Sun, 15 Feb 2026 23:11:23 -0500 Subject: [PATCH 019/257] fix: calculate and log SHA256 and SHA512 hashes in release workflows --- .github/workflows/nightly-release.yml | 42 ++++++++++++++++----------- .github/workflows/prod-release.yml | 42 ++++++++++++++++----------- 2 files changed, 50 insertions(+), 34 deletions(-) diff --git a/.github/workflows/nightly-release.yml b/.github/workflows/nightly-release.yml index 583f3b15..c685629b 100644 --- a/.github/workflows/nightly-release.yml +++ b/.github/workflows/nightly-release.yml @@ -201,11 +201,13 @@ jobs: Write-Host "Using EXE: $($mainExe.Name)" - # Calculate SHA256 hash - $hash = (Get-FileHash -Path $mainExe.FullName -Algorithm SHA256).Hash.ToLower() + # Calculate SHA256 and SHA512 hashes + $sha256 = (Get-FileHash -Path $mainExe.FullName -Algorithm SHA256).Hash.ToLower() + $sha512 = (Get-FileHash -Path $mainExe.FullName -Algorithm SHA512).Hash.ToLower() $size = $mainExe.Length - Write-Host "SHA256: $hash" + Write-Host "SHA256: $sha256" + Write-Host "SHA512: $sha512" Write-Host "Size: $size" # Create new YAML content with correct hashes @@ -215,8 +217,8 @@ jobs: files = @( @{ url = $mainExe.Name - sha512 = $null - sha256 = $hash + sha512 = $sha512 + sha256 = $sha256 size = $size blockMapSize = $null } @@ -227,8 +229,8 @@ jobs: $newYmlContent = "version: $version`n" $newYmlContent += "files:`n" $newYmlContent += " - url: $($mainExe.Name)`n" - $newYmlContent += " sha512: null`n" - $newYmlContent += " sha256: $hash`n" + $newYmlContent += " sha512: $sha512`n" + $newYmlContent += " sha256: $sha256`n" $newYmlContent += " size: $size`n" $newYmlContent += " blockMapSize: null`n" $newYmlContent += "releaseDate: $releaseDate" @@ -399,18 +401,21 @@ jobs: if [[ -n "$APP_IMAGE" && -f "$APP_IMAGE" ]]; then echo " Found AppImage: $(basename "$APP_IMAGE")" - # Calculate SHA256 hash - HASH=$(sha256sum "$APP_IMAGE" | awk '{print $1}') + # Calculate SHA256 and SHA512 hashes + HASH256=$(sha256sum "$APP_IMAGE" | awk '{print $1}') + HASH512=$(sha512sum "$APP_IMAGE" | awk '{print $1}') SIZE=$(stat -c %s "$APP_IMAGE" 2>/dev/null || stat -f %z "$APP_IMAGE" 2>/dev/null) - echo " SHA256: $HASH" + echo " SHA256: $HASH256" + echo " SHA512: $HASH512" echo " Size: $SIZE" # Create new YAML with correct hashes using printf to avoid YAML parsing issues - printf "version: %s\nfiles:\n - url: %s\n sha512: null\n sha256: %s\n size: %s\n blockMapSize: null\nreleaseDate: %s\n" \ + printf "version: %s\nfiles:\n - url: %s\n sha512: %s\n sha256: %s\n size: %s\n blockMapSize: null\nreleaseDate: %s\n" \ "$VERSION" \ "$(basename "$APP_IMAGE")" \ - "$HASH" \ + "$HASH512" \ + "$HASH256" \ "$SIZE" \ "$(date -u +'%Y-%m-%dT%H:%M:%S.000Z')" > "$YML_FILE" echo " ✅ Updated $YML_FILE" @@ -632,18 +637,21 @@ jobs: if [[ -n "$DMG_FILE" && -f "$DMG_FILE" ]]; then echo " Found DMG: $(basename "$DMG_FILE")" - # Calculate SHA256 hash of the stapled DMG - HASH=$(shasum -a 256 "$DMG_FILE" | awk '{print $1}') + # Calculate SHA256 and SHA512 hashes of the stapled DMG + HASH256=$(shasum -a 256 "$DMG_FILE" | awk '{print $1}') + HASH512=$(shasum -a 512 "$DMG_FILE" | awk '{print $1}') SIZE=$(stat -f '%z' "$DMG_FILE" 2>/dev/null || stat -c '%s' "$DMG_FILE" 2>/dev/null) - echo " SHA256: $HASH" + echo " SHA256: $HASH256" + echo " SHA512: $HASH512" echo " Size: $SIZE" # Create new YAML with correct hashes using printf to avoid YAML parsing issues - printf "version: %s\nfiles:\n - url: %s\n sha512: null\n sha256: %s\n size: %s\n blockMapSize: null\nreleaseDate: %s\n" \ + printf "version: %s\nfiles:\n - url: %s\n sha512: %s\n sha256: %s\n size: %s\n blockMapSize: null\nreleaseDate: %s\n" \ "$VERSION" \ "$(basename "$DMG_FILE")" \ - "$HASH" \ + "$HASH512" \ + "$HASH256" \ "$SIZE" \ "$(date -u +'%Y-%m-%dT%H:%M:%S.000Z')" > "$YML_FILE" echo " ✅ Updated $YML_FILE" diff --git a/.github/workflows/prod-release.yml b/.github/workflows/prod-release.yml index 3f977381..095ed43d 100644 --- a/.github/workflows/prod-release.yml +++ b/.github/workflows/prod-release.yml @@ -205,11 +205,13 @@ jobs: Write-Host "Using EXE: $($mainExe.Name)" - # Calculate SHA256 hash - $hash = (Get-FileHash -Path $mainExe.FullName -Algorithm SHA256).Hash.ToLower() + # Calculate SHA256 and SHA512 hashes + $sha256 = (Get-FileHash -Path $mainExe.FullName -Algorithm SHA256).Hash.ToLower() + $sha512 = (Get-FileHash -Path $mainExe.FullName -Algorithm SHA512).Hash.ToLower() $size = $mainExe.Length - Write-Host "SHA256: $hash" + Write-Host "SHA256: $sha256" + Write-Host "SHA512: $sha512" Write-Host "Size: $size" # Create new YAML content with correct hashes @@ -219,8 +221,8 @@ jobs: files = @( @{ url = $mainExe.Name - sha512 = $null - sha256 = $hash + sha512 = $sha512 + sha256 = $sha256 size = $size blockMapSize = $null } @@ -231,8 +233,8 @@ jobs: $newYmlContent = "version: $version`n" $newYmlContent += "files:`n" $newYmlContent += " - url: $($mainExe.Name)`n" - $newYmlContent += " sha512: null`n" - $newYmlContent += " sha256: $hash`n" + $newYmlContent += " sha512: $sha512`n" + $newYmlContent += " sha256: $sha256`n" $newYmlContent += " size: $size`n" $newYmlContent += " blockMapSize: null`n" $newYmlContent += "releaseDate: $releaseDate" @@ -403,18 +405,21 @@ jobs: if [[ -n "$APP_IMAGE" && -f "$APP_IMAGE" ]]; then echo " Found AppImage: $(basename "$APP_IMAGE")" - # Calculate SHA256 hash - HASH=$(sha256sum "$APP_IMAGE" | awk '{print $1}') + # Calculate SHA256 and SHA512 hashes + HASH256=$(sha256sum "$APP_IMAGE" | awk '{print $1}') + HASH512=$(sha512sum "$APP_IMAGE" | awk '{print $1}') SIZE=$(stat -c %s "$APP_IMAGE" 2>/dev/null || stat -f %z "$APP_IMAGE" 2>/dev/null) - echo " SHA256: $HASH" + echo " SHA256: $HASH256" + echo " SHA512: $HASH512" echo " Size: $SIZE" # Create new YAML with correct hashes using printf to avoid YAML parsing issues - printf "version: %s\nfiles:\n - url: %s\n sha512: null\n sha256: %s\n size: %s\n blockMapSize: null\nreleaseDate: %s\n" \ + printf "version: %s\nfiles:\n - url: %s\n sha512: %s\n sha256: %s\n size: %s\n blockMapSize: null\nreleaseDate: %s\n" \ "$VERSION" \ "$(basename "$APP_IMAGE")" \ - "$HASH" \ + "$HASH512" \ + "$HASH256" \ "$SIZE" \ "$(date -u +'%Y-%m-%dT%H:%M:%S.000Z')" > "$YML_FILE" echo " ✅ Updated $YML_FILE" @@ -585,18 +590,21 @@ jobs: if [[ -n "$DMG_FILE" && -f "$DMG_FILE" ]]; then echo " Found DMG: $(basename "$DMG_FILE")" - # Calculate SHA256 hash of the stapled DMG - HASH=$(shasum -a 256 "$DMG_FILE" | awk '{print $1}') + # Calculate SHA256 and SHA512 hashes of the stapled DMG + HASH256=$(shasum -a 256 "$DMG_FILE" | awk '{print $1}') + HASH512=$(shasum -a 512 "$DMG_FILE" | awk '{print $1}') SIZE=$(stat -f '%z' "$DMG_FILE" 2>/dev/null || stat -c '%s' "$DMG_FILE" 2>/dev/null) - echo " SHA256: $HASH" + echo " SHA256: $HASH256" + echo " SHA512: $HASH512" echo " Size: $SIZE" # Create new YAML with correct hashes using printf to avoid YAML parsing issues - printf "version: %s\nfiles:\n - url: %s\n sha512: null\n sha256: %s\n size: %s\n blockMapSize: null\nreleaseDate: %s\n" \ + printf "version: %s\nfiles:\n - url: %s\n sha512: %s\n sha256: %s\n size: %s\n blockMapSize: null\nreleaseDate: %s\n" \ "$VERSION" \ "$(basename "$DMG_FILE")" \ - "$HASH" \ + "$HASH512" \ + "$HASH256" \ "$SIZE" \ "$(date -u +'%Y-%m-%dT%H:%M:%S.000Z')" > "$YML_FILE" echo " ✅ Updated $YML_FILE" From 397ef022f22830ce589c00e6d5da35d83e232056 Mon Sep 17 00:00:00 2001 From: Power-Maverick Date: Wed, 18 Feb 2026 16:31:54 -0500 Subject: [PATCH 020/257] chore: update RELEASE_NOTES.md for version 1.1.3 with highlights and fixes --- RELEASE_NOTES.md | 51 ++++++++++++++++++++++++------------------------ 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 53bc939b..319b6146 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,45 +1,44 @@ -# Power Platform ToolBox 1.1.2 +# Power Platform ToolBox 1.1.3 ## Highlights -- MSAL-based authentication isolates tokens per connection and validates access with WhoAmI for more reliable sign-in -- Troubleshooting modal runs configuration checks and surfaces Sentry diagnostics to speed up support and debugging -- Tool updates show inline progress and accessible status feedback while tools are updating -- Terminal UI hides the Terminal button when no terminals exist and includes additional terminal reliability improvements -- Tool menu adds dynamic feedback and quick DevTools options for tool developers -- Dataverse API expands with solution deployment/import status helpers and relationship associate/disassociate endpoints -- Windows and macOS release pipelines improve signing/notarization handling for more trustworthy installers +- Hardened tool filesystem sandbox so tools can only access user-selected paths and system directories are blocked +- Connection sign-in supports choosing Chrome/Edge plus a specific browser profile to better isolate sessions per connection +- Signed Windows installers (EXE/MSI) via Azure Trusted Signing and repackaged portable ZIPs with signed binaries +- Release metadata now records correct SHA256 and SHA512 hashes for stronger artifact integrity verification +- macOS release pipeline notarizes and staples DMG/ZIP/PKG artifacts with improved signing verification steps +- Dataverse API adds metadata CRUD operations and a `getCSDLDocument` helper for retrieving the OData CSDL document +- Save dialogs support optional file-type filters with extension-based default filter derivation +- Loading overlay positioning is fixed and includes a manual dismiss button ## Fixes -- Dataverse Functions now format parameters correctly, avoiding invocation failures -- Packaged app avoids `ERR_REQUIRE_ESM` issues by properly handling externalized telemetry dependencies -- Modal dialogs no longer remain always-on-top after closing on Windows 11 -- Connection context menu no longer renders behind BrowserViews -- Settings form populates correctly on app reload and avoids duplicate IPC handler registration on macOS window recreation -- macOS notarization scripts handle missing modules/unavailable submission logs and clarify submission/status output -- Authentication token reuse/refresh reduces unexpected expiry prompts with proactive refresh and expiry detection +- Connections: hardened auth/session isolation to reduce cross-connection token and browser profile leakage +- macOS notarization and stapling no longer skips artifacts and handles unavailable submission logs more reliably +- macOS code signing verification avoids premature `spctl --assess` failures before notarization/stapling completes +- Release workflows regenerate Windows update metadata with correct SHA256/SHA512 after signing +- Tool filesystem reads/writes now enforce explicit user-consent access and reject unsafe/system paths +- Connection and toolbox API handling is more robust for multi-connection scenarios and updated connection fields +- Release workflow date formatting is consistent across jobs and platforms ## Developer & Build -- Telemetry identifiers switch from machine ID to install ID for privacy-safe, stable analytics -- Windows packaging adds ARM64 support, MSI targets, and refactored electron-builder configurations -- macOS signing/notarization workflows add submission/status retrieval steps and improved error handling -- `dataverseAPI` types add `deploySolution`, `getImportJobStatus`, and `associate`/`disassociate` helpers -- `toolboxAPI` adds a `fileSystem` API set (path validation + updated publish/selectPath flows) -- Sentry logging helpers and noise reduction improve production diagnostics signal-to-noise +- `dataverseAPI` types expand with metadata CRUD operations and `getCSDLDocument` +- `toolboxAPI.fileSystem.saveFile` supports filters and derives defaults from filename extensions +- Added `BrowserManager` for browser detection and profile enumeration used by interactive auth flows +- Signing/notarization scripts and workflows improved for multi-artifact pipelines and better diagnostics ## Install -- Windows: Power-Platform-ToolBox-1.1.2-Setup.exe -- macOS: Power-Platform-ToolBox-1.1.2.dmg (drag to Applications) -- Linux: Power-Platform-ToolBox-1.1.2.AppImage (chmod +x, then run) +- Windows: Power-Platform-ToolBox-1.1.3-Setup.exe +- macOS: Power-Platform-ToolBox-1.1.3.dmg (drag to Applications) +- Linux: Power-Platform-ToolBox-1.1.3.AppImage (chmod +x, then run) ## Notes - No manual migration needed; existing settings and connections continue to work. -- You may be prompted to sign in again after upgrading because stored tokens and MSAL instances are cleared on app restart. +- Tool developers: filesystem reads/writes now require `toolboxAPI.fileSystem.selectPath()` or `saveFile()` to grant access. ## Full Changelog -https://github.com/PowerPlatformToolBox/desktop-app/compare/v1.1.1...v1.1.2 +https://github.com/PowerPlatformToolBox/desktop-app/compare/v1.1.2...v1.1.3 From 55167b92ab3e6b940329751fe88aa1165d4478cf Mon Sep 17 00:00:00 2001 From: Power-Maverick Date: Wed, 18 Feb 2026 20:48:54 -0500 Subject: [PATCH 021/257] fix: update version to 1.0.20 in package.json --- packages/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/package.json b/packages/package.json index 05edf8a6..982c5738 100644 --- a/packages/package.json +++ b/packages/package.json @@ -1,6 +1,6 @@ { "name": "@pptb/types", - "version": "1.0.19", + "version": "1.0.20", "description": "TypeScript type definitions for Power Platform ToolBox API", "main": "index.d.ts", "types": "index.d.ts", @@ -25,4 +25,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 +} From e67b760d3af6f0524e02b02973535e8d656192d5 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 09:20:12 -0500 Subject: [PATCH 022/257] Fix Windows and macOS auto-update downloading wrong architecture installer (#397) * Initial plan * Add Windows multi-architecture support to latest.yml generation Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Apply Windows YAML merge fix to nightly-release workflow Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Add standalone script to generate merged Windows latest.yml Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Add buildScripts/latest.yml to gitignore and remove example file Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Add macOS multi-architecture support to latest-mac.yml generation Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Remove legacy build scripts and add new merge scripts for Windows and macOS - Deleted old merge scripts: merge-macos-latest-yml.sh, merge-windows-latest-yml.sh, test-macos-build.sh, verify-build.sh - Added new merge scripts for Windows and macOS with improved functionality and documentation. - Created README for merge scripts detailing usage and troubleshooting. - Implemented new test and verification scripts for macOS builds to ensure proper packaging and structure. * fix: correct path to verify-build.sh script in build workflow * Update buildScripts/sh/merge-latest-yml.sh Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update .github/workflows/prod-release.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update .github/workflows/nightly-release.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> Co-authored-by: Power-Maverick Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/build.yml | 2 +- .github/workflows/nightly-release.yml | 215 +++++++++++++---- .github/workflows/prod-release.yml | 215 +++++++++++++---- .gitignore | 4 + .../sh/README-merge-windows-latest-yml.md | 221 ++++++++++++++++++ buildScripts/sh/merge-latest-yml.sh | 169 ++++++++++++++ buildScripts/sh/merge-macos-latest-yml.sh | 179 ++++++++++++++ buildScripts/{ => sh}/test-macos-build.sh | 0 buildScripts/{ => sh}/verify-build.sh | 0 9 files changed, 918 insertions(+), 87 deletions(-) create mode 100644 buildScripts/sh/README-merge-windows-latest-yml.md create mode 100755 buildScripts/sh/merge-latest-yml.sh create mode 100755 buildScripts/sh/merge-macos-latest-yml.sh rename buildScripts/{ => sh}/test-macos-build.sh (100%) rename buildScripts/{ => sh}/verify-build.sh (100%) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 10eaf713..5b43d9bd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -57,6 +57,6 @@ jobs: - name: Verify build output run: | if [ "$RUNNER_OS" != "Windows" ]; then - bash buildScripts/verify-build.sh + bash buildScripts/sh/verify-build.sh fi shell: bash diff --git a/.github/workflows/nightly-release.yml b/.github/workflows/nightly-release.yml index c685629b..0c667436 100644 --- a/.github/workflows/nightly-release.yml +++ b/.github/workflows/nightly-release.yml @@ -615,51 +615,94 @@ jobs: - name: Regenerate latest-mac.yml with correct SHA256 hashes shell: bash run: | - echo "🔄 Regenerating latest-mac.yml with stapled artifact hashes..." - - # Find all YAML files - YML_FILES=$(find notarize -name "latest*.yml" -o -name "*-mac.yml") - - if [[ -z "$YML_FILES" ]]; then - echo "⚠️ No YAML files found, skipping regeneration" - exit 0 - fi - - for YML_FILE in $YML_FILES; do - echo "Processing: $YML_FILE" - - # Extract version from existing YAML - VERSION=$(grep -oP 'version:\s+\K[^\s]+' "$YML_FILE" || echo "unknown") - - # Find the stapled DMG file in the same directory - DMG_FILE=$(find "$(dirname "$YML_FILE")" -maxdepth 1 -name "*.dmg" | head -n 1) - - if [[ -n "$DMG_FILE" && -f "$DMG_FILE" ]]; then - echo " Found DMG: $(basename "$DMG_FILE")" - - # Calculate SHA256 and SHA512 hashes of the stapled DMG - HASH256=$(shasum -a 256 "$DMG_FILE" | awk '{print $1}') - HASH512=$(shasum -a 512 "$DMG_FILE" | awk '{print $1}') - SIZE=$(stat -f '%z' "$DMG_FILE" 2>/dev/null || stat -c '%s' "$DMG_FILE" 2>/dev/null) + echo "🔄 Merging macOS x64 and ARM64 latest-mac.yml files..." + + # Find all DMG files + X64_DMG=$(find notarize -name "*-x64-mac.dmg" -type f | head -n 1) + ARM64_DMG=$(find notarize -name "*-arm64-mac.dmg" -type f | head -n 1) + + if [[ -z "$X64_DMG" || -z "$ARM64_DMG" ]]; then + echo "⚠️ One or both macOS DMG files not found. Skipping merge." + echo "X64_DMG: $X64_DMG" + echo "ARM64_DMG: $ARM64_DMG" - echo " SHA256: $HASH256" - echo " SHA512: $HASH512" - echo " Size: $SIZE" - - # Create new YAML with correct hashes using printf to avoid YAML parsing issues - printf "version: %s\nfiles:\n - url: %s\n sha512: %s\n sha256: %s\n size: %s\n blockMapSize: null\nreleaseDate: %s\n" \ - "$VERSION" \ - "$(basename "$DMG_FILE")" \ - "$HASH512" \ - "$HASH256" \ - "$SIZE" \ - "$(date -u +'%Y-%m-%dT%H:%M:%S.000Z')" > "$YML_FILE" - echo " ✅ Updated $YML_FILE" - else - echo " ⚠️ No DMG found in $(dirname "$YML_FILE")" - fi + # Fallback to single architecture (original behavior) + YML_FILES=$(find notarize -name "latest*.yml" -o -name "*-mac.yml") + if [[ -n "$YML_FILES" ]]; then + for YML_FILE in $YML_FILES; do + VERSION=$(grep -oP 'version:\s+\K[^\s]+' "$YML_FILE" || echo "unknown") + DMG_FILE=$(find "$(dirname "$YML_FILE")" -maxdepth 1 -name "*.dmg" | head -n 1) + if [[ -n "$DMG_FILE" && -f "$DMG_FILE" ]]; then + HASH256=$(shasum -a 256 "$DMG_FILE" | awk '{print $1}') + HASH512=$(shasum -a 512 "$DMG_FILE" | awk '{print $1}') + SIZE=$(stat -f '%z' "$DMG_FILE" 2>/dev/null || stat -c '%s' "$DMG_FILE" 2>/dev/null) + printf "version: %s\nfiles:\n - url: %s\n sha512: %s\n sha256: %s\n size: %s\n blockMapSize: null\nreleaseDate: %s\n" \ + "$VERSION" "$(basename "$DMG_FILE")" "$HASH512" "$HASH256" "$SIZE" "$(date -u +'%Y-%m-%dT%H:%M:%S.000Z')" > "$YML_FILE" + fi + done + fi + exit 0 + fi + + echo "Found X64 DMG: $(basename "$X64_DMG")" + echo "Found ARM64 DMG: $(basename "$ARM64_DMG")" + + # Find any existing YAML to extract version + EXISTING_YML=$(find notarize -name "latest*.yml" -o -name "*-mac.yml" | head -n 1) + VERSION=$(awk '/^version:/{print $2; exit}' "$EXISTING_YML" 2>/dev/null || echo "unknown") + + echo "Version: $VERSION" + + # Calculate hashes for x64 + echo "🔐 Calculating hashes for x64 DMG..." + X64_SHA256=$(shasum -a 256 "$X64_DMG" | awk '{print $1}') + X64_SHA512=$(shasum -a 512 "$X64_DMG" | awk '{print $1}') + X64_SIZE=$(stat -f '%z' "$X64_DMG" 2>/dev/null || stat -c '%s' "$X64_DMG" 2>/dev/null) + + echo " SHA256: $X64_SHA256" + echo " SHA512: $X64_SHA512" + echo " Size: $X64_SIZE" + + # Calculate hashes for ARM64 + echo "🔐 Calculating hashes for ARM64 DMG..." + ARM64_SHA256=$(shasum -a 256 "$ARM64_DMG" | awk '{print $1}') + ARM64_SHA512=$(shasum -a 512 "$ARM64_DMG" | awk '{print $1}') + ARM64_SIZE=$(stat -f '%z' "$ARM64_DMG" 2>/dev/null || stat -c '%s' "$ARM64_DMG" 2>/dev/null) + + echo " SHA256: $ARM64_SHA256" + echo " SHA512: $ARM64_SHA512" + echo " Size: $ARM64_SIZE" + + # Create merged YAML with both architectures + MERGED_YML="notarize/latest-mac.yml" + cat > "$MERGED_YML" << EOF + version: $VERSION + files: + - url: $(basename "$X64_DMG") + sha512: $X64_SHA512 + sha256: $X64_SHA256 + size: $X64_SIZE + blockMapSize: null + - url: $(basename "$ARM64_DMG") + sha512: $ARM64_SHA512 + sha256: $ARM64_SHA256 + size: $ARM64_SIZE + blockMapSize: null + releaseDate: $(date -u +'%Y-%m-%dT%H:%M:%S.000Z') + EOF + + echo "" + echo "✅ Merged latest-mac.yml created:" + cat "$MERGED_YML" + + # Remove any other YAML files in subdirectories to prevent conflicts + find notarize -name "latest*.yml" -o -name "*-mac.yml" | while read yml; do + if [[ "$yml" != "$MERGED_YML" ]]; then + rm -f "$yml" + echo "Removed: $yml" + fi done - + echo "✅ Regeneration complete" - name: Upload stapled macOS artifacts @@ -695,11 +738,97 @@ jobs: - name: Display structure of downloaded files run: ls -R artifacts + - name: Merge Windows latest.yml files + run: | + echo "🔄 Merging Windows x64 and ARM64 latest.yml files..." + + # Find the Windows YAML files + X64_YML=$(find artifacts/windows-x64-build -name "latest.yml" 2>/dev/null || echo "") + ARM64_YML=$(find artifacts/windows-arm64-build -name "latest.yml" 2>/dev/null || echo "") + + if [[ -z "$X64_YML" || -z "$ARM64_YML" ]]; then + echo "⚠️ One or both Windows YAML files not found. Skipping merge." + echo "X64_YML: $X64_YML" + echo "ARM64_YML: $ARM64_YML" + exit 0 + fi + + echo "Found X64 YAML: $X64_YML" + echo "Found ARM64 YAML: $ARM64_YML" + + # Extract version and release date from x64 YAML (should be the same for both) + VERSION=$(grep -oP 'version:\s+\K[^\s]+' "$X64_YML" || echo "unknown") + RELEASE_DATE=$(grep -oP 'releaseDate:\s+\K.+' "$X64_YML" || date -u +'%Y-%m-%dT%H:%M:%S.000Z') + + echo "Version: $VERSION" + echo "Release Date: $RELEASE_DATE" + + # Find the EXE files in each artifact directory + X64_EXE=$(find artifacts/windows-x64-build -name "*-x64-win.exe" -type f | head -n 1) + ARM64_EXE=$(find artifacts/windows-arm64-build -name "*-arm64-win.exe" -type f | head -n 1) + + if [[ -z "$X64_EXE" || -z "$ARM64_EXE" ]]; then + echo "❌ Could not find both x64 and ARM64 EXE files" + echo "X64_EXE: $X64_EXE" + echo "ARM64_EXE: $ARM64_EXE" + exit 1 + fi + + echo "Found X64 EXE: $(basename "$X64_EXE")" + echo "Found ARM64 EXE: $(basename "$ARM64_EXE")" + + # Calculate hashes for x64 + X64_SHA256=$(sha256sum "$X64_EXE" | awk '{print $1}') + X64_SHA512=$(sha512sum "$X64_EXE" | awk '{print $1}') + X64_SIZE=$(stat -c %s "$X64_EXE" 2>/dev/null || stat -f %z "$X64_EXE" 2>/dev/null) + + echo "X64 SHA256: $X64_SHA256" + echo "X64 SHA512: $X64_SHA512" + echo "X64 Size: $X64_SIZE" + + # Calculate hashes for ARM64 + ARM64_SHA256=$(sha256sum "$ARM64_EXE" | awk '{print $1}') + ARM64_SHA512=$(sha512sum "$ARM64_EXE" | awk '{print $1}') + ARM64_SIZE=$(stat -c %s "$ARM64_EXE" 2>/dev/null || stat -f %z "$ARM64_EXE" 2>/dev/null) + + echo "ARM64 SHA256: $ARM64_SHA256" + echo "ARM64 SHA512: $ARM64_SHA512" + echo "ARM64 Size: $ARM64_SIZE" + + # Create merged YAML with both architectures + MERGED_YML="artifacts/latest.yml" + cat > "$MERGED_YML" << EOF + version: $VERSION + files: + - url: $(basename "$X64_EXE") + sha512: $X64_SHA512 + sha256: $X64_SHA256 + size: $X64_SIZE + blockMapSize: null + - url: $(basename "$ARM64_EXE") + sha512: $ARM64_SHA512 + sha256: $ARM64_SHA256 + size: $ARM64_SIZE + blockMapSize: null + releaseDate: $RELEASE_DATE + EOF + + echo "" + echo "✅ Merged latest.yml created:" + cat "$MERGED_YML" + + # Remove the individual YAML files so they don't get uploaded + rm -f "$X64_YML" "$ARM64_YML" + echo "" + echo "✅ Individual YAML files removed" + shell: bash + - name: Prepare release files run: | echo "📦 Preparing release files..." mkdir -p release-files + # Copy all release artifacts (excluding individual Windows YAML files which were removed) find artifacts -type f \( -name "*.AppImage" -o -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.msi" -o -name "*.pkg" -o -name "*.snap" -o -name "*.deb" -o -name "*.rpm" -o -name "latest*.yml" \) -exec cp {} release-files/ \; echo "✅ Release files prepared" diff --git a/.github/workflows/prod-release.yml b/.github/workflows/prod-release.yml index 095ed43d..c9d84fb0 100644 --- a/.github/workflows/prod-release.yml +++ b/.github/workflows/prod-release.yml @@ -568,51 +568,94 @@ jobs: - name: Regenerate latest-mac.yml with correct SHA256 hashes shell: bash run: | - echo "🔄 Regenerating latest-mac.yml with stapled artifact hashes..." - - # Find all YAML files - YML_FILES=$(find notarize -name "latest*.yml" -o -name "*-mac.yml") - - if [[ -z "$YML_FILES" ]]; then - echo "⚠️ No YAML files found, skipping regeneration" - exit 0 - fi - - for YML_FILE in $YML_FILES; do - echo "Processing: $YML_FILE" - - # Extract version from existing YAML - VERSION=$(grep -oP 'version:\s+\K[^\s]+' "$YML_FILE" || echo "unknown") - - # Find the stapled DMG file in the same directory - DMG_FILE=$(find "$(dirname "$YML_FILE")" -maxdepth 1 -name "*.dmg" | head -n 1) - - if [[ -n "$DMG_FILE" && -f "$DMG_FILE" ]]; then - echo " Found DMG: $(basename "$DMG_FILE")" - - # Calculate SHA256 and SHA512 hashes of the stapled DMG - HASH256=$(shasum -a 256 "$DMG_FILE" | awk '{print $1}') - HASH512=$(shasum -a 512 "$DMG_FILE" | awk '{print $1}') - SIZE=$(stat -f '%z' "$DMG_FILE" 2>/dev/null || stat -c '%s' "$DMG_FILE" 2>/dev/null) + echo "🔄 Merging macOS x64 and ARM64 latest-mac.yml files..." + + # Find all DMG files + X64_DMG=$(find notarize -name "*-x64-mac.dmg" -type f | head -n 1) + ARM64_DMG=$(find notarize -name "*-arm64-mac.dmg" -type f | head -n 1) + + if [[ -z "$X64_DMG" || -z "$ARM64_DMG" ]]; then + echo "⚠️ One or both macOS DMG files not found. Skipping merge." + echo "X64_DMG: $X64_DMG" + echo "ARM64_DMG: $ARM64_DMG" - echo " SHA256: $HASH256" - echo " SHA512: $HASH512" - echo " Size: $SIZE" - - # Create new YAML with correct hashes using printf to avoid YAML parsing issues - printf "version: %s\nfiles:\n - url: %s\n sha512: %s\n sha256: %s\n size: %s\n blockMapSize: null\nreleaseDate: %s\n" \ - "$VERSION" \ - "$(basename "$DMG_FILE")" \ - "$HASH512" \ - "$HASH256" \ - "$SIZE" \ - "$(date -u +'%Y-%m-%dT%H:%M:%S.000Z')" > "$YML_FILE" - echo " ✅ Updated $YML_FILE" - else - echo " ⚠️ No DMG found in $(dirname "$YML_FILE")" - fi + # Fallback to single architecture (original behavior) + YML_FILES=$(find notarize -name "latest*.yml" -o -name "*-mac.yml") + if [[ -n "$YML_FILES" ]]; then + for YML_FILE in $YML_FILES; do + VERSION=$(grep -oP 'version:\s+\K[^\s]+' "$YML_FILE" || echo "unknown") + DMG_FILE=$(find "$(dirname "$YML_FILE")" -maxdepth 1 -name "*.dmg" | head -n 1) + if [[ -n "$DMG_FILE" && -f "$DMG_FILE" ]]; then + HASH256=$(shasum -a 256 "$DMG_FILE" | awk '{print $1}') + HASH512=$(shasum -a 512 "$DMG_FILE" | awk '{print $1}') + SIZE=$(stat -f '%z' "$DMG_FILE" 2>/dev/null || stat -c '%s' "$DMG_FILE" 2>/dev/null) + printf "version: %s\nfiles:\n - url: %s\n sha512: %s\n sha256: %s\n size: %s\n blockMapSize: null\nreleaseDate: %s\n" \ + "$VERSION" "$(basename "$DMG_FILE")" "$HASH512" "$HASH256" "$SIZE" "$(date -u +'%Y-%m-%dT%H:%M:%S.000Z')" > "$YML_FILE" + fi + done + fi + exit 0 + fi + + echo "Found X64 DMG: $(basename "$X64_DMG")" + echo "Found ARM64 DMG: $(basename "$ARM64_DMG")" + + # Find any existing YAML to extract version + EXISTING_YML=$(find notarize -name "latest*.yml" -o -name "*-mac.yml" | head -n 1) + VERSION=$(if [ -n "$EXISTING_YML" ]; then awk '/^version:[[:space:]]*/ {print $2; exit}' "$EXISTING_YML"; fi 2>/dev/null || echo "unknown") + + echo "Version: $VERSION" + + # Calculate hashes for x64 + echo "🔐 Calculating hashes for x64 DMG..." + X64_SHA256=$(shasum -a 256 "$X64_DMG" | awk '{print $1}') + X64_SHA512=$(shasum -a 512 "$X64_DMG" | awk '{print $1}') + X64_SIZE=$(stat -f '%z' "$X64_DMG" 2>/dev/null || stat -c '%s' "$X64_DMG" 2>/dev/null) + + echo " SHA256: $X64_SHA256" + echo " SHA512: $X64_SHA512" + echo " Size: $X64_SIZE" + + # Calculate hashes for ARM64 + echo "🔐 Calculating hashes for ARM64 DMG..." + ARM64_SHA256=$(shasum -a 256 "$ARM64_DMG" | awk '{print $1}') + ARM64_SHA512=$(shasum -a 512 "$ARM64_DMG" | awk '{print $1}') + ARM64_SIZE=$(stat -f '%z' "$ARM64_DMG" 2>/dev/null || stat -c '%s' "$ARM64_DMG" 2>/dev/null) + + echo " SHA256: $ARM64_SHA256" + echo " SHA512: $ARM64_SHA512" + echo " Size: $ARM64_SIZE" + + # Create merged YAML with both architectures + MERGED_YML="notarize/latest-mac.yml" + cat > "$MERGED_YML" << EOF + version: $VERSION + files: + - url: $(basename "$X64_DMG") + sha512: $X64_SHA512 + sha256: $X64_SHA256 + size: $X64_SIZE + blockMapSize: null + - url: $(basename "$ARM64_DMG") + sha512: $ARM64_SHA512 + sha256: $ARM64_SHA256 + size: $ARM64_SIZE + blockMapSize: null + releaseDate: $(date -u +'%Y-%m-%dT%H:%M:%S.000Z') + EOF + + echo "" + echo "✅ Merged latest-mac.yml created:" + cat "$MERGED_YML" + + # Remove any other YAML files in subdirectories to prevent conflicts + find notarize -name "latest*.yml" -o -name "*-mac.yml" | while read yml; do + if [[ "$yml" != "$MERGED_YML" ]]; then + rm -f "$yml" + echo "Removed: $yml" + fi done - + echo "✅ Regeneration complete" - name: Upload stapled macOS artifacts @@ -652,11 +695,97 @@ jobs: - name: Display structure of downloaded files run: ls -R artifacts + - name: Merge Windows latest.yml files + run: | + echo "🔄 Merging Windows x64 and ARM64 latest.yml files..." + + # Find the Windows YAML files + X64_YML=$(find artifacts/windows-x64-release -name "latest.yml" 2>/dev/null || echo "") + ARM64_YML=$(find artifacts/windows-arm64-release -name "latest.yml" 2>/dev/null || echo "") + + if [[ -z "$X64_YML" || -z "$ARM64_YML" ]]; then + echo "⚠️ One or both Windows YAML files not found. Skipping merge." + echo "X64_YML: $X64_YML" + echo "ARM64_YML: $ARM64_YML" + exit 0 + fi + + echo "Found X64 YAML: $X64_YML" + echo "Found ARM64 YAML: $ARM64_YML" + + # Extract version and release date from x64 YAML (should be the same for both) + VERSION=$(grep -oP 'version:\s+\K[^\s]+' "$X64_YML" || echo "unknown") + RELEASE_DATE=$(grep -oP 'releaseDate:\s+\K.+' "$X64_YML" || date -u +'%Y-%m-%dT%H:%M:%S.000Z') + + echo "Version: $VERSION" + echo "Release Date: $RELEASE_DATE" + + # Find the EXE files in each artifact directory + X64_EXE=$(find artifacts/windows-x64-release -name "*-x64-win.exe" -type f | head -n 1) + ARM64_EXE=$(find artifacts/windows-arm64-release -name "*-arm64-win.exe" -type f | head -n 1) + + if [[ -z "$X64_EXE" || -z "$ARM64_EXE" ]]; then + echo "❌ Could not find both x64 and ARM64 EXE files" + echo "X64_EXE: $X64_EXE" + echo "ARM64_EXE: $ARM64_EXE" + exit 1 + fi + + echo "Found X64 EXE: $(basename "$X64_EXE")" + echo "Found ARM64 EXE: $(basename "$ARM64_EXE")" + + # Calculate hashes for x64 + X64_SHA256=$(sha256sum "$X64_EXE" | awk '{print $1}') + X64_SHA512=$(sha512sum "$X64_EXE" | awk '{print $1}') + X64_SIZE=$(stat -c %s "$X64_EXE" 2>/dev/null || stat -f %z "$X64_EXE" 2>/dev/null) + + echo "X64 SHA256: $X64_SHA256" + echo "X64 SHA512: $X64_SHA512" + echo "X64 Size: $X64_SIZE" + + # Calculate hashes for ARM64 + ARM64_SHA256=$(sha256sum "$ARM64_EXE" | awk '{print $1}') + ARM64_SHA512=$(sha512sum "$ARM64_EXE" | awk '{print $1}') + ARM64_SIZE=$(stat -c %s "$ARM64_EXE" 2>/dev/null || stat -f %z "$ARM64_EXE" 2>/dev/null) + + echo "ARM64 SHA256: $ARM64_SHA256" + echo "ARM64 SHA512: $ARM64_SHA512" + echo "ARM64 Size: $ARM64_SIZE" + + # Create merged YAML with both architectures + MERGED_YML="artifacts/latest.yml" + cat > "$MERGED_YML" << EOF + version: $VERSION + files: + - url: $(basename "$X64_EXE") + sha512: $X64_SHA512 + sha256: $X64_SHA256 + size: $X64_SIZE + blockMapSize: null + - url: $(basename "$ARM64_EXE") + sha512: $ARM64_SHA512 + sha256: $ARM64_SHA256 + size: $ARM64_SIZE + blockMapSize: null + releaseDate: $RELEASE_DATE + EOF + + echo "" + echo "✅ Merged latest.yml created:" + cat "$MERGED_YML" + + # Remove the individual YAML files so they don't get uploaded + rm -f "$X64_YML" "$ARM64_YML" + echo "" + echo "✅ Individual YAML files removed" + shell: bash + - name: Prepare release files run: | echo "📦 Preparing release files..." mkdir -p release-files + # Copy all release artifacts (excluding individual Windows YAML files which were removed) find artifacts -type f \( -name "*.AppImage" -o -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.msi" -o -name "*.pkg" -o -name "*.snap" -o -name "*.deb" -o -name "*.rpm" -o -name "latest*.yml" \) -exec cp {} release-files/ \; echo "✅ Release files prepared" diff --git a/.gitignore b/.gitignore index bd1e54d3..d9f00490 100644 --- a/.gitignore +++ b/.gitignore @@ -148,6 +148,10 @@ dist/ package-lock.json .vscode/settings.json +# Generated YAML files from merge scripts (example outputs) +buildScripts/*/latest.yml +buildScripts/*/latest-mac.yml + # Any macOS Certificate files *.cer *.p12 diff --git a/buildScripts/sh/README-merge-windows-latest-yml.md b/buildScripts/sh/README-merge-windows-latest-yml.md new file mode 100644 index 00000000..01ed16c1 --- /dev/null +++ b/buildScripts/sh/README-merge-windows-latest-yml.md @@ -0,0 +1,221 @@ +# Merge Windows/macOS latest.yml Scripts + +## Purpose + +These scripts generate properly formatted update metadata files containing both x64 and ARM64 installers for electron-updater auto-update functionality. + +Use these when you need to manually fix a GitHub release's update files without re-running the entire build pipeline. + +## The Problem They Solve + +When Windows/macOS x64 and ARM64 builds upload separate YAML files, only one architecture is included in the final update metadata. This causes electron-updater to only see one architecture, leading to users downloading the wrong installer. + +The merged YAML files contain both architectures, allowing electron-updater to automatically select the correct one based on the user's system architecture. + +## Prerequisites + +- `bash` shell (Linux, macOS, WSL, or Git Bash on Windows) +- `curl` (for downloading from GitHub releases) +- `sha256sum` and `sha512sum` (for hash calculation on Linux) +- `shasum` (for hash calculation on macOS) +- `gh` CLI (optional, for uploading to GitHub) + +## Available Scripts + +### Windows: `merge-windows-latest-yml.sh` + +Generates `latest.yml` for Windows updates (EXE installers). + +### macOS: `merge-macos-latest-yml.sh` + +Generates `latest-mac.yml` for macOS updates (DMG installers). + +## Usage + +### Option 1: Download from Existing GitHub Release + +Use this to fix an already-published release (like v1.1.3): + +**Windows:** +```bash +cd buildScripts +./merge-windows-latest-yml.sh 1.1.3 v1.1.3 +gh release upload v1.1.3 latest.yml --clobber +``` + +**macOS:** +```bash +cd buildScripts +./merge-macos-latest-yml.sh 1.1.3 v1.1.3 +gh release upload v1.1.3 latest-mac.yml --clobber +``` + +This will: +1. Download both x64 and ARM64 installers from the v1.1.3 GitHub release +2. Calculate SHA256/SHA512 hashes +3. Generate a merged YAML file in the current directory + +### Option 2: Use Local Build Files + +Use this when you have local build artifacts: + +**Windows:** +```bash +cd buildScripts +./merge-windows-latest-yml.sh 1.1.3 +``` + +**macOS:** +```bash +cd buildScripts +./merge-macos-latest-yml.sh 1.1.3 +``` + +This will look for installer files in the `build/` directory. + +## Output + +The scripts create YAML files in the current directory: + +**Windows (`latest.yml`):** +```yaml +version: 1.1.3 +files: + - url: Power-Platform-ToolBox-1.1.3-x64-win.exe + sha512: + sha256: + size: 83575696 + blockMapSize: null + - url: Power-Platform-ToolBox-1.1.3-arm64-win.exe + sha512: + sha256: + size: 86587224 + blockMapSize: null +releaseDate: 2026-02-19T10:54:00.000Z +``` + +**macOS (`latest-mac.yml`):** +```yaml +version: 1.1.3 +files: + - url: Power-Platform-ToolBox-1.1.3-x64-mac.dmg + sha512: + sha256: + size: 110450111 + blockMapSize: null + - url: Power-Platform-ToolBox-1.1.3-arm64-mac.dmg + sha512: + sha256: + size: 103969103 + blockMapSize: null +releaseDate: 2026-02-19T10:54:00.000Z +``` + +## Uploading to GitHub Release + +### Using GitHub CLI + +**Windows:** +```bash +gh release upload v1.1.3 latest.yml --clobber +``` + +**macOS:** +```bash +gh release upload v1.1.3 latest-mac.yml --clobber +``` + +The `--clobber` flag replaces the existing file. + +### Using GitHub Web UI + +1. Go to https://github.com/PowerPlatformToolBox/desktop-app/releases/edit/v1.1.3 +2. Scroll to the release assets section +3. Delete the existing `latest.yml` or `latest-mac.yml` file +4. Upload the new file generated by the script + +## Fixing v1.1.3 Release + +To fix the current v1.1.3 release for both platforms: + +**Windows:** +```bash +cd buildScripts +./merge-windows-latest-yml.sh 1.1.3 v1.1.3 +gh release upload v1.1.3 latest.yml --clobber +``` + +**macOS:** +```bash +cd buildScripts +./merge-macos-latest-yml.sh 1.1.3 v1.1.3 +gh release upload v1.1.3 latest-mac.yml --clobber +``` + +After uploading, users who check for updates will receive the correct installer for their architecture. + +## How It Works + +1. **Locates Installers**: Finds or downloads both x64 and ARM64 installers (EXE for Windows, DMG for macOS) +2. **Calculates Hashes**: Computes SHA256 and SHA512 checksums for integrity verification +3. **Generates YAML**: Creates a YAML file with both file entries +4. **Architecture Detection**: electron-updater automatically selects the correct installer + +## Architecture Detection + +The electron-updater library automatically detects the user's architecture: + +**Windows:** +- On x64 systems: `process.arch === "x64"` → downloads `*-x64-win.exe` +- On ARM64 systems: `process.arch === "arm64"` → downloads `*-arm64-win.exe` + +**macOS:** +- On Intel Macs: `process.arch === "x64"` → downloads `*-x64-mac.dmg` +- On Apple Silicon: `process.arch === "arm64"` → downloads `*-arm64-mac.dmg` + +This is done by matching the architecture string in the filename. + +## Troubleshooting + +### "Failed to download x64 installer/DMG" + +The release tag or version doesn't exist on GitHub. Check: +- The release tag is correct (e.g., `v1.1.3` not `1.1.3`) +- The release has been published (not a draft) +- The installers have been uploaded to the release + +### "Could not find x64 installer/DMG in build/ directory" + +When using local files, ensure you've run the build first: + +**Windows:** +```bash +pnpm run build +pnpm run package:win # For x64 +pnpm run package:win-arm64 # For ARM64 +``` + +**macOS:** +```bash +pnpm run build +pnpm run package:mac # Builds both x64 and ARM64 +``` + +### "sha256sum: command not found" (macOS) + +On macOS, use `shasum` which is already included. The scripts handle this automatically. + +If you get errors, you may need to install coreutils: + +```bash +brew install coreutils +``` + +## Related Files + +- `.github/workflows/prod-release.yml` - Production release workflow (automated merge for both Windows and macOS) +- `.github/workflows/nightly-release.yml` - Nightly release workflow (automated merge for both Windows and macOS) + +## Future Enhancements + +Starting with v1.1.4 and later, the GitHub Actions workflows automatically merge both Windows and macOS YAML files, so manual intervention won't be needed for new releases. diff --git a/buildScripts/sh/merge-latest-yml.sh b/buildScripts/sh/merge-latest-yml.sh new file mode 100755 index 00000000..7e7f805e --- /dev/null +++ b/buildScripts/sh/merge-latest-yml.sh @@ -0,0 +1,169 @@ +#!/bin/bash +# +# Merge Windows latest.yml Script +# +# This script generates a merged latest.yml file containing both x64 and ARM64 +# Windows installers for electron-updater auto-update functionality. +# +# Usage: +# ./merge-latest-yml.sh [release-tag] +# +# Examples: +# # Generate for v1.1.3 (downloads from GitHub release) +# ./merge-latest-yml.sh 1.1.3 v1.1.3 +# +# # Generate for local files in build/ directory +# ./merge-latest-yml.sh 1.1.3 +# +# The script will: +# 1. Download or locate both x64 and ARM64 Windows EXE files +# 2. Calculate SHA256 and SHA512 hashes +# 3. Generate a merged latest.yml with both architectures +# +# Output: latest.yml (in current directory) +# + +set -e + +VERSION="${1}" +RELEASE_TAG="${2}" + +if [[ -z "$VERSION" ]]; then + echo "Usage: $0 [release-tag]" + echo "" + echo "Examples:" + echo " $0 1.1.3 v1.1.3 # Download from GitHub release" + echo " $0 1.1.3 # Use local files from build/ directory" + exit 1 +fi + +echo "=== Windows latest.yml Merger ===" +echo "Version: $VERSION" +echo "" + +# Temporary directory for downloads +TEMP_DIR=$(mktemp -d) +trap 'rm -rf "$TEMP_DIR"' EXIT + +# Function to find or download x64 EXE +find_x64_exe() { + if [[ -n "$RELEASE_TAG" ]]; then + echo "📥 Downloading x64 installer from GitHub release $RELEASE_TAG..." >&2 + local url="https://github.com/PowerPlatformToolBox/desktop-app/releases/download/${RELEASE_TAG}/Power-Platform-ToolBox-${VERSION}-x64-win.exe" + local dest="$TEMP_DIR/Power-Platform-ToolBox-${VERSION}-x64-win.exe" + + if curl -sL -f -o "$dest" "$url"; then + echo "$dest" + else + echo "❌ Failed to download x64 installer from $url" >&2 + return 1 + fi + else + echo "🔍 Looking for x64 installer in build/ directory..." >&2 + local exe=$(find build -name "*-x64-win.exe" -type f 2>/dev/null | head -n 1) + if [[ -n "$exe" && -f "$exe" ]]; then + echo "$exe" + else + echo "❌ Could not find x64 installer in build/ directory" >&2 + return 1 + fi + fi +} + +# Function to find or download ARM64 EXE +find_arm64_exe() { + if [[ -n "$RELEASE_TAG" ]]; then + echo "📥 Downloading ARM64 installer from GitHub release $RELEASE_TAG..." >&2 + local url="https://github.com/PowerPlatformToolBox/desktop-app/releases/download/${RELEASE_TAG}/Power-Platform-ToolBox-${VERSION}-arm64-win.exe" + local dest="$TEMP_DIR/Power-Platform-ToolBox-${VERSION}-arm64-win.exe" + + if curl -sL -f -o "$dest" "$url"; then + echo "$dest" + else + echo "❌ Failed to download ARM64 installer from $url" >&2 + return 1 + fi + else + echo "🔍 Looking for ARM64 installer in build/ directory..." >&2 + local exe=$(find build -name "*-arm64-win.exe" -type f 2>/dev/null | head -n 1) + if [[ -n "$exe" && -f "$exe" ]]; then + echo "$exe" + else + echo "❌ Could not find ARM64 installer in build/ directory" >&2 + return 1 + fi + fi +} + +# Find/download the EXE files +X64_EXE=$(find_x64_exe) +ARM64_EXE=$(find_arm64_exe) + +if [[ -z "$X64_EXE" || -z "$ARM64_EXE" ]]; then + echo "❌ Could not find both installers" + exit 1 +fi + +echo "" +echo "✅ Found x64 installer: $(basename "$X64_EXE")" +echo "✅ Found ARM64 installer: $(basename "$ARM64_EXE")" +echo "" + +# Calculate hashes for x64 +echo "🔐 Calculating hashes for x64 installer..." +X64_SHA256=$(sha256sum "$X64_EXE" | awk '{print $1}') +X64_SHA512=$(sha512sum "$X64_EXE" | awk '{print $1}') +X64_SIZE=$(stat -c %s "$X64_EXE" 2>/dev/null || stat -f %z "$X64_EXE" 2>/dev/null) + +echo " SHA256: $X64_SHA256" +echo " SHA512: $X64_SHA512" +echo " Size: $X64_SIZE bytes" +echo "" + +# Calculate hashes for ARM64 +echo "🔐 Calculating hashes for ARM64 installer..." +ARM64_SHA256=$(sha256sum "$ARM64_EXE" | awk '{print $1}') +ARM64_SHA512=$(sha512sum "$ARM64_EXE" | awk '{print $1}') +ARM64_SIZE=$(stat -c %s "$ARM64_EXE" 2>/dev/null || stat -f %z "$ARM64_EXE" 2>/dev/null) + +echo " SHA256: $ARM64_SHA256" +echo " SHA512: $ARM64_SHA512" +echo " Size: $ARM64_SIZE bytes" +echo "" + +# Generate release date in ISO 8601 format +RELEASE_DATE=$(date -u +'%Y-%m-%dT%H:%M:%S.000Z') + +# Create merged latest.yml +OUTPUT_FILE="latest.yml" + +cat > "$OUTPUT_FILE" << EOF +version: $VERSION +files: + - url: $(basename "$X64_EXE") + sha512: $X64_SHA512 + sha256: $X64_SHA256 + size: $X64_SIZE + blockMapSize: null + - url: $(basename "$ARM64_EXE") + sha512: $ARM64_SHA512 + sha256: $ARM64_SHA256 + size: $ARM64_SIZE + blockMapSize: null +releaseDate: $RELEASE_DATE +EOF + +echo "✅ Merged latest.yml created successfully!" +echo "" +echo "=== Output: $OUTPUT_FILE ===" +cat "$OUTPUT_FILE" +echo "" +echo "=== Next Steps ===" +echo "1. Review the generated latest.yml file above" +echo "2. Upload it to the GitHub release, replacing the existing latest.yml" +echo "" +echo "To upload to GitHub release manually:" +echo " gh release upload $RELEASE_TAG $OUTPUT_FILE --clobber" +echo "" +echo "Or use the GitHub web UI:" +echo " https://github.com/PowerPlatformToolBox/desktop-app/releases/edit/$RELEASE_TAG" diff --git a/buildScripts/sh/merge-macos-latest-yml.sh b/buildScripts/sh/merge-macos-latest-yml.sh new file mode 100755 index 00000000..231c6a7b --- /dev/null +++ b/buildScripts/sh/merge-macos-latest-yml.sh @@ -0,0 +1,179 @@ +#!/bin/bash +# +# Merge macOS latest-mac.yml Script +# +# This script generates a merged latest-mac.yml file containing both x64 and ARM64 +# macOS installers for electron-updater auto-update functionality. +# +# Usage: +# ./merge-macos-latest-yml.sh [release-tag] +# +# Examples: +# # Generate for v1.1.3 (downloads from GitHub release) +# ./merge-macos-latest-yml.sh 1.1.3 v1.1.3 +# +# # Generate for local files in build/ directory +# ./merge-macos-latest-yml.sh 1.1.3 +# +# The script will: +# 1. Download or locate both x64 and ARM64 macOS DMG files +# 2. Calculate SHA256 and SHA512 hashes +# 3. Generate a merged latest-mac.yml with both architectures +# +# Output: latest-mac.yml (in current directory) +# + +set -e + +VERSION="${1}" +RELEASE_TAG="${2}" + +if [[ -z "$VERSION" ]]; then + echo "Usage: $0 [release-tag]" + echo "" + echo "Examples:" + echo " $0 1.1.3 v1.1.3 # Download from GitHub release" + echo " $0 1.1.3 # Use local files from build/ directory" + exit 1 +fi + +echo "=== macOS latest-mac.yml Merger ===" +echo "Version: $VERSION" +echo "" + +# Temporary directory for downloads +TEMP_DIR=$(mktemp -d) +trap 'rm -rf "$TEMP_DIR"' EXIT + +# Function to find or download x64 DMG +find_x64_dmg() { + if [[ -n "$RELEASE_TAG" ]]; then + echo "📥 Downloading x64 DMG from GitHub release $RELEASE_TAG..." >&2 + local url="https://github.com/PowerPlatformToolBox/desktop-app/releases/download/${RELEASE_TAG}/Power-Platform-ToolBox-${VERSION}-x64-mac.dmg" + local dest="$TEMP_DIR/Power-Platform-ToolBox-${VERSION}-x64-mac.dmg" + + if curl -sL -f -o "$dest" "$url"; then + echo "$dest" + else + echo "❌ Failed to download x64 DMG from $url" >&2 + return 1 + fi + else + echo "🔍 Looking for x64 DMG in build/ directory..." >&2 + local dmg=$(find build -name "*-x64-mac.dmg" -type f 2>/dev/null | head -n 1) + if [[ -n "$dmg" && -f "$dmg" ]]; then + echo "$dmg" + else + echo "❌ Could not find x64 DMG in build/ directory" >&2 + return 1 + fi + fi +} + +# Function to find or download ARM64 DMG +find_arm64_dmg() { + if [[ -n "$RELEASE_TAG" ]]; then + echo "📥 Downloading ARM64 DMG from GitHub release $RELEASE_TAG..." >&2 + local url="https://github.com/PowerPlatformToolBox/desktop-app/releases/download/${RELEASE_TAG}/Power-Platform-ToolBox-${VERSION}-arm64-mac.dmg" + local dest="$TEMP_DIR/Power-Platform-ToolBox-${VERSION}-arm64-mac.dmg" + + if curl -sL -f -o "$dest" "$url"; then + echo "$dest" + else + echo "❌ Failed to download ARM64 DMG from $url" >&2 + return 1 + fi + else + echo "🔍 Looking for ARM64 DMG in build/ directory..." >&2 + local dmg=$(find build -name "*-arm64-mac.dmg" -type f 2>/dev/null | head -n 1) + if [[ -n "$dmg" && -f "$dmg" ]]; then + echo "$dmg" + else + echo "❌ Could not find ARM64 DMG in build/ directory" >&2 + return 1 + fi + fi +} + +# Find/download the DMG files +X64_DMG=$(find_x64_dmg) +ARM64_DMG=$(find_arm64_dmg) + +if [[ -z "$X64_DMG" || -z "$ARM64_DMG" ]]; then + echo "❌ Could not find both DMG files" + exit 1 +fi + +echo "" +echo "✅ Found x64 DMG: $(basename "$X64_DMG")" +echo "✅ Found ARM64 DMG: $(basename "$ARM64_DMG")" +echo "" + +# Calculate hashes for x64 +echo "🔐 Calculating hashes for x64 DMG..." +X64_SHA256=$(shasum -a 256 "$X64_DMG" | awk '{print $1}') +X64_SHA512=$(shasum -a 512 "$X64_DMG" | awk '{print $1}') +# Try macOS stat first, then Linux stat +if stat -f '%z' "$X64_DMG" >/dev/null 2>&1; then + X64_SIZE=$(stat -f '%z' "$X64_DMG") +else + X64_SIZE=$(stat -c '%s' "$X64_DMG") +fi + +echo " SHA256: $X64_SHA256" +echo " SHA512: $X64_SHA512" +echo " Size: $X64_SIZE bytes" +echo "" + +# Calculate hashes for ARM64 +echo "🔐 Calculating hashes for ARM64 DMG..." +ARM64_SHA256=$(shasum -a 256 "$ARM64_DMG" | awk '{print $1}') +ARM64_SHA512=$(shasum -a 512 "$ARM64_DMG" | awk '{print $1}') +# Try macOS stat first, then Linux stat +if stat -f '%z' "$ARM64_DMG" >/dev/null 2>&1; then + ARM64_SIZE=$(stat -f '%z' "$ARM64_DMG") +else + ARM64_SIZE=$(stat -c '%s' "$ARM64_DMG") +fi + +echo " SHA256: $ARM64_SHA256" +echo " SHA512: $ARM64_SHA512" +echo " Size: $ARM64_SIZE bytes" +echo "" + +# Generate release date in ISO 8601 format +RELEASE_DATE=$(date -u +'%Y-%m-%dT%H:%M:%S.000Z') + +# Create merged latest-mac.yml +OUTPUT_FILE="latest-mac.yml" + +cat > "$OUTPUT_FILE" << EOF +version: $VERSION +files: + - url: $(basename "$X64_DMG") + sha512: $X64_SHA512 + sha256: $X64_SHA256 + size: $X64_SIZE + blockMapSize: null + - url: $(basename "$ARM64_DMG") + sha512: $ARM64_SHA512 + sha256: $ARM64_SHA256 + size: $ARM64_SIZE + blockMapSize: null +releaseDate: $RELEASE_DATE +EOF + +echo "✅ Merged latest-mac.yml created successfully!" +echo "" +echo "=== Output: $OUTPUT_FILE ===" +cat "$OUTPUT_FILE" +echo "" +echo "=== Next Steps ===" +echo "1. Review the generated latest-mac.yml file above" +echo "2. Upload it to the GitHub release, replacing the existing latest-mac.yml" +echo "" +echo "To upload to GitHub release manually:" +echo " gh release upload $RELEASE_TAG $OUTPUT_FILE --clobber" +echo "" +echo "Or use the GitHub web UI:" +echo " https://github.com/PowerPlatformToolBox/desktop-app/releases/edit/$RELEASE_TAG" diff --git a/buildScripts/test-macos-build.sh b/buildScripts/sh/test-macos-build.sh similarity index 100% rename from buildScripts/test-macos-build.sh rename to buildScripts/sh/test-macos-build.sh diff --git a/buildScripts/verify-build.sh b/buildScripts/sh/verify-build.sh similarity index 100% rename from buildScripts/verify-build.sh rename to buildScripts/sh/verify-build.sh From 4114d571061371c5a5ec10d7da2f699ac9a7243c Mon Sep 17 00:00:00 2001 From: Power-Maverick Date: Thu, 19 Feb 2026 10:41:09 -0500 Subject: [PATCH 023/257] fix: update macOS release scripts to use ZIP artifacts instead of DMG files --- .github/workflows/nightly-release.yml | 110 ++++++++--------- .github/workflows/prod-release.yml | 110 ++++++++--------- .../sh/README-merge-windows-latest-yml.md | 66 +++++++---- buildScripts/sh/merge-macos-latest-yml.sh | 112 +++++++++--------- 4 files changed, 205 insertions(+), 193 deletions(-) diff --git a/.github/workflows/nightly-release.yml b/.github/workflows/nightly-release.yml index 0c667436..95721351 100644 --- a/.github/workflows/nightly-release.yml +++ b/.github/workflows/nightly-release.yml @@ -616,85 +616,85 @@ jobs: shell: bash run: | echo "🔄 Merging macOS x64 and ARM64 latest-mac.yml files..." - - # Find all DMG files - X64_DMG=$(find notarize -name "*-x64-mac.dmg" -type f | head -n 1) - ARM64_DMG=$(find notarize -name "*-arm64-mac.dmg" -type f | head -n 1) - - if [[ -z "$X64_DMG" || -z "$ARM64_DMG" ]]; then - echo "⚠️ One or both macOS DMG files not found. Skipping merge." - echo "X64_DMG: $X64_DMG" - echo "ARM64_DMG: $ARM64_DMG" + + # Find all ZIP files (required for mac auto-update) + X64_ZIP=$(find notarize -name "*-x64-mac.zip" -type f | head -n 1) + ARM64_ZIP=$(find notarize -name "*-arm64-mac.zip" -type f | head -n 1) + + if [[ -z "$X64_ZIP" || -z "$ARM64_ZIP" ]]; then + echo "⚠️ One or both macOS ZIP files not found. Skipping merge." + echo "X64_ZIP: $X64_ZIP" + echo "ARM64_ZIP: $ARM64_ZIP" # Fallback to single architecture (original behavior) YML_FILES=$(find notarize -name "latest*.yml" -o -name "*-mac.yml") if [[ -n "$YML_FILES" ]]; then for YML_FILE in $YML_FILES; do VERSION=$(grep -oP 'version:\s+\K[^\s]+' "$YML_FILE" || echo "unknown") - DMG_FILE=$(find "$(dirname "$YML_FILE")" -maxdepth 1 -name "*.dmg" | head -n 1) - if [[ -n "$DMG_FILE" && -f "$DMG_FILE" ]]; then - HASH256=$(shasum -a 256 "$DMG_FILE" | awk '{print $1}') - HASH512=$(shasum -a 512 "$DMG_FILE" | awk '{print $1}') - SIZE=$(stat -f '%z' "$DMG_FILE" 2>/dev/null || stat -c '%s' "$DMG_FILE" 2>/dev/null) + ZIP_FILE=$(find "$(dirname "$YML_FILE")" -maxdepth 1 -name "*.zip" | head -n 1) + if [[ -n "$ZIP_FILE" && -f "$ZIP_FILE" ]]; then + HASH256=$(shasum -a 256 "$ZIP_FILE" | awk '{print $1}') + HASH512=$(shasum -a 512 "$ZIP_FILE" | awk '{print $1}') + SIZE=$(stat -f '%z' "$ZIP_FILE" 2>/dev/null || stat -c '%s' "$ZIP_FILE" 2>/dev/null) printf "version: %s\nfiles:\n - url: %s\n sha512: %s\n sha256: %s\n size: %s\n blockMapSize: null\nreleaseDate: %s\n" \ - "$VERSION" "$(basename "$DMG_FILE")" "$HASH512" "$HASH256" "$SIZE" "$(date -u +'%Y-%m-%dT%H:%M:%S.000Z')" > "$YML_FILE" + "$VERSION" "$(basename "$ZIP_FILE")" "$HASH512" "$HASH256" "$SIZE" "$(date -u +'%Y-%m-%dT%H:%M:%S.000Z')" > "$YML_FILE" fi done fi exit 0 fi - - echo "Found X64 DMG: $(basename "$X64_DMG")" - echo "Found ARM64 DMG: $(basename "$ARM64_DMG")" - + + echo "Found X64 ZIP: $(basename "$X64_ZIP")" + echo "Found ARM64 ZIP: $(basename "$ARM64_ZIP")" + # Find any existing YAML to extract version EXISTING_YML=$(find notarize -name "latest*.yml" -o -name "*-mac.yml" | head -n 1) VERSION=$(awk '/^version:/{print $2; exit}' "$EXISTING_YML" 2>/dev/null || echo "unknown") - + echo "Version: $VERSION" - - # Calculate hashes for x64 - echo "🔐 Calculating hashes for x64 DMG..." - X64_SHA256=$(shasum -a 256 "$X64_DMG" | awk '{print $1}') - X64_SHA512=$(shasum -a 512 "$X64_DMG" | awk '{print $1}') - X64_SIZE=$(stat -f '%z' "$X64_DMG" 2>/dev/null || stat -c '%s' "$X64_DMG" 2>/dev/null) - + + # Calculate hashes for x64 ZIP + echo "🔐 Calculating hashes for x64 ZIP..." + X64_SHA256=$(shasum -a 256 "$X64_ZIP" | awk '{print $1}') + X64_SHA512=$(shasum -a 512 "$X64_ZIP" | awk '{print $1}') + X64_SIZE=$(stat -f '%z' "$X64_ZIP" 2>/dev/null || stat -c '%s' "$X64_ZIP" 2>/dev/null) + echo " SHA256: $X64_SHA256" echo " SHA512: $X64_SHA512" echo " Size: $X64_SIZE" - - # Calculate hashes for ARM64 - echo "🔐 Calculating hashes for ARM64 DMG..." - ARM64_SHA256=$(shasum -a 256 "$ARM64_DMG" | awk '{print $1}') - ARM64_SHA512=$(shasum -a 512 "$ARM64_DMG" | awk '{print $1}') - ARM64_SIZE=$(stat -f '%z' "$ARM64_DMG" 2>/dev/null || stat -c '%s' "$ARM64_DMG" 2>/dev/null) - + + # Calculate hashes for ARM64 ZIP + echo "🔐 Calculating hashes for ARM64 ZIP..." + ARM64_SHA256=$(shasum -a 256 "$ARM64_ZIP" | awk '{print $1}') + ARM64_SHA512=$(shasum -a 512 "$ARM64_ZIP" | awk '{print $1}') + ARM64_SIZE=$(stat -f '%z' "$ARM64_ZIP" 2>/dev/null || stat -c '%s' "$ARM64_ZIP" 2>/dev/null) + echo " SHA256: $ARM64_SHA256" echo " SHA512: $ARM64_SHA512" echo " Size: $ARM64_SIZE" - + # Create merged YAML with both architectures MERGED_YML="notarize/latest-mac.yml" cat > "$MERGED_YML" << EOF version: $VERSION files: - - url: $(basename "$X64_DMG") + - url: $(basename "$X64_ZIP") sha512: $X64_SHA512 sha256: $X64_SHA256 size: $X64_SIZE blockMapSize: null - - url: $(basename "$ARM64_DMG") + - url: $(basename "$ARM64_ZIP") sha512: $ARM64_SHA512 sha256: $ARM64_SHA256 size: $ARM64_SIZE blockMapSize: null releaseDate: $(date -u +'%Y-%m-%dT%H:%M:%S.000Z') EOF - + echo "" echo "✅ Merged latest-mac.yml created:" cat "$MERGED_YML" - + # Remove any other YAML files in subdirectories to prevent conflicts find notarize -name "latest*.yml" -o -name "*-mac.yml" | while read yml; do if [[ "$yml" != "$MERGED_YML" ]]; then @@ -702,7 +702,7 @@ jobs: echo "Removed: $yml" fi done - + echo "✅ Regeneration complete" - name: Upload stapled macOS artifacts @@ -741,60 +741,60 @@ jobs: - name: Merge Windows latest.yml files run: | echo "🔄 Merging Windows x64 and ARM64 latest.yml files..." - + # Find the Windows YAML files X64_YML=$(find artifacts/windows-x64-build -name "latest.yml" 2>/dev/null || echo "") ARM64_YML=$(find artifacts/windows-arm64-build -name "latest.yml" 2>/dev/null || echo "") - + if [[ -z "$X64_YML" || -z "$ARM64_YML" ]]; then echo "⚠️ One or both Windows YAML files not found. Skipping merge." echo "X64_YML: $X64_YML" echo "ARM64_YML: $ARM64_YML" exit 0 fi - + echo "Found X64 YAML: $X64_YML" echo "Found ARM64 YAML: $ARM64_YML" - + # Extract version and release date from x64 YAML (should be the same for both) VERSION=$(grep -oP 'version:\s+\K[^\s]+' "$X64_YML" || echo "unknown") RELEASE_DATE=$(grep -oP 'releaseDate:\s+\K.+' "$X64_YML" || date -u +'%Y-%m-%dT%H:%M:%S.000Z') - + echo "Version: $VERSION" echo "Release Date: $RELEASE_DATE" - + # Find the EXE files in each artifact directory X64_EXE=$(find artifacts/windows-x64-build -name "*-x64-win.exe" -type f | head -n 1) ARM64_EXE=$(find artifacts/windows-arm64-build -name "*-arm64-win.exe" -type f | head -n 1) - + if [[ -z "$X64_EXE" || -z "$ARM64_EXE" ]]; then echo "❌ Could not find both x64 and ARM64 EXE files" echo "X64_EXE: $X64_EXE" echo "ARM64_EXE: $ARM64_EXE" exit 1 fi - + echo "Found X64 EXE: $(basename "$X64_EXE")" echo "Found ARM64 EXE: $(basename "$ARM64_EXE")" - + # Calculate hashes for x64 X64_SHA256=$(sha256sum "$X64_EXE" | awk '{print $1}') X64_SHA512=$(sha512sum "$X64_EXE" | awk '{print $1}') X64_SIZE=$(stat -c %s "$X64_EXE" 2>/dev/null || stat -f %z "$X64_EXE" 2>/dev/null) - + echo "X64 SHA256: $X64_SHA256" echo "X64 SHA512: $X64_SHA512" echo "X64 Size: $X64_SIZE" - + # Calculate hashes for ARM64 ARM64_SHA256=$(sha256sum "$ARM64_EXE" | awk '{print $1}') ARM64_SHA512=$(sha512sum "$ARM64_EXE" | awk '{print $1}') ARM64_SIZE=$(stat -c %s "$ARM64_EXE" 2>/dev/null || stat -f %z "$ARM64_EXE" 2>/dev/null) - + echo "ARM64 SHA256: $ARM64_SHA256" echo "ARM64 SHA512: $ARM64_SHA512" echo "ARM64 Size: $ARM64_SIZE" - + # Create merged YAML with both architectures MERGED_YML="artifacts/latest.yml" cat > "$MERGED_YML" << EOF @@ -812,11 +812,11 @@ jobs: blockMapSize: null releaseDate: $RELEASE_DATE EOF - + echo "" echo "✅ Merged latest.yml created:" cat "$MERGED_YML" - + # Remove the individual YAML files so they don't get uploaded rm -f "$X64_YML" "$ARM64_YML" echo "" diff --git a/.github/workflows/prod-release.yml b/.github/workflows/prod-release.yml index c9d84fb0..8bf7b2a6 100644 --- a/.github/workflows/prod-release.yml +++ b/.github/workflows/prod-release.yml @@ -569,85 +569,85 @@ jobs: shell: bash run: | echo "🔄 Merging macOS x64 and ARM64 latest-mac.yml files..." - - # Find all DMG files - X64_DMG=$(find notarize -name "*-x64-mac.dmg" -type f | head -n 1) - ARM64_DMG=$(find notarize -name "*-arm64-mac.dmg" -type f | head -n 1) - - if [[ -z "$X64_DMG" || -z "$ARM64_DMG" ]]; then - echo "⚠️ One or both macOS DMG files not found. Skipping merge." - echo "X64_DMG: $X64_DMG" - echo "ARM64_DMG: $ARM64_DMG" + + # Find all ZIP files (required for mac auto-update) + X64_ZIP=$(find notarize -name "*-x64-mac.zip" -type f | head -n 1) + ARM64_ZIP=$(find notarize -name "*-arm64-mac.zip" -type f | head -n 1) + + if [[ -z "$X64_ZIP" || -z "$ARM64_ZIP" ]]; then + echo "⚠️ One or both macOS ZIP files not found. Skipping merge." + echo "X64_ZIP: $X64_ZIP" + echo "ARM64_ZIP: $ARM64_ZIP" # Fallback to single architecture (original behavior) YML_FILES=$(find notarize -name "latest*.yml" -o -name "*-mac.yml") if [[ -n "$YML_FILES" ]]; then for YML_FILE in $YML_FILES; do VERSION=$(grep -oP 'version:\s+\K[^\s]+' "$YML_FILE" || echo "unknown") - DMG_FILE=$(find "$(dirname "$YML_FILE")" -maxdepth 1 -name "*.dmg" | head -n 1) - if [[ -n "$DMG_FILE" && -f "$DMG_FILE" ]]; then - HASH256=$(shasum -a 256 "$DMG_FILE" | awk '{print $1}') - HASH512=$(shasum -a 512 "$DMG_FILE" | awk '{print $1}') - SIZE=$(stat -f '%z' "$DMG_FILE" 2>/dev/null || stat -c '%s' "$DMG_FILE" 2>/dev/null) + ZIP_FILE=$(find "$(dirname "$YML_FILE")" -maxdepth 1 -name "*.zip" | head -n 1) + if [[ -n "$ZIP_FILE" && -f "$ZIP_FILE" ]]; then + HASH256=$(shasum -a 256 "$ZIP_FILE" | awk '{print $1}') + HASH512=$(shasum -a 512 "$ZIP_FILE" | awk '{print $1}') + SIZE=$(stat -f '%z' "$ZIP_FILE" 2>/dev/null || stat -c '%s' "$ZIP_FILE" 2>/dev/null) printf "version: %s\nfiles:\n - url: %s\n sha512: %s\n sha256: %s\n size: %s\n blockMapSize: null\nreleaseDate: %s\n" \ - "$VERSION" "$(basename "$DMG_FILE")" "$HASH512" "$HASH256" "$SIZE" "$(date -u +'%Y-%m-%dT%H:%M:%S.000Z')" > "$YML_FILE" + "$VERSION" "$(basename "$ZIP_FILE")" "$HASH512" "$HASH256" "$SIZE" "$(date -u +'%Y-%m-%dT%H:%M:%S.000Z')" > "$YML_FILE" fi done fi exit 0 fi - - echo "Found X64 DMG: $(basename "$X64_DMG")" - echo "Found ARM64 DMG: $(basename "$ARM64_DMG")" - + + echo "Found X64 ZIP: $(basename "$X64_ZIP")" + echo "Found ARM64 ZIP: $(basename "$ARM64_ZIP")" + # Find any existing YAML to extract version EXISTING_YML=$(find notarize -name "latest*.yml" -o -name "*-mac.yml" | head -n 1) VERSION=$(if [ -n "$EXISTING_YML" ]; then awk '/^version:[[:space:]]*/ {print $2; exit}' "$EXISTING_YML"; fi 2>/dev/null || echo "unknown") - + echo "Version: $VERSION" - - # Calculate hashes for x64 - echo "🔐 Calculating hashes for x64 DMG..." - X64_SHA256=$(shasum -a 256 "$X64_DMG" | awk '{print $1}') - X64_SHA512=$(shasum -a 512 "$X64_DMG" | awk '{print $1}') - X64_SIZE=$(stat -f '%z' "$X64_DMG" 2>/dev/null || stat -c '%s' "$X64_DMG" 2>/dev/null) - + + # Calculate hashes for x64 ZIP + echo "🔐 Calculating hashes for x64 ZIP..." + X64_SHA256=$(shasum -a 256 "$X64_ZIP" | awk '{print $1}') + X64_SHA512=$(shasum -a 512 "$X64_ZIP" | awk '{print $1}') + X64_SIZE=$(stat -f '%z' "$X64_ZIP" 2>/dev/null || stat -c '%s' "$X64_ZIP" 2>/dev/null) + echo " SHA256: $X64_SHA256" echo " SHA512: $X64_SHA512" echo " Size: $X64_SIZE" - - # Calculate hashes for ARM64 - echo "🔐 Calculating hashes for ARM64 DMG..." - ARM64_SHA256=$(shasum -a 256 "$ARM64_DMG" | awk '{print $1}') - ARM64_SHA512=$(shasum -a 512 "$ARM64_DMG" | awk '{print $1}') - ARM64_SIZE=$(stat -f '%z' "$ARM64_DMG" 2>/dev/null || stat -c '%s' "$ARM64_DMG" 2>/dev/null) - + + # Calculate hashes for ARM64 ZIP + echo "🔐 Calculating hashes for ARM64 ZIP..." + ARM64_SHA256=$(shasum -a 256 "$ARM64_ZIP" | awk '{print $1}') + ARM64_SHA512=$(shasum -a 512 "$ARM64_ZIP" | awk '{print $1}') + ARM64_SIZE=$(stat -f '%z' "$ARM64_ZIP" 2>/dev/null || stat -c '%s' "$ARM64_ZIP" 2>/dev/null) + echo " SHA256: $ARM64_SHA256" echo " SHA512: $ARM64_SHA512" echo " Size: $ARM64_SIZE" - + # Create merged YAML with both architectures MERGED_YML="notarize/latest-mac.yml" cat > "$MERGED_YML" << EOF version: $VERSION files: - - url: $(basename "$X64_DMG") + - url: $(basename "$X64_ZIP") sha512: $X64_SHA512 sha256: $X64_SHA256 size: $X64_SIZE blockMapSize: null - - url: $(basename "$ARM64_DMG") + - url: $(basename "$ARM64_ZIP") sha512: $ARM64_SHA512 sha256: $ARM64_SHA256 size: $ARM64_SIZE blockMapSize: null releaseDate: $(date -u +'%Y-%m-%dT%H:%M:%S.000Z') EOF - + echo "" echo "✅ Merged latest-mac.yml created:" cat "$MERGED_YML" - + # Remove any other YAML files in subdirectories to prevent conflicts find notarize -name "latest*.yml" -o -name "*-mac.yml" | while read yml; do if [[ "$yml" != "$MERGED_YML" ]]; then @@ -655,7 +655,7 @@ jobs: echo "Removed: $yml" fi done - + echo "✅ Regeneration complete" - name: Upload stapled macOS artifacts @@ -698,60 +698,60 @@ jobs: - name: Merge Windows latest.yml files run: | echo "🔄 Merging Windows x64 and ARM64 latest.yml files..." - + # Find the Windows YAML files X64_YML=$(find artifacts/windows-x64-release -name "latest.yml" 2>/dev/null || echo "") ARM64_YML=$(find artifacts/windows-arm64-release -name "latest.yml" 2>/dev/null || echo "") - + if [[ -z "$X64_YML" || -z "$ARM64_YML" ]]; then echo "⚠️ One or both Windows YAML files not found. Skipping merge." echo "X64_YML: $X64_YML" echo "ARM64_YML: $ARM64_YML" exit 0 fi - + echo "Found X64 YAML: $X64_YML" echo "Found ARM64 YAML: $ARM64_YML" - + # Extract version and release date from x64 YAML (should be the same for both) VERSION=$(grep -oP 'version:\s+\K[^\s]+' "$X64_YML" || echo "unknown") RELEASE_DATE=$(grep -oP 'releaseDate:\s+\K.+' "$X64_YML" || date -u +'%Y-%m-%dT%H:%M:%S.000Z') - + echo "Version: $VERSION" echo "Release Date: $RELEASE_DATE" - + # Find the EXE files in each artifact directory X64_EXE=$(find artifacts/windows-x64-release -name "*-x64-win.exe" -type f | head -n 1) ARM64_EXE=$(find artifacts/windows-arm64-release -name "*-arm64-win.exe" -type f | head -n 1) - + if [[ -z "$X64_EXE" || -z "$ARM64_EXE" ]]; then echo "❌ Could not find both x64 and ARM64 EXE files" echo "X64_EXE: $X64_EXE" echo "ARM64_EXE: $ARM64_EXE" exit 1 fi - + echo "Found X64 EXE: $(basename "$X64_EXE")" echo "Found ARM64 EXE: $(basename "$ARM64_EXE")" - + # Calculate hashes for x64 X64_SHA256=$(sha256sum "$X64_EXE" | awk '{print $1}') X64_SHA512=$(sha512sum "$X64_EXE" | awk '{print $1}') X64_SIZE=$(stat -c %s "$X64_EXE" 2>/dev/null || stat -f %z "$X64_EXE" 2>/dev/null) - + echo "X64 SHA256: $X64_SHA256" echo "X64 SHA512: $X64_SHA512" echo "X64 Size: $X64_SIZE" - + # Calculate hashes for ARM64 ARM64_SHA256=$(sha256sum "$ARM64_EXE" | awk '{print $1}') ARM64_SHA512=$(sha512sum "$ARM64_EXE" | awk '{print $1}') ARM64_SIZE=$(stat -c %s "$ARM64_EXE" 2>/dev/null || stat -f %z "$ARM64_EXE" 2>/dev/null) - + echo "ARM64 SHA256: $ARM64_SHA256" echo "ARM64 SHA512: $ARM64_SHA512" echo "ARM64 Size: $ARM64_SIZE" - + # Create merged YAML with both architectures MERGED_YML="artifacts/latest.yml" cat > "$MERGED_YML" << EOF @@ -769,11 +769,11 @@ jobs: blockMapSize: null releaseDate: $RELEASE_DATE EOF - + echo "" echo "✅ Merged latest.yml created:" cat "$MERGED_YML" - + # Remove the individual YAML files so they don't get uploaded rm -f "$X64_YML" "$ARM64_YML" echo "" diff --git a/buildScripts/sh/README-merge-windows-latest-yml.md b/buildScripts/sh/README-merge-windows-latest-yml.md index 01ed16c1..5c4594a9 100644 --- a/buildScripts/sh/README-merge-windows-latest-yml.md +++ b/buildScripts/sh/README-merge-windows-latest-yml.md @@ -28,7 +28,7 @@ Generates `latest.yml` for Windows updates (EXE installers). ### macOS: `merge-macos-latest-yml.sh` -Generates `latest-mac.yml` for macOS updates (DMG installers). +Generates `latest-mac.yml` for macOS updates (ZIP artifacts, required by electron-updater). ## Usage @@ -37,6 +37,7 @@ Generates `latest-mac.yml` for macOS updates (DMG installers). Use this to fix an already-published release (like v1.1.3): **Windows:** + ```bash cd buildScripts ./merge-windows-latest-yml.sh 1.1.3 v1.1.3 @@ -44,13 +45,15 @@ gh release upload v1.1.3 latest.yml --clobber ``` **macOS:** + ```bash -cd buildScripts +cd buildScripts/sh ./merge-macos-latest-yml.sh 1.1.3 v1.1.3 gh release upload v1.1.3 latest-mac.yml --clobber ``` This will: + 1. Download both x64 and ARM64 installers from the v1.1.3 GitHub release 2. Calculate SHA256/SHA512 hashes 3. Generate a merged YAML file in the current directory @@ -60,12 +63,14 @@ This will: Use this when you have local build artifacts: **Windows:** + ```bash cd buildScripts ./merge-windows-latest-yml.sh 1.1.3 ``` **macOS:** + ```bash cd buildScripts ./merge-macos-latest-yml.sh 1.1.3 @@ -78,36 +83,38 @@ This will look for installer files in the `build/` directory. The scripts create YAML files in the current directory: **Windows (`latest.yml`):** + ```yaml version: 1.1.3 files: - - url: Power-Platform-ToolBox-1.1.3-x64-win.exe - sha512: - sha256: - size: 83575696 - blockMapSize: null - - url: Power-Platform-ToolBox-1.1.3-arm64-win.exe - sha512: - sha256: - size: 86587224 - blockMapSize: null + - url: Power-Platform-ToolBox-1.1.3-x64-win.exe + sha512: + sha256: + size: 83575696 + blockMapSize: null + - url: Power-Platform-ToolBox-1.1.3-arm64-win.exe + sha512: + sha256: + size: 86587224 + blockMapSize: null releaseDate: 2026-02-19T10:54:00.000Z ``` **macOS (`latest-mac.yml`):** + ```yaml version: 1.1.3 files: - - url: Power-Platform-ToolBox-1.1.3-x64-mac.dmg - sha512: - sha256: - size: 110450111 - blockMapSize: null - - url: Power-Platform-ToolBox-1.1.3-arm64-mac.dmg - sha512: - sha256: - size: 103969103 - blockMapSize: null + - url: Power-Platform-ToolBox-1.1.3-x64-mac.zip + sha512: + sha256: + size: + blockMapSize: null + - url: Power-Platform-ToolBox-1.1.3-arm64-mac.zip + sha512: + sha256: + size: + blockMapSize: null releaseDate: 2026-02-19T10:54:00.000Z ``` @@ -116,11 +123,13 @@ releaseDate: 2026-02-19T10:54:00.000Z ### Using GitHub CLI **Windows:** + ```bash gh release upload v1.1.3 latest.yml --clobber ``` **macOS:** + ```bash gh release upload v1.1.3 latest-mac.yml --clobber ``` @@ -139,6 +148,7 @@ The `--clobber` flag replaces the existing file. To fix the current v1.1.3 release for both platforms: **Windows:** + ```bash cd buildScripts ./merge-windows-latest-yml.sh 1.1.3 v1.1.3 @@ -146,6 +156,7 @@ gh release upload v1.1.3 latest.yml --clobber ``` **macOS:** + ```bash cd buildScripts ./merge-macos-latest-yml.sh 1.1.3 v1.1.3 @@ -156,7 +167,7 @@ After uploading, users who check for updates will receive the correct installer ## How It Works -1. **Locates Installers**: Finds or downloads both x64 and ARM64 installers (EXE for Windows, DMG for macOS) +1. **Locates Installers**: Finds or downloads both x64 and ARM64 installers (EXE for Windows, ZIP for macOS) 2. **Calculates Hashes**: Computes SHA256 and SHA512 checksums for integrity verification 3. **Generates YAML**: Creates a YAML file with both file entries 4. **Architecture Detection**: electron-updater automatically selects the correct installer @@ -166,12 +177,14 @@ After uploading, users who check for updates will receive the correct installer The electron-updater library automatically detects the user's architecture: **Windows:** + - On x64 systems: `process.arch === "x64"` → downloads `*-x64-win.exe` - On ARM64 systems: `process.arch === "arm64"` → downloads `*-arm64-win.exe` **macOS:** -- On Intel Macs: `process.arch === "x64"` → downloads `*-x64-mac.dmg` -- On Apple Silicon: `process.arch === "arm64"` → downloads `*-arm64-mac.dmg` + +- On Intel Macs: `process.arch === "x64"` → downloads `*-x64-mac.zip` +- On Apple Silicon: `process.arch === "arm64"` → downloads `*-arm64-mac.zip` This is done by matching the architecture string in the filename. @@ -180,6 +193,7 @@ This is done by matching the architecture string in the filename. ### "Failed to download x64 installer/DMG" The release tag or version doesn't exist on GitHub. Check: + - The release tag is correct (e.g., `v1.1.3` not `1.1.3`) - The release has been published (not a draft) - The installers have been uploaded to the release @@ -189,6 +203,7 @@ The release tag or version doesn't exist on GitHub. Check: When using local files, ensure you've run the build first: **Windows:** + ```bash pnpm run build pnpm run package:win # For x64 @@ -196,6 +211,7 @@ pnpm run package:win-arm64 # For ARM64 ``` **macOS:** + ```bash pnpm run build pnpm run package:mac # Builds both x64 and ARM64 diff --git a/buildScripts/sh/merge-macos-latest-yml.sh b/buildScripts/sh/merge-macos-latest-yml.sh index 231c6a7b..0ed7cd15 100755 --- a/buildScripts/sh/merge-macos-latest-yml.sh +++ b/buildScripts/sh/merge-macos-latest-yml.sh @@ -3,7 +3,7 @@ # Merge macOS latest-mac.yml Script # # This script generates a merged latest-mac.yml file containing both x64 and ARM64 -# macOS installers for electron-updater auto-update functionality. +# macOS ZIP artifacts for electron-updater auto-update functionality. # # Usage: # ./merge-macos-latest-yml.sh [release-tag] @@ -16,7 +16,7 @@ # ./merge-macos-latest-yml.sh 1.1.3 # # The script will: -# 1. Download or locate both x64 and ARM64 macOS DMG files +# 1. Download or locate both x64 and ARM64 macOS ZIP files # 2. Calculate SHA256 and SHA512 hashes # 3. Generate a merged latest-mac.yml with both architectures # @@ -45,79 +45,79 @@ echo "" TEMP_DIR=$(mktemp -d) trap 'rm -rf "$TEMP_DIR"' EXIT -# Function to find or download x64 DMG -find_x64_dmg() { +# Function to find or download x64 ZIP +find_x64_zip() { if [[ -n "$RELEASE_TAG" ]]; then - echo "📥 Downloading x64 DMG from GitHub release $RELEASE_TAG..." >&2 - local url="https://github.com/PowerPlatformToolBox/desktop-app/releases/download/${RELEASE_TAG}/Power-Platform-ToolBox-${VERSION}-x64-mac.dmg" - local dest="$TEMP_DIR/Power-Platform-ToolBox-${VERSION}-x64-mac.dmg" + echo "📥 Downloading x64 ZIP from GitHub release $RELEASE_TAG..." >&2 + local url="https://github.com/PowerPlatformToolBox/desktop-app/releases/download/${RELEASE_TAG}/Power-Platform-ToolBox-${VERSION}-x64-mac.zip" + local dest="$TEMP_DIR/Power-Platform-ToolBox-${VERSION}-x64-mac.zip" if curl -sL -f -o "$dest" "$url"; then echo "$dest" else - echo "❌ Failed to download x64 DMG from $url" >&2 + echo "❌ Failed to download x64 ZIP from $url" >&2 return 1 fi else - echo "🔍 Looking for x64 DMG in build/ directory..." >&2 - local dmg=$(find build -name "*-x64-mac.dmg" -type f 2>/dev/null | head -n 1) - if [[ -n "$dmg" && -f "$dmg" ]]; then - echo "$dmg" + echo "🔍 Looking for x64 ZIP in build/ directory..." >&2 + local zip=$(find build -name "*-x64-mac.zip" -type f 2>/dev/null | head -n 1) + if [[ -n "$zip" && -f "$zip" ]]; then + echo "$zip" else - echo "❌ Could not find x64 DMG in build/ directory" >&2 + echo "❌ Could not find x64 ZIP in build/ directory" >&2 return 1 fi fi } -# Function to find or download ARM64 DMG -find_arm64_dmg() { +# Function to find or download ARM64 ZIP +find_arm64_zip() { if [[ -n "$RELEASE_TAG" ]]; then - echo "📥 Downloading ARM64 DMG from GitHub release $RELEASE_TAG..." >&2 - local url="https://github.com/PowerPlatformToolBox/desktop-app/releases/download/${RELEASE_TAG}/Power-Platform-ToolBox-${VERSION}-arm64-mac.dmg" - local dest="$TEMP_DIR/Power-Platform-ToolBox-${VERSION}-arm64-mac.dmg" + echo "📥 Downloading ARM64 ZIP from GitHub release $RELEASE_TAG..." >&2 + local url="https://github.com/PowerPlatformToolBox/desktop-app/releases/download/${RELEASE_TAG}/Power-Platform-ToolBox-${VERSION}-arm64-mac.zip" + local dest="$TEMP_DIR/Power-Platform-ToolBox-${VERSION}-arm64-mac.zip" if curl -sL -f -o "$dest" "$url"; then echo "$dest" else - echo "❌ Failed to download ARM64 DMG from $url" >&2 + echo "❌ Failed to download ARM64 ZIP from $url" >&2 return 1 fi else - echo "🔍 Looking for ARM64 DMG in build/ directory..." >&2 - local dmg=$(find build -name "*-arm64-mac.dmg" -type f 2>/dev/null | head -n 1) - if [[ -n "$dmg" && -f "$dmg" ]]; then - echo "$dmg" + echo "🔍 Looking for ARM64 ZIP in build/ directory..." >&2 + local zip=$(find build -name "*-arm64-mac.zip" -type f 2>/dev/null | head -n 1) + if [[ -n "$zip" && -f "$zip" ]]; then + echo "$zip" else - echo "❌ Could not find ARM64 DMG in build/ directory" >&2 + echo "❌ Could not find ARM64 ZIP in build/ directory" >&2 return 1 fi fi } -# Find/download the DMG files -X64_DMG=$(find_x64_dmg) -ARM64_DMG=$(find_arm64_dmg) +# Find/download the ZIP files +X64_ZIP=$(find_x64_zip) +ARM64_ZIP=$(find_arm64_zip) -if [[ -z "$X64_DMG" || -z "$ARM64_DMG" ]]; then - echo "❌ Could not find both DMG files" +if [[ -z "$X64_ZIP" || -z "$ARM64_ZIP" ]]; then + echo "❌ Could not find both ZIP files" exit 1 fi echo "" -echo "✅ Found x64 DMG: $(basename "$X64_DMG")" -echo "✅ Found ARM64 DMG: $(basename "$ARM64_DMG")" +echo "✅ Found x64 ZIP: $(basename "$X64_ZIP")" +echo "✅ Found ARM64 ZIP: $(basename "$ARM64_ZIP")" echo "" # Calculate hashes for x64 -echo "🔐 Calculating hashes for x64 DMG..." -X64_SHA256=$(shasum -a 256 "$X64_DMG" | awk '{print $1}') -X64_SHA512=$(shasum -a 512 "$X64_DMG" | awk '{print $1}') +echo "🔐 Calculating hashes for x64 ZIP..." +X64_SHA256=$(shasum -a 256 "$X64_ZIP" | awk '{print $1}') +X64_SHA512=$(shasum -a 512 "$X64_ZIP" | awk '{print $1}') # Try macOS stat first, then Linux stat -if stat -f '%z' "$X64_DMG" >/dev/null 2>&1; then - X64_SIZE=$(stat -f '%z' "$X64_DMG") +if stat -f '%z' "$X64_ZIP" >/dev/null 2>&1; then + X64_SIZE=$(stat -f '%z' "$X64_ZIP") else - X64_SIZE=$(stat -c '%s' "$X64_DMG") + X64_SIZE=$(stat -c '%s' "$X64_ZIP") fi echo " SHA256: $X64_SHA256" @@ -126,14 +126,14 @@ echo " Size: $X64_SIZE bytes" echo "" # Calculate hashes for ARM64 -echo "🔐 Calculating hashes for ARM64 DMG..." -ARM64_SHA256=$(shasum -a 256 "$ARM64_DMG" | awk '{print $1}') -ARM64_SHA512=$(shasum -a 512 "$ARM64_DMG" | awk '{print $1}') +echo "🔐 Calculating hashes for ARM64 ZIP..." +ARM64_SHA256=$(shasum -a 256 "$ARM64_ZIP" | awk '{print $1}') +ARM64_SHA512=$(shasum -a 512 "$ARM64_ZIP" | awk '{print $1}') # Try macOS stat first, then Linux stat -if stat -f '%z' "$ARM64_DMG" >/dev/null 2>&1; then - ARM64_SIZE=$(stat -f '%z' "$ARM64_DMG") +if stat -f '%z' "$ARM64_ZIP" >/dev/null 2>&1; then + ARM64_SIZE=$(stat -f '%z' "$ARM64_ZIP") else - ARM64_SIZE=$(stat -c '%s' "$ARM64_DMG") + ARM64_SIZE=$(stat -c '%s' "$ARM64_ZIP") fi echo " SHA256: $ARM64_SHA256" @@ -147,21 +147,17 @@ RELEASE_DATE=$(date -u +'%Y-%m-%dT%H:%M:%S.000Z') # Create merged latest-mac.yml OUTPUT_FILE="latest-mac.yml" -cat > "$OUTPUT_FILE" << EOF -version: $VERSION -files: - - url: $(basename "$X64_DMG") - sha512: $X64_SHA512 - sha256: $X64_SHA256 - size: $X64_SIZE - blockMapSize: null - - url: $(basename "$ARM64_DMG") - sha512: $ARM64_SHA512 - sha256: $ARM64_SHA256 - size: $ARM64_SIZE - blockMapSize: null -releaseDate: $RELEASE_DATE -EOF +printf "version: %s\nfiles:\n - url: %s\n sha512: %s\n sha256: %s\n size: %s\n blockMapSize: null\n - url: %s\n sha512: %s\n sha256: %s\n size: %s\n blockMapSize: null\nreleaseDate: %s\n" \ + "$VERSION" \ + "$(basename "$X64_ZIP")" \ + "$X64_SHA512" \ + "$X64_SHA256" \ + "$X64_SIZE" \ + "$(basename "$ARM64_ZIP")" \ + "$ARM64_SHA512" \ + "$ARM64_SHA256" \ + "$ARM64_SIZE" \ + "$RELEASE_DATE" > "$OUTPUT_FILE" echo "✅ Merged latest-mac.yml created successfully!" echo "" From 4f0e1ee828fcc09ff5bb9ba99fd7ce659b4a4a84 Mon Sep 17 00:00:00 2001 From: LinkeD365 <43988771+LinkeD365@users.noreply.github.com> Date: Sun, 22 Feb 2026 11:58:22 +0000 Subject: [PATCH 024/257] fix: add webresource to entity mapping in DataverseManager (#402) --- src/main/managers/dataverseManager.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/managers/dataverseManager.ts b/src/main/managers/dataverseManager.ts index 5004127f..e2b08c5b 100644 --- a/src/main/managers/dataverseManager.ts +++ b/src/main/managers/dataverseManager.ts @@ -436,6 +436,7 @@ export class DataverseManager { systemuser: "systemusers", usersettingscollection: "usersettingscollection", principalobjectaccess: "principalobjectaccessset", + webresource: "webresourceset", }; const lowerName = entityLogicalName.toLowerCase(); From 87d8ef97ac4981f2835fba484b6a2096ed5c4dd6 Mon Sep 17 00:00:00 2001 From: Danish Naglekar <36135520+Power-Maverick@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:20:18 -0500 Subject: [PATCH 025/257] Move marketplace to azure blob (#403) * Add support for bundled SVG icons in tools with backward compatibility (#380) * Initial plan * Add SVG icon support with pptb-webview protocol for bundled icons Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Address code review feedback: fix recent tools icon handling and add HTML escaping Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Add comprehensive HTML escaping for XSS prevention in icon URLs Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Improve accessibility and eliminate code duplication in HTML escaping Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Add defensive URL validation to prevent JavaScript protocol injection Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Fix CodeQL warning by blocking vbscript protocol in URL validation Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Remove HTTP(S) icon support, change path to dist/ relative, rename iconUrl to icon Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Update ToolManifest comment to reflect bundled icons only (no HTTP URLs) Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Add support for new Supabase 'icon' column with backward compatibility fallback to 'iconurl' Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Fix CSP error by allowing HTTP(S) URLs for marketplace icon display Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Add pptb-webview protocol to CSP img-src to allow local SVG icon loading Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * feat: add SVG icon support and improve icon URL resolution * feat: implement theme-aware SVG icon support with CSS masks and improve icon rendering * refactor: remove tool icon configuration section from README.md --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> Co-authored-by: Power-Maverick * Move tool distribution from GitHub Releases to Azure Blob Storage (#400) * Initial plan * Add Azure Blob Storage support for tool distribution and registry fallback Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Remove hard-coded Azure Blob storage account name; resolve URLs at runtime from AZURE_BLOB_BASE_URL Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Simplify registry.json downloadUrl to filename-only; packages/ prefix added automatically at runtime Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Use per-tool version folder layout in Azure Blob, mirroring GitHub Releases structure Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Read download URL from new 'download' column in Supabase tools table, fall back to 'downloadurl' Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * fix: update Azure Blob Storage migration documentation to reflect new package structure --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> Co-authored-by: Power-Maverick * feat: enhance ToolManager to create tools from installed manifests and improve tool retrieval logic * Update src/main/constants.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update Azure Blob test download URL to dedicated test path (#404) * Initial plan * Update test tool download URL to /test/pptb-standard-sample-tool-download-test.tar.gz Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Fix resolveDownloadUrl to include packages/ prefix for Azure Blob Storage paths (#405) * Initial plan * fix: include packages/ prefix in resolveDownloadUrl for Azure Blob Storage Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Fix: include `icon` field in `fetchAzureBlobRegistry` tool mapping (#406) * Initial plan * fix: include icon field in fetchAzureBlobRegistry mapping Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Update src/main/index.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/AZURE_BLOB_MIGRATION.md | 426 ++++++++++++++++++ packages/README.md | 1 + src/common/types/tool.ts | 6 +- src/main/constants.ts | 15 + src/main/data/registry.json | 4 +- src/main/index.ts | 25 +- src/main/managers/modalWindowManager.ts | 2 +- src/main/managers/toolRegistryManager.ts | 141 +++++- src/main/managers/toolsManager.ts | 76 +++- src/renderer/index.html | 6 +- src/renderer/modules/homepageManagement.ts | 32 +- src/renderer/modules/marketplaceManagement.ts | 44 +- src/renderer/modules/themeManagement.ts | 4 +- .../modules/toolsSidebarManagement.ts | 17 +- src/renderer/styles.scss | 17 + src/renderer/styles/homepage.scss | 15 +- src/renderer/types/index.ts | 2 +- src/renderer/utils/toolIconResolver.ts | 142 ++++++ vite.config.ts | 8 + 19 files changed, 883 insertions(+), 100 deletions(-) create mode 100644 docs/AZURE_BLOB_MIGRATION.md create mode 100644 src/renderer/utils/toolIconResolver.ts diff --git a/docs/AZURE_BLOB_MIGRATION.md b/docs/AZURE_BLOB_MIGRATION.md new file mode 100644 index 00000000..bf82e0e8 --- /dev/null +++ b/docs/AZURE_BLOB_MIGRATION.md @@ -0,0 +1,426 @@ +# Azure Blob Storage Migration Strategy + +This document describes the strategy for moving tool distribution from GitHub Releases to Azure Blob Storage and outlines the updated intake process. + +## Overview + +Tool packages (`.tar.gz` archives) were previously hosted as GitHub Release assets on the `pptb-web` repository. They are being migrated to **Azure Blob Storage** to allow easier automation, lower latency, and decoupled storage from GitHub. + +The ToolBox application already fetches tool metadata from **Supabase**. Azure Blob Storage becomes the authoritative location for the binary artifacts (the `.tar.gz` packages) **and** for a remote fallback registry index when Supabase is unreachable. + +--- + +## Azure Blob Container Layout + +All tool assets live in a single public Azure Blob container (anonymous read access on blobs), with each tool version in its own folder — mirroring the GitHub Releases structure: + +``` +.blob.core.windows.net/tools/ +├── registry.json # Remote registry index (fallback after Supabase) +└── packages/ + └── -/ # Per-tool version folder + ├── -.tar.gz # Tool package archive + └── -.svg # Tool icon +``` + +**Example:** + +``` +https://.blob.core.windows.net/tools/registry.json +https://.blob.core.windows.net/tools/packages/pptb-standard-sample-tool-1.0.9/pptb-standard-sample-tool-1.0.9.tar.gz +https://.blob.core.windows.net/tools/packages/pptb-standard-sample-tool-1.0.9/pptb-standard-sample-tool-1.0.9.svg +``` + +--- + +## Configuration + +Set the following environment variable before building the app (add it to your `.env` file or CI/CD pipeline secrets): + +| Variable | Description | Example | +| --------------------- | ------------------------------------------------ | ------------------------------------------------------- | +| `AZURE_BLOB_BASE_URL` | Full URL to the root of the tools blob container | `https://.blob.core.windows.net/tools` | +| `SUPABASE_URL` | Supabase project URL (unchanged) | `https://xyz.supabase.co` | +| `SUPABASE_ANON_KEY` | Supabase anonymous key (unchanged) | `eyJ...` | + +### `.env` example + +```bash +SUPABASE_URL=https://your-project.supabase.co +SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +AZURE_BLOB_BASE_URL=https://.blob.core.windows.net/tools +``` + +> **Note:** `AZURE_BLOB_BASE_URL` is injected at build time via Vite and is **not** a runtime secret. The container must allow anonymous read access (no SAS token required for downloads). + +--- + +## Registry Fallback Chain + +The ToolBox app resolves the tool registry in the following order: + +``` +1. Supabase (primary) – real-time metadata, analytics, contributor info + ↓ (on failure) +2. Azure Blob registry.json – remote static snapshot (requires AZURE_BLOB_BASE_URL) + ↓ (on failure or not configured) +3. Local registry.json – bundled fallback shipped with the app binary +``` + +The Azure Blob `registry.json` must follow the same schema as the local `src/main/data/registry.json` file (see [Local Registry Schema](#local-registry-schema) below). + +--- + +## Tool Package Format + +Tool packages are `.tar.gz` archives containing the tool's files. The archive is extracted with: + +```sh +tar -xzf -.tar.gz -C +``` + +The extracted directory must contain a `package.json` at its root: + +``` +/ +├── package.json # Required – contains tool metadata (name, version, description, …) +├── index.html # Required – tool entry point +└── ... # Additional assets +``` + +--- + +## Local Registry Schema + +Both `registry.json` (bundled) and the Azure Blob `registry.json` share this schema: + +> **`downloadUrl` convention:** +> +> - Supabase rows should use **absolute** HTTPS URLs. +> - For Azure Blob `registry.json` fallback, you can either: +> - Use **absolute** HTTPS URLs (recommended when `registry.json` is at `.../tools/registry.json` but packages are under `.../tools/packages/...`), or +> - Use just the **filename** (e.g. `my-tool-1.0.0.tar.gz`) _only if_ `AZURE_BLOB_BASE_URL` points at the same prefix used for packages and `registry.json` is also under that prefix. +> +> The app resolves relative filenames by deriving a folder name from the filename (strip `.tar.gz`) and joining it to `AZURE_BLOB_BASE_URL`. + +```json +{ + "version": "1.0", + "updatedAt": "", + "description": "Power Platform ToolBox - Official Tool Registry", + "tools": [ + { + "id": "my-tool-id", + "packageName": "my-tool-npm-package", + "name": "My Tool", + "description": "Tool description", + "authors": ["Author Name"], + "version": "1.0.0", + "downloadUrl": "my-tool-id-1.0.0.tar.gz", + "icon": "icon.png", + "checksum": "sha256:", + "size": 75000, + "publishedAt": "", + "tags": ["dataverse"], + "readme": "https://...", + "minToolboxVersion": "1.0.0", + "repository": "https://github.com/...", + "homepage": "https://...", + "license": "MIT", + "cspExceptions": { + "connect-src": ["https://*.dynamics.com"] + } + } + ] +} +``` + +--- + +## Updated Intake Process + +### Current Process (GitHub Releases) + +``` +User submits tool via web app (pptb-web) + → Review & approval + → convert-tool GitHub Action pre-packages the tool from npm + → Package uploaded as a GitHub Release asset on pptb-web + → Supabase row updated with downloadurl pointing to the GitHub Release asset +``` + +### New Process (Azure Blob Storage) + +``` +User submits tool via web app (pptb-web) + → Review & approval + → convert-tool GitHub Action pre-packages the tool from npm (unchanged) + → Both the .tar.gz and .svg (icon) are uploaded to a per-tool version folder in Azure Blob: + az storage blob upload \ + --account-name \ + --container-name tools \ + --name "packages/-/-.tar.gz" \ + --file "-.tar.gz" \ + --auth-mode login + az storage blob upload \ + --account-name \ + --container-name tools \ + --name "packages/-/-.svg" \ + --file "-.svg" \ + --auth-mode login + → Supabase row updated with downloadurl pointing to the Azure Blob URL: + https://.blob.core.windows.net/tools/packages/-/-.tar.gz + → (Optional) registry.json in the blob container is regenerated to include the new entry +``` + +### Changes to the `convert-tool` GitHub Action + +Replace the GitHub Release upload step with an Azure Blob upload step. The CI/CD pipeline will need the following secrets configured: + +| Secret | Description | +| ------------------------- | ------------------------------------------------------------------------- | +| `AZURE_STORAGE_ACCOUNT` | Storage account name (e.g. ``) | +| `AZURE_STORAGE_CONTAINER` | Container name (e.g. `tools`) | +| `AZURE_CREDENTIALS` | Azure service principal credentials JSON (used with `azure/login` action) | + +**Example workflow snippet (replace the current GitHub Release upload step):** + +```yaml +- name: Login to Azure + uses: azure/login@v2 + with: + creds: ${{ secrets.AZURE_CREDENTIALS }} + +- name: Upload tool package and icon to Azure Blob + run: | + FOLDER="${{ env.TOOL_ID }}-${{ env.TOOL_VERSION }}" + az storage blob upload \ + --account-name ${{ secrets.AZURE_STORAGE_ACCOUNT }} \ + --container-name ${{ secrets.AZURE_STORAGE_CONTAINER }} \ + --name "packages/${FOLDER}/${FOLDER}.tar.gz" \ + --file "${FOLDER}.tar.gz" \ + --auth-mode login \ + --overwrite true + az storage blob upload \ + --account-name ${{ secrets.AZURE_STORAGE_ACCOUNT }} \ + --container-name ${{ secrets.AZURE_STORAGE_CONTAINER }} \ + --name "packages/${FOLDER}/${FOLDER}.svg" \ + --file "${FOLDER}.svg" \ + --auth-mode login \ + --overwrite true + +- name: Regenerate Azure Blob registry.json + run: | + # Download current registry.json, add new tool entry, re-upload + az storage blob download \ + --account-name ${{ secrets.AZURE_STORAGE_ACCOUNT }} \ + --container-name ${{ secrets.AZURE_STORAGE_CONTAINER }} \ + --name registry.json --file registry.json --auth-mode login || echo '{"version":"1.0","tools":[]}' > registry.json + node buildScripts/updateRegistry.js "${{ env.TOOL_ID }}" "${{ env.TOOL_VERSION }}" "${{ env.TOOL_METADATA_JSON }}" + az storage blob upload \ + --account-name ${{ secrets.AZURE_STORAGE_ACCOUNT }} \ + --container-name ${{ secrets.AZURE_STORAGE_CONTAINER }} \ + --name registry.json --file registry.json \ + --auth-mode login --overwrite true + +- name: Update Supabase downloadurl + env: + SUPABASE_URL: ${{ secrets.SUPABASE_URL }} + SUPABASE_SERVICE_KEY: ${{ secrets.SUPABASE_SERVICE_KEY }} + run: | + FOLDER="${{ env.TOOL_ID }}-${{ env.TOOL_VERSION }}" + node buildScripts/updateSupabase.js \ + "${{ env.TOOL_ID }}" \ + "https://${{ secrets.AZURE_STORAGE_ACCOUNT }}.blob.core.windows.net/${{ secrets.AZURE_STORAGE_CONTAINER }}/${FOLDER}/${FOLDER}.tar.gz" +``` + +--- + +## Azure Blob Storage Setup + +### 1. Create Storage Account and Container + +```bash +# Create resource group (if needed) +az group create --name pptoolbox-rg --location eastus + +# Create storage account +az storage account create \ + --name \ + --resource-group pptoolbox-rg \ + --location eastus \ + --sku Standard_LRS \ + --allow-blob-public-access true + +# Create container with anonymous read access (blobs only) +az storage container create \ + --name tools \ + --account-name \ + --public-access blob \ + --auth-mode login +``` + +### 2. Upload Initial registry.json + +```bash +az storage blob upload \ + --account-name \ + --container-name tools \ + --name registry.json \ + --file src/main/data/registry.json \ + --auth-mode login +``` + +### 3. Configure CORS (if needed for browser-based access) + +```bash +az storage cors add \ + --methods GET HEAD \ + --origins "https://powerplatformtoolbox.com" \ + --services b \ + --account-name +``` + +--- + +## Transition / Rollout Plan + +1. **Create the Azure Blob container** following the setup steps above. +2. **Upload existing tool packages** to their per-tool version folders in the blob container (e.g. `-/-.tar.gz`). +3. **Upload an initial `registry.json`** to the blob container root. +4. **Update Supabase** `downloadurl` column for all tools to point to Azure Blob. +5. **Set `AZURE_BLOB_BASE_URL`** in the app's build environment and redeploy. +6. **Update the `convert-tool` GitHub Action** in `pptb-web` to upload to Azure Blob instead of (or in addition to) GitHub Releases. +7. **Monitor** for any download failures via Sentry before retiring GitHub Release uploads. + +> During the transition period, old GitHub Release URLs remain accessible, and newly installed tools will automatically use the Azure Blob URLs stored in Supabase. + +--- + +## Bulk migration scripts (pptb-web → Azure Blob + Supabase URL update) + +This repo includes two helper scripts to migrate historical assets and then update Supabase to point at the new Azure Blob URLs: + +- PowerShell: [buildScripts/powershell/Move-PptbWebReleasesToAzureBlob.ps1](../buildScripts/powershell/Move-PptbWebReleasesToAzureBlob.ps1) +- SQL: [buildScripts/sql/update-tools-download-and-icon-urls.sql](../buildScripts/sql/update-tools-download-and-icon-urls.sql) + +### 1) Copy all GitHub Release assets into Azure Blob + +This copies matching release assets from `https://github.com/PowerPlatformToolBox/pptb-web/releases` into your `tools` container using the documented layout: + +``` +tools/ + registry.json + packages/ + -/ + -.tar.gz + -.svg +``` + +> Note: some historical GitHub release icon assets are named like `--icon.svg`. The migration script normalizes these into Azure Blob as `packages/-/-.svg` (so the Supabase `iconurl` can be made consistent). + +**Prereqs** + +- Azure CLI installed and logged in: `az login` +- Access to the target storage account + container +- Optional but recommended: set `GITHUB_TOKEN` for higher GitHub API rate limits + +**Run** + +```pwsh +# Optional (recommended): increases GitHub API rate limit +$env:GITHUB_TOKEN = "" + +pwsh ./buildScripts/powershell/Move-PptbWebReleasesToAzureBlob.ps1 ` + -StorageAccount ` + -Container tools +``` + +**Dry run** + +```pwsh +pwsh ./buildScripts/powershell/Move-PptbWebReleasesToAzureBlob.ps1 ` + -StorageAccount ` + -Container tools ` + -WhatIf +``` + +**Overwrite behavior** + +- By default, existing blobs are left as-is. +- To force re-copying, pass `-Overwrite` (the script deletes the destination blob before copying). + +**Registry.json regeneration** + +- By default, the script does NOT modify `registry.json`. +- If you want the script to regenerate and upload `tools/registry.json` from Supabase after copying, pass `-RegenerateRegistryJson` (requires `SUPABASE_URL` + `SUPABASE_ANON_KEY`). + +**Regenerate `registry.json` only (no copy)** + +Use this if you want to retry registry generation/upload without re-copying any GitHub assets: + +```pwsh +$env:SUPABASE_URL = "https://.supabase.co" +$env:SUPABASE_ANON_KEY = "" + +pwsh ./buildScripts/powershell/Move-PptbWebReleasesToAzureBlob.ps1 ` + -StorageAccount ` + -Container tools ` + -OnlyRegenerateRegistryJson +``` + +Dry run: + +```pwsh +pwsh ./buildScripts/powershell/Move-PptbWebReleasesToAzureBlob.ps1 ` + -StorageAccount ` + -Container tools ` + -OnlyRegenerateRegistryJson ` + -WhatIf +``` + +### 2) Update Supabase `tools` table URLs (download + icon) + +The desktop app reads tool metadata from Supabase (table `tools`) and expects these columns: + +- `downloadurl` (full URL to `.tar.gz`) +- `iconurl` (full URL to `.svg`) + +The included SQL script updates both columns by: + +1. Extracting the filename from the existing URL (everything after the final `/`). +2. Deriving the folder from the filename (`-`). +3. Rewriting the URL to: + +``` +https://.blob.core.windows.net/tools/packages/-/ +``` + +**Run** + +1. Open the script: [buildScripts/sql/update-tools-download-and-icon-urls.sql](../buildScripts/sql/update-tools-download-and-icon-urls.sql) +2. Replace the placeholder `https://.blob.core.windows.net/tools` with your real base URL (no trailing slash). +3. Paste into the Supabase SQL editor and run. + +**Notes** + +- The SQL only targets rows that still point at GitHub (or `release-assets.githubusercontent.com`) and skips rows already pointing at `*.blob.core.windows.net`. +- The icon update is limited to `.svg` URLs. + +### Troubleshooting + +- If you see a transient message like "The specified blob does not exist" during the migration copy step, that can occur briefly right after starting a server-side copy. The PowerShell script retries and waits for the copy status to become available. +- If you see an error like "A redirected response (HTTP status code 302) from the copy source is not supported" / `CannotVerifyCopySource`, that is expected with GitHub Release download URLs (they often redirect to a signed, temporary URL). The PowerShell script attempts to resolve redirects and will fall back to downloading locally and uploading to Azure Blob if server-side copy cannot be used. +- If `az storage blob copy start` fails with a message like "The request may be blocked by network rules of storage account", your storage account is restricting access by network. Run the migration from an allowed network, or temporarily allow your public IP in the storage account firewall. + + Inspect current rules: + + ```bash + az storage account show -n -g --query networkRuleSet -o jsonc + ``` + + Temporarily allow a public IP (example): + + ```bash + az storage account network-rule add -n -g --ip-address + ``` diff --git a/packages/README.md b/packages/README.md index ef031e5a..9f6faf49 100644 --- a/packages/README.md +++ b/packages/README.md @@ -18,6 +18,7 @@ TypeScript type definitions for Power Platform ToolBox APIs. - [FetchXML Queries](#fetchxml-queries) - [Metadata Operations](#metadata-operations) - [Execute Actions/Functions](#execute-actionsfunctions) + - [Deploy Solutions](#deploy-solutions) - [API Reference](#api-reference) - [ToolBox API (`window.toolboxAPI`)](#toolbox-api-windowtoolboxapi) - [Connections](#connections-1) diff --git a/src/common/types/tool.ts b/src/common/types/tool.ts index 3edc044d..ee301bc6 100644 --- a/src/common/types/tool.ts +++ b/src/common/types/tool.ts @@ -28,7 +28,7 @@ export interface Tool { publishedAt?: string; createdAt?: string; // ISO date string from created_at field authors?: string[]; - iconUrl?: string; + icon?: string; // Relative path to SVG icon in dist/ folder (e.g., "icon.svg" or "icons/icon.svg") settings?: ToolSettings; localPath?: string; // For local development tools - absolute path to tool directory npmPackageName?: string; // For npm-installed tools - package name in node_modules @@ -54,7 +54,7 @@ export interface ToolRegistryEntry { description: string; authors?: string[]; // full list of contributors version: string; - iconUrl?: string; + icon?: string; // Relative path to SVG icon in dist/ folder (e.g., "icon.svg" or "icons/icon.svg") downloadUrl: string; readmeUrl?: string; // URL or relative path to README file checksum?: string; @@ -82,7 +82,7 @@ export interface ToolManifest { version: string; description: string; authors?: string[]; // contributors list - icon?: string; + icon?: string; // Relative path to SVG icon in dist/ folder (e.g., "icon.svg" or "icons/icon.svg") installPath: string; installedAt: string; source: "registry" | "npm" | "local"; // Track installation source diff --git a/src/main/constants.ts b/src/main/constants.ts index 60928936..ce1b076f 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -22,3 +22,18 @@ export const TOOL_REGISTRY_URL = "https://www.powerplatformtoolbox.com/registry/ */ export const SUPABASE_URL = process.env.SUPABASE_URL || ""; export const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY || ""; + +/** + * Azure Blob Storage Configuration + * Base URL for the Azure Blob container that hosts tool packages and the remote registry. + * The container should be publicly readable (anonymous read access for blobs). + * Set AZURE_BLOB_BASE_URL to the full container URL, e.g.: + * https://.blob.core.windows.net/tools + * + * Expected layout inside the container: + * registry.json – remote registry index (fallback after Supabase) + * packages/-/-.tar.gz – pre-packaged tool archive + * packages/-/icon-light.png – light theme icon for the tool/version + * packages/-/icon-dark.png – dark theme icon for the tool/version + */ +export const AZURE_BLOB_BASE_URL = process.env.AZURE_BLOB_BASE_URL || ""; diff --git a/src/main/data/registry.json b/src/main/data/registry.json index 2ec244fd..9cc2c27b 100644 --- a/src/main/data/registry.json +++ b/src/main/data/registry.json @@ -10,7 +10,7 @@ "description": "Generate Entity Relationship Diagrams for Dataverse", "authors": ["Power Platform ToolBox Team"], "version": "1.0.4", - "downloadUrl": "https://github.com/PowerPlatformToolBox/pptb-web/releases/download/power-maverick-tool-erd-generator-1.0.4/power-maverick-tool-erd-generator-1.0.4.tar.gz", + "downloadUrl": "power-maverick-tool-erd-generator-1.0.4.tar.gz", "icon": "icon.png", "checksum": "sha256:0000000000000000000000000000000000000000000000000000000000000000", "size": 50000, @@ -29,7 +29,7 @@ "description": "A sample HTML tool that showcases various features provided by the ToolBox", "authors": ["Power Maverick"], "version": "1.0.5", - "downloadUrl": "https://github.com/PowerPlatformToolBox/pptb-web/releases/download/pptb-standard-sample-tool-1.0.5/pptb-standard-sample-tool-1.0.5.tar.gz", + "downloadUrl": "pptb-standard-sample-tool-1.0.5.tar.gz", "icon": "icon.png", "checksum": "sha256:0000000000000000000000000000000000000000000000000000000000000000", "size": 75000, diff --git a/src/main/index.ts b/src/main/index.ts index 38e6ba17..bda35fac 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -142,8 +142,14 @@ class ToolBoxApp { this.connectionsManager = new ConnectionsManager(); this.api = new ToolBoxUtilityManager(); - // Pass Supabase credentials from environment variables or use defaults from constants - this.toolManager = new ToolManager(path.join(app.getPath("userData"), "tools"), process.env.SUPABASE_URL, process.env.SUPABASE_ANON_KEY, this.installIdManager); + // Pass Supabase credentials and Azure Blob base URL from environment variables + this.toolManager = new ToolManager( + path.join(app.getPath("userData"), "tools"), + process.env.SUPABASE_URL, + process.env.SUPABASE_ANON_KEY, + this.installIdManager, + process.env.AZURE_BLOB_BASE_URL, + ); this.browserviewProtocolManager = new BrowserviewProtocolManager(this.toolManager, this.settingsManager); this.autoUpdateManager = new AutoUpdateManager(); this.browserManager = new BrowserManager(); @@ -2520,19 +2526,24 @@ class ToolBoxApp { /** * Check tool download capability - * Tests downloading a sample tool from GitHub releases + * Tests downloading a tool package from Azure Blob Storage (when configured) or + * falls back to checking reachability of the registry endpoint. */ private async checkToolDownload(): Promise<{ success: boolean; message?: string }> { - const TEST_TOOL_DOWNLOAD_URL = "https://github.com/PowerPlatformToolBox/pptb-web/releases/download/pptb-standard-sample-tool-1.0.9/pptb-standard-sample-tool-1.0.9.tar.gz"; + const azureBlobBaseUrl = process.env.AZURE_BLOB_BASE_URL || ""; + const TEST_TOOL_DOWNLOAD_URL = azureBlobBaseUrl + ? `${azureBlobBaseUrl.replace(/\/$/, "")}/test/pptb-standard-sample-tool-download-test.tar.gz` + : "https://github.com/PowerPlatformToolBox/pptb-web/releases/download/test/pptb-standard-sample-tool-download-test.tar.gz"; const tempDir = path.join(app.getPath("temp"), "pptb-download-test"); - const downloadPath = path.join(tempDir, "pptb-standard-sample-tool-1.0.9.tar.gz"); + const downloadPath = path.join(tempDir, "pptb-standard-sample-tool-download-test.tar.gz"); try { if (!fs.existsSync(tempDir)) { fs.mkdirSync(tempDir, { recursive: true }); } - logInfo(`[Troubleshooting] Testing download from GitHub release: ${TEST_TOOL_DOWNLOAD_URL}`); + const downloadSource = azureBlobBaseUrl ? "Azure Blob Storage" : "GitHub release"; + logInfo(`[Troubleshooting] Testing download from ${downloadSource}: ${TEST_TOOL_DOWNLOAD_URL}`); await new Promise((resolve, reject) => { const download = (url: string, redirectDepth = 0) => { @@ -2590,7 +2601,7 @@ class ToolBoxApp { return { success: true, - message: `Successfully downloaded GitHub release asset (${fileSizeMB} MB)`, + message: `Successfully downloaded tool package from ${azureBlobBaseUrl ? "Azure Blob Storage" : "GitHub release"} (${fileSizeMB} MB)`, }; } catch (error) { try { diff --git a/src/main/managers/modalWindowManager.ts b/src/main/managers/modalWindowManager.ts index 2c6b2952..bb43e3e3 100644 --- a/src/main/managers/modalWindowManager.ts +++ b/src/main/managers/modalWindowManager.ts @@ -137,7 +137,7 @@ export class ModalWindowManager { - + `; + + const releaseNotesHtml = buildReleaseNotesHtml(model.releaseNotes, model.version); + + const processSteps = isAvailable + ? [ + { n: "1", text: "The update will download in the background." }, + { n: "2", text: "Once downloaded, you will be prompted to install." }, + { n: "3", text: "The app will restart automatically to apply the update." }, + ] + : [ + { n: "1", text: "Click Restart & Install to apply the update now." }, + { n: "2", text: "The app will close and restart automatically." }, + { n: "3", text: "Any in-progress work in open tools will be lost. Your app settings and connections will be preserved." }, + ]; + + const heroIcon = isAvailable + ? ` + + ` + : ` + + `; + + const stepsHtml = processSteps.map((s) => `

${s.n}${s.text}
`).join("\n"); + + const bannerText = isAvailable ? "This update requires an app restart to take effect. You can choose to download now or be reminded later." : "This update has been downloaded and is ready to install. The app will restart to apply the changes."; + + const footerButtons = isAvailable + ? ` + ` + : ` + `; + + const body = ` +
+
+
${heroIcon}
+
+

${isAvailable ? "Software Update" : "Ready to Install"}

+

${isAvailable ? "Update Available" : "Update Downloaded"}

+ Version ${model.version} +
+ +
+
+ Downloading update… +
+
+
+
+ + + + ${bannerText} +
+
+

What to expect

+ ${stepsHtml} +
+ ${releaseNotesHtml} +
+
+ ${footerButtons} +
+
`; + + return { styles, body }; +} + +function buildReleaseNotesHtml(releaseNotes: string | null | undefined, version: string): string { + if (!releaseNotes) { + return ""; + } + + // releaseNotes from electron-updater can be a string (HTML or plain text) or an array of objects + const rawText = typeof releaseNotes === "string" ? releaseNotes.trim() : ""; + if (!rawText) { + return ""; + } + + // Extract only the ## Highlights section from the markdown-formatted release notes. + // The release notes follow a structured format with sections like ## Highlights, ## Fixes, etc. + const highlightsText = extractHighlightsSection(rawText); + + // Sanitize using an allowlist approach: strip all tags except safe formatting elements, + // and strip all attributes from allowed tags to prevent XSS via event handlers or + // javascript: URLs in the inline data URL modal context. + const ALLOWED_TAGS = new Set(["b", "i", "em", "strong", "ul", "ol", "li", "p", "br", "code", "pre", "span"]); + const sanitize = (html: string) => + html.replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)\b[^>]*>/g, (match, tag: string) => { + if (!ALLOWED_TAGS.has(tag.toLowerCase())) { + return ""; + } + // Keep only the tag name, strip all attributes + const isClosing = match.startsWith("` : `<${tag.toLowerCase()}>`; + }); + + const fullNotesUrl = `https://github.com/PowerPlatformToolBox/desktop-app/releases/tag/v${version}`; + + if (highlightsText) { + // Convert the extracted plain-text bullet list to basic HTML list items + const listItems = highlightsText + .split("\n") + .map((line) => line.replace(/^-\s*/, "").trim()) + .filter((line) => line.length > 0) + .map((line) => `
  • ${sanitize(line)}
  • `) + .join("\n"); + + return ` +
    +

    Highlights

    +
      ${listItems}
    + View full release notes → +
    `; + } + + // Fallback: no structured highlights found — show sanitized raw notes with a link + const sanitizedRaw = sanitize(rawText); + return ` +
    +

    Release Notes

    +
    ${sanitizedRaw}
    + View full release notes → +
    `; +} + +/** + * Extract the content of the "## Highlights" section from markdown-formatted release notes. + * Returns the raw bullet-list text, or an empty string if the section is not found. + */ +function extractHighlightsSection(markdown: string): string { + // Match the ## Highlights section up to the next ## heading or end of string + const match = /^##\s+Highlights\s*\n([\s\S]*?)(?=^##\s|\s*$)/im.exec(markdown); + if (!match) { + return ""; + } + return match[1].trim(); +} diff --git a/src/renderer/modules/autoUpdateManagement.ts b/src/renderer/modules/autoUpdateManagement.ts index 26ea8365..8cb64e15 100644 --- a/src/renderer/modules/autoUpdateManagement.ts +++ b/src/renderer/modules/autoUpdateManagement.ts @@ -3,6 +3,85 @@ * Handles application auto-update UI and status */ +import { getUpdateNotificationModalControllerScript } from "../modals/updateNotification/controller"; +import { getUpdateNotificationModalView } from "../modals/updateNotification/view"; +import { offBrowserWindowModalClosed, offBrowserWindowModalMessage, onBrowserWindowModalClosed, onBrowserWindowModalMessage, sendBrowserWindowModalMessage, showBrowserWindowModal } from "./browserWindowModals"; + +const UPDATE_NOTIFICATION_MODAL_ID = "update-notification"; +const UPDATE_NOTIFICATION_MODAL_CHANNELS = { + download: "update-notification:download", + install: "update-notification:install", + dismiss: "update-notification:dismiss", + openExternal: "update-notification:open-external", +} as const; + +const UPDATE_NOTIFICATION_MODAL_WIDTH = 560; +const UPDATE_NOTIFICATION_MODAL_HEIGHT = 540; + +let updateModalOpen = false; + +/** + * Build and show the update notification modal + */ +async function showUpdateNotificationModal(type: "available" | "downloaded", version: string, releaseNotes?: string | null): Promise { + if (updateModalOpen) { + return; + } + + const isDarkTheme = document.body.classList.contains("dark-theme"); + const currentVersion = (await window.toolboxAPI.getAppVersion().catch(() => "")) as string; + + const { styles, body } = getUpdateNotificationModalView({ + type, + version, + currentVersion, + releaseNotes, + isDarkTheme, + }); + + const script = getUpdateNotificationModalControllerScript({ + type, + channels: UPDATE_NOTIFICATION_MODAL_CHANNELS, + }); + + const html = `${styles}\n${body}\n${script}`.trim(); + + const onMessage = (payload: { channel: string; data?: unknown }) => { + if (!payload) return; + if (payload.channel === UPDATE_NOTIFICATION_MODAL_CHANNELS.download) { + window.toolboxAPI.downloadUpdate().catch(() => undefined); + } else if (payload.channel === UPDATE_NOTIFICATION_MODAL_CHANNELS.install) { + window.toolboxAPI.quitAndInstall(); + } else if (payload.channel === UPDATE_NOTIFICATION_MODAL_CHANNELS.openExternal) { + const url = (payload.data as { url?: string })?.url; + if (url) { + window.toolboxAPI.openExternal(url).catch(() => undefined); + } + } + }; + + const onClosed = () => { + updateModalOpen = false; + offBrowserWindowModalMessage(onMessage); + offBrowserWindowModalClosed(onClosed); + }; + + onBrowserWindowModalMessage(onMessage); + onBrowserWindowModalClosed(onClosed); + + updateModalOpen = true; + try { + await showBrowserWindowModal({ + id: UPDATE_NOTIFICATION_MODAL_ID, + html, + width: UPDATE_NOTIFICATION_MODAL_WIDTH, + height: UPDATE_NOTIFICATION_MODAL_HEIGHT, + }); + } catch (_error) { + onClosed(); + } +} + /** * Update UI elements for check for updates button */ @@ -153,6 +232,7 @@ export function setupAutoUpdateListeners(): void { window.toolboxAPI.onUpdateAvailable((info: any) => { showUpdateStatus(`Update available: Version ${info.version}`, "success"); updateCheckForUpdatesUI("available", `Update available: Version ${info.version}`); + void showUpdateNotificationModal("available", info.version, info.releaseNotes as string | null); }); window.toolboxAPI.onUpdateNotAvailable(() => { @@ -164,12 +244,18 @@ export function setupAutoUpdateListeners(): void { showUpdateProgress(); updateProgress(progress.percent); showUpdateStatus(`Downloading update: ${progress.percent}%`, "info"); + void sendBrowserWindowModalMessage({ channel: "update:progress", data: { percent: progress.percent } }).catch(() => undefined); }); window.toolboxAPI.onUpdateDownloaded((info: any) => { hideUpdateProgress(); showUpdateStatus(`Update downloaded: Version ${info.version}. Restart to install.`, "success"); updateCheckForUpdatesUI("idle"); + if (updateModalOpen) { + void sendBrowserWindowModalMessage({ channel: "update:downloaded", data: { version: info.version } }).catch(() => undefined); + } else { + void showUpdateNotificationModal("downloaded", info.version); + } }); window.toolboxAPI.onUpdateError((error: string) => { From 288d006d0f43bddcb21f4466f96f30ae8e892997 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 17:07:50 -0500 Subject: [PATCH 028/257] Add global search command palette to activity bar (#409) * Initial plan * Add global search command palette to activity bar Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Enhance global search: launch tools, show marketplace detail, focus settings Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Update src/renderer/index.html Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/renderer/constants/index.ts | 1 + src/renderer/icons/dark/search.svg | 1 + src/renderer/icons/light/search.svg | 1 + src/renderer/index.html | 30 ++ .../modules/globalSearchManagement.ts | 488 ++++++++++++++++++ src/renderer/modules/initialization.ts | 4 + src/renderer/styles.scss | 273 ++++++++++ 7 files changed, 798 insertions(+) create mode 100644 src/renderer/icons/dark/search.svg create mode 100644 src/renderer/icons/light/search.svg create mode 100644 src/renderer/modules/globalSearchManagement.ts diff --git a/src/renderer/constants/index.ts b/src/renderer/constants/index.ts index fc8ea85c..b7d85434 100644 --- a/src/renderer/constants/index.ts +++ b/src/renderer/constants/index.ts @@ -35,6 +35,7 @@ export const ACTIVITY_BAR_ICONS = [ { id: "tools-icon", file: "tools.svg" }, { id: "connections-icon", file: "connections.svg" }, { id: "marketplace-icon", file: "marketplace.svg" }, + { id: "search-icon", file: "search.svg" }, { id: "debug-icon", file: "debug.svg" }, { id: "settings-icon", file: "settings.svg" }, ] as const; diff --git a/src/renderer/icons/dark/search.svg b/src/renderer/icons/dark/search.svg new file mode 100644 index 00000000..24380900 --- /dev/null +++ b/src/renderer/icons/dark/search.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/icons/light/search.svg b/src/renderer/icons/light/search.svg new file mode 100644 index 00000000..31e3c321 --- /dev/null +++ b/src/renderer/icons/light/search.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/index.html b/src/renderer/index.html index 75c2ecd8..61183502 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -29,6 +29,9 @@ + @@ -801,6 +804,33 @@

    Tool Settings

    + +
    +
    +
    + + + ESC +
    +
    + +
    +
    + ↑↓ navigate + ↵ select + ESC close +
    +
    +
    + diff --git a/src/renderer/modules/globalSearchManagement.ts b/src/renderer/modules/globalSearchManagement.ts new file mode 100644 index 00000000..bdef8374 --- /dev/null +++ b/src/renderer/modules/globalSearchManagement.ts @@ -0,0 +1,488 @@ +/** + * Global Search management module + * Implements a Command Palette-style global search over installed tools, + * marketplace tools, connections, and settings. + */ + +import { captureException, logInfo } from "../../common/sentryHelper"; +import type { DataverseConnection } from "../../common/types/connection"; +import type { Tool } from "../../common/types/tool"; +import type { ToolDetail } from "../types/index"; +import { escapeHtml } from "../utils/toolIconResolver"; +import { getToolLibrary, openToolDetail } from "./marketplaceManagement"; +import { switchSidebar } from "./sidebarManagement"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +type ResultCategory = "installed" | "marketplace" | "connection" | "settings"; + +interface SearchResult { + id: string; + name: string; + description: string; + category: ResultCategory; + iconUrl?: string; + action: () => void; +} + +// ── Module state ────────────────────────────────────────────────────────────── + +let isOpen = false; +let selectedIndex = -1; +let currentResults: SearchResult[] = []; + +// ── Static settings entries ─────────────────────────────────────────────────── + +const SETTINGS_ENTRIES: Array<{ name: string; description: string; focusId?: string }> = [ + { name: "Theme", description: "Change the application theme (light / dark / system)", focusId: "sidebar-theme-select" }, + { name: "Auto Update", description: "Configure automatic updates", focusId: "sidebar-auto-update-check" }, + { name: "Debug Menu", description: "Show or hide the debug / install panel", focusId: "sidebar-show-debug-menu-check" }, + { name: "Terminal Font", description: "Customize the integrated terminal font", focusId: "sidebar-terminal-font-select" }, + { name: "Deprecated Tools", description: "Control visibility of deprecated tools", focusId: "sidebar-deprecated-tools-select" }, + { name: "Tool Display Mode", description: "Choose standard or compact tool display", focusId: "sidebar-tool-display-mode-select" }, + { name: "Connections", description: "Manage Dataverse connections" }, + { name: "Installed Tools", description: "Browse installed tools" }, + { name: "Marketplace", description: "Browse and install tools from the marketplace" }, +]; + +// ── DOM helpers ─────────────────────────────────────────────────────────────── + +function getOverlay(): HTMLElement | null { + return document.getElementById("global-search-overlay"); +} + +function getInput(): HTMLInputElement | null { + return document.getElementById("global-search-input") as HTMLInputElement | null; +} + +function getResultsContainer(): HTMLElement | null { + return document.getElementById("global-search-results"); +} + +// ── Open / close ────────────────────────────────────────────────────────────── + +/** + * Open the global search command palette. + */ +export function openGlobalSearch(): void { + const overlay = getOverlay(); + const input = getInput(); + if (!overlay || !input) return; + + isOpen = true; + selectedIndex = -1; + currentResults = []; + + overlay.style.display = "flex"; + input.value = ""; + + // Sync input icon to current theme + syncInputIconTheme(); + + // Show empty / default state + renderResults([]); + + // Focus the input after the layout pass + requestAnimationFrame(() => { + input.focus(); + }); + + logInfo("Global search opened", {}); +} + +/** + * Close the global search command palette. + */ +export function closeGlobalSearch(): void { + const overlay = getOverlay(); + if (!overlay) return; + + isOpen = false; + selectedIndex = -1; + currentResults = []; + overlay.style.display = "none"; +} + +// ── Theme helpers ───────────────────────────────────────────────────────────── + +function syncInputIconTheme(): void { + const isDark = document.body.classList.contains("dark-theme"); + const icon = document.getElementById("global-search-input-icon") as HTMLImageElement | null; + if (icon) { + icon.src = isDark ? "icons/dark/search.svg" : "icons/light/search.svg"; + } +} + +// ── Settings focus helper ───────────────────────────────────────────────────── + +/** + * Switch to settings sidebar and focus/scroll a specific setting element. + */ +function navigateToSetting(focusId: string | undefined): void { + switchSidebar("settings"); + if (!focusId) return; + + // Wait for sidebar transition then focus/scroll the element + requestAnimationFrame(() => { + const el = document.getElementById(focusId) as HTMLElement | null; + if (!el) return; + el.scrollIntoView({ behavior: "smooth", block: "center" }); + el.focus(); + // Highlight briefly so the user sees the focused setting + el.classList.add("global-search-highlight"); + setTimeout(() => el.classList.remove("global-search-highlight"), 1500); + }); +} + +// ── Search ──────────────────────────────────────────────────────────────────── + +async function runSearch(query: string): Promise { + const q = query.trim().toLowerCase(); + const results: SearchResult[] = []; + + try { + // 1. Installed tools + const installedRaw = await window.toolboxAPI.getAllTools(); + const installedTools = installedRaw as Tool[]; + for (const tool of installedTools) { + if (matches(q, tool.name, tool.description)) { + const toolId = tool.id; + results.push({ + id: `installed:${toolId}`, + name: tool.name, + description: tool.description ?? "", + category: "installed", + action: () => { + closeGlobalSearch(); + // Dynamically import to avoid circular dependency + import("./toolManagement") + .then(({ launchTool }) => launchTool(toolId)) + .catch((err) => { + captureException(err instanceof Error ? err : new Error(String(err)), { + tags: { context: "global_search", action: "launch_tool" }, + level: "warning", + }); + }); + }, + }); + } + } + + // 2. Marketplace tools (already cached in memory) + const libraryTools: ToolDetail[] = getToolLibrary(); + const installedIds = new Set(installedTools.map((t) => t.id)); + for (const tool of libraryTools) { + // Skip tools already shown in installed list + if (installedIds.has(tool.id)) continue; + if (matches(q, tool.name, tool.description)) { + const toolSnapshot = tool; + results.push({ + id: `marketplace:${tool.id}`, + name: tool.name, + description: tool.description ?? "", + category: "marketplace", + action: () => { + closeGlobalSearch(); + openToolDetail(toolSnapshot, false).catch((err) => { + captureException(err instanceof Error ? err : new Error(String(err)), { + tags: { context: "global_search", action: "open_tool_detail" }, + level: "warning", + }); + }); + }, + }); + } + } + + // 3. Connections + const connectionsRaw = await window.toolboxAPI.connections.getAll(); + const connections = connectionsRaw as DataverseConnection[]; + for (const conn of connections) { + if (matches(q, conn.name, conn.url, conn.environment)) { + results.push({ + id: `connection:${conn.id}`, + name: conn.name, + description: `${conn.environment} · ${conn.url}`, + category: "connection", + action: () => { + closeGlobalSearch(); + switchSidebar("connections"); + }, + }); + } + } + + // 4. Settings entries + for (const entry of SETTINGS_ENTRIES) { + if (matches(q, entry.name, entry.description)) { + const focusId = entry.focusId; + const entryName = entry.name; + results.push({ + id: `settings:${entryName}`, + name: entryName, + description: entry.description, + category: "settings", + action: () => { + closeGlobalSearch(); + if (entryName === "Connections") { + switchSidebar("connections"); + } else if (entryName === "Installed Tools") { + switchSidebar("tools"); + } else if (entryName === "Marketplace") { + switchSidebar("marketplace"); + } else { + navigateToSetting(focusId); + } + }, + }); + } + } + } catch (err) { + captureException(err instanceof Error ? err : new Error(String(err)), { + tags: { context: "global_search", action: "run_search" }, + level: "warning", + }); + } + + currentResults = results; + selectedIndex = results.length > 0 ? 0 : -1; + renderResults(results); +} + +function matches(query: string, ...fields: (string | undefined)[]): boolean { + if (!query) return true; + return fields.some((f) => f && f.toLowerCase().includes(query)); +} + +// ── Rendering ───────────────────────────────────────────────────────────────── + +function renderResults(results: SearchResult[]): void { + const container = getResultsContainer(); + if (!container) return; + + if (results.length === 0) { + const input = getInput(); + const query = input?.value.trim() ?? ""; + if (!query) { + container.innerHTML = ` +
    + + Start typing to search tools, connections, and settings… +
    `; + syncEmptyIconTheme(); + } else { + container.innerHTML = ` +
    + + No results for "${escapeHtml(query)}" +
    `; + syncEmptyIconTheme(); + } + return; + } + + // Group results by category + const grouped: Record = { + installed: [], + marketplace: [], + connection: [], + settings: [], + }; + for (const r of results) { + grouped[r.category].push(r); + } + + const sectionOrder: ResultCategory[] = ["installed", "marketplace", "connection", "settings"]; + const sectionLabels: Record = { + installed: "Installed Tools", + marketplace: "Marketplace", + connection: "Connections", + settings: "Settings", + }; + const badgeClasses: Record = { + installed: "badge-installed", + marketplace: "badge-marketplace", + connection: "badge-connection", + settings: "badge-settings", + }; + const actionHints: Record = { + installed: "Launch", + marketplace: "View Details", + connection: "Go to Connections", + settings: "Go to Settings", + }; + + let html = ""; + let globalIdx = 0; + + for (const category of sectionOrder) { + const group = grouped[category]; + if (group.length === 0) continue; + + html += `
    ${escapeHtml(sectionLabels[category])}
    `; + for (const result of group) { + const isSelected = globalIdx === selectedIndex; + const badgeClass = badgeClasses[result.category]; + const hint = actionHints[result.category]; + html += ` +
    +
    + +
    +
    +
    ${escapeHtml(result.name)}
    +
    ${escapeHtml(result.description)}
    +
    + ${escapeHtml(sectionLabels[result.category])} +
    `; + globalIdx++; + } + html += `
    `; + } + + container.innerHTML = html; + syncEmptyIconTheme(); + + // Attach click listeners + container.querySelectorAll(".global-search-item").forEach((item) => { + item.addEventListener("click", () => { + const idx = parseInt(item.dataset["index"] ?? "-1", 10); + if (idx >= 0 && idx < currentResults.length) { + currentResults[idx]?.action(); + } + }); + }); +} + +function getDefaultIconForCategory(category: ResultCategory): string { + const isDark = document.body.classList.contains("dark-theme"); + const theme = isDark ? "dark" : "light"; + switch (category) { + case "installed": + return `icons/${theme}/tools.svg`; + case "marketplace": + return `icons/${theme}/marketplace.svg`; + case "connection": + return `icons/${theme}/connections.svg`; + case "settings": + return `icons/${theme}/settings.svg`; + } +} + +function syncEmptyIconTheme(): void { + const isDark = document.body.classList.contains("dark-theme"); + const emptyIcon = document.getElementById("global-search-empty-icon") as HTMLImageElement | null; + if (emptyIcon) { + emptyIcon.src = isDark ? "icons/dark/search.svg" : "icons/light/search.svg"; + } +} + +// ── Keyboard navigation ─────────────────────────────────────────────────────── + +function moveSelection(delta: number): void { + if (currentResults.length === 0) return; + + if (selectedIndex === -1) { + selectedIndex = delta > 0 ? 0 : currentResults.length - 1; + } else { + selectedIndex = (selectedIndex + delta + currentResults.length) % currentResults.length; + } + + updateSelectionUI(); +} + +function updateSelectionUI(): void { + const container = getResultsContainer(); + if (!container) return; + + container.querySelectorAll(".global-search-item").forEach((item) => { + const idx = parseInt(item.dataset["index"] ?? "-1", 10); + item.classList.toggle("selected", idx === selectedIndex); + item.setAttribute("aria-selected", String(idx === selectedIndex)); + }); + + // Scroll selected item into view + const selectedEl = container.querySelector(".global-search-item.selected"); + selectedEl?.scrollIntoView({ block: "nearest" }); +} + +function activateSelected(): void { + if (selectedIndex >= 0 && selectedIndex < currentResults.length) { + currentResults[selectedIndex]?.action(); + } +} + +// ── Event binding ───────────────────────────────────────────────────────────── + +/** + * Initialize the global search feature. + * Should be called once during application initialization. + */ +export function initializeGlobalSearch(): void { + // Activity bar search button + const searchBtn = document.getElementById("global-search-btn"); + if (searchBtn && !(searchBtn as HTMLElement & { _pptbBound?: boolean })._pptbBound) { + (searchBtn as HTMLElement & { _pptbBound?: boolean })._pptbBound = true; + searchBtn.addEventListener("click", () => openGlobalSearch()); + } + + // Overlay backdrop click → close + const overlay = getOverlay(); + if (overlay && !(overlay as HTMLElement & { _pptbBound?: boolean })._pptbBound) { + (overlay as HTMLElement & { _pptbBound?: boolean })._pptbBound = true; + overlay.addEventListener("click", (e) => { + if (e.target === overlay) closeGlobalSearch(); + }); + } + + // Search input + const input = getInput(); + if (input && !(input as HTMLElement & { _pptbBound?: boolean })._pptbBound) { + (input as HTMLElement & { _pptbBound?: boolean })._pptbBound = true; + + input.addEventListener("input", () => { + runSearch(input.value).catch((err) => { + captureException(err instanceof Error ? err : new Error(String(err)), { + tags: { context: "global_search", action: "input_search" }, + level: "warning", + }); + }); + }); + + input.addEventListener("keydown", (e: KeyboardEvent) => { + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + moveSelection(1); + break; + case "ArrowUp": + e.preventDefault(); + moveSelection(-1); + break; + case "Enter": + e.preventDefault(); + activateSelected(); + break; + case "Escape": + e.preventDefault(); + closeGlobalSearch(); + break; + } + }); + } + + // Global keyboard shortcut: Ctrl+Shift+P + document.addEventListener("keydown", (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === "P") { + e.preventDefault(); + if (isOpen) { + closeGlobalSearch(); + } else { + openGlobalSearch(); + } + } + }); + + logInfo("Global search initialized", {}); +} + diff --git a/src/renderer/modules/initialization.ts b/src/renderer/modules/initialization.ts index a03b5dd9..30fb3dfa 100644 --- a/src/renderer/modules/initialization.ts +++ b/src/renderer/modules/initialization.ts @@ -69,6 +69,7 @@ import { DEFAULT_TERMINAL_FONT, LOADING_SCREEN_FADE_DURATION } from "../constant import { handleCheckForUpdates, setupAutoUpdateListeners } from "./autoUpdateManagement"; import { initializeBrowserWindowModals } from "./browserWindowModals"; import { handleReauthentication, initializeAddConnectionModalBridge, loadSidebarConnections, openAddConnectionModal, updateFooterConnection } from "./connectionManagement"; +import { initializeGlobalSearch } from "./globalSearchManagement"; import { loadHomepageData, setupHomepageActions } from "./homepageManagement"; import { loadMarketplace, loadToolsLibrary } from "./marketplaceManagement"; import { closeModal, openModal } from "./modalManagement"; @@ -140,6 +141,9 @@ export async function initializeApplication(): Promise { // Set up homepage actions setupHomepageActions(); + // Set up global search command palette + initializeGlobalSearch(); + addBreadcrumb("UI components initialized", "init", "info"); // Load and apply theme settings on startup diff --git a/src/renderer/styles.scss b/src/renderer/styles.scss index 7c62a706..19c4084b 100644 --- a/src/renderer/styles.scss +++ b/src/renderer/styles.scss @@ -4490,3 +4490,276 @@ body.dark-theme .csp-warning ul { .context-menu-item span { flex: 1; } + +/* ============================================================ + Global Search Command Palette + ============================================================ */ + +.global-search-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 9999; + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 80px; + backdrop-filter: blur(4px); +} + +.global-search-container { + background: var(--bg-color); + border: 1px solid var(--border-color); + border-radius: 8px; + width: 680px; + max-width: calc(100vw - 48px); + max-height: calc(100vh - 160px); + box-shadow: var(--elevation-high); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.global-search-input-wrapper { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + border-bottom: 1px solid var(--border-color); + background: var(--bg-color); +} + +.global-search-input-icon { + width: 18px; + height: 18px; + opacity: 0.6; + flex-shrink: 0; +} + +.global-search-input { + flex: 1; + background: transparent; + border: none; + outline: none; + font-size: 15px; + color: var(--text-color); + caret-color: var(--accent-color); + font-family: inherit; +} + +.global-search-input::placeholder { + color: var(--text-secondary); +} + +.global-search-kbd { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px 6px; + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 11px; + color: var(--text-secondary); + background: var(--secondary-color); + font-family: inherit; + cursor: default; + flex-shrink: 0; +} + +.global-search-results { + flex: 1; + overflow-y: auto; + max-height: 480px; + padding: 8px 0; +} + +.global-search-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 32px 16px; + color: var(--text-secondary); + font-size: 14px; + gap: 8px; +} + +.global-search-empty-icon { + width: 32px; + height: 32px; + opacity: 0.4; +} + +.global-search-section-label { + padding: 6px 16px 4px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-secondary); + user-select: none; +} + +.global-search-item { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 16px; + cursor: pointer; + transition: background 0.1s; + border-radius: 0; + outline: none; +} + +.global-search-item:hover, +.global-search-item.selected { + background: var(--activity-item-hover-bg); +} + +.global-search-item.selected { + background: var(--activity-item-active-bg); + border-left: 2px solid var(--accent-color); + padding-left: 14px; +} + +.global-search-item-icon { + width: 24px; + height: 24px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + overflow: hidden; +} + +.global-search-item-icon img { + width: 20px; + height: 20px; + object-fit: contain; +} + +.global-search-item-text { + flex: 1; + min-width: 0; +} + +.global-search-item-name { + font-size: 13px; + font-weight: 500; + color: var(--text-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.global-search-item-desc { + font-size: 11px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 1px; +} + +.global-search-item-badge { + display: inline-flex; + align-items: center; + padding: 2px 7px; + border-radius: 10px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + flex-shrink: 0; +} + +.global-search-item-badge.badge-installed { + background: rgba(16, 124, 16, 0.15); + color: #107c10; +} + +.global-search-item-badge.badge-marketplace { + background: rgba(0, 120, 212, 0.12); + color: #0078d4; +} + +.global-search-item-badge.badge-connection { + background: rgba(255, 140, 0, 0.12); + color: #c87800; +} + +.global-search-item-badge.badge-settings { + background: rgba(102, 102, 102, 0.12); + color: #666; +} + +body.dark-theme .global-search-item-badge.badge-installed { + background: rgba(54, 189, 54, 0.15); + color: #36bd36; +} + +body.dark-theme .global-search-item-badge.badge-marketplace { + background: rgba(0, 180, 255, 0.15); + color: #29b6f6; +} + +body.dark-theme .global-search-item-badge.badge-connection { + background: rgba(255, 180, 60, 0.15); + color: #ffb43a; +} + +body.dark-theme .global-search-item-badge.badge-settings { + background: rgba(180, 180, 180, 0.15); + color: #b4b4b4; +} + +.global-search-footer { + display: flex; + align-items: center; + gap: 16px; + padding: 8px 16px; + border-top: 1px solid var(--border-color); + font-size: 11px; + color: var(--text-secondary); + background: var(--secondary-color); +} + +.global-search-footer kbd { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 1px 5px; + border: 1px solid var(--border-color); + border-radius: 3px; + font-size: 10px; + background: var(--bg-color); + font-family: inherit; + margin-right: 3px; +} + +.global-search-divider { + height: 1px; + background: var(--border-color); + margin: 4px 0; +} + +/* Highlight pulse for focused setting element */ +@keyframes global-search-pulse { + 0% { + outline: 2px solid transparent; + outline-offset: 2px; + } + 30% { + outline: 2px solid var(--accent-color); + outline-offset: 3px; + } + 100% { + outline: 2px solid transparent; + outline-offset: 2px; + } +} + +.global-search-highlight { + animation: global-search-pulse 1.5s ease-out; +} From 5a5eeca20c8c9ee6a3a615d3291902d6c5bbf1b2 Mon Sep 17 00:00:00 2001 From: Danish Naglekar <36135520+Power-Maverick@users.noreply.github.com> Date: Tue, 24 Feb 2026 17:15:09 -0500 Subject: [PATCH 029/257] Improve tool load time (#410) * Improve tool load time * Update src/renderer/index.html Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/main/managers/toolRegistryManager.ts | 54 ++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/src/main/managers/toolRegistryManager.ts b/src/main/managers/toolRegistryManager.ts index 4de4f092..f00b67d1 100644 --- a/src/main/managers/toolRegistryManager.ts +++ b/src/main/managers/toolRegistryManager.ts @@ -123,6 +123,18 @@ export class ToolRegistryManager extends EventEmitter { private installIdManager: InstallIdManager | null = null; private azureBlobBaseUrl: string; + // Registry fetch de-duping + caching + private registryFetchInFlight: Promise | null = null; + private registryCache: { + tools: ToolRegistryEntry[]; + fetchedAtMs: number; + source: "supabase" | "azureBlob" | "local"; + } | null = null; + + // Multiple renderer modules request the registry during startup (homepage stats, marketplace, etc.). + // Keep this short so the marketplace stays fresh, but long enough to prevent thrash. + private static readonly REGISTRY_CACHE_TTL_MS = 30_000; + constructor(toolsDirectory: string, supabaseUrl?: string, supabaseKey?: string, installIdManager?: InstallIdManager, azureBlobBaseUrl?: string) { super(); this.toolsDirectory = toolsDirectory; @@ -161,11 +173,47 @@ export class ToolRegistryManager extends EventEmitter { * Fetch the tool registry from Supabase database or local fallback */ async fetchRegistry(): Promise { - // Use remote/local fallback if Supabase is not configured - if (this.useLocalFallback) { - return this.fetchFallbackRegistry(); + const now = Date.now(); + + // Serve from cache when still fresh + if (this.registryCache && now - this.registryCache.fetchedAtMs < ToolRegistryManager.REGISTRY_CACHE_TTL_MS) { + return this.registryCache.tools; + } + + // If a fetch is already running, await it instead of starting another one. + if (this.registryFetchInFlight) { + return this.registryFetchInFlight; + } + + this.registryFetchInFlight = (async () => { + // Use remote/local fallback if Supabase is not configured + if (this.useLocalFallback) { + const tools = await this.fetchFallbackRegistry(); + this.registryCache = { + tools, + fetchedAtMs: Date.now(), + source: this.azureBlobBaseUrl ? "azureBlob" : "local", + }; + return tools; + } + + const tools = await this.fetchRegistryFromSupabase(); + this.registryCache = { + tools, + fetchedAtMs: Date.now(), + source: "supabase", + }; + return tools; + })(); + + try { + return await this.registryFetchInFlight; + } finally { + this.registryFetchInFlight = null; } + } + private async fetchRegistryFromSupabase(): Promise { try { logInfo(`[ToolRegistry] Fetching registry from Supabase (new schema)`); From 64db72919dff806b593916887815f161e6b80776 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 21:59:44 -0500 Subject: [PATCH 030/257] Add tool version compatibility checking with proper cache management (#378) * Initial plan * Add version compatibility types and backend logic Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Add UI components for tool version compatibility Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Add GitHub workflow steps for version sync and @pptb/types publishing Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Add comprehensive documentation for tool version compatibility feature Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Add implementation checklist for version compatibility feature Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Fix code review issues: improve version comparison and validation logic Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Fix documentation to match corrected implementation logic Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Fix compatibility logic to support forward compatibility as requested - Remove maxAPI restriction - tools built with older APIs work on newer ToolBox - Add MIN_SUPPORTED_API_VERSION check to prevent tools using deprecated APIs - Update documentation with correct examples matching user's requirements - Tool with minAPI="1.0.0", maxAPI="1.0.4" now works on ToolBox v1.0.5 Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * docs: enhance TOOL_VERSION_COMPATIBILITY.md with clearer formatting and examples for version checks * Refactor version logic into VersionManager and correct data flow - Create new VersionManager class to handle all version comparison and compatibility checking - Move compareVersions and isToolSupported functions from toolsManager to VersionManager - Update toolRegistryManager to only read version info from Supabase (not from package.json/npm-shrinkwrap) - Version data (min_api, max_api) is now pre-processed during tool intake and stored in database - Update documentation to reflect corrected data flow - toolsManager now uses VersionManager.isToolSupported() for compatibility checks Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Use app.getVersion() instead of TOOLBOX_VERSION constant and create reusable npm publish workflow - Replace TOOLBOX_VERSION constant with app.getVersion() call in VersionManager - Remove TOOLBOX_VERSION from constants.ts and vite.config.ts - Create standalone publish-npm-types.yml workflow with trusted publishing support - Update prod-release.yml to use reusable workflow for npm publishing - Update nightly-release.yml to use reusable workflow for npm beta publishing - Add id-token: write permission for npm trusted publishing - Add --provenance flag to pnpm publish for supply chain transparency Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Fix marketplace not showing unsupported tools - add min_api and max_api to Supabase query - Add min_api and max_api columns to Supabase SELECT query in toolRegistryManager - Compute isSupported field in fetchAvailableTools() using VersionManager - Include minAPI, maxAPI, and isSupported fields when mapping to ToolDetail in marketplace - Fix bug where tools with minAPI higher than current ToolBox version were showing as valid Scenario fixed: Tool with minAPI=1.2.2 on ToolBox v1.2.0 now correctly shows as "Not Supported" Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Disable install buttons for unsupported tools in marketplace and modal - Add disabled attribute to install button in standard marketplace view (was only in compact) - Pass isSupported field to tool detail modal view and controller - Disable install button in modal for unsupported tools with helpful tooltip - Add compatibility double-check in modal install handler - Prevent installation attempts for incompatible tools across all UI entry points Fixes: Tool detail modal now prevents installation of unsupported tools Fixes: Standard marketplace view install button now disabled for unsupported tools Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Fix installed tools not showing compatibility status or preventing launch - Add minAPI and maxAPI fields to createToolFromInstalledManifest - Compute isSupported field using VersionManager.isToolSupported() - Installed tools now correctly display "Not Supported" badge in sidebar - Launch prevention now works for installed tools that become incompatible Fixes scenario: Tool with minAPI=1.0.20, maxAPI=1.0.20 on ToolBox v1.2.0 (MIN_SUPPORTED_API=1.2.0) now shows as "Not Supported" in installed tools section and cannot be launched Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Fix cached tools not recomputing compatibility after ToolBox version changes - Always recompute isSupported field in getTool() when returning cached tool - Always recompute isSupported field in getAllTools() for cached tools - Ensures tools that become incompatible after ToolBox update are properly detected - Launch prevention now works correctly even for tools loaded before version update Root cause: Cached Tool objects retained old isSupported value computed at load time Fix: Recompute isSupported on every retrieval using current ToolBox version Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Add comprehensive logging to VersionManager for debugging compatibility issues - Add console.log statements at each decision point in isToolSupported() - Log minAPI, maxAPI, toolboxVersion, MIN_SUPPORTED_API_VERSION - Log comparison results for version checks - Log final compatibility decision (COMPATIBLE/INCOMPATIBLE) This will help identify why tools with minAPI=1.0.20 are still launching when MIN_SUPPORTED_API_VERSION=1.2.0 and toolbox version=1.2.0 Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Add stack trace logging to identify where legacy tool check is called from - Add stack trace to all isToolSupported() calls - Move logging before legacy check to capture ALL calls - This will help identify which code path is calling isToolSupported without version parameters Debug info will show exactly where in the code the second (legacy) call originates from Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Fix launch prevention by migrating version fields to installed tool manifests ROOT CAUSE IDENTIFIED: Installed tool manifest.json files don't have minAPI/maxAPI fields. When tools load for launch, they read from manifest without version info, causing isToolSupported() to return COMPATIBLE as "legacy tool". SOLUTION: Add manifest migration in loadTool(): - Check if installed manifest is missing version fields - Fetch version info from Supabase registry - Update local manifest.json with minAPI/maxAPI - Tool will now correctly show as INCOMPATIBLE and prevent launch NEW METHODS: - toolRegistryManager.getToolFromRegistry() - fetch tool from Supabase - toolRegistryManager.updateInstalledManifest() - update local manifest CLEANUP: - Removed all debug logging from VersionManager.isToolSupported() This fix ensures all installed tools (even those installed before version checking was implemented) will have their compatibility correctly determined. Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Fix launch prevention by clearing tool cache on uninstall ROOT CAUSE: Tools were remaining in memory cache after uninstall. When reinstalled with updated manifest (including minAPI/maxAPI), the old cached tool object (without version fields) was reused, causing isToolSupported() to be called with undefined values and return COMPATIBLE. FIX: Clear tool from cache when uninstalled. This ensures that on reinstall, the tool is reloaded from the new manifest with correct version information. REVERTED: Previous manifest migration commit (9713490) - was unnecessary and incorrect approach. CLEANUP: Removed all debug logging from VersionManager.isToolSupported() This fix ensures tools properly show "Not Supported" badge and prevent launch when incompatible, even after uninstall/reinstall cycles. Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Added proper fix after copilot wasnt able to fix it :) * Update src/renderer/modals/toolDetail/controller.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: clarify minAPI and maxAPI comments in Tool interface --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> Co-authored-by: Power-Maverick Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/nightly-release.yml | 10 + .github/workflows/prod-release.yml | 31 + .github/workflows/publish-npm-types.yml | 52 ++ docs/IMPLEMENTATION_CHECKLIST.md | 429 +++++++++++++ docs/SUPABASE_SCHEMA_UPDATES.md | 467 ++++++++++++++ docs/TOOL_VERSION_COMPATIBILITY.md | 577 ++++++++++++++++++ src/common/ipc/channels.ts | 1 + src/common/types/api.ts | 1 + src/common/types/tool.ts | 13 + src/common/utils/version.ts | 27 + src/main/constants.ts | 8 + src/main/index.ts | 9 + src/main/managers/toolRegistryManager.ts | 24 + src/main/managers/toolsManager.ts | 34 +- src/main/managers/versionManager.ts | 71 +++ src/main/preload.ts | 1 + src/renderer/modals/toolDetail/controller.ts | 9 + src/renderer/modals/toolDetail/view.ts | 3 +- src/renderer/modules/marketplaceManagement.ts | 20 +- src/renderer/modules/toolManagement.ts | 20 + .../modules/toolsSidebarManagement.ts | 11 +- src/renderer/styles.scss | 58 ++ src/renderer/types/index.ts | 3 + src/renderer/utils/toolCompatibility.ts | 81 +++ 24 files changed, 1948 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/publish-npm-types.yml create mode 100644 docs/IMPLEMENTATION_CHECKLIST.md create mode 100644 docs/SUPABASE_SCHEMA_UPDATES.md create mode 100644 docs/TOOL_VERSION_COMPATIBILITY.md create mode 100644 src/common/utils/version.ts create mode 100644 src/main/managers/versionManager.ts create mode 100644 src/renderer/utils/toolCompatibility.ts diff --git a/.github/workflows/nightly-release.yml b/.github/workflows/nightly-release.yml index 95721351..550fd5a9 100644 --- a/.github/workflows/nightly-release.yml +++ b/.github/workflows/nightly-release.yml @@ -885,3 +885,13 @@ jobs: make_latest: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + publish-types-beta: + needs: publish-release + if: needs.check-commits.outputs.should_build == 'true' + uses: ./.github/workflows/publish-npm-types.yml + with: + branch: dev + tag: beta + secrets: inherit + diff --git a/.github/workflows/prod-release.yml b/.github/workflows/prod-release.yml index 8bf7b2a6..8e7d8aa6 100644 --- a/.github/workflows/prod-release.yml +++ b/.github/workflows/prod-release.yml @@ -39,6 +39,28 @@ jobs: echo "Release notes validation passed for Power Platform ToolBox $CURRENT_VERSION." + - name: Validate @pptb/types version matches ToolBox version + shell: bash + run: | + TOOLBOX_VERSION=$(node -p "require('./package.json').version") + TYPES_VERSION=$(node -p "require('./packages/package.json').version") + + # Extract major.minor.patch from both versions (ignore pre-release tags) + TOOLBOX_BASE=$(echo "$TOOLBOX_VERSION" | cut -d'-' -f1) + TYPES_BASE=$(echo "$TYPES_VERSION" | cut -d'-' -f1) + + if [ "$TOOLBOX_BASE" != "$TYPES_BASE" ]; then + echo "❌ Error: @pptb/types version ($TYPES_VERSION) does not match ToolBox version ($TOOLBOX_VERSION)" + echo "The base version (major.minor.patch) must be identical for stable releases." + echo "" + echo "To fix this:" + echo "1. Update packages/package.json version to match $TOOLBOX_VERSION" + echo "2. Commit the change and push" + exit 1 + fi + + echo "✅ Version validation passed: ToolBox $TOOLBOX_VERSION matches @pptb/types $TYPES_VERSION" + build: needs: preflight if: > @@ -805,3 +827,12 @@ jobs: fail_on_unmatched_files: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + publish-types: + needs: publish-release + uses: ./.github/workflows/publish-npm-types.yml + with: + branch: main + tag: latest + secrets: inherit + diff --git a/.github/workflows/publish-npm-types.yml b/.github/workflows/publish-npm-types.yml new file mode 100644 index 00000000..8fa23296 --- /dev/null +++ b/.github/workflows/publish-npm-types.yml @@ -0,0 +1,52 @@ +name: Publish @pptb/types to npm + +on: + workflow_call: + inputs: + branch: + description: "Branch to checkout" + required: true + type: string + tag: + description: "npm tag (latest or beta)" + required: true + type: string + +permissions: + contents: read + id-token: write # Required for npm trusted publishing + +jobs: + publish-types: + runs-on: ubuntu-latest + + steps: + - name: Checkout branch + uses: actions/checkout@v4 + with: + ref: ${{ inputs.branch }} + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + registry-url: "https://registry.npmjs.org" + + - name: Install pnpm + run: npm install -g pnpm@10.18.3 + + - name: Get version + id: version + run: | + VERSION=$(node -p "require('./packages/package.json').version") + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "📦 Publishing @pptb/types version: $VERSION with tag: ${{ inputs.tag }}" + + - name: Publish @pptb/types to npm + working-directory: ./packages + run: | + echo "Publishing @pptb/types@${{ steps.version.outputs.version }} with tag ${{ inputs.tag }} to npm..." + pnpm publish --access public --tag ${{ inputs.tag }} --no-git-checks --provenance + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/docs/IMPLEMENTATION_CHECKLIST.md b/docs/IMPLEMENTATION_CHECKLIST.md new file mode 100644 index 00000000..0229d3a6 --- /dev/null +++ b/docs/IMPLEMENTATION_CHECKLIST.md @@ -0,0 +1,429 @@ +# Implementation Checklist: Tool Version Compatibility Feature + +This document provides a step-by-step checklist for implementing the tool version compatibility feature across all systems and processes. + +## Overview + +This feature allows tools to declare version compatibility requirements, preventing users from installing or using tools that are incompatible with their ToolBox version. + +--- + +## ✅ Phase 1: Application Code (COMPLETED) + +### Backend Changes + +- [x] Add `minAPI` field to `ToolFeatures` interface (`src/common/types/tool.ts`) +- [x] Add `minAPI`, `maxAPI`, and `isSupported` fields to `Tool` interface +- [x] Add `minAPI` and `maxAPI` fields to `ToolManifest` interface +- [x] Add `minAPI` and `maxAPI` fields to `ToolRegistryEntry` interface +- [x] Add `TOOLBOX_VERSION` and `MIN_SUPPORTED_API_VERSION` constants (`src/main/constants.ts`) +- [x] Create `compareVersions()` utility function in `toolsManager.ts` +- [x] Create `isToolSupported()` compatibility check function +- [x] Update `loadToolFromManifest()` to set version fields and compatibility status +- [x] Update `installTool()` to extract `minAPI` from package.json +- [x] Update `installTool()` to extract `maxAPI` from npm-shrinkwrap.json +- [x] Update Supabase schema mappings to include `min_api` and `max_api` +- [x] Update local registry interface to support version fields + +### UI Changes + +- [x] Add version compatibility check in `launchTool()` function +- [x] Show warning notification when launching unsupported tool +- [x] Add `isUnsupported` check in sidebar tool rendering +- [x] Add "Not Supported" badge HTML for sidebar tools +- [x] Add CSS class `unsupported` to unsupported tool items in sidebar +- [x] Add `isUnsupported` check in marketplace tool rendering +- [x] Add "Not Supported" badge HTML for marketplace tools +- [x] Add CSS class `unsupported` to unsupported tool items in marketplace +- [x] Disable install button for unsupported tools in marketplace +- [x] Add CSS styles for `.tool-unsupported-badge` +- [x] Add CSS styles for `.tool-item-pptb.unsupported` +- [x] Add CSS styles for `.marketplace-item-unsupported-badge` +- [x] Add CSS styles for `.marketplace-item-pptb.unsupported` +- [x] Update `ToolDetail` interface to include version fields + +### Build Configuration + +- [x] Update `vite.config.ts` to inject `TOOLBOX_VERSION` at build time +- [x] Verify TypeScript compilation succeeds +- [x] Verify linting passes + +--- + +## ✅ Phase 2: GitHub Workflows (COMPLETED) + +### Stable Release Workflow + +- [x] Add version validation step in `prod-release.yml` preflight job +- [x] Check that @pptb/types version matches ToolBox version +- [x] Add `publish-types` job after `publish-release` +- [x] Setup Node.js with npm registry authentication +- [x] Publish @pptb/types to npm with `latest` tag +- [x] Use `NPM_TOKEN` secret for authentication + +### Nightly Release Workflow + +- [x] Add `publish-types-beta` job after `publish-release` +- [x] Setup Node.js with npm registry authentication +- [x] Publish @pptb/types to npm with `beta` tag +- [x] Use `NPM_TOKEN` secret for authentication + +--- + +## ✅ Phase 3: Documentation (COMPLETED) + +- [x] Create `TOOL_VERSION_COMPATIBILITY.md` with: + - [x] Overview and version compatibility rules + - [x] Guide for tool developers + - [x] Guide for ToolBox maintainers + - [x] Guide for registry administrators + - [x] Technical implementation details + - [x] User experience documentation + - [x] Troubleshooting guide + +- [x] Create `SUPABASE_SCHEMA_UPDATES.md` with: + - [x] Database schema changes + - [x] Migration scripts + - [x] Validation rules + - [x] API changes + - [x] Testing procedures + - [x] Monitoring queries + +- [x] Create this implementation checklist + +--- + +## ⏳ Phase 4: Database Updates (PENDING - EXTERNAL) + +### Supabase Schema Migration + +- [x] Connect to Supabase SQL Editor +- [x] Run schema update script: + ```sql + ALTER TABLE tools + ADD COLUMN IF NOT EXISTS min_api TEXT, + ADD COLUMN IF NOT EXISTS max_api TEXT; + ``` +- [ ] Add column comments: + ```sql + COMMENT ON COLUMN tools.min_api IS 'Minimum ToolBox API version required'; + COMMENT ON COLUMN tools.max_api IS 'Maximum ToolBox API version tested'; + ``` +- [x] Create performance indexes: + ```sql + CREATE INDEX IF NOT EXISTS idx_tools_min_api ON tools(min_api) + WHERE min_api IS NOT NULL; + CREATE INDEX IF NOT EXISTS idx_tools_max_api ON tools(max_api) + WHERE max_api IS NOT NULL; + CREATE INDEX IF NOT EXISTS idx_tools_versions ON tools(min_api, max_api) + WHERE min_api IS NOT NULL AND max_api IS NOT NULL; + ``` +- [x] Verify indexes were created: + ```sql + SELECT indexname, indexdef + FROM pg_indexes + WHERE tablename = 'tools' AND indexname LIKE '%api%'; + ``` +- [x] Test with sample data insertion +- [ ] Document rollback procedure + +### Data Migration Strategy + +- [ ] Decide on strategy for existing tools: + - Option A: Leave as NULL (backward compatible) + - Option B: Set to default version (e.g., "1.0.0") + - Option C: Backfill from tool packages +- [ ] If backfilling, develop extraction script +- [ ] Test backfill script on subset of tools +- [ ] Execute backfill for all tools +- [ ] Verify data quality + +### Monitoring Setup + +- [ ] Create dashboard for version statistics +- [ ] Set up alerts for tools without version info +- [ ] Monitor query performance after migration + +--- + +## ⏳ Phase 5: GitHub Repository Setup (PENDING - EXTERNAL) + +### Repository Secrets + +- [ ] Generate npm access token: + 1. Log into npm account + 2. Navigate to Access Tokens + 3. Generate new "Automation" token + 4. Copy token value +- [ ] Add `NPM_TOKEN` secret to GitHub repository: + 1. Go to repository Settings → Secrets and variables → Actions + 2. Click "New repository secret" + 3. Name: `NPM_TOKEN` + 4. Value: [paste token] + 5. Click "Add secret" +- [ ] Verify token has publish permissions for `@pptb` scope +- [ ] Test token with manual publish (optional) + +### Verify Existing Secrets + +- [ ] Confirm `SUPABASE_URL` is set +- [ ] Confirm `SUPABASE_ANON_KEY` is set +- [ ] Confirm `SENTRY_DSN` is set (optional) +- [ ] Confirm `SENTRY_AUTH_TOKEN` is set (optional) + +--- + +## ⏳ Phase 6: Tool Registry/Intake System (PENDING - EXTERNAL) + +### Update Intake Process + +- [ ] Modify tool submission form to mention version requirements +- [ ] Update submission validation to check for: + - [ ] `features.minAPI` in package.json + - [ ] Valid semver format for minAPI + - [ ] Presence of npm-shrinkwrap.json + - [ ] `@pptb/types` in shrinkwrap dependencies +- [ ] Add extraction logic: + - [ ] Read `package.json` → extract `features.minAPI` + - [ ] Read `npm-shrinkwrap.json` → extract `@pptb/types` version + - [ ] Remove semver prefixes (^, ~) from maxAPI +- [ ] Add validation logic: + - [ ] Validate semver format + - [ ] Check minAPI <= maxAPI if both present + - [ ] Check minAPI >= MIN_SUPPORTED_API_VERSION +- [ ] Update database insert/update to include version fields +- [ ] Test with sample tool submission + +### Update Rejection Criteria + +Add to tool submission guidelines: + +- [ ] Tools must include `features.minAPI` in package.json +- [ ] Tools must include npm-shrinkwrap.json +- [ ] Tools must have `@pptb/types` in devDependencies +- [ ] Version format must be valid semver + +### Update Local Registry + +- [ ] Update `src/main/data/registry.json` with version fields +- [ ] Add minAPI and maxAPI to existing tools +- [ ] Commit updated registry + +--- + +## ⏳ Phase 7: Communication & Rollout (PENDING - EXTERNAL) + +### Tool Developer Communication + +- [ ] Draft announcement email/post +- [ ] Include: + - [ ] Feature overview + - [ ] Why it matters + - [ ] How to update existing tools + - [ ] Link to documentation + - [ ] Migration deadline (if any) +- [ ] Post announcement in: + - [ ] GitHub Discussions + - [ ] Discord/Slack community + - [ ] Developer newsletter + - [ ] Blog post +- [ ] Send direct emails to active tool developers + +### User Communication + +- [ ] Update main documentation/wiki +- [ ] Add section to user guide explaining: + - [ ] What "Not Supported" badge means + - [ ] How to update ToolBox + - [ ] What to do if tool shows as unsupported +- [ ] Create FAQ entries +- [ ] Prepare support team with common questions + +### Release Notes + +- [ ] Add to CHANGELOG.md: + + ```markdown + ## [Version X.Y.Z] - YYYY-MM-DD + + ### Added + + - Tool version compatibility checking + - Visual indicators for unsupported tools + - Automatic @pptb/types publishing in release workflow + + ### Changed + + - Tools now require minimum version specification + - Install button disabled for incompatible tools + ``` + +--- + +## ⏳ Phase 8: Testing & Validation (PENDING) + +### Manual Testing + +- [ ] **Test 1: Tool Installation** + 1. Install a test tool with version info + 2. Verify minAPI and maxAPI are captured in manifest.json + 3. Check tool displays correctly in sidebar + +- [ ] **Test 2: Compatibility Check** + 1. Temporarily modify MIN_SUPPORTED_API_VERSION + 2. Verify tool shows "Not Supported" badge + 3. Attempt to launch tool + 4. Verify warning notification appears + 5. Restore original constant + +- [ ] **Test 3: UI Display** + 1. View tool in sidebar (both compact and standard modes) + 2. View tool in marketplace + 3. Verify badge appears correctly + 4. Verify install button is disabled + 5. Check visual styling (opacity, border) + +- [ ] **Test 4: Legacy Tools** + 1. Install a tool without version info + 2. Verify it's treated as compatible (no badge) + 3. Verify it can be launched normally + +- [ ] **Test 5: Marketplace Filtering** + 1. Browse marketplace with various ToolBox versions + 2. Verify unsupported tools are clearly marked + 3. Verify install button behavior + +### Automated Testing + +- [ ] Write unit tests for `compareVersions()` function +- [ ] Write unit tests for `isToolSupported()` function +- [ ] Test version extraction from package.json +- [ ] Test version extraction from npm-shrinkwrap.json +- [ ] Test database schema with sample data + +### Cross-Platform Testing + +- [ ] Test on Windows 10/11 +- [ ] Test on macOS (Intel) +- [ ] Test on macOS (Apple Silicon) +- [ ] Test on Linux (Ubuntu/Debian) +- [ ] Verify consistent behavior across platforms + +### Workflow Testing + +- [ ] Create test PR to trigger workflows +- [ ] Verify version validation step works +- [ ] Verify @pptb/types publishing works (can use beta channel) +- [ ] Test with intentional version mismatch +- [ ] Verify workflow fails appropriately + +--- + +## ⏳ Phase 9: Monitoring & Iteration (ONGOING) + +### Week 1 After Release + +- [ ] Monitor error logs for version-related issues +- [ ] Track support tickets related to version compatibility +- [ ] Gather user feedback +- [ ] Monitor tool submission issues + +### Week 2-4 After Release + +- [ ] Analyze adoption rate by tool developers +- [ ] Identify tools still missing version info +- [ ] Reach out to developers of popular tools +- [ ] Refine documentation based on feedback + +### Ongoing + +- [ ] Monthly review of version distribution +- [ ] Quarterly review of MIN_SUPPORTED_API_VERSION +- [ ] Track feature requests and improvements +- [ ] Document lessons learned + +--- + +## 🚨 Rollback Plan + +If critical issues are discovered: + +### Application Rollback + +1. Revert PR commits +2. Redeploy previous version +3. Notify users + +### Database Rollback + +1. Backup current data: + ```sql + CREATE TABLE tools_version_backup AS + SELECT id, min_api, max_api FROM tools; + ``` +2. Drop indexes and constraints +3. Remove columns +4. Restore from backup if needed + +### Communication + +- [ ] Post rollback announcement +- [ ] Explain issues encountered +- [ ] Provide timeline for re-implementation +- [ ] Thank community for patience + +--- + +## ✅ Success Criteria + +The feature is considered successfully implemented when: + +- [x] All Phase 1 (Application Code) tasks complete +- [x] All Phase 2 (GitHub Workflows) tasks complete +- [x] All Phase 3 (Documentation) tasks complete +- [ ] All Phase 4 (Database) tasks complete +- [ ] All Phase 5 (GitHub Setup) tasks complete +- [ ] All Phase 6 (Intake System) tasks complete +- [ ] All Phase 7 (Communication) tasks complete +- [ ] All Phase 8 (Testing) tasks complete +- [ ] No critical bugs in production for 2 weeks +- [ ] Positive community feedback +- [ ] At least 50% of active tools updated with version info + +--- + +## Resources + +- **Documentation**: + - `docs/TOOL_VERSION_COMPATIBILITY.md` + - `docs/SUPABASE_SCHEMA_UPDATES.md` +- **Code Changes**: + - `src/common/types/tool.ts` + - `src/main/constants.ts` + - `src/main/managers/toolsManager.ts` + - `src/main/managers/toolRegistryManager.ts` + - `src/renderer/modules/toolManagement.ts` + - `src/renderer/modules/toolsSidebarManagement.ts` + - `src/renderer/modules/marketplaceManagement.ts` + - `src/renderer/styles.scss` + +- **Workflows**: + - `.github/workflows/prod-release.yml` + - `.github/workflows/nightly-release.yml` + +- **External Resources**: + - [Semantic Versioning](https://semver.org/) + - [npm Shrinkwrap Docs](https://docs.npmjs.com/cli/v8/commands/npm-shrinkwrap) + - [Supabase Documentation](https://supabase.com/docs) + +--- + +## Notes + +- This checklist should be reviewed and updated as implementation progresses +- Mark items as complete with timestamps and assignee names +- Document any deviations from the plan +- Keep stakeholders informed of progress + +**Last Updated**: 2026-02-11 +**Status**: Phases 1-3 Complete, Phases 4-9 Pending External Action diff --git a/docs/SUPABASE_SCHEMA_UPDATES.md b/docs/SUPABASE_SCHEMA_UPDATES.md new file mode 100644 index 00000000..e20261af --- /dev/null +++ b/docs/SUPABASE_SCHEMA_UPDATES.md @@ -0,0 +1,467 @@ +# Supabase Database Schema Updates for Tool Version Compatibility + +This document outlines the database schema changes required to support tool version compatibility in the Power Platform ToolBox marketplace. + +## Overview + +The tool version compatibility feature requires storing minimum and maximum API version information for each tool in the registry. This allows the application to determine which tools are compatible with a given ToolBox version. + +--- + +## Schema Changes + +### 1. Add Version Columns to `tools` Table + +Execute the following SQL in your Supabase SQL Editor: + +```sql +-- Add min_api and max_api columns to the tools table +ALTER TABLE tools + ADD COLUMN IF NOT EXISTS min_api TEXT, + ADD COLUMN IF NOT EXISTS max_api TEXT; + +-- Add comments to explain the columns +COMMENT ON COLUMN tools.min_api IS 'Minimum ToolBox API version required by this tool (from package.json features.minAPI)'; +COMMENT ON COLUMN tools.max_api IS 'Maximum ToolBox API version tested with this tool (from npm-shrinkwrap @pptb/types version)'; +``` + +### 2. Create Indexes for Performance + +Add indexes to improve query performance when filtering by version: + +```sql +-- Create indexes on version columns for faster lookups +CREATE INDEX IF NOT EXISTS idx_tools_min_api ON tools(min_api) + WHERE min_api IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_tools_max_api ON tools(max_api) + WHERE max_api IS NOT NULL; + +-- Composite index for version range queries +CREATE INDEX IF NOT EXISTS idx_tools_versions ON tools(min_api, max_api) + WHERE min_api IS NOT NULL AND max_api IS NOT NULL; +``` + +### 3. Update Row Level Security (RLS) Policies + +If you have RLS enabled, ensure the new columns are included: + +```sql +-- No changes needed to RLS policies - the columns follow the same access pattern +-- Just verify that SELECT policies allow reading these columns + +-- Example verification query: +SELECT + schemaname, + tablename, + policyname, + permissive, + roles, + cmd, + qual, + with_check +FROM pg_policies +WHERE tablename = 'tools'; +``` + +--- + +## Data Migration + +### Option A: Set Defaults for Existing Tools + +For tools that already exist without version information: + +```sql +-- Option 1: Set to NULL (allows all versions - backward compatible) +-- No action needed - columns default to NULL + +-- Option 2: Set to earliest supported version +UPDATE tools +SET + min_api = '1.0.0', + max_api = '1.1.3' -- Current latest version +WHERE min_api IS NULL; +``` + +**Recommendation:** Leave as NULL for existing tools to maintain backward compatibility. Tools without version info are assumed compatible with all versions. + +### Option B: Backfill from Tool Packages + +If you have access to the tool packages, you can extract the version information: + +```javascript +// Example Node.js script to backfill version data +const { createClient } = require('@supabase/supabase-js'); +const fs = require('fs'); +const path = require('path'); + +const supabase = createClient( + process.env.SUPABASE_URL, + process.env.SUPABASE_SERVICE_ROLE_KEY +); + +async function backfillVersions() { + // Get all tools without version info + const { data: tools, error } = await supabase + .from('tools') + .select('id, packagename') + .is('min_api', null); + + for (const tool of tools) { + try { + // Download and extract tool package + const packageJsonPath = `./temp/${tool.id}/package.json`; + const shrinkwrapPath = `./temp/${tool.id}/npm-shrinkwrap.json`; + + if (fs.existsSync(packageJsonPath)) { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); + const minAPI = packageJson.features?.minAPI; + + let maxAPI = null; + if (fs.existsSync(shrinkwrapPath)) { + const shrinkwrap = JSON.parse(fs.readFileSync(shrinkwrapPath, 'utf-8')); + const typesVersion = shrinkwrap.dependencies?.['@pptb/types']?.version; + maxAPI = typesVersion?.replace(/^\^|~/, ''); + } + + // Update database + await supabase + .from('tools') + .update({ min_api: minAPI, max_api: maxAPI }) + .eq('id', tool.id); + + console.log(`Updated ${tool.id}: minAPI=${minAPI}, maxAPI=${maxAPI}`); + } + } catch (error) { + console.error(`Failed to process ${tool.id}:`, error); + } + } +} + +backfillVersions(); +``` + +--- + +## Validation Rules + +### Database-Level Constraints + +Add check constraints to ensure data quality: + +```sql +-- Ensure versions follow semver format (basic check) +ALTER TABLE tools + ADD CONSTRAINT check_min_api_format + CHECK (min_api IS NULL OR min_api ~ '^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?$'); + +ALTER TABLE tools + ADD CONSTRAINT check_max_api_format + CHECK (max_api IS NULL OR max_api ~ '^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?$'); + +-- Note: These are basic checks. Full semver validation should happen in application code. +``` + +### Application-Level Validation + +In your tool intake/update API: + +```typescript +interface ToolSubmission { + id: string; + name: string; + version: string; + minAPI?: string; + maxAPI?: string; + // ... other fields +} + +function validateToolVersions(tool: ToolSubmission): string[] { + const errors: string[] = []; + + // Validate minAPI format + if (tool.minAPI && !isValidSemver(tool.minAPI)) { + errors.push('Invalid minAPI format. Must be valid semver (e.g., 1.0.0)'); + } + + // Validate maxAPI format + if (tool.maxAPI && !isValidSemver(tool.maxAPI)) { + errors.push('Invalid maxAPI format. Must be valid semver (e.g., 1.0.0)'); + } + + // Ensure minAPI <= maxAPI if both are provided + if (tool.minAPI && tool.maxAPI) { + if (compareVersions(tool.minAPI, tool.maxAPI) > 0) { + errors.push('minAPI cannot be greater than maxAPI'); + } + } + + return errors; +} +``` + +--- + +## API Changes + +### 1. Update Tool Query + +Update your existing tool query to include the new fields: + +```sql +SELECT + t.id, + t.name, + t.version, + t.description, + t.downloadurl, + t.iconurl, + t.min_api, -- NEW + t.max_api, -- NEW + -- ... other fields +FROM tools t +WHERE t.status = 'active'; +``` + +### 2. Update Tool Insert/Update + +When creating or updating tools: + +```sql +INSERT INTO tools ( + id, + name, + version, + min_api, -- NEW + max_api, -- NEW + -- ... other fields +) VALUES ( + $1, $2, $3, $4, $5, ... +) +ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + version = EXCLUDED.version, + min_api = EXCLUDED.min_api, -- NEW + max_api = EXCLUDED.max_api, -- NEW + updated_at = NOW(); +``` + +### 3. Add Compatibility Filter Endpoint + +Create a new API endpoint or update existing ones to filter by compatibility: + +```typescript +// Example Supabase Edge Function +export async function getCompatibleTools(toolboxVersion: string) { + const { data, error } = await supabase + .from('tools') + .select('*') + .or(`min_api.is.null,min_api.lte.${toolboxVersion}`) + .or(`max_api.is.null,max_api.gte.${toolboxVersion}`) + .eq('status', 'active'); + + return data; +} +``` + +**Note:** Version comparison in SQL is complex. It's recommended to do filtering in the application layer for semantic versioning. + +--- + +## Testing + +### 1. Verify Schema Changes + +```sql +-- Check columns exist +SELECT + column_name, + data_type, + is_nullable +FROM information_schema.columns +WHERE table_name = 'tools' + AND column_name IN ('min_api', 'max_api'); + +-- Expected output: +-- column_name | data_type | is_nullable +-- min_api | text | YES +-- max_api | text | YES +``` + +### 2. Verify Indexes + +```sql +-- Check indexes exist +SELECT + indexname, + indexdef +FROM pg_indexes +WHERE tablename = 'tools' + AND indexname LIKE '%api%'; + +-- Expected output should include: +-- idx_tools_min_api +-- idx_tools_max_api +-- idx_tools_versions +``` + +### 3. Test Data + +Insert test data to verify the schema: + +```sql +-- Insert test tool with version info +INSERT INTO tools ( + id, + name, + version, + description, + downloadurl, + iconurl, + min_api, + max_api, + status +) VALUES ( + 'test-tool-001', + 'Test Tool', + '1.0.0', + 'A test tool for version compatibility', + 'https://example.com/test-tool.tgz', + 'https://example.com/icon.svg', + '1.0.0', + '1.1.3', + 'active' +); + +-- Verify insertion +SELECT id, name, min_api, max_api FROM tools WHERE id = 'test-tool-001'; + +-- Clean up +DELETE FROM tools WHERE id = 'test-tool-001'; +``` + +--- + +## Monitoring and Analytics + +### Useful Queries + +**1. Tools with version information:** + +```sql +SELECT + COUNT(*) FILTER (WHERE min_api IS NOT NULL) as with_min_api, + COUNT(*) FILTER (WHERE max_api IS NOT NULL) as with_max_api, + COUNT(*) FILTER (WHERE min_api IS NOT NULL AND max_api IS NOT NULL) as with_both, + COUNT(*) as total +FROM tools +WHERE status = 'active'; +``` + +**2. Version distribution:** + +```sql +SELECT + min_api, + COUNT(*) as tool_count +FROM tools +WHERE status = 'active' AND min_api IS NOT NULL +GROUP BY min_api +ORDER BY min_api DESC; +``` + +**3. Tools potentially incompatible with a version:** + +```sql +-- Note: This is a simple string comparison, not proper semver +-- Use this for monitoring only, not for actual compatibility checks +SELECT + id, + name, + version, + min_api, + max_api +FROM tools +WHERE status = 'active' + AND (min_api > '1.0.0' OR max_api < '1.1.3'); +``` + +--- + +## Rollback Plan + +If you need to rollback the changes: + +```sql +-- 1. Drop indexes +DROP INDEX IF EXISTS idx_tools_versions; +DROP INDEX IF EXISTS idx_tools_max_api; +DROP INDEX IF EXISTS idx_tools_min_api; + +-- 2. Drop constraints (if added) +ALTER TABLE tools DROP CONSTRAINT IF EXISTS check_min_api_format; +ALTER TABLE tools DROP CONSTRAINT IF EXISTS check_max_api_format; + +-- 3. Remove columns +ALTER TABLE tools DROP COLUMN IF EXISTS min_api; +ALTER TABLE tools DROP COLUMN IF EXISTS max_api; +``` + +**Warning:** This will permanently delete version data. Create a backup first: + +```sql +-- Backup version data before rollback +CREATE TABLE tools_version_backup AS +SELECT id, min_api, max_api FROM tools; +``` + +--- + +## Support and Troubleshooting + +### Common Issues + +**Issue 1: Column not found** +``` +Error: column "min_api" does not exist +``` +**Solution:** Run the ALTER TABLE command to add the columns. + +**Issue 2: Invalid semver format** +``` +Error: new row violates check constraint "check_min_api_format" +``` +**Solution:** Ensure version strings follow semantic versioning format (X.Y.Z). + +**Issue 3: Performance degradation** +``` +Query taking too long to filter by version +``` +**Solution:** +- Verify indexes are created +- Analyze query plan: `EXPLAIN ANALYZE SELECT ... WHERE min_api ...` +- Consider application-level filtering instead of database + +--- + +## Next Steps + +After completing the database changes: + +1. ✅ Verify schema changes are applied +2. ✅ Test with sample data +3. ✅ Update API endpoints to return new fields +4. ✅ Update tool submission process to capture version data +5. ✅ Monitor tools without version info +6. ✅ Reach out to tool developers for updates +7. ✅ Document version requirements in tool submission guidelines + +--- + +## References + +- [Supabase SQL Editor](https://supabase.com/docs/guides/database/sql-editor) +- [PostgreSQL ALTER TABLE](https://www.postgresql.org/docs/current/sql-altertable.html) +- [PostgreSQL Indexes](https://www.postgresql.org/docs/current/indexes.html) +- [Semantic Versioning Specification](https://semver.org/) diff --git a/docs/TOOL_VERSION_COMPATIBILITY.md b/docs/TOOL_VERSION_COMPATIBILITY.md new file mode 100644 index 00000000..8e5e82ae --- /dev/null +++ b/docs/TOOL_VERSION_COMPATIBILITY.md @@ -0,0 +1,577 @@ +# Tool Version Compatibility System + +This document explains the tool version compatibility feature in Power Platform ToolBox, which ensures that tools are only usable with compatible ToolBox versions. + +## Table of Contents + +1. [Overview](#overview) +2. [Version Compatibility Rules](#version-compatibility-rules) +3. [For Tool Developers](#for-tool-developers) +4. [For ToolBox Maintainers](#for-toolbox-maintainers) +5. [For Tool Registry Administrators](#for-tool-registry-administrators) +6. [Technical Implementation](#technical-implementation) +7. [User Experience](#user-experience) + +--- + +## Overview + +The Tool Version Compatibility System allows tools to specify which versions of Power Platform ToolBox they are compatible with. This prevents users from experiencing issues when: + +- A tool requires newer API features not available in an older ToolBox version +- A tool was built against an older API that may not work with breaking changes in newer versions +- Organizations with restricted update policies cannot use tools requiring newer features + +### Key Concepts + +- **ToolBox Version**: The version of the Power Platform ToolBox application (e.g., `1.1.3`) +- **API Version**: The version of the `@pptb/types` package that defines the tool API surface (matches ToolBox version) +- **Minimum API Version (minAPI)**: The oldest ToolBox version required by the tool +- **Maximum API Version (maxAPI)**: The newest ToolBox version the tool was built and tested against + +--- + +## Version Compatibility Rules + +A tool is considered **compatible** and will be enabled if: + +1. **Minimum API Support Check**: `tool.minAPI >= ToolBox.MIN_SUPPORTED_API_VERSION` + - The tool doesn't require APIs that have been deprecated or removed + - Ensures backward compatibility within supported range + +2. **Minimum Version Check**: `ToolBox.VERSION >= tool.minAPI` + - The current ToolBox must be at least as new as what the tool requires + - Ensures the ToolBox has all APIs the tool needs + +3. **Maximum Version**: The `maxAPI` field is **informational only** + - Tools built with older APIs continue to work on newer ToolBox versions + - Breaking changes are tracked by updating `MIN_SUPPORTED_API_VERSION` on ToolBox side + - This allows forward compatibility by default + +### Examples + +#### Example 1: Tool Works on Newer ToolBox + +**Scenario:** + +- ToolBox installed: `v1.0.5` (MIN_SUPPORTED_API_VERSION = `1.0.2`) +- Tool built against: API `v1.0.4` (from `@pptb/types@1.0.4`) +- Tool declares: `minAPI: "1.0.0"` + +**Result:** ✅ Compatible + +- Tool's minAPI (1.0.0) >= MIN_SUPPORTED_API_VERSION (1.0.2)? → ❌ BUT tool still works because... +- Actually: Tool's minAPI (1.0.0) < MIN_SUPPORTED_API_VERSION (1.0.2) would fail +- Let's correct: minAPI (1.0.3) >= MIN_SUPPORTED_API_VERSION (1.0.2) ✓ +- ToolBox version (1.0.5) >= tool.minAPI (1.0.3) ✓ +- Tool works because ToolBox v1.0.5 is backward compatible with APIs from v1.0.3 + +#### Example 2: Requires Newer ToolBox + +**Scenario:** + +- ToolBox installed: `v1.0.1` (MIN_SUPPORTED_API_VERSION = `1.0.0`) +- Tool built against: API `v1.0.2` +- Tool declares: `minAPI: "1.0.2"` + +**Result:** ❌ Not Compatible + +- Tool's minAPI (1.0.2) >= MIN_SUPPORTED_API_VERSION (1.0.0) ✓ +- ToolBox version (1.0.1) >= tool.minAPI (1.0.2) ✗ +- Tool uses APIs added in v1.0.2 that don't exist in v1.0.1 + +**Action Required:** User must upgrade ToolBox to v1.0.2 or newer + +#### Example 3: Tool Uses Deprecated APIs + +**Scenario:** + +- ToolBox installed: `v1.5.0` (MIN_SUPPORTED_API_VERSION = `1.2.0`) +- Tool built against: API `v1.0.5` +- Tool declares: `minAPI: "1.0.0"` + +**Result:** ❌ Not Compatible + +- Tool's minAPI (1.0.0) >= MIN_SUPPORTED_API_VERSION (1.2.0) ✗ +- Tool uses APIs from v1.0.0 that were removed in breaking change at v1.2.0 + +**Action Required:** Tool developer must update tool to use newer APIs + +#### Example 4: Perfect Compatibility Range + +**Scenario:** + +- ToolBox installed: `v1.0.5` (MIN_SUPPORTED_API_VERSION = `1.0.2`) +- Tool built against: API `v1.0.4` +- Tool declares: `minAPI: "1.0.2"` + +**Result:** ✅ Compatible + +- Tool's minAPI (1.0.2) >= MIN_SUPPORTED_API_VERSION (1.0.2) ✓ +- ToolBox version (1.0.5) >= tool.minAPI (1.0.2) ✓ +- Tool maxAPI (1.0.4) is ignored - tool works on v1.0.5 because no breaking changes + +--- + +## For Tool Developers + +### 1. Specify Minimum API Version + +Add the `minAPI` field to your tool's `package.json`: + +```json +{ + "name": "my-awesome-tool", + "version": "1.0.0", + "features": { + "minAPI": "1.0.12", + "multiConnection": "optional" + } +} +``` + +**How to determine minAPI:** + +- Set it to the version of `@pptb/types` you're developing against +- If you only use stable APIs, you can set it to the oldest version you want to support +- Consider your user base - setting a very recent version may exclude users on older ToolBox + +### 2. Use @pptb/types Package + +Install the appropriate version as a dev dependency: + +```bash +npm install --save-dev @pptb/types@^1.0.12 +``` + +### 3. Create npm-shrinkwrap.json + +After installing dependencies, create a shrinkwrap file: + +```bash +npm shrinkwrap +``` + +This captures the exact `@pptb/types` version used, which becomes the `maxAPI` value. + +### 4. Testing Compatibility + +Before releasing your tool, test it with: + +- The minimum ToolBox version you claim to support (from `minAPI`) +- The latest ToolBox version available +- Any versions in between if there were significant API changes + +### 5. Tool Submission Checklist + +When submitting your tool to the marketplace: + +- [ ] `package.json` includes `features.minAPI` field +- [ ] `npm-shrinkwrap.json` exists and includes `@pptb/types` +- [ ] Tool has been tested with minimum required version +- [ ] Tool has been tested with latest ToolBox version +- [ ] README documents version requirements + +--- + +## For ToolBox Maintainers + +### Version Synchronization + +**Critical Rule:** The ToolBox version and `@pptb/types` version **MUST** be kept in sync. + +When releasing a new ToolBox version: + +1. **Update Package Versions:** + + ```bash + # Update main package.json + npm version patch # or minor/major + + # Update @pptb/types to match + cd packages + npm version patch # Must match main version + cd .. + ``` + +2. **Commit Changes:** + + ```bash + git add package.json packages/package.json + git commit -m "Bump version to X.Y.Z" + ``` + +3. **Release Process (Automated):** + - Push to main branch + - GitHub Actions will: + - Validate versions match + - Build and package ToolBox + - Publish `@pptb/types` to npm + - Create GitHub release + +### Setting Minimum Supported API Version + +In `src/main/constants.ts`: + +```typescript +export const MIN_SUPPORTED_API_VERSION = "1.0.0"; +``` + +**When to Update MIN_SUPPORTED_API_VERSION:** + +Update this value **ONLY** when introducing breaking changes: + +- Removing deprecated APIs +- Changing existing API signatures in incompatible ways +- Renaming APIs +- Changing behavior that breaks existing tools + +**Guidelines:** + +- Set to the version where breaking changes were introduced +- Announce breaking changes well in advance (at least 2 major versions) +- Document what APIs are no longer supported +- Consider user impact - many organizations update slowly +- Tools with minAPI below this version will show as "Not Supported" + +**Example Timeline:** + +1. v1.0.0: Introduce `executeFunction` API +2. v1.1.0: Add new `execute` API, mark `executeFunction` as `@deprecated` +3. v1.2.0: Still support both APIs, warn users +4. v2.0.0: Remove `executeFunction`, set `MIN_SUPPORTED_API_VERSION = "1.1.0"` + +**Important:** Do NOT update MIN_SUPPORTED_API_VERSION for additive changes (new APIs). Tools built with older APIs will continue to work on newer ToolBox versions automatically. + +### API Changes Best Practices + +1. **Non-Breaking Changes (Patch/Minor):** + - Add new optional API methods + - Add new optional parameters to existing methods + - Fix bugs + - Update documentation + +2. **Breaking Changes (Major):** + - Remove deprecated APIs (after warning period) + - Change existing API signatures + - Rename APIs + - Change behavior in incompatible ways + - Announcing deprecated APIs + +3. **Deprecation Process:** + - Mark APIs as `@deprecated` in `@pptb/types` + - Document replacement APIs + - Wait at least 2 major versions before removal + - Update `MIN_SUPPORTED_API_VERSION` when removing + +--- + +## For Tool Registry Administrators + +### Database Schema + +The Supabase `tools` table should include these columns: + +```sql +-- Add to existing tools table +ALTER TABLE tools ADD COLUMN min_api TEXT; +ALTER TABLE tools ADD COLUMN max_api TEXT; + +-- Indexes for performance +CREATE INDEX idx_tools_min_api ON tools(min_api); +CREATE INDEX idx_tools_max_api ON tools(max_api); +``` + +### Tool Intake Process + +When processing a new tool submission or update: + +1. **Extract Version Information:** + - Read `package.json` → get `features.minAPI` + - Read `npm-shrinkwrap.json` → get `dependencies["@pptb/types"].version` + - Validate both values are present and valid semver + +2. **Validate Versions:** + + ```typescript + // Pseudo-code validation + if (!semver.valid(minAPI)) { + reject("Invalid minAPI version format"); + } + if (!semver.valid(maxAPI)) { + reject("Invalid maxAPI version format"); + } + if (semver.gt(minAPI, maxAPI)) { + reject("minAPI cannot be greater than maxAPI"); + } + ``` + +3. **Store in Database:** + + ```sql + INSERT INTO tools (id, name, version, min_api, max_api, ...) + VALUES ($1, $2, $3, $4, $5, ...); + ``` + +4. **Update Local Registry (Backup):** + - Update `src/main/data/registry.json` with new tool + - Include `minAPI` and `maxAPI` fields + - Commit to repository + +### Handling Legacy Tools + +For existing tools without version information: + +- Set `minAPI = null` (assumed compatible) +- Set `maxAPI = null` (assumed compatible) +- Reach out to tool developers to update their submissions +- Add notification in tool detail page about missing version info + +--- + +## Technical Implementation + +### Version Comparison Algorithm + +Located in `src/main/managers/toolsManager.ts`: + +```typescript +function compareVersions(v1: string, v2: string): number { + // Split version into numeric and pre-release parts + const parseVersion = (v: string) => { + const [numericPart, preRelease] = v.split("-"); + const numeric = numericPart.split(".").map((p) => parseInt(p, 10) || 0); + return { numeric, preRelease: preRelease || null }; + }; + + const parsed1 = parseVersion(v1); + const parsed2 = parseVersion(v2); + + // Compare numeric parts + const maxLength = Math.max(parsed1.numeric.length, parsed2.numeric.length); + for (let i = 0; i < maxLength; i++) { + const p1 = parsed1.numeric[i] || 0; + const p2 = parsed2.numeric[i] || 0; + if (p1 < p2) return -1; + if (p1 > p2) return 1; + } + + // If numeric parts are equal, compare pre-release + // Release version (no pre-release) > Pre-release version + if (parsed1.preRelease === null && parsed2.preRelease !== null) return 1; + if (parsed1.preRelease !== null && parsed2.preRelease === null) return -1; + if (parsed1.preRelease !== null && parsed2.preRelease !== null) { + // Simple string comparison for pre-release tags + if (parsed1.preRelease < parsed2.preRelease) return -1; + if (parsed1.preRelease > parsed2.preRelease) return 1; + } + + return 0; +} +``` + +**Note:** This implementation properly handles pre-release versions (e.g., `1.0.0-beta.1 < 1.0.0`). + +### Compatibility Check Logic + +```typescript +function isToolSupported(minAPI?: string, maxAPI?: string): boolean { + // No version constraints = compatible (legacy tools) + if (!minAPI && !maxAPI) return true; + + if (minAPI) { + // Check 1: Tool's minAPI >= MIN_SUPPORTED_API_VERSION + // Ensures tool doesn't use deprecated/removed APIs + if (compareVersions(minAPI, MIN_SUPPORTED_API_VERSION) < 0) { + return false; // Tool uses APIs older than we support + } + + // Check 2: TOOLBOX_VERSION >= tool.minAPI + // Ensures ToolBox has minimum APIs the tool needs + if (compareVersions(TOOLBOX_VERSION, minAPI) < 0) { + return false; // ToolBox is older than tool requires + } + } + + // maxAPI is informational only - tools work on newer versions + // unless breaking changes occur (tracked by MIN_SUPPORTED_API_VERSION) + + return true; +} +``` + +**Key Points:** + +- `maxAPI` does not restrict compatibility - it's for informational purposes only +- Tools built with older APIs continue to work on newer ToolBox versions +- Breaking changes are signaled by updating `MIN_SUPPORTED_API_VERSION` +- This approach maximizes forward compatibility +- Version checking logic is handled by `VersionManager` class + +### Data Flow + +1. **Installation:** + - User clicks "Install" on a tool + - `toolRegistryManager.installTool()` downloads the package + - Reads `minAPI` and `maxAPI` from Supabase tools table (min_api and max_api columns) + - Stores in `manifest.json` as `minAPI` and `maxAPI` + +2. **Loading:** + - `toolsManager.loadTool()` reads manifest + - `loadToolFromManifest()` creates Tool object + - `VersionManager.isToolSupported()` checks compatibility + - Sets `tool.isSupported` boolean + +3. **UI Display:** + - Sidebar and marketplace read `tool.isSupported` + - Unsupported tools get red "Not Supported" badge + - CSS applies visual indicators (opacity, border) + - Launch button disabled with helpful tooltip + +**Note:** Version information (min_api and max_api) is pre-processed during tool intake/submission and stored in Supabase. The ToolBox application reads these values from the database, not from the tool package files. + +--- + +## User Experience + +### Visual Indicators + +**Unsupported Tools:** + +- Red "⚠ Not Supported" badge in top-right corner +- Red left border (3px solid #c50f1f) +- Reduced opacity (70%) +- Disabled install/launch buttons + +**CSS Classes:** + +```scss +.tool-unsupported-badge { + background: #c50f1f; + color: white; + padding: 2px 8px; + border-radius: 3px; +} + +.tool-item-pptb.unsupported { + border-left: 3px solid #c50f1f; + background: rgba(197, 15, 31, 0.05); + opacity: 0.7; +} +``` + +### User Actions + +**Attempting to Launch Unsupported Tool:** + +``` +[Notification] +Title: "Tool Not Supported" +Message: "{ToolName} requires a different version of Power Platform ToolBox. + Please update your ToolBox to use this tool." +Type: Warning +``` + +**In Marketplace:** + +- Install button disabled +- Tooltip: "This tool requires ToolBox version X.Y.Z or higher" +- Tool detail modal shows version requirements + +### Tool Detail Modal + +Shows version compatibility information: + +``` +Minimum ToolBox Version: 1.0.12 +Built with API Version: 1.3.1 +Your ToolBox Version: 1.0.1 + +⚠ This tool requires ToolBox v1.0.12 or newer +→ Update ToolBox to use this tool +``` + +--- + +## Troubleshooting + +### Tool Shows as Unsupported + +**Check 1: ToolBox Version** + +```bash +# In ToolBox settings, check "About" section +Current Version: 1.0.1 +``` + +**Check 2: Tool Requirements** + +- Right-click tool → "Details" +- Look for "Minimum Version" and "API Version" +- Compare with your ToolBox version + +**Solution:** + +- Update ToolBox to the required version +- Or contact tool developer to support older versions + +### Tool Works But Shows Unsupported + +**Possible Causes:** + +1. Tool missing `minAPI` in package.json +2. Tool missing `npm-shrinkwrap.json` +3. Registry data outdated + +**Solution:** + +- Contact tool developer to update submission +- Reinstall tool after developer updates + +### Version Mismatch in Workflow + +**Error in GitHub Actions:** + +``` +❌ Error: @pptb/types version (1.0.10) does not match ToolBox version (1.0.11) +``` + +**Solution:** + +```bash +cd packages +npm version 1.0.11 --no-git-tag-version +git add package.json +git commit -m "Sync @pptb/types version to 1.0.11" +``` + +--- + +## Future Enhancements + +1. **Automatic Updates:** + - Prompt user to update ToolBox when loading unsupported tool + - Direct link to download page + +2. **Version Range Support:** + - Allow tools to specify compatible range: `"minAPI": ">=1.0.0 <2.0.0"` + +3. **API Feature Detection:** + - Instead of version numbers, check for specific API features + - More flexible for backward compatibility + +4. **Tool Migration Assistance:** + - When API changes, provide migration guide + - Automated tool updating scripts + +5. **Registry Analytics:** + - Track which ToolBox versions are most common + - Help tool developers make version support decisions + +--- + +## References + +- [Semantic Versioning](https://semver.org/) +- [npm Shrinkwrap Documentation](https://docs.npmjs.com/cli/v8/commands/npm-shrinkwrap) +- [Power Platform ToolBox API Types](https://www.npmjs.com/package/@pptb/types) diff --git a/src/common/ipc/channels.ts b/src/common/ipc/channels.ts index 3e477f64..aca34f05 100644 --- a/src/common/ipc/channels.ts +++ b/src/common/ipc/channels.ts @@ -137,6 +137,7 @@ export const UPDATE_CHANNELS = { DOWNLOAD_UPDATE: "download-update", QUIT_AND_INSTALL: "quit-and-install", GET_APP_VERSION: "get-app-version", + GET_VERSION_COMPATIBILITY_INFO: "get-version-compatibility-info", } as const; // Dataverse-related IPC channels diff --git a/src/common/types/api.ts b/src/common/types/api.ts index 0b332161..1b34f608 100644 --- a/src/common/types/api.ts +++ b/src/common/types/api.ts @@ -209,6 +209,7 @@ export interface ToolboxAPI { downloadUpdate: () => Promise; quitAndInstall: () => Promise; getAppVersion: () => Promise; + getVersionCompatibilityInfo: () => Promise<{ appVersion: string; minSupportedApiVersion: string }>; onUpdateChecking: (callback: () => void) => void; onUpdateAvailable: (callback: (info: unknown) => void) => void; onUpdateNotAvailable: (callback: () => void) => void; diff --git a/src/common/types/tool.ts b/src/common/types/tool.ts index ee301bc6..196a062b 100644 --- a/src/common/types/tool.ts +++ b/src/common/types/tool.ts @@ -15,6 +15,12 @@ export interface ToolFeatures { * - "none": Single connection only (default behavior) */ multiConnection?: "required" | "optional" | "none"; + /** + * Minimum ToolBox API version required by this tool + * Tool developers should specify this in their package.json + * @example "1.0.12" + */ + minAPI?: string; } /** @@ -43,6 +49,9 @@ export interface Tool { status?: "active" | "deprecated" | "archived"; // Tool lifecycle status repository?: string; website?: string; + minAPI?: string; // Minimum ToolBox API version required + maxAPI?: string; // Maximum ToolBox API version tested + isSupported?: boolean; // Whether this tool is compatible with current ToolBox version } /** @@ -71,6 +80,8 @@ export interface ToolRegistryEntry { status?: "active" | "deprecated" | "archived"; // Tool lifecycle status repository?: string; website?: string; + minAPI?: string; // Minimum ToolBox API version required (from features.minAPI) + maxAPI?: string; // Maximum ToolBox API version tested (from npm-shrinkwrap @pptb/types version) } /** @@ -100,6 +111,8 @@ export interface ToolManifest { website?: string; publishedAt?: string; createdAt?: string; + minAPI?: string; // Minimum ToolBox API version required (from features.minAPI) + maxAPI?: string; // Maximum ToolBox API version tested (from npm-shrinkwrap @pptb/types version) } /** diff --git a/src/common/utils/version.ts b/src/common/utils/version.ts new file mode 100644 index 00000000..86f2d7ca --- /dev/null +++ b/src/common/utils/version.ts @@ -0,0 +1,27 @@ +export function compareVersions(v1: string, v2: string): number { + const parseVersion = (version: string) => { + const [numericPart, preRelease] = version.split("-"); + const numeric = numericPart.split(".").map((part) => parseInt(part, 10) || 0); + return { numeric, preRelease: preRelease || null }; + }; + + const parsed1 = parseVersion(v1); + const parsed2 = parseVersion(v2); + + const maxLength = Math.max(parsed1.numeric.length, parsed2.numeric.length); + for (let i = 0; i < maxLength; i++) { + const p1 = parsed1.numeric[i] || 0; + const p2 = parsed2.numeric[i] || 0; + if (p1 < p2) return -1; + if (p1 > p2) return 1; + } + + if (parsed1.preRelease === null && parsed2.preRelease !== null) return 1; + if (parsed1.preRelease !== null && parsed2.preRelease === null) return -1; + if (parsed1.preRelease !== null && parsed2.preRelease !== null) { + if (parsed1.preRelease < parsed2.preRelease) return -1; + if (parsed1.preRelease > parsed2.preRelease) return 1; + } + + return 0; +} diff --git a/src/main/constants.ts b/src/main/constants.ts index ce1b076f..bec93f64 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -23,6 +23,14 @@ export const TOOL_REGISTRY_URL = "https://www.powerplatformtoolbox.com/registry/ export const SUPABASE_URL = process.env.SUPABASE_URL || ""; export const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY || ""; +/** + * Minimum API version supported by this ToolBox version + * Tools requiring older API versions than this will not be supported + * This represents backwards compatibility - how far back we support + * 1.0.17 - File System breaking change was introduced + */ +export const MIN_SUPPORTED_API_VERSION = "1.0.17"; + /** * Azure Blob Storage Configuration * Base URL for the Azure Blob container that hosts tool packages and the remote registry. diff --git a/src/main/index.ts b/src/main/index.ts index bda35fac..64972c61 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -100,6 +100,7 @@ import { ToolBoxUtilityManager } from "./managers/toolboxUtilityManager"; import { ToolFileSystemAccessManager } from "./managers/toolFileSystemAccessManager"; import { ToolManager } from "./managers/toolsManager"; import { ToolWindowManager } from "./managers/toolWindowManager"; +import { VersionManager } from "./managers/versionManager"; // Constants const MENU_CREATION_DEBOUNCE_MS = 150; // Debounce delay for menu recreation during rapid tool switches @@ -393,6 +394,7 @@ class ToolBoxApp { ipcMain.removeHandler(UPDATE_CHANNELS.DOWNLOAD_UPDATE); ipcMain.removeHandler(UPDATE_CHANNELS.QUIT_AND_INSTALL); ipcMain.removeHandler(UPDATE_CHANNELS.GET_APP_VERSION); + ipcMain.removeHandler(UPDATE_CHANNELS.GET_VERSION_COMPATIBILITY_INFO); // Dataverse handlers ipcMain.removeHandler(DATAVERSE_CHANNELS.CREATE); @@ -1244,6 +1246,13 @@ class ToolBoxApp { return this.autoUpdateManager.getCurrentVersion(); }); + ipcMain.handle(UPDATE_CHANNELS.GET_VERSION_COMPATIBILITY_INFO, () => { + return { + appVersion: this.autoUpdateManager.getCurrentVersion(), + minSupportedApiVersion: VersionManager.getMinSupportedApiVersion(), + }; + }); + // Dataverse API handlers // All handlers automatically get the connectionId from the calling tool's WebContents // For multi-connection tools, an optional connectionTarget parameter can be passed to specify "primary" or "secondary" diff --git a/src/main/managers/toolRegistryManager.ts b/src/main/managers/toolRegistryManager.ts index f00b67d1..55bc7bea 100644 --- a/src/main/managers/toolRegistryManager.ts +++ b/src/main/managers/toolRegistryManager.ts @@ -73,6 +73,8 @@ interface SupabaseTool { status?: string; // Tool lifecycle status: active, deprecated, archived repository?: string; website?: string; + min_api?: string; // Minimum ToolBox API version required + max_api?: string; // Maximum ToolBox API version tested tool_categories?: SupabaseCategoryRow[]; tool_contributors?: SupabaseContributorRow[]; tool_analytics?: SupabaseAnalyticsRow | SupabaseAnalyticsRow[]; // sometimes array depending on RLS / joins @@ -108,6 +110,8 @@ interface LocalRegistryTool { cspExceptions?: CspExceptions; features?: Record; status?: string; // Tool lifecycle status: active, deprecated, archived + minAPI?: string; // Minimum ToolBox API version required + maxAPI?: string; // Maximum ToolBox API version tested } /** @@ -238,6 +242,8 @@ export class ToolRegistryManager extends EventEmitter { "status", "repository", "website", + "min_api", + "max_api", // embedded relations "tool_categories(categories(name))", "tool_contributors(contributors(name,profile_url))", @@ -295,6 +301,8 @@ export class ToolRegistryManager extends EventEmitter { rating, mau, status: (tool.status as "active" | "deprecated" | "archived" | undefined) || "active", + minAPI: tool.min_api, // Include min API version from database + maxAPI: tool.max_api, // Include max API version from database } as ToolRegistryEntry; }); @@ -460,6 +468,8 @@ export class ToolRegistryManager extends EventEmitter { features: tool.features, license: tool.license, status: (tool.status as "active" | "deprecated" | "archived" | undefined) || "active", + minAPI: tool.minAPI, + maxAPI: tool.maxAPI, })); logInfo(`[ToolRegistry] Fetched ${tools.length} tools from local registry`); @@ -609,6 +619,16 @@ export class ToolRegistryManager extends EventEmitter { const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")); + // Extract version information from registry (Supabase) + // These are pre-processed during tool intake and stored in the database + const minAPI: string | undefined = tool.minAPI; // From Supabase tools table (min_api column) + const maxAPI: string | undefined = tool.maxAPI; // From Supabase tools table (max_api column) + + // Log if version info is missing (informational only, tools will still work as legacy) + if (!minAPI && !maxAPI) { + logInfo(`[ToolRegistry] Tool ${toolId} does not have version information in registry. Tool will be treated as compatible with all versions (legacy behavior).`); + } + // Create manifest // Normalize authors list: prefer registry contributors, fallback to package.json author let authors: string[] | undefined = tool.authors; @@ -642,6 +662,8 @@ export class ToolRegistryManager extends EventEmitter { website: tool.website, // Include website URL from registry createdAt: tool.createdAt, publishedAt: tool.publishedAt, + minAPI, // Minimum API version required + maxAPI, // Maximum API version tested (from @pptb/types) }; // Save to manifest file @@ -753,6 +775,8 @@ export class ToolRegistryManager extends EventEmitter { mau: manifestEntry.mau, publishedAt: manifestEntry.publishedAt, createdAt: manifestEntry.createdAt, + minAPI: manifestEntry.minAPI, + maxAPI: manifestEntry.maxAPI, }; } diff --git a/src/main/managers/toolsManager.ts b/src/main/managers/toolsManager.ts index a7d15bcd..889226d8 100644 --- a/src/main/managers/toolsManager.ts +++ b/src/main/managers/toolsManager.ts @@ -7,6 +7,7 @@ import { captureMessage, logInfo } from "../../common/sentryHelper"; import { CspExceptions, Tool, ToolFeatures, ToolManifest } from "../../common/types"; import { InstallIdManager } from "./installIdManager"; import { ToolRegistryManager } from "./toolRegistryManager"; +import { VersionManager } from "./versionManager"; /** * Package.json structure for tool validation @@ -48,6 +49,8 @@ export class ToolManager extends EventEmitter { this.emit("tool:installed", manifest); }); this.registryManager.on("tool:uninstalled", (toolId) => { + // Clear from cache when uninstalled + this.tools.delete(toolId); this.emit("tool:uninstalled", toolId); }); } @@ -73,6 +76,9 @@ export class ToolManager extends EventEmitter { readmeUrl: manifest.readme, publishedAt: manifest.publishedAt, createdAt: manifest.createdAt, + minAPI: manifest.minAPI, + maxAPI: manifest.maxAPI, + isSupported: VersionManager.isToolSupported(manifest.minAPI, manifest.maxAPI), }; const cached = this.analyticsCache.get(tool.id); @@ -140,6 +146,9 @@ export class ToolManager extends EventEmitter { repository: manifest.repository, website: manifest.website, readmeUrl: manifest.readme, + minAPI: manifest.minAPI, + maxAPI: manifest.maxAPI, + isSupported: VersionManager.isToolSupported(manifest.minAPI, manifest.maxAPI), }; const cached = this.analyticsCache.get(tool.id); @@ -192,6 +201,8 @@ export class ToolManager extends EventEmitter { getTool(toolId: string): Tool | undefined { const tool = this.tools.get(toolId); if (tool) { + // Always recompute isSupported in case ToolBox version changed + tool.isSupported = VersionManager.isToolSupported(tool.minAPI, tool.maxAPI); return tool; } @@ -218,13 +229,21 @@ export class ToolManager extends EventEmitter { const installedManifests = this.registryManager.getInstalledToolsSync(); installedManifests.forEach((manifest) => { const loaded = this.tools.get(manifest.id); - toolsById.set(manifest.id, loaded || this.createToolFromInstalledManifest(manifest)); + if (loaded) { + // Always recompute isSupported in case ToolBox version changed + loaded.isSupported = VersionManager.isToolSupported(loaded.minAPI, loaded.maxAPI); + toolsById.set(manifest.id, loaded); + } else { + toolsById.set(manifest.id, this.createToolFromInstalledManifest(manifest)); + } }); // Include any loaded tools that might not be in the registry manifest // (e.g., local dev tools). this.tools.forEach((tool, id) => { if (!toolsById.has(id)) { + // Recompute isSupported for these tools too + tool.isSupported = VersionManager.isToolSupported(tool.minAPI, tool.maxAPI); toolsById.set(id, tool); } }); @@ -251,8 +270,17 @@ export class ToolManager extends EventEmitter { /** * Fetch available tools from registry */ - async fetchAvailableTools() { - return await this.registryManager.fetchRegistry(); + async fetchAvailableTools(): Promise { + const registryTools = await this.registryManager.fetchRegistry(); + + // Convert ToolRegistryEntry[] to Tool[] and add isSupported field + return registryTools.map((registryTool) => { + const tool: Tool = { + ...registryTool, + isSupported: VersionManager.isToolSupported(registryTool.minAPI, registryTool.maxAPI), + }; + return tool; + }); } /** diff --git a/src/main/managers/versionManager.ts b/src/main/managers/versionManager.ts new file mode 100644 index 00000000..490ee42c --- /dev/null +++ b/src/main/managers/versionManager.ts @@ -0,0 +1,71 @@ +import { app } from "electron"; +import { compareVersions } from "../../common/utils/version"; +import { MIN_SUPPORTED_API_VERSION } from "../constants"; + +/** + * Version Manager + * Handles version comparison and compatibility checking for tools + */ +export class VersionManager { + /** + * Check if a tool is compatible with the current ToolBox version + * @param minAPI - Minimum API version required by the tool (from Supabase) + * @param maxAPI - Maximum API version tested by the tool (from Supabase, informational only) + * @returns true if the tool is supported, false otherwise + * + * Compatibility rules: + * 1. If tool has no version constraints (legacy): always compatible + * 2. Tool's minAPI must be >= MIN_SUPPORTED_API_VERSION (doesn't use deprecated APIs) + * 3. Tool's minAPI must be <= current ToolBox version (ToolBox meets minimum requirement) + * 4. maxAPI is informational only - tools built with older APIs continue to work + * unless breaking changes are introduced (tracked by MIN_SUPPORTED_API_VERSION) + */ + static isToolSupported(minAPI?: string, maxAPI?: string): boolean { + const toolboxVersion = VersionManager.getToolBoxVersion(); + + // If no version constraints, assume compatible (legacy tools) + if (!minAPI && !maxAPI) { + return true; + } + + // Check minimum version requirements + if (minAPI) { + // Tool's minAPI must be >= MIN_SUPPORTED_API_VERSION + // This ensures the tool doesn't require APIs that have been deprecated/removed + const minAPIvsMinSupported = compareVersions(minAPI, MIN_SUPPORTED_API_VERSION); + if (minAPIvsMinSupported < 0) { + // Tool requires APIs older than what we support + return false; + } + + // Tool's minAPI must be <= current ToolBox version + // This ensures the current ToolBox has the minimum APIs the tool needs + const toolboxVsMinAPI = compareVersions(toolboxVersion, minAPI); + if (toolboxVsMinAPI < 0) { + // Current ToolBox version is older than what tool requires + return false; + } + } + + // TODO: For future enhancement, if installed ToolBox version is less than the the API tool is built on + // maxAPI is informational only - tools built with older APIs will continue + // to work on newer ToolBox versions unless we introduce breaking changes + // Breaking changes are tracked by updating MIN_SUPPORTED_API_VERSION + + return true; + } + + /** + * Get the current ToolBox version from Electron app + */ + static getToolBoxVersion(): string { + return app.getVersion(); + } + + /** + * Get the minimum supported API version + */ + static getMinSupportedApiVersion(): string { + return MIN_SUPPORTED_API_VERSION; + } +} diff --git a/src/main/preload.ts b/src/main/preload.ts index 163f1fe8..a1af3afd 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -180,6 +180,7 @@ contextBridge.exposeInMainWorld("toolboxAPI", { downloadUpdate: () => ipcRenderer.invoke(UPDATE_CHANNELS.DOWNLOAD_UPDATE), quitAndInstall: () => ipcRenderer.invoke(UPDATE_CHANNELS.QUIT_AND_INSTALL), getAppVersion: () => ipcRenderer.invoke(UPDATE_CHANNELS.GET_APP_VERSION), + getVersionCompatibilityInfo: () => ipcRenderer.invoke(UPDATE_CHANNELS.GET_VERSION_COMPATIBILITY_INFO), onUpdateChecking: (callback: () => void) => { ipcRenderer.on(EVENT_CHANNELS.UPDATE_CHECKING, callback); }, diff --git a/src/renderer/modals/toolDetail/controller.ts b/src/renderer/modals/toolDetail/controller.ts index 844601de..a5eb35a5 100644 --- a/src/renderer/modals/toolDetail/controller.ts +++ b/src/renderer/modals/toolDetail/controller.ts @@ -10,6 +10,7 @@ export interface ToolDetailModalState { toolId: string; toolName: string; isInstalled: boolean; + isSupported?: boolean; readmeUrl?: string | null; reviewUrl: string; repositoryUrl?: string | null; @@ -71,6 +72,14 @@ export function getToolDetailModalControllerScript(config: ToolDetailModalContro const handleInstallClick = () => { if (!(installBtn instanceof HTMLButtonElement)) return; if (installBtn.disabled) return; + + // Double-check compatibility + if (CONFIG.state.isSupported === false) { + installBtn.disabled = true; + installBtn.textContent = "Not supported"; + setFeedback("This tool is not compatible with your version of Power Platform ToolBox. Please update your ToolBox to use this tool.", true); + return; + } installBtn.disabled = true; installBtn.textContent = "Installing..."; setFeedback(""); diff --git a/src/renderer/modals/toolDetail/view.ts b/src/renderer/modals/toolDetail/view.ts index 7768e29d..3730b8c8 100644 --- a/src/renderer/modals/toolDetail/view.ts +++ b/src/renderer/modals/toolDetail/view.ts @@ -14,6 +14,7 @@ export interface ToolDetailModalViewModel { metaBadges: string[]; categories: string[]; isInstalled: boolean; + isSupported?: boolean; readmeUrl?: string; isDarkTheme: boolean; repository?: string; @@ -284,7 +285,7 @@ export function getToolDetailModalView(model: ToolDetailModalViewModel): ModalVi

    By ${model.authors}

    ${badgeMarkup || ratingsHtml ? `
    ${badgeMarkup}${ratingsHtml}
    ` : ""}
    - + Installed
    ${linksMarkup} diff --git a/src/renderer/modules/marketplaceManagement.ts b/src/renderer/modules/marketplaceManagement.ts index ed81d056..a48187ae 100644 --- a/src/renderer/modules/marketplaceManagement.ts +++ b/src/renderer/modules/marketplaceManagement.ts @@ -8,6 +8,7 @@ import type { ModalWindowClosedPayload, ModalWindowMessagePayload, Tool } from " import { getToolDetailModalControllerScript } from "../modals/toolDetail/controller"; import { getToolDetailModalView } from "../modals/toolDetail/view"; import type { ToolDetail } from "../types/index"; +import { getUnsupportedBadgeTitle, getUnsupportedRequirement } from "../utils/toolCompatibility"; import { applyToolIconMasks, escapeHtml, generateToolIconHtml, resolveToolIconUrl } from "../utils/toolIconResolver"; import { onBrowserWindowModalClosed, onBrowserWindowModalMessage, sendBrowserWindowModalMessage, showBrowserWindowModal } from "./browserWindowModals"; import { loadSidebarTools } from "./toolsSidebarManagement"; @@ -75,6 +76,9 @@ export async function loadToolsLibrary(): Promise { repository: tool.repository, website: tool.website, createdAt: tool.createdAt, // Use createdAt for new tool detection + minAPI: tool.minAPI, // Include min API version + maxAPI: tool.maxAPI, // Include max API version + isSupported: tool.isSupported, // Include compatibility status }) as ToolDetail, ); @@ -110,6 +114,7 @@ export async function loadMarketplace(): Promise { // Get display mode setting const displayMode = ((await window.toolboxAPI.getSetting("toolDisplayMode")) as string) || "standard"; + const versionInfo = await window.toolboxAPI.getVersionCompatibilityInfo().catch(() => null); // Get filter and sort values const searchInput = document.getElementById("marketplace-search-input") as HTMLInputElement | null; @@ -234,7 +239,10 @@ export async function loadMarketplace(): Promise { // Show all categories for this tool const categoriesHtml = tool.categories && tool.categories.length ? tool.categories.map((t) => `${t}`).join("") : ""; const isDeprecated = tool.status === "deprecated"; + const isUnsupported = tool.isSupported === false; + const unsupportedRequirement = getUnsupportedRequirement(tool, versionInfo); const deprecatedBadgeHtml = isDeprecated ? 'Deprecated' : ""; + const unsupportedBadgeHtml = isUnsupported ? `Not Supported` : ""; const newBadgeHtml = isNewTool ? 'NEW' : ""; const analyticsHtml = `
    ${tool.downloads !== undefined ? `⬇ ${tool.downloads}` : ""} @@ -252,7 +260,7 @@ export async function loadMarketplace(): Promise { if (displayMode === "compact") { // Compact mode: icon, name, version, author only return ` -
    +
    ${toolIconHtml}
    @@ -265,7 +273,7 @@ export async function loadMarketplace(): Promise { ${ isInstalled ? '✓' - : `` }
    @@ -277,7 +285,7 @@ export async function loadMarketplace(): Promise { // Standard mode: full details return ` -
    +
    ${toolIconHtml}
    @@ -290,7 +298,7 @@ export async function loadMarketplace(): Promise { ${ isInstalled ? '✓' - : `` }
    @@ -300,7 +308,7 @@ export async function loadMarketplace(): Promise {
    ${analyticsHtml}
    -
    ${newBadgeHtml}${categoriesHtml}${deprecatedBadgeHtml}
    +
    ${newBadgeHtml}${categoriesHtml}${deprecatedBadgeHtml}${unsupportedBadgeHtml}
    `; }) @@ -609,6 +617,7 @@ function buildToolDetailModalHtml(tool: ToolDetail, isInstalled: boolean): strin metaBadges: metaBadges.map((badge) => escapeHtml(badge)), categories: categories, isInstalled, + isSupported: tool.isSupported, readmeUrl: tool.readmeUrl, isDarkTheme, repository: tool.repository, @@ -622,6 +631,7 @@ function buildToolDetailModalHtml(tool: ToolDetail, isInstalled: boolean): strin toolId: tool.id, toolName: tool.name, isInstalled, + isSupported: tool.isSupported, readmeUrl: tool.readmeUrl || null, reviewUrl: `https://www.powerplatformtoolbox.com/rate-tool?toolId=${encodeURIComponent(tool.id)}`, repositoryUrl: tool.repository || null, diff --git a/src/renderer/modules/toolManagement.ts b/src/renderer/modules/toolManagement.ts index 90476737..5b71b7ed 100644 --- a/src/renderer/modules/toolManagement.ts +++ b/src/renderer/modules/toolManagement.ts @@ -6,6 +6,7 @@ import { captureException, captureMessage, logInfo, logWarn } from "../../common/sentryHelper"; import type { DataverseConnection } from "../../common/types/connection"; import type { OpenTool, SessionData } from "../types/index"; +import { getUnsupportedRequirement, getUnsupportedToolMessage } from "../utils/toolCompatibility"; import { openSelectConnectionModal, openSelectMultiConnectionModal } from "./connectionManagement"; import { openCspExceptionModal } from "./cspExceptionModal"; import { hideHomePage, showHomePage as showDynamicHomePage } from "./homepageManagement"; @@ -95,6 +96,25 @@ export async function launchTool(toolId: string, options?: LaunchToolOptions): P return; } + // Check if tool is supported by current ToolBox version + if (tool.isSupported === false) { + const versionInfo = await window.toolboxAPI.getVersionCompatibilityInfo().catch((error) => { + logWarn("Failed to retrieve version compatibility info for unsupported tool message", { + error: error instanceof Error ? error.message : String(error), + toolId, + }); + return null; + }); + + const unsupportedRequirement = getUnsupportedRequirement(tool, versionInfo); + window.toolboxAPI.utils.showNotification({ + title: "Tool Not Supported", + body: getUnsupportedToolMessage(tool.name, unsupportedRequirement), + type: "warning", + }); + return; + } + // Determine multi-connection mode const multiConnectionMode = tool.features?.multiConnection || "none"; diff --git a/src/renderer/modules/toolsSidebarManagement.ts b/src/renderer/modules/toolsSidebarManagement.ts index e1100b80..2722dd55 100644 --- a/src/renderer/modules/toolsSidebarManagement.ts +++ b/src/renderer/modules/toolsSidebarManagement.ts @@ -5,6 +5,7 @@ import { captureMessage, logInfo } from "../../common/sentryHelper"; import { ToolDetail } from "../types/index"; +import { getUnsupportedBadgeTitle, getUnsupportedRequirement } from "../utils/toolCompatibility"; import { applyToolIconMasks, generateToolIconHtml } from "../utils/toolIconResolver"; import { getToolSourceIconHtml } from "../utils/toolSourceIcon"; import { loadMarketplace, openToolDetail } from "./marketplaceManagement"; @@ -25,6 +26,7 @@ export async function loadSidebarTools(): Promise { const favoriteTools = await window.toolboxAPI.getFavoriteTools(); const deprecatedToolsVisibility = (await window.toolboxAPI.getSetting("deprecatedToolsVisibility")) || "hide-all"; const displayMode = ((await window.toolboxAPI.getSetting("toolDisplayMode")) as string) || "standard"; + const versionInfo = await window.toolboxAPI.getVersionCompatibilityInfo().catch(() => null); if (tools.length === 0) { toolsList.innerHTML = ` @@ -190,6 +192,8 @@ export async function loadSidebarTools(): Promise { const latestVersion = tool.latestVersion; const description = tool.description || ""; const isDeprecated = tool.status === "deprecated"; + const isUnsupported = tool.isSupported === false; + const unsupportedRequirement = getUnsupportedRequirement(tool, versionInfo); // Show up to two categories, with a +N indicator if more remain const categoriesHtml = (() => { if (!tool.categories || !tool.categories.length) return ""; @@ -200,6 +204,7 @@ export async function loadSidebarTools(): Promise { return `${visibleHtml}${moreHtml}`; })(); const deprecatedBadgeHtml = isDeprecated ? '⚠ Deprecated' : ""; + const unsupportedBadgeHtml = isUnsupported ? `⚠ Not Supported` : ""; // Get tool source icon const sourceIconHtml = getToolSourceIconHtml(tool.id); @@ -240,7 +245,7 @@ export async function loadSidebarTools(): Promise { if (displayMode === "compact") { // Compact mode: icon, name, version, author only return ` -
    +
    ${updatingOverlayHtml}
    @@ -271,7 +276,7 @@ export async function loadSidebarTools(): Promise { // Standard mode: full details return ` -
    +
    ${updatingOverlayHtml}
    @@ -309,7 +314,7 @@ export async function loadSidebarTools(): Promise {
    ${analyticsHtml}
    -
    ${categoriesHtml}${deprecatedBadgeHtml}
    +
    ${categoriesHtml}${deprecatedBadgeHtml}${unsupportedBadgeHtml}
    ${ shouldShowUpdateInfo ? `
    ` diff --git a/src/renderer/styles.scss b/src/renderer/styles.scss index 19c4084b..788a7f9f 100644 --- a/src/renderer/styles.scss +++ b/src/renderer/styles.scss @@ -2180,6 +2180,19 @@ img.tool-item-icon-img { margin-right: 4px; } +.tool-unsupported-badge { + background: #c50f1f; + color: white; + padding: 2px 8px; + border-radius: 3px; + font-size: 10px; + font-weight: 500; + display: inline-flex; + align-items: center; + gap: 4px; + margin-right: 4px; +} + .tool-item-pptb.deprecated { border-left: 3px solid #d83b01; background: rgba(216, 59, 1, 0.05); @@ -2189,6 +2202,16 @@ img.tool-item-icon-img { background: rgba(216, 59, 1, 0.1); } +.tool-item-pptb.unsupported { + border-left: 3px solid #c50f1f; + background: rgba(197, 15, 31, 0.05); + opacity: 0.7; +} + +.tool-item-pptb.unsupported:hover { + background: rgba(197, 15, 31, 0.1); +} + /* Tool updating state */ .tool-item-pptb.tool-item-updating { position: relative; @@ -3446,6 +3469,41 @@ body.dark-theme .modern-markdown code { content: "⚠"; } +.marketplace-item-unsupported-badge { + background: #c50f1f; + color: white; + padding: 2px 8px; + border-radius: 3px; + font-size: 10px; + font-weight: 500; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.marketplace-item-unsupported-badge::before { + content: "⚠"; +} + +.marketplace-item-pptb.deprecated { + border-left: 3px solid #d83b01; + background: rgba(216, 59, 1, 0.05); +} + +.marketplace-item-pptb.deprecated:hover { + background: rgba(216, 59, 1, 0.1); +} + +.marketplace-item-pptb.unsupported { + border-left: 3px solid #c50f1f; + background: rgba(197, 15, 31, 0.05); + opacity: 0.7; +} + +.marketplace-item-pptb.unsupported:hover { + background: rgba(197, 15, 31, 0.1); +} + /* Marketplace item with NEW badge */ .marketplace-item-new-badge { background: linear-gradient(135deg, #6a00ff 0%, #8b00ff 100%); diff --git a/src/renderer/types/index.ts b/src/renderer/types/index.ts index 9db4540c..9815e5e9 100644 --- a/src/renderer/types/index.ts +++ b/src/renderer/types/index.ts @@ -92,4 +92,7 @@ export interface ToolDetail { repository?: string; website?: string; createdAt?: string; // ISO date string from created_at field + minAPI?: string; // Minimum ToolBox API version required + maxAPI?: string; // Maximum ToolBox API version tested + isSupported?: boolean; // Whether this tool is compatible with current ToolBox version } diff --git a/src/renderer/utils/toolCompatibility.ts b/src/renderer/utils/toolCompatibility.ts new file mode 100644 index 00000000..1f9e05c9 --- /dev/null +++ b/src/renderer/utils/toolCompatibility.ts @@ -0,0 +1,81 @@ +import { compareVersions } from "../../common/utils/version"; + +export interface VersionCompatibilityInfo { + appVersion: string; + minSupportedApiVersion: string; +} + +interface ToolVersionLike { + minAPI?: string; + features?: { + minAPI?: string; + }; +} + +export type UnsupportedReason = "toolbox-too-old" | "tool-outdated" | "unknown"; + +export interface UnsupportedRequirement { + reason: UnsupportedReason; + requiredVersion?: string; +} + +export function getUnsupportedRequirement(tool: ToolVersionLike, versionInfo?: VersionCompatibilityInfo | null): UnsupportedRequirement { + const toolMinApi = tool.minAPI || tool.features?.minAPI; + + if (!toolMinApi) { + return { reason: "unknown" }; + } + + if (!versionInfo) { + return { reason: "unknown", requiredVersion: toolMinApi }; + } + + if (compareVersions(toolMinApi, versionInfo.minSupportedApiVersion) < 0) { + return { + reason: "tool-outdated", + requiredVersion: versionInfo.minSupportedApiVersion, + }; + } + + if (compareVersions(versionInfo.appVersion, toolMinApi) < 0) { + return { + reason: "toolbox-too-old", + requiredVersion: toolMinApi, + }; + } + + return { + reason: "unknown", + requiredVersion: toolMinApi, + }; +} + +export function getUnsupportedToolMessage(toolName: string, requirement: UnsupportedRequirement): string { + if (requirement.reason === "tool-outdated") { + return requirement.requiredVersion + ? `${toolName} is built for APIs older than this ToolBox supports (minimum supported API is v${requirement.requiredVersion}). Please update the tool to a newer version or contact the tool author.` + : `${toolName} is built for APIs older than this ToolBox supports. Please update the tool to a newer version or contact the tool author.`; + } + + if (requirement.reason === "toolbox-too-old") { + return requirement.requiredVersion + ? `${toolName} requires Power Platform ToolBox v${requirement.requiredVersion} or later. Please update your ToolBox to use this tool.` + : `${toolName} requires a newer version of Power Platform ToolBox. Please update your ToolBox to use this tool.`; + } + + return `${toolName} is not compatible with this ToolBox version. Please update the tool or contact the tool author.`; +} + +export function getUnsupportedBadgeTitle(requirement: UnsupportedRequirement): string { + if (requirement.reason === "tool-outdated") { + return requirement.requiredVersion + ? `Tool update required (ToolBox supports API v${requirement.requiredVersion}+). Update the tool or contact the author` + : "Tool update required. Update the tool or contact the author"; + } + + if (requirement.reason === "toolbox-too-old") { + return requirement.requiredVersion ? `Requires ToolBox v${requirement.requiredVersion} or later` : "Requires a newer ToolBox version"; + } + + return "Tool is not compatible with this ToolBox version"; +} From 9a33b3492355feecdc19431d05b130472c327851 Mon Sep 17 00:00:00 2001 From: Power-Maverick Date: Thu, 26 Feb 2026 22:37:30 -0500 Subject: [PATCH 031/257] fix: update permissions for publish-types jobs in release workflows --- .github/workflows/nightly-release.yml | 5 ++++- .github/workflows/prod-release.yml | 12 +++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/.github/workflows/nightly-release.yml b/.github/workflows/nightly-release.yml index 550fd5a9..318b504c 100644 --- a/.github/workflows/nightly-release.yml +++ b/.github/workflows/nightly-release.yml @@ -887,8 +887,11 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} publish-types-beta: - needs: publish-release + needs: [publish-release] if: needs.check-commits.outputs.should_build == 'true' + permissions: + contents: read + id-token: write uses: ./.github/workflows/publish-npm-types.yml with: branch: dev diff --git a/.github/workflows/prod-release.yml b/.github/workflows/prod-release.yml index 8e7d8aa6..41cf33cf 100644 --- a/.github/workflows/prod-release.yml +++ b/.github/workflows/prod-release.yml @@ -44,11 +44,11 @@ jobs: run: | TOOLBOX_VERSION=$(node -p "require('./package.json').version") TYPES_VERSION=$(node -p "require('./packages/package.json').version") - + # Extract major.minor.patch from both versions (ignore pre-release tags) TOOLBOX_BASE=$(echo "$TOOLBOX_VERSION" | cut -d'-' -f1) TYPES_BASE=$(echo "$TYPES_VERSION" | cut -d'-' -f1) - + if [ "$TOOLBOX_BASE" != "$TYPES_BASE" ]; then echo "❌ Error: @pptb/types version ($TYPES_VERSION) does not match ToolBox version ($TOOLBOX_VERSION)" echo "The base version (major.minor.patch) must be identical for stable releases." @@ -58,7 +58,7 @@ jobs: echo "2. Commit the change and push" exit 1 fi - + echo "✅ Version validation passed: ToolBox $TOOLBOX_VERSION matches @pptb/types $TYPES_VERSION" build: @@ -829,10 +829,12 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} publish-types: - needs: publish-release + needs: [publish-release] + permissions: + contents: read + id-token: write uses: ./.github/workflows/publish-npm-types.yml with: branch: main tag: latest secrets: inherit - From 8bbf5508ab0810604f1abac0e937bb55a44d4e20 Mon Sep 17 00:00:00 2001 From: Power-Maverick Date: Thu, 26 Feb 2026 22:52:00 -0500 Subject: [PATCH 032/257] fix: replace pnpm with npm for publishing @pptb/types --- .github/workflows/publish-npm-types.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/publish-npm-types.yml b/.github/workflows/publish-npm-types.yml index 8fa23296..6bec1ba3 100644 --- a/.github/workflows/publish-npm-types.yml +++ b/.github/workflows/publish-npm-types.yml @@ -33,9 +33,6 @@ jobs: node-version: "20" registry-url: "https://registry.npmjs.org" - - name: Install pnpm - run: npm install -g pnpm@10.18.3 - - name: Get version id: version run: | @@ -47,6 +44,4 @@ jobs: working-directory: ./packages run: | echo "Publishing @pptb/types@${{ steps.version.outputs.version }} with tag ${{ inputs.tag }} to npm..." - pnpm publish --access public --tag ${{ inputs.tag }} --no-git-checks --provenance - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + npm publish --access public --tag ${{ inputs.tag }} --provenance From b0ca54129c80997249f40d79e10015e1a0aa43c1 Mon Sep 17 00:00:00 2001 From: Power-Maverick Date: Thu, 26 Feb 2026 22:58:14 -0500 Subject: [PATCH 033/257] fix: update job dependencies and version handling in release workflows --- .github/workflows/nightly-release.yml | 4 ++-- .github/workflows/prod-release.yml | 3 ++- .github/workflows/publish-npm-types.yml | 16 +++++++++++++++- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/.github/workflows/nightly-release.yml b/.github/workflows/nightly-release.yml index 318b504c..a9d7e9ae 100644 --- a/.github/workflows/nightly-release.yml +++ b/.github/workflows/nightly-release.yml @@ -887,7 +887,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} publish-types-beta: - needs: [publish-release] + needs: [check-commits, publish-release, create-release-draft] if: needs.check-commits.outputs.should_build == 'true' permissions: contents: read @@ -896,5 +896,5 @@ jobs: with: branch: dev tag: beta + version: ${{ needs.create-release-draft.outputs.new_version }} secrets: inherit - diff --git a/.github/workflows/prod-release.yml b/.github/workflows/prod-release.yml index 41cf33cf..395c8f50 100644 --- a/.github/workflows/prod-release.yml +++ b/.github/workflows/prod-release.yml @@ -829,7 +829,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} publish-types: - needs: [publish-release] + needs: [publish-release, create-release-draft] permissions: contents: read id-token: write @@ -837,4 +837,5 @@ jobs: with: branch: main tag: latest + version: ${{ needs.create-release-draft.outputs.version }} secrets: inherit diff --git a/.github/workflows/publish-npm-types.yml b/.github/workflows/publish-npm-types.yml index 6bec1ba3..a0f0f5cb 100644 --- a/.github/workflows/publish-npm-types.yml +++ b/.github/workflows/publish-npm-types.yml @@ -11,6 +11,10 @@ on: description: "npm tag (latest or beta)" required: true type: string + version: + description: "Version to publish for @pptb/types (must match app version)" + required: false + type: string permissions: contents: read @@ -36,10 +40,20 @@ jobs: - name: Get version id: version run: | - VERSION=$(node -p "require('./packages/package.json').version") + if [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + else + VERSION=$(node -p "require('./package.json').version") + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT echo "📦 Publishing @pptb/types version: $VERSION with tag: ${{ inputs.tag }}" + - name: Sync @pptb/types version to app version + working-directory: ./packages + run: | + npm version "${{ steps.version.outputs.version }}" --no-git-tag-version --allow-same-version + - name: Publish @pptb/types to npm working-directory: ./packages run: | From be4252d0c7440f9f8a98cb90c2a5f9898d40c08e Mon Sep 17 00:00:00 2001 From: Power-Maverick Date: Thu, 26 Feb 2026 23:11:33 -0500 Subject: [PATCH 034/257] fix: update versioning scheme from dev to beta for insider builds --- .github/workflows/nightly-release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/nightly-release.yml b/.github/workflows/nightly-release.yml index a9d7e9ae..eff7e487 100644 --- a/.github/workflows/nightly-release.yml +++ b/.github/workflows/nightly-release.yml @@ -75,7 +75,7 @@ jobs: run: | CURRENT_VERSION=$(node -p "require('./package.json').version") DATE_TAG=$(date +%Y%m%d) - NEW_VERSION="$CURRENT_VERSION-dev.$DATE_TAG" + NEW_VERSION="$CURRENT_VERSION-beta.$DATE_TAG" echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT echo "📦 Insider version: $NEW_VERSION" @@ -468,7 +468,7 @@ jobs: run: | CURRENT_VERSION=$(node -p "require('./package.json').version") DATE_TAG=$(date +%Y%m%d) - NEW_VERSION="$CURRENT_VERSION-dev.$DATE_TAG" + NEW_VERSION="$CURRENT_VERSION-beta.$DATE_TAG" echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT echo "tag_name=v$NEW_VERSION" >> $GITHUB_OUTPUT echo "release_name=Insider Dev Build - $NEW_VERSION" >> $GITHUB_OUTPUT From f0bf3b07ef268380e09718339ad52e47cc663730 Mon Sep 17 00:00:00 2001 From: LinkeD365 <43988771+LinkeD365@users.noreply.github.com> Date: Fri, 27 Feb 2026 21:38:08 +0000 Subject: [PATCH 035/257] Relationshipdefinitions setname (#415) * fix: add relationshipdefinition to entity metadata mapping in DataverseManager * fix: remove commented-out debug code in DataverseManager * Update src/main/managers/dataverseManager.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/main/managers/dataverseManager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/managers/dataverseManager.ts b/src/main/managers/dataverseManager.ts index e2b08c5b..de829bb9 100644 --- a/src/main/managers/dataverseManager.ts +++ b/src/main/managers/dataverseManager.ts @@ -418,7 +418,6 @@ export class DataverseManager { const { connection, accessToken } = await this.getConnectionWithToken(connectionId); const entitySetName = this.getEntitySetName(entityLogicalName); const url = this.buildApiUrl(connection, `api/data/${DATAVERSE_API_VERSION}/${entitySetName}(${id})`); - await this.makeHttpRequest(url, "DELETE", accessToken); } @@ -437,6 +436,7 @@ export class DataverseManager { usersettingscollection: "usersettingscollection", principalobjectaccess: "principalobjectaccessset", webresource: "webresourceset", + relationshipdefinition: "RelationshipDefinitions", }; const lowerName = entityLogicalName.toLowerCase(); From 02208a6d183d75a8472c730a5939eccff37123e8 Mon Sep 17 00:00:00 2001 From: Power-Maverick Date: Sat, 28 Feb 2026 21:49:09 -0500 Subject: [PATCH 036/257] fix: add registry URL configuration for npm publishing --- .github/workflows/publish-npm-types.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish-npm-types.yml b/.github/workflows/publish-npm-types.yml index a0f0f5cb..7613a3db 100644 --- a/.github/workflows/publish-npm-types.yml +++ b/.github/workflows/publish-npm-types.yml @@ -35,7 +35,6 @@ jobs: uses: actions/setup-node@v4 with: node-version: "20" - registry-url: "https://registry.npmjs.org" - name: Get version id: version @@ -56,6 +55,9 @@ jobs: - name: Publish @pptb/types to npm working-directory: ./packages + env: + NPM_CONFIG_USERCONFIG: ${{ runner.temp }}/npmrc run: | + printf "registry=https://registry.npmjs.org/\n" > "$NPM_CONFIG_USERCONFIG" echo "Publishing @pptb/types@${{ steps.version.outputs.version }} with tag ${{ inputs.tag }} to npm..." npm publish --access public --tag ${{ inputs.tag }} --provenance From dcc56e65c7ae934803d1a0cbaf301ef7c40304ac Mon Sep 17 00:00:00 2001 From: Power-Maverick Date: Sun, 1 Mar 2026 21:18:15 -0500 Subject: [PATCH 037/257] fix: comment out publish-types jobs in release workflows --- .github/workflows/nightly-release.yml | 24 ++++++++++++------------ .github/workflows/prod-release.yml | 22 +++++++++++----------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/.github/workflows/nightly-release.yml b/.github/workflows/nightly-release.yml index eff7e487..e205b122 100644 --- a/.github/workflows/nightly-release.yml +++ b/.github/workflows/nightly-release.yml @@ -886,15 +886,15 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - publish-types-beta: - needs: [check-commits, publish-release, create-release-draft] - if: needs.check-commits.outputs.should_build == 'true' - permissions: - contents: read - id-token: write - uses: ./.github/workflows/publish-npm-types.yml - with: - branch: dev - tag: beta - version: ${{ needs.create-release-draft.outputs.new_version }} - secrets: inherit + # publish-types-beta: + # needs: [check-commits, publish-release, create-release-draft] + # if: needs.check-commits.outputs.should_build == 'true' + # permissions: + # contents: read + # id-token: write + # uses: ./.github/workflows/publish-npm-types.yml + # with: + # branch: dev + # tag: beta + # version: ${{ needs.create-release-draft.outputs.new_version }} + # secrets: inherit diff --git a/.github/workflows/prod-release.yml b/.github/workflows/prod-release.yml index 395c8f50..3113e1b8 100644 --- a/.github/workflows/prod-release.yml +++ b/.github/workflows/prod-release.yml @@ -828,14 +828,14 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - publish-types: - needs: [publish-release, create-release-draft] - permissions: - contents: read - id-token: write - uses: ./.github/workflows/publish-npm-types.yml - with: - branch: main - tag: latest - version: ${{ needs.create-release-draft.outputs.version }} - secrets: inherit + # publish-types: + # needs: [publish-release, create-release-draft] + # permissions: + # contents: read + # id-token: write + # uses: ./.github/workflows/publish-npm-types.yml + # with: + # branch: main + # tag: latest + # version: ${{ needs.create-release-draft.outputs.version }} + # secrets: inherit From 37cf6eb80874c944234cd538c887fb93ec49d124 Mon Sep 17 00:00:00 2001 From: Danish Naglekar <36135520+Power-Maverick@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:25:44 -0500 Subject: [PATCH 038/257] feat: implement custom protocol handler for tool installation via pptb:// links (#419) * feat: implement custom protocol handler for tool installation via pptb:// links * Update src/main/managers/protocolHandlerManager.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/main/managers/protocolHandlerManager.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: ensure pptb:// deep links are never dropped on cold launch or early macOS open-url (#420) * Initial plan * fix: move protocol handler early listeners before whenReady() and buffer deep links until renderer ready Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> --- buildScripts/electron-builder-linux.json | 10 +- buildScripts/electron-builder-mac.json | 10 +- buildScripts/electron-builder-win-arm64.json | 10 +- buildScripts/electron-builder-win.json | 10 +- docs/PROTOCOL_HANDLER.md | 414 ++++++++++++++++++ src/common/ipc/channels.ts | 6 + src/common/types/api.ts | 3 + src/main/index.ts | 49 +++ src/main/managers/protocolHandlerManager.ts | 341 +++++++++++++++ src/main/preload.ts | 5 + src/renderer/modules/initialization.ts | 12 +- src/renderer/modules/marketplaceManagement.ts | 79 +++- 12 files changed, 943 insertions(+), 6 deletions(-) create mode 100644 docs/PROTOCOL_HANDLER.md create mode 100644 src/main/managers/protocolHandlerManager.ts diff --git a/buildScripts/electron-builder-linux.json b/buildScripts/electron-builder-linux.json index 43247bf0..a4217d33 100644 --- a/buildScripts/electron-builder-linux.json +++ b/buildScripts/electron-builder-linux.json @@ -11,5 +11,13 @@ ], "category": "Development", "maintainer": "Power Platform ToolBox" - } + }, + "protocols": [ + { + "name": "Power Platform ToolBox Protocol", + "schemes": [ + "pptb" + ] + } + ] } \ No newline at end of file diff --git a/buildScripts/electron-builder-mac.json b/buildScripts/electron-builder-mac.json index 7eb052f9..498106d2 100644 --- a/buildScripts/electron-builder-mac.json +++ b/buildScripts/electron-builder-mac.json @@ -31,5 +31,13 @@ }, "dmg": { "sign": true - } + }, + "protocols": [ + { + "name": "Power Platform ToolBox Protocol", + "schemes": [ + "pptb" + ] + } + ] } \ No newline at end of file diff --git a/buildScripts/electron-builder-win-arm64.json b/buildScripts/electron-builder-win-arm64.json index 937c9207..b314927e 100644 --- a/buildScripts/electron-builder-win-arm64.json +++ b/buildScripts/electron-builder-win-arm64.json @@ -29,5 +29,13 @@ "allowToChangeInstallationDirectory": true, "createDesktopShortcut": true, "createStartMenuShortcut": true - } + }, + "protocols": [ + { + "name": "Power Platform ToolBox Protocol", + "schemes": [ + "pptb" + ] + } + ] } \ No newline at end of file diff --git a/buildScripts/electron-builder-win.json b/buildScripts/electron-builder-win.json index 4ad4ea57..6a6c519d 100644 --- a/buildScripts/electron-builder-win.json +++ b/buildScripts/electron-builder-win.json @@ -35,5 +35,13 @@ "allowToChangeInstallationDirectory": true, "createDesktopShortcut": true, "createStartMenuShortcut": true - } + }, + "protocols": [ + { + "name": "Power Platform ToolBox Protocol", + "schemes": [ + "pptb" + ] + } + ] } \ No newline at end of file diff --git a/docs/PROTOCOL_HANDLER.md b/docs/PROTOCOL_HANDLER.md new file mode 100644 index 00000000..8602868d --- /dev/null +++ b/docs/PROTOCOL_HANDLER.md @@ -0,0 +1,414 @@ +# Custom Protocol Handler Implementation - `pptb://` + +## Overview + +This document describes the implementation of the `pptb://` custom protocol handler for the Power Platform ToolBox (PPTB) desktop application. This feature enables deep linking from external sources (such as a web-based tool catalog) to trigger tool installations in the desktop app. + +## Architecture + +The implementation follows VS Code's extension architecture pattern with security-first design: + +### Key Components + +1. **ProtocolHandlerManager** (`src/main/managers/protocolHandlerManager.ts`) + - Manages protocol registration and URL parsing + - Implements security validations and rate limiting + - Handles single-instance application locking +2. **IPC Communication** (`src/common/ipc/channels.ts`) + - New event channel: `PROTOCOL_INSTALL_TOOL_REQUEST` + - Enables main → renderer communication for protocol events + +3. **UI Handler** (`src/renderer/modules/marketplaceManagement.ts`) + - `handleProtocolInstallToolRequest()` function + - Shows tool detail modal for user confirmation + - Integrates with existing tool installation flow + +4. **Protocol Registration** (electron-builder configs) + - Windows: `buildScripts/electron-builder-win.json` + - macOS: `buildScripts/electron-builder-mac.json` + - Linux: `buildScripts/electron-builder-linux.json` + +## URL Format + +``` +pptb://install?toolId={toolId}&toolName={toolName} +``` + +### Parameters + +- **`toolId`** (required): Unique identifier of the tool + - Must be alphanumeric with hyphens/underscores only + - Maximum length: 100 characters + - Validation regex: `/^[a-zA-Z0-9_-]+$/` + +- **`toolName`** (optional): Human-readable name of the tool + - URL-encoded automatically (spaces as `%20`, etc.) + - Maximum length: 200 characters + - Used for display purposes only + +### Example URLs + +``` +pptb://install?toolId=dataverse-explorer&toolName=Dataverse%20Explorer +pptb://install?toolId=pcf-builder&toolName=PCF%20Component%20Builder +pptb://install?toolId=solution-viewer +``` + +## Security Features + +### 1. URL Validation + +- ✅ Whitelisted actions (only "install" allowed) +- ✅ Strict toolId format validation +- ✅ Length limits on all parameters +- ✅ URL decoding with error handling + +### 2. Rate Limiting + +- **Window**: 5 seconds +- **Max Requests**: 3 per window +- Prevents protocol spam/DOS attacks + +### 3. Input Sanitization + +```typescript +// Only alphanumeric, hyphens, and underscores allowed +const TOOL_ID_REGEX = /^[a-zA-Z0-9_-]+$/; + +// Malicious examples that are BLOCKED: +pptb://install?toolId=../../etc/passwd // ❌ Blocked +pptb://install?toolId= // ❌ Blocked +pptb://install?toolId='; DROP TABLE tools; -- // ❌ Blocked +``` + +### 4. User Confirmation Flow + +1. Protocol URL detected +2. App brought to foreground +3. Tool detail modal shown +4. User must explicitly click "Install" +5. No automatic installation + +### 5. Single Instance Lock + +- Ensures only one app instance runs +- Second instance passes URL to first instance +- Prevents race conditions + +## Platform-Specific Behavior + +### macOS + +- Handled via `app.on('open-url')` event +- Protocol registration in `.plist` file (handled by electron-builder) +- Works when app is not running or already running + +### Windows + +- Registered via Windows Registry during installation +- Handled via `app.on('second-instance')` or command-line args +- Protocol URL passed in command-line arguments + +### Linux + +- Registered in `.desktop` file (AppImage) +- Handled similarly to Windows via command-line args +- May require desktop environment restart to register + +## Implementation Flow + +``` +┌─────────────────┐ +│ Web App/Link │ +│ clicks: │ +│ pptb://install │ +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ OS Protocol │ ◄──── Registered during installation +│ Handler │ (Windows Registry / macOS .plist / Linux .desktop) +└────────┬────────┘ + │ + ▼ +┌─────────────────────────────────────────────────┐ +│ PPTB App (Main Process) │ +│ ProtocolHandlerManager │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ 1. Check single instance lock │ │ +│ │ 2. Parse & validate URL │ │ +│ │ 3. Rate limit check │ │ +│ │ 4. Sanitize toolId │ │ +│ │ 5. Send IPC event to renderer │ │ +│ └─────────────────────────────────────────────┘ │ +└────────┬────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────┐ +│ Renderer Process │ +│ marketplaceManagement.ts │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ 1. Fetch tool library │ │ +│ │ 2. Find tool by toolId │ │ +│ │ 3. Check if already installed │ │ +│ │ 4. Show tool detail modal │ │ +│ │ 5. User clicks "Install" │ │ +│ │ 6. Install via installToolFromRegistry() │ │ +│ └─────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────┘ +``` + +## Testing Instructions + +### Prerequisites + +```bash +pnpm install +pnpm run build +pnpm run package # To create installer +``` + +### Manual Testing + +#### macOS + +```bash +# Test with app running +open "pptb://install?toolId=dataverse-explorer&toolName=Dataverse%20Explorer" + +# Test with app not running +killall "Power Platform ToolBox" +open "pptb://install?toolId=dataverse-explorer&toolName=Dataverse%20Explorer" +``` + +#### Windows (PowerShell) + +```powershell +# Test with app running +Start-Process "pptb://install?toolId=dataverse-explorer&toolName=Dataverse%20Explorer" + +# Test with app not running +taskkill /IM "Power Platform ToolBox.exe" /F +Start-Process "pptb://install?toolId=dataverse-explorer&toolName=Dataverse%20Explorer" +``` + +#### Linux + +```bash +# Test with app running +xdg-open "pptb://install?toolId=dataverse-explorer&toolName=Dataverse%20Explorer" + +# Test with app not running +pkill -f "Power Platform ToolBox" +xdg-open "pptb://install?toolId=dataverse-explorer&toolName=Dataverse%20Explorer" +``` + +### Test Cases + +1. **Valid Tool ID** + + ``` + pptb://install?toolId=valid-tool-123&toolName=Test%20Tool + ✅ Should open tool detail modal + ``` + +2. **Invalid Tool ID (Special Characters)** + + ``` + pptb://install?toolId=../../../etc/passwd + ❌ Should be blocked, show error + ``` + +3. **Missing Tool ID** + + ``` + pptb://install?toolName=Test%20Tool + ❌ Should be blocked, show error + ``` + +4. **Non-existent Tool** + + ``` + pptb://install?toolId=nonexistent-tool-xyz + ⚠️ Should show "Tool Not Found" notification + ``` + +5. **Already Installed Tool** + + ``` + pptb://install?toolId=already-installed-tool + ℹ️ Should show "Already Installed" notification + ``` + +6. **Rate Limiting** + + ```bash + # Click protocol link 4 times rapidly + ⚠️ 4th request should be blocked + ``` + +7. **URL Encoding** + ``` + pptb://install?toolId=test-tool&toolName=Test%20Tool%20With%20Spaces + ✅ Should decode correctly + ``` + +### Debugging + +Enable verbose logging by checking Sentry logs: + +```typescript +// In development, check console for: +[ProtocolHandler] Received open-url event: pptb://install?... +[ProtocolHandler] Handling protocol URL: pptb://install?... +[Protocol] Handling install request for tool: {toolId} +``` + +### Integration with Web App + +Example HTML: + +```html + Install in Desktop App +``` + +Example JavaScript: + +```javascript +function installInDesktop(toolId, toolName) { + const url = `pptb://install?toolId=${encodeURIComponent(toolId)}&toolName=${encodeURIComponent(toolName)}`; + window.location.href = url; +} + +// Usage +installInDesktop("dataverse-explorer", "Dataverse Explorer"); +``` + +## Error Handling + +### Graceful Degradation + +1. **Tool Not Found**: Shows notification, doesn't crash +2. **Invalid URL**: Logged to Sentry, silently ignored +3. **Rate Limited**: Logged to Sentry, request dropped +4. **Installation Failure**: Shows error notification with details + +### Monitoring + +All protocol events are logged to Sentry with appropriate tags: + +- `manager: ProtocolHandler` +- `phase: parse_url|handle_callback|protocol_install` +- `trigger: open-url|second-instance|startup` + +## Best Practices & Recommendations + +### For Web Developers + +1. **Always URL-encode parameters**: + + ```javascript + const toolName = "My Tool 2.0 (Beta)"; + const encoded = encodeURIComponent(toolName); + // Result: "My%20Tool%202.0%20%28Beta%29" + ``` + +2. **Provide fallback for browsers without handler**: + + ```javascript + function installTool(toolId, toolName) { + const protocolUrl = `pptb://install?toolId=${toolId}&toolName=${encodeURIComponent(toolName)}`; + + // Try protocol first + window.location.href = protocolUrl; + + // Fallback after 2 seconds if app not installed + setTimeout(() => { + if (confirm("Desktop app not installed. Download now?")) { + window.location.href = "https://github.com/PowerPlatformToolBox/desktop-app/releases"; + } + }, 2000); + } + ``` + +3. **Validate toolId before generating link**: + + ```javascript + const TOOL_ID_REGEX = /^[a-zA-Z0-9_-]+$/; + + if (!TOOL_ID_REGEX.test(toolId)) { + console.error("Invalid toolId format"); + return; + } + ``` + +### For Desktop App Maintainers + +1. **Never auto-install**: Always show confirmation dialog +2. **Log all protocol events**: Essential for debugging +3. **Update rate limits**: If needed based on usage patterns +4. **Monitor Sentry**: Check for blocked malicious attempts + +## Future Enhancements + +### Potential Features + +1. **Multi-action support**: + - `pptb://uninstall?toolId={toolId}` + - `pptb://launch?toolId={toolId}&connectionId={connectionId}` + - `pptb://update?toolId={toolId}` + +2. **Deep linking parameters**: + - `pptb://install?toolId={toolId}&autoStart=true` + - `pptb://install?toolId={toolId}&category={category}` + +3. **Analytics tracking**: + - Track protocol install vs manual install + - Monitor success/failure rates + - A/B test different web flows + +4. **Enhanced security**: + - Token-based authentication + - Time-limited install URLs + - Signed URLs from trusted sources + +## Troubleshooting + +### Protocol Not Registered + +**Symptoms**: Clicking link does nothing or opens browser + +**Solutions**: + +- **Windows**: Reinstall app (protocol registered during install) +- **macOS**: App must be moved to `/Applications` folder +- **Linux**: Run `update-desktop-database` after install + +### App Not Launching + +**Symptoms**: Error message "No application found" + +**Solutions**: + +1. Verify app is installed correctly +2. Check protocol registration: + - **Windows**: Check `HKEY_CLASSES_ROOT\pptb` in Registry + - **macOS**: Check `/Applications/Power Platform ToolBox.app/Contents/Info.plist` + - **Linux**: Check `~/.local/share/applications/*.desktop` + +### Rate Limiting Issues + +**Symptoms**: Protocol clicks not working after multiple attempts + +**Solutions**: + +- Wait 5 seconds between requests +- Restart app to reset rate limiter +- Check Sentry logs for rate limit warnings + +## References + +- [Electron Custom Protocol API](https://www.electronjs.org/docs/latest/api/protocol) +- [Electron App setAsDefaultProtocolClient](https://www.electronjs.org/docs/latest/api/app#appsetasdefaultprotocolclientprotocol-path-args) +- [VS Code URI Handlers](https://code.visualstudio.com/api/references/vscode-api#Uri) +- [Deep Linking Best Practices](https://developer.apple.com/documentation/xcode/defining-a-custom-url-scheme-for-your-app) diff --git a/src/common/ipc/channels.ts b/src/common/ipc/channels.ts index aca34f05..6871570a 100644 --- a/src/common/ipc/channels.ts +++ b/src/common/ipc/channels.ts @@ -190,6 +190,11 @@ export const DATAVERSE_CHANNELS = { GET_CSDL_DOCUMENT: "dataverse.getCSDLDocument", } as const; +// Protocol handler-related IPC channels +export const PROTOCOL_CHANNELS = { + PROTOCOL_INSTALL_TOOL: "protocol:install-tool", +} as const; + // Event-related IPC channels (from main to renderer) export const EVENT_CHANNELS = { TOOLBOX_EVENT: "toolbox-event", @@ -211,6 +216,7 @@ export const EVENT_CHANNELS = { MODAL_WINDOW_MESSAGE: "modal-window:message", TOOL_UPDATE_STARTED: "tool:update-started", TOOL_UPDATE_COMPLETED: "tool:update-completed", + PROTOCOL_INSTALL_TOOL_REQUEST: "protocol:install-tool-request", } as const; // Internal BrowserWindow modal channels (modal content -> main process) diff --git a/src/common/types/api.ts b/src/common/types/api.ts index 1b34f608..9d5b9dba 100644 --- a/src/common/types/api.ts +++ b/src/common/types/api.ts @@ -230,6 +230,9 @@ export interface ToolboxAPI { onToolUpdateStarted: (callback: (toolId: string) => void) => void; onToolUpdateCompleted: (callback: (toolId: string) => void) => void; + // Protocol deep link events + onProtocolInstallToolRequest: (callback: (params: { toolId: string; toolName: string }) => void) => void; + // Dataverse namespace dataverse: DataverseAPI; } diff --git a/src/main/index.ts b/src/main/index.ts index 64972c61..e7eaef1c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -94,6 +94,7 @@ 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"; import { SettingsManager } from "./managers/settingsManager"; import { TerminalManager } from "./managers/terminalManager"; import { ToolBoxUtilityManager } from "./managers/toolboxUtilityManager"; @@ -112,6 +113,7 @@ class ToolBoxApp { private connectionsManager: ConnectionsManager; private toolManager: ToolManager; private browserviewProtocolManager: BrowserviewProtocolManager; + private protocolHandlerManager: ProtocolHandlerManager; private toolWindowManager: ToolWindowManager | null = null; private notificationWindowManager: NotificationWindowManager | null = null; private loadingOverlayWindowManager: LoadingOverlayWindowManager | null = null; @@ -152,6 +154,7 @@ class ToolBoxApp { process.env.AZURE_BLOB_BASE_URL, ); this.browserviewProtocolManager = new BrowserviewProtocolManager(this.toolManager, this.settingsManager); + this.protocolHandlerManager = new ProtocolHandlerManager(); this.autoUpdateManager = new AutoUpdateManager(); this.browserManager = new BrowserManager(); this.authManager = new AuthManager(this.browserManager); @@ -2876,6 +2879,15 @@ class ToolBoxApp { this.browserviewProtocolManager.registerScheme(); addBreadcrumb("Registered custom protocol scheme", "init", "info"); + // Register deep link protocol handler (pptb://) + this.protocolHandlerManager.registerScheme(); + addBreadcrumb("Registered pptb:// protocol scheme", "init", "info"); + + // Initialize early protocol listeners (single-instance lock, open-url, second-instance) + // MUST be called before app.whenReady() so no deep link is missed. + this.protocolHandlerManager.initialize(); + addBreadcrumb("Protocol handler early listeners registered", "init", "info"); + await app.whenReady(); logCheckpoint("Electron app ready"); @@ -2886,6 +2898,43 @@ class ToolBoxApp { this.createWindow(); logCheckpoint("Main window 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 + // reliably delivered even on a cold launch via pptb://. + this.protocolHandlerManager.setupProtocolHandler(async (action, params) => { + logInfo(`[ProtocolHandler] Received ${action} request for tool: ${params.toolId}`); + + // Bring app window to focus + if (this.mainWindow) { + if (this.mainWindow.isMinimized()) { + this.mainWindow.restore(); + } + this.mainWindow.focus(); + } + + // Deliver the IPC event to the renderer. If the renderer is still + // loading (e.g. cold launch via protocol URL), defer until it finishes. + if (this.mainWindow) { + const webContents = this.mainWindow.webContents; + const deliver = (): void => { + if (!webContents.isDestroyed()) { + webContents.send(EVENT_CHANNELS.PROTOCOL_INSTALL_TOOL_REQUEST, { + toolId: params.toolId, + toolName: params.toolName, + }); + } + }; + + if (webContents.isLoading()) { + webContents.once("did-finish-load", deliver); + } else { + deliver(); + } + } + }); + addBreadcrumb("Protocol handler callback registered", "init", "info"); + // Load all installed tools from registry try { await this.toolManager.loadAllInstalledTools(); diff --git a/src/main/managers/protocolHandlerManager.ts b/src/main/managers/protocolHandlerManager.ts new file mode 100644 index 00000000..343f6fca --- /dev/null +++ b/src/main/managers/protocolHandlerManager.ts @@ -0,0 +1,341 @@ +import { app } from "electron"; +import { captureException, captureMessage, logInfo } from "../../common/sentryHelper"; + +/** + * Protocol URL structure for tool installation + */ +interface ToolInstallProtocolParams { + toolId: string; + toolName: string; +} + +/** + * Protocol handler action types + */ +export type ProtocolAction = "install"; + +/** + * Protocol handler callback function + */ +type ProtocolHandlerCallback = (action: ProtocolAction, params: ToolInstallProtocolParams) => Promise; + +/** + * ProtocolHandlerManager + * Manages the custom pptb:// protocol for deep linking from web apps + * + * **Security Features**: + * - Validates protocol action (only "install" allowed) + * - Sanitizes and validates toolId (alphanumeric, hyphens, underscores only) + * - Decodes URL-encoded parameters + * - Rate limiting to prevent protocol spam/DOS + * - Blocks malformed or suspicious URLs + * + * **URL Format**: pptb://install?toolId={toolId}&toolName={toolName} + * + * Example: + * pptb://install?toolId=dataverse-explorer&toolName=Dataverse%20Explorer + */ +export class ProtocolHandlerManager { + private static readonly PROTOCOL_SCHEME = "pptb"; + private static readonly ALLOWED_ACTIONS: ProtocolAction[] = ["install"]; + private static readonly TOOL_ID_REGEX = /^[a-zA-Z0-9_-]+$/; + private static readonly MAX_TOOL_ID_LENGTH = 100; + private static readonly MAX_TOOL_NAME_LENGTH = 200; + private static readonly RATE_LIMIT_WINDOW_MS = 5000; // 5 seconds + private static readonly MAX_REQUESTS_PER_WINDOW = 3; + + private protocolCallback: ProtocolHandlerCallback | null = null; + private recentProtocolRequests: number[] = []; + private pendingUrls: string[] = []; + + constructor() { + logInfo("[ProtocolHandler] Initializing protocol handler manager"); + } + + /** + * Register the protocol as a standard protocol scheme + * Must be called BEFORE app.whenReady() + */ + registerScheme(): void { + try { + if (app.isReady()) { + captureMessage("[ProtocolHandler] Warning: registerScheme called after app is ready. This may not work correctly.", "warning"); + } + + // Register the scheme as standard to allow query parameters + app.setAsDefaultProtocolClient(ProtocolHandlerManager.PROTOCOL_SCHEME); + + logInfo(`[ProtocolHandler] Registered ${ProtocolHandlerManager.PROTOCOL_SCHEME}:// as default protocol client`); + } catch (error) { + captureException(error instanceof Error ? error : new Error(String(error)), { + tags: { manager: "ProtocolHandler", phase: "register_scheme" }, + level: "error", + }); + } + } + + /** + * 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. + */ + initialize(): void { + // Acquire the single-instance lock as early as possible so a second launch + // forwards its command line to the first instance and then quits. + const gotTheLock = app.requestSingleInstanceLock(); + if (!gotTheLock) { + logInfo("[ProtocolHandler] Another instance is already running, quitting this instance"); + app.quit(); + return; + } + + // macOS: open-url is emitted before (or around) app.whenReady() – must be + // registered here so we never miss a launch-via-protocol event. + app.on("open-url", (event, url) => { + event.preventDefault(); + logInfo(`[ProtocolHandler] Received open-url event: ${url}`); + this.bufferOrHandle(url, "open-url"); + }); + + // Windows/Linux: a second instance forwards its command line here. + app.on("second-instance", (_event, commandLine) => { + logInfo("[ProtocolHandler] Second instance detected, processing command line"); + const url = commandLine.find((arg) => arg.startsWith(`${ProtocolHandlerManager.PROTOCOL_SCHEME}://`)); + if (url) { + logInfo(`[ProtocolHandler] Processing protocol URL from second instance: ${url}`); + this.bufferOrHandle(url, "second-instance"); + } + }); + + // Windows/Linux first launch via protocol URL: the URL is in process.argv. + if (process.platform === "win32" || process.platform === "linux") { + const protocolUrl = process.argv.find((arg) => arg.startsWith(`${ProtocolHandlerManager.PROTOCOL_SCHEME}://`)); + if (protocolUrl) { + logInfo(`[ProtocolHandler] Buffering protocol URL from startup args: ${protocolUrl}`); + this.pendingUrls.push(protocolUrl); + } + } + + logInfo("[ProtocolHandler] Early protocol listeners registered"); + } + + /** + * Register the protocol handler callback and flush any URLs that were buffered + * before the callback was available. Must be called AFTER the main window has + * been created so that the callback can safely deliver IPC to the renderer. + */ + setupProtocolHandler(callback: ProtocolHandlerCallback): void { + this.protocolCallback = callback; + + // Process any URLs received before the callback was registered. + const buffered = this.pendingUrls.splice(0); + for (const url of buffered) { + logInfo(`[ProtocolHandler] Processing buffered protocol URL: ${url}`); + this.handleProtocolUrl(url).catch((error) => { + captureException(error instanceof Error ? error : new Error(String(error)), { + tags: { manager: "ProtocolHandler", trigger: "buffered" }, + }); + }); + } + + logInfo("[ProtocolHandler] Protocol handler callback registered"); + } + + /** + * Buffer the URL for later processing, or handle it immediately if the + * callback has already been registered. + */ + private bufferOrHandle(url: string, trigger: string): void { + if (this.protocolCallback) { + this.handleProtocolUrl(url).catch((error) => { + captureException(error instanceof Error ? error : new Error(String(error)), { + tags: { manager: "ProtocolHandler", trigger }, + }); + }); + } else { + this.pendingUrls.push(url); + } + } + + /** + * Parse and validate protocol URL + * Format: pptb://install?toolId={toolId}&toolName={toolName} + */ + private parseProtocolUrl(urlString: string): { action: ProtocolAction; params: ToolInstallProtocolParams } | null { + try { + // Validate protocol scheme + if (!urlString.startsWith(`${ProtocolHandlerManager.PROTOCOL_SCHEME}://`)) { + captureMessage(`[ProtocolHandler] Invalid protocol scheme: ${urlString}`, "warning"); + return null; + } + + const url = new URL(urlString); + + // Validate action (host part of URL) + const action = url.hostname.toLowerCase(); + if (!ProtocolHandlerManager.ALLOWED_ACTIONS.includes(action as ProtocolAction)) { + captureMessage(`[ProtocolHandler] Invalid action: ${action}`, "warning", { + extra: { allowed: ProtocolHandlerManager.ALLOWED_ACTIONS }, + }); + return null; + } + + // Extract and decode parameters + const toolId = url.searchParams.get("toolId"); + const toolName = url.searchParams.get("toolName"); + + // Validate required parameters + if (!toolId) { + captureMessage("[ProtocolHandler] Missing required parameter: toolId", "warning"); + return null; + } + + // Sanitize and validate toolId + const sanitizedToolId = this.sanitizeToolId(toolId); + if (!sanitizedToolId) { + captureMessage(`[ProtocolHandler] Invalid toolId format: ${toolId}`, "warning"); + return null; + } + + // Sanitize toolName (optional, will be fetched from registry if missing) + const sanitizedToolName = toolName ? this.sanitizeToolName(toolName) : sanitizedToolId; + + return { + action: action as ProtocolAction, + params: { + toolId: sanitizedToolId, + toolName: sanitizedToolName, + }, + }; + } catch (error) { + captureException(error instanceof Error ? error : new Error(String(error)), { + tags: { manager: "ProtocolHandler", phase: "parse_url" }, + extra: { url: urlString }, + }); + return null; + } + } + + /** + * Sanitize and validate toolId + * Only allows alphanumeric characters, hyphens, and underscores + */ + private sanitizeToolId(toolId: string): string | null { + if (!toolId || typeof toolId !== "string") { + return null; + } + + const trimmed = toolId.trim(); + + // Check length + if (trimmed.length === 0 || trimmed.length > ProtocolHandlerManager.MAX_TOOL_ID_LENGTH) { + return null; + } + + // Validate against regex (only alphanumeric, hyphens, underscores) + if (!ProtocolHandlerManager.TOOL_ID_REGEX.test(trimmed)) { + return null; + } + + return trimmed; + } + + /** + * Sanitize and validate toolName (expects an already-decoded value) + */ + private sanitizeToolName(toolName: string): string { + if (!toolName || typeof toolName !== "string") { + return ""; + } + + // Trim and limit length before escaping + const trimmed = toolName.trim().substring(0, ProtocolHandlerManager.MAX_TOOL_NAME_LENGTH); + + // HTML-encode special characters to prevent HTML/JS injection when rendered in notification HTML + const escaped = trimmed.replace(/[&<>"']/g, (char) => { + switch (char) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case "\"": + return """; + case "'": + return "'"; + default: + return char; + } + }); + + return escaped; + } + + /** + * Rate limiting check to prevent protocol spam/DOS + */ + private checkRateLimit(): boolean { + const now = Date.now(); + + // Remove old requests outside the window + this.recentProtocolRequests = this.recentProtocolRequests.filter((timestamp) => now - timestamp < ProtocolHandlerManager.RATE_LIMIT_WINDOW_MS); + + // Check if rate limit exceeded + if (this.recentProtocolRequests.length >= ProtocolHandlerManager.MAX_REQUESTS_PER_WINDOW) { + captureMessage("[ProtocolHandler] Rate limit exceeded", "warning", { + extra: { + requestCount: this.recentProtocolRequests.length, + window: ProtocolHandlerManager.RATE_LIMIT_WINDOW_MS, + }, + }); + return false; + } + + // Add current request + this.recentProtocolRequests.push(now); + return true; + } + + /** + * Handle protocol URL and invoke callback + */ + private async handleProtocolUrl(urlString: string): Promise { + logInfo(`[ProtocolHandler] Handling protocol URL: ${urlString}`); + + // Check rate limit + if (!this.checkRateLimit()) { + captureMessage("[ProtocolHandler] Protocol request blocked due to rate limiting", "warning"); + return; + } + + // Parse and validate URL + const parsed = this.parseProtocolUrl(urlString); + if (!parsed) { + captureMessage("[ProtocolHandler] Failed to parse or validate protocol URL", "warning", { + extra: { url: urlString }, + }); + return; + } + + // Invoke callback if registered + if (!this.protocolCallback) { + captureMessage("[ProtocolHandler] No protocol callback registered", "warning"); + return; + } + + try { + await this.protocolCallback(parsed.action, parsed.params); + logInfo(`[ProtocolHandler] Protocol action completed: ${parsed.action} for tool ${parsed.params.toolId}`); + } catch (error) { + captureException(error instanceof Error ? error : new Error(String(error)), { + tags: { manager: "ProtocolHandler", phase: "handle_callback" }, + extra: { + action: parsed.action, + toolId: parsed.params.toolId, + }, + }); + } + } +} diff --git a/src/main/preload.ts b/src/main/preload.ts index a1af3afd..2a347a02 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -229,6 +229,11 @@ contextBridge.exposeInMainWorld("toolboxAPI", { ipcRenderer.on(EVENT_CHANNELS.TOOL_UPDATE_COMPLETED, (_, toolId) => callback(toolId)); }, + // Protocol deep link events + onProtocolInstallToolRequest: (callback: (params: { toolId: string; toolName: string }) => void) => { + ipcRenderer.on(EVENT_CHANNELS.PROTOCOL_INSTALL_TOOL_REQUEST, (_, params) => callback(params)); + }, + // Dataverse API - Can be called by tools via message routing dataverse: { create: (entityLogicalName: string, record: Record, connectionTarget?: "primary" | "secondary") => diff --git a/src/renderer/modules/initialization.ts b/src/renderer/modules/initialization.ts index 30fb3dfa..d95d2f03 100644 --- a/src/renderer/modules/initialization.ts +++ b/src/renderer/modules/initialization.ts @@ -71,7 +71,7 @@ import { initializeBrowserWindowModals } from "./browserWindowModals"; import { handleReauthentication, initializeAddConnectionModalBridge, loadSidebarConnections, openAddConnectionModal, updateFooterConnection } from "./connectionManagement"; import { initializeGlobalSearch } from "./globalSearchManagement"; import { loadHomepageData, setupHomepageActions } from "./homepageManagement"; -import { loadMarketplace, loadToolsLibrary } from "./marketplaceManagement"; +import { handleProtocolInstallToolRequest, loadMarketplace, loadToolsLibrary } from "./marketplaceManagement"; import { closeModal, openModal } from "./modalManagement"; import { showPPTBNotification } from "./notifications"; import { saveSidebarSettings } from "./settingsManagement"; @@ -683,6 +683,16 @@ function setupApplicationEventListeners(): void { }); }); }); + + // Protocol deep link handler + window.toolboxAPI.onProtocolInstallToolRequest((params: { toolId: string; toolName: string }) => { + handleProtocolInstallToolRequest(params).catch((error) => { + captureException(error instanceof Error ? error : new Error(String(error)), { + tags: { phase: "protocol_install" }, + extra: { toolId: params.toolId, toolName: params.toolName }, + }); + }); + }); } /** diff --git a/src/renderer/modules/marketplaceManagement.ts b/src/renderer/modules/marketplaceManagement.ts index a48187ae..6274d3c6 100644 --- a/src/renderer/modules/marketplaceManagement.ts +++ b/src/renderer/modules/marketplaceManagement.ts @@ -3,7 +3,7 @@ * Handles tool library, marketplace UI, and tool installation */ -import { captureMessage, logInfo } from "../../common/sentryHelper"; +import { captureException, captureMessage, logInfo } from "../../common/sentryHelper"; import type { ModalWindowClosedPayload, ModalWindowMessagePayload, Tool } from "../../common/types"; import { getToolDetailModalControllerScript } from "../modals/toolDetail/controller"; import { getToolDetailModalView } from "../modals/toolDetail/view"; @@ -698,3 +698,80 @@ function clearMarketplaceFilters(): void { // Reload the marketplace to reflect the cleared filters loadMarketplace(); } + +/** + * Handle protocol deep link install request + * Called when user clicks pptb://install?toolId={toolId}&toolName={toolName} + * + * @param params - Protocol parameters containing toolId and toolName + */ +export async function handleProtocolInstallToolRequest(params: { toolId: string; toolName: string }): Promise { + logInfo(`[Protocol] Handling install request for tool: ${params.toolId}`); + + try { + // First, fetch tool library to get full tool details + await loadToolsLibrary(); + + // Find the tool in the library + const tool = toolLibrary.find((t) => t.id === params.toolId); + + if (!tool) { + captureMessage(`[Protocol] Tool not found in registry: ${params.toolId}`, "warning", { + extra: { toolId: params.toolId, toolName: params.toolName }, + }); + + window.toolboxAPI.utils.showNotification({ + title: "Tool Not Found", + body: `The tool "${params.toolName}" (${params.toolId}) could not be found in the registry.`, + type: "error", + }); + + return; + } + + // Check if already installed + const installedTools = await window.toolboxAPI.getAllTools(); + const isInstalled = installedTools.some((t) => t.id === params.toolId); + + if (isInstalled) { + logInfo(`[Protocol] Tool ${params.toolId} is already installed`); + + window.toolboxAPI.utils.showNotification({ + title: "Already Installed", + body: `${tool.name} is already installed.`, + type: "info", + }); + + // Switch to marketplace view to show the tool + const marketplaceBtn = document.getElementById("marketplace-btn"); + if (marketplaceBtn) { + marketplaceBtn.click(); + } + + return; + } + + // Show tool detail modal with install option + logInfo(`[Protocol] Opening tool detail modal for ${params.toolId}`); + await openToolDetail(tool, isInstalled); + + // Show notification to guide user + window.toolboxAPI.utils.showNotification({ + title: "Tool Installation", + body: `Click "Install" to add ${tool.name} to your toolbox.`, + type: "info", + }); + } catch (error) { + const errorMessage = formatError(error); + captureException(error instanceof Error ? error : new Error(String(error)), { + tags: { phase: "protocol_install" }, + extra: { toolId: params.toolId, toolName: params.toolName }, + }); + + window.toolboxAPI.utils.showNotification({ + title: "Installation Failed", + body: `Failed to process installation request: ${errorMessage}`, + type: "error", + }); + } +} From 744b6d5cde3787655442c0eab51d2922b163566d Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 12:59:38 -0500 Subject: [PATCH 039/257] feat: Show tool details as a tab instead of a modal dialog (#421) * Initial plan * feat: show tool details as a tab instead of a modal dialog - Add HIDE_ALL IPC channel to hide all BrowserViews when showing detail tabs - Add hideToolWindows() IPC handler in ToolWindowManager and expose via preload - Add isDetailTab flag to OpenTool type for distinguishing detail tabs - Add #tool-detail-content-panel div to index.html for inline content rendering - Add openToolDetailTab() function in toolManagement.ts with full tab lifecycle - Modify switchToTool() to handle detail tabs (hide BrowserViews, show panel) - Modify closeTool() to handle detail tabs (no BrowserView to close) - Modify saveSession() to exclude detail tabs from persistence - Replace modal-based openToolDetail() in marketplaceManagement.ts with tab approach - Add renderToolDetailContent() that renders tool details directly in the renderer - Open all links (review, repo, website, README links) via toolboxAPI.openExternal - Add CSS styles for tool-detail-content-panel in styles.scss - Add visual indicator for active tool detail tabs Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * fix: improve type safety for marked library loader and add XSS comment Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * fix: tool detail tab full-width, ' - Details' label, and README loading - Hide tool-panel-content when detail tab is active so the detail panel gets the full available space (not 50/50 split) - Restore tool-panel-content when switching back to a real tool tab - Tab now shows '{Tool Name} - Details' label as requested - Fix README not loading: replace CDN-loaded marked with bundled dependency (cdn.jsdelivr.net was blocked by CSP script-src) - Add https://*.blob.core.windows.net to connect-src in CSP so README files hosted on Azure Blob can be fetched Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * fix: address PR review security and correctness issues in tool detail tab - XSS: configure marked renderer to escape both block and inline raw HTML (html() handles Tokens.HTML + Tokens.Tag) preventing event handler injection - Race condition: loadToolReadme() now checks data-tab-id on the detail panel after async fetch and on error to discard stale results - Fake toolId: detail tab entries use empty string toolId so connections are never persisted under a synthetic tab ID Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> --- package.json | 1 + pnpm-lock.yaml | 10 + src/common/ipc/channels.ts | 1 + src/common/types/api.ts | 1 + src/main/managers/toolWindowManager.ts | 9 + src/main/preload.ts | 1 + src/renderer/index.html | 4 +- src/renderer/modules/marketplaceManagement.ts | 325 +++++++++--------- src/renderer/modules/toolManagement.ts | 190 +++++++++- src/renderer/styles.scss | 211 ++++++++++++ src/renderer/types/index.ts | 1 + 11 files changed, 565 insertions(+), 189 deletions(-) diff --git a/package.json b/package.json index 07ab0816..26ac4bda 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "ansi-to-html": "^0.7.2", "electron-store": "^8.1.0", "electron-updater": "^6.6.2", + "marked": "17.0.3", "uuid": "^13.0.0" }, "build": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cadaefab..07b5b61b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: electron-updater: specifier: ^6.6.2 version: 6.7.3 + marked: + specifier: 17.0.3 + version: 17.0.3 uuid: specifier: ^13.0.0 version: 13.0.0 @@ -2185,6 +2188,11 @@ packages: resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==} engines: {node: '>=12'} + marked@17.0.3: + resolution: {integrity: sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A==} + engines: {node: '>= 20'} + hasBin: true + matcher@3.0.0: resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} engines: {node: '>=10'} @@ -5414,6 +5422,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + marked@17.0.3: {} + matcher@3.0.0: dependencies: escape-string-regexp: 4.0.0 diff --git a/src/common/ipc/channels.ts b/src/common/ipc/channels.ts index 6871570a..41ce7ead 100644 --- a/src/common/ipc/channels.ts +++ b/src/common/ipc/channels.ts @@ -83,6 +83,7 @@ export const TOOL_WINDOW_CHANNELS = { GET_ACTIVE: "tool-window:get-active", GET_OPEN_TOOLS: "tool-window:get-open-tools", UPDATE_TOOL_CONNECTION: "tool-window:update-tool-connection", + HIDE_ALL: "tool-window:hide-all", } as const; // Terminal-related IPC channels diff --git a/src/common/types/api.ts b/src/common/types/api.ts index 9d5b9dba..f126ac57 100644 --- a/src/common/types/api.ts +++ b/src/common/types/api.ts @@ -148,6 +148,7 @@ export interface ToolboxAPI { launchToolWindow: (instanceId: string, tool: Tool, primaryConnectionId: string | null, secondaryConnectionId?: string | null) => Promise; switchToolWindow: (toolId: string) => Promise; closeToolWindow: (toolId: string) => Promise; + hideToolWindows: () => Promise; getActiveToolWindow: () => Promise; getOpenToolWindows: () => Promise; updateToolConnection: (instanceId: string, primaryConnectionId: string | null, secondaryConnectionId?: string | null) => Promise; diff --git a/src/main/managers/toolWindowManager.ts b/src/main/managers/toolWindowManager.ts index 6edaa521..17ef8e74 100644 --- a/src/main/managers/toolWindowManager.ts +++ b/src/main/managers/toolWindowManager.ts @@ -121,6 +121,7 @@ export class ToolWindowManager { 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); } /** @@ -162,6 +163,14 @@ export class ToolWindowManager { return this.updateToolConnection(instanceId, primaryConnectionId, secondaryConnectionId); }); + // Hide all tool windows (used when showing tool detail tabs) + ipcMain.handle(TOOL_WINDOW_CHANNELS.HIDE_ALL, async () => { + this.mainWindow.setBrowserView(null); + this.activeToolId = null; + this.invokeActiveToolChangedCallback(); + return true; + }); + // Restore renderer-provided bounds flow ipcMain.on("get-tool-panel-bounds-response", this.boundsResponseListener); diff --git a/src/main/preload.ts b/src/main/preload.ts index 2a347a02..5cdd5a5c 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -54,6 +54,7 @@ contextBridge.exposeInMainWorld("toolboxAPI", { ipcRenderer.invoke(TOOL_WINDOW_CHANNELS.LAUNCH, instanceId, tool, primaryConnectionId, secondaryConnectionId), 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), getActiveToolWindow: () => ipcRenderer.invoke(TOOL_WINDOW_CHANNELS.GET_ACTIVE), getOpenToolWindows: () => ipcRenderer.invoke(TOOL_WINDOW_CHANNELS.GET_OPEN_TOOLS), updateToolConnection: (instanceId: string, primaryConnectionId: string | null, secondaryConnectionId?: string | null) => diff --git a/src/renderer/index.html b/src/renderer/index.html index 61183502..d838d527 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -5,7 +5,7 @@ Power Platform ToolBox @@ -413,6 +413,8 @@

    SETTINGS

    + +
    diff --git a/src/renderer/modules/marketplaceManagement.ts b/src/renderer/modules/marketplaceManagement.ts index 6274d3c6..b6492e1d 100644 --- a/src/renderer/modules/marketplaceManagement.ts +++ b/src/renderer/modules/marketplaceManagement.ts @@ -4,14 +4,24 @@ */ import { captureException, captureMessage, logInfo } from "../../common/sentryHelper"; -import type { ModalWindowClosedPayload, ModalWindowMessagePayload, Tool } from "../../common/types"; -import { getToolDetailModalControllerScript } from "../modals/toolDetail/controller"; -import { getToolDetailModalView } from "../modals/toolDetail/view"; +import { marked } from "marked"; +import type { Tool } from "../../common/types"; import type { ToolDetail } from "../types/index"; import { getUnsupportedBadgeTitle, getUnsupportedRequirement } from "../utils/toolCompatibility"; import { applyToolIconMasks, escapeHtml, generateToolIconHtml, resolveToolIconUrl } from "../utils/toolIconResolver"; -import { onBrowserWindowModalClosed, onBrowserWindowModalMessage, sendBrowserWindowModalMessage, showBrowserWindowModal } from "./browserWindowModals"; import { loadSidebarTools } from "./toolsSidebarManagement"; +import { openToolDetailTab } from "./toolManagement"; + +// Disable raw HTML pass-through in markdown rendering to prevent XSS via inline event handlers. +// marked's html() renderer is invoked for both block HTML (Tokens.HTML) and inline HTML (Tokens.Tag), +// so escaping here covers all raw HTML in README content. +marked.use({ + renderer: { + html({ text }: { text: string }): string { + return escapeHtml(text); + }, + }, +}); interface InstalledTool { id: string; @@ -22,26 +32,10 @@ interface InstalledTool { // Tool library loaded from registry let toolLibrary: ToolDetail[] = []; -const TOOL_DETAIL_MODAL_CHANNELS = { - install: "tool-detail:install", - installResult: "tool-detail:install:result", - review: "tool-detail:review", - repository: "tool-detail:repository", - website: "tool-detail:website", -} as const; - -const TOOL_DETAIL_MODAL_DIMENSIONS = { - width: 860, - height: 720, -}; - const DEFAULT_TOOL_ICON_DARK_SVG = ` `; -let toolDetailModalHandlersRegistered = false; -let activeToolDetailModal: { tool: ToolDetail; isInstalled: boolean } | null = null; - /** * Get tool library */ @@ -471,177 +465,166 @@ function isToolNew(tool: ToolDetail): boolean { } /** - * Open tool detail modal (BrowserWindow-based) + * Open tool detail as a tab (replaces the old BrowserWindow modal approach) */ export async function openToolDetail(tool: ToolDetail, isInstalled: boolean): Promise { - initializeToolDetailModalBridge(); - activeToolDetailModal = { tool, isInstalled }; - - try { - //const readmeHtml = await loadToolReadmeHtml(tool); - const modalHtml = buildToolDetailModalHtml(tool, isInstalled); - await showBrowserWindowModal({ - id: `tool-detail-modal-${tool.id}`, - html: modalHtml, - width: TOOL_DETAIL_MODAL_DIMENSIONS.width, - height: TOOL_DETAIL_MODAL_DIMENSIONS.height, - }); - } catch (error) { - captureMessage("Failed to open tool detail modal", "error", { extra: { error } }); - await window.toolboxAPI.utils.showNotification({ - title: "Tool Details", - body: `Unable to open modal: ${formatError(error)}`, - type: "error", - }); - activeToolDetailModal = null; - } + const tabId = `tool-detail-${tool.id}`; + await openToolDetailTab(tabId, tool.name, (panel: HTMLElement) => { + renderToolDetailContent(panel, tool, isInstalled); + }); } -function initializeToolDetailModalBridge(): void { - if (toolDetailModalHandlersRegistered) return; - onBrowserWindowModalMessage(handleToolDetailModalMessage); - onBrowserWindowModalClosed(handleToolDetailModalClosed); - toolDetailModalHandlersRegistered = true; -} +/** + * Render tool detail content into the given panel element + */ +function renderToolDetailContent(panel: HTMLElement, tool: ToolDetail, isInstalled: boolean): void { + const authorsDisplay = tool.authors?.length ? tool.authors.join(", ") : "Unknown author"; + const metaBadges: string[] = []; + if (tool.version) metaBadges.push(`v${tool.version}`); + if (tool.downloads !== undefined) metaBadges.push(`${tool.downloads.toLocaleString()} downloads`); + const categories = tool.categories?.length ? tool.categories.map((c) => escapeHtml(c)) : []; -function handleToolDetailModalMessage(payload: ModalWindowMessagePayload): void { - if (!payload || typeof payload.channel !== "string") return; - - switch (payload.channel) { - case TOOL_DETAIL_MODAL_CHANNELS.install: - void handleToolDetailInstallRequest(); - break; - case TOOL_DETAIL_MODAL_CHANNELS.review: { - const data = (payload.data ?? {}) as { url?: unknown }; - const url = typeof data.url === "string" ? data.url : undefined; - if (!url) { - return; - } - void window.toolboxAPI.openExternal(url).catch((error) => { - captureMessage("Failed to open review link", "error", { extra: { error } }); - }); - break; - } - case TOOL_DETAIL_MODAL_CHANNELS.repository: { - const data = (payload.data ?? {}) as { url?: unknown }; - const url = typeof data.url === "string" ? data.url : undefined; - if (!url) { - return; - } - void window.toolboxAPI.openExternal(url).catch((error) => { - captureMessage("Failed to open repository link", "error", { extra: { error } }); - }); - break; - } - case TOOL_DETAIL_MODAL_CHANNELS.website: { - const data = (payload.data ?? {}) as { url?: unknown }; - const url = typeof data.url === "string" ? data.url : undefined; - if (!url) { - return; - } - void window.toolboxAPI.openExternal(url).catch((error) => { - captureMessage("Failed to open website link", "error", { extra: { error } }); - }); - break; - } - default: - break; + const tagsMarkup = categories.length ? categories.map((tag) => `${tag}`).join("") : ""; + const badgeMarkup = metaBadges.map((badge) => `${escapeHtml(badge)}`).join(""); + const ratingsHtml = tool.rating !== undefined ? `${tool.rating.toFixed(1)} ★` : ""; + + const iconHtml = buildToolIconHtml(tool); + + const linkItems: string[] = []; + const reviewUrl = `https://www.powerplatformtoolbox.com/rate-tool?toolId=${encodeURIComponent(tool.id)}`; + linkItems.push(`Leave a review`); + if (tool.repository) { + linkItems.push(`Repository`); } -} + if (tool.website) { + linkItems.push(`Website`); + } + const linksMarkup = linkItems.length ? `
    ${linkItems.join(' • ')}
    ` : ""; -async function handleToolDetailInstallRequest(): Promise { - if (!activeToolDetailModal) return; + const readmePlaceholder = tool.readmeUrl ? "Loading README..." : "README is not available for this tool."; + const unsupportedAttr = tool.isSupported === false ? `disabled title="This tool is not compatible with your version of Power Platform ToolBox"` : ""; - try { - await window.toolboxAPI.installToolFromRegistry(activeToolDetailModal.tool.id); + panel.innerHTML = ` +
    +
    +
    +
    ${iconHtml}
    +
    +
    + ${tagsMarkup ? `
    ${tagsMarkup}
    ` : ""} +

    ${escapeHtml(tool.name)}

    +

    ${escapeHtml(tool.description || "")}

    +

    By ${escapeHtml(authorsDisplay)}

    + ${badgeMarkup || ratingsHtml ? `
    ${badgeMarkup}${ratingsHtml}
    ` : ""} +
    + + ✓ Installed +
    + ${linksMarkup} +
    +
    +
    +
    +
    +

    README

    +
    ${readmePlaceholder}
    +
    +
    + `; - window.toolboxAPI.utils.showNotification({ - title: "Tool Installed", - body: `${activeToolDetailModal.tool.name} has been installed successfully`, - type: "success", + // Wire up links to open in browser + panel.querySelectorAll(".tool-detail-tab-link").forEach((link) => { + link.addEventListener("click", (e) => { + e.preventDefault(); + const url = link.getAttribute("data-url") || link.getAttribute("href"); + if (url && url.startsWith("https://")) { + window.toolboxAPI.openExternal(url).catch((error) => { + captureMessage("Failed to open external link", "error", { extra: { error } }); + }); + } }); + }); - activeToolDetailModal.isInstalled = true; + // Wire up install button + const installBtn = panel.querySelector("#tool-detail-install-btn"); + const installedBadge = panel.querySelector("#tool-detail-installed-badge"); + installBtn?.addEventListener("click", async () => { + if (!installBtn || installBtn.disabled) return; + installBtn.disabled = true; + installBtn.textContent = "Installing..."; + try { + await window.toolboxAPI.installToolFromRegistry(tool.id); + installBtn.style.display = "none"; + if (installedBadge) installedBadge.style.display = "inline-flex"; + window.toolboxAPI.utils.showNotification({ + title: "Tool Installed", + body: `${tool.name} has been installed successfully`, + type: "success", + }); + await loadMarketplace(); + await loadSidebarTools(); + } catch (error) { + installBtn.disabled = false; + installBtn.textContent = "Install"; + window.toolboxAPI.utils.showNotification({ + title: "Installation Failed", + body: `Failed to install tool: ${formatError(error)}`, + type: "error", + }); + } + }); - await loadMarketplace(); - await loadSidebarTools(); + // Apply icon masks for SVG icons + applyToolIconMasks(panel); - await sendBrowserWindowModalMessage({ - channel: TOOL_DETAIL_MODAL_CHANNELS.installResult, - data: { - success: true, - }, - }); - } catch (error) { - const errorMessage = formatError(error); - await sendBrowserWindowModalMessage({ - channel: TOOL_DETAIL_MODAL_CHANNELS.installResult, - data: { - success: false, - error: errorMessage, - }, - }); - await window.toolboxAPI.utils.showNotification({ - title: "Installation Failed", - body: `Failed to install tool: ${errorMessage}`, - type: "error", - }); - } + // Async README loading — pass the tabId so stale fetches are discarded + const tabId = `tool-detail-${tool.id}`; + void loadToolReadme(panel, tool.readmeUrl, tabId); } -function handleToolDetailModalClosed(payload: ModalWindowClosedPayload): void { - if (!payload || typeof payload.id !== "string") { - activeToolDetailModal = null; +async function loadToolReadme(panel: HTMLElement, readmeUrl: string | undefined, tabId: string): Promise { + const readmeContainer = panel.querySelector("#tool-detail-readme-content"); + if (!readmeContainer) return; + if (!readmeUrl) { + readmeContainer.textContent = "README is not available for this tool."; return; } - - if (payload.id.startsWith("tool-detail-modal-")) { - activeToolDetailModal = null; + try { + const response = await fetch(readmeUrl, { cache: "no-store" }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const markdown = await response.text(); + + // Discard if the user switched away from this detail tab while the fetch was in flight + const detailPanel = document.getElementById("tool-detail-content-panel"); + if (!detailPanel || detailPanel.getAttribute("data-tab-id") !== tabId) return; + + // Note: marked renders markdown to HTML with raw HTML blocks escaped (see marked.use configuration above). + readmeContainer.innerHTML = marked.parse(markdown) as string; + // Open all links in the README via the external browser + readmeContainer.querySelectorAll("a[href]").forEach((a) => { + a.addEventListener("click", (e) => { + e.preventDefault(); + const href = a.getAttribute("href"); + if (href && (href.startsWith("https://") || href.startsWith("http://"))) { + window.toolboxAPI.openExternal(href).catch((error) => { + captureMessage("Failed to open README link", "error", { extra: { error } }); + }); + } + }); + }); + } catch (error) { + captureException(error instanceof Error ? error : new Error(String(error)), { + tags: { phase: "readme_load" }, + level: "error", + }); + // Only write the error message if this tab is still active + const detailPanel = document.getElementById("tool-detail-content-panel"); + if (detailPanel && detailPanel.getAttribute("data-tab-id") === tabId) { + readmeContainer.textContent = "Unable to load README."; + } } } -function buildToolDetailModalHtml(tool: ToolDetail, isInstalled: boolean): string { - const authorsDisplay = tool.authors && tool.authors.length ? tool.authors.join(", ") : "Unknown author"; - const metaBadges: string[] = []; - if (tool.version) metaBadges.push(`v${tool.version}`); - if (tool.downloads !== undefined) metaBadges.push(`${tool.downloads.toLocaleString()} downloads`); - const categories = tool.categories && tool.categories.length ? tool.categories.map((category) => escapeHtml(category)) : []; - const isDarkTheme = document.body.classList.contains("dark-theme"); - - const { styles, body } = getToolDetailModalView({ - toolId: escapeHtml(tool.id), - name: escapeHtml(tool.name), - description: escapeHtml(tool.description || ""), - iconHtml: buildToolIconHtml(tool), - authors: escapeHtml(authorsDisplay), - metaBadges: metaBadges.map((badge) => escapeHtml(badge)), - categories: categories, - isInstalled, - isSupported: tool.isSupported, - readmeUrl: tool.readmeUrl, - isDarkTheme, - repository: tool.repository, - website: tool.website, - rating: tool.rating, - }); - - const script = getToolDetailModalControllerScript({ - channels: TOOL_DETAIL_MODAL_CHANNELS, - state: { - toolId: tool.id, - toolName: tool.name, - isInstalled, - isSupported: tool.isSupported, - readmeUrl: tool.readmeUrl || null, - reviewUrl: `https://www.powerplatformtoolbox.com/rate-tool?toolId=${encodeURIComponent(tool.id)}`, - repositoryUrl: tool.repository || null, - websiteUrl: tool.website || null, - }, - }); - - return `${styles}\n${body}\n${script}`.trim(); -} - function buildToolIconHtml(tool: ToolDetail): string { // defaultToolIcon is a safe data:image/svg+xml URI generated from application constant const defaultToolIcon = svgToDataUri(DEFAULT_TOOL_ICON_DARK_SVG); diff --git a/src/renderer/modules/toolManagement.ts b/src/renderer/modules/toolManagement.ts index 5b71b7ed..8732baf6 100644 --- a/src/renderer/modules/toolManagement.ts +++ b/src/renderer/modules/toolManagement.ts @@ -27,6 +27,9 @@ const openTools = new Map(); let activeToolId: string | null = null; // Now stores instanceId let draggedTab: HTMLElement | null = null; +// Detail tab state - maps tabId to render callback for tool detail tabs +const detailTabs = new Map void>(); + /** * Check if a connection token is expired * @param tokenExpiry ISO date string of token expiry @@ -387,6 +390,93 @@ export function createTab(instanceId: string, tool: any, instanceNumber: number updateTabScrollButtons(); } +/** + * Open a tool detail tab (shows tool details as a tab instead of a modal dialog) + * @param tabId Unique identifier for the tab (e.g., "tool-detail-{toolId}") + * @param displayName Name shown on the tab + * @param renderContent Callback that populates the detail panel with content + */ +export async function openToolDetailTab(tabId: string, displayName: string, renderContent: (panel: HTMLElement) => void): Promise { + // If this tool's detail tab is already open, just switch to it + if (openTools.has(tabId)) { + // Refresh content in case install state changed + detailTabs.set(tabId, renderContent); + const detailPanel = document.getElementById("tool-detail-content-panel"); + if (detailPanel) { + detailPanel.removeAttribute("data-tab-id"); + } + await switchToTool(tabId); + return; + } + + // Store the render callback + detailTabs.set(tabId, renderContent); + + // Create the tab element + const toolTabs = document.getElementById("tool-tabs"); + if (!toolTabs) return; + + const tab = document.createElement("div"); + tab.className = "tool-tab tool-detail-tab"; + tab.id = `tool-tab-${tabId}`; + tab.setAttribute("data-instance-id", tabId); + tab.setAttribute("draggable", "false"); + + const name = document.createElement("span"); + name.className = "tool-tab-name"; + name.textContent = `${displayName} - Details`; + name.title = `${displayName} - Details`; + tab.appendChild(name); + + const closeBtn = document.createElement("button"); + closeBtn.className = "tool-tab-close"; + closeBtn.textContent = "×"; + closeBtn.title = "Close"; + closeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + closeTool(tabId); + }); + tab.appendChild(closeBtn); + + tab.addEventListener("click", () => { + switchToTool(tabId); + }); + + // Middle-click to close + tab.addEventListener("mousedown", (e) => { + if (e.button === MIDDLE_MOUSE_BUTTON) { + e.preventDefault(); + e.stopPropagation(); + closeTool(tabId); + } + }); + + toolTabs.appendChild(tab); + + // Register as an open tool entry (detail tab variant) + openTools.set(tabId, { + instanceId: tabId, + toolId: "", + tool: { name: displayName }, + isPinned: false, + connectionId: null, + secondaryConnectionId: null, + isDetailTab: true, + }); + + // Ensure tool panel is visible + hideHomePage(); + const toolPanel = document.getElementById("tool-panel"); + if (toolPanel) { + toolPanel.style.display = "flex"; + } + + // Switch to the new detail tab + await switchToTool(tabId); + + updateTabScrollButtons(); +} + /** * Get the current display name for a tool tab instance */ @@ -417,6 +507,53 @@ export async function switchToTool(instanceId: string): Promise { activeTab.classList.add("active"); } + const openTool = openTools.get(instanceId); + + // Handle tool detail tabs (no BrowserView - content is rendered inline) + if (openTool?.isDetailTab) { + // Hide any active BrowserView + window.toolboxAPI.hideToolWindows().catch((error: any) => { + captureException(error instanceof Error ? error : new Error(String(error)), { + tags: { phase: "hide_tool_windows" }, + level: "error", + }); + }); + + // Hide the BrowserView placeholder so detail panel gets full space + const toolPanelContent = document.getElementById("tool-panel-content"); + if (toolPanelContent) { + toolPanelContent.style.display = "none"; + } + + // Show detail panel and populate with this tab's content + const detailPanel = document.getElementById("tool-detail-content-panel"); + if (detailPanel) { + const currentTabId = detailPanel.getAttribute("data-tab-id"); + if (currentTabId !== instanceId) { + const renderContent = detailTabs.get(instanceId); + if (renderContent) { + detailPanel.innerHTML = ""; + renderContent(detailPanel); + detailPanel.setAttribute("data-tab-id", instanceId); + } + } + detailPanel.style.display = "flex"; + } + + await updateActiveToolConnectionStatus(); + return; + } + + // Regular tool tab: restore tool-panel-content, hide detail panel, show BrowserView + const toolPanelContent = document.getElementById("tool-panel-content"); + if (toolPanelContent) { + toolPanelContent.style.display = ""; + } + const detailPanel = document.getElementById("tool-detail-content-panel"); + if (detailPanel) { + detailPanel.style.display = "none"; + } + // Use IPC to switch the BrowserView in the backend // The ToolWindowManager will show the appropriate BrowserView window.toolboxAPI.switchToolWindow(instanceId).catch((error: any) => { @@ -437,8 +574,8 @@ export function closeTool(instanceId: string): void { const openTool = openTools.get(instanceId); if (!openTool) return; - // Check if tab is pinned - if (openTool.isPinned) { + // Check if tab is pinned (only for real tool instances, not detail tabs) + if (!openTool.isDetailTab && openTool.isPinned) { window.toolboxAPI.utils.showNotification({ title: "Cannot Close Pinned Tab", body: "Unpin the tab before closing it", @@ -453,14 +590,31 @@ export function closeTool(instanceId: string): void { tab.remove(); } - // Close the tool window via IPC - // The ToolWindowManager will destroy the BrowserView - window.toolboxAPI.closeToolWindow(instanceId).catch((error: any) => { - captureException(error instanceof Error ? error : new Error(String(error)), { - tags: { phase: "tool_close", instanceId }, - level: "error", + if (openTool.isDetailTab) { + // Detail tab: clean up render callback and hide detail panel if active + detailTabs.delete(instanceId); + if (activeToolId === instanceId) { + const detailPanel = document.getElementById("tool-detail-content-panel"); + if (detailPanel) { + detailPanel.style.display = "none"; + detailPanel.removeAttribute("data-tab-id"); + } + // Restore tool-panel-content visibility for when a real tool is shown next + const toolPanelContent = document.getElementById("tool-panel-content"); + if (toolPanelContent) { + toolPanelContent.style.display = ""; + } + } + } else { + // Real tool: close the tool window via IPC + // The ToolWindowManager will destroy the BrowserView + window.toolboxAPI.closeToolWindow(instanceId).catch((error: any) => { + captureException(error instanceof Error ? error : new Error(String(error)), { + tags: { phase: "tool_close", instanceId }, + level: "error", + }); }); - }); + } // Remove from open tools openTools.delete(instanceId); @@ -593,14 +747,16 @@ function handleDragEnd(e: DragEvent, tab: HTMLElement): void { */ export function saveSession(): void { const session: SessionData = { - openTools: Array.from(openTools.entries()).map(([instanceId, tool]) => ({ - instanceId, - toolId: tool.toolId, - isPinned: tool.isPinned, - connectionId: tool.connectionId, - secondaryConnectionId: tool.secondaryConnectionId, - })), - activeToolId, + openTools: Array.from(openTools.entries()) + .filter(([, tool]) => !tool.isDetailTab) + .map(([instanceId, tool]) => ({ + instanceId, + toolId: tool.toolId, + isPinned: tool.isPinned, + connectionId: tool.connectionId, + secondaryConnectionId: tool.secondaryConnectionId, + })), + activeToolId: activeToolId && openTools.get(activeToolId)?.isDetailTab ? null : activeToolId, }; localStorage.setItem("toolbox-session", JSON.stringify(session)); } diff --git a/src/renderer/styles.scss b/src/renderer/styles.scss index 788a7f9f..65e5e7ce 100644 --- a/src/renderer/styles.scss +++ b/src/renderer/styles.scss @@ -1513,6 +1513,11 @@ body.dark-theme .settings-section-card { border-bottom: 2px solid #8a8886; } +/* Tool detail tabs have a slightly different indicator */ +.tool-tab.tool-detail-tab.active { + border-bottom: 2px solid var(--accent-color); +} + .tool-tab-icon { font-size: 16px; } @@ -1746,6 +1751,212 @@ body.dark-theme .settings-section-card { border: none; } +/* Tool detail tab content panel */ +#tool-detail-content-panel { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--bg-color); +} + +.tool-detail-tab-header { + display: flex; + gap: 16px; + padding: 24px 28px 16px; + border-bottom: 1px solid var(--border-color); + flex-shrink: 0; +} + +.tool-detail-tab-header-left { + display: flex; + gap: 24px; + flex: 1; + min-width: 0; +} + +.tool-detail-tab-icon-shell { + width: 80px; + height: 80px; + border-radius: 16px; + border: 1px solid var(--border-color); + background: var(--input-background); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.tool-detail-tab-icon img { + width: 60px; + height: 60px; + object-fit: contain; +} + +.tool-detail-tab-icon span { + width: 48px; + height: 48px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.tool-detail-tab-meta { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.tool-detail-tab-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.tool-detail-tab-tags span { + border-radius: 999px; + padding: 2px 10px; + border: 1px solid var(--border-color); + background: var(--input-background); + font-size: 12px; + color: var(--text-color); +} + +.tool-detail-tab-name { + margin: 0; + font-size: 22px; + font-weight: 600; + color: var(--text-color); +} + +.tool-detail-tab-description { + margin: 0; + color: var(--text-secondary); + font-size: 14px; + line-height: 1.5; +} + +.tool-detail-tab-authors { + margin: 0; + font-size: 13px; + color: var(--text-secondary); +} + +.tool-detail-tab-meta-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + font-size: 12px; + color: var(--text-secondary); +} + +.tool-detail-tab-meta-list span + span::before { + content: "•"; + margin-right: 6px; + color: var(--text-secondary); +} + +.tool-detail-tab-actions { + display: flex; + gap: 12px; + align-items: center; + flex-wrap: wrap; + margin-top: 4px; +} + +.tool-detail-tab-installed-badge { + border: 1px solid rgba(16, 124, 16, 0.35); + background: #107c10; + color: white; + padding: 5px 14px; + border-radius: 4px; + font-size: 13px; + display: inline-flex; + align-items: center; +} + +.tool-detail-tab-links { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + font-size: 13px; + margin-top: 2px; +} + +.tool-detail-tab-link { + color: var(--primary-color); + text-decoration: none; + cursor: pointer; +} + +.tool-detail-tab-link:hover { + text-decoration: underline; +} + +.tool-detail-tab-body { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + padding: 16px 28px 20px; + overflow: hidden; +} + +.tool-detail-tab-readme-card { + background: var(--input-background); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 16px 20px; + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.tool-detail-tab-readme-card h3 { + margin: 0 0 12px 0; + font-size: 14px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.tool-detail-tab-markdown { + flex: 1; + overflow-y: auto; + line-height: 1.6; + font-size: 14px; + color: var(--text-color); +} + +.tool-detail-tab-markdown h1, +.tool-detail-tab-markdown h2, +.tool-detail-tab-markdown h3, +.tool-detail-tab-markdown h4 { + margin-top: 20px; + margin-bottom: 8px; +} + +.tool-detail-tab-markdown pre { + background: var(--bg-color); + border: 1px solid var(--border-color); + padding: 12px; + border-radius: 6px; + overflow-x: auto; + font-size: 13px; +} + +.tool-detail-tab-markdown a { + color: var(--primary-color); +} + +.tool-detail-tab-markdown img { + max-width: 100%; +} + .btn-icon { background: none; border: none; diff --git a/src/renderer/types/index.ts b/src/renderer/types/index.ts index 9815e5e9..3138c37a 100644 --- a/src/renderer/types/index.ts +++ b/src/renderer/types/index.ts @@ -12,6 +12,7 @@ export interface OpenTool { isPinned: boolean; connectionId: string | null; // Primary connection secondaryConnectionId: string | null; // Secondary connection (for multi-connection tools) + isDetailTab?: boolean; // True for tool detail view tabs (not real tool instances) } /** From eca1c827f8043fbc2cbae572662cbd886cddca05 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:44:40 -0500 Subject: [PATCH 040/257] Fix bug where Restart & Install Now does nothing (#427) * Initial plan * fix: Restart & Install Now sends install channel after download-in-modal transition Root cause: CONFIG is JSON-serialized at modal creation with type:"available". When a download completes while the "available" modal is still open, the UI switches to show "Restart & Install Now" but CONFIG.type remains "available", so clicking the button sent the `download` channel again (re-triggering a download) instead of the `install` channel (calling quitAndInstall). Fix: - Introduce mutable `effectiveType` in controller.ts (init from CONFIG.type, updated to "downloaded" on update:downloaded message). All click/dismiss logic uses effectiveType instead of CONFIG.type. - Add setErrorState() helper and handle update:error message so failures surface in the modal rather than silently disabling the button. - In autoUpdateManagement.ts, catch downloadUpdate/quitAndInstall failures and forward them to the modal via sendBrowserWindowModalMessage. - Forward onUpdateError events to the modal when it is open so autoUpdater-level errors (e.g. missing installer path) are also shown. Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * fix: address PR review comments on dismiss channel and error color state - autoUpdateManagement.ts: add explicit dismiss branch in onMessage to make clear the channel is intentionally received with no side-effect (autoInstallOnAppQuit handles install-on-exit automatically) - controller.ts: reset progressLabel.style.color to "" in setDownloadingState so the hard-coded error red is cleared when the user retries after an error Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> --- .../modals/updateNotification/controller.ts | 36 +++++++++++++++++-- src/renderer/modules/autoUpdateManagement.ts | 15 ++++++-- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/renderer/modals/updateNotification/controller.ts b/src/renderer/modals/updateNotification/controller.ts index 645aebff..c75e2c6c 100644 --- a/src/renderer/modals/updateNotification/controller.ts +++ b/src/renderer/modals/updateNotification/controller.ts @@ -28,10 +28,19 @@ export function getUpdateNotificationModalControllerScript(config: UpdateNotific const progressFill = document.getElementById("update-progress-fill"); const progressLabel = document.getElementById("update-progress-label"); + // effectiveType tracks the current state and is updated when a download + // completes inside an already-open "available" modal, so the action button + // correctly sends "install" instead of "download" after the transition, and + // the dismiss logic correctly sets installOnExit only when in "downloaded" state. + let effectiveType = CONFIG.type; + const setDownloadingState = (percent) => { if (progressWrap) progressWrap.style.display = "flex"; if (progressFill) progressFill.style.width = percent + "%"; - if (progressLabel) progressLabel.textContent = "Downloading update\\u2026 " + percent + "%"; + if (progressLabel) { + progressLabel.textContent = "Downloading update\\u2026 " + percent + "%"; + progressLabel.style.color = ""; + } if (actionBtn instanceof HTMLButtonElement) { actionBtn.disabled = true; actionBtn.textContent = "Downloading\\u2026"; @@ -41,9 +50,25 @@ export function getUpdateNotificationModalControllerScript(config: UpdateNotific } }; + const setErrorState = (message) => { + if (progressWrap) progressWrap.style.display = "none"; + if (actionBtn instanceof HTMLButtonElement) { + actionBtn.disabled = false; + actionBtn.textContent = effectiveType === "downloaded" ? "Restart & Install Now" : "Download & Install"; + } + if (laterBtn instanceof HTMLButtonElement) { + laterBtn.disabled = false; + } + if (progressLabel) { + progressLabel.textContent = message || "Unable to complete update. Please check your connection and try again."; + progressLabel.style.color = "#d13438"; + if (progressWrap) progressWrap.style.display = "flex"; + } + }; + actionBtn?.addEventListener("click", () => { if (!(actionBtn instanceof HTMLButtonElement) || actionBtn.disabled) return; - if (CONFIG.type === "available") { + if (effectiveType === "available") { setDownloadingState(0); modalBridge.send(CONFIG.channels.download, {}); } else { @@ -57,7 +82,7 @@ export function getUpdateNotificationModalControllerScript(config: UpdateNotific laterBtn?.addEventListener("click", () => { if (!(laterBtn instanceof HTMLButtonElement) || laterBtn.disabled) return; - modalBridge.send(CONFIG.channels.dismiss, { installOnExit: CONFIG.type === "downloaded" }); + modalBridge.send(CONFIG.channels.dismiss, { installOnExit: effectiveType === "downloaded" }); modalBridge.close(); }); @@ -84,6 +109,7 @@ export function getUpdateNotificationModalControllerScript(config: UpdateNotific setDownloadingState(percent); } if (payload.channel === "update:downloaded") { + effectiveType = "downloaded"; if (progressWrap) progressWrap.style.display = "none"; if (actionBtn instanceof HTMLButtonElement) { actionBtn.disabled = false; @@ -94,6 +120,10 @@ export function getUpdateNotificationModalControllerScript(config: UpdateNotific laterBtn.textContent = "Install on Exit"; } } + if (payload.channel === "update:error") { + const message = typeof payload.data?.message === "string" ? payload.data.message : undefined; + setErrorState(message); + } }); })(); `; diff --git a/src/renderer/modules/autoUpdateManagement.ts b/src/renderer/modules/autoUpdateManagement.ts index 8cb64e15..60066022 100644 --- a/src/renderer/modules/autoUpdateManagement.ts +++ b/src/renderer/modules/autoUpdateManagement.ts @@ -49,14 +49,22 @@ async function showUpdateNotificationModal(type: "available" | "downloaded", ver const onMessage = (payload: { channel: string; data?: unknown }) => { if (!payload) return; if (payload.channel === UPDATE_NOTIFICATION_MODAL_CHANNELS.download) { - window.toolboxAPI.downloadUpdate().catch(() => undefined); + window.toolboxAPI.downloadUpdate().catch((error: unknown) => { + void sendBrowserWindowModalMessage({ channel: "update:error", data: { message: (error as Error)?.message ?? "Failed to download the update. Please check your connection and try again." } }).catch(() => undefined); + }); } else if (payload.channel === UPDATE_NOTIFICATION_MODAL_CHANNELS.install) { - window.toolboxAPI.quitAndInstall(); + window.toolboxAPI.quitAndInstall().catch((error: unknown) => { + void sendBrowserWindowModalMessage({ channel: "update:error", data: { message: (error as Error)?.message ?? "Failed to restart and install the update." } }).catch(() => undefined); + }); } else if (payload.channel === UPDATE_NOTIFICATION_MODAL_CHANNELS.openExternal) { const url = (payload.data as { url?: string })?.url; if (url) { window.toolboxAPI.openExternal(url).catch(() => undefined); } + } else if (payload.channel === UPDATE_NOTIFICATION_MODAL_CHANNELS.dismiss) { + // The modal is closing. autoInstallOnAppQuit=true means a downloaded update + // will automatically be installed when the app quits normally, so no explicit + // action is required here regardless of the installOnExit payload value. } }; @@ -262,5 +270,8 @@ export function setupAutoUpdateListeners(): void { hideUpdateProgress(); showUpdateStatus(`Update error: ${error}`, "error"); updateCheckForUpdatesUI("error", `Update error: ${error}`); + if (updateModalOpen) { + void sendBrowserWindowModalMessage({ channel: "update:error", data: { message: error } }).catch(() => undefined); + } }); } From 6d25125680ecf31cb3bbbaef7b0c25fad9b05ae9 Mon Sep 17 00:00:00 2001 From: LinkeD365 <43988771+LinkeD365@users.noreply.github.com> Date: Tue, 3 Mar 2026 09:37:49 +0000 Subject: [PATCH 041/257] fix: update launchTool and createTab functions to handle environment names in tool tabs (#424) * fix: update launchTool and createTab functions to handle environment names in tool tabs [Feature]: Add connection name to tool header Fixes #370 * Initial plan * fix: use getById, connection name+env, and normalize error in createTab Co-authored-by: LinkeD365 <43988771+LinkeD365@users.noreply.github.com> * fix: update createTab function to remove environment from connection labels * Initial plan * fix: make createTab sync with async connection subtext, parallel fetches, rename vars Co-authored-by: LinkeD365 <43988771+LinkeD365@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- src/renderer/modules/toolManagement.ts | 43 +++++++++++++++++++++++++- src/renderer/styles.scss | 16 ++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/renderer/modules/toolManagement.ts b/src/renderer/modules/toolManagement.ts index 8732baf6..9c7ddc11 100644 --- a/src/renderer/modules/toolManagement.ts +++ b/src/renderer/modules/toolManagement.ts @@ -279,6 +279,7 @@ export async function launchTool(toolId: string, options?: LaunchToolOptions): P }); // Create and add tab with instance number if multiple instances exist + // Tab is appended synchronously; connection subtext is populated asynchronously createTab(instanceId, tool, instanceNumber); // Switch to the new tab (this will also call backend to show the BrowserView) @@ -324,6 +325,46 @@ export function createTab(instanceId: string, tool: any, instanceNumber: number name.textContent = displayName; name.title = displayName; + // Create a container for the name and subtext + const nameContainer = document.createElement("div"); + nameContainer.className = "tool-tab-name-container"; + nameContainer.appendChild(name); + + // Fetch connection names asynchronously and populate subtext once ready + (async () => { + let connectionSubtext = ""; + try { + const openTool = openTools.get(instanceId); + + const [primaryConnection, secondaryConnection] = await Promise.all([ + openTool?.connectionId ? window.toolboxAPI.connections.getById(openTool.connectionId) : Promise.resolve(null), + openTool?.secondaryConnectionId ? window.toolboxAPI.connections.getById(openTool.secondaryConnectionId) : Promise.resolve(null), + ]); + + const primaryLabel = primaryConnection?.name ?? null; + const secondaryLabel = secondaryConnection?.name ?? null; + + // Display both connections if both exist, otherwise just the primary + if (primaryLabel && secondaryLabel) { + connectionSubtext = `${primaryLabel} / ${secondaryLabel}`; + } else if (primaryLabel) { + connectionSubtext = primaryLabel; + } + } catch (error) { + const normalizedError = error instanceof Error ? error.message : String(error); + logWarn("Failed to fetch connection names for tab:", { error: normalizedError }); + } + + // Add connection names as subtext if available + if (connectionSubtext) { + const subtext = document.createElement("span"); + subtext.className = "tool-tab-subtext"; + subtext.textContent = connectionSubtext; + subtext.title = connectionSubtext; + nameContainer.appendChild(subtext); + } + })(); + const pinBtn = document.createElement("button"); pinBtn.className = "tool-tab-pin"; pinBtn.title = "Pin tab"; @@ -381,7 +422,7 @@ export function createTab(instanceId: string, tool: any, instanceNumber: number tab.addEventListener("drop", (e) => handleDrop(e)); tab.addEventListener("dragend", (e) => handleDragEnd(e, tab)); - tab.appendChild(name); + tab.appendChild(nameContainer); tab.appendChild(pinBtn); tab.appendChild(closeBtn); toolTabs.appendChild(tab); diff --git a/src/renderer/styles.scss b/src/renderer/styles.scss index 65e5e7ce..d10a34c6 100644 --- a/src/renderer/styles.scss +++ b/src/renderer/styles.scss @@ -1529,6 +1529,22 @@ body.dark-theme .settings-section-card { font-size: 13px; } +.tool-tab-name-container { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + min-width: 0; +} + +.tool-tab-subtext { + font-size: 11px; + opacity: 0.7; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .tool-tab-connection { font-size: 12px; opacity: 0.7; From b418297a0c34a3ceda791bfb04f5be26637c4745 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 04:44:31 -0500 Subject: [PATCH 042/257] Show browser profile badge in connection selection modals (#429) * Initial plan * Add browser profile badge to connection selection modals Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * feat: add browser icons and update connection selection modals * Address PR review: use ?inline for icons, fix double badge call, escape HTML fields Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> Co-authored-by: Power-Maverick --- src/common/types/connection.ts | 3 ++ .../modals/selectConnection/controller.ts | 43 ++++++++++++++++--- .../selectMultiConnection/controller.ts | 40 ++++++++++++++--- src/renderer/modals/sharedStyles.ts | 42 ++++++++++++++++++ src/renderer/modules/connectionManagement.ts | 6 +++ src/renderer/renderer.ts | 2 +- src/renderer/types/assets.d.ts | 11 +++++ .../{types.d.ts => types/renderer.d.ts} | 2 +- src/renderer/utils/browserIcons.ts | 12 ++++++ tsconfig.renderer.json | 2 +- 10 files changed, 149 insertions(+), 14 deletions(-) create mode 100644 src/renderer/types/assets.d.ts rename src/renderer/{types.d.ts => types/renderer.d.ts} (94%) create mode 100644 src/renderer/utils/browserIcons.ts diff --git a/src/common/types/connection.ts b/src/common/types/connection.ts index 0547e504..067f4dbf 100644 --- a/src/common/types/connection.ts +++ b/src/common/types/connection.ts @@ -73,6 +73,9 @@ export interface UIConnectionData { isActive: boolean; lastUsedAt?: string; createdAt?: string; + browserType?: BrowserType; + browserProfile?: string; + browserProfileName?: string; } /** diff --git a/src/renderer/modals/selectConnection/controller.ts b/src/renderer/modals/selectConnection/controller.ts index 28e67c88..1b8e1b6e 100644 --- a/src/renderer/modals/selectConnection/controller.ts +++ b/src/renderer/modals/selectConnection/controller.ts @@ -1,4 +1,5 @@ import { UIConnectionData } from "../../../common/types/connection"; +import { chromeIconUrl, edgeIconUrl } from "../../utils/browserIcons"; import { getConnectionSortingUtilitiesScript } from "../../utils/connectionSorting"; export interface SelectConnectionModalChannelIds { @@ -57,6 +58,32 @@ export function getSelectConnectionModalControllerScript(channels: SelectConnect }; return labels[authType] || authType; }; + + const escapeHtml = (value) => { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + }; + + const getBrowserBadgeMarkup = (conn) => { + const browserType = conn.browserType; + if (!browserType || browserType === "default") return ""; + const profileName = conn.browserProfileName || conn.browserProfile; + if (!profileName) return ""; + const browserLabels = { chrome: "Chrome", edge: "Edge" }; + const browserLabel = browserLabels[browserType] || "Browser"; + const iconPaths = { chrome: ${JSON.stringify(chromeIconUrl)}, edge: ${JSON.stringify(edgeIconUrl)} }; + const iconPath = iconPaths[browserType]; + const safeProfile = escapeHtml(profileName); + const safeTitle = escapeHtml(browserLabel + " \xb7 " + profileName); + const iconMarkup = iconPath + ? \`\${browserLabel} icon\` + : \`\${browserLabel.charAt(0).toUpperCase()}\`; + return \`\${iconMarkup}\${safeProfile}\`; + }; ${sortingUtilities} const getFilteredConnections = () => { const searchTerm = searchInput?.value?.toLowerCase() || ""; @@ -127,21 +154,25 @@ ${sortingUtilities} return; } - connectionsListContainer.innerHTML = connections.map(conn => \` -
    + connectionsListContainer.innerHTML = connections.map(conn => { + const browserBadge = getBrowserBadgeMarkup(conn); + return \` +
    -
    \${conn.name}
    +
    \${escapeHtml(conn.name)}
    -
    \${conn.url}
    +
    \${escapeHtml(conn.url)}
    - \${conn.environment} + \${escapeHtml(conn.environment)} \${formatAuthType(conn.authenticationType)} \${conn.isActive ? '✓ Active' : ''}
    + \${browserBadge ? \`
    \${browserBadge}
    \` : ''}
    - \`).join(''); + \`; + }).join(''); // Add click handlers to connection items const connectionItems = connectionsListContainer.querySelectorAll('.connection-item'); diff --git a/src/renderer/modals/selectMultiConnection/controller.ts b/src/renderer/modals/selectMultiConnection/controller.ts index d2c23f20..f96dd98d 100644 --- a/src/renderer/modals/selectMultiConnection/controller.ts +++ b/src/renderer/modals/selectMultiConnection/controller.ts @@ -1,4 +1,5 @@ import { UIConnectionData } from "../../../common/types/connection"; +import { chromeIconUrl, edgeIconUrl } from "../../utils/browserIcons"; import { getConnectionSortingUtilitiesScript } from "../../utils/connectionSorting"; export interface SelectMultiConnectionModalChannelIds { @@ -62,6 +63,32 @@ export function getSelectMultiConnectionModalControllerScript(channels: SelectMu }; return labels[authType] || authType; }; + + const escapeHtml = (value) => { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + }; + + const getBrowserBadgeMarkup = (conn) => { + const browserType = conn.browserType; + if (!browserType || browserType === "default") return ""; + const profileName = conn.browserProfileName || conn.browserProfile; + if (!profileName) return ""; + const browserLabels = { chrome: "Chrome", edge: "Edge" }; + const browserLabel = browserLabels[browserType] || "Browser"; + const iconPaths = { chrome: ${JSON.stringify(chromeIconUrl)}, edge: ${JSON.stringify(edgeIconUrl)} }; + const iconPath = iconPaths[browserType]; + const safeProfile = escapeHtml(profileName); + const safeTitle = escapeHtml(browserLabel + " \xb7 " + profileName); + const iconMarkup = iconPath + ? \`\${browserLabel} icon\` + : \`\${browserLabel.charAt(0).toUpperCase()}\`; + return \`\${iconMarkup}\${safeProfile}\`; + }; ${sortingUtilities} const getFilteredConnections = () => { const searchTerm = searchInput?.value?.toLowerCase() || ""; @@ -145,26 +172,29 @@ ${sortingUtilities} const connectionHtml = (conn, idPrefix, isDisabled = false) => { const isAuthenticated = (idPrefix === 'primary' && conn.id === authenticatedPrimaryConnectionId) || (idPrefix === 'secondary' && conn.id === authenticatedSecondaryConnectionId); + const browserBadge = getBrowserBadgeMarkup(conn); + const safeId = escapeHtml(conn.id); return \`
    -
    \${conn.name}
    +
    \${escapeHtml(conn.name)}
    \${isAuthenticated ? '
    ✅ Connected
    ' - : '' + : '' }
    -
    \${conn.url}
    +
    \${escapeHtml(conn.url)}
    - \${conn.environment} + \${escapeHtml(conn.environment)} \${formatAuthType(conn.authenticationType)}
    + \${browserBadge ? \`
    \${browserBadge}
    \` : ''}
    \`; diff --git a/src/renderer/modals/sharedStyles.ts b/src/renderer/modals/sharedStyles.ts index e7df9b4e..07d4a22c 100644 --- a/src/renderer/modals/sharedStyles.ts +++ b/src/renderer/modals/sharedStyles.ts @@ -526,5 +526,47 @@ export function getModalStyles(isDarkTheme: boolean): string { background: ${isDarkTheme ? "rgba(255, 255, 255, 0.08)" : "rgba(0, 0, 0, 0.08)"}; margin: 4px 0; } + + .connection-item-meta-right { + display: flex; + align-items: center; + } + + .browser-profile-badge { + display: inline-flex; + align-items: center; + gap: 6px; + background: ${isDarkTheme ? "#2d2d30" : "#ffffff"}; + font-size: 11px; + color: ${isDarkTheme ? "#f3f2f1" : "#323130"}; + line-height: 1; + } + + .browser-profile-icon { + width: 14px; + height: 14px; + object-fit: contain; + } + + .browser-profile-icon-fallback { + width: 16px; + height: 16px; + border-radius: 50%; + background: ${isDarkTheme ? "rgba(255, 255, 255, 0.15)" : "rgba(0, 0, 0, 0.08)"}; + color: ${isDarkTheme ? "#f3f2f1" : "#323130"}; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + } + + .browser-profile-label { + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } `; } diff --git a/src/renderer/modules/connectionManagement.ts b/src/renderer/modules/connectionManagement.ts index 8e9a17de..3f0819c9 100644 --- a/src/renderer/modules/connectionManagement.ts +++ b/src/renderer/modules/connectionManagement.ts @@ -390,6 +390,9 @@ async function handlePopulateConnectionsRequest(): Promise { // If highlightConnectionId is set (tool-specific modal), use it to mark as active // Otherwise, mark none as active since there's no global active connection isActive: highlightConnectionId ? conn.id === highlightConnectionId : false, + browserType: conn.browserType, + browserProfile: conn.browserProfile, + browserProfileName: conn.browserProfileName, }), ), }, @@ -577,6 +580,9 @@ async function handlePopulateMultiConnectionsRequest(): Promise { lastUsedAt: conn.lastUsedAt, createdAt: conn.createdAt, isActive: false, + browserType: conn.browserType, + browserProfile: conn.browserProfile, + browserProfileName: conn.browserProfileName, })), }, }); diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index f14cf1d7..78daf0a2 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -1,5 +1,5 @@ // eslint-disable-next-line @typescript-eslint/triple-slash-reference -/// +/// /** * Main renderer process entry point diff --git a/src/renderer/types/assets.d.ts b/src/renderer/types/assets.d.ts new file mode 100644 index 00000000..59cd9403 --- /dev/null +++ b/src/renderer/types/assets.d.ts @@ -0,0 +1,11 @@ +// Vite asset imports - PNG files imported normally resolve to URL strings +declare module "*.png" { + const url: string; + export default url; +} + +// PNG files imported with ?inline are always inlined as base64 data: URIs +declare module "*.png?inline" { + const dataUri: string; + export default dataUri; +} diff --git a/src/renderer/types.d.ts b/src/renderer/types/renderer.d.ts similarity index 94% rename from src/renderer/types.d.ts rename to src/renderer/types/renderer.d.ts index 927e99ff..e1cb6f42 100644 --- a/src/renderer/types.d.ts +++ b/src/renderer/types/renderer.d.ts @@ -3,7 +3,7 @@ * Re-exports shared types and extends with renderer-specific definitions */ -import type { ToolboxAPI, ToolContext } from "../common/types"; +import type { ToolboxAPI, ToolContext } from "../../common/types"; // Re-export for convenience export type { ToolboxAPI, ToolContext }; diff --git a/src/renderer/utils/browserIcons.ts b/src/renderer/utils/browserIcons.ts new file mode 100644 index 00000000..1ef0815c --- /dev/null +++ b/src/renderer/utils/browserIcons.ts @@ -0,0 +1,12 @@ +/** + * Browser icon data URIs for use in modal windows. + * + * These PNGs are imported with Vite's `?inline` query so they are always + * emitted as base64 data: URIs. This allows them to be used in modal + * BrowserWindows that load via data: URLs where relative paths cannot resolve + * and where the CSP restricts `img-src` to `data:`. + */ +import chromeIconUrl from "../icons/logos/chrome.png?inline"; +import edgeIconUrl from "../icons/logos/edge.png?inline"; + +export { chromeIconUrl, edgeIconUrl }; diff --git a/tsconfig.renderer.json b/tsconfig.renderer.json index 0bd186b6..02071491 100644 --- a/tsconfig.renderer.json +++ b/tsconfig.renderer.json @@ -6,7 +6,7 @@ "module": "ES2022", "moduleResolution": "bundler", // Ensure our ambient renderer declarations (e.g. window.toolboxAPI) are picked up reliably - "typeRoots": ["./node_modules/@types", "./src/renderer"] + "typeRoots": ["./node_modules/@types", "./src/renderer/types"] }, "include": ["src/renderer/**/*", "src/common/**/*"], "exclude": ["node_modules", "dist", "build"] From 1b177f5d6a6bcb079476366db6e05643d604028d Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 04:57:55 -0500 Subject: [PATCH 043/257] fix: Improve CSP exception modal wording to be user-friendly (#428) * Initial plan * fix: improve CSP exception modal wording to be user-friendly Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * fix: HTML-escape CSP modal content and rename allDomains to allSources Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * fix: improve wording in CSP exception modal for clarity and trust --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> Co-authored-by: Power-Maverick --- src/renderer/modals/cspException/view.ts | 78 ++++++++++-------------- 1 file changed, 32 insertions(+), 46 deletions(-) diff --git a/src/renderer/modals/cspException/view.ts b/src/renderer/modals/cspException/view.ts index 0c8f52f8..29621bdc 100644 --- a/src/renderer/modals/cspException/view.ts +++ b/src/renderer/modals/cspException/view.ts @@ -1,3 +1,4 @@ +import { escapeHtml } from "../../utils/toolIconResolver"; import { getModalStyles } from "../sharedStyles"; export interface ModalViewTemplate { @@ -20,21 +21,16 @@ export function getCspExceptionModalView(model: CspExceptionModalViewModel): Mod const authorsList = model.authors && model.authors.length ? model.authors.join(", ") : "Unknown"; - // Build list of CSP exceptions - let exceptionsHtml = ""; - for (const [directive, sources] of Object.entries(model.cspExceptions)) { - if (Array.isArray(sources) && sources.length > 0) { - const directiveName = directive.replace("-src", "").replace(/-/g, " "); - exceptionsHtml += ` -
    - ${directiveName}: -
      - ${sources.map((source: string) => `
    • ${source}
    • `).join("")} -
    -
    - `; + // Build flat list of unique CSP source expressions across all directives + const allSources = new Set(); + for (const sources of Object.values(model.cspExceptions)) { + if (Array.isArray(sources)) { + sources.forEach((source: string) => allSources.add(source)); } } + const exceptionsHtml = Array.from(allSources) + .map((source: string) => `
  • ${escapeHtml(source)}
  • `) + .join(""); const styles = getModalStyles(isDarkTheme) + @@ -70,39 +66,24 @@ export function getCspExceptionModalView(model: CspExceptionModalViewModel): Mod background: ${isDarkTheme ? "rgba(255, 255, 255, 0.03)" : "rgba(0, 0, 0, 0.03)"}; border: 1px solid ${isDarkTheme ? "rgba(255, 255, 255, 0.08)" : "rgba(0, 0, 0, 0.08)"}; border-radius: 8px; - padding: 16px; + padding: 12px 16px; margin: 16px 0; max-height: 300px; overflow-y: auto; } - .csp-exception { - margin-bottom: 16px; - } - - .csp-exception:last-child { - margin-bottom: 0; - } - - .csp-exception strong { - display: block; - margin-bottom: 8px; - color: #4cc2ff; - text-transform: capitalize; - } - - .csp-exception ul { + .csp-exceptions-list ul { margin: 0; padding-left: 20px; } - .csp-exception li { + .csp-exceptions-list li { margin: 4px 0; font-size: 13px; color: ${isDarkTheme ? "rgba(255, 255, 255, 0.8)" : "rgba(0, 0, 0, 0.8)"}; } - .csp-exception code { + .csp-exceptions-list code { background: ${isDarkTheme ? "rgba(255, 255, 255, 0.06)" : "rgba(0, 0, 0, 0.06)"}; border: 1px solid ${isDarkTheme ? "rgba(255, 255, 255, 0.12)" : "rgba(0, 0, 0, 0.12)"}; border-radius: 3px; @@ -145,42 +126,47 @@ export function getCspExceptionModalView(model: CspExceptionModalViewModel): Mod margin: 4px 0; font-size: 13px; } + + .csp-learn-more { + color: #4cc2ff; + } `; const body = `
    -

    ⚠️ Security Permissions

    -

    Review Required

    +

    ⚠️ Permission Request

    +

    Website Access Required

    - ${model.toolName} by ${authorsList} - is requesting permission to access external resources. + ${escapeHtml(model.toolName)} by ${escapeHtml(authorsList)} + wants to connect to websites outside this application.

    - This tool needs the following Content Security Policy (CSP) exceptions to function properly: + Only allow if you trust this tool and the author(s) who created it. + Allowing access means this tool can download information, load content, and communicate with the listed websites:

    - ${exceptionsHtml} +
      + ${exceptionsHtml} +

    - ⚠️ Important: Only grant these permissions if you trust this tool and its author. - These permissions will allow the tool to: + ⚠️ Only allow if you trust this tool. +

    +

    + If you are unsure, decline and check the tool's documentation or contact its author before proceeding. + Learn more about website permissions.

    -
      -
    • Make network requests to the specified domains
    • -
    • Load scripts and styles from external sources
    • -
    • Access external resources as specified above
    • -
    - +
    `; From bb1a67985d84ce0b2453e0d4c66c0016d6ae7a08 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 19:41:10 -0500 Subject: [PATCH 044/257] Fix: Toast notifications no longer always-on-top; add configurable display duration (#432) * Initial plan * fix: remove alwaysOnTop from toast notifications and add notification duration setting Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * fix: address review comments - use DEFAULT_NOTIFICATION_DURATION constant and fix duration=0 handling Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * fix: pass notification duration as-is so defaultNotificationDuration is respected Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> --- src/common/types/settings.ts | 1 + .../managers/notificationWindowManager.ts | 9 ++++---- src/main/managers/settingsManager.ts | 1 + src/renderer/index.html | 12 +++++++++++ src/renderer/modules/initialization.ts | 7 ++++--- src/renderer/modules/notifications.ts | 21 +++++++++++++++---- src/renderer/modules/settingsManagement.ts | 17 ++++++++++++++- src/renderer/types/index.ts | 1 + 8 files changed, 57 insertions(+), 12 deletions(-) diff --git a/src/common/types/settings.ts b/src/common/types/settings.ts index 2beeb52c..10df81c7 100644 --- a/src/common/types/settings.ts +++ b/src/common/types/settings.ts @@ -59,6 +59,7 @@ export interface UserSettings { language: string; autoUpdate: boolean; terminalFont: string; + notificationDuration: number; // Duration in milliseconds (0 = persistent) showDebugMenu: boolean; deprecatedToolsVisibility?: DeprecatedToolsVisibility; toolDisplayMode?: ToolDisplayMode; diff --git a/src/main/managers/notificationWindowManager.ts b/src/main/managers/notificationWindowManager.ts index 2eec1e48..a1f87343 100644 --- a/src/main/managers/notificationWindowManager.ts +++ b/src/main/managers/notificationWindowManager.ts @@ -12,8 +12,9 @@ interface NotificationOptions { /** * NotificationWindowManager * - * Manages a frameless, always-on-top BrowserWindow for displaying notifications. - * This ensures notifications are always visible above BrowserView components. + * Manages a frameless BrowserWindow for displaying notifications. + * The window is set as a child of the main window so it stays + * associated without floating above unrelated windows (e.g. modals). * * Notification system - notifications appear in a dedicated * window that floats above the main application. @@ -43,7 +44,7 @@ export class NotificationWindowManager { height: this.calculateWindowHeight(), frame: false, transparent: true, - alwaysOnTop: true, + alwaysOnTop: false, skipTaskbar: true, resizable: false, movable: false, @@ -174,7 +175,7 @@ export class NotificationWindowManager { this.updateWindow(); // Auto-dismiss after duration - const duration = options.duration || 5000; + const duration = options.duration !== undefined ? options.duration : 5000; if (duration > 0) { setTimeout(() => { const index = this.notifications.indexOf(options); diff --git a/src/main/managers/settingsManager.ts b/src/main/managers/settingsManager.ts index b8e60b84..44d917b0 100644 --- a/src/main/managers/settingsManager.ts +++ b/src/main/managers/settingsManager.ts @@ -17,6 +17,7 @@ export class SettingsManager { language: "en", autoUpdate: true, terminalFont: "'Consolas', 'Monaco', 'Courier New', monospace", + notificationDuration: 5000, showDebugMenu: false, deprecatedToolsVisibility: "hide-all", lastUsedTools: [], diff --git a/src/renderer/index.html b/src/renderer/index.html index d838d527..c95b19f8 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -313,6 +313,18 @@

    SETTINGS

    Choose how tools are displayed in Installed Tools and Marketplace.
    +
    + + + How long toast notifications stay visible before auto-dismissing. +
    diff --git a/src/renderer/modules/initialization.ts b/src/renderer/modules/initialization.ts index d95d2f03..bbfb4f2e 100644 --- a/src/renderer/modules/initialization.ts +++ b/src/renderer/modules/initialization.ts @@ -65,7 +65,7 @@ if (sentryConfig) { logInfo("[Sentry] Telemetry disabled - no DSN configured"); } -import { DEFAULT_TERMINAL_FONT, LOADING_SCREEN_FADE_DURATION } from "../constants"; +import { DEFAULT_NOTIFICATION_DURATION, DEFAULT_TERMINAL_FONT, LOADING_SCREEN_FADE_DURATION } from "../constants"; import { handleCheckForUpdates, setupAutoUpdateListeners } from "./autoUpdateManagement"; import { initializeBrowserWindowModals } from "./browserWindowModals"; import { handleReauthentication, initializeAddConnectionModalBridge, loadSidebarConnections, openAddConnectionModal, updateFooterConnection } from "./connectionManagement"; @@ -73,7 +73,7 @@ import { initializeGlobalSearch } from "./globalSearchManagement"; import { loadHomepageData, setupHomepageActions } from "./homepageManagement"; import { handleProtocolInstallToolRequest, loadMarketplace, loadToolsLibrary } from "./marketplaceManagement"; import { closeModal, openModal } from "./modalManagement"; -import { showPPTBNotification } from "./notifications"; +import { showPPTBNotification, setDefaultNotificationDuration } from "./notifications"; import { saveSidebarSettings } from "./settingsManagement"; import { switchSidebar } from "./sidebarManagement"; import { handleTerminalClosed, handleTerminalCommandCompleted, handleTerminalCreated, handleTerminalError, handleTerminalOutput, setupTerminalPanel } from "./terminalManagement"; @@ -703,6 +703,7 @@ async function loadInitialSettings(): Promise { applyTheme(settings.theme); applyTerminalFont(settings.terminalFont || DEFAULT_TERMINAL_FONT); applyDebugMenuVisibility(settings.showDebugMenu ?? false); + setDefaultNotificationDuration(settings.notificationDuration ?? DEFAULT_NOTIFICATION_DURATION); } /** @@ -808,7 +809,7 @@ function setupToolboxEventListeners(): void { title: notificationData.title, body: notificationData.body, type: notificationData.type || "info", - duration: notificationData.duration || 5000, + duration: notificationData.duration, }); } diff --git a/src/renderer/modules/notifications.ts b/src/renderer/modules/notifications.ts index 0b381395..d8fdd7a8 100644 --- a/src/renderer/modules/notifications.ts +++ b/src/renderer/modules/notifications.ts @@ -4,6 +4,7 @@ */ import type { NotificationOptions } from "../types/index"; +import { DEFAULT_NOTIFICATION_DURATION } from "../constants"; // Store callbacks for notification actions with their expiry timestamps interface CallbackEntry { @@ -25,6 +26,16 @@ let cleanupIntervalId: ReturnType | null = null; // Flag to track if the notification action listener is already set up let isNotificationActionListenerSetUp = false; +// Default notification display duration (can be overridden by user settings) +let defaultNotificationDuration: number = DEFAULT_NOTIFICATION_DURATION; + +/** + * Update the default notification duration used when no explicit duration is provided + */ +export function setDefaultNotificationDuration(duration: number): void { + defaultNotificationDuration = duration; +} + /** * Clean up expired callbacks to prevent memory leaks * This runs periodically to remove callbacks whose notifications have been dismissed @@ -97,9 +108,11 @@ export function showPPTBNotification(options: NotificationOptions): void { // Store callbacks for later invocation with TTL for automatic cleanup if (options.actions && actions) { - const duration = options.duration || 5000; - // Callback expires after notification duration plus a buffer to handle edge cases - const expiresAt = Date.now() + duration + CALLBACK_TTL_BUFFER_MS; + const duration = options.duration !== undefined ? options.duration : defaultNotificationDuration; + // For persistent notifications (duration === 0), use a very large TTL so callbacks + // remain available until the user explicitly dismisses the notification. + const effectiveDuration = duration === 0 ? Number.MAX_SAFE_INTEGER - Date.now() : duration; + const expiresAt = Date.now() + effectiveDuration + CALLBACK_TTL_BUFFER_MS; actions.forEach((action: { label: string; callback: string }, index: number) => { const originalCallback = options.actions![index].callback; @@ -118,7 +131,7 @@ export function showPPTBNotification(options: NotificationOptions): void { title: options.title, body: options.body, type: options.type || "info", - duration: options.duration || 5000, + duration: options.duration !== undefined ? options.duration : defaultNotificationDuration, actions, }); } diff --git a/src/renderer/modules/settingsManagement.ts b/src/renderer/modules/settingsManagement.ts index 846918f8..4c036e9b 100644 --- a/src/renderer/modules/settingsManagement.ts +++ b/src/renderer/modules/settingsManagement.ts @@ -3,9 +3,10 @@ * Handles user settings UI and persistence */ -import { DEFAULT_TERMINAL_FONT } from "../constants"; +import { DEFAULT_NOTIFICATION_DURATION, DEFAULT_TERMINAL_FONT } from "../constants"; import type { SettingsState } from "../types/index"; import { loadMarketplace } from "./marketplaceManagement"; +import { setDefaultNotificationDuration } from "./notifications"; import { applyDebugMenuVisibility, applyTerminalFont, applyTheme } from "./themeManagement"; import { loadSidebarTools } from "./toolsSidebarManagement"; @@ -24,6 +25,7 @@ export async function loadSidebarSettings(): Promise { const terminalFontSelect = document.getElementById("sidebar-terminal-font-select") as any; // Fluent UI select element const customFontInput = document.getElementById("sidebar-terminal-font-custom") as HTMLInputElement; const customFontContainer = document.getElementById("custom-font-input-container"); + const notificationDurationSelect = document.getElementById("sidebar-notification-duration-select") as HTMLSelectElement | null; if (themeSelect && autoUpdateCheck && showDebugMenuCheck && deprecatedToolsSelect && toolDisplayModeSelect && terminalFontSelect) { const settings = await window.toolboxAPI.getUserSettings(); @@ -36,6 +38,7 @@ export async function loadSidebarSettings(): Promise { deprecatedToolsVisibility: settings.deprecatedToolsVisibility ?? "hide-all", toolDisplayMode: settings.toolDisplayMode ?? "standard", terminalFont: settings.terminalFont || DEFAULT_TERMINAL_FONT, + notificationDuration: settings.notificationDuration ?? DEFAULT_NOTIFICATION_DURATION, }; themeSelect.value = settings.theme; @@ -44,6 +47,10 @@ export async function loadSidebarSettings(): Promise { deprecatedToolsSelect.value = settings.deprecatedToolsVisibility ?? "hide-all"; toolDisplayModeSelect.value = settings.toolDisplayMode ?? "standard"; + if (notificationDurationSelect) { + notificationDurationSelect.value = String(settings.notificationDuration ?? DEFAULT_NOTIFICATION_DURATION); + } + const terminalFont = settings.terminalFont || DEFAULT_TERMINAL_FONT; // Check if the font is a predefined option @@ -82,6 +89,7 @@ export async function saveSidebarSettings(): Promise { const toolDisplayModeSelect = document.getElementById("sidebar-tool-display-mode-select") as any; // Fluent UI select element const terminalFontSelect = document.getElementById("sidebar-terminal-font-select") as any; // Fluent UI select element const customFontInput = document.getElementById("sidebar-terminal-font-custom") as HTMLInputElement; + const notificationDurationSelect = document.getElementById("sidebar-notification-duration-select") as HTMLSelectElement | null; if (!themeSelect || !autoUpdateCheck || !showDebugMenuCheck || !deprecatedToolsSelect || !toolDisplayModeSelect || !terminalFontSelect) return; @@ -92,6 +100,8 @@ export async function saveSidebarSettings(): Promise { terminalFont = customFontInput.value.trim() || DEFAULT_TERMINAL_FONT; } + const notificationDuration = notificationDurationSelect ? Number(notificationDurationSelect.value) : 5000; + const currentSettings = { theme: themeSelect.value, autoUpdate: autoUpdateCheck.checked, @@ -99,6 +109,7 @@ export async function saveSidebarSettings(): Promise { deprecatedToolsVisibility: deprecatedToolsSelect.value, toolDisplayMode: toolDisplayModeSelect.value, terminalFont: terminalFont, + notificationDuration, }; // Only include changed settings in the update @@ -122,6 +133,9 @@ export async function saveSidebarSettings(): Promise { if (currentSettings.terminalFont !== originalSettings.terminalFont) { changedSettings.terminalFont = currentSettings.terminalFont; } + if (currentSettings.notificationDuration !== originalSettings.notificationDuration) { + changedSettings.notificationDuration = currentSettings.notificationDuration; + } // Only save and emit event if something changed if (Object.keys(changedSettings).length > 0) { @@ -131,6 +145,7 @@ export async function saveSidebarSettings(): Promise { applyTheme(currentSettings.theme); applyTerminalFont(currentSettings.terminalFont); applyDebugMenuVisibility(currentSettings.showDebugMenu); + setDefaultNotificationDuration(currentSettings.notificationDuration); // Reload tools list if deprecated tools visibility changed if (changedSettings.deprecatedToolsVisibility !== undefined) { diff --git a/src/renderer/types/index.ts b/src/renderer/types/index.ts index 3138c37a..0db92ec4 100644 --- a/src/renderer/types/index.ts +++ b/src/renderer/types/index.ts @@ -56,6 +56,7 @@ export interface SettingsState { deprecatedToolsVisibility?: string; toolDisplayMode?: string; terminalFont?: string; + notificationDuration?: number; } /** From 5704309e6a1e0917f06455cb1fe064aa6d1c0f9e Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:43:13 -0500 Subject: [PATCH 045/257] Add Category and Environment Color to Dataverse connections (#433) * Initial plan * Add Category and Environment Color to connections with two-column modal layout Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Increase add/edit connection modal width to 920px to match select multi-connection modal Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Add categoryColor support and connection grouping by category - Add categoryColor to DataverseConnection and UIConnectionData types - Add categoryColor to ToolSafeConnection in toolPreloadBridge - Add categoryColor to ConnectionFormPayload and buildConnectionFromPayload - Pass category, environmentColor, categoryColor in populate handlers - Group sidebar connections by category with collapsible headers - Add category filter dropdown to sidebar with dynamic population - Update updateToolPanelBorder to support inline environmentColor/categoryColor - Apply environmentColor as inline border style on tool panel - Apply categoryColor as inline border-bottom on active tool tab - Restructure add/edit connection modals: two-col layout with browser settings and auth fields side by side - Add categoryColor color picker to add/edit connection modals - Show environmentColor and categoryColor badges in select/multi-select modals - Add connection-group CSS for grouped sidebar display - Add category-badge and auth-fields-column CSS to sharedStyles - Add category filter section to connections sidebar in index.html Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix HTML escaping in category group rendering and simplify group toggle - Use escapeHtml() for category names in option values and group data attrs - Use closest() to find sibling group-items instead of CSS.escape querySelector Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add categoryColor, env color to tool border, category grouping/filter in sidebar, restructured modal layout, color in select modals Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Apply environmentColor to footer status elements; fix multi-connection gradient border Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Address code review: escape HTML, fix color sentinel, keyboard a11y for group headers Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * feat: update default color values for environment and category in add/edit connection modals --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Power-Maverick --- src/common/types/connection.ts | 7 + src/main/toolPreloadBridge.ts | 6 + src/renderer/index.html | 7 + .../modals/addConnection/controller.ts | 54 +++++ src/renderer/modals/addConnection/view.ts | 188 ++++++++++-------- .../modals/editConnection/controller.ts | 86 ++++++++ src/renderer/modals/editConnection/view.ts | 188 ++++++++++-------- .../modals/selectConnection/controller.ts | 8 +- .../selectMultiConnection/controller.ts | 8 +- src/renderer/modals/sharedStyles.ts | 57 ++++++ src/renderer/modules/connectionManagement.ts | 174 ++++++++++++++-- src/renderer/modules/toolManagement.ts | 118 ++++++++--- src/renderer/styles.scss | 56 ++++++ 13 files changed, 759 insertions(+), 198 deletions(-) diff --git a/src/common/types/connection.ts b/src/common/types/connection.ts index 067f4dbf..776ec322 100644 --- a/src/common/types/connection.ts +++ b/src/common/types/connection.ts @@ -43,6 +43,10 @@ export interface DataverseConnection { browserType?: BrowserType; browserProfile?: string; browserProfileName?: string; + // Grouping and visual customization + category?: string; + environmentColor?: string; + categoryColor?: string; } /** @@ -76,6 +80,9 @@ export interface UIConnectionData { browserType?: BrowserType; browserProfile?: string; browserProfileName?: string; + category?: string; + environmentColor?: string; + categoryColor?: string; } /** diff --git a/src/main/toolPreloadBridge.ts b/src/main/toolPreloadBridge.ts index 2834e952..2dfbfca7 100644 --- a/src/main/toolPreloadBridge.ts +++ b/src/main/toolPreloadBridge.ts @@ -103,6 +103,9 @@ type ToolSafeConnection = { createdAt?: string; lastUsedAt?: string; isActive?: boolean; + category?: string; + environmentColor?: string; + categoryColor?: string; }; function toToolSafeConnection(connection: unknown): ToolSafeConnection | null { @@ -129,6 +132,9 @@ function toToolSafeConnection(connection: unknown): ToolSafeConnection | null { createdAt: typeof source.createdAt === "string" ? source.createdAt : undefined, lastUsedAt: typeof source.lastUsedAt === "string" ? source.lastUsedAt : undefined, isActive: typeof source.isActive === "boolean" ? source.isActive : undefined, + category: typeof source.category === "string" ? source.category : undefined, + environmentColor: typeof source.environmentColor === "string" ? source.environmentColor : undefined, + categoryColor: typeof source.categoryColor === "string" ? source.categoryColor : undefined, }; } diff --git a/src/renderer/index.html b/src/renderer/index.html index c95b19f8..30e56388 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -133,6 +133,13 @@

    CONNECTIONS

    +
    +
    +
    Category
    + +
    diff --git a/src/renderer/modals/addConnection/controller.ts b/src/renderer/modals/addConnection/controller.ts index 8f098d03..fb614fd7 100644 --- a/src/renderer/modals/addConnection/controller.ts +++ b/src/renderer/modals/addConnection/controller.ts @@ -150,6 +150,21 @@ export function getAddConnectionModalControllerScript(channels: AddConnectionMod usernamePasswordTenantId: getInputValue("connection-tenant-id-up"), connectionString: getInputValue("connection-string-input"), browserType: getInputValue("connection-browser-type") || "default", + category: getInputValue("connection-category"), + environmentColor: (() => { + const colorInput = document.getElementById("connection-environment-color"); + if (colorInput instanceof HTMLInputElement && colorInput.dataset.customSet === "true") { + return colorInput.value; + } + return ""; + })(), + categoryColor: (() => { + const colorInput = document.getElementById("connection-category-color"); + if (colorInput instanceof HTMLInputElement && colorInput.dataset.customSet === "true") { + return colorInput.value; + } + return ""; + })(), ...(() => { const selection = getBrowserProfileSelection(); return { @@ -186,6 +201,45 @@ export function getAddConnectionModalControllerScript(channels: AddConnectionMod authTypeSelect?.addEventListener("change", updateAuthVisibility); updateAuthVisibility(); + // Color picker setup + const colorInput = document.getElementById("connection-environment-color"); + const colorLabel = document.getElementById("connection-environment-color-label"); + const clearColorBtn = document.getElementById("clear-environment-color"); + if (colorInput instanceof HTMLInputElement) { + // Initialize with no custom color + colorInput.dataset.customSet = "false"; + colorInput.addEventListener("input", () => { + colorInput.dataset.customSet = "true"; + if (colorLabel) colorLabel.textContent = colorInput.value; + }); + } + clearColorBtn?.addEventListener("click", () => { + if (colorInput instanceof HTMLInputElement) { + colorInput.dataset.customSet = "false"; + colorInput.value = "#0288d1"; + if (colorLabel) colorLabel.textContent = "Pick a custom color for the environment badge"; + } + }); + + // Category color picker setup + const categoryColorInput = document.getElementById("connection-category-color"); + const categoryColorLabel = document.getElementById("connection-category-color-label"); + const clearCategoryColorBtn = document.getElementById("clear-category-color"); + if (categoryColorInput instanceof HTMLInputElement) { + categoryColorInput.dataset.customSet = "false"; + categoryColorInput.addEventListener("input", () => { + categoryColorInput.dataset.customSet = "true"; + if (categoryColorLabel) categoryColorLabel.textContent = categoryColorInput.value; + }); + } + clearCategoryColorBtn?.addEventListener("click", () => { + if (categoryColorInput instanceof HTMLInputElement) { + categoryColorInput.dataset.customSet = "false"; + categoryColorInput.value = "#2e7d32"; + if (categoryColorLabel) categoryColorLabel.textContent = "Pick a color for the category"; + } + }); + // Browser type change listener browserTypeSelect?.addEventListener("change", () => { loadBrowserProfiles(); diff --git a/src/renderer/modals/addConnection/view.ts b/src/renderer/modals/addConnection/view.ts index 045fab89..8a02ea9b 100644 --- a/src/renderer/modals/addConnection/view.ts +++ b/src/renderer/modals/addConnection/view.ts @@ -21,95 +21,125 @@ export function getAddConnectionModalView(isDarkTheme: boolean): ModalViewTempla
    -
    - - +
    +
    + + +
    +
    + + +
    -
    - - -
    -
    - - -
    -
    - Browser Settings (Optional) - - -

    Choose which browser to use when opening URLs with authentication. Defaults to your system's default browser.

    -
    - ⚠️ Selected browser is not installed. URLs will open using the system default browser. +
    +
    + +
    - - -

    Select a browser profile to use. Profiles will be loaded when you select a browser above.

    -
    -
    - Microsoft Login Options - - -

    Pre-fill the login prompt with a specific email address. Leave empty to choose from browser accounts.

    - - -

    Override the default Azure AD App ID if needed. Leave empty to use the development app.

    - - -

    Defaults to 'organizations' for multi-tenant authentication. Specify your tenant ID for single-tenant apps.

    -
    -
    - Client Secret Authentication - - - -
    - - +
    + +
    + + Pick a custom color for the environment badge + +
    - -
    -
    - Username & Password - - - -
    - - +
    +
    + + +
    +
    + +
    + + Pick a color for the category + +
    - - -

    Override the default Azure AD App ID if needed. Leave empty to use the development app.

    - - -

    Defaults to 'organizations' for multi-tenant authentication. Specify your tenant ID for single-tenant apps.

    -
    - Connection String - - -

    Enter your connection string. Supports Office365, OAuth, and ClientSecret authentication types. URL and authentication details will be extracted automatically.

    +
    +
    + Browser Settings (Optional) + + +

    Choose which browser to use when opening URLs with authentication. Defaults to your system's default browser.

    +
    + ⚠️ Selected browser is not installed. URLs will open using the system default browser. +
    + + +

    Select a browser profile to use. Profiles will be loaded when you select a browser above.

    +
    +
    +
    + Microsoft Login Options + + +

    Pre-fill the login prompt with a specific email address. Leave empty to choose from browser accounts.

    + + +

    Override the default Azure AD App ID if needed. Leave empty to use the development app.

    + + +

    Defaults to 'organizations' for multi-tenant authentication. Specify your tenant ID for single-tenant apps.

    +
    +
    + Client Secret Authentication + + + +
    + + +
    + + +
    +
    + Username & Password + + + +
    + + +
    + + +

    Override the default Azure AD App ID if needed. Leave empty to use the development app.

    + + +

    Defaults to 'organizations' for multi-tenant authentication. Specify your tenant ID for single-tenant apps.

    +
    +
    + Connection String + + +

    Enter your connection string. Supports Office365, OAuth, and ClientSecret authentication types. URL and authentication details will be extracted automatically.

    +
    +
    diff --git a/src/renderer/modals/editConnection/controller.ts b/src/renderer/modals/editConnection/controller.ts index 44e372dc..bffa34d6 100644 --- a/src/renderer/modals/editConnection/controller.ts +++ b/src/renderer/modals/editConnection/controller.ts @@ -168,6 +168,21 @@ export function getEditConnectionModalControllerScript(channels: EditConnectionM usernamePasswordTenantId: getInputValue("connection-tenant-id-up"), connectionString: getInputValue("connection-string-input"), browserType: getInputValue("connection-browser-type") || "default", + category: getInputValue("connection-category"), + environmentColor: (() => { + const colorInput = document.getElementById("connection-environment-color"); + if (colorInput instanceof HTMLInputElement && colorInput.dataset.customSet === "true") { + return colorInput.value; + } + return ""; + })(), + categoryColor: (() => { + const colorInput = document.getElementById("connection-category-color"); + if (colorInput instanceof HTMLInputElement && colorInput.dataset.customSet === "true") { + return colorInput.value; + } + return ""; + })(), ...(() => { const selection = getBrowserProfileSelection(); return { @@ -197,6 +212,39 @@ export function getEditConnectionModalControllerScript(channels: EditConnectionM browserProfileSelect.value = connection.browserProfile; } }); + + // Populate category + setInputValue("connection-category", connection.category || ""); + + // Populate environment color + const colorInput = document.getElementById("connection-environment-color"); + const colorLabel = document.getElementById("connection-environment-color-label"); + if (colorInput instanceof HTMLInputElement) { + if (connection.environmentColor) { + colorInput.value = connection.environmentColor; + colorInput.dataset.customSet = "true"; + if (colorLabel) colorLabel.textContent = connection.environmentColor; + } else { + colorInput.value = "#0288d1"; + colorInput.dataset.customSet = "false"; + if (colorLabel) colorLabel.textContent = "Pick a custom color for the environment badge"; + } + } + + // Populate category color + const catColorInput = document.getElementById("connection-category-color"); + const catColorLabel = document.getElementById("connection-category-color-label"); + if (catColorInput instanceof HTMLInputElement) { + if (connection.categoryColor) { + catColorInput.value = connection.categoryColor; + catColorInput.dataset.customSet = "true"; + if (catColorLabel) catColorLabel.textContent = connection.categoryColor; + } else { + catColorInput.value = "#2e7d32"; + catColorInput.dataset.customSet = "false"; + if (catColorLabel) catColorLabel.textContent = "Pick a color for the category"; + } + } // Populate auth type specific fields if (connection.authenticationType === "clientSecret") { @@ -242,6 +290,44 @@ export function getEditConnectionModalControllerScript(channels: EditConnectionM authTypeSelect?.addEventListener("change", updateAuthVisibility); updateAuthVisibility(); + // Color picker setup + const colorInput = document.getElementById("connection-environment-color"); + const colorLabel = document.getElementById("connection-environment-color-label"); + const clearColorBtn = document.getElementById("clear-environment-color"); + if (colorInput instanceof HTMLInputElement) { + if (!colorInput.dataset.customSet) colorInput.dataset.customSet = "false"; + colorInput.addEventListener("input", () => { + colorInput.dataset.customSet = "true"; + if (colorLabel) colorLabel.textContent = colorInput.value; + }); + } + clearColorBtn?.addEventListener("click", () => { + if (colorInput instanceof HTMLInputElement) { + colorInput.dataset.customSet = "false"; + colorInput.value = "#0288d1"; + if (colorLabel) colorLabel.textContent = "Pick a custom color for the environment badge"; + } + }); + + // Category color picker setup + const categoryColorInput = document.getElementById("connection-category-color"); + const categoryColorLabel = document.getElementById("connection-category-color-label"); + const clearCategoryColorBtn = document.getElementById("clear-category-color"); + if (categoryColorInput instanceof HTMLInputElement) { + if (!categoryColorInput.dataset.customSet) categoryColorInput.dataset.customSet = "false"; + categoryColorInput.addEventListener("input", () => { + categoryColorInput.dataset.customSet = "true"; + if (categoryColorLabel) categoryColorLabel.textContent = categoryColorInput.value; + }); + } + clearCategoryColorBtn?.addEventListener("click", () => { + if (categoryColorInput instanceof HTMLInputElement) { + categoryColorInput.dataset.customSet = "false"; + categoryColorInput.value = "#2e7d32"; + if (categoryColorLabel) categoryColorLabel.textContent = "Pick a color for the category"; + } + }); + // Browser type change listener browserTypeSelect?.addEventListener("change", () => { loadBrowserProfiles(); diff --git a/src/renderer/modals/editConnection/view.ts b/src/renderer/modals/editConnection/view.ts index f7636809..77ee0cc3 100644 --- a/src/renderer/modals/editConnection/view.ts +++ b/src/renderer/modals/editConnection/view.ts @@ -21,95 +21,125 @@ export function getEditConnectionModalView(isDarkTheme: boolean): ModalViewTempl
    -
    - - +
    +
    + + +
    +
    + + +
    -
    - - -
    -
    - - -
    -
    - Browser Settings (Optional) - - -

    Choose which browser to use when opening URLs with authentication. Defaults to your system's default browser.

    -
    - ⚠️ Selected browser is not installed. URLs will open using the system default browser. +
    +
    + +
    - - -

    Select a browser profile to use. Profiles will be loaded when you select a browser above.

    -
    -
    - Microsoft Login Options - - -

    Pre-fill the login prompt with a specific email address. Leave empty to choose from browser accounts.

    - - -

    Override the default Azure AD App ID if needed. Leave empty to use the development app.

    - - -

    Defaults to 'organizations' for multi-tenant authentication. Specify your tenant ID for single-tenant apps.

    -
    -
    - Client Secret Authentication - - - -
    - - +
    + +
    + + Pick a custom color for the environment badge + +
    - -
    -
    - Username & Password - - - -
    - - +
    +
    + + +
    +
    + +
    + + Pick a color for the category + +
    - - -

    Override the default Azure AD App ID if needed. Leave empty to use the development app.

    - - -

    Defaults to 'organizations' for multi-tenant authentication. Specify your tenant ID for single-tenant apps.

    -
    - Connection String - - -

    Enter your connection string. Supports Office365, OAuth, and ClientSecret authentication types. URL and authentication details will be extracted automatically.

    +
    +
    + Browser Settings (Optional) + + +

    Choose which browser to use when opening URLs with authentication. Defaults to your system's default browser.

    +
    + ⚠️ Selected browser is not installed. URLs will open using the system default browser. +
    + + +

    Select a browser profile to use. Profiles will be loaded when you select a browser above.

    +
    +
    +
    + Microsoft Login Options + + +

    Pre-fill the login prompt with a specific email address. Leave empty to choose from browser accounts.

    + + +

    Override the default Azure AD App ID if needed. Leave empty to use the development app.

    + + +

    Defaults to 'organizations' for multi-tenant authentication. Specify your tenant ID for single-tenant apps.

    +
    +
    + Client Secret Authentication + + + +
    + + +
    + + +
    +
    + Username & Password + + + +
    + + +
    + + +

    Override the default Azure AD App ID if needed. Leave empty to use the development app.

    + + +

    Defaults to 'organizations' for multi-tenant authentication. Specify your tenant ID for single-tenant apps.

    +
    +
    + Connection String + + +

    Enter your connection string. Supports Office365, OAuth, and ClientSecret authentication types. URL and authentication details will be extracted automatically.

    +
    +
    diff --git a/src/renderer/modals/selectConnection/controller.ts b/src/renderer/modals/selectConnection/controller.ts index 1b8e1b6e..f2592b93 100644 --- a/src/renderer/modals/selectConnection/controller.ts +++ b/src/renderer/modals/selectConnection/controller.ts @@ -156,6 +156,11 @@ ${sortingUtilities} connectionsListContainer.innerHTML = connections.map(conn => { const browserBadge = getBrowserBadgeMarkup(conn); + const envColor = conn.environmentColor && /^#[0-9A-Fa-f]{6}$/.test(conn.environmentColor) ? conn.environmentColor : null; + const envBadgeStyle = envColor ? \` style="background-color:\${envColor}1a;color:\${envColor};border:1px solid \${envColor}4d"\` : ''; + const envBadgeClass = envColor ? 'connection-env-badge' : \`connection-env-badge env-\${escapeHtml(conn.environment.toLowerCase())}\`; + const catColor = conn.categoryColor && /^#[0-9A-Fa-f]{6}$/.test(conn.categoryColor) ? conn.categoryColor : null; + const catBadgeMarkup = conn.category ? \`\${escapeHtml(conn.category)}\` : ''; return \`
    @@ -164,8 +169,9 @@ ${sortingUtilities}
    \${escapeHtml(conn.url)}
    - \${escapeHtml(conn.environment)} + \${escapeHtml(conn.environment)} \${formatAuthType(conn.authenticationType)} + \${catBadgeMarkup} \${conn.isActive ? '✓ Active' : ''}
    \${browserBadge ? \`
    \${browserBadge}
    \` : ''} diff --git a/src/renderer/modals/selectMultiConnection/controller.ts b/src/renderer/modals/selectMultiConnection/controller.ts index f96dd98d..5746a01d 100644 --- a/src/renderer/modals/selectMultiConnection/controller.ts +++ b/src/renderer/modals/selectMultiConnection/controller.ts @@ -174,6 +174,11 @@ ${sortingUtilities} (idPrefix === 'secondary' && conn.id === authenticatedSecondaryConnectionId); const browserBadge = getBrowserBadgeMarkup(conn); const safeId = escapeHtml(conn.id); + const envColor = conn.environmentColor && /^#[0-9A-Fa-f]{6}$/.test(conn.environmentColor) ? conn.environmentColor : null; + const envBadgeStyle = envColor ? \` style="background-color:\${envColor}1a;color:\${envColor};border:1px solid \${envColor}4d"\` : ''; + const envBadgeClass = envColor ? 'connection-env-badge' : \`connection-env-badge env-\${escapeHtml(conn.environment.toLowerCase())}\`; + const catColor = conn.categoryColor && /^#[0-9A-Fa-f]{6}$/.test(conn.categoryColor) ? conn.categoryColor : null; + const catBadgeMarkup = conn.category ? \`\${escapeHtml(conn.category)}\` : ''; return \`
    \${escapeHtml(conn.url)}
    - \${escapeHtml(conn.environment)} + \${escapeHtml(conn.environment)} \${formatAuthType(conn.authenticationType)} + \${catBadgeMarkup}
    \${browserBadge ? \`
    \${browserBadge}
    \` : ''}
    diff --git a/src/renderer/modals/sharedStyles.ts b/src/renderer/modals/sharedStyles.ts index 07d4a22c..612cf750 100644 --- a/src/renderer/modals/sharedStyles.ts +++ b/src/renderer/modals/sharedStyles.ts @@ -92,6 +92,46 @@ export function getModalStyles(isDarkTheme: boolean): string { margin-bottom: 16px; } + .form-row-two-col { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-bottom: 16px; + } + + .form-row-two-col .form-group { + margin-bottom: 0; + } + + .color-picker-row { + display: flex; + align-items: center; + gap: 10px; + } + + .modal-color-input { + width: 38px; + height: 34px; + padding: 2px; + border-radius: 6px; + border: 1px solid ${isDarkTheme ? "rgba(255, 255, 255, 0.16)" : "rgba(0, 0, 0, 0.16)"}; + background: ${isDarkTheme ? "rgba(255, 255, 255, 0.05)" : "rgba(0, 0, 0, 0.05)"}; + cursor: pointer; + flex-shrink: 0; + } + + .color-picker-label { + flex: 1; + font-size: 13px; + color: ${isDarkTheme ? "rgba(255, 255, 255, 0.6)" : "rgba(0, 0, 0, 0.6)"}; + } + + .color-clear-btn { + padding: 6px 12px; + font-size: 12px; + flex-shrink: 0; + } + label { font-size: 13px; color: ${isDarkTheme ? "rgba(255, 255, 255, 0.9)" : "rgba(0, 0, 0, 0.8)"}; @@ -568,5 +608,22 @@ export function getModalStyles(isDarkTheme: boolean): string { text-overflow: ellipsis; white-space: nowrap; } + + .category-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 500; + background: rgba(0, 0, 0, 0.08); + color: inherit; + border: 1px solid rgba(0, 0, 0, 0.12); + } + + .auth-fields-column { + display: flex; + flex-direction: column; + } `; } diff --git a/src/renderer/modules/connectionManagement.ts b/src/renderer/modules/connectionManagement.ts index 3f0819c9..851cbb88 100644 --- a/src/renderer/modules/connectionManagement.ts +++ b/src/renderer/modules/connectionManagement.ts @@ -47,6 +47,9 @@ interface ConnectionFormPayload { browserType?: string; browserProfile?: string; browserProfileName?: string; + category?: string; + environmentColor?: string; + categoryColor?: string; } interface AuthenticateConnectionAction { @@ -78,7 +81,7 @@ const ADD_CONNECTION_MODAL_CHANNELS = { } as const; const ADD_CONNECTION_MODAL_DIMENSIONS = { - width: 520, + width: 920, height: 700, }; @@ -92,7 +95,7 @@ const EDIT_CONNECTION_MODAL_CHANNELS = { } as const; const EDIT_CONNECTION_MODAL_DIMENSIONS = { - width: 520, + width: 920, height: 700, }; @@ -393,6 +396,9 @@ async function handlePopulateConnectionsRequest(): Promise { browserType: conn.browserType, browserProfile: conn.browserProfile, browserProfileName: conn.browserProfileName, + category: conn.category, + environmentColor: conn.environmentColor, + categoryColor: conn.categoryColor, }), ), }, @@ -583,6 +589,9 @@ async function handlePopulateMultiConnectionsRequest(): Promise { browserType: conn.browserType, browserProfile: conn.browserProfile, browserProfileName: conn.browserProfileName, + category: conn.category, + environmentColor: conn.environmentColor, + categoryColor: conn.categoryColor, })), }, }); @@ -632,7 +641,7 @@ export async function loadConnections(): Promise {
    ${conn.name}
    - ${conn.environment} + ${getEnvBadgeMarkup(conn as DataverseConnection)}
    ${ @@ -1181,6 +1190,16 @@ function buildConnectionFromPayload(formPayload: ConnectionFormPayload, mode: "a connection.browserProfile = browserProfile || undefined; connection.browserProfileName = browserProfileName || undefined; + // Category and custom environment color + const category = sanitizeInput(formPayload.category); + const environmentColorRaw = sanitizeInput(formPayload.environmentColor); + const environmentColor = /^#[0-9A-Fa-f]{6}$/.test(environmentColorRaw) ? environmentColorRaw : undefined; + const categoryColorRaw = sanitizeInput(formPayload.categoryColor); + const categoryColor = /^#[0-9A-Fa-f]{6}$/.test(categoryColorRaw) ? categoryColorRaw : undefined; + connection.category = category || undefined; + connection.environmentColor = environmentColor; + connection.categoryColor = categoryColor; + return connection; } @@ -1203,6 +1222,16 @@ function buildConnectionFromPayload(formPayload: ConnectionFormPayload, mode: "a connection.browserProfile = browserProfile || undefined; connection.browserProfileName = browserProfileName || undefined; + // Category and custom environment color + const category = sanitizeInput(formPayload.category); + const environmentColorRaw = sanitizeInput(formPayload.environmentColor); + const environmentColor = /^#[0-9A-Fa-f]{6}$/.test(environmentColorRaw) ? environmentColorRaw : undefined; + const categoryColorRaw = sanitizeInput(formPayload.categoryColor); + const categoryColor = /^#[0-9A-Fa-f]{6}$/.test(categoryColorRaw) ? categoryColorRaw : undefined; + connection.category = category || undefined; + connection.environmentColor = environmentColor; + connection.categoryColor = categoryColor; + if (authenticationType === "clientSecret") { connection.clientId = sanitizeInput(formPayload.clientId); connection.clientSecret = sanitizeInput(formPayload.clientSecret); @@ -1286,6 +1315,16 @@ function getBrowserBadgeMarkup(conn: DataverseConnection): string { `; } +function getEnvBadgeMarkup(conn: DataverseConnection): string { + const env = conn.environment || "Dev"; + const safeEnv = escapeHtml(env); + if (conn.environmentColor && /^#[0-9A-Fa-f]{6}$/.test(conn.environmentColor)) { + const safeColor = escapeHtml(conn.environmentColor); + return `${safeEnv}`; + } + return `${safeEnv}`; +} + function formatBrowserType(browserType: DataverseConnection["browserType"]): string { const labels: Record = { default: "Browser", @@ -1478,6 +1517,33 @@ export async function loadSidebarConnections(): Promise { } const sortOption = sortSelect ? coerceConnectionsSortOption(sortSelect.value) : savedSort; + // Build unique categories for the category filter dropdown + const categoryFilter = document.getElementById("connections-category-filter") as HTMLSelectElement | null; + if (categoryFilter) { + const allCategories = new Set(); + connections.forEach((conn: DataverseConnection) => { + if (conn.category) allCategories.add(conn.category); + }); + const currentCategoryValue = categoryFilter.value; + // Rebuild options (keep "All Categories" + "__default__" if there are any uncategorized) + const hasDefault = connections.some((conn: DataverseConnection) => !conn.category); + let optionsHtml = ''; + if (hasDefault) { + optionsHtml += ''; + } + allCategories.forEach((cat) => { + optionsHtml += ``; + }); + categoryFilter.innerHTML = optionsHtml; + // Restore previous selection if still valid + if (currentCategoryValue) { + categoryFilter.value = currentCategoryValue; + } + } + + // Category filter + const selectedCategory = categoryFilter?.value || ""; + // Apply filters const filteredConnections = connections.filter((conn: DataverseConnection) => { // Search filter (name or URL) @@ -1498,6 +1564,14 @@ export async function loadSidebarConnections(): Promise { return false; } + // Category filter + if (selectedCategory) { + if (selectedCategory === "__default__") { + return !conn.category; + } + return conn.category === selectedCategory; + } + return true; }); @@ -1523,18 +1597,34 @@ export async function loadSidebarConnections(): Promise { return; } - connectionsList.innerHTML = sortedConnections - .map((conn: DataverseConnection) => { - const isDarkTheme = document.body.classList.contains("dark-theme"); - const moreIconPath = isDarkTheme ? "icons/dark/more-icon.svg" : "icons/light/more-icon.svg"; - const browserBadgeMarkup = getBrowserBadgeMarkup(conn); + // Group connections by category (empty string = uncategorized / "Default") + const groupMap = new Map(); + sortedConnections.forEach((conn: DataverseConnection) => { + const key = conn.category || ""; + if (!groupMap.has(key)) groupMap.set(key, []); + groupMap.get(key)!.push(conn); + }); + + // Sort groups: uncategorized ("") first, then alphabetical + const groupKeys = Array.from(groupMap.keys()).sort((a, b) => { + if (a === "") return -1; + if (b === "") return 1; + return a.localeCompare(b); + }); - return ` + const renderConnectionItem = (conn: DataverseConnection): string => { + const isDarkTheme = document.body.classList.contains("dark-theme"); + const moreIconPath = isDarkTheme ? "icons/dark/more-icon.svg" : "icons/light/more-icon.svg"; + const browserBadgeMarkup = getBrowserBadgeMarkup(conn); + const envBadgeMarkup = getEnvBadgeMarkup(conn); + const safeName = escapeHtml(conn.name || ""); + const safeUrl = escapeHtml(conn.url || ""); + return `
    -
    ${conn.name}
    +
    ${safeName}
    @@ -1543,10 +1633,10 @@ export async function loadSidebarConnections(): Promise {
    -
    ${conn.url}
    +
    ${safeUrl}
    - ${conn.environment} + ${envBadgeMarkup} ${formatAuthType(conn.authenticationType)}
    @@ -1555,8 +1645,33 @@ export async function loadSidebarConnections(): Promise {
    `; - }) - .join(""); + }; + + const useGroups = groupKeys.length > 1 || (groupKeys.length === 1 && groupKeys[0] !== ""); + + if (useGroups) { + connectionsList.innerHTML = groupKeys + .map((groupKey) => { + const groupConns = groupMap.get(groupKey)!; + const displayKey = groupKey === "" ? "Default" : groupKey; + const escapedKey = escapeHtml(displayKey); + const items = groupConns.map(renderConnectionItem).join(""); + return ` +
    +
    + ${escapedKey} + ${groupConns.length} + ▼ +
    +
    + ${items} +
    +
    `; + }) + .join(""); + } else { + connectionsList.innerHTML = sortedConnections.map(renderConnectionItem).join(""); + } // Add event listeners for more buttons and context menu connectionsList.querySelectorAll(".tool-more-btn").forEach((button) => { @@ -1573,6 +1688,29 @@ export async function loadSidebarConnections(): Promise { }); }); + // Setup group header collapse toggle + connectionsList.querySelectorAll(".connection-group-header").forEach((header) => { + const headerEl = header as HTMLElement; + const toggleGroup = () => { + const group = headerEl.closest(".connection-group"); + const items = group?.querySelector(".connection-group-items"); + if (!items) return; + const isCollapsed = items.classList.contains("collapsed"); + items.classList.toggle("collapsed", !isCollapsed); + const toggle = headerEl.querySelector(".connection-group-toggle"); + if (toggle) toggle.textContent = isCollapsed ? "▼" : "▶"; + headerEl.setAttribute("aria-expanded", (!isCollapsed).toString()); + }; + headerEl.addEventListener("click", toggleGroup); + headerEl.addEventListener("keydown", (event: Event) => { + const ke = event as KeyboardEvent; + if (ke.key === "Enter" || ke.key === " ") { + ke.preventDefault(); + toggleGroup(); + } + }); + }); + // Keep legacy event listener for any remaining action buttons (fallback) connectionsList.querySelectorAll("button[data-action]").forEach((button) => { button.addEventListener("click", async (e) => { @@ -1617,6 +1755,14 @@ export async function loadSidebarConnections(): Promise { }); } + // Setup category filter event listener + if (categoryFilter && !(categoryFilter as any)._pptbBound) { + (categoryFilter as any)._pptbBound = true; + categoryFilter.addEventListener("change", () => { + loadSidebarConnections(); + }); + } + // Setup sort event listener if (sortSelect && !(sortSelect as any)._pptbBound) { (sortSelect as any)._pptbBound = true; diff --git a/src/renderer/modules/toolManagement.ts b/src/renderer/modules/toolManagement.ts index 9c7ddc11..10b3839b 100644 --- a/src/renderer/modules/toolManagement.ts +++ b/src/renderer/modules/toolManagement.ts @@ -917,12 +917,16 @@ export async function updateActiveToolConnectionStatus(): Promise { if (secondaryStatusElement) { secondaryStatusElement.classList.remove("visible", "connected", "expired"); secondaryStatusElement.textContent = ""; + secondaryStatusElement.style.color = ""; + secondaryStatusElement.style.backgroundColor = ""; } if (!activeToolId) { // No active tool, show "Not Connected" statusElement.textContent = "Not Connected"; statusElement.className = "connection-status"; + statusElement.style.color = ""; + statusElement.style.backgroundColor = ""; // Clear tool panel border updateToolPanelBorder(null); return; @@ -953,7 +957,16 @@ export async function updateActiveToolConnectionStatus(): Promise { statusElement.textContent = primaryText; const primaryEnvClass = `env-${primaryConnection.environment.toLowerCase()}`; const primaryStatusClass = isPrimaryExpired ? "expired" : "connected"; - statusElement.className = `connection-status ${primaryStatusClass} ${primaryEnvClass}`; + const primaryHasCustomColor = !isPrimaryExpired && primaryConnection.environmentColor && /^#[0-9A-Fa-f]{6}$/.test(primaryConnection.environmentColor); + if (primaryHasCustomColor) { + statusElement.className = `connection-status ${primaryStatusClass}`; + statusElement.style.color = primaryConnection.environmentColor as string; + statusElement.style.backgroundColor = `${primaryConnection.environmentColor}1a`; + } else { + statusElement.className = `connection-status ${primaryStatusClass} ${primaryEnvClass}`; + statusElement.style.color = ""; + statusElement.style.backgroundColor = ""; + } // Handle secondary connection display if (secondaryStatusElement) { @@ -970,10 +983,19 @@ export async function updateActiveToolConnectionStatus(): Promise { secondaryStatusElement.textContent = secondaryText; const secondaryEnvClass = `env-${secondaryConnection.environment.toLowerCase()}`; const secondaryStatusClass = isSecondaryExpired ? "expired" : "connected"; - secondaryStatusElement.className = `secondary-connection-status ${secondaryStatusClass} visible ${secondaryEnvClass}`; + const secondaryHasCustomColor = !isSecondaryExpired && secondaryConnection.environmentColor && /^#[0-9A-Fa-f]{6}$/.test(secondaryConnection.environmentColor); + if (secondaryHasCustomColor) { + secondaryStatusElement.className = `secondary-connection-status ${secondaryStatusClass} visible`; + secondaryStatusElement.style.color = secondaryConnection.environmentColor as string; + secondaryStatusElement.style.backgroundColor = `${secondaryConnection.environmentColor}1a`; + } else { + secondaryStatusElement.className = `secondary-connection-status ${secondaryStatusClass} visible ${secondaryEnvClass}`; + secondaryStatusElement.style.color = ""; + secondaryStatusElement.style.backgroundColor = ""; + } // Update tool panel border based on both primary and secondary environment - updateToolPanelBorder(primaryConnection.environment, secondaryConnection.environment); + updateToolPanelBorder(primaryConnection.environment, secondaryConnection.environment, primaryConnection.environmentColor, secondaryConnection.environmentColor, primaryConnection.categoryColor); return; } } else { @@ -990,7 +1012,7 @@ export async function updateActiveToolConnectionStatus(): Promise { } // Update tool panel border based on primary environment only - updateToolPanelBorder(primaryConnection.environment); + updateToolPanelBorder(primaryConnection.environment, null, primaryConnection.environmentColor, null, primaryConnection.categoryColor); return; } } else if (toolConnectionId) { @@ -1005,64 +1027,112 @@ export async function updateActiveToolConnectionStatus(): Promise { if (isExpired) { statusElement.textContent = `${activeTool.tool.name} is connected to: ${toolConnection.name} ⚠ (Token Expired)`; statusElement.className = `connection-status expired ${envClass}`; + statusElement.style.color = ""; + statusElement.style.backgroundColor = ""; } else { statusElement.textContent = `${activeTool.tool.name} is connected to: ${toolConnection.name}`; - statusElement.className = `connection-status connected ${envClass}`; + const singleHasCustomColor = toolConnection.environmentColor && /^#[0-9A-Fa-f]{6}$/.test(toolConnection.environmentColor); + if (singleHasCustomColor) { + statusElement.className = `connection-status connected`; + statusElement.style.color = toolConnection.environmentColor as string; + statusElement.style.backgroundColor = `${toolConnection.environmentColor}1a`; + } else { + statusElement.className = `connection-status connected ${envClass}`; + statusElement.style.color = ""; + statusElement.style.backgroundColor = ""; + } } // Update tool panel border based on environment - updateToolPanelBorder(toolConnection.environment); + updateToolPanelBorder(toolConnection.environment, null, toolConnection.environmentColor, null, toolConnection.categoryColor); return; } } // Tool doesn't have a connection statusElement.textContent = `${activeTool.tool.name} is not connected`; statusElement.className = "connection-status"; + statusElement.style.color = ""; + statusElement.style.backgroundColor = ""; // Clear tool panel border updateToolPanelBorder(null); } +/** + * Resolve the CSS-variable-based border color for a given environment type. + * Used as a fallback when no custom environmentColor is set on a connection. + */ +function getEnvBorderColor(environment: string): string { + const styles = getComputedStyle(document.documentElement); + const varMap: Record = { + dev: "--env-border-dev", + test: "--env-border-test", + uat: "--env-border-uat", + production: "--env-border-prod", + }; + const cssVar = varMap[environment.toLowerCase()] || "--env-border-dev"; + return styles.getPropertyValue(cssVar).trim() || "#8a8886"; +} + /** * Update the tool panel border and tab highlight based on the connection environment * @param environment The connection environment (Dev, Test, UAT, Production) or null to clear */ -function updateToolPanelBorder(environment: string | null, secondaryEnvironment?: string | null): void { +function updateToolPanelBorder(environment: string | null, secondaryEnvironment?: string | null, environmentColor?: string | null, secondaryEnvironmentColor?: string | null, categoryColor?: string | null): void { const toolPanelWrapper = document.getElementById("tool-panel-content-wrapper"); if (toolPanelWrapper) { // Remove all environment classes from panel const classesToRemove = Array.from(toolPanelWrapper.classList).filter((cls) => cls.startsWith("env-") || cls.startsWith("multi-env-")); classesToRemove.forEach((cls) => toolPanelWrapper.classList.remove(cls)); + // Reset inline styles + toolPanelWrapper.style.border = ""; + toolPanelWrapper.style.borderImage = ""; - // Add the appropriate class based on environment(s) + // Add the appropriate class or inline style based on environment(s) if (environment && secondaryEnvironment) { - const primaryEnvClass = environment.toLowerCase(); - const secondaryEnvClass = secondaryEnvironment.toLowerCase(); - - // If both environments are the same, use single environment class for efficiency - if (primaryEnvClass === secondaryEnvClass) { - toolPanelWrapper.classList.add(`env-${primaryEnvClass}`); + const primaryColor = environmentColor && /^#[0-9A-Fa-f]{6}$/.test(environmentColor) ? environmentColor : null; + const secColor = secondaryEnvironmentColor && /^#[0-9A-Fa-f]{6}$/.test(secondaryEnvironmentColor) ? secondaryEnvironmentColor : null; + if (primaryColor || secColor) { + // At least one connection has a custom color — use inline gradient border + const leftColor = primaryColor || getEnvBorderColor(environment); + const rightColor = secColor || getEnvBorderColor(secondaryEnvironment); + toolPanelWrapper.style.border = "5px solid transparent"; + toolPanelWrapper.style.borderImage = `linear-gradient(to right, ${leftColor} 50%, ${rightColor} 50%) 1`; } else { - // Multi-connection: use split border with both environments - const multiEnvClass = `multi-env-${primaryEnvClass}-${secondaryEnvClass}`; - toolPanelWrapper.classList.add(multiEnvClass); + const primaryEnvClass = environment.toLowerCase(); + const secondaryEnvClass = secondaryEnvironment.toLowerCase(); + if (primaryEnvClass === secondaryEnvClass) { + toolPanelWrapper.classList.add(`env-${primaryEnvClass}`); + } else { + const multiEnvClass = `multi-env-${primaryEnvClass}-${secondaryEnvClass}`; + toolPanelWrapper.classList.add(multiEnvClass); + } } } else if (environment) { - // Single connection: use solid border - const envClass = `env-${environment.toLowerCase()}`; - toolPanelWrapper.classList.add(envClass); + if (environmentColor && /^#[0-9A-Fa-f]{6}$/.test(environmentColor)) { + toolPanelWrapper.style.border = `5px solid ${environmentColor}`; + } else { + const envClass = `env-${environment.toLowerCase()}`; + toolPanelWrapper.classList.add(envClass); + } } } - // Update the active tab with environment class + // Update the active tab with environment class or category color if (activeToolId) { const activeTab = document.getElementById(`tool-tab-${activeToolId}`); if (activeTab) { // Remove all environment classes from tab activeTab.classList.remove("env-dev", "env-test", "env-uat", "env-production"); + // Reset inline style + activeTab.style.borderBottom = ""; - // Add the appropriate class based on environment (use primary for tabs) + // Add the appropriate class or inline style based on environment (use primary for tabs) if (environment) { - const envClass = `env-${environment.toLowerCase()}`; - activeTab.classList.add(envClass); + if (categoryColor && /^#[0-9A-Fa-f]{6}$/.test(categoryColor)) { + activeTab.style.borderBottom = `5px solid ${categoryColor}`; + } else { + const envClass = `env-${environment.toLowerCase()}`; + activeTab.classList.add(envClass); + } } } } diff --git a/src/renderer/styles.scss b/src/renderer/styles.scss index d10a34c6..3aebe1d6 100644 --- a/src/renderer/styles.scss +++ b/src/renderer/styles.scss @@ -5048,3 +5048,59 @@ body.dark-theme .global-search-item-badge.badge-settings { .global-search-highlight { animation: global-search-pulse 1.5s ease-out; } + +.connection-group { + margin-bottom: 4px; +} + +.connection-group-header { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + cursor: pointer; + border-radius: 6px; + user-select: none; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-secondary, rgba(0, 0, 0, 0.6)); + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + .dark-theme & { + color: rgba(255, 255, 255, 0.6); + + &:hover { + background: rgba(255, 255, 255, 0.05); + } + } +} + +.connection-group-title { + flex: 1; +} + +.connection-group-count { + font-size: 11px; + opacity: 0.7; + background: rgba(0, 0, 0, 0.1); + border-radius: 10px; + padding: 1px 6px; + + .dark-theme & { + background: rgba(255, 255, 255, 0.1); + } +} + +.connection-group-toggle { + font-size: 10px; + opacity: 0.7; +} + +.connection-group-items.collapsed { + display: none; +} From 66969dd94dc530ea17ff29c4ec5f1d3a76a305f7 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 21:04:54 -0500 Subject: [PATCH 046/257] feat(packages): add pptb-validate CLI for pre-publish tool validation (#436) * Initial plan * feat: add pptb-validate CLI for tool package validation Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * docs: add long-term plan for shared @pptb/validation package Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * feat: update tool validation script and README for improved usability * feat(validate): warn on missing optional fields (icon, website, funding) Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * feat(validate): improve funding validation logic in tool validation script * Update packages/lib/validate.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update packages/lib/validate.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update packages/lib/validate.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * feat(validate): add 'media-src' to valid CSP directives in validation logic * fix(validate): reject Windows absolute paths and backslashes in icon paths Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> Co-authored-by: Power-Maverick Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/SHARED_VALIDATION_PACKAGE.md | 96 +++++++++ packages/README.md | 71 ++++++- packages/bin/pptb-validate.js | 181 +++++++++++++++++ packages/lib/validate.js | 324 ++++++++++++++++++++++++++++++ packages/package.json | 17 +- 5 files changed, 685 insertions(+), 4 deletions(-) create mode 100644 docs/SHARED_VALIDATION_PACKAGE.md create mode 100755 packages/bin/pptb-validate.js create mode 100644 packages/lib/validate.js diff --git a/docs/SHARED_VALIDATION_PACKAGE.md b/docs/SHARED_VALIDATION_PACKAGE.md new file mode 100644 index 00000000..4c84b5de --- /dev/null +++ b/docs/SHARED_VALIDATION_PACKAGE.md @@ -0,0 +1,96 @@ +# Long-Term Plan: Shared `@pptb/validation` Package + +## Problem + +Tool validation logic is currently maintained in two separate places: + +1. **`pptb-web/lib/tool-validation.ts`** — used by the official review pipeline (website + GitHub Actions). +2. **`packages/lib/validate.js`** (added in `@pptb/types`) — used by the `pptb-validate` CLI that tool developers run locally. + +These two copies can drift over time. If the review criteria change in `pptb-web` but `packages/lib/validate.js` is not updated, a tool can pass local validation and still fail the official review. This is the opposite of the goal. + +--- + +## Proposed Solution + +Extract the validation logic into a dedicated, published npm package — **`@pptb/validation`** — that is consumed by both `pptb-web` and `@pptb/types`. There is then a single source of truth; any change to the validation rules is made once and takes effect everywhere. + +``` +┌────────────────────┐ ┌──────────────────────┐ +│ pptb-web │ │ @pptb/types │ +│ (review pipeline) │ │ (pptb-validate CLI) │ +└────────┬───────────┘ └──────────┬────────────┘ + │ │ + └──────────┬─────────────────┘ + │ depends on + ▼ + ┌──────────────────────┐ + │ @pptb/validation │ + │ (single source of │ + │ truth for rules) │ + └──────────────────────┘ +``` + +--- + +## Implementation Steps + +### Phase 1 — Create the `@pptb/validation` package + +- [ ] Create a new directory: `packages-validation/` (or a new repo `pptb-validation`) for the package. +- [ ] Set `"name": "@pptb/validation"` in `package.json`. +- [ ] Port `packages/lib/validate.js` (or `pptb-web/lib/tool-validation.ts`) into this package as the canonical source. TypeScript is preferred; ship both ESM and CJS builds. +- [ ] Export the public API: + - `validatePackageJson(pkg, options?)` — async validation function + - `APPROVED_LICENSES` — approved license list + - TypeScript types: `ValidationResult`, `ToolPackageJson`, `ValidationOptions` +- [ ] Write unit tests covering every validation rule. +- [ ] Publish to npm as `@pptb/validation`. + +### Phase 2 — Update `pptb-web` + +- [ ] Add `@pptb/validation` as a dependency in `pptb-web`. +- [ ] Replace the inline validation logic in `pptb-web/lib/tool-validation.ts` with a call to `validatePackageJson` from `@pptb/validation`. +- [ ] Run the existing `pptb-web` test suite to confirm no regression. +- [ ] Deploy `pptb-web`. + +### Phase 3 — Update `@pptb/types` + +- [ ] Add `@pptb/validation` as a dependency in `packages/package.json`. +- [ ] Replace `packages/lib/validate.js` with a thin re-export / proxy that delegates to `@pptb/validation`. +- [ ] Update `packages/bin/pptb-validate.js` to `require("@pptb/validation")` instead of `../lib/validate`. +- [ ] Bump and publish a new version of `@pptb/types`. + +### Phase 4 — Ongoing governance + +- [ ] Add a CI check in both `pptb-web` and `desktop-app` that fails if either repo pins an older version of `@pptb/validation` than the latest published version (optional but recommended). +- [ ] Document the update process: when validation rules change, update `@pptb/validation` first, then update the consumer packages. + +--- + +## Migration Guide (for contributors) + +When changing a validation rule: + +1. Open a PR against the `@pptb/validation` package. +2. Update the rule and its tests. +3. Publish a new version (e.g. patch for bug fixes, minor for new checks). +4. Open PRs in `pptb-web` and `desktop-app` to bump the dependency. + +Do **not** edit `pptb-web/lib/tool-validation.ts` or `packages/lib/validate.js` directly once the shared package is in place — those files should simply delegate to `@pptb/validation`. + +--- + +## Interim State + +Until `@pptb/validation` is created and both consumers are migrated, `packages/lib/validate.js` is the authoritative local copy. Any rule change in `pptb-web/lib/tool-validation.ts` **must** also be applied to `packages/lib/validate.js` manually, and vice versa. Reviewers should check both files when merging validation-related PRs. + +--- + +## Related Files + +| File | Role | +|---|---| +| `packages/lib/validate.js` | Current local copy of validation rules (in `@pptb/types`) | +| `packages/bin/pptb-validate.js` | CLI entry point; calls `validate.js` | +| `pptb-web/lib/tool-validation.ts` | Canonical server-side validation (review pipeline) | diff --git a/packages/README.md b/packages/README.md index 9f6faf49..152a18cd 100644 --- a/packages/README.md +++ b/packages/README.md @@ -1,9 +1,13 @@ # @pptb/types -TypeScript type definitions for Power Platform ToolBox APIs. +TypeScript type definitions for Power Platform ToolBox APIs, plus a built-in CLI validator that checks your tool's `package.json` against the official review criteria before you publish to npm. - [@pptb/types](#pptbtypes) - [Installation](#installation) + - [Tool Validation](#tool-validation) + - [Quick start](#quick-start) + - [CLI options](#cli-options) + - [What is validated](#what-is-validated) - [Overview](#overview) - [Usage](#usage) - [Include all type definitions](#include-all-type-definitions) @@ -40,6 +44,71 @@ TypeScript type definitions for Power Platform ToolBox APIs. npm install --save-dev @pptb/types ``` +## Tool Validation + +The `@pptb/types` package ships with a `pptb-validate` binary that validates your tool's `package.json` against the **same rules** used by the official Power Platform ToolBox review process. Running it before publishing helps you catch configuration problems early, reduces failed reviews, and avoids publishing unnecessary npm versions. + +### Quick start + +Add a script to your tool's `package.json`: + +```json +{ + "scripts": { + "validate": "pptb-validate" + } +} +``` + +Then run: + +```bash +npm run validate +``` + +You can also run it directly (no script entry needed once `@pptb/types` is installed): + +```bash +npx pptb-validate +``` + +Or point it at a specific file: + +```bash +npx pptb-validate path/to/package.json +``` + +### CLI options + +| Option | Description | +| ------------------- | ---------------------------------------------------------- | +| `--skip-url-checks` | Skip URL reachability checks (faster, works offline) | +| `--json` | Print results as a JSON object (suitable for CI pipelines) | +| `--help`, `-h` | Show help information | + +### What is validated + +The validator checks every field that the official review pipeline inspects: + +| Field | Required | Rules | +| --------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | ✅ | Must be a string | +| `version` | ✅ | Must be a string | +| `displayName` | ✅ | Must be a string | +| `description` | ✅ | Must be a string | +| `license` | ✅ | Must be one of the approved OSS 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) | +| `contributors` | ✅ | Non-empty array; each entry needs a `name` | +| `configurations.repository` | ✅ | Valid, reachable URL | +| `configurations.readmeUrl` | ✅ | Valid URL; must **not** be hosted on `github.com` (use `raw.githubusercontent.com`) | +| `configurations.website` | ❌ | Valid, reachable URL when provided | +| `configurations.funding` | ❌ | Valid, reachable URL when provided | +| `icon` | ❌ | Relative path to a `.svg` file bundled under `dist/`; must not be an HTTP URL or an absolute path | +| `cspExceptions` | ❌ | When present: must not be empty; only recognised directives; each directive must be a non-empty array | +| `features.multiConnection` | ❌\* | Required when `features` is present; must be `"required"`, `"optional"`, or `"none"` | +| `features.minAPI` | ❌ | Valid semver string when provided | + +> \* Required only when the `features` object is present. + ## Overview The `@pptb/types` package provides TypeScript definitions for two main APIs: diff --git a/packages/bin/pptb-validate.js b/packages/bin/pptb-validate.js new file mode 100755 index 00000000..cea47887 --- /dev/null +++ b/packages/bin/pptb-validate.js @@ -0,0 +1,181 @@ +#!/usr/bin/env node +// @ts-check +"use strict"; + +/** + * pptb-validate - CLI tool for validating Power Platform ToolBox tool packages + * + * Usage: + * pptb-validate [options] [path/to/package.json] + * + * Options: + * --skip-url-checks Skip URL accessibility checks (faster, offline-friendly) + * --json Output results as JSON + * --help, -h Show help information + */ + +const fs = require("fs"); +const path = require("path"); +const { validatePackageJson } = require("../lib/validate"); + +// ANSI colour helpers – gracefully degrade when colours are unsupported +const NO_COLOR = !process.stdout.isTTY || process.env.NO_COLOR; +const c = { + red: (s) => (NO_COLOR ? s : `\x1b[31m${s}\x1b[0m`), + yellow: (s) => (NO_COLOR ? s : `\x1b[33m${s}\x1b[0m`), + green: (s) => (NO_COLOR ? s : `\x1b[32m${s}\x1b[0m`), + cyan: (s) => (NO_COLOR ? s : `\x1b[36m${s}\x1b[0m`), + bold: (s) => (NO_COLOR ? s : `\x1b[1m${s}\x1b[0m`), + dim: (s) => (NO_COLOR ? s : `\x1b[2m${s}\x1b[0m`), +}; + +function printHelp() { + console.log(` +${c.bold("pptb-validate")} – Power Platform ToolBox tool validator + +${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. + +${c.bold("OPTIONS")} + ${c.cyan("--skip-url-checks")} Skip URL reachability checks (faster, works offline) + ${c.cyan("--json")} Print results as a JSON object instead of human-readable text + ${c.cyan("--help")}, ${c.cyan("-h")} Show this help message + +${c.bold("ADD TO YOUR TOOL'S package.json")} + ${c.dim(`"scripts": { + "validate": "pptb-validate" + }`)} + + Then run: ${c.cyan("npm run validate")} + +${c.bold("EXAMPLES")} + npm run validate + npm run validate ./my-tool/package.json + npm run validate --skip-url-checks + npm run validate --json +`); +} + +async function main() { + const args = process.argv.slice(2); + + if (args.includes("--help") || args.includes("-h")) { + printHelp(); + process.exit(0); + } + + const skipUrlChecks = args.includes("--skip-url-checks"); + const jsonOutput = args.includes("--json"); + + // Find the package.json path from positional args (skip flags) + const positional = args.filter((a) => !a.startsWith("-")); + let packageJsonPath = positional[0] || path.join(process.cwd(), "package.json"); + + // Resolve to absolute path + if (!path.isAbsolute(packageJsonPath)) { + packageJsonPath = path.resolve(process.cwd(), packageJsonPath); + } + + // --- Load package.json --- + if (!fs.existsSync(packageJsonPath)) { + if (jsonOutput) { + console.log(JSON.stringify({ valid: false, errors: [`package.json not found at: ${packageJsonPath}`], warnings: [] }, null, 2)); + } else { + console.error(c.red(`✖ package.json not found at: ${packageJsonPath}`)); + } + process.exit(1); + } + + let packageJson; + try { + packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (jsonOutput) { + console.log(JSON.stringify({ valid: false, errors: [`Failed to parse package.json: ${message}`], warnings: [] }, null, 2)); + } else { + console.error(c.red(`✖ Failed to parse package.json: ${message}`)); + } + process.exit(1); + } + + // --- 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 (skipUrlChecks) { + console.log(c.yellow("⚠ URL reachability checks are skipped")); + } + console.log(); + } + + let result; + try { + result = await validatePackageJson(packageJson, { skipUrlChecks }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (jsonOutput) { + console.log(JSON.stringify({ valid: false, errors: [`Unexpected validation error: ${message}`], warnings: [] }, null, 2)); + } else { + console.error(c.red(`✖ Unexpected validation error: ${message}`)); + } + process.exit(1); + } + + // --- Output results --- + if (jsonOutput) { + console.log(JSON.stringify(result, null, 2)); + process.exit(result.valid ? 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}`)); + 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}`)); + console.log(); + } + + if (result.valid) { + const info = result.packageInfo; + console.log(c.green(c.bold("✔ Validation passed"))); + console.log(); + console.log(c.bold("Package summary")); + console.log(c.dim("─".repeat(45))); + console.log(` Name : ${info.name}`); + console.log(` Version : ${info.version}`); + 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(", ")}`); + 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}` : ""}`); + } + console.log(); + } else { + console.log(c.red(c.bold("✖ Validation failed"))); + console.log(); + console.log(c.dim("Fix the errors listed above and re-run pptb-validate before publishing.")); + console.log(); + } + + process.exit(result.valid ? 0 : 1); +} + +main().catch((err) => { + console.error(c.red(`✖ Fatal error: ${err instanceof Error ? err.message : String(err)}`)); + process.exit(1); +}); diff --git a/packages/lib/validate.js b/packages/lib/validate.js new file mode 100644 index 00000000..bed83327 --- /dev/null +++ b/packages/lib/validate.js @@ -0,0 +1,324 @@ +// @ts-check +"use strict"; + +/** + * Tool validation logic for Power Platform ToolBox tools. + * Mirrors the validation rules used during the official review process. + */ + +/** @typedef {{ name: string; url?: string }} Contributor */ +/** @typedef {{ "connect-src"?: string[]; "script-src"?: string[]; "style-src"?: string[]; "img-src"?: string[]; "font-src"?: string[]; "frame-src"?: string[]; "media-src"?: string[] }} CspExceptions */ +/** @typedef {{ repository?: string; website?: string; funding?: string; readmeUrl?: string }} Configurations */ +/** @typedef {{ multiConnection?: "required" | "optional" | "none"; minAPI?: string }} Features */ +/** + * @typedef {{ + * name: string; + * version: string; + * displayName?: string; + * description?: string; + * contributors?: Contributor[]; + * cspExceptions?: CspExceptions; + * license?: string; + * icon?: string; + * configurations?: Configurations; + * features?: Features; + * }} ToolPackageJson + */ +/** + * @typedef {{ + * valid: boolean; + * errors: string[]; + * warnings: string[]; + * packageInfo?: object; + * }} ValidationResult + */ + +// 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"]; + +// Valid multiConnection values +const VALID_MULTI_CONNECTION_VALUES = ["required", "optional", "none"]; + +// Semver regex for minAPI validation +const SEMVER_REGEX = /^\d+\.\d+\.\d+(-[0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*)?(\+[0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*)?$/; + +/** + * Checks if a string is a valid URL. + * @param {string} url + * @returns {boolean} + */ +function isValidUrl(url) { + try { + new URL(url); + return true; + } catch { + return false; + } +} + +/** + * Checks if a URL hostname is a GitHub domain. + * @param {string} url + * @returns {boolean} + */ +function isGithubDomain(url) { + try { + const hostname = new URL(url).hostname.toLowerCase(); + return hostname === "github.com" || hostname.endsWith(".github.com"); + } catch { + return false; + } +} + +/** + * Validates an icon path string. + * @param {string} fieldName + * @param {string} iconPath + * @param {string[]} errors + */ +function validateIconPath(fieldName, iconPath, errors) { + if (iconPath.startsWith("http://") || iconPath.startsWith("https://")) { + errors.push(`${fieldName} cannot be an HTTP(S) URL - icons must be bundled under dist`); + return; + } + if (iconPath.startsWith("/")) { + errors.push(`${fieldName} must be a relative path (e.g., 'icon.svg' or 'icons/icon.svg')`); + return; + } + // Reject Windows absolute paths like "C:\..." or "C:/" + if (/^[a-zA-Z]:/.test(iconPath)) { + errors.push(`${fieldName} must be a relative path (e.g., 'icon.svg' or 'icons/icon.svg')`); + return; + } + // Reject backslashes — icon paths must use forward slashes + if (iconPath.includes("\\")) { + errors.push(`${fieldName} must use forward slashes, not backslashes (e.g., 'icons/icon.svg')`); + return; + } + if (iconPath.includes("..")) { + errors.push(`${fieldName} cannot contain '..' (path traversal not allowed)`); + return; + } + if (!iconPath.toLowerCase().endsWith(".svg")) { + errors.push(`${fieldName} must be an SVG file with .svg extension`); + } +} + +/** + * Checks if a URL is accessible by making a HEAD request. + * @param {string} url + * @returns {Promise} + */ +async function isUrlAccessible(url) { + try { + const response = await fetch(url, { method: "HEAD", redirect: "follow" }); + return response.ok; + } catch { + return false; + } +} + +/** + * Validates a tool's package.json against the official review criteria. + * + * @param {ToolPackageJson} packageJson - The parsed package.json object. + * @param {{ skipUrlChecks?: boolean }} [options] - Validation options. + * @returns {Promise} + */ +async function validatePackageJson(packageJson, options = {}) { + const { skipUrlChecks = false } = options; + const errors = /** @type {string[]} */ ([]); + const warnings = /** @type {string[]} */ ([]); + + // Required fields + if (!packageJson.name || typeof packageJson.name !== "string") { + errors.push("Package name is required and must be a string"); + } + + if (!packageJson.version || typeof packageJson.version !== "string") { + errors.push("Package version is required and must be a string"); + } + + if (!packageJson.displayName || typeof packageJson.displayName !== "string") { + errors.push("displayName is required and must be a string"); + } + + if (!packageJson.description || typeof packageJson.description !== "string") { + errors.push("description is required and must be a string"); + } + + // License validation + if (!packageJson.license) { + errors.push("license is required"); + } else if (!APPROVED_LICENSES.includes(packageJson.license)) { + errors.push(`License "${packageJson.license}" is not in the approved list. Approved licenses: ${APPROVED_LICENSES.join(", ")}`); + } + + // Icon validation (optional, but must be a relative SVG path if provided) + if (packageJson.icon === undefined || packageJson.icon === null) { + warnings.push("icon is not set; consider adding a bundled SVG icon so your tool displays properly in the marketplace"); + } else if (typeof packageJson.icon !== "string") { + errors.push("icon must be a string (relative path to bundled SVG under dist)"); + } else { + validateIconPath("icon", packageJson.icon, errors); + } + + // Contributors validation + if (!packageJson.contributors || !Array.isArray(packageJson.contributors)) { + errors.push("contributors is required and must be an array"); + } else if (packageJson.contributors.length === 0) { + errors.push("At least one contributor is required"); + } else { + packageJson.contributors.forEach((contributor, index) => { + if (!contributor.name || typeof contributor.name !== "string") { + errors.push(`Contributor at index ${index} must have a name`); + } + if (contributor.url && !isValidUrl(contributor.url)) { + warnings.push(`Contributor "${contributor.name}" has an invalid URL`); + } + }); + } + + // Configurations validation + if (!packageJson.configurations || typeof packageJson.configurations !== "object") { + errors.push("configurations is required and must include repository and readmeUrl"); + } else { + const configs = packageJson.configurations; + + // configurations.iconUrl is no longer supported + if (/** @type {Record} */ (configs).iconUrl !== undefined) { + errors.push("configurations.iconUrl is no longer supported; use top-level 'icon' for bundled SVG path"); + } + + // Repository validation + if (!configs.repository || typeof configs.repository !== "string") { + errors.push("configurations.repository is required and must be a URL"); + } else if (!isValidUrl(configs.repository)) { + errors.push("configurations.repository has an invalid URL format"); + } else if (!skipUrlChecks) { + const accessible = await isUrlAccessible(configs.repository); + if (!accessible) { + errors.push("configurations.repository URL is not accessible"); + } + } + + // Website validation (optional but recommended) + if (!configs.website) { + warnings.push("configurations.website is not set; consider adding a URL where users can learn more about your tool"); + } else if (!isValidUrl(configs.website)) { + warnings.push("configurations.website has an invalid URL format"); + } else if (!skipUrlChecks) { + const accessible = await isUrlAccessible(configs.website); + if (!accessible) { + warnings.push("configurations.website URL is not accessible"); + } + } + + // Funding validation (optional but recommended) + if (configs.funding) { + if (!isValidUrl(configs.funding)) { + warnings.push("configurations.funding has an invalid URL format"); + } else if (!skipUrlChecks) { + const accessible = await isUrlAccessible(configs.funding); + if (!accessible) { + warnings.push("configurations.funding URL is not accessible"); + } + } + } + + // ReadmeUrl validation + if (!configs.readmeUrl || typeof configs.readmeUrl !== "string") { + errors.push("configurations.readmeUrl is required and must be a URL"); + } else if (!isValidUrl(configs.readmeUrl)) { + errors.push("configurations.readmeUrl has an invalid URL format"); + } else if (isGithubDomain(configs.readmeUrl)) { + errors.push("configurations.readmeUrl cannot be hosted on github.com; use raw.githubusercontent.com or another domain"); + } else if (!skipUrlChecks) { + const accessible = await isUrlAccessible(configs.readmeUrl); + if (!accessible) { + errors.push("configurations.readmeUrl is not accessible"); + } + } + } + + // CSP Exceptions validation (optional, but validated if present) + if (packageJson.cspExceptions) { + const cspExceptions = packageJson.cspExceptions; + + if (typeof cspExceptions !== "object" || cspExceptions === null || Array.isArray(cspExceptions)) { + errors.push("cspExceptions must be an object mapping CSP directives to arrays of strings"); + } else { + const validCspDirectives = ["connect-src", "script-src", "style-src", "img-src", "font-src", "frame-src", "media-src"]; + + const hasAnyDirectives = Object.keys(cspExceptions).length > 0; + if (!hasAnyDirectives) { + errors.push("cspExceptions cannot be empty. If CSP exceptions are not needed, remove the cspExceptions field"); + } + + Object.keys(cspExceptions).forEach((directive) => { + if (!validCspDirectives.includes(directive)) { + warnings.push(`Unknown CSP directive: ${directive}`); + } + const values = cspExceptions[/** @type {keyof CspExceptions} */ (directive)]; + if (values && !Array.isArray(values)) { + errors.push(`CSP directive "${directive}" must be an array of strings`); + } else if (values && values.length === 0) { + errors.push(`CSP directive "${directive}" cannot be an empty array`); + } + }); + } + } + + // Features validation (optional, but validated if present) + if (packageJson.features !== undefined) { + const features = packageJson.features; + + if (features === null || typeof features !== "object" || Array.isArray(features)) { + errors.push("features must be a non-array object with optional 'multiConnection' and 'minAPI' properties"); + } else { + const VALID_FEATURE_KEYS = ["multiConnection", "minAPI"]; + const featureKeys = Object.keys(features); + const invalidKeys = featureKeys.filter((key) => !VALID_FEATURE_KEYS.includes(key)); + + if (invalidKeys.length > 0) { + errors.push(`features can only contain ${VALID_FEATURE_KEYS.map((k) => `'${k}'`).join(", ")} properties. Invalid properties: ${invalidKeys.join(", ")}`); + } + + if (features.multiConnection === undefined) { + errors.push("features.multiConnection is required when features object is provided"); + } else if (!VALID_MULTI_CONNECTION_VALUES.includes(features.multiConnection)) { + errors.push(`features.multiConnection must be one of: ${VALID_MULTI_CONNECTION_VALUES.join(", ")}`); + } + + if (features.minAPI !== undefined) { + if (typeof features.minAPI !== "string" || !SEMVER_REGEX.test(features.minAPI)) { + errors.push("features.minAPI must be a valid semantic version string (e.g., '1.0.0')"); + } + } + } + } + + const valid = errors.length === 0; + + return { + valid, + errors, + warnings, + packageInfo: valid + ? { + name: packageJson.name, + version: packageJson.version, + displayName: packageJson.displayName, + description: packageJson.description, + license: packageJson.license, + contributors: packageJson.contributors, + cspExceptions: packageJson.cspExceptions, + icon: packageJson.icon, + configurations: packageJson.configurations, + features: packageJson.features, + } + : undefined, + }; +} + +module.exports = { validatePackageJson, isValidUrl, APPROVED_LICENSES }; diff --git a/packages/package.json b/packages/package.json index 57839383..1cf1a36d 100644 --- a/packages/package.json +++ b/packages/package.json @@ -1,7 +1,7 @@ { "name": "@pptb/types", - "version": "1.0.20", - "description": "TypeScript type definitions for Power Platform ToolBox API", + "version": "1.1.3-beta.2", + "description": "Type definitions for Power Platform ToolBox APIs and validity checks for tool packages", "main": "index.d.ts", "types": "index.d.ts", "keywords": [ @@ -19,10 +19,21 @@ "url": "https://github.com/PowerPlatformToolBox/desktop-app.git", "directory": "packages" }, + "bin": { + "pptb-validate": "./bin/pptb-validate.js" + }, + "files": [ + "index.d.ts", + "toolboxAPI.d.ts", + "dataverseAPI.d.ts", + "bin/", + "lib/", + "README.md" + ], "scripts": { "version:beta": "pnpm version prerelease --preid beta --no-git-tag-version", "version:stable": "pnpm version patch --no-git-tag-version", "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 +} From aebd2a8edbcae9eed71f81e85f4f784258ee0d0a Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 21:26:49 -0500 Subject: [PATCH 047/257] feat: Allow toolmakers to explain why they need CSP exceptions with selective optional domain consent (#431) * Initial plan * Allow toolmakers to explain why they need CSP exceptions Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Split CSP modal into required/optional sections with selective opt-in checkboxes Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Store required domains in CSP consent record alongside optional domains Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Consolidate CSP consent storage and add disabled checkboxes for required domains Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * fix: update CSP documentation link in exception modal * Update src/renderer/modals/cspException/view.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/renderer/modules/toolManagement.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: enhance CSP exception domain display with additional styling --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> Co-authored-by: Power-Maverick Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/common/types/api.ts | 6 +- src/common/types/common.ts | 44 +++- src/common/types/settings.ts | 13 +- src/main/index.ts | 4 +- .../managers/browserviewProtocolManager.ts | 13 +- src/main/managers/settingsManager.ts | 31 ++- src/main/preload.ts | 2 +- .../modals/cspException/controller.ts | 15 +- src/renderer/modals/cspException/view.ts | 213 +++++++++++++++--- src/renderer/modules/cspExceptionModal.ts | 29 ++- src/renderer/modules/toolManagement.ts | 25 +- 11 files changed, 324 insertions(+), 71 deletions(-) diff --git a/src/common/types/api.ts b/src/common/types/api.ts index f126ac57..dbf5633e 100644 --- a/src/common/types/api.ts +++ b/src/common/types/api.ts @@ -6,7 +6,7 @@ import { FileDialogFilter, ModalWindowMessagePayload, ModalWindowOptions, SelectPathOptions, Theme } from "./common"; import { DataverseConnection } from "./connection"; import { DataverseExecuteRequest } from "./dataverse"; -import { LastUsedToolEntry, LastUsedToolUpdate, UserSettings } from "./settings"; +import { CspConsentRecord, LastUsedToolEntry, LastUsedToolUpdate, UserSettings } from "./settings"; import { Terminal, TerminalOptions } from "./terminal"; import { Tool, ToolContext, ToolSettings } from "./tool"; @@ -137,9 +137,9 @@ export interface ToolboxAPI { // CSP consent management hasCspConsent: (toolId: string) => Promise; - grantCspConsent: (toolId: string) => Promise; + grantCspConsent: (toolId: string, requiredDomains?: string[], approvedOptionalDomains?: string[]) => Promise; revokeCspConsent: (toolId: string) => Promise; - getCspConsents: () => Promise<{ [toolId: string]: boolean }>; + getCspConsents: () => Promise<{ [toolId: string]: CspConsentRecord }>; // Webview URL generation getToolWebviewUrl: (toolId: string) => Promise; diff --git a/src/common/types/common.ts b/src/common/types/common.ts index b1515100..ed09ec5e 100644 --- a/src/common/types/common.ts +++ b/src/common/types/common.ts @@ -2,18 +2,46 @@ * Common types shared across the application */ +/** + * Extended CSP exception entry that allows tool developers to explain why they need the exception + */ +export interface CspExceptionEntry { + /** The domain or source expression being allowed (e.g. "api.example.com") */ + domain: string; + /** Markdown-formatted explanation of why this domain is needed */ + exceptionReason?: string; + /** Whether this exception is optional (tool still functions without it) */ + optional?: boolean; +} + +/** + * A CSP exception source can be a plain domain string (legacy) or a detailed entry object + */ +export type CspExceptionSource = string | CspExceptionEntry; + +/** + * Normalize a CspExceptionSource to a CspExceptionEntry object + */ +export function normalizeCspExceptionSource(source: CspExceptionSource): CspExceptionEntry { + if (typeof source === "string") { + return { domain: source }; + } + return source; +} + /** * CSP (Content Security Policy) exceptions for a tool - * Allows tools to specify which external resources they need to access + * 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. */ export interface CspExceptions { - "connect-src"?: string[]; - "script-src"?: string[]; - "style-src"?: string[]; - "img-src"?: string[]; - "font-src"?: string[]; - "frame-src"?: string[]; - "media-src"?: string[]; + "connect-src"?: CspExceptionSource[]; + "script-src"?: CspExceptionSource[]; + "style-src"?: CspExceptionSource[]; + "img-src"?: CspExceptionSource[]; + "font-src"?: CspExceptionSource[]; + "frame-src"?: CspExceptionSource[]; + "media-src"?: CspExceptionSource[]; } /** diff --git a/src/common/types/settings.ts b/src/common/types/settings.ts index 10df81c7..51a5cc89 100644 --- a/src/common/types/settings.ts +++ b/src/common/types/settings.ts @@ -51,6 +51,17 @@ export interface LastUsedToolUpdate { lastUsedAt?: string; } +/** + * Per-tool CSP consent record. + * Stores whether consent was granted, and which required/optional domains were + * present at the time of consent (used for future re-consent detection). + */ +export interface CspConsentRecord { + allowed: boolean; + required: string[]; + optional: string[]; +} + /** * User settings for the ToolBox application */ @@ -67,7 +78,7 @@ export interface UserSettings { connections: DataverseConnection[]; installedTools: string[]; // List of installed tool package names favoriteTools: string[]; // List of favorite tool IDs - cspConsents: { [toolId: string]: boolean }; // Track CSP consent for each tool + cspConsents: { [toolId: string]: CspConsentRecord }; // CSP consent records per tool toolConnections: { [toolId: string]: string }; // Map of toolId to connectionId toolSecondaryConnections: { [toolId: string]: string }; // Map of toolId to secondary connectionId for multi-connection tools installId?: string; // Unique install identifier for analytics diff --git a/src/main/index.ts b/src/main/index.ts index e7eaef1c..ee03f9be 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -895,8 +895,8 @@ class ToolBoxApp { return this.settingsManager.hasCspConsent(toolId); }); - ipcMain.handle(SETTINGS_CHANNELS.GRANT_CSP_CONSENT, (_, toolId) => { - this.settingsManager.grantCspConsent(toolId); + ipcMain.handle(SETTINGS_CHANNELS.GRANT_CSP_CONSENT, (_, toolId, requiredDomains?: string[], approvedOptionalDomains?: string[]) => { + this.settingsManager.grantCspConsent(toolId, requiredDomains, approvedOptionalDomains); }); ipcMain.handle(SETTINGS_CHANNELS.REVOKE_CSP_CONSENT, (_, toolId) => { diff --git a/src/main/managers/browserviewProtocolManager.ts b/src/main/managers/browserviewProtocolManager.ts index 8a2c3ecc..b188b19a 100644 --- a/src/main/managers/browserviewProtocolManager.ts +++ b/src/main/managers/browserviewProtocolManager.ts @@ -2,6 +2,7 @@ import { app, protocol } from "electron"; import * as fs from "fs"; import * as path from "path"; import { captureMessage, logInfo } from "../../common/sentryHelper"; +import { normalizeCspExceptionSource } from "../../common/types"; import { SettingsManager } from "./settingsManager"; import { ToolManager } from "./toolsManager"; @@ -280,6 +281,9 @@ export class BrowserviewProtocolManager { // Only apply CSP exceptions if consent is granted const cspExceptions = hasConsent ? tool.cspExceptions || {} : {}; + // Get the set of optional domains the user has explicitly approved + const approvedOptionalDomains = new Set(hasConsent ? this.settingsManager.getApprovedOptionalDomains(tool.id) : []); + // Default CSP directives for tools const directives: { [key: string]: string[] } = { "default-src": ["'self'"], @@ -296,7 +300,14 @@ export class BrowserviewProtocolManager { if (!directives[directive]) { directives[directive] = ["'self'"]; } - directives[directive].push(...sources); + for (const s of sources) { + const entry = normalizeCspExceptionSource(s); + // Skip optional domains that were not approved by the user + if (entry.optional && !approvedOptionalDomains.has(entry.domain)) { + continue; + } + directives[directive].push(entry.domain); + } } } diff --git a/src/main/managers/settingsManager.ts b/src/main/managers/settingsManager.ts index 44d917b0..844c2e08 100644 --- a/src/main/managers/settingsManager.ts +++ b/src/main/managers/settingsManager.ts @@ -1,5 +1,5 @@ import Store from "electron-store"; -import { LastUsedToolConnectionInfo, LastUsedToolEntry, LastUsedToolUpdate, ToolSettings, UserSettings } from "../../common/types"; +import { CspConsentRecord, LastUsedToolConnectionInfo, LastUsedToolEntry, LastUsedToolUpdate, ToolSettings, UserSettings } from "../../common/types"; /** * Manages user settings using electron-store @@ -168,15 +168,18 @@ export class SettingsManager { */ hasCspConsent(toolId: string): boolean { const cspConsents = this.store.get("cspConsents") || {}; - return cspConsents[toolId] === true; + return cspConsents[toolId]?.allowed === true; } /** * Grant CSP consent for a tool + * @param toolId - The tool ID + * @param requiredDomains - The required (non-optional) domains at the time of consent + * @param approvedOptionalDomains - Optional domains approved by the user (empty means none approved) */ - grantCspConsent(toolId: string): void { + grantCspConsent(toolId: string, requiredDomains: string[] = [], approvedOptionalDomains: string[] = []): void { const cspConsents = this.store.get("cspConsents") || {}; - cspConsents[toolId] = true; + cspConsents[toolId] = { allowed: true, required: requiredDomains, optional: approvedOptionalDomains }; this.store.set("cspConsents", cspConsents); } @@ -190,12 +193,28 @@ export class SettingsManager { } /** - * Get all tools with CSP consent + * Get all tools with CSP consent (keyed by tool ID) */ - getCspConsents(): { [toolId: string]: boolean } { + getCspConsents(): { [toolId: string]: CspConsentRecord } { return this.store.get("cspConsents") || {}; } + /** + * Get the list of required domains that were consented to for a tool + */ + getApprovedRequiredDomains(toolId: string): string[] { + const cspConsents = this.store.get("cspConsents") || {}; + return cspConsents[toolId]?.required ?? []; + } + + /** + * Get the list of approved optional domains for a tool + */ + getApprovedOptionalDomains(toolId: string): string[] { + const cspConsents = this.store.get("cspConsents") || {}; + return cspConsents[toolId]?.optional ?? []; + } + /** * Set connection for a specific tool */ diff --git a/src/main/preload.ts b/src/main/preload.ts index 5cdd5a5c..d1cbc79e 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -85,7 +85,7 @@ contextBridge.exposeInMainWorld("toolboxAPI", { // CSP consent management - Only for PPTB UI hasCspConsent: (toolId: string) => ipcRenderer.invoke(SETTINGS_CHANNELS.HAS_CSP_CONSENT, toolId), - grantCspConsent: (toolId: string) => ipcRenderer.invoke(SETTINGS_CHANNELS.GRANT_CSP_CONSENT, toolId), + grantCspConsent: (toolId: string, requiredDomains?: string[], approvedOptionalDomains?: string[]) => ipcRenderer.invoke(SETTINGS_CHANNELS.GRANT_CSP_CONSENT, toolId, requiredDomains, approvedOptionalDomains), revokeCspConsent: (toolId: string) => ipcRenderer.invoke(SETTINGS_CHANNELS.REVOKE_CSP_CONSENT, toolId), getCspConsents: () => ipcRenderer.invoke(SETTINGS_CHANNELS.GET_CSP_CONSENTS), diff --git a/src/renderer/modals/cspException/controller.ts b/src/renderer/modals/cspException/controller.ts index 83cf59a7..8f67f850 100644 --- a/src/renderer/modals/cspException/controller.ts +++ b/src/renderer/modals/cspException/controller.ts @@ -1,3 +1,5 @@ +import type { CspExceptionSource } from "../../../common/types"; + export interface CspExceptionModalChannelIds { acceptConsent: string; declineConsent: string; @@ -7,7 +9,7 @@ export interface CspExceptionData { toolName: string; authors: string[]; cspExceptions: { - [directive: string]: string[]; + [directive: string]: CspExceptionSource[]; }; } @@ -29,9 +31,16 @@ export function getCspExceptionModalControllerScript(channels: CspExceptionModal const acceptButton = document.getElementById("csp-accept-btn"); const declineButton = document.getElementById("csp-decline-btn"); - // Handle accept button + // Handle accept button — collect which optional (non-disabled) domains are checked acceptButton?.addEventListener('click', () => { - modalBridge.send(CHANNELS.acceptConsent, {}); + const checkboxes = document.querySelectorAll('.csp-optional-checkbox:not([disabled])'); + const approvedOptionalDomains = []; + checkboxes.forEach((cb) => { + if (cb.checked) { + approvedOptionalDomains.push(cb.value); + } + }); + modalBridge.send(CHANNELS.acceptConsent, { approvedOptionalDomains }); }); // Handle decline button diff --git a/src/renderer/modals/cspException/view.ts b/src/renderer/modals/cspException/view.ts index 29621bdc..57813283 100644 --- a/src/renderer/modals/cspException/view.ts +++ b/src/renderer/modals/cspException/view.ts @@ -1,3 +1,4 @@ +import { type CspExceptionSource, normalizeCspExceptionSource } from "../../../common/types"; import { escapeHtml } from "../../utils/toolIconResolver"; import { getModalStyles } from "../sharedStyles"; @@ -9,28 +10,115 @@ export interface ModalViewTemplate { export interface CspExceptionModalViewModel { toolName: string; authors: string[]; - cspExceptions: { [directive: string]: string[] }; + cspExceptions: { [directive: string]: CspExceptionSource[] }; isDarkTheme: boolean; } +/** + * Render a subset of inline Markdown to safe HTML. + * Supports: **bold**, *italic*, `inline code`. + * All text is HTML-escaped first to prevent injection. + */ +function renderMarkdownInline(text: string): string { + let result = escapeHtml(text); + // Bold: **text** (non-greedy, processed before italic) + result = result.replace(/\*\*(.+?)\*\*/g, "$1"); + // Italic: *text* — use lookahead/lookbehind to avoid matching ** bold markers + result = result.replace(/(?$1"); + // Inline code: `text` + result = result.replace(/`([^`\n]+)`/g, "$1"); + return result; +} + /** * Returns the view markup (styles + body) for the CSP exception modal BrowserWindow. + * Required and optional exceptions are shown in separate sections. + * Optional exceptions have checkboxes so the user can selectively approve them. */ export function getCspExceptionModalView(model: CspExceptionModalViewModel): ModalViewTemplate { const isDarkTheme = model.isDarkTheme; const authorsList = model.authors && model.authors.length ? model.authors.join(", ") : "Unknown"; - // Build flat list of unique CSP source expressions across all directives - const allSources = new Set(); + // Build flat map of unique CSP source entries across all directives, keyed by domain + const allEntries = new Map(); for (const sources of Object.values(model.cspExceptions)) { if (Array.isArray(sources)) { - sources.forEach((source: string) => allSources.add(source)); + sources.forEach((source: CspExceptionSource) => { + const entry = normalizeCspExceptionSource(source); + const existing = allEntries.get(entry.domain); + if (!existing) { + allEntries.set(entry.domain, entry); + } else { + // Merge duplicate domains deterministically: + // - Treat as required if any occurrence is required. + // - Prefer non-empty exception reasons, combining if they differ. + const mergedOptional = (existing.optional ?? false) && (entry.optional ?? false) ? true : undefined; + let mergedReason: string | undefined; + const existingReason = existing.exceptionReason && existing.exceptionReason.trim().length > 0 ? existing.exceptionReason : undefined; + const newReason = entry.exceptionReason && entry.exceptionReason.trim().length > 0 ? entry.exceptionReason : undefined; + if (existingReason && newReason && existingReason !== newReason) { + mergedReason = `${existingReason}\n\n${newReason}`; + } else { + mergedReason = existingReason ?? newReason; + } + allEntries.set(entry.domain, { + ...existing, + ...entry, + optional: mergedOptional, + exceptionReason: mergedReason, + }); + } + }); } } - const exceptionsHtml = Array.from(allSources) - .map((source: string) => `
  • ${escapeHtml(source)}
  • `) - .join(""); + + const requiredEntries = Array.from(allEntries.values()).filter((e) => !e.optional); + const optionalEntries = Array.from(allEntries.values()).filter((e) => e.optional); + + const renderEntryItem = (entry: { domain: string; exceptionReason?: string }, isCheckbox = false, isDisabled = false): string => { + const domainHtml = `${escapeHtml(entry.domain)}`; + const reasonHtml = entry.exceptionReason ? `
    ${renderMarkdownInline(entry.exceptionReason)}
    ` : ""; + if (isCheckbox) { + const disabledAttr = isDisabled ? " disabled" : ""; + const itemClass = isDisabled ? "csp-optional-item csp-required-item" : "csp-optional-item"; + return ` +
  • + +
  • `; + } + return `
  • ${domainHtml}${reasonHtml}
  • `; + }; + + const requiredHtml = requiredEntries.map((e) => renderEntryItem(e, true, true)).join(""); + const optionalHtml = optionalEntries.map((e) => renderEntryItem(e, true, false)).join(""); + + const requiredSectionHtml = + requiredEntries.length > 0 + ? ` +
    Required
    +
    +
      ${requiredHtml}
    +
    ` + : ""; + + const optionalSectionHtml = + optionalEntries.length > 0 + ? ` +
    + Optional + Uncheck any you do not want to allow +
    +
    +
      ${optionalHtml}
    +
    ` + : ""; const styles = getModalStyles(isDarkTheme) + @@ -62,25 +150,56 @@ export function getCspExceptionModalView(model: CspExceptionModalViewModel): Mod margin-top: 4px; } + .csp-section-label { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: ${isDarkTheme ? "rgba(255, 255, 255, 0.5)" : "rgba(0, 0, 0, 0.45)"}; + margin-bottom: 6px; + margin-top: 12px; + display: flex; + align-items: center; + gap: 8px; + } + + .csp-section-label-optional { + color: ${isDarkTheme ? "rgba(76, 194, 255, 0.8)" : "rgba(0, 110, 200, 0.8)"}; + } + + .csp-section-sublabel { + font-size: 10px; + font-weight: 400; + text-transform: none; + letter-spacing: 0; + color: ${isDarkTheme ? "rgba(255, 255, 255, 0.45)" : "rgba(0, 0, 0, 0.4)"}; + } + .csp-exceptions-list { background: ${isDarkTheme ? "rgba(255, 255, 255, 0.03)" : "rgba(0, 0, 0, 0.03)"}; border: 1px solid ${isDarkTheme ? "rgba(255, 255, 255, 0.08)" : "rgba(0, 0, 0, 0.08)"}; border-radius: 8px; - padding: 12px 16px; - margin: 16px 0; - max-height: 300px; + padding: 8px 12px; + margin-bottom: 4px; + max-height: 160px; overflow-y: auto; } + .csp-exceptions-list-optional { + border-color: ${isDarkTheme ? "rgba(76, 194, 255, 0.2)" : "rgba(0, 110, 200, 0.2)"}; + } + .csp-exceptions-list ul { margin: 0; padding-left: 20px; + list-style: none; } .csp-exceptions-list li { - margin: 4px 0; + margin: 6px 0; font-size: 13px; color: ${isDarkTheme ? "rgba(255, 255, 255, 0.8)" : "rgba(0, 0, 0, 0.8)"}; + padding-left: 0; } .csp-exceptions-list code { @@ -93,6 +212,42 @@ export function getCspExceptionModalView(model: CspExceptionModalViewModel): Mod color: ${isDarkTheme ? "#f48771" : "#d84315"}; } + .csp-optional-item { + list-style: none; + padding-left: 0; + } + + .csp-optional-label { + display: flex; + align-items: flex-start; + gap: 8px; + cursor: pointer; + } + + .csp-optional-checkbox { + margin-top: 3px; + flex-shrink: 0; + width: 14px; + height: 14px; + cursor: pointer; + accent-color: #0e639c; + } + + .csp-required-item .csp-optional-checkbox { + cursor: not-allowed; + opacity: 0.6; + } + + .csp-required-item .csp-optional-label { + cursor: default; + } + + .csp-optional-content { + display: flex; + flex-direction: column; + gap: 2px; + } + .csp-warning { background: rgba(255, 185, 0, 0.1); border: 1px solid rgba(255, 185, 0, 0.3); @@ -116,19 +271,23 @@ export function getCspExceptionModalView(model: CspExceptionModalViewModel): Mod color: #ffb900; } - .csp-warning ul { - margin: 0; - padding-left: 20px; - color: ${isDarkTheme ? "rgba(255, 255, 255, 0.8)" : "rgba(0, 0, 0, 0.8)"}; + .csp-learn-more { + color: #4cc2ff; } - .csp-warning li { - margin: 4px 0; - font-size: 13px; + .csp-exception-domain-code { + width: fit-content; } - .csp-learn-more { - color: #4cc2ff; + .csp-exception-reason { + font-size: 12px; + color: ${isDarkTheme ? "rgba(255, 255, 255, 0.55)" : "rgba(0, 0, 0, 0.55)"}; + margin-top: 2px; + line-height: 1.4; + } + + .csp-exception-reason code { + font-size: 11px; } `; @@ -145,22 +304,16 @@ export function getCspExceptionModalView(model: CspExceptionModalViewModel): Mod ${escapeHtml(model.toolName)} by ${escapeHtml(authorsList)} wants to connect to websites outside this application.

    -

    - Only allow if you trust this tool and the author(s) who created it. - Allowing access means this tool can download information, load content, and communicate with the listed websites: -

    -
    -
      - ${exceptionsHtml} -
    -
    +

    Only allow if you trust this tool and the author(s) who created it.

    + ${requiredSectionHtml} + ${optionalSectionHtml}

    ⚠️ Only allow if you trust this tool.

    If you are unsure, decline and check the tool's documentation or contact its author before proceeding. - Learn more about website permissions. + Learn more about website permissions.

    diff --git a/src/renderer/modules/cspExceptionModal.ts b/src/renderer/modules/cspExceptionModal.ts index f6750e82..0164d197 100644 --- a/src/renderer/modules/cspExceptionModal.ts +++ b/src/renderer/modules/cspExceptionModal.ts @@ -9,7 +9,7 @@ import { getCspExceptionModalView } from "../modals/cspException/view"; import { closeBrowserWindowModal, offBrowserWindowModalClosed, onBrowserWindowModalClosed, onBrowserWindowModalMessage, showBrowserWindowModal } from "./browserWindowModals"; interface CspExceptionModalPromiseHandlers { - resolve: ((granted: boolean) => void) | null; + resolve: ((approvedOptionalDomains: string[] | null) => void) | null; reject: ((error: Error) => void) | null; } @@ -20,7 +20,7 @@ const CSP_EXCEPTION_MODAL_CHANNELS = { const CSP_EXCEPTION_MODAL_DIMENSIONS = { width: 600, - height: 580, + height: 620, }; let cspExceptionModalHandlersRegistered = false; @@ -31,10 +31,11 @@ const cspExceptionModalPromiseHandlers: CspExceptionModalPromiseHandlers = { let cspExceptionModalClosedHandler: ((payload: ModalWindowClosedPayload) => void) | null = null; /** - * Open the CSP exception consent modal - * Returns a promise that resolves with true if user accepts, false if declines + * Open the CSP exception consent modal. + * Returns a promise that resolves with the list of approved optional domains if the user accepts, + * or null if the user declines. */ -export async function openCspExceptionModal(tool: any): Promise { +export async function openCspExceptionModal(tool: any): Promise { return new Promise((resolve, reject) => { initializeCspExceptionModalBridge(); @@ -81,7 +82,7 @@ function handleCspExceptionModalMessage(payload: ModalWindowMessagePayload): voi switch (payload.channel) { case CSP_EXCEPTION_MODAL_CHANNELS.acceptConsent: - handleCspConsentAccepted(); + handleCspConsentAccepted(payload.data); break; case CSP_EXCEPTION_MODAL_CHANNELS.declineConsent: handleCspConsentDeclined(); @@ -92,15 +93,23 @@ function handleCspExceptionModalMessage(payload: ModalWindowMessagePayload): voi } /** - * Handle accept consent action + * Handle accept consent action with selected optional domains */ -function handleCspConsentAccepted(): void { +function handleCspConsentAccepted(data: unknown): void { if (!cspExceptionModalPromiseHandlers.resolve) return; + interface CspConsentData { + approvedOptionalDomains?: unknown[]; + } + const consentData = data as CspConsentData; + const approvedOptionalDomains: string[] = Array.isArray(consentData?.approvedOptionalDomains) + ? consentData.approvedOptionalDomains.filter((d): d is string => typeof d === "string") + : []; + const resolveHandler = cspExceptionModalPromiseHandlers.resolve; cleanupModalHandlers(); void closeBrowserWindowModal(); - resolveHandler(true); + resolveHandler(approvedOptionalDomains); } /** @@ -112,7 +121,7 @@ function handleCspConsentDeclined(): void { const resolveHandler = cspExceptionModalPromiseHandlers.resolve; cleanupModalHandlers(); void closeBrowserWindowModal(); - resolveHandler(false); + resolveHandler(null); } /** diff --git a/src/renderer/modules/toolManagement.ts b/src/renderer/modules/toolManagement.ts index 10b3839b..44ab224d 100644 --- a/src/renderer/modules/toolManagement.ts +++ b/src/renderer/modules/toolManagement.ts @@ -5,6 +5,7 @@ import { captureException, captureMessage, logInfo, logWarn } from "../../common/sentryHelper"; import type { DataverseConnection } from "../../common/types/connection"; +import { normalizeCspExceptionSource, type CspExceptionSource } from "../../common/types"; import type { OpenTool, SessionData } from "../types/index"; import { getUnsupportedRequirement, getUnsupportedToolMessage } from "../utils/toolCompatibility"; import { openSelectConnectionModal, openSelectMultiConnectionModal } from "./connectionManagement"; @@ -211,15 +212,15 @@ export async function launchTool(toolId: string, options?: LaunchToolOptions): P if (!hasConsent) { // Show consent dialog using BrowserWindow modal framework - let consentGranted = false; + let approvedOptionalDomains: string[] | null = null; try { - consentGranted = await openCspExceptionModal(tool); + approvedOptionalDomains = await openCspExceptionModal(tool); } catch (error) { logInfo("CSP consent modal closed without selection:", { error }); - consentGranted = false; + approvedOptionalDomains = null; } - if (!consentGranted) { + if (approvedOptionalDomains === null) { // User declined or closed, don't load the tool window.toolboxAPI.utils.showNotification({ title: "Tool Launch Cancelled", @@ -229,8 +230,20 @@ export async function launchTool(toolId: string, options?: LaunchToolOptions): P return; } - // Grant consent - await window.toolboxAPI.grantCspConsent(tool.id); + // Grant consent — store required domains (for future re-consent detection) and selected optional domains + const requiredDomainsSet = new Set(); + for (const sources of Object.values(tool.cspExceptions as Record)) { + if (Array.isArray(sources)) { + for (const s of sources) { + const entry = normalizeCspExceptionSource(s); + if (!entry.optional) { + requiredDomainsSet.add(entry.domain); + } + } + } + } + const requiredDomains = Array.from(requiredDomainsSet).sort(); + await window.toolboxAPI.grantCspConsent(tool.id, requiredDomains, approvedOptionalDomains); } } From 947756a9cbb2ac726790687479ab93f3e04344b7 Mon Sep 17 00:00:00 2001 From: Danish Naglekar <36135520+Power-Maverick@users.noreply.github.com> Date: Wed, 4 Mar 2026 22:15:00 -0500 Subject: [PATCH 048/257] =?UTF-8?q?fix:=20add=20alwaysOnTop=20option=20for?= =?UTF-8?q?=20auto=20update=20notification=E2=80=A6=20(#437)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: add alwaysOnTop option for modal windows and update notification text * fix: ensure modal windows move to the top when opened * fix: update action button text for update notification modal --- src/common/types/common.ts | 1 + src/main/managers/modalWindowManager.ts | 9 +++++++++ .../modals/updateNotification/controller.ts | 4 ++-- .../modals/updateNotification/view.ts | 20 +++++++++++-------- src/renderer/modules/autoUpdateManagement.ts | 15 ++++++++++++-- 5 files changed, 37 insertions(+), 12 deletions(-) diff --git a/src/common/types/common.ts b/src/common/types/common.ts index ed09ec5e..7a47b773 100644 --- a/src/common/types/common.ts +++ b/src/common/types/common.ts @@ -83,6 +83,7 @@ export interface ModalWindowOptions { width: number; height: number; resizable?: boolean; + alwaysOnTop?: boolean; } /** diff --git a/src/main/managers/modalWindowManager.ts b/src/main/managers/modalWindowManager.ts index bb43e3e3..1be29f35 100644 --- a/src/main/managers/modalWindowManager.ts +++ b/src/main/managers/modalWindowManager.ts @@ -45,6 +45,10 @@ export class ModalWindowManager { .then(() => { if (!modalWindow.isDestroyed()) { modalWindow.show(); + if (this.currentOptions?.alwaysOnTop) { + modalWindow.setAlwaysOnTop(true, "modal-panel"); + } + modalWindow.moveTop(); modalWindow.focus(); this.mainWindow.webContents.send(EVENT_CHANNELS.MODAL_WINDOW_OPENED, { id: this.currentOptions?.id ?? null }); } @@ -115,6 +119,11 @@ export class ModalWindowManager { this.mainWindow.on("restore", () => { if (this.currentOptions && this.modalWindow && !this.modalWindow.isDestroyed()) { this.modalWindow.show(); + if (this.currentOptions.alwaysOnTop) { + this.modalWindow.setAlwaysOnTop(true, "modal-panel"); + this.modalWindow.moveTop(); + } + this.modalWindow.focus(); } }); this.mainWindow.on("closed", () => this.destroy()); diff --git a/src/renderer/modals/updateNotification/controller.ts b/src/renderer/modals/updateNotification/controller.ts index c75e2c6c..2c27c41d 100644 --- a/src/renderer/modals/updateNotification/controller.ts +++ b/src/renderer/modals/updateNotification/controller.ts @@ -54,7 +54,7 @@ export function getUpdateNotificationModalControllerScript(config: UpdateNotific if (progressWrap) progressWrap.style.display = "none"; if (actionBtn instanceof HTMLButtonElement) { actionBtn.disabled = false; - actionBtn.textContent = effectiveType === "downloaded" ? "Restart & Install Now" : "Download & Install"; + actionBtn.textContent = effectiveType === "downloaded" ? "Close & Install Now" : "Download Now"; } if (laterBtn instanceof HTMLButtonElement) { laterBtn.disabled = false; @@ -113,7 +113,7 @@ export function getUpdateNotificationModalControllerScript(config: UpdateNotific if (progressWrap) progressWrap.style.display = "none"; if (actionBtn instanceof HTMLButtonElement) { actionBtn.disabled = false; - actionBtn.textContent = "Restart & Install Now"; + actionBtn.textContent = "Close & Install Now"; } if (laterBtn instanceof HTMLButtonElement) { laterBtn.disabled = false; diff --git a/src/renderer/modals/updateNotification/view.ts b/src/renderer/modals/updateNotification/view.ts index a478b033..4d39ac5b 100644 --- a/src/renderer/modals/updateNotification/view.ts +++ b/src/renderer/modals/updateNotification/view.ts @@ -278,13 +278,13 @@ export function getUpdateNotificationModalView(model: UpdateNotificationModalVie const processSteps = isAvailable ? [ - { n: "1", text: "The update will download in the background." }, + { n: "1", text: "The update will download." }, { n: "2", text: "Once downloaded, you will be prompted to install." }, - { n: "3", text: "The app will restart automatically to apply the update." }, + { n: "3", text: "The app will restart to apply the update." }, ] : [ - { n: "1", text: "Click Restart & Install to apply the update now." }, - { n: "2", text: "The app will close and restart automatically." }, + { n: "1", text: "Click Close & Install to apply the update now." }, + { n: "2", text: "The app will close and start the installation process." }, { n: "3", text: "Any in-progress work in open tools will be lost. Your app settings and connections will be preserved." }, ]; @@ -296,15 +296,19 @@ export function getUpdateNotificationModalView(model: UpdateNotificationModalVie `; - const stepsHtml = processSteps.map((s) => `
    ${s.n}${s.text}
    `).join("\n"); + const stepsHtml = processSteps + .map((s) => `
    ${s.n}${s.text}
    `) + .join("\n"); - const bannerText = isAvailable ? "This update requires an app restart to take effect. You can choose to download now or be reminded later." : "This update has been downloaded and is ready to install. The app will restart to apply the changes."; + const bannerText = isAvailable + ? "This update requires an app restart to take effect. You can choose to download now or be reminded later." + : "This update has been downloaded and is ready to install. The app will restart to apply the changes."; const footerButtons = isAvailable ? ` - ` + ` : ` - `; + `; const body = `
    diff --git a/src/renderer/modules/autoUpdateManagement.ts b/src/renderer/modules/autoUpdateManagement.ts index 60066022..bf9d7eed 100644 --- a/src/renderer/modules/autoUpdateManagement.ts +++ b/src/renderer/modules/autoUpdateManagement.ts @@ -5,7 +5,14 @@ import { getUpdateNotificationModalControllerScript } from "../modals/updateNotification/controller"; import { getUpdateNotificationModalView } from "../modals/updateNotification/view"; -import { offBrowserWindowModalClosed, offBrowserWindowModalMessage, onBrowserWindowModalClosed, onBrowserWindowModalMessage, sendBrowserWindowModalMessage, showBrowserWindowModal } from "./browserWindowModals"; +import { + offBrowserWindowModalClosed, + offBrowserWindowModalMessage, + onBrowserWindowModalClosed, + onBrowserWindowModalMessage, + sendBrowserWindowModalMessage, + showBrowserWindowModal, +} from "./browserWindowModals"; const UPDATE_NOTIFICATION_MODAL_ID = "update-notification"; const UPDATE_NOTIFICATION_MODAL_CHANNELS = { @@ -50,7 +57,10 @@ async function showUpdateNotificationModal(type: "available" | "downloaded", ver if (!payload) return; if (payload.channel === UPDATE_NOTIFICATION_MODAL_CHANNELS.download) { window.toolboxAPI.downloadUpdate().catch((error: unknown) => { - void sendBrowserWindowModalMessage({ channel: "update:error", data: { message: (error as Error)?.message ?? "Failed to download the update. Please check your connection and try again." } }).catch(() => undefined); + void sendBrowserWindowModalMessage({ + channel: "update:error", + data: { message: (error as Error)?.message ?? "Failed to download the update. Please check your connection and try again." }, + }).catch(() => undefined); }); } else if (payload.channel === UPDATE_NOTIFICATION_MODAL_CHANNELS.install) { window.toolboxAPI.quitAndInstall().catch((error: unknown) => { @@ -84,6 +94,7 @@ async function showUpdateNotificationModal(type: "available" | "downloaded", ver html, width: UPDATE_NOTIFICATION_MODAL_WIDTH, height: UPDATE_NOTIFICATION_MODAL_HEIGHT, + alwaysOnTop: true, }); } catch (_error) { onClosed(); From 5d7393b9c770a3062158499665d5bceac9475b4a Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:13:49 -0500 Subject: [PATCH 049/257] feat: Add category filter and grouping to connection selection modals (#440) * Initial plan * feat: add category filter and grouping to connection selection modals Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> --- .../modals/selectConnection/controller.ts | 88 ++++++++++++++++- src/renderer/modals/selectConnection/view.ts | 7 ++ .../selectMultiConnection/controller.ts | 99 +++++++++++++++++-- .../modals/selectMultiConnection/view.ts | 7 ++ src/renderer/modals/sharedStyles.ts | 44 +++++++++ 5 files changed, 233 insertions(+), 12 deletions(-) diff --git a/src/renderer/modals/selectConnection/controller.ts b/src/renderer/modals/selectConnection/controller.ts index f2592b93..18a0c408 100644 --- a/src/renderer/modals/selectConnection/controller.ts +++ b/src/renderer/modals/selectConnection/controller.ts @@ -36,6 +36,7 @@ export function getSelectConnectionModalControllerScript(channels: SelectConnect const searchInput = document.getElementById("select-connection-search"); const envFilter = document.getElementById("select-connection-env-filter"); const authFilter = document.getElementById("select-connection-auth-filter"); + const categoryFilter = document.getElementById("select-connection-category-filter"); const sortSelect = document.getElementById("select-connection-sort"); const filterButton = document.getElementById("select-connection-filter-btn"); const filterDropdown = document.getElementById("select-connection-filter-dropdown"); @@ -89,6 +90,7 @@ ${sortingUtilities} const searchTerm = searchInput?.value?.toLowerCase() || ""; const selectedEnv = envFilter?.value || ""; const selectedAuth = authFilter?.value || ""; + const selectedCategory = categoryFilter?.value || ""; const selectedSort = sanitizeSortOption(sortSelect?.value || injectedSortOption); let filtered = allConnections.filter(conn => { @@ -110,6 +112,15 @@ ${sortingUtilities} return false; } + // Category filter + if (selectedCategory) { + if (selectedCategory === "__default__") { + if (conn.category) return false; + } else if (conn.category !== selectedCategory) { + return false; + } + } + return true; }); @@ -132,6 +143,21 @@ ${sortingUtilities} } } + // Populate category filter dropdown from actual connection data + if (categoryFilter && Array.isArray(connectionsData)) { + const allCategories = new Set(); + allConnections.forEach(conn => { if (conn.category) allCategories.add(conn.category); }); + const currentCategoryValue = categoryFilter.value; + const hasDefault = allConnections.some(conn => !conn.category); + let optionsHtml = ''; + if (hasDefault) optionsHtml += ''; + [...allCategories].sort().forEach(cat => { + optionsHtml += \`\`; + }); + categoryFilter.innerHTML = optionsHtml; + if (currentCategoryValue) categoryFilter.value = currentCategoryValue; + } + const connections = getFilteredConnections(); if (allConnections.length === 0) { @@ -154,7 +180,7 @@ ${sortingUtilities} return; } - connectionsListContainer.innerHTML = connections.map(conn => { + const renderConnectionItem = (conn) => { const browserBadge = getBrowserBadgeMarkup(conn); const envColor = conn.environmentColor && /^#[0-9A-Fa-f]{6}$/.test(conn.environmentColor) ? conn.environmentColor : null; const envBadgeStyle = envColor ? \` style="background-color:\${envColor}1a;color:\${envColor};border:1px solid \${envColor}4d"\` : ''; @@ -178,7 +204,64 @@ ${sortingUtilities}
    \`; - }).join(''); + }; + + // Group connections by category + const groupMap = new Map(); + connections.forEach(conn => { + const key = conn.category || ""; + if (!groupMap.has(key)) groupMap.set(key, []); + groupMap.get(key).push(conn); + }); + const groupKeys = [...groupMap.keys()].sort((a, b) => { + if (a === "") return -1; + if (b === "") return 1; + return a.localeCompare(b); + }); + const useGroups = groupKeys.length > 1 || (groupKeys.length === 1 && groupKeys[0] !== ""); + + if (useGroups) { + connectionsListContainer.innerHTML = groupKeys.map(groupKey => { + const groupConns = groupMap.get(groupKey); + const displayKey = groupKey === "" ? "Default" : groupKey; + const escapedKey = escapeHtml(displayKey); + const items = groupConns.map(renderConnectionItem).join(''); + return \` +
    +
    + \${escapedKey} + \${groupConns.length} + ▼ +
    +
    + \${items} +
    +
    \`; + }).join(''); + + // Add group toggle handlers + connectionsListContainer.querySelectorAll('.connection-group-header').forEach(header => { + const toggleGroup = () => { + const group = header.closest('.connection-group'); + const items = group?.querySelector('.connection-group-items'); + if (!items) return; + const isCollapsed = items.classList.contains('collapsed'); + items.classList.toggle('collapsed', !isCollapsed); + const toggle = header.querySelector('.connection-group-toggle'); + if (toggle) toggle.textContent = isCollapsed ? '▼' : '▶'; + header.setAttribute('aria-expanded', String(isCollapsed)); + }; + header.addEventListener('click', toggleGroup); + header.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + toggleGroup(); + } + }); + }); + } else { + connectionsListContainer.innerHTML = connections.map(renderConnectionItem).join(''); + } // Add click handlers to connection items const connectionItems = connectionsListContainer.querySelectorAll('.connection-item'); @@ -292,6 +375,7 @@ ${sortingUtilities} searchInput?.addEventListener('input', () => renderConnections(allConnections)); envFilter?.addEventListener('change', () => renderConnections(allConnections)); authFilter?.addEventListener('change', () => renderConnections(allConnections)); + categoryFilter?.addEventListener('change', () => renderConnections(allConnections)); sortSelect?.addEventListener('change', () => { injectedSortOption = sanitizeSortOption(sortSelect.value); renderConnections(allConnections); diff --git a/src/renderer/modals/selectConnection/view.ts b/src/renderer/modals/selectConnection/view.ts index ff95b0fe..e9cc902a 100644 --- a/src/renderer/modals/selectConnection/view.ts +++ b/src/renderer/modals/selectConnection/view.ts @@ -64,6 +64,13 @@ export function getSelectConnectionModalView(isDarkTheme: boolean): ModalViewTem
    +
    +
    +
    Category
    + +
    diff --git a/src/renderer/modals/selectMultiConnection/controller.ts b/src/renderer/modals/selectMultiConnection/controller.ts index 5746a01d..514d7602 100644 --- a/src/renderer/modals/selectMultiConnection/controller.ts +++ b/src/renderer/modals/selectMultiConnection/controller.ts @@ -40,6 +40,7 @@ export function getSelectMultiConnectionModalControllerScript(channels: SelectMu const searchInput = document.getElementById("multi-connection-search"); const envFilter = document.getElementById("multi-connection-env-filter"); const authFilter = document.getElementById("multi-connection-auth-filter"); + const categoryFilter = document.getElementById("multi-connection-category-filter"); const sortSelect = document.getElementById("multi-connection-sort"); const filterButton = document.getElementById("multi-connection-filter-btn"); const filterDropdown = document.getElementById("multi-connection-filter-dropdown"); @@ -94,6 +95,7 @@ ${sortingUtilities} const searchTerm = searchInput?.value?.toLowerCase() || ""; const selectedEnv = envFilter?.value || ""; const selectedAuth = authFilter?.value || ""; + const selectedCategory = categoryFilter?.value || ""; const selectedSort = sanitizeSortOption(sortSelect?.value || injectedSortOption); let filtered = allConnections.filter(conn => { @@ -115,6 +117,15 @@ ${sortingUtilities} return false; } + // Category filter + if (selectedCategory) { + if (selectedCategory === "__default__") { + if (conn.category) return false; + } else if (conn.category !== selectedCategory) { + return false; + } + } + return true; }); @@ -135,6 +146,21 @@ ${sortingUtilities} } } + // Populate category filter dropdown from actual connection data + if (categoryFilter && Array.isArray(connectionsData)) { + const allCategories = new Set(); + allConnections.forEach(conn => { if (conn.category) allCategories.add(conn.category); }); + const currentCategoryValue = categoryFilter.value; + const hasDefault = allConnections.some(conn => !conn.category); + let optionsHtml = ''; + if (hasDefault) optionsHtml += ''; + [...allCategories].sort().forEach(cat => { + optionsHtml += \`\`; + }); + categoryFilter.innerHTML = optionsHtml; + if (currentCategoryValue) categoryFilter.value = currentCategoryValue; + } + const connections = getFilteredConnections(); if (allConnections.length === 0) { @@ -206,19 +232,71 @@ ${sortingUtilities} \`; }; + // Group connections by category + const groupMap = new Map(); + connections.forEach(conn => { + const key = conn.category || ""; + if (!groupMap.has(key)) groupMap.set(key, []); + groupMap.get(key).push(conn); + }); + const groupKeys = [...groupMap.keys()].sort((a, b) => { + if (a === "") return -1; + if (b === "") return 1; + return a.localeCompare(b); + }); + const useGroups = groupKeys.length > 1 || (groupKeys.length === 1 && groupKeys[0] !== ""); + + const renderGroupedList = (container, idPrefix, disabledConnectionId) => { + if (!container) return; + if (useGroups) { + container.innerHTML = groupKeys.map(groupKey => { + const groupConns = groupMap.get(groupKey); + const displayKey = groupKey === "" ? "Default" : groupKey; + const escapedKey = escapeHtml(displayKey); + const items = groupConns.map(conn => connectionHtml(conn, idPrefix, conn.id === disabledConnectionId)).join(''); + return \` +
    +
    + \${escapedKey} + \${groupConns.length} + ▼ +
    +
    + \${items} +
    +
    \`; + }).join(''); + + // Add group toggle handlers + container.querySelectorAll('.connection-group-header').forEach(header => { + const toggleGroup = () => { + const group = header.closest('.connection-group'); + const items = group?.querySelector('.connection-group-items'); + if (!items) return; + const isCollapsed = items.classList.contains('collapsed'); + items.classList.toggle('collapsed', !isCollapsed); + const toggle = header.querySelector('.connection-group-toggle'); + if (toggle) toggle.textContent = isCollapsed ? '▼' : '▶'; + header.setAttribute('aria-expanded', String(isCollapsed)); + }; + header.addEventListener('click', toggleGroup); + header.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + toggleGroup(); + } + }); + }); + } else { + container.innerHTML = connections.map(conn => connectionHtml(conn, idPrefix, conn.id === disabledConnectionId)).join(''); + } + }; + // Render primary connections (disable if selected as secondary) - if (primaryConnectionsListContainer) { - primaryConnectionsListContainer.innerHTML = connections - .map(conn => connectionHtml(conn, 'primary', conn.id === authenticatedSecondaryConnectionId)) - .join(''); - } + renderGroupedList(primaryConnectionsListContainer, 'primary', authenticatedSecondaryConnectionId); // Render secondary connections (disable if selected as primary) - if (secondaryConnectionsListContainer) { - secondaryConnectionsListContainer.innerHTML = connections - .map(conn => connectionHtml(conn, 'secondary', conn.id === authenticatedPrimaryConnectionId)) - .join(''); - } + renderGroupedList(secondaryConnectionsListContainer, 'secondary', authenticatedPrimaryConnectionId); // Add click handlers to all connect buttons document.querySelectorAll('.connect-button').forEach(button => { @@ -348,6 +426,7 @@ ${sortingUtilities} searchInput?.addEventListener('input', () => renderConnections(allConnections)); envFilter?.addEventListener('change', () => renderConnections(allConnections)); authFilter?.addEventListener('change', () => renderConnections(allConnections)); + categoryFilter?.addEventListener('change', () => renderConnections(allConnections)); sortSelect?.addEventListener('change', () => { injectedSortOption = sanitizeSortOption(sortSelect.value); renderConnections(allConnections); diff --git a/src/renderer/modals/selectMultiConnection/view.ts b/src/renderer/modals/selectMultiConnection/view.ts index fc799494..63622972 100644 --- a/src/renderer/modals/selectMultiConnection/view.ts +++ b/src/renderer/modals/selectMultiConnection/view.ts @@ -153,6 +153,13 @@ export function getSelectMultiConnectionModalView(isDarkTheme: boolean, isSecond
    +
    +
    +
    Category
    + +
    diff --git a/src/renderer/modals/sharedStyles.ts b/src/renderer/modals/sharedStyles.ts index 612cf750..292c2b57 100644 --- a/src/renderer/modals/sharedStyles.ts +++ b/src/renderer/modals/sharedStyles.ts @@ -625,5 +625,49 @@ export function getModalStyles(isDarkTheme: boolean): string { display: flex; flex-direction: column; } + + .connection-group { + margin-bottom: 4px; + } + + .connection-group-header { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + cursor: pointer; + border-radius: 6px; + user-select: none; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: ${isDarkTheme ? "rgba(255, 255, 255, 0.6)" : "rgba(0, 0, 0, 0.6)"}; + } + + .connection-group-header:hover { + background: ${isDarkTheme ? "rgba(255, 255, 255, 0.05)" : "rgba(0, 0, 0, 0.05)"}; + } + + .connection-group-title { + flex: 1; + } + + .connection-group-count { + font-size: 11px; + opacity: 0.7; + background: ${isDarkTheme ? "rgba(255, 255, 255, 0.1)" : "rgba(0, 0, 0, 0.1)"}; + border-radius: 10px; + padding: 1px 6px; + } + + .connection-group-toggle { + font-size: 10px; + opacity: 0.7; + } + + .connection-group-items.collapsed { + display: none; + } `; } From b4d7dc2cd31cd4f926dc0a07a8eff05d1726acce Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 22:18:15 -0500 Subject: [PATCH 050/257] Remove Sentry monitoring and replace with centralized console logging (#443) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * feat: remove Sentry and replace with console logging Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Replace sentryHelper calls with direct console.* equivalents - logInfo → console.info - logWarn → console.warn - logDebug → console.debug - logCheckpoint → console.log - captureException(err, ctx) → console.error(err) (ctx dropped) - captureMessage(msg, level, ctx) → console.error/warn(msg) (ctx dropped) - addBreadcrumb(...) → removed entirely - wrapAsyncOperation(name, async () => { body }, ctx) → (async () => { body })() - Removed all sentryHelper import lines across 26 files Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: resolve lint errors after sentryHelper removal - Fix unterminated string literals in encryptionManager, terminalManager, toolRegistryManager - Rename unused catch params to _error/_trigger to satisfy no-unused-vars rule - Rename unused function param extra -> _extra in homepageManagement Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: replace console.* calls with centralized logger functions Replace all direct console.info/warn/error/debug/log calls in src/main/ and src/renderer/modules/ with the corresponding logger functions from the new src/common/logger module: - console.info → logInfo - console.warn → logWarn - console.error → logError - console.debug → logDebug - console.log → logCheckpoint Each file receives a scoped import for only the functions it uses. Files in src/renderer/modals/ are intentionally left unchanged as they contain console calls inside template literal strings for inline browser scripts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: introduce src/common/logger.ts and route all logging through it Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * refactor: pass caught error as second arg to logError/logWarn in catch blocks In catch blocks across the codebase, replace error message interpolation (e.g. `${(error as Error).message}`) with passing the raw catch variable as the second `data` argument to logError/logWarn. Also strip trailing colon-only patterns where the colon was only there to precede the error. Files changed: - src/main/managers/authManager.ts (5 calls) - src/main/managers/browserManager.ts (4 calls) - src/main/managers/browserviewProtocolManager.ts (2 calls) - src/main/managers/encryptionManager.ts (1 call) - src/main/managers/toolRegistryManager.ts (7 calls) - src/main/managers/toolWindowManager.ts (11 calls) - src/main/managers/toolsManager.ts (1 call) - src/renderer/modules/connectionManagement.ts (14 calls) - src/renderer/modules/marketplaceManagement.ts (1 call) - src/renderer/modules/toolsSidebarManagement.ts (1 call) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: apply all PR review feedback - severity corrections, pass error args, remove unused params, simplify IIFEs Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .eslintrc.js | 2 + package.json | 2 - pnpm-lock.yaml | 1289 +---------------- src/common/ipc/channels.ts | 1 - src/common/logger.ts | 79 + src/common/sentry.ts | 81 -- src/common/sentryHelper.ts | 492 ------- src/common/types/api.ts | 1 - src/main/index.ts | 289 +--- src/main/managers/authManager.ts | 26 +- src/main/managers/browserManager.ts | 10 +- .../managers/browserviewProtocolManager.ts | 26 +- src/main/managers/connectionsManager.ts | 2 +- src/main/managers/dataverseManager.ts | 26 +- src/main/managers/encryptionManager.ts | 10 +- src/main/managers/installIdManager.ts | 2 +- src/main/managers/modalWindowManager.ts | 4 +- src/main/managers/protocolHandlerManager.ts | 61 +- src/main/managers/terminalManager.ts | 4 +- .../managers/toolFileSystemAccessManager.ts | 2 +- src/main/managers/toolRegistryManager.ts | 50 +- src/main/managers/toolWindowManager.ts | 59 +- src/main/managers/toolsManager.ts | 14 +- src/main/preload.ts | 1 - src/main/toolPreloadBridge.ts | 2 +- .../modals/troubleshooting/controller.ts | 8 - src/renderer/modals/troubleshooting/view.ts | 12 - src/renderer/modules/connectionManagement.ts | 38 +- .../modules/globalSearchManagement.ts | 22 +- src/renderer/modules/homepageManagement.ts | 14 +- src/renderer/modules/initialization.ts | 221 +-- src/renderer/modules/marketplaceManagement.ts | 22 +- src/renderer/modules/sidebarManagement.ts | 12 +- src/renderer/modules/terminalManagement.ts | 4 +- src/renderer/modules/toolManagement.ts | 35 +- .../modules/toolsSidebarManagement.ts | 4 +- .../modules/troubleshootingManagement.ts | 7 +- vite.config.ts | 41 +- 38 files changed, 299 insertions(+), 2676 deletions(-) create mode 100644 src/common/logger.ts delete mode 100644 src/common/sentry.ts delete mode 100644 src/common/sentryHelper.ts diff --git a/.eslintrc.js b/.eslintrc.js index 91fec26c..56f34bfb 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -12,5 +12,7 @@ module.exports = { rules: { "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/explicit-module-boundary-types": "off", + // Allow underscore-prefixed parameters to be intentionally unused (API compatibility stubs) + "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], }, }; diff --git a/package.json b/package.json index 26ac4bda..e3041716 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,6 @@ "license": "GPL-3.0", "devDependencies": { "@electron/notarize": "^3.1.0", - "@sentry/vite-plugin": "^4.6.1", "@types/node": "^20.19.21", "@typescript-eslint/eslint-plugin": "^6.13.0", "@typescript-eslint/parser": "^6.13.0", @@ -54,7 +53,6 @@ "@azure/msal-node": "^3.8.0", "@fluentui/svg-icons": "^1.1.312", "@fluentui/tokens": "^1.0.0-alpha.22", - "@sentry/electron": "^7.5.0", "@supabase/supabase-js": "^2.84.0", "@types/uuid": "^10.0.0", "ansi-to-html": "^0.7.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 07b5b61b..b7d10eaa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,9 +17,6 @@ importers: '@fluentui/tokens': specifier: ^1.0.0-alpha.22 version: 1.0.0-alpha.22 - '@sentry/electron': - specifier: ^7.5.0 - version: 7.5.0 '@supabase/supabase-js': specifier: ^2.84.0 version: 2.89.0 @@ -45,9 +42,6 @@ importers: '@electron/notarize': specifier: ^3.1.0 version: 3.1.1 - '@sentry/vite-plugin': - specifier: ^4.6.1 - version: 4.6.1 '@types/node': specifier: ^20.19.21 version: 20.19.27 @@ -96,12 +90,6 @@ packages: 7zip-bin@5.2.0: resolution: {integrity: sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==} - '@apm-js-collab/code-transformer@0.8.2': - resolution: {integrity: sha512-YRjJjNq5KFSjDUoqu5pFUWrrsvGOxl6c3bu+uMFc9HNNptZ2rNU/TI2nLw4jnhQNtka972Ee2m3uqbvDQtPeCA==} - - '@apm-js-collab/tracing-hooks@0.3.1': - resolution: {integrity: sha512-Vu1CbmPURlN5fTboVuKMoJjbO5qcq9fA5YXpskx3dXe/zTBvjODFoerw+69rVBlRLrJpwPqSDqEuJDEKIrTldw==} - '@azure/msal-common@15.13.3': resolution: {integrity: sha512-shSDU7Ioecya+Aob5xliW9IGq1Ui8y4EVSdWGyI1Gbm4Vg61WpP95LuzcY214/wEjSn6w4PZYD4/iVldErHayQ==} engines: {node: '>=0.8.0'} @@ -110,77 +98,10 @@ packages: resolution: {integrity: sha512-lvuAwsDpPDE/jSuVQOBMpLbXuVuLsPNRwWCyK3/6bPlBk0fGWegqoZ0qjZclMWyQ2JNvIY3vHY7hoFmFmFQcOw==} engines: {node: '>=16'} - '@babel/code-frame@7.27.1': - resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} - engines: {node: '>=6.9.0'} - - '@babel/compat-data@7.28.5': - resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==} - engines: {node: '>=6.9.0'} - - '@babel/core@7.28.5': - resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==} - engines: {node: '>=6.9.0'} - - '@babel/generator@7.28.5': - resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} - engines: {node: '>=6.9.0'} - - '@babel/helper-compilation-targets@7.27.2': - resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} - engines: {node: '>=6.9.0'} - - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-imports@7.27.1': - resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-transforms@7.28.3': - resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} - engines: {node: '>=6.9.0'} - - '@babel/helpers@7.28.4': - resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} - engines: {node: '>=6.9.0'} - - '@babel/parser@7.28.5': - resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} - engines: {node: '>=6.0.0'} - hasBin: true - '@babel/runtime@7.28.4': resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} engines: {node: '>=6.9.0'} - '@babel/template@7.27.2': - resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} - engines: {node: '>=6.9.0'} - - '@babel/traverse@7.28.5': - resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} - engines: {node: '>=6.9.0'} - - '@babel/types@7.28.5': - resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} - engines: {node: '>=6.9.0'} - '@develar/schema-utils@2.6.5': resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==} engines: {node: '>= 8.9.0'} @@ -408,22 +329,6 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - - '@jridgewell/remapping@2.3.5': - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@malept/cross-spawn-promise@1.1.1': resolution: {integrity: sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ==} engines: {node: '>= 10'} @@ -444,190 +349,6 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@opentelemetry/api-logs@0.208.0': - resolution: {integrity: sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==} - engines: {node: '>=8.0.0'} - - '@opentelemetry/api@1.9.0': - resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} - engines: {node: '>=8.0.0'} - - '@opentelemetry/context-async-hooks@2.2.0': - resolution: {integrity: sha512-qRkLWiUEZNAmYapZ7KGS5C4OmBLcP/H2foXeOEaowYCR0wi89fHejrfYfbuLVCMLp/dWZXKvQusdbUEZjERfwQ==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': '>=1.0.0 <1.10.0' - - '@opentelemetry/core@2.2.0': - resolution: {integrity: sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': '>=1.0.0 <1.10.0' - - '@opentelemetry/instrumentation-amqplib@0.55.0': - resolution: {integrity: sha512-5ULoU8p+tWcQw5PDYZn8rySptGSLZHNX/7srqo2TioPnAAcvTy6sQFQXsNPrAnyRRtYGMetXVyZUy5OaX1+IfA==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-connect@0.52.0': - resolution: {integrity: sha512-GXPxfNB5szMbV3I9b7kNWSmQBoBzw7MT0ui6iU/p+NIzVx3a06Ri2cdQO7tG9EKb4aKSLmfX9Cw5cKxXqX6Ohg==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-dataloader@0.26.0': - resolution: {integrity: sha512-P2BgnFfTOarZ5OKPmYfbXfDFjQ4P9WkQ1Jji7yH5/WwB6Wm/knynAoA1rxbjWcDlYupFkyT0M1j6XLzDzy0aCA==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-express@0.57.0': - resolution: {integrity: sha512-HAdx/o58+8tSR5iW+ru4PHnEejyKrAy9fYFhlEI81o10nYxrGahnMAHWiSjhDC7UQSY3I4gjcPgSKQz4rm/asg==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-fs@0.28.0': - resolution: {integrity: sha512-FFvg8fq53RRXVBRHZViP+EMxMR03tqzEGpuq55lHNbVPyFklSVfQBN50syPhK5UYYwaStx0eyCtHtbRreusc5g==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-generic-pool@0.52.0': - resolution: {integrity: sha512-ISkNcv5CM2IwvsMVL31Tl61/p2Zm2I2NAsYq5SSBgOsOndT0TjnptjufYVScCnD5ZLD1tpl4T3GEYULLYOdIdQ==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-graphql@0.56.0': - resolution: {integrity: sha512-IPvNk8AFoVzTAM0Z399t34VDmGDgwT6rIqCUug8P9oAGerl2/PEIYMPOl/rerPGu+q8gSWdmbFSjgg7PDVRd3Q==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-hapi@0.55.0': - resolution: {integrity: sha512-prqAkRf9e4eEpy4G3UcR32prKE8NLNlA90TdEU1UsghOTg0jUvs40Jz8LQWFEs5NbLbXHYGzB4CYVkCI8eWEVQ==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-http@0.208.0': - resolution: {integrity: sha512-rhmK46DRWEbQQB77RxmVXGyjs6783crXCnFjYQj+4tDH/Kpv9Rbg3h2kaNyp5Vz2emF1f9HOQQvZoHzwMWOFZQ==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-ioredis@0.56.0': - resolution: {integrity: sha512-XSWeqsd3rKSsT3WBz/JKJDcZD4QYElZEa0xVdX8f9dh4h4QgXhKRLorVsVkK3uXFbC2sZKAS2Ds+YolGwD83Dg==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-kafkajs@0.18.0': - resolution: {integrity: sha512-KCL/1HnZN5zkUMgPyOxfGjLjbXjpd4odDToy+7c+UsthIzVLFf99LnfIBE8YSSrYE4+uS7OwJMhvhg3tWjqMBg==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-knex@0.53.0': - resolution: {integrity: sha512-xngn5cH2mVXFmiT1XfQ1aHqq1m4xb5wvU6j9lSgLlihJ1bXzsO543cpDwjrZm2nMrlpddBf55w8+bfS4qDh60g==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-koa@0.57.0': - resolution: {integrity: sha512-3JS8PU/D5E3q295mwloU2v7c7/m+DyCqdu62BIzWt+3u9utjxC9QS7v6WmUNuoDN3RM+Q+D1Gpj13ERo+m7CGg==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.9.0 - - '@opentelemetry/instrumentation-lru-memoizer@0.53.0': - resolution: {integrity: sha512-LDwWz5cPkWWr0HBIuZUjslyvijljTwmwiItpMTHujaULZCxcYE9eU44Qf/pbVC8TulT0IhZi+RoGvHKXvNhysw==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-mongodb@0.61.0': - resolution: {integrity: sha512-OV3i2DSoY5M/pmLk+68xr5RvkHU8DRB3DKMzYJdwDdcxeLs62tLbkmRyqJZsYf3Ht7j11rq35pHOWLuLzXL7pQ==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-mongoose@0.55.0': - resolution: {integrity: sha512-5afj0HfF6aM6Nlqgu6/PPHFk8QBfIe3+zF9FGpX76jWPS0/dujoEYn82/XcLSaW5LPUDW8sni+YeK0vTBNri+w==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-mysql2@0.55.0': - resolution: {integrity: sha512-0cs8whQG55aIi20gnK8B7cco6OK6N+enNhW0p5284MvqJ5EPi+I1YlWsWXgzv/V2HFirEejkvKiI4Iw21OqDWg==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-mysql@0.54.0': - resolution: {integrity: sha512-bqC1YhnwAeWmRzy1/Xf9cDqxNG2d/JDkaxnqF5N6iJKN1eVWI+vg7NfDkf52/Nggp3tl1jcC++ptC61BD6738A==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-pg@0.61.0': - resolution: {integrity: sha512-UeV7KeTnRSM7ECHa3YscoklhUtTQPs6V6qYpG283AB7xpnPGCUCUfECFT9jFg6/iZOQTt3FHkB1wGTJCNZEvPw==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-redis@0.57.0': - resolution: {integrity: sha512-bCxTHQFXzrU3eU1LZnOZQ3s5LURxQPDlU3/upBzlWY77qOI1GZuGofazj3jtzjctMJeBEJhNwIFEgRPBX1kp/Q==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-tedious@0.27.0': - resolution: {integrity: sha512-jRtyUJNZppPBjPae4ZjIQ2eqJbcRaRfJkr0lQLHFmOU/no5A6e9s1OHLd5XZyZoBJ/ymngZitanyRRA5cniseA==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-undici@0.19.0': - resolution: {integrity: sha512-Pst/RhR61A2OoZQZkn6OLpdVpXp6qn3Y92wXa6umfJe9rV640r4bc6SWvw4pPN6DiQqPu2c8gnSSZPDtC6JlpQ==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.7.0 - - '@opentelemetry/instrumentation@0.208.0': - resolution: {integrity: sha512-Eju0L4qWcQS+oXxi6pgh7zvE2byogAkcsVv0OjHF/97iOz1N/aKE6etSGowYkie+YA1uo6DNwdSxaaNnLvcRlA==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/redis-common@0.38.2': - resolution: {integrity: sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==} - engines: {node: ^18.19.0 || >=20.6.0} - - '@opentelemetry/resources@2.2.0': - resolution: {integrity: sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': '>=1.3.0 <1.10.0' - - '@opentelemetry/sdk-trace-base@2.2.0': - resolution: {integrity: sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': '>=1.3.0 <1.10.0' - - '@opentelemetry/semantic-conventions@1.38.0': - resolution: {integrity: sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg==} - engines: {node: '>=14'} - - '@opentelemetry/sql-common@0.41.2': - resolution: {integrity: sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.1.0 - '@parcel/watcher-android-arm64@2.5.1': resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==} engines: {node: '>= 10.0.0'} @@ -714,11 +435,6 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} - '@prisma/instrumentation@6.19.0': - resolution: {integrity: sha512-QcuYy25pkXM8BJ37wVFBO7Zh34nyRV1GOb2n3lPkkbRYfl4hWl3PTcImP41P0KrzVXfa/45p6eVCos27x3exIg==} - peerDependencies: - '@opentelemetry/api': ^1.8 - '@rollup/rollup-android-arm-eabi@4.55.1': resolution: {integrity: sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==} cpu: [arm] @@ -844,128 +560,6 @@ packages: cpu: [x64] os: [win32] - '@sentry-internal/browser-utils@10.29.0': - resolution: {integrity: sha512-M3kycMY6f3KY9a8jDYac+yG0E3ZgWVWSxlOEC5MhYyX+g7mqxkwrb3LFQyuxSm/m+CCgMTCaPOOaB2twXP6EQg==} - engines: {node: '>=18'} - - '@sentry-internal/feedback@10.29.0': - resolution: {integrity: sha512-Y7IRsNeS99cEONu1mZWZc3HvbjNnu59Hgymm0swFFKbdgbCgdT6l85kn2oLsuq4Ew8Dw/pL/Sgpwsl9UgYFpUg==} - engines: {node: '>=18'} - - '@sentry-internal/replay-canvas@10.29.0': - resolution: {integrity: sha512-typY4JrpAQQGPuSyd/BD8+nNCbvTV2UVvKzr+iKgI0m1qc4Dz8tHZ4Nfais2Z8eYn/pL1kqVQN5ERTmJoYFdIw==} - engines: {node: '>=18'} - - '@sentry-internal/replay@10.29.0': - resolution: {integrity: sha512-45NVw9PwB9TQ8z+xJ6G6Za+wmQ1RTA35heBSzR6U4bknj8LmA04k2iwnobvxCBEQXeLfcJEO1vFgagMoqMZMBw==} - engines: {node: '>=18'} - - '@sentry/babel-plugin-component-annotate@4.6.1': - resolution: {integrity: sha512-aSIk0vgBqv7PhX6/Eov+vlI4puCE0bRXzUG5HdCsHBpAfeMkI8Hva6kSOusnzKqs8bf04hU7s3Sf0XxGTj/1AA==} - engines: {node: '>= 14'} - - '@sentry/browser@10.29.0': - resolution: {integrity: sha512-XdbyIR6F4qoR9Z1JCWTgunVcTJjS9p2Th+v4wYs4ME+ZdLC4tuKKmRgYg3YdSIWCn1CBfIgdI6wqETSf7H6Njw==} - engines: {node: '>=18'} - - '@sentry/bundler-plugin-core@4.6.1': - resolution: {integrity: sha512-WPeRbnMXm927m4Kr69NTArPfI+p5/34FHftdCRI3LFPMyhZDzz6J3wLy4hzaVUgmMf10eLzmq2HGEMvpQmdynA==} - engines: {node: '>= 14'} - - '@sentry/cli-darwin@2.58.4': - resolution: {integrity: sha512-kbTD+P4X8O+nsNwPxCywtj3q22ecyRHWff98rdcmtRrvwz8CKi/T4Jxn/fnn2i4VEchy08OWBuZAqaA5Kh2hRQ==} - engines: {node: '>=10'} - os: [darwin] - - '@sentry/cli-linux-arm64@2.58.4': - resolution: {integrity: sha512-0g0KwsOozkLtzN8/0+oMZoOuQ0o7W6O+hx+ydVU1bktaMGKEJLMAWxOQNjsh1TcBbNIXVOKM/I8l0ROhaAb8Ig==} - engines: {node: '>=10'} - cpu: [arm64] - os: [linux, freebsd, android] - - '@sentry/cli-linux-arm@2.58.4': - resolution: {integrity: sha512-rdQ8beTwnN48hv7iV7e7ZKucPec5NJkRdrrycMJMZlzGBPi56LqnclgsHySJ6Kfq506A2MNuQnKGaf/sBC9REA==} - engines: {node: '>=10'} - cpu: [arm] - os: [linux, freebsd, android] - - '@sentry/cli-linux-i686@2.58.4': - resolution: {integrity: sha512-NseoIQAFtkziHyjZNPTu1Gm1opeQHt7Wm1LbLrGWVIRvUOzlslO9/8i6wETUZ6TjlQxBVRgd3Q0lRBG2A8rFYA==} - engines: {node: '>=10'} - cpu: [x86, ia32] - os: [linux, freebsd, android] - - '@sentry/cli-linux-x64@2.58.4': - resolution: {integrity: sha512-d3Arz+OO/wJYTqCYlSN3Ktm+W8rynQ/IMtSZLK8nu0ryh5mJOh+9XlXY6oDXw4YlsM8qCRrNquR8iEI1Y/IH+Q==} - engines: {node: '>=10'} - cpu: [x64] - os: [linux, freebsd, android] - - '@sentry/cli-win32-arm64@2.58.4': - resolution: {integrity: sha512-bqYrF43+jXdDBh0f8HIJU3tbvlOFtGyRjHB8AoRuMQv9TEDUfENZyCelhdjA+KwDKYl48R1Yasb4EHNzsoO83w==} - engines: {node: '>=10'} - cpu: [arm64] - os: [win32] - - '@sentry/cli-win32-i686@2.58.4': - resolution: {integrity: sha512-3triFD6jyvhVcXOmGyttf+deKZcC1tURdhnmDUIBkiDPJKGT/N5xa4qAtHJlAB/h8L9jgYih9bvJnvvFVM7yug==} - engines: {node: '>=10'} - cpu: [x86, ia32] - os: [win32] - - '@sentry/cli-win32-x64@2.58.4': - resolution: {integrity: sha512-cSzN4PjM1RsCZ4pxMjI0VI7yNCkxiJ5jmWncyiwHXGiXrV1eXYdQ3n1LhUYLZ91CafyprR0OhDcE+RVZ26Qb5w==} - engines: {node: '>=10'} - cpu: [x64] - os: [win32] - - '@sentry/cli@2.58.4': - resolution: {integrity: sha512-ArDrpuS8JtDYEvwGleVE+FgR+qHaOp77IgdGSacz6SZy6Lv90uX0Nu4UrHCQJz8/xwIcNxSqnN22lq0dH4IqTg==} - engines: {node: '>= 10'} - hasBin: true - - '@sentry/core@10.29.0': - resolution: {integrity: sha512-olQ2DU9dA/Bwsz3PtA9KNXRMqBWRQSkPw+MxwWEoU1K1qtiM9L0j6lbEFb5iSY3d7WYD5MB+1d5COugjSBrHtw==} - engines: {node: '>=18'} - - '@sentry/electron@7.5.0': - resolution: {integrity: sha512-88t/YsB5iO75faKdd7lIuJkwp9FGKgFlkDuaSJhsJiVcjlywkn8CwUbctAbS0gu6Suc0raHCF4ULvGyksKAoww==} - peerDependencies: - '@sentry/node-native': 10.29.0 - peerDependenciesMeta: - '@sentry/node-native': - optional: true - - '@sentry/node-core@10.29.0': - resolution: {integrity: sha512-f/Y0okHhPPb5HnYNBqCivJ2YuXtSadvcIx16dzU5mHQxZhgGednUCPEX7rsvPcd4HneQz12HKLqxbAmNu+b3FA==} - engines: {node: '>=18'} - peerDependencies: - '@opentelemetry/api': ^1.9.0 - '@opentelemetry/context-async-hooks': ^1.30.1 || ^2.1.0 || ^2.2.0 - '@opentelemetry/core': ^1.30.1 || ^2.1.0 || ^2.2.0 - '@opentelemetry/instrumentation': '>=0.57.1 <1' - '@opentelemetry/resources': ^1.30.1 || ^2.1.0 || ^2.2.0 - '@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.1.0 || ^2.2.0 - '@opentelemetry/semantic-conventions': ^1.37.0 - - '@sentry/node@10.29.0': - resolution: {integrity: sha512-9j8VzV06VCj+H8tlxpfa7BNN4HzH5exv68WOufdMTXzzWLOXnzrdNDoYplm1G2S3LMvWsc1SVI3a8A0yBY7oWg==} - engines: {node: '>=18'} - - '@sentry/opentelemetry@10.29.0': - resolution: {integrity: sha512-5QvtAwS73HlI/+OTF1poAFELzsc0se+PHmMsXGGrOeNBvjCr3ZE8qvke09aeMn7uRImf3Nc9J6i2KtSHJnbKPA==} - engines: {node: '>=18'} - peerDependencies: - '@opentelemetry/api': ^1.9.0 - '@opentelemetry/context-async-hooks': ^1.30.1 || ^2.1.0 || ^2.2.0 - '@opentelemetry/core': ^1.30.1 || ^2.1.0 || ^2.2.0 - '@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.1.0 || ^2.2.0 - '@opentelemetry/semantic-conventions': ^1.37.0 - - '@sentry/vite-plugin@4.6.1': - resolution: {integrity: sha512-Qvys1y3o8/bfL3ikrHnJS9zxdjt0z3POshdBl3967UcflrTqBmnGNkcVk53SlmtJWIfh85fgmrLvGYwZ2YiqNg==} - engines: {node: '>= 14'} - '@sindresorhus/is@4.6.0': resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} @@ -1008,9 +602,6 @@ packages: '@types/cacheable-request@6.0.3': resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} - '@types/connect@3.4.38': - resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} - '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -1032,21 +623,12 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/mysql@2.15.27': - resolution: {integrity: sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==} - '@types/node@18.19.130': resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} '@types/node@20.19.27': resolution: {integrity: sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==} - '@types/pg-pool@2.0.6': - resolution: {integrity: sha512-TaAUE5rq2VQYxab5Ts7WZhKNmuN78Q6PiFonTDdpbx8a1H0M1vhy3rhiMjl+e2iHmogyMw7jZF4FrE6eJUy5HQ==} - - '@types/pg@8.15.6': - resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==} - '@types/phoenix@1.6.7': resolution: {integrity: sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==} @@ -1059,9 +641,6 @@ packages: '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} - '@types/tedious@4.0.14': - resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==} - '@types/uuid@10.0.0': resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} @@ -1139,11 +718,6 @@ packages: resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} engines: {node: '>=10.0.0'} - acorn-import-attributes@1.9.5: - resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} - peerDependencies: - acorn: ^8 - acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1207,10 +781,6 @@ packages: engines: {node: '>=8.0.0'} hasBin: true - anymatch@3.1.3: - resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} - engines: {node: '>= 8'} - app-builder-bin@4.0.0: resolution: {integrity: sha512-xwdG0FJPQMe0M0UA4Tz0zEB8rBJTRA5a476ZawAqiBkMv16GRK5xpXThOjMaEOFnZ6zabejjG4J3da0SXG63KA==} @@ -1277,14 +847,6 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.9.12: - resolution: {integrity: sha512-Mij6Lij93pTAIsSYy5cyBQ975Qh9uLEc5rwGTpomiZeXZL9yIS6uORJakb3ScHgfs0serMMfIbXzokPMuEiRyw==} - hasBin: true - - binary-extensions@2.3.0: - resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} - engines: {node: '>=8'} - bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} @@ -1308,11 +870,6 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.28.1: - resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} @@ -1360,9 +917,6 @@ packages: resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} engines: {node: '>=6'} - caniuse-lite@1.0.30001762: - resolution: {integrity: sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==} - chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -1370,10 +924,6 @@ packages: chardet@0.7.0: resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} - chokidar@3.6.0: - resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} - engines: {node: '>= 8.10.0'} - chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -1389,9 +939,6 @@ packages: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} engines: {node: '>=8'} - cjs-module-lexer@1.4.3: - resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} - cli-cursor@3.1.0: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} @@ -1451,9 +998,6 @@ packages: config-file-ts@0.2.6: resolution: {integrity: sha512-6boGVaglwblBgJqGyxm4+xCmEGcWgnWHSWHY5jad58awQhB6gftq0G8HbzU39YqCIYHMLAiL1yjwiZ36m/CL8w==} - convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - core-util-is@1.0.2: resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} @@ -1558,10 +1102,6 @@ packages: dotenv-expand@5.1.0: resolution: {integrity: sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==} - dotenv@16.6.1: - resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} - engines: {node: '>=12'} - dotenv@9.0.2: resolution: {integrity: sha512-I9OvvrHp4pIARv4+x9iuewrWycX6CcZtoAu1XrzPxc5UygMJXJZYmBsynku8IkrJwgypE5DGNjDPmPRhDCptUg==} engines: {node: '>=10'} @@ -1595,9 +1135,6 @@ packages: electron-store@8.2.0: resolution: {integrity: sha512-ukLL5Bevdil6oieAOXz3CMy+OgaItMiVBg701MNlG6W5RaC0AHN7rvlqTCmeb6O7jP0Qa1KKYTE0xV0xbhF4Hw==} - electron-to-chromium@1.5.267: - resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} - electron-updater@6.7.3: resolution: {integrity: sha512-EgkT8Z9noqXKbwc3u5FkJA+r48jwZ5DTUiOkJMOTEEH//n5Am6wfQGz7nvSFEA2oIAMv9jRzn5JKTyWeSKOPgg==} @@ -1781,9 +1318,6 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} - forwarded-parse@2.1.2: - resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==} - fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -1814,10 +1348,6 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} - get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -1962,9 +1492,6 @@ packages: resolution: {integrity: sha512-CiuXOFFSzkU5x/CR0+z7T91Iht4CXgfCxVOFRhh2Zyhg5wOpWvvDLQUsWl+gcN+QscYBjez8hDCt85O7RLDttQ==} engines: {node: '>=8'} - import-in-the-middle@2.0.1: - resolution: {integrity: sha512-bruMpJ7xz+9jwGzrwEhWgvRrlKRYCRDBrfU+ur3FcasYXLJDxTruJ//8g2Noj+QFyRBeqbpj8Bhn4Fbw6HjvhA==} - imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -1980,10 +1507,6 @@ packages: resolution: {integrity: sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA==} engines: {node: '>=8.0.0'} - is-binary-path@2.1.0: - resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} - engines: {node: '>=8'} - is-ci@3.0.1: resolution: {integrity: sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==} hasBin: true @@ -2043,18 +1566,10 @@ packages: engines: {node: '>=10'} hasBin: true - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true - jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} - engines: {node: '>=6'} - hasBin: true - json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -2177,17 +1692,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lru-cache@6.0.0: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} - magic-string@0.30.8: - resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==} - engines: {node: '>=12'} - marked@17.0.3: resolution: {integrity: sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A==} engines: {node: '>= 20'} @@ -2277,9 +1785,6 @@ packages: engines: {node: '>=10'} hasBin: true - module-details-from-path@1.0.4: - resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2312,9 +1817,6 @@ packages: encoding: optional: true - node-releases@2.0.27: - resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} - normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -2418,17 +1920,6 @@ packages: pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} - pg-int8@1.0.1: - resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} - engines: {node: '>=4.0.0'} - - pg-protocol@1.10.3: - resolution: {integrity: sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==} - - pg-types@2.2.0: - resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} - engines: {node: '>=4'} - picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2456,22 +1947,6 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} - postgres-array@2.0.0: - resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} - engines: {node: '>=4'} - - postgres-bytea@1.0.1: - resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} - engines: {node: '>=0.10.0'} - - postgres-date@1.0.7: - resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} - engines: {node: '>=0.10.0'} - - postgres-interval@1.2.0: - resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} - engines: {node: '>=0.10.0'} - prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -2492,9 +1967,6 @@ packages: resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} engines: {node: '>=10'} - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - pump@3.0.3: resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} @@ -2523,10 +1995,6 @@ packages: readdir-glob@1.1.3: resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} - readdirp@3.6.0: - resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} - engines: {node: '>=8.10.0'} - readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -2539,10 +2007,6 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} - require-in-the-middle@8.0.1: - resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} - engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} - require-main-filename@2.0.0: resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} @@ -2837,15 +2301,6 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} - unplugin@1.0.1: - resolution: {integrity: sha512-aqrHaVBWW1JVKBHmGo33T5TxeL0qWzfvjWokObHA9bYmN7eNDkwOxmLjhioHl9878qDFMAaT51XNroRyuz7WxA==} - - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -2921,13 +2376,6 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} - webpack-sources@3.3.3: - resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==} - engines: {node: '>=10.13.0'} - - webpack-virtual-modules@0.5.0: - resolution: {integrity: sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw==} - whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -2977,10 +2425,6 @@ packages: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} - xtend@4.0.2: - resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} - engines: {node: '>=0.4'} - y18n@4.0.3: resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} @@ -2988,9 +2432,6 @@ packages: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} - yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} @@ -3025,16 +2466,6 @@ snapshots: 7zip-bin@5.2.0: {} - '@apm-js-collab/code-transformer@0.8.2': {} - - '@apm-js-collab/tracing-hooks@0.3.1': - dependencies: - '@apm-js-collab/code-transformer': 0.8.2 - debug: 4.4.3 - module-details-from-path: 1.0.4 - transitivePeerDependencies: - - supports-color - '@azure/msal-common@15.13.3': {} '@azure/msal-node@3.8.4': @@ -3043,134 +2474,34 @@ snapshots: jsonwebtoken: 9.0.3 uuid: 8.3.2 - '@babel/code-frame@7.27.1': + '@babel/runtime@7.28.4': {} + + '@develar/schema-utils@2.6.5': dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 + ajv: 6.12.6 + ajv-keywords: 3.5.2(ajv@6.12.6) + + '@electron/asar@3.4.1': + dependencies: + commander: 5.1.0 + glob: 7.2.3 + minimatch: 3.1.2 - '@babel/compat-data@7.28.5': {} - - '@babel/core@7.28.5': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.5 - '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) - '@babel/helpers': 7.28.4 - '@babel/parser': 7.28.5 - '@babel/template': 7.27.2 - '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 - '@jridgewell/remapping': 2.3.5 - convert-source-map: 2.0.0 + '@electron/get@2.0.3': + dependencies: debug: 4.4.3 - gensync: 1.0.0-beta.2 - json5: 2.2.3 + env-paths: 2.2.1 + fs-extra: 8.1.0 + got: 11.8.6 + progress: 2.0.3 semver: 6.3.1 + sumchecker: 3.0.1 + optionalDependencies: + global-agent: 3.0.0 transitivePeerDependencies: - supports-color - '@babel/generator@7.28.5': - dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - - '@babel/helper-compilation-targets@7.27.2': - dependencies: - '@babel/compat-data': 7.28.5 - '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.1 - lru-cache: 5.1.1 - semver: 6.3.1 - - '@babel/helper-globals@7.28.0': {} - - '@babel/helper-module-imports@7.27.1': - dependencies: - '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-module-imports': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.28.5 - transitivePeerDependencies: - - supports-color - - '@babel/helper-string-parser@7.27.1': {} - - '@babel/helper-validator-identifier@7.28.5': {} - - '@babel/helper-validator-option@7.27.1': {} - - '@babel/helpers@7.28.4': - dependencies: - '@babel/template': 7.27.2 - '@babel/types': 7.28.5 - - '@babel/parser@7.28.5': - dependencies: - '@babel/types': 7.28.5 - - '@babel/runtime@7.28.4': {} - - '@babel/template@7.27.2': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 - - '@babel/traverse@7.28.5': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.5 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.28.5 - '@babel/template': 7.27.2 - '@babel/types': 7.28.5 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - '@babel/types@7.28.5': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - - '@develar/schema-utils@2.6.5': - dependencies: - ajv: 6.12.6 - ajv-keywords: 3.5.2(ajv@6.12.6) - - '@electron/asar@3.4.1': - dependencies: - commander: 5.1.0 - glob: 7.2.3 - minimatch: 3.1.2 - - '@electron/get@2.0.3': - dependencies: - debug: 4.4.3 - env-paths: 2.2.1 - fs-extra: 8.1.0 - got: 11.8.6 - progress: 2.0.3 - semver: 6.3.1 - sumchecker: 3.0.1 - optionalDependencies: - global-agent: 3.0.0 - transitivePeerDependencies: - - supports-color - - '@electron/notarize@2.2.1': + '@electron/notarize@2.2.1': dependencies: debug: 4.4.3 fs-extra: 9.1.0 @@ -3336,25 +2667,6 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/remapping@2.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - '@malept/cross-spawn-promise@1.1.1': dependencies: cross-spawn: 7.0.6 @@ -3380,237 +2692,6 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@opentelemetry/api-logs@0.208.0': - dependencies: - '@opentelemetry/api': 1.9.0 - - '@opentelemetry/api@1.9.0': {} - - '@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - - '@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/semantic-conventions': 1.38.0 - - '@opentelemetry/instrumentation-amqplib@0.55.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-connect@0.52.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.38.0 - '@types/connect': 3.4.38 - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-dataloader@0.26.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-express@0.57.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.38.0 - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-fs@0.28.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-generic-pool@0.52.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-graphql@0.56.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-hapi@0.55.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.38.0 - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-http@0.208.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.38.0 - forwarded-parse: 2.1.2 - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-ioredis@0.56.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) - '@opentelemetry/redis-common': 0.38.2 - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-kafkajs@0.18.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.38.0 - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-knex@0.53.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.38.0 - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-koa@0.57.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.38.0 - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-lru-memoizer@0.53.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-mongodb@0.61.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-mongoose@0.55.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-mysql2@0.55.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.38.0 - '@opentelemetry/sql-common': 0.41.2(@opentelemetry/api@1.9.0) - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-mysql@0.54.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) - '@types/mysql': 2.15.27 - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-pg@0.61.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.38.0 - '@opentelemetry/sql-common': 0.41.2(@opentelemetry/api@1.9.0) - '@types/pg': 8.15.6 - '@types/pg-pool': 2.0.6 - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-redis@0.57.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) - '@opentelemetry/redis-common': 0.38.2 - '@opentelemetry/semantic-conventions': 1.38.0 - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-tedious@0.27.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) - '@types/tedious': 4.0.14 - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-undici@0.19.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.38.0 - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.208.0 - import-in-the-middle: 2.0.1 - require-in-the-middle: 8.0.1 - transitivePeerDependencies: - - supports-color - - '@opentelemetry/redis-common@0.38.2': {} - - '@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.38.0 - - '@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.38.0 - - '@opentelemetry/semantic-conventions@1.38.0': {} - - '@opentelemetry/sql-common@0.41.2(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) - '@parcel/watcher-android-arm64@2.5.1': optional: true @@ -3675,13 +2756,6 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@prisma/instrumentation@6.19.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) - transitivePeerDependencies: - - supports-color - '@rollup/rollup-android-arm-eabi@4.55.1': optional: true @@ -3757,175 +2831,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.55.1': optional: true - '@sentry-internal/browser-utils@10.29.0': - dependencies: - '@sentry/core': 10.29.0 - - '@sentry-internal/feedback@10.29.0': - dependencies: - '@sentry/core': 10.29.0 - - '@sentry-internal/replay-canvas@10.29.0': - dependencies: - '@sentry-internal/replay': 10.29.0 - '@sentry/core': 10.29.0 - - '@sentry-internal/replay@10.29.0': - dependencies: - '@sentry-internal/browser-utils': 10.29.0 - '@sentry/core': 10.29.0 - - '@sentry/babel-plugin-component-annotate@4.6.1': {} - - '@sentry/browser@10.29.0': - dependencies: - '@sentry-internal/browser-utils': 10.29.0 - '@sentry-internal/feedback': 10.29.0 - '@sentry-internal/replay': 10.29.0 - '@sentry-internal/replay-canvas': 10.29.0 - '@sentry/core': 10.29.0 - - '@sentry/bundler-plugin-core@4.6.1': - dependencies: - '@babel/core': 7.28.5 - '@sentry/babel-plugin-component-annotate': 4.6.1 - '@sentry/cli': 2.58.4 - dotenv: 16.6.1 - find-up: 5.0.0 - glob: 10.5.0 - magic-string: 0.30.8 - unplugin: 1.0.1 - transitivePeerDependencies: - - encoding - - supports-color - - '@sentry/cli-darwin@2.58.4': - optional: true - - '@sentry/cli-linux-arm64@2.58.4': - optional: true - - '@sentry/cli-linux-arm@2.58.4': - optional: true - - '@sentry/cli-linux-i686@2.58.4': - optional: true - - '@sentry/cli-linux-x64@2.58.4': - optional: true - - '@sentry/cli-win32-arm64@2.58.4': - optional: true - - '@sentry/cli-win32-i686@2.58.4': - optional: true - - '@sentry/cli-win32-x64@2.58.4': - optional: true - - '@sentry/cli@2.58.4': - dependencies: - https-proxy-agent: 5.0.1 - node-fetch: 2.7.0 - progress: 2.0.3 - proxy-from-env: 1.1.0 - which: 2.0.2 - optionalDependencies: - '@sentry/cli-darwin': 2.58.4 - '@sentry/cli-linux-arm': 2.58.4 - '@sentry/cli-linux-arm64': 2.58.4 - '@sentry/cli-linux-i686': 2.58.4 - '@sentry/cli-linux-x64': 2.58.4 - '@sentry/cli-win32-arm64': 2.58.4 - '@sentry/cli-win32-i686': 2.58.4 - '@sentry/cli-win32-x64': 2.58.4 - transitivePeerDependencies: - - encoding - - supports-color - - '@sentry/core@10.29.0': {} - - '@sentry/electron@7.5.0': - dependencies: - '@sentry/browser': 10.29.0 - '@sentry/core': 10.29.0 - '@sentry/node': 10.29.0 - transitivePeerDependencies: - - supports-color - - '@sentry/node-core@10.29.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.38.0)': - dependencies: - '@apm-js-collab/tracing-hooks': 0.3.1 - '@opentelemetry/api': 1.9.0 - '@opentelemetry/context-async-hooks': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.38.0 - '@sentry/core': 10.29.0 - '@sentry/opentelemetry': 10.29.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.38.0) - import-in-the-middle: 2.0.1 - transitivePeerDependencies: - - supports-color - - '@sentry/node@10.29.0': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/context-async-hooks': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-amqplib': 0.55.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-connect': 0.52.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-dataloader': 0.26.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-express': 0.57.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-fs': 0.28.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-generic-pool': 0.52.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-graphql': 0.56.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-hapi': 0.55.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-http': 0.208.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-ioredis': 0.56.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-kafkajs': 0.18.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-knex': 0.53.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-koa': 0.57.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-lru-memoizer': 0.53.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-mongodb': 0.61.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-mongoose': 0.55.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-mysql': 0.54.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-mysql2': 0.55.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-pg': 0.61.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-redis': 0.57.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-tedious': 0.27.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-undici': 0.19.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.38.0 - '@prisma/instrumentation': 6.19.0(@opentelemetry/api@1.9.0) - '@sentry/core': 10.29.0 - '@sentry/node-core': 10.29.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.38.0) - '@sentry/opentelemetry': 10.29.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.38.0) - import-in-the-middle: 2.0.1 - minimatch: 9.0.5 - transitivePeerDependencies: - - supports-color - - '@sentry/opentelemetry@10.29.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.38.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/context-async-hooks': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.38.0 - '@sentry/core': 10.29.0 - - '@sentry/vite-plugin@4.6.1': - dependencies: - '@sentry/bundler-plugin-core': 4.6.1 - unplugin: 1.0.1 - transitivePeerDependencies: - - encoding - - supports-color - '@sindresorhus/is@4.6.0': {} '@supabase/auth-js@2.89.0': @@ -3983,10 +2888,6 @@ snapshots: '@types/node': 20.19.27 '@types/responselike': 1.0.3 - '@types/connect@3.4.38': - dependencies: - '@types/node': 20.19.27 - '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -4007,10 +2908,6 @@ snapshots: '@types/ms@2.1.0': {} - '@types/mysql@2.15.27': - dependencies: - '@types/node': 20.19.27 - '@types/node@18.19.130': dependencies: undici-types: 5.26.5 @@ -4019,16 +2916,6 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/pg-pool@2.0.6': - dependencies: - '@types/pg': 8.15.6 - - '@types/pg@8.15.6': - dependencies: - '@types/node': 20.19.27 - pg-protocol: 1.10.3 - pg-types: 2.2.0 - '@types/phoenix@1.6.7': {} '@types/plist@3.0.5': @@ -4043,10 +2930,6 @@ snapshots: '@types/semver@7.7.1': {} - '@types/tedious@4.0.14': - dependencies: - '@types/node': 20.19.27 - '@types/uuid@10.0.0': {} '@types/verror@1.10.11': @@ -4151,10 +3034,6 @@ snapshots: '@xmldom/xmldom@0.8.11': {} - acorn-import-attributes@1.9.5(acorn@8.15.0): - dependencies: - acorn: 8.15.0 - acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -4224,11 +3103,6 @@ snapshots: dependencies: entities: 2.2.0 - anymatch@3.1.3: - dependencies: - normalize-path: 3.0.0 - picomatch: 2.3.1 - app-builder-bin@4.0.0: {} app-builder-lib@24.13.3(dmg-builder@24.13.3)(electron-builder-squirrel-windows@24.13.3): @@ -4336,10 +3210,6 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.9.12: {} - - binary-extensions@2.3.0: {} - bl@4.1.0: dependencies: buffer: 5.7.1 @@ -4368,14 +3238,6 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.28.1: - dependencies: - baseline-browser-mapping: 2.9.12 - caniuse-lite: 1.0.30001762 - electron-to-chromium: 1.5.267 - node-releases: 2.0.27 - update-browserslist-db: 1.2.3(browserslist@4.28.1) - buffer-crc32@0.2.13: {} buffer-equal-constant-time@1.0.1: {} @@ -4445,8 +3307,6 @@ snapshots: camelcase@5.3.1: {} - caniuse-lite@1.0.30001762: {} - chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -4454,18 +3314,6 @@ snapshots: chardet@0.7.0: {} - chokidar@3.6.0: - dependencies: - anymatch: 3.1.3 - braces: 3.0.3 - glob-parent: 5.1.2 - is-binary-path: 2.1.0 - is-glob: 4.0.3 - normalize-path: 3.0.0 - readdirp: 3.6.0 - optionalDependencies: - fsevents: 2.3.3 - chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -4476,8 +3324,6 @@ snapshots: ci-info@3.9.0: {} - cjs-module-lexer@1.4.3: {} - cli-cursor@3.1.0: dependencies: restore-cursor: 3.1.0 @@ -4549,8 +3395,6 @@ snapshots: glob: 10.5.0 typescript: 5.9.3 - convert-source-map@2.0.0: {} - core-util-is@1.0.2: optional: true @@ -4663,8 +3507,6 @@ snapshots: dotenv-expand@5.1.0: {} - dotenv@16.6.1: {} - dotenv@9.0.2: {} dunder-proto@1.0.1: @@ -4727,8 +3569,6 @@ snapshots: conf: 10.2.0 type-fest: 2.19.0 - electron-to-chromium@1.5.267: {} - electron-updater@6.7.3: dependencies: builder-util-runtime: 9.5.1 @@ -4983,8 +3823,6 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 - forwarded-parse@2.1.2: {} - fs-constants@1.0.0: {} fs-extra@10.1.0: @@ -5017,8 +3855,6 @@ snapshots: function-bind@1.1.2: {} - gensync@1.0.0-beta.2: {} - get-caller-file@2.0.5: {} get-intrinsic@1.3.0: @@ -5205,13 +4041,6 @@ snapshots: dependencies: resolve-from: 5.0.0 - import-in-the-middle@2.0.1: - dependencies: - acorn: 8.15.0 - acorn-import-attributes: 1.9.5(acorn@8.15.0) - cjs-module-lexer: 1.4.3 - module-details-from-path: 1.0.4 - imurmurhash@0.1.4: {} inflight@1.0.6: @@ -5237,10 +4066,6 @@ snapshots: strip-ansi: 6.0.1 through: 2.3.8 - is-binary-path@2.1.0: - dependencies: - binary-extensions: 2.3.0 - is-ci@3.0.1: dependencies: ci-info: 3.9.0 @@ -5285,14 +4110,10 @@ snapshots: filelist: 1.0.4 picocolors: 1.1.1 - js-tokens@4.0.0: {} - js-yaml@4.1.1: dependencies: argparse: 2.0.1 - jsesc@3.1.0: {} - json-buffer@3.0.1: {} json-fixer@1.6.15: @@ -5410,18 +4231,10 @@ snapshots: lru-cache@10.4.3: {} - lru-cache@5.1.1: - dependencies: - yallist: 3.1.1 - lru-cache@6.0.0: dependencies: yallist: 4.0.0 - magic-string@0.30.8: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - marked@17.0.3: {} matcher@3.0.0: @@ -5487,8 +4300,6 @@ snapshots: mkdirp@1.0.4: {} - module-details-from-path@1.0.4: {} - ms@2.1.3: {} mute-stream@0.0.8: {} @@ -5509,8 +4320,6 @@ snapshots: dependencies: whatwg-url: 5.0.0 - node-releases@2.0.27: {} - normalize-path@3.0.0: {} normalize-url@6.1.0: {} @@ -5594,18 +4403,6 @@ snapshots: pend@1.2.0: {} - pg-int8@1.0.1: {} - - pg-protocol@1.10.3: {} - - pg-types@2.2.0: - dependencies: - pg-int8: 1.0.1 - postgres-array: 2.0.0 - postgres-bytea: 1.0.1 - postgres-date: 1.0.7 - postgres-interval: 1.2.0 - picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -5630,16 +4427,6 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - postgres-array@2.0.0: {} - - postgres-bytea@1.0.1: {} - - postgres-date@1.0.7: {} - - postgres-interval@1.2.0: - dependencies: - xtend: 4.0.2 - prelude-ls@1.2.1: {} prettier@2.8.8: @@ -5654,8 +4441,6 @@ snapshots: err-code: 2.0.3 retry: 0.12.0 - proxy-from-env@1.1.0: {} - pump@3.0.3: dependencies: end-of-stream: 1.4.5 @@ -5696,23 +4481,12 @@ snapshots: dependencies: minimatch: 5.1.6 - readdirp@3.6.0: - dependencies: - picomatch: 2.3.1 - readdirp@4.1.2: {} require-directory@2.1.1: {} require-from-string@2.0.2: {} - require-in-the-middle@8.0.1: - dependencies: - debug: 4.4.3 - module-details-from-path: 1.0.4 - transitivePeerDependencies: - - supports-color - require-main-filename@2.0.0: {} resolve-alpn@1.2.1: {} @@ -6001,19 +4775,6 @@ snapshots: universalify@2.0.1: {} - unplugin@1.0.1: - dependencies: - acorn: 8.15.0 - chokidar: 3.6.0 - webpack-sources: 3.3.3 - webpack-virtual-modules: 0.5.0 - - update-browserslist-db@1.2.3(browserslist@4.28.1): - dependencies: - browserslist: 4.28.1 - escalade: 3.2.0 - picocolors: 1.1.1 - uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -6054,10 +4815,6 @@ snapshots: webidl-conversions@3.0.1: {} - webpack-sources@3.3.3: {} - - webpack-virtual-modules@0.5.0: {} - whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -6097,14 +4854,10 @@ snapshots: xmlbuilder@15.1.1: {} - xtend@4.0.2: {} - y18n@4.0.3: {} y18n@5.0.8: {} - yallist@3.1.1: {} - yallist@4.0.0: {} yargs-parser@18.1.3: diff --git a/src/common/ipc/channels.ts b/src/common/ipc/channels.ts index 41ce7ead..2b25c312 100644 --- a/src/common/ipc/channels.ts +++ b/src/common/ipc/channels.ts @@ -114,7 +114,6 @@ export const UTIL_CHANNELS = { CHECK_USER_SETTINGS: "check-user-settings", CHECK_TOOL_SETTINGS: "check-tool-settings", CHECK_CONNECTIONS: "check-connections", - CHECK_SENTRY_LOGGING: "check-sentry-logging", CHECK_TOOL_DOWNLOAD: "check-tool-download", CHECK_INTERNET_CONNECTIVITY: "check-internet-connectivity", } as const; diff --git a/src/common/logger.ts b/src/common/logger.ts new file mode 100644 index 00000000..60cdafde --- /dev/null +++ b/src/common/logger.ts @@ -0,0 +1,79 @@ +/** + * Centralized application logger + * + * All application logging flows through this module so that the underlying + * implementation (currently console.*) can be swapped out in one place when + * a replacement telemetry or structured-logging solution is introduced. + * + * Usage: + * import { logInfo, logWarn, logError, logDebug, logCheckpoint } from "../../common/logger"; + * + * NOTE: This module is safe to use in both the main and renderer processes. + */ + +/** + * Log an informational message. + */ +export function logInfo(message: string, data?: unknown): void { + if (data !== undefined) { + // eslint-disable-next-line no-console + console.info(message, data); + } else { + // eslint-disable-next-line no-console + console.info(message); + } +} + +/** + * Log a warning message. + */ +export function logWarn(message: string, data?: unknown): void { + if (data !== undefined) { + // eslint-disable-next-line no-console + console.warn(message, data); + } else { + // eslint-disable-next-line no-console + console.warn(message); + } +} + +/** + * Log an error message or Error object. + */ +export function logError(messageOrError: string | Error, data?: unknown): void { + if (data !== undefined) { + // eslint-disable-next-line no-console + console.error(messageOrError, data); + } else { + // eslint-disable-next-line no-console + console.error(messageOrError); + } +} + +/** + * Log a debug message. + */ +export function logDebug(message: string, data?: unknown): void { + if (data !== undefined) { + // eslint-disable-next-line no-console + console.debug(message, data); + } else { + // eslint-disable-next-line no-console + console.debug(message); + } +} + +/** + * Log a key application checkpoint / milestone (e.g. startup stages). + * These map to console.log so that they are always visible regardless of + * the browser/Node console log-level filter. + */ +export function logCheckpoint(message: string, data?: unknown): void { + if (data !== undefined) { + // eslint-disable-next-line no-console + console.log(message, data); + } else { + // eslint-disable-next-line no-console + console.log(message); + } +} diff --git a/src/common/sentry.ts b/src/common/sentry.ts deleted file mode 100644 index 6fdc5b3b..00000000 --- a/src/common/sentry.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * Sentry configuration for telemetry and error tracking - * This file provides initialization logic for both main and renderer processes - */ - -/** - * Sentry initialization options - */ -export interface SentryConfig { - dsn: string; - environment?: string; - release?: string; - enableTracing?: boolean; - tracesSampleRate?: number; - replaysSessionSampleRate?: number; - replaysOnErrorSampleRate?: number; -} - -/** - * Get Sentry configuration from environment - * Returns null if Sentry DSN is not configured - */ -export function getSentryConfig(): SentryConfig | null { - const dsn = process.env.SENTRY_DSN; - - // If no DSN is configured, return null to disable Sentry - if (!dsn || dsn.trim() === "") { - return null; - } - - // Determine environment (production, development, etc.) - // In Electron main process, we check if app is packaged - // In renderer process, we check NODE_ENV - let environment: string; - let release = "unknown"; - - // Try to detect if we're in main process by checking for electron module availability - // and attempting to access main process APIs - try { - // This will only work in main process - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { app } = require("electron"); - if (app && typeof app.isPackaged !== "undefined") { - // Successfully accessed main process app - we're in main process - environment = app.isPackaged ? "production" : "development"; - release = `powerplatform-toolbox@${app.getVersion()}`; - } else { - // app exists but isPackaged is undefined - fall back to NODE_ENV - environment = process.env.NODE_ENV || "development"; - } - } catch (error) { - // Failed to access electron.app - likely in renderer process or other context - environment = process.env.NODE_ENV || "development"; - // Try to get version from package.json if available - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const pkg = require("../../package.json"); - release = `powerplatform-toolbox@${pkg.version}`; - } catch (pkgError) { - // If package.json is not available, use default - release = "powerplatform-toolbox@unknown"; - } - } - - return { - dsn, - environment, - release, - enableTracing: true, - tracesSampleRate: environment === "production" ? 0.1 : 1.0, // 10% in production, 100% in development - replaysSessionSampleRate: environment === "production" ? 0.1 : 1.0, // 10% in production, 100% in development - replaysOnErrorSampleRate: 1.0, // Always capture replays on error - }; -} - -/** - * Check if Sentry is enabled - */ -export function isSentryEnabled(): boolean { - return getSentryConfig() !== null; -} diff --git a/src/common/sentryHelper.ts b/src/common/sentryHelper.ts deleted file mode 100644 index b421bff4..00000000 --- a/src/common/sentryHelper.ts +++ /dev/null @@ -1,492 +0,0 @@ -/** - * Sentry helper utilities for enhanced logging and tracing - * Provides utility functions to add context, breadcrumbs, and install ID to all Sentry events - * - * NOTE: This helper can be used in both main and renderer processes, but must import - * Sentry from the appropriate subpath in the calling code - */ - -// Define types for Sentry operations (these are compatible with both main and renderer) -export interface SentryScope { - setTag(key: string, value: string): void; - setExtra(key: string, value: unknown): void; - setLevel(level: string): void; - clear(): void; -} - -export interface SentryTransaction { - setStatus(status: string): void; - finish(): void; -} - -let installId: string | null = null; -// Use any type for flexibility across different Sentry module versions -// eslint-disable-next-line @typescript-eslint/no-explicit-any -let sentryModule: any = null; - -// Environment detection - determines if we're in development mode -let isDevelopment = false; - -/** - * Detect if we're running in development mode - * This checks both NODE_ENV and whether the app is packaged (Electron main process) - * @returns true if in development mode, false otherwise - */ -function isDevelopmentEnvironment(): boolean { - // Check NODE_ENV first - if (process.env.NODE_ENV === "development") { - return true; - } - - // Try to detect if we're in Electron main process and check if app is packaged - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { app } = require("electron"); - return !app.isPackaged; - } catch { - // Not in main process or electron not available - // Default to production mode for safety - return false; - } -} - -/** - * Initialize the Sentry helper with the Sentry module - * Call this from main or renderer after importing the appropriate Sentry module - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function initializeSentryHelper(sentry: any): void { - sentryModule = sentry; - isDevelopment = isDevelopmentEnvironment(); -} - -/** - * Set the install ID to be included in all Sentry events - * This should be called early in the application initialization - */ -export function setSentryInstallId(id: string): void { - installId = id; - - if (!sentryModule) return; - - // Set as user context so it appears in all events - sentryModule.setUser({ - id: id, - username: `install-${id}`, - }); - - // Also set as a tag for easier filtering - sentryModule.setTag("install_id", id); - - logInfo(`[Sentry] Install ID set: ${id}`); -} - -/** - * Get the current install ID - */ -export function getSentryInstallId(): string | null { - return installId; -} - -/** - * Add a breadcrumb with install ID context - * Breadcrumbs help recreate the sequence of events leading to an error - */ -export function addBreadcrumb(message: string, category: string, level: "debug" | "info" | "warning" | "error" = "info", data?: Record): void { - if (!sentryModule) return; - - sentryModule.addBreadcrumb({ - message, - category, - level, - data: { - ...data, - install_id: installId, - timestamp: new Date().toISOString(), - }, - }); -} - -/** - * Start a new Sentry span for performance monitoring - * Use this for important operations like tool loading, connection testing, etc. - * - * Note: Returns a simple transaction-like object that's compatible with both old and new Sentry APIs - */ -export function startTransaction(name: string, op: string, data?: Record): SentryTransaction | undefined { - if (!sentryModule) return undefined; - - // Create a simple wrapper that's compatible with our needs - // For newer Sentry versions, we just track timing in breadcrumbs instead of full transactions - const startTime = Date.now(); - let finished = false; - let status = "ok"; - - const transactionWrapper: SentryTransaction = { - setStatus: (newStatus: string) => { - status = newStatus; - }, - finish: () => { - if (!finished) { - finished = true; - const duration = Date.now() - startTime; - - // Add breadcrumb with timing information (use debug level to avoid creating Issues) - addBreadcrumb(`Operation ${name} finished`, "performance", "debug", { - operation: name, - op, - duration_ms: duration, - status, - ...data, - }); - - // Log to structured logger instead - logDebug(`Operation ${name} completed: ${duration}ms`, { - operation: name, - op, - duration_ms: duration, - status, - ...data, - }); - } - }, - }; - - // Add breadcrumb for operation start - addBreadcrumb(`Operation ${name} started`, "performance", "debug", { - operation: name, - op, - ...data, - }); - - logDebug(`Operation ${name} started`, { - operation: name, - op, - ...data, - }); - - return transactionWrapper; -} - -/** - * Capture an exception with enhanced context - */ -export function captureException( - error: Error, - context?: { - tags?: Record; - extra?: Record; - level?: string; - }, -): void { - if (!sentryModule) return; - - // Log the error using the appropriate log level - const level = context?.level || "error"; - const errorMessage = `${error.name}: ${error.message}`; - const errorData = { - ...context?.extra, - ...context?.tags, - stack: error.stack, - }; - - if (level === "fatal") { - logFatal(errorMessage, errorData); - } else { - logError(errorMessage, errorData); - } - - sentryModule.withScope((scope: SentryScope) => { - // Add install ID to scope - scope.setTag("install_id", installId || "unknown"); - - // Add any custom tags - if (context?.tags) { - Object.entries(context.tags).forEach(([key, value]) => { - scope.setTag(key, value); - }); - } - - // Add any custom extra data - if (context?.extra) { - Object.entries(context.extra).forEach(([key, value]) => { - scope.setExtra(key, value); - }); - } - - // Set level if provided - if (context?.level) { - scope.setLevel(context.level); - } - - sentryModule.captureException(error); - }); -} - -/** - * Capture a message with enhanced context - * Use this ONLY for error/warning level messages that should appear as Issues - * For info/debug messages, use the logInfo/logDebug functions instead - */ -export function captureMessage( - message: string, - level: "fatal" | "error" | "warning" = "error", - context?: { - tags?: Record; - extra?: Record; - }, -): void { - if (!sentryModule) return; - - // Log using the appropriate structured logger for full traceability - const logData = { - ...context?.extra, - ...context?.tags, - }; - - switch (level) { - case "fatal": - logFatal(message, logData); - break; - case "error": - logError(message, logData); - break; - case "warning": - logWarn(message, logData); - break; - } - - // Create Sentry Issue with install ID context - sentryModule.withScope((scope: SentryScope) => { - scope.setTag("install_id", installId || "unknown"); - - if (context?.tags) { - Object.entries(context.tags).forEach(([key, value]) => { - scope.setTag(key, value); - }); - } - - if (context?.extra) { - Object.entries(context.extra).forEach(([key, value]) => { - scope.setExtra(key, value); - }); - } - - sentryModule.captureMessage(message, level); - }); -} - -/** - * Set context for a specific area of the application - * This helps organize errors by feature/module - */ -export function setContext(key: string, value: Record): void { - if (!sentryModule) return; - - sentryModule.setContext(key, { - ...value, - install_id: installId, - }); -} - -/** - * Wrap an async function with error capturing and performance tracking - * Use this for critical operations to ensure errors are captured with full context - */ -export function wrapAsyncOperation( - operationName: string, - operation: () => Promise, - context?: { - tags?: Record; - extra?: Record; - }, -): Promise { - const transaction = startTransaction(operationName, "function"); - - logDebug(`Starting operation: ${operationName}`, context?.extra); - - return operation() - .then((result) => { - transaction?.setStatus("ok"); - transaction?.finish(); - addBreadcrumb(`${operationName} completed successfully`, "operation", "info"); - logInfo(`Operation completed: ${operationName}`, { - operation: operationName, - ...context?.extra, - }); - return result; - }) - .catch((error) => { - transaction?.setStatus("internal_error"); - transaction?.finish(); - - captureException(error instanceof Error ? error : new Error(String(error)), { - tags: { - operation: operationName, - ...context?.tags, - }, - extra: { - ...context?.extra, - }, - level: "error", - }); - - addBreadcrumb(`${operationName} failed: ${error}`, "operation", "error"); - throw error; - }); -} - -/** - * Log an important application junction/checkpoint - * Use this at critical points in the application flow - */ -export function logCheckpoint(checkpoint: string, data?: Record): void { - // Always log to console for local debugging - // eslint-disable-next-line no-console - console.log(`[Checkpoint] ${checkpoint}`, data ? JSON.stringify(data, null, 2) : ""); - - // Log to Sentry using structured logger - logInfo(`Checkpoint: ${checkpoint}`, data); - - // Add as breadcrumb for context - addBreadcrumb(checkpoint, "checkpoint", "info", data); -} - -/** - * Set custom tags that will be included in all subsequent events - */ -export function setTags(tags: Record): void { - if (!sentryModule) return; - - Object.entries(tags).forEach(([key, value]) => { - sentryModule.setTag(key, value); - }); -} - -/** - * Clear the current scope (useful when switching contexts) - */ -export function clearScope(): void { - if (!sentryModule) return; - - sentryModule.configureScope((scope: SentryScope) => scope.clear()); -} - -/** - * Sentry Logger API wrappers - * These functions use Sentry's structured logging API for better log organization - */ - -/** - * Log a trace message to Sentry - * Use for detailed diagnostic information - * Note: Only sent to Sentry in development mode - */ -export function logTrace(message: string, data?: Record): void { - // Log to console in development mode only (very verbose) - if (isDevelopment) { - // eslint-disable-next-line no-console - console.debug(`[TRACE] ${message}`, data || ""); - } - - // Only send to Sentry in development mode to reduce noise - if (!sentryModule || !sentryModule.logger || !isDevelopment) return; - - sentryModule.logger.trace(message, { - ...data, - install_id: installId, - }); -} - -/** - * Log a debug message to Sentry - * Use for debugging information during development - * Note: Only sent to Sentry in development mode - */ -export function logDebug(message: string, data?: Record): void { - // Log to console in development mode only (verbose) - if (isDevelopment) { - // eslint-disable-next-line no-console - console.debug(`[DEBUG] ${message}`, data || ""); - } - - // Only send to Sentry in development mode to reduce noise - if (!sentryModule || !sentryModule.logger || !isDevelopment) return; - - sentryModule.logger.debug(message, { - ...data, - install_id: installId, - }); -} - -/** - * Log an info message to Sentry - * Use for general informational messages - * Note: Sent to Sentry in all environments, but creates breadcrumbs not Issues - */ -export function logInfo(message: string, data?: Record): void { - // Always log to console for debugging - // eslint-disable-next-line no-console - console.info(`[INFO] ${message}`, data || ""); - - if (!sentryModule || !sentryModule.logger) return; - - sentryModule.logger.info(message, { - ...data, - install_id: installId, - }); -} - -/** - * Log a warning message to Sentry - * Use for warning conditions that should be reviewed - * Note: Sent to Sentry in all environments - */ -export function logWarn(message: string, data?: Record): void { - // Always log to console for debugging - // eslint-disable-next-line no-console - console.warn(`[WARN] ${message}`, data || ""); - - if (!sentryModule || !sentryModule.logger) return; - - sentryModule.logger.warn(message, { - ...data, - install_id: installId, - }); -} - -/** - * Log an error message to Sentry - * Use for error conditions that need attention - * Note: Sent to Sentry in all environments - */ -export function logError(message: string, data?: Record): void { - // Always log to console for debugging - // eslint-disable-next-line no-console - console.error(`[ERROR] ${message}`, data || ""); - - if (!sentryModule || !sentryModule.logger) return; - - sentryModule.logger.error(message, { - ...data, - install_id: installId, - }); -} - -/** - * Log a fatal error message to Sentry - * Use for critical errors that require immediate attention - * Note: Sent to Sentry in all environments - */ -export function logFatal(message: string, data?: Record): void { - // Always log to console for debugging - // eslint-disable-next-line no-console - console.error(`[FATAL] ${message}`, data || ""); - - if (!sentryModule || !sentryModule.logger) return; - - sentryModule.logger.fatal(message, { - ...data, - install_id: installId, - }); -} diff --git a/src/common/types/api.ts b/src/common/types/api.ts index dbf5633e..d38357d2 100644 --- a/src/common/types/api.ts +++ b/src/common/types/api.ts @@ -86,7 +86,6 @@ export interface TroubleshootingAPI { checkUserSettings: () => Promise<{ success: boolean; message?: string }>; checkToolSettings: () => Promise<{ success: boolean; message?: string }>; checkConnections: () => Promise<{ success: boolean; message?: string; connectionCount?: number }>; - checkSentryLogging: () => Promise<{ success: boolean; message?: string }>; checkToolDownload: () => Promise<{ success: boolean; message?: string }>; checkInternetConnectivity: () => Promise<{ success: boolean; message?: string }>; } diff --git a/src/main/index.ts b/src/main/index.ts index ee03f9be..c388f906 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,61 +1,3 @@ -// Initialize Sentry as early as possible in the main process -import * as Sentry from "@sentry/electron/main"; -import { getSentryConfig } from "../common/sentry"; -import { addBreadcrumb, captureException, captureMessage, initializeSentryHelper, logCheckpoint, logInfo, setSentryInstallId } from "../common/sentryHelper"; - -const sentryConfig = getSentryConfig(); -if (sentryConfig) { - Sentry.init({ - dsn: sentryConfig.dsn, - environment: sentryConfig.environment, - release: sentryConfig.release, - tracesSampleRate: sentryConfig.tracesSampleRate, - // Enable Sentry logger for structured logging only in development to reduce telemetry noise - // In production, we rely on captureException/captureMessage for explicit error reporting - enableLogs: sentryConfig.environment === "development", - // Capture unhandled promise rejections and console errors - integrations: [ - Sentry.captureConsoleIntegration({ - levels: ["error", "warn"], - }), - // Add HTTP integration for network request tracing - Sentry.httpIntegration(), - // Add Node integrations for better context - Sentry.nodeContextIntegration(), - Sentry.contextLinesIntegration(), - Sentry.localVariablesIntegration(), - Sentry.modulesIntegration(), - ], - // Before sending events, add install ID and additional context - beforeSend(event) { - // Ensure install ID is in tags - if (!event.tags) { - event.tags = {}; - } - event.tags.process = "main"; - - // Add platform information - if (!event.contexts) { - event.contexts = {}; - } - event.contexts.os = { - name: process.platform, - version: process.getSystemVersion ? process.getSystemVersion() : "unknown", - }; - - return event; - }, - }); - - // Initialize the helper with the Sentry module - initializeSentryHelper(Sentry); - - logInfo("[Sentry] Initialized in main process with tracing and logging"); - addBreadcrumb("Main process Sentry initialized", "init", "info"); -} else { - logInfo("[Sentry] Telemetry disabled - no DSN configured"); -} - import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, MenuItemConstructorOptions, nativeTheme, shell } from "electron"; import * as fs from "fs"; import { createWriteStream } from "fs"; @@ -84,6 +26,7 @@ import { ModalWindowOptions, ToolBoxEvent, } from "../common/types"; +import { logInfo, logWarn, logError, logCheckpoint } from "../common/logger"; import { AuthManager } from "./managers/authManager"; import { AutoUpdateManager } from "./managers/autoUpdateManager"; import { BrowserManager } from "./managers/browserManager"; @@ -136,13 +79,6 @@ class ToolBoxApp { this.settingsManager = new SettingsManager(); this.installIdManager = new InstallIdManager(this.settingsManager); - // Initialize Sentry with install ID as early as possible - if (sentryConfig) { - const installId = this.installIdManager.getInstallId(); - setSentryInstallId(installId); - logCheckpoint("Sentry install ID configured", { installId }); - } - this.connectionsManager = new ConnectionsManager(); this.api = new ToolBoxUtilityManager(); // Pass Supabase credentials and Azure Blob base URL from environment variables @@ -168,10 +104,7 @@ class ToolBoxApp { logCheckpoint("ToolBoxApp constructor completed"); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); - captureException(err, { - tags: { phase: "construction" }, - level: "fatal", - }); + logError(err); throw error; } } @@ -748,10 +681,7 @@ class ToolBoxApp { return { success: true }; } catch (error) { const errorMessage = `Failed to refresh token for connection '${connection.name}': ${(error as Error).message}`; - captureException(error instanceof Error ? error : new Error(String(error)), { - tags: { phase: "token_refresh", connectionId, connectionName: connection.name }, - level: "error", - }); + logError(error instanceof Error ? error : new Error(String(error))); throw new Error(errorMessage); } }); @@ -998,17 +928,7 @@ class ToolBoxApp { this.loadingOverlayWindowManager.show(message || "Loading...", bounds); } catch (error) { // Capture bounds retrieval failure for diagnostics, then fall back to full window overlay - captureException(error instanceof Error ? error : new Error(String(error)), { - extra: { - source: "UTIL_CHANNELS.SHOW_LOADING", - context: "Failed to compute active tool bounds for loading overlay; falling back to full-window overlay.", - hasLoadingOverlayWindowManager: !!this.loadingOverlayWindowManager, - hasMainWindow: !!this.mainWindow, - hasToolWindowManager: !!this.toolWindowManager, - activeToolId: this.toolWindowManager?.getActiveToolId() || null, - message, - }, - }); + logError(error instanceof Error ? error : new Error(String(error))); // On error, show without bounds (full window fallback) this.loadingOverlayWindowManager.show(message || "Loading..."); } @@ -1062,10 +982,6 @@ class ToolBoxApp { return await this.checkConnections(); }); - ipcMain.handle(UTIL_CHANNELS.CHECK_SENTRY_LOGGING, async () => { - return await this.checkSentryLogging(); - }); - ipcMain.handle(UTIL_CHANNELS.CHECK_TOOL_DOWNLOAD, async () => { return await this.checkToolDownload(); }); @@ -2367,7 +2283,7 @@ class ToolBoxApp { /** * Show About dialog with version and environment info - * Includes install ID and other important information for Sentry tracing + * Includes install ID and other important information for diagnostics */ private showAboutDialog(): void { if (this.mainWindow) { @@ -2418,18 +2334,10 @@ class ToolBoxApp { logInfo(`[Troubleshooting] Supabase connectivity check passed: ${tools.length} tools found`); return { success: true, message: `Connected successfully. Found ${tools.length} tools in registry.` }; } - captureMessage("[Troubleshooting] Supabase returned invalid data", "warning", { - extra: { toolsType: typeof tools, toolsValue: tools }, - }); + logWarn("[Troubleshooting] Supabase returned invalid data"); return { success: false, message: "Unable to fetch tools from registry" }; } catch (error) { - captureException(error as Error, { - tags: { check: "supabase-connectivity" }, - extra: { - errorMessage: error instanceof Error ? error.message : String(error), - errorStack: error instanceof Error ? error.stack : undefined, - }, - }); + logError(error as Error); return { success: false, message: error instanceof Error ? error.message : "Unknown error connecting to Supabase", @@ -2445,9 +2353,7 @@ class ToolBoxApp { const toolsDirectory = path.join(app.getPath("userData"), "tools"); if (!fs.existsSync(toolsDirectory)) { - captureMessage("[Troubleshooting] Tools directory missing for local registry check", "warning", { - extra: { toolsDirectory }, - }); + logWarn("[Troubleshooting] Tools directory missing for local registry check"); return { success: false, message: "Tools directory not found. Launch a tool at least once to initialize it.", @@ -2456,9 +2362,7 @@ class ToolBoxApp { const manifestPath = path.join(toolsDirectory, "manifest.json"); if (!fs.existsSync(manifestPath)) { - captureMessage("[Troubleshooting] Local manifest file not found", "warning", { - extra: { manifestPath }, - }); + logWarn("[Troubleshooting] Local manifest file not found"); return { success: false, message: "Local manifest file not found under tools directory", @@ -2476,13 +2380,7 @@ class ToolBoxApp { toolCount: tools.length, }; } catch (error) { - captureException(error as Error, { - tags: { check: "registry-file" }, - extra: { - errorMessage: error instanceof Error ? error.message : String(error), - errorStack: error instanceof Error ? error.stack : undefined, - }, - }); + logError(error as Error); return { success: false, message: error instanceof Error ? error.message : "Failed to read local manifest", @@ -2509,26 +2407,13 @@ class ToolBoxApp { message: "Internet connectivity verified via GitHub", }; } - captureMessage("[Troubleshooting] Internet connectivity check returned non-OK status", "warning", { - extra: { - url: INTERNET_CHECK_URL, - status: response.status, - statusText: response.statusText, - }, - }); + logWarn("[Troubleshooting] Internet connectivity check returned non-OK status"); return { success: false, message: `Internet connectivity check failed: HTTP ${response.status}`, }; } catch (error) { - captureException(error as Error, { - tags: { check: "internet-connectivity" }, - extra: { - url: INTERNET_CHECK_URL, - errorMessage: error instanceof Error ? error.message : String(error), - errorStack: error instanceof Error ? error.stack : undefined, - }, - }); + logError(error as Error); return { success: false, message: error instanceof Error ? error.message : "Network error during internet connectivity check", @@ -2621,19 +2506,10 @@ class ToolBoxApp { fs.rmSync(tempDir, { recursive: true, force: true }); } } catch (cleanupError) { - captureMessage("[Troubleshooting] Failed to clean up download test artifacts", "warning", { - extra: { cleanupError: cleanupError instanceof Error ? cleanupError.message : String(cleanupError) }, - }); + logWarn("[Troubleshooting] Failed to clean up download test artifacts"); } - captureException(error as Error, { - tags: { check: "tool-download" }, - extra: { - downloadUrl: TEST_TOOL_DOWNLOAD_URL, - errorMessage: error instanceof Error ? error.message : String(error), - errorStack: error instanceof Error ? error.stack : undefined, - }, - }); + logError(error as Error); return { success: false, message: error instanceof Error ? error.message : "Unknown error during download test", @@ -2649,9 +2525,7 @@ class ToolBoxApp { try { const settings = this.settingsManager.getUserSettings(); if (!settings) { - captureMessage("[Troubleshooting] User settings returned null or undefined", "error", { - extra: { settingsValue: settings }, - }); + logError("[Troubleshooting] User settings returned null or undefined"); return { success: false, message: "User settings file could not be loaded", @@ -2667,12 +2541,7 @@ class ToolBoxApp { if (!hasTheme) missingFields.push("theme"); if (!hasAutoUpdate) missingFields.push("autoUpdate"); - captureMessage("[Troubleshooting] User settings missing required fields", "warning", { - extra: { - missingFields, - settingsKeys: Object.keys(settings), - }, - }); + logWarn("[Troubleshooting] User settings missing required fields"); return { success: false, @@ -2686,13 +2555,7 @@ class ToolBoxApp { message: `User settings loaded successfully (${Object.keys(settings).length} properties)`, }; } catch (error) { - captureException(error as Error, { - tags: { check: "user-settings" }, - extra: { - errorMessage: error instanceof Error ? error.message : String(error), - errorStack: error instanceof Error ? error.stack : undefined, - }, - }); + logError(error as Error); return { success: false, message: error instanceof Error ? error.message : "Failed to load user settings", @@ -2728,13 +2591,7 @@ class ToolBoxApp { message: `Tool settings accessible (${toolSettingsCount} configured out of ${installedTools.length} loaded tools)`, }; } catch (error) { - captureException(error as Error, { - tags: { check: "tool-settings" }, - extra: { - errorMessage: error instanceof Error ? error.message : String(error), - errorStack: error instanceof Error ? error.stack : undefined, - }, - }); + logError(error as Error); return { success: false, message: error instanceof Error ? error.message : "Failed to access tool settings", @@ -2751,12 +2608,7 @@ class ToolBoxApp { const connections = this.connectionsManager.getConnections(); if (!Array.isArray(connections)) { - captureMessage("[Troubleshooting] Connections is not an array", "error", { - extra: { - connectionsType: typeof connections, - connectionsValue: connections, - }, - }); + logError("[Troubleshooting] Connections is not an array"); return { success: false, message: "Connections data is corrupted (not an array)", @@ -2780,13 +2632,7 @@ class ToolBoxApp { } if (invalidConnections.length > 0) { - captureMessage("[Troubleshooting] Some connections have invalid structure", "warning", { - extra: { - totalConnections: connections.length, - validConnections, - invalidConnections, - }, - }); + logWarn("[Troubleshooting] Some connections have invalid structure"); } logInfo(`[Troubleshooting] Connections check passed: ${validConnections} valid connections out of ${connections.length} total`); @@ -2796,13 +2642,7 @@ class ToolBoxApp { connectionCount: validConnections, }; } catch (error) { - captureException(error as Error, { - tags: { check: "connections" }, - extra: { - errorMessage: error instanceof Error ? error.message : String(error), - errorStack: error instanceof Error ? error.stack : undefined, - }, - }); + logError(error as Error); return { success: false, message: error instanceof Error ? error.message : "Failed to load connections", @@ -2810,58 +2650,6 @@ class ToolBoxApp { } } - /** - * Check Sentry logging functionality - * Tests if Sentry is configured and can send events - */ - private async checkSentryLogging(): Promise<{ success: boolean; message?: string }> { - try { - const sentryConfig = getSentryConfig(); - - if (!sentryConfig || !sentryConfig.dsn) { - logInfo("[Troubleshooting] Sentry is not configured (DSN missing)"); - return { - success: true, - message: "Sentry telemetry disabled (no DSN configured)", - }; - } - - // Test Sentry by sending a test message - const testMessage = `[Troubleshooting] Sentry connectivity test at ${new Date().toISOString()}`; - captureMessage(testMessage, "warning", { - tags: { - check: "sentry-logging", - testEvent: "true", - }, - extra: { - installId: this.installIdManager.getInstallId(), - appVersion: app.getVersion(), - platform: process.platform, - arch: process.arch, - }, - }); - - logInfo("[Troubleshooting] Sentry test message sent successfully"); - return { - success: true, - message: `Sentry configured and test event sent (DSN: ${sentryConfig.dsn.substring(0, 20)}...)`, - }; - } catch (error) { - // Even if this fails, we want to capture it to Sentry - captureException(error as Error, { - tags: { check: "sentry-logging" }, - extra: { - errorMessage: error instanceof Error ? error.message : String(error), - errorStack: error instanceof Error ? error.stack : undefined, - }, - }); - return { - success: false, - message: error instanceof Error ? error.message : "Failed to test Sentry logging", - }; - } - } - /** * Initialize the application */ @@ -2872,28 +2660,23 @@ class ToolBoxApp { // Set app user model ID for Windows notifications if (process.platform === "win32") { app.setAppUserModelId("com.powerplatform.toolbox"); - addBreadcrumb("Set Windows app user model ID", "init", "info"); } // Register custom protocol scheme before app is ready this.browserviewProtocolManager.registerScheme(); - addBreadcrumb("Registered custom protocol scheme", "init", "info"); // Register deep link protocol handler (pptb://) this.protocolHandlerManager.registerScheme(); - addBreadcrumb("Registered pptb:// protocol scheme", "init", "info"); // Initialize early protocol listeners (single-instance lock, open-url, second-instance) // MUST be called before app.whenReady() so no deep link is missed. this.protocolHandlerManager.initialize(); - addBreadcrumb("Protocol handler early listeners registered", "init", "info"); await app.whenReady(); logCheckpoint("Electron app ready"); // Register protocol handler after app is ready this.browserviewProtocolManager.registerHandler(); - addBreadcrumb("Registered protocol handler", "init", "info"); this.createWindow(); logCheckpoint("Main window created"); @@ -2933,22 +2716,13 @@ class ToolBoxApp { } } }); - addBreadcrumb("Protocol handler callback registered", "init", "info"); // Load all installed tools from registry try { await this.toolManager.loadAllInstalledTools(); logCheckpoint("Tools loaded from registry"); } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); - captureException(err, { - tags: { phase: "tool_loading" }, - level: "error", - }); - captureException(error instanceof Error ? error : new Error(String(error)), { - tags: { phase: "tools_loading" }, - level: "error", - }); + logError(error instanceof Error ? error : new Error(String(error))); } // Check if auto-update is enabled @@ -2956,24 +2730,20 @@ class ToolBoxApp { if (autoUpdate) { // Enable automatic update checks every 6 hours this.autoUpdateManager.enableAutoUpdateChecks(6); - addBreadcrumb("Auto-update enabled", "settings", "info", { intervalHours: 6 }); } // Clear any msal caches on startup await this.authManager.cleanup(); this.connectionsManager.clearAllConnectionTokens(); - addBreadcrumb("Cleared MSAL caches and connection tokens", "auth", "info"); app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) { this.createWindow(); - addBreadcrumb("Window recreated on activate", "window", "info"); } }); app.on("window-all-closed", () => { if (process.platform !== "darwin") { - addBreadcrumb("All windows closed, quitting app", "window", "info"); app.quit(); } }); @@ -2988,16 +2758,12 @@ class ToolBoxApp { this.authManager.cleanup(); // Clean up connection tokens this.connectionsManager.clearAllConnectionTokens(); - addBreadcrumb("Cleanup completed", "shutdown", "info"); }); logCheckpoint("Application initialization completed successfully"); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); - captureException(err, { - tags: { phase: "initialization" }, - level: "fatal", - }); + logError(err); logCheckpoint("Application initialization failed", { error: err.message }); throw error; } @@ -3007,12 +2773,5 @@ class ToolBoxApp { // Create and initialize the application const toolboxApp = new ToolBoxApp(); toolboxApp.initialize().catch((error) => { - captureException(error instanceof Error ? error : new Error(String(error)), { - tags: { phase: "main_initialization" }, - level: "fatal", - }); - // If Sentry is available, capture the error - if (sentryConfig) { - Sentry.captureException(error); - } + logError(error instanceof Error ? error : new Error(String(error))); }); diff --git a/src/main/managers/authManager.ts b/src/main/managers/authManager.ts index 080b3711..78c07fc2 100644 --- a/src/main/managers/authManager.ts +++ b/src/main/managers/authManager.ts @@ -3,10 +3,10 @@ import { BrowserWindow } from "electron"; import * as http from "http"; import * as https from "https"; import { EVENT_CHANNELS } from "../../common/ipc/channels"; -import { captureMessage, logInfo, logWarn } from "../../common/sentryHelper"; import { DataverseConnection } from "../../common/types"; import { DATAVERSE_API_VERSION } from "../constants"; import { BrowserManager } from "./browserManager"; +import { logInfo, logWarn, logError } from "../../common/logger"; /** * Manages authentication for Power Platform connections @@ -146,9 +146,7 @@ export class AuthManager { return authResult; } catch (error) { - captureMessage("Interactive authentication failed:", "error", { - extra: { error }, - }); + logError("Interactive authentication failed", error); // Error is already displayed in the localhost browser page during listenForAuthCodeAndValidate // No need to show modal dialog as it causes UI conflicts throw new Error(`Authentication failed: ${(error as Error).message}`); @@ -440,9 +438,7 @@ export class AuthManager { return authResult; } catch (error) { - captureMessage("Client secret authentication failed:", "error", { - extra: { error }, - }); + logError("Client secret authentication failed", error); const errorMessage = `Authentication failed: ${(error as Error).message}`; // Show error in a modal dialog (for main window context) // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -495,9 +491,7 @@ export class AuthManager { msalAccountId: response.account?.homeAccountId, // Store for silent token acquisition }; } catch (error) { - captureMessage("Username/password authentication failed:", "error", { - extra: { error }, - }); + logError("Username/password authentication failed", error); // Extract error message from MSAL error or generic error let errorMessage = ""; @@ -583,9 +577,7 @@ export class AuthManager { throw new Error("Connection test failed: Unable to verify identity"); } catch (error) { - captureMessage("Test connection failed:", "error", { - extra: { error }, - }); + logError("Test connection failed", error); throw error; } } @@ -764,9 +756,7 @@ export class AuthManager { }; } catch (error) { // Silent acquisition failed - likely refresh token expired - captureMessage("Silent token acquisition failed - re-authentication required", "warning", { - extra: { error, connectionId: connection.id }, - }); + logWarn("Silent token acquisition failed - re-authentication required"); throw new Error("Token refresh failed. Please authenticate again."); } } @@ -802,9 +792,7 @@ export class AuthManager { expiresOn: new Date(Date.now() + data.expires_in * 1000), }; } catch (error) { - captureMessage("Token refresh failed:", "error", { - extra: { error }, - }); + logError("Token refresh failed", error); throw new Error(`Token refresh failed: ${(error as Error).message}`); } } diff --git a/src/main/managers/browserManager.ts b/src/main/managers/browserManager.ts index a4e82afd..0f07d6c7 100644 --- a/src/main/managers/browserManager.ts +++ b/src/main/managers/browserManager.ts @@ -3,8 +3,8 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { shell } from "electron"; -import { logInfo, logWarn } from "../../common/sentryHelper"; import { DataverseConnection } from "../../common/types"; +import { logInfo, logWarn } from "../../common/logger"; /** * Manages browser detection, profile enumeration, and browser launching @@ -84,7 +84,7 @@ export class BrowserManager { return this.getChromiumProfiles(browserType, platform); } } catch (error) { - logWarn(`Failed to get profiles for ${browserType}: ${(error as Error).message}`); + logWarn(`Failed to get profiles for ${browserType}`, error); return []; } @@ -155,7 +155,7 @@ export class BrowserManager { } } } catch (error) { - logWarn(`Failed to read Local State file, falling back to directory scan: ${(error as Error).message}`); + logWarn("Failed to read Local State file, falling back to directory scan", error); } // Fallback: Scan directories and try to read individual Preferences files @@ -198,7 +198,7 @@ export class BrowserManager { } } } catch (error) { - logWarn(`Failed to scan browser profile directories: ${(error as Error).message}`); + logWarn("Failed to scan browser profile directories", error); } return profiles; @@ -315,7 +315,7 @@ export class BrowserManager { }).unref(); } catch (error) { // If browser launch fails, fallback to default browser - logWarn(`Failed to launch ${browserType} with profile, falling back to default: ${(error as Error).message}`); + logWarn(`Failed to launch ${browserType} with profile, falling back to default`, error); return shell.openExternal(url); } } diff --git a/src/main/managers/browserviewProtocolManager.ts b/src/main/managers/browserviewProtocolManager.ts index b188b19a..3ac9c375 100644 --- a/src/main/managers/browserviewProtocolManager.ts +++ b/src/main/managers/browserviewProtocolManager.ts @@ -1,10 +1,10 @@ import { app, protocol } from "electron"; import * as fs from "fs"; import * as path from "path"; -import { captureMessage, logInfo } from "../../common/sentryHelper"; import { normalizeCspExceptionSource } from "../../common/types"; import { SettingsManager } from "./settingsManager"; import { ToolManager } from "./toolsManager"; +import { logInfo, logError } from "../../common/logger"; /** * BrowserviewProtocolManager @@ -77,9 +77,7 @@ export class BrowserviewProtocolManager { const tool = this.toolManager.getAllTools().find((t) => t.id === toolId); if (!tool) { - captureMessage(`[pptb-webview] Tool not found: ${toolId}`, "error", { - extra: { toolId, filePath }, - }); + logError(`[pptb-webview] Tool not found: ${toolId}`); callback({ error: -6 }); // FILE_NOT_FOUND return; } @@ -87,9 +85,7 @@ export class BrowserviewProtocolManager { // Determine the tool's base directory const toolBaseDir = this.getToolBaseDirectory(tool); if (!toolBaseDir) { - captureMessage(`[pptb-webview] Cannot determine tool directory for: ${toolId}`, "error", { - extra: { toolId }, - }); + logError(`[pptb-webview] Cannot determine tool directory for: ${toolId}`); callback({ error: -6 }); return; } @@ -99,18 +95,14 @@ export class BrowserviewProtocolManager { // Security: Ensure the path is within the tool's directory if (!this.isPathSafe(fullPath, toolBaseDir)) { - captureMessage(`[pptb-webview] Path traversal attempt blocked: ${fullPath}`, "error", { - extra: { fullPath }, - }); + logError(`[pptb-webview] Path traversal attempt blocked: ${fullPath}`); callback({ error: -6 }); return; } // Check if file exists if (!fs.existsSync(fullPath)) { - captureMessage(`[pptb-webview] File not found: ${fullPath}`, "error", { - extra: { fullPath }, - }); + logError(`[pptb-webview] File not found: ${fullPath}`); callback({ error: -6 }); return; } @@ -153,9 +145,7 @@ export class BrowserviewProtocolManager { }); return; } catch (error) { - captureMessage(`[pptb-webview] Error injecting CSP/bridge: ${(error as Error).message}`, "error", { - extra: { error }, - }); + logError("[pptb-webview] Error injecting CSP/bridge", error); callback({ error: -2 }); // FAILED return; } @@ -169,9 +159,7 @@ export class BrowserviewProtocolManager { data: content, }); } catch (error) { - captureMessage(`[pptb-webview] Error handling protocol request: ${(error as Error).message}`, "error", { - extra: { error }, - }); + logError("[pptb-webview] Error handling protocol request", error); callback({ error: -2 }); // FAILED } } diff --git a/src/main/managers/connectionsManager.ts b/src/main/managers/connectionsManager.ts index cd39b794..c1ff9d6e 100644 --- a/src/main/managers/connectionsManager.ts +++ b/src/main/managers/connectionsManager.ts @@ -1,7 +1,7 @@ import Store from "electron-store"; -import { logInfo } from "../../common/sentryHelper"; import { DataverseConnection } from "../../common/types"; import { EncryptionManager } from "./encryptionManager"; +import { logInfo } from "../../common/logger"; /** * Sensitive fields that should be encrypted in DataverseConnection objects diff --git a/src/main/managers/dataverseManager.ts b/src/main/managers/dataverseManager.ts index de829bb9..971b031d 100644 --- a/src/main/managers/dataverseManager.ts +++ b/src/main/managers/dataverseManager.ts @@ -11,7 +11,7 @@ import { LocalizedLabel, MetadataOperationOptions, } from "../../common/types"; -import { captureMessage } from "../../common/sentryHelper"; +import { logWarn, logError } from "../../common/logger"; import { DATAVERSE_API_VERSION } from "../constants"; import { AuthManager } from "./authManager"; import { ConnectionsManager } from "./connectionsManager"; @@ -191,9 +191,7 @@ export class DataverseManager { if (!hasAccount) { // MSAL cache is empty (e.g., after app restart), clear stored tokens to force re-authentication this.connectionsManager.clearConnectionTokens(connectionId); - captureMessage("MSAL account not found in cache - tokens cleared", "warning", { - extra: { connectionId, connectionName: connection.name }, - }); + logWarn("MSAL account not found in cache - tokens cleared"); throw new Error(errorMessage); } } @@ -227,9 +225,7 @@ export class DataverseManager { } catch (error) { // Silent acquisition failed - re-auth required const errorMessage = `Authentication expired for connection '${connection.name}'. Please reconnect to continue.`; - captureMessage("MSAL silent token acquisition failed", "error", { - extra: { connectionId, connectionName: connection.name, error }, - }); + logError("MSAL silent token acquisition failed", error); throw new Error(errorMessage); } } @@ -253,9 +249,7 @@ export class DataverseManager { return { connection, accessToken: authResult.accessToken }; } catch (error) { const errorMessage = `Client secret authentication failed for '${connection.name}'. Please verify your credentials.`; - captureMessage("Client secret authentication failed", "error", { - extra: { connectionId, connectionName: connection.name, error }, - }); + logError("Client secret authentication failed", error); throw new Error(errorMessage); } } @@ -284,9 +278,7 @@ export class DataverseManager { } catch (error) { // Silent token acquisition failed - user needs to re-authenticate const errorMessage = `Token refresh failed for '${connection.name}'. Please re-enter your credentials.`; - captureMessage("Username/password silent token acquisition failed", "error", { - extra: { connectionId, connectionName: connection.name, error }, - }); + logError("Username/password silent token acquisition failed", error); throw new Error(errorMessage); } } @@ -309,9 +301,7 @@ export class DataverseManager { return { connection, accessToken: authResult.accessToken }; } catch (error) { const errorMessage = `Token refresh failed for '${connection.name}'. Please re-enter your credentials.`; - captureMessage("Username/password token refresh failed", "error", { - extra: { connectionId, connectionName: connection.name, error }, - }); + logError("Username/password token refresh failed", error); throw new Error(errorMessage); } } @@ -336,9 +326,7 @@ export class DataverseManager { return { connection, accessToken: authResult.accessToken }; } catch (error) { const errorMessage = `Token refresh failed for '${connection.name}'. Please sign in again.`; - captureMessage("Legacy interactive token refresh failed", "warning", { - extra: { connectionId, connectionName: connection.name, error }, - }); + logWarn("Legacy interactive token refresh failed"); throw new Error(errorMessage); } } diff --git a/src/main/managers/encryptionManager.ts b/src/main/managers/encryptionManager.ts index 78c8b572..c8b49ca3 100644 --- a/src/main/managers/encryptionManager.ts +++ b/src/main/managers/encryptionManager.ts @@ -1,5 +1,5 @@ import { safeStorage } from "electron"; -import { captureMessage } from "../../common/sentryHelper"; +import { logError, logWarn } from "../../common/logger"; /** * Manages encryption and decryption of sensitive data using Electron's safeStorage API @@ -23,7 +23,7 @@ export class EncryptionManager { } if (!this.isEncryptionAvailable()) { - captureMessage("Encryption not available, storing data in plain text", "warning"); + logWarn("Encryption not available, returning data as-is"); return plaintext; } @@ -40,7 +40,7 @@ export class EncryptionManager { } if (!this.isEncryptionAvailable()) { - captureMessage("Encryption not available, returning data as-is", "warning"); + logWarn("Encryption not available, returning data as-is"); return encrypted; } @@ -48,9 +48,7 @@ export class EncryptionManager { const buffer = Buffer.from(encrypted, "base64"); return safeStorage.decryptString(buffer); } catch (error) { - captureMessage("Failed to decrypt data:", "error", { - extra: { error }, - }); + logError("Failed to decrypt data", error); // If decryption fails, it might be plain text from before encryption was added // Return as-is for backwards compatibility return encrypted; diff --git a/src/main/managers/installIdManager.ts b/src/main/managers/installIdManager.ts index 41960772..84e8d7c6 100644 --- a/src/main/managers/installIdManager.ts +++ b/src/main/managers/installIdManager.ts @@ -1,6 +1,6 @@ import { randomUUID } from "crypto"; -import { logInfo } from "../../common/sentryHelper"; import { SettingsManager } from "./settingsManager"; +import { logInfo } from "../../common/logger"; /** * Manages a unique install identifier for analytics purposes diff --git a/src/main/managers/modalWindowManager.ts b/src/main/managers/modalWindowManager.ts index 1be29f35..7558f7b5 100644 --- a/src/main/managers/modalWindowManager.ts +++ b/src/main/managers/modalWindowManager.ts @@ -1,8 +1,8 @@ import { BrowserWindow } from "electron"; import * as path from "path"; import { EVENT_CHANNELS, MODAL_WINDOW_CHANNELS } from "../../common/ipc/channels"; -import { captureMessage } from "../../common/sentryHelper"; import { ModalWindowClosedPayload, ModalWindowMessagePayload, ModalWindowOptions } from "../../common/types"; +import { logError } from "../../common/logger"; const MIN_MODAL_WIDTH = 280; const MIN_MODAL_HEIGHT = 180; @@ -54,7 +54,7 @@ export class ModalWindowManager { } }) .catch((error) => { - captureMessage("Failed to load modal content", "error", { extra: { error } }); + logError("Failed to load modal content", error); }); } diff --git a/src/main/managers/protocolHandlerManager.ts b/src/main/managers/protocolHandlerManager.ts index 343f6fca..f29506d7 100644 --- a/src/main/managers/protocolHandlerManager.ts +++ b/src/main/managers/protocolHandlerManager.ts @@ -1,5 +1,5 @@ import { app } from "electron"; -import { captureException, captureMessage, logInfo } from "../../common/sentryHelper"; +import { logInfo, logWarn, logError } from "../../common/logger"; /** * Protocol URL structure for tool installation @@ -59,7 +59,7 @@ export class ProtocolHandlerManager { registerScheme(): void { try { if (app.isReady()) { - captureMessage("[ProtocolHandler] Warning: registerScheme called after app is ready. This may not work correctly.", "warning"); + logWarn("[ProtocolHandler] Warning: registerScheme called after app is ready. This may not work correctly."); } // Register the scheme as standard to allow query parameters @@ -67,10 +67,7 @@ export class ProtocolHandlerManager { logInfo(`[ProtocolHandler] Registered ${ProtocolHandlerManager.PROTOCOL_SCHEME}:// as default protocol client`); } catch (error) { - captureException(error instanceof Error ? error : new Error(String(error)), { - tags: { manager: "ProtocolHandler", phase: "register_scheme" }, - level: "error", - }); + logError(error instanceof Error ? error : new Error(String(error))); } } @@ -95,7 +92,7 @@ export class ProtocolHandlerManager { app.on("open-url", (event, url) => { event.preventDefault(); logInfo(`[ProtocolHandler] Received open-url event: ${url}`); - this.bufferOrHandle(url, "open-url"); + this.bufferOrHandle(url); }); // Windows/Linux: a second instance forwards its command line here. @@ -104,7 +101,7 @@ export class ProtocolHandlerManager { const url = commandLine.find((arg) => arg.startsWith(`${ProtocolHandlerManager.PROTOCOL_SCHEME}://`)); if (url) { logInfo(`[ProtocolHandler] Processing protocol URL from second instance: ${url}`); - this.bufferOrHandle(url, "second-instance"); + this.bufferOrHandle(url); } }); @@ -133,9 +130,7 @@ export class ProtocolHandlerManager { for (const url of buffered) { logInfo(`[ProtocolHandler] Processing buffered protocol URL: ${url}`); this.handleProtocolUrl(url).catch((error) => { - captureException(error instanceof Error ? error : new Error(String(error)), { - tags: { manager: "ProtocolHandler", trigger: "buffered" }, - }); + logError(error instanceof Error ? error : new Error(String(error))); }); } @@ -146,12 +141,10 @@ export class ProtocolHandlerManager { * Buffer the URL for later processing, or handle it immediately if the * callback has already been registered. */ - private bufferOrHandle(url: string, trigger: string): void { + private bufferOrHandle(url: string): void { if (this.protocolCallback) { this.handleProtocolUrl(url).catch((error) => { - captureException(error instanceof Error ? error : new Error(String(error)), { - tags: { manager: "ProtocolHandler", trigger }, - }); + logError(error instanceof Error ? error : new Error(String(error))); }); } else { this.pendingUrls.push(url); @@ -166,7 +159,7 @@ export class ProtocolHandlerManager { try { // Validate protocol scheme if (!urlString.startsWith(`${ProtocolHandlerManager.PROTOCOL_SCHEME}://`)) { - captureMessage(`[ProtocolHandler] Invalid protocol scheme: ${urlString}`, "warning"); + logWarn(`[ProtocolHandler] Invalid protocol scheme: ${urlString}`); return null; } @@ -175,9 +168,7 @@ export class ProtocolHandlerManager { // Validate action (host part of URL) const action = url.hostname.toLowerCase(); if (!ProtocolHandlerManager.ALLOWED_ACTIONS.includes(action as ProtocolAction)) { - captureMessage(`[ProtocolHandler] Invalid action: ${action}`, "warning", { - extra: { allowed: ProtocolHandlerManager.ALLOWED_ACTIONS }, - }); + logWarn(`[ProtocolHandler] Invalid action: ${action}`); return null; } @@ -187,14 +178,14 @@ export class ProtocolHandlerManager { // Validate required parameters if (!toolId) { - captureMessage("[ProtocolHandler] Missing required parameter: toolId", "warning"); + logWarn("[ProtocolHandler] Missing required parameter: toolId"); return null; } // Sanitize and validate toolId const sanitizedToolId = this.sanitizeToolId(toolId); if (!sanitizedToolId) { - captureMessage(`[ProtocolHandler] Invalid toolId format: ${toolId}`, "warning"); + logWarn(`[ProtocolHandler] Invalid toolId format: ${toolId}`); return null; } @@ -209,10 +200,7 @@ export class ProtocolHandlerManager { }, }; } catch (error) { - captureException(error instanceof Error ? error : new Error(String(error)), { - tags: { manager: "ProtocolHandler", phase: "parse_url" }, - extra: { url: urlString }, - }); + logError(error instanceof Error ? error : new Error(String(error))); return null; } } @@ -284,12 +272,7 @@ export class ProtocolHandlerManager { // Check if rate limit exceeded if (this.recentProtocolRequests.length >= ProtocolHandlerManager.MAX_REQUESTS_PER_WINDOW) { - captureMessage("[ProtocolHandler] Rate limit exceeded", "warning", { - extra: { - requestCount: this.recentProtocolRequests.length, - window: ProtocolHandlerManager.RATE_LIMIT_WINDOW_MS, - }, - }); + logWarn("[ProtocolHandler] Rate limit exceeded"); return false; } @@ -306,22 +289,20 @@ export class ProtocolHandlerManager { // Check rate limit if (!this.checkRateLimit()) { - captureMessage("[ProtocolHandler] Protocol request blocked due to rate limiting", "warning"); + logWarn("[ProtocolHandler] Protocol request blocked due to rate limiting"); return; } // Parse and validate URL const parsed = this.parseProtocolUrl(urlString); if (!parsed) { - captureMessage("[ProtocolHandler] Failed to parse or validate protocol URL", "warning", { - extra: { url: urlString }, - }); + logWarn("[ProtocolHandler] Failed to parse or validate protocol URL"); return; } // Invoke callback if registered if (!this.protocolCallback) { - captureMessage("[ProtocolHandler] No protocol callback registered", "warning"); + logWarn("[ProtocolHandler] No protocol callback registered"); return; } @@ -329,13 +310,7 @@ export class ProtocolHandlerManager { await this.protocolCallback(parsed.action, parsed.params); logInfo(`[ProtocolHandler] Protocol action completed: ${parsed.action} for tool ${parsed.params.toolId}`); } catch (error) { - captureException(error instanceof Error ? error : new Error(String(error)), { - tags: { manager: "ProtocolHandler", phase: "handle_callback" }, - extra: { - action: parsed.action, - toolId: parsed.params.toolId, - }, - }); + logError(error instanceof Error ? error : new Error(String(error))); } } } diff --git a/src/main/managers/terminalManager.ts b/src/main/managers/terminalManager.ts index e6e0c09c..dc5a75af 100644 --- a/src/main/managers/terminalManager.ts +++ b/src/main/managers/terminalManager.ts @@ -1,8 +1,8 @@ import { ChildProcessWithoutNullStreams, spawn } from "child_process"; import { randomUUID } from "crypto"; import { EventEmitter } from "events"; -import { captureMessage, logInfo } from "../../common/sentryHelper"; import { Terminal, TerminalCommandResult, TerminalOptions } from "../../common/types"; +import { logInfo, logWarn } from "../../common/logger"; /** * Manages terminal instances for tools @@ -48,7 +48,7 @@ export class TerminalManager extends EventEmitter { // Verify shell exists, fallback to default if not if (options.shell && !(await this.shellExists(options.shell))) { - captureMessage(`Shell ${options.shell} not found, using default shell ${this.defaultShell}`, "warning"); + logWarn(`Shell ${options.shell} not found, using default shell ${this.defaultShell}`); shell = this.defaultShell; } diff --git a/src/main/managers/toolFileSystemAccessManager.ts b/src/main/managers/toolFileSystemAccessManager.ts index 50bcddd9..63268f06 100644 --- a/src/main/managers/toolFileSystemAccessManager.ts +++ b/src/main/managers/toolFileSystemAccessManager.ts @@ -1,5 +1,5 @@ import * as path from "path"; -import { logInfo, logWarn } from "../../common/sentryHelper"; +import { logInfo, logWarn } from "../../common/logger"; /** * Manages filesystem access permissions for tools diff --git a/src/main/managers/toolRegistryManager.ts b/src/main/managers/toolRegistryManager.ts index 55bc7bea..bb23721f 100644 --- a/src/main/managers/toolRegistryManager.ts +++ b/src/main/managers/toolRegistryManager.ts @@ -6,10 +6,10 @@ import * as http from "http"; import * as https from "https"; import * as path from "path"; import { pipeline } from "stream/promises"; -import { captureMessage, logInfo } from "../../common/sentryHelper"; import { CspExceptions, ToolManifest, ToolRegistryEntry } from "../../common/types"; import { AZURE_BLOB_BASE_URL, SUPABASE_ANON_KEY, SUPABASE_URL } from "../constants"; import { InstallIdManager } from "./installIdManager"; +import { logInfo, logWarn, logError } from "../../common/logger"; /** * Supabase database types @@ -153,8 +153,8 @@ export class ToolRegistryManager extends EventEmitter { // Validate Supabase credentials and create client if (!url || !key || url === "" || key === "") { - captureMessage("[ToolRegistry] Supabase credentials not configured. Set SUPABASE_URL and SUPABASE_ANON_KEY environment variables.", "warning"); - captureMessage("[ToolRegistry] Falling back to local registry.json file.", "warning"); + logWarn("[ToolRegistry] Supabase credentials not configured. Set SUPABASE_URL and SUPABASE_ANON_KEY environment variables."); + logWarn("[ToolRegistry] Falling back to local registry.json file."); this.useLocalFallback = true; } else { logInfo("[ToolRegistry] Initializing Supabase client"); @@ -309,9 +309,7 @@ export class ToolRegistryManager extends EventEmitter { logInfo(`[ToolRegistry] Fetched ${tools.length} tools (enhanced) from Supabase registry`); return tools; } catch (error) { - captureMessage(`[ToolRegistry] Failed to fetch registry from Supabase: ${(error as Error).message}`, "error", { - extra: { error }, - }); + logError("[ToolRegistry] Failed to fetch registry from Supabase", error); throw new Error(`Failed to fetch registry: ${error instanceof Error ? error.message : String(error)}`); } } @@ -328,9 +326,7 @@ export class ToolRegistryManager extends EventEmitter { return tools; } } catch (error) { - captureMessage(`[ToolRegistry] Azure Blob registry fetch failed, falling back to local: ${(error as Error).message}`, "warning", { - extra: { error }, - }); + logWarn(`[ToolRegistry] Azure Blob registry fetch failed`); } } return this.fetchLocalRegistry(); @@ -408,7 +404,7 @@ export class ToolRegistryManager extends EventEmitter { */ private resolveDownloadUrl(downloadUrl: string): string { if (!downloadUrl) { - captureMessage("[ToolRegistry] Tool entry has no downloadUrl; tool cannot be installed from this registry source", "warning"); + logWarn("[ToolRegistry] Tool entry has no downloadUrl; tool cannot be installed from this registry source"); return ""; } if (downloadUrl.startsWith("http://") || downloadUrl.startsWith("https://")) { @@ -423,7 +419,7 @@ export class ToolRegistryManager extends EventEmitter { return `${base}/packages/${folder}/${filename}`; } // No base URL configured – cannot resolve - captureMessage(`[ToolRegistry] Cannot resolve relative download URL "${downloadUrl}": AZURE_BLOB_BASE_URL is not configured`, "warning"); + logWarn(`[ToolRegistry] Cannot resolve relative download URL "${downloadUrl}": AZURE_BLOB_BASE_URL is not configured`); return ""; } @@ -435,7 +431,7 @@ export class ToolRegistryManager extends EventEmitter { logInfo(`[ToolRegistry] Fetching registry from local file: ${this.localRegistryPath}`); if (!fs.existsSync(this.localRegistryPath)) { - captureMessage(`[ToolRegistry] Local registry file not found at ${this.localRegistryPath}`, "warning"); + logWarn(`[ToolRegistry] Local registry file not found at ${this.localRegistryPath}`); return []; } @@ -475,9 +471,7 @@ export class ToolRegistryManager extends EventEmitter { logInfo(`[ToolRegistry] Fetched ${tools.length} tools from local registry`); return tools; } catch (error) { - captureMessage(`[ToolRegistry] Failed to fetch local registry: ${(error as Error).message}`, "error", { - extra: { error }, - }); + logError("[ToolRegistry] Failed to fetch local registry", error); throw new Error(`Failed to fetch local registry: ${error instanceof Error ? error.message : String(error)}`); } } @@ -674,9 +668,7 @@ export class ToolRegistryManager extends EventEmitter { // Track the download (async, don't wait for completion) this.trackToolDownload(toolId).catch((error) => { - captureMessage(`[ToolRegistry] Failed to track download asynchronously: ${(error as Error).message}`, "error", { - extra: { error }, - }); + logError("[ToolRegistry] Failed to track download asynchronously", error); }); return manifest; @@ -730,9 +722,7 @@ export class ToolRegistryManager extends EventEmitter { const tools: Record[] = manifest.tools || []; return tools.map((entry) => this.normalizeManifestEntry(entry)); } catch (error) { - captureMessage(`[ToolRegistry] Failed to read manifest: ${(error as Error).message}`, "error", { - extra: { error }, - }); + logError("[ToolRegistry] Failed to read manifest", error); return []; } } @@ -794,9 +784,7 @@ export class ToolRegistryManager extends EventEmitter { const { data, error } = await this.supabase!.from("tools").select("id, tool_analytics(downloads,rating,mau)").in("id", toolIds); if (error) { - captureMessage(`[ToolRegistry] Failed to fetch analytics: ${(error as Error).message}`, "error", { - extra: { error }, - }); + logError(`[ToolRegistry] Failed to fetch analytics: ${(error as Error).message}`); return map; } @@ -807,9 +795,7 @@ export class ToolRegistryManager extends EventEmitter { } }); } catch (error) { - captureMessage(`[ToolRegistry] Error fetching analytics: ${(error as Error).message}`, "error", { - extra: { error }, - }); + logError("[ToolRegistry] Error fetching analytics", error); } return map; @@ -938,9 +924,7 @@ export class ToolRegistryManager extends EventEmitter { logInfo(`[ToolRegistry] Download tracked successfully for ${toolId} (total: ${newDownloads})`); } catch (error) { // Log but don't throw - analytics failures shouldn't break tool installation - captureMessage(`[ToolRegistry] Failed to track download for ${toolId}: ${(error as Error).message}`, "error", { - extra: { error }, - }); + logError(`[ToolRegistry] Failed to track download for ${toolId}`, error); } } @@ -958,7 +942,7 @@ export class ToolRegistryManager extends EventEmitter { // Skip if no install ID manager available if (!this.installIdManager) { - captureMessage(`[ToolRegistry] Skipping usage tracking (no InstallIdManager)`, "warning"); + logWarn(`[ToolRegistry] Skipping usage tracking (no InstallIdManager)`); return; } @@ -1016,9 +1000,7 @@ export class ToolRegistryManager extends EventEmitter { logInfo(`[ToolRegistry] Usage tracked successfully for ${toolId} (MAU: ${count})`); } catch (error) { // Log but don't throw - analytics failures shouldn't break tool functionality - captureMessage(`[ToolRegistry] Failed to track usage for ${toolId}: ${(error as Error).message}`, "error", { - extra: { error }, - }); + logError(`[ToolRegistry] Failed to track usage for ${toolId}`, error); } } } diff --git a/src/main/managers/toolWindowManager.ts b/src/main/managers/toolWindowManager.ts index 17ef8e74..bc5341db 100644 --- a/src/main/managers/toolWindowManager.ts +++ b/src/main/managers/toolWindowManager.ts @@ -1,7 +1,6 @@ import { BrowserView, BrowserWindow, ipcMain } from "electron"; import * as path from "path"; import { EVENT_CHANNELS, TOOL_WINDOW_CHANNELS } from "../../common/ipc/channels"; -import { captureException, captureMessage, logInfo } from "../../common/sentryHelper"; import { LastUsedToolConnectionInfo, Tool } from "../../common/types"; import { ToolBoxEvent } from "../../common/types/events"; import { BrowserviewProtocolManager } from "./browserviewProtocolManager"; @@ -10,6 +9,7 @@ import { SettingsManager } from "./settingsManager"; import { TerminalManager } from "./terminalManager"; import { ToolFileSystemAccessManager } from "./toolFileSystemAccessManager"; import { ToolManager } from "./toolsManager"; +import { logInfo, logWarn, logError } from "../../common/logger"; /** * ToolWindowManager @@ -308,9 +308,7 @@ export class ToolWindowManager { // Track tool usage for analytics (async, don't wait for completion) this.toolManager.trackToolUsage(toolId).catch((error) => { - captureMessage(`[ToolWindowManager] Failed to track tool usage asynchronously: ${(error as Error).message}`, "error", { - extra: { error }, - }); + logError("[ToolWindowManager] Failed to track tool usage asynchronously", error); }); // Add to recently used tools list @@ -323,9 +321,7 @@ export class ToolWindowManager { logInfo(`[ToolWindowManager] Tool instance launched successfully: ${instanceId}`); return true; } catch (error) { - captureMessage(`[ToolWindowManager] Error launching tool instance ${instanceId}: ${(error as Error).message}`, "error", { - extra: { error }, - }); + logError(`[ToolWindowManager] Error launching tool instance ${instanceId}`, error); return false; } @@ -339,7 +335,7 @@ export class ToolWindowManager { try { const toolView = this.toolViews.get(instanceId); if (!toolView) { - captureMessage(`[ToolWindowManager] Tool instance not found: ${instanceId}`, "error"); + logError(`[ToolWindowManager] Tool instance not found: ${instanceId}`); return false; } @@ -357,7 +353,7 @@ export class ToolWindowManager { try { (toolView as any).setAutoResize?.({ width: true, height: true }); } catch (err) { - captureMessage(`[ToolWindowManager] Error enabling auto-resize for tool view ${instanceId}: ${err}`, "warning"); + logWarn(`[ToolWindowManager] Error enabling auto-resize for tool view ${instanceId}`, err); } this.activeToolId = instanceId; this.invokeActiveToolChangedCallback(); @@ -369,9 +365,7 @@ export class ToolWindowManager { return true; } catch (error) { - captureMessage(`[ToolWindowManager] Error switching to tool instance ${instanceId}: ${(error as Error).message}`, "error", { - extra: { error }, - }); + logError(`[ToolWindowManager] Error switching to tool instance ${instanceId}`, error); return false; } } @@ -413,9 +407,7 @@ export class ToolWindowManager { logInfo(`[ToolWindowManager] Tool instance closed: ${instanceId}`); return true; } catch (error) { - captureMessage(`[ToolWindowManager] Error closing tool instance ${instanceId}: ${(error as Error).message}`, "error", { - extra: { error }, - }); + logError(`[ToolWindowManager] Error closing tool instance ${instanceId}`, error); return false; } @@ -523,7 +515,7 @@ export class ToolWindowManager { // Encourage tool content to reflow toolView.webContents.executeJavaScript("try{window.dispatchEvent(new Event('resize'));}catch(e){}", true).catch(() => {}); } catch (err) { - captureMessage("[ToolWindowManager] Error in fallback bounds update:", "error", { extra: { err } }); + logError("[ToolWindowManager] Error in fallback bounds update", err); } finally { this.boundsUpdatePending = false; } @@ -559,7 +551,7 @@ export class ToolWindowManager { toolView.setBounds(clamped); this.boundsUpdatePending = false; } catch (error) { - captureMessage("[ToolWindowManager] Error applying tool view bounds:", "error", { extra: { error } }); + logError("[ToolWindowManager] Error applying tool view bounds", error); } } @@ -582,9 +574,7 @@ export class ToolWindowManager { // Send to tool via IPC toolView.webContents.send("toolbox:context", toolContext); } catch (error) { - captureMessage(`[ToolWindowManager] Error sending context to tool ${toolId}: ${(error as Error).message}`, "error", { - extra: { error }, - }); + logError(`[ToolWindowManager] Error sending context to tool ${toolId}`, error); } } @@ -595,7 +585,7 @@ export class ToolWindowManager { async updateToolConnection(instanceId: string, primaryConnectionId: string | null, secondaryConnectionId?: string | null): Promise { const toolView = this.toolViews.get(instanceId); if (!toolView || toolView.webContents.isDestroyed()) { - captureMessage(`[ToolWindowManager] Tool instance ${instanceId} not found or destroyed`, "warning"); + logWarn(`[ToolWindowManager] Tool instance ${instanceId} not found or destroyed`); return; } @@ -692,9 +682,7 @@ export class ToolWindowManager { toolView.webContents.destroy(); } } catch (error) { - captureMessage(`[ToolWindowManager] Error destroying tool view ${toolId}: ${(error as Error).message}`, "error", { - extra: { error }, - }); + logError(`[ToolWindowManager] Error destroying tool view ${toolId}`, error); } } this.toolViews.clear(); @@ -712,9 +700,7 @@ export class ToolWindowManager { toolView.webContents.send(EVENT_CHANNELS.TOOLBOX_EVENT, eventPayload); } } catch (error) { - captureMessage(`[ToolWindowManager] Error forwarding event to tool ${toolId}: ${(error as Error).message}`, "error", { - extra: { error }, - }); + logError(`[ToolWindowManager] Error forwarding event to tool ${toolId}`, error); } } } @@ -732,13 +718,13 @@ export class ToolWindowManager { */ openDevToolsForActiveTool(): boolean { if (!this.activeToolId) { - captureMessage("[ToolWindowManager] No active tool to open DevTools for", "warning"); + logWarn("[ToolWindowManager] No active tool to open DevTools for"); return false; } const toolView = this.toolViews.get(this.activeToolId); if (!toolView || !toolView.webContents || toolView.webContents.isDestroyed()) { - captureMessage(`[ToolWindowManager] Tool view not found or destroyed: ${this.activeToolId}`, "warning"); + logWarn(`[ToolWindowManager] Tool view not found or destroyed: ${this.activeToolId}`); return false; } @@ -747,7 +733,7 @@ export class ToolWindowManager { logInfo(`[ToolWindowManager] Opened DevTools for tool: ${this.activeToolId}`); return true; } catch (error) { - captureMessage(`[ToolWindowManager] Error opening DevTools for tool ${this.activeToolId}: ${error}`, "error"); + logError(`[ToolWindowManager] Error opening DevTools for tool ${this.activeToolId}`, error); return false; } } @@ -758,7 +744,7 @@ export class ToolWindowManager { */ setOnActiveToolChanged(callback: ((activeToolId: string | null) => void) | null | undefined): void { if (callback !== null && callback !== undefined && typeof callback !== "function") { - captureMessage("[ToolWindowManager] setOnActiveToolChanged called with non-function callback", "warning"); + logWarn("[ToolWindowManager] setOnActiveToolChanged called with non-function callback"); return; } @@ -800,16 +786,7 @@ export class ToolWindowManager { } catch (error) { // Normalize error and capture with full context const normalizedError = error instanceof Error ? error : new Error(String(error)); - captureException(normalizedError, { - tags: { - component: "ToolWindowManager", - method: "getActiveToolBounds", - }, - extra: { - activeToolId: this.activeToolId, - errorMessage: normalizedError.message, - }, - }); + logError(normalizedError); return null; } } diff --git a/src/main/managers/toolsManager.ts b/src/main/managers/toolsManager.ts index 889226d8..0e675f32 100644 --- a/src/main/managers/toolsManager.ts +++ b/src/main/managers/toolsManager.ts @@ -3,11 +3,11 @@ import { EventEmitter } from "events"; import * as fs from "fs"; import * as path from "path"; import { pathToFileURL } from "url"; -import { captureMessage, logInfo } from "../../common/sentryHelper"; import { CspExceptions, Tool, ToolFeatures, ToolManifest } from "../../common/types"; import { InstallIdManager } from "./installIdManager"; import { ToolRegistryManager } from "./toolRegistryManager"; import { VersionManager } from "./versionManager"; +import { logInfo, logError } from "../../common/logger"; /** * Package.json structure for tool validation @@ -115,7 +115,7 @@ export class ToolManager extends EventEmitter { // Refresh analytics for this tool only (non-blocking) this.refreshAnalyticsForTools([toolId]).catch((error) => { - captureMessage(`[ToolManager] Failed to refresh analytics for ${toolId}:`, "error", { extra: { error } }); + logError(`[ToolManager] Failed to refresh analytics for ${toolId}`, error); }); return tool; @@ -177,7 +177,7 @@ export class ToolManager extends EventEmitter { await this.loadTool(manifest.id); toolIds.push(manifest.id); } catch (error) { - captureMessage(`Failed to load registry tool ${manifest.id}:`, "error", { extra: { error } }); + logError(`Failed to load registry tool ${manifest.id}`, error); } } @@ -401,7 +401,7 @@ export class ToolManager extends EventEmitter { return { command: process.platform === "win32" ? "npm.cmd" : "npm", name: "npm" }; } - captureMessage(`[ToolManager] Neither pnpm nor npm found globally installed`, "error"); + logError(`[ToolManager] Neither pnpm nor npm found globally installed`); return null; } @@ -441,7 +441,7 @@ export class ToolManager extends EventEmitter { install.stderr?.on("data", (data: Buffer) => { const output = data.toString(); stderr += output; - captureMessage(`[ToolManager] ${pkgManager.name} stderr: ${output}`, "error"); + logError(`[ToolManager] ${pkgManager.name} stderr: ${output}`); }); install.on("close", (code: number) => { @@ -454,7 +454,7 @@ export class ToolManager extends EventEmitter { }); install.on("error", (err: Error) => { - captureMessage(`[ToolManager] ${pkgManager.name} process error: ${err.message}`, "error"); + logError(`[ToolManager] ${pkgManager.name} process error: ${err.message}`); if (err.message.includes("ENOENT")) { const instructions = this.getInstallInstructions(); reject(new Error(`${pkgManager.name} command not found. Please install it globally:\n\n${instructions}`)); @@ -757,7 +757,7 @@ export class ToolManager extends EventEmitter { getLocalToolWebviewHtml(localPath: string): string | undefined { // Validate path safety before loading if (!this.isPathSafe(localPath)) { - captureMessage(`[ToolManager] Unsafe local path rejected: ${localPath}`, "error", { extra: { localPath } }); + logError(`[ToolManager] Unsafe local path rejected: ${localPath}`); return undefined; } diff --git a/src/main/preload.ts b/src/main/preload.ts index d1cbc79e..9c27933b 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -133,7 +133,6 @@ contextBridge.exposeInMainWorld("toolboxAPI", { checkUserSettings: () => ipcRenderer.invoke(UTIL_CHANNELS.CHECK_USER_SETTINGS), checkToolSettings: () => ipcRenderer.invoke(UTIL_CHANNELS.CHECK_TOOL_SETTINGS), checkConnections: () => ipcRenderer.invoke(UTIL_CHANNELS.CHECK_CONNECTIONS), - checkSentryLogging: () => ipcRenderer.invoke(UTIL_CHANNELS.CHECK_SENTRY_LOGGING), checkToolDownload: () => ipcRenderer.invoke(UTIL_CHANNELS.CHECK_TOOL_DOWNLOAD), checkInternetConnectivity: () => ipcRenderer.invoke(UTIL_CHANNELS.CHECK_INTERNET_CONNECTIVITY), }, diff --git a/src/main/toolPreloadBridge.ts b/src/main/toolPreloadBridge.ts index 2dfbfca7..98cf7ebf 100644 --- a/src/main/toolPreloadBridge.ts +++ b/src/main/toolPreloadBridge.ts @@ -12,8 +12,8 @@ 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 { logInfo } from "../common/sentryHelper"; import type { EntityRelatedMetadataPath, EntityRelatedMetadataResponse } from "../common/types"; +import { logInfo } from "../common/logger"; // Tool context received from main process let toolContext: Record | null = null; diff --git a/src/renderer/modals/troubleshooting/controller.ts b/src/renderer/modals/troubleshooting/controller.ts index 2f14273f..ac029ba9 100644 --- a/src/renderer/modals/troubleshooting/controller.ts +++ b/src/renderer/modals/troubleshooting/controller.ts @@ -68,7 +68,6 @@ export function getTroubleshootingModalControllerScript(config: TroubleshootingM setCheckStatus("check-user-settings", "pending", "Ready to check"); setCheckStatus("check-tool-settings", "pending", "Ready to check"); setCheckStatus("check-connections", "pending", "Ready to check"); - setCheckStatus("check-sentry", "pending", "Ready to check"); setCheckStatus("check-internet", "pending", "Ready to check"); setCheckStatus("check-supabase", "pending", "Ready to check"); setCheckStatus("check-registry", "pending", "Ready to check"); @@ -95,9 +94,6 @@ export function getTroubleshootingModalControllerScript(config: TroubleshootingM case "connections": checkId = "check-connections"; break; - case "sentry": - checkId = "check-sentry"; - break; case "supabase": checkId = "check-supabase"; break; @@ -152,10 +148,6 @@ export function getTroubleshootingModalControllerScript(config: TroubleshootingM modalBridge.send(CONFIG.channels.runCheck, { checkType: "connections" }); await new Promise(resolve => setTimeout(resolve, 400)); - setCheckStatus("check-sentry", "loading", "Checking Sentry logging..."); - modalBridge.send(CONFIG.channels.runCheck, { checkType: "sentry" }); - await new Promise(resolve => setTimeout(resolve, 400)); - // Connectivity checks setCheckStatus("check-internet", "loading", "Checking internet connectivity..."); modalBridge.send(CONFIG.channels.runCheck, { checkType: "internet" }); diff --git a/src/renderer/modals/troubleshooting/view.ts b/src/renderer/modals/troubleshooting/view.ts index b32ecae1..63780625 100644 --- a/src/renderer/modals/troubleshooting/view.ts +++ b/src/renderer/modals/troubleshooting/view.ts @@ -240,18 +240,6 @@ export function getTroubleshootingModalView(model: TroubleshootingModalViewModel

    Ready to check

    - -
    -
    - - - -
    -
    -

    Sentry Logging

    -

    Ready to check

    -
    -
    diff --git a/src/renderer/modules/connectionManagement.ts b/src/renderer/modules/connectionManagement.ts index 851cbb88..b80e5b35 100644 --- a/src/renderer/modules/connectionManagement.ts +++ b/src/renderer/modules/connectionManagement.ts @@ -3,7 +3,6 @@ * Handles connection UI, CRUD operations, and authentication */ -import { captureMessage, logDebug, logInfo } from "../../common/sentryHelper"; import type { ConnectionsSortOption, DataverseConnection, ModalWindowClosedPayload, ModalWindowMessagePayload, UIConnectionData } from "../../common/types"; import { parseConnectionString } from "../../common/types/connection"; import { getAddConnectionModalControllerScript } from "../modals/addConnection/controller"; @@ -23,6 +22,7 @@ import { sendBrowserWindowModalMessage, showBrowserWindowModal, } from "./browserWindowModals"; +import { logInfo, logWarn, logError, logDebug } from "../../common/logger"; type ConnectionEnvironment = "Dev" | "Test" | "UAT" | "Production"; type ConnectionAuthenticationType = "interactive" | "clientSecret" | "usernamePassword" | "connectionString"; @@ -166,7 +166,7 @@ async function getConnectionsSortPreference(): Promise { const storedPreference = await window.toolboxAPI.getSetting(CONNECTIONS_SORT_SETTING_KEY); return coerceConnectionsSortOption(storedPreference); } catch (error) { - captureMessage("Failed to read connections sort preference", "warning", { extra: { error } }); + logWarn("Failed to read connections sort preference"); return DEFAULT_CONNECTIONS_SORT; } } @@ -190,7 +190,7 @@ export async function updateFooterConnection(): Promise { footerChangeBtn.style.display = "none"; } } catch (error) { - captureMessage("Error updating footer connection:", "error", { extra: { error } }); + logError("Error updating footer connection", error); } } @@ -347,7 +347,7 @@ async function handleSelectConnectionRequest(data?: { connectionId?: string }): resolveHandler(connectionId); } } catch (error) { - captureMessage("Error connecting to selected connection:", "error", { extra: { error } }); + logError("Error connecting to selected connection", error); // Clean up the error message - remove IPC wrapper text let errorMessage = (error as Error).message; @@ -404,7 +404,7 @@ async function handlePopulateConnectionsRequest(): Promise { }, }); } catch (error) { - captureMessage("Failed to populate connections:", "error", { extra: { error } }); + logError("Failed to populate connections", error); await sendBrowserWindowModalMessage({ channel: SELECT_CONNECTION_MODAL_CHANNELS.populateConnections, data: { connections: [] }, @@ -502,7 +502,7 @@ async function handleSelectMultiConnectionsRequest(data?: SelectMultiConnectionP }, }); } catch (error) { - captureMessage("Error authenticating connection:", "error", { extra: { error } }); + logError("Error authenticating connection", error); // Send failure message back to modal await sendBrowserWindowModalMessage({ channel: SELECT_MULTI_CONNECTION_MODAL_CHANNELS.connectReady, @@ -533,7 +533,7 @@ async function handleSelectMultiConnectionsRequest(data?: SelectMultiConnectionP resolveHandler({ primaryConnectionId: data.primaryConnectionId, secondaryConnectionId: data.secondaryConnectionId }); } } catch (error) { - captureMessage("Error confirming multi-connections:", "error", { extra: { error } }); + logError("Error confirming multi-connections", error); } return; } @@ -561,7 +561,7 @@ async function handleSelectMultiConnectionsRequest(data?: SelectMultiConnectionP resolveHandler({ primaryConnectionId, secondaryConnectionId }); } } catch (error) { - captureMessage("Error selecting multi-connections:", "error", { extra: { error } }); + logError("Error selecting multi-connections", error); await signalSelectMultiConnectionReady(); } } @@ -596,7 +596,7 @@ async function handlePopulateMultiConnectionsRequest(): Promise { }, }); } catch (error) { - captureMessage("Failed to populate multi-connections:", "error", { extra: { error } }); + logError("Failed to populate multi-connections", error); await sendBrowserWindowModalMessage({ channel: SELECT_MULTI_CONNECTION_MODAL_CHANNELS.populateConnections, data: { connections: [] }, @@ -615,7 +615,7 @@ export async function loadConnections(): Promise { logInfo("loadConnections() called"); const connectionsList = document.getElementById("connections-list"); if (!connectionsList) { - captureMessage("connections-list element not found", "error"); + logError("connections-list element not found"); return; } @@ -685,7 +685,7 @@ export async function loadConnections(): Promise { const activeConn = connections.find((c: any) => c.isActive); updateFooterConnectionStatus(activeConn || null); } catch (error) { - captureMessage("Error loading connections:", "error", { extra: { error } }); + logError("Error loading connections", error); connectionsList.innerHTML = `

    Error loading connections

    @@ -792,9 +792,7 @@ export async function handleReauthentication(connectionId: string): Promise { if (!editingConnectionId) { - captureMessage("No connection ID to edit", "error"); + logError("No connection ID to edit"); return; } @@ -992,7 +990,7 @@ async function handlePopulateEditConnectionRequest(): Promise { data: connection, }); } catch (error) { - captureMessage("Failed to populate connection for editing:", "error", { extra: { error } }); + logError("Failed to populate connection for editing", error); await window.toolboxAPI.utils.showNotification({ title: "Failed to Load Connection", body: (error as Error).message, @@ -1038,7 +1036,7 @@ async function handleEditConnectionSubmit(formPayload?: ConnectionFormPayload): await loadConnections(); await loadSidebarConnections(); } catch (error) { - captureMessage("Error updating connection:", "error", { extra: { error } }); + logError("Error updating connection", error); await window.toolboxAPI.utils.showNotification({ title: "Failed to Update Connection", body: (error as Error).message, @@ -1081,7 +1079,7 @@ export async function deleteConnection(id: string): Promise { await loadConnections(); } catch (error) { - captureMessage("Error deleting connection:", "error", { extra: { error } }); + logError("Error deleting connection", error); await window.toolboxAPI.utils.showNotification({ title: "Failed to Delete Connection", body: (error as Error).message, @@ -1775,6 +1773,6 @@ export async function loadSidebarConnections(): Promise { }); } } catch (error) { - captureMessage("Failed to load connections:", "error", { extra: { error } }); + logError("Failed to load connections", error); } } diff --git a/src/renderer/modules/globalSearchManagement.ts b/src/renderer/modules/globalSearchManagement.ts index bdef8374..7569b924 100644 --- a/src/renderer/modules/globalSearchManagement.ts +++ b/src/renderer/modules/globalSearchManagement.ts @@ -4,13 +4,13 @@ * marketplace tools, connections, and settings. */ -import { captureException, logInfo } from "../../common/sentryHelper"; import type { DataverseConnection } from "../../common/types/connection"; import type { Tool } from "../../common/types/tool"; import type { ToolDetail } from "../types/index"; import { escapeHtml } from "../utils/toolIconResolver"; import { getToolLibrary, openToolDetail } from "./marketplaceManagement"; import { switchSidebar } from "./sidebarManagement"; +import { logInfo, logError } from "../../common/logger"; // ── Types ───────────────────────────────────────────────────────────────────── @@ -158,10 +158,7 @@ async function runSearch(query: string): Promise { import("./toolManagement") .then(({ launchTool }) => launchTool(toolId)) .catch((err) => { - captureException(err instanceof Error ? err : new Error(String(err)), { - tags: { context: "global_search", action: "launch_tool" }, - level: "warning", - }); + logError(err instanceof Error ? err : new Error(String(err))); }); }, }); @@ -184,10 +181,7 @@ async function runSearch(query: string): Promise { action: () => { closeGlobalSearch(); openToolDetail(toolSnapshot, false).catch((err) => { - captureException(err instanceof Error ? err : new Error(String(err)), { - tags: { context: "global_search", action: "open_tool_detail" }, - level: "warning", - }); + logError(err instanceof Error ? err : new Error(String(err))); }); }, }); @@ -238,10 +232,7 @@ async function runSearch(query: string): Promise { } } } catch (err) { - captureException(err instanceof Error ? err : new Error(String(err)), { - tags: { context: "global_search", action: "run_search" }, - level: "warning", - }); + logError(err instanceof Error ? err : new Error(String(err))); } currentResults = results; @@ -442,10 +433,7 @@ export function initializeGlobalSearch(): void { input.addEventListener("input", () => { runSearch(input.value).catch((err) => { - captureException(err instanceof Error ? err : new Error(String(err)), { - tags: { context: "global_search", action: "input_search" }, - level: "warning", - }); + logError(err instanceof Error ? err : new Error(String(err))); }); }); diff --git a/src/renderer/modules/homepageManagement.ts b/src/renderer/modules/homepageManagement.ts index 61f685f1..26e36cd3 100644 --- a/src/renderer/modules/homepageManagement.ts +++ b/src/renderer/modules/homepageManagement.ts @@ -3,11 +3,11 @@ * Handles homepage display, data loading, and user interactions */ -import { captureException } from "../../common/sentryHelper"; import type { LastUsedToolEntry } from "../../common/types"; import { applyToolIconMasks, generateToolIconHtml } from "../utils/toolIconResolver"; import { switchSidebar } from "./sidebarManagement"; import { launchTool, LaunchToolOptions } from "./toolManagement"; +import { logError } from "../../common/logger"; function normalizeHomepageError(error: unknown, fallbackMessage: string): Error { if (error instanceof Error) { @@ -26,15 +26,9 @@ function normalizeHomepageError(error: unknown, fallbackMessage: string): Error } } -function reportHomepageError(operation: string, error: unknown, extra?: Record): void { +function reportHomepageError(operation: string, error: unknown): void { const normalized = normalizeHomepageError(error, `Homepage operation failed: ${operation}`); - captureException(normalized, { - tags: { - module: "homepage", - operation, - }, - extra, - }); + logError(normalized); } /** @@ -490,7 +484,7 @@ async function openTool(toolId: string, options?: LaunchToolOptions): Promise { logCheckpoint("Renderer initialization started"); try { - // Get install ID from main process and set it in Sentry - if (sentryConfig) { - try { - const settings = await window.toolboxAPI.getUserSettings(); - const installId = settings.installId || settings.machineId; - if (installId) { - setSentryInstallId(installId); - logCheckpoint("Install ID set in renderer Sentry", { installId }); - } - } catch (error) { - // Use logWarn instead of console.warn for proper telemetry tracking - logWarn("Failed to get install ID for Sentry", { error: error instanceof Error ? error.message : String(error) }); - } - } - initializeBrowserWindowModals(); initializeAddConnectionModalBridge(); - addBreadcrumb("Modal bridges initialized", "init", "info"); // Set up Activity Bar navigation setupActivityBar(); @@ -144,108 +67,42 @@ export async function initializeApplication(): Promise { // Set up global search command palette initializeGlobalSearch(); - addBreadcrumb("UI components initialized", "init", "info"); // Load and apply theme settings on startup - await wrapAsyncOperation( - "loadInitialSettings", - async () => { - await loadInitialSettings(); - }, - { tags: { phase: "initialization" } }, - ); + await loadInitialSettings(); logCheckpoint("Initial settings loaded"); // Load tools library from registry - await wrapAsyncOperation( - "loadToolsLibrary", - async () => { - await loadToolsLibrary(); - }, - { tags: { phase: "tools_library_loading" } }, - ).catch((error) => { - const err = error instanceof Error ? error : new Error(String(error)); - captureException(err, { - tags: { phase: "tools_library_loading" }, - level: "warning", - }); + await loadToolsLibrary().catch((error) => { + logError(error instanceof Error ? error : new Error(String(error))); }); logCheckpoint("Tools library loaded"); // Load initial sidebar content (tools by default) - await wrapAsyncOperation( - "loadSidebarTools", - async () => { - await loadSidebarTools(); - }, - { tags: { phase: "sidebar_loading" } }, - ); - - await wrapAsyncOperation( - "loadMarketplace", - async () => { - await loadMarketplace(); - }, - { tags: { phase: "marketplace_loading" } }, - ); - addBreadcrumb("Sidebar content loaded", "init", "info"); + await loadSidebarTools(); + + await loadMarketplace(); // Load connections in sidebar immediately (was previously delayed until events) - await wrapAsyncOperation( - "loadSidebarConnections", - async () => { - await loadSidebarConnections(); - }, - { tags: { phase: "connections_loading" } }, - ).catch((error) => { - const err = error instanceof Error ? error : new Error(String(error)); - captureException(err, { - tags: { phase: "connections_loading" }, - level: "warning", - }); + await loadSidebarConnections().catch((error) => { + logError(error instanceof Error ? error : new Error(String(error))); }); logCheckpoint("Connections loaded"); // Update footer connection info // Update footer connection status // Note: Footer shows active tool's connection, not a global connection - await wrapAsyncOperation( - "updateFooterConnection", - async () => { - await updateFooterConnection(); - }, - { tags: { phase: "footer_update" } }, - ); + await updateFooterConnection(); // Load homepage data - await wrapAsyncOperation( - "loadHomepageData", - async () => { - await loadHomepageData(); - }, - { tags: { phase: "homepage_loading" } }, - ).catch((error) => { - const err = error instanceof Error ? error : new Error(String(error)); - captureException(err, { - tags: { phase: "homepage_loading" }, - level: "warning", - }); + await loadHomepageData().catch((error) => { + logError(error instanceof Error ? error : new Error(String(error))); }); logCheckpoint("Homepage data loaded"); // Restore previous session - await wrapAsyncOperation( - "restoreSession", - async () => { - await restoreSession(); - }, - { tags: { phase: "session_restore" } }, - ).catch((error) => { - const err = error instanceof Error ? error : new Error(String(error)); - captureException(err, { - tags: { phase: "session_restore" }, - level: "warning", - }); + await restoreSession().catch((error) => { + logError(error instanceof Error ? error : new Error(String(error))); }); logCheckpoint("Session restored"); @@ -270,17 +127,9 @@ export async function initializeApplication(): Promise { // Set up periodic token expiry checking for active tool connections setupTokenExpiryCheck(); - addBreadcrumb("All listeners set up", "init", "info"); logCheckpoint("Renderer initialization completed successfully"); } catch (error) { - // If Sentry is available, capture the error - if (sentryConfig) { - const err = error instanceof Error ? error : new Error(String(error)); - captureException(err, { - tags: { phase: "renderer_initialization" }, - level: "fatal", - }); - } + logError(error instanceof Error ? error : new Error(String(error))); // Show error to user using a proper error modal const errorMessage = (error as Error).message || "Unknown error occurred"; const errorElement = document.createElement("div"); @@ -372,10 +221,7 @@ function setupSidebarButtons(): void { if (sidebarAddConnectionBtn) { sidebarAddConnectionBtn.addEventListener("click", () => { openAddConnectionModal().catch((error) => { - captureException(error instanceof Error ? error : new Error(String(error)), { - tags: { phase: "modal_opening" }, - level: "error", - }); + logError(error instanceof Error ? error : new Error(String(error))); }); }); } @@ -421,10 +267,7 @@ function setupSidebarButtons(): void { try { await handleCheckForUpdates(); } catch (error) { - captureException(error instanceof Error ? error : new Error(String(error)), { - tags: { phase: "check_for_updates" }, - level: "error", - }); + logError(error instanceof Error ? error : new Error(String(error))); } }); } @@ -667,30 +510,21 @@ function setupApplicationEventListeners(): void { window.toolboxAPI.onToolUpdateStarted(() => { logInfo("Tool update started, reloading tools..."); loadSidebarTools().catch((err) => { - captureException(err instanceof Error ? err : new Error(String(err)), { - tags: { phase: "tools_reload" }, - level: "warning", - }); + logError(err instanceof Error ? err : new Error(String(err))); }); }); window.toolboxAPI.onToolUpdateCompleted(() => { logInfo("Tool update completed, reloading tools..."); loadSidebarTools().catch((err) => { - captureException(err instanceof Error ? err : new Error(String(err)), { - tags: { phase: "tools_reload" }, - level: "warning", - }); + logError(err instanceof Error ? err : new Error(String(err))); }); }); // Protocol deep link handler window.toolboxAPI.onProtocolInstallToolRequest((params: { toolId: string; toolName: string }) => { handleProtocolInstallToolRequest(params).catch((error) => { - captureException(error instanceof Error ? error : new Error(String(error)), { - tags: { phase: "protocol_install" }, - extra: { toolId: params.toolId, toolName: params.toolName }, - }); + logError(error instanceof Error ? error : new Error(String(error))); }); }); } @@ -817,18 +651,12 @@ function setupToolboxEventListeners(): void { if (payload.event === "connection:created" || payload.event === "connection:updated" || payload.event === "connection:deleted") { logInfo("Connection event detected, reloading connections..."); loadSidebarConnections().catch((err) => { - captureException(err instanceof Error ? err : new Error(String(err)), { - tags: { phase: "connection_reload" }, - level: "warning", - }); + logError(err instanceof Error ? err : new Error(String(err))); }); // Update active tool connection status to reflect changes import("./toolManagement").then(({ updateActiveToolConnectionStatus }) => { updateActiveToolConnectionStatus().catch((err) => { - captureException(err instanceof Error ? err : new Error(String(err)), { - tags: { phase: "footer_update" }, - level: "warning", - }); + logError(err instanceof Error ? err : new Error(String(err))); }); }); } @@ -837,10 +665,7 @@ function setupToolboxEventListeners(): void { if (payload.event === "tool:loaded" || payload.event === "tool:unloaded") { logInfo("Tool event detected, reloading tools..."); loadSidebarTools().catch((err) => { - captureException(err instanceof Error ? err : new Error(String(err)), { - tags: { phase: "tools_reload" }, - level: "warning", - }); + logError(err instanceof Error ? err : new Error(String(err))); }); } diff --git a/src/renderer/modules/marketplaceManagement.ts b/src/renderer/modules/marketplaceManagement.ts index b6492e1d..36d945f8 100644 --- a/src/renderer/modules/marketplaceManagement.ts +++ b/src/renderer/modules/marketplaceManagement.ts @@ -3,7 +3,6 @@ * Handles tool library, marketplace UI, and tool installation */ -import { captureException, captureMessage, logInfo } from "../../common/sentryHelper"; import { marked } from "marked"; import type { Tool } from "../../common/types"; import type { ToolDetail } from "../types/index"; @@ -11,6 +10,7 @@ import { getUnsupportedBadgeTitle, getUnsupportedRequirement } from "../utils/to import { applyToolIconMasks, escapeHtml, generateToolIconHtml, resolveToolIconUrl } from "../utils/toolIconResolver"; import { loadSidebarTools } from "./toolsSidebarManagement"; import { openToolDetailTab } from "./toolManagement"; +import { logInfo, logWarn, logError } from "../../common/logger"; // Disable raw HTML pass-through in markdown rendering to prevent XSS via inline event handlers. // marked's html() renderer is invoked for both block HTML (Tokens.HTML) and inline HTML (Tokens.Tag), @@ -78,7 +78,7 @@ export async function loadToolsLibrary(): Promise { logInfo(`Loaded ${toolLibrary.length} tools from registry`); } catch (error) { - captureMessage("Failed to load tools from registry:", "error", { extra: { error } }); + logError("Failed to load tools from registry", error); toolLibrary = []; // Error will be shown in the marketplace UI } @@ -539,7 +539,7 @@ function renderToolDetailContent(panel: HTMLElement, tool: ToolDetail, isInstall const url = link.getAttribute("data-url") || link.getAttribute("href"); if (url && url.startsWith("https://")) { window.toolboxAPI.openExternal(url).catch((error) => { - captureMessage("Failed to open external link", "error", { extra: { error } }); + logError("Failed to open external link", error); }); } }); @@ -607,16 +607,13 @@ async function loadToolReadme(panel: HTMLElement, readmeUrl: string | undefined, const href = a.getAttribute("href"); if (href && (href.startsWith("https://") || href.startsWith("http://"))) { window.toolboxAPI.openExternal(href).catch((error) => { - captureMessage("Failed to open README link", "error", { extra: { error } }); + logError("Failed to open README link", error); }); } }); }); } catch (error) { - captureException(error instanceof Error ? error : new Error(String(error)), { - tags: { phase: "readme_load" }, - level: "error", - }); + logError(error instanceof Error ? error : new Error(String(error))); // Only write the error message if this tab is still active const detailPanel = document.getElementById("tool-detail-content-panel"); if (detailPanel && detailPanel.getAttribute("data-tab-id") === tabId) { @@ -699,9 +696,7 @@ export async function handleProtocolInstallToolRequest(params: { toolId: string; const tool = toolLibrary.find((t) => t.id === params.toolId); if (!tool) { - captureMessage(`[Protocol] Tool not found in registry: ${params.toolId}`, "warning", { - extra: { toolId: params.toolId, toolName: params.toolName }, - }); + logWarn(`[Protocol] Tool not found in registry: ${params.toolId}`); window.toolboxAPI.utils.showNotification({ title: "Tool Not Found", @@ -746,10 +741,7 @@ export async function handleProtocolInstallToolRequest(params: { toolId: string; }); } catch (error) { const errorMessage = formatError(error); - captureException(error instanceof Error ? error : new Error(String(error)), { - tags: { phase: "protocol_install" }, - extra: { toolId: params.toolId, toolName: params.toolName }, - }); + logError(error instanceof Error ? error : new Error(String(error))); window.toolboxAPI.utils.showNotification({ title: "Installation Failed", diff --git a/src/renderer/modules/sidebarManagement.ts b/src/renderer/modules/sidebarManagement.ts index e914b7ff..ee559c04 100644 --- a/src/renderer/modules/sidebarManagement.ts +++ b/src/renderer/modules/sidebarManagement.ts @@ -3,8 +3,8 @@ * Handles sidebar switching and activity bar navigation */ -import { captureException } from "../../common/sentryHelper"; import { loadSidebarSettings } from "./settingsManagement"; +import { logError } from "../../common/logger"; // Track current sidebar let currentSidebarId: string | null = "tools"; @@ -43,10 +43,7 @@ export function switchSidebar(sidebarId: string): void { // Load settings when re-expanding settings sidebar if (sidebarId === "settings") { loadSidebarSettings().catch((err) => { - captureException(err instanceof Error ? err : new Error(String(err)), { - tags: { context: "sidebar_settings_load", action: "re-expand" }, - level: "warning", - }); + logError(err instanceof Error ? err : new Error(String(err))); }); } } @@ -79,10 +76,7 @@ export function switchSidebar(sidebarId: string): void { // Load settings when switching to settings sidebar if (sidebarId === "settings") { loadSidebarSettings().catch((err) => { - captureException(err instanceof Error ? err : new Error(String(err)), { - tags: { context: "sidebar_settings_load", action: "switch" }, - level: "warning", - }); + logError(err instanceof Error ? err : new Error(String(err))); }); } diff --git a/src/renderer/modules/terminalManagement.ts b/src/renderer/modules/terminalManagement.ts index eaa6e716..6776791f 100644 --- a/src/renderer/modules/terminalManagement.ts +++ b/src/renderer/modules/terminalManagement.ts @@ -4,10 +4,10 @@ */ import AnsiToHtml from "ansi-to-html"; -import { captureMessage, logInfo } from "../../common/sentryHelper"; import { ANSI_CONVERTER_CONFIG, TERMINAL_RESIZE_CONFIG } from "../constants"; import type { TerminalTab } from "../types/index"; import { getToolInstanceDisplayName } from "./toolManagement"; +import { logInfo, logError } from "../../common/logger"; // Create ANSI to HTML converter instance const ansiConverter = new AnsiToHtml(ANSI_CONVERTER_CONFIG); @@ -165,7 +165,7 @@ function createTerminalTab(terminal: any): void { } }) .catch((error: Error) => { - captureMessage("Failed to apply terminal font:", "error", { extra: { error } }); + logError("Failed to apply terminal font", error); }); // Store terminal tab diff --git a/src/renderer/modules/toolManagement.ts b/src/renderer/modules/toolManagement.ts index 44ab224d..656acb17 100644 --- a/src/renderer/modules/toolManagement.ts +++ b/src/renderer/modules/toolManagement.ts @@ -3,7 +3,6 @@ * Handles tool launching, tabs, sessions, and lifecycle */ -import { captureException, captureMessage, logInfo, logWarn } from "../../common/sentryHelper"; import type { DataverseConnection } from "../../common/types/connection"; import { normalizeCspExceptionSource, type CspExceptionSource } from "../../common/types"; import type { OpenTool, SessionData } from "../types/index"; @@ -11,6 +10,7 @@ import { getUnsupportedRequirement, getUnsupportedToolMessage } from "../utils/t import { openSelectConnectionModal, openSelectMultiConnectionModal } from "./connectionManagement"; import { openCspExceptionModal } from "./cspExceptionModal"; import { hideHomePage, showHomePage as showDynamicHomePage } from "./homepageManagement"; +import { logInfo, logWarn, logError } from "../../common/logger"; // Constants const TAB_SCROLL_AMOUNT = 200; // Pixels to scroll when clicking scroll buttons @@ -306,10 +306,7 @@ export async function launchTool(toolId: string, options?: LaunchToolOptions): P logInfo("Tool launched successfully:", { toolName: tool.name, instanceNumber: instanceNumber }); } catch (error) { - captureException(error instanceof Error ? error : new Error(String(error)), { - tags: { phase: "tool_launch", toolId }, - level: "error", - }); + logError(error instanceof Error ? error : new Error(String(error))); window.toolboxAPI.utils.showNotification({ title: "Tool Launch Error", body: `Failed to launch tool: ${error}`, @@ -567,10 +564,7 @@ export async function switchToTool(instanceId: string): Promise { if (openTool?.isDetailTab) { // Hide any active BrowserView window.toolboxAPI.hideToolWindows().catch((error: any) => { - captureException(error instanceof Error ? error : new Error(String(error)), { - tags: { phase: "hide_tool_windows" }, - level: "error", - }); + logError(error instanceof Error ? error : new Error(String(error))); }); // Hide the BrowserView placeholder so detail panel gets full space @@ -611,10 +605,7 @@ export async function switchToTool(instanceId: string): Promise { // Use IPC to switch the BrowserView in the backend // The ToolWindowManager will show the appropriate BrowserView window.toolboxAPI.switchToolWindow(instanceId).catch((error: any) => { - captureException(error instanceof Error ? error : new Error(String(error)), { - tags: { phase: "tool_switch", instanceId }, - level: "error", - }); + logError(error instanceof Error ? error : new Error(String(error))); }); // Update connection status display based on this tool's connection @@ -663,10 +654,7 @@ export function closeTool(instanceId: string): void { // Real tool: close the tool window via IPC // The ToolWindowManager will destroy the BrowserView window.toolboxAPI.closeToolWindow(instanceId).catch((error: any) => { - captureException(error instanceof Error ? error : new Error(String(error)), { - tags: { phase: "tool_close", instanceId }, - level: "error", - }); + logError(error instanceof Error ? error : new Error(String(error))); }); } @@ -835,10 +823,7 @@ export async function restoreSession(): Promise { // Note: activeToolId won't match since we have new instanceIds } } catch (error) { - captureException(error instanceof Error ? error : new Error(String(error)), { - tags: { phase: "session_restore" }, - level: "error", - }); + logError(error instanceof Error ? error : new Error(String(error))); } } @@ -1276,9 +1261,7 @@ export async function openToolConnectionModal(): Promise { } } catch (error) { // User cancelled or error occurred - captureMessage("Connection selection cancelled or failed", "error", { - extra: { error }, - }); + logError("Connection selection cancelled or failed", error); } } @@ -1358,9 +1341,7 @@ export async function openToolSecondaryConnectionModal(): Promise { } } catch (error) { // User cancelled or error occurred - captureMessage("Secondary connection selection cancelled or failed", "error", { - extra: { error }, - }); + logError("Secondary connection selection cancelled or failed", error); } } diff --git a/src/renderer/modules/toolsSidebarManagement.ts b/src/renderer/modules/toolsSidebarManagement.ts index 2722dd55..03bba5ee 100644 --- a/src/renderer/modules/toolsSidebarManagement.ts +++ b/src/renderer/modules/toolsSidebarManagement.ts @@ -3,7 +3,6 @@ * Handles the display and management of installed tools in the sidebar */ -import { captureMessage, logInfo } from "../../common/sentryHelper"; import { ToolDetail } from "../types/index"; import { getUnsupportedBadgeTitle, getUnsupportedRequirement } from "../utils/toolCompatibility"; import { applyToolIconMasks, generateToolIconHtml } from "../utils/toolIconResolver"; @@ -11,6 +10,7 @@ import { getToolSourceIconHtml } from "../utils/toolSourceIcon"; import { loadMarketplace, openToolDetail } from "./marketplaceManagement"; import { switchSidebar } from "./sidebarManagement"; import { launchTool } from "./toolManagement"; +import { logInfo, logError } from "../../common/logger"; let activeToolContextMenu: { menu: HTMLElement; anchor: HTMLElement; cleanup: () => void } | null = null; @@ -379,7 +379,7 @@ export async function loadSidebarTools(): Promise { }); }); } catch (error) { - captureMessage("Failed to load sidebar tools:", "error", { extra: { error } }); + logError("Failed to load sidebar tools", error); toolsList.innerHTML = `

    Error loading tools

    diff --git a/src/renderer/modules/troubleshootingManagement.ts b/src/renderer/modules/troubleshootingManagement.ts index f82f74b8..7990e6d4 100644 --- a/src/renderer/modules/troubleshootingManagement.ts +++ b/src/renderer/modules/troubleshootingManagement.ts @@ -3,11 +3,11 @@ * Handles the troubleshooting modal for diagnosing connectivity issues */ -import { captureMessage } from "../../common/sentryHelper"; import type { ModalWindowMessagePayload } from "../../common/types"; import { getTroubleshootingModalControllerScript } from "../modals/troubleshooting/controller"; import { getTroubleshootingModalView } from "../modals/troubleshooting/view"; import { onBrowserWindowModalClosed, onBrowserWindowModalMessage, sendBrowserWindowModalMessage, showBrowserWindowModal } from "./browserWindowModals"; +import { logError } from "../../common/logger"; const TROUBLESHOOTING_MODAL_CHANNELS = { runCheck: "troubleshooting:run-check", @@ -36,7 +36,7 @@ export async function openTroubleshootingModal(isDarkTheme: boolean): Promise { const isProd = mode === "production"; - // Enable source maps for Sentry in production (hidden source maps) - // Hidden source maps are not included in the bundle but available for upload to Sentry - const enableSourceMap = isProd ? "hidden" : true; + // Enable source maps in development mode only + const enableSourceMap = !isProd; // Load environment variables from .env file const env = loadEnv(mode, process.cwd(), ""); @@ -19,11 +17,6 @@ export default defineConfig(({ mode }) => { const supabaseUrl = env.SUPABASE_URL || process.env.SUPABASE_URL || ""; const supabaseKey = env.SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY || ""; const azureBlobBaseUrl = env.AZURE_BLOB_BASE_URL || process.env.AZURE_BLOB_BASE_URL || ""; - const sentryDsn = env.SENTRY_DSN || process.env.SENTRY_DSN || ""; - const sentryAuthToken = env.SENTRY_AUTH_TOKEN || process.env.SENTRY_AUTH_TOKEN || ""; - const sentryOrg = env.SENTRY_ORG || process.env.SENTRY_ORG || ""; - const sentryProject = env.SENTRY_PROJECT || process.env.SENTRY_PROJECT || ""; - const shouldUploadSentrySourceMaps = isProd && Boolean(sentryAuthToken && sentryOrg && sentryProject); if (supabaseUrl && supabaseKey) { console.log("[Vite] Supabase credentials loaded successfully"); @@ -38,24 +31,12 @@ export default defineConfig(({ mode }) => { console.warn("[Vite] WARNING: AZURE_BLOB_BASE_URL not set - Azure Blob registry fallback will be disabled"); } - if (sentryDsn) { - console.log("[Vite] Sentry DSN loaded successfully"); - if (shouldUploadSentrySourceMaps) { - console.log("[Vite] Sentry source map upload enabled"); - } else if (isProd) { - console.warn("[Vite] WARNING: Sentry source map upload disabled - missing SENTRY_AUTH_TOKEN, SENTRY_ORG, or SENTRY_PROJECT"); - } - } else { - console.log("[Vite] Sentry DSN not found - telemetry will be disabled"); - } - // Define environment variables for the build // These will be replaced at build time, not exposed in the bundle const envDefines = { "process.env.SUPABASE_URL": JSON.stringify(supabaseUrl), "process.env.SUPABASE_ANON_KEY": JSON.stringify(supabaseKey), "process.env.AZURE_BLOB_BASE_URL": JSON.stringify(azureBlobBaseUrl), - "process.env.SENTRY_DSN": JSON.stringify(sentryDsn), }; return { @@ -215,24 +196,6 @@ export default defineConfig(({ mode }) => { } }, }, - // Sentry source map upload plugin (only in production with auth token) - ...(shouldUploadSentrySourceMaps - ? [ - sentryVitePlugin({ - org: sentryOrg, - project: sentryProject, - authToken: sentryAuthToken, - sourcemaps: { - assets: ["./dist/**/*.js", "./dist/**/*.js.map"], - filesToDeleteAfterUpload: ["./dist/**/*.js.map"], - }, - release: { - name: `powerplatform-toolbox@${packageJson.version}`, - }, - telemetry: false, - }), - ] - : []), ], // Define environment variables for renderer process as well define: envDefines, From 959416507dc080b70574dc371677d9c234b4a67c Mon Sep 17 00:00:00 2001 From: Power-Maverick Date: Sat, 7 Mar 2026 22:29:03 -0500 Subject: [PATCH 051/257] feat: add preflight job for version validation and release notes check in build workflow --- .github/workflows/build.yml | 52 +++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5b43d9bd..6c48c1e3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,8 +11,60 @@ on: branches: - main - dev + workflow_dispatch: jobs: + preflight: + if: > + ${{ (github.event.pull_request.merged == true && + github.base_ref == 'main') || + github.event_name == 'workflow_dispatch' }} + runs-on: ubuntu-latest + steps: + - name: Checkout main branch + uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Validate version bump and release notes + shell: bash + run: | + CURRENT_VERSION=$(node -p "require('./package.json').version") + if ! grep -q "^# Power Platform ToolBox $CURRENT_VERSION" RELEASE_NOTES.md; then + echo "Error: RELEASE_NOTES.md does not contain heading '# Power Platform ToolBox $CURRENT_VERSION'." + exit 1 + fi + + echo "Release notes validation passed for Power Platform ToolBox $CURRENT_VERSION." + + - name: Validate @pptb/types version matches ToolBox version + shell: bash + run: | + TOOLBOX_VERSION=$(node -p "require('./package.json').version") + TYPES_VERSION=$(node -p "require('./packages/package.json').version") + + # Extract major.minor.patch from both versions (ignore pre-release tags) + TOOLBOX_BASE=$(echo "$TOOLBOX_VERSION" | cut -d'-' -f1) + TYPES_BASE=$(echo "$TYPES_VERSION" | cut -d'-' -f1) + + if [ "$TOOLBOX_BASE" != "$TYPES_BASE" ]; then + echo "❌ Error: @pptb/types version ($TYPES_VERSION) does not match ToolBox version ($TOOLBOX_VERSION)" + echo "The base version (major.minor.patch) must be identical for stable releases." + echo "" + echo "To fix this:" + echo "1. Update packages/package.json version to match $TOOLBOX_VERSION" + echo "2. Commit the change and push" + exit 1 + fi + + echo "✅ Version validation passed: ToolBox $TOOLBOX_VERSION matches @pptb/types $TYPES_VERSION" + build: name: Build runs-on: ubuntu-latest From d3c39340f181b923c1d5e1cae443b1fa3158637a Mon Sep 17 00:00:00 2001 From: Power-Maverick Date: Mon, 9 Mar 2026 10:48:04 -0400 Subject: [PATCH 052/257] fix: update version to 1.2.0 in package.json --- packages/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/package.json b/packages/package.json index 1cf1a36d..07c61764 100644 --- a/packages/package.json +++ b/packages/package.json @@ -1,6 +1,6 @@ { "name": "@pptb/types", - "version": "1.1.3-beta.2", + "version": "1.2.0", "description": "Type definitions for Power Platform ToolBox APIs and validity checks for tool packages", "main": "index.d.ts", "types": "index.d.ts", @@ -36,4 +36,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 From 9587368b94217dbfc1f1e90c21d705aa039291c7 Mon Sep 17 00:00:00 2001 From: David Rivard <38399134+drivardxrm@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:50:25 -0400 Subject: [PATCH 053/257] handle entity collection bound action/function correctly (#445) --- src/main/managers/dataverseManager.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/main/managers/dataverseManager.ts b/src/main/managers/dataverseManager.ts index 971b031d..1093ed88 100644 --- a/src/main/managers/dataverseManager.ts +++ b/src/main/managers/dataverseManager.ts @@ -586,11 +586,21 @@ export class DataverseManager { let url = this.buildApiUrl(connection, `api/data/${DATAVERSE_API_VERSION}/`); // Build URL based on operation type - if (request.entityName && request.entityId) { - // Bound operation - use entity set name + if (request.entityName) { + const entitySetName = this.getEntitySetName(request.entityName); - url += `${entitySetName}(${request.entityId})/Microsoft.Dynamics.CRM.${request.operationName}`; - } else { + if(request.entityId) + { + // Bound operation - Entity + url += `${entitySetName}(${request.entityId})/Microsoft.Dynamics.CRM.${request.operationName}`; + }else{ + + // Bound operation - Entity Collection + url += `${entitySetName}/Microsoft.Dynamics.CRM.${request.operationName}`; + } + + } + else { // Unbound operation url += request.operationName; } From d1b215043d9028e78d69aeec2d5d52c45acfb1c2 Mon Sep 17 00:00:00 2001 From: David Rivard <38399134+drivardxrm@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:51:18 -0400 Subject: [PATCH 054/257] correctly handle dates in function parameters (formatFunctionParameter) (#446) --- src/main/managers/dataverseManager.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/managers/dataverseManager.ts b/src/main/managers/dataverseManager.ts index 1093ed88..b6c9cd17 100644 --- a/src/main/managers/dataverseManager.ts +++ b/src/main/managers/dataverseManager.ts @@ -1076,6 +1076,11 @@ export class DataverseManager { return value.toString(); } + // Handle dates - convert to ISO string + if (value instanceof Date) { + return value.toISOString(); + } + // Handle string if (typeof value === "string") { // Check if it's a Dataverse enum value with Microsoft.Dynamics.CRM prefix From 5f1b298522e1b2f675683d96c635dfb5a5925d65 Mon Sep 17 00:00:00 2001 From: Power-Maverick Date: Mon, 9 Mar 2026 15:30:02 -0400 Subject: [PATCH 055/257] fix: update preflight job condition to correctly handle pull request events --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6c48c1e3..31e518dc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,7 +16,7 @@ on: jobs: preflight: if: > - ${{ (github.event.pull_request.merged == true && + ${{ (github.event_name == 'pull_request' && github.base_ref == 'main') || github.event_name == 'workflow_dispatch' }} runs-on: ubuntu-latest From 192ac6bf39e2449740f1c48369a58c1d1e72e3ac Mon Sep 17 00:00:00 2001 From: Power-Maverick Date: Mon, 9 Mar 2026 15:32:37 -0400 Subject: [PATCH 056/257] feat: enhance preflight checks with detailed failure comments for release notes and version validation --- .github/workflows/build.yml | 49 +++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 31e518dc..12ee5508 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,6 +20,8 @@ jobs: github.base_ref == 'main') || github.event_name == 'workflow_dispatch' }} runs-on: ubuntu-latest + permissions: + pull-requests: write steps: - name: Checkout main branch uses: actions/checkout@v4 @@ -33,6 +35,7 @@ jobs: node-version: "20" - name: Validate version bump and release notes + id: validate_release_notes shell: bash run: | CURRENT_VERSION=$(node -p "require('./package.json').version") @@ -44,6 +47,7 @@ jobs: echo "Release notes validation passed for Power Platform ToolBox $CURRENT_VERSION." - name: Validate @pptb/types version matches ToolBox version + id: validate_types_version shell: bash run: | TOOLBOX_VERSION=$(node -p "require('./package.json').version") @@ -65,6 +69,51 @@ jobs: echo "✅ Version validation passed: ToolBox $TOOLBOX_VERSION matches @pptb/types $TYPES_VERSION" + - name: Comment on PR with failure reason + if: failure() && github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const outcomes = { + validate_release_notes: '${{ steps.validate_release_notes.outcome }}', + validate_types_version: '${{ steps.validate_types_version.outcome }}' + }; + + const failures = []; + + if (outcomes.validate_release_notes === 'failure') { + failures.push( + '### ❌ Release notes validation failed\n' + + 'The `RELEASE_NOTES.md` file does not contain a heading matching the current version in `package.json`.\n\n' + + '**To fix:** Add a heading in `RELEASE_NOTES.md` that matches `# Power Platform ToolBox `.' + ); + } + + if (outcomes.validate_types_version === 'failure') { + failures.push( + '### ❌ @pptb/types version mismatch\n' + + 'The base version (`major.minor.patch`) in `packages/package.json` does not match the version in `package.json`.\n\n' + + '**To fix:** Update `packages/package.json` so its version matches the ToolBox version.' + ); + } + + const body = [ + '## 🚨 Preflight checks failed', + '', + 'The following checks must pass before this PR can be merged into `main`:', + '', + ...failures, + '', + `> Triggered by commit ${context.sha.slice(0, 7)} — [view run](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})` + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body + }); + build: name: Build runs-on: ubuntu-latest From b0c8d63b665442f3c0f4b5f21f2940210734c329 Mon Sep 17 00:00:00 2001 From: Power-Maverick Date: Mon, 9 Mar 2026 15:41:51 -0400 Subject: [PATCH 057/257] feat: enhance preflight checks with detailed error reporting for version validation and release notes --- .github/workflows/build.yml | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 12ee5508..f563c54d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,10 +23,9 @@ jobs: permissions: pull-requests: write steps: - - name: Checkout main branch + - name: Checkout PR branch uses: actions/checkout@v4 with: - ref: main fetch-depth: 0 - name: Setup Node.js @@ -36,6 +35,7 @@ jobs: - name: Validate version bump and release notes id: validate_release_notes + continue-on-error: true shell: bash run: | CURRENT_VERSION=$(node -p "require('./package.json').version") @@ -48,6 +48,7 @@ jobs: - name: Validate @pptb/types version matches ToolBox version id: validate_types_version + continue-on-error: true shell: bash run: | TOOLBOX_VERSION=$(node -p "require('./package.json').version") @@ -69,6 +70,30 @@ jobs: echo "✅ Version validation passed: ToolBox $TOOLBOX_VERSION matches @pptb/types $TYPES_VERSION" + - name: Assert all preflight checks passed + id: assert + shell: bash + run: | + ERRORS=() + + if [ "${{ steps.validate_release_notes.outcome }}" = "failure" ]; then + ERRORS+=(" - RELEASE_NOTES.md is missing a heading for the current version") + fi + + if [ "${{ steps.validate_types_version.outcome }}" = "failure" ]; then + ERRORS+=(" - @pptb/types version (packages/package.json) does not match the ToolBox version") + fi + + if [ ${#ERRORS[@]} -gt 0 ]; then + echo "❌ Preflight failed with ${#ERRORS[@]} error(s):" + for ERR in "${ERRORS[@]}"; do + echo "$ERR" + done + exit 1 + fi + + echo "✅ All preflight checks passed." + - name: Comment on PR with failure reason if: failure() && github.event_name == 'pull_request' uses: actions/github-script@v7 From b9e30415776ad50bf4c62b73de2a88b26e64cf3f Mon Sep 17 00:00:00 2001 From: Power-Maverick Date: Mon, 9 Mar 2026 15:46:53 -0400 Subject: [PATCH 058/257] chore: update release notes for version 1.2.0 with new highlights, fixes, and developer changes --- RELEASE_NOTES.md | 50 ++++++++++++++++++++++++------------------------ 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 319b6146..90ba8e1f 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,44 +1,44 @@ -# Power Platform ToolBox 1.1.3 +# Power Platform ToolBox 1.2.0 ## Highlights -- Hardened tool filesystem sandbox so tools can only access user-selected paths and system directories are blocked -- Connection sign-in supports choosing Chrome/Edge plus a specific browser profile to better isolate sessions per connection -- Signed Windows installers (EXE/MSI) via Azure Trusted Signing and repackaged portable ZIPs with signed binaries -- Release metadata now records correct SHA256 and SHA512 hashes for stronger artifact integrity verification -- macOS release pipeline notarizes and staples DMG/ZIP/PKG artifacts with improved signing verification steps -- Dataverse API adds metadata CRUD operations and a `getCSDLDocument` helper for retrieving the OData CSDL document -- Save dialogs support optional file-type filters with extension-based default filter derivation -- Loading overlay positioning is fixed and includes a manual dismiss button +- Global search command palette in the activity bar for faster navigation and commands +- Tool details open as a tab (instead of a modal) for smoother browsing and install decisions +- Tool version compatibility checking to prevent running incompatible tools +- Marketplace content moved to Azure Blob storage for improved reliability and load performance +- `pptb://` protocol handler to install tools directly from links +- Connections: category filter/grouping plus environment color and browser-profile badges in selection modals +- CSP exceptions: toolmakers can explain why an exception is needed with optional per-domain user consent +- Update UX uses a themed in-app modal instead of native OS dialogs ## Fixes -- Connections: hardened auth/session isolation to reduce cross-connection token and browser profile leakage -- macOS notarization and stapling no longer skips artifacts and handles unavailable submission logs more reliably -- macOS code signing verification avoids premature `spctl --assess` failures before notarization/stapling completes -- Release workflows regenerate Windows update metadata with correct SHA256/SHA512 after signing -- Tool filesystem reads/writes now enforce explicit user-consent access and reject unsafe/system paths -- Connection and toolbox API handling is more robust for multi-connection scenarios and updated connection fields -- Release workflow date formatting is consistent across jobs and platforms +- Auto-update: "Restart & Install Now" now triggers the update correctly +- Auto-update: update notification always-on-top behavior respects the configured option +- Tools: tool tabs and launch logic correctly handle environment names in tab titles +- Dataverse: entity collection bound actions/functions are handled correctly +- Dataverse: date values in function parameters are formatted correctly +- Notifications: toast behavior no longer forces always-on-top ## Developer & Build -- `dataverseAPI` types expand with metadata CRUD operations and `getCSDLDocument` -- `toolboxAPI.fileSystem.saveFile` supports filters and derives defaults from filename extensions -- Added `BrowserManager` for browser detection and profile enumeration used by interactive auth flows -- Signing/notarization scripts and workflows improved for multi-artifact pipelines and better diagnostics +- Added `pptb-validate` CLI for pre-publish tool validation (`packages/bin/pptb-validate.js`) +- Added CI workflow to publish `@pptb/types` with improved npm auth and environment isolation +- Added build preflight checks to validate app version and ensure release notes are updated +- Release workflows: refined versioning scheme and improved cross-platform artifact merge scripts +- Telemetry: removed Sentry monitoring in favor of the centralized logger ## Install -- Windows: Power-Platform-ToolBox-1.1.3-Setup.exe -- macOS: Power-Platform-ToolBox-1.1.3.dmg (drag to Applications) -- Linux: Power-Platform-ToolBox-1.1.3.AppImage (chmod +x, then run) +- Windows: Power-Platform-ToolBox-1.2.0-Setup.exe +- macOS: Power-Platform-ToolBox-1.2.0.dmg (drag to Applications) +- Linux: Power-Platform-ToolBox-1.2.0.AppImage (chmod +x, then run) ## Notes - No manual migration needed; existing settings and connections continue to work. -- Tool developers: filesystem reads/writes now require `toolboxAPI.fileSystem.selectPath()` or `saveFile()` to grant access. +- Tool developers: run `pptb-validate` before publishing, and include clear CSP exception rationale for any requested domains. ## Full Changelog -https://github.com/PowerPlatformToolBox/desktop-app/compare/v1.1.2...v1.1.3 +https://github.com/PowerPlatformToolBox/desktop-app/compare/v1.1.3...v1.2.0 From 59420583dfcbcd244d2b18d34ee6f5f6f62e1875 Mon Sep 17 00:00:00 2001 From: Power-Maverick Date: Mon, 9 Mar 2026 15:50:49 -0400 Subject: [PATCH 059/257] fix: update version to 1.2.1 in package.json and 1.2.1-beta.0 in packages/package.json --- package.json | 2 +- packages/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index e3041716..dc9b9fd0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "powerplatform-toolbox", - "version": "1.2.0", + "version": "1.2.1", "description": "A universal desktop app that contains multiple tools to ease the customization and configuration of Power Platform", "main": "dist/main/index.js", "scripts": { diff --git a/packages/package.json b/packages/package.json index 07c61764..1d0e822c 100644 --- a/packages/package.json +++ b/packages/package.json @@ -1,6 +1,6 @@ { "name": "@pptb/types", - "version": "1.2.0", + "version": "1.2.1-beta.0", "description": "Type definitions for Power Platform ToolBox APIs and validity checks for tool packages", "main": "index.d.ts", "types": "index.d.ts", From e0d8ae25837f0955b217c908dc6c3635dd6e4904 Mon Sep 17 00:00:00 2001 From: Power-Maverick Date: Mon, 9 Mar 2026 17:10:41 -0400 Subject: [PATCH 060/257] fix: adjust loading overlay window settings to avoid conflicts with system modals. fix for [Bug]: Update is above all windows, not just toolbox Fixes #447 --- src/main/managers/loadingOverlayWindowManager.ts | 14 +++++++------- src/renderer/modules/autoUpdateManagement.ts | 1 - 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/main/managers/loadingOverlayWindowManager.ts b/src/main/managers/loadingOverlayWindowManager.ts index dd098dd8..cb2be976 100644 --- a/src/main/managers/loadingOverlayWindowManager.ts +++ b/src/main/managers/loadingOverlayWindowManager.ts @@ -34,7 +34,7 @@ export class LoadingOverlayWindowManager { height: 300, frame: false, transparent: true, - alwaysOnTop: true, + alwaysOnTop: false, // Don't set alwaysOnTop to avoid issues with other system modals (e.g. file dialogs) skipTaskbar: true, resizable: false, movable: false, @@ -52,7 +52,7 @@ export class LoadingOverlayWindowManager { }, }); this.overlayWindow.setParentWindow(this.mainWindow); - + // Handle close button click - hide the overlay instead of destroying it // Allow close during app shutdown to prevent blocking quit this.closeHandler = (e: Electron.Event) => { @@ -63,7 +63,7 @@ export class LoadingOverlayWindowManager { // Otherwise allow close to proceed during shutdown }; this.overlayWindow.on("close", this.closeHandler); - + this.reloadContent(); this.updateWindowBounds(); } @@ -71,12 +71,12 @@ export class LoadingOverlayWindowManager { /** Resize & reposition to cover the tool panel area (or entire window as fallback) */ private updateWindowBounds(): void { if (!this.overlayWindow) return; - + if (this.currentBounds) { // BrowserView bounds are relative to window content area (x, y from top-left of content) // We need to convert to screen coordinates for the overlay BrowserWindow const contentBounds = this.mainWindow.getContentBounds(); - + // Position overlay in screen coordinates this.overlayWindow.setBounds({ x: contentBounds.x + this.currentBounds.x, @@ -107,7 +107,7 @@ export class LoadingOverlayWindowManager { private generateHTML(message: string): string { // Escape message to prevent HTML/script injection const escapedMessage = this.escapeHtml(message); - + return ` `; + + const appIcon = ` + + `; + + const body = ` +
    +
    +
    ${appIcon}
    +
    +

    About

    +

    Power Platform ToolBox

    + Version ${escapeHtml(model.appVersion)} +
    + +
    +
    +
    +

    Environment

    +
    +
    + Electron + ${escapeHtml(model.electronVersion)} +
    +
    + Node.js + ${escapeHtml(model.nodeVersion)} +
    +
    + Chromium + ${escapeHtml(model.chromeVersion)} +
    +
    +
    +
    +

    System

    +
    +
    + OS + ${escapeHtml(model.platform)} ${escapeHtml(model.arch)} +
    +
    + OS Version + ${escapeHtml(model.osVersion)} +
    +
    + Locale + ${escapeHtml(model.locale)} +
    +
    +
    +
    +

    Diagnostics

    +
    +
    + Install ID + ${escapeHtml(model.installId)} +
    +
    +
    +
    +
    + + +
    +
    `; + + return { styles, body }; +} + +function escapeHtml(text: string): string { + return text.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); +} diff --git a/src/renderer/modules/aboutManagement.ts b/src/renderer/modules/aboutManagement.ts new file mode 100644 index 00000000..358428f3 --- /dev/null +++ b/src/renderer/modules/aboutManagement.ts @@ -0,0 +1,97 @@ +/** + * About dialog management module + * Handles the custom About dialog displayed as a modal BrowserWindow + */ + +import { getAboutModalControllerScript } from "../modals/about/controller"; +import { getAboutModalView } from "../modals/about/view"; +import { offBrowserWindowModalClosed, offBrowserWindowModalMessage, onBrowserWindowModalClosed, onBrowserWindowModalMessage, showBrowserWindowModal } from "./browserWindowModals"; +import { showPPTBNotification } from "./notifications"; + +const ABOUT_MODAL_ID = "about-dialog"; +const ABOUT_COPY_CHANNEL = "about:copy"; + +const ABOUT_MODAL_WIDTH = 480; +const ABOUT_MODAL_HEIGHT = 480; + +let aboutModalOpen = false; + +export interface AboutModalInfo { + appVersion: string; + installId: string; + locale: string; + electronVersion: string; + nodeVersion: string; + chromeVersion: string; + platform: string; + arch: string; + osVersion: string; + isDarkTheme: boolean; +} + +/** + * Build and show the About modal dialog + */ +export async function openAboutModal(info: AboutModalInfo): Promise { + if (aboutModalOpen) { + return; + } + + const { styles, body } = getAboutModalView(info); + + const script = getAboutModalControllerScript({ + copyChannel: ABOUT_COPY_CHANNEL, + appVersion: info.appVersion, + installId: info.installId, + electronVersion: info.electronVersion, + nodeVersion: info.nodeVersion, + chromeVersion: info.chromeVersion, + platform: info.platform, + arch: info.arch, + osVersion: info.osVersion, + locale: info.locale, + }); + + const html = `${styles}\n${body}\n${script}`.trim(); + + const onMessage = (payload: { channel: string; data?: unknown }) => { + if (!payload) return; + if (payload.channel === ABOUT_COPY_CHANNEL) { + const text = (payload.data as { text?: string })?.text ?? ""; + if (text) { + window.toolboxAPI.utils + .copyToClipboard(text) + .then(() => { + showPPTBNotification({ + title: "Copied to Clipboard", + body: "About information has been copied to the clipboard.", + type: "success", + duration: 3000, + }); + }) + .catch(() => undefined); + } + } + }; + + const onClosed = () => { + aboutModalOpen = false; + offBrowserWindowModalMessage(onMessage); + offBrowserWindowModalClosed(onClosed); + }; + + onBrowserWindowModalMessage(onMessage); + onBrowserWindowModalClosed(onClosed); + + aboutModalOpen = true; + try { + await showBrowserWindowModal({ + id: ABOUT_MODAL_ID, + html, + width: ABOUT_MODAL_WIDTH, + height: ABOUT_MODAL_HEIGHT, + }); + } catch (_error) { + onClosed(); + } +} diff --git a/src/renderer/modules/initialization.ts b/src/renderer/modules/initialization.ts index a341b310..e3477feb 100644 --- a/src/renderer/modules/initialization.ts +++ b/src/renderer/modules/initialization.ts @@ -553,6 +553,14 @@ function setupApplicationEventListeners(): void { await openTroubleshootingModal(isDarkTheme); }); + // About dialog listener + window.toolboxAPI.onShowAbout(async (info) => { + const { openAboutModal } = await import("./aboutManagement"); + const currentTheme = await window.toolboxAPI.utils.getCurrentTheme(); + const isDarkTheme = currentTheme === "dark"; + await openAboutModal({ ...info, isDarkTheme }); + }); + // Tool update event listeners window.toolboxAPI.onToolUpdateStarted(() => { logInfo("Tool update started, reloading tools..."); From 742e077506180d4cb5496291487c7b845192122a Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Mar 2026 22:21:05 -0400 Subject: [PATCH 075/257] Add appearance settings for category color and environment color borders (#481) * Initial plan * Add appearance settings for category/environment color show/hide and border thickness Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> Agent-Logs-Url: https://github.com/PowerPlatformToolBox/desktop-app/sessions/193f644f-df36-4d4c-8ed0-96b7a0f482c9 * Move hardcoded appearance defaults into named constants Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> Agent-Logs-Url: https://github.com/PowerPlatformToolBox/desktop-app/sessions/f393659c-9ed8-4b82-a91b-e94a799801a8 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> --- src/common/types/settings.ts | 5 + src/renderer/constants/index.ts | 10 ++ src/renderer/modules/initialization.ts | 13 ++- src/renderer/modules/settingsManagement.ts | 111 +++++++++++++++++++- src/renderer/modules/toolManagement.ts | 113 ++++++++++++++------- src/renderer/styles.scss | 44 ++++---- src/renderer/types/index.ts | 4 + 7 files changed, 240 insertions(+), 60 deletions(-) diff --git a/src/common/types/settings.ts b/src/common/types/settings.ts index 6dcfb7ef..9c1cddeb 100644 --- a/src/common/types/settings.ts +++ b/src/common/types/settings.ts @@ -88,4 +88,9 @@ export interface UserSettings { installedToolsSort?: InstalledToolsSortOption; connectionsSort?: ConnectionsSortOption; marketplaceSort?: MarketplaceSortOption; + // Appearance - color indicators + showCategoryColor?: boolean; // Show/hide the category color strip under the tool tab + showEnvironmentColor?: boolean; // Show/hide the environment color border around the tool panel + categoryColorThickness?: number; // Thickness in pixels of the category color border under the tab + environmentColorThickness?: number; // Thickness in pixels of the environment color border around the tool panel } diff --git a/src/renderer/constants/index.ts b/src/renderer/constants/index.ts index e4065257..b1768bf0 100644 --- a/src/renderer/constants/index.ts +++ b/src/renderer/constants/index.ts @@ -58,3 +58,13 @@ export const TERMINAL_RESIZE_CONFIG = { MIN_HEIGHT: 100, MAX_HEIGHT_RATIO: 0.8, } as const; + +/** + * Appearance defaults – color indicator visibility and border thickness + */ +export const DEFAULT_SHOW_CATEGORY_COLOR = true; +export const DEFAULT_SHOW_ENVIRONMENT_COLOR = true; +export const DEFAULT_CATEGORY_COLOR_THICKNESS = 5; +export const DEFAULT_ENVIRONMENT_COLOR_THICKNESS = 5; +export const MIN_COLOR_BORDER_THICKNESS = 1; +export const MAX_COLOR_BORDER_THICKNESS = 10; diff --git a/src/renderer/modules/initialization.ts b/src/renderer/modules/initialization.ts index e3477feb..128382e5 100644 --- a/src/renderer/modules/initialization.ts +++ b/src/renderer/modules/initialization.ts @@ -5,7 +5,15 @@ import { logCheckpoint, logError, logInfo, logWarn } from "../../common/logger"; import { TOOL_WINDOW_CHANNELS } from "../../common/ipc/channels"; -import { DEFAULT_NOTIFICATION_DURATION, DEFAULT_TERMINAL_FONT, LOADING_SCREEN_FADE_DURATION } from "../constants"; +import { + DEFAULT_CATEGORY_COLOR_THICKNESS, + DEFAULT_ENVIRONMENT_COLOR_THICKNESS, + DEFAULT_NOTIFICATION_DURATION, + DEFAULT_SHOW_CATEGORY_COLOR, + DEFAULT_SHOW_ENVIRONMENT_COLOR, + DEFAULT_TERMINAL_FONT, + LOADING_SCREEN_FADE_DURATION, +} from "../constants"; import { setupAutoUpdateListeners } from "./autoUpdateManagement"; import { initializeBrowserWindowModals } from "./browserWindowModals"; import { handleReauthentication, initializeAddConnectionModalBridge, importConnections, exportConnections, loadSidebarConnections, openAddConnectionModal, updateFooterConnection } from "./connectionManagement"; @@ -18,7 +26,7 @@ import { openSettingsTab } from "./settingsManagement"; import { switchSidebar } from "./sidebarManagement"; import { handleTerminalClosed, handleTerminalCommandCompleted, handleTerminalCreated, handleTerminalError, handleTerminalOutput, setupTerminalPanel } from "./terminalManagement"; import { applyDebugMenuVisibility, applyTerminalFont, applyTheme } from "./themeManagement"; -import { closeAllTools, initializeTabScrollButtons, launchTool, restoreSession, setupKeyboardShortcuts, showHomePage } from "./toolManagement"; +import { applyAppearanceSettings, closeAllTools, initializeTabScrollButtons, launchTool, restoreSession, setupKeyboardShortcuts, showHomePage } from "./toolManagement"; import { loadSidebarTools } from "./toolsSidebarManagement"; /** @@ -593,6 +601,7 @@ async function loadInitialSettings(): Promise { applyTerminalFont(settings.terminalFont || DEFAULT_TERMINAL_FONT); applyDebugMenuVisibility(settings.showDebugMenu ?? false); setDefaultNotificationDuration(settings.notificationDuration ?? DEFAULT_NOTIFICATION_DURATION); + applyAppearanceSettings(settings.showCategoryColor ?? DEFAULT_SHOW_CATEGORY_COLOR, settings.showEnvironmentColor ?? DEFAULT_SHOW_ENVIRONMENT_COLOR, settings.categoryColorThickness ?? DEFAULT_CATEGORY_COLOR_THICKNESS, settings.environmentColorThickness ?? DEFAULT_ENVIRONMENT_COLOR_THICKNESS); } /** diff --git a/src/renderer/modules/settingsManagement.ts b/src/renderer/modules/settingsManagement.ts index 23c30b30..c7ba2376 100644 --- a/src/renderer/modules/settingsManagement.ts +++ b/src/renderer/modules/settingsManagement.ts @@ -4,12 +4,21 @@ */ import { logError } from "../../common/logger"; -import { DEFAULT_NOTIFICATION_DURATION, DEFAULT_TERMINAL_FONT } from "../constants"; +import { + DEFAULT_CATEGORY_COLOR_THICKNESS, + DEFAULT_ENVIRONMENT_COLOR_THICKNESS, + DEFAULT_NOTIFICATION_DURATION, + DEFAULT_SHOW_CATEGORY_COLOR, + DEFAULT_SHOW_ENVIRONMENT_COLOR, + DEFAULT_TERMINAL_FONT, + MAX_COLOR_BORDER_THICKNESS, + MIN_COLOR_BORDER_THICKNESS, +} from "../constants"; import type { SettingsState } from "../types/index"; import { loadMarketplace } from "./marketplaceManagement"; import { setDefaultNotificationDuration } from "./notifications"; import { applyDebugMenuVisibility, applyTerminalFont, applyTheme } from "./themeManagement"; -import { openToolDetailTab } from "./toolManagement"; +import { applyAppearanceSettings, openToolDetailTab } from "./toolManagement"; import { loadSidebarTools } from "./toolsSidebarManagement"; // Track original settings to detect changes @@ -28,6 +37,10 @@ export async function loadSettings(): Promise { const customFontInput = document.getElementById("sidebar-terminal-font-custom") as HTMLInputElement; const customFontContainer = document.getElementById("custom-font-input-container"); const notificationDurationSelect = document.getElementById("sidebar-notification-duration-select") as HTMLSelectElement | null; + const showCategoryColorCheck = document.getElementById("sidebar-show-category-color-check") as HTMLInputElement | null; + const showEnvironmentColorCheck = document.getElementById("sidebar-show-environment-color-check") as HTMLInputElement | null; + const categoryColorThicknessInput = document.getElementById("sidebar-category-color-thickness") as HTMLInputElement | null; + const environmentColorThicknessInput = document.getElementById("sidebar-environment-color-thickness") as HTMLInputElement | null; if (themeSelect && autoUpdateCheck && showDebugMenuCheck && deprecatedToolsSelect && toolDisplayModeSelect && terminalFontSelect) { const settings = await window.toolboxAPI.getUserSettings(); @@ -41,6 +54,10 @@ export async function loadSettings(): Promise { toolDisplayMode: settings.toolDisplayMode ?? "standard", terminalFont: settings.terminalFont || DEFAULT_TERMINAL_FONT, notificationDuration: settings.notificationDuration ?? DEFAULT_NOTIFICATION_DURATION, + showCategoryColor: settings.showCategoryColor ?? DEFAULT_SHOW_CATEGORY_COLOR, + showEnvironmentColor: settings.showEnvironmentColor ?? DEFAULT_SHOW_ENVIRONMENT_COLOR, + categoryColorThickness: settings.categoryColorThickness ?? DEFAULT_CATEGORY_COLOR_THICKNESS, + environmentColorThickness: settings.environmentColorThickness ?? DEFAULT_ENVIRONMENT_COLOR_THICKNESS, }; themeSelect.value = settings.theme; @@ -53,6 +70,19 @@ export async function loadSettings(): Promise { notificationDurationSelect.value = String(settings.notificationDuration ?? DEFAULT_NOTIFICATION_DURATION); } + if (showCategoryColorCheck) { + showCategoryColorCheck.checked = settings.showCategoryColor ?? DEFAULT_SHOW_CATEGORY_COLOR; + } + if (showEnvironmentColorCheck) { + showEnvironmentColorCheck.checked = settings.showEnvironmentColor ?? DEFAULT_SHOW_ENVIRONMENT_COLOR; + } + if (categoryColorThicknessInput) { + categoryColorThicknessInput.value = String(settings.categoryColorThickness ?? DEFAULT_CATEGORY_COLOR_THICKNESS); + } + if (environmentColorThicknessInput) { + environmentColorThicknessInput.value = String(settings.environmentColorThickness ?? DEFAULT_ENVIRONMENT_COLOR_THICKNESS); + } + const terminalFont = settings.terminalFont || DEFAULT_TERMINAL_FONT; // Check if the font is a predefined option @@ -92,6 +122,10 @@ export async function saveSettings(): Promise { const terminalFontSelect = document.getElementById("sidebar-terminal-font-select") as any; // Fluent UI select element const customFontInput = document.getElementById("sidebar-terminal-font-custom") as HTMLInputElement; const notificationDurationSelect = document.getElementById("sidebar-notification-duration-select") as HTMLSelectElement | null; + const showCategoryColorCheck = document.getElementById("sidebar-show-category-color-check") as HTMLInputElement | null; + const showEnvironmentColorCheck = document.getElementById("sidebar-show-environment-color-check") as HTMLInputElement | null; + const categoryColorThicknessInput = document.getElementById("sidebar-category-color-thickness") as HTMLInputElement | null; + const environmentColorThicknessInput = document.getElementById("sidebar-environment-color-thickness") as HTMLInputElement | null; if (!themeSelect || !autoUpdateCheck || !showDebugMenuCheck || !deprecatedToolsSelect || !toolDisplayModeSelect || !terminalFontSelect) return; @@ -102,7 +136,15 @@ export async function saveSettings(): Promise { terminalFont = customFontInput.value.trim() || DEFAULT_TERMINAL_FONT; } - const notificationDuration = notificationDurationSelect ? Number(notificationDurationSelect.value) : 5000; + const notificationDuration = notificationDurationSelect ? Number(notificationDurationSelect.value) : DEFAULT_NOTIFICATION_DURATION; + const showCategoryColor = showCategoryColorCheck ? showCategoryColorCheck.checked : DEFAULT_SHOW_CATEGORY_COLOR; + const showEnvironmentColor = showEnvironmentColorCheck ? showEnvironmentColorCheck.checked : DEFAULT_SHOW_ENVIRONMENT_COLOR; + const categoryColorThickness = categoryColorThicknessInput + ? Math.min(MAX_COLOR_BORDER_THICKNESS, Math.max(MIN_COLOR_BORDER_THICKNESS, Number(categoryColorThicknessInput.value) || DEFAULT_CATEGORY_COLOR_THICKNESS)) + : DEFAULT_CATEGORY_COLOR_THICKNESS; + const environmentColorThickness = environmentColorThicknessInput + ? Math.min(MAX_COLOR_BORDER_THICKNESS, Math.max(MIN_COLOR_BORDER_THICKNESS, Number(environmentColorThicknessInput.value) || DEFAULT_ENVIRONMENT_COLOR_THICKNESS)) + : DEFAULT_ENVIRONMENT_COLOR_THICKNESS; const currentSettings = { theme: themeSelect.value, @@ -112,6 +154,10 @@ export async function saveSettings(): Promise { toolDisplayMode: toolDisplayModeSelect.value, terminalFont: terminalFont, notificationDuration, + showCategoryColor, + showEnvironmentColor, + categoryColorThickness, + environmentColorThickness, }; // Only include changed settings in the update @@ -138,6 +184,18 @@ export async function saveSettings(): Promise { if (currentSettings.notificationDuration !== originalSettings.notificationDuration) { changedSettings.notificationDuration = currentSettings.notificationDuration; } + if (currentSettings.showCategoryColor !== originalSettings.showCategoryColor) { + changedSettings.showCategoryColor = currentSettings.showCategoryColor; + } + if (currentSettings.showEnvironmentColor !== originalSettings.showEnvironmentColor) { + changedSettings.showEnvironmentColor = currentSettings.showEnvironmentColor; + } + if (currentSettings.categoryColorThickness !== originalSettings.categoryColorThickness) { + changedSettings.categoryColorThickness = currentSettings.categoryColorThickness; + } + if (currentSettings.environmentColorThickness !== originalSettings.environmentColorThickness) { + changedSettings.environmentColorThickness = currentSettings.environmentColorThickness; + } // Only save and emit event if something changed if (Object.keys(changedSettings).length > 0) { @@ -148,6 +206,7 @@ export async function saveSettings(): Promise { applyTerminalFont(currentSettings.terminalFont); applyDebugMenuVisibility(currentSettings.showDebugMenu); setDefaultNotificationDuration(currentSettings.notificationDuration); + applyAppearanceSettings(currentSettings.showCategoryColor, currentSettings.showEnvironmentColor, currentSettings.categoryColorThickness, currentSettings.environmentColorThickness); // Reload tools list if deprecated tools visibility changed if (changedSettings.deprecatedToolsVisibility !== undefined) { @@ -205,6 +264,52 @@ export function renderSettingsContent(panel: HTMLElement): void {
    + +
    +
    + +

    Display the connection's category color as a strip under the active tool tab.

    +
    +
    + +
    +
    + +
    +
    + +

    Thickness in pixels of the category color border displayed under the tool tab (${MIN_COLOR_BORDER_THICKNESS}–${MAX_COLOR_BORDER_THICKNESS} px).

    +
    +
    + +
    +
    + +
    +
    + +

    Display the connection's environment color as a border around the active tool panel.

    +
    +
    + +
    +
    + +
    +
    + +

    Thickness in pixels of the environment color border displayed around the tool panel (${MIN_COLOR_BORDER_THICKNESS}–${MAX_COLOR_BORDER_THICKNESS} px).

    +
    +
    + +
    +
    diff --git a/src/renderer/modules/toolManagement.ts b/src/renderer/modules/toolManagement.ts index 513862e0..c39aba37 100644 --- a/src/renderer/modules/toolManagement.ts +++ b/src/renderer/modules/toolManagement.ts @@ -7,6 +7,14 @@ import { logError, logInfo, logWarn } from "../../common/logger"; import { normalizeCspExceptionSource, type CspExceptionSource } from "../../common/types"; import type { DataverseConnection } from "../../common/types/connection"; import type { OpenTool, SessionData } from "../types/index"; +import { + DEFAULT_CATEGORY_COLOR_THICKNESS, + DEFAULT_ENVIRONMENT_COLOR_THICKNESS, + DEFAULT_SHOW_CATEGORY_COLOR, + DEFAULT_SHOW_ENVIRONMENT_COLOR, + MAX_COLOR_BORDER_THICKNESS, + MIN_COLOR_BORDER_THICKNESS, +} from "../constants"; import { getUnsupportedRequirement, getUnsupportedToolMessage } from "../utils/toolCompatibility"; import { openSelectConnectionModal, openSelectMultiConnectionModal } from "./connectionManagement"; import { openCspExceptionModal } from "./cspExceptionModal"; @@ -29,6 +37,32 @@ let activeToolId: string | null = null; // Now stores instanceId let draggedTab: HTMLElement | null = null; let hasWarnedAboutMissingContextMenuHandler = false; +// Appearance settings - cached values used when rendering borders +let _showCategoryColor = DEFAULT_SHOW_CATEGORY_COLOR; +let _showEnvironmentColor = DEFAULT_SHOW_ENVIRONMENT_COLOR; +let _categoryColorThickness = DEFAULT_CATEGORY_COLOR_THICKNESS; +let _environmentColorThickness = DEFAULT_ENVIRONMENT_COLOR_THICKNESS; + +function clampThickness(value: number): number { + return Math.min(MAX_COLOR_BORDER_THICKNESS, Math.max(MIN_COLOR_BORDER_THICKNESS, value)); +} + +/** + * Apply appearance settings for color indicators. + * Caches the values and immediately refreshes any visible borders. + */ +export function applyAppearanceSettings(showCategoryColor: boolean, showEnvironmentColor: boolean, categoryColorThickness: number, environmentColorThickness: number): void { + _showCategoryColor = showCategoryColor; + _showEnvironmentColor = showEnvironmentColor; + _categoryColorThickness = clampThickness(categoryColorThickness); + _environmentColorThickness = clampThickness(environmentColorThickness); + + // Refresh the active tool's borders to reflect new settings immediately + updateActiveToolConnectionStatus().catch((err) => { + logError(err instanceof Error ? err : new Error(String(err))); + }); +} + // Detail tab state - maps tabId to render callback for tool detail tabs const detailTabs = new Map void>(); @@ -1262,6 +1296,9 @@ function updateToolPanelBorder( categoryColor?: string | null, secondaryCategoryColor?: string | null, ): void { + const envThickness = _environmentColorThickness; + const catThickness = _categoryColorThickness; + const toolPanelWrapper = document.getElementById("tool-panel-content-wrapper"); if (toolPanelWrapper) { // Remove all environment classes from panel @@ -1271,32 +1308,36 @@ function updateToolPanelBorder( toolPanelWrapper.style.border = ""; toolPanelWrapper.style.borderImage = ""; - // Add the appropriate class or inline style based on environment(s) - if (environment && secondaryEnvironment) { - const primaryColor = environmentColor && /^#[0-9A-Fa-f]{6}$/.test(environmentColor) ? environmentColor : null; - const secColor = secondaryEnvironmentColor && /^#[0-9A-Fa-f]{6}$/.test(secondaryEnvironmentColor) ? secondaryEnvironmentColor : null; - if (primaryColor || secColor) { - // At least one connection has a custom color — use inline gradient border - const leftColor = primaryColor || getEnvBorderColor(environment); - const rightColor = secColor || getEnvBorderColor(secondaryEnvironment); - toolPanelWrapper.style.border = "5px solid transparent"; - toolPanelWrapper.style.borderImage = `linear-gradient(to right, ${leftColor} 50%, ${rightColor} 50%) 1`; - } else { - const primaryEnvClass = environment.toLowerCase(); - const secondaryEnvClass = secondaryEnvironment.toLowerCase(); - if (primaryEnvClass === secondaryEnvClass) { - toolPanelWrapper.classList.add(`env-${primaryEnvClass}`); + if (_showEnvironmentColor) { + // Add the appropriate class or inline style based on environment(s) + if (environment && secondaryEnvironment) { + const primaryColor = environmentColor && /^#[0-9A-Fa-f]{6}$/.test(environmentColor) ? environmentColor : null; + const secColor = secondaryEnvironmentColor && /^#[0-9A-Fa-f]{6}$/.test(secondaryEnvironmentColor) ? secondaryEnvironmentColor : null; + if (primaryColor || secColor) { + // At least one connection has a custom color — use inline gradient border + const leftColor = primaryColor || getEnvBorderColor(environment); + const rightColor = secColor || getEnvBorderColor(secondaryEnvironment); + toolPanelWrapper.style.border = `${envThickness}px solid transparent`; + toolPanelWrapper.style.borderImage = `linear-gradient(to right, ${leftColor} 50%, ${rightColor} 50%) 1`; } else { - const multiEnvClass = `multi-env-${primaryEnvClass}-${secondaryEnvClass}`; - toolPanelWrapper.classList.add(multiEnvClass); + const primaryEnvClass = environment.toLowerCase(); + const secondaryEnvClass = secondaryEnvironment.toLowerCase(); + if (primaryEnvClass === secondaryEnvClass) { + toolPanelWrapper.classList.add(`env-${primaryEnvClass}`); + } else { + const multiEnvClass = `multi-env-${primaryEnvClass}-${secondaryEnvClass}`; + toolPanelWrapper.classList.add(multiEnvClass); + } + toolPanelWrapper.style.setProperty("--env-border-thickness", `${envThickness}px`); + } + } else if (environment) { + if (environmentColor && /^#[0-9A-Fa-f]{6}$/.test(environmentColor)) { + toolPanelWrapper.style.border = `${envThickness}px solid ${environmentColor}`; + } else { + const envClass = `env-${environment.toLowerCase()}`; + toolPanelWrapper.classList.add(envClass); + toolPanelWrapper.style.setProperty("--env-border-thickness", `${envThickness}px`); } - } - } else if (environment) { - if (environmentColor && /^#[0-9A-Fa-f]{6}$/.test(environmentColor)) { - toolPanelWrapper.style.border = `5px solid ${environmentColor}`; - } else { - const envClass = `env-${environment.toLowerCase()}`; - toolPanelWrapper.classList.add(envClass); } } } @@ -1312,19 +1353,21 @@ function updateToolPanelBorder( activeTab.style.borderBottom = ""; activeTab.style.removeProperty("border-image"); - const primaryCatColor = categoryColor && /^#[0-9A-Fa-f]{6}$/.test(categoryColor) ? categoryColor : null; - const secondaryCatColor = secondaryCategoryColor && /^#[0-9A-Fa-f]{6}$/.test(secondaryCategoryColor) ? secondaryCategoryColor : null; + if (_showCategoryColor) { + const primaryCatColor = categoryColor && /^#[0-9A-Fa-f]{6}$/.test(categoryColor) ? categoryColor : null; + const secondaryCatColor = secondaryCategoryColor && /^#[0-9A-Fa-f]{6}$/.test(secondaryCategoryColor) ? secondaryCategoryColor : null; - if (primaryCatColor && secondaryCatColor && primaryCatColor !== secondaryCatColor) { - // Dual connection with two different category colors — split gradient on bottom border - activeTab.style.borderBottom = "5px solid transparent"; - activeTab.style.setProperty("border-image", `linear-gradient(to right, ${primaryCatColor} 50%, ${secondaryCatColor} 50%) 0 0 1 0 / 0 0 5px 0`); - } else { - const singleColor = primaryCatColor || secondaryCatColor; - if (singleColor) { - activeTab.style.borderBottom = `5px solid ${singleColor}`; + if (primaryCatColor && secondaryCatColor && primaryCatColor !== secondaryCatColor) { + // Dual connection with two different category colors — split gradient on bottom border + activeTab.style.borderBottom = `${catThickness}px solid transparent`; + activeTab.style.setProperty("border-image", `linear-gradient(to right, ${primaryCatColor} 50%, ${secondaryCatColor} 50%) 0 0 1 0 / 0 0 ${catThickness}px 0`); + } else { + const singleColor = primaryCatColor || secondaryCatColor; + if (singleColor) { + activeTab.style.borderBottom = `${catThickness}px solid ${singleColor}`; + } + // If no category color is present, leave the tab with no color indicator } - // If no category color is present, leave the tab with no color indicator } } } diff --git a/src/renderer/styles.scss b/src/renderer/styles.scss index 08241bed..3fab03c4 100644 --- a/src/renderer/styles.scss +++ b/src/renderer/styles.scss @@ -1710,19 +1710,19 @@ body.dark-theme .settings-section-card { /* Environment-specific tab highlights */ .tool-tab.active.env-dev { - border-bottom: 5px solid var(--env-border-dev); + border-bottom: var(--cat-border-thickness, 5px) solid var(--env-border-dev); } .tool-tab.active.env-test { - border-bottom: 5px solid var(--env-border-test); + border-bottom: var(--cat-border-thickness, 5px) solid var(--env-border-test); } .tool-tab.active.env-uat { - border-bottom: 5px solid var(--env-border-uat); + border-bottom: var(--cat-border-thickness, 5px) solid var(--env-border-uat); } .tool-tab.active.env-production { - border-bottom: 5px solid var(--env-border-prod); + border-bottom: var(--cat-border-thickness, 5px) solid var(--env-border-prod); } .tool-tab.secondary-active { @@ -1852,83 +1852,83 @@ body.dark-theme .settings-section-card { /* Environment-specific border styles */ .tool-panel-content-wrapper.env-dev { - border: 5px solid var(--env-border-dev); + border: var(--env-border-thickness, 5px) solid var(--env-border-dev); } .tool-panel-content-wrapper.env-test { - border: 5px solid var(--env-border-test); + border: var(--env-border-thickness, 5px) solid var(--env-border-test); } .tool-panel-content-wrapper.env-uat { - border: 5px solid var(--env-border-uat); + border: var(--env-border-thickness, 5px) solid var(--env-border-uat); } .tool-panel-content-wrapper.env-production { - border: 5px solid var(--env-border-prod); + border: var(--env-border-thickness, 5px) solid var(--env-border-prod); } /* Multi-connection split border styles */ /* Dev Primary */ .tool-panel-content-wrapper.multi-env-dev-test { - border: 5px solid; + border: var(--env-border-thickness, 5px) solid; border-image: linear-gradient(to right, var(--env-border-dev) 50%, var(--env-border-test) 50%) 1; } .tool-panel-content-wrapper.multi-env-dev-uat { - border: 5px solid; + border: var(--env-border-thickness, 5px) solid; border-image: linear-gradient(to right, var(--env-border-dev) 50%, var(--env-border-uat) 50%) 1; } .tool-panel-content-wrapper.multi-env-dev-production { - border: 5px solid; + border: var(--env-border-thickness, 5px) solid; border-image: linear-gradient(to right, var(--env-border-dev) 50%, var(--env-border-prod) 50%) 1; } /* Test Primary */ .tool-panel-content-wrapper.multi-env-test-dev { - border: 5px solid; + border: var(--env-border-thickness, 5px) solid; border-image: linear-gradient(to right, var(--env-border-test) 50%, var(--env-border-dev) 50%) 1; } .tool-panel-content-wrapper.multi-env-test-uat { - border: 5px solid; + border: var(--env-border-thickness, 5px) solid; border-image: linear-gradient(to right, var(--env-border-test) 50%, var(--env-border-uat) 50%) 1; } .tool-panel-content-wrapper.multi-env-test-production { - border: 5px solid; + border: var(--env-border-thickness, 5px) solid; border-image: linear-gradient(to right, var(--env-border-test) 50%, var(--env-border-prod) 50%) 1; } /* UAT Primary */ .tool-panel-content-wrapper.multi-env-uat-dev { - border: 5px solid; + border: var(--env-border-thickness, 5px) solid; border-image: linear-gradient(to right, var(--env-border-uat) 50%, var(--env-border-dev) 50%) 1; } .tool-panel-content-wrapper.multi-env-uat-test { - border: 5px solid; + border: var(--env-border-thickness, 5px) solid; border-image: linear-gradient(to right, var(--env-border-uat) 50%, var(--env-border-test) 50%) 1; } .tool-panel-content-wrapper.multi-env-uat-production { - border: 5px solid; + border: var(--env-border-thickness, 5px) solid; border-image: linear-gradient(to right, var(--env-border-uat) 50%, var(--env-border-prod) 50%) 1; } /* Production Primary */ .tool-panel-content-wrapper.multi-env-production-dev { - border: 5px solid; + border: var(--env-border-thickness, 5px) solid; border-image: linear-gradient(to right, var(--env-border-prod) 50%, var(--env-border-dev) 50%) 1; } .tool-panel-content-wrapper.multi-env-production-test { - border: 5px solid; + border: var(--env-border-thickness, 5px) solid; border-image: linear-gradient(to right, var(--env-border-prod) 50%, var(--env-border-test) 50%) 1; } .tool-panel-content-wrapper.multi-env-production-uat { - border: 5px solid; + border: var(--env-border-thickness, 5px) solid; border-image: linear-gradient(to right, var(--env-border-prod) 50%, var(--env-border-uat) 50%) 1; } @@ -2138,6 +2138,10 @@ body.dark-theme .settings-vscode-item:hover { box-sizing: border-box; } +.settings-vscode-number-input { + width: 80px; +} + .settings-vscode-btn { align-self: flex-start; } diff --git a/src/renderer/types/index.ts b/src/renderer/types/index.ts index 0db92ec4..9a789a92 100644 --- a/src/renderer/types/index.ts +++ b/src/renderer/types/index.ts @@ -57,6 +57,10 @@ export interface SettingsState { toolDisplayMode?: string; terminalFont?: string; notificationDuration?: number; + showCategoryColor?: boolean; + showEnvironmentColor?: boolean; + categoryColorThickness?: number; + environmentColorThickness?: number; } /** From 80b81037a768790fffe0b66c552e7f8afcc0c6ce Mon Sep 17 00:00:00 2001 From: Danish Naglekar <36135520+Power-Maverick@users.noreply.github.com> Date: Sat, 21 Mar 2026 23:00:35 -0400 Subject: [PATCH 076/257] fix: enhance dual connection handling in tool management (#483) --- src/renderer/modules/toolManagement.ts | 49 +++++++++++++++++++------- 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/src/renderer/modules/toolManagement.ts b/src/renderer/modules/toolManagement.ts index c39aba37..a8dc72c3 100644 --- a/src/renderer/modules/toolManagement.ts +++ b/src/renderer/modules/toolManagement.ts @@ -6,7 +6,6 @@ import { logError, logInfo, logWarn } from "../../common/logger"; import { normalizeCspExceptionSource, type CspExceptionSource } from "../../common/types"; import type { DataverseConnection } from "../../common/types/connection"; -import type { OpenTool, SessionData } from "../types/index"; import { DEFAULT_CATEGORY_COLOR_THICKNESS, DEFAULT_ENVIRONMENT_COLOR_THICKNESS, @@ -15,6 +14,7 @@ import { MAX_COLOR_BORDER_THICKNESS, MIN_COLOR_BORDER_THICKNESS, } from "../constants"; +import type { OpenTool, SessionData } from "../types/index"; import { getUnsupportedRequirement, getUnsupportedToolMessage } from "../utils/toolCompatibility"; import { openSelectConnectionModal, openSelectMultiConnectionModal } from "./connectionManagement"; import { openCspExceptionModal } from "./cspExceptionModal"; @@ -118,22 +118,45 @@ async function changeToolConnectionForInstance(instanceId: string): Promise item.id === result.primaryConnectionId); + const secondaryConnection = result.secondaryConnectionId ? connections.find((item: DataverseConnection) => item.id === result.secondaryConnectionId) : null; - const connections = await window.toolboxAPI.connections.getAll(); - const connection = connections.find((item: DataverseConnection) => item.id === selectedConnectionId); - window.toolboxAPI.utils.showNotification({ - title: "Connection Set", - body: `${targetTool.tool.name} is now connected to ${connection?.name || "the selected connection"}.`, - type: "success", - }); + const connectionDetails = secondaryConnection ? `${primaryConnection?.name || "Primary"} and ${secondaryConnection.name}` : primaryConnection?.name || "the selected connection"; + + window.toolboxAPI.utils.showNotification({ + title: "Connections Set", + body: `${targetTool.tool.name} is now connected to ${connectionDetails}.`, + type: "success", + }); + } else { + const selectedConnectionId = await openSelectConnectionModal(targetTool.connectionId); + + if (!selectedConnectionId) { + return; + } + + await setToolConnection(instanceId, selectedConnectionId); + + const connections = await window.toolboxAPI.connections.getAll(); + const connection = connections.find((item: DataverseConnection) => item.id === selectedConnectionId); + window.toolboxAPI.utils.showNotification({ + title: "Connection Set", + body: `${targetTool.tool.name} is now connected to ${connection?.name || "the selected connection"}.`, + type: "success", + }); + } } catch (error) { logError("Connection selection cancelled or failed", error); } From b9baa9c56087b75664989a29d1cb514d82feb9ee Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Mar 2026 00:18:03 -0400 Subject: [PATCH 077/257] Add option to disable session restore on startup (#482) * Initial plan * Add 'Restore Session on Startup' setting to skip reopening tools on app start Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> Agent-Logs-Url: https://github.com/PowerPlatformToolBox/desktop-app/sessions/8ec8d9a2-6b1a-4686-879a-b04708e4cbe8 * Fix build error, add tool name to connection modals, fix missing braces from merge Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> Agent-Logs-Url: https://github.com/PowerPlatformToolBox/desktop-app/sessions/3d6bb8bf-f335-43ee-9634-7e61be7db8fc * fix: silently re-authenticate connections on session restore, show modal on failure Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> Agent-Logs-Url: https://github.com/PowerPlatformToolBox/desktop-app/sessions/24765ec3-ad59-4605-a741-566555503b34 * feat: add unsaved changes detection and close guard for settings tab feat: made settings button sticky * fix pr review comment - https://github.com/PowerPlatformToolBox/desktop-app/pull/482#discussion_r2970765680 * fix: apply unresolved reviewer feedback - shared escapeHtml, await closeOtherTabs Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> Agent-Logs-Url: https://github.com/PowerPlatformToolBox/desktop-app/sessions/fb9f6281-aee9-4813-b322-1751c3a0844b --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> Co-authored-by: Power-Maverick --- src/common/types/settings.ts | 1 + src/main/managers/settingsManager.ts | 1 + src/renderer/modals/selectConnection/view.ts | 8 +- .../modals/selectMultiConnection/view.ts | 10 +- src/renderer/modules/connectionManagement.ts | 23 +++- src/renderer/modules/initialization.ts | 23 +++- src/renderer/modules/settingsManagement.ts | 80 +++++++++++- src/renderer/modules/toolManagement.ts | 117 ++++++++++++++---- src/renderer/styles.scss | 4 + src/renderer/types/index.ts | 1 + 10 files changed, 229 insertions(+), 39 deletions(-) diff --git a/src/common/types/settings.ts b/src/common/types/settings.ts index 9c1cddeb..b7596d56 100644 --- a/src/common/types/settings.ts +++ b/src/common/types/settings.ts @@ -84,6 +84,7 @@ export interface UserSettings { installId?: string; // Unique install identifier for analytics machineId?: string; // @deprecated - legacy machine identifier retained for migrations pendingWhatsNewVersion?: string | null; // Version whose What's New should be shown after restart (auto-update) + restoreSessionOnStartup?: boolean; // Whether to reopen previously open tools on app start // Sort preferences installedToolsSort?: InstalledToolsSortOption; connectionsSort?: ConnectionsSortOption; diff --git a/src/main/managers/settingsManager.ts b/src/main/managers/settingsManager.ts index 844c2e08..e7268c5f 100644 --- a/src/main/managers/settingsManager.ts +++ b/src/main/managers/settingsManager.ts @@ -28,6 +28,7 @@ export class SettingsManager { toolConnections: {}, // Map of toolId to connectionId toolSecondaryConnections: {}, // Map of toolId to secondary connectionId connectionsSort: "last-used", + restoreSessionOnStartup: true, // Reopen previously open tools on app start }, }); diff --git a/src/renderer/modals/selectConnection/view.ts b/src/renderer/modals/selectConnection/view.ts index 99c95033..fd4511af 100644 --- a/src/renderer/modals/selectConnection/view.ts +++ b/src/renderer/modals/selectConnection/view.ts @@ -1,4 +1,5 @@ import { getModalStyles } from "../sharedStyles"; +import { escapeHtml } from "../../utils/toolIconResolver"; export interface ModalViewTemplate { styles: string; @@ -8,14 +9,17 @@ export interface ModalViewTemplate { /** * Returns the view markup (styles + body) for the select connection modal BrowserWindow. */ -export function getSelectConnectionModalView(isDarkTheme: boolean): ModalViewTemplate { +export function getSelectConnectionModalView(isDarkTheme: boolean, toolName?: string): ModalViewTemplate { const styles = getModalStyles(isDarkTheme); + const toolNameHtml = toolName + ? `

    ${escapeHtml(toolName)}

    ` + : `

    Connections

    `; const body = `
    -

    Connections

    + ${toolNameHtml}

    Select Connection

    diff --git a/src/renderer/modals/selectMultiConnection/view.ts b/src/renderer/modals/selectMultiConnection/view.ts index b88f0f28..45d34f51 100644 --- a/src/renderer/modals/selectMultiConnection/view.ts +++ b/src/renderer/modals/selectMultiConnection/view.ts @@ -1,4 +1,5 @@ import { getModalStyles } from "../sharedStyles"; +import { escapeHtml } from "../../utils/toolIconResolver"; export interface ModalViewTemplate { styles: string; @@ -9,8 +10,9 @@ export interface ModalViewTemplate { * Returns the view markup (styles + body) for the select multi-connection modal BrowserWindow. * @param isDarkTheme - Whether dark theme is enabled * @param isSecondaryRequired - Whether the secondary connection is required (true) or optional (false) + * @param toolName - Optional name of the tool requesting the connections */ -export function getSelectMultiConnectionModalView(isDarkTheme: boolean, isSecondaryRequired: boolean = true): ModalViewTemplate { +export function getSelectMultiConnectionModalView(isDarkTheme: boolean, isSecondaryRequired: boolean = true, toolName?: string): ModalViewTemplate { const styles = getModalStyles(isDarkTheme) + ` @@ -97,11 +99,15 @@ export function getSelectMultiConnectionModalView(isDarkTheme: boolean, isSecond } `; + const toolNameHtml = toolName + ? `

    ${escapeHtml(toolName)}

    ` + : `

    Multi-Connection ${isSecondaryRequired ? "Required" : "Optional"}

    `; + const body = `
    -

    Multi-Connection ${isSecondaryRequired ? "Required" : "Optional"}

    + ${toolNameHtml}

    Select Connections

    diff --git a/src/renderer/modules/connectionManagement.ts b/src/renderer/modules/connectionManagement.ts index 7c94afe7..67dbe079 100644 --- a/src/renderer/modules/connectionManagement.ts +++ b/src/renderer/modules/connectionManagement.ts @@ -147,6 +147,9 @@ const selectMultiConnectionModalPromiseHandlers: { // Store the connection ID to highlight in the modal (for tool-specific connection selection) let highlightConnectionId: string | null = null; +// Store the name of the tool requesting a connection (shown in the modal header) +let requestingToolName: string | undefined = undefined; + // Store the connection ID being edited let editingConnectionId: string | null = null; @@ -250,14 +253,18 @@ export function initializeSelectConnectionModalBridge(): void { * Open the select connection modal * Returns a promise that resolves with the selected connectionId when a connection is selected and connected, or rejects if cancelled * @param toolConnectionId - Optional connection ID to highlight as active (for tool-specific selection) + * @param toolName - Optional name of the tool requesting the connection (shown in modal header) */ -export async function openSelectConnectionModal(toolConnectionId?: string | null): Promise { +export async function openSelectConnectionModal(toolConnectionId?: string | null, toolName?: string): Promise { return new Promise((resolve, reject) => { initializeSelectConnectionModalBridge(); // Store the tool connection ID to highlight in the modal highlightConnectionId = toolConnectionId || null; + // Store the tool name to display in the modal header + requestingToolName = toolName; + // Store resolve/reject handlers for later use selectConnectionModalPromiseHandlers.resolve = resolve; selectConnectionModalPromiseHandlers.reject = reject; @@ -270,6 +277,7 @@ export async function openSelectConnectionModal(toolConnectionId?: string | null selectConnectionModalPromiseHandlers.resolve = null; selectConnectionModalPromiseHandlers.reject = null; highlightConnectionId = null; // Clear highlight + requestingToolName = undefined; // Clear tool name // Remove the handler after first call offBrowserWindowModalClosed(modalClosedHandler); } @@ -305,7 +313,7 @@ function handleSelectConnectionModalMessage(payload: ModalWindowMessagePayload): function buildSelectConnectionModalHtml(): string { const isDarkTheme = document.body.classList.contains("dark-theme"); - const { styles, body } = getSelectConnectionModalView(isDarkTheme); + const { styles, body } = getSelectConnectionModalView(isDarkTheme, requestingToolName); const script = getSelectConnectionModalControllerScript(SELECT_CONNECTION_MODAL_CHANNELS); return `${styles}\n${body}\n${script}`.trim(); } @@ -338,6 +346,7 @@ async function handleSelectConnectionRequest(data?: { connectionId?: string }): // Clear highlight connection ID highlightConnectionId = null; + requestingToolName = undefined; // Close the modal await closeBrowserWindowModal(); @@ -431,11 +440,15 @@ export function initializeSelectMultiConnectionModalBridge(): void { * Open the select multi-connection modal for tools that require two connections * Returns a promise that resolves with both connection IDs, or rejects if cancelled * @param isSecondaryRequired - Whether the secondary connection is required (true) or optional (false) + * @param toolName - Optional name of the tool requesting the connections (shown in modal header) */ -export async function openSelectMultiConnectionModal(isSecondaryRequired: boolean = true): Promise<{ primaryConnectionId: string; secondaryConnectionId: string | null }> { +export async function openSelectMultiConnectionModal(isSecondaryRequired: boolean = true, toolName?: string): Promise<{ primaryConnectionId: string; secondaryConnectionId: string | null }> { return new Promise((resolve, reject) => { initializeSelectMultiConnectionModalBridge(); + // Store the tool name to display in the modal header + requestingToolName = toolName; + // Store resolve/reject handlers for later use selectMultiConnectionModalPromiseHandlers.resolve = resolve; selectMultiConnectionModalPromiseHandlers.reject = reject; @@ -447,6 +460,7 @@ export async function openSelectMultiConnectionModal(isSecondaryRequired: boolea selectMultiConnectionModalPromiseHandlers.reject(new Error("Multi-connection selection cancelled")); selectMultiConnectionModalPromiseHandlers.resolve = null; selectMultiConnectionModalPromiseHandlers.reject = null; + requestingToolName = undefined; // Clear tool name // Remove the handler after first call offBrowserWindowModalClosed(modalClosedHandler); } @@ -482,7 +496,7 @@ function handleSelectMultiConnectionModalMessage(payload: ModalWindowMessagePayl function buildSelectMultiConnectionModalHtml(isSecondaryRequired: boolean = true): string { const isDarkTheme = document.body.classList.contains("dark-theme"); - const { styles, body } = getSelectMultiConnectionModalView(isDarkTheme, isSecondaryRequired); + const { styles, body } = getSelectMultiConnectionModalView(isDarkTheme, isSecondaryRequired, requestingToolName); const script = getSelectMultiConnectionModalControllerScript(SELECT_MULTI_CONNECTION_MODAL_CHANNELS, isSecondaryRequired); return `${styles}\n${body}\n${script}`.trim(); } @@ -526,6 +540,7 @@ async function handleSelectMultiConnectionsRequest(data?: SelectMultiConnectionP const resolveHandler = selectMultiConnectionModalPromiseHandlers.resolve; selectMultiConnectionModalPromiseHandlers.resolve = null; selectMultiConnectionModalPromiseHandlers.reject = null; + requestingToolName = undefined; // Clear tool name // Close the modal await closeBrowserWindowModal(); diff --git a/src/renderer/modules/initialization.ts b/src/renderer/modules/initialization.ts index 128382e5..34b24a22 100644 --- a/src/renderer/modules/initialization.ts +++ b/src/renderer/modules/initialization.ts @@ -3,8 +3,8 @@ * Main entry point that sets up all event listeners and initializes the application */ -import { logCheckpoint, logError, logInfo, logWarn } from "../../common/logger"; import { TOOL_WINDOW_CHANNELS } from "../../common/ipc/channels"; +import { logCheckpoint, logError, logInfo, logWarn } from "../../common/logger"; import { DEFAULT_CATEGORY_COLOR_THICKNESS, DEFAULT_ENVIRONMENT_COLOR_THICKNESS, @@ -16,7 +16,15 @@ import { } from "../constants"; import { setupAutoUpdateListeners } from "./autoUpdateManagement"; import { initializeBrowserWindowModals } from "./browserWindowModals"; -import { handleReauthentication, initializeAddConnectionModalBridge, importConnections, exportConnections, loadSidebarConnections, openAddConnectionModal, updateFooterConnection } from "./connectionManagement"; +import { + exportConnections, + handleReauthentication, + importConnections, + initializeAddConnectionModalBridge, + loadSidebarConnections, + openAddConnectionModal, + updateFooterConnection, +} from "./connectionManagement"; import { initializeGlobalSearch } from "./globalSearchManagement"; import { loadHomepageData, setupHomepageActions } from "./homepageManagement"; import { handleProtocolInstallToolRequest, loadMarketplace, loadToolsLibrary } from "./marketplaceManagement"; @@ -232,8 +240,8 @@ function setupActivityBar(): void { function setupToolbarButtons(): void { const closeAllToolsBtn = document.getElementById("close-all-tools"); if (closeAllToolsBtn) { - closeAllToolsBtn.addEventListener("click", () => { - closeAllTools(); + closeAllToolsBtn.addEventListener("click", async () => { + await closeAllTools(); }); } @@ -601,7 +609,12 @@ async function loadInitialSettings(): Promise { applyTerminalFont(settings.terminalFont || DEFAULT_TERMINAL_FONT); applyDebugMenuVisibility(settings.showDebugMenu ?? false); setDefaultNotificationDuration(settings.notificationDuration ?? DEFAULT_NOTIFICATION_DURATION); - applyAppearanceSettings(settings.showCategoryColor ?? DEFAULT_SHOW_CATEGORY_COLOR, settings.showEnvironmentColor ?? DEFAULT_SHOW_ENVIRONMENT_COLOR, settings.categoryColorThickness ?? DEFAULT_CATEGORY_COLOR_THICKNESS, settings.environmentColorThickness ?? DEFAULT_ENVIRONMENT_COLOR_THICKNESS); + applyAppearanceSettings( + settings.showCategoryColor ?? DEFAULT_SHOW_CATEGORY_COLOR, + settings.showEnvironmentColor ?? DEFAULT_SHOW_ENVIRONMENT_COLOR, + settings.categoryColorThickness ?? DEFAULT_CATEGORY_COLOR_THICKNESS, + settings.environmentColorThickness ?? DEFAULT_ENVIRONMENT_COLOR_THICKNESS, + ); } /** diff --git a/src/renderer/modules/settingsManagement.ts b/src/renderer/modules/settingsManagement.ts index c7ba2376..79ed503f 100644 --- a/src/renderer/modules/settingsManagement.ts +++ b/src/renderer/modules/settingsManagement.ts @@ -18,7 +18,7 @@ import type { SettingsState } from "../types/index"; import { loadMarketplace } from "./marketplaceManagement"; import { setDefaultNotificationDuration } from "./notifications"; import { applyDebugMenuVisibility, applyTerminalFont, applyTheme } from "./themeManagement"; -import { applyAppearanceSettings, openToolDetailTab } from "./toolManagement"; +import { applyAppearanceSettings, openToolDetailTab, registerCloseGuard } from "./toolManagement"; import { loadSidebarTools } from "./toolsSidebarManagement"; // Track original settings to detect changes @@ -37,6 +37,7 @@ export async function loadSettings(): Promise { const customFontInput = document.getElementById("sidebar-terminal-font-custom") as HTMLInputElement; const customFontContainer = document.getElementById("custom-font-input-container"); const notificationDurationSelect = document.getElementById("sidebar-notification-duration-select") as HTMLSelectElement | null; + const restoreSessionCheck = document.getElementById("sidebar-restore-session-check") as HTMLInputElement | null; const showCategoryColorCheck = document.getElementById("sidebar-show-category-color-check") as HTMLInputElement | null; const showEnvironmentColorCheck = document.getElementById("sidebar-show-environment-color-check") as HTMLInputElement | null; const categoryColorThicknessInput = document.getElementById("sidebar-category-color-thickness") as HTMLInputElement | null; @@ -54,6 +55,7 @@ export async function loadSettings(): Promise { toolDisplayMode: settings.toolDisplayMode ?? "standard", terminalFont: settings.terminalFont || DEFAULT_TERMINAL_FONT, notificationDuration: settings.notificationDuration ?? DEFAULT_NOTIFICATION_DURATION, + restoreSessionOnStartup: settings.restoreSessionOnStartup ?? true, showCategoryColor: settings.showCategoryColor ?? DEFAULT_SHOW_CATEGORY_COLOR, showEnvironmentColor: settings.showEnvironmentColor ?? DEFAULT_SHOW_ENVIRONMENT_COLOR, categoryColorThickness: settings.categoryColorThickness ?? DEFAULT_CATEGORY_COLOR_THICKNESS, @@ -70,6 +72,9 @@ export async function loadSettings(): Promise { notificationDurationSelect.value = String(settings.notificationDuration ?? DEFAULT_NOTIFICATION_DURATION); } + if (restoreSessionCheck) { + restoreSessionCheck.checked = settings.restoreSessionOnStartup ?? true; + } if (showCategoryColorCheck) { showCategoryColorCheck.checked = settings.showCategoryColor ?? DEFAULT_SHOW_CATEGORY_COLOR; } @@ -122,6 +127,7 @@ export async function saveSettings(): Promise { const terminalFontSelect = document.getElementById("sidebar-terminal-font-select") as any; // Fluent UI select element const customFontInput = document.getElementById("sidebar-terminal-font-custom") as HTMLInputElement; const notificationDurationSelect = document.getElementById("sidebar-notification-duration-select") as HTMLSelectElement | null; + const restoreSessionCheck = document.getElementById("sidebar-restore-session-check") as HTMLInputElement | null; const showCategoryColorCheck = document.getElementById("sidebar-show-category-color-check") as HTMLInputElement | null; const showEnvironmentColorCheck = document.getElementById("sidebar-show-environment-color-check") as HTMLInputElement | null; const categoryColorThicknessInput = document.getElementById("sidebar-category-color-thickness") as HTMLInputElement | null; @@ -154,6 +160,7 @@ export async function saveSettings(): Promise { toolDisplayMode: toolDisplayModeSelect.value, terminalFont: terminalFont, notificationDuration, + restoreSessionOnStartup: restoreSessionCheck ? restoreSessionCheck.checked : true, showCategoryColor, showEnvironmentColor, categoryColorThickness, @@ -184,6 +191,9 @@ export async function saveSettings(): Promise { if (currentSettings.notificationDuration !== originalSettings.notificationDuration) { changedSettings.notificationDuration = currentSettings.notificationDuration; } + if (currentSettings.restoreSessionOnStartup !== originalSettings.restoreSessionOnStartup) { + changedSettings.restoreSessionOnStartup = currentSettings.restoreSessionOnStartup; + } if (currentSettings.showCategoryColor !== originalSettings.showCategoryColor) { changedSettings.showCategoryColor = currentSettings.showCategoryColor; } @@ -239,6 +249,57 @@ export function getOriginalSettings(): SettingsState { return originalSettings; } +/** + * Check whether the settings UI currently differs from the last saved state. + * Returns true if there are unsaved changes. + */ +function hasUnsavedChanges(): boolean { + const themeSelect = document.getElementById("sidebar-theme-select") as any; + const autoUpdateCheck = document.getElementById("sidebar-auto-update-check") as any; + const showDebugMenuCheck = document.getElementById("sidebar-show-debug-menu-check") as any; + const deprecatedToolsSelect = document.getElementById("sidebar-deprecated-tools-select") as any; + const toolDisplayModeSelect = document.getElementById("sidebar-tool-display-mode-select") as any; + const terminalFontSelect = document.getElementById("sidebar-terminal-font-select") as any; + const customFontInput = document.getElementById("sidebar-terminal-font-custom") as HTMLInputElement | null; + const notificationDurationSelect = document.getElementById("sidebar-notification-duration-select") as HTMLSelectElement | null; + const restoreSessionCheck = document.getElementById("sidebar-restore-session-check") as HTMLInputElement | null; + const showCategoryColorCheck = document.getElementById("sidebar-show-category-color-check") as HTMLInputElement | null; + const showEnvironmentColorCheck = document.getElementById("sidebar-show-environment-color-check") as HTMLInputElement | null; + const categoryColorThicknessInput = document.getElementById("sidebar-category-color-thickness") as HTMLInputElement | null; + const environmentColorThicknessInput = document.getElementById("sidebar-environment-color-thickness") as HTMLInputElement | null; + + // If the DOM elements aren't present the settings panel isn't rendered — no unsaved changes + if (!themeSelect || !autoUpdateCheck || !showDebugMenuCheck || !deprecatedToolsSelect || !toolDisplayModeSelect || !terminalFontSelect) { + return false; + } + + let terminalFont = terminalFontSelect.value; + if (terminalFont === "custom" && customFontInput) { + terminalFont = customFontInput.value.trim() || DEFAULT_TERMINAL_FONT; + } + + if (themeSelect.value !== originalSettings.theme) return true; + if (autoUpdateCheck.checked !== originalSettings.autoUpdate) return true; + if (showDebugMenuCheck.checked !== (originalSettings.showDebugMenu ?? false)) return true; + if (deprecatedToolsSelect.value !== (originalSettings.deprecatedToolsVisibility ?? "hide-all")) return true; + if (toolDisplayModeSelect.value !== (originalSettings.toolDisplayMode ?? "standard")) return true; + if (terminalFont !== (originalSettings.terminalFont || DEFAULT_TERMINAL_FONT)) return true; + if (notificationDurationSelect && Number(notificationDurationSelect.value) !== (originalSettings.notificationDuration ?? DEFAULT_NOTIFICATION_DURATION)) return true; + if (restoreSessionCheck && restoreSessionCheck.checked !== (originalSettings.restoreSessionOnStartup ?? true)) return true; + if (showCategoryColorCheck && showCategoryColorCheck.checked !== (originalSettings.showCategoryColor ?? DEFAULT_SHOW_CATEGORY_COLOR)) return true; + if (showEnvironmentColorCheck && showEnvironmentColorCheck.checked !== (originalSettings.showEnvironmentColor ?? DEFAULT_SHOW_ENVIRONMENT_COLOR)) return true; + if (categoryColorThicknessInput) { + const val = Math.min(MAX_COLOR_BORDER_THICKNESS, Math.max(MIN_COLOR_BORDER_THICKNESS, Number(categoryColorThicknessInput.value) || DEFAULT_CATEGORY_COLOR_THICKNESS)); + if (val !== (originalSettings.categoryColorThickness ?? DEFAULT_CATEGORY_COLOR_THICKNESS)) return true; + } + if (environmentColorThicknessInput) { + const val = Math.min(MAX_COLOR_BORDER_THICKNESS, Math.max(MIN_COLOR_BORDER_THICKNESS, Number(environmentColorThicknessInput.value) || DEFAULT_ENVIRONMENT_COLOR_THICKNESS)); + if (val !== (originalSettings.environmentColorThickness ?? DEFAULT_ENVIRONMENT_COLOR_THICKNESS)) return true; + } + + return false; +} + /** * Render settings UI into a container element (used for the settings tab) * Layout follows a VSCode-style two-column design: sticky nav on left, scrollable content on right. @@ -328,6 +389,19 @@ export function renderSettingsContent(panel: HTMLElement): void {
    +
    +
    + +

    Automatically reopen the tools that were open when the app was last closed. If a saved connection is no longer valid, you will be prompted to select a new one.

    +
    +
    + +
    +
    +
    @@ -547,5 +621,9 @@ export function renderSettingsContent(panel: HTMLElement): void { * Open settings as a tab in the main content area */ export async function openSettingsTab(): Promise { + registerCloseGuard("app-settings", async () => { + if (!hasUnsavedChanges()) return true; + return window.confirm("You have unsaved settings changes. Close anyway and discard them?"); + }); await openToolDetailTab("app-settings", "Settings", renderSettingsContent, ""); } diff --git a/src/renderer/modules/toolManagement.ts b/src/renderer/modules/toolManagement.ts index a8dc72c3..6bed7f92 100644 --- a/src/renderer/modules/toolManagement.ts +++ b/src/renderer/modules/toolManagement.ts @@ -66,6 +66,24 @@ export function applyAppearanceSettings(showCategoryColor: boolean, showEnvironm // Detail tab state - maps tabId to render callback for tool detail tabs const detailTabs = new Map void>(); +// Close guards - async callbacks that can cancel a tab close (return false to prevent) +const closeGuards = new Map Promise>(); + +/** + * Register a close guard for a tab. The guard is called before the tab closes; + * returning false cancels the close (e.g., to prompt about unsaved changes). + */ +export function registerCloseGuard(instanceId: string, guard: () => Promise): void { + closeGuards.set(instanceId, guard); +} + +/** + * Remove a previously registered close guard. + */ +export function unregisterCloseGuard(instanceId: string): void { + closeGuards.delete(instanceId); +} + function canCloseTab(instanceId: string): boolean { const openTool = openTools.get(instanceId); if (!openTool) { @@ -79,16 +97,16 @@ function getClosableTabIds(excludedInstanceId?: string): string[] { return Array.from(openTools.keys()).filter((instanceId) => instanceId !== excludedInstanceId && canCloseTab(instanceId)); } -function closeTabs(instanceIds: string[]): void { - instanceIds.forEach((instanceId) => { +async function closeTabs(instanceIds: string[]): Promise { + for (const instanceId of instanceIds) { if (openTools.has(instanceId)) { - closeTool(instanceId); + await closeTool(instanceId); } - }); + } } -function closeOtherTabs(instanceId: string): void { - closeTabs(getClosableTabIds(instanceId)); +async function closeOtherTabs(instanceId: string): Promise { + await closeTabs(getClosableTabIds(instanceId)); } function canManageToolTab(instanceId: string): boolean { @@ -217,17 +235,17 @@ async function showTabContextMenu(instanceId: string, clientX: number, clientY: } if (action === "close-current") { - closeTool(instanceId); + await closeTool(instanceId); return; } if (action === "close-others") { - closeOtherTabs(instanceId); + await closeOtherTabs(instanceId); return; } if (action === "close-all") { - closeTabs(getClosableTabIds()); + await closeTabs(getClosableTabIds()); } } @@ -368,7 +386,7 @@ export async function launchTool(toolId: string, options?: LaunchToolOptions): P if (missingPrimary || missingSecondary) { try { - const result = await openSelectMultiConnectionModal(isSecondaryRequired); + const result = await openSelectMultiConnectionModal(isSecondaryRequired, tool.name); primaryConnectionId = result.primaryConnectionId; secondaryConnectionId = result.secondaryConnectionId; logInfo("Multi-connections selected:", { primaryConnectionId, secondaryConnectionId }); @@ -394,7 +412,7 @@ export async function launchTool(toolId: string, options?: LaunchToolOptions): P // Regular single-connection flow - prompt if no stored connection logInfo("Showing connection selection modal for new instance..."); try { - const selectedConnectionId = await openSelectConnectionModal(); + const selectedConnectionId = await openSelectConnectionModal(null, tool.name); logInfo("Connection established. Continuing with tool launch..."); if (selectedConnectionId) { primaryConnectionId = selectedConnectionId; @@ -571,7 +589,7 @@ export function createTab(instanceId: string, tool: any, instanceNumber: number closeBtn.addEventListener("click", (e) => { e.stopPropagation(); - closeTool(instanceId); + void closeTool(instanceId); }); tab.addEventListener("click", () => { @@ -595,7 +613,7 @@ export function createTab(instanceId: string, tool: any, instanceNumber: number return; } - closeTool(instanceId); + void closeTool(instanceId); } }); @@ -709,7 +727,7 @@ export async function openToolDetailTab(tabId: string, displayName: string, rend closeBtn.title = "Close"; closeBtn.addEventListener("click", (e) => { e.stopPropagation(); - closeTool(tabId); + void closeTool(tabId); }); tab.appendChild(closeBtn); @@ -722,7 +740,7 @@ export async function openToolDetailTab(tabId: string, displayName: string, rend if (e.button === MIDDLE_MOUSE_BUTTON) { e.preventDefault(); e.stopPropagation(); - closeTool(tabId); + void closeTool(tabId); } }); @@ -841,10 +859,17 @@ export async function switchToTool(instanceId: string): Promise { /** * Close a tool */ -export function closeTool(instanceId: string): void { +export async function closeTool(instanceId: string): Promise { const openTool = openTools.get(instanceId); if (!openTool) return; + // Run close guard if registered for this tab + const guard = closeGuards.get(instanceId); + if (guard) { + const canClose = await guard(); + if (!canClose) return; + } + // Check if tab is pinned (only for real tool instances, not detail tabs) if (!openTool.isDetailTab && openTool.isPinned) { window.toolboxAPI.utils.showNotification({ @@ -861,6 +886,9 @@ export function closeTool(instanceId: string): void { tab.remove(); } + // Clean up close guard + closeGuards.delete(instanceId); + if (openTool.isDetailTab) { // Detail tab: clean up render callback and hide detail panel if active detailTabs.delete(instanceId); @@ -920,12 +948,12 @@ export function closeTool(instanceId: string): void { /** * Close all tools */ -export function closeAllTools(): void { +export async function closeAllTools(): Promise { // Close all tools const toolIds = Array.from(openTools.keys()); - toolIds.forEach((toolId) => { - closeTool(toolId); - }); + for (const toolId of toolIds) { + await closeTool(toolId); + } } /** @@ -1033,6 +1061,12 @@ export function saveSession(): void { * Restore session from local storage */ export async function restoreSession(): Promise { + // Check if the user has disabled session restore + const settings = await window.toolboxAPI.getUserSettings(); + if (settings.restoreSessionOnStartup === false) { + return; + } + const sessionData = localStorage.getItem("toolbox-session"); if (!sessionData) return; @@ -1041,11 +1075,44 @@ export async function restoreSession(): Promise { if (session.openTools && Array.isArray(session.openTools)) { // Note: We can't restore exact instanceIds since they're timestamp-based // Instead, we launch the tools fresh, which creates new instances. - // Saved connection IDs are passed so the tool opens without prompting. + // Saved connection IDs are passed so the tool opens without prompting + // when authentication can be restored silently. for (const toolInfo of session.openTools) { + // Attempt silent re-authentication for each saved connection. + // If the token is still valid it will be reused directly. + // If it can be refreshed (client-secret, username/password, stored + // refresh token) that will happen automatically. + // If silent auth fails (e.g. MSAL in-memory cache cleared after + // restart and token expired), pass null so launchTool shows the + // appropriate connection modal (single or multi, with tool name). + let primaryConnectionId: string | null = toolInfo.connectionId ?? null; + let secondaryConnectionId: string | null = toolInfo.secondaryConnectionId ?? null; + + if (primaryConnectionId) { + try { + await window.toolboxAPI.connections.authenticate(primaryConnectionId); + } catch (authError) { + logWarn(`Silent auth failed for primary connection ${primaryConnectionId} on session restore – connection modal will be shown`, { + error: authError instanceof Error ? authError.message : String(authError), + }); + primaryConnectionId = null; + } + } + + if (secondaryConnectionId) { + try { + await window.toolboxAPI.connections.authenticate(secondaryConnectionId); + } catch (authError) { + logWarn(`Silent auth failed for secondary connection ${secondaryConnectionId} on session restore – connection modal will be shown`, { + error: authError instanceof Error ? authError.message : String(authError), + }); + secondaryConnectionId = null; + } + } + await launchTool(toolInfo.toolId, { - primaryConnectionId: toolInfo.connectionId, - secondaryConnectionId: toolInfo.secondaryConnectionId, + primaryConnectionId, + secondaryConnectionId, }); } // Note: activeToolId won't match since we have new instanceIds @@ -1116,7 +1183,7 @@ export function setupKeyboardShortcuts(): void { if (e.ctrlKey && e.key === "w") { e.preventDefault(); if (activeToolId) { - closeTool(activeToolId); + void closeTool(activeToolId); } } @@ -1562,7 +1629,7 @@ export async function openToolSecondaryConnectionModal(): Promise { const { openSelectConnectionModal } = await import("./connectionManagement"); // Open the modal and pass the tool's current secondary connection ID to highlight it - const selectedConnectionId = await openSelectConnectionModal(activeTool.secondaryConnectionId); + const selectedConnectionId = await openSelectConnectionModal(activeTool.secondaryConnectionId, activeTool.tool?.name); // After modal closes with a successful connection, update the tool's secondary connection if (selectedConnectionId && activeToolId) { diff --git a/src/renderer/styles.scss b/src/renderer/styles.scss index 3fab03c4..ae3e29b8 100644 --- a/src/renderer/styles.scss +++ b/src/renderer/styles.scss @@ -2181,6 +2181,10 @@ body.dark-theme .settings-vscode-item:hover { gap: 16px; padding: 20px 24px; border-top: 1px solid var(--border-color); + position: sticky; + bottom: 0; + background: var(--bg-color); + z-index: 10; } .tool-detail-tab-header { diff --git a/src/renderer/types/index.ts b/src/renderer/types/index.ts index 9a789a92..c68c215c 100644 --- a/src/renderer/types/index.ts +++ b/src/renderer/types/index.ts @@ -57,6 +57,7 @@ export interface SettingsState { toolDisplayMode?: string; terminalFont?: string; notificationDuration?: number; + restoreSessionOnStartup?: boolean; showCategoryColor?: boolean; showEnvironmentColor?: boolean; categoryColorThickness?: number; From 72cad4b32f6093853a873185ad999bfa6d8c83df Mon Sep 17 00:00:00 2001 From: Danish Naglekar <36135520+Power-Maverick@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:35:45 -0700 Subject: [PATCH 078/257] fix: improve connection import handling and clean up logger imports (#495) --- src/renderer/modules/connectionManagement.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/renderer/modules/connectionManagement.ts b/src/renderer/modules/connectionManagement.ts index 67dbe079..bc0d2bbf 100644 --- a/src/renderer/modules/connectionManagement.ts +++ b/src/renderer/modules/connectionManagement.ts @@ -3,6 +3,7 @@ * Handles connection UI, CRUD operations, and authentication */ +import { logDebug, logError, logInfo, logWarn } from "../../common/logger"; import type { ConnectionsSortOption, DataverseConnection, ModalWindowClosedPayload, ModalWindowMessagePayload, UIConnectionData } from "../../common/types"; import { parseConnectionString } from "../../common/types/connection"; import { getAddConnectionModalControllerScript } from "../modals/addConnection/controller"; @@ -22,7 +23,6 @@ import { sendBrowserWindowModalMessage, showBrowserWindowModal, } from "./browserWindowModals"; -import { logInfo, logWarn, logError, logDebug } from "../../common/logger"; type ConnectionEnvironment = "Dev" | "Test" | "UAT" | "Production"; type ConnectionAuthenticationType = "interactive" | "clientSecret" | "usernamePassword" | "connectionString"; @@ -1193,9 +1193,6 @@ function parseXtbXmlToImportPayload(xmlContent: string): { version: 1; exportedA case "clientsecret": authenticationType = "clientSecret"; break; - case "ad": - case "office365": - case "onlinefederation": case "ifd": authenticationType = "usernamePassword"; break; @@ -1204,6 +1201,9 @@ function parseXtbXmlToImportPayload(xmlContent: string): { version: 1; exportedA // These auth types are not supported in PPTB; log a warning here and skip this connection logWarn("[importConnections] Skipping unsupported XTB auth type", { authType: newAuthType, name: getText("ConnectionName") }); continue; + case "ad": + case "office365": + case "onlinefederation": case "oauth": default: authenticationType = "interactive"; @@ -1971,11 +1971,12 @@ export async function loadSidebarConnections(): Promise { const groupConns = groupMap.get(groupKey)!; const displayKey = groupKey === "" ? "Default" : groupKey; const escapedKey = escapeHtml(displayKey); - const catColor = groupKey !== "" ? (groupConns.find((c: DataverseConnection) => c.categoryColor)?.categoryColor || "") : ""; + const catColor = groupKey !== "" ? groupConns.find((c: DataverseConnection) => c.categoryColor)?.categoryColor || "" : ""; const safeCatColor = catColor && /^#[0-9A-Fa-f]{6}$/.test(catColor) ? escapeHtml(catColor) : ""; - const colorSwatchHtml = groupKey !== "" - ? `` - : ""; + const colorSwatchHtml = + groupKey !== "" + ? `` + : ""; const items = groupConns.map(renderConnectionItem).join(""); return `
    From 1721ec09a970f1837f63c936e700556cd897a985 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Mar 2026 23:22:41 -0400 Subject: [PATCH 079/257] Fix: Global search command palette renders behind active tool BrowserView and align UI with modal windows (#491) * Initial plan * Fix global search overlay appearing behind tool BrowserView Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> Agent-Logs-Url: https://github.com/PowerPlatformToolBox/desktop-app/sessions/98496443-e99d-461f-9385-da0c10cd5dc5 * UI: Add modal-style header with close button to global search Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> Agent-Logs-Url: https://github.com/PowerPlatformToolBox/desktop-app/sessions/263a78e7-da1f-4f02-9ab5-5b5d92d93119 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> --- src/renderer/index.html | 6 +- .../modules/globalSearchManagement.ts | 59 +++++++++++++++++++ src/renderer/styles.scss | 59 +++++++++++++------ 3 files changed, 105 insertions(+), 19 deletions(-) diff --git a/src/renderer/index.html b/src/renderer/index.html index 2744f136..017be7e0 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -716,6 +716,10 @@

    Tool Settings

    +
    +

    GLOBAL SEARCH

    + +
    Tool Settings spellcheck="false" aria-label="Search tools, connections, and settings" /> - ESC
    @@ -735,7 +738,6 @@

    Tool Settings

    ↑↓ navigate ↵ select - ESC close
    diff --git a/src/renderer/modules/globalSearchManagement.ts b/src/renderer/modules/globalSearchManagement.ts index 01a70c99..4f8c7884 100644 --- a/src/renderer/modules/globalSearchManagement.ts +++ b/src/renderer/modules/globalSearchManagement.ts @@ -32,6 +32,16 @@ let isOpen = false; let selectedIndex = -1; let currentResults: SearchResult[] = []; +/** The tool instance that was active before the search was opened (used to restore on dismiss). */ +let previousActiveInstanceId: string | null = null; + +/** + * When true, closeGlobalSearch() will restore the previously active tool BrowserView. + * Set to false in action callbacks that navigate to a new tool/feature so we don't + * briefly flash the old view before the new destination renders. + */ +let shouldRestoreToolOnClose = false; + // ── Static settings entries ─────────────────────────────────────────────────── const SETTINGS_ENTRIES: Array<{ name: string; description: string; focusId?: string }> = [ @@ -64,6 +74,9 @@ function getResultsContainer(): HTMLElement | null { /** * Open the global search command palette. + * Hides the active tool BrowserView so the overlay is not obscured by the native + * Electron BrowserView (which always composites above HTML content regardless of + * CSS z-index). The view is restored when the palette is dismissed without an action. */ export function openGlobalSearch(): void { const overlay = getOverlay(); @@ -74,6 +87,23 @@ export function openGlobalSearch(): void { selectedIndex = -1; currentResults = []; + // Hide the active BrowserView so the overlay is not rendered underneath it. + // We capture the current active instance first so we can restore it on dismiss. + void window.toolboxAPI + .getActiveToolWindow() + .then((activeId: string | null) => { + previousActiveInstanceId = activeId; + if (previousActiveInstanceId) { + // Only flag restoration when there is actually a tool to restore. + shouldRestoreToolOnClose = true; + void window.toolboxAPI.hideToolWindows(); + } + }) + .catch((err: unknown) => { + logError(err instanceof Error ? err : new Error(String(err))); + previousActiveInstanceId = null; + }); + overlay.style.display = "flex"; input.value = ""; @@ -93,6 +123,8 @@ export function openGlobalSearch(): void { /** * Close the global search command palette. + * Restores the previously active tool BrowserView unless an action was taken that + * handles its own navigation (those actions set shouldRestoreToolOnClose = false). */ export function closeGlobalSearch(): void { const overlay = getOverlay(); @@ -102,6 +134,18 @@ export function closeGlobalSearch(): void { selectedIndex = -1; currentResults = []; overlay.style.display = "none"; + + // Restore the tool BrowserView only when the user dismisses the palette without + // taking an action (ESC or backdrop click). Actions set shouldRestoreToolOnClose + // to false because they manage their own navigation target. + if (shouldRestoreToolOnClose && previousActiveInstanceId) { + window.toolboxAPI.switchToolWindow(previousActiveInstanceId).catch((err: unknown) => { + logError(err instanceof Error ? err : new Error(String(err))); + }); + } + + previousActiveInstanceId = null; + shouldRestoreToolOnClose = false; } // ── Theme helpers ───────────────────────────────────────────────────────────── @@ -158,6 +202,8 @@ async function runSearch(query: string): Promise { description: tool.description ?? "", category: "installed", action: () => { + // launchTool handles showing the BrowserView; don't restore the old one. + shouldRestoreToolOnClose = false; closeGlobalSearch(); // Dynamically import to avoid circular dependency import("./toolManagement") @@ -184,6 +230,8 @@ async function runSearch(query: string): Promise { description: tool.description ?? "", category: "marketplace", action: () => { + // openToolDetail opens a detail tab and hides BrowserViews itself. + shouldRestoreToolOnClose = false; closeGlobalSearch(); openToolDetail(toolSnapshot, false).catch((err) => { logError(err instanceof Error ? err : new Error(String(err))); @@ -204,6 +252,8 @@ async function runSearch(query: string): Promise { description: `${conn.environment} · ${conn.url}`, category: "connection", action: () => { + // Sidebar navigation: restore the tool BrowserView so it stays visible + // in the main content area while the user browses the sidebar. closeGlobalSearch(); switchSidebar("connections"); }, @@ -222,6 +272,8 @@ async function runSearch(query: string): Promise { description: entry.description, category: "settings", action: () => { + // Sidebar navigation: restore the tool BrowserView so it stays visible + // in the main content area while the user browses the sidebar. closeGlobalSearch(); if (entryName === "Connections") { switchSidebar("connections"); @@ -422,6 +474,13 @@ export function initializeGlobalSearch(): void { searchBtn.addEventListener("click", () => openGlobalSearch()); } + // Close button inside the container header + const closeBtn = document.getElementById("global-search-close-btn"); + if (closeBtn && !(closeBtn as HTMLElement & { _pptbBound?: boolean })._pptbBound) { + (closeBtn as HTMLElement & { _pptbBound?: boolean })._pptbBound = true; + closeBtn.addEventListener("click", () => closeGlobalSearch()); + } + // Overlay backdrop click → close const overlay = getOverlay(); if (overlay && !(overlay as HTMLElement & { _pptbBound?: boolean })._pptbBound) { diff --git a/src/renderer/styles.scss b/src/renderer/styles.scss index ae3e29b8..20fa588a 100644 --- a/src/renderer/styles.scss +++ b/src/renderer/styles.scss @@ -5284,7 +5284,7 @@ body.dark-theme .csp-warning ul { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.5); - z-index: 9999; + z-index: $z-modal; display: flex; align-items: flex-start; justify-content: center; @@ -5299,12 +5299,52 @@ body.dark-theme .csp-warning ul { width: 680px; max-width: calc(100vw - 48px); max-height: calc(100vh - 160px); - box-shadow: var(--elevation-high); + box-shadow: 0 30px 80px rgba(0, 0, 0, 0.35); display: flex; flex-direction: column; overflow: hidden; } +.global-search-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px 10px; + border-bottom: 1px solid var(--border-color); + flex-shrink: 0; +} + +.global-search-eyebrow { + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 11px; + font-weight: 600; + color: var(--text-secondary); + margin: 0; +} + +.global-search-close-btn { + background: var(--activity-item-hover-bg); + border: none; + color: var(--text-color); + width: 28px; + height: 28px; + border-radius: 6px; + cursor: pointer; + font-size: 18px; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: background 0.15s; + padding: 0; + + &:hover { + background: var(--activity-item-active-bg); + } +} + .global-search-input-wrapper { display: flex; align-items: center; @@ -5336,21 +5376,6 @@ body.dark-theme .csp-warning ul { color: var(--text-secondary); } -.global-search-kbd { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 2px 6px; - border: 1px solid var(--border-color); - border-radius: 4px; - font-size: 11px; - color: var(--text-secondary); - background: var(--secondary-color); - font-family: inherit; - cursor: default; - flex-shrink: 0; -} - .global-search-results { flex: 1; overflow-y: auto; From d6f1e402529f2b900e9adf6cdc899ac62714065c Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 12:48:19 -0400 Subject: [PATCH 080/257] Fix: TypeError "Object has been destroyed" in emitModalClosed during force-close with auto-update (#501) * Initial plan * fix: guard webContents.isDestroyed() to prevent crash on force-close during auto-update Agent-Logs-Url: https://github.com/PowerPlatformToolBox/desktop-app/sessions/58b630b9-e75d-4941-9bae-5a32113756eb Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> --- src/main/managers/autoUpdateManager.ts | 2 +- src/main/managers/modalWindowManager.ts | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/managers/autoUpdateManager.ts b/src/main/managers/autoUpdateManager.ts index ddd9b2f7..585ef30f 100644 --- a/src/main/managers/autoUpdateManager.ts +++ b/src/main/managers/autoUpdateManager.ts @@ -87,7 +87,7 @@ export class AutoUpdateManager extends EventEmitter { * Send update events to renderer process */ private sendToRenderer(channel: string, data?: unknown): void { - if (this.mainWindow && !this.mainWindow.isDestroyed()) { + if (this.mainWindow && !this.mainWindow.isDestroyed() && !this.mainWindow.webContents.isDestroyed()) { this.mainWindow.webContents.send(channel, data); } } diff --git a/src/main/managers/modalWindowManager.ts b/src/main/managers/modalWindowManager.ts index 7558f7b5..f3a7ea92 100644 --- a/src/main/managers/modalWindowManager.ts +++ b/src/main/managers/modalWindowManager.ts @@ -50,7 +50,9 @@ export class ModalWindowManager { } modalWindow.moveTop(); modalWindow.focus(); - this.mainWindow.webContents.send(EVENT_CHANNELS.MODAL_WINDOW_OPENED, { id: this.currentOptions?.id ?? null }); + if (!this.mainWindow.isDestroyed() && !this.mainWindow.webContents.isDestroyed()) { + this.mainWindow.webContents.send(EVENT_CHANNELS.MODAL_WINDOW_OPENED, { id: this.currentOptions?.id ?? null }); + } } }) .catch((error) => { @@ -186,7 +188,7 @@ ${content} } private emitModalClosed(): void { - if (!this.currentOptions || this.mainWindow.isDestroyed()) { + if (!this.currentOptions || this.mainWindow.isDestroyed() || this.mainWindow.webContents.isDestroyed()) { return; } From 87353dda4a024ea41b52c7ff1fe7dcb48e9d17af Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 17:06:55 -0400 Subject: [PATCH 081/257] Rename "Important Links" to "Community Resources" with dynamic Supabase-backed links (#499) * feat: rename Important Links to Community Resources, make links dynamic via Supabase community_links table Agent-Logs-Url: https://github.com/PowerPlatformToolBox/desktop-app/sessions/3e132f84-fa7c-40ca-801c-b1d29fae727b Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * fix: address code review - remove unnecessary type assertion and simplify Supabase row cast Agent-Logs-Url: https://github.com/PowerPlatformToolBox/desktop-app/sessions/3e132f84-fa7c-40ca-801c-b1d29fae727b Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * fix: remove group_sort_order column - group ordering handled by the app Agent-Logs-Url: https://github.com/PowerPlatformToolBox/desktop-app/sessions/e2dc53c8-11c9-4527-8170-7a70b191705b Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> * Update src/main/managers/toolRegistryManager.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/main/managers/toolRegistryManager.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Power-Maverick <36135520+Power-Maverick@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/COMMUNITY_LINKS_TABLE.md | 106 ++++++++++++++ src/common/ipc/channels.ts | 1 + src/common/types/api.ts | 2 + src/common/types/communityLinks.ts | 20 +++ src/common/types/index.ts | 3 + src/main/index.ts | 6 + src/main/managers/toolRegistryManager.ts | 84 ++++++++++- src/main/managers/toolsManager.ts | 10 +- src/main/preload.ts | 1 + src/renderer/index.html | 4 +- ...=> communityResourcesSidebarManagement.ts} | 132 ++++++++++-------- src/renderer/modules/sidebarManagement.ts | 14 +- src/renderer/styles.scss | 7 + 13 files changed, 321 insertions(+), 69 deletions(-) create mode 100644 docs/COMMUNITY_LINKS_TABLE.md create mode 100644 src/common/types/communityLinks.ts rename src/renderer/modules/{importantLinksSidebarManagement.ts => communityResourcesSidebarManagement.ts} (64%) diff --git a/docs/COMMUNITY_LINKS_TABLE.md b/docs/COMMUNITY_LINKS_TABLE.md new file mode 100644 index 00000000..14087f0a --- /dev/null +++ b/docs/COMMUNITY_LINKS_TABLE.md @@ -0,0 +1,106 @@ +# Supabase `community_links` Table Design + +This document describes the schema for the `community_links` table used to drive the +**Community Resources** sidebar panel in the Power Platform ToolBox desktop app. + +--- + +## Table: `community_links` + +```sql +CREATE TABLE public.community_links ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + group_id TEXT NOT NULL, + group_title TEXT NOT NULL, + label TEXT NOT NULL, + url TEXT NOT NULL, + sort_order INT NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +``` + +### Column descriptions + +| Column | Type | Description | +|--------|------|-------------| +| `id` | `uuid` | Primary key, auto-generated. | +| `group_id` | `text` | Stable slug that identifies the category group (e.g. `"newsletters"`). Rows that share a `group_id` are rendered as a single collapsible section. | +| `group_title` | `text` | Human-readable title displayed as the group header (e.g. `"Newsletters"`). | +| `label` | `text` | Display label for the individual link (e.g. `"PP Weekly"`). | +| `url` | `text` | Full HTTPS URL of the link. Non-HTTPS URLs are rejected by the app. | +| `sort_order` | `int` | Controls the order of links within a group. Lower values appear first. Group ordering is handled by the app. | +| `is_active` | `boolean` | When `false` the link is excluded from the app without deleting the row. | +| `created_at` | `timestamptz` | Row creation timestamp (auto-set). | +| `updated_at` | `timestamptz` | Row last-updated timestamp. Update via trigger (see below). | + +--- + +## Row-Level Security + +```sql +-- Enable RLS +ALTER TABLE public.community_links ENABLE ROW LEVEL SECURITY; + +-- Allow everyone (including anonymous / unauthenticated users) to read active links. +-- The app always requests only is_active = true rows, but this policy ensures +-- even a direct Supabase query cannot leak inactive rows to anonymous callers. +CREATE POLICY "Public read access" + ON public.community_links + FOR SELECT + USING (is_active = true); +``` + +--- + +## Auto-update `updated_at` + +```sql +-- Reuse (or create) a generic timestamp trigger function +CREATE OR REPLACE FUNCTION public.set_updated_at() +RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + NEW.updated_at := now(); + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_community_links_updated_at + BEFORE UPDATE ON public.community_links + FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); +``` + +--- + +## Example seed data + +```sql +INSERT INTO public.community_links + (group_id, group_title, label, url, sort_order) +VALUES + -- Newsletters + ('newsletters', 'Newsletters', 'PP Weekly', 'https://www.ppweekly.com/', 10), + ('newsletters', 'Newsletters', 'PP Dev Weekly', 'https://www.ppdevweekly.com/', 20), + + -- Release plans + ('release-plans', 'Release plans', 'Release Plans Visualized', 'https://releaseplans.net/', 10), + + -- Calculators / estimators + ('calculators-estimators', 'Calculators / estimators', 'Dataverse Capacity Calculator', 'https://dataverse.licensing.guide/', 10), + ('calculators-estimators', 'Calculators / estimators', 'Power Pages Licensing Cost Calculator', 'https://powerportals.de/tools/power-pages-pricing-calculator.html', 20), + ('calculators-estimators', 'Calculators / estimators', 'Microsoft agent usage estimator', 'https://microsoft.github.io/copilot-studio-estimator/', 30); +``` + +--- + +## App behaviour + +1. When the **Community Resources** sidebar is opened the app calls the `FETCH_COMMUNITY_LINKS` + IPC channel, which queries Supabase for all rows where `is_active = true`, ordered by + `sort_order ASC`. +2. Rows are grouped by `group_id` / `group_title` and rendered as collapsible sections. + Group ordering follows the natural insertion order returned by the query. +3. Only `https://` URLs are accepted; any row with a non-HTTPS URL is silently skipped. +4. If Supabase is unreachable or not configured the app falls back to the bundled static + data in `src/renderer/data/importantLinks.json`. diff --git a/src/common/ipc/channels.ts b/src/common/ipc/channels.ts index 84631470..0b36338c 100644 --- a/src/common/ipc/channels.ts +++ b/src/common/ipc/channels.ts @@ -72,6 +72,7 @@ export const TOOL_CHANNELS = { GET_LOCAL_TOOL_WEBVIEW_HTML: "get-local-tool-webview-html", OPEN_DIRECTORY_PICKER: "open-directory-picker", FETCH_REGISTRY_TOOLS: "fetch-registry-tools", + FETCH_COMMUNITY_LINKS: "fetch-community-links", INSTALL_TOOL_FROM_REGISTRY: "install-tool-from-registry", CHECK_TOOL_UPDATES: "check-tool-updates", UPDATE_TOOL: "update-tool", diff --git a/src/common/types/api.ts b/src/common/types/api.ts index 327b7c6d..a3a8f990 100644 --- a/src/common/types/api.ts +++ b/src/common/types/api.ts @@ -4,6 +4,7 @@ */ import { FileDialogFilter, ModalWindowMessagePayload, ModalWindowOptions, NativeContextMenuRequest, SelectPathOptions, Theme } from "./common"; +import { CommunityLinksCollection } from "./communityLinks"; import { DataverseConnection } from "./connection"; import { DataverseExecuteRequest } from "./dataverse"; import { CspConsentRecord, LastUsedToolEntry, LastUsedToolUpdate, UserSettings } from "./settings"; @@ -189,6 +190,7 @@ export interface ToolboxAPI { // Registry-based tools fetchRegistryTools: () => Promise; + fetchCommunityLinks: () => Promise; installToolFromRegistry: (toolId: string) => Promise<{ manifest: unknown; tool: Tool }>; checkToolUpdates: (toolId: string) => Promise<{ hasUpdate: boolean; latestVersion?: string }>; isToolUpdating: (toolId: string) => Promise; diff --git a/src/common/types/communityLinks.ts b/src/common/types/communityLinks.ts new file mode 100644 index 00000000..2da182ed --- /dev/null +++ b/src/common/types/communityLinks.ts @@ -0,0 +1,20 @@ +/** + * Community Links / Community Resources types + * Shared between main process (Supabase fetch) and renderer (display). + */ + +export interface CommunityLinksItem { + id: string; + label: string; + url: string; +} + +export interface CommunityLinksGroup { + id: string; + title: string; + links: CommunityLinksItem[]; +} + +export interface CommunityLinksCollection { + groups: CommunityLinksGroup[]; +} diff --git a/src/common/types/index.ts b/src/common/types/index.ts index 4e5c6e89..4cb9af90 100644 --- a/src/common/types/index.ts +++ b/src/common/types/index.ts @@ -26,3 +26,6 @@ export * from "./dataverse"; // API types (for renderer process) export * from "./api"; + +// Community Links / Community Resources types +export * from "./communityLinks"; diff --git a/src/main/index.ts b/src/main/index.ts index b65b101e..8fe3d37a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -264,6 +264,7 @@ class ToolBoxApp { ipcMain.removeHandler(TOOL_CHANNELS.UNLOAD_TOOL); ipcMain.removeHandler(TOOL_CHANNELS.INSTALL_TOOL_FROM_REGISTRY); ipcMain.removeHandler(TOOL_CHANNELS.FETCH_REGISTRY_TOOLS); + ipcMain.removeHandler(TOOL_CHANNELS.FETCH_COMMUNITY_LINKS); ipcMain.removeHandler(TOOL_CHANNELS.CHECK_TOOL_UPDATES); ipcMain.removeHandler(TOOL_CHANNELS.UPDATE_TOOL); ipcMain.removeHandler(TOOL_CHANNELS.IS_TOOL_UPDATING); @@ -767,6 +768,11 @@ class ToolBoxApp { return await this.toolManager.fetchAvailableTools(); }); + // Fetch community resource links from Supabase (returns null on failure; renderer falls back to bundled data) + ipcMain.handle(TOOL_CHANNELS.FETCH_COMMUNITY_LINKS, async () => { + return await this.toolManager.fetchCommunityLinks(); + }); + // Check for tool updates ipcMain.handle(TOOL_CHANNELS.CHECK_TOOL_UPDATES, async (_, toolId) => { return await this.toolManager.checkForUpdates(toolId); diff --git a/src/main/managers/toolRegistryManager.ts b/src/main/managers/toolRegistryManager.ts index bb23721f..cdae6234 100644 --- a/src/main/managers/toolRegistryManager.ts +++ b/src/main/managers/toolRegistryManager.ts @@ -6,7 +6,7 @@ import * as http from "http"; import * as https from "https"; import * as path from "path"; import { pipeline } from "stream/promises"; -import { CspExceptions, ToolManifest, ToolRegistryEntry } from "../../common/types"; +import { CspExceptions, ToolManifest, ToolRegistryEntry, CommunityLinksCollection, CommunityLinksGroup, CommunityLinksItem } from "../../common/types"; import { AZURE_BLOB_BASE_URL, SUPABASE_ANON_KEY, SUPABASE_URL } from "../constants"; import { InstallIdManager } from "./installIdManager"; import { logInfo, logWarn, logError } from "../../common/logger"; @@ -80,6 +80,19 @@ interface SupabaseTool { tool_analytics?: SupabaseAnalyticsRow | SupabaseAnalyticsRow[]; // sometimes array depending on RLS / joins } +/** + * Supabase community_links table row + */ +interface SupabaseCommunityLink { + id: string; + group_id: string; + group_title: string; + label: string; + url: string; + sort_order: number; + is_active: boolean; +} + /** * Local registry JSON file structure */ @@ -1003,4 +1016,73 @@ export class ToolRegistryManager extends EventEmitter { logError(`[ToolRegistry] Failed to track usage for ${toolId}`, error); } } + + /** + * Fetch community resource links from the Supabase community_links table. + * Returns null when Supabase is not configured or the query fails, so the caller + * can fall back to bundled static data. + */ + async fetchCommunityLinks(): Promise { + if (!this.supabase || this.useLocalFallback) { + return null; + } + + try { + logInfo("[ToolRegistry] Fetching community links from Supabase"); + + const { data, error } = await this.supabase + .from("community_links") + .select("id, group_id, group_title, label, url, sort_order") + .eq("is_active", true) + .order("sort_order", { ascending: true }); + + if (error) { + throw new Error(`Supabase community_links query failed: ${error.message}`); + } + + if (!data || data.length === 0) { + logInfo("[ToolRegistry] No community links found in Supabase"); + const emptyCollection: CommunityLinksCollection = { + groups: [], + }; + return emptyCollection; + } + + // Transform flat rows into grouped structure + const groupMap = new Map(); + const rows = data as SupabaseCommunityLink[]; + for (const row of rows) { + if (typeof row.url !== "string" || !row.url.startsWith("https://")) { + logWarn("[ToolRegistry] Skipping community link with non-https URL", { id: row.id, url: row.url }); + continue; + } + + if (!groupMap.has(row.group_id)) { + groupMap.set(row.group_id, { + id: row.group_id, + title: row.group_title, + links: [], + }); + } + + const item: CommunityLinksItem = { + id: row.id, + label: row.label, + url: row.url, + }; + groupMap.get(row.group_id)!.links.push(item); + } + + const collection: CommunityLinksCollection = { + groups: Array.from(groupMap.values()), + }; + + logInfo(`[ToolRegistry] Fetched ${data.length} community links in ${collection.groups.length} groups`); + return collection; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logWarn("[ToolRegistry] Failed to fetch community links from Supabase, caller should use local fallback", { error: errorMessage }); + return null; + } + } } diff --git a/src/main/managers/toolsManager.ts b/src/main/managers/toolsManager.ts index 0e675f32..9ed94038 100644 --- a/src/main/managers/toolsManager.ts +++ b/src/main/managers/toolsManager.ts @@ -3,7 +3,7 @@ import { EventEmitter } from "events"; import * as fs from "fs"; import * as path from "path"; import { pathToFileURL } from "url"; -import { CspExceptions, Tool, ToolFeatures, ToolManifest } from "../../common/types"; +import { CspExceptions, Tool, ToolFeatures, ToolManifest, CommunityLinksCollection } from "../../common/types"; import { InstallIdManager } from "./installIdManager"; import { ToolRegistryManager } from "./toolRegistryManager"; import { VersionManager } from "./versionManager"; @@ -283,6 +283,14 @@ export class ToolManager extends EventEmitter { }); } + /** + * Fetch community resource links from Supabase. + * Returns null when Supabase is not configured or the query fails. + */ + async fetchCommunityLinks(): Promise { + return this.registryManager.fetchCommunityLinks(); + } + /** * Check for tool updates */ diff --git a/src/main/preload.ts b/src/main/preload.ts index 08b101e2..3d48c16e 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -76,6 +76,7 @@ contextBridge.exposeInMainWorld("toolboxAPI", { // Registry-based tools (new primary method) fetchRegistryTools: () => ipcRenderer.invoke(TOOL_CHANNELS.FETCH_REGISTRY_TOOLS), + fetchCommunityLinks: () => ipcRenderer.invoke(TOOL_CHANNELS.FETCH_COMMUNITY_LINKS), installToolFromRegistry: (toolId: string) => ipcRenderer.invoke(TOOL_CHANNELS.INSTALL_TOOL_FROM_REGISTRY, toolId), checkToolUpdates: (toolId: string) => ipcRenderer.invoke(TOOL_CHANNELS.CHECK_TOOL_UPDATES, toolId), updateTool: (toolId: string) => ipcRenderer.invoke(TOOL_CHANNELS.UPDATE_TOOL, toolId), diff --git a/src/renderer/index.html b/src/renderer/index.html index 017be7e0..613331e8 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -32,7 +32,7 @@ - +
    +
    +

    Choose the source format for the connection file you want to import.

    +
    + + +
    +
    + 💡 How to export connections from XrmToolBox: +
      +
    1. Open XrmToolBox and click the Connect button to open the Connection Manager.
    2. +
    3. In the Connection Manager, right-click a connection and choose Export to XML or export all connections via the toolbar.
    4. +
    5. Save the file and select it here. The file is typically named ConnectionsList.xml.
    6. +
    +
    + Default location: +
    + %AppData%\\MscrmTools\\XrmToolBox\\Connections\\ConnectionsList.xml + +
    +
    +
    +
    +
    + + +
    +
    +`; + + return { styles: styles + extraStyles, body }; +} diff --git a/src/renderer/modules/connectionManagement.ts b/src/renderer/modules/connectionManagement.ts index bc0d2bbf..149fd456 100644 --- a/src/renderer/modules/connectionManagement.ts +++ b/src/renderer/modules/connectionManagement.ts @@ -10,6 +10,8 @@ import { getAddConnectionModalControllerScript } from "../modals/addConnection/c import { getAddConnectionModalView } from "../modals/addConnection/view"; import { getEditConnectionModalControllerScript } from "../modals/editConnection/controller"; import { getEditConnectionModalView } from "../modals/editConnection/view"; +import { getImportConnectionSourceModalControllerScript } from "../modals/importConnectionSource/controller"; +import { getImportConnectionSourceModalView } from "../modals/importConnectionSource/view"; import { getSelectConnectionModalControllerScript } from "../modals/selectConnection/controller"; import { getSelectConnectionModalView } from "../modals/selectConnection/view"; import { getSelectMultiConnectionModalControllerScript } from "../modals/selectMultiConnection/controller"; @@ -121,10 +123,20 @@ const SELECT_MULTI_CONNECTION_MODAL_DIMENSIONS = { height: 700, }; +const IMPORT_CONNECTION_SOURCE_MODAL_CHANNELS = { + select: "import-connection-source:select", +} as const; + +const IMPORT_CONNECTION_SOURCE_MODAL_DIMENSIONS = { + width: 520, + height: 600, +}; + let addConnectionModalHandlersRegistered = false; let editConnectionModalHandlersRegistered = false; let selectConnectionModalHandlersRegistered = false; let selectMultiConnectionModalHandlersRegistered = false; +let importConnectionSourceModalHandlersRegistered = false; // Store promise handlers for select connection modal - now returns connectionId const selectConnectionModalPromiseHandlers: { @@ -144,6 +156,15 @@ const selectMultiConnectionModalPromiseHandlers: { reject: null, }; +// Store promise handlers for import connection source modal +const importConnectionSourceModalPromiseHandlers: { + resolve: ((value: "xtb" | "pptb" | null) => void) | null; + reject: ((error: Error) => void) | null; +} = { + resolve: null, + reject: null, +}; + // Store the connection ID to highlight in the modal (for tool-specific connection selection) let highlightConnectionId: string | null = null; @@ -627,6 +648,77 @@ async function signalSelectMultiConnectionReady(): Promise { await sendBrowserWindowModalMessage({ channel: SELECT_MULTI_CONNECTION_MODAL_CHANNELS.connectReady }); } +/** + * Initialize import connection source modal bridge + */ +function initializeImportConnectionSourceModalBridge(): void { + if (importConnectionSourceModalHandlersRegistered) return; + onBrowserWindowModalMessage(handleImportConnectionSourceModalMessage); + importConnectionSourceModalHandlersRegistered = true; +} + +/** + * Open the import connection source selection modal. + * Returns a promise that resolves with "xtb" or "pptb" when a source is chosen, or null if cancelled. + */ +function openImportConnectionSourceModal(): Promise<"xtb" | "pptb" | null> { + return new Promise((resolve) => { + initializeImportConnectionSourceModalBridge(); + + importConnectionSourceModalPromiseHandlers.resolve = resolve; + importConnectionSourceModalPromiseHandlers.reject = null; + + const modalClosedHandler = (payload: ModalWindowClosedPayload) => { + if (payload?.id === "import-connection-source-browser-modal") { + offBrowserWindowModalClosed(modalClosedHandler); + if (importConnectionSourceModalPromiseHandlers.resolve) { + importConnectionSourceModalPromiseHandlers.resolve(null); + importConnectionSourceModalPromiseHandlers.resolve = null; + } + } + }; + + onBrowserWindowModalClosed(modalClosedHandler); + + void showBrowserWindowModal({ + id: "import-connection-source-browser-modal", + html: buildImportConnectionSourceModalHtml(), + width: IMPORT_CONNECTION_SOURCE_MODAL_DIMENSIONS.width, + height: IMPORT_CONNECTION_SOURCE_MODAL_DIMENSIONS.height, + }); + }); +} + +function buildImportConnectionSourceModalHtml(): string { + const isDarkTheme = document.body.classList.contains("dark-theme"); + const themeClass = isDarkTheme ? "dark-theme" : "light-theme"; + const { styles, body } = getImportConnectionSourceModalView(isDarkTheme); + const script = getImportConnectionSourceModalControllerScript(IMPORT_CONNECTION_SOURCE_MODAL_CHANNELS); + const bodyWithTheme = body.replace("", ``); + return `${styles}\n${bodyWithTheme}\n${script}`.trim(); +} + +function handleImportConnectionSourceModalMessage(payload: ModalWindowMessagePayload): void { + if (!payload || typeof payload !== "object" || typeof payload.channel !== "string") { + return; + } + + if (payload.channel === IMPORT_CONNECTION_SOURCE_MODAL_CHANNELS.select) { + const data = payload.data as { source?: string } | undefined; + const source = data?.source === "xtb" ? "xtb" : data?.source === "pptb" ? "pptb" : null; + + const resolveHandler = importConnectionSourceModalPromiseHandlers.resolve; + importConnectionSourceModalPromiseHandlers.resolve = null; + importConnectionSourceModalPromiseHandlers.reject = null; + + void closeBrowserWindowModal().then(() => { + if (resolveHandler) { + resolveHandler(source); + } + }); + } +} + /** * Load connections list in the connections view */ @@ -1268,22 +1360,27 @@ function parseXtbXmlToImportPayload(xmlContent: string): { version: 1; exportedA /** * Import connections from a file selected by the user. - * Supports both PPTB JSON export files and XrmToolBox XML connection files. + * Shows a source selection modal first, then opens a file picker filtered to the chosen format. + * Supports XrmToolBox XML connection files and PPTB JSON export files. */ export async function importConnections(): Promise { try { - // Ask user to select a file – accept both PPTB JSON and XTB XML formats + // Show source selection modal to let user pick XTB or PPTB + const source = await openImportConnectionSourceModal(); + if (!source) { + return; // User cancelled + } + + const isXml = source === "xtb"; + + // Ask user to select a file – filter by source format const filePath = await window.toolboxAPI.fileSystem.selectPath({ type: "file", - filters: [ - { name: "Connection Files (PPTB JSON or XrmToolBox XML)", extensions: ["json", "xml"] }, - { name: "PPTB JSON Files", extensions: ["json"] }, - { name: "XrmToolBox XML Files", extensions: ["xml"] }, - ], + filters: isXml ? [{ name: "XrmToolBox Connection Files", extensions: ["xml"] }] : [{ name: "PPTB Connection Files", extensions: ["json"] }], }); if (!filePath) { - return; // User cancelled + return; // User cancelled file picker } // Read the file content @@ -1299,9 +1396,6 @@ export async function importConnections(): Promise { return; } - // Detect format: XML files (XrmToolBox) vs JSON files (PPTB) - const isXml = filePath.toLowerCase().endsWith(".xml") || fileContent.trimStart().startsWith(" Date: Sun, 29 Mar 2026 11:58:44 -0400 Subject: [PATCH 083/257] fix: update DevTools opening mode to detach for main and tool windows --- src/main/index.ts | 2 +- src/main/managers/modalWindowManager.ts | 3 --- src/main/managers/toolWindowManager.ts | 4 ++-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 8fe3d37a..688ffb36 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2523,7 +2523,7 @@ class ToolBoxApp { // Open DevTools in development if (process.env.NODE_ENV === "development") { - this.mainWindow.webContents.openDevTools(); + this.mainWindow.webContents.openDevTools({ mode: "detach" }); } this.mainWindow.on("closed", () => { diff --git a/src/main/managers/modalWindowManager.ts b/src/main/managers/modalWindowManager.ts index ebf31e92..a56596a6 100644 --- a/src/main/managers/modalWindowManager.ts +++ b/src/main/managers/modalWindowManager.ts @@ -58,9 +58,6 @@ export class ModalWindowManager { .catch((error) => { logError("Failed to load modal content", error); }); - - // DEBUG: Open DevTools for modal window - modalWindow.webContents.openDevTools({ mode: "detach" }); } hideModal(): void { diff --git a/src/main/managers/toolWindowManager.ts b/src/main/managers/toolWindowManager.ts index 4bb845f7..ae3067bc 100644 --- a/src/main/managers/toolWindowManager.ts +++ b/src/main/managers/toolWindowManager.ts @@ -1,6 +1,7 @@ import { BrowserView, BrowserWindow, ipcMain } from "electron"; import * as path from "path"; import { EVENT_CHANNELS, TOOL_WINDOW_CHANNELS } from "../../common/ipc/channels"; +import { logError, logInfo, logWarn } from "../../common/logger"; import { LastUsedToolConnectionInfo, Tool } from "../../common/types"; import { ToolBoxEvent } from "../../common/types/events"; import { BrowserviewProtocolManager } from "./browserviewProtocolManager"; @@ -9,7 +10,6 @@ import { SettingsManager } from "./settingsManager"; import { TerminalManager } from "./terminalManager"; import { ToolFileSystemAccessManager } from "./toolFileSystemAccessManager"; import { ToolManager } from "./toolsManager"; -import { logInfo, logWarn, logError } from "../../common/logger"; /** * ToolWindowManager @@ -776,7 +776,7 @@ export class ToolWindowManager { } try { - toolView.webContents.openDevTools(); + toolView.webContents.openDevTools({ mode: "detach" }); logInfo(`[ToolWindowManager] Opened DevTools for tool: ${this.activeToolId}`); return true; } catch (error) { From 034b11ee338f63ef1bf93b68df5c9cfcc6e94157 Mon Sep 17 00:00:00 2001 From: Danish Naglekar Date: Sun, 29 Mar 2026 12:18:25 -0400 Subject: [PATCH 084/257] feat: add backdrop overlay to all modal windows Copy the global-search overlay pattern (semi-transparent backdrop + backdrop-filter blur) to all BrowserWindow-backed modals. - modalWindowManager: modal windows now fill the full main-window bounds so the overlay covers the entire app; injects --modal-panel-width and --modal-panel-height CSS vars so each panel keeps its intended size - sharedStyles: add .modal-overlay rule (fixed inset-0, rgba(0,0,0,0.5) background, backdrop-filter blur(4px), flex-centered); update .modal-panel to CSS-var sizing with border-radius and max constraints - All 10 modal view.ts files: wrap panel div in
    - Custom-panel modals (about, troubleshooting, updateNotification, toolDetail): inject .modal-overlay CSS inline and update panel class to CSS-var dimensions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/main/managers/modalWindowManager.ts | 14 ++++++------- src/renderer/modals/about/view.ts | 19 ++++++++++++++++-- src/renderer/modals/addConnection/view.ts | 2 ++ src/renderer/modals/cspException/view.ts | 2 ++ src/renderer/modals/editConnection/view.ts | 2 ++ .../modals/importConnectionSource/view.ts | 2 ++ src/renderer/modals/selectConnection/view.ts | 2 ++ .../modals/selectMultiConnection/view.ts | 2 ++ src/renderer/modals/sharedStyles.ts | 18 +++++++++++++++-- src/renderer/modals/toolDetail/view.ts | 20 +++++++++++++++++-- src/renderer/modals/troubleshooting/view.ts | 20 +++++++++++++++++-- .../modals/updateNotification/view.ts | 19 ++++++++++++++++-- 12 files changed, 105 insertions(+), 17 deletions(-) diff --git a/src/main/managers/modalWindowManager.ts b/src/main/managers/modalWindowManager.ts index a56596a6..a4a84568 100644 --- a/src/main/managers/modalWindowManager.ts +++ b/src/main/managers/modalWindowManager.ts @@ -133,23 +133,23 @@ export class ModalWindowManager { private updateWindowBounds(): void { if (!this.modalWindow || !this.currentOptions) return; - const bounds = this.mainWindow.getBounds(); - const width = this.currentOptions.width; - const height = this.currentOptions.height; - const x = Math.round(bounds.x + (bounds.width - width) / 2); - const y = Math.round(bounds.y + (bounds.height - height) / 2); - - this.modalWindow.setBounds({ x, y, width, height }); + this.modalWindow.setBounds({ x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }); } private composeDocumentHtml(content: string): string { + const panelWidth = this.currentOptions?.width ?? 400; + const panelHeight = this.currentOptions?.height ?? 600; return ` `; const body = ` +
    @@ -321,6 +322,7 @@ export function getCspExceptionModalView(model: CspExceptionModalViewModel): Mod
    +
    `; return { styles, body }; diff --git a/src/renderer/modals/editConnection/view.ts b/src/renderer/modals/editConnection/view.ts index ad8cb012..dec7561a 100644 --- a/src/renderer/modals/editConnection/view.ts +++ b/src/renderer/modals/editConnection/view.ts @@ -12,6 +12,7 @@ export function getEditConnectionModalView(isDarkTheme: boolean): ModalViewTempl const styles = getModalStyles(isDarkTheme); const body = ` +
    @@ -153,6 +154,7 @@ export function getEditConnectionModalView(isDarkTheme: boolean): ModalViewTempl
    +
    `; return { styles, body }; diff --git a/src/renderer/modals/importConnectionSource/view.ts b/src/renderer/modals/importConnectionSource/view.ts index d31c4243..9af25f23 100644 --- a/src/renderer/modals/importConnectionSource/view.ts +++ b/src/renderer/modals/importConnectionSource/view.ts @@ -196,6 +196,7 @@ export function getImportConnectionSourceModalView(isDarkTheme: boolean): ModalV const body = ` +
    @@ -250,6 +251,7 @@ export function getImportConnectionSourceModalView(isDarkTheme: boolean): ModalV
    +
    `; return { styles: styles + extraStyles, body }; diff --git a/src/renderer/modals/selectConnection/view.ts b/src/renderer/modals/selectConnection/view.ts index fd4511af..80c755c7 100644 --- a/src/renderer/modals/selectConnection/view.ts +++ b/src/renderer/modals/selectConnection/view.ts @@ -16,6 +16,7 @@ export function getSelectConnectionModalView(isDarkTheme: boolean, toolName?: st : `

    Connections

    `; const body = ` +
    @@ -88,6 +89,7 @@ export function getSelectConnectionModalView(isDarkTheme: boolean, toolName?: st
    +
    `; return { styles, body }; diff --git a/src/renderer/modals/selectMultiConnection/view.ts b/src/renderer/modals/selectMultiConnection/view.ts index 45d34f51..2f845b5a 100644 --- a/src/renderer/modals/selectMultiConnection/view.ts +++ b/src/renderer/modals/selectMultiConnection/view.ts @@ -104,6 +104,7 @@ export function getSelectMultiConnectionModalView(isDarkTheme: boolean, isSecond : `

    Multi-Connection ${isSecondaryRequired ? "Required" : "Optional"}

    `; const body = ` +
    @@ -198,6 +199,7 @@ export function getSelectMultiConnectionModalView(isDarkTheme: boolean, isSecond
    +
    `; return { styles, body }; diff --git a/src/renderer/modals/sharedStyles.ts b/src/renderer/modals/sharedStyles.ts index 332b55c1..5c4939d8 100644 --- a/src/renderer/modals/sharedStyles.ts +++ b/src/renderer/modals/sharedStyles.ts @@ -30,9 +30,21 @@ export function getModalStyles(isDarkTheme: boolean): string { color: ${isDarkTheme ? "#f3f3f3" : "#1f1f1f"}; } + .modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(4px); + } + .modal-panel { - width: 100%; - height: 100%; + width: var(--modal-panel-width, 400px); + height: var(--modal-panel-height, 600px); + max-width: calc(100vw - 48px); + max-height: calc(100vh - 48px); display: flex; flex-direction: column; gap: 16px; @@ -40,6 +52,8 @@ export function getModalStyles(isDarkTheme: boolean): string { background: ${isDarkTheme ? "#1f1f23" : "#ffffff"}; border: 1px solid ${isDarkTheme ? "rgba(255, 255, 255, 0.08)" : "rgba(0, 0, 0, 0.08)"}; box-shadow: 0 30px 80px rgba(0, 0, 0, ${isDarkTheme ? "0.6" : "0.15"}); + border-radius: 8px; + overflow: hidden; } .modal-header { diff --git a/src/renderer/modals/toolDetail/view.ts b/src/renderer/modals/toolDetail/view.ts index 3730b8c8..62bef2c5 100644 --- a/src/renderer/modals/toolDetail/view.ts +++ b/src/renderer/modals/toolDetail/view.ts @@ -29,9 +29,21 @@ export function getToolDetailModalView(model: ToolDetailModalViewModel): ModalVi `; const body = ` +
    @@ -298,6 +313,7 @@ export function getTroubleshootingModalView(model: TroubleshootingModalViewModel
    +
    `; return { styles, body }; diff --git a/src/renderer/modals/updateNotification/view.ts b/src/renderer/modals/updateNotification/view.ts index 4d39ac5b..2c681612 100644 --- a/src/renderer/modals/updateNotification/view.ts +++ b/src/renderer/modals/updateNotification/view.ts @@ -22,9 +22,21 @@ export function getUpdateNotificationModalView(model: UpdateNotificationModalVie getModalStyles(model.isDarkTheme) + ` diff --git a/src/renderer/modals/about/view.ts b/src/renderer/modals/about/view.ts index aea43c6f..3594edb1 100644 --- a/src/renderer/modals/about/view.ts +++ b/src/renderer/modals/about/view.ts @@ -23,28 +23,15 @@ export function getAboutModalView(model: AboutModalViewModel): AboutModalViewTem getModalStyles(model.isDarkTheme) + ` `; const body = ` -
    +
    @@ -322,7 +322,7 @@ export function getCspExceptionModalView(model: CspExceptionModalViewModel): Mod
    -
    +
    `; return { styles, body }; diff --git a/src/renderer/modals/editConnection/view.ts b/src/renderer/modals/editConnection/view.ts index dec7561a..3c730041 100644 --- a/src/renderer/modals/editConnection/view.ts +++ b/src/renderer/modals/editConnection/view.ts @@ -12,7 +12,7 @@ export function getEditConnectionModalView(isDarkTheme: boolean): ModalViewTempl const styles = getModalStyles(isDarkTheme); const body = ` -
    +
    @@ -154,7 +154,7 @@ export function getEditConnectionModalView(isDarkTheme: boolean): ModalViewTempl
    -
    +
    `; return { styles, body }; diff --git a/src/renderer/modals/importConnectionSource/view.ts b/src/renderer/modals/importConnectionSource/view.ts index 9af25f23..9313597f 100644 --- a/src/renderer/modals/importConnectionSource/view.ts +++ b/src/renderer/modals/importConnectionSource/view.ts @@ -196,7 +196,7 @@ export function getImportConnectionSourceModalView(isDarkTheme: boolean): ModalV const body = ` -
    +
    @@ -251,7 +251,6 @@ export function getImportConnectionSourceModalView(isDarkTheme: boolean): ModalV
    -
    `; return { styles: styles + extraStyles, body }; diff --git a/src/renderer/modals/selectConnection/view.ts b/src/renderer/modals/selectConnection/view.ts index 80c755c7..b21ac63f 100644 --- a/src/renderer/modals/selectConnection/view.ts +++ b/src/renderer/modals/selectConnection/view.ts @@ -16,7 +16,7 @@ export function getSelectConnectionModalView(isDarkTheme: boolean, toolName?: st : `

    Connections

    `; const body = ` -
    +
    @@ -89,7 +89,7 @@ export function getSelectConnectionModalView(isDarkTheme: boolean, toolName?: st
    -
    +
    `; return { styles, body }; diff --git a/src/renderer/modals/selectMultiConnection/view.ts b/src/renderer/modals/selectMultiConnection/view.ts index 2f845b5a..d28fd134 100644 --- a/src/renderer/modals/selectMultiConnection/view.ts +++ b/src/renderer/modals/selectMultiConnection/view.ts @@ -104,7 +104,7 @@ export function getSelectMultiConnectionModalView(isDarkTheme: boolean, isSecond : `

    Multi-Connection ${isSecondaryRequired ? "Required" : "Optional"}

    `; const body = ` -
    +
    @@ -199,7 +199,7 @@ export function getSelectMultiConnectionModalView(isDarkTheme: boolean, isSecond
    -
    +
    `; return { styles, body }; diff --git a/src/renderer/modals/sharedStyles.ts b/src/renderer/modals/sharedStyles.ts index 5c4939d8..332b55c1 100644 --- a/src/renderer/modals/sharedStyles.ts +++ b/src/renderer/modals/sharedStyles.ts @@ -30,21 +30,9 @@ export function getModalStyles(isDarkTheme: boolean): string { color: ${isDarkTheme ? "#f3f3f3" : "#1f1f1f"}; } - .modal-overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.5); - display: flex; - align-items: center; - justify-content: center; - backdrop-filter: blur(4px); - } - .modal-panel { - width: var(--modal-panel-width, 400px); - height: var(--modal-panel-height, 600px); - max-width: calc(100vw - 48px); - max-height: calc(100vh - 48px); + width: 100%; + height: 100%; display: flex; flex-direction: column; gap: 16px; @@ -52,8 +40,6 @@ export function getModalStyles(isDarkTheme: boolean): string { background: ${isDarkTheme ? "#1f1f23" : "#ffffff"}; border: 1px solid ${isDarkTheme ? "rgba(255, 255, 255, 0.08)" : "rgba(0, 0, 0, 0.08)"}; box-shadow: 0 30px 80px rgba(0, 0, 0, ${isDarkTheme ? "0.6" : "0.15"}); - border-radius: 8px; - overflow: hidden; } .modal-header { diff --git a/src/renderer/modals/toolDetail/view.ts b/src/renderer/modals/toolDetail/view.ts index 62bef2c5..a25d23d2 100644 --- a/src/renderer/modals/toolDetail/view.ts +++ b/src/renderer/modals/toolDetail/view.ts @@ -29,21 +29,9 @@ export function getToolDetailModalView(model: ToolDetailModalViewModel): ModalVi `; const body = ` -
    diff --git a/src/renderer/modals/updateNotification/view.ts b/src/renderer/modals/updateNotification/view.ts index 2c681612..9ca6d89d 100644 --- a/src/renderer/modals/updateNotification/view.ts +++ b/src/renderer/modals/updateNotification/view.ts @@ -22,21 +22,9 @@ export function getUpdateNotificationModalView(model: UpdateNotificationModalVie getModalStyles(model.isDarkTheme) + ` diff --git a/src/renderer/index.html b/src/renderer/index.html index 613331e8..1e95d9e1 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -713,6 +713,9 @@

    Tool Settings

    + +
    +
    diff --git a/src/renderer/modules/browserWindowModals.ts b/src/renderer/modules/browserWindowModals.ts index c9d9a26a..6713c958 100644 --- a/src/renderer/modules/browserWindowModals.ts +++ b/src/renderer/modules/browserWindowModals.ts @@ -11,14 +11,21 @@ let listenersInitialized = false; function initializeIpcListeners(): void { if (listenersInitialized) return; - window.api.on(EVENT_CHANNELS.MODAL_WINDOW_MESSAGE, (_, payload) => { - messageHandlers.forEach((handler) => handler((payload as ModalWindowMessagePayload) ?? { channel: "" })); + window.api.on(EVENT_CHANNELS.MODAL_WINDOW_OPENED, () => { + const backdrop = document.getElementById("modal-backdrop"); + if (backdrop) backdrop.style.display = "block"; }); window.api.on(EVENT_CHANNELS.MODAL_WINDOW_CLOSED, (_, payload) => { + const backdrop = document.getElementById("modal-backdrop"); + if (backdrop) backdrop.style.display = "none"; closedHandlers.forEach((handler) => handler((payload as ModalWindowClosedPayload) ?? { id: null })); }); + window.api.on(EVENT_CHANNELS.MODAL_WINDOW_MESSAGE, (_, payload) => { + messageHandlers.forEach((handler) => handler((payload as ModalWindowMessagePayload) ?? { channel: "" })); + }); + listenersInitialized = true; } diff --git a/src/renderer/styles.scss b/src/renderer/styles.scss index 57ef3ff2..08db597a 100644 --- a/src/renderer/styles.scss +++ b/src/renderer/styles.scss @@ -5283,6 +5283,18 @@ body.dark-theme .csp-warning ul { flex: 1; } +/* ============================================================ + Modal Backdrop Overlay + ============================================================ */ + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: $z-modal; + backdrop-filter: blur(4px); +} + /* ============================================================ Global Search Command Palette ============================================================ */ From 4418353ab9671bbdc1d184f22e62f64eb8361e24 Mon Sep 17 00:00:00 2001 From: Power-Maverick Date: Sun, 29 Mar 2026 13:07:20 -0400 Subject: [PATCH 087/257] fix: show modal backdrop immediately with correct display value - Show backdrop synchronously in showBrowserWindowModal() before awaiting the modal window, eliminating the timing gap where the modal appears before the dark overlay - Use display: flex for .modal-backdrop (matching .global-search-overlay) and update all JS toggle calls to use flex instead of block Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/renderer/modules/browserWindowModals.ts | 7 ++++++- src/renderer/styles.scss | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/renderer/modules/browserWindowModals.ts b/src/renderer/modules/browserWindowModals.ts index 6713c958..7dd39caf 100644 --- a/src/renderer/modules/browserWindowModals.ts +++ b/src/renderer/modules/browserWindowModals.ts @@ -13,7 +13,7 @@ function initializeIpcListeners(): void { window.api.on(EVENT_CHANNELS.MODAL_WINDOW_OPENED, () => { const backdrop = document.getElementById("modal-backdrop"); - if (backdrop) backdrop.style.display = "block"; + if (backdrop) backdrop.style.display = "flex"; }); window.api.on(EVENT_CHANNELS.MODAL_WINDOW_CLOSED, (_, payload) => { @@ -43,6 +43,11 @@ export async function showBrowserWindowModal(options: ModalWindowOptions): Promi } initializeIpcListeners(); + + // Show backdrop immediately (before awaiting modal window) so it appears in sync with the modal + const backdrop = document.getElementById("modal-backdrop"); + if (backdrop) backdrop.style.display = "flex"; + await window.toolboxAPI.utils.showModalWindow(options); } diff --git a/src/renderer/styles.scss b/src/renderer/styles.scss index 08db597a..9cb7d543 100644 --- a/src/renderer/styles.scss +++ b/src/renderer/styles.scss @@ -5292,6 +5292,7 @@ body.dark-theme .csp-warning ul { inset: 0; background: rgba(0, 0, 0, 0.5); z-index: $z-modal; + display: flex; backdrop-filter: blur(4px); } From b3f05432150669f899e462a57e9ebf770c63018d Mon Sep 17 00:00:00 2001 From: Power-Maverick Date: Thu, 2 Apr 2026 15:35:31 -0400 Subject: [PATCH 088/257] chore: update release notes for version 1.2.1 with highlights, fixes, and developer notes --- CHANGELOG.md | 287 +++++++++++++++++++++++++++++++++++++++++++++++ RELEASE_NOTES.md | 48 ++++---- 2 files changed, 310 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca124831..8eae43aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,192 @@ # Changelog +## v1.2.1 (2026-04-02) + +### 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 + +### 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 + +### 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 + +Full Changelog: https://github.com/PowerPlatformToolBox/desktop-app/compare/v1.2.0...v1.2.1 + +## v1.2.0 (2026-03-09) + +### Highlights + +- Global search command palette in the activity bar for faster navigation and commands +- Tool details open as a tab (instead of a modal) for smoother browsing and install decisions +- Tool version compatibility checking to prevent running incompatible tools +- Marketplace content moved to Azure Blob storage for improved reliability and load performance +- `pptb://` protocol handler to install tools directly from links +- Connections: category filter/grouping plus environment color and browser-profile badges in selection modals +- CSP exceptions: toolmakers can explain why an exception is needed with optional per-domain user consent +- Update UX uses a themed in-app modal instead of native OS dialogs + +### Fixes + +- Auto-update: "Restart & Install Now" now triggers the update correctly +- Auto-update: update notification always-on-top behavior respects the configured option +- Tools: tool tabs and launch logic correctly handle environment names in tab titles +- Dataverse: entity collection bound actions/functions are handled correctly +- Dataverse: date values in function parameters are formatted correctly +- Notifications: toast behavior no longer forces always-on-top + +### Developer & Build + +- Added `pptb-validate` CLI for pre-publish tool validation (`packages/bin/pptb-validate.js`) +- Added CI workflow to publish `@pptb/types` with improved npm auth and environment isolation +- Added build preflight checks to validate app version and ensure release notes are updated +- Release workflows: refined versioning scheme and improved cross-platform artifact merge scripts +- Telemetry: removed Sentry monitoring in favor of the centralized logger + +Full Changelog: https://github.com/PowerPlatformToolBox/desktop-app/compare/v1.1.3...v1.2.0 + +## v1.1.3 (2026-02-18) + +### Highlights + +- Hardened tool filesystem sandbox so tools can only access user-selected paths and system directories are blocked +- Connection sign-in supports choosing Chrome/Edge plus a specific browser profile to better isolate sessions per connection +- Signed Windows installers (EXE/MSI) via Azure Trusted Signing and repackaged portable ZIPs with signed binaries +- Release metadata now records correct SHA256 and SHA512 hashes for stronger artifact integrity verification +- macOS release pipeline notarizes and staples DMG/ZIP/PKG artifacts with improved signing verification steps +- Dataverse API adds metadata CRUD operations and a `getCSDLDocument` helper for retrieving the OData CSDL document +- Save dialogs support optional file-type filters with extension-based default filter derivation +- Loading overlay positioning is fixed and includes a manual dismiss button + +### Fixes + +- Connections: hardened auth/session isolation to reduce cross-connection token and browser profile leakage +- macOS notarization and stapling no longer skips artifacts and handles unavailable submission logs more reliably +- macOS code signing verification avoids premature `spctl --assess` failures before notarization/stapling completes +- Release workflows regenerate Windows update metadata with correct SHA256/SHA512 after signing +- Tool filesystem reads/writes now enforce explicit user-consent access and reject unsafe/system paths +- Connection and toolbox API handling is more robust for multi-connection scenarios and updated connection fields +- Release workflow date formatting is consistent across jobs and platforms + +### Developer & Build + +- `dataverseAPI` types expand with metadata CRUD operations and `getCSDLDocument` +- `toolboxAPI.fileSystem.saveFile` supports filters and derives defaults from filename extensions +- Added `BrowserManager` for browser detection and profile enumeration used by interactive auth flows +- Signing/notarization scripts and workflows improved for multi-artifact pipelines and better diagnostics + +Full Changelog: https://github.com/PowerPlatformToolBox/desktop-app/compare/v1.1.2...v1.1.3 + +## v1.1.2 (2026-02-09) + +### Highlights + +- MSAL-based authentication isolates tokens per connection and validates access with WhoAmI for more reliable sign-in +- Troubleshooting modal runs configuration checks and surfaces Sentry diagnostics to speed up support and debugging +- Tool updates show inline progress and accessible status feedback while tools are updating +- Terminal UI hides the Terminal button when no terminals exist and includes additional terminal reliability improvements +- Tool menu adds dynamic feedback and quick DevTools options for tool developers +- Dataverse API expands with solution deployment/import status helpers and relationship associate/disassociate endpoints +- Windows and macOS release pipelines improve signing/notarization handling for more trustworthy installers + +### Fixes + +- Dataverse Functions now format parameters correctly, avoiding invocation failures +- Packaged app avoids `ERR_REQUIRE_ESM` issues by properly handling externalized telemetry dependencies +- Modal dialogs no longer remain always-on-top after closing on Windows 11 +- Connection context menu no longer renders behind BrowserViews +- Settings form populates correctly on app reload and avoids duplicate IPC handler registration on macOS window recreation +- macOS notarization scripts handle missing modules/unavailable submission logs and clarify submission/status output +- Authentication token reuse/refresh reduces unexpected expiry prompts with proactive refresh and expiry detection + +### Developer & Build + +- Telemetry identifiers switch from machine ID to install ID for privacy-safe, stable analytics +- Windows packaging adds ARM64 support, MSI targets, and refactored electron-builder configurations +- macOS signing/notarization workflows add submission/status retrieval steps and improved error handling +- `dataverseAPI` types add `deploySolution`, `getImportJobStatus`, and `associate`/`disassociate` helpers +- `toolboxAPI` adds a `fileSystem` API set (path validation + updated publish/selectPath flows) +- Sentry logging helpers and noise reduction improve production diagnostics signal-to-noise + +Full Changelog: https://github.com/PowerPlatformToolBox/desktop-app/compare/v1.1.1...v1.1.2 + +## v1.1.1 (2026-01-19) + +### Highlights + +- Settings changes now queue until Save, preventing accidental toggles from instantly applying across the app +- Installed tools, favorites, and connection icons hot-swap with the active theme so “more” and star glyphs always stay legible +- Connections sidebar adds a default Last Used sort plus synchronized filters for faster environment switching +- Single and multi-connection pickers share the same search, filter, and Last Used ordering for a consistent selection flow +- Marketplace install button adopts a compact icon-only style with spinner feedback and refreshed badges +- Activity bar hover/active treatments gain higher-contrast light-theme colors for clearer navigation cues + +### Fixes + +- Resolved theme mismatches where tool more-menu and favorite icons failed to refresh after switching themes +- Fixed connection sidebar filter buttons whose active state and backgrounds ignored the current theme palette +- Corrected marketplace install hover contrast and badge radius so labels read cleanly in both themes +- Activity items now render hover and active states in light mode, restoring visual focus feedback +- Select connection and multi-connection modals now honor the saved Last Used sort instead of falling back to alphabetical order + +### Developer & Build + +- SettingsManager seeds `connectionsSort` to `last-used` and sanitizes persisted values for predictable ordering +- `UIConnectionData` carries `lastUsedAt`/`createdAt`, enabling tool authors to build smarter connection pickers +- Modal controller scripts share timestamp-based sorting helpers and guard filter dropdown state handling + +Full Changelog: https://github.com/PowerPlatformToolBox/desktop-app/compare/v1.1.0...v1.1.1 + +## v1.1.0 (2026-01-13) + +### Highlights + +- VS Code-style search, filter, and sorting with saved preferences across app sections for faster discovery +- Theme-aware modal refresh with improved accessibility, contrast, and consistent styling +- Homepage and theme updates with refreshed icons and marketplace visuals +- Multi-connection tooling improvements: side-by-side layouts, secondary footer display, and lifecycle status visibility +- Tool insights: source indicators (Registry/NPM/Local), related links, version badges, analytics for downloads/MAU +- Dataverse upgrades: bulk operations support, formatted FetchXML values, `getEntitySetName` helper, improved mappings +- Telemetry and diagnostics: Sentry instrumentation, machine ID tracking, Application Insights hookup, richer About dialog + +### Fixes + +- Resolved macOS window recreation duplicate IPC handlers +- Fixed override client ID clearing for interactive authentication flows +- Settings form now persists correctly after reload; settings and connection events emit reliably without duplicates +- Toast reconnect actions, connection footer colors, and badge palettes now honor theme/environment contrast +- Addressed race conditions in tool context initialization and improved CSP handling for tools +- Debug menu/npm-local tool loading reliability improvements + +### Developer & Build + +- Marketplace shows tool versions and related links; multi-connection support for npm/local tools +- Structured logging and breadcrumb tracing via Sentry; Application Insights connection string support in pipelines +- Modular renderer architecture and better modal management for maintainability + +Full Changelog: https://github.com/PowerPlatformToolBox/desktop-app/compare/v1.0.7...v1.1.0 + +## v1.0.7 (2025-12-10) + +Full Changelog: https://github.com/PowerPlatformToolBox/desktop-app/compare/v1.0.6...v1.0.7 + ## v1.0.6 (2025-11-11) ### Bug Fix - Windows Antivirus False Positive @@ -17,6 +204,106 @@ - Included standard Windows icon sizes for proper display at all resolutions - Follows industry best practices for Electron application icons +## v1.0.5 (2025-11-10) + +### Highlights + +- Add app icon to README for improved visual appeal +- Implement token refresh and expiry management +- Add missing updateTool functionality to fix tool update error +- Add tool icons to marketplace items and move installed badge to footer + +### Fixes + +- Fix authentication response to return refreshToken instead of homeAccountId +- Fix duplicate token expiry notifications +- Fix footer connection status not updating on app load +- Fix footer connection status to show expired state + +### Developer & Build + +- update publishing steps in Tool Development Guide +- Refactor README.md: Update downloads section and remove local testing instructions +- Add @mikefactorial as a contributor + +Full Changelog: https://github.com/PowerPlatformToolBox/desktop-app/compare/v1.0.4...v1.0.5 + +## v1.0.4 (2025-11-05) + +### Highlights + +- update release workflow to prepare release files and generate release notes +- add step to prepare release files and copy necessary artifacts + +### Fixes + +- Fix auto-updater to use ZIP files for macOS updates +- Fix duplicate filename issue in release artifacts +- update checkout step to use the correct branch for release workflow +- Fix Release.yml create-release "Not Found" error + +### Developer & Build + +- update version to 1.0.4 and restore pull request template +- test auto-update +- bump version to 1.0.2 in package.json + +Full Changelog: https://github.com/PowerPlatformToolBox/desktop-app/compare/v1.0.3...v1.0.4 + +## v1.0.3 (2025-11-05) + +Full Changelog: https://github.com/PowerPlatformToolBox/desktop-app/compare/v1.0.2...v1.0.3 + +## v1.0.2 (2025-11-05) + +Full Changelog: https://github.com/PowerPlatformToolBox/desktop-app/compare/v1.0.1...v1.0.2 + +## v1.0.1 (2025-11-04) + +### Highlights + +- Add macOS platform check in afterPack script +- Add gatekeeper assessment and quarantine removal script for macOS builds +- Add manual trigger to prod-release workflow +- Address code review feedback - improve security and type safety + +### Fixes + +- Fix macOS package corruption by adding identity: null to mac build config +- Remove getLatestToolVersion IPC handler and update renderer to use checkToolUpdates +- Fix intake-validation.yml workflow: improve error handling for npm audit +- Fix CodeQL security vulnerability: Use spawn instead of exec to prevent command injection + +### Developer & Build + +- Bump version to 1.0.1 in package.json +- Remove legacy build scripts and migrate configuration files +- Add testing notice to nightly build release notes + +Full Changelog: https://github.com/PowerPlatformToolBox/desktop-app/compare/v1.0.0...v1.0.1 + +## v1.0.0 (2025-11-01) + +### Highlights + +- Add step to delete old pre-releases before creating new ones +- Add Fluent UI styled toastr notifications +- Replace OOTB notifications with toastr custom notifications +- Enhance debugging and build process for Electron app + +### Fixes + +- Fix shell script to handle special characters and increase limit +- Remove default toastr icons and adjust toast container styles for improved visibility +- Update toastr position class to bottom-right for better visibility +- Fix XSS vulnerability in settings-api-example.html by escaping HTML + +### Developer & Build + +- Refactor notification styles to use SCSS variables +- improve validation consistency across all new methods +- Refactor README.md for clarity and consistency in section titles + ## Recent Updates (2025-10-19) ### CSS Organization and CI/CD Improvements diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 90ba8e1f..5a77acee 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,44 +1,42 @@ -# Power Platform ToolBox 1.2.0 +# Power Platform ToolBox 1.2.1 ## Highlights -- Global search command palette in the activity bar for faster navigation and commands -- Tool details open as a tab (instead of a modal) for smoother browsing and install decisions -- Tool version compatibility checking to prevent running incompatible tools -- Marketplace content moved to Azure Blob storage for improved reliability and load performance -- `pptb://` protocol handler to install tools directly from links -- Connections: category filter/grouping plus environment color and browser-profile badges in selection modals -- CSP exceptions: toolmakers can explain why an exception is needed with optional per-domain user consent -- Update UX uses a themed in-app modal instead of native OS dialogs +- 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 ## Fixes -- Auto-update: "Restart & Install Now" now triggers the update correctly -- Auto-update: update notification always-on-top behavior respects the configured option -- Tools: tool tabs and launch logic correctly handle environment names in tab titles -- Dataverse: entity collection bound actions/functions are handled correctly -- Dataverse: date values in function parameters are formatted correctly -- Notifications: toast behavior no longer forces always-on-top +- 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 ## Developer & Build -- Added `pptb-validate` CLI for pre-publish tool validation (`packages/bin/pptb-validate.js`) -- Added CI workflow to publish `@pptb/types` with improved npm auth and environment isolation -- Added build preflight checks to validate app version and ensure release notes are updated -- Release workflows: refined versioning scheme and improved cross-platform artifact merge scripts -- Telemetry: removed Sentry monitoring in favor of the centralized logger +- 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 ## Install -- Windows: Power-Platform-ToolBox-1.2.0-Setup.exe -- macOS: Power-Platform-ToolBox-1.2.0.dmg (drag to Applications) -- Linux: Power-Platform-ToolBox-1.2.0.AppImage (chmod +x, then run) +- 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) ## Notes - No manual migration needed; existing settings and connections continue to work. -- Tool developers: run `pptb-validate` before publishing, and include clear CSP exception rationale for any requested domains. +- Tool developers: plan to remove `showLoading`/`hideLoading` usage and move to the newer loading UX patterns. ## Full Changelog -https://github.com/PowerPlatformToolBox/desktop-app/compare/v1.1.3...v1.2.0 +https://github.com/PowerPlatformToolBox/desktop-app/compare/v1.2.0...v1.2.1 From 6a66bf53189ff753def641cdb73c572d90bb1ff3 Mon Sep 17 00:00:00 2001 From: Power-Maverick Date: Thu, 2 Apr 2026 15:35:43 -0400 Subject: [PATCH 089/257] fix: improve formatting and organization of changelog entries for clarity --- CHANGELOG.md | 421 ++++++++++++++++++++++++--------------------------- 1 file changed, 201 insertions(+), 220 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8eae43aa..1173f4f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -191,18 +191,17 @@ Full Changelog: https://github.com/PowerPlatformToolBox/desktop-app/compare/v1.0 ### Bug Fix - Windows Antivirus False Positive -- **Fixed antivirus false positive detection in v1.0.5** - - - Rebuilt `icons/icon.ico` with multiple icon sizes (16, 32, 48, 64, 96, 128, 256) - - Previous v1.0.5 icon had only a single 256x256 PNG which triggered false positives - - New icon follows Microsoft Windows icon guidelines with proper multi-resolution format - - Icon file size increased from 20KB to 154KB to include all required resolutions - - This fix addresses Windows Defender and other antivirus software flagging the installer as malicious - -- **Technical Details** - - Used ImageMagick to generate properly formatted ICO file from source PNG - - Included standard Windows icon sizes for proper display at all resolutions - - Follows industry best practices for Electron application icons +- **Fixed antivirus false positive detection in v1.0.5** + - Rebuilt `icons/icon.ico` with multiple icon sizes (16, 32, 48, 64, 96, 128, 256) + - Previous v1.0.5 icon had only a single 256x256 PNG which triggered false positives + - New icon follows Microsoft Windows icon guidelines with proper multi-resolution format + - Icon file size increased from 20KB to 154KB to include all required resolutions + - This fix addresses Windows Defender and other antivirus software flagging the installer as malicious + +- **Technical Details** + - Used ImageMagick to generate properly formatted ICO file from source PNG + - Included standard Windows icon sizes for proper display at all resolutions + - Follows industry best practices for Electron application icons ## v1.0.5 (2025-11-10) @@ -308,154 +307,139 @@ Full Changelog: https://github.com/PowerPlatformToolBox/desktop-app/compare/v1.0 ### CSS Organization and CI/CD Improvements -- **Migrated to SCSS with Modular Organization** - - - Converted `styles.css` (2,216 lines) to `styles.scss` - - Created modular structure with `_variables.scss` and `_mixins.scss` - - Implemented SCSS variables for colors, spacing, typography, and z-index - - Created reusable mixins for flexbox, cards, buttons, and scrollbars - - Utilized SCSS nesting for better code organization - - Leveraged modern `@use` syntax (avoiding deprecated `@import`) - - Updated `index.html` to reference `styles.scss` - -- **Added CI/CD Bundle Size Tracking** - - - Created GitHub Actions workflow (`bundle-size.yml`) - - Automated bundle size analysis on PRs and pushes - - Automatic PR comments with bundle size reports - - Bundle analysis artifacts uploaded for detailed review - - Configurable size limit checks with warnings - - Prevents bundle bloat through continuous monitoring - -- **Documentation** - - Updated `docs/BUILD_OPTIMIZATION.md` with SCSS organization guide - - Added CI/CD bundle tracking documentation - - Included examples for custom size limit checks +- **Migrated to SCSS with Modular Organization** + - Converted `styles.css` (2,216 lines) to `styles.scss` + - Created modular structure with `_variables.scss` and `_mixins.scss` + - Implemented SCSS variables for colors, spacing, typography, and z-index + - Created reusable mixins for flexbox, cards, buttons, and scrollbars + - Utilized SCSS nesting for better code organization + - Leveraged modern `@use` syntax (avoiding deprecated `@import`) + - Updated `index.html` to reference `styles.scss` + +- **Added CI/CD Bundle Size Tracking** + - Created GitHub Actions workflow (`bundle-size.yml`) + - Automated bundle size analysis on PRs and pushes + - Automatic PR comments with bundle size reports + - Bundle analysis artifacts uploaded for detailed review + - Configurable size limit checks with warnings + - Prevents bundle bloat through continuous monitoring + +- **Documentation** + - Updated `docs/BUILD_OPTIMIZATION.md` with SCSS organization guide + - Added CI/CD bundle tracking documentation + - Included examples for custom size limit checks ## Recent Updates (2025-10-18) ### Build Optimizations and ESM Migration -- **Added Bundle Analysis** - - - Integrated `rollup-plugin-visualizer` for bundle size analysis - - Generates visual reports for main and renderer processes - - Reports show module sizes (gzipped and brotli compressed) - - Treemap, sunburst, and network visualizations available - - Output files: `dist/stats-main.html` and `dist/stats-renderer.html` - -- **Configured Code Splitting** - - - Automatic vendor chunk separation for better caching - - Manual chunks configuration for custom split points - - Improved load times and parallel loading - - Better browser caching of vendor dependencies - -- **Added CSS Preprocessing Support** - - - Integrated Sass/SCSS preprocessor - - Configuration for global SCSS variables and mixins - - Support for Less, Stylus, and PostCSS out of the box - - Preprocessor options configurable in `vite.config.ts` - -- **Full ESM Migration** - - - Migrated all `require()` calls to ES6 `import` statements - - Improved tree-shaking and bundle optimization - - Better static analysis and type checking - - Future-proof module system aligned with ECMAScript standards - - Refactored `child_process` imports in `toolsManager.ts` - - Refactored file system imports in `vite.config.ts` - -- **Documentation** - - Created comprehensive `docs/BUILD_OPTIMIZATION.md` guide - - Updated README.md with bundle analysis instructions - - Added troubleshooting and optimization tips +- **Added Bundle Analysis** + - Integrated `rollup-plugin-visualizer` for bundle size analysis + - Generates visual reports for main and renderer processes + - Reports show module sizes (gzipped and brotli compressed) + - Treemap, sunburst, and network visualizations available + - Output files: `dist/stats-main.html` and `dist/stats-renderer.html` + +- **Configured Code Splitting** + - Automatic vendor chunk separation for better caching + - Manual chunks configuration for custom split points + - Improved load times and parallel loading + - Better browser caching of vendor dependencies + +- **Added CSS Preprocessing Support** + - Integrated Sass/SCSS preprocessor + - Configuration for global SCSS variables and mixins + - Support for Less, Stylus, and PostCSS out of the box + - Preprocessor options configurable in `vite.config.ts` + +- **Full ESM Migration** + - Migrated all `require()` calls to ES6 `import` statements + - Improved tree-shaking and bundle optimization + - Better static analysis and type checking + - Future-proof module system aligned with ECMAScript standards + - Refactored `child_process` imports in `toolsManager.ts` + - Refactored file system imports in `vite.config.ts` + +- **Documentation** + - Created comprehensive `docs/BUILD_OPTIMIZATION.md` guide + - Updated README.md with bundle analysis instructions + - Added troubleshooting and optimization tips ### Build System Migration to Vite -- **Replaced TypeScript compiler with Vite bundler** - - - Integrated Vite 7.1 with vite-plugin-electron for optimal Electron support - - Created comprehensive `vite.config.ts` with custom plugins - - Automatic handling of static assets (icons, JSON, bridge files) - - Optimized bundling with tree-shaking and code splitting - -- **Performance Improvements** - - - Initial build time: ~5-8s → ~3.5s (50% faster) - - Incremental builds: ~3-5s → <1s with HMR (80% faster) - - Development startup: ~10s → ~3.5s (65% faster) - - Hot Module Replacement (HMR) for instant renderer updates - -- **Simplified Build Scripts** - - - Consolidated multiple npm scripts into single `vite build` command - - Removed manual file copying operations - - Removed `shx` dependency (no longer needed) - -- **Bug Fixes** - - - Fixed CSS syntax error (extra closing brace in styles.css line 2186) - - Fixed HTML asset paths in bundled output - -- **Documentation Updates** - - - Updated README.md with Vite development workflow - - Updated CONTRIBUTING.md with new build instructions - - Created VITE_MIGRATION.md comprehensive migration guide - - Updated verify-build.sh to validate Vite output structure - -- **Maintained Compatibility** - - TypeScript configs preserved for IDE support - - ESLint configuration unchanged - - electron-builder packaging works seamlessly - - Same dist/ output structure for backward compatibility +- **Replaced TypeScript compiler with Vite bundler** + - Integrated Vite 7.1 with vite-plugin-electron for optimal Electron support + - Created comprehensive `vite.config.ts` with custom plugins + - Automatic handling of static assets (icons, JSON, bridge files) + - Optimized bundling with tree-shaking and code splitting + +- **Performance Improvements** + - Initial build time: ~5-8s → ~3.5s (50% faster) + - Incremental builds: ~3-5s → <1s with HMR (80% faster) + - Development startup: ~10s → ~3.5s (65% faster) + - Hot Module Replacement (HMR) for instant renderer updates + +- **Simplified Build Scripts** + - Consolidated multiple npm scripts into single `vite build` command + - Removed manual file copying operations + - Removed `shx` dependency (no longer needed) + +- **Bug Fixes** + - Fixed CSS syntax error (extra closing brace in styles.css line 2186) + - Fixed HTML asset paths in bundled output + +- **Documentation Updates** + - Updated README.md with Vite development workflow + - Updated CONTRIBUTING.md with new build instructions + - Created VITE_MIGRATION.md comprehensive migration guide + - Updated verify-build.sh to validate Vite output structure + +- **Maintained Compatibility** + - TypeScript configs preserved for IDE support + - ESLint configuration unchanged + - electron-builder packaging works seamlessly + - Same dist/ output structure for backward compatibility ## Recent Updates (2025-10-17) ### Documentation Reorganization -- **Moved all documentation to `docs/` folder** - - - `ARCHITECTURE.md` → `docs/ARCHITECTURE.md` - - `TOOL_DEVELOPMENT.md` → `docs/TOOL_DEVELOPMENT.md` - - `TOOL_HOST_ARCHITECTURE.md` → `docs/TOOL_HOST_ARCHITECTURE.md` - - `CONTRIBUTING.md` → `CONTRIBUTING.md` +- **Moved all documentation to `docs/` folder** + - `ARCHITECTURE.md` → `docs/ARCHITECTURE.md` + - `TOOL_DEVELOPMENT.md` → `docs/TOOL_DEVELOPMENT.md` + - `TOOL_HOST_ARCHITECTURE.md` → `docs/TOOL_HOST_ARCHITECTURE.md` + - `CONTRIBUTING.md` → `CONTRIBUTING.md` -- **Removed temporary documentation files** +- **Removed temporary documentation files** + - Deleted `IMPLEMENTATION_SUMMARY.md` + - Deleted `PROJECT_SUMMARY.md` + - Deleted `PR_SUMMARY.md` + - Deleted `REQUIREMENTS_CHECKLIST.md` - - Deleted `IMPLEMENTATION_SUMMARY.md` - - Deleted `PROJECT_SUMMARY.md` - - Deleted `PR_SUMMARY.md` - - Deleted `REQUIREMENTS_CHECKLIST.md` - -- **Updated all documentation references** - - Updated `README.md` with new documentation paths - - Fixed all internal links to point to `docs/` folder - - Added comprehensive branch and PR naming conventions to `CONTRIBUTING.md` +- **Updated all documentation references** + - Updated `README.md` with new documentation paths + - Fixed all internal links to point to `docs/` folder + - Added comprehensive branch and PR naming conventions to `CONTRIBUTING.md` ### TypeScript Configuration Updates -- **Updated root `tsconfig.json`** - - - Target: ES2022 (from ES2020) - - Module: Node16 (from commonjs) - - Module Resolution: Node16 (from node) - - Added `allowSyntheticDefaultImports` and `isolatedModules` - -- **Updated `tsconfig.renderer.json`** +- **Updated root `tsconfig.json`** + - Target: ES2022 (from ES2020) + - Module: Node16 (from commonjs) + - Module Resolution: Node16 (from node) + - Added `allowSyntheticDefaultImports` and `isolatedModules` - - Target: ES2022 (from ES2020) - - Lib: ES2022 + DOM (from ES2020 + DOM) - - Module: ES2022 (from inherited commonjs) - - Module Resolution: bundler (modern strategy) +- **Updated `tsconfig.renderer.json`** + - Target: ES2022 (from ES2020) + - Lib: ES2022 + DOM (from ES2020 + DOM) + - Module: ES2022 (from inherited commonjs) + - Module Resolution: bundler (modern strategy) -- **Updated `examples/example-tool/tsconfig.json`** - - Target: ES2022 (from ES2020) - - Module: ES2022 (from ES2020) - - Module Resolution: bundler (from node) - - Added `allowSyntheticDefaultImports` and `isolatedModules` +- **Updated `examples/example-tool/tsconfig.json`** + - Target: ES2022 (from ES2020) + - Module: ES2022 (from ES2020) + - Module Resolution: bundler (from node) + - Added `allowSyntheticDefaultImports` and `isolatedModules` ### New Framework Examples @@ -463,108 +447,105 @@ Added three new complete example tools demonstrating modern framework integratio #### React Example (`examples/react-example/`) -- **Framework**: React 18 with TypeScript -- **Build Tool**: Vite 6 -- **Features**: - - React Hooks (useState, useEffect) - - ToolBox API integration - - Connection management - - Event handling - - Modern component architecture - - Full TypeScript support +- **Framework**: React 18 with TypeScript +- **Build Tool**: Vite 6 +- **Features**: + - React Hooks (useState, useEffect) + - ToolBox API integration + - Connection management + - Event handling + - Modern component architecture + - Full TypeScript support #### Vue Example (`examples/vue-example/`) -- **Framework**: Vue 3 with Composition API -- **Build Tool**: Vite 6 -- **Features**: - - Composition API with ` + +`; + } + + destroy(): void { + this.removeIpcHandlers(); + if (this.historyWindow) { + this.historyWindow.destroy(); + this.historyWindow = null; + } + this.history = []; + this.unreadCount = 0; + this.isPanelOpen = false; + } +} + /** * NotificationWindowManager * @@ -23,6 +470,7 @@ export class NotificationWindowManager { private notificationWindow: BrowserWindow | null = null; private mainWindow: BrowserWindow; private notifications: NotificationOptions[] = []; + private historyManager: NotificationHistoryWindowManager | null = null; private readonly MAX_NOTIFICATIONS = 3; private readonly WINDOW_WIDTH = 400; private readonly NOTIFICATION_HEIGHT = 100; @@ -35,6 +483,11 @@ export class NotificationWindowManager { this.setupMainWindowListeners(); } + /** Wire up the history manager so each shown notification is also recorded. */ + setHistoryManager(manager: NotificationHistoryWindowManager): void { + this.historyManager = manager; + } + /** * Create the notification window */ @@ -130,6 +583,7 @@ export class NotificationWindowManager { ipcMain.handle("notification:show", async (event, options: NotificationOptions) => { this.showNotification(options); + this.historyManager?.addNotification(options); }); ipcMain.on("notification:dismiss", (event, index: number) => { diff --git a/src/main/notificationPreload.ts b/src/main/notificationPreload.ts index 6032b644..25145888 100644 --- a/src/main/notificationPreload.ts +++ b/src/main/notificationPreload.ts @@ -4,4 +4,6 @@ import { contextBridge, ipcRenderer } from "electron"; contextBridge.exposeInMainWorld("electron", { dismissNotification: (index: number) => ipcRenderer.send("notification:dismiss", index), actionClicked: (index: number, actionIndex: number) => ipcRenderer.send("notification:action", index, actionIndex), + clearHistory: () => ipcRenderer.send("notification-history:clear"), + closeHistoryPanel: () => ipcRenderer.send("notification-history:close"), }); diff --git a/src/renderer/index.html b/src/renderer/index.html index 1a6c2f9d..3ab29cb2 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -605,6 +605,12 @@

    What's New

    Show Terminal +
    diff --git a/src/renderer/modules/initialization.ts b/src/renderer/modules/initialization.ts index 79ec38a6..b5dd3f7e 100644 --- a/src/renderer/modules/initialization.ts +++ b/src/renderer/modules/initialization.ts @@ -30,7 +30,7 @@ import { loadHomepageData, setupHomepageActions } from "./homepageManagement"; import { clearMarketplaceDropdownFilters, handleProtocolInstallToolRequest, loadMarketplace, loadToolsLibrary } from "./marketplaceManagement"; import { openAgentInvocationLogsTab } from "./mcpManagement"; import { closeModal, openModal } from "./modalManagement"; -import { setDefaultNotificationDuration, showPPTBNotification } from "./notifications"; +import { setDefaultNotificationDuration, showPPTBNotification, initNotificationHistoryPanel } from "./notifications"; import { openSettingsTab } from "./settingsManagement"; import { switchSidebar } from "./sidebarManagement"; import { handleTerminalClosed, handleTerminalCommandCompleted, handleTerminalCreated, handleTerminalError, handleTerminalOutput, setupTerminalPanel } from "./terminalManagement"; @@ -110,6 +110,10 @@ export async function initializeApplication(): Promise { // Set up global search command palette initializeGlobalSearch(); + // Set up notification history panel (bell icon in footer) early so the click + // handler is registered before any async operations that might delay init. + initNotificationHistoryPanel(); + // Load and apply theme settings on startup await loadInitialSettings(); logCheckpoint("Initial settings loaded"); @@ -804,6 +808,7 @@ function setupToolPanelBoundsListener(): void { width: Math.round(rect.width), height: adjustedHeight, }; + logInfo("[Renderer] Sending tool panel bounds:", bounds); window.api.send("get-tool-panel-bounds-response", bounds); } else { diff --git a/src/renderer/modules/notifications.ts b/src/renderer/modules/notifications.ts index d8fdd7a8..a01533f7 100644 --- a/src/renderer/modules/notifications.ts +++ b/src/renderer/modules/notifications.ts @@ -94,7 +94,8 @@ function setupNotificationActionListener(): void { /** * Show PPTB notification using dedicated BrowserWindow - * Notifications are displayed in an always-on-top frameless window above the BrowserView + * Notifications are displayed in an always-on-top frameless window above the BrowserView. + * Each notification is also forwarded to the main process for persistent history tracking. */ export function showPPTBNotification(options: NotificationOptions): void { // Ensure the action listener is set up @@ -113,7 +114,7 @@ export function showPPTBNotification(options: NotificationOptions): void { // remain available until the user explicitly dismisses the notification. const effectiveDuration = duration === 0 ? Number.MAX_SAFE_INTEGER - Date.now() : duration; const expiresAt = Date.now() + effectiveDuration + CALLBACK_TTL_BUFFER_MS; - + actions.forEach((action: { label: string; callback: string }, index: number) => { const originalCallback = options.actions![index].callback; notificationCallbacks.set(action.callback, { @@ -121,12 +122,12 @@ export function showPPTBNotification(options: NotificationOptions): void { expiresAt, }); }); - + // Start the cleanup interval to handle dismissed notifications startCleanupInterval(); } - // Send to notification window manager via IPC + // Send to notification window manager via IPC (also records in main-process history) window.api.invoke("notification:show", { title: options.title, body: options.body, @@ -135,3 +136,69 @@ export function showPPTBNotification(options: NotificationOptions): void { actions, }); } + +// ── Notification History Panel ──────────────────────────────────────────────── +// The history panel is a separate always-on-top BrowserWindow managed by the +// main process (NotificationHistoryWindowManager). The renderer is responsible +// only for the bell button UI and badge update. + +/** Whether the history panel window is currently open */ +let isPanelOpen = false; + +/** + * Initialize the notification history panel bell button. + * Wires up the bell button click to open/close the main-process history window + * and listens for badge-update and panel-closed events from the main process. + * Must be called once after the DOM is ready. + */ +export function initNotificationHistoryPanel(): void { + const bellBtn = document.getElementById("footer-notification-bell-btn"); + if (!bellBtn) return; + + // Toggle the history window on bell button click + bellBtn.addEventListener("click", (e) => { + e.stopPropagation(); + if (isPanelOpen) { + window.api.send("notification-history:close"); + } else { + isPanelOpen = true; + bellBtn.setAttribute("aria-pressed", "true"); + window.api.send("notification-history:open"); + } + }); + + // Main process confirms the panel opened (updates aria-pressed) + window.api.on("notification-history:opened", () => { + isPanelOpen = true; + bellBtn.setAttribute("aria-pressed", "true"); + }); + + // Main process notifies us when the panel closed (blur, Escape, or explicit close) + window.api.on("notification-history:closed", () => { + isPanelOpen = false; + bellBtn.setAttribute("aria-pressed", "false"); + }); + + // Main process sends badge updates whenever the unread count changes. + // window.api.on wraps ipcRenderer.on directly, so args[0] is the IPC event object + // and args[1] is the first data payload (the unread count). + window.api.on("notification:badge-update", (...args: unknown[]) => { + const count = typeof args[1] === "number" ? args[1] : 0; + updateBadge(count); + }); +} + +/** Update the badge element to reflect the current unread count */ +function updateBadge(count: number): void { + const badge = document.getElementById("notification-badge"); + if (!badge) return; + + if (count > 0) { + const label = count > 99 ? "99+" : String(count); + badge.textContent = label; + badge.setAttribute("aria-label", `${label} unread notifications`); + badge.hidden = false; + } else { + badge.hidden = true; + } +} diff --git a/src/renderer/styles.scss b/src/renderer/styles.scss index 444d4e02..6dddcdca 100644 --- a/src/renderer/styles.scss +++ b/src/renderer/styles.scss @@ -5863,3 +5863,52 @@ body.dark-theme .global-search-item-badge.badge-settings { font-weight: normal; text-transform: none; } + +/* ── Notification Bell Button ───────────────────────────────────────────── */ + +.footer-bell-btn { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: none; + background: transparent; + color: var(--text-secondary); + border-radius: 4px; + cursor: pointer; + transition: color 0.15s ease, background 0.15s ease; + flex-shrink: 0; +} + +.footer-bell-btn:hover { + background: rgba(0, 0, 0, 0.08); + color: var(--text-color); +} + +.dark-theme .footer-bell-btn:hover { + background: rgba(255, 255, 255, 0.1); +} + +.footer-bell-btn[aria-pressed="true"] { + color: var(--accent-color, #0078d4); +} + +.notification-badge { + position: absolute; + top: 1px; + right: 1px; + min-width: 14px; + height: 14px; + padding: 0 3px; + background: #d13438; + color: #ffffff; + font-size: 9px; + font-weight: 700; + line-height: 14px; + border-radius: 7px; + text-align: center; + pointer-events: none; +} diff --git a/tests/e2e/notificationHistory.spec.ts b/tests/e2e/notificationHistory.spec.ts new file mode 100644 index 00000000..7b1235cd --- /dev/null +++ b/tests/e2e/notificationHistory.spec.ts @@ -0,0 +1,122 @@ +import type { ElectronApplication, Page } from "playwright"; +import { test, expect } from "./fixtures"; + +/** + * E2E: Notification history bell button in the footer. + * + * The history panel is now a separate always-on-top BrowserWindow managed by + * the main process (NotificationHistoryWindowManager). Clicking the bell + * button in the footer opens/closes that window. + * + * Prerequisites: run `pnpm run build` before executing these tests. + */ + +/** + * Click the bell button and wait for the newly-created history BrowserWindow + * to appear. The history window is created lazily on first click. + */ +async function openHistoryWindow(electronApp: ElectronApplication, window: Page): Promise { + const bellBtn = window.locator("#footer-notification-bell-btn"); + await expect(bellBtn).toBeVisible({ timeout: 15_000 }); + + const historyWindowPromise = electronApp.waitForEvent("window", { timeout: 10_000 }); + await bellBtn.click(); + const historyWindow = await historyWindowPromise; + await historyWindow.waitForLoadState("domcontentloaded"); + return historyWindow; +} + +/** Check whether the Notification History BrowserWindow is currently visible. */ +async function isHistoryWindowVisible(electronApp: ElectronApplication): Promise { + return electronApp.evaluate(({ BrowserWindow }) => { + const win = BrowserWindow.getAllWindows().find((w) => w.getTitle() === "Notification History"); + return win?.isVisible() ?? false; + }); +} + +test.describe("Notification history panel", () => { + test("bell button is visible in the footer", async ({ window }) => { + const bellBtn = window.locator("#footer-notification-bell-btn"); + await expect(bellBtn).toBeVisible({ timeout: 15_000 }); + }); + + test("notification badge is initially hidden", async ({ window }) => { + const badge = window.locator("#notification-badge"); + await expect(badge).toBeHidden({ timeout: 15_000 }); + }); + + test("bell button aria-pressed is false initially", async ({ window }) => { + const bellBtn = window.locator("#footer-notification-bell-btn"); + await expect(bellBtn).toBeVisible({ timeout: 15_000 }); + await expect(bellBtn).toHaveAttribute("aria-pressed", "false"); + }); + + test("clicking the bell button opens the history window", async ({ electronApp, window }) => { + await openHistoryWindow(electronApp, window); + + const isVisible = await isHistoryWindowVisible(electronApp); + expect(isVisible).toBe(true); + }); + + test("clicking the bell button again closes the history window", async ({ electronApp, window }) => { + const bellBtn = window.locator("#footer-notification-bell-btn"); + await expect(bellBtn).toBeVisible({ timeout: 15_000 }); + + // Open the history window + await openHistoryWindow(electronApp, window); + await expect(bellBtn).toHaveAttribute("aria-pressed", "true", { timeout: 5_000 }); + + // Click bell again to close + await bellBtn.click(); + await expect(bellBtn).toHaveAttribute("aria-pressed", "false", { timeout: 5_000 }); + + const isVisible = await isHistoryWindowVisible(electronApp); + expect(isVisible).toBe(false); + }); + + test("pressing Escape in the history window closes it", async ({ electronApp, window }) => { + const bellBtn = window.locator("#footer-notification-bell-btn"); + await expect(bellBtn).toBeVisible({ timeout: 15_000 }); + + const historyWindow = await openHistoryWindow(electronApp, window); + await expect(bellBtn).toHaveAttribute("aria-pressed", "true", { timeout: 5_000 }); + + await historyWindow.keyboard.press("Escape"); + await expect(bellBtn).toHaveAttribute("aria-pressed", "false", { timeout: 5_000 }); + }); + + test("empty state is shown when there are no notifications", async ({ electronApp, window }) => { + const historyWindow = await openHistoryWindow(electronApp, window); + + const emptyState = historyWindow.locator("#notification-history-empty"); + await expect(emptyState).toBeVisible({ timeout: 5_000 }); + await expect(emptyState).toHaveText(/no notifications yet/i); + }); + + test("history window has a Clear All button", async ({ electronApp, window }) => { + const historyWindow = await openHistoryWindow(electronApp, window); + + const clearBtn = historyWindow.locator("#notification-clear-all-btn"); + await expect(clearBtn).toBeVisible({ timeout: 5_000 }); + }); + + test("bell button aria-pressed reflects window open state", async ({ electronApp, window }) => { + const bellBtn = window.locator("#footer-notification-bell-btn"); + await expect(bellBtn).toBeVisible({ timeout: 15_000 }); + + await expect(bellBtn).toHaveAttribute("aria-pressed", "false"); + + await openHistoryWindow(electronApp, window); + await expect(bellBtn).toHaveAttribute("aria-pressed", "true", { timeout: 5_000 }); + + await bellBtn.click(); + await expect(bellBtn).toHaveAttribute("aria-pressed", "false", { timeout: 5_000 }); + }); + + test("notification list is present inside the history window", async ({ electronApp, window }) => { + const historyWindow = await openHistoryWindow(electronApp, window); + + const list = historyWindow.locator("#notification-history-list"); + await expect(list).toBeAttached({ timeout: 5_000 }); + }); +}); From 3822c5981f2c0660b08863d726e00d83df1bb0cc Mon Sep 17 00:00:00 2001 From: Danish Naglekar <36135520+Power-Maverick@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:04:42 -0400 Subject: [PATCH 222/257] Removed the incorrect filter and updated the wording on connection selection modal for PP API (#586) * Removed the incorrect filter and updated the wording for PP API * fix the test script --- .../modals/selectConnection/controller.ts | 16 ++++++------ src/renderer/modals/selectConnection/view.ts | 2 +- .../selectMultiConnection/controller.ts | 8 +----- .../modals/selectMultiConnection/view.ts | 2 +- tests/e2e/app.spec.ts | 25 +++++++++++++------ 5 files changed, 30 insertions(+), 23 deletions(-) diff --git a/src/renderer/modals/selectConnection/controller.ts b/src/renderer/modals/selectConnection/controller.ts index 900cac58..3895e4a7 100644 --- a/src/renderer/modals/selectConnection/controller.ts +++ b/src/renderer/modals/selectConnection/controller.ts @@ -16,7 +16,7 @@ export interface ConnectionListData { /** * Returns the controller script that wires up DOM events for the select connection modal. * @param channels - Channel IDs for IPC communication - * @param enabledForPowerPlatformAPI - Whether to filter for Power Platform API enabled connections + * @param enabledForPowerPlatformAPI - Whether to show Power Platform API guidance/tag context */ export function getSelectConnectionModalControllerScript(channels: SelectConnectionModalChannelIds, enabledForPowerPlatformAPI: boolean = false): string { const serializedChannels = JSON.stringify(channels); @@ -96,7 +96,6 @@ ${sortingUtilities} const selectedAuth = authFilter?.value || ""; const selectedCategory = categoryFilter?.value || ""; const selectedSort = sanitizeSortOption(sortSelect?.value || injectedSortOption); - const requirePowerPlatformApi = ENABLED_FOR_POWER_PLATFORM_API === true; let filtered = allConnections.filter(conn => { // Search filter @@ -126,11 +125,6 @@ ${sortingUtilities} } } - // Power Platform API filter - only show connections enabled for Power Platform API - if (requirePowerPlatformApi && conn.enabledForPowerPlatformAPI !== true) { - return false; - } - return true; }); @@ -426,6 +420,14 @@ ${sortingUtilities} } else { console.warn("modalBridge.onMessage is not available"); } + + // Show Power Platform API info message if required + if (ENABLED_FOR_POWER_PLATFORM_API === true) { + const ppApiInfo = document.getElementById("power-platform-api-info"); + if (ppApiInfo) { + ppApiInfo.style.display = "block"; + } + } // Request connections list from main process modalBridge.send(CHANNELS.populateConnections, {}); diff --git a/src/renderer/modals/selectConnection/view.ts b/src/renderer/modals/selectConnection/view.ts index b5ff6e82..1ef0c4ee 100644 --- a/src/renderer/modals/selectConnection/view.ts +++ b/src/renderer/modals/selectConnection/view.ts @@ -28,7 +28,7 @@ export function getSelectConnectionModalView(isDarkTheme: boolean, toolName?: st Please select a connection to connect to your Dataverse environment before using this tool.
    - Only connections enabled for Power Platform API are shown. Add a connection with Client ID/Secret authentication and select "Enable for Power Platform API" to use it with this tool. + This tool uses Power Platform API. Selecting a connection that is not enabled for PP API may cause issues while using this tool.
    diff --git a/src/renderer/modals/selectMultiConnection/controller.ts b/src/renderer/modals/selectMultiConnection/controller.ts index b8c8340f..bc3ce730 100644 --- a/src/renderer/modals/selectMultiConnection/controller.ts +++ b/src/renderer/modals/selectMultiConnection/controller.ts @@ -17,7 +17,7 @@ export interface ConnectionListData { * Returns the controller script that wires up DOM events for the select multi-connection modal. * @param channels - Channel IDs for IPC communication * @param isSecondaryRequired - Whether the secondary connection is required (true) or optional (false) - * @param enabledForPowerPlatformAPI - Whether to filter for Power Platform API enabled connections + * @param enabledForPowerPlatformAPI - Whether to show Power Platform API guidance/tag context */ export function getSelectMultiConnectionModalControllerScript( channels: SelectMultiConnectionModalChannelIds, @@ -104,7 +104,6 @@ ${sortingUtilities} const selectedAuth = authFilter?.value || ""; const selectedCategory = categoryFilter?.value || ""; const selectedSort = sanitizeSortOption(sortSelect?.value || injectedSortOption); - const requirePowerPlatformApi = ENABLED_FOR_POWER_PLATFORM_API === true; let filtered = allConnections.filter(conn => { // Search filter @@ -134,11 +133,6 @@ ${sortingUtilities} } } - // Power Platform API filter - only show connections enabled for Power Platform API - if (requirePowerPlatformApi && conn.enabledForPowerPlatformAPI !== true) { - return false; - } - return true; }); diff --git a/src/renderer/modals/selectMultiConnection/view.ts b/src/renderer/modals/selectMultiConnection/view.ts index a90801c2..ae4b773b 100644 --- a/src/renderer/modals/selectMultiConnection/view.ts +++ b/src/renderer/modals/selectMultiConnection/view.ts @@ -118,7 +118,7 @@ export function getSelectMultiConnectionModalView(isDarkTheme: boolean, isSecond } to continue.
    - Only connections enabled for Power Platform API are shown. Add a connection with Client ID/Secret authentication and select "Enable for Power Platform API" to use it with this tool. + This tool uses Power Platform API. Selecting a connection that is not enabled for PP API may cause issues while using this tool.
    diff --git a/tests/e2e/app.spec.ts b/tests/e2e/app.spec.ts index 9dec4b5b..f5ba39ab 100644 --- a/tests/e2e/app.spec.ts +++ b/tests/e2e/app.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "./fixtures"; +import { expect, test } from "./fixtures"; /** * E2E: basic application launch and window visibility. @@ -26,12 +26,20 @@ test.describe("App launch", () => { }); test("no JavaScript errors thrown at startup", async ({ electronApp }) => { - const errors: string[] = []; + const jsErrors: string[] = []; + const consoleErrors: string[] = []; const win = await electronApp.firstWindow(); + + // Track actual unhandled JS exceptions in the renderer. + win.on("pageerror", (error) => { + jsErrors.push(error.message); + }); + + // Keep console error tracking for diagnostics only. win.on("console", (msg) => { if (msg.type() === "error") { - errors.push(msg.text()); + consoleErrors.push(msg.text()); } }); @@ -40,11 +48,14 @@ test.describe("App launch", () => { // networkidle can time out in Electron — that's OK }); - // Filter out known benign console errors from third-party libs - const significantErrors = errors.filter( - (e) => !e.includes("favicon") && !e.includes("net::ERR_") && !e.includes("Mixed Content"), + // Assert only on true JavaScript startup errors. + expect(jsErrors).toHaveLength(0); + + // Optional sanity check: keep known noisy resource/network console errors out of signal. + const significantConsoleErrors = consoleErrors.filter( + (e) => !e.includes("favicon") && !e.includes("net::ERR_") && !e.includes("Mixed Content") && !e.includes("Failed to load resource: the server responded with a status of 404"), ); - expect(significantErrors).toHaveLength(0); + expect(significantConsoleErrors).toHaveLength(0); }); }); From 857476038b528a71c17d6c2126e61a18bb68661f Mon Sep 17 00:00:00 2001 From: Danish Naglekar <36135520+Power-Maverick@users.noreply.github.com> Date: Fri, 26 Jun 2026 09:40:25 -0400 Subject: [PATCH 223/257] [Feature] side by side toolview (#588) * implemented correct split functionality * added test cases plus update the PR template --- .github/pull_request_template.md | 81 ++- .github/workflows/pr-checklist.yml | 222 +++++++ src/common/ipc/channels.ts | 16 + src/common/types/api.ts | 38 ++ src/common/types/settings.ts | 1 + src/main/index.ts | 6 + src/main/managers/splitLayoutManager.ts | 441 +++++++++++++ src/main/managers/toolWindowManager.ts | 62 ++ src/main/preload.ts | 15 + src/renderer/index.html | 26 +- src/renderer/modules/initialization.ts | 6 +- src/renderer/modules/toolManagement.ts | 338 +++++++++- src/renderer/styles.scss | 101 ++- tests/__mocks__/electron.ts | 4 + tests/e2e/splitLayout.spec.ts | 168 +++++ .../main/managers/splitLayoutManager.test.ts | 614 ++++++++++++++++++ 16 files changed, 2116 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/pr-checklist.yml create mode 100644 src/main/managers/splitLayoutManager.ts create mode 100644 tests/e2e/splitLayout.spec.ts create mode 100644 tests/unit/main/managers/splitLayoutManager.test.ts diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 65488c0e..7eed5a06 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,7 +1,76 @@ -Please fill in this template. + + + -- [ ] Use a meaningful title for the pull request. -- [ ] Follow the guidelines from the [CONTRIBUTING.md](https://github.com/PowerPlatformToolBox/desktop-app/CONTRIBUTING.md#pull-requests). -- [ ] Mention the bug or the feature number the PR will be targeting. -- [ ] Test the change in your own code. (Compile and run) -- [ ] Resolve all GH Copilot comments. +## Summary + + + +Closes # + +## Type of change + +- [ ] New feature +- [ ] Bug fix +- [ ] Refactor (no functional change) +- [ ] Documentation +- [ ] Chore / maintenance (dependency update, build, config) +- [ ] Test addition / improvement + +## Changes + + + +- + +## Architecture checklist + +### Packages (`types` & `validation`) + + + +- [ ] **Not applicable** — no changes to `packages/` + +If you did change a package: + +- [ ] `@pptb/types` (`types`): type definitions updated and version bumped in `packages/types/package.json` +- [ ] `@pptb/validate` (`validation`): validation rules updated and version bumped in `packages/validation/package.json` + +### Code quality + +- [ ] `pnpm run typecheck` passes with **0 errors** (warnings are acceptable) +- [ ] `pnpm run lint` passes with **0 errors** (warnings are acceptable) +- [ ] `pnpm run build` completes successfully + +## Testing + + + +- [ ] `pnpm run test:unit` passes (for changes to `src/main/`, `src/common/`, or `src/renderer/` utilities) +- [ ] `pnpm run test:e2e` passes (for UI / navigation / end-to-end flows) +- [ ] Manually tested in the running app (`pnpm run dev`) + +**Scenario tested:** + + + +## Screenshots / recordings + + + +## Breaking changes + + + +- [ ] No breaking changes +- [ ] Yes — describe impact and migration path below: + + + +## Reviewer notes + + + +- [ ] I have added appropriate unit and/or e2e tests for this change +- [ ] I have resolved all GitHub Copilot review comments +- [ ] I have followed the guidelines in [CONTRIBUTING.md](https://github.com/PowerPlatformToolBox/desktop-app/blob/dev/CONTRIBUTING.md#pull-requests) diff --git a/.github/workflows/pr-checklist.yml b/.github/workflows/pr-checklist.yml new file mode 100644 index 00000000..011a9c19 --- /dev/null +++ b/.github/workflows/pr-checklist.yml @@ -0,0 +1,222 @@ +name: PR Checklist Validation + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + branches: + - main + - dev + +permissions: + pull-requests: write + issues: write + contents: read + +jobs: + validate-checklist: + name: PR Checklist + runs-on: ubuntu-latest + + steps: + # ----------------------------------------------------------------- + # Parse the PR body, validate required checkboxes, manage labels, + # and post/update a single sticky comment with the result. + # ----------------------------------------------------------------- + - name: Validate checklist and apply labels + uses: actions/github-script@v7 + with: + script: | + const body = context.payload.pull_request.body || ''; + const title = context.payload.pull_request.title || ''; + const prNumber = context.payload.pull_request.number; + const author = context.payload.pull_request.user.login; + const existingLabels = context.payload.pull_request.labels.map(l => l.name); + + // ── Helpers ───────────────────────────────────────────────────────── + + /** + * Extract the content of a Markdown section identified by its heading + * prefix (e.g. "## Testing" or "### Code quality"). Stops as soon as + * it encounters another heading at the same level or higher. + */ + function extractSection(text, headingPrefix) { + const lines = text.split('\n'); + const headingLevel = (headingPrefix.match(/^(#+)/) || ['', '#'])[1].length; + let capturing = false; + const result = []; + + for (const line of lines) { + if (!capturing && line.startsWith(headingPrefix)) { + capturing = true; + result.push(line); + } else if (capturing) { + const m = line.match(/^(#+)\s/); + if (m && m[1].length <= headingLevel) break; + result.push(line); + } + } + return result.join('\n'); + } + + /** True if the section contains at least one `- [x]` line. */ + function atLeastOneChecked(section) { + return /^- \[x\]/im.test(section); + } + + /** True if every checkbox in the section is `- [x]` (none are `- [ ]`). */ + function allChecked(section) { + return atLeastOneChecked(section) && !/^- \[ \]/im.test(section); + } + + // ── Extract relevant sections ──────────────────────────────────────── + + const typeSection = extractSection(body, '## Type of change'); + const packagesSection = extractSection(body, '### Packages'); + const codeQuality = extractSection(body, '### Code quality'); + const testingSection = extractSection(body, '## Testing'); + const breakingSection = extractSection(body, '## Breaking changes'); + const reviewerSection = extractSection(body, '## Reviewer notes'); + + // ── Validation rules ───────────────────────────────────────────────── + + const errors = []; + + // PR title must follow [Type] Brief description + if (!/^\[(Feature|Fix|Docs|Refactor|Chore|Test)\] .+/.test(title)) { + errors.push( + '**PR title** must follow `[Type] Brief description` ' + + '— valid types: `Feature` · `Fix` · `Docs` · `Refactor` · `Chore` · `Test`' + ); + } + + // Type of change — at least one + if (!atLeastOneChecked(typeSection)) { + errors.push('**Type of change** — select at least one option'); + } + + // Packages — at least one (including "Not applicable") + if (!atLeastOneChecked(packagesSection)) { + errors.push( + '**Packages** — select at least one option ' + + '(tick "Not applicable" if no package changes were made)' + ); + } + + // Code quality — all boxes must be ticked + if (!allChecked(codeQuality)) { + errors.push('**Code quality** — all checkboxes must be ticked before merging'); + } + + // Testing — all boxes must be ticked + if (!allChecked(testingSection)) { + errors.push('**Testing** — all checkboxes must be ticked before merging'); + } + + // Breaking changes — at least one option selected + if (!atLeastOneChecked(breakingSection)) { + errors.push('**Breaking changes** — select at least one option'); + } + + // Reviewer notes — all boxes must be ticked + if (!allChecked(reviewerSection)) { + errors.push('**Reviewer notes** — all checkboxes must be ticked before merging'); + } + + // ── Label management ───────────────────────────────────────────────── + + // breaking-change: the "Yes — describe impact" line is checked + const isBreakingChange = /^- \[x\].*Yes/im.test(breakingSection); + + // package-changed: any @pptb line is checked (i.e. not just "Not applicable") + const isPackageChanged = /^- \[x\].*@pptb/im.test(packagesSection); + + async function syncLabel(name, shouldHave) { + const has = existingLabels.includes(name); + if (shouldHave && !has) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + labels: [name], + }); + } else if (!shouldHave && has) { + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + name, + }); + } catch { + // Label may have already been removed — ignore + } + } + } + + await syncLabel('breaking-change', isBreakingChange); + await syncLabel('package-changed', isPackageChanged); + + // ── Sticky comment ─────────────────────────────────────────────────── + // We post one comment and update it in place on subsequent runs so the + // PR timeline stays clean. + + const MARKER = ''; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + }); + const existing = comments.find(c => c.body && c.body.includes(MARKER)); + + const labelNote = [ + isBreakingChange ? '🔴 `breaking-change` label applied' : null, + isPackageChanged ? '🔵 `package-changed` label applied' : null, + ].filter(Boolean).join('\n'); + + let commentBody; + + const errorList = errors.map(e => `- ${e}`).join('\n'); + + if (errors.length === 0) { + commentBody = + `${MARKER}\n` + + `### ✅ PR Checklist\n\n` + + `All required checklist items are complete. This PR is ready for review.\n` + + (labelNote ? `\n${labelNote}` : ''); + } else { + // On updates we omit the @mention to avoid re-notifying on every push. + const mention = existing ? '' : `@${author} — please address the items below.\n\n`; + commentBody = + `${MARKER}\n` + + `### ❌ PR Checklist — Action Required\n\n` + + `${mention}` + + `The following items must be completed before this PR can be merged:\n\n` + + `${errorList}\n` + + (labelNote ? `\n---\n${labelNote}` : ''); + } + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body: commentBody, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: commentBody, + }); + } + + // ── Fail the status check ──────────────────────────────────────────── + + if (errors.length > 0) { + core.setFailed( + `PR checklist incomplete — ${errors.length} item${errors.length > 1 ? 's' : ''} outstanding:\n` + + errors.map(e => ` • ${e.replace(/\*\*/g, '')}`).join('\n') + ); + } diff --git a/src/common/ipc/channels.ts b/src/common/ipc/channels.ts index 43c2dc1b..4f261d93 100644 --- a/src/common/ipc/channels.ts +++ b/src/common/ipc/channels.ts @@ -285,5 +285,21 @@ export const MODAL_WINDOW_CHANNELS = { RENDERER_MESSAGE: "modal-window:renderer-message", } as const; +// Split layout channels +export const SPLIT_LAYOUT_CHANNELS = { + ACTIVATE: "split-layout:activate", + DEACTIVATE: "split-layout:deactivate", + SET_RATIO: "split-layout:set-ratio", + GET_STATE: "split-layout:get-state", + /** setActiveInPane(pane, instanceId) — make a group member the visible tool in its pane */ + SWITCH_PANE: "split-layout:switch-pane", + /** moveToPane(instanceId, targetPane) — move a tab from one group to another */ + MOVE_TO_PANE: "split-layout:move-to-pane", + /** setFocusedPane(pane) — set which pane receives newly opened tools */ + FOCUS_PANE: "split-layout:focus-pane", + /** Pushed from main to renderer when split state changes */ + STATE_CHANGED: "split-layout:state-changed", +} as const; + // Type helper to extract channel names export type ChannelName = T[keyof T]; diff --git a/src/common/types/api.ts b/src/common/types/api.ts index 031d34cd..17f048b5 100644 --- a/src/common/types/api.ts +++ b/src/common/types/api.ts @@ -159,6 +159,41 @@ export interface DataverseAPI { getEntitySetName: (entityLogicalName: string) => Promise; } +/** + * Split layout state returned by the main process + */ +export interface SplitLayoutState { + isActive: boolean; + /** Ordered list of instanceIds assigned to the left pane. */ + leftGroup: string[]; + /** Ordered list of instanceIds assigned to the right pane. */ + rightGroup: string[]; + /** The currently visible (active) tool in the left pane. */ + activeLeftInstanceId: string | null; + /** The currently visible (active) tool in the right pane. */ + activeRightInstanceId: string | null; + /** Which pane receives newly opened tools. */ + focusedPane: "left" | "right"; + ratio: number; +} + +/** + * Split Layout API namespace + */ +export interface SplitLayoutAPI { + activate: (leftInstanceId: string, rightInstanceId: string) => Promise; + deactivate: () => Promise; + setRatio: (ratio: number) => Promise; + getState: () => Promise; + /** Make instanceId the active (visible) tool in its pane. Also focuses that pane. */ + switchPane: (pane: "left" | "right", instanceId: string) => Promise; + /** Move instanceId from its current group to targetPane. Collapses split if source becomes empty. */ + moveToPane: (instanceId: string, targetPane: "left" | "right") => Promise; + /** Set which pane receives newly opened tools. */ + focusPane: (pane: "left" | "right") => Promise; + onStateChanged: (callback: (state: SplitLayoutState) => void) => void; +} + /** * Main ToolboxAPI interface */ @@ -276,6 +311,9 @@ export interface ToolboxAPI { /** Install the beta (pre-release) npm package for a registry tool and return the loaded Tool. */ installPrereleaseToolFromNpm: (npmPackageName: string) => Promise; + // Split layout namespace + splitLayout: SplitLayoutAPI; + // Utils namespace utils: UtilsAPI; diff --git a/src/common/types/settings.ts b/src/common/types/settings.ts index 1d146b02..a781ed1e 100644 --- a/src/common/types/settings.ts +++ b/src/common/types/settings.ts @@ -95,4 +95,5 @@ export interface UserSettings { categoryColorThickness?: number; // Thickness in pixels of the category color border under the tab environmentColorThickness?: number; // Thickness in pixels of the environment color border around the tool panel mcpAccessToken?: string; // Access token for local MCP server authentication + splitDividerRatio?: number; // Persisted position of the split-pane divider (0.15–0.85) } diff --git a/src/main/index.ts b/src/main/index.ts index 008f17d0..d47ca423 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -43,6 +43,7 @@ import { NotificationHistoryWindowManager, NotificationWindowManager } from "./m import { PowerPlatformManager } from "./managers/powerplatformManager"; import { ProtocolHandlerManager } from "./managers/protocolHandlerManager"; import { SettingsManager } from "./managers/settingsManager"; +import { SplitLayoutManager } from "./managers/splitLayoutManager"; import { TerminalManager } from "./managers/terminalManager"; import { ToolBoxUtilityManager } from "./managers/toolboxUtilityManager"; import { ToolFileSystemAccessManager } from "./managers/toolFileSystemAccessManager"; @@ -78,6 +79,7 @@ class ToolBoxApp { private browserviewProtocolManager: BrowserviewProtocolManager; private protocolHandlerManager: ProtocolHandlerManager; private toolWindowManager: ToolWindowManager | null = null; + private splitLayoutManager: SplitLayoutManager | null = null; private notificationWindowManager: NotificationWindowManager | null = null; private notificationHistoryWindowManager: NotificationHistoryWindowManager | null = null; private modalWindowManager: ModalWindowManager | null = null; @@ -2676,6 +2678,10 @@ class ToolBoxApp { this.mcpServerManager.setToolWindowManager(this.toolWindowManager); + // Initialize SplitLayoutManager — depends on the shared toolViews map from ToolWindowManager + this.splitLayoutManager = new SplitLayoutManager(this.mainWindow, this.settingsManager, this.toolWindowManager.getToolViews()); + this.toolWindowManager.setSplitLayoutManager(this.splitLayoutManager); + // Set up callback to rebuild menu when active tool changes (debounced to prevent excessive recreation) this.toolWindowManager.setOnActiveToolChanged(() => { this.debouncedCreateMenu(); diff --git a/src/main/managers/splitLayoutManager.ts b/src/main/managers/splitLayoutManager.ts new file mode 100644 index 00000000..0b4751ba --- /dev/null +++ b/src/main/managers/splitLayoutManager.ts @@ -0,0 +1,441 @@ +import { BrowserView, BrowserWindow, ipcMain } from "electron"; +import { SPLIT_LAYOUT_CHANNELS } from "../../common/ipc/channels"; +import { logError, logInfo, logWarn } from "../../common/logger"; +import { SplitLayoutState } from "../../common/types/api"; +import { SettingsManager } from "./settingsManager"; + +/** Pixel width of the gap kept between the two pane BrowserViews for the divider. */ +export const SPLIT_DIVIDER_WIDTH = 4; + +const DEFAULT_RATIO = 0.5; +const MIN_RATIO = 0.15; +const MAX_RATIO = 0.85; + +/** + * SplitLayoutManager + * + * Manages a VS Code-style split-pane layout. Each pane is a *group* of tool + * instances (tabs); only one tool per pane is visible at a time (the active one). + * + * Design constraints: + * - Does NOT create or destroy BrowserViews — that is ToolWindowManager's job. + * - Does NOT touch the inter-tool invocation / return flow. + * - Is purely a display concern: it repositions existing BrowserViews using + * `addBrowserView` so both active pane views are visible simultaneously. + */ +export class SplitLayoutManager { + private mainWindow: BrowserWindow; + private settingsManager: SettingsManager; + /** Shared reference to ToolWindowManager's internal view map (live reference). */ + private toolViews: Map; + + private _isActive = false; + /** Ordered list of instanceIds in the left pane group. */ + private leftGroup: string[] = []; + /** Ordered list of instanceIds in the right pane group. */ + private rightGroup: string[] = []; + /** Currently visible (active) tool in the left pane. */ + private activeLeftId: string | null = null; + /** Currently visible (active) tool in the right pane. */ + private activeRightId: string | null = null; + /** Which pane receives newly opened tools. */ + private _focusedPane: "left" | "right" = "left"; + private ratio: number = DEFAULT_RATIO; + + /** Last full bounds received from the renderer, used to re-apply on ratio / pane changes. */ + private lastKnownFullBounds: { x: number; y: number; width: number; height: number } | null = null; + + constructor(mainWindow: BrowserWindow, settingsManager: SettingsManager, toolViews: Map) { + this.mainWindow = mainWindow; + this.settingsManager = settingsManager; + this.toolViews = toolViews; + + // Restore persisted divider ratio + const savedRatio = settingsManager.getSetting("splitDividerRatio"); + if (typeof savedRatio === "number" && savedRatio >= MIN_RATIO && savedRatio <= MAX_RATIO) { + this.ratio = savedRatio; + } + + this.setupIpcHandlers(); + } + + // ─── Public Getters ─────────────────────────────────────────────────────── + + get isActive(): boolean { + return this._isActive; + } + + get focusedPane(): "left" | "right" { + return this._focusedPane; + } + + // ─── IPC ───────────────────────────────────────────────────────────────── + + private removeIpcHandlers(): void { + ipcMain.removeHandler(SPLIT_LAYOUT_CHANNELS.ACTIVATE); + ipcMain.removeHandler(SPLIT_LAYOUT_CHANNELS.DEACTIVATE); + ipcMain.removeHandler(SPLIT_LAYOUT_CHANNELS.SET_RATIO); + ipcMain.removeHandler(SPLIT_LAYOUT_CHANNELS.GET_STATE); + ipcMain.removeHandler(SPLIT_LAYOUT_CHANNELS.SWITCH_PANE); + ipcMain.removeHandler(SPLIT_LAYOUT_CHANNELS.MOVE_TO_PANE); + ipcMain.removeHandler(SPLIT_LAYOUT_CHANNELS.FOCUS_PANE); + } + + private setupIpcHandlers(): void { + this.removeIpcHandlers(); + + ipcMain.handle(SPLIT_LAYOUT_CHANNELS.ACTIVATE, async (_event, leftInstanceId: string, rightInstanceId: string) => { + return this.activate(leftInstanceId, rightInstanceId); + }); + + ipcMain.handle(SPLIT_LAYOUT_CHANNELS.DEACTIVATE, async () => { + return this.deactivate(); + }); + + ipcMain.handle(SPLIT_LAYOUT_CHANNELS.SET_RATIO, async (_event, ratio: number) => { + return this.setRatio(ratio); + }); + + ipcMain.handle(SPLIT_LAYOUT_CHANNELS.GET_STATE, async () => { + return this.getState(); + }); + + ipcMain.handle(SPLIT_LAYOUT_CHANNELS.SWITCH_PANE, async (_event, pane: "left" | "right", instanceId: string) => { + return this.setActiveInPane(pane, instanceId); + }); + + ipcMain.handle(SPLIT_LAYOUT_CHANNELS.MOVE_TO_PANE, async (_event, instanceId: string, targetPane: "left" | "right") => { + return this.moveToPane(instanceId, targetPane); + }); + + ipcMain.handle(SPLIT_LAYOUT_CHANNELS.FOCUS_PANE, async (_event, pane: "left" | "right") => { + this.setFocusedPane(pane); + }); + } + + // ─── Public API ─────────────────────────────────────────────────────────── + + getState(): SplitLayoutState { + return { + isActive: this._isActive, + leftGroup: [...this.leftGroup], + rightGroup: [...this.rightGroup], + activeLeftInstanceId: this.activeLeftId, + activeRightInstanceId: this.activeRightId, + focusedPane: this._focusedPane, + ratio: this.ratio, + }; + } + + /** Returns which pane the instance belongs to, or null if not in split. */ + getPaneForInstance(instanceId: string): "left" | "right" | null { + if (!this._isActive) return null; + if (this.leftGroup.includes(instanceId)) return "left"; + if (this.rightGroup.includes(instanceId)) return "right"; + return null; + } + + /** Returns true if the given instanceId is in either pane group (backward compat). */ + isInstanceInSplit(instanceId: string): boolean { + return this.getPaneForInstance(instanceId) !== null; + } + + /** + * Activate split mode with one tool per pane to start. + * Each instance becomes a one-entry group; both BrowserViews shown immediately. + */ + activate(leftInstanceId: string, rightInstanceId: string): boolean { + if (leftInstanceId === rightInstanceId) { + logWarn("[SplitLayoutManager] Cannot split the same instance into both panes"); + return false; + } + + this._isActive = true; + this.leftGroup = [leftInstanceId]; + this.rightGroup = [rightInstanceId]; + this.activeLeftId = leftInstanceId; + this.activeRightId = rightInstanceId; + this._focusedPane = "left"; + + logInfo(`[SplitLayoutManager] Activated: left=${leftInstanceId}, right=${rightInstanceId}, ratio=${this.ratio}`); + + if (this.lastKnownFullBounds) { + this.applyLayout(this.lastKnownFullBounds); + } + + this.notifyRenderer(); + return true; + } + + /** + * Deactivate split mode. All BrowserViews are cleared; + * ToolWindowManager restores the surviving tool via the STATE_CHANGED path. + */ + deactivate(): boolean { + if (!this._isActive) return false; + + this._isActive = false; + this.leftGroup = []; + this.rightGroup = []; + this.activeLeftId = null; + this.activeRightId = null; + this._focusedPane = "left"; + + // Clear all BrowserViews so ToolWindowManager can re-attach via setBrowserView() + this.clearAllBrowserViews(); + + logInfo("[SplitLayoutManager] Deactivated"); + this.notifyRenderer(); + return true; + } + + /** Update the divider ratio and reposition both panes. Persisted to settings. */ + setRatio(ratio: number): void { + const clamped = Math.max(MIN_RATIO, Math.min(MAX_RATIO, ratio)); + this.ratio = clamped; + this.settingsManager.setSetting("splitDividerRatio", clamped); + + if (this._isActive && this.lastKnownFullBounds) { + this.applyLayout(this.lastKnownFullBounds); + } + + this.notifyRenderer(); + } + + /** + * Make instanceId the active (visible) tool in the given pane. + * The instance must already be in that pane's group. + * Also updates focusedPane so future tools open to this pane. + */ + setActiveInPane(pane: "left" | "right", instanceId: string): boolean { + const group = pane === "left" ? this.leftGroup : this.rightGroup; + if (!group.includes(instanceId)) { + logWarn(`[SplitLayoutManager] setActiveInPane: ${instanceId} not in ${pane} group`); + return false; + } + if (pane === "left") { + this.activeLeftId = instanceId; + } else { + this.activeRightId = instanceId; + } + this._focusedPane = pane; + + if (this.lastKnownFullBounds) { + this.applyLayout(this.lastKnownFullBounds); + } + this.notifyRenderer(); + return true; + } + + /** + * Add a new tool to the focused pane and make it active there. + * Called by ToolWindowManager when a new tool opens while split is active. + */ + addToolToFocusedPane(instanceId: string): void { + if (!this._isActive) return; + + if (this._focusedPane === "left") { + if (!this.leftGroup.includes(instanceId)) { + this.leftGroup.push(instanceId); + } + this.activeLeftId = instanceId; + } else { + if (!this.rightGroup.includes(instanceId)) { + this.rightGroup.push(instanceId); + } + this.activeRightId = instanceId; + } + + logInfo(`[SplitLayoutManager] Added ${instanceId} to focused ${this._focusedPane} pane`); + if (this.lastKnownFullBounds) { + this.applyLayout(this.lastKnownFullBounds); + } + this.notifyRenderer(); + } + + /** + * Move instanceId from its current group to targetPane. + * If the source group becomes empty, split is collapsed. + */ + moveToPane(instanceId: string, targetPane: "left" | "right"): boolean { + const sourcePane = this.getPaneForInstance(instanceId); + if (!sourcePane) { + logWarn(`[SplitLayoutManager] moveToPane: ${instanceId} not in any group`); + return false; + } + if (sourcePane === targetPane) return true; + + // Remove from source group + if (sourcePane === "left") { + this.leftGroup = this.leftGroup.filter((id) => id !== instanceId); + if (this.activeLeftId === instanceId) { + this.activeLeftId = this.leftGroup[this.leftGroup.length - 1] ?? null; + } + } else { + this.rightGroup = this.rightGroup.filter((id) => id !== instanceId); + if (this.activeRightId === instanceId) { + this.activeRightId = this.rightGroup[this.rightGroup.length - 1] ?? null; + } + } + + // Add to target group and make it active + if (targetPane === "left") { + this.leftGroup.push(instanceId); + this.activeLeftId = instanceId; + } else { + this.rightGroup.push(instanceId); + this.activeRightId = instanceId; + } + this._focusedPane = targetPane; + + // If the source group is now empty, collapse to single-pane + const sourceEmpty = sourcePane === "left" ? this.leftGroup.length === 0 : this.rightGroup.length === 0; + if (sourceEmpty) { + logInfo(`[SplitLayoutManager] Source pane (${sourcePane}) empty after move — collapsing`); + this.deactivate(); + return true; + } + + logInfo(`[SplitLayoutManager] Moved ${instanceId}: ${sourcePane} → ${targetPane}`); + if (this.lastKnownFullBounds) { + this.applyLayout(this.lastKnownFullBounds); + } + this.notifyRenderer(); + return true; + } + + /** Set which pane receives newly opened tools. */ + setFocusedPane(pane: "left" | "right"): void { + this._focusedPane = pane; + this.notifyRenderer(); + } + + /** + * Called by ToolWindowManager when any tool closes. + * Removes from its group; collapses split if the group becomes empty. + */ + handleToolClosed(instanceId: string): void { + if (!this._isActive) return; + + const inLeft = this.leftGroup.includes(instanceId); + const inRight = this.rightGroup.includes(instanceId); + if (!inLeft && !inRight) return; + + if (inLeft) { + this.leftGroup = this.leftGroup.filter((id) => id !== instanceId); + if (this.activeLeftId === instanceId) { + this.activeLeftId = this.leftGroup[this.leftGroup.length - 1] ?? null; + } + } else { + this.rightGroup = this.rightGroup.filter((id) => id !== instanceId); + if (this.activeRightId === instanceId) { + this.activeRightId = this.rightGroup[this.rightGroup.length - 1] ?? null; + } + } + + if (this.leftGroup.length === 0 || this.rightGroup.length === 0) { + logInfo(`[SplitLayoutManager] Pane group empty after closing ${instanceId} — collapsing`); + this.deactivate(); + } else { + if (this.lastKnownFullBounds) { + this.applyLayout(this.lastKnownFullBounds); + } + this.notifyRenderer(); + } + } + + /** + * Apply the side-by-side layout using the currently active tools for each pane. + * Hides all inactive BrowserViews so only the two active ones are visible. + * @param fullBounds The full content bounds of the tool-panel-content element + * as reported by the renderer (CSS pixels, window-relative). + */ + applyLayout(fullBounds: { x: number; y: number; width: number; height: number }): void { + if (!this._isActive || !this.activeLeftId || !this.activeRightId) return; + + this.lastKnownFullBounds = fullBounds; + + const leftView = this.toolViews.get(this.activeLeftId); + const rightView = this.toolViews.get(this.activeRightId); + + if (!leftView || !rightView) { + logWarn("[SplitLayoutManager] Active BrowserViews not found — cannot apply layout"); + return; + } + + if (leftView.webContents.isDestroyed() || rightView.webContents.isDestroyed()) { + logWarn("[SplitLayoutManager] Active BrowserViews are destroyed — skipping layout"); + return; + } + + try { + const totalWidth = fullBounds.width; + const halfDivider = Math.floor(SPLIT_DIVIDER_WIDTH / 2); + const splitPoint = Math.floor(totalWidth * this.ratio); + + const leftWidth = Math.max(1, splitPoint - halfDivider); + const rightStartX = fullBounds.x + splitPoint + (SPLIT_DIVIDER_WIDTH - halfDivider); + const rightWidth = Math.max(1, totalWidth - splitPoint - (SPLIT_DIVIDER_WIDTH - halfDivider)); + + // addBrowserView is additive — both active views are shown simultaneously. + this.mainWindow.addBrowserView(leftView); + this.mainWindow.addBrowserView(rightView); + + leftView.setBounds({ + x: fullBounds.x, + y: fullBounds.y, + width: leftWidth, + height: Math.max(1, fullBounds.height), + }); + + rightView.setBounds({ + x: rightStartX, + y: fullBounds.y, + width: rightWidth, + height: Math.max(1, fullBounds.height), + }); + + // Hide all inactive BrowserViews (tools not currently the active pane view) + for (const [id, view] of this.toolViews) { + if (id !== this.activeLeftId && id !== this.activeRightId) { + try { + this.mainWindow.removeBrowserView(view); + } catch { + // ignore individual removal errors + } + } + } + + logInfo(`[SplitLayoutManager] Layout: left=${this.activeLeftId}(w=${leftWidth}), right=${this.activeRightId}(x=${rightStartX},w=${rightWidth}), ratio=${this.ratio}`); + } catch (err) { + logError("[SplitLayoutManager] Error applying layout", err); + } + } + + // ─── Private helpers ───────────────────────────────────────────────────── + + private clearAllBrowserViews(): void { + try { + const views = this.mainWindow.getBrowserViews?.() ?? []; + for (const view of views) { + try { + this.mainWindow.removeBrowserView(view); + } catch { + // ignore individual removal errors + } + } + } catch (err) { + logWarn("[SplitLayoutManager] Error clearing BrowserViews", err); + } + } + + private notifyRenderer(): void { + try { + if (!this.mainWindow.isDestroyed()) { + this.mainWindow.webContents.send(SPLIT_LAYOUT_CHANNELS.STATE_CHANGED, this.getState()); + } + } catch (err) { + logWarn("[SplitLayoutManager] Error sending state to renderer", err); + } + } +} diff --git a/src/main/managers/toolWindowManager.ts b/src/main/managers/toolWindowManager.ts index 9670ba42..49ffdad4 100644 --- a/src/main/managers/toolWindowManager.ts +++ b/src/main/managers/toolWindowManager.ts @@ -7,6 +7,7 @@ import { ToolBoxEvent } from "../../common/types/events"; import { BrowserviewProtocolManager } from "./browserviewProtocolManager"; import { ConnectionsManager } from "./connectionsManager"; import { SettingsManager } from "./settingsManager"; +import { SplitLayoutManager } from "./splitLayoutManager"; import { TerminalManager } from "./terminalManager"; import { ToolFileSystemAccessManager } from "./toolFileSystemAccessManager"; import { ToolManager } from "./toolsManager"; @@ -101,6 +102,8 @@ export class ToolWindowManager { private activeToolId: string | null = null; private boundsUpdatePending: boolean = false; private frameScheduled = false; + /** Optional split layout manager — injected after construction via setSplitLayoutManager(). */ + private splitLayoutManager: SplitLayoutManager | null = null; private boundsResponseListener: (event: Electron.IpcMainEvent, bounds: { x: number; y: number; width: number; height: number }) => void; private terminalVisibilityListener: () => void; private bannerVisibilityListener: () => void; @@ -691,6 +694,39 @@ export class ToolWindowManager { return false; } + // ── Split-mode handling ─────────────────────────────────────────────────── + // When split is active, all tool switches are handled here regardless of + // whether the instance is already in a pane or is a brand-new tool. + if (this.splitLayoutManager?.isActive) { + const pane = this.splitLayoutManager.getPaneForInstance(instanceId); + if (pane) { + // Already in a pane — make it the visible (active) tool for that pane + this.splitLayoutManager.setActiveInPane(pane, instanceId); + } else { + // New tool not yet in any pane — route to the focused pane + this.splitLayoutManager.addToolToFocusedPane(instanceId); + } + + this.activeToolId = instanceId; + this.invokeActiveToolChangedCallback(); + + const invocationEntryS = this.pendingInvocations.get(instanceId); + if (invocationEntryS) { + const callerToolNameS = this.toolInstanceNames.get(invocationEntryS.callerInstanceId) ?? "Caller"; + if (invocationEntryS.noReturn) { + this.mainWindow.webContents.send(TOOL_WINDOW_CHANNELS.INVOCATION_BANNER_STATE, { visible: false }); + } else { + this.mainWindow.webContents.send(TOOL_WINDOW_CHANNELS.INVOCATION_BANNER_STATE, { visible: true, callerToolName: callerToolNameS }); + } + } else { + this.mainWindow.webContents.send(TOOL_WINDOW_CHANNELS.INVOCATION_BANNER_STATE, { visible: false }); + } + + logInfo(`[ToolWindowManager] Split mode: ${pane ? "active in " + pane + " pane" : "added to focused pane"} → ${instanceId}`); + this.scheduleBoundsUpdate(); + return true; + } + // Hide current tool if any if (this.activeToolId && this.activeToolId !== instanceId) { const currentView = this.toolViews.get(this.activeToolId); @@ -801,6 +837,9 @@ export class ToolWindowManager { // Revoke filesystem access for this specific tool instance this.toolFilesystemAccessManager.revokeAllAccess(instanceId); + // Notify split layout manager so it can deactivate split if a pane tool closed + this.splitLayoutManager?.handleToolClosed(instanceId); + logInfo(`[ToolWindowManager] Tool instance closed: ${instanceId}`); return true; } catch (error) { @@ -992,6 +1031,13 @@ export class ToolWindowManager { * Apply the bounds to the active tool view */ private applyToolViewBounds(bounds: { x: number; y: number; width: number; height: number }): void { + // ── Split-mode: delegate entirely to SplitLayoutManager ────────────────── + if (this.splitLayoutManager?.isActive) { + this.splitLayoutManager.applyLayout(bounds); + this.boundsUpdatePending = false; + return; + } + if (!this.activeToolId) return; const toolView = this.toolViews.get(this.activeToolId); @@ -1265,6 +1311,22 @@ export class ToolWindowManager { return this.activeToolId; } + /** + * Expose the internal BrowserView map so SplitLayoutManager can reference the same + * instance without duplicating state. Callers must not mutate the map directly. + */ + getToolViews(): Map { + return this.toolViews; + } + + /** + * Wire up the SplitLayoutManager. Must be called after construction so that the + * shared toolViews reference is already stable. + */ + setSplitLayoutManager(manager: SplitLayoutManager): void { + this.splitLayoutManager = manager; + } + /** * Get the bounds of the active tool's BrowserView * @returns The bounds of the active tool's BrowserView, or null if no tool is active diff --git a/src/main/preload.ts b/src/main/preload.ts index c626ee79..d0ed9465 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -7,6 +7,7 @@ import { FILESYSTEM_CHANNELS, MCP_SERVER_CHANNELS, SETTINGS_CHANNELS, + SPLIT_LAYOUT_CHANNELS, TERMINAL_CHANNELS, TOOL_CHANNELS, TOOL_WINDOW_CHANNELS, @@ -102,6 +103,20 @@ contextBridge.exposeInMainWorld("toolboxAPI", { ipcRenderer.on(TOOL_WINDOW_CHANNELS.CALLEE_TOOL_CLOSED, (_event, data) => callback(data)); }, + // Split layout namespace + splitLayout: { + activate: (leftInstanceId: string, rightInstanceId: string) => ipcRenderer.invoke(SPLIT_LAYOUT_CHANNELS.ACTIVATE, leftInstanceId, rightInstanceId), + deactivate: () => ipcRenderer.invoke(SPLIT_LAYOUT_CHANNELS.DEACTIVATE), + setRatio: (ratio: number) => ipcRenderer.invoke(SPLIT_LAYOUT_CHANNELS.SET_RATIO, ratio), + getState: () => ipcRenderer.invoke(SPLIT_LAYOUT_CHANNELS.GET_STATE), + switchPane: (pane: "left" | "right", instanceId: string) => ipcRenderer.invoke(SPLIT_LAYOUT_CHANNELS.SWITCH_PANE, pane, instanceId), + moveToPane: (instanceId: string, targetPane: "left" | "right") => ipcRenderer.invoke(SPLIT_LAYOUT_CHANNELS.MOVE_TO_PANE, instanceId, targetPane), + focusPane: (pane: "left" | "right") => ipcRenderer.invoke(SPLIT_LAYOUT_CHANNELS.FOCUS_PANE, pane), + onStateChanged: (callback: (state: import("../common/types/api").SplitLayoutState) => void) => { + ipcRenderer.on(SPLIT_LAYOUT_CHANNELS.STATE_CHANGED, (_event, state) => callback(state)); + }, + }, + // Favorite tools - Only for PPTB UI addFavoriteTool: (toolId: string) => ipcRenderer.invoke(SETTINGS_CHANNELS.ADD_FAVORITE_TOOL, toolId), removeFavoriteTool: (toolId: string) => ipcRenderer.invoke(SETTINGS_CHANNELS.REMOVE_FAVORITE_TOOL, toolId), diff --git a/src/renderer/index.html b/src/renderer/index.html index 3ab29cb2..9eebb14e 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -342,15 +342,16 @@

    DEBUG

    -
    -
    +
    + +
    - +
    + +
    +
    +
    + +
    +
    @@ -365,6 +373,16 @@

    DEBUG

    + +
    @@ -607,7 +625,7 @@

    What's New

    diff --git a/src/renderer/modules/initialization.ts b/src/renderer/modules/initialization.ts index b5dd3f7e..d511ea8f 100644 --- a/src/renderer/modules/initialization.ts +++ b/src/renderer/modules/initialization.ts @@ -30,7 +30,7 @@ import { loadHomepageData, setupHomepageActions } from "./homepageManagement"; import { clearMarketplaceDropdownFilters, handleProtocolInstallToolRequest, loadMarketplace, loadToolsLibrary } from "./marketplaceManagement"; import { openAgentInvocationLogsTab } from "./mcpManagement"; import { closeModal, openModal } from "./modalManagement"; -import { setDefaultNotificationDuration, showPPTBNotification, initNotificationHistoryPanel } from "./notifications"; +import { initNotificationHistoryPanel, setDefaultNotificationDuration, showPPTBNotification } from "./notifications"; import { openSettingsTab } from "./settingsManagement"; import { switchSidebar } from "./sidebarManagement"; import { handleTerminalClosed, handleTerminalCommandCompleted, handleTerminalCreated, handleTerminalError, handleTerminalOutput, setupTerminalPanel } from "./terminalManagement"; @@ -42,6 +42,7 @@ import { initializeInvocationBanner, initializeInvocationConnectionsPrompt, initializeTabScrollButtons, + initSplitLayout, launchTool, restoreSession, setupKeyboardShortcuts, @@ -71,6 +72,9 @@ export async function initializeApplication(): Promise { // BrowserView bounds during session restore, causing tools to fill the whole window. setupToolPanelBoundsListener(); + // Set up the split-pane divider and listen for state changes from the main process + initSplitLayout(); + // Set up Activity Bar navigation setupActivityBar(); diff --git a/src/renderer/modules/toolManagement.ts b/src/renderer/modules/toolManagement.ts index 4b63c9ca..cfb4a61e 100644 --- a/src/renderer/modules/toolManagement.ts +++ b/src/renderer/modules/toolManagement.ts @@ -190,6 +190,12 @@ async function showTabContextMenu(instanceId: string, clientX: number, clientY: const canCloseCurrent = canCloseTab(instanceId); const closableOtherTabIds = getClosableTabIds(instanceId); const closableTabIds = getClosableTabIds(); + // "Split Right" is available when there is at least one OTHER regular open tab + const canSplitRight = canManageTab && Array.from(openTools.values()).some((t) => t.instanceId !== instanceId && !t.isDetailTab); + // "Move to Right/Left Pane" based on current split state and which pane this tab is in + const currentPane = isSplitActive ? getTabCurrentPane(instanceId) : null; + const canMoveToRight = isSplitActive && currentPane === "left" && canManageTab; + const canMoveToLeft = isSplitActive && currentPane === "right" && canManageTab; let action: string | null = null; try { action = await window.toolboxAPI.utils.showContextMenu({ @@ -200,6 +206,10 @@ async function showTabContextMenu(instanceId: string, clientX: number, clientY: { id: "close-others", label: "Close Other Tabs", enabled: closableOtherTabIds.length > 0 }, { id: "close-all", label: "Close All Tabs", enabled: closableTabIds.length > 0 }, { type: "separator" }, + { id: "split-right", label: "Split Right", enabled: canSplitRight && !isSplitActive }, + { id: "move-to-right", label: "Move to Right Pane", enabled: canMoveToRight }, + { id: "move-to-left", label: "Move to Left Pane", enabled: canMoveToLeft }, + { type: "separator" }, { id: "duplicate-tab", label: "Duplicate Tab", enabled: canManageTab }, { id: "duplicate-tab-new-connection", label: "Duplicate Tab with New Connection", enabled: canManageTab }, { id: "change-connection", label: "Change Connection", enabled: canManageTab }, @@ -224,6 +234,21 @@ async function showTabContextMenu(instanceId: string, clientX: number, clientY: return; } + if (action === "split-right") { + await splitRight(instanceId); + return; + } + + if (action === "move-to-right") { + await window.toolboxAPI.splitLayout.moveToPane(instanceId, "right").catch(() => {}); + return; + } + + if (action === "move-to-left") { + await window.toolboxAPI.splitLayout.moveToPane(instanceId, "left").catch(() => {}); + return; + } + if (action === "duplicate-tab") { await duplicateToolTab(instanceId); return; @@ -1031,17 +1056,23 @@ function handleDragOver(e: DragEvent, tab: HTMLElement): false { } if (draggedTab && tab !== draggedTab) { - const toolTabs = document.getElementById("tool-tabs"); - if (!toolTabs) return false; - - const tabs = Array.from(toolTabs.children); - const draggedIndex = tabs.indexOf(draggedTab); - const targetIndex = tabs.indexOf(tab); - - if (draggedIndex < targetIndex) { - toolTabs.insertBefore(draggedTab, tab.nextSibling); - } else { - toolTabs.insertBefore(draggedTab, tab); + const draggedParent = draggedTab.parentElement; + const targetParent = tab.parentElement; + + if (draggedParent && targetParent && draggedParent !== targetParent) { + // Cross-pane drag — highlight target container but don't move DOM yet + tab.classList.add("over"); + } else if (targetParent) { + // Same-pane reorder + const tabs = Array.from(targetParent.children); + const draggedIndex = tabs.indexOf(draggedTab); + const targetIndex = tabs.indexOf(tab); + + if (draggedIndex < targetIndex) { + targetParent.insertBefore(draggedTab, tab.nextSibling); + } else { + targetParent.insertBefore(draggedTab, tab); + } } } @@ -1052,6 +1083,22 @@ function handleDrop(e: DragEvent): false { if (e.stopPropagation) { e.stopPropagation(); } + + if (draggedTab && isSplitActive) { + const draggedParent = draggedTab.parentElement; + // Find the tool-tabs container the drop occurred in (may be the tab's parent) + const dropTarget = e.currentTarget as HTMLElement; + const targetContainer = dropTarget.closest(".tool-tabs") as HTMLElement | null; + if (draggedParent && targetContainer && draggedParent !== targetContainer) { + const targetIsRight = targetContainer.id === "right-tool-tabs"; + const targetPane: "left" | "right" = targetIsRight ? "right" : "left"; + const instanceId = draggedTab.getAttribute("data-instance-id"); + if (instanceId) { + void window.toolboxAPI.splitLayout.moveToPane(instanceId, targetPane).catch(() => {}); + } + } + } + return false; } @@ -1931,3 +1978,272 @@ export function initializeCalleeToolListeners(): void { } }); } + +// ─── Split Layout ───────────────────────────────────────────────────────────── + +/** Half-width of the visible divider gap (matches SPLIT_DIVIDER_WIDTH / 2 in main process). */ +const SPLIT_HALF_DIVIDER_PX = 2; + +// ── Renderer-side split state mirror ────────────────────────────────────────── +// Updated on every STATE_CHANGED event from the main process. +let splitLeftGroup: string[] = []; +let splitRightGroup: string[] = []; +let isSplitActive = false; + +/** Return which pane the given instanceId is currently in, or null if not in split. */ +function getTabCurrentPane(instanceId: string): "left" | "right" | null { + if (!isSplitActive) return null; + if (splitLeftGroup.includes(instanceId)) return "left"; + if (splitRightGroup.includes(instanceId)) return "right"; + return null; +} + +/** + * Activate split-right for the given tab. + * The current active tab becomes the left pane; instanceId moves to the right pane. + */ +async function splitRight(instanceId: string): Promise { + const openTool = openTools.get(instanceId); + if (!openTool || openTool.isDetailTab) return; + + // Choose the left pane: prefer the currently active tab, otherwise any other regular tool + let leftInstanceId: string | null = null; + + if (activeToolId && activeToolId !== instanceId) { + const leftTool = openTools.get(activeToolId); + if (leftTool && !leftTool.isDetailTab) { + leftInstanceId = activeToolId; + } + } + + if (!leftInstanceId) { + for (const [id, tool] of openTools) { + if (id !== instanceId && !tool.isDetailTab) { + leftInstanceId = id; + break; + } + } + } + + if (!leftInstanceId) { + await window.toolboxAPI.utils.showNotification({ + title: "Split Right", + body: "Open at least two tools before splitting the view.", + type: "info", + }); + return; + } + + await window.toolboxAPI.switchToolWindow(leftInstanceId).catch(() => {}); + const success = await window.toolboxAPI.splitLayout.activate(leftInstanceId, instanceId); + if (!success) { + logWarn("splitRight: main process declined split activation"); + } +} + +/** Update the content-area divider position and the tab-bar widths. */ +function updateSplitDividerPosition(ratio: number): void { + const divider = document.getElementById("split-pane-divider") as HTMLElement | null; + if (divider) { + divider.style.left = `calc(${ratio * 100}% - ${SPLIT_HALF_DIVIDER_PX}px)`; + } + // Keep tab-bar proportions in sync with the content-area divider + const header = document.getElementById("tool-panel-header") as HTMLElement | null; + if (header) { + header.style.setProperty("--split-left-basis", `${ratio * 100}%`); + } +} + +/** + * Reconcile tab DOM elements between the two tab bars based on the latest split state. + * Tabs in leftGroup go to #tool-tabs; tabs in rightGroup go to #right-tool-tabs. + * Tabs not in either group (shouldn't normally happen) stay in #tool-tabs. + */ +function reconcileTabBars(state: { leftGroup: string[]; rightGroup: string[]; activeLeftInstanceId: string | null; activeRightInstanceId: string | null }): void { + const leftBar = document.getElementById("tool-tabs") as HTMLElement | null; + const rightBar = document.getElementById("right-tool-tabs") as HTMLElement | null; + if (!leftBar || !rightBar) return; + + // Move right-group tabs to the right bar + for (const instanceId of state.rightGroup) { + const tab = document.getElementById(`tool-tab-${instanceId}`); + if (tab && tab.parentElement !== rightBar) { + rightBar.appendChild(tab); + } + } + + // Move left-group tabs (and any stray tabs not in right group) to the left bar + for (const instanceId of state.leftGroup) { + const tab = document.getElementById(`tool-tab-${instanceId}`); + if (tab && tab.parentElement !== leftBar) { + leftBar.appendChild(tab); + } + } + + // Apply active states in each bar + leftBar.querySelectorAll(".tool-tab").forEach((tab) => { + const id = tab.getAttribute("data-instance-id"); + tab.classList.toggle("active", id === state.activeLeftInstanceId); + }); + rightBar.querySelectorAll(".tool-tab").forEach((tab) => { + const id = tab.getAttribute("data-instance-id"); + tab.classList.toggle("active", id === state.activeRightInstanceId); + }); +} + +/** Move all tabs from the right bar back to the left bar (called on split deactivation). */ +function mergeTabBarsToLeft(): void { + const leftBar = document.getElementById("tool-tabs") as HTMLElement | null; + const rightBar = document.getElementById("right-tool-tabs") as HTMLElement | null; + if (!leftBar || !rightBar) return; + + Array.from(rightBar.children).forEach((tab) => leftBar.appendChild(tab)); +} + +/** Show or hide the right tab bar and separator. */ +function setRightTabBarVisible(visible: boolean): void { + const rightContainer = document.getElementById("right-tabs-container") as HTMLElement | null; + const separator = document.getElementById("split-tabs-bar-divider") as HTMLElement | null; + const header = document.getElementById("tool-panel-header") as HTMLElement | null; + if (rightContainer) rightContainer.style.display = visible ? "flex" : "none"; + if (separator) separator.style.display = visible ? "flex" : "none"; + if (header) header.classList.toggle("split-active", visible); +} + +/** Set up drop-zone handlers on a tab bar container for cross-pane DnD. */ +function setupTabBarDropZone(container: HTMLElement, targetPane: "left" | "right"): void { + container.addEventListener("dragover", (e) => { + if (!isSplitActive || !draggedTab) return; + const draggedParent = draggedTab.parentElement; + const dropBar = container.querySelector(".tool-tabs") as HTMLElement | null; + if (draggedParent && dropBar && draggedParent !== dropBar) { + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = "move"; + container.classList.add("drag-over-pane"); + } + }); + container.addEventListener("dragleave", () => { + container.classList.remove("drag-over-pane"); + }); + container.addEventListener("drop", (e) => { + container.classList.remove("drag-over-pane"); + if (!isSplitActive || !draggedTab) return; + const dropBar = container.querySelector(".tool-tabs") as HTMLElement | null; + if (draggedTab.parentElement !== dropBar) { + e.stopPropagation(); + const instanceId = draggedTab.getAttribute("data-instance-id"); + if (instanceId) { + void window.toolboxAPI.splitLayout.moveToPane(instanceId, targetPane).catch(() => {}); + } + } + }); +} + +/** Handle a split STATE_CHANGED event pushed by the main process. */ +function onSplitStateChanged(state: import("../../common/types/api").SplitLayoutState): void { + const wasActive = isSplitActive; + + isSplitActive = state.isActive; + splitLeftGroup = state.leftGroup; + splitRightGroup = state.rightGroup; + + const divider = document.getElementById("split-pane-divider") as HTMLElement | null; + + if (state.isActive) { + // Show/update split UI + if (divider) divider.style.display = "block"; + setRightTabBarVisible(true); + updateSplitDividerPosition(state.ratio); + reconcileTabBars(state); + } else { + // Tear down split UI + if (divider) divider.style.display = "none"; + setRightTabBarVisible(false); + mergeTabBarsToLeft(); + + // When split is deactivated by the main process (e.g. last tab in a pane closed + // or moved), activate the surviving tool. + if (wasActive) { + // The surviving active tool is whichever was left: prefer focused pane's active. + // At deactivation time our local state is already zeroed, so look at open tools. + const survivingId = + (state.activeLeftInstanceId && openTools.has(state.activeLeftInstanceId) ? state.activeLeftInstanceId : null) ?? + (state.activeRightInstanceId && openTools.has(state.activeRightInstanceId) ? state.activeRightInstanceId : null) ?? + (openTools.size > 0 ? Array.from(openTools.keys())[openTools.size - 1] : null); + if (survivingId) { + void switchToTool(survivingId); + } + } + } +} + +/** + * Set up the split-pane divider drag behaviour, drop zones on both tab bars, + * and subscribe to STATE_CHANGED from the main process. + * Called once during application initialisation. + */ +export function initSplitLayout(): void { + const divider = document.getElementById("split-pane-divider") as HTMLElement | null; + if (divider) { + setupDividerDrag(divider); + } + + // Set up cross-pane drag-and-drop on both tab bar containers + const leftContainer = document.getElementById("left-tabs-container") as HTMLElement | null; + const rightContainer = document.getElementById("right-tabs-container") as HTMLElement | null; + if (leftContainer) setupTabBarDropZone(leftContainer, "left"); + if (rightContainer) setupTabBarDropZone(rightContainer, "right"); + + // Subscribe to state changes pushed by the main process + window.toolboxAPI.splitLayout.onStateChanged((state) => { + onSplitStateChanged(state); + }); +} + +function setupDividerDrag(divider: HTMLElement): void { + let isDragging = false; + let lastSentRatio = -1; + + divider.addEventListener("mousedown", (e) => { + e.preventDefault(); + isDragging = true; + divider.classList.add("dragging"); + + const onMouseMove = (e: MouseEvent) => { + if (!isDragging) return; + const content = document.getElementById("tool-panel-content"); + if (!content) return; + const rect = content.getBoundingClientRect(); + const newRatio = Math.max(0.15, Math.min(0.85, (e.clientX - rect.left) / rect.width)); + + // Update divider and tab bar positions immediately for smooth feedback + updateSplitDividerPosition(newRatio); + + // Throttle IPC calls — only send when ratio changed meaningfully + if (Math.abs(newRatio - lastSentRatio) > 0.002) { + lastSentRatio = newRatio; + window.toolboxAPI.splitLayout.setRatio(newRatio).catch(() => {}); + } + }; + + const onMouseUp = (e: MouseEvent) => { + if (!isDragging) return; + isDragging = false; + divider.classList.remove("dragging"); + + // Persist the final position + const content = document.getElementById("tool-panel-content"); + if (content) { + const rect = content.getBoundingClientRect(); + const finalRatio = Math.max(0.15, Math.min(0.85, (e.clientX - rect.left) / rect.width)); + window.toolboxAPI.splitLayout.setRatio(finalRatio).catch(() => {}); + } + + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + }; + + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + }); +} diff --git a/src/renderer/styles.scss b/src/renderer/styles.scss index 6dddcdca..6e4e5d73 100644 --- a/src/renderer/styles.scss +++ b/src/renderer/styles.scss @@ -1614,6 +1614,48 @@ body.dark-theme .settings-section-card { height: 40px; min-height: 40px; position: relative; + overflow: hidden; + + /* ── Split-active layout ───────────────────────────────────────────────── + * When .split-active is added, the left/right tab containers sit side by + * side. Their flex-basis values are set via JS (--split-left-basis / + * --split-right-basis) so the separator aligns with the content divider. + */ + &.split-active { + overflow: visible; // allow tab bar shadow to be visible + + #left-tabs-container { + flex: 0 0 var(--split-left-basis, 50%); + min-width: 80px; + max-width: calc(100% - 80px - 4px); + border-right: none; + } + + #right-tabs-container { + flex: 1 1 auto; + min-width: 80px; + overflow: hidden; + } + } +} + +/* ── Split-pane tab-bar visual separator ────────────────────────────────────── */ + +.split-tabs-bar-divider { + flex: 0 0 4px; + width: 4px; + align-self: stretch; + background-color: var(--border-color); + cursor: col-resize; + z-index: 5; +} + +/* Right tab bar drop-zone highlight when a tab is dragged over it */ +#right-tabs-container.drag-over-pane, +#left-tabs-container.drag-over-pane { + background: color-mix(in srgb, var(--accent-color, #6a00ff) 12%, transparent); + outline: 1px solid var(--accent-color, #6a00ff); + outline-offset: -1px; } .tool-panel-controls { @@ -1870,6 +1912,61 @@ body.dark-theme .settings-section-card { overflow: hidden; } +/* ── Split-pane divider ───────────────────────────────────────────────────── */ + +.split-pane-divider { + position: absolute; + top: 0; + height: 100%; + width: 4px; + background-color: var(--border-color); + cursor: col-resize; + z-index: 2; + user-select: none; + transition: background-color 0.15s ease; + + &::after { + content: ""; + position: absolute; + top: 0; + left: -4px; + right: -4px; + bottom: 0; + } + + &:hover, + &.dragging { + background-color: var(--accent-color, #6a00ff); + } +} + +/* ── Split-pane tab badges (L / R) ───────────────────────────────────────── */ + +.split-pane-badge { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 9px; + font-weight: 700; + line-height: 1; + width: 14px; + height: 14px; + border-radius: 3px; + margin-right: 4px; + flex-shrink: 0; + pointer-events: none; + background-color: var(--accent-color, #6a00ff); + color: #fff; +} + +.tool-tab.split-pane-left .split-pane-badge { + background-color: var(--accent-color, #6a00ff); +} + +.tool-tab.split-pane-right .split-pane-badge { + background-color: var(--accent-color-alt, #00b4d8); +} + .tool-panel-content-wrapper { flex: 1; display: flex; @@ -5879,7 +5976,9 @@ body.dark-theme .global-search-item-badge.badge-settings { color: var(--text-secondary); border-radius: 4px; cursor: pointer; - transition: color 0.15s ease, background 0.15s ease; + transition: + color 0.15s ease, + background 0.15s ease; flex-shrink: 0; } diff --git a/tests/__mocks__/electron.ts b/tests/__mocks__/electron.ts index 921633d5..20fc4b3c 100644 --- a/tests/__mocks__/electron.ts +++ b/tests/__mocks__/electron.ts @@ -53,6 +53,9 @@ export class BrowserWindow { hide = jest.fn(); close = jest.fn(); isDestroyed = jest.fn(() => false); + addBrowserView = jest.fn(); + removeBrowserView = jest.fn(); + getBrowserViews = jest.fn(() => [] as BrowserView[]); static getAllWindows = jest.fn(() => []); static fromWebContents = jest.fn(() => null); } @@ -140,6 +143,7 @@ export class BrowserView { loadURL: jest.fn(() => Promise.resolve()), executeJavaScript: jest.fn(() => Promise.resolve()), setWindowOpenHandler: jest.fn(), + isDestroyed: jest.fn(() => false), }; setBounds = jest.fn(); setAutoResize = jest.fn(); diff --git a/tests/e2e/splitLayout.spec.ts b/tests/e2e/splitLayout.spec.ts new file mode 100644 index 00000000..c17b25ef --- /dev/null +++ b/tests/e2e/splitLayout.spec.ts @@ -0,0 +1,168 @@ +import { expect, test } from "./fixtures"; + +/** + * E2E: Split-layout DOM structure and initial state. + * + * These tests verify that the split-layout HTML elements are present in the + * document with their correct initial (inactive) state. They do NOT require + * any tools to be installed or running — the split UI is structurally present + * but hidden until two tools are opened and the user activates the split. + * + * Prerequisites: run `pnpm run build` before executing these tests. + */ + +// --------------------------------------------------------------------------- +// Helper: evaluate a style property even when the element's ancestor is hidden. +// Playwright's toBeVisible() checks the full visibility chain, so we use +// JavaScript evaluation to inspect the element directly. +// --------------------------------------------------------------------------- +async function getInlineStyle(window: import("playwright").Page, selector: string, prop: string): Promise { + return window.evaluate( + ([sel, p]) => { + const el = document.querySelector(sel as string) as HTMLElement | null; + return el ? el.style.getPropertyValue(p as string) : ""; + }, + [selector, prop], + ); +} + +async function getAttribute(window: import("playwright").Page, selector: string, attr: string): Promise { + return window.evaluate( + ([sel, a]) => { + const el = document.querySelector(sel as string); + return el ? el.getAttribute(a as string) : null; + }, + [selector, attr], + ); +} + +test.describe("Split Layout — initial DOM state", () => { + // ----------------------------------------------------------------------- + // Structural presence + // ----------------------------------------------------------------------- + test("left-tabs-container exists in the DOM", async ({ window }) => { + // Wait for the app to be ready before querying DOM + await window.waitForLoadState("domcontentloaded"); + const count = await window.locator("#left-tabs-container").count(); + expect(count).toBe(1); + }); + + test("right-tabs-container exists in the DOM", async ({ window }) => { + await window.waitForLoadState("domcontentloaded"); + const count = await window.locator("#right-tabs-container").count(); + expect(count).toBe(1); + }); + + test("split-tabs-bar-divider exists in the DOM", async ({ window }) => { + await window.waitForLoadState("domcontentloaded"); + const count = await window.locator("#split-tabs-bar-divider").count(); + expect(count).toBe(1); + }); + + test("split-pane-divider exists in the DOM", async ({ window }) => { + await window.waitForLoadState("domcontentloaded"); + const count = await window.locator("#split-pane-divider").count(); + expect(count).toBe(1); + }); + + test("right-tool-tabs container exists in the DOM", async ({ window }) => { + await window.waitForLoadState("domcontentloaded"); + const count = await window.locator("#right-tool-tabs").count(); + expect(count).toBe(1); + }); + + test("tool-panel-header exists in the DOM", async ({ window }) => { + await window.waitForLoadState("domcontentloaded"); + const count = await window.locator("#tool-panel-header").count(); + expect(count).toBe(1); + }); + + // ----------------------------------------------------------------------- + // Initial hidden state + // ----------------------------------------------------------------------- + test("right-tabs-container has display:none initially", async ({ window }) => { + await window.waitForLoadState("domcontentloaded"); + const display = await getInlineStyle(window, "#right-tabs-container", "display"); + expect(display).toBe("none"); + }); + + test("split-tabs-bar-divider has display:none initially", async ({ window }) => { + await window.waitForLoadState("domcontentloaded"); + const display = await getInlineStyle(window, "#split-tabs-bar-divider", "display"); + expect(display).toBe("none"); + }); + + test("split-pane-divider has display:none initially", async ({ window }) => { + await window.waitForLoadState("domcontentloaded"); + const display = await getInlineStyle(window, "#split-pane-divider", "display"); + expect(display).toBe("none"); + }); + + // ----------------------------------------------------------------------- + // ARIA attributes + // ----------------------------------------------------------------------- + test("split-tabs-bar-divider has aria-hidden=true", async ({ window }) => { + await window.waitForLoadState("domcontentloaded"); + const ariaHidden = await getAttribute(window, "#split-tabs-bar-divider", "aria-hidden"); + expect(ariaHidden).toBe("true"); + }); + + test("split-pane-divider has role=separator", async ({ window }) => { + await window.waitForLoadState("domcontentloaded"); + const role = await getAttribute(window, "#split-pane-divider", "role"); + expect(role).toBe("separator"); + }); + + test("split-pane-divider has aria-orientation=vertical", async ({ window }) => { + await window.waitForLoadState("domcontentloaded"); + const orientation = await getAttribute(window, "#split-pane-divider", "aria-orientation"); + expect(orientation).toBe("vertical"); + }); + + test("right-tabs-container has role=tablist", async ({ window }) => { + await window.waitForLoadState("domcontentloaded"); + const role = await getAttribute(window, "#right-tabs-container", "role"); + expect(role).toBe("tablist"); + }); + + // ----------------------------------------------------------------------- + // Class state + // ----------------------------------------------------------------------- + test("tool-panel-header does not have split-active class initially", async ({ window }) => { + await window.waitForLoadState("domcontentloaded"); + const hasSplitActive = await window.evaluate(() => { + const el = document.getElementById("tool-panel-header"); + return el?.classList.contains("split-active") ?? false; + }); + expect(hasSplitActive).toBe(false); + }); + + test("left-tabs-container is always present without split-active on header", async ({ window }) => { + await window.waitForLoadState("domcontentloaded"); + const count = await window.locator("#tool-tabs").count(); + expect(count).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// Split Layout — tool panel controls +// --------------------------------------------------------------------------- +test.describe("Split Layout — tool panel controls", () => { + test("close-all-tools button is present inside the tool panel header", async ({ window }) => { + await window.waitForLoadState("domcontentloaded"); + const count = await window.locator("#close-all-tools").count(); + expect(count).toBe(1); + }); + + test("tool-panel-content wrapper is present in the DOM", async ({ window }) => { + await window.waitForLoadState("domcontentloaded"); + const count = await window.locator("#tool-panel-content-wrapper").count(); + expect(count).toBe(1); + }); + + test("tool-panel-content is present in the DOM", async ({ window }) => { + await window.waitForLoadState("domcontentloaded"); + const count = await window.locator("#tool-panel-content").count(); + expect(count).toBe(1); + }); +}); diff --git a/tests/unit/main/managers/splitLayoutManager.test.ts b/tests/unit/main/managers/splitLayoutManager.test.ts new file mode 100644 index 00000000..f593ddd6 --- /dev/null +++ b/tests/unit/main/managers/splitLayoutManager.test.ts @@ -0,0 +1,614 @@ +/// + +import { BrowserView, BrowserWindow } from "electron"; +import { SPLIT_LAYOUT_CHANNELS } from "../../../../src/common/ipc/channels"; +import { SettingsManager } from "../../../../src/main/managers/settingsManager"; +import { SPLIT_DIVIDER_WIDTH, SplitLayoutManager } from "../../../../src/main/managers/splitLayoutManager"; + +// electron and electron-store are replaced by manual mocks in tests/__mocks__/ + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Create a mock BrowserView registered in the shared toolViews map. */ +function makeBrowserView(): InstanceType { + return new BrowserView(); +} + +/** + * Create a BrowserWindow mock instance. + * The module-level mock class is re-instantiated so jest.fn() counters are fresh. + */ +function makeMainWindow(): InstanceType { + return new BrowserWindow(); +} + +interface TestContext { + mainWindow: InstanceType; + settingsManager: SettingsManager; + toolViews: Map>; + manager: SplitLayoutManager; +} + +function buildContext(): TestContext { + const mainWindow = makeMainWindow(); + const settingsManager = new SettingsManager(); + const toolViews = new Map>(); + const manager = new SplitLayoutManager(mainWindow as unknown as import("electron").BrowserWindow, settingsManager, toolViews as unknown as Map); + return { mainWindow, settingsManager, toolViews, manager }; +} + +/** Register a BrowserView in the toolViews map under the given id and return it. */ +function registerView(toolViews: Map>, id: string): InstanceType { + const view = makeBrowserView(); + toolViews.set(id, view); + return view; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("SplitLayoutManager", () => { + let ctx: TestContext; + + beforeEach(() => { + ctx = buildContext(); + }); + + // ----------------------------------------------------------------------- + // Initial state + // ----------------------------------------------------------------------- + describe("initial state", () => { + it("is not active after construction", () => { + expect(ctx.manager.isActive).toBe(false); + }); + + it("getState() returns an inactive snapshot with empty groups", () => { + const state = ctx.manager.getState(); + expect(state.isActive).toBe(false); + expect(state.leftGroup).toEqual([]); + expect(state.rightGroup).toEqual([]); + expect(state.activeLeftInstanceId).toBeNull(); + expect(state.activeRightInstanceId).toBeNull(); + expect(state.focusedPane).toBe("left"); + }); + + it("restores a valid persisted ratio from settings", () => { + ctx.settingsManager.setSetting("splitDividerRatio", 0.65); + // Rebuild manager so constructor picks up the persisted value + const mgr = new SplitLayoutManager( + ctx.mainWindow as unknown as import("electron").BrowserWindow, + ctx.settingsManager, + ctx.toolViews as unknown as Map, + ); + expect(mgr.getState().ratio).toBe(0.65); + }); + + it("falls back to 0.5 for out-of-range persisted ratio", () => { + ctx.settingsManager.setSetting("splitDividerRatio", 0.05); // below MIN (0.15) + const mgr = new SplitLayoutManager( + ctx.mainWindow as unknown as import("electron").BrowserWindow, + ctx.settingsManager, + ctx.toolViews as unknown as Map, + ); + expect(mgr.getState().ratio).toBe(0.5); + }); + }); + + // ----------------------------------------------------------------------- + // activate() + // ----------------------------------------------------------------------- + describe("activate()", () => { + it("activates split mode with two distinct instance ids", () => { + registerView(ctx.toolViews, "tool-a"); + registerView(ctx.toolViews, "tool-b"); + const result = ctx.manager.activate("tool-a", "tool-b"); + expect(result).toBe(true); + expect(ctx.manager.isActive).toBe(true); + }); + + it("sets left and right groups to single-item arrays", () => { + ctx.manager.activate("tool-a", "tool-b"); + const state = ctx.manager.getState(); + expect(state.leftGroup).toEqual(["tool-a"]); + expect(state.rightGroup).toEqual(["tool-b"]); + }); + + it("sets both active ids on activation", () => { + ctx.manager.activate("tool-a", "tool-b"); + const state = ctx.manager.getState(); + expect(state.activeLeftInstanceId).toBe("tool-a"); + expect(state.activeRightInstanceId).toBe("tool-b"); + }); + + it("sets focusedPane to 'left' on activation", () => { + ctx.manager.activate("tool-a", "tool-b"); + expect(ctx.manager.getState().focusedPane).toBe("left"); + }); + + it("returns false when both instance ids are the same", () => { + const result = ctx.manager.activate("tool-a", "tool-a"); + expect(result).toBe(false); + expect(ctx.manager.isActive).toBe(false); + }); + + it("notifies the renderer after activation", () => { + ctx.manager.activate("tool-a", "tool-b"); + expect(ctx.mainWindow.webContents.send).toHaveBeenCalledWith(SPLIT_LAYOUT_CHANNELS.STATE_CHANGED, expect.objectContaining({ isActive: true })); + }); + }); + + // ----------------------------------------------------------------------- + // deactivate() + // ----------------------------------------------------------------------- + describe("deactivate()", () => { + beforeEach(() => { + ctx.manager.activate("tool-a", "tool-b"); + (ctx.mainWindow.webContents.send as jest.Mock).mockClear(); + }); + + it("deactivates split mode", () => { + ctx.manager.deactivate(); + expect(ctx.manager.isActive).toBe(false); + }); + + it("clears all groups and active ids", () => { + ctx.manager.deactivate(); + const state = ctx.manager.getState(); + expect(state.leftGroup).toEqual([]); + expect(state.rightGroup).toEqual([]); + expect(state.activeLeftInstanceId).toBeNull(); + expect(state.activeRightInstanceId).toBeNull(); + }); + + it("resets focusedPane to 'left'", () => { + ctx.manager.setFocusedPane("right"); + ctx.manager.deactivate(); + expect(ctx.manager.getState().focusedPane).toBe("left"); + }); + + it("returns false when not active", () => { + ctx.manager.deactivate(); // first deactivate + const result = ctx.manager.deactivate(); // second call + expect(result).toBe(false); + }); + + it("notifies the renderer after deactivation", () => { + ctx.manager.deactivate(); + expect(ctx.mainWindow.webContents.send).toHaveBeenCalledWith(SPLIT_LAYOUT_CHANNELS.STATE_CHANGED, expect.objectContaining({ isActive: false })); + }); + }); + + // ----------------------------------------------------------------------- + // getPaneForInstance() + // ----------------------------------------------------------------------- + describe("getPaneForInstance()", () => { + it("returns null when split is not active", () => { + expect(ctx.manager.getPaneForInstance("tool-a")).toBeNull(); + }); + + it("returns 'left' for a member of the left group", () => { + ctx.manager.activate("tool-a", "tool-b"); + expect(ctx.manager.getPaneForInstance("tool-a")).toBe("left"); + }); + + it("returns 'right' for a member of the right group", () => { + ctx.manager.activate("tool-a", "tool-b"); + expect(ctx.manager.getPaneForInstance("tool-b")).toBe("right"); + }); + + it("returns null for an id not in any group", () => { + ctx.manager.activate("tool-a", "tool-b"); + expect(ctx.manager.getPaneForInstance("tool-c")).toBeNull(); + }); + }); + + // ----------------------------------------------------------------------- + // isInstanceInSplit() + // ----------------------------------------------------------------------- + describe("isInstanceInSplit()", () => { + it("returns false when not active", () => { + expect(ctx.manager.isInstanceInSplit("tool-a")).toBe(false); + }); + + it("returns true for left-pane members", () => { + ctx.manager.activate("tool-a", "tool-b"); + expect(ctx.manager.isInstanceInSplit("tool-a")).toBe(true); + }); + + it("returns true for right-pane members", () => { + ctx.manager.activate("tool-a", "tool-b"); + expect(ctx.manager.isInstanceInSplit("tool-b")).toBe(true); + }); + + it("returns false for tools not in split", () => { + ctx.manager.activate("tool-a", "tool-b"); + expect(ctx.manager.isInstanceInSplit("tool-c")).toBe(false); + }); + }); + + // ----------------------------------------------------------------------- + // addToolToFocusedPane() + // ----------------------------------------------------------------------- + describe("addToolToFocusedPane()", () => { + beforeEach(() => { + ctx.manager.activate("tool-a", "tool-b"); + (ctx.mainWindow.webContents.send as jest.Mock).mockClear(); + }); + + it("adds a new tool to the left pane when focusedPane is 'left'", () => { + ctx.manager.setFocusedPane("left"); + ctx.manager.addToolToFocusedPane("tool-c"); + expect(ctx.manager.getState().leftGroup).toContain("tool-c"); + }); + + it("adds a new tool to the right pane when focusedPane is 'right'", () => { + ctx.manager.setFocusedPane("right"); + ctx.manager.addToolToFocusedPane("tool-c"); + expect(ctx.manager.getState().rightGroup).toContain("tool-c"); + }); + + it("makes the newly added tool the active one in its pane", () => { + ctx.manager.setFocusedPane("left"); + ctx.manager.addToolToFocusedPane("tool-c"); + expect(ctx.manager.getState().activeLeftInstanceId).toBe("tool-c"); + }); + + it("makes the newly added right-pane tool active", () => { + ctx.manager.setFocusedPane("right"); + ctx.manager.addToolToFocusedPane("tool-c"); + expect(ctx.manager.getState().activeRightInstanceId).toBe("tool-c"); + }); + + it("does not duplicate an instance already in the pane", () => { + ctx.manager.setFocusedPane("left"); + ctx.manager.addToolToFocusedPane("tool-a"); // tool-a is already in left group + const leftGroup = ctx.manager.getState().leftGroup; + expect(leftGroup.filter((id) => id === "tool-a")).toHaveLength(1); + }); + + it("does nothing when split is not active", () => { + const inactiveCtx = buildContext(); + inactiveCtx.manager.addToolToFocusedPane("tool-x"); + expect(inactiveCtx.manager.getState().leftGroup).toEqual([]); + }); + + it("notifies the renderer after adding a tool", () => { + ctx.manager.addToolToFocusedPane("tool-c"); + expect(ctx.mainWindow.webContents.send).toHaveBeenCalledWith(SPLIT_LAYOUT_CHANNELS.STATE_CHANGED, expect.any(Object)); + }); + }); + + // ----------------------------------------------------------------------- + // setActiveInPane() + // ----------------------------------------------------------------------- + describe("setActiveInPane()", () => { + beforeEach(() => { + ctx.manager.activate("tool-a", "tool-b"); + ctx.manager.addToolToFocusedPane("tool-c"); // tool-c in left group + (ctx.mainWindow.webContents.send as jest.Mock).mockClear(); + }); + + it("switches the active tool in the left pane", () => { + ctx.manager.setActiveInPane("left", "tool-a"); + expect(ctx.manager.getState().activeLeftInstanceId).toBe("tool-a"); + }); + + it("switches the active tool in the right pane", () => { + ctx.manager.setFocusedPane("right"); + ctx.manager.addToolToFocusedPane("tool-d"); + ctx.manager.setActiveInPane("right", "tool-b"); + expect(ctx.manager.getState().activeRightInstanceId).toBe("tool-b"); + }); + + it("updates focusedPane to the pane being switched", () => { + ctx.manager.setFocusedPane("left"); + ctx.manager.setActiveInPane("right", "tool-b"); + expect(ctx.manager.getState().focusedPane).toBe("right"); + }); + + it("returns false when the instance is not in the target pane", () => { + // tool-b is in the right pane, not left + const result = ctx.manager.setActiveInPane("left", "tool-b"); + expect(result).toBe(false); + }); + + it("returns true on success", () => { + const result = ctx.manager.setActiveInPane("left", "tool-c"); + expect(result).toBe(true); + }); + + it("notifies the renderer on success", () => { + ctx.manager.setActiveInPane("left", "tool-c"); + expect(ctx.mainWindow.webContents.send).toHaveBeenCalledWith(SPLIT_LAYOUT_CHANNELS.STATE_CHANGED, expect.any(Object)); + }); + }); + + // ----------------------------------------------------------------------- + // setFocusedPane() + // ----------------------------------------------------------------------- + describe("setFocusedPane()", () => { + it("updates the focusedPane to 'right'", () => { + ctx.manager.setFocusedPane("right"); + expect(ctx.manager.focusedPane).toBe("right"); + }); + + it("updates the focusedPane back to 'left'", () => { + ctx.manager.setFocusedPane("right"); + ctx.manager.setFocusedPane("left"); + expect(ctx.manager.focusedPane).toBe("left"); + }); + + it("notifies the renderer", () => { + (ctx.mainWindow.webContents.send as jest.Mock).mockClear(); + ctx.manager.setFocusedPane("right"); + expect(ctx.mainWindow.webContents.send).toHaveBeenCalledWith(SPLIT_LAYOUT_CHANNELS.STATE_CHANGED, expect.objectContaining({ focusedPane: "right" })); + }); + }); + + // ----------------------------------------------------------------------- + // setRatio() + // ----------------------------------------------------------------------- + describe("setRatio()", () => { + it("accepts a valid ratio within bounds", () => { + ctx.manager.setRatio(0.6); + expect(ctx.manager.getState().ratio).toBe(0.6); + }); + + it("clamps values below MIN_RATIO (0.15) to 0.15", () => { + ctx.manager.setRatio(0.05); + expect(ctx.manager.getState().ratio).toBe(0.15); + }); + + it("clamps values above MAX_RATIO (0.85) to 0.85", () => { + ctx.manager.setRatio(0.99); + expect(ctx.manager.getState().ratio).toBe(0.85); + }); + + it("persists the clamped ratio to settings", () => { + ctx.manager.setRatio(0.7); + expect(ctx.settingsManager.getSetting("splitDividerRatio")).toBe(0.7); + }); + + it("persists clamped value — not raw input", () => { + ctx.manager.setRatio(0.0); + expect(ctx.settingsManager.getSetting("splitDividerRatio")).toBe(0.15); + }); + + it("notifies the renderer", () => { + (ctx.mainWindow.webContents.send as jest.Mock).mockClear(); + ctx.manager.setRatio(0.6); + expect(ctx.mainWindow.webContents.send).toHaveBeenCalledWith(SPLIT_LAYOUT_CHANNELS.STATE_CHANGED, expect.objectContaining({ ratio: 0.6 })); + }); + }); + + // ----------------------------------------------------------------------- + // moveToPane() + // ----------------------------------------------------------------------- + describe("moveToPane()", () => { + beforeEach(() => { + ctx.manager.activate("tool-a", "tool-b"); + }); + + it("moves a tool from the left pane to the right pane", () => { + // Add a second tool to left so the pane won't become empty after the move + ctx.manager.addToolToFocusedPane("tool-c"); + ctx.manager.moveToPane("tool-a", "right"); + const state = ctx.manager.getState(); + expect(state.rightGroup).toContain("tool-a"); + expect(state.leftGroup).not.toContain("tool-a"); + }); + + it("moves a tool from the right pane to the left pane", () => { + // Add a second tool to right so the pane won't become empty after the move + ctx.manager.setFocusedPane("right"); + ctx.manager.addToolToFocusedPane("tool-d"); + ctx.manager.moveToPane("tool-b", "left"); + const state = ctx.manager.getState(); + expect(state.leftGroup).toContain("tool-b"); + expect(state.rightGroup).not.toContain("tool-b"); + }); + + it("makes the moved tool the active one in the target pane", () => { + // Add a second tool to right so the pane stays alive + ctx.manager.setFocusedPane("right"); + ctx.manager.addToolToFocusedPane("tool-d"); + ctx.manager.moveToPane("tool-b", "left"); + expect(ctx.manager.getState().activeLeftInstanceId).toBe("tool-b"); + }); + + it("updates focusedPane to the target pane", () => { + // Add a second tool to left so the pane stays alive after moving tool-a + ctx.manager.addToolToFocusedPane("tool-c"); + ctx.manager.moveToPane("tool-a", "right"); + expect(ctx.manager.getState().focusedPane).toBe("right"); + }); + + it("returns false when the instance is not in any group", () => { + const result = ctx.manager.moveToPane("tool-unknown", "right"); + expect(result).toBe(false); + }); + + it("returns true without changing state when already in target pane", () => { + const result = ctx.manager.moveToPane("tool-a", "left"); + expect(result).toBe(true); + expect(ctx.manager.getState().leftGroup).toContain("tool-a"); + }); + + it("deactivates split when moving the only tool out of the left pane", () => { + // left has only tool-a; move it to right → left becomes empty → deactivate + ctx.manager.moveToPane("tool-a", "right"); + expect(ctx.manager.isActive).toBe(false); + }); + + it("deactivates split when moving the only tool out of the right pane", () => { + // right has only tool-b; move it to left → right becomes empty → deactivate + ctx.manager.moveToPane("tool-b", "left"); + expect(ctx.manager.isActive).toBe(false); + }); + + it("updates the source pane's activeId to the previous item when active tool is moved", () => { + // Add tool-c to left first, make it active + ctx.manager.addToolToFocusedPane("tool-c"); // left: [tool-a, tool-c], active=tool-c + // Move tool-c to right — activeLeftId should fall back to tool-a + ctx.manager.moveToPane("tool-c", "right"); + expect(ctx.manager.getState().activeLeftInstanceId).toBe("tool-a"); + }); + + it("returns true after a successful move", () => { + // Add another tool to left so the pane won't be empty after move + ctx.manager.addToolToFocusedPane("tool-c"); + const result = ctx.manager.moveToPane("tool-a", "right"); + expect(result).toBe(true); + }); + }); + + // ----------------------------------------------------------------------- + // handleToolClosed() + // ----------------------------------------------------------------------- + describe("handleToolClosed()", () => { + beforeEach(() => { + ctx.manager.activate("tool-a", "tool-b"); + }); + + it("does nothing when split is not active", () => { + const inactiveCtx = buildContext(); + inactiveCtx.manager.handleToolClosed("tool-x"); // should not throw + expect(inactiveCtx.manager.isActive).toBe(false); + }); + + it("does nothing for a tool not in any group", () => { + ctx.manager.handleToolClosed("tool-unknown"); + expect(ctx.manager.isActive).toBe(true); + }); + + it("removes a closed tool from the left group", () => { + // Add a second tool to left so it won't collapse + ctx.manager.addToolToFocusedPane("tool-c"); + ctx.manager.handleToolClosed("tool-a"); + expect(ctx.manager.getState().leftGroup).not.toContain("tool-a"); + }); + + it("removes a closed tool from the right group", () => { + // Add a second tool to right so it won't collapse + ctx.manager.setFocusedPane("right"); + ctx.manager.addToolToFocusedPane("tool-d"); + ctx.manager.handleToolClosed("tool-b"); + expect(ctx.manager.getState().rightGroup).not.toContain("tool-b"); + }); + + it("deactivates split when the left group becomes empty", () => { + // left has only tool-a; closing it collapses split + ctx.manager.handleToolClosed("tool-a"); + expect(ctx.manager.isActive).toBe(false); + }); + + it("deactivates split when the right group becomes empty", () => { + // right has only tool-b; closing it collapses split + ctx.manager.handleToolClosed("tool-b"); + expect(ctx.manager.isActive).toBe(false); + }); + + it("updates activeLeftId to the previous item when the active left tool is closed", () => { + ctx.manager.addToolToFocusedPane("tool-c"); // left: [tool-a, tool-c], active=tool-c + ctx.manager.handleToolClosed("tool-c"); + expect(ctx.manager.getState().activeLeftInstanceId).toBe("tool-a"); + }); + + it("updates activeRightId when the active right tool is closed", () => { + ctx.manager.setFocusedPane("right"); + ctx.manager.addToolToFocusedPane("tool-d"); // right: [tool-b, tool-d], active=tool-d + ctx.manager.handleToolClosed("tool-d"); + expect(ctx.manager.getState().activeRightInstanceId).toBe("tool-b"); + }); + + it("does not change activeLeftId when a non-active left tool is closed", () => { + ctx.manager.addToolToFocusedPane("tool-c"); // left: [tool-a, tool-c], active=tool-c + ctx.manager.handleToolClosed("tool-a"); // close non-active left tool + expect(ctx.manager.getState().activeLeftInstanceId).toBe("tool-c"); + }); + + it("notifies the renderer after closing a tool (no collapse)", () => { + ctx.manager.addToolToFocusedPane("tool-c"); // ensure left group stays non-empty + (ctx.mainWindow.webContents.send as jest.Mock).mockClear(); + ctx.manager.handleToolClosed("tool-a"); + expect(ctx.mainWindow.webContents.send).toHaveBeenCalledWith(SPLIT_LAYOUT_CHANNELS.STATE_CHANGED, expect.any(Object)); + }); + }); + + // ----------------------------------------------------------------------- + // getState() snapshot immutability + // ----------------------------------------------------------------------- + describe("getState() snapshot immutability", () => { + it("returns a copy of the group arrays, not live references", () => { + ctx.manager.activate("tool-a", "tool-b"); + const state = ctx.manager.getState(); + // Mutating the snapshot should not affect internal state + state.leftGroup.push("intruder"); + expect(ctx.manager.getState().leftGroup).toEqual(["tool-a"]); + }); + + it("reflects the most-recent active tool after setActiveInPane", () => { + ctx.manager.activate("tool-a", "tool-b"); + ctx.manager.addToolToFocusedPane("tool-c"); + ctx.manager.setActiveInPane("left", "tool-a"); + expect(ctx.manager.getState().activeLeftInstanceId).toBe("tool-a"); + }); + }); + + // ----------------------------------------------------------------------- + // applyLayout() — bounds calculation + // ----------------------------------------------------------------------- + describe("applyLayout()", () => { + it("calls setBounds on both active BrowserViews", () => { + const leftView = registerView(ctx.toolViews, "tool-a"); + const rightView = registerView(ctx.toolViews, "tool-b"); + ctx.manager.activate("tool-a", "tool-b"); + + const bounds = { x: 0, y: 40, width: 1000, height: 600 }; + ctx.manager.applyLayout(bounds); + + expect(leftView.setBounds).toHaveBeenCalledTimes(1); + expect(rightView.setBounds).toHaveBeenCalledTimes(1); + }); + + it("left pane width + divider + right pane width equals total width", () => { + const leftView = registerView(ctx.toolViews, "tool-a"); + const rightView = registerView(ctx.toolViews, "tool-b"); + ctx.manager.activate("tool-a", "tool-b"); + ctx.manager.setRatio(0.5); + + const bounds = { x: 0, y: 40, width: 1000, height: 600 }; + ctx.manager.applyLayout(bounds); + + const leftCall = (leftView.setBounds as jest.Mock).mock.calls[0][0] as { width: number }; + const rightCall = (rightView.setBounds as jest.Mock).mock.calls[0][0] as { width: number }; + + expect(leftCall.width + SPLIT_DIVIDER_WIDTH + rightCall.width).toBe(bounds.width); + }); + + it("skips layout when BrowserViews are not in the toolViews map", () => { + // Don't register views + ctx.manager.activate("tool-a", "tool-b"); + // applyLayout should not throw even with missing views + expect(() => ctx.manager.applyLayout({ x: 0, y: 0, width: 800, height: 600 })).not.toThrow(); + }); + + it("does nothing when split is not active", () => { + registerView(ctx.toolViews, "tool-a"); + ctx.manager.applyLayout({ x: 0, y: 0, width: 800, height: 600 }); // should not throw + }); + }); + + // ----------------------------------------------------------------------- + // SPLIT_DIVIDER_WIDTH export + // ----------------------------------------------------------------------- + describe("SPLIT_DIVIDER_WIDTH", () => { + it("is exported and is a positive integer", () => { + expect(SPLIT_DIVIDER_WIDTH).toBeGreaterThan(0); + expect(Number.isInteger(SPLIT_DIVIDER_WIDTH)).toBe(true); + }); + }); +}); From 9dcdc256355d912ff9aad13953779dcdd8d4cdb2 Mon Sep 17 00:00:00 2001 From: Power-Maverick Date: Fri, 26 Jun 2026 10:50:33 -0400 Subject: [PATCH 224/257] added ElicitRequest to MCP --- src/main/mcp/mcpServer.ts | 125 ++++++++++++++++++++++++++++++++++---- 1 file changed, 113 insertions(+), 12 deletions(-) diff --git a/src/main/mcp/mcpServer.ts b/src/main/mcp/mcpServer.ts index 5ccfe4b1..0f027099 100644 --- a/src/main/mcp/mcpServer.ts +++ b/src/main/mcp/mcpServer.ts @@ -1,6 +1,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; -import { CallToolRequestSchema, CallToolResult, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; +import { CallToolRequestSchema, CallToolResult, ElicitRequestFormParams, ListToolsRequestSchema, PrimitiveSchemaDefinition } from "@modelcontextprotocol/sdk/types.js"; import { promises as fs } from "fs"; import { createServer, IncomingMessage, ServerResponse } from "http"; import os from "os"; @@ -17,6 +17,7 @@ import { ToolWindowManager } from "../managers/toolWindowManager"; import { logInvocation } from "./agentInvocationLogger"; import { AgentExecutionMode, AgentInvocationMode, AgentTool, getAgentInvokableTools, resolveToolId } from "./agentToolRegistry"; import { createHeadlessLogger, invokeHeadlessTool } from "./headlessToolRuntime"; +import { JsonObjectSchema } from "./schemaConverter"; const MCP_AUTH_HEADER = "x-mcp-auth-token"; const MCP_AUTH_HEADER_DISPLAY_NAME = "X-MCP-Auth-Token"; @@ -98,6 +99,54 @@ function stripInvocationMeta(args: Record): Record | null { + const properties = isRecord(inputSchema.properties) ? inputSchema.properties : {}; + const result: Record = {}; + + for (const name of fieldNames) { + const prop = isRecord(properties[name]) ? (properties[name] as Record) : undefined; + const type = typeof prop?.type === "string" ? prop.type : undefined; + const description = typeof prop?.description === "string" ? prop.description : undefined; + const title = typeof prop?.title === "string" ? prop.title : undefined; + const enumValues = Array.isArray(prop?.enum) ? (prop.enum as unknown[]).filter((v): v is string => typeof v === "string") : undefined; + + if (type === "boolean") { + result[name] = { type: "boolean", ...(title ? { title } : {}), ...(description ? { description } : {}) }; + } else if (type === "number" || type === "integer") { + result[name] = { type: "number", ...(title ? { title } : {}), ...(description ? { description } : {}) }; + } else if (enumValues && enumValues.length > 0) { + result[name] = { type: "string", enum: enumValues, ...(title ? { title } : {}), ...(description ? { description } : {}) }; + } else if (type === "string" || type === undefined) { + result[name] = { type: "string", ...(title ? { title } : {}), ...(description ? { description } : {}) }; + } else { + // Object or array — cannot be entered via a flat elicitation form + return null; + } + } + + return Object.keys(result).length > 0 ? result : null; +} + function createInvocationError(text: string): CallToolResult { return { content: [{ type: "text", text }], @@ -643,7 +692,7 @@ export class McpServerManager { const toolIdFromName = resolveToolId(request.params.name); const toolArgs = isRecord(request.params.arguments) ? request.params.arguments : {}; const invocationMeta = parseInvocationMeta(toolArgs); - const prefillData = stripInvocationMeta(toolArgs); + let prefillData = stripInvocationMeta(toolArgs); const agentTools = await this.getAgentTools(); const matchedTool = agentTools.find((tool) => tool.toolId === toolIdFromName); @@ -664,16 +713,68 @@ export class McpServerManager { const displayName = matchedTool.displayName; const inputValidationErrors = validateAgainstSchema(prefillData, matchedTool.inputSchema); if (inputValidationErrors.length > 0) { - const errorText = `Input validation failed: ${inputValidationErrors.join("; ")}`; - logInvocationWithMeta({ - toolId, - toolName: displayName, - connectionId: null, - prefillData, - outcome: "rejected", - error: errorText, - }); - return createInvocationError(errorText); + const missingFields = extractMissingFieldNames(inputValidationErrors); + let elicitedSuccessfully = false; + + // Attempt elicitation only when every validation error is a missing required field + // and all of those fields can be represented as flat primitives in a form. + if (missingFields.length > 0 && missingFields.length === inputValidationErrors.length) { + const elicitProperties = buildElicitationProperties(matchedTool.inputSchema, missingFields); + if (elicitProperties) { + try { + const clientCaps = server.server.getClientCapabilities(); + if (clientCaps?.elicitation?.form) { + const elicitParams: ElicitRequestFormParams = { + message: `The tool "${displayName}" requires the following parameters. Please provide values to continue.`, + requestedSchema: { + type: "object", + properties: elicitProperties, + required: missingFields, + }, + }; + const elicitResult = await server.server.elicitInput(elicitParams); + + if (elicitResult.action === "accept" && isRecord(elicitResult.content)) { + const mergedPrefill = { ...prefillData, ...elicitResult.content }; + const revalidationErrors = validateAgainstSchema(mergedPrefill, matchedTool.inputSchema); + if (revalidationErrors.length === 0) { + prefillData = mergedPrefill; + elicitedSuccessfully = true; + } else { + const errorText = `Input validation failed after elicitation: ${revalidationErrors.join("; ")}`; + logInvocationWithMeta({ toolId, toolName: displayName, connectionId: null, prefillData, outcome: "rejected", error: errorText }); + return createInvocationError(errorText); + } + } else { + logInvocationWithMeta({ + toolId, + toolName: displayName, + connectionId: null, + prefillData, + outcome: "rejected", + error: "User declined or cancelled parameter elicitation", + }); + return createInvocationError(`Tool invocation cancelled: required parameters were not provided for "${displayName}".`); + } + } + } catch { + // Elicitation not supported or failed; fall through to the validation error below + } + } + } + + if (!elicitedSuccessfully) { + const errorText = `Input validation failed: ${inputValidationErrors.join("; ")}`; + logInvocationWithMeta({ + toolId, + toolName: displayName, + connectionId: null, + prefillData, + outcome: "rejected", + error: errorText, + }); + return createInvocationError(errorText); + } } let executionMode: AgentExecutionMode; From 41bdcab9fdf20bf3032a70c32a36e4c8cdd6648c Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 26 Jun 2026 22:34:15 -0400 Subject: [PATCH 225/257] [Feature] Add preview feature flagging, gate MCP Server behind it, move MCP icon to activity bar footer (#589) * Add preview feature flagging and move MCP button to activity bar footer * Rename mcpBtn to mcpButton for consistency --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- src/common/types/settings.ts | 1 + src/main/managers/settingsManager.ts | 1 + src/renderer/index.html | 6 ++-- src/renderer/modules/initialization.ts | 3 +- src/renderer/modules/settingsManagement.ts | 33 +++++++++++++++++++++- src/renderer/modules/themeManagement.ts | 11 ++++++++ src/renderer/types/index.ts | 1 + 7 files changed, 51 insertions(+), 5 deletions(-) diff --git a/src/common/types/settings.ts b/src/common/types/settings.ts index a781ed1e..d022b20e 100644 --- a/src/common/types/settings.ts +++ b/src/common/types/settings.ts @@ -96,4 +96,5 @@ export interface UserSettings { environmentColorThickness?: number; // Thickness in pixels of the environment color border around the tool panel mcpAccessToken?: string; // Access token for local MCP server authentication splitDividerRatio?: number; // Persisted position of the split-pane divider (0.15–0.85) + enablePreviewFeatures?: boolean; // Show preview/experimental features in the UI } diff --git a/src/main/managers/settingsManager.ts b/src/main/managers/settingsManager.ts index 11abf73b..475b859a 100644 --- a/src/main/managers/settingsManager.ts +++ b/src/main/managers/settingsManager.ts @@ -38,6 +38,7 @@ export class SettingsManager { toolSecondaryConnections: {}, // Map of toolId to secondary connectionId connectionsSort: "last-used", restoreSessionOnStartup: true, // Reopen previously open tools on app start + enablePreviewFeatures: false, // Show preview/experimental features in the UI }, }); diff --git a/src/renderer/index.html b/src/renderer/index.html index 9eebb14e..e8f2deb0 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -32,9 +32,6 @@ - @@ -43,6 +40,9 @@
    + diff --git a/src/renderer/modules/initialization.ts b/src/renderer/modules/initialization.ts index d511ea8f..81dda198 100644 --- a/src/renderer/modules/initialization.ts +++ b/src/renderer/modules/initialization.ts @@ -34,7 +34,7 @@ import { initNotificationHistoryPanel, setDefaultNotificationDuration, showPPTBN import { openSettingsTab } from "./settingsManagement"; import { switchSidebar } from "./sidebarManagement"; import { handleTerminalClosed, handleTerminalCommandCompleted, handleTerminalCreated, handleTerminalError, handleTerminalOutput, setupTerminalPanel } from "./terminalManagement"; -import { applyDebugMenuVisibility, applyTerminalFont, applyTheme } from "./themeManagement"; +import { applyDebugMenuVisibility, applyPreviewFeaturesVisibility, applyTerminalFont, applyTheme } from "./themeManagement"; import { applyAppearanceSettings, closeAllTools, @@ -646,6 +646,7 @@ async function loadInitialSettings(): Promise { applyTheme(settings.theme); applyTerminalFont(settings.terminalFont || DEFAULT_TERMINAL_FONT); applyDebugMenuVisibility(settings.showDebugMenu ?? false); + applyPreviewFeaturesVisibility(settings.enablePreviewFeatures ?? false); setDefaultNotificationDuration(settings.notificationDuration ?? DEFAULT_NOTIFICATION_DURATION); applyAppearanceSettings( settings.showCategoryColor ?? DEFAULT_SHOW_CATEGORY_COLOR, diff --git a/src/renderer/modules/settingsManagement.ts b/src/renderer/modules/settingsManagement.ts index 5177355d..15d1a616 100644 --- a/src/renderer/modules/settingsManagement.ts +++ b/src/renderer/modules/settingsManagement.ts @@ -17,7 +17,7 @@ import { import type { SettingsState } from "../types/index"; import { loadMarketplace } from "./marketplaceManagement"; import { setDefaultNotificationDuration } from "./notifications"; -import { applyDebugMenuVisibility, applyTerminalFont, applyTheme } from "./themeManagement"; +import { applyDebugMenuVisibility, applyPreviewFeaturesVisibility, applyTerminalFont, applyTheme } from "./themeManagement"; import { applyAppearanceSettings, openLocalPageAsTab, registerCloseGuard } from "./toolManagement"; import { loadSidebarTools } from "./toolsSidebarManagement"; @@ -42,6 +42,7 @@ export async function loadSettings(): Promise { const showEnvironmentColorCheck = document.getElementById("sidebar-show-environment-color-check") as HTMLInputElement | null; const categoryColorThicknessInput = document.getElementById("sidebar-category-color-thickness") as HTMLInputElement | null; const environmentColorThicknessInput = document.getElementById("sidebar-environment-color-thickness") as HTMLInputElement | null; + const enablePreviewFeaturesCheck = document.getElementById("sidebar-enable-preview-features-check") as HTMLInputElement | null; if (themeSelect && autoUpdateCheck && showDebugMenuCheck && deprecatedToolsSelect && toolDisplayModeSelect && terminalFontSelect) { const settings = await window.toolboxAPI.getUserSettings(); @@ -60,6 +61,7 @@ export async function loadSettings(): Promise { showEnvironmentColor: settings.showEnvironmentColor ?? DEFAULT_SHOW_ENVIRONMENT_COLOR, categoryColorThickness: settings.categoryColorThickness ?? DEFAULT_CATEGORY_COLOR_THICKNESS, environmentColorThickness: settings.environmentColorThickness ?? DEFAULT_ENVIRONMENT_COLOR_THICKNESS, + enablePreviewFeatures: settings.enablePreviewFeatures ?? false, }; themeSelect.value = settings.theme; @@ -87,6 +89,9 @@ export async function loadSettings(): Promise { if (environmentColorThicknessInput) { environmentColorThicknessInput.value = String(settings.environmentColorThickness ?? DEFAULT_ENVIRONMENT_COLOR_THICKNESS); } + if (enablePreviewFeaturesCheck) { + enablePreviewFeaturesCheck.checked = settings.enablePreviewFeatures ?? false; + } const terminalFont = settings.terminalFont || DEFAULT_TERMINAL_FONT; @@ -132,6 +137,7 @@ export async function saveSettings(): Promise { const showEnvironmentColorCheck = document.getElementById("sidebar-show-environment-color-check") as HTMLInputElement | null; const categoryColorThicknessInput = document.getElementById("sidebar-category-color-thickness") as HTMLInputElement | null; const environmentColorThicknessInput = document.getElementById("sidebar-environment-color-thickness") as HTMLInputElement | null; + const enablePreviewFeaturesCheck = document.getElementById("sidebar-enable-preview-features-check") as HTMLInputElement | null; if (!themeSelect || !autoUpdateCheck || !showDebugMenuCheck || !deprecatedToolsSelect || !toolDisplayModeSelect || !terminalFontSelect) return; @@ -151,6 +157,7 @@ export async function saveSettings(): Promise { const environmentColorThickness = environmentColorThicknessInput ? Math.min(MAX_COLOR_BORDER_THICKNESS, Math.max(MIN_COLOR_BORDER_THICKNESS, Number(environmentColorThicknessInput.value) || DEFAULT_ENVIRONMENT_COLOR_THICKNESS)) : DEFAULT_ENVIRONMENT_COLOR_THICKNESS; + const enablePreviewFeatures = enablePreviewFeaturesCheck ? enablePreviewFeaturesCheck.checked : false; const currentSettings = { theme: themeSelect.value, @@ -165,6 +172,7 @@ export async function saveSettings(): Promise { showEnvironmentColor, categoryColorThickness, environmentColorThickness, + enablePreviewFeatures, }; // Only include changed settings in the update @@ -206,6 +214,9 @@ export async function saveSettings(): Promise { if (currentSettings.environmentColorThickness !== originalSettings.environmentColorThickness) { changedSettings.environmentColorThickness = currentSettings.environmentColorThickness; } + if (currentSettings.enablePreviewFeatures !== (originalSettings.enablePreviewFeatures ?? false)) { + changedSettings.enablePreviewFeatures = currentSettings.enablePreviewFeatures; + } // Only save and emit event if something changed if (Object.keys(changedSettings).length > 0) { @@ -215,6 +226,7 @@ export async function saveSettings(): Promise { applyTheme(currentSettings.theme); applyTerminalFont(currentSettings.terminalFont); applyDebugMenuVisibility(currentSettings.showDebugMenu); + applyPreviewFeaturesVisibility(currentSettings.enablePreviewFeatures); setDefaultNotificationDuration(currentSettings.notificationDuration); applyAppearanceSettings(currentSettings.showCategoryColor, currentSettings.showEnvironmentColor, currentSettings.categoryColorThickness, currentSettings.environmentColorThickness); @@ -267,6 +279,7 @@ function hasUnsavedChanges(): boolean { const showEnvironmentColorCheck = document.getElementById("sidebar-show-environment-color-check") as HTMLInputElement | null; const categoryColorThicknessInput = document.getElementById("sidebar-category-color-thickness") as HTMLInputElement | null; const environmentColorThicknessInput = document.getElementById("sidebar-environment-color-thickness") as HTMLInputElement | null; + const enablePreviewFeaturesCheck = document.getElementById("sidebar-enable-preview-features-check") as HTMLInputElement | null; // If the DOM elements aren't present the settings panel isn't rendered — no unsaved changes if (!themeSelect || !autoUpdateCheck || !showDebugMenuCheck || !deprecatedToolsSelect || !toolDisplayModeSelect || !terminalFontSelect) { @@ -296,6 +309,7 @@ function hasUnsavedChanges(): boolean { const val = Math.min(MAX_COLOR_BORDER_THICKNESS, Math.max(MIN_COLOR_BORDER_THICKNESS, Number(environmentColorThicknessInput.value) || DEFAULT_ENVIRONMENT_COLOR_THICKNESS)); if (val !== (originalSettings.environmentColorThickness ?? DEFAULT_ENVIRONMENT_COLOR_THICKNESS)) return true; } + if (enablePreviewFeaturesCheck && enablePreviewFeaturesCheck.checked !== (originalSettings.enablePreviewFeatures ?? false)) return true; return false; } @@ -513,6 +527,23 @@ export function renderSettingsContent(panel: HTMLElement): void {
    +
    +

    Preview Features

    + +
    +
    + +

    Show experimental and preview features in the UI. These features are still in development and may change or be removed. Currently includes: MCP Server.

    +
    +
    + +
    +
    +
    +
    Changes apply instantly after saving. diff --git a/src/renderer/modules/themeManagement.ts b/src/renderer/modules/themeManagement.ts index 4722e2a8..63103d97 100644 --- a/src/renderer/modules/themeManagement.ts +++ b/src/renderer/modules/themeManagement.ts @@ -317,3 +317,14 @@ export function applyDebugMenuVisibility(showDebugMenu: boolean): void { debugActivityItem.style.display = showDebugMenu ? "" : "none"; } } + +/** + * Apply preview features visibility setting + * Controls visibility of preview-gated items (e.g. the MCP Server button) + */ +export function applyPreviewFeaturesVisibility(enablePreviewFeatures: boolean): void { + const mcpButton = document.getElementById("agent-invocation-logs-btn") as HTMLElement | null; + if (mcpButton) { + mcpButton.style.display = enablePreviewFeatures ? "" : "none"; + } +} diff --git a/src/renderer/types/index.ts b/src/renderer/types/index.ts index 3cdc94ba..cc6e3be8 100644 --- a/src/renderer/types/index.ts +++ b/src/renderer/types/index.ts @@ -62,6 +62,7 @@ export interface SettingsState { showEnvironmentColor?: boolean; categoryColorThickness?: number; environmentColorThickness?: number; + enablePreviewFeatures?: boolean; } /** From 47783113afe4afe5fc822149faf1eaeff520b00d Mon Sep 17 00:00:00 2001 From: Power-Maverick Date: Fri, 26 Jun 2026 22:42:20 -0400 Subject: [PATCH 226/257] Added MCP to preview feature and added preview feature enablement in settings --- src/renderer/index.html | 2 +- .../modules/agentInvocationLogsManagement.ts | 16 ++++++++-------- src/renderer/modules/initialization.ts | 5 +++-- src/renderer/modules/mcpManagement.ts | 14 +++++++------- src/renderer/modules/previewFeatureManagement.ts | 10 ++++++++++ src/renderer/modules/settingsManagement.ts | 3 ++- src/renderer/modules/themeManagement.ts | 11 ----------- 7 files changed, 31 insertions(+), 30 deletions(-) create mode 100644 src/renderer/modules/previewFeatureManagement.ts diff --git a/src/renderer/index.html b/src/renderer/index.html index e8f2deb0..8b3a81bc 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -40,7 +40,7 @@
    -