diff --git a/.github/workflows/package-managers.yml b/.github/workflows/package-managers.yml index a1c3de0227..2a435f42e9 100644 --- a/.github/workflows/package-managers.yml +++ b/.github/workflows/package-managers.yml @@ -1,8 +1,8 @@ name: Update Package Manager Manifests on: - # release: - # types: [released] + release: + types: [released] workflow_dispatch: inputs: version: @@ -75,7 +75,7 @@ jobs: echo "deb_sha256=$DEB_SHA" >> "$GITHUB_OUTPUT" echo "jar_sha256=$JAR_SHA" >> "$GITHUB_OUTPUT" - update-homebrew: + update-homebrew-and-scoop: needs: get-release-info runs-on: ubuntu-latest permissions: @@ -86,22 +86,21 @@ jobs: with: egress-policy: audit - - name: Checkout homebrew tap + - name: Checkout homebrew-stirling-pdf tap (also hosts Scoop bucket) uses: actions/checkout@v4 with: repository: Stirling-Tools/homebrew-stirling-pdf token: ${{ secrets.HOMEBREW_TAP_TOKEN }} - path: homebrew-tap + path: tap - - name: Update cask (stirling-pdf.rb) + - name: Update Homebrew cask (Casks/stirling-pdf.rb) env: VERSION: ${{ needs.get-release-info.outputs.version }} ARM64_SHA: ${{ needs.get-release-info.outputs.dmg_arm64_sha256 }} X64_SHA: ${{ needs.get-release-info.outputs.dmg_x86_64_sha256 }} run: | - CASK="homebrew-tap/Casks/stirling-pdf.rb" + CASK="tap/Casks/stirling-pdf.rb" sed -i "s/version \".*\"/version \"${VERSION}\"/" "$CASK" - # Update ARM64 sha256 (line following on_arm block) awk -v arm="$ARM64_SHA" -v x64="$X64_SHA" ' /on_arm/ { in_arm=1 } /on_intel/ { in_arm=0; in_intel=1 } @@ -111,87 +110,50 @@ jobs: { print } ' "$CASK" > tmp && mv tmp "$CASK" - - name: Update formula (stirling-pdf-server.rb) + - name: Update Homebrew formula (Formula/stirling-pdf-server.rb) env: VERSION: ${{ needs.get-release-info.outputs.version }} JAR_SHA: ${{ needs.get-release-info.outputs.jar_sha256 }} run: | - FORMULA="homebrew-tap/Formula/stirling-pdf-server.rb" + FORMULA="tap/Formula/stirling-pdf-server.rb" sed -i "s/version \".*\"/version \"${VERSION}\"/" "$FORMULA" sed -i "s/sha256 \".*\"/sha256 \"${JAR_SHA}\"/" "$FORMULA" - - name: Show homebrew tap diff (for dry-run visibility) - working-directory: homebrew-tap - run: | - echo "--- diff --stat ---" - git diff --stat - echo "--- full diff ---" - git diff - - - name: Commit and push homebrew tap updates - if: ${{ github.event_name == 'release' || inputs.dry_run == false }} - working-directory: homebrew-tap - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add Casks/stirling-pdf.rb Formula/stirling-pdf-server.rb - git diff --cached --quiet && echo "No changes" && exit 0 - git commit -m "chore: bump Stirling-PDF to v${{ needs.get-release-info.outputs.version }}" - git push - - update-scoop: - needs: get-release-info - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Harden Runner - uses: step-security/harden-runner@v2 - with: - egress-policy: audit - - - name: Checkout Scoop bucket (shared with Homebrew tap) - uses: actions/checkout@v4 - with: - repository: Stirling-Tools/homebrew-stirling-pdf - token: ${{ secrets.SCOOP_BUCKET_TOKEN }} - path: scoop-bucket - - - name: Update stirling-pdf.json + - name: Update Scoop stirling-pdf.json env: VERSION: ${{ needs.get-release-info.outputs.version }} MSI_SHA: ${{ needs.get-release-info.outputs.msi_sha256 }} run: | - MANIFEST="scoop-bucket/scoop/stirling-pdf.json" + MANIFEST="tap/scoop/stirling-pdf.json" jq --arg v "$VERSION" --arg h "$MSI_SHA" \ '.version = $v | .architecture["64bit"].url = "https://github.com/Stirling-Tools/Stirling-PDF/releases/download/v\($v)/Stirling-PDF-windows-x86_64.msi" | .architecture["64bit"].hash = $h' \ "$MANIFEST" > tmp.json && mv tmp.json "$MANIFEST" - - name: Update stirling-pdf-server.json + - name: Update Scoop stirling-pdf-server.json env: VERSION: ${{ needs.get-release-info.outputs.version }} JAR_SHA: ${{ needs.get-release-info.outputs.jar_sha256 }} run: | - MANIFEST="scoop-bucket/scoop/stirling-pdf-server.json" + MANIFEST="tap/scoop/stirling-pdf-server.json" jq --arg v "$VERSION" --arg h "$JAR_SHA" \ '.version = $v | .url = "https://github.com/Stirling-Tools/Stirling-PDF/releases/download/v\($v)/Stirling-PDF-with-login.jar" | .hash = $h' \ "$MANIFEST" > tmp.json && mv tmp.json "$MANIFEST" - - name: Show Scoop bucket diff (for dry-run visibility) - working-directory: scoop-bucket + - name: Show tap diff (for dry-run visibility) + working-directory: tap run: | echo "--- diff --stat ---" git diff --stat echo "--- full diff ---" git diff - - name: Commit and push Scoop bucket updates + - name: Commit and push all tap updates if: ${{ github.event_name == 'release' || inputs.dry_run == false }} - working-directory: scoop-bucket + working-directory: tap run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git add scoop/stirling-pdf.json scoop/stirling-pdf-server.json + git add Casks/stirling-pdf.rb Formula/stirling-pdf-server.rb scoop/stirling-pdf.json scoop/stirling-pdf-server.json git diff --cached --quiet && echo "No changes" && exit 0 git commit -m "chore: bump Stirling-PDF to v${{ needs.get-release-info.outputs.version }}" git push diff --git a/AGENTS.md b/AGENTS.md index 6fe3cacc99..5ed391821e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,15 +43,86 @@ After modifying any files in the project, you must run the relevant `task check` ### Security Mode Development Set `DOCKER_ENABLE_SECURITY=true` environment variable to enable security features during development. This is required for testing the full version locally. -### Python Development -Development for the AI engine happens in the `engine/` folder. The frontend calls the Python via Java as a proxy. +### Python Development (AI Engine) -- Follow the engine-specific guidance in [engine/AGENTS.md](engine/AGENTS.md) for Python architecture, code style, and AI usage. -- Use Task commands from the repo root: - - `task engine:check` — lint, type-check, test - - `task engine:fix` — auto-fix linting and formatting - - `task engine:install` — install dependencies -- The project structure is defined in `engine/pyproject.toml`. Any new dependencies should be listed there, followed by running `task engine:install`. +The engine is a Python reasoning service for Stirling: it plans and interprets work, but it does not own durable state, and it does not execute Stirling PDF operations directly. Keep the service narrow: typed contracts in, typed contracts out, with AI only where it adds reasoning value. The frontend calls the Python engine via Java as a proxy. + +#### Python Commands +All engine commands run from the repo root using Task: +- `task engine:check` — run all checks (typecheck + lint + format-check + test) +- `task engine:fix` — auto-fix lint + formatting +- `task engine:install` — install Python dependencies via uv +- `task engine:dev` — start FastAPI with hot reload (localhost:5001) +- `task engine:test` — run pytest +- `task engine:lint` — run ruff linting +- `task engine:typecheck` — run pyright +- `task engine:format` — format code with ruff +- `task engine:tool-models` — generate `tool_models.py` from the Java OpenAPI spec + +The project structure is defined in `engine/pyproject.toml`. Any new dependencies should be listed there, followed by running `task engine:install`. + +#### Python Code Style +- Keep `task engine:check` passing. +- Use modern Python when it improves clarity. +- Prefer explicit names to cleverness. +- Avoid nested functions and nested classes unless the language construct requires them. +- Prefer composition to inheritance when combining concepts. +- Avoid speculative abstractions. Add a layer only when it removes real duplication or clarifies lifecycle. +- Add comments sparingly and only when they explain non-obvious intent. + +#### Python Typing and Models +- Deserialize into Pydantic models as early as possible. +- Serialize from Pydantic models as late as possible. +- Do not pass raw `dict[str, Any]` or `dict[str, object]` across important boundaries when a typed model can exist instead. +- Avoid `Any` wherever possible. +- Avoid `cast()` wherever possible (reconsider the structure first). +- All shared models should subclass `stirling.models.ApiModel` so the service behaves consistently. +- Do not use string literals for any type annotations, including `cast()`. + +#### Python Configuration +- Keep application-owned configuration in `stirling.config`. +- Only add `STIRLING_*` environment variables that the engine itself truly owns. +- Do not mirror third-party provider environment variables unless the engine is actually interpreting them. +- Let `pydantic-ai` own provider authentication configuration when possible. + +#### Python Architecture + +**Package roles:** +- `stirling.contracts`: request/response models and shared typed workflow contracts. If a shape crosses a module or service boundary, it probably belongs here. +- `stirling.models`: shared model primitives and generated tool models. +- `stirling.agents`: reasoning modules for individual capabilities. +- `stirling.api`: HTTP layer, dependency access, and app startup wiring. +- `stirling.services`: shared runtime and non-AI infrastructure. +- `stirling.config`: application-owned settings. + +**Source of truth:** +- `stirling.models.tool_models` is the source of truth for operation IDs and parameter models. +- Do not duplicate operation lists if they can be derived from `tool_models.OPERATIONS`. +- Do not hand-maintain parallel parameter schemas when the generated tool models already define them. +- If a tool ID must match a parameter model, validate that relationship explicitly in code. + +**Boundaries:** +- Keep the API layer thin. Route modules should bind requests, resolve dependencies, and call agents or services. They should not contain business logic. +- Keep agents focused on one reasoning domain. They should not own FastAPI routing, persistence, or execution of Stirling operations. +- Build long-lived runtime objects centrally at startup when possible rather than reconstructing heavy AI objects per request. +- If an agent delegates to another agent, the delegated agent should remain the source of truth for its own domain output. + +#### Python AI Usage +- The system must work with any AI, including self-hosted models. We require that the models support structured outputs, but should minimise model-specific code beyond that. +- Use AI for reasoning-heavy outputs, not deterministic glue. +- Do not ask the model to invent data that Python can derive safely. +- Do not fabricate fallback user-facing copy in code to hide incomplete model output. +- AI output schemas should be impossible to instantiate incorrectly. + - Do not require the model to keep separate structures in sync. For example, instead of generating two lists which must be the same length, generate one list of a model containing the same data. + - Prefer Python to derive deterministic follow-up structure from a valid AI result. +- Use `NativeOutput(...)` for structured model outputs. +- Use `ToolOutput(...)` when the model should select and call delegate functions. + +#### Python Testing +- Test contracts directly. +- Test agents directly where behaviour matters. +- Test API routes as thin integration points. +- Prefer dependency overrides or startup-state seams to monkeypatching random globals. ### Frontend Development - **Frontend dev server**: `task frontend:dev` — requires backend on localhost:8080 diff --git a/build.gradle b/build.gradle index 0721c37baa..e75eae575e 100644 --- a/build.gradle +++ b/build.gradle @@ -78,7 +78,7 @@ springBoot { allprojects { group = 'stirling.software' - version = '2.9.2' + version = '2.10.0' configurations.configureEach { exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat" diff --git a/engine/AGENTS.md b/engine/AGENTS.md deleted file mode 100644 index 8e45662740..0000000000 --- a/engine/AGENTS.md +++ /dev/null @@ -1,90 +0,0 @@ -# Stirling AI Engine Guide - -This file is for AI agents working in `engine/`. - -The engine is a Python reasoning service for Stirling. It plans and interprets work, but it does not own durable state, and it does not execute Stirling PDF operations directly. Keep the service narrow: typed contracts in, typed contracts out, with AI only where it adds reasoning value. - -## Commands - -All engine commands can be run from the repository root using Task: - -- `task engine:check` — run all checks (typecheck + lint + format-check + test) -- `task engine:fix` — auto-fix lint + formatting -- `task engine:install` — install Python dependencies via uv -- `task engine:dev` — start FastAPI with hot reload (localhost:5001) -- `task engine:test` — run pytest -- `task engine:lint` — run ruff linting -- `task engine:typecheck` — run pyright -- `task engine:format` — format code with ruff -- `task engine:tool-models` — generate tool_models.py from Java OpenAPI spec - -## Code Style - -- Keep `task engine:check` passing. -- Use modern Python when it improves clarity. -- Prefer explicit names to cleverness. -- Avoid nested functions and nested classes unless the language construct requires them. -- Prefer composition to inheritance when combining concepts. -- Avoid speculative abstractions. Add a layer only when it removes real duplication or clarifies lifecycle. -- Add comments sparingly and only when they explain non-obvious intent. - -### Typing and Models - -- Deserialize into Pydantic models as early as possible. -- Serialize from Pydantic models as late as possible. -- Do not pass raw `dict[str, Any]` or `dict[str, object]` across important boundaries when a typed model can exist instead. -- Avoid `Any` wherever possible. -- Avoid `cast()` wherever possible (reconsider the structure first). -- All shared models should subclass `stirling.models.ApiModel` so the service behaves consistently. -- Do not use string literals for any type annotations, including `cast()`. - -### Configuration - -- Keep application-owned configuration in `stirling.config`. -- Only add `STIRLING_*` environment variables that the engine itself truly owns. -- Do not mirror third-party provider environment variables unless the engine is actually interpreting them. -- Let `pydantic-ai` own provider authentication configuration when possible. - -## Architecture - -### Package Roles - -- `stirling.contracts`: request/response models and shared typed workflow contracts. If a shape crosses a module or service boundary, it probably belongs here. -- `stirling.models`: shared model primitives and generated tool models. -- `stirling.agents`: reasoning modules for individual capabilities. -- `stirling.api`: HTTP layer, dependency access, and app startup wiring. -- `stirling.services`: shared runtime and non-AI infrastructure. -- `stirling.config`: application-owned settings. - -### Source Of Truth - -- `stirling.models.tool_models` is the source of truth for operation IDs and parameter models. -- Do not duplicate operation lists if they can be derived from `tool_models.OPERATIONS`. -- Do not hand-maintain parallel parameter schemas when the generated tool models already define them. -- If a tool ID must match a parameter model, validate that relationship explicitly in code. - -### Boundaries - -- Keep the API layer thin. Route modules should bind requests, resolve dependencies, and call agents or services. They should not contain business logic. -- Keep agents focused on one reasoning domain. They should not own FastAPI routing, persistence, or execution of Stirling operations. -- Build long-lived runtime objects centrally at startup when possible rather than reconstructing heavy AI objects per request. -- If an agent delegates to another agent, the delegated agent should remain the source of truth for its own domain output. - -## AI Usage - -- The system must work with any AI, including self-hosted models. We require that the models support structured outputs, but should minimise model-specific code beyond that. -- Use AI for reasoning-heavy outputs, not deterministic glue. -- Do not ask the model to invent data that Python can derive safely. -- Do not fabricate fallback user-facing copy in code to hide incomplete model output. -- AI output schemas should be impossible to instantiate incorrectly. - - Do not require the model to keep separate structures in sync. For example, instead of generating two lists which must be the same length, generate one list of a model containing the same data. - - Prefer Python to derive deterministic follow-up structure from a valid AI result. -- Use `NativeOutput(...)` for structured model outputs. -- Use `ToolOutput(...)` when the model should select and call delegate functions. - -## Testing - -- Test contracts directly. -- Test agents directly where behaviour matters. -- Test API routes as thin integration points. -- Prefer dependency overrides or startup-state seams to monkeypatching random globals. diff --git a/frontend/package.json b/frontend/package.json index a26b3315fd..2b41366fb0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -80,7 +80,7 @@ "web-vitals": "^5.1.0" }, "scripts": { - "update:minor": "npm outdated || npm update --before=$(date -v-7d +%Y-%m-%d) && (npm audit fix --before=$(date -v-7d +%Y-%m-%d) || true) && npm test", + "update:minor": "node scripts/update-minor.js", "update:major": "npx npm-check-updates -u && npm install", "update:interactive": "npx npm-check-updates -i", "update:minor-strict": "npx npm-check-updates -u --target minor && npm install" diff --git a/frontend/scripts/update-minor.js b/frontend/scripts/update-minor.js new file mode 100644 index 0000000000..3765265656 --- /dev/null +++ b/frontend/scripts/update-minor.js @@ -0,0 +1,54 @@ +#!/usr/bin/env node + +/** + * Cross-platform update:minor script + * Calculates date from 7 days ago and runs npm update/audit with that date + */ + +const { spawn } = require("child_process"); + +// Calculate date from 7 days ago in YYYY-MM-DD format +const date = new Date(); +date.setDate(date.getDate() - 7); +const beforeDate = date.toISOString().split("T")[0]; + +console.log(`Updating packages modified before: ${beforeDate}`); + +let lastExitCode = 0; + +// Run npm outdated first +const outdated = spawn("npm", ["outdated"], { stdio: "inherit", shell: true }); + +outdated.on("close", (_code) => { + // npm outdated returns exit code 1 if updates are available, so we ignore it + + // Run npm update with before date + const update = spawn("npm", ["update", `--before=${beforeDate}`], { + stdio: "inherit", + shell: true, + }); + + update.on("close", (updateCode) => { + // Track update failures + if (updateCode !== 0) { + lastExitCode = updateCode; + } + + // Run npm audit fix with before date + const audit = spawn("npm", ["audit", "fix", `--before=${beforeDate}`], { + stdio: "inherit", + shell: true, + }); + + audit.on("close", (auditCode) => { + // Track audit failures (but don't override critical update failures) + if (auditCode !== 0 && lastExitCode === 0) { + lastExitCode = auditCode; + } + + // Update complete - report with tracked exit code + console.log("\nPackage update complete!"); + process.exit(lastExitCode); + }); + }); +}); diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index 117aea592e..6e643e50d3 100644 --- a/frontend/src-tauri/tauri.conf.json +++ b/frontend/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "Stirling-PDF", - "version": "2.9.2", + "version": "2.10.0", "identifier": "stirling.pdf.dev", "build": { "frontendDist": "../dist", diff --git a/frontend/src/core/hooks/useIndexedDBThumbnail.ts b/frontend/src/core/hooks/useIndexedDBThumbnail.ts index 5ee9e45e26..3b454e6849 100644 --- a/frontend/src/core/hooks/useIndexedDBThumbnail.ts +++ b/frontend/src/core/hooks/useIndexedDBThumbnail.ts @@ -3,6 +3,7 @@ import { StirlingFileStub } from "@app/types/fileContext"; import { useIndexedDB } from "@app/contexts/IndexedDBContext"; import { generateThumbnailForFile } from "@app/utils/thumbnailUtils"; import { FileId } from "@app/types/fileContext"; +import { useFileManagement } from "@app/contexts/FileContext"; /** * Hook for IndexedDB-aware thumbnail loading @@ -17,6 +18,7 @@ export function useIndexedDBThumbnail( const [thumb, setThumb] = useState(null); const [generating, setGenerating] = useState(false); const indexedDB = useIndexedDB(); + const { updateStirlingFileStub } = useFileManagement(); useEffect(() => { let cancelled = false; @@ -27,58 +29,59 @@ export function useIndexedDBThumbnail( return; } - // First priority: use stored thumbnail + // Tier 1: stored thumbnail on the stub. if (file.thumbnailUrl) { setThumb(file.thumbnailUrl); return; } - // Second priority: generate thumbnail for files under 100MB - if (file.size < 100 * 1024 * 1024 && !generating) { - setGenerating(true); - try { - let fileObject: File; - - // Try to load file from IndexedDB using new context - if (file.id && indexedDB) { - const loadedFile = await indexedDB.loadFile(file.id as FileId); - if (!loadedFile) { - throw new Error("File not found in IndexedDB"); - } - fileObject = loadedFile; - } else { - throw new Error( - "File ID not available or IndexedDB context not available", - ); - } - - // Use the universal thumbnail generator - const thumbnail = await generateThumbnailForFile(fileObject); - if (!cancelled) { - setThumb(thumbnail); - - // Save thumbnail to IndexedDB for persistence - if (file.id && indexedDB && thumbnail) { - try { - await indexedDB.updateThumbnail(file.id as FileId, thumbnail); - } catch (error) { - console.warn("Failed to save thumbnail to IndexedDB:", error); - } - } - } - } catch (error) { - console.warn( - "Failed to generate thumbnail for file", - file.name, - error, - ); - if (!cancelled) setThumb(null); - } finally { - if (!cancelled) setGenerating(false); - } - } else { - // Large files - no thumbnail + // >=100MB files are skipped entirely — no thumbnail. + if (file.size >= 100 * 1024 * 1024) { setThumb(null); + return; + } + + // Tier 2: generate on demand from the File bytes in IndexedDB. + // Re-entry guard is handled by the effect's cleanup/cancelled pattern — + // `generating` is NOT in the deps, so setGenerating() does not trigger + // the effect to re-run and cancel itself mid-flight. + setGenerating(true); + try { + if (!file.id || !indexedDB) { + throw new Error( + `missing prerequisite fileId=${file.id} indexedDB=${Boolean(indexedDB)}`, + ); + } + + const loadedFile = await indexedDB.loadFile(file.id as FileId); + if (!loadedFile) { + throw new Error("not in IndexedDB (likely remote-only stub)"); + } + + const thumbnail = await generateThumbnailForFile(loadedFile); + if (cancelled) return; + + setThumb(thumbnail); + + if (file.id && indexedDB && thumbnail) { + try { + await indexedDB.updateThumbnail(file.id as FileId, thumbnail); + // Also sync the in-memory stub so subsequent re-mounts hit tier 1 + // instead of regenerating. IndexedDB persistence alone only helps + // the next page load; the current session reads file.thumbnailUrl + // from the FileContext stub. + updateStirlingFileStub(file.id as FileId, { + thumbnailUrl: thumbnail, + }); + } catch (error) { + console.warn("Failed to persist thumbnail:", error); + } + } + } catch (error) { + console.warn("Failed to generate thumbnail for file", file.name, error); + if (!cancelled) setThumb(null); + } finally { + if (!cancelled) setGenerating(false); } } @@ -86,7 +89,11 @@ export function useIndexedDBThumbnail( return () => { cancelled = true; }; - }, [file, file?.thumbnailUrl, file?.id, indexedDB, generating]); + // `generating` is intentionally NOT in the deps — it's an internal flag + // set by this effect, and including it caused the effect to cancel + // itself mid-flight (orphaning the render and leaving generating=true + // stuck forever). + }, [file, file?.thumbnailUrl, file?.id, indexedDB, updateStirlingFileStub]); return { thumbnail: thumb, isGenerating: generating }; } diff --git a/frontend/src/core/testing/serverExperienceSimulations.ts b/frontend/src/core/testing/serverExperienceSimulations.ts index 1ddb61ccae..62dec0213c 100644 --- a/frontend/src/core/testing/serverExperienceSimulations.ts +++ b/frontend/src/core/testing/serverExperienceSimulations.ts @@ -38,7 +38,7 @@ const FREE_LICENSE_INFO: LicenseInfo = { const BASE_NO_LOGIN_CONFIG: AppConfig = { enableAnalytics: true, - appVersion: "2.9.2", + appVersion: "2.10.0", serverCertificateEnabled: false, enableAlphaFunctionality: false, serverPort: 8080, diff --git a/frontend/src/core/utils/patchDomForTranslators.ts b/frontend/src/core/utils/patchDomForTranslators.ts new file mode 100644 index 0000000000..7efa285935 --- /dev/null +++ b/frontend/src/core/utils/patchDomForTranslators.ts @@ -0,0 +1,99 @@ +// Browser page translators (Edge, Chrome, extensions) wrap text nodes in +// injected elements, reparenting nodes React is holding. React's commit +// phase then throws NotFoundError on removeChild/insertBefore and the +// ErrorBoundary unmounts the app. https://github.com/facebook/react/issues/11538 +// +// We watch for translator fingerprints (Google Translate's translated-* class +// on , or any injected ) and install guards on Node.prototype only +// once one appears, so native DOM semantics are preserved when no translator +// is active. + +declare global { + interface Node { + __stirlingTranslatorPatched?: boolean; + } +} + +let patchApplied = false; + +function isGoogleTranslateActive(): boolean { + const cls = document.documentElement.classList; + return cls.contains("translated-ltr") || cls.contains("translated-rtl"); +} + +function applyDomPatch(trigger: string): void { + if (patchApplied) return; + if (typeof Node === "undefined" || !Node.prototype) return; + if (Node.prototype.__stirlingTranslatorPatched) return; + patchApplied = true; + Node.prototype.__stirlingTranslatorPatched = true; + + console.warn( + `[dom-patch] Browser page translator detected (${trigger}). ` + + "Installing removeChild/insertBefore guards to prevent React crashes. " + + "The UI may show minor glitches while the translator is active.", + ); + + const originalRemoveChild = Node.prototype.removeChild; + Node.prototype.removeChild = function patchedRemoveChild( + this: Node, + child: T, + ): T { + if (child.parentNode !== this) return child; + return originalRemoveChild.call(this, child) as T; + } as typeof Node.prototype.removeChild; + + const originalInsertBefore = Node.prototype.insertBefore; + Node.prototype.insertBefore = function patchedInsertBefore( + this: Node, + newNode: T, + referenceNode: Node | null, + ): T { + if (referenceNode && referenceNode.parentNode !== this) return newNode; + return originalInsertBefore.call(this, newNode, referenceNode) as T; + } as typeof Node.prototype.insertBefore; +} + +export function armTranslatorDetector(): void { + if (typeof window === "undefined" || typeof MutationObserver === "undefined") + return; + if (typeof document === "undefined" || !document.documentElement) return; + + // Edge case: class already set (e.g., bfcache restore). + if (isGoogleTranslateActive()) { + applyDomPatch("html class was already translated-* on arm"); + return; + } + + const observer = new MutationObserver((mutations) => { + for (const m of mutations) { + if ( + m.type === "attributes" && + m.target === document.documentElement && + isGoogleTranslateActive() + ) { + applyDomPatch(" translated-* class appeared"); + observer.disconnect(); + return; + } + if (m.type === "childList") { + for (const n of m.addedNodes) { + if (n.nodeName === "FONT") { + applyDomPatch(" element injected into DOM"); + observer.disconnect(); + return; + } + } + } + } + }); + + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + childList: true, + subtree: true, + }); +} + +armTranslatorDetector(); diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index a5c4783488..5a7b6272fb 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -1,3 +1,8 @@ +// Must be imported before React so the DOM-prototype patch is installed +// before React's commit phase runs. Prevents browser page translators +// (Edge / Google Translate / extensions) from crashing the app via +// parent-mismatch DOMExceptions. See the module for details. +import "@app/utils/patchDomForTranslators"; import "@mantine/core/styles.css"; import "@mantine/dates/styles.css"; import "../vite-env.d.ts"; // eslint-disable-line no-restricted-imports -- Outside app paths diff --git a/frontend/src/proprietary/testing/serverExperienceSimulations.ts b/frontend/src/proprietary/testing/serverExperienceSimulations.ts index 7c7d46db43..c5a62e813e 100644 --- a/frontend/src/proprietary/testing/serverExperienceSimulations.ts +++ b/frontend/src/proprietary/testing/serverExperienceSimulations.ts @@ -48,7 +48,7 @@ const FREE_LICENSE_INFO: LicenseInfo = { const BASE_NO_LOGIN_CONFIG: AppConfig = { enableAnalytics: true, - appVersion: "2.9.2", + appVersion: "2.10.0", serverCertificateEnabled: false, enableAlphaFunctionality: false, enableDesktopInstallSlide: true,