diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 6bf6ac9..5a18743 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -33,6 +33,7 @@ regress scroll performance, nothing floats against the viewport, stay offline). ## Checklist - [ ] `npm run lint` passes +- [ ] `npm test` passes - [ ] `npm run build` passes - [ ] Ran it with `npm run electron:dev` and tried the change, including unsaved changes and more than one open tab where relevant - [ ] No colour literals added outside `src/brand.css` (`rg -n '#[0-9a-fA-F]{3,8}\b' src/App.css`; hits must be inside `@media print`) diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index b0ffad0..2d6813c 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -1,19 +1,43 @@ # Linux release pipeline. One tag push does all of this: # -# build AppImage, .deb (Ubuntu/Debian), .rpm (Fedora/RHEL, GPG-signed), .snap -# attach all of them + latest-linux.yml to the GitHub release for the tag -# apt publish a signed flat apt repository -> `apt install fate` / `apt upgrade` -# dnf publish signed dnf repodata -> `dnf install fate` / `dnf upgrade` -# snap upload to the Snap Store stable channel (when SNAPCRAFT_STORE_CREDENTIALS is set) -# flatpak build + lint the Flathub manifest against the released .deb (Flathub itself is -# updated by flatpak-external-data-checker from the release; nothing to do here) -# test install `fate` from the apt repo in a Debian container and from the dnf repo in a -# Fedora container, exactly as a user would +# build AppImage, .deb (Ubuntu/Debian), .rpm (Fedora/RHEL) and the snap's staging tree. +# NO secrets: this job runs `npm ci` (every dependency's install scripts) and +# electron-builder's tool downloads. +# snap builds the .snap with snapcore/action-build. No secrets either. +# publish signs the .rpm, attaches everything + latest-linux.yml to the GitHub release, and +# republishes the signed apt and dnf repositories. The ONLY job that sees the signing +# key, and it runs no npm and no third-party action: GitHub's own checkout and +# download-artifact (pinned), then system tools, gpg and gh. +# snap-store uploads the snap to the Snap Store stable channel (when SNAPCRAFT_STORE_CREDENTIALS +# is set). Its only secret is that store token. +# test-repos installs `fate` from the apt repo in a Debian container and from the dnf repo in a +# Fedora container, exactly as a user would. +# flatpak builds + lints the Flathub manifest against the released .deb (Flathub itself is +# updated by flatpak-external-data-checker from the release; nothing to do here). # # The maintainer's part is # gh release create vX.Y.Z --title ... --notes ... # which creates the tag and fires this and build-windows.yml (the Windows installer and latest.yml). # +# ── Guards: what gets published where ────────────────────────────────────────────────────────── +# * The tag must be v + package.json's version (the same check as build-windows.yml), or a tag +# without a version bump would republish the old build under the new name. Checked before +# anything is built. +# * A recovery run is started from the release's tag (Run workflow → Use workflow from → Tags) +# and builds that commit; a `release_tag` naming any other tag is refused. +# * apt, dnf and the Snap Store are stable channels. They are updated only when the release is +# published (not a draft: draft assets 404 for everyone), is not a prerelease (and the tag has +# no "-": v1.15.0-beta.1 never goes there), and is the repository's latest release (so a +# recovery run for an older tag cannot roll the repositories back). A release that fails these +# still gets its files; the job says which rule held it back. +# * Assets already on the release are kept: a re-run would replace them with new bytes and new +# hashes, which breaks the AUR's and Flathub's checksums. The AppImage, .deb, .rpm and +# latest-linux.yml are one unit (latest-linux.yml carries their hashes): all are uploaded, or +# none. A release holding only some of them stops the job; Run workflow with force replaces +# them all. The apt and dnf indexes are built from the packages ON the release, never from +# this run's rebuild, so they always describe the bytes users download. +# * Publishing jobs of different runs never overlap (one fixed concurrency group). +# # ── How the apt/dnf repositories live entirely in GitHub Releases ───────────────────────────── # Both package managers need an index, a signature, and the packages. The packages are the # versioned release assets already. The indexes go to two ROLLING releases whose tags are chosen @@ -31,13 +55,19 @@ # the newest non-prerelease. A normal `apt` release would hijack every Windows and AppImage # install's update check. Keep them prereleases forever. # -# Signing: the FATE_GPG_PRIVATE_KEY secret (created once by scripts/setup-signing-key.sh). The -# public keyring is exported into build/ before packaging so the .deb/.rpm carry it and their -# post-install scripts (build/linux/after-install-*.sh) can register the repositories. +# ── Signing ──────────────────────────────────────────────────────────────────────────────────── +# The FATE_GPG_PRIVATE_KEY secret (created once by scripts/setup-signing-key.sh) signs the .rpm, +# the apt index (InRelease, Release.gpg) and the dnf repodata (repomd.xml.asc). The PUBLIC keyring +# is committed: build/linux/fate-archive-keyring.{gpg,asc}. The packages ship it, the post-install +# scripts (build/linux/after-install-*.sh) register the repositories with it, and it is uploaded +# beside the indexes. SIGNING_KEYS below names the allowed signing keys by fingerprint; publish +# refuses to sign unless the secret holds exactly those keys and the committed keyring has every +# one of them (npm test checks the keyring side on every PR). Changing it is a key rotation: +# SECURITY.md → Package signing key. # -# "Run workflow" (workflow_dispatch) on a branch builds everything and attaches it to the run. -# Give it a `release_tag` to ALSO publish to that existing release and the repositories: the -# recovery path when a tag's own run failed. +# "Run workflow" (workflow_dispatch) on a branch builds everything and attaches it to the run; +# the .rpm on the run is unsigned (only release runs touch the key). Started from a release's tag +# instead, it publishes that release: the recovery path when a tag's own run failed. name: Build Linux on: @@ -46,13 +76,18 @@ on: workflow_dispatch: inputs: release_tag: - description: 'Existing release tag to publish to (empty = run artifacts only)' + description: 'Optional check: the release tag this run was started from (Use workflow from → Tags). Started from a branch, the run only builds.' required: false default: '' type: string + force: + description: 'Replace Linux assets already on that release (their hashes change: the AUR and Flathub checksums break)' + required: false + default: false + type: boolean permissions: - contents: write + contents: read concurrency: group: build-linux-${{ github.ref }} @@ -60,58 +95,77 @@ concurrency: env: REPO_BASE: https://github.com/${{ github.repository }}/releases/download + # The key(s) allowed to sign, by full fingerprint, space-separated. All of them sign the apt and + # dnf indexes; the first also signs the .rpm (an rpm carries one signature). One key normally; + # two only during a key rotation (SECURITY.md → Package signing key). + SIGNING_KEYS: 5E78FD802EB3DDCCAB6F10268B2F82F6B2F71B42 jobs: - linux: + build: + name: Build packages runs-on: ubuntu-latest + timeout-minutes: 45 outputs: version: ${{ steps.meta.outputs.version }} - release_tag: ${{ steps.meta.outputs.release_tag }} - deb_sha256: ${{ steps.hashes.outputs.deb_sha256 }} - env: - HAS_SNAP_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS != '' }} + release_tag: ${{ steps.target.outputs.release_tag }} + artifact: ${{ steps.meta.outputs.artifact }} steps: - - uses: actions/checkout@v4 + # Inputs reach scripts only through env, never pasted into the script text. + - name: Resolve the release tag + id: target + env: + RELEASE_TAG_INPUT: ${{ inputs.release_tag }} + run: | + set -euo pipefail + TAG="" + case "$GITHUB_REF" in refs/tags/*) TAG="$GITHUB_REF_NAME" ;; esac + # A recovery run builds the commit it was STARTED from, so it is started from the tag + # (Run workflow → Use workflow from → Tags). Checking out a ref named in an input instead + # would run that ref's code in a job whose caches land in the default branch's scope. + if [ -n "$RELEASE_TAG_INPUT" ] && [ "$RELEASE_TAG_INPUT" != "$TAG" ]; then + echo "::error::To rebuild $RELEASE_TAG_INPUT, start Run workflow from that tag: Use workflow from → Tags → $RELEASE_TAG_INPUT. This run started from $GITHUB_REF_NAME." + exit 1 + fi + if [ -n "$TAG" ] && ! [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "::error::The release tag must look like v1.2.3 or v1.2.3-beta.1." + exit 1 + fi + echo "release_tag=$TAG" >> "$GITHUB_OUTPUT" - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: - node-version: 22 - cache: npm + persist-credentials: false - # rpm: rpmbuild + rpmsign for the Fedora package. createrepo-c: dnf repodata. - # dpkg-dev + apt-utils: the apt index (dpkg-scanpackages, apt-ftparchive). - # appstream + desktop-file-utils: validators for the desktop integration. - - name: Install packaging tools - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends rpm createrepo-c dpkg-dev apt-utils gnupg appstream desktop-file-utils + # TODO(pin): replace @v4 with the full commit SHA: gh api repos/actions/setup-node/commits/v4 --jq .sha + # No npm cache in release builds: a Run workflow recovery checks out a tag the dispatcher + # chose, and a cache saved by that run would land in the default branch's scope for later + # runs to restore (cache poisoning). Releases are rare; a cold npm ci costs a minute. + - uses: actions/setup-node@v4 # v4 + with: + node-version: 22 - name: Release metadata id: meta + env: + TAG: ${{ steps.target.outputs.release_tag }} run: | + set -euo pipefail VERSION="$(node -p "require('./package.json').version")" - TAG="" - case "$GITHUB_REF" in refs/tags/*) TAG="$GITHUB_REF_NAME" ;; esac - if [ -n "${{ inputs.release_tag }}" ]; then TAG="${{ inputs.release_tag }}"; fi + if [ -n "$TAG" ] && [ "$TAG" != "v$VERSION" ]; then + echo "::error::Tag $TAG does not match package.json's version $VERSION. Bump the version before tagging." + exit 1 + fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "release_tag=$TAG" >> "$GITHUB_OUTPUT" + # Not github.ref_name: a branch like fix/x would put "/" in the name, which artifacts refuse. + echo "artifact=FATE-linux-$VERSION-run$GITHUB_RUN_NUMBER" >> "$GITHUB_OUTPUT" echo "version $VERSION: publishing to release: ${TAG:-no (artifacts only)}" - - name: Import the package-signing key - env: - FATE_GPG_PRIVATE_KEY: ${{ secrets.FATE_GPG_PRIVATE_KEY }} + # rpm: rpmbuild for the Fedora package, and rpm -q to inspect it. + # appstream + desktop-file-utils: validators for the desktop integration. + - name: Install packaging tools run: | - set -euo pipefail - if [ -z "$FATE_GPG_PRIVATE_KEY" ]; then - echo "::error::The FATE_GPG_PRIVATE_KEY repository secret is missing. Run scripts/setup-signing-key.sh once (README → Building from source → Releases)." - exit 1 - fi - printf '%s\n' "$FATE_GPG_PRIVATE_KEY" | gpg --batch --quiet --import - FPR="$(gpg --list-secret-keys --with-colons | awk -F: '/^fpr/ { print $10; exit }')" - echo "GPG_FPR=$FPR" >> "$GITHUB_ENV" - gpg --export "$FPR" > build/fate-archive-keyring.gpg - gpg --armor --export "$FPR" > build/fate-archive-keyring.asc - echo "Signing with $FPR" + sudo apt-get update + sudo apt-get install -y --no-install-recommends rpm appstream desktop-file-utils - name: Validate the AppStream metadata run: appstreamcli validate --no-net --explain build/com.vaguedustin.fate.metainfo.xml @@ -119,29 +173,11 @@ jobs: - run: npm ci - run: npm run icons - run: npm run lint + - run: npm test - name: Build AppImage, .deb and .rpm run: npm run electron:build:linux -- --publish never - - name: Sign the rpm - run: | - set -euo pipefail - rpmsign --define "_gpg_name $GPG_FPR" --addsign dist-electron/*.rpm - # Verify the way dnf will: import the public key into an rpm database and let rpm check - # the signature, in a PRIVATE database (--dbpath), because a `sudo rpm --import` lands - # in root's database and the unprivileged `rpm -K` that follows cannot see it (NOKEY). - # (Querying %{SIGPGP} is wrong here too: rpm 4.14+ writes a V4 header signature into - # RSAHEADER, and SIGPGP reads back as "(none)".) - RPMDB="$PWD/rpmdb-verify" - rm -rf "$RPMDB" && mkdir -p "$RPMDB" - rpm --dbpath "$RPMDB" --initdb - rpm --dbpath "$RPMDB" --import build/fate-archive-keyring.asc - echo "--- signature on the rpm:" - rpm --dbpath "$RPMDB" -qip dist-electron/*.rpm | grep '^Signature' - rpm --dbpath "$RPMDB" -K dist-electron/*.rpm > /tmp/rpm-K.txt - cat /tmp/rpm-K.txt - grep -q 'signatures OK' /tmp/rpm-K.txt - - name: Check the packages and their desktop integration run: | set -euo pipefail @@ -167,10 +203,15 @@ jobs: grep -q '^/etc/pki/rpm-gpg/RPM-GPG-KEY-fate$' /tmp/rpm-files.txt grep -q 'usr/share/keyrings/fate-archive-keyring.gpg' /tmp/deb-files.txt - echo "--- post-install scripts register the repositories" + echo "--- maintainer scripts: post-install registers the repositories, remove takes them out" rm -rf /tmp/debctl && mkdir -p /tmp/debctl && dpkg-deb -e dist-electron/*.deb /tmp/debctl grep -q 'sources.list.d/fate.sources' /tmp/debctl/postinst + grep -q 'sources.list.d/fate.sources' /tmp/debctl/postrm grep -q 'yum.repos.d/fate.repo' /tmp/rpm-scripts.txt + # The rpm's post-uninstall must sit out upgrades; posttrans repairs what older ones broke. + sed -n '/^postuninstall scriptlet/,/^[a-z]* scriptlet/p' /tmp/rpm-scripts.txt > /tmp/rpm-postun.txt + grep -q '"$1" -ge 1' /tmp/rpm-postun.txt + grep -q '^posttrans scriptlet' /tmp/rpm-scripts.txt echo "--- desktop entry" rm -rf /tmp/deb && mkdir -p /tmp/deb && dpkg-deb -x dist-electron/*.deb /tmp/deb @@ -178,70 +219,342 @@ jobs: cat /tmp/deb/usr/share/applications/FATE.desktop echo "--- MIME types declared: $(grep '^MimeType=' /tmp/deb/usr/share/applications/FATE.desktop | tr ';' '\n' | grep -c /)" - - name: Package hashes - id: hashes - run: echo "deb_sha256=$(sha256sum dist-electron/*.deb | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" + echo "--- the keyring the packages ship is the committed one" + cmp /tmp/deb/usr/share/keyrings/fate-archive-keyring.gpg build/linux/fate-archive-keyring.gpg + + # app.asar holds the renderer bundle and the main process's two runtime packages, about + # 15 MB. Up to 1.13 it was 211 MB: every renderer package shipped a second time as + # node_modules because they sat in "dependencies". Renderer packages belong in + # devDependencies (Vite bundles them). + ASAR_BYTES="$(stat -c %s dist-electron/linux-unpacked/resources/app.asar)" + echo "--- app.asar: $((ASAR_BYTES / 1048576)) MB" + if [ "$ASAR_BYTES" -gt $((50 * 1048576)) ]; then + echo "::error::app.asar is $((ASAR_BYTES / 1048576)) MB. A renderer package is probably in package.json's dependencies instead of devDependencies." + exit 1 + fi - # ── Snap ─────────────────────────────────────────────────────────────────────────────── - name: Stage the snap build + env: + VERSION: ${{ steps.meta.outputs.version }} run: | set -euo pipefail node scripts/write-snap-desktop.mjs rm -rf snapbuild && mkdir -p snapbuild cp -r snap snapbuild/snap cp -r dist-electron/linux-unpacked snapbuild/linux-unpacked - sed -i "s|^version: .*|version: '${{ steps.meta.outputs.version }}'|" snapbuild/snap/snapcraft.yaml + sed -i "s|^version: .*|version: '$VERSION'|" snapbuild/snap/snapcraft.yaml grep '^version:' snapbuild/snap/snapcraft.yaml + # Artifacts drop file modes; the tarball keeps FATE and its helpers executable. + tar -cf snapbuild.tar snapbuild + + # TODO(pin): replace @v4 with the full commit SHA: gh api repos/actions/upload-artifact/commits/v4 --jq .sha + - uses: actions/upload-artifact@v4 # v4 + with: + name: ${{ steps.meta.outputs.artifact }} + if-no-files-found: error + path: | + dist-electron/*.AppImage + dist-electron/*.deb + dist-electron/*.rpm + dist-electron/latest-linux.yml + + - uses: actions/upload-artifact@v4 # v4 (TODO(pin), as above) + with: + name: snapbuild + if-no-files-found: error + # Only the snap job reads it; a week still covers re-running that job. + retention-days: 7 + path: snapbuild.tar + + # ── Snap: build only. No secrets here; snap-store below does the upload. ─────────────────── + snap: + name: Build the snap + needs: build + runs-on: ubuntu-latest + timeout-minutes: 45 + outputs: + artifact: ${{ steps.name.outputs.artifact }} + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: snapbuild + + - run: tar -xf snapbuild.tar - name: Build the snap id: snap - uses: snapcore/action-build@v1 + uses: snapcore/action-build@3bdaa03e1ba6bf59a65f84a751d943d549a54e79 # v1 with: path: snapbuild - name: Inspect the snap + id: name + env: + SNAP_FILE: ${{ steps.snap.outputs.snap }} + VERSION: ${{ needs.build.outputs.version }} run: | - ls -la "${{ steps.snap.outputs.snap }}" - cp "${{ steps.snap.outputs.snap }}" dist-electron/ + set -euo pipefail + ls -la "$SNAP_FILE" + echo "artifact=FATE-snap-$VERSION-run$GITHUB_RUN_NUMBER" >> "$GITHUB_OUTPUT" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v4 # v4 (TODO(pin), as above) with: - name: FATE-linux-${{ github.ref_name }} + name: ${{ steps.name.outputs.artifact }} if-no-files-found: error - path: | - dist-electron/*.AppImage - dist-electron/*.deb - dist-electron/*.rpm - dist-electron/*.snap - dist-electron/latest-linux.yml + path: ${{ steps.snap.outputs.snap }} - # ── Release ──────────────────────────────────────────────────────────────────────────── - - name: Attach the packages to the GitHub release - if: steps.meta.outputs.release_tag != '' - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ steps.meta.outputs.release_tag }} + # ── Sign and publish: the only job with the signing key. No npm, no third-party actions. ──── + publish: + name: Sign and publish + needs: [build, snap] + if: needs.build.outputs.release_tag != '' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write + # One fixed group: a tag run and a recovery run never publish at the same time. (GitHub keeps + # one run waiting per group; a third arriving meanwhile cancels the waiting one.) + concurrency: + group: build-linux-publish + cancel-in-progress: false + outputs: + stable: ${{ steps.release.outputs.stable }} + deb_sha256: ${{ steps.published.outputs.deb_sha256 }} + env: + TAG: ${{ needs.build.outputs.release_tag }} + VERSION: ${{ needs.build.outputs.version }} + FORCE: ${{ inputs.force }} + GH_TOKEN: ${{ github.token }} + steps: + # For the committed public keyring, from the tag being published. + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + ref: refs/tags/${{ needs.build.outputs.release_tag }} + persist-credentials: false + + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: ${{ needs.build.outputs.artifact }} + path: dist + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: ${{ needs.snap.outputs.artifact }} + path: dist + + # rpm: rpmsign. createrepo-c: dnf repodata. dpkg-dev + apt-utils: the apt index + # (dpkg-scanpackages, apt-ftparchive). + - name: Install repository tools + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends rpm createrepo-c dpkg-dev apt-utils gnupg + + - name: Check the release + id: release run: | set -euo pipefail - if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json tagName >/dev/null 2>&1; then echo "No release for $TAG yet; creating it as a draft." - gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --draft --title "$TAG" \ - --notes "Linux packages built by GitHub Actions. The Windows installer and latest.yml are attached by the maintainer." + gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --draft --verify-tag --title "$TAG" \ + --notes "Linux packages built by GitHub Actions. The Windows installer and latest.yml are attached by Build Windows." + fi + DRAFT="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft)" + PRERELEASE="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isPrerelease --jq .isPrerelease)" + LATEST="$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name 2>/dev/null || true)" + SEMVER_PRERELEASE=false + case "$TAG" in *-*) SEMVER_PRERELEASE=true ;; esac + + # A prerelease version on a release that is not marked prerelease would become the + # latest release, and every installed copy would update to it. + if [ "$SEMVER_PRERELEASE" = true ] && [ "$PRERELEASE" != true ]; then + echo "::error::$TAG is a prerelease version, but its GitHub release is not marked as a prerelease. Mark it (gh release edit $TAG --prerelease), then re-run this job." + exit 1 + fi + + STABLE=false + if [ "$DRAFT" = true ]; then + echo "::notice::$TAG is a draft: apt, dnf and the Snap Store are not updated (draft assets are not downloadable)." + elif [ "$PRERELEASE" = true ] || [ "$SEMVER_PRERELEASE" = true ]; then + echo "::notice::$TAG is a prerelease: its files are attached; apt, dnf and the Snap Store stay on the stable release." + elif [ "$LATEST" != "$TAG" ]; then + echo "::notice::$TAG is not the latest release (${LATEST:-none}): apt, dnf and the Snap Store are left alone." + else + STABLE=true + fi + echo "draft=$DRAFT" >> "$GITHUB_OUTPUT" + echo "stable=$STABLE" >> "$GITHUB_OUTPUT" + echo "draft=$DRAFT prerelease=$PRERELEASE latest=${LATEST:-none} -> stable channels: $STABLE" + + - name: Decide what to upload + id: plan + run: | + set -euo pipefail + shopt -s nullglob + for pattern in '*.AppImage' '*.deb' '*.rpm' '*.snap'; do + # shellcheck disable=SC2206 # the pattern is meant to glob + files=(dist/$pattern) + if [ "${#files[@]}" -ne 1 ]; then + echo "::error::Expected one dist/$pattern from the build, found ${#files[@]}." + exit 1 + fi + done + if [ ! -f dist/latest-linux.yml ]; then + echo "::error::dist/latest-linux.yml is missing from the build." + exit 1 + fi + gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' > existing.txt + have() { grep -qxF "$1" existing.txt; } + + # latest-linux.yml carries the sha512 of the AppImage, .deb and .rpm, so the four go up + # together or not at all. + UNIT=() + for f in dist/*.AppImage dist/*.deb dist/*.rpm dist/latest-linux.yml; do UNIT+=("$(basename "$f")"); done + present=0 + for name in "${UNIT[@]}"; do if have "$name"; then present=$((present + 1)); fi; done + if [ "$FORCE" = true ]; then + unit=replace + elif [ "$present" -eq 0 ]; then + unit=upload + elif [ "$present" -eq "${#UNIT[@]}" ]; then + unit=keep + echo "::notice::$TAG already has ${UNIT[*]}; leaving them in place (Run workflow with force to replace them)." + else + echo "::error::$TAG has only some of ${UNIT[*]}. This build's bytes differ from the ones there, and latest-linux.yml must match all of them. Run workflow from the tag $TAG and force to replace them all." + exit 1 + fi + + snap=upload + SNAP_NAME="$(basename dist/*.snap)" + if [ "$FORCE" = true ]; then snap=replace; elif have "$SNAP_NAME"; then snap=keep; fi + echo "unit=$unit" >> "$GITHUB_OUTPUT" + echo "snap=$snap" >> "$GITHUB_OUTPUT" + echo "packages: $unit, snap: $snap" + + - name: Import the signing key and check it is the expected one + env: + FATE_GPG_PRIVATE_KEY: ${{ secrets.FATE_GPG_PRIVATE_KEY }} + run: | + set -euo pipefail + if [ -z "$FATE_GPG_PRIVATE_KEY" ]; then + echo "::error::The FATE_GPG_PRIVATE_KEY repository secret is missing. Run scripts/setup-signing-key.sh once (README → Building from source → Releases)." + exit 1 + fi + printf '%s\n' "$FATE_GPG_PRIVATE_KEY" | gpg --batch --quiet --import + # Primary-key fingerprints: the fpr line straight after each sec/pub line. + primaries() { awk -F: '$1 == "sec" || $1 == "pub" { want = 1; next } want && $1 == "fpr" { print $10; want = 0 }'; } + read -ra KEYS <<< "$SIGNING_KEYS" + HAVE="$(gpg --batch --list-secret-keys --with-colons | primaries | sort)" + WANT="$(printf '%s\n' "${KEYS[@]}" | sort)" + if [ "$HAVE" != "$WANT" ]; then + echo "::error::FATE_GPG_PRIVATE_KEY holds $(tr '\n' ' ' <<< "$HAVE")but this workflow signs only with $SIGNING_KEYS. Nothing was signed. If the key was rotated on purpose, SIGNING_KEYS and build/linux/fate-archive-keyring.* change in the same commit (SECURITY.md → Package signing key)." + exit 1 + fi + SHIPPED="$(gpg --batch --show-keys --with-colons build/linux/fate-archive-keyring.asc | primaries)" + for fpr in "${KEYS[@]}"; do + if ! grep -qx "$fpr" <<< "$SHIPPED"; then + echo "::error::Signing key $fpr is not in build/linux/fate-archive-keyring.asc, so installs would not trust what it signs." + exit 1 + fi + done + echo "PRIMARY_KEY=${KEYS[0]}" >> "$GITHUB_ENV" + echo "Signing with: $SIGNING_KEYS" + + - name: Sign the rpm + if: steps.plan.outputs.unit != 'keep' + run: | + set -euo pipefail + RPM="$(ls dist/*.rpm)" + rpmsign --define "_gpg_name $PRIMARY_KEY" --addsign "$RPM" + # Verify the way dnf will: import the public key into an rpm database and let rpm check + # the signature, in a PRIVATE database (--dbpath), because a `sudo rpm --import` lands + # in root's database and the unprivileged `rpm -K` that follows cannot see it (NOKEY). + # (Querying %{SIGPGP} is wrong here too: rpm 4.14+ writes a V4 header signature into + # RSAHEADER, and SIGPGP reads back as "(none)".) + RPMDB="$PWD/rpmdb-verify" + rm -rf "$RPMDB" && mkdir -p "$RPMDB" + rpm --dbpath "$RPMDB" --initdb + rpm --dbpath "$RPMDB" --import build/linux/fate-archive-keyring.asc + echo "--- signature on the rpm:" + rpm --dbpath "$RPMDB" -qip "$RPM" | grep '^Signature' + rpm --dbpath "$RPMDB" -K "$RPM" > rpm-K.txt + cat rpm-K.txt + grep -q 'signatures OK' rpm-K.txt + + # electron-builder listed the rpm in latest-linux.yml with the sha512 and size of the + # UNSIGNED file. Signing rewrote it, so restate both, or an update that follows + # latest-linux.yml to the rpm would fail its checksum. + NAME="$(basename "$RPM")" + if grep -qxF " - url: $NAME" dist/latest-linux.yml; then + SHA512="$(openssl dgst -sha512 -binary "$RPM" | base64 -w0)" + SIZE="$(stat -c %s "$RPM")" + awk -v name="$NAME" -v sha="$SHA512" -v size="$SIZE" ' + /^ - url: / { inrpm = ($3 == name) } + /^[^ ]/ { inrpm = 0; top = top || ($1 == "path:" && $2 == name) } + inrpm && /^ sha512: / { print " sha512: " sha; n++; next } + inrpm && /^ size: / { print " size: " size; n++; next } + top && /^sha512: / { print "sha512: " sha; next } + { print } + END { if (n != 2) exit 3 } + ' dist/latest-linux.yml > latest-linux.yml.new + mv latest-linux.yml.new dist/latest-linux.yml + grep -qxF " sha512: $SHA512" dist/latest-linux.yml + echo "--- latest-linux.yml"; cat dist/latest-linux.yml + fi + + # Binaries first, latest-linux.yml LAST: the AppImage updater follows latest-linux.yml and + # must never be sent to a file that is not there yet. + - name: Attach the packages to the release + env: + UNIT: ${{ steps.plan.outputs.unit }} + SNAP: ${{ steps.plan.outputs.snap }} + run: | + set -euo pipefail + upload() { # mode files... + local mode="$1"; shift + if [ "$mode" = replace ]; then + gh release upload "$TAG" --repo "$GITHUB_REPOSITORY" --clobber "$@" + else + gh release upload "$TAG" --repo "$GITHUB_REPOSITORY" "$@" + fi + } + if [ "$UNIT" != keep ]; then + upload "$UNIT" dist/*.AppImage dist/*.deb dist/*.rpm + fi + if [ "$SNAP" != keep ]; then + upload "$SNAP" dist/*.snap + fi + if [ "$UNIT" != keep ]; then + upload "$UNIT" dist/latest-linux.yml fi - gh release upload "$TAG" --repo "$GITHUB_REPOSITORY" --clobber \ - dist-electron/*.AppImage dist-electron/*.deb dist-electron/*.rpm dist-electron/*.snap dist-electron/latest-linux.yml echo "Release assets now:" gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' + - name: Stop at a draft + if: steps.release.outputs.draft == 'true' + run: | + echo "::error::$TAG is a draft. Its files are attached, but apt, dnf, the Snap Store and the Flatpak check did not run: their indexes would point at draft assets, which nobody can download. Publish the release, then re-run this job (or Run workflow from the tag $TAG)." + exit 1 + + # The indexes describe the files users actually download: the ones on the release, which a + # kept unit means are not this run's rebuild. + - name: Fetch the published packages + id: published + run: | + set -euo pipefail + rm -rf published && mkdir -p published + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir published \ + --pattern "$(basename dist/*.deb)" --pattern "$(basename dist/*.rpm)" + ls -la published + echo "deb_sha256=$(sha256sum published/*.deb | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" + - name: Publish the apt repository - if: steps.meta.outputs.release_tag != '' - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ steps.meta.outputs.release_tag }} + if: steps.release.outputs.stable == 'true' run: | set -euo pipefail + read -ra KEYS <<< "$SIGNING_KEYS" + SIGN=() + for fpr in "${KEYS[@]}"; do SIGN+=(--local-user "$fpr"); done rm -rf repo && mkdir -p repo/apt "repo/$TAG" - cp dist-electron/*.deb "repo/$TAG/" + cp published/*.deb "repo/$TAG/" # Filename: entries come out as ./vX.Y.Z/FATE-...deb; apt wants them relative to the # base URL, so drop the ./; the result is exactly the release asset URL. ( cd repo && dpkg-scanpackages --multiversion . /dev/null ) > repo/apt/Packages @@ -255,8 +568,12 @@ jobs: -o APT::FTPArchive::Release::Architectures="amd64" \ -o APT::FTPArchive::Release::Description="FATE - Formatted Article & Text Editor" \ release . ) > repo/apt/Release - gpg --batch --yes --armor --detach-sign -o repo/apt/Release.gpg repo/apt/Release - gpg --batch --yes --clearsign -o repo/apt/InRelease repo/apt/Release + # One signature per signing key: apt accepts the index when any of them is one it trusts. + gpg --batch --yes --armor --detach-sign "${SIGN[@]}" -o repo/apt/Release.gpg repo/apt/Release + gpg --batch --yes --clearsign "${SIGN[@]}" -o repo/apt/InRelease repo/apt/Release + # The first line is also the marker the .deb's postrm looks for: removing the package + # deletes this file when a user downloaded it (README), because the keyring it names + # goes with the package. Keep it. cat > repo/apt/fate.sources < rpm-K-published.txt + cat rpm-K-published.txt + grep -q 'signatures OK' rpm-K-published.txt # --baseurl stamps each package with the versioned release URL, so the # repodata can live under the rolling `repodata` tag while packages stay put. createrepo_c --simple-md-filenames --no-database --general-compress-type gz \ --baseurl "$REPO_BASE/$TAG/" repo/rpm - gpg --batch --yes --armor --detach-sign -o repo/rpm/repodata/repomd.xml.asc repo/rpm/repodata/repomd.xml + gpg --batch --yes --armor --detach-sign "${SIGN[@]}" -o repo/rpm/repodata/repomd.xml.asc repo/rpm/repodata/repomd.xml cat > repo/rpm/fate.repo <> "$GITHUB_OUTPUT" + + - name: Publish to the Snap Store (stable) + if: env.HAS_SNAP_CREDENTIALS == 'true' + uses: snapcore/action-publish@214b86e5ca036ead1668c79afb81e550e6c54d40 # v1 + env: + SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} + with: + snap: ${{ steps.file.outputs.snap }} + release: stable + + # ── Install tests: exactly what a user does ──────────────────────────────────────────────── + test-repos: + name: Install from the repositories + needs: [build, publish] + if: needs.publish.outputs.stable == 'true' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + ref: refs/tags/${{ needs.build.outputs.release_tag }} + persist-credentials: false - # ── Install tests: exactly what a user does ──────────────────────────────────────────── - name: Test `apt install fate` in a Debian container - if: steps.meta.outputs.release_tag != '' run: | sleep 15 - docker run --rm -e REPO_BASE -v "$PWD/build:/k:ro" debian:stable-slim bash -euxc ' + docker run --rm -e REPO_BASE -v "$PWD/build/linux:/k:ro" debian:stable-slim bash -euxc ' apt-get update -qq apt-get install -y -qq --no-install-recommends ca-certificates >/dev/null install -m644 /k/fate-archive-keyring.gpg /usr/share/keyrings/fate-archive-keyring.gpg @@ -331,7 +710,6 @@ jobs: echo "apt install fate: OK"' - name: Test `dnf install fate` in a Fedora container - if: steps.meta.outputs.release_tag != '' run: | docker run --rm -e REPO_BASE fedora:latest bash -euxc ' curl -fsSL -o /etc/yum.repos.d/fate.repo "$REPO_BASE/repodata/fate.repo" @@ -342,57 +720,88 @@ jobs: test -f /etc/yum.repos.d/fate.repo echo "dnf install fate: OK"' - # ── Snap Store ───────────────────────────────────────────────────────────────────────── - - name: Publish to the Snap Store (stable) - if: steps.meta.outputs.release_tag != '' && env.HAS_SNAP_CREDENTIALS == 'true' - uses: snapcore/action-publish@v1 - env: - SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} - with: - snap: ${{ steps.snap.outputs.snap }} - release: stable - - - name: Snap Store publishing skipped - if: steps.meta.outputs.release_tag != '' && env.HAS_SNAP_CREDENTIALS != 'true' - run: | - echo "::notice::SNAPCRAFT_STORE_CREDENTIALS is not set; the snap was built and attached to the release but not uploaded to the Snap Store. See README → Building from source → Releases." - # ── Flatpak: prove the Flathub manifest builds against the released .deb ────────────────── flatpak: - needs: linux - if: needs.linux.outputs.release_tag != '' + name: Flathub manifest + needs: [build, publish] + if: needs.publish.outputs.deb_sha256 != '' runs-on: ubuntu-latest + timeout-minutes: 45 container: image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-25.08 options: --privileged steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + ref: refs/tags/${{ needs.build.outputs.release_tag }} + persist-credentials: false - name: Render the manifest for this release + env: + VERSION: ${{ needs.build.outputs.version }} + DEB_SHA256: ${{ needs.publish.outputs.deb_sha256 }} run: | set -euo pipefail mkdir -p flatpak/rendered - sed -e "s|@VERSION@|${{ needs.linux.outputs.version }}|g" \ - -e "s|@DEB_SHA256@|${{ needs.linux.outputs.deb_sha256 }}|g" \ + sed -e "s|@VERSION@|$VERSION|g" \ + -e "s|@DEB_SHA256@|$DEB_SHA256|g" \ flatpak/com.vaguedustin.fate.yml > flatpak/rendered/com.vaguedustin.fate.yml cp flatpak/flathub.json flatpak/rendered/flathub.json grep -n "url:\|sha256:" flatpak/rendered/com.vaguedustin.fate.yml - - uses: flatpak/flatpak-github-actions/flatpak-builder@v6 + - uses: flatpak/flatpak-github-actions/flatpak-builder@79327416609af08178ad73b352877e51450790b3 # v6 with: bundle: com.vaguedustin.fate.flatpak manifest-path: flatpak/rendered/com.vaguedustin.fate.yml cache: false upload-artifact: false + # Flathub's own linter. Its errors fail the job, except these, which every build outside + # Flathub reports or which Flathub settles at submission: + # finish-args-home-filesystem-access FATE is a file editor; --filesystem=home is the + # point (manifest comment). Flathub grants it as an + # exception when the app is submitted. + # appstream-external-screenshot-url, only Flathub's own build mirrors screenshots to + # appstream-screenshots-not-mirrored-in-ostree dl.flathub.org. + # appid-url-not-reachable the linter fetches https://vaguedustin.com, and + # the site's bot protection answers it with a 403. + # Domain ownership is verified at submission. + # Anything else, including a linter that cannot run at all, is a real failure. - name: Lint the manifest and the built repo (Flathub's own checks) - continue-on-error: true run: | - flatpak install -y --noninteractive flathub org.flatpak.Builder || true - flatpak run --command=flatpak-builder-lint org.flatpak.Builder manifest flatpak/rendered/com.vaguedustin.fate.yml || echo "::warning::manifest lint reported issues (see above)" - flatpak run --command=flatpak-builder-lint org.flatpak.Builder repo repo || echo "::warning::repo lint reported issues (see above)" - - - uses: actions/upload-artifact@v4 + set -euo pipefail + flatpak install -y --noninteractive flathub org.flatpak.Builder + ACCEPTED=' finish-args-home-filesystem-access appstream-external-screenshot-url appstream-screenshots-not-mirrored-in-ostree appid-url-not-reachable ' + failed=0 + for kind in manifest repo; do + case "$kind" in + manifest) target=flatpak/rendered/com.vaguedustin.fate.yml ;; + repo) target=repo ;; + esac + status=0 + flatpak run --command=flatpak-builder-lint org.flatpak.Builder "$kind" "$target" > "lint-$kind.json" || status=$? + cat "lint-$kind.json" + if [ "$status" -eq 0 ]; then + continue + fi + # The JSON report lists error ids one per line inside "errors": [ ... ]. + errors="$(sed -n '/"errors": \[/,/\]/p' "lint-$kind.json" | grep -v '"errors"' | grep -o '"[^"]*"' | tr -d '"' || true)" + if [ -z "$errors" ]; then + echo "::error::flatpak-builder-lint $kind exited $status without a readable error list." + failed=1 + continue + fi + for e in $errors; do + case "$ACCEPTED" in + *" $e "*) echo "accepted: $e" ;; + *) echo "::error::flatpak-builder-lint $kind: $e (https://docs.flathub.org/linter)"; failed=1 ;; + esac + done + done + exit "$failed" + + # TODO(pin): replace @v4 with the full commit SHA: gh api repos/actions/upload-artifact/commits/v4 --jq .sha + - uses: actions/upload-artifact@v4 # v4 with: - name: flathub-submission-${{ needs.linux.outputs.version }} + name: flathub-submission-${{ needs.build.outputs.version }} path: flatpak/rendered/ diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 6f95b57..30b320f 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -1,10 +1,14 @@ # Windows release build. One tag push does this, alongside build-linux.yml: # -# build the NSIS installer (FATE-Setup-X.Y.Z.exe + latest.yml) and the Microsoft Store .appx -# attach the installer and latest.yml to the GitHub release for the tag; electron-updater on -# Windows reads latest.yml from the latest release, so this is what existing installs -# update from -# artifact all three on the run, the .appx for the Store submission (Partner Center) +# build the NSIS installer (FATE-Setup-X.Y.Z.exe, its .blockmap, latest.yml) and the Microsoft +# Store .appx. Read-only: this job runs `npm ci` and electron-builder. +# publish attaches the installer, the blockmap and latest.yml to the GitHub release; the only +# job with write access, and it runs nothing but download-artifact and gh. +# electron-updater on Windows reads latest.yml from the latest release, so this is +# what existing installs update from, and the blockmap lets them download only the +# blocks that changed (differential updates need it on both the old and the new +# release, so they start from the release after the first one that carries it). +# artifact everything on the run, the .appx for the Store submission (Partner Center) # # The maintainer's part is # gh release create vX.Y.Z --title ... --notes ... @@ -12,80 +16,128 @@ # ten minutes), update checks on Windows report that latest.yml is missing and succeed on the next # check; the AppImage has always had the same gap for latest-linux.yml. # -# A release that already carries FATE-Setup-X.Y.Z.exe and latest.yml (built locally and passed to -# `gh release create`, the old way) is left alone: replacing a live installer under a running -# update check would hand out a latest.yml whose hash matches neither file for a moment. -# -# The tag must be v + package.json's version, or the installer would be named for a different -# release than the one it is attached to; the job stops before uploading anything if they differ. +# Guards, as in build-linux.yml: +# * The tag must be v + package.json's version, or the installer would be named for a different +# release than the one it is attached to; the build stops before anything else if they differ. +# * A recovery run is started from the release's tag (Run workflow → Use workflow from → Tags) +# and builds that commit; a `release_tag` naming any other tag is refused. +# * A prerelease version (a "-" in the tag) is attached only to a release marked prerelease. +# Otherwise it would be the latest release, and its latest.yml would update every install. +# * The installer, its blockmap and latest.yml go up together, and latest.yml last, because it +# names the installer and its hash. A release that already carries FATE-Setup-X.Y.Z.exe and +# latest.yml (a re-run, or built locally and passed to `gh release create`, the old way) is left +# alone: replacing a live installer under a running update check would hand out a latest.yml +# whose hash matches neither file for a moment. A release holding only some of them stops the +# job; Run workflow with force replaces all three. # # "Run workflow" (workflow_dispatch) on a branch builds everything and attaches it to the run. -# Give it a `release_tag` to ALSO attach the installer to that existing release: the recovery path -# when a tag's own run failed. +# Started from a release's tag instead, it ALSO attaches the installer to that release: the recovery +# path when a tag's own run failed. name: Build Windows on: push: tags: ['v*'] - # A PR that changes this workflow or the Windows build configuration builds too (artifacts only), - # so a broken installer or Store package shows up on the PR rather than at release time. + # A PR that touches anything that goes into the installer or the Store package builds too + # (artifacts only), so a broken installer shows up on the PR rather than at release time. pull_request: paths: - '.github/workflows/build-windows.yml' - 'package.json' + - 'package-lock.json' - 'build/installer.nsh' - - 'scripts/escape-appx-manifest.cjs' + - 'electron/**' + - 'src/fileKinds.js' + - 'scripts/appx-manifest.cjs' + - 'scripts/generate-*' + - 'brand/**' workflow_dispatch: inputs: release_tag: - description: 'Existing release tag to attach the installer to (empty = run artifacts only)' + description: 'Optional check: the release tag this run was started from (Use workflow from → Tags). Started from a branch, the run only builds.' required: false default: '' type: string + force: + description: 'Replace the installer, blockmap and latest.yml already on that release' + required: false + default: false + type: boolean permissions: - contents: write + contents: read concurrency: group: build-windows-${{ github.ref }} - cancel-in-progress: false + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: - windows: + build: + name: Build the installer and the Store package runs-on: windows-latest timeout-minutes: 45 defaults: run: shell: bash + outputs: + version: ${{ steps.meta.outputs.version }} + release_tag: ${{ steps.target.outputs.release_tag }} + artifact: ${{ steps.meta.outputs.artifact }} steps: - - uses: actions/checkout@v4 + # Inputs reach scripts only through env, never pasted into the script text. + - name: Resolve the release tag + id: target + env: + RELEASE_TAG_INPUT: ${{ inputs.release_tag }} + run: | + set -euo pipefail + TAG="" + case "$GITHUB_REF" in refs/tags/*) TAG="$GITHUB_REF_NAME" ;; esac + # A recovery run builds the commit it was STARTED from, so it is started from the tag + # (Run workflow → Use workflow from → Tags). Checking out a ref named in an input instead + # would run that ref's code in a job whose caches land in the default branch's scope. + if [ -n "$RELEASE_TAG_INPUT" ] && [ "$RELEASE_TAG_INPUT" != "$TAG" ]; then + echo "::error::To rebuild $RELEASE_TAG_INPUT, start Run workflow from that tag: Use workflow from → Tags → $RELEASE_TAG_INPUT. This run started from $GITHUB_REF_NAME." + exit 1 + fi + if [ -n "$TAG" ] && ! [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "::error::The release tag must look like v1.2.3 or v1.2.3-beta.1." + exit 1 + fi + echo "release_tag=$TAG" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false - - uses: actions/setup-node@v4 + # TODO(pin): replace @v4 with the full commit SHA: gh api repos/actions/setup-node/commits/v4 --jq .sha + # No npm cache in release builds: a Run workflow recovery checks out a tag the dispatcher + # chose, and a cache saved by that run would land in the default branch's scope for later + # runs to restore (cache poisoning). Releases are rare; a cold npm ci costs a minute. + - uses: actions/setup-node@v4 # v4 with: node-version: 22 - cache: npm - name: Release metadata id: meta env: - RELEASE_TAG_INPUT: ${{ inputs.release_tag }} + TAG: ${{ steps.target.outputs.release_tag }} run: | set -euo pipefail VERSION="$(node -p "require('./package.json').version")" - TAG="" - case "$GITHUB_REF" in refs/tags/*) TAG="$GITHUB_REF_NAME" ;; esac - if [ -n "$RELEASE_TAG_INPUT" ]; then TAG="$RELEASE_TAG_INPUT"; fi if [ -n "$TAG" ] && [ "$TAG" != "v$VERSION" ]; then echo "::error::Tag $TAG does not match package.json's version $VERSION. Bump the version before tagging." exit 1 fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "release_tag=$TAG" >> "$GITHUB_OUTPUT" + # Not github.ref_name: on a PR that is "N/merge", and artifact names cannot contain "/". + echo "artifact=FATE-windows-$VERSION-run$GITHUB_RUN_NUMBER" >> "$GITHUB_OUTPUT" echo "version $VERSION: publishing to release: ${TAG:-no (artifacts only)}" - run: npm ci - run: npm run icons - run: npm run lint + - run: npm test # Targets come from package.json: nsis and appx. Neither is code-signed (the Store signs the # .appx on submission), the same as the installers built locally before this workflow. @@ -97,46 +149,101 @@ jobs: VERSION: ${{ steps.meta.outputs.version }} run: | set -euo pipefail - ls -la dist-electron/*.exe dist-electron/*.appx dist-electron/latest.yml + ls -la dist-electron/*.exe dist-electron/*.blockmap dist-electron/*.appx dist-electron/latest.yml test -f "dist-electron/FATE-Setup-$VERSION.exe" + test -f "dist-electron/FATE-Setup-$VERSION.exe.blockmap" tr -d '\r' < dist-electron/latest.yml > latest.check cat latest.check grep -qx "version: $VERSION" latest.check grep -qx "path: FATE-Setup-$VERSION.exe" latest.check - - uses: actions/upload-artifact@v4 + # The Store package's file types (scripts/appx-manifest.cjs): Markdown, text and the code + # types the installer registers, and never .bat or .cmd. + APPX=(dist-electron/*.appx) + python -c "import sys, zipfile; sys.stdout.write(zipfile.ZipFile(sys.argv[1]).read('AppxManifest.xml').decode('utf-8'))" \ + "${APPX[0]}" > appx-manifest.check + echo "--- Store file types: $(grep -c '' appx-manifest.check)" + grep -q '\.md' appx-manifest.check + grep -q '\.markdown' appx-manifest.check + grep -q '\.py' appx-manifest.check + if grep -Eq '\.(bat|cmd)' appx-manifest.check; then + echo "::error::The Store package declares .bat or .cmd." + exit 1 + fi + + # TODO(pin): replace @v4 with the full commit SHA: gh api repos/actions/upload-artifact/commits/v4 --jq .sha + - uses: actions/upload-artifact@v4 # v4 with: - # Not github.ref_name: on a PR that is "N/merge", and artifact names cannot contain "/". - name: FATE-windows-${{ steps.meta.outputs.version }}-run${{ github.run_number }} + name: ${{ steps.meta.outputs.artifact }} if-no-files-found: error path: | dist-electron/FATE-Setup-*.exe + dist-electron/FATE-Setup-*.exe.blockmap dist-electron/latest.yml dist-electron/*.appx - # ── Release ──────────────────────────────────────────────────────────────────────────── + # ── Release: the only job with write access. No npm, no third-party actions. ─────────────── + publish: + name: Attach to the release + needs: build + if: needs.build.outputs.release_tag != '' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write + # One fixed group: a tag run and a recovery run never attach at the same time. + concurrency: + group: build-windows-publish + cancel-in-progress: false + env: + TAG: ${{ needs.build.outputs.release_tag }} + VERSION: ${{ needs.build.outputs.version }} + FORCE: ${{ inputs.force }} + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: ${{ needs.build.outputs.artifact }} + path: dist + - name: Attach the installer to the GitHub release - if: steps.meta.outputs.release_tag != '' - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ steps.meta.outputs.release_tag }} - EXE: FATE-Setup-${{ steps.meta.outputs.version }}.exe run: | set -euo pipefail + EXE="FATE-Setup-$VERSION.exe" + BLOCKMAP="$EXE.blockmap" + ls -la dist # No draft is created here: build-linux.yml creates one when a bare tag is pushed, and two # jobs racing to create it would leave two drafts for the same tag. - if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - echo "::error::No release for $TAG. Create it with gh release create, then re-run this job or Run workflow with release_tag=$TAG." + if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json tagName >/dev/null 2>&1; then + echo "::error::No release for $TAG. Create it with gh release create, then re-run this job or Run workflow from the tag $TAG." exit 1 fi + PRERELEASE="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isPrerelease --jq .isPrerelease)" + case "$TAG" in + *-*) + if [ "$PRERELEASE" != true ]; then + echo "::error::$TAG is a prerelease version, but its GitHub release is not marked as a prerelease, so its latest.yml would update every install. Mark it (gh release edit $TAG --prerelease), then re-run this job." + exit 1 + fi + ;; + esac + gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' > assets.txt - if grep -qx "$EXE" assets.txt && grep -qx latest.yml assets.txt; then - echo "::notice::$TAG already has $EXE and latest.yml; leaving them in place." + have() { grep -qxF "$1" assets.txt; } + if [ "$FORCE" = true ]; then + CLOBBER=(--clobber) + elif have "$EXE" && have latest.yml; then + echo "::notice::$TAG already has $EXE and latest.yml; leaving them in place (Run workflow with force to replace them)." exit 0 + elif have "$EXE" || have latest.yml || have "$BLOCKMAP"; then + echo "::error::$TAG has only some of $EXE, $BLOCKMAP and latest.yml. latest.yml names the installer by hash, so they must come from one build. Run workflow from the tag $TAG and force to replace them all." + exit 1 + else + CLOBBER=() fi - # Installer first, then latest.yml: electron-updater follows latest.yml, so it must never - # name an installer that is not there yet. - gh release upload "$TAG" --repo "$GITHUB_REPOSITORY" --clobber "dist-electron/$EXE" - gh release upload "$TAG" --repo "$GITHUB_REPOSITORY" --clobber dist-electron/latest.yml + # Installer and blockmap first, then latest.yml: electron-updater follows latest.yml, so it + # must never name an installer that is not there yet. + gh release upload "$TAG" --repo "$GITHUB_REPOSITORY" "${CLOBBER[@]}" "dist/$EXE" "dist/$BLOCKMAP" + gh release upload "$TAG" --repo "$GITHUB_REPOSITORY" "${CLOBBER[@]}" dist/latest.yml echo "Release assets now:" gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..cffc520 --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,95 @@ +# Checks for every pull request and every push to main: lint, the unit tests, the production +# renderer build, and an unpacked Linux package that has to start. Up to 1.13 nothing ran on a PR +# except the Windows build (and only for a few paths), so a broken main process or a lockfile that +# no longer installs surfaced at release time. +# +# Read-only and secret-free, so it is safe on pull requests from forks. +name: Checks + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: checks-${{ github.ref }} + cancel-in-progress: true + +jobs: + checks: + name: Lint, test, build + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + + # TODO(pin): replace @v4 with the full commit SHA: gh api repos/actions/setup-node/commits/v4 --jq .sha + - uses: actions/setup-node@v4 # v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + - run: npm run lint + - run: npm test + - run: npm run build + + # The unpacked app only (no installers): enough to prove that what electron-builder packs + # starts, and that the renderer's packages stay out of app.asar. + - run: npm run icons + - name: Package for Linux (unpacked) + run: npx electron-builder --linux dir --publish never + + - name: Check app.asar + run: | + set -euo pipefail + ASAR=dist-electron/linux-unpacked/resources/app.asar + ASAR_BYTES="$(stat -c %s "$ASAR")" + echo "app.asar: $((ASAR_BYTES / 1048576)) MB" + # @electron/asar's CLI, which electron-builder installs; never fetched from the registry. + node_modules/.bin/asar list "$ASAR" > asar-files.txt + sed -n 's|^/node_modules/\(@[^/]*/[^/]*\)$|\1|p; s|^/node_modules/\([^@/][^/]*\)$|\1|p' asar-files.txt | sort > asar-packages.txt + echo "--- packages in app.asar:"; tr '\n' ' ' < asar-packages.txt; echo + # What electron/*.cjs requires at runtime must be there... + for p in electron-store electron-updater; do + grep -qx "$p" asar-packages.txt || { echo "::error::$p is missing from app.asar; the main process requires it."; exit 1; } + done + # ...and what Vite already bundled must not ship a second time (1.13 shipped 211 MB). + for p in mermaid react react-dom katex highlight.js @codemirror/view; do + if grep -qx "$p" asar-packages.txt; then + echo "::error::$p is in app.asar. Renderer packages belong in devDependencies; Vite bundles them into dist/." + exit 1 + fi + done + if [ "$ASAR_BYTES" -gt $((50 * 1048576)) ]; then + echo "::error::app.asar is $((ASAR_BYTES / 1048576)) MB." + exit 1 + fi + + - name: Start the packaged app + run: | + set -uo pipefail + sudo apt-get update -qq + sudo apt-get install -y -qq --no-install-recommends xvfb > /dev/null + export FATE_USER_DATA="$RUNNER_TEMP/fate-userdata" + # --no-sandbox: Ubuntu 24.04 runners forbid the unprivileged user namespaces Chromium's + # sandbox needs. A main-process crash shows Electron's error dialog and keeps running, + # so the log is what tells: a healthy start is still running, quietly, when timeout ends + # it (124, or 137 if it needed the KILL). `|| status=$?`, not a bare `status=$?` on the + # next line: Actions runs steps with `bash -e`, which would end the step at that 124. + status=0 + timeout -k 5 20 xvfb-run -a dist-electron/linux-unpacked/FATE --no-sandbox "$PWD/README.md" > app.log 2>&1 || status=$? + cat app.log + if [ "$status" -ne 124 ] && [ "$status" -ne 137 ]; then + echo "::error::The packaged app exited by itself (status $status) instead of running." + exit 1 + fi + if grep -Eq 'App threw an error during load|Cannot find module|A JavaScript error occurred' app.log; then + echo "::error::The packaged app failed to start (see the log above)." + exit 1 + fi diff --git a/.gitignore b/.gitignore index d21bd99..981f0ee 100644 --- a/.gitignore +++ b/.gitignore @@ -38,12 +38,11 @@ build/* # The AppStream metadata for the Linux packages (.deb/.rpm), hand-authored source as well. !build/com.vaguedustin.fate.metainfo.xml -# Post-install scripts for the .deb/.rpm (they register the apt/dnf repositories). Source too. +# The .deb/.rpm maintainer scripts (they register and remove the apt/dnf repositories) and the +# package-signing PUBLIC keyring, fate-archive-keyring.{asc,gpg}, which the packages ship and CI +# checks the signing key against. Source too. !build/linux/ -# The package-signing PUBLIC keyring is derived in CI from the FATE_GPG_PRIVATE_KEY secret and -# lands in build/ as fate-archive-keyring.{asc,gpg}; it stays ignored (build/*) on purpose. - # Snap: snap/snapcraft.yaml is source; snap/gui/ is generated by scripts/write-snap-desktop.mjs, # snapbuild/ is the staging directory the workflow builds in, *.snap is output. snap/gui/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b6be20..b91faf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,42 @@ What changed in each FATE release, newest first. Downloads and the same notes are on the [Releases page](https://github.com/VagueDustin/FATE/releases). +## v1.14.0 +- **[New]** **Copy buttons on code blocks, and clean copies from the reading view.** Every code block in a Markdown document has a Copy button that copies its exact text, without the trailing newline that made a pasted command run straight away, and with Windows line breaks on Windows. Copying a selection no longer brings the theme with it (Word, Outlook and Teams pasted navy boxes or white text): code copies as plain text, everything else as plain text plus clean HTML that the other app formats its own way, and maths copies as its TeX source. Ctrl+A in the reading view selects the document instead of the whole window. +- **[New]** **Right-click menus.** Cut, Copy, Paste and Select All in the editor and in fields, Copy on a selection in the reading view, Copy Link Address on links, and spelling suggestions when spell check is on. Tabs have their own: Copy Full Path, Open Containing Folder, Close, Close Others and Close Tabs to the Right. +- **[New]** **An application menu.** Press Alt for File (with Open Recent), Edit, View and Help, labelled with your own shortcut bindings. On Windows, files you open also appear in FATE's Jump List. +- **[New]** **Files keep their line endings, encoding and indentation.** FATE detects each file's line endings (CRLF or LF), encoding (UTF-8, UTF-8 with BOM, UTF-16 LE/BE, Windows-1252) and indentation (tabs or spaces, and how wide), writes it back exactly that way, and shows all three in the status bar, where a click changes them: convert the line endings, save with another encoding, reopen a file whose encoding was guessed wrong, switch between tabs and spaces. UTF-16 files, such as PowerShell 5.1's output, now open instead of being refused as binary, and Makefiles and Go files indent with tabs. +- **[New]** **Hot exit.** Unsaved changes, Untitled tabs included, are backed up a moment after you type and come back if FATE crashes or loses power. +- **[New]** **When a file changes on disk while you're editing it,** a bar says so and offers Reload, Keep mine or Compare (side by side). A file deleted or moved away gets its own bar, and saving puts it back. +- **[New]** **Links in Markdown work.** A link to another file opens it in a tab (with or without a `#section`), `#anchors` scroll, and web and email links open in your browser after a confirmation. Headings get GitHub-style anchors, so links written for GitHub land in FATE too. +- **[New]** **Command palette modes:** `:` goes to a line, `@` lists the symbols in the current file (functions, classes, headings), and `#` searches the text of every open tab. +- **[New]** **Find in the reading view** (Ctrl+F), with a match count and next and previous. +- **[New]** **Edit mode scrolls the preview with you,** and renders Mermaid diagrams too. +- **[New]** **Large files and logs.** Files over 5 MB open with the heavier editor features off, and a Follow toggle in the status bar keeps a growing log scrolled to its end. Plain `.txt` files open as text in the editor (so do Markdown files over 2 MB), with Render as Markdown when you want the preview. +- **[New]** **Spell check while editing Markdown** (Settings → Markdown). On by default on Windows, where it uses Windows' own spell checker; off by default on Linux (see Privacy below). +- **[New]** **PowerShell, batch and Dockerfile code blocks are highlighted** in the reading view. +- **[Bugfix]** **Unsaved Markdown edits could be lost.** After editing a Markdown document and switching back to the reading view without saving, a change to the file on disk, or opening it again, replaced your edits; and going back into Edit mode treated the unsaved text as saved, so closing the tab or quitting no longer asked. Both are fixed. +- **[Bugfix]** **Clicking a relative link in a document replaced the whole app with an error page,** taking every tab and unsaved edit with it, and Ctrl+R or Ctrl+Shift+R did the same (Electron's hidden default menu reloaded the window). Neither can happen now, and anything that tries to reload a window with unsaved work asks first. +- **[Bugfix]** **Saving no longer converts Windows line endings to LF** (git showed every line changed, and LF-only batch files can break), and files that weren't UTF-8 are no longer damaged on save. **Saves are atomic:** FATE writes a temporary file and swaps it in, so a full disk or a dropped network share can't leave half a file behind; links stay links and permissions are kept. +- **[Bugfix]** **A large text file no longer blanks the window.** A 10 MB log crashed the Markdown renderer and took every tab with it. Rendering now falls back to plain text, and a recovery screen catches anything else, with your unsaved work backed up. +- **[Bugfix]** **Fewer ways to lose work:** text typed while a save was in progress was marked saved without reaching the disk; dropping a file onto the editor pasted its contents in as well as opening it; a reload jumped the caret to line 1; Save As over a file open in another tab dropped that tab's unsaved changes. Closing a tab with unsaved changes now offers Save. And the same file no longer opens twice when FATE starts with a file from the last session, or grows another duplicate in the session on every launch. +- **[Security]** **Documents can't reach out of the preview any more.** Style tags, inline styles outside maths, forms, dialogs and the app's own class names are stripped, so a document can't restyle or cover the app; a Content Security Policy now applies to the whole window; Mermaid's output is sanitised too. An image written as `//server/share/x.png` is treated as a web image and never fetched over Windows file sharing, which handed your Windows sign-in hash to that server just for opening the document, and FATE's local image protocol now serves only local image files. +- **[Privacy]** **Images from the internet stay off until you allow them,** for one document (Load images) or always (Settings → Markdown). Before, opening a document fetched them silently, which tells their servers when and from where it was read. FATE also no longer fetches remote images while rendering in the background, and Mermaid diagrams never load them. +- **[Privacy]** **No dictionary download on Linux.** Electron's built-in spell checker downloaded a dictionary from Google's servers the first time FATE ran on Linux, although FATE never used it. Nothing is downloaded now unless you turn spell check on. +- **[Security]** The main process only saves to files FATE has open and only accepts settings it knows, so a compromised document can't use them to write elsewhere, and web links opened in a new window ask first, like every other link. +- **[Bugfix]** **Maths:** square roots, vector arrows, wide hats, braces and tall brackets render again (their SVG was being stripped), "$5 and $10" stays text, `\ ` stays a space, and the LaTeX repair pass leaves code alone (it rewrote tab-indented code and `My\ Documents`). A UTF-8 byte-order mark no longer hides the first heading. +- **[Bugfix]** **Mermaid diagrams render reliably.** They were wiped whenever the window regained focus or the app updated its status, never rendered in the right-hand split pane, and a broken diagram left error graphics that could print. Local images with spaces or accented letters in their names load. +- **[Bugfix]** **Diffs are syntax highlighted** (they never were), and a diff closes with its tabs. +- **[Bugfix]** **Escape only closes a tab when you're not typing.** In the editor, a field or a menu it no longer closes the document. +- **[Bugfix]** **Shortcuts:** the recorder no longer stays armed after Settings closes, where a stray key could become a shortcut; single letters can't be bound; shortcuts that use the + key work. +- **[Bugfix]** **Split view:** shortcuts act on the pane you're working in, and the contents sidebar resizes correctly there. +- **[Bugfix]** Live reload recovers when a file is deleted and recreated, and another editor's save causes one reload instead of three. Log files that start with `[` are no longer mistaken for JSON, and comments in files like `tsconfig.json` aren't underlined as errors. Printing a Markdown document in Edit mode prints the rendered page. A second "Open with FATE" no longer briefly starts a second copy, and it opens every selected file and resolves relative paths properly. Several unreadable files from the last session produce one message, not one each. +- **[Enhancement]** **Faster:** the reading view no longer rebuilds every open document whenever the window regains focus or a tab switches; recent files are checked without freezing on unreachable network drives; the home-screen starfield pauses while FATE isn't focused. +- **[Enhancement]** **Accessibility:** Settings and the command palette are proper dialogs (focus moves in, stays in, and returns when they close), every setting has a name for screen readers, and the contents sidebar and the tab strip work from the keyboard. +- **[Enhancement]** **Much smaller downloads.** The packages carried a second copy of every library the app had already bundled. The app's own archive inside every installer, AppImage, package and snap shrinks from 211 MB to 13 MB. +- **[Bugfix]** **Installing and uninstalling.** Removing the `.deb` no longer leaves a repository entry that makes every later `apt update` fail; upgrading the `.rpm` no longer deletes the `FATE` command (and the upgrade to 1.14.0 puts it back); uninstalling on Windows also removes FATE's per-user file associations, so `.md` files don't point at a missing `FATE.exe` afterwards. The Microsoft Store package now offers *Open with FATE* for `.markdown`, `.txt` and the code types, not just `.md`. Windows updates download only what changed, from 1.14.1 on. +- **[Security]** **Release pipeline.** The key that signs the apt and dnf repositories is now used only in a job that runs no npm and no third-party code, and only if it matches the published fingerprint; third-party GitHub Actions are pinned to exact commits; a release must match the app's version, and drafts and prereleases never reach the stable channels. Pull requests are linted, tested, built and started before they merge. + ## v1.13.4 - **[Bugfix]** **Linux: the taskbar recognises FATE's window.** Every desktop entry said `StartupWMClass=FATE - Formatted Article & Text Editor`, but FATE's window reports itself as `fate-markdown-viewer`, so desktops could not match the running window to its launcher: a generic icon in the taskbar, and pinning and grouping misbehaved. The `.deb`, `.rpm`, AppImage and snap entries now name the window's real app id. - **[Bugfix]** **Linux: FATE no longer becomes the default for SVG images.** FATE declared `image/svg+xml`, so on a system with no explicit SVG default, double-clicking an image could open it in the editor. SVG source still opens through *Open With → FATE*. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c82bce8..95e09c7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,8 +28,8 @@ agree to that, please open an issue describing the change instead of a PR. - **Linux packaging.** AppImage, `.deb`, `.rpm`, Snap and Flatpak testing on distributions and desktops the maintainer doesn't use every day. - **Accessibility.** Keyboard access, focus handling, screen reader labels, contrast in every theme. -- **Tests.** FATE has no automated test suite yet. Well-scoped tests for the pure modules in `src/` - (for example `languageDetect.js`, `fileKinds.js`, `markdown.js`) would be a welcome start. +- **Tests.** `npm test` runs the `node --test` suites in `test/`. More well-scoped tests for the pure + modules in `src/` (for example `languageDetect.js`, `fileKinds.js`, `markdown.js`) are welcome. - **Documentation.** Clearer README sections, better screenshots, corrections. ## Before you write code @@ -56,12 +56,13 @@ npm run electron:dev # Vite and Electron together, with hot reload | `npm run electron:dev` | Vite dev server and Electron together; the normal way to run FATE while you work | | `npm run dev` | renderer only, in a browser (no Electron APIs; the app degrades gracefully) | | `npm run lint` | ESLint; **must pass** | +| `npm test` | the unit tests in `test/` (`node --test`); **must pass** | | `npm run build` | production renderer build; **must pass** | | `npm run icons` | regenerate every icon from the masters in `brand/` (needed before packaging) | | `npm run electron:build` | Windows installer and Store package in `dist-electron/` | -| `npm run electron:build:linux` | Linux AppImage, `.deb` and `.rpm` in `dist-electron/`; run `npm run icons` first, have `rpmbuild` installed, and put the public signing keyring in `build/` (README → Building from source) | +| `npm run electron:build:linux` | Linux AppImage, `.deb` and `.rpm` in `dist-electron/`; run `npm run icons` first and have `rpmbuild` installed | | `node scripts/write-snap-desktop.mjs` | regenerate `snap/gui/` (desktop entry and icon) for the snap build from package.json | -| `bash scripts/setup-signing-key.sh` | **maintainer, once:** create the package-signing key and store it as the `FATE_GPG_PRIVATE_KEY` secret | +| `bash scripts/setup-signing-key.sh` | **maintainer, once:** create the package-signing key and store it as the `FATE_GPG_PRIVATE_KEY` secret (a later key change is a rotation: SECURITY.md → Package signing key) | | `powershell -File scripts/setup-snap-store-token.ps1` | **maintainer, once:** create the Snap Store upload token for `fate` and store it as the `SNAPCRAFT_STORE_CREDENTIALS` secret | To run a development copy beside an installed FATE without sharing settings, set the @@ -70,9 +71,10 @@ single-instance lock. ## Testing your change -There is no automated test suite yet, so testing means: +Testing means: -1. `npm run lint` and `npm run build` both pass. +1. `npm run lint`, `npm test` and `npm run build` pass. The **Checks** workflow runs them on every PR, + and also packages FATE for Linux (unpacked) and makes sure it starts. 2. Run the app with `npm run electron:dev` and exercise what you changed, including the edge cases (unsaved changes, several tabs open, a large file, a file changed on disk). 3. If it's visual, check it in more than one theme and at the minimum window size (680×520). @@ -106,18 +108,26 @@ Say in the PR what you tried. "Built it and clicked through it on Windows 11" is - `electron/`: the main process (`main.cjs`: windows, file I/O, file watching, printing, Windows integration, updates), the preload bridge and the snap confinement helpers. - `build/`: generated output and gitignored, with a few tracked exceptions (below). +- `test/`: the unit tests (`npm test`), including the packaging checks described below. - `snap/`, `flatpak/` and `.github/workflows/build-linux.yml`: Linux distribution channels. - `.github/workflows/build-windows.yml`: the Windows installer and Microsoft Store package. +- `.github/workflows/checks.yml`: lint, tests, the renderer build and an unpacked Linux package, on + every pull request. **Note:** `build/` is gitignored except for hand-authored build source: `build/installer.nsh` (the NSIS installer script), `build/com.vaguedustin.fate.metainfo.xml` (the AppStream metadata the Linux -packages install for software centres) and `build/linux/` (post-install scripts). That is why -`npm run icons` followed by `npm run electron:build` works from a fresh clone. +packages install for software centres) and `build/linux/` (the `.deb`/`.rpm` install and remove +scripts, and the public signing keyring the packages ship). That is why `npm run icons` followed by +`npm run electron:build` works from a fresh clone. The install and remove scripts run as root on +users' machines: `test/linux-package-scripts.test.mjs` runs them against a scratch directory, so +extend it with any change. **The code-extension list lives in three places that must agree:** `electron/main.cjs`, -`src/fileKinds.js`, and the generated blocks in `build/installer.nsh`. The list is a curated dialog -filter and the set of types FATE registers for on Windows. It does **not** decide what opens. FATE -opens any text file; the only gates are the size cap and the binary sniff in `openAndWatchFile`. +`src/fileKinds.js`, and the generated blocks in `build/installer.nsh` +(`test/windows-registration.test.mjs` fails when they don't). The Microsoft Store package takes its +file types from `installer.nsh` at build time (`scripts/appx-manifest.cjs`). The list is a curated +dialog filter and the set of types FATE registers for on Windows. It does **not** decide what opens. +FATE opens any text file; the only gates are the size cap and the binary sniff in `openAndWatchFile`. Don't reintroduce an extension check on the command line or drag and drop (1.12.0 and earlier had one, and "Edit in FATE" on a `.config` silently did nothing). @@ -132,10 +142,12 @@ them). The Linux channels are all driven by `.github/workflows/build-linux.yml` from a release tag: apt and dnf repositories (served from GitHub Releases under the rolling prerelease tags `apt` and `repodata`), the Snap Store (`snap/snapcraft.yaml`) and Flathub (`flatpak/`). The workflow's header -comment explains the moving parts. The one rule to know is that the two rolling releases must stay -marked *prerelease*, or electron-updater on Windows and the AppImage will treat them as the latest -version. The same tag fires `.github/workflows/build-windows.yml`, which attaches the Windows -installer and `latest.yml` to the release. +comment explains the moving parts. Two rules to know: the two rolling releases must stay marked +*prerelease*, or electron-updater on Windows and the AppImage will treat them as the latest version; +and the package-signing key stays in the publish job, which runs no npm and no third-party action, so +nothing that installs or runs dependencies goes in that job. The same tag fires +`.github/workflows/build-windows.yml`, which attaches the Windows installer, its blockmap and +`latest.yml` to the release. Actions are pinned by commit SHA, with the version in a comment. --- diff --git a/PRIVACY.md b/PRIVACY.md index 5ea9507..3cd8ebb 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,6 +1,6 @@ # Privacy Policy for FATE - Formatted Article & Text Editor -**Effective Date:** September 15, 2026 (supersedes the August 5, 2026 policy) +**Effective Date:** October 4, 2026 (supersedes the September 15, 2026 policy) FATE is a local, offline desktop application for reading and editing documents. Your files and your data belong to you. This policy describes, in plain language, everything FATE does that could @@ -14,6 +14,12 @@ conceivably touch data. There is very little. editing happens entirely on your machine. Typefaces are bundled with the app; nothing is fetched from a CDN or webfont service. * **No accounts.** FATE has no sign-in, no registration, and never asks for personal information. +* **Images from the internet stay off.** A Markdown document can point at images on other websites. + FATE does not load them unless you choose to, for one document or always (Settings), because + loading them tells those servers your IP address and that the document was opened. +* **Spell check** is optional. On Windows it uses Windows' own spellchecker, entirely on your + machine. On Linux it is off by default: turning it on makes FATE download a dictionary for your + language from Google's servers, once, the way Chromium-based apps do. ## 2. File Access @@ -21,7 +27,7 @@ FATE requests local file system access ("Full Trust") solely to open, display, e files **you explicitly choose**: through the open dialog, drag & drop, the recent-files list, a file association, or the "Edit in FATE" context-menu entry. While a file is open, FATE watches it for external changes so the view can refresh. FATE does not scan, index, upload, or share your files. -They never leave your device. +They never leave your device. Links in a document open in your web browser only after you confirm. ## 3. Data Stored Locally @@ -30,6 +36,10 @@ fonts, keyboard shortcuts, and similar), the paths of recently opened files, and restore is enabled) the paths of the tabs you had open. This file stays on your device, is never transmitted anywhere, and is removed if you delete the app's data folder. +While a document has unsaved changes, FATE keeps a copy of them in a backups folder inside the same +data folder, so a crash or power cut doesn't lose your work. A copy is deleted as soon as you save, +or close the document without saving. + On Windows, FATE also writes standard registry entries so that it appears in "Open with" menus and on its page in Windows Settings → Default apps. These entries are local system configuration (they contain no personal data), only change further when you turn on a setting that asks for it diff --git a/README.md b/README.md index 812e1dc..bfb0ada 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,10 @@ The only network request FATE makes is the update check on installs that update - **Opens any text file.** Nothing is gated on the extension. The only limits are a 25 MB size cap and a check that refuses binary files, and both explain themselves. +- **Files stay the way they were.** Line endings (CRLF or LF), encoding (UTF-8 with or without a byte-order + mark, UTF-16 and Windows-1252) and indentation (tabs or spaces, and how wide) are detected and written + back unchanged. The status bar shows all three, and a click converts the line endings, saves with + another encoding, reopens a file whose encoding was guessed wrong, or switches tabs and spaces. - **Syntax highlighting for around 150 languages**, each loaded the first time you open a file of that type. Files with unfamiliar extensions are detected from their content (XML and HTML prologues, JSON, INI sections, registry exports, shebang lines), and anything ambiguous stays plain text. @@ -42,21 +46,31 @@ The only network request FATE makes is the update check on installs that update configs to set up. You can turn this off in Settings → Code Editor, which also has word wrap and indent size. - **Careful saving.** `Ctrl`+`S` and Save As, an unsaved marker in the title and on each tab, and a - Save / Don't save / Cancel prompt for every unsaved tab when you quit. + Save / Don't save / Cancel prompt for every unsaved tab when you close it or quit. Saves are atomic: a + temporary file is written and swapped in, so a full disk or a dropped network share can't leave half a + file behind. +- **Hot exit.** Unsaved work, Untitled tabs included, is backed up moments after you type and comes back + if FATE crashes or the power goes. - **Live reload that respects your edits.** Files changed on disk reload in place while your buffer is - clean, including editors that save by writing a temp file and renaming it. Unsaved edits are never - overwritten. + clean, keeping your place, including editors that save by writing a temp file and renaming it. Unsaved + edits are never overwritten: a bar offers Reload, Keep mine or Compare, and a deleted file gets its own. +- **Large files and logs.** Files over 5 MB open with the heavier editor features off, and **Follow** keeps + a growing log scrolled to its end. ### Tabs, split view and navigation - **Tabs, Notepad++ style.** Mix Markdown and code freely. Each tab keeps its scroll position, cursor, - selection and undo history in the background. `Ctrl`+`Tab` cycles, `Ctrl`+`1` to `9` jumps, and - middle-click closes. + selection and undo history in the background. `Ctrl`+`Tab` cycles, `Ctrl`+`1` to `9` jumps, middle-click + closes, and right-click copies the file's path, opens its folder or closes other tabs. - **Session restore** reopens last session's tabs on launch (optional). - **Split view** (`Ctrl`+`\`) puts any two open tabs side by side, and a **Diff** toggle compares them chunk by chunk with syntax highlighting. With no split open, the same button diffs your unsaved changes against the saved file. - **Command palette** (`Ctrl`+`K`): one fuzzy search across open tabs, recent files, commands and themes. + Start with `:` to go to a line, `@` to jump to a function, class or heading, or `#` to search the text of + every open tab. +- **An application menu** (`Alt`) with Open Recent, labelled with your own shortcuts. On Windows, opened + files also appear in FATE's Jump List. - **Recent files** on the home screen, **focus mode** (`Ctrl`+`Shift`+`F`) and **drag and drop** of one or many files anywhere in the window. - **Every shortcut is rebindable** in Settings → Shortcuts, with conflict detection. @@ -64,17 +78,27 @@ The only network request FATE makes is the update check on installs that update ### Markdown preview and editing - **Reading view** renders Markdown as a typeset document, with a table of contents sidebar, reading - progress and reading time. -- **Edit mode** (`Ctrl`+`E`) opens the source beside a live preview. Switch back and the reading view - shows your edits straight away. + progress, reading time and **find** (`Ctrl`+`F`). +- **Edit mode** (`Ctrl`+`E`) opens the source beside a live preview that scrolls with the editor. Switch + back and the reading view shows your edits straight away. Spell check is available here (Settings → + Markdown). +- **Links work.** A link to another file opens it in a tab, `#anchors` scroll (headings get GitHub-style + anchors), and web and email links open in your browser after a confirmation. +- **Copy buttons on code blocks**, and clean copies: code copies as plain text with its exact spacing, + ready to paste into a terminal; prose copies without the theme's colours; maths copies as its TeX + source. - **KaTeX math**, inline and block, including fractions, multi-line matrices and equations inside headings (they render in the table of contents too). - **LaTeX repair.** A repair pass fixes badly escaped backslashes (`\theta`, `\begin`, `\approx`) that - some Markdown generators and export tools produce, before they reach the screen. -- **Mermaid diagrams** from `mermaid` code fences, rendered offline and matched to the current theme. + some Markdown generators and export tools produce, inside maths only, before they reach the screen. +- **Mermaid diagrams** from `mermaid` code fences, rendered offline and matched to the current theme, in + the reading view, split view and Edit mode's preview. - **Highlighted code fences** that use the same colours as the code editor, so a PowerShell fence and an open `.ps1` file look the same. -- **Local images** referenced by relative path load correctly. +- **Local images** referenced by relative path load correctly, names with spaces or accents included. + **Images from the internet** load only when you allow them, for one document or always, because loading + them tells their servers you opened the document. +- **Safe to open.** A document can't run scripts, restyle or cover the app, or navigate it away. ### Printing and PDF export @@ -137,7 +161,9 @@ latest release. It updates itself. **Linux: Snap Store.** `sudo snap install fate` on Ubuntu and any distribution with snapd ([snapcraft.io/fate](https://snapcraft.io/fate)). snapd keeps it updated. To edit files on USB sticks and other removable drives, allow it once with `sudo snap connect fate:removable-media`. FATE reminds you if -you forget. +you forget. The snap is confined to your home folder's ordinary files: hidden files and folders there +(`~/.bashrc`, `~/.gitconfig`, anything in `~/.config`) and system files such as `/etc/hosts` can't be +opened from it. To edit those, use the `.deb`, `.rpm` or AppImage. **Ubuntu / Debian / Mint / Pop!_OS.** Install the `.deb` from the latest release, or add the repository once. Either way, `apt upgrade` keeps FATE current: @@ -146,13 +172,15 @@ sudo curl -fsSL -o /usr/share/keyrings/fate-archive-keyring.gpg https://github.c sudo curl -fsSL -o /etc/apt/sources.list.d/fate.sources https://github.com/VagueDustin/FATE/releases/download/apt/fate.sources sudo apt update && sudo apt install fate ``` -(The `.deb` registers the same repository during installation, so there is nothing to add afterwards.) +(The `.deb` registers the same repository during installation, so there is nothing to add afterwards. +`sudo apt remove fate` takes the repository out again, because its key goes with the package.) **Fedora / RHEL / openSUSE.** The same choice, `.rpm` or repository: ```bash sudo curl -fsSL -o /etc/yum.repos.d/fate.repo https://github.com/VagueDustin/FATE/releases/download/repodata/fate.repo sudo dnf install fate ``` +(Here too the `.rpm` registers the repository itself, and `sudo dnf remove fate` takes that entry out.) **Arch / Manjaro / EndeavourOS / CachyOS.** An AUR package, `fate-editor-bin`, is on its way. Until it is published, build the same package from a clone of this repository: @@ -170,7 +198,8 @@ it. It updates itself. AppImages need FUSE 2, which some distributions no longer **Flathub.** Not listed yet. The Flatpak manifest in `flatpak/` is ready and is built and linted on every release; the Flathub submission itself has to be made by the maintainer. -Packages and repository indexes are signed. The key is `fate-archive-keyring.gpg` on the `apt` release. +Packages and repository indexes are signed. The key is `fate-archive-keyring.gpg` on the `apt` release, +fingerprint `5E78 FD80 2EB3 DDCC AB6F 1026 8B2F 82F6 B2F7 1B42` (see [SECURITY.md](SECURITY.md#package-signing-key)). ## Keyboard shortcuts @@ -180,16 +209,18 @@ Defaults: | Action | Shortcut | | --- | --- | | **Command palette** | `Ctrl` + `K` | +| **Go to line / symbol / search all tabs** | `Ctrl` + `K`, then `:` / `@` / `#` | +| **Menu bar** | `Alt` | | **New file** | `Ctrl` + `T` | | **Open file** | `Ctrl` + `O` | | **Save / Save As** | `Ctrl` + `S` / `Ctrl` + `Shift` + `S` | -| **Find and replace (editor)** | `Ctrl` + `F` | +| **Find (and replace, in the editor)** | `Ctrl` + `F` | | **Edit / view Markdown** | `Ctrl` + `E` | | **Split view** | `Ctrl` + `\` | | **Focus mode** | `Ctrl` + `Shift` + `F` | | **Next / previous tab** | `Ctrl` + `Tab` / `Ctrl` + `Shift` + `Tab` | | **Jump to tab** | `Ctrl` + `1` to `9` (9 = last) | -| **Close tab** | `Ctrl` + `W` or `Escape` | +| **Close tab** | `Ctrl` + `W`, or `Escape` in the reading view | | **Go home / Settings** | `Alt` + `Home` / `Ctrl` + `,` | | **Zoom in / out / reset** | `Ctrl` + `+` / `-` / `0` | | **Print preview / Export PDF** | `Ctrl` + `P` / `Ctrl` + `Shift` + `E` | @@ -211,9 +242,10 @@ You need [Node.js](https://nodejs.org/) (CI uses Node 22) and git. ```bash npm run electron:dev ``` -4. **Check your work:** +4. **Check your work** (the **Checks** workflow runs the same on every pull request): ```bash npm run lint + npm test npm run build ``` 5. **Build the Windows installer and Store package:** @@ -230,10 +262,9 @@ You need [Node.js](https://nodejs.org/) (CI uses Node 22) and git. ``` Run `npm run icons` first, because the icon set lives in the gitignored `build/` directory and is generated from the tracked masters in `brand/`. The `.rpm` needs `rpmbuild` (`sudo apt install rpm` - on Ubuntu; Fedora has it already). A local Linux build also needs the public signing keyring in - `build/`, because the `.deb` and `.rpm` ship it: - `curl -fsSL -o build/fate-archive-keyring.gpg https://github.com/VagueDustin/FATE/releases/download/apt/fate-archive-keyring.gpg`, - and the same for `fate-archive-keyring.asc` (CI derives both from the signing secret). + on Ubuntu; Fedora has it already). The public signing keyring the `.deb` and `.rpm` ship is + committed (`build/linux/fate-archive-keyring.gpg` and `.asc`), so a fresh clone needs nothing else. + A local `.rpm` is unsigned; CI signs the released one. Build Linux packages on Linux, or let the **Build Linux** GitHub Actions workflow (`.github/workflows/build-linux.yml`) do it. Cross-building from Windows stops at @@ -257,21 +288,42 @@ or Gear Lever if you want it in the menu and the *Open With* list. Releases are cut by the maintainer: bump the version, then `gh release create vX.Y.Z --title ... --notes ...`. That creates the tag, and two workflows build and publish everything from it. **Build Windows** (`.github/workflows/build-windows.yml`) builds the NSIS -installer and the Microsoft Store `.appx`, attaches `FATE-Setup-X.Y.Z.exe` and `latest.yml` to the -release, and keeps the `.appx` on the run for the Store submission. **Build Linux** builds and attaches -the AppImage, `.deb`, signed `.rpm` and `.snap`; republishes the apt and dnf repositories; uploads the -snap to the Snap Store; builds and lints the Flathub manifest; and installs `fate` from the live -repositories in Debian and Fedora containers as a smoke test. *Run workflow* on a branch builds -everything and attaches it to the run only. The AppImage updates itself through `latest-linux.yml`, the -same way the Windows installer uses `latest.yml`; the `.deb` and `.rpm` update through the package -manager. For the ten minutes or so before the workflows attach those two files, update checks report -them missing and succeed on the next check. A release created with a locally built installer -(`gh release create vX.Y.Z FATE-Setup-X.Y.Z.exe latest.yml ...`) still works: Build Windows leaves -those files in place. +installer and the Microsoft Store `.appx`, attaches `FATE-Setup-X.Y.Z.exe`, its `.blockmap` (so updates +download only the blocks that changed) and `latest.yml` to the release, and keeps the `.appx` on the run +for the Store submission. **Build Linux** builds and attaches the AppImage, `.deb`, signed `.rpm` and +`.snap`; republishes the apt and dnf repositories; uploads the snap to the Snap Store; builds and lints +the Flathub manifest; and installs `fate` from the live repositories in Debian and Fedora containers as +a smoke test. The AppImage updates itself through `latest-linux.yml`, the same way the Windows installer +uses `latest.yml`; the `.deb` and `.rpm` update through the package manager. Both `latest` files go up +after the files they name. For the ten minutes or so before the workflows attach them, update checks +report them missing and succeed on the next check. + +The workflows refuse to publish what they shouldn't: + +- The tag must be `v` + `package.json`'s version; a tag without a version bump stops before anything is + built. +- apt, dnf and the Snap Store take only a published (not draft), non-prerelease release that is the + repository's latest. A tag like `v1.15.0-beta.1` must be a GitHub prerelease, and gets its files + attached but never reaches those channels. +- Files already on a release stay as they are, because a rebuild has new hashes that would break the + AUR's and Flathub's checksums. A release created with a locally built installer + (`gh release create vX.Y.Z FATE-Setup-X.Y.Z.exe latest.yml ...`) still works: Build Windows leaves + those files in place. +- The signing key is only ever loaded in Build Linux's publish job, which runs no npm and no + third-party action, and only after it checks the key's fingerprint against the committed one. + +When a tag's run fails for a reason outside the code (a missing secret, a network error), use *Run +workflow* and pick the tag under *Use workflow from → Tags*: it builds that tag's commit and publishes +it. A fix to the code itself needs a new version. Add `force` only to replace files that are already on +the release. *Run workflow* from a branch builds everything and attaches it to the run only (its `.rpm` +is unsigned). Pushing a bare tag without a release makes Build Linux create a draft and attach the Linux +files; publish the draft, then re-run the failed publish job (or *Run workflow* from the tag) to update +the repositories. One-time setup, all in the repository: `scripts/setup-signing-key.sh` creates the `FATE_GPG_PRIVATE_KEY` secret, and `scripts/setup-snap-store-token.ps1` creates the -`SNAPCRAFT_STORE_CREDENTIALS` secret through Canonical's snapcraft container. +`SNAPCRAFT_STORE_CREDENTIALS` secret through Canonical's snapcraft container. Replacing the signing key +is a rotation, not a re-run of that script: see [SECURITY.md](SECURITY.md#package-signing-key). ## Contributing diff --git a/SECURITY.md b/SECURITY.md index 95a36af..bdfeace 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -45,3 +45,58 @@ opening a file the user explicitly chose (FATE displays files; it does not sandb FATE makes no network requests beyond the GitHub update check in the Windows `.exe` and the AppImage. Microsoft Store builds and installs from the Snap Store, Flatpak, apt or dnf make none at all. See [PRIVACY.md](PRIVACY.md). + +## Package signing key + +The `.rpm` and the indexes of the apt and dnf repositories are signed with this key: + +``` +FATE Package Signing +RSA 4096, created 2026-09-14 +5E78 FD80 2EB3 DDCC AB6F 1026 8B2F 82F6 B2F7 1B42 +``` + +Its public half is committed as `build/linux/fate-archive-keyring.asc` (and `.gpg`); the `.deb` and +`.rpm` ship it and register the repositories with it. To check a downloaded copy: +`gpg --show-keys fate-archive-keyring.asc`. + +The private key exists in two places: the `FATE_GPG_PRIVATE_KEY` secret of this repository and the +maintainer's offline backup. In CI it is loaded only by the publish job of +`.github/workflows/build-linux.yml`, which runs no npm and no third-party action, after the build +jobs have finished. That job refuses to sign unless the secret holds exactly the keys listed in its +`SIGNING_KEYS` and every one of them is in the committed keyring, so a swapped or mistaken secret +stops a release instead of signing it with a key no install trusts. + +### Rotating the key + +Every installed system trusts only the keys its package shipped. A release signed by a new key alone +would never reach it: `apt update` and `dnf upgrade` would reject the repository. So the new key +travels inside a release signed by the old one, and only later takes over: + +1. **Create the new key** without touching the live secret: + `BACKUP_DIR=~/FATE-package-signing-key-new bash scripts/setup-signing-key.sh --new-key-only` +2. **One commit:** both public keys in the keyring + (`gpg --armor --export OLD NEW > build/linux/fate-archive-keyring.asc` and + `gpg --export OLD NEW > build/linux/fate-archive-keyring.gpg`, from a keyring holding both), and + `SIGNING_KEYS: OLD NEW` in `build-linux.yml`, old key first. +3. **Both private keys in the secret:** + `cat old-PRIVATE.asc new-PRIVATE.asc | gh secret set FATE_GPG_PRIVATE_KEY --repo VagueDustin/FATE`. + (If the commit and the secret disagree when a release runs, the publish job stops before signing + anything.) +4. **Release as usual. This is the transition release:** its packages carry both keys, its `.rpm` is + signed by the old key (an rpm holds one signature), and the apt and dnf indexes by both. Installs + that still know only the old key keep updating; apt warns about the key it does not know yet + until the upgrade installs it. Keep releasing like this until the installs you care about have + taken one of these releases (weeks, not days). +5. **Switch:** `SIGNING_KEYS: NEW`, and the secret holds the new key alone. Keep the old public key in + the keyring unless it was compromised. + +Before the first transition release, check dnf in a Fedora container that trusts only the old key: +apt accepts an index when one of its signatures verifies and warns about the rest, but this pipeline +does not test how dnf treats an index signed twice. (If dnf refuses it, leave `SIGNING_KEYS` and the +secret on the old key alone for the transition releases: they still carry the new key, just without +its signature.) An install that skipped every transition release has to take the new keyring by +hand, most simply by installing the current `.deb` or `.rpm` from the Releases page once. + +If the old key leaked, skip the waiting: remove it from the keyring and the secret straight away, +release, and announce that users must take the new keyring by hand. diff --git a/build/com.vaguedustin.fate.metainfo.xml b/build/com.vaguedustin.fate.metainfo.xml index ffbc307..31a6a2c 100644 --- a/build/com.vaguedustin.fate.metainfo.xml +++ b/build/com.vaguedustin.fate.metainfo.xml @@ -92,13 +92,16 @@ + + https://github.com/VagueDustin/FATE/releases/tag/v1.14.0 + https://github.com/VagueDustin/FATE/releases/tag/v1.13.4 https://github.com/VagueDustin/FATE/releases/tag/v1.13.3 - + https://github.com/VagueDustin/FATE/releases/tag/v1.13.2 diff --git a/build/installer.nsh b/build/installer.nsh index a35f1a2..c092432 100644 --- a/build/installer.nsh +++ b/build/installer.nsh @@ -15,8 +15,15 @@ # app's own Settings → Windows page now, which deep-links into Windows Settings. The installer # only *registers* capabilities; it never claims a UserChoice, because Windows wouldn't honour that # anyway. +# +# BUILD_UNINSTALLER: electron-builder writes the uninstaller by compiling this script a second +# time and RUNNING the result on the build machine, and that pass's .onInit inserts preInit too. +# Unguarded, every build read the builder's own registry and seeded its InstallLocation, and on a +# machine with a pre-rename FATE installed it would have run that uninstaller silently. The guard +# empties preInit in that pass; the real installer is unchanged. !macro preInit +!ifndef BUILD_UNINSTALLER SetRegView 64 ; Read the remembered install location EXPLICITLY from HKLM (perMachine build), falling back to @@ -56,6 +63,7 @@ WriteRegExpandStr HKLM "${INSTALL_REGISTRY_KEY}" InstallLocation "$PROGRAMFILES64\VagueDustin Enterprises\FATE" WriteRegExpandStr HKCU "${INSTALL_REGISTRY_KEY}" InstallLocation "$PROGRAMFILES64\VagueDustin Enterprises\FATE" ${EndIf} +!endif !macroend # ── Code-file registration ────────────────────────────────────────────────────────────────────── @@ -86,9 +94,23 @@ WriteRegStr SHCTX "Software\FATE\Capabilities\FileAssociations" ".${EXT}" "FATE.${EXT}" !macroend +# Uninstall only (it uses ${isUpdated}, which exists in the uninstaller). The machine-wide entries +# go on every run, updates included, as they always have: the new version's customInstall writes +# them straight back. The per-user copies are the app's self-heal (ensureWindowsRegistration in +# electron/main.cjs, one ProgId and one Open-with value per type under HKCU) and go only on a real +# uninstall. Left behind, they still named FATE.exe after it was gone, so Windows kept offering +# FATE and double-clicking said it couldn't find the program. During an update they must stay: +# deleting them would break every per-type default until the new version's first launch rewrote +# them. FATE.CodeFile is the 1.10/1.11 shared ProgId the self-heal now deletes; older profiles can +# still have it listed. !macro UnregisterCodeType EXT DeleteRegValue SHCTX "Software\Classes\.${EXT}\OpenWithProgids" "FATE.${EXT}" DeleteRegKey SHCTX "Software\Classes\FATE.${EXT}" + ${IfNot} ${isUpdated} + DeleteRegValue HKCU "Software\Classes\.${EXT}\OpenWithProgids" "FATE.${EXT}" + DeleteRegValue HKCU "Software\Classes\.${EXT}\OpenWithProgids" "FATE.CodeFile" + DeleteRegKey HKCU "Software\Classes\FATE.${EXT}" + ${EndIf} !macroend # ── Retroactive repair: give .bat and .cmd back to the command processor ──────────────────────── @@ -278,13 +300,43 @@ DeleteRegKey HKCU "Software\FATE\Capabilities" DeleteRegKey /ifempty HKCU "Software\FATE" + # The rest of the per-user registration, on a real uninstall only (an update keeps it; see + # UnregisterCodeType, which also clears the per-type ProgIds below). + # + # HKCU means ONE user here: whoever this uninstaller runs as. Elevated by an administrator who + # is not the person who used FATE, it is the administrator's own hive. Other accounts' hives are + # not loaded and can't be cleaned from here; their entries stay until FATE is installed again or + # they remove them, and Windows offers to pick another app when one of them is used. + ${IfNot} ${isUpdated} + # "Markdown Document" is the .md ProgId. The self-heal points its HKCU open command at FATE.exe + # so .md keeps opening even when the machine-wide registration is gone. The name is generic + # and another editor could use it too, so it goes only while the command still points into + # this install: "\FATE.exe" "%1". (LogicLib's == ignores case, like Windows paths.) + ReadRegStr $R0 HKCU "Software\Classes\Markdown Document\shell\open\command" "" + StrCpy $R1 '"$INSTDIR\' + StrLen $R2 $R1 + StrCpy $R3 $R0 $R2 + ${If} $R0 != "" + ${AndIf} $R3 == $R1 + DeleteRegKey HKCU "Software\Classes\Markdown Document\shell\open\command" + DeleteRegKey /ifempty HKCU "Software\Classes\Markdown Document\shell\open" + DeleteRegKey /ifempty HKCU "Software\Classes\Markdown Document\shell" + DeleteRegKey /ifempty HKCU "Software\Classes\Markdown Document" + ${EndIf} + + # The per-user copy of the remembered install location, which preInit seeds beside the HKLM + # one. electron-builder deletes only the HKLM key. + DeleteRegKey HKCU "${INSTALL_REGISTRY_KEY}" + ${EndIf} + # If we were installed inside the publisher directory, remove it too, but only if empty # (other VagueDustin Enterprises software may live beside us). ${GetParent} "$INSTDIR" $R0 RMDir "$R0" - # Per-type ProgIds and their Open-with entries. Generated block; the extension list mirrors - # CODE_EXTENSIONS in electron/main.cjs and src/fileKinds.js. + # Per-type ProgIds and their Open-with entries, machine-wide and (on a real uninstall) per-user. + # Generated block; the extension list mirrors CODE_EXTENSIONS in electron/main.cjs and + # src/fileKinds.js (test/windows-registration.test.mjs checks them against each other). !insertmacro UnregisterCodeType "js" !insertmacro UnregisterCodeType "mjs" !insertmacro UnregisterCodeType "cjs" diff --git a/build/linux/after-install-deb.sh b/build/linux/after-install-deb.sh index 1179434..4939c9c 100644 --- a/build/linux/after-install-deb.sh +++ b/build/linux/after-install-deb.sh @@ -53,6 +53,8 @@ fi # .github/workflows/build-linux.yml). The signing key ships in this package as # /usr/share/keyrings/fate-archive-keyring.gpg. Written only if absent, so a user who deletes or # edits it is left alone; opt out entirely with `repo_add_once=false` in /etc/default/fate. +# The file's first line is the marker after-remove-deb.sh looks for before deleting it when the +# package is removed (the keyring it names goes with the package): keep that line as it is. FATE_SOURCES=/etc/apt/sources.list.d/fate.sources FATE_DEFAULTS=/etc/default/fate repo_add_once=true diff --git a/build/linux/after-install-rpm.sh b/build/linux/after-install-rpm.sh index b272b28..c4cd304 100644 --- a/build/linux/after-install-rpm.sh +++ b/build/linux/after-install-rpm.sh @@ -39,6 +39,8 @@ fi # .github/workflows/build-linux.yml); the packages themselves are the versioned release assets. # The signing key ships in this package as /etc/pki/rpm-gpg/RPM-GPG-KEY-fate. Written only if # absent; opt out entirely with `repo_add_once=false` in /etc/default/fate. +# The file's first line is the marker after-remove-rpm.sh looks for before deleting it when the +# package is erased (the key it names goes with the package): keep that line as it is. FATE_REPO=/etc/yum.repos.d/fate.repo FATE_DEFAULTS=/etc/default/fate repo_add_once=true diff --git a/build/linux/after-remove-deb.sh b/build/linux/after-remove-deb.sh new file mode 100644 index 0000000..fd734e1 --- /dev/null +++ b/build/linux/after-remove-deb.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# Post-remove (postrm) for the .deb. +# +# TEMPLATE, not a plain script: electron-builder substitutes the two macros executable and +# sanitizedProductName (written as a dollar sign + braces) at build time and FAILS THE BUILD on +# any other braced name, comments included. So: only those two use braces, shell variables never +# do ($VAR only). +# +# Setting deb.afterRemove REPLACES electron-builder's default after-remove.tpl, so the first half +# is that template's two jobs, unchanged and run for every postrm call exactly as before: drop the +# update-alternatives link, and unload and delete the AppArmor profile. (During an upgrade dpkg +# runs the old package's postrm before the new one's postinst, which puts both back.) The second +# half removes the apt source that after-install-deb.sh registered. + +# Delete the link to the binary +# update-alternatives --remove : 'path' must be the registered alternative binary, +# not the generic symlink +if type update-alternatives >/dev/null 2>&1; then + update-alternatives --remove '${executable}' '/opt/${sanitizedProductName}/${executable}' +else + rm -f '/usr/bin/${executable}' +fi + +APPARMOR_PROFILE_DEST='/etc/apparmor.d/${executable}' + +# Remove and unload apparmor profile. +if [ -f "$APPARMOR_PROFILE_DEST" ]; then + # Unload the profile from the running kernel before deleting the file so the policy is not left + # enforced until the next reboot. Live AppArmor operations mean nothing inside a chroot. + if apparmor_status --enabled > /dev/null 2>&1; then + if ! { [ -x '/usr/bin/ischroot' ] && /usr/bin/ischroot; } && hash apparmor_parser 2>/dev/null; then + apparmor_parser --remove "$APPARMOR_PROFILE_DEST" || true + fi + fi + rm -f "$APPARMOR_PROFILE_DEST" +fi + +# ── FATE apt repository ─────────────────────────────────────────────────────────────────────── +# The source names Signed-By: /usr/share/keyrings/fate-archive-keyring.gpg, a file this package +# owns and dpkg has just deleted. Left behind, it makes every later `apt update` fail ("The +# repository ... is not signed"), so it goes on remove and purge, never on upgrade. +# +# Only a file FATE wrote is removed. Its first line is the marker: after-install-deb.sh writes +# "# FATE - Formatted Article & Text Editor. Added by the fate package ...", and the fate.sources +# that the README has people download from the `apt` release starts +# "# FATE - Formatted Article & Text Editor: apt repository ...". Both name the keyring that just +# went away. A source the user wrote by hand has no such line and is left alone. +case "$1" in + remove|purge) + FATE_SOURCES=/etc/apt/sources.list.d/fate.sources + if [ -f "$FATE_SOURCES" ] && grep -q '^# FATE - Formatted Article & Text Editor[.:]' "$FATE_SOURCES"; then + rm -f "$FATE_SOURCES" + fi + ;; +esac + +# A failing postrm leaves the package half-removed; nothing above is worth that. +exit 0 diff --git a/build/linux/after-remove-rpm.sh b/build/linux/after-remove-rpm.sh new file mode 100644 index 0000000..ddaad50 --- /dev/null +++ b/build/linux/after-remove-rpm.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# Post-uninstall (postun) for the .rpm. +# +# TEMPLATE, not a plain script: electron-builder substitutes the two macros executable and +# sanitizedProductName (written as a dollar sign + braces) at build time and FAILS THE BUILD on +# any other braced name, comments included. So: only those two use braces, shell variables never +# do ($VAR only). fpm then pastes the result verbatim into the spec file, where rpmbuild expands +# macros, so this file must not contain a single percent sign either. +# +# Setting rpm.afterRemove REPLACES electron-builder's default after-remove.tpl, which ran +# unguarded. In an upgrade rpm runs the NEW package's post-install first and the OLD package's +# post-uninstall last, so `dnf upgrade` deleted the /usr/bin link the new version had just +# created, and FATE disappeared from PATH. $1 is the number of FATE packages left once this one is +# gone: 0 on erase, 1 or more on upgrade. An upgrade leaves everything alone. (Upgrades away from +# 1.13.x and older still run THEIR unguarded script; posttrans-rpm.sh repairs the link after it.) + +if [ "$1" -ge 1 ] 2>/dev/null; then + exit 0 +fi + +# Erase: electron-builder's default template, unchanged. Delete the link to the binary +# (update-alternatives --remove : 'path' is the registered alternative binary, not +# the generic symlink), then unload and delete the AppArmor profile. +if type update-alternatives >/dev/null 2>&1; then + update-alternatives --remove '${executable}' '/opt/${sanitizedProductName}/${executable}' +else + rm -f '/usr/bin/${executable}' +fi + +APPARMOR_PROFILE_DEST='/etc/apparmor.d/${executable}' + +if [ -f "$APPARMOR_PROFILE_DEST" ]; then + if apparmor_status --enabled > /dev/null 2>&1; then + if ! { [ -x '/usr/bin/ischroot' ] && /usr/bin/ischroot; } && hash apparmor_parser 2>/dev/null; then + apparmor_parser --remove "$APPARMOR_PROFILE_DEST" || true + fi + fi + rm -f "$APPARMOR_PROFILE_DEST" +fi + +# ── FATE dnf repository ─────────────────────────────────────────────────────────────────────── +# after-install-rpm.sh wrote /etc/yum.repos.d/fate.repo with gpgkey= pointing at +# /etc/pki/rpm-gpg/RPM-GPG-KEY-fate, a file this package owns and rpm has just deleted. Only that +# file goes, recognised by its first line. The fate.repo the README has people download from the +# `repodata` release fetches its key from GitHub, keeps working without the package, and is left +# alone, as is anything the user wrote. +FATE_REPO=/etc/yum.repos.d/fate.repo +if [ -f "$FATE_REPO" ] && grep -q '^# FATE - Formatted Article & Text Editor\. Added by the fate package' "$FATE_REPO"; then + rm -f "$FATE_REPO" +fi + +exit 0 diff --git a/build/linux/fate-archive-keyring.asc b/build/linux/fate-archive-keyring.asc new file mode 100644 index 0000000..b2ee2b9 --- /dev/null +++ b/build/linux/fate-archive-keyring.asc @@ -0,0 +1,29 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGqoQWQBEADDZQ8Z0NSY/dJp6izrKwYC/US16g4LolN2+sBXK8sv3BQc5t6u +bzwHWeKWLulMc6bBFICaOcoKDjTZ9tQog1mDC+Q54Ff0gBAMA6qYT/zrsWex73Mo +aEPdbOtEf3qETzTUexgYxjJJkY1pzAoJf7WTp6vZnrGZ2eR09pNl2dPVfmTa+V8I +EeTfNqZD8AVRZ7LSA5/a6EaSMQygaIYrFAKgUZTJW+6mFrAorAFCEWRVKRHuFL0q +OrZNOWfK3O6jk/EoQcXEyn9MfPSwBZhXucEl3N1R+BOAzXWjuC5xrWAdbhvZTwJg +b0uy05byWH3f1ii8RFXaP96S3TTmkqBvmVvpWPzjECR+3hA0MW2XcaHYfJhUtA1P +us88tY4AP4AWyUOTbtp5c+VsAqPrb/BVI857u8YS97kx/lgWBLBTqeQ/Zl9XK86v +bRgmpkTXhwcs2RUtKm845JI246qEkIp4b9H08nfqxMw7Yq0Cr6o7wXwpaMt5aSjI +7YQcggQo51oMu5rk7oYR/Bg0O3nohyOrph4/bQD0rAqSSYVlyUplL4hGkg0dmcCP +7XGHjy89RrPTYoyv8Gsxx3kPy0NC5qyWwmcdaj1qIv0W/vTqPaMX8RmfPpNdWDNx +98kPEE4R/Rpv7hGADU2GsNQce0Z07mBxJ3PlbnBUX9HpR4rb/UXD3799kwARAQAB +tDJGQVRFIFBhY2thZ2UgU2lnbmluZyA8ZW50ZXJwcmlzZXNAdmFndWVkdXN0aW4u +Y29tPokCTgQTAQoAOBYhBF54/YAus93Mq28QJosvgvay9xtCBQJqqEFkAhsDBQsJ +CAcCBhUKCQgLAgQWAgMBAh4BAheAAAoJEIsvgvay9xtCr+QQAIgetLfQuFrDpNaY +hFjmYM5k+EDRNI891h71bNWQ/1Ca3M43CjzXHrs2HmZ3rLDaHNSqFV3AlUiMI9Ly +aj/jwpajBTgoUk3czzGWq6Fa1VYXi2FHdV1PNjlvaap/9ayf3bD8wzepc91g891R +ov2XviV2moqRExnlAEh1H1n4R7tBMA2SCbqjTurG/gsb9j96g+zrsrjR/MfUM5pl +499hCUM0G3xsNuz21t1EgInNHKxKZzMpq66luWILn62i7evVV3JBFLFoDtYR4qqu +CDHtvamnZxfyEbqgJ+KkyQ5x98oMLYLC0vT/HM3RdHNsU2V01V2U4UtrLszibxqy +UXXDqyOvQagPv5reNQC+70PokM3zocPy4UmHlIzcEnaZBEG9L5EXv7Notf61en/f +yZkLo5goHZ3Yc8+1s/nwYPOwuFlcxWC64nlwucEycJWLb6M/foAw1bjAIbfQPMDz +IrGQyyp4b74tfz3VWdJQtTUvmNxXYNyJ3GML2OLsVZ+LFnYptFk6jcADOhdxrl1N +vC6UgztdpbvsbtHGNU/GJkwL8SsUsjZr+jYnL4XBL6FPR3cNp1VAIt1Kgvb+7sNH +qBLt250cplcsYAbwOi0ezFmsQJF/7btbZL8+eo1GJZ+DbNjkHGbK5tWEpPYOveyx +MVQfX8ScAzC7xMekag8wc5Tlcs0p +=TUDJ +-----END PGP PUBLIC KEY BLOCK----- diff --git a/build/linux/fate-archive-keyring.gpg b/build/linux/fate-archive-keyring.gpg new file mode 100644 index 0000000..a001dfb Binary files /dev/null and b/build/linux/fate-archive-keyring.gpg differ diff --git a/build/linux/posttrans-rpm.sh b/build/linux/posttrans-rpm.sh new file mode 100644 index 0000000..3a91eda --- /dev/null +++ b/build/linux/posttrans-rpm.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# posttrans for the .rpm: runs once, at the very end of an install or upgrade transaction, after +# every other scriptlet, including the OLD package's post-uninstall. +# +# FATE 1.13.x and older shipped electron-builder's default post-uninstall, which removes the +# /usr/bin/FATE alternative unconditionally, and rpm runs it AFTER the new package's post-install. +# So moving off one of those versions deletes the command the new version has just created. +# after-remove-rpm.sh stops that from this version on; this puts the link back after the old +# script has run, and does nothing when the link is already there. +# +# NOT an electron-builder template: fpm reads it as is (--rpm-posttrans in package.json), so the +# paths are spelled out. Like every rpm scriptlet it lands in the spec file, where rpmbuild expands +# macros: no percent signs. +FATE_EXE='/opt/FATE - Formatted Article & Text Editor/FATE' + +if [ -x "$FATE_EXE" ] && [ ! -e /usr/bin/FATE ]; then + if type update-alternatives >/dev/null 2>&1; then + update-alternatives --install /usr/bin/FATE FATE "$FATE_EXE" 100 || ln -sf "$FATE_EXE" /usr/bin/FATE + else + ln -sf "$FATE_EXE" /usr/bin/FATE + fi +fi + +exit 0 diff --git a/electron/appMenu.cjs b/electron/appMenu.cjs new file mode 100644 index 0000000..b801328 --- /dev/null +++ b/electron/appMenu.cjs @@ -0,0 +1,308 @@ +'use strict'; +/** + * appMenu.cjs: FATE's application menu (File, Edit, View, Help; Alt shows it), the tab strip's + * right-click menu, and the print preview's menu. Templates only: main.cjs builds them with + * Electron's Menu. Free of Electron imports; test/appMenu.test.cjs runs it under plain node. + * + * ── Why FATE has a menu at all ──────────────────────────────────────────────────────────────── + * Up to 1.13.4 it set none, so Electron installed its default one. autoHideMenuBar hid it, but its + * accelerators stayed live: Ctrl+R and Ctrl+Shift+R reloaded the window, and a reload is not a + * close, so the unsaved-changes guard never ran and every unsaved edit (Untitled buffers included) + * was gone for good. This menu has NO Reload or Force Reload anywhere, and Toggle Developer Tools + * only in unpackaged builds. + * + * ── Who handles the keys ────────────────────────────────────────────────────────────────────── + * The renderer owns every rebindable shortcut (App.jsx's keydown handler, CodeMirror's keymaps), + * so items it implements are DISPLAY-ONLY: `registerAccelerator: false`, a click sends + * 'menu-command' with the item's id, and the label shows the user's own binding, which the + * renderer reports through 'update-menu'. Registering them as well would run a command twice, or + * run the old binding after a rebind. + * + * Cut, Copy, Paste and Select All are display-only too. On Windows and Linux Chromium already maps + * Ctrl+X/C/V/A inside the page (and CodeMirror and FATE's reading-view Ctrl+A take theirs first), + * so a registered role accelerator could only ever fire as a second handler for a key the page + * passed on. Zoom, full screen and Exit keep their registered accelerators: nothing in the page + * handles those keys, and Electron only fires a menu accelerator for a key the page did not + * consume (measured on Electron 42), so a user binding on the same key still wins. + */ + +const RELEASES_URL = 'https://github.com/VagueDustin/FATE/releases'; +const ISSUES_URL = 'https://github.com/VagueDustin/FATE/issues/new/choose'; + +/** Ids the renderer implements; a click sends ('menu-command', id). The contract in preload.cjs. */ +const MENU_COMMANDS = Object.freeze([ + 'newFile', 'openFile', 'save', 'saveAs', 'print', 'exportPdf', 'closeTab', 'toggleEdit', + 'toggleSplit', 'focusMode', 'palette', 'settings', 'shortcuts', 'about', 'find', 'gotoLine', + 'gotoSymbol', 'undo', 'redo' +]); + +/** + * Keys that are fixed rather than rebindable (FIXED_SHORTCUTS in src/settingsMeta.js, plus + * CodeMirror's history keys). Shown unless 'update-menu' sends a binding for the same id, so the + * day one of them becomes rebindable the renderer's value takes over without a change here. + */ +const FIXED_BINDINGS = Object.freeze({ undo: 'Control+Z', redo: 'Control+Y', find: 'Control+F' }); + +/** Labels are display-only, so a binding is short; anything longer is not one FATE recorded. */ +const MAX_BINDING_LENGTH = 64; + +/* ════════════════════════════════════════════════════════════════════════════════════════════ + SHORTCUT LABELS + ════════════════════════════════════════════════════════════════════════════════════════════ */ + +const MODIFIERS = { Control: 'Ctrl', Shift: 'Shift', Alt: 'Alt', Meta: 'Super' }; + +/** `KeyboardEvent.key` names (what App.jsx records) → Electron accelerator key codes. */ +const NAMED_KEYS = { + Escape: 'Esc', Esc: 'Esc', Enter: 'Enter', Return: 'Enter', Tab: 'Tab', Space: 'Space', + Backspace: 'Backspace', Delete: 'Delete', Insert: 'Insert', Home: 'Home', End: 'End', + PageUp: 'PageUp', PageDown: 'PageDown', ArrowUp: 'Up', ArrowDown: 'Down', ArrowLeft: 'Left', + ArrowRight: 'Right', Up: 'Up', Down: 'Down', Left: 'Left', Right: 'Right', Plus: 'Plus', + PrintScreen: 'PrintScreen' +}; + +/** Printable keys Electron's accelerator parser knows (US layout), `+` spelled as Plus. */ +const PUNCTUATION_KEYS = new Set(Array.from(')!@#$%^&*(:;=<,_->.?/~`{][|\\}"\'')); + +/** + * FATE's binding format (`Control+Shift+S`: modifiers then `KeyboardEvent.key`, joined with `+`) + * → an Electron accelerator (`Ctrl+Shift+S`), or null when it cannot be shown. + * + * Null means "no label", never an error: a dead key, a non-ASCII key (`é`, `ß`) or anything + * malformed simply shows no shortcut, which is better than a wrong one. The renderer still + * handles the key itself either way. A literal `+` key arrives as `Control++` (App.jsx joins with + * the separator it records); it is read as the Plus key here. + */ +function bindingToAccelerator(binding) { + if (typeof binding !== 'string' || !binding || binding.length > MAX_BINDING_LENGTH) return null; + let mods; + let key; + if (binding === '+') { + mods = []; + key = '+'; + } else if (binding.endsWith('++')) { + const head = binding.slice(0, -2); + mods = head ? head.split('+') : []; + key = '+'; + } else { + mods = binding.split('+'); + key = mods.pop(); + } + + const out = []; + for (const mod of mods) { + const name = MODIFIERS[mod]; + if (!name || out.includes(name)) return null; + out.push(name); + } + + let code = null; + if (key === '+') code = 'Plus'; + else if (Object.prototype.hasOwnProperty.call(NAMED_KEYS, key)) code = NAMED_KEYS[key]; + else if (/^F([1-9]|1[0-9]|2[0-4])$/.test(key)) code = key; + else if (/^[a-zA-Z0-9]$/.test(key)) code = key.toUpperCase(); + else if (key.length === 1 && PUNCTUATION_KEYS.has(key)) code = key; + if (!code) return null; + + out.push(code); + return out.join('+'); +} + +/** + * The 'update-menu' payload's shortcuts, kept to what the menu can use: known ids, short strings. + * The renderer is the only sender, but its values end up in native menu labels. + */ +function sanitizeMenuShortcuts(shortcuts) { + const clean = {}; + if (!shortcuts || typeof shortcuts !== 'object' || Array.isArray(shortcuts)) return clean; + for (const id of MENU_COMMANDS) { + const value = shortcuts[id]; + if (typeof value === 'string' && value.length <= MAX_BINDING_LENGTH) clean[id] = value; + } + return clean; +} + +/* ════════════════════════════════════════════════════════════════════════════════════════════ + LABELS + ════════════════════════════════════════════════════════════════════════════════════════════ */ + +/** + * Menu labels on Windows and Linux treat `&` as the mnemonic marker: `R&D notes.md` would show as + * "RD notes.md" with an underlined D. Text that is not ours (file names, spelling suggestions) + * doubles it, which displays a literal `&`. + */ +function escapeMenuLabel(text) { + return String(text).replace(/&/g, '&&'); +} + +/** A recent file as the menu shows it: the full path (two `notes.md` stay apart), `~` for home. */ +function recentFileLabel(filePath, { homeDir = '', platform = process.platform, maxLength = 80 } = {}) { + let shown = String(filePath); + if (platform !== 'win32' && homeDir && (shown === homeDir || shown.startsWith(`${homeDir}/`))) { + shown = `~${shown.slice(homeDir.length)}`; + } + // Long paths keep their start (drive, home) and their end (the file name), not the middle. + if (shown.length > maxLength) { + const tail = Math.ceil((maxLength - 1) * 0.65); + shown = `${shown.slice(0, maxLength - 1 - tail)}…${shown.slice(-tail)}`; + } + return escapeMenuLabel(shown); +} + +/* ════════════════════════════════════════════════════════════════════════════════════════════ + TEMPLATES + ════════════════════════════════════════════════════════════════════════════════════════════ */ + +/** + * The application menu. + * + * `state`: { shortcuts (from 'update-menu'), recentFiles (paths, newest first), isPackaged, + * platform, homeDir }. `actions`: { command(id), openRecent(path), clearRecent(), + * checkForUpdates(), openUrl(url) }, which main.cjs wires to IPC, file opening and the updater. + */ +function buildAppMenuTemplate(state, actions) { + const { shortcuts = {}, recentFiles = [], isPackaged = true, platform = process.platform, homeDir = '' } = state || {}; + const separator = { type: 'separator' }; + + /** An item the renderer implements: display-only shortcut, click → 'menu-command'. */ + const command = (label, id) => { + const accelerator = bindingToAccelerator(shortcuts[id] ?? FIXED_BINDINGS[id]); + return { + id, + label, + ...(accelerator ? { accelerator } : {}), + registerAccelerator: false, + click: () => actions.command(id) + }; + }; + + const recentItems = recentFiles + .filter((p) => typeof p === 'string' && p) + .map((filePath, i) => ({ + id: `recent${i}`, + label: recentFileLabel(filePath, { homeDir, platform }), + click: () => actions.openRecent(filePath) + })); + const openRecent = recentItems.length + ? [...recentItems, separator, { id: 'clearRecent', label: 'Clear Recently Opened', click: () => actions.clearRecent() }] + : [ + { id: 'noRecent', label: 'No Recent Files', enabled: false }, + separator, + { id: 'clearRecent', label: 'Clear Recently Opened', enabled: false } + ]; + + return [ + { + label: '&File', + submenu: [ + command('&New', 'newFile'), + command('&Open…', 'openFile'), + { id: 'openRecent', label: 'Open &Recent', submenu: openRecent }, + separator, + command('&Save', 'save'), + command('Save &As…', 'saveAs'), + separator, + command('Print Pre&view…', 'print'), + command('&Export as PDF…', 'exportPdf'), + separator, + command('&Close Tab', 'closeTab'), + separator, + command('Se&ttings…', 'settings'), + separator, + // Goes through the window's close guard like the title-bar X, so unsaved work is asked + // about first. The role keeps Ctrl+Q on Linux and nothing on Windows, where Alt+F4 is it. + { id: 'exit', role: 'quit', label: 'E&xit' } + ] + }, + { + label: '&Edit', + submenu: [ + // CodeMirror keeps its own history, so these are commands, not the undo/redo roles + // (which would run Chromium's document-level undo underneath the editor). + command('&Undo', 'undo'), + command('&Redo', 'redo'), + separator, + { id: 'cut', role: 'cut', label: 'Cu&t', registerAccelerator: false }, + { id: 'copy', role: 'copy', label: '&Copy', registerAccelerator: false }, + { id: 'paste', role: 'paste', label: '&Paste', registerAccelerator: false }, + { id: 'selectAll', role: 'selectAll', label: 'Select &All', registerAccelerator: false }, + separator, + command('&Find…', 'find'), + command('Go to &Line…', 'gotoLine'), + command('Go to S&ymbol…', 'gotoSymbol') + ] + }, + { + label: '&View', + submenu: [ + command('Edit/View &Markdown', 'toggleEdit'), + command('&Split View', 'toggleSplit'), + command('&Focus Mode', 'focusMode'), + separator, + command('&Command Palette…', 'palette'), + separator, + { id: 'resetZoom', role: 'resetZoom', label: '&Actual Size' }, + { id: 'zoomIn', role: 'zoomIn', label: 'Zoom &In' }, + // The role's accelerator is Ctrl+Plus, which on most layouts needs Shift; Ctrl+= is the + // key people press. Hidden items' accelerators still fire on Windows and Linux. + { id: 'zoomInEquals', role: 'zoomIn', accelerator: 'CommandOrControl+=', visible: false }, + { id: 'zoomOut', role: 'zoomOut', label: 'Zoom &Out' }, + separator, + { id: 'fullScreen', role: 'togglefullscreen', label: 'Toggle F&ull Screen' }, + ...(isPackaged ? [] : [separator, { id: 'devTools', role: 'toggleDevTools', label: 'Toggle &Developer Tools' }]) + ] + }, + { + label: '&Help', + submenu: [ + command('&Keyboard Shortcuts', 'shortcuts'), + separator, + { id: 'releaseNotes', label: '&Release Notes', click: () => actions.openUrl(RELEASES_URL) }, + { id: 'reportIssue', label: 'Report an &Issue', click: () => actions.openUrl(ISSUES_URL) }, + separator, + { id: 'checkForUpdates', label: 'Check for &Updates', click: () => actions.checkForUpdates() }, + separator, + command('&About FATE', 'about') + ] + } + ]; +} + +/** + * A tab's right-click menu. `pick(action)` returns the click handler that reports that action; + * the renderer carries it out (copying the path, revealing the file, closing tabs), so main only + * ever says which item was chosen. Path items are disabled for a tab with no file (Untitled). + */ +function buildTabMenuTemplate({ hasPath }, pick) { + return [ + { id: 'copyPath', label: 'Copy Full Path', enabled: !!hasPath, click: pick('copyPath') }, + { id: 'reveal', label: 'Open Containing Folder', enabled: !!hasPath, click: pick('reveal') }, + { type: 'separator' }, + { id: 'close', label: 'Close', click: pick('close') }, + { id: 'closeOthers', label: 'Close Others', click: pick('closeOthers') }, + { id: 'closeRight', label: 'Close Tabs to the Right', click: pick('closeRight') } + ]; +} + +/** + * The print preview window's menu. Without one it would inherit the application menu, whose + * commands all act on the main window, and lose Ctrl+W, which closed the preview through + * Electron's default menu before FATE had its own. + */ +function buildPreviewMenuTemplate() { + return [{ label: '&File', submenu: [{ id: 'closePreview', role: 'close', label: '&Close Preview' }] }]; +} + +module.exports = { + MENU_COMMANDS, + FIXED_BINDINGS, + RELEASES_URL, + ISSUES_URL, + bindingToAccelerator, + sanitizeMenuShortcuts, + escapeMenuLabel, + recentFileLabel, + buildAppMenuTemplate, + buildTabMenuTemplate, + buildPreviewMenuTemplate +}; diff --git a/electron/backups.cjs b/electron/backups.cjs new file mode 100644 index 0000000..d12e88c --- /dev/null +++ b/electron/backups.cjs @@ -0,0 +1,198 @@ +'use strict'; +/** + * backups.cjs: hot exit. The renderer copies each unsaved buffer (Untitled ones included) here a + * moment after it changes, so a crash, a forced reload or a power cut loses nothing; on the next + * launch it lists what is left and offers it back. + * + * One file per tab: `/.json`, written atomically (textFiles.writeFileAtomic), so a crash + * DURING a backup leaves the previous backup rather than half a file. The folder is created + * owner-only (0700) and the files 0600: a backup of ~/.ssh/config should not become readable by + * other accounts just because FATE copied it. + * + * The renderer is trusted with its own documents but not with the file system, so: + * - `id` must match ID_PATTERN; it becomes a file name, and must never be able to say `../x`. + * - every field is type-checked, and sizes are capped (per string, per file, and in number of + * backups), so a runaway caller cannot fill the disk. + * - `list()` skips anything it cannot parse or that has the wrong shape instead of throwing: + * one corrupt file must never stop the rest from being restored. + * + * Main never deletes a backup on its own; only `remove(id)` (the tab was saved or closed) and + * `clear()` (the session ended cleanly) do. + * + * Calls for the same id run in order (a per-id queue), so a `remove` that arrives while that + * tab's `write` is still on its way to disk cannot be overtaken by it and resurrect the backup. + * + * Free of Electron imports; test/backups.test.cjs runs it under plain node. + */ +const fs = require('fs'); +const path = require('path'); +const { writeFileAtomic } = require('./textFiles.cjs'); +const { resolveFormat } = require('./fileFormat.cjs'); + +const fsp = fs.promises; + +const ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; +const FILE_PATTERN = /^([A-Za-z0-9_-]{1,64})\.json$/; +const KINDS = ['markdown', 'code']; + +/** Per text field (content, savedContent), in UTF-16 units. FATE opens files up to 25 MB. */ +const MAX_TEXT_CHARS = 64 * 1024 * 1024; +/** A whole backup file, serialised. Also the most `list()` will read. */ +const MAX_FILE_BYTES = 192 * 1024 * 1024; +/** Open tabs with unsaved work, at most. Far past any real session; a stop for a runaway loop. */ +const MAX_BACKUPS = 100; + +function codedError(code, message) { + const err = new Error(message); + err.code = code; + return err; +} + +function checkId(id) { + if (typeof id !== 'string' || !ID_PATTERN.test(id)) throw codedError('BAD_ID', 'Backup ids are 1-64 letters, digits, _ or -'); +} + +/** + * The renderer's data, checked and reduced to the known fields. Missing optional fields get + * their empty value; a field of the wrong type is an error. An unusable `format` becomes null + * rather than costing the user the backup. + */ +function cleanData(data) { + if (!data || typeof data !== 'object' || Array.isArray(data)) throw codedError('BAD_DATA', 'Backup data must be an object'); + const bad = (field) => codedError('BAD_DATA', `Backup field "${field}" is missing or the wrong type`); + const { kind, name, content } = data; + const filePath = data.path ?? null; + const savedContent = data.savedContent ?? null; + const untitled = data.untitled ?? false; + + if (!KINDS.includes(kind)) throw bad('kind'); + if (typeof name !== 'string' || name.length > 1024) throw bad('name'); + if (filePath !== null && (typeof filePath !== 'string' || filePath.length === 0 || filePath.length > 32767)) throw bad('path'); + if (typeof content !== 'string') throw bad('content'); + if (savedContent !== null && typeof savedContent !== 'string') throw bad('savedContent'); + if (typeof untitled !== 'boolean') throw bad('untitled'); + if (data.editMode !== undefined && typeof data.editMode !== 'boolean') throw bad('editMode'); + if (content.length > MAX_TEXT_CHARS || (savedContent && savedContent.length > MAX_TEXT_CHARS)) { + throw codedError('TOO_LARGE', 'This document is too large to back up'); + } + + let format = null; + if (data.format != null) { + try { + format = resolveFormat(data.format, {}); + } catch { + format = null; + } + } + const clean = { kind, name, path: filePath, content, format, savedContent, untitled }; + if (data.editMode !== undefined) clean.editMode = data.editMode; + return clean; +} + +/** + * @param {string} dir the backups folder (main passes userData/backups) + * @param {{ now?: () => number }} [opts] + */ +function createBackupStore(dir, { now = Date.now } = {}) { + const queues = new Map(); + + /** Run `job` after every earlier call for `id`; returns its promise. */ + function enqueue(id, job) { + const previous = queues.get(id) || Promise.resolve(); + const run = previous.then(job, job); + const settled = run.catch(() => {}); + queues.set(id, settled); + settled.then(() => { + if (queues.get(id) === settled) queues.delete(id); + }); + return run; + } + + const fileFor = (id) => path.join(dir, `${id}.json`); + + /** Store (or replace) the backup for `id`. Resolves { savedAt }. */ + async function write(id, data) { + checkId(id); + const clean = cleanData(data); + return enqueue(id, async () => { + await fsp.mkdir(dir, { recursive: true, mode: 0o700 }); + const names = await fsp.readdir(dir); + if (!names.includes(`${id}.json`) && names.filter((n) => FILE_PATTERN.test(n)).length >= MAX_BACKUPS) { + throw codedError('TOO_MANY', 'Too many unsaved documents to back up'); + } + const savedAt = now(); + const json = JSON.stringify({ fate: 'backup', version: 1, savedAt, ...clean }); + const bytes = Buffer.from(json, 'utf8'); + if (bytes.length > MAX_FILE_BYTES) throw codedError('TOO_LARGE', 'This document is too large to back up'); + await writeFileAtomic(fileFor(id), bytes, { mode: 0o600 }); + return { savedAt }; + }); + } + + /** Delete the backup for `id`; a backup that isn't there is not an error. */ + async function remove(id) { + checkId(id); + return enqueue(id, async () => { + try { + await fsp.unlink(fileFor(id)); + } catch (err) { + if (err.code !== 'ENOENT') throw err; + } + }); + } + + /** Every readable backup: [{ id, savedAt, ...data }], oldest first. */ + async function list() { + await Promise.all([...queues.values()]); + let names; + try { + names = await fsp.readdir(dir); + } catch (err) { + if (err.code === 'ENOENT') return []; + throw err; + } + const backups = []; + for (const name of names) { + const match = FILE_PATTERN.exec(name); + if (!match) continue; // temporary files from an interrupted write, or strangers + try { + const file = path.join(dir, name); + const stat = await fsp.stat(file); + if (!stat.isFile() || stat.size > MAX_FILE_BYTES) continue; + const parsed = JSON.parse(await fsp.readFile(file, 'utf8')); + if (!parsed || parsed.fate !== 'backup') continue; + const savedAt = Number.isFinite(parsed.savedAt) ? parsed.savedAt : stat.mtimeMs; + backups.push({ id: match[1], savedAt, ...cleanData(parsed) }); + } catch { + /* corrupt, half-written by a crash, or the wrong shape: skip it, restore the rest */ + } + } + return backups.sort((a, b) => a.savedAt - b.savedAt); + } + + /** Delete every backup (and any temporary file left by an interrupted write). */ + async function clear() { + await Promise.all([...queues.values()]); + let names; + try { + names = await fsp.readdir(dir); + } catch (err) { + if (err.code === 'ENOENT') return; + throw err; + } + await Promise.all( + names.map(async (name) => { + const file = path.join(dir, name); + try { + if ((await fsp.lstat(file)).isFile()) await fsp.unlink(file); + } catch { + /* gone already */ + } + }) + ); + } + + return { dir, write, remove, list, clear }; +} + +module.exports = { createBackupStore, ID_PATTERN, MAX_BACKUPS }; diff --git a/electron/fileFormat.cjs b/electron/fileFormat.cjs new file mode 100644 index 0000000..4ec4fd9 --- /dev/null +++ b/electron/fileFormat.cjs @@ -0,0 +1,414 @@ +'use strict'; +/** + * fileFormat.cjs: how a text file is stored on disk, and the conversions in and out of it. + * + * The renderer only ever holds clean text: decoded, no byte-order mark, `\n` line breaks. + * Everything else about the bytes on disk is the file's FORMAT, + * + * { encoding: 'utf8' | 'utf16le' | 'utf16be' | 'windows1252', bom: boolean, eol: '\n' | '\r\n' | '\r' } + * + * which `decode` reports on open and `encode` applies on save, so a file goes back to disk the + * way it came off it. Up to 1.13.4 FATE read every file as UTF-8 and wrote `\n`-joined UTF-8 + * back: one edit to a CRLF .bat or .ps1 rewrote every line ending (git showed the whole file + * changed), Windows-1252 text came back with é turned into U+FFFD for good, and UTF-16 (what + * PowerShell 5.1's `>` and Out-File write) was refused as binary because of its NUL bytes. + * + * The rule throughout is that a round trip never changes a byte FATE did not have to change: + * - Detection never picks a lossy decoding. Bytes that are not valid UTF-8 are read as + * Windows-1252, which maps every byte to its own character, rather than as UTF-8 with + * replacement characters that would be written back as U+FFFD. + * - UTF-16 is decoded code unit for code unit (Buffer's utf16le), so even unpaired surrogates, + * which Windows allows in file and registry text, survive. + * - Windows-1252 refuses to save text it cannot represent (UNENCODABLE) instead of writing '?'. + * + * Pure functions, free of Electron and fs on purpose: they run under plain node for the tests in + * test/fileFormat.test.cjs. + */ + +const crypto = require('crypto'); + +const ENCODINGS = ['utf8', 'utf16le', 'utf16be', 'windows1252']; +const EOLS = ['\n', '\r\n', '\r']; + +/** Names for messages and status bars. */ +const ENCODING_LABELS = { + utf8: 'UTF-8', + utf16le: 'UTF-16 LE', + utf16be: 'UTF-16 BE', + windows1252: 'Windows-1252' +}; + +/** Byte-order marks, longest first is irrelevant here: no BOM is a prefix of another. */ +const BOMS = [ + { encoding: 'utf8', bytes: Buffer.from([0xef, 0xbb, 0xbf]) }, + { encoding: 'utf16le', bytes: Buffer.from([0xff, 0xfe]) }, + { encoding: 'utf16be', bytes: Buffer.from([0xfe, 0xff]) } +]; + +/** + * How much of a file the binary and UTF-16 sniffs look at. 8 KB is the same window the binary + * sniff has always used: enough to see an executable's or image's header, cheap on a 25 MB file. + */ +const SNIFF_BYTES = 8192; + +/** + * The format for a file FATE has never read: a new file from Save As, or a path main has no + * record of. UTF-8 without a BOM everywhere; line breaks follow the platform, because a new file + * on Windows is expected to be CRLF (Notepad, PowerShell and cmd all assume it). + */ +function defaultFormat(platform = process.platform) { + return { encoding: 'utf8', bom: false, eol: platformEol(platform) }; +} + +function platformEol(platform = process.platform) { + return platform === 'win32' ? '\r\n' : '\n'; +} + +/** An Error carrying a machine-readable `code`, so callers can choose the message. */ +function codedError(code, message, extra) { + const err = new Error(message); + err.code = code; + return Object.assign(err, extra); +} + +function toBuffer(input) { + if (Buffer.isBuffer(input)) return input; + if (input instanceof Uint8Array) return Buffer.from(input.buffer, input.byteOffset, input.byteLength); + throw codedError('BAD_ARGS', 'Expected the file contents as bytes'); +} + +/** The BOM at the start of `buf`, as { encoding, length }, or null. */ +function sniffBom(buf) { + for (const { encoding, bytes } of BOMS) { + if (buf.length >= bytes.length && buf.subarray(0, bytes.length).equals(bytes)) { + return { encoding, length: bytes.length }; + } + } + return null; +} + +/** C0 controls a text file has no business containing: everything below 0x20 except tab, LF, + * VT, FF, CR and ESC (terminal colour codes in logs). */ +function isStrayControl(byte) { + return byte < 0x20 && byte !== 0x09 && byte !== 0x0a && byte !== 0x0b && byte !== 0x0c && byte !== 0x0d && byte !== 0x1b; +} + +/** + * UTF-16 without a BOM, recognised by where its NUL bytes fall. Text that is mostly ASCII or + * Latin-1 has a zero high byte in most code units: the odd bytes for little-endian, the even + * bytes for big-endian, while the other half is almost never zero. + * + * Deliberately conservative; a miss just leaves the file to the binary sniff, as before 1.14.0. + * Every one of these must hold, over the first 8 KB: + * - the file length is even (UTF-16 is two bytes per unit); + * - no code unit is 0x0000: no text contains NUL, and binary formats are full of it; + * - at least half the units have a zero byte on one side, and under 5% on the other; + * - at most 1% of the ASCII-range units are stray control characters, which rules out arrays + * of small 16-bit integers (zero high bytes, but low bytes like 0x01 and 0x05). + * So text in non-Latin scripts without a BOM (Cyrillic, CJK) is not detected; Windows tools write + * a BOM for those, and the BOM path handles them. + * + * Returns 'utf16le', 'utf16be' or null. + */ +function sniffUtf16(buf) { + if (buf.length < 2 || buf.length % 2 !== 0) return null; + const end = Math.min(buf.length, SNIFF_BYTES); + const units = end >> 1; + let evenNul = 0; + let oddNul = 0; + let leControls = 0; + let beControls = 0; + for (let i = 0; i < end; i += 2) { + const even = buf[i]; + const odd = buf[i + 1]; + if (even === 0 && odd === 0) return null; + if (even === 0) { + evenNul++; + if (isStrayControl(odd)) beControls++; + } else if (odd === 0) { + oddNul++; + if (isStrayControl(even)) leControls++; + } + } + if (oddNul * 2 >= units && evenNul * 20 < units && leControls * 100 <= units) return 'utf16le'; + if (evenNul * 2 >= units && oddNul * 20 < units && beControls * 100 <= units) return 'utf16be'; + return null; +} + +/** + * Cheap binary sniff, per encoding. For the 8-bit encodings it is the check FATE always made: a + * NUL byte in the first 8 KB. Text never contains NUL; executables, images and archives contain + * it almost immediately. UTF-16 text is FULL of NUL bytes (every ASCII character has one), so for + * UTF-16 the same idea moves up a level: a NUL code unit, two zero bytes on a unit boundary. + * + * Must run AFTER UTF-16 detection; with the 8-bit rule applied first, every UTF-16 file was + * refused as binary up to 1.13.4. A guard against "Open with FATE" on the wrong file, not a + * general-purpose detector. + */ +function isProbablyBinary(input, encoding = 'utf8') { + const buf = toBuffer(input); + const head = buf.subarray(0, SNIFF_BYTES); + if (encoding === 'utf16le' || encoding === 'utf16be') { + for (let i = 0; i + 1 < head.length; i += 2) { + if (head[i] === 0 && head[i + 1] === 0) return true; + } + return false; + } + return head.includes(0); +} + +/* + * Decoders are created once. `ignoreBOM: true` because the BOM is stripped by hand below, and a + * SECOND U+FEFF after it is content that must survive the round trip. + */ +const strictUtf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }); +let cp1252Decoder = null; +function cp1252() { + if (!cp1252Decoder) cp1252Decoder = new TextDecoder('windows-1252'); + return cp1252Decoder; +} + +/** + * UTF-16 through Buffer rather than TextDecoder: Buffer's utf16le maps each code unit straight + * to a JS string unit and back, unpaired surrogates included, so decode → encode is exact. + * TextDecoder would replace those with U+FFFD. An odd trailing byte (a truncated file) cannot be + * a character; it becomes U+FFFD, the one lossy case, and only for a file already damaged. + */ +function decodeUtf16(body, bigEndian) { + const evenLength = body.length - (body.length % 2); + let units = body.subarray(0, evenLength); + if (bigEndian) units = Buffer.from(units).swap16(); // swap a copy: swap16 works in place + const text = units.toString('utf16le'); + return evenLength === body.length ? text : `${text}�`; +} + +/** + * Count line breaks and normalise them to `\n`. The format's eol is the most common style, so a + * mixed file is written back consistently in its majority style. A file with no line break at + * all takes `defaultEol`. Ties go to `defaultEol`, then CRLF, then LF. + */ +function normalizeEol(text, defaultEol) { + if (text.indexOf('\r') === -1) { + return { text, eol: text.indexOf('\n') === -1 ? defaultEol : '\n' }; + } + let crlf = 0; + let cr = 0; + let lf = 0; + for (let i = 0; i < text.length; i++) { + const c = text.charCodeAt(i); + if (c === 13) { + if (text.charCodeAt(i + 1) === 10) { + crlf++; + i++; + } else { + cr++; + } + } else if (c === 10) { + lf++; + } + } + const counts = { '\r\n': crlf, '\n': lf, '\r': cr }; + let eol = defaultEol; + for (const candidate of [defaultEol, '\r\n', '\n', '\r']) { + if (counts[candidate] > counts[eol]) eol = candidate; + } + return { text: text.replace(/\r\n?/g, '\n'), eol }; +} + +/** `\r\n` and lone `\r` become `\n`. What the renderer holds, and what comparisons use. */ +function normalizeText(text) { + return text.indexOf('\r') === -1 ? text : text.replace(/\r\n?/g, '\n'); +} + +/** + * A short fingerprint of a document's text, for "has the file really changed?" checks without + * holding a second copy of a 25 MB log. Not a security measure; collisions are irrelevant here. + */ +function textDigest(text) { + return crypto.createHash('sha1').update(text).digest('base64'); +} + +/** + * Decode a file's bytes. + * + * Without `forcedEncoding`: a BOM decides; else UTF-16 by its NUL pattern; else strict UTF-8; + * else Windows-1252 (the Windows "ANSI" code page, which is what a non-UTF-8 text file on a + * Western Windows machine almost always is). A file that starts with a UTF-8 BOM but is not + * valid UTF-8 is read as Windows-1252 too, BOM bytes included as text (): wrong-looking but + * lossless, where UTF-8 with replacement characters would destroy the bad bytes on save. + * + * With `forcedEncoding` (Reopen with encoding): that encoding, and a BOM is only stripped when it + * belongs to it. A forced decoding that could not be saved back unchanged is refused rather than + * opened lossy: UTF-8 over invalid bytes, UTF-16 over an odd number of bytes (INVALID). + * + * Throws coded errors: BINARY (looks like a binary file in that encoding), INVALID, BAD_ENCODING. + * Their messages are fragments ("it contains …") for the caller to put after the file name. + * + * @param {Buffer|Uint8Array} input + * @param {string|null} [forcedEncoding] + * @param {{ defaultEol?: string, platform?: string }} [opts] eol for files without line breaks + * @returns {{ text: string, format: { encoding: string, bom: boolean, eol: string } }} + */ +function decode(input, forcedEncoding = null, opts = {}) { + const buf = toBuffer(input); + const defaultEol = EOLS.includes(opts.defaultEol) ? opts.defaultEol : platformEol(opts.platform); + if (forcedEncoding != null && !ENCODINGS.includes(forcedEncoding)) { + throw codedError('BAD_ENCODING', `Unknown encoding: ${forcedEncoding}`); + } + + const bom = sniffBom(buf); + let encoding; + let bomLength = 0; + if (forcedEncoding) { + encoding = forcedEncoding; + if (bom && bom.encoding === forcedEncoding) bomLength = bom.length; + } else if (bom) { + encoding = bom.encoding; + bomLength = bom.length; + } else { + encoding = sniffUtf16(buf) || 'utf8'; + } + + if (isProbablyBinary(buf, encoding)) { + throw codedError('BINARY', `it doesn't look like ${ENCODING_LABELS[encoding]} text`); + } + + const body = buf.subarray(bomLength); + let text; + if (encoding === 'utf8') { + try { + text = strictUtf8.decode(body); + } catch { + if (forcedEncoding) { + throw codedError('INVALID', "it contains bytes that aren't valid UTF-8"); + } + encoding = 'windows1252'; + bomLength = 0; + text = cp1252().decode(buf); + } + } else if (encoding === 'utf16le' || encoding === 'utf16be') { + if (forcedEncoding && body.length % 2 !== 0) { + throw codedError('INVALID', "it has an odd number of bytes, so it can't be UTF-16"); + } + text = decodeUtf16(body, encoding === 'utf16be'); + } else { + text = cp1252().decode(body); + } + + const normalized = normalizeEol(text, defaultEol); + return { text: normalized.text, format: { encoding, bom: bomLength > 0, eol: normalized.eol } }; +} + +/* + * Windows-1252 reverse table, built from the platform's own decoder over all 256 bytes rather + * than typed in. The WHATWG table maps the five bytes Microsoft left undefined (0x81, 0x8D, 0x8F, + * 0x90, 0x9D) to the matching C1 controls, so every byte has a character and every one of those + * 256 characters has exactly one byte: decode → encode is exact for any file. Int16 so -1 can + * mark "no byte for this character". Built on first use (128 KB). + */ +let cp1252Reverse = null; +function cp1252Table() { + if (!cp1252Reverse) { + const chars = cp1252().decode(Uint8Array.from({ length: 256 }, (_, i) => i)); + cp1252Reverse = new Int16Array(65536).fill(-1); + for (let byte = 0; byte < chars.length; byte++) cp1252Reverse[chars.charCodeAt(byte)] = byte; + } + return cp1252Reverse; +} + +/** The first character Windows-1252 cannot hold, as a coded UNENCODABLE error, or null. */ +function findUnencodable(text) { + const table = cp1252Table(); + for (let i = 0; i < text.length; i++) { + if (table[text.charCodeAt(i)] >= 0) continue; + const codePoint = text.codePointAt(i); + const char = String.fromCodePoint(codePoint); + let line = 1; + for (let j = text.indexOf('\n'); j !== -1 && j < i; j = text.indexOf('\n', j + 1)) line++; + const hex = codePoint.toString(16).toUpperCase().padStart(4, '0'); + return codedError( + 'UNENCODABLE', + `“${char}” (U+${hex}) on line ${line} can't be saved in Windows-1252. Save the file as UTF-8 instead.`, + { char, codePoint, index: i, line } + ); + } + return null; +} + +function encodeCp1252(text) { + const table = cp1252Table(); + const out = Buffer.allocUnsafe(text.length); + for (let i = 0; i < text.length; i++) out[i] = table[text.charCodeAt(i)]; + return out; +} + +/** + * A complete, valid format: `candidate`'s fields over `base`'s. Unknown keys are dropped (the + * renderer may carry more per-tab state than main needs). Windows-1252 has no BOM, so `bom` is + * forced off for it. Throws BAD_FORMAT for anything else that is wrong; a save in a format + * nobody asked for is worse than a save that fails and says why. + */ +function resolveFormat(candidate, base) { + if (candidate != null && (typeof candidate !== 'object' || Array.isArray(candidate))) { + throw codedError('BAD_FORMAT', 'The file format to save in is not valid'); + } + const fallback = base || defaultFormat(); + const pick = (key) => (candidate && candidate[key] !== undefined ? candidate[key] : fallback[key]); + const format = { encoding: pick('encoding'), bom: pick('bom'), eol: pick('eol') }; + if (!ENCODINGS.includes(format.encoding)) { + throw codedError('BAD_FORMAT', `Unknown encoding: ${String(format.encoding)}`); + } + if (typeof format.bom !== 'boolean') throw codedError('BAD_FORMAT', 'The byte-order mark setting is not valid'); + if (!EOLS.includes(format.eol)) throw codedError('BAD_FORMAT', 'The line-ending setting is not valid'); + if (format.encoding === 'windows1252') format.bom = false; + return format; +} + +function sameFormat(a, b) { + return !!a && !!b && a.encoding === b.encoding && a.bom === b.bom && a.eol === b.eol; +} + +/** + * Encode renderer text for disk: `\n` → format.eol, then the encoding, then the BOM. Any `\r` + * already in the text is normalised first, so stray CRs cannot turn into `\r\r\n`. + * Throws UNENCODABLE (Windows-1252 only, naming the first offending character and its line) and + * BAD_FORMAT. + * + * @returns {Buffer} + */ +function encode(text, format) { + if (typeof text !== 'string') throw codedError('BAD_ARGS', 'Expected the document text as a string'); + const f = resolveFormat(format); + const lf = normalizeText(text); + if (f.encoding === 'windows1252') { + const err = findUnencodable(lf); + if (err) throw err; + } + const body = f.eol === '\n' ? lf : lf.replace(/\n/g, f.eol); + + let data; + if (f.encoding === 'utf8') data = Buffer.from(body, 'utf8'); + else if (f.encoding === 'utf16le') data = Buffer.from(body, 'utf16le'); + else if (f.encoding === 'utf16be') data = Buffer.from(body, 'utf16le').swap16(); + else data = encodeCp1252(body); + + if (!f.bom) return data; + const bom = BOMS.find((b) => b.encoding === f.encoding); + return Buffer.concat([bom.bytes, data]); +} + +module.exports = { + ENCODINGS, + EOLS, + ENCODING_LABELS, + defaultFormat, + platformEol, + decode, + encode, + resolveFormat, + sameFormat, + normalizeText, + textDigest, + isProbablyBinary, + sniffUtf16 +}; diff --git a/electron/fileWatch.cjs b/electron/fileWatch.cjs new file mode 100644 index 0000000..5230e18 --- /dev/null +++ b/electron/fileWatch.cjs @@ -0,0 +1,298 @@ +'use strict'; +/** + * fileWatch.cjs: live reload for open files, one watcher per path. + * + * What fs.watch hands over is noisy and fragile, and up to 1.13.4 FATE took it at face value: + * + * - BURSTS. An atomic save by another editor (write a temp file, rename it over the original) + * fires two or three events, and each one re-read the file and reloaded the tab. Events are + * now coalesced per path: a read happens once the path has been quiet for `debounceMs`, or + * `maxWaitMs` after the first event at the latest, so a file that is written continuously + * (a log) still updates instead of waiting for a pause that never comes. + * + * - REPLACED FILES. On Linux the watch is bound to the file's inode, so once a rename puts a new + * file at the path the old watcher hears nothing more. After a 'rename' the watcher re-attaches + * to whatever is at the path now. + * + * - DELETED FILES. If the file was missing when the rename timer fired (a branch switch, an + * editor that deletes before writing), the old code returned without re-arming: live reload + * was dead for that tab and the watcher leaked. Now the dead watcher is closed and the path is + * retried with backoff (`retryDelays`); if the file comes back it is watched and read again, + * and if it never does, `onDeleted` reports it once. + * + * - ERRORS. A watcher can emit 'error' (Windows does when the folder goes away); with no + * listener that is an uncaught exception in the main process. It is now treated as a rename. + * + * The reads themselves belong to the caller (`read`, async), and so does what a read means + * (`onRead`). This module guarantees that only the newest read is delivered: a read that was + * overtaken by a newer one, or by a `hold`, is dropped. + * + * `hold(path)` is for FATE's own saves. While a save is writing, events for that path are parked + * and any read already in flight is discarded (it may have caught the file mid-replace); when + * the hold is released, one read runs. Without it, a reload that raced a save could hand the + * renderer the pre-save text as an "external change". + * + * Free of Electron imports; test/fileWatch.test.cjs drives it against real files. + */ +const fs = require('fs'); + +const fsp = fs.promises; + +const MISSING = new Set(['ENOENT', 'ENOTDIR']); + +/** + * @param {object} options + * @param {(filePath: string) => Promise} options.read read the file (after a burst settles) + * @param {(filePath: string, result: any) => void} options.onRead the newest read's result + * @param {(filePath: string) => void} [options.onDeleted] gone, and stayed gone through every retry + * @param {(filePath: string, err: Error) => void} [options.onError] a read or watch failed for another reason + * @param {(filePath: string) => string} [options.keyOf] map key for a path (case folding on Windows) + * @param {number} [options.debounceMs] + * @param {number} [options.maxWaitMs] + * @param {number[]} [options.retryDelays] + */ +function createFileWatcher(options) { + const { + read, + onRead, + onDeleted = () => {}, + onError = () => {}, + keyOf = (p) => p, + debounceMs = 150, + maxWaitMs = 1000, + retryDelays = [100, 300, 1000, 2000, 5000] + } = options; + + /** key → entry: { path, handle, timer, retryTimer, burstStart, renamed, attempt, holds, pending, seq, closed } */ + const entries = new Map(); + + function closeHandle(entry) { + const handle = entry.handle; + entry.handle = null; + if (!handle) return; + try { + handle.close(); + } catch { + /* already dead */ + } + } + + /** Attach a fresh watcher to whatever is at the path now. Returns null, or the error. */ + function arm(entry) { + closeHandle(entry); + let handle; + try { + handle = fs.watch(entry.path, (eventType) => { + if (entry.handle === handle) onEvent(entry, eventType); + }); + } catch (err) { + return err; + } + handle.on('error', () => { + if (entry.handle === handle) onEvent(entry, 'rename'); + }); + entry.handle = handle; + entry.renamed = false; + return null; + } + + function onEvent(entry, eventType) { + if (entry.closed) return; + if (eventType === 'rename') entry.renamed = true; + schedule(entry); + } + + function schedule(entry) { + if (entry.closed) return; + if (entry.holds > 0) { + entry.pending = true; + return; + } + const now = Date.now(); + if (!entry.burstStart) entry.burstStart = now; + clearTimeout(entry.timer); + const wait = Math.max(0, Math.min(debounceMs, entry.burstStart + maxWaitMs - now)); + entry.timer = setTimeout(() => { + entry.timer = null; + entry.burstStart = 0; + settle(entry); + }, wait); + } + + /** A burst is over: decide what is at the path now. */ + async function settle(entry) { + if (entry.closed) return; + if (entry.holds > 0) { + entry.pending = true; + return; + } + let stat = null; + try { + stat = await fsp.stat(entry.path); + } catch (err) { + if (!MISSING.has(err.code)) { + onError(entry.path, err); + return; + } + } + if (entry.closed) return; + if (entry.holds > 0) { + // A save began while we were looking; whatever we saw may already be stale. + entry.pending = true; + return; + } + if (!stat || !stat.isFile()) { + lost(entry); + return; + } + if (entry.renamed || !entry.handle) { + const err = arm(entry); + if (err && MISSING.has(err.code)) { + lost(entry); + return; + } + if (err) onError(entry.path, err); + } + readNow(entry); + } + + /** The file is not at its path: drop the dead watcher and start looking for it again. */ + function lost(entry) { + closeHandle(entry); + entry.renamed = false; + entry.attempt = 0; + retry(entry); + } + + function retry(entry) { + clearTimeout(entry.retryTimer); + if (entry.attempt >= retryDelays.length) { + entry.retryTimer = null; + onDeleted(entry.path); + return; + } + entry.retryTimer = setTimeout(async () => { + entry.retryTimer = null; + if (entry.closed) return; + if (entry.holds > 0) { + // One of FATE's own saves is (re)creating it; the save re-arms the watcher itself. + entry.pending = true; + return; + } + let stat = null; + try { + stat = await fsp.stat(entry.path); + } catch { + /* still missing */ + } + if (entry.closed || entry.retryTimer || entry.handle) return; // re-armed meanwhile + if (entry.holds > 0) { + entry.pending = true; + return; + } + if (stat && stat.isFile()) { + const err = arm(entry); + if (!err || !MISSING.has(err.code)) { + // Back. (If it can't be watched, e.g. the inotify watch limit, still show what's there.) + if (err) onError(entry.path, err); + entry.attempt = 0; + readNow(entry); + return; + } + } + entry.attempt++; + retry(entry); + }, retryDelays[entry.attempt]); + } + + async function readNow(entry) { + if (entry.holds > 0) { + entry.pending = true; + return; + } + const seq = ++entry.seq; + let result; + try { + result = await read(entry.path); + } catch (err) { + if (entry.closed || seq !== entry.seq) return; + if (MISSING.has(err.code)) lost(entry); + else onError(entry.path, err); + return; + } + // Superseded by a newer read, by one of FATE's own saves, or by the tab closing. + if (entry.closed || seq !== entry.seq) return; + onRead(entry.path, result); + } + + /** + * Watch a path (no-op if it is already watched). `rearm` re-attaches even so: after FATE's + * own atomic save the path holds a new file, which a Linux watcher on the old one won't see. + * A path in the missing state is re-armed on any call. + */ + function watch(filePath, { rearm = false } = {}) { + const key = keyOf(filePath); + let entry = entries.get(key); + if (entry && entry.handle && !rearm) return; + if (!entry) { + entry = { path: filePath, handle: null, timer: null, retryTimer: null, burstStart: 0, renamed: false, attempt: 0, holds: 0, pending: false, seq: 0, closed: false }; + entries.set(key, entry); + } + clearTimeout(entry.retryTimer); + entry.retryTimer = null; + entry.attempt = 0; + const err = arm(entry); + if (err) { + if (MISSING.has(err.code)) lost(entry); + else onError(filePath, err); + } + } + + function unwatch(filePath) { + const key = keyOf(filePath); + const entry = entries.get(key); + if (!entry) return; + entry.closed = true; + clearTimeout(entry.timer); + clearTimeout(entry.retryTimer); + closeHandle(entry); + entries.delete(key); + } + + /** Park events for a path while FATE itself writes it. Returns the release function. */ + function hold(filePath) { + const entry = entries.get(keyOf(filePath)); + if (!entry) return () => {}; + entry.holds++; + entry.seq++; // a read already in flight may have seen the file mid-save: drop it + if (entry.timer) { + clearTimeout(entry.timer); + entry.timer = null; + entry.burstStart = 0; + entry.pending = true; + } + let released = false; + return () => { + if (released) return; + released = true; + entry.holds--; + if (entry.holds === 0 && entry.pending && !entry.closed) { + entry.pending = false; + schedule(entry); + } + }; + } + + function isWatching(filePath) { + const entry = entries.get(keyOf(filePath)); + return !!entry && !!entry.handle; + } + + function closeAll() { + for (const entry of [...entries.values()]) unwatch(entry.path); + } + + return { watch, unwatch, hold, isWatching, closeAll }; +} + +module.exports = { createFileWatcher }; diff --git a/electron/fsAccess.cjs b/electron/fsAccess.cjs index 2a81912..58af73a 100644 --- a/electron/fsAccess.cjs +++ b/electron/fsAccess.cjs @@ -18,12 +18,40 @@ * was dropped, and `Open with → FATE` from a USB stick did not open anything. * * Free of Electron imports on purpose; it runs under plain node for tests. + * + * Besides `title`/`message` (an error box) and `short` (the status bar), every result carries + * `reason`, a few words without the file name, and `advice`, the snap remedy or null, so a + * summary of several failures (session restore) can list the files once and the remedy once. */ const path = require('path'); const { execFileSync } = require('child_process'); const REMOVABLE_MEDIA_ROOTS = ['/media', '/run/media', '/mnt']; +/** + * Plain words for the errors people actually hit, in place of Node's "ENOSPC: no space left on + * device, write". Also keeps the temporary file of an atomic save (textFiles.cjs) out of the + * message: it is the path in many raw messages, and means nothing to the user. + */ +const PLAIN_REASONS = { + ENOSPC: 'the disk is full', + EDQUOT: 'your disk quota is used up', + EROFS: 'the drive is read-only', + EBUSY: 'another program is using it', + ENOENT: 'it or its folder no longer exists', + ENOTDIR: 'it or its folder no longer exists', + EISDIR: 'it is a folder', + ENAMETOOLONG: 'the path is too long', + EIO: 'the drive reported a read or write error', + ETIMEDOUT: 'the drive did not respond', + EHOSTDOWN: 'the network drive is unreachable', + EHOSTUNREACH: 'the network drive is unreachable', + ENETUNREACH: 'the network drive is unreachable', + ENETDOWN: 'the network drive is unreachable', + EMFILE: 'too many files are open', + ENFILE: 'too many files are open' +}; + /** The part of `filePath` below `root`, or null when it is not inside it. POSIX paths only. */ function relativeWithin(root, filePath) { const rel = path.posix.relative(root, filePath); @@ -46,8 +74,9 @@ function snapPlugConnected(plug) { * @param {string} filePath the path that failed * @param {'open'|'save'} action what FATE was doing * @param {object} [deps] `env` (default process.env) and `isConnected(plug)`, for tests - * @returns {{ title: string, message: string, short: string }} - * `title` and `message` suit dialog.showErrorBox; `short` is a one-liner for the status bar. + * @returns {{ title: string, message: string, short: string, reason: string, advice: string|null }} + * `title` and `message` suit dialog.showErrorBox; `short` is a one-liner for the status bar; + * `reason` and `advice` are the pieces of a multi-file summary (see the note at the top). */ function describeFsError(err, filePath, action = 'open', deps = {}) { const env = deps.env || process.env; @@ -71,7 +100,9 @@ function describeFsError(err, filePath, action = 'open', deps = {}) { `${name} is on a removable drive, and this copy of FATE is a snap. Snaps cannot reach ` + `removable drives until you allow it, once, in a terminal:\n\n ${cmd}\n\n` + `Then ${verb} the file again.`, - short: `Snap cannot reach removable drives. Run: ${cmd}` + short: `Snap cannot reach removable drives. Run: ${cmd}`, + reason: 'on a removable drive the snap cannot reach yet', + advice: `This copy of FATE is a snap. Snaps cannot reach removable drives until you allow it, once, in a terminal:\n\n ${cmd}` }; } @@ -84,7 +115,11 @@ function describeFsError(err, filePath, action = 'open', deps = {}) { `${name} is inside a hidden folder of your home directory (one whose name starts with ` + `a dot). Snap confinement does not let any snap read or write those, and no setting ` + `changes it.\n\nTo ${verb} files like this one, use FATE from the .deb, .rpm or AppImage instead.`, - short: 'Snap confinement blocks hidden files in your home folder' + short: 'Snap confinement blocks hidden files in your home folder', + reason: 'in a hidden folder of your home directory, which snaps cannot reach', + advice: + 'Snap confinement does not let any snap read or write hidden folders of your home ' + + 'directory. For files like these, use FATE from the .deb, .rpm or AppImage instead.' }; } } @@ -94,7 +129,11 @@ function describeFsError(err, filePath, action = 'open', deps = {}) { message: `FATE was not allowed to ${verb} ${name}.\n\n${detail}\n\nThis copy of FATE is a snap, ` + `which can reach your home directory (hidden files excepted) and connected removable drives only.`, - short: `Permission denied: ${name}` + short: `Permission denied: ${name}`, + reason: 'permission denied', + advice: + 'This copy of FATE is a snap, which can reach your home directory (hidden files excepted) ' + + 'and connected removable drives only.' }; } @@ -102,14 +141,19 @@ function describeFsError(err, filePath, action = 'open', deps = {}) { return { title: 'Permission denied', message: `FATE was not allowed to ${verb} ${name}.\n\n${detail}`, - short: `Permission denied: ${name}` + short: `Permission denied: ${name}`, + reason: 'permission denied', + advice: null }; } + const plain = PLAIN_REASONS[code]; return { title: action === 'save' ? 'Could not save file' : 'Could not open file', - message: `${name} could not be ${action === 'save' ? 'saved' : 'opened'}.\n\n${detail}`, - short: detail + message: `${name} could not be ${action === 'save' ? 'saved' : 'opened'}.\n\n${plain ? `${plain[0].toUpperCase()}${plain.slice(1)}.` : detail}`, + short: plain ? `Can't ${verb} ${name}: ${plain}` : detail, + reason: plain || detail, + advice: null }; } diff --git a/electron/launchArgs.cjs b/electron/launchArgs.cjs new file mode 100644 index 0000000..1932436 --- /dev/null +++ b/electron/launchArgs.cjs @@ -0,0 +1,41 @@ +'use strict'; +/** + * launchArgs.cjs: which command-line arguments might name files to open, as absolute paths. + * + * A shape filter only: main.cjs's isOpenableArg then keeps the ones that are existing regular + * files (and not FATE's own entry script), and openAndWatchFile decides whether each is text. + * + * - Flags (`--ozone-platform=x11`, Chromium's own `--original-process-start-time=…`) are skipped. + * argv[0] is the executable. + * - Relative paths resolve against `cwd`. For a SECOND instance that must be the second + * process's working directory (the 'second-instance' event's `workingDirectory`): resolving + * against the running instance's folder opened the wrong file, or none. + * - `file:` URIs become paths. FATE's Linux desktop entries launch it with `%U`, and file + * managers may pass local files as `file:///home/me/notes.md`; those used to fail the stat in + * isOpenableArg and open nothing. + * + * Free of Electron imports; test/launchArgs.test.cjs covers Windows-style paths under plain node. + */ + +const path = require('path'); +const { fileURLToPath } = require('url'); + +function candidateFilePaths(argv, { cwd, platform = process.platform } = {}) { + const p = platform === 'win32' ? path.win32 : path.posix; + const paths = []; + for (const arg of (Array.isArray(argv) ? argv : []).slice(1)) { + if (typeof arg !== 'string' || !arg || arg.startsWith('-') || arg.includes('\0')) continue; + let filePath = arg; + if (/^file:/i.test(arg)) { + try { + filePath = fileURLToPath(arg, { windows: platform === 'win32' }); + } catch { + continue; // a remote host on Linux, or not a valid file URL at all + } + } + paths.push(cwd ? p.resolve(cwd, filePath) : p.resolve(filePath)); + } + return paths; +} + +module.exports = { candidateFilePaths }; diff --git a/electron/linkSecurity.cjs b/electron/linkSecurity.cjs new file mode 100644 index 0000000..2176f77 --- /dev/null +++ b/electron/linkSecurity.cjs @@ -0,0 +1,170 @@ +'use strict'; +/** + * linkSecurity.cjs: which URLs FATE's windows may navigate to, which links FATE hands to the + * operating system, and which files the fate-local:// image protocol may serve. + * + * All three answer the same question: a Markdown document is untrusted input, so what may a link + * or an image in one make the app do? Free of Electron imports; test/linkSecurity.test.cjs runs it + * under plain node, Windows-style paths included (every function takes the platform explicitly). + */ + +const path = require('path'); + +/* ════════════════════════════════════════════════════════════════════════════════════════════ + NAVIGATION + ════════════════════════════════════════════════════════════════════════════════════════════ */ + +/** + * May a window navigate to `rawUrl`? Only to the one document it was opened on. + * + * `allowed` is `{ devServerUrl }` (dev: anything on the Vite server's origin, so its full-page + * reloads keep working) or `{ entryUrl }` (the exact file URL, ignoring a #hash). + * + * Up to 1.13.4 the main window allowed anything under the `dist/` folder's URL. A relative link in + * a document, `[Contributing](CONTRIBUTING.md)`, resolves against the PAGE, not the document, so it + * became `…/dist/CONTRIBUTING.md`: inside the prefix, allowed, and the whole app was replaced by + * an ERR_FILE_NOT_FOUND page with every tab and unsaved edit gone. + * + * File URLs are compared by host and decoded path rather than as strings, so the percent-encoding + * Chromium chooses for a navigation (an install path with spaces or accents) cannot make the app's + * own URL look foreign; on Windows, case-insensitively, like the file system. + */ +function isAllowedNavigation(rawUrl, allowed, platform = process.platform) { + const target = parseUrl(rawUrl); + if (!target) return false; + if (allowed.devServerUrl) { + const dev = parseUrl(allowed.devServerUrl); + return !!dev && target.origin === dev.origin; + } + const entry = parseUrl(allowed.entryUrl); + if (!entry || target.protocol !== 'file:' || entry.protocol !== 'file:') return false; + if (target.search !== entry.search) return false; // `index.html?…` is a different document + const key = fileUrlKey(target, platform); + return key !== null && key === fileUrlKey(entry, platform); +} + +/** Comparable identity of a file URL: host (UNC installs have one) plus the decoded path. */ +function fileUrlKey(url, platform) { + let decoded; + try { + decoded = decodeURIComponent(url.pathname); + } catch { + return null; + } + const key = `${url.hostname}|${decoded}`; + return platform === 'win32' ? key.replace(/\\/g, '/').toLowerCase() : key; +} + +function parseUrl(raw) { + if (typeof raw !== 'string') return null; + try { + return new URL(raw); + } catch { + return null; + } +} + +/* ════════════════════════════════════════════════════════════════════════════════════════════ + EXTERNAL LINKS + ════════════════════════════════════════════════════════════════════════════════════════════ */ + +/** Longer than any real link; refuses pathological input before it reaches a dialog or the OS. */ +const MAX_EXTERNAL_URL_LENGTH = 8192; + +/** + * How a link may leave FATE: `{ kind: 'web', url }` for http(s), which the caller opens only + * after the user confirms; `{ kind: 'mailto', url }`, opened directly because all it can do is + * open a compose window; or null, refused. + * + * Refused on purpose: `file:` (the OS would OPEN or RUN the target: a document can link to + * `file:///C:/Users/me/Downloads/setup.exe`), `javascript:`/`data:`/`blob:` (meaningless outside + * the page), and every other scheme, because custom schemes (`ms-settings:`, `steam://`, + * `vscode://`, `search-ms:`) launch other applications with arguments the document chose. + * + * `url` is the parser's canonical form, so whitespace or control characters around the input + * never reach the confirmation dialog or `shell.openExternal`. + */ +function classifyExternalUrl(rawUrl) { + if (typeof rawUrl !== 'string' || rawUrl.length > MAX_EXTERNAL_URL_LENGTH) return null; + const url = parseUrl(rawUrl); + if (!url) return null; + if (url.protocol === 'http:' || url.protocol === 'https:') { + return url.hostname ? { kind: 'web', url: url.href } : null; + } + if (url.protocol === 'mailto:') return { kind: 'mailto', url: url.href }; + return null; +} + +/* ════════════════════════════════════════════════════════════════════════════════════════════ + LOCAL IMAGES (fate-local://) + ════════════════════════════════════════════════════════════════════════════════════════════ */ + +/** What the preview may load through fate-local: images, and nothing a page script could read. */ +const LOCAL_IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif', 'apng']); + +/** Windows device names: `C:\x\COM1.png` opens the serial port, not a file, whatever the extension. */ +const WINDOWS_DEVICE_NAME = /^(con|prn|aux|nul|com[0-9¹²³]|lpt[0-9¹²³]|conin\$|conout\$)$/i; + +/** + * Map a fate-local:// request to the file it may serve: `{ ok: true, filePath }`, or + * `{ ok: false, status, reason }` for the handler to answer with. + * + * URL shape (built in src/markdown.js): `fate-local://local/`, + * `/C%3A/Users/…` on Windows and `/home/…` elsewhere. Refused: + * + * - NETWORK PATHS. `![](//attacker.example/share/x.png)` used to become a fate-local URL for + * `//attacker.example/share/x.png`, and fetching that on Windows opens an SMB connection that + * hands the user's NTLM hash to the attacker's machine. Merely opening the document was enough. + * Any path starting with two slashes of either kind is refused before anything touches the + * file system, and on Windows so is any path whose root is a UNC or device root (`\\server\`, + * `\\?\`, `\\.\`), which also catches mixed spellings like `/\server/share`. + * - RELATIVE PATHS. The renderer only ever sends absolute ones; on Windows that means a drive + * letter (`\x.png` would resolve against whatever the current drive is). + * - ANYTHING NOT AN IMAGE. The handler used to serve every local file to anything in the page, + * `fetch()` included, so one sanitiser bypass could read `~/.ssh/id_rsa`. Now it serves images + * by extension only (and the scheme no longer supports the Fetch API at all). + */ +function resolveLocalImagePath(rawUrl, platform = process.platform) { + const refuse = (status, reason) => ({ ok: false, status, reason }); + const url = parseUrl(rawUrl); + if (!url || url.protocol !== 'fate-local:' || url.hostname !== 'local') return refuse(400, 'Bad fate-local URL'); + + let filePath; + try { + filePath = decodeURIComponent(url.pathname); + } catch { + return refuse(400, 'Bad fate-local URL'); + } + if (filePath.includes('\0')) return refuse(400, 'Bad fate-local URL'); + + // '/C:/Users/…' → 'C:/Users/…'. POSIX paths keep their leading slash. + if (platform === 'win32' && /^\/[a-zA-Z]:/.test(filePath)) filePath = filePath.slice(1); + if (/^[\\/]{2}/.test(filePath)) return refuse(403, 'Network paths are not served'); + + const p = platform === 'win32' ? path.win32 : path.posix; + if (platform === 'win32') { + filePath = p.normalize(filePath.replace(/\//g, '\\')); + if (p.parse(filePath).root.startsWith('\\\\')) return refuse(403, 'Network paths are not served'); + if (!/^[a-zA-Z]:\\/.test(filePath)) return refuse(403, 'Only absolute paths are served'); + } else { + if (!p.isAbsolute(filePath)) return refuse(403, 'Only absolute paths are served'); + filePath = p.normalize(filePath); + } + + // extname ignores a trailing separator, so `x.png/` (a folder, or nothing) would pass it. + const ext = /[\\/]$/.test(filePath) ? '' : p.extname(filePath).slice(1).toLowerCase(); + if (!LOCAL_IMAGE_EXTENSIONS.has(ext)) return refuse(403, 'Only images are served'); + // Windows matches device names on the part before the FIRST dot, trailing spaces ignored. + if (platform === 'win32' && WINDOWS_DEVICE_NAME.test(p.basename(filePath).split('.')[0].replace(/ +$/, ''))) { + return refuse(403, 'Only images are served'); + } + return { ok: true, filePath }; +} + +module.exports = { + isAllowedNavigation, + classifyExternalUrl, + resolveLocalImagePath, + LOCAL_IMAGE_EXTENSIONS, + MAX_EXTERNAL_URL_LENGTH +}; diff --git a/electron/main.cjs b/electron/main.cjs index 1c3cf37..5821e20 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -1,4 +1,4 @@ -const { app, BrowserWindow, ipcMain, shell, protocol, dialog, net } = require('electron'); +const { app, BrowserWindow, ipcMain, shell, protocol, dialog, net, Menu, clipboard, session } = require('electron'); const path = require('path'); const fs = require('fs'); const { execFile } = require('child_process'); @@ -6,6 +6,11 @@ const { autoUpdater } = require('electron-updater'); const { pathToFileURL } = require('url'); const Store = require('electron-store'); const { describeFsError } = require('./fsAccess.cjs'); +const { isAllowedNavigation, classifyExternalUrl, resolveLocalImagePath } = require('./linkSecurity.cjs'); +const appMenu = require('./appMenu.cjs'); +const { createSpellcheckController, resolveSpellcheckSetting } = require('./spellcheck.cjs'); +const { removeStaleTempFiles } = require('./tempCleanup.cjs'); +const { candidateFilePaths } = require('./launchArgs.cjs'); /** * ProgId that electron-builder's NSIS installer actually registers for `.md`. @@ -38,7 +43,8 @@ const MAX_RECENT_FILES = 8; /* ════════════════════════════════════════════════════════════════════════════════════════════ FILE TYPES The main process reads bytes and watches paths; it does not care what a file *is*. FATE opens - ANY text file. The only gates are size (MAX_FILE_BYTES) and a binary sniff (isProbablyBinary). + ANY text file. The only gates are size (MAX_FILE_BYTES) and a binary sniff (isProbablyBinary, + in fileFormat.cjs). These lists exist for the curated open-dialog filters and for Windows registration (which types the installer advertises FATE for). Up to 1.12.0 they ALSO gated the command line and drag & drop, so "Edit in FATE" on a `.config`, `.properties`, `.reg`, `.csv` or any other @@ -46,6 +52,12 @@ const MAX_RECENT_FILES = 8; The renderer owns the markdown-vs-code routing decision (see fileKindForName in App.jsx). ════════════════════════════════════════════════════════════════════════════════════════════ */ +/* + * The types registered with the "Markdown Document" ProgID on Windows. `txt` stays here although + * since 1.14.0 the renderer opens .txt as plain text (fileKinds.js): a registration moved to another + * ProgID would orphan the default users already gave FATE for .txt, and Windows answers that by + * resetting it. The ProgID decides the icon and the Explorer type name, not how FATE opens the file. + */ const MARKDOWN_EXTENSIONS = ['md', 'markdown', 'txt']; /** Code files offered in the open dialog's curated filter and registered on Windows. */ @@ -127,15 +139,6 @@ function isOpenableArg(arg) { return true; } -/** - * Cheap binary sniff: a NUL byte in the first 8 KB. Text encodings FATE can read (UTF-8, ASCII) - * never contain NUL; executables, images and archives contain them almost immediately. This is a - * guard against "Open with FATE" on the wrong file, not a general-purpose detector. - */ -function isProbablyBinary(buffer) { - return buffer.subarray(0, 8192).includes(0); -} - /** * The registered application name (Windows Settings, RegisteredApplications) and the home-screen * window title. Renamed from "FATE - Markdown Viewer" in 1.11.0, since the app is a full editor now. @@ -164,6 +167,20 @@ if (process.env.FATE_USER_DATA) { app.setPath('userData', process.env.FATE_USER_DATA); } +/* + * One FATE per profile. Opening a file while FATE runs starts a second process, which hands its + * command line to the first ('second-instance', at the bottom of this file) and quits. + * + * Taken HERE, as early as possible: the lock lives in userData, so after the override above, and + * before anything below starts real work. Up to 1.13.4 it was taken at the very end of the file, + * after app.whenReady() had been wired up, and app.quit() before 'ready' does not stop 'ready' + * from firing. So the losing instance still created a window and ran the Windows registration + * self-heal (a reg import) and the association repair (PowerShell) before it went away. Now the + * whenReady work returns at once without the lock; see the first line there. + */ +const gotTheLock = app.requestSingleInstanceLock(); +if (!gotTheLock) app.quit(); + const store = new Store({ defaults: { // 'fate' = VagueDustin Enterprises navy & gold (utility tier), the default since 1.5.0. @@ -181,7 +198,10 @@ const store = new Store({ // Page setup for print preview and PDF export. 'Letter' rather than 'A4' because the app is // Windows-only and US Letter is the more common default there; both are offered in Settings. printPageSize: 'Letter', - printLandscape: false + printLandscape: false, + // On where it is free (Windows' own checker); off elsewhere, where turning it on downloads a + // dictionary from Google's servers. See electron/spellcheck.cjs. + spellcheck: process.platform === 'win32' } }); @@ -198,18 +218,82 @@ for (const staleKey of []) { RECENT DOCUMENTS ════════════════════════════════════════════════════════════════════════════════════════════ */ +/** + * The stored list, minus anything malformed. Every open records itself here, so one bad entry + * (a hand-edited config.json, or one written before the renderer lost write access to this key) + * must not be able to throw and break opening files. + */ +function storedRecents() { + const entries = store.get('recentFiles'); + return Array.isArray(entries) ? entries.filter((entry) => entry && typeof entry.path === 'string' && entry.path) : []; +} + +/** + * The recents list changed (an open, a Save As, Clear, a dead entry dropped). The application + * menu's Open Recent rebuilds from this event; it carries the stored list, [{ path, openedAt }], + * newest first. + */ +function recentsChanged() { + app.emit('fate-recents-changed', storedRecents()); +} + /** * Record a document in the recents list: newest first, de-duplicated by path, capped. * Stores only the path and a timestamp. The display name and existence check are derived on read, * so a moved or renamed file cannot leave a stale name behind in the store. + * + * On Windows the document also goes into the Recent category of FATE's taskbar Jump List. */ function rememberRecentFile(filePath) { - const existing = store.get('recentFiles') || []; + const existing = storedRecents(); const normalized = path.normalize(filePath); const key = watchKey(filePath); const deduped = existing.filter((entry) => watchKey(entry.path) !== key); deduped.unshift({ path: normalized, openedAt: Date.now() }); store.set('recentFiles', deduped.slice(0, MAX_RECENT_FILES)); + if (process.platform === 'win32') app.addRecentDocument(normalized); + recentsChanged(); +} + +/** Drop one entry whose file is gone, so the list heals itself instead of offering it again. */ +function forgetRecentFile(filePath) { + const existing = storedRecents(); + const key = watchKey(filePath); + const remaining = existing.filter((entry) => watchKey(entry.path) !== key); + if (remaining.length === existing.length) return; + store.set('recentFiles', remaining); + recentsChanged(); +} + +/** Clear the list, and on Windows the Jump List too: whoever clears one expects both gone. */ +function clearRecentFiles() { + store.set('recentFiles', []); + if (process.platform === 'win32') app.clearRecentDocuments(); + recentsChanged(); +} + +/* + * The home screen's existence checks run in parallel and off the main thread. Up to 1.13.4 they + * were fs.existsSync in a loop, so one entry on a disconnected network drive froze the window for + * the whole network timeout. They also stop waiting after RECENT_CHECK_TIMEOUT_MS: an entry with + * no answer by then shows as present, because opening it will say what is wrong, while greying it + * out as "File not found" just because a server is slow would be false. A check still running + * from an earlier visit is reused rather than repeated, so a dead share ties up one libuv thread, + * not one more per visit. + */ +const RECENT_CHECK_TIMEOUT_MS = 1500; +const existenceChecks = new Map(); + +function isExistingFile(filePath) { + let check = existenceChecks.get(filePath); + if (!check) { + check = fs.promises + .stat(filePath) + .then((stat) => stat.isFile(), () => false) + .finally(() => existenceChecks.delete(filePath)); + existenceChecks.set(filePath, check); + } + return check; } /** @@ -217,15 +301,18 @@ function rememberRecentFile(filePath) { * Missing files are returned rather than filtered out so the UI can show them greyed with a reason: * silently dropping an entry looks like the app forgot the file. */ -function readRecentFiles() { - const entries = store.get('recentFiles') || []; - return entries.map((entry) => ({ - path: entry.path, - name: path.basename(entry.path), - dir: path.dirname(entry.path), - openedAt: entry.openedAt, - exists: fs.existsSync(entry.path) - })); +async function readRecentFiles() { + const entries = storedRecents(); + const deadline = new Promise((resolve) => setTimeout(resolve, RECENT_CHECK_TIMEOUT_MS, true)); + return Promise.all( + entries.map(async (entry) => ({ + path: entry.path, + name: path.basename(entry.path), + dir: path.dirname(entry.path), + openedAt: entry.openedAt, + exists: await Promise.race([isExistingFile(entry.path), deadline]) + })) + ); } /* ════════════════════════════════════════════════════════════════════════════════════════════ @@ -833,7 +920,8 @@ function safeFileStem(name) { // Strip the final extension whatever it is: "script.ps1" should export as "script.pdf", // not "script.ps1.pdf". (Was markdown-only before code files existed.) .replace(/\.[a-z0-9]{1,10}$/i, '') - .replace(/[<>:"/\\|?*\x00-\x1f]/g, '-') + // Characters Windows refuses in file names, and control characters (\p{Cc}: C0, DEL, C1). + .replace(/[<>:"/\\|?*\p{Cc}]/gu, '-') .replace(/\s+/g, ' ') .trim() .slice(0, 120) || 'document' @@ -886,11 +974,23 @@ async function showPrintPreview(docName) { } }); - // The preview must not become a browser. Nothing in a PDF should be able to navigate it. + /* + * The preview must not become a browser. Nothing in a PDF should be able to navigate it: a + * link clicked in Chromium's PDF viewer navigates the WINDOW (only new-window links reached the + * handler that used to be the sole guard here), so both routes go through the same policy as + * the main window's links, confirmation for web links included. + */ + const previewUrl = pathToFileURL(file).toString(); previewWindow.webContents.setWindowOpenHandler(({ url }) => { - if (url.startsWith('http')) shell.openExternal(url); + openExternalLink(url, previewWindow); return { action: 'deny' }; }); + previewWindow.webContents.on('will-navigate', (event, url) => { + if (isAllowedNavigation(url, { entryUrl: previewUrl })) return; + event.preventDefault(); + openExternalLink(url, previewWindow); + }); + previewWindow.setMenu(Menu.buildFromTemplate(appMenu.buildPreviewMenuTemplate())); previewWindow.on('closed', () => { previewWindow = null; @@ -919,7 +1019,7 @@ async function showPrintPreview(docName) { shell.openPath(file); }); - previewWindow.loadURL(pathToFileURL(file).toString()); + previewWindow.loadURL(previewUrl); return { ok: true, reused: false }; } @@ -941,8 +1041,18 @@ async function exportPdf(docName) { return { ok: true, filePath }; } +/* + * fate-local:// serves the images a Markdown document references from disk (handler in + * app.whenReady). Its privileges are what needs and nothing more: + * standard real URL parsing (host, relative resolution); see the `local` host note at the + * handler for why that matters. + * secure counts as a secure origin, so the images are never mixed content. + * Dropped in 1.14.0: `supportFetchAPI`, which let any script in the page fetch() and READ any + * local file through this scheme; `bypassCSP`, since the page's CSP lists fate-local: in img-src; + * and `stream`, which is for media range requests, not images. + */ protocol.registerSchemesAsPrivileged([ - { scheme: 'fate-local', privileges: { bypassCSP: true, supportFetchAPI: true, secure: true, standard: true, stream: true } } + { scheme: 'fate-local', privileges: { secure: true, standard: true } } ]); const isDev = process.env.NODE_ENV === 'development'; @@ -993,23 +1103,45 @@ const updateSource = detectUpdateSource(); let mainWindow; /* - * ── Multi-tab file tracking ─────────────────────────────────────────────────────────────────── - * Since tabs (1.10.0) any number of files can be open at once, so watching is per-path: - * - * fileWatchers path → fs.FSWatcher. One live watcher per open tab. - * lastSavedByApp path → content of the last write FATE itself made to that file. Saving from - * the editor fires the same fs.watch 'change' an external edit does; without - * this, every save would bounce back to the renderer as a "file changed on - * disk" event. The watcher compares what it read against this and stays silent - * on a match. + * ── Open documents ──────────────────────────────────────────────────────────────────────────── + * The file I/O lives in small modules beside this one, each free of Electron and tested under + * plain node (test/): fileFormat.cjs (encodings, byte-order marks, line endings), textFiles.cjs + * (async reads, atomic saves), fileWatch.cjs (live reload), backups.cjs (hot exit) and + * rendererSettings.cjs (the settings keys the renderer may touch). Required here, beside the code + * that uses them. + */ +const { ENCODINGS, ENCODING_LABELS, defaultFormat, encode, resolveFormat, sameFormat, normalizeText, textDigest } = require('./fileFormat.cjs'); +const { readTextFile, writeFileAtomic } = require('./textFiles.cjs'); +const { createFileWatcher } = require('./fileWatch.cjs'); +const { createBackupStore } = require('./backups.cjs'); +const { isRendererSettingKey, checkRendererSetting } = require('./rendererSettings.cjs'); + +/* + * Since tabs (1.10.0) any number of files can be open at once, so all of this is per path, keyed + * by watchKey(): * - * Paths are normalized+lowercased as map keys (Windows paths are case-insensitive). + * fileFormats path → the file's format on disk, { encoding, bom, eol } (fileFormat.cjs). + * Recorded on open, on every save and on every reload; dropped when the tab + * closes. A save that names no format writes this one, which is what keeps a + * CRLF or UTF-16 file that way. Doubles as the list of paths the renderer may + * save to: see isOpenPath. + * knownText path → digest of the text the renderer last received for the file, or last + * saved to it. Saving fires the same watch events an external edit does, and + * Windows fires them for Defender scans and indexing too; a reload whose text + * matches is not a change and is not sent. (This was lastSavedByApp, which knew + * only FATE's own saves and compared raw text, so a CRLF file reloaded on every + * stray event: its \r\n text never equalled the editor's \n text.) A digest, + * so a 25 MB log is not held a second time. + * forcedEncodings path → the encoding picked with Reopen with encoding. Reloads keep decoding + * with it; Windows-1252 text that happens to be valid UTF-8 would otherwise + * flip back to UTF-8 at the next watch event. */ -const fileWatchers = new Map(); -const lastSavedByApp = new Map(); +const fileFormats = new Map(); +const knownText = new Map(); +const forcedEncodings = new Map(); /** - * The comparable key for a path, used for the watcher map, recents dedupe and tab dedupe. + * The comparable key for a path, used for the per-path maps, recents dedupe and tab dedupe. * Windows and (by default) macOS compare paths case-insensitively; Linux does not, where * `Notes.md` and `notes.md` are two files and folding case would give them one watcher. */ @@ -1019,6 +1151,81 @@ function watchKey(filePath) { return CASE_INSENSITIVE_PATHS ? normalized.toLowerCase() : normalized; } +/** + * May the renderer save to (or reopen) this path? Only when FATE opened it, by any route, or the + * user chose it in Save As, and its tab is still open. Up to 1.13.4 save-file wrote wherever it + * was told, which a renderer compromised through a document could have used to drop a script + * into the Startup folder. + */ +function isOpenPath(filePath) { + return typeof filePath === 'string' && filePath !== '' && fileFormats.has(watchKey(filePath)); +} + +/** + * Read a document the way its tab expects: in the user's forced encoding if there is one (back to + * detection if the file no longer decodes that way) and, for a file without a line break, with + * the line ending it already had rather than the platform's. + */ +async function readDocument(filePath) { + const key = watchKey(filePath); + const opts = { maxBytes: MAX_FILE_BYTES, defaultEol: fileFormats.get(key)?.eol }; + const forced = forcedEncodings.get(key); + if (forced) { + try { + return await readTextFile(filePath, { ...opts, forcedEncoding: forced }); + } catch (err) { + if (err.code !== 'INVALID' && err.code !== 'BINARY') throw err; + forcedEncodings.delete(key); + } + } + return readTextFile(filePath, opts); +} + +/** + * The format to record for text just read. Detection is ambiguous in a way that must not flip a + * file's format back and forth: plain ASCII reads as UTF-8 whatever it was saved as. When the + * recorded format still produces exactly these bytes, it stays. + */ +function adoptFormat(key, doc) { + const recorded = fileFormats.get(key); + if (!recorded || sameFormat(recorded, doc.format)) return doc.format; + try { + if (encode(doc.text, recorded).equals(doc.bytes)) return recorded; + } catch { + /* the recorded format cannot even hold this text */ + } + return doc.format; +} + +/** + * Live reload, one watcher per open file (fileWatch.cjs): bursts coalesced, files replaced by + * atomic saves followed, deleted files retried and reported if they stay gone, and only the + * newest read ever delivered. + */ +const fileWatcher = createFileWatcher({ + keyOf: watchKey, + read: readDocument, + onRead: (filePath, doc) => { + const key = watchKey(filePath); + if (!fileFormats.has(key)) return; // the tab closed meanwhile + const format = adoptFormat(key, doc); + const digest = textDigest(doc.text); + // Our own save landing, or a scan or indexer touching the file: not a change. + if (digest === knownText.get(key) && sameFormat(format, fileFormats.get(key))) return; + fileFormats.set(key, format); + knownText.set(key, digest); + /* + * The path rides along so the renderer can route the update to the right tab; the format, + * because it can change on its own (a CRLF → LF conversion arrives with the text unchanged). + */ + if (mainWindow) mainWindow.webContents.send('file-changed', doc.text, filePath, { format }); + }, + onDeleted: (filePath) => { + if (mainWindow && isOpenPath(filePath)) mainWindow.webContents.send('file-deleted', filePath); + }, + onError: (filePath, err) => console.error(`Live reload of ${filePath} failed:`, err.message) +}); + /** * Whether ANY open tab holds unsaved edits. Mirrored over the 'set-edited' channel on every * aggregate transition, so the window-close guard below can ask before discarding them. @@ -1047,7 +1254,14 @@ function createWindow() { webPreferences: { preload: path.join(__dirname, 'preload.cjs'), nodeIntegration: false, - contextIsolation: true + contextIsolation: true, + /* + * Always true: the `spellcheck` SETTING is applied to the session (see electron/spellcheck.cjs, + * which also explains why this flag cannot be what turns it off). A window created with + * false never marks misspellings, even once the session is enabled, so the setting could + * only take effect after a restart. + */ + spellcheck: true }, autoHideMenuBar: true }); @@ -1066,11 +1280,19 @@ function createWindow() { * `closeConfirmed` is what lets the second close() through; this handler runs again for it. The * timeout is the escape hatch for a renderer that never answers (mid-crash, or a stuck dialog); * without it the window would be unclosable. + * + * `closeWalkDone` (the user answered the walk) and `closing` (a close is under way) are for the + * renderer's beforeunload guard; see 'will-prevent-unload' below. */ let closeConfirmed = false; + let closeWalkDone = false; + let closing = false; let closeHandoffTimer = null; mainWindow.on('close', (e) => { - if (closeConfirmed || !documentEdited) return; + if (closeConfirmed || !documentEdited) { + closing = true; + return; + } e.preventDefault(); clearTimeout(closeHandoffTimer); closeHandoffTimer = setTimeout(() => { @@ -1087,196 +1309,673 @@ function createWindow() { clearTimeout(closeHandoffTimer); if (!proceed) return; closeConfirmed = true; + closeWalkDone = true; documentEdited = false; if (mainWindow && !mainWindow.isDestroyed()) mainWindow.close(); }); - // SECURITY: Prevent inner navigation and force external links to open in default browser - mainWindow.webContents.setWindowOpenHandler(({ url }) => { - if (url.startsWith('http')) { - shell.openExternal(url); + /* + * The renderer's beforeunload guard asked to keep the page: Chromium would show its own + * "Leave site?" box, which Electron replaces with this event. Without a handler the unload is + * simply cancelled, so a reload (DevTools, a dev-server restart) silently does nothing. + * + * After the close walk the user has already said what to do with every dirty tab, so the close + * goes ahead without asking twice. Anything else asks, and must ask synchronously: the answer is + * read when this handler returns. The wording follows what is being lost: a window closing + * without the walk (the 60-second escape hatch, or a close that raced the renderer's dirty + * flag), or a reload, which is the only other way this page unloads now that navigation is + * locked to the app's own URL and the menu has no Reload. + */ + mainWindow.webContents.on('will-prevent-unload', (event) => { + const wasClosing = closing; + closing = false; + if (closeWalkDone) { + event.preventDefault(); + return; + } + const choice = dialog.showMessageBoxSync(mainWindow, { + type: 'warning', + buttons: ['Discard changes', 'Cancel'], + defaultId: 1, + cancelId: 1, + title: 'Unsaved changes', + message: wasClosing ? 'Discard unsaved changes and close?' : 'Discard unsaved changes and reload?', + detail: 'Your edits have not been saved.' + }); + if (choice === 0) event.preventDefault(); + else closeConfirmed = false; + }); + + /* + * Right-click menu. Electron ships none, so right-clicking did nothing anywhere in FATE: no Copy + * in the reading view, no Cut or Paste in the editor. The roles run Chromium's own editing + * commands, so Copy fires the same 'copy' event as Ctrl+C (the preview's clean copy still + * applies; see src/previewClipboard.js), and the edit flags grey out whatever doesn't apply where + * the click landed. No Undo/Redo: CodeMirror keeps its own history, which those would bypass. + * + * On a misspelled word (only while the spellcheck setting is on) the dictionary's suggestions + * come first, as in every editor: a click replaces the word in place, through the same editing + * command a typed correction would use, so CodeMirror sees an ordinary edit (and can undo it). + */ + mainWindow.webContents.on('context-menu', (_event, params) => { + const { editFlags, isEditable, selectionText, linkURL, misspelledWord, dictionarySuggestions } = params; + const groups = []; + if (misspelledWord) { + const wc = mainWindow.webContents; + const suggestions = (dictionarySuggestions || []).slice(0, 8).map((word) => ({ + label: appMenu.escapeMenuLabel(word), + click: () => wc.replaceMisspelling(word) + })); + groups.push([ + ...(suggestions.length ? suggestions : [{ label: 'No Spelling Suggestions', enabled: false }]), + { + label: 'Add to Dictionary', + click: () => wc.session.addWordToSpellCheckerDictionary(misspelledWord) + } + ]); + } + if (/^https?:/i.test(linkURL)) { + groups.push([{ label: 'Copy Link Address', click: () => clipboard.writeText(linkURL) }]); } + if (isEditable) { + groups.push( + [ + { role: 'cut', enabled: editFlags.canCut }, + { role: 'copy', enabled: editFlags.canCopy }, + { role: 'paste', enabled: editFlags.canPaste } + ], + [{ role: 'selectAll', enabled: editFlags.canSelectAll }] + ); + } else if (selectionText.trim()) { + groups.push([{ role: 'copy' }]); + } + if (groups.length === 0) return; + const template = groups.flatMap((group, i) => (i ? [{ type: 'separator' }, ...group] : group)); + Menu.buildFromTemplate(template).popup({ window: mainWindow }); + }); + + /* + * SECURITY: the window never becomes a browser. New-window requests (target=_blank links, + * window.open) and navigations both go through openExternalLink: web links after the user + * confirms (until 1.13.4 a target=_blank link skipped the confirmation a plain click got), mail + * links straight to the mail client, everything else refused. + */ + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + openExternalLink(url, mainWindow); return { action: 'deny' }; }); - mainWindow.webContents.on('will-navigate', async (event, url) => { + mainWindow.webContents.on('will-navigate', (event, url) => { /* - * The window may only ever navigate to ITSELF (dev server or the packaged dist). The old - * check allowed any file:// URL, which meant a file dropped outside a drop handler made - * Chromium NAVIGATE the whole app to that file: the "drag & drop doesn't work" bug. The - * renderer now preventDefault()s all drops; this is the second line of defence. + * The window may only ever navigate to ITSELF: its exact entry document (dev: the Vite + * server). Up to 1.13.4 this allowed anything under dist/, so a relative link in a document + * (`[x](CONTRIBUTING.md)` resolves against the PAGE, to dist/CONTRIBUTING.md) replaced the + * whole app with an error page and every unsaved edit with it; see isAllowedNavigation. + * Before that it allowed any file:// URL, and a file dropped outside a drop handler navigated + * the app to the file. The renderer handles link clicks and drops itself; this is the second + * line of defence. */ - const appOrigin = isDev - ? 'http://localhost:5173' - : pathToFileURL(path.join(__dirname, '..', 'dist')).toString(); - if (url.startsWith(appOrigin) || url.startsWith('devtools://')) return; - + if (isAllowedNavigation(url, APP_ENTRY)) return; event.preventDefault(); + openExternalLink(url, mainWindow); + }); + + if (isDev) { + mainWindow.loadURL(APP_ENTRY.devServerUrl); + } else { + mainWindow.loadFile(APP_ENTRY_FILE); + } - if (url.startsWith('http://') || url.startsWith('https://')) { - const { response } = await dialog.showMessageBox(mainWindow, { + /* + * 'app-ready': the renderer is listening for 'open-file' from here on. Open the launch + * arguments (once per run, not again after a reload), then any command lines second instances + * handed over while it was loading. A reload makes the page deaf again until it re-announces. + */ + rendererReady = false; + let launchArgsOpened = false; + ipcMain.removeAllListeners('app-ready'); // createWindow can run again (macOS 'activate') + ipcMain.on('app-ready', (event) => { + if (BrowserWindow.fromWebContents(event.sender) !== mainWindow) return; + rendererReady = true; + if (!launchArgsOpened) { + launchArgsOpened = true; + handleArgs(process.argv, process.cwd()); + } + for (const { argv, cwd } of pendingSecondInstanceArgs.splice(0)) handleArgs(argv, cwd); + }); + // 'did-navigate', not 'did-start-navigation': the latter also fires for the navigations + // will-navigate then refuses, and the page that stays is still listening. + mainWindow.webContents.on('did-navigate', () => { + rendererReady = false; + }); + + // The page's renderer usually starts while the spellcheck dictionary is still loading, and then + // never receives it; re-send it now that the page is up (see electron/spellcheck.cjs). + mainWindow.webContents.on('did-finish-load', () => { + if (spellcheck) spellcheck.refresh(); + }); +} + +/* + * ── Where the main window may go ────────────────────────────────────────────────────────────── + * Its own entry document and nothing else (see will-navigate above). + */ +const APP_ENTRY_FILE = path.join(__dirname, '..', 'dist', 'index.html'); +const APP_ENTRY = isDev + ? { devServerUrl: 'http://localhost:5173' } + : { entryUrl: pathToFileURL(APP_ENTRY_FILE).toString() }; + +/** + * Hand a link to the operating system, under the one policy every route shares: the main + * window's navigations and new-window requests, the print preview's, and the renderer's + * 'open-external' (link clicks in the preview). + * + * http(s) after the confirmation dialog: a document's link text can say anything, and this + * shows the address that will actually open. + * mailto: straight to the mail client, which only opens a compose window. + * other refused: file: would open or RUN the target, custom schemes start other apps with + * arguments the document chose (see classifyExternalUrl). + * + * Resolves { ok: true } once handed over, else { ok: false, reason: 'refused' | 'canceled' | + * 'pending' | 'failed' }. 'pending': the same link is already waiting on its dialog, so a + * double-click does not stack two. + */ +const pendingLinkPrompts = new Set(); +async function openExternalLink(rawUrl, parentWindow) { + const link = classifyExternalUrl(rawUrl); + if (!link) return { ok: false, reason: 'refused' }; + + if (link.kind === 'web') { + if (pendingLinkPrompts.has(link.url)) return { ok: false, reason: 'pending' }; + pendingLinkPrompts.add(link.url); + try { + // A runaway URL still has to fit on screen; the browser gets the whole thing. + const shown = link.url.length > 600 ? `${link.url.slice(0, 600)}…` : link.url; + const options = { type: 'warning', buttons: ['Cancel', 'Open Browser'], defaultId: 1, cancelId: 0, title: 'External Link', - message: `You are about to open an external link:\n${url}\n\nDo you want to continue?` - }); - if (response === 1) { - shell.openExternal(url); - } + message: `You are about to open an external link:\n${shown}\n\nDo you want to continue?` + }; + const parent = parentWindow && !parentWindow.isDestroyed() ? parentWindow : null; + const { response } = parent ? await dialog.showMessageBox(parent, options) : await dialog.showMessageBox(options); + if (response !== 1) return { ok: false, reason: 'canceled' }; + } finally { + pendingLinkPrompts.delete(link.url); } - // Anything else (stray file:// navigations included) is silently refused. - }); + } - if (isDev) { - mainWindow.loadURL('http://localhost:5173'); - } else { - mainWindow.loadFile(path.join(__dirname, '../dist/index.html')); + try { + await shell.openExternal(link.url); + return { ok: true }; + } catch (err) { + console.error('Could not open link:', err.message); + return { ok: false, reason: 'failed' }; } +} - ipcMain.once('app-ready', () => { - handleArgs(process.argv); - }); +/* ════════════════════════════════════════════════════════════════════════════════════════════ + APPLICATION SHELL + The application menu, spellcheck, "check for updates", and the IPC the window chrome uses + (links, tab menu, file-manager reveal). Started once from app.whenReady, before the window. + ════════════════════════════════════════════════════════════════════════════════════════════ */ + +/** The renderer's shortcut bindings as 'update-menu' last reported them. Labels only. */ +let menuShortcuts = {}; +/** The recents list 'fate-recents-changed' last carried ([{ path, openedAt }]); null: read the store. */ +let menuRecents = null; +let menuRebuildTimer = null; +/** { set(enabled), refresh(), isEnabled() } from electron/spellcheck.cjs, once startAppShell has run. */ +let spellcheck = null; + +/** + * (Re)build the application menu (see electron/appMenu.cjs) from the current bindings and recent + * files: the list 'fate-recents-changed' last carried, or the store's. Neither is checked for + * existence here: a stat per entry on every rebuild is exactly what freezes the main thread when + * one of them is on a disconnected network drive. Open Recent checks the one file clicked. + * + * Menu.setApplicationMenu re-menus EVERY open window, so the print preview gets its own back. + */ +function installAppMenu() { + clearTimeout(menuRebuildTimer); + menuRebuildTimer = null; + const template = appMenu.buildAppMenuTemplate( + { + shortcuts: menuShortcuts, + recentFiles: (menuRecents || store.get('recentFiles') || []).map((entry) => entry && entry.path), + isPackaged: app.isPackaged, + platform: process.platform, + homeDir: app.getPath('home') + }, + { + command: sendMenuCommand, + openRecent: openRecentFromMenu, + clearRecent: clearRecentFromMenu, + checkForUpdates: checkForUpdatesNow, + openUrl: (url) => shell.openExternal(url).catch(() => {}) + } + ); + Menu.setApplicationMenu(Menu.buildFromTemplate(template)); + if (previewWindow && !previewWindow.isDestroyed()) { + previewWindow.setMenu(Menu.buildFromTemplate(appMenu.buildPreviewMenuTemplate())); + } +} + +/** Coalesces bursts (a session restore records every file it opens) into one rebuild. */ +function scheduleAppMenuRebuild() { + if (!menuRebuildTimer) menuRebuildTimer = setTimeout(installAppMenu, 50); +} + +/** A menu item the renderer implements; the ids are listed in preload.cjs (onMenuCommand). */ +function sendMenuCommand(id) { + if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('menu-command', id); } /** - * Start (or keep) a live-reload watcher for a path. One watcher per open tab; closing the tab - * removes it via 'close-file'. Separate from openAndWatchFile because Save As needs to adopt a - * new path WITHOUT re-sending 'open-file' (the renderer already holds the content, and reloading it - * would throw away the cursor and scroll position for no reason). + * File → Open Recent. The home screen greys out a recent file that has gone; a menu cannot, so a + * click on one says so. openRecentFile (the home screen's own open) has already dropped it from the + * list by then. Any other failure (permissions, snap confinement) openAndWatchFile has explained. */ -function watchFile(filePath) { - const key = watchKey(filePath); - if (fileWatchers.has(key)) return; // already watching (e.g. the same file reopened) +async function openRecentFromMenu(filePath) { + const result = await openRecentFile(filePath); + if (result.reason !== 'missing') return; + const options = { + type: 'info', + title: 'File not found', + message: `Can't open ${path.basename(filePath)}: it was moved or deleted.`, + detail: filePath + }; + const shown = mainWindow && !mainWindow.isDestroyed() ? dialog.showMessageBox(mainWindow, options) : dialog.showMessageBox(options); + shown.catch(() => {}); +} + +/** + * File → Open Recent → Clear Recently Opened: the same clear as the home screen's (the Windows Jump + * List goes too). The home screen's list refreshes the next time it is shown. + */ +function clearRecentFromMenu() { + clearRecentFiles(); +} + +function sendUpdateMessage(message, action = null) { + if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('update-message', message, action); +} + +/** + * "Check for updates": the status-bar button and the palette (over 'check-for-updates') and + * Help → Check for Updates. The answer arrives through the autoUpdater events, which post to the + * status bar. The promises are caught (M13): electron-updater emits 'error' and THEN rejects, so + * an offline check left an unhandled rejection behind; the download it starts rejects the same way. + */ +function checkForUpdatesNow() { + /* + * Store builds: electron-updater cannot update an AppX; the Store owns that pipeline, and + * the previous behaviour ("Checking for updates…" followed by silence or an error) looked + * broken because it was. Route to the Store's own updates page instead. + */ + if (isWindowsStore) { + shell.openExternal('ms-windows-store://downloadsandupdates').catch(() => {}); + return; + } + // Flatpak / Snap / apt / dnf own updates for this install (see detectUpdateSource). The + // button still does something useful: it shows what the newest release contains. + if (updateSource.managed) { + shell.openExternal('https://github.com/VagueDustin/FATE/releases/latest').catch(() => {}); + return; + } + // Both of these used to do nothing at all, silently, which read as a broken button. + if (isDev || !app.isPackaged) { + sendUpdateMessage('Update checks run only in installed builds.'); + return; + } + if (!store.get('autoUpdatesEnabled')) { + sendUpdateMessage('Automatic updates are off. Turn them on in Settings to check for updates.'); + return; + } + autoUpdater + .checkForUpdates() + .then((result) => result?.downloadPromise) + .catch(() => {}); +} - const readAndNotify = () => { +/** Everything in this section that has to exist before the window does. */ +function startAppShell() { + // Before any window: the language list decides whether a dictionary download starts at all. + spellcheck = createSpellcheckController(session.defaultSession, { + enabled: resolveSpellcheckSetting(store.get('spellcheck'), process.platform), + preferredLanguages: [app.getLocale(), ...app.getPreferredSystemLanguages()], + platform: process.platform, + warn: (message) => console.warn(message) + }); + + installAppMenu(); + // The file I/O code emits this whenever the recent-files list changes, with the new list. + app.on('fate-recents-changed', (list) => { + menuRecents = Array.isArray(list) ? list : null; + scheduleAppMenuRebuild(); + }); + + /** The renderer's current bindings, for the menu's shortcut labels. Sent on load and on rebinds. */ + ipcMain.on('update-menu', (event, state) => { + if (!mainWindow || event.sender !== mainWindow.webContents) return; + const next = appMenu.sanitizeMenuShortcuts(state && state.shortcuts); + if (JSON.stringify(next) === JSON.stringify(menuShortcuts)) return; + menuShortcuts = next; + scheduleAppMenuRebuild(); + }); + + /** Settings → spellcheck. The renderer stores the choice itself (store.set('spellcheck')). */ + ipcMain.handle('set-spellcheck', (_event, enabled) => { + if (typeof enabled !== 'boolean') return { ok: false, error: 'Spellcheck can only be on or off' }; + spellcheck.set(enabled); + return { ok: true, enabled }; + }); + + /** Link clicks in the preview: the same policy as a navigation. See openExternalLink. */ + ipcMain.handle('open-external', (event, url) => openExternalLink(url, BrowserWindow.fromWebContents(event.sender))); + + /** + * Tab menu → Open Containing Folder. Only a file FATE has open (isOpenPath), and only while it is + * an existing regular file: this hands the path to the file manager, and the renderer is the one + * asking. + */ + ipcMain.handle('show-item-in-folder', async (_event, filePath) => { + if (typeof filePath !== 'string' || !filePath || filePath.includes('\0') || !path.isAbsolute(filePath)) { + return { ok: false, error: 'Not a file path' }; + } + const name = path.basename(filePath); + if (!isOpenPath(filePath)) return { ok: false, error: `Can't show ${name}: it isn't open in FATE.` }; try { - const updatedContent = fs.readFileSync(filePath, 'utf-8'); - // Our own save just landing back on us, not an external change. See lastSavedByApp. - if (updatedContent === lastSavedByApp.get(key)) return; - if (mainWindow) { - // The path rides along so the renderer can route the update to the right tab. - mainWindow.webContents.send('file-changed', updatedContent, filePath); - } + const stat = await fs.promises.stat(filePath); + if (!stat.isFile()) return { ok: false, error: `Can't show ${name}: it is not a file.` }; } catch (err) { - console.error('Error reading updated file:', err); - } - }; - - const watcher = fs.watch(filePath, (eventType) => { - if (eventType === 'change') { - readAndNotify(); - } else if (eventType === 'rename') { - /* - * ATOMIC SAVES arrive as 'rename', not 'change': most editors (VS Code among them) write a - * temp file and rename it over the original, which replaces the inode this watcher is bound - * to. Ignoring 'rename' meant edits from such editors never live-reloaded. Re-attach to the - * new inode (after a beat; the rename may still be mid-flight) and read it. - */ - setTimeout(() => { - if (!fileWatchers.has(key)) return; // tab closed in the meantime - if (!fs.existsSync(filePath)) return; // genuinely deleted/moved away - try { - fileWatchers.get(key)?.close(); - } catch { - /* already dead */ - } - fileWatchers.delete(key); - watchFile(filePath); - readAndNotify(); - }, 100); + if (err.code === 'ENOENT' || err.code === 'ENOTDIR') { + return { ok: false, error: `Can't show ${name}: it was moved or deleted.` }; + } + return { ok: false, error: describeFsError(err, filePath, 'open').short }; } + shell.showItemInFolder(filePath); + return { ok: true }; }); - fileWatchers.set(key, watcher); + /** + * A tab's right-click menu. Resolves the chosen action ('copyPath', 'reveal', 'close', + * 'closeOthers', 'closeRight') or null when dismissed; the renderer carries it out. A chosen + * item's click runs before the menu's close callback, and setImmediate keeps it that way even + * if a platform delivered them in the other order. + */ + ipcMain.handle('show-tab-context-menu', (event, info) => + new Promise((resolve) => { + const win = BrowserWindow.fromWebContents(event.sender); + if (!win) { + resolve(null); + return; + } + let settled = false; + const finish = (action) => { + if (settled) return; + settled = true; + resolve(action); + }; + const hasPath = !!info && typeof info.path === 'string' && info.path !== ''; + const menu = Menu.buildFromTemplate(appMenu.buildTabMenuTemplate({ hasPath }, (action) => () => finish(action))); + menu.popup({ window: win, callback: () => setImmediate(() => finish(null)) }); + }) + ); + + // Temp files a crashed run left behind (tempCleanup.cjs). Later, off the startup path. + setTimeout(() => removeStaleTempFiles(app.getPath('temp')), 10000); } -/** Stop watching a path (its tab closed) and drop its save-suppression record. */ -function unwatchFile(filePath) { - if (!filePath) return; +/** + * Stop tracking a path: its tab closed, or Save As moved the tab elsewhere. The watcher goes, and + * so does the path's place among the files the renderer may save to. + */ +function forgetPath(filePath) { + if (typeof filePath !== 'string' || !filePath) return; const key = watchKey(filePath); - const watcher = fileWatchers.get(key); - if (watcher) { - watcher.close(); - fileWatchers.delete(key); - } - lastSavedByApp.delete(key); + fileWatcher.unwatch(filePath); + fileFormats.delete(key); + knownText.delete(key); + forcedEncodings.delete(key); } -async function openAndWatchFile(filePath, opts = {}) { - let stat; - try { - stat = fs.statSync(filePath); - } catch (err) { - // Gone (a stale recent, or a restored tab whose file was deleted since): stay quiet, as always. - if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return; - // Anything else the user needs to hear. Under snap confinement "permission denied" means an - // interface is not connected, and until 1.13.2 it looked exactly like "no such file". - const { title, message } = describeFsError(err, filePath, 'open'); - dialog.showErrorBox(title, message); - return; +/* + * ── Opening ─────────────────────────────────────────────────────────────────────────────────── + * Every open funnels through openAndWatchFile: the dialog, argv and second instances, and + * open-recent-file (recents, drag and drop, session restore). + * + * Reads run in parallel, but tabs are delivered in the order the opens were asked for. Session + * restore asks for every path at once, and with async reads a small file would otherwise + * overtake a large one and shuffle the user's tabs. The wait for an earlier open is capped at + * OPEN_ORDER_WAIT_MS, so one tab on a dead network share cannot hold every other tab back for + * the whole network timeout; it arrives late, in its own time. + */ +const OPEN_ORDER_WAIT_MS = 2000; +let openDelivery = Promise.resolve(); + +/** + * Open a file in a tab and watch it. Resolves { ok: true }, or { ok: false, reason }: 'missing' + * (gone, or not a file; quiet, as always, since a stale recent or a restored tab whose file was + * deleted is not news) or 'error' (the user has been told why). + */ +function openAndWatchFile(filePath, opts = {}) { + const reading = readDocument(filePath).then((doc) => ({ doc }), (err) => ({ err })); + const turn = Promise.race([openDelivery, new Promise((resolve) => setTimeout(resolve, OPEN_ORDER_WAIT_MS))]); + const delivered = turn + .then(() => reading) + .then(({ doc, err }) => deliverOpenedFile(filePath, opts, doc, err)) + .catch((err) => { + console.error('Error opening file:', err); + return { ok: false, reason: 'error' }; + }); + openDelivery = delivered; + return delivered; +} + +function deliverOpenedFile(filePath, opts, doc, err) { + if (err) { + if (err.code === 'ENOENT' || err.code === 'ENOTDIR' || err.code === 'NOT_FILE') return { ok: false, reason: 'missing' }; + reportOpenFailure(filePath, err, opts); + return { ok: false, reason: 'error' }; } - if (!stat.isFile()) return; + /* + * Since tabs, opening a file ADDS a tab rather than replacing anything, so there is no + * unsaved-changes gate here any more. (If the file is already open, the renderer just + * activates its existing tab.) Dirty buffers are guarded where something is actually + * discarded: closing a tab, and closing the window. + */ + const key = watchKey(filePath); + const format = adoptFormat(key, doc); + fileFormats.set(key, format); + knownText.set(key, textDigest(doc.text)); + rememberRecentFile(filePath); - try { + if (mainWindow) { /* - * Since tabs, opening a file ADDS a tab rather than replacing anything, so there is no - * unsaved-changes gate here any more. (If the file is already open, the renderer just - * activates its existing tab.) Dirty buffers are guarded where something is actually - * discarded: closing a tab, and closing the window. + * `fromRestore` rides along so the renderer can tell a tab the user just asked for from one + * it is merely reinstating. Session restore replays every path from last time, and each + * replay used to activate its tab, so double-clicking a file to LAUNCH FATE landed you on + * whichever restored tab happened to arrive last, not on the file you opened. + * `format` is how the file is stored; the text itself is already decoded, BOM-free, `\n`. */ - if (stat.size > MAX_FILE_BYTES) { - dialog.showErrorBox( - 'File too large', - `${path.basename(filePath)} is ${(stat.size / 1048576).toFixed(1)} MB. ` + - `FATE opens text files up to ${MAX_FILE_BYTES / 1048576} MB.` - ); - return; - } + mainWindow.webContents.send('open-file', doc.text, path.basename(filePath), filePath, { + fromRestore: !!opts.fromRestore, + format + }); + } + fileWatcher.watch(filePath); + return { ok: true }; +} - const buffer = fs.readFileSync(filePath); - if (isProbablyBinary(buffer)) { - dialog.showErrorBox( - 'Not a text file', - `${path.basename(filePath)} appears to be a binary file, which FATE cannot display.` - ); - return; - } +/** Error-box wording for a file that would not open, plus the reason/advice a summary lists. */ +function describeOpenFailure(filePath, err) { + const name = path.basename(filePath); + const limitMb = MAX_FILE_BYTES / 1048576; + if (err.code === 'TOO_LARGE') { + const mb = (err.size / 1048576).toFixed(1); + return { + title: 'File too large', + message: `${name} is ${mb} MB. FATE opens text files up to ${limitMb} MB.`, + reason: `${mb} MB, over the ${limitMb} MB limit`, + advice: null + }; + } + if (err.code === 'BINARY') { + return { + title: 'Not a text file', + message: `${name} appears to be a binary file, which FATE cannot display.`, + reason: 'looks like a binary file', + advice: null + }; + } + // Under snap confinement "permission denied" means an interface is not connected, and until + // 1.13.2 it looked exactly like "no such file"; describeFsError says which. + const { title, message, reason, advice } = describeFsError(err, filePath, 'open'); + return { title, message, reason, advice }; +} - const content = buffer.toString('utf-8'); - const name = path.basename(filePath); - lastSavedByApp.delete(watchKey(filePath)); // fresh read, nothing saved from the app yet - rememberRecentFile(filePath); +/* + * Session restore reopens every tab at once, and each one that failed used to raise its own modal + * error box: a session on an unplugged drive meant clicking through a box per file. Failures of + * restored tabs are collected instead and reported in ONE dialog once they stop arriving: each + * file with its reason, then each distinct remedy (such as the snap guidance) once. + */ +const RESTORE_REPORT_DELAY_MS = 600; +const restoreFailures = []; +let restoreReportTimer = null; + +function reportOpenFailure(filePath, err, opts) { + const failure = describeOpenFailure(filePath, err); + if (!opts.fromRestore) { + dialog.showErrorBox(failure.title, failure.message); + return; + } + restoreFailures.push({ name: path.basename(filePath), ...failure }); + clearTimeout(restoreReportTimer); + restoreReportTimer = setTimeout(showRestoreFailures, RESTORE_REPORT_DELAY_MS); +} - if (mainWindow) { - /* - * `fromRestore` rides along so the renderer can tell a tab the user just asked for from one - * it is merely reinstating. Session restore replays every path from last time, and each - * replay used to activate its tab, so double-clicking a file to LAUNCH FATE landed you on - * whichever restored tab happened to arrive last, not on the file you opened. - */ - mainWindow.webContents.send('open-file', content, name, filePath, { fromRestore: !!opts.fromRestore }); - } +function showRestoreFailures() { + restoreReportTimer = null; + const failures = restoreFailures.splice(0); + if (failures.length === 0) return; + let options; + if (failures.length === 1) { + const [failure] = failures; + options = { type: 'error', title: failure.title, message: failure.title, detail: failure.message }; + } else { + const remedies = [...new Set(failures.map((f) => f.advice).filter(Boolean))]; + options = { + type: 'warning', + title: 'Some tabs could not be restored', + message: `FATE couldn't reopen ${failures.length} files from your last session.`, + detail: [failures.map((f) => `${f.name}: ${f.reason}`).join('\n'), ...remedies].join('\n\n') + }; + } + const win = mainWindow && !mainWindow.isDestroyed() ? mainWindow : null; + (win ? dialog.showMessageBox(win, options) : dialog.showMessageBox(options)).catch(() => {}); +} - watchFile(filePath); - } catch (e) { - console.error('Error reading file:', e); - const { title, message } = describeFsError(e, filePath, 'open'); - dialog.showErrorBox(title, message); +/** + * Open a path the renderer names (a recents entry, a dropped file, a restored session tab); also + * the way in for an Open Recent menu. A missing file is dropped from recents so the list heals + * itself instead of offering it again. Resolves { ok } or { ok: false, reason }. + */ +async function openRecentFile(filePath, opts) { + if (typeof filePath !== 'string' || !filePath) return { ok: false, reason: 'missing' }; + const result = await openAndWatchFile(filePath, { fromRestore: !!(opts && opts.fromRestore) }); + if (result.reason === 'missing') forgetRecentFile(filePath); + return result; +} + +/* + * ── Saving ──────────────────────────────────────────────────────────────────────────────────── + * The renderer hands over `\n` text and, optionally, the format to write it in. Without one, the + * format the path was opened or last saved with applies (fileFormats), and for a path FATE has + * never read, the platform default, so a CRLF or UTF-16 file goes back the way it came. + */ +const saveFailure = (code, error) => ({ ok: false, code, error }); + +/** Settle the format and encode, before anything touches the disk: { ok, format, data } or a failure. */ +function prepareSave(name, content, passedFormat, recordedFormat) { + try { + const format = resolveFormat(passedFormat, recordedFormat || defaultFormat()); + return { ok: true, format, data: encode(content, format) }; + } catch (err) { + // UNENCODABLE's message says what and where: "“→” (U+2192) on line 3 can't be saved in + // Windows-1252. Save the file as UTF-8 instead." + if (err.code === 'UNENCODABLE') return saveFailure(err.code, `${name}: ${err.message}`); + return saveFailure(err.code || 'BAD_FORMAT', `Can't save ${name}: ${err.message}`); + } +} + +/** + * Write encoded bytes atomically (textFiles.cjs) and record what is now on disk. The path's + * watcher is held meanwhile, so neither a reload racing the write nor the write's own events can + * come back to the renderer as an external change; then it is re-armed, because an atomic save + * leaves a new file at the path. + */ +async function commitSave(filePath, prepared, content) { + const key = watchKey(filePath); + const release = fileWatcher.hold(filePath); + try { + await writeFileAtomic(filePath, prepared.data); + fileFormats.set(key, prepared.format); + knownText.set(key, textDigest(normalizeText(content))); + if (forcedEncodings.get(key) !== prepared.format.encoding) forcedEncodings.delete(key); + fileWatcher.watch(filePath, { rearm: true }); + return { ok: true, format: prepared.format }; + } catch (err) { + return saveFailure(err.code || 'EIO', describeFsError(err, filePath, 'save').short); + } finally { + release(); } } -function handleArgs(argv) { +/* + * ── Opening files from a command line ───────────────────────────────────────────────────────── + * Selecting several files in a file manager and choosing Open with FATE passes them all, so every + * openable argument opens, in order (up to 1.13.4 only the first did). Relative paths resolve + * against the working directory of the process that received them; see launchArgs.cjs. + * + * `rendererReady`: 'open-file' sent before the renderer has registered its listener is lost, so a + * second instance's command line that arrives while the window is still loading waits in + * `pendingSecondInstanceArgs` until 'app-ready' (createWindow). + */ +let rendererReady = false; +const pendingSecondInstanceArgs = []; +let openArgsQueue = Promise.resolve(); + +function handleArgs(argv, cwd) { // argv also carries the exe path, the app path in dev, and Chromium switches; see // isOpenableArg for why this is a shape check and not an extension list. - const filePath = argv.slice(1).find(isOpenableArg); - if (filePath) { - openAndWatchFile(filePath); - } + const seen = new Set(); + const files = candidateFilePaths(argv, { cwd }).filter((filePath) => { + const key = watchKey(filePath); + if (seen.has(key) || !isOpenableArg(filePath)) return false; + seen.add(key); + return true; + }); + // One at a time, and one command line after another: the tabs open in the order given, and the + // last file named is the one left in front. + openArgsQueue = openArgsQueue.then(async () => { + for (const filePath of files) { + try { + await openAndWatchFile(filePath); + } catch (err) { + console.error('Could not open', filePath, err); + } + } + }); + return openArgsQueue; } app.whenReady().then(() => { + if (!gotTheLock) return; // a second instance: it handed its files over and is quitting (top of file) + // Register custom protocol for local images /* * Local images referenced from a markdown file. URL shape: fate-local://local/ { * path that never existed. Every local image failed with net::ERR_FILE_NOT_FOUND while the * attribute still read `fate-local:///C:/…`. Verified on Electron 42.3.3 and 42.11.3 * alike; it was never a Chromium regression, just the URL shape. + * + * What it will serve (absolute local paths to image files, never a network path) is decided in + * resolveLocalImagePath, which explains each refusal. Network paths are refused BEFORE anything + * touches the file system: even a stat of `\\host\share\x.png` makes Windows connect to the host. */ - protocol.handle('fate-local', (request) => { - let urlPath; + protocol.handle('fate-local', async (request) => { + const target = resolveLocalImagePath(request.url, process.platform); + if (!target.ok) return new Response(target.reason, { status: target.status }); try { - urlPath = decodeURIComponent(new URL(request.url).pathname); + const stat = await fs.promises.stat(target.filePath); + if (!stat.isFile()) return new Response('Not found', { status: 404 }); } catch { - return new Response('Bad fate-local URL', { status: 400 }); + return new Response('Not found', { status: 404 }); } - // '/C:/Users/…' → 'C:/Users/…'. POSIX paths keep their leading slash. - if (process.platform === 'win32' && /^\/[a-zA-Z]:/.test(urlPath)) urlPath = urlPath.slice(1); - return net.fetch(pathToFileURL(urlPath).toString()); + return net.fetch(pathToFileURL(target.filePath).toString()); }); + startAppShell(); // spellcheck, the application menu and their IPC; before the window exists createWindow(); ipcMain.handle('get-app-version', () => app.getVersion()); @@ -1325,9 +2029,19 @@ app.whenReady().then(() => { documentEdited = !!edited; }); - ipcMain.handle('store-get', (event, key) => store.get(key)); + /* + * Settings: only the keys the renderer owns, each type-checked (rendererSettings.cjs). Main-only + * state such as recentFiles and the Windows registrationStamp is out of the renderer's reach. + */ + ipcMain.handle('store-get', (event, key) => (isRendererSettingKey(key) ? store.get(key) : undefined)); ipcMain.handle('store-set', (event, key, val) => { + const check = checkRendererSetting(key, val); + if (!check.ok) { + console.warn(`store-set refused: ${check.error}`); + return check; + } store.set(key, val); + return { ok: true }; }); ipcMain.handle('open-file-dialog', async () => { @@ -1349,18 +2063,22 @@ app.whenReady().then(() => { } }); - // ── Saving (code editor) ────────────────────────────────────────────────────────────────── - ipcMain.handle('save-file', (event, filePath, content) => { - try { - if (!filePath) return { ok: false, error: 'No file path to save to' }; - // Record BEFORE writing: fs.watch can fire before writeFileSync returns. - lastSavedByApp.set(watchKey(filePath), content); - fs.writeFileSync(filePath, content, 'utf-8'); - return { ok: true }; - } catch (err) { - lastSavedByApp.delete(watchKey(filePath)); - return { ok: false, error: describeFsError(err, filePath, 'save').short }; + // ── Saving ──────────────────────────────────────────────────────────────────────────────── + /** + * Save a tab to its own path, in the format passed or else the path's recorded one. Only paths + * FATE has open are accepted (isOpenPath); anywhere else is refused, and Save As is the way to + * write somewhere new. Resolves { ok: true, format } with the format actually written, or + * { ok: false, code, error }. + */ + ipcMain.handle('save-file', async (event, filePath, content, format) => { + if (typeof filePath !== 'string' || !filePath) return saveFailure('BAD_ARGS', 'No file path to save to'); + if (typeof content !== 'string') return saveFailure('BAD_ARGS', 'Nothing to save'); + const name = path.basename(filePath); + if (!isOpenPath(filePath)) { + return saveFailure('NOT_OPEN', `Can't save ${name}: it isn't a file FATE has open. Use Save As instead.`); } + const prepared = prepareSave(name, content, format, fileFormats.get(watchKey(filePath))); + return prepared.ok ? commitSave(filePath, prepared, content) : prepared; }); /** @@ -1368,13 +2086,22 @@ app.whenReady().then(() => { * the new path without re-sending 'open-file'. The renderer already holds the content, and a * reload would discard the cursor and scroll position. The renderer updates its own name/path * from the response instead. `oldPath` (the tab's previous path, if any) stops being watched. + * + * The format is the one passed, else `oldPath`'s, else the default, and the text is encoded + * BEFORE the dialog opens, so text that Windows-1252 can't hold fails without a wasted dialog. */ - ipcMain.handle('save-file-as', async (event, suggestedName, content, oldPath) => { - let chosenPath = null; + ipcMain.handle('save-file-as', async (event, suggestedName, content, oldPath, format) => { + if (typeof content !== 'string') return saveFailure('BAD_ARGS', 'Nothing to save'); + const suggested = typeof suggestedName === 'string' && suggestedName ? suggestedName : 'untitled.txt'; + const fromPath = isOpenPath(oldPath) ? oldPath : null; + const prepared = prepareSave(suggested, content, format, fromPath && fileFormats.get(watchKey(fromPath))); + if (!prepared.ok) return prepared; + + let filePath; try { - const { canceled, filePath } = await dialog.showSaveDialog(mainWindow, { + const result = await dialog.showSaveDialog(mainWindow, { title: 'Save As', - defaultPath: suggestedName || 'untitled.txt', + defaultPath: suggested, // "All files" first: with a curated filter selected, Windows appends that filter's first // extension to any name typed without one, so saving a new buffer as `Dockerfile` or // `.env` produced `Dockerfile.md`. The typed name is now taken literally. @@ -1384,24 +2111,57 @@ app.whenReady().then(() => { { name: 'Code files', extensions: [...CODE_EXTENSIONS] } ] }); - if (canceled || !filePath) return { ok: false, canceled: true }; - chosenPath = filePath; - - lastSavedByApp.set(watchKey(filePath), content); - fs.writeFileSync(filePath, content, 'utf-8'); - if (oldPath && watchKey(oldPath) !== watchKey(filePath)) unwatchFile(oldPath); - rememberRecentFile(filePath); - watchFile(filePath); - return { ok: true, filePath, name: path.basename(filePath) }; + if (result.canceled || !result.filePath) return { ok: false, canceled: true }; + filePath = result.filePath; } catch (err) { - if (chosenPath) lastSavedByApp.delete(watchKey(chosenPath)); - return { ok: false, error: chosenPath ? describeFsError(err, chosenPath, 'save').short : err.message }; + return saveFailure('DIALOG', err.message); } + + const saved = await commitSave(filePath, prepared, content); + if (!saved.ok) return saved; + if (fromPath && watchKey(fromPath) !== watchKey(filePath)) forgetPath(fromPath); + rememberRecentFile(filePath); + return { ok: true, filePath, name: path.basename(filePath), format: saved.format }; }); - /** A tab closed: stop watching its file. */ + /** A tab closed: stop watching its file, and stop accepting saves to it (forgetPath). */ ipcMain.on('close-file', (event, filePath) => { - unwatchFile(filePath); + forgetPath(filePath); + }); + + /** + * Re-read an open file decoding it as `encoding`, for a file whose encoding was guessed wrong. + * Refused rather than opened lossy when that decoding could not be saved back unchanged (see + * fileFormat.decode). The choice sticks for the tab's later reloads (forcedEncodings). The + * watcher is held so a reload already in flight, decoded the old way, cannot land afterwards. + * Resolves { ok: true, content, format } or { ok: false, code, error }. + */ + ipcMain.handle('reopen-with-encoding', async (event, filePath, encoding) => { + if (!isOpenPath(filePath)) return { ok: false, code: 'NOT_OPEN', error: "That file isn't open in FATE" }; + if (!ENCODINGS.includes(encoding)) return { ok: false, code: 'BAD_ENCODING', error: `Unknown encoding: ${String(encoding)}` }; + const key = watchKey(filePath); + const name = path.basename(filePath); + const release = fileWatcher.hold(filePath); + try { + const doc = await readTextFile(filePath, { + maxBytes: MAX_FILE_BYTES, + forcedEncoding: encoding, + defaultEol: fileFormats.get(key)?.eol + }); + if (!isOpenPath(filePath)) return { ok: false, code: 'NOT_OPEN', error: `${name} was closed` }; + fileFormats.set(key, doc.format); + knownText.set(key, textDigest(doc.text)); + forcedEncodings.set(key, encoding); + return { ok: true, content: doc.text, format: doc.format }; + } catch (err) { + if (err.code === 'BINARY' || err.code === 'INVALID') { + return { ok: false, code: err.code, error: `Can't reopen ${name} as ${ENCODING_LABELS[encoding]}: ${err.message}.` }; + } + if (err.code === 'TOO_LARGE') return { ok: false, code: err.code, error: describeOpenFailure(filePath, err).message }; + return { ok: false, code: err.code, error: describeFsError(err, filePath, 'open').short }; + } finally { + release(); + } }); /** @@ -1444,24 +2204,36 @@ app.whenReady().then(() => { // ── Recent documents ────────────────────────────────────────────────────────────────────── ipcMain.handle('get-recent-files', () => readRecentFiles()); - ipcMain.handle('open-recent-file', (event, filePath, opts) => { - // Re-check existence here rather than trusting the renderer's cached `exists` flag; the file - // may have been deleted since the list was rendered. - if (!filePath || !fs.existsSync(filePath)) { - // Drop the dead entry so the list self-heals instead of offering it again. - const remaining = (store.get('recentFiles') || []).filter((e) => watchKey(e.path) !== watchKey(filePath)); - store.set('recentFiles', remaining); - return { ok: false, reason: 'missing' }; - } - openAndWatchFile(filePath, opts || {}); - return { ok: true }; - }); + // Recents, drag and drop and session restore all open through here (see openRecentFile). + ipcMain.handle('open-recent-file', (event, filePath, opts) => openRecentFile(filePath, opts)); ipcMain.handle('clear-recent-files', () => { - store.set('recentFiles', []); + clearRecentFiles(); return { ok: true }; }); + // ── Hot exit ────────────────────────────────────────────────────────────────────────────── + /* + * Unsaved buffers, backed up by the renderer a moment after each change and offered back after + * a crash (backups.cjs owns the file format, the id rules and the size caps). Failures resolve + * { ok: false, code, error } rather than reject; a backup folder that can't be read lists as []. + */ + const backups = createBackupStore(path.join(app.getPath('userData'), 'backups')); + const backupResult = (job) => + job.then( + (value) => ({ ok: true, ...value }), + (err) => ({ ok: false, code: err.code, error: err.message }) + ); + ipcMain.handle('backup-write', (event, id, data) => backupResult(backups.write(id, data))); + ipcMain.handle('backup-remove', (event, id) => backupResult(backups.remove(id))); + ipcMain.handle('backup-clear', () => backupResult(backups.clear())); + ipcMain.handle('backup-list', () => + backups.list().catch((err) => { + console.error('Could not list backups:', err.message); + return []; + }) + ); + // ── Printing & PDF export ───────────────────────────────────────────────────────────────── // Both are wrapped so a render failure surfaces in the UI instead of rejecting into the void. ipcMain.handle('print-preview', async (event, docName) => { @@ -1499,15 +2271,18 @@ app.whenReady().then(() => { * via GDI+ (one hidden PowerShell call, ~300 names) and cached; font installs mid-session are * rare enough that a restart picking them up is fine. Purely local: the list never leaves the * process, matching the privacy posture. + * + * The cache holds the PROMISE, so callers that arrive while the enumeration is still running + * share it instead of each starting another PowerShell or fc-list (the old cache only filled in + * once the first run finished). An empty result is not kept, so a run that failed (no fc-list, + * a timeout) is retried by the next caller. */ - let systemFontsCache = null; + let systemFontsPromise = null; const finishFontList = (resolve, names) => { const clean = names.filter((n) => typeof n === 'string' && n.trim()).map((n) => n.trim()); - systemFontsCache = [...new Set(clean)].sort((a, b) => a.localeCompare(b)); - resolve(systemFontsCache); + resolve([...new Set(clean)].sort((a, b) => a.localeCompare(b))); }; - ipcMain.handle('get-system-fonts', () => { - if (systemFontsCache) return systemFontsCache; + const enumerateSystemFonts = () => { if (process.platform !== 'win32') { /* * Linux/macOS: fontconfig's `fc-list : family` prints one line per face, and a line can @@ -1551,6 +2326,15 @@ app.whenReady().then(() => { } ); }); + }; + ipcMain.handle('get-system-fonts', () => { + if (!systemFontsPromise) { + systemFontsPromise = enumerateSystemFonts().then((fonts) => { + if (fonts.length === 0) systemFontsPromise = null; + return fonts; + }); + } + return systemFontsPromise; }); /* @@ -1591,25 +2375,9 @@ app.whenReady().then(() => { }) ); + // The status-bar button and the palette; Help → Check for Updates runs the same function. ipcMain.handle('check-for-updates', () => { - /* - * Store builds: electron-updater cannot update an AppX; the Store owns that pipeline, and - * the previous behaviour ("Checking for updates…" followed by silence or an error) looked - * broken because it was. Route to the Store's own updates page instead. - */ - if (isWindowsStore) { - shell.openExternal('ms-windows-store://downloadsandupdates'); - return; - } - // Flatpak / Snap / apt / dnf own updates for this install (see detectUpdateSource). The - // button still does something useful: it shows what the newest release contains. - if (updateSource.managed) { - shell.openExternal('https://github.com/VagueDustin/FATE/releases/latest'); - return; - } - if (!isDev && store.get('autoUpdatesEnabled')) { - autoUpdater.checkForUpdates(); - } + checkForUpdatesNow(); }); ipcMain.handle('install-update', () => { @@ -1637,7 +2405,8 @@ app.whenReady().then(() => { }); if (!isDev && !updateSource.managed) { - autoUpdater.checkForUpdatesAndNotify(); + // Offline launches reject here; the 'error' handler above has already told the status bar. + autoUpdater.checkForUpdatesAndNotify().catch(() => {}); } ensureWindowsRegistration(); @@ -1671,16 +2440,19 @@ app.on('activate', () => { } }); -const gotTheLock = app.requestSingleInstanceLock() - -if (!gotTheLock) { - app.quit() -} else { - app.on('second-instance', (event, commandLine, workingDirectory) => { - if (mainWindow) { - if (mainWindow.isMinimized()) mainWindow.restore() - mainWindow.focus() - handleArgs(commandLine) +/* + * Another launch while FATE runs (a file double-clicked, Open with FATE): the lock at the top of + * this file turned it away, and its command line arrives here. Its relative paths are relative to + * ITS working directory, not this process's. A launch that lands while the window is still + * loading is queued for 'app-ready' rather than sent to a renderer that is not listening yet. + */ +if (gotTheLock) { + app.on('second-instance', (_event, commandLine, workingDirectory) => { + if (mainWindow && !mainWindow.isDestroyed()) { + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.focus(); } - }) + if (rendererReady) handleArgs(commandLine, workingDirectory); + else pendingSecondInstanceArgs.push({ argv: commandLine, cwd: workingDirectory }); + }); } diff --git a/electron/preload.cjs b/electron/preload.cjs index 8d5d180..8d5ae7c 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -17,19 +17,40 @@ contextBridge.exposeInMainWorld('electronAPI', { return null; } }, + /* + * ── File formats ───────────────────────────────────────────────────────────────────────────── + * The main process owns how a file is stored on disk. The renderer only ever sees clean text: + * LF line breaks, no byte-order mark, already decoded. Each open carries the file's FORMAT, + * { encoding: 'utf8' | 'utf16le' | 'utf16be' | 'windows1252', bom: boolean, eol: '\n' | '\r\n' | '\r' } + * and a save writes the text back in a format: the one passed, or else the one the file was + * opened with. So a CRLF file stays CRLF and a UTF-16 file stays UTF-16 without the renderer + * doing anything. + */ + // `meta.fromRestore` marks a tab being reinstated from last session rather than one the user // just asked for, so restoring tabs cannot steal focus from the file that launched the app. + // `meta.format` is the file's on-disk format (see above). onOpenFile: (callback) => { ipcRenderer.removeAllListeners('open-file'); ipcRenderer.on('open-file', (_event, content, name, path, meta) => callback(content, name, path, meta || {})); }, // The path rides along so the renderer can route the update to the right TAB, since any number of - // files can be watched at once since 1.10.0. + // files can be watched at once since 1.10.0. `meta.format` as for onOpenFile. onFileChanged: (callback) => { ipcRenderer.removeAllListeners('file-changed'); - ipcRenderer.on('file-changed', (_event, content, path) => callback(content, path)); + ipcRenderer.on('file-changed', (_event, content, path, meta) => callback(content, path, meta || {})); + }, + /** A watched file is gone from disk and did not come back (deleted, or moved away). */ + onFileDeleted: (callback) => { + ipcRenderer.removeAllListeners('file-deleted'); + ipcRenderer.on('file-deleted', (_event, path) => callback(path)); }, openFileDialog: () => ipcRenderer.invoke('open-file-dialog'), + /** + * Re-read a file decoding it as `encoding` (for a file whose encoding was guessed wrong). + * Resolves { ok, content, format } or { ok: false, error }; the renderer swaps the buffer itself. + */ + reopenWithEncoding: (filePath, encoding) => ipcRenderer.invoke('reopen-with-encoding', filePath, encoding), /** A tab closed: the main process stops watching its file. */ closeFile: (filePath) => ipcRenderer.send('close-file', filePath), @@ -45,9 +66,22 @@ contextBridge.exposeInMainWorld('electronAPI', { * path WITHOUT re-sending 'open-file'. The response carries { filePath, name } and the renderer * updates its own state, keeping cursor and scroll position intact. */ - saveFile: (filePath, content) => ipcRenderer.invoke('save-file', filePath, content), - saveFileAs: (suggestedName, content, oldPath) => - ipcRenderer.invoke('save-file-as', suggestedName, content, oldPath), + saveFile: (filePath, content, format) => ipcRenderer.invoke('save-file', filePath, content, format), + saveFileAs: (suggestedName, content, oldPath, format) => + ipcRenderer.invoke('save-file-as', suggestedName, content, oldPath, format), + + /** + * Hot exit: unsaved buffers (Untitled ones included) are copied to the app's data folder a moment + * after each change, so a crash or a reload loses nothing. `id` is the renderer's own stable key + * for a tab; `data` is { kind, name, path, content, format, savedContent, untitled }. + * `list()` resolves the backups left over from a session that did not end cleanly. + */ + backups: { + write: (id, data) => ipcRenderer.invoke('backup-write', id, data), + remove: (id) => ipcRenderer.invoke('backup-remove', id), + list: () => ipcRenderer.invoke('backup-list'), + clear: () => ipcRenderer.invoke('backup-clear') + }, /** * Mirror the editor's dirty flag into the main process on every transition. This is what arms @@ -135,5 +169,32 @@ contextBridge.exposeInMainWorld('electronAPI', { onUpdateMessage: (callback) => { ipcRenderer.removeAllListeners('update-message'); ipcRenderer.on('update-message', (_event, message, action) => callback(message, action)); - } + }, + + /** + * Open a link outside FATE: http(s) after the same confirmation an in-page navigation gets, + * mailto: directly. Anything else is refused. Resolves { ok }. + */ + openExternal: (url) => ipcRenderer.invoke('open-external', url), + /** Show a file in the system file manager (only for files FATE has open). */ + showItemInFolder: (filePath) => ipcRenderer.invoke('show-item-in-folder', filePath), + /** + * Native menu for a tab's right-click. `info` is { path, name }; resolves the chosen action, + * 'copyPath' | 'reveal' | 'close' | 'closeOthers' | 'closeRight', or null when dismissed. + * The renderer carries the action out. + */ + showTabContextMenu: (info) => ipcRenderer.invoke('show-tab-context-menu', info), + /** + * The application menu (Alt shows it) sends commands here: 'newFile', 'openFile', 'save', + * 'saveAs', 'print', 'exportPdf', 'closeTab', 'toggleEdit', 'toggleSplit', 'focusMode', + * 'palette', 'settings', 'shortcuts', 'about', 'find', 'gotoLine', 'gotoSymbol', 'undo', 'redo'. + */ + onMenuCommand: (callback) => { + ipcRenderer.removeAllListeners('menu-command'); + ipcRenderer.on('menu-command', (_event, command, arg) => callback(command, arg)); + }, + /** Tell the menu the user's current shortcut bindings (shown as labels; the renderer handles the keys). */ + updateMenu: (state) => ipcRenderer.send('update-menu', state), + /** Turn the spellchecker on or off. Off by default on Linux, where it downloads dictionaries. */ + setSpellcheck: (enabled) => ipcRenderer.invoke('set-spellcheck', enabled) }); diff --git a/electron/rendererSettings.cjs b/electron/rendererSettings.cjs new file mode 100644 index 0000000..93a4616 --- /dev/null +++ b/electron/rendererSettings.cjs @@ -0,0 +1,80 @@ +'use strict'; +/** + * rendererSettings.cjs: the settings the renderer may read and write through store-get/store-set. + * + * Up to 1.13.4 those two handlers passed any key straight to electron-store. That is fine while + * the renderer is trusted, but the renderer displays documents, and one sanitiser bypass would + * have let a document rewrite main-only state: `recentFiles` (whose paths main opens), the + * Windows `registrationStamp` (so the registry self-heal never runs again), or a dotted path + * into any of them, since electron-store reads `a.b` as a nested key. + * + * So the renderer gets exactly the keys below, each with a cheap type check. Main-only keys + * (recentFiles, registrationStamp, claimedTypes, and anything main adds later) are neither + * readable nor writable from the renderer. The checks are deliberately loose about CONTENT: the + * renderer already sanitises every value it reads back (resolveTheme, resolveFonts, + * resolveShortcuts, resolveCustomTheme), so this only keeps out the wrong TYPES and runaway sizes. + * + * ADDING A SETTING: add its key here, or store.set() of it is refused (main logs a warning + * naming the key, and the value is simply not saved). + * + * Free of Electron imports; test/rendererSettings.test.cjs runs it under plain node. + */ + +const isBool = (v) => typeof v === 'boolean'; +const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v); +const isString = (max) => (v) => typeof v === 'string' && v.length <= max; +const isNumber = (min, max) => (v) => typeof v === 'number' && Number.isFinite(v) && v >= min && v <= max; + +/** { action id: 'Control+Shift+X' } */ +const isShortcutMap = (v) => isPlainObject(v) && Object.values(v).every((s) => typeof s === 'string' && s.length <= 64); + +/** + * The tabs to reopen: { paths: string[], active: string | null }. Other fields are allowed so the + * renderer can keep more per-tab state here without a main-process change. + */ +const isSession = (v) => + isPlainObject(v) && + (v.paths === undefined || (Array.isArray(v.paths) && v.paths.every((p) => typeof p === 'string'))) && + (v.active === undefined || v.active === null || typeof v.active === 'string'); + +const RENDERER_SETTINGS = Object.freeze({ + theme: isString(64), + autoUpdatesEnabled: isBool, + sidebarWidth: isNumber(0, 10000), + shortcuts: isShortcutMap, + printPageSize: isString(32), + printLandscape: isBool, + editorWrap: isBool, + editorTabSize: isNumber(1, 16), + editorLint: isBool, + fonts: isPlainObject, + restoreSession: isBool, + customTheme: (v) => v === null || isPlainObject(v), + session: isSession, + // 1.14.0 + spellcheck: isBool, + remoteImages: isBool +}); + +/** No single setting needs more than this once serialised; a session of hundreds of tabs is ~50 KB. */ +const MAX_VALUE_BYTES = 256 * 1024; + +function isRendererSettingKey(key) { + return typeof key === 'string' && Object.prototype.hasOwnProperty.call(RENDERER_SETTINGS, key); +} + +/** May the renderer store `value` under `key`? Resolves { ok: true } or { ok: false, error }. */ +function checkRendererSetting(key, value) { + if (!isRendererSettingKey(key)) return { ok: false, error: `Unknown setting: ${String(key).slice(0, 64)}` }; + if (!RENDERER_SETTINGS[key](value)) return { ok: false, error: `Wrong type for setting ${key}` }; + let json; + try { + json = JSON.stringify(value); + } catch { + return { ok: false, error: `Setting ${key} cannot be stored` }; + } + if (json.length > MAX_VALUE_BYTES) return { ok: false, error: `Setting ${key} is too large` }; + return { ok: true }; +} + +module.exports = { RENDERER_SETTINGS, isRendererSettingKey, checkRendererSetting }; diff --git a/electron/spellcheck.cjs b/electron/spellcheck.cjs new file mode 100644 index 0000000..e6e7227 --- /dev/null +++ b/electron/spellcheck.cjs @@ -0,0 +1,119 @@ +'use strict'; +/** + * spellcheck.cjs: the `spellcheck` setting, applied to an Electron session. + * + * ── Why this is not just setSpellCheckerEnabled ─────────────────────────────────────────────── + * Electron's spellchecker is on by default, and on Linux it backs onto Hunspell dictionaries it + * DOWNLOADS from Google's servers (redirector.gvt1.com) the first time a window opens: a fresh + * FATE profile on Linux had `Dictionaries/en-US-10-1.bdic` in it, contradicting PRIVACY.md. + * Measured on Electron 42: neither `session.setSpellCheckerEnabled(false)` nor + * `webPreferences.spellcheck: false` (nor both) stops that download. The session loads a + * dictionary for every language in its list whether or not checking is enabled. What stops it is + * an EMPTY language list, set before the first window exists. So "off" here means disabled AND no + * languages, and "on" puts the user's language back. + * + * Windows defaults to on: Chromium uses the Windows spell checker there, which needs no download. + * + * ── Two more Electron behaviours this works around (both measured) ──────────────────────────── + * 1. A window created with `webPreferences.spellcheck: false` never marks misspellings, even after + * the session is enabled, so turning the setting on would need a restart. main.cjs creates the + * window with it true and lets the session decide, which is what makes the toggle live. + * 2. A renderer that starts while its dictionary is still loading never receives it, so checking + * silently does nothing for the whole session. That is the normal case at launch (measured: + * the dictionary is ready ~50 ms after the window is created, ~125 ms before the page has + * loaded) and after a first-time download. Re-applying "enabled" re-sends the dictionary to + * every renderer, so refresh() runs once a dictionary reports itself initialized, and main.cjs + * calls it again whenever the window's page finishes loading. Either alone misses a case. + * + * Free of Electron imports; test/spellcheck.test.cjs drives it with a fake session. + */ + +/** The setting's default when the user has never chosen: on where it costs nothing (Windows). */ +function defaultSpellcheckEnabled(platform) { + return platform === 'win32'; +} + +/** The stored setting, or the platform default when there is none (or it is not a boolean). */ +function resolveSpellcheckSetting(stored, platform) { + return typeof stored === 'boolean' ? stored : defaultSpellcheckEnabled(platform); +} + +/** + * The dictionary to check with: the first of the user's languages that Chromium has one for. + * Exact matches first (`en-GB`), then the bare language (`de-AT` → `de`), then any regional one + * (`es-CO` → the first `es-…`), then US English. Null when nothing at all is available. + */ +function pickSpellcheckLanguage(preferred, available) { + const byLower = new Map(); + for (const code of available || []) { + if (typeof code === 'string' && code) byLower.set(code.toLowerCase(), code); + } + const wanted = (preferred || []) + .filter((code) => typeof code === 'string' && code) + .map((code) => code.replace(/_/g, '-').toLowerCase()); + + for (const code of wanted) { + if (byLower.has(code)) return byLower.get(code); + } + for (const code of wanted) { + const lang = code.split('-')[0]; + if (byLower.has(lang)) return byLower.get(lang); + for (const [lower, original] of byLower) { + if (lower.startsWith(`${lang}-`)) return original; + } + } + return byLower.get('en-us') ?? null; +} + +/** + * Apply the setting to `session` now and whenever `set(enabled)` is called. Returns + * { set(enabled), refresh(), isEnabled() }; refresh() is workaround 2 above. + * + * `preferredLanguages` is what the OS says the user reads (app.getLocale() first, then + * app.getPreferredSystemLanguages()). macOS ignores language lists (it uses the system checker), + * so they are only set elsewhere. + */ +function createSpellcheckController(session, { enabled, preferredLanguages = [], platform = process.platform, warn = () => {} }) { + const setsLanguages = platform !== 'darwin'; + let on = false; + + const setLanguages = (languages) => { + if (!setsLanguages) return; + try { + session.setSpellCheckerLanguages(languages); + } catch (err) { + warn(`Spellcheck languages not applied: ${err.message}`); + } + }; + + function set(next) { + on = next === true; + if (on) { + const language = pickSpellcheckLanguage(preferredLanguages, session.availableSpellCheckerLanguages); + setLanguages(language ? [language] : []); + session.setSpellCheckerEnabled(true); + } else { + session.setSpellCheckerEnabled(false); + setLanguages([]); // no languages, no dictionary, no download: see the header + } + } + + // Workaround 2 above. Toggling the enable flag re-initialises every renderer's spellchecker with + // whatever dictionaries have loaded by now. A no-op while off: there is nothing to send. + function refresh() { + if (!on) return; + session.setSpellCheckerEnabled(false); + session.setSpellCheckerEnabled(true); + } + session.on('spellcheck-dictionary-initialized', refresh); + + set(enabled); + return { set, refresh, isEnabled: () => on }; +} + +module.exports = { + defaultSpellcheckEnabled, + resolveSpellcheckSetting, + pickSpellcheckLanguage, + createSpellcheckController +}; diff --git a/electron/tempCleanup.cjs b/electron/tempCleanup.cjs new file mode 100644 index 0000000..09c9d36 --- /dev/null +++ b/electron/tempCleanup.cjs @@ -0,0 +1,54 @@ +'use strict'; +/** + * tempCleanup.cjs: remove temp files a previous FATE run left behind. + * + * FATE writes three kinds of temp file and deletes each one itself when it is done: the print + * preview's PDF (when the preview closes), the PowerShell scripts behind the Windows association + * checks (when PowerShell exits), and the self-heal's .reg import (when reg.exe exits). A crash, a + * kill or a power cut between the write and the delete leaves the file in the temp folder for good, + * one per run. At launch, anything matching those names and older than a day goes. + * + * Only these exact name shapes, only regular files (never a symlink), and only old ones: the temp + * folder is shared with every other program (and, on Linux, every user), and a preview another + * FATE has open right now is younger than a day. Errors are ignored file by file: another user's + * file, one Windows still has open, or one already gone are all fine to skip. + * + * Free of Electron imports; test/tempCleanup.test.cjs runs it against a scratch folder. + */ + +const fs = require('fs'); +const path = require('path'); + +const STALE_TEMP_FILE_PATTERNS = Object.freeze([ + /^FATE-preview-\d+\.pdf$/, // showPrintPreview in main.cjs + /^fate-ps-\d+-\d+\.ps1$/, // runPowerShell in main.cjs + /^fate-registration-\d+\.reg$/ // ensureWindowsRegistration in main.cjs +]); + +const STALE_AFTER_MS = 24 * 60 * 60 * 1000; + +/** Resolves the names it removed. Never rejects. */ +async function removeStaleTempFiles(dir, { now = Date.now(), maxAgeMs = STALE_AFTER_MS } = {}) { + let names; + try { + names = await fs.promises.readdir(dir); + } catch { + return []; + } + const removed = []; + for (const name of names) { + if (!STALE_TEMP_FILE_PATTERNS.some((pattern) => pattern.test(name))) continue; + const file = path.join(dir, name); + try { + const stat = await fs.promises.lstat(file); + if (!stat.isFile() || now - stat.mtimeMs < maxAgeMs) continue; + await fs.promises.unlink(file); + removed.push(name); + } catch { + /* not ours to delete, still open, or already gone */ + } + } + return removed; +} + +module.exports = { removeStaleTempFiles, STALE_TEMP_FILE_PATTERNS, STALE_AFTER_MS }; diff --git a/electron/textFiles.cjs b/electron/textFiles.cjs new file mode 100644 index 0000000..74d5369 --- /dev/null +++ b/electron/textFiles.cjs @@ -0,0 +1,229 @@ +'use strict'; +/** + * textFiles.cjs: documents on disk, read and written without blocking the main thread. + * + * Up to 1.13.4 every open, live reload and save was a *Sync call on Electron's main thread, so a + * file on a slow or disconnected network share froze the whole window for the network timeout. + * Everything here is fs.promises. + * + * Saves are ATOMIC. The old writeFileSync truncated the file first and then wrote it, so a full + * disk, a dropped network share or a crash part-way through left the user with a damaged file + * and no way back. writeFileAtomic writes a temporary file beside the target, flushes it to + * disk, and renames it over the original: at every instant the path holds either the old file or + * the complete new one. + * + * Free of Electron imports on purpose; test/textFiles.test.cjs runs it under plain node. + */ +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { promisify } = require('util'); +const { decode } = require('./fileFormat.cjs'); + +const fsp = fs.promises; + +/* + * The JS realpath, not fs.promises.realpath (the native one). On Windows the native call turns a + * mapped drive letter into its \\server\share path and has failed outright on some virtual drives; + * this one walks the path with lstat/readlink and leaves drive letters alone. + */ +const realpath = promisify(fs.realpath); + +function codedError(code, message, extra) { + const err = new Error(message); + err.code = code; + return Object.assign(err, extra); +} + +/** + * Read and decode a text file: `{ text, format, bytes }` (see fileFormat.cjs for `format`; + * `bytes` is the raw Buffer, for callers that need to compare it with an encoding of the text). + * + * Throws the fs error as is (ENOENT, EACCES, …) or a coded one: NOT_FILE (a folder, a device, + * a pipe: anything readFile could hang on or that isn't a document), TOO_LARGE (with `size`), + * and decode's BINARY / INVALID. + * + * @param {string} filePath + * @param {{ maxBytes?: number, forcedEncoding?: string|null, defaultEol?: string }} [opts] + */ +async function readTextFile(filePath, { maxBytes = Infinity, forcedEncoding = null, defaultEol } = {}) { + const stat = await fsp.stat(filePath); + if (!stat.isFile()) throw codedError('NOT_FILE', `${path.basename(filePath)} is not a file`); + if (stat.size > maxBytes) throw codedError('TOO_LARGE', `${path.basename(filePath)} is too large`, { size: stat.size }); + const buffer = await fsp.readFile(filePath); + // It may have grown between the stat and the read (a log being written, say). + if (buffer.length > maxBytes) throw codedError('TOO_LARGE', `${path.basename(filePath)} is too large`, { size: buffer.length }); + const { text, format } = decode(buffer, forcedEncoding, { defaultEol }); + return { text, format, bytes: buffer }; +} + +/** + * Errors that mean "this folder or this file will not take a rename", not "the disk is broken". + * Windows refuses to replace a file another program holds open (EBUSY/EPERM) or one marked + * read-only; a folder the user can write files in but not create files in gives EACCES for the + * temporary file; a bind-mounted single file (Docker, Flatpak portals) or a FUSE mount without + * rename support gives EBUSY, EXDEV or ENOTSUP. For all of those the in-place write FATE always + * did is still worth trying. NOT on the list: ENOSPC and EDQUOT, a full disk, where an in-place + * write would truncate the original and then fail half way, which is the very damage the + * atomic path exists to prevent. + */ +const FALLBACK_CODES = new Set(['EPERM', 'EACCES', 'EBUSY', 'EXDEV', 'ENOTSUP', 'ENOSYS']); + +/** + * Write `data` into an existing file, keeping the file itself: same inode, hard links, owner, + * permissions and (on Windows) attributes, ACLs and alternate streams. + * + * 'r+' rather than 'w' on purpose: 'w' opens with CREATE_ALWAYS on Windows, which fails with + * EPERM on a hidden or system file. The length is set after writing, so a write that dies part + * way leaves the old tail rather than an empty file. + */ +async function writeInPlace(filePath, data) { + let handle; + try { + handle = await fsp.open(filePath, 'r+'); + } catch (err) { + if (err.code !== 'ENOENT') throw err; + handle = await fsp.open(filePath, 'w'); // a dangling symlink: create the file it points at + } + try { + await handle.writeFile(data); + await handle.truncate(data.length); + await handle.sync(); + } finally { + await handle.close(); + } +} + +/** + * Where a save should land and what is there now: symlinks resolved, so saving through a link + * updates the file it points at and the link stays a link (renaming over the link itself would + * replace it with a regular file). + */ +async function resolveTarget(filePath) { + let real = filePath; + try { + real = await realpath(filePath); + } catch (err) { + if (err.code === 'ENOENT') { + let link = null; + try { + link = await fsp.lstat(filePath); + } catch { + /* nothing there at all: a new file */ + } + return { path: filePath, stat: null, danglingLink: !!link && link.isSymbolicLink() }; + } + // Anything else is the resolver's problem, not the save's; write to the path as given. + } + const stat = await fsp.stat(real); + if (!stat.isFile()) throw codedError('EISDIR', `${path.basename(filePath)} is not a file`); + return { path: real, stat, danglingLink: false }; +} + +/** + * Save `data` (a Buffer) to `filePath` atomically: temporary file in the same folder, fsync, + * rename over the target. Same folder because a rename is only atomic within one file system. + * + * Kept from the original file: its permission bits and, when FATE can set it, its owner; on + * Windows its mark of the web (the Zone.Identifier stream that makes PowerShell's RemoteSigned + * policy refuse a downloaded script; losing it on save would quietly lift that protection). + * + * Falls back to an in-place write (see writeInPlace) instead when the atomic route would change + * what the file IS or cannot work: + * - the file has other hard links (a rename would split this name off from them); + * - its owner cannot be carried over (a shared file in someone else's name would become ours); + * - the temporary file or the rename is refused (FALLBACK_CODES); + * - the path is a dangling symlink (writing through it creates the target, link intact). + * The temporary file never outlives the call. + * + * @param {string} filePath + * @param {Buffer} data + * @param {{ mode?: number }} [opts] permissions for a NEW file (default 0o666, less the umask) + * @returns {Promise<{ path: string, method: 'atomic' | 'in-place' }>} + */ +async function writeFileAtomic(filePath, data, { mode = 0o666 } = {}) { + const target = await resolveTarget(filePath); + const inPlace = async () => { + await writeInPlace(target.path, data); + return { path: target.path, method: 'in-place' }; + }; + if (target.danglingLink) return inPlace(); + /* + * A rename only needs permission on the FOLDER, so without this check a read-only file in a + * writable folder would be silently replaced, where the old in-place write was refused. A file + * marked read-only (or a git-annex locked one) must stay protected: refuse with EACCES/EPERM. + */ + if (target.stat) await fsp.access(target.path, fs.constants.W_OK); + if (target.stat && target.stat.nlink > 1) return inPlace(); + + const dir = path.dirname(target.path); + // Dot-prefixed so file managers and `ls` don't flash it up during the save. + const tmp = path.join(dir, `.${path.basename(target.path)}.${process.pid}-${crypto.randomBytes(4).toString('hex')}.fate-tmp`); + const posix = process.platform !== 'win32'; + let handle = null; + let tmpExists = false; + try { + try { + handle = await fsp.open(tmp, 'wx', target.stat ? target.stat.mode & 0o777 : mode); + tmpExists = true; + } catch (err) { + if (target.stat && FALLBACK_CODES.has(err.code)) return await inPlace(); + throw err; + } + // Before the write, so a refusal costs nothing. + if (target.stat && posix && (target.stat.uid !== process.getuid() || target.stat.gid !== process.getgid())) { + try { + await handle.chown(target.stat.uid, target.stat.gid); + } catch { + return await inPlace(); + } + } + await handle.writeFile(data); + await handle.sync(); + if (target.stat && posix) { + // open()'s mode is filtered through the umask; set the original bits exactly (after the + // chown, which clears set-id bits). A file system without Unix permissions (FAT, exFAT, + // some network mounts) refuses, and that's fine. + await handle.chmod(target.stat.mode & 0o7777).catch(() => {}); + } + await handle.close(); + handle = null; + + if (target.stat && !posix) { + try { + const zone = await fsp.readFile(`${target.path}:Zone.Identifier`); + await fsp.writeFile(`${tmp}:Zone.Identifier`, zone); + } catch { + /* no mark of the web, or a drive without alternate data streams */ + } + } + + try { + await fsp.rename(tmp, target.path); + tmpExists = false; + } catch (err) { + if (target.stat && FALLBACK_CODES.has(err.code)) return await inPlace(); + throw err; + } + + // Make the rename itself durable (the folder entry), where the platform allows it. + if (posix) { + try { + const dirHandle = await fsp.open(dir, 'r'); + try { + await dirHandle.sync(); + } finally { + await dirHandle.close(); + } + } catch { + /* best effort */ + } + } + return { path: target.path, method: 'atomic' }; + } finally { + if (handle) await handle.close().catch(() => {}); + if (tmpExists) await fsp.unlink(tmp).catch(() => {}); + } +} + +module.exports = { readTextFile, writeFileAtomic }; diff --git a/eslint.config.js b/eslint.config.js index ea36dd3..5593133 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh' import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ - globalIgnores(['dist']), + globalIgnores(['dist', 'dist-electron', 'snapbuild', 'flatpak/rendered']), { files: ['**/*.{js,jsx}'], extends: [ @@ -18,4 +18,22 @@ export default defineConfig([ parserOptions: { ecmaFeatures: { jsx: true } }, }, }, + // The main process and the preload. Until 1.14 nothing matched .cjs, so ESLint parsed these + // files and applied no rules at all. + { + files: ['electron/**/*.cjs'], + extends: [js.configs.recommended], + languageOptions: { + sourceType: 'commonjs', + globals: globals.node, + }, + }, + // Build and maintenance scripts, run by Node (the icon generators, the electron-builder hooks). + { + files: ['scripts/**/*.{js,mjs,cjs}'], + extends: [js.configs.recommended], + languageOptions: { + globals: globals.node, + }, + }, ]) diff --git a/flatpak/flathub.json b/flatpak/flathub.json index 637604e..aace8d2 100644 --- a/flatpak/flathub.json +++ b/flatpak/flathub.json @@ -1,3 +1,4 @@ { - "only-arches": ["x86_64"] + "only-arches": ["x86_64"], + "automerge-flathubbot-prs": true } diff --git a/index.html b/index.html index b64f8de..902786e 100644 --- a/index.html +++ b/index.html @@ -12,6 +12,10 @@ `data-theme` is set here as well as by React so the first paint is already navy; otherwise there is a flash of unstyled content before the store round-trip resolves. + + Builds get a Content-Security-Policy injected first in (vite.config.js): no + inline scripts, nothing loaded from outside the app but images the user chooses to load. + Anything added here has to live within it. --> FATE - Formatted Article & Text Editor diff --git a/package-lock.json b/package-lock.json index d380199..c0224b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,18 @@ { "name": "fate-markdown-viewer", - "version": "1.13.4", + "version": "1.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fate-markdown-viewer", - "version": "1.13.4", + "version": "1.14.0", "license": "AGPL-3.0-only", "dependencies": { + "electron-store": "^8.1.0", + "electron-updater": "^6.8.3" + }, + "devDependencies": { "@codemirror/autocomplete": "^6.20.3", "@codemirror/commands": "^6.10.4", "@codemirror/language": "^6.12.4", @@ -18,6 +22,7 @@ "@codemirror/search": "^6.7.1", "@codemirror/state": "^6.7.1", "@codemirror/view": "^6.43.8", + "@eslint/js": "^10.0.1", "@fontsource/cascadia-code": "^5.3.0", "@fontsource/cinzel": "^5.3.0", "@fontsource/fira-code": "^5.3.0", @@ -32,37 +37,32 @@ "@fontsource/source-serif-4": "^5.3.0", "@lezer/highlight": "^1.2.3", "@phosphor-icons/react": "^2.1.10", - "codemirror": "^6.0.2", - "dompurify": "^3.4.16", - "electron-store": "^8.1.0", - "electron-updater": "^6.8.3", - "highlight.js": "^11.11.1", - "katex": "^0.17.0", - "marked": "^18.0.5", - "marked-highlight": "^2.2.4", - "marked-katex-extension": "^5.1.10", - "mermaid": "^11.16.1", - "react": "^19.2.6", - "react-dom": "^19.2.6" - }, - "devDependencies": { - "@eslint/js": "^10.0.1", "@types/dompurify": "^3.0.5", "@types/highlight.js": "^9.12.4", "@types/marked": "^5.0.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", + "codemirror": "^6.0.2", "concurrently": "^10.0.3", "cross-env": "^10.1.0", + "dompurify": "^3.4.16", "electron": "^42.3.3", "electron-builder": "^26.8.1", "eslint": "^10.3.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", + "highlight.js": "^11.11.1", + "katex": "^0.17.0", + "marked": "^18.0.5", + "marked-highlight": "^2.2.4", + "marked-katex-extension": "^5.1.10", + "mermaid": "^11.16.1", "png-to-ico": "^3.0.2", "puppeteer-core": "^25.5.0", + "react": "^19.2.6", + "react-dom": "^19.2.6", "sharp": "^0.35.3", "vite": "^8.0.12", "wait-on": "^9.0.10" @@ -72,6 +72,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", + "dev": true, "license": "MIT", "dependencies": { "package-manager-detector": "^1.3.0", @@ -325,18 +326,21 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", + "dev": true, "license": "MIT" }, "node_modules/@chevrotain/types": { "version": "11.1.2", "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz", "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", + "dev": true, "license": "Apache-2.0" }, "node_modules/@codemirror/autocomplete": { "version": "6.20.3", "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "dev": true, "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", @@ -349,6 +353,7 @@ "version": "6.10.4", "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz", "integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==", + "dev": true, "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", @@ -361,6 +366,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/@codemirror/lang-angular/-/lang-angular-0.1.4.tgz", "integrity": "sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g==", + "dev": true, "license": "MIT", "dependencies": { "@codemirror/lang-html": "^6.0.0", @@ -375,6 +381,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/@codemirror/lang-cpp/-/lang-cpp-6.0.3.tgz", "integrity": "sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==", + "dev": true, "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", @@ -385,6 +392,7 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", + "dev": true, "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", @@ -398,6 +406,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/@codemirror/lang-go/-/lang-go-6.0.1.tgz", "integrity": "sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==", + "dev": true, "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", @@ -411,6 +420,7 @@ "version": "6.4.12", "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.12.tgz", "integrity": "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==", + "dev": true, "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", @@ -428,6 +438,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/@codemirror/lang-java/-/lang-java-6.0.2.tgz", "integrity": "sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==", + "dev": true, "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", @@ -438,6 +449,7 @@ "version": "6.2.5", "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", + "dev": true, "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", @@ -453,6 +465,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/@codemirror/lang-jinja/-/lang-jinja-6.0.1.tgz", "integrity": "sha512-P5kyHLObzjtbGj16h+hyvZTxJhSjBEeSx4wMjbnAf3b0uwTy2+F0zGjMZL4PQOm/mh2eGZ5xUDVZXgwP783Nsw==", + "dev": true, "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", @@ -469,6 +482,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "dev": true, "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", @@ -479,6 +493,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/@codemirror/lang-less/-/lang-less-6.0.2.tgz", "integrity": "sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==", + "dev": true, "license": "MIT", "dependencies": { "@codemirror/lang-css": "^6.2.0", @@ -492,6 +507,7 @@ "version": "6.3.2", "resolved": "https://registry.npmjs.org/@codemirror/lang-liquid/-/lang-liquid-6.3.2.tgz", "integrity": "sha512-6PDVU3ZnfeYyz1at1E/ttorErZvZFXXt1OPhtfe1EZJ2V2iDFa0CwPqPgG5F7NXN0yONGoBogKmFAafKTqlwIw==", + "dev": true, "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", @@ -508,6 +524,7 @@ "version": "6.5.2", "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.2.tgz", "integrity": "sha512-AwBOdkWYuA//WcM0xO5PfHPUcmz/O2i5o0Nsg1U69SII/loCJlFI1Romd9xp2HYb1kYJRGZotyqRghuHH5n8Kw==", + "dev": true, "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.7.1", @@ -523,6 +540,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/@codemirror/lang-php/-/lang-php-6.0.2.tgz", "integrity": "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==", + "dev": true, "license": "MIT", "dependencies": { "@codemirror/lang-html": "^6.0.0", @@ -536,6 +554,7 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz", "integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==", + "dev": true, "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.3.2", @@ -549,6 +568,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/@codemirror/lang-rust/-/lang-rust-6.0.2.tgz", "integrity": "sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==", + "dev": true, "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", @@ -559,6 +579,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/@codemirror/lang-sass/-/lang-sass-6.0.2.tgz", "integrity": "sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==", + "dev": true, "license": "MIT", "dependencies": { "@codemirror/lang-css": "^6.2.0", @@ -572,6 +593,7 @@ "version": "6.10.0", "resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz", "integrity": "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==", + "dev": true, "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", @@ -586,6 +608,7 @@ "version": "0.1.3", "resolved": "https://registry.npmjs.org/@codemirror/lang-vue/-/lang-vue-0.1.3.tgz", "integrity": "sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==", + "dev": true, "license": "MIT", "dependencies": { "@codemirror/lang-html": "^6.0.0", @@ -600,6 +623,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/@codemirror/lang-wast/-/lang-wast-6.0.2.tgz", "integrity": "sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==", + "dev": true, "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", @@ -612,6 +636,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz", "integrity": "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==", + "dev": true, "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", @@ -626,6 +651,7 @@ "version": "6.1.3", "resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz", "integrity": "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==", + "dev": true, "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", @@ -641,6 +667,7 @@ "version": "6.12.4", "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", + "dev": true, "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", @@ -655,6 +682,7 @@ "version": "6.5.2", "resolved": "https://registry.npmjs.org/@codemirror/language-data/-/language-data-6.5.2.tgz", "integrity": "sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg==", + "dev": true, "license": "MIT", "dependencies": { "@codemirror/lang-angular": "^0.1.0", @@ -686,6 +714,7 @@ "version": "6.5.3", "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.3.tgz", "integrity": "sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==", + "dev": true, "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0" @@ -695,6 +724,7 @@ "version": "6.9.7", "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "dev": true, "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", @@ -706,6 +736,7 @@ "version": "6.12.2", "resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.2.tgz", "integrity": "sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w==", + "dev": true, "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", @@ -719,6 +750,7 @@ "version": "6.7.1", "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz", "integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==", + "dev": true, "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", @@ -730,6 +762,7 @@ "version": "6.7.1", "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz", "integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==", + "dev": true, "license": "MIT", "dependencies": { "@marijn/find-cluster-break": "^1.0.0" @@ -739,6 +772,7 @@ "version": "6.43.8", "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.8.tgz", "integrity": "sha512-qtItTDssZ/5GFfi94hrILu9j/VUeFPDPkhovEfmWFj2ipTxnzPB8DdHgfbb8HYTzLTYhrndKmyQxXUz/PDLenw==", + "dev": true, "license": "MIT", "dependencies": { "@codemirror/state": "^6.7.0", @@ -1269,109 +1303,121 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/@fontsource/cascadia-code/-/cascadia-code-5.3.0.tgz", "integrity": "sha512-XEGnkay3kCOAJXcrZqokezGlw1sATtpqOMOrBOuvYt6Bj6EaejU2BvO1q8AdWdNfDQ4AoYP/Zs30QRY5m9UEZw==", - "license": "OFL-1.1", + "dev": true, "funding": { "url": "https://github.com/sponsors/ayuhito" - } + }, + "license": "OFL-1.1" }, "node_modules/@fontsource/cinzel": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@fontsource/cinzel/-/cinzel-5.3.0.tgz", "integrity": "sha512-KEOYTsrRppW0uRIPWBDPrQgunCS6f8u4nzZxYRBfo9SCknKojLYjK7B0ZoVmJhyVLefz271ZQRF9EOpHZlfhRw==", - "license": "OFL-1.1", + "dev": true, "funding": { "url": "https://github.com/sponsors/ayuhito" - } + }, + "license": "OFL-1.1" }, "node_modules/@fontsource/fira-code": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@fontsource/fira-code/-/fira-code-5.3.0.tgz", "integrity": "sha512-EJL968RJRkakubAj/coU8pSUaeTE5UNoRjtzAr6kGiSZ3jWuN8/AKWHwym/PFUaQL1q7IL/H+EXs4358YhrTBQ==", - "license": "OFL-1.1", + "dev": true, "funding": { "url": "https://github.com/sponsors/ayuhito" - } + }, + "license": "OFL-1.1" }, "node_modules/@fontsource/ibm-plex-mono": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.3.0.tgz", "integrity": "sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==", - "license": "OFL-1.1", + "dev": true, "funding": { "url": "https://github.com/sponsors/ayuhito" - } + }, + "license": "OFL-1.1" }, "node_modules/@fontsource/ibm-plex-sans": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-sans/-/ibm-plex-sans-5.3.0.tgz", "integrity": "sha512-CbE4CbbEEZJX860XyUiRpsksXIQR8Rp2XDva2VO53NJox9tVNtusrysd2x5YkUEY3ErQ66W1IiiQL8/wihhw5w==", - "license": "OFL-1.1", + "dev": true, "funding": { "url": "https://github.com/sponsors/ayuhito" - } + }, + "license": "OFL-1.1" }, "node_modules/@fontsource/inter": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.3.0.tgz", "integrity": "sha512-RofMylZmjlJEfELXeNHFWBRcSs75rGU/6bV2S2jfnvv/3rPXPGe0LgUJTklcHZ9lM4OZmAVFhcJPnACfb91A3g==", - "license": "OFL-1.1", + "dev": true, "funding": { "url": "https://github.com/sponsors/ayuhito" - } + }, + "license": "OFL-1.1" }, "node_modules/@fontsource/jetbrains-mono": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", "integrity": "sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==", - "license": "OFL-1.1", + "dev": true, "funding": { "url": "https://github.com/sponsors/ayuhito" - } + }, + "license": "OFL-1.1" }, "node_modules/@fontsource/lora": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@fontsource/lora/-/lora-5.3.0.tgz", "integrity": "sha512-OK1g1z01rHVPqO5aw/FOIoDqc46KKYZdgL3NOLkxLWDk6QhDHhdr/h2BV1cke6gTvR1tzpDfdKyUkGsC+sQLeQ==", - "license": "OFL-1.1", + "dev": true, "funding": { "url": "https://github.com/sponsors/ayuhito" - } + }, + "license": "OFL-1.1" }, "node_modules/@fontsource/merriweather": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@fontsource/merriweather/-/merriweather-5.3.0.tgz", "integrity": "sha512-9LpPsNTYzRALj468Uj1K9Usl5eXk991L/kKmcpdWbTF1na9NfMyAXMgUABlMReViU1MYBz9kJ8jEzEOtX9qGDg==", - "license": "OFL-1.1", + "dev": true, "funding": { "url": "https://github.com/sponsors/ayuhito" - } + }, + "license": "OFL-1.1" }, "node_modules/@fontsource/roboto-mono": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@fontsource/roboto-mono/-/roboto-mono-5.3.0.tgz", "integrity": "sha512-yhwplHMHW9dCk+1pZuERfcJe+K3rosGVZEoK0XktkxRSqQ9qsipSwwiuW2/8Qmtp9lzu3Ixa/ie4BMH3i8CkZQ==", - "license": "OFL-1.1", + "dev": true, "funding": { "url": "https://github.com/sponsors/ayuhito" - } + }, + "license": "OFL-1.1" }, "node_modules/@fontsource/source-code-pro": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@fontsource/source-code-pro/-/source-code-pro-5.3.0.tgz", "integrity": "sha512-JxaCODU69HDS3mVra9u96nyBF911La6IvtGLgpQD+PZLxJ1i9IxooNfLR6Y37kx06IFjVGkvoUmg3WPwh/8gBg==", - "license": "OFL-1.1", + "dev": true, "funding": { "url": "https://github.com/sponsors/ayuhito" - } + }, + "license": "OFL-1.1" }, "node_modules/@fontsource/source-serif-4": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@fontsource/source-serif-4/-/source-serif-4-5.3.0.tgz", "integrity": "sha512-yQz8xmIgMzks8zQbpuca3UYtjxK798XCyQAGdDXmzBgvV7sdoK6d0YvE9F9kiYxORCEMRBgGZDZsSYcXj5KvwA==", - "license": "OFL-1.1", + "dev": true, "funding": { "url": "https://github.com/sponsors/ayuhito" - } + }, + "license": "OFL-1.1" }, "node_modules/@hapi/address": { "version": "5.1.1", @@ -1497,12 +1543,14 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "dev": true, "license": "MIT" }, "node_modules/@iconify/utils": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.4.tgz", "integrity": "sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==", + "dev": true, "license": "MIT", "dependencies": { "@antfu/install-pkg": "^1.1.0", @@ -2104,12 +2152,14 @@ "version": "1.5.2", "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "dev": true, "license": "MIT" }, "node_modules/@lezer/cpp": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@lezer/cpp/-/cpp-1.1.6.tgz", "integrity": "sha512-vh9gWWJOXFVY8HBHK3Twzq8MgwG2iN4GSyzBP9sCGTe37P15x2R14VaBQk0VA0ezTRN1KHYBBsHhvpGZ2Xy/pA==", + "dev": true, "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", @@ -2121,6 +2171,7 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.6.tgz", "integrity": "sha512-YJE78Wcg+zX8f10hiHWQ4Az48Qr/c13eId0VtRQYLBpxHDmDeSrXIlkbl+fJGW42rWC/uoUco9mhBZeVWP/A1g==", + "dev": true, "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", @@ -2132,6 +2183,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@lezer/go/-/go-1.0.1.tgz", "integrity": "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==", + "dev": true, "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", @@ -2143,6 +2195,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "dev": true, "license": "MIT", "dependencies": { "@lezer/common": "^1.3.0" @@ -2152,6 +2205,7 @@ "version": "1.3.13", "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", + "dev": true, "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", @@ -2163,6 +2217,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/@lezer/java/-/java-1.1.3.tgz", "integrity": "sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==", + "dev": true, "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", @@ -2174,6 +2229,7 @@ "version": "1.5.4", "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "dev": true, "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", @@ -2185,6 +2241,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "dev": true, "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", @@ -2196,6 +2253,7 @@ "version": "1.4.10", "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "dev": true, "license": "MIT", "dependencies": { "@lezer/common": "^1.0.0" @@ -2205,6 +2263,7 @@ "version": "1.7.2", "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.7.2.tgz", "integrity": "sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ==", + "dev": true, "license": "MIT", "dependencies": { "@lezer/common": "^1.5.0", @@ -2215,6 +2274,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.5.tgz", "integrity": "sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==", + "dev": true, "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", @@ -2226,6 +2286,7 @@ "version": "1.1.19", "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.19.tgz", "integrity": "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==", + "dev": true, "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", @@ -2237,6 +2298,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/@lezer/rust/-/rust-1.0.2.tgz", "integrity": "sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==", + "dev": true, "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", @@ -2248,6 +2310,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@lezer/sass/-/sass-1.1.0.tgz", "integrity": "sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==", + "dev": true, "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", @@ -2259,6 +2322,7 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz", "integrity": "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==", + "dev": true, "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", @@ -2270,6 +2334,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz", "integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==", + "dev": true, "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", @@ -2336,12 +2401,14 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz", "integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==", + "dev": true, "license": "MIT" }, "node_modules/@mermaid-js/parser": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.0.tgz", "integrity": "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==", + "dev": true, "license": "MIT", "dependencies": { "@chevrotain/types": "~11.1.2" @@ -2448,6 +2515,7 @@ "version": "2.1.10", "resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz", "integrity": "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -2845,6 +2913,7 @@ "version": "7.4.3", "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "dev": true, "license": "MIT", "dependencies": { "@types/d3-array": "*", @@ -2883,12 +2952,14 @@ "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "dev": true, "license": "MIT" }, "node_modules/@types/d3-axis": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "dev": true, "license": "MIT", "dependencies": { "@types/d3-selection": "*" @@ -2898,6 +2969,7 @@ "version": "3.0.6", "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "dev": true, "license": "MIT", "dependencies": { "@types/d3-selection": "*" @@ -2907,18 +2979,21 @@ "version": "3.0.6", "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "dev": true, "license": "MIT" }, "node_modules/@types/d3-color": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "dev": true, "license": "MIT" }, "node_modules/@types/d3-contour": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "dev": true, "license": "MIT", "dependencies": { "@types/d3-array": "*", @@ -2929,18 +3004,21 @@ "version": "6.0.4", "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "dev": true, "license": "MIT" }, "node_modules/@types/d3-dispatch": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "dev": true, "license": "MIT" }, "node_modules/@types/d3-drag": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/d3-selection": "*" @@ -2950,18 +3028,21 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "dev": true, "license": "MIT" }, "node_modules/@types/d3-ease": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "dev": true, "license": "MIT" }, "node_modules/@types/d3-fetch": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "dev": true, "license": "MIT", "dependencies": { "@types/d3-dsv": "*" @@ -2971,18 +3052,21 @@ "version": "3.0.10", "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "dev": true, "license": "MIT" }, "node_modules/@types/d3-format": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "dev": true, "license": "MIT" }, "node_modules/@types/d3-geo": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.1.tgz", "integrity": "sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==", + "dev": true, "license": "MIT", "dependencies": { "@types/geojson": "*" @@ -2992,12 +3076,14 @@ "version": "3.1.7", "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "dev": true, "license": "MIT" }, "node_modules/@types/d3-interpolate": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dev": true, "license": "MIT", "dependencies": { "@types/d3-color": "*" @@ -3007,30 +3093,35 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "dev": true, "license": "MIT" }, "node_modules/@types/d3-polygon": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "dev": true, "license": "MIT" }, "node_modules/@types/d3-quadtree": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "dev": true, "license": "MIT" }, "node_modules/@types/d3-random": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.4.tgz", "integrity": "sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==", + "dev": true, "license": "MIT" }, "node_modules/@types/d3-scale": { "version": "4.0.9", "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "dev": true, "license": "MIT", "dependencies": { "@types/d3-time": "*" @@ -3040,18 +3131,21 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "dev": true, "license": "MIT" }, "node_modules/@types/d3-selection": { "version": "3.0.11", "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "dev": true, "license": "MIT" }, "node_modules/@types/d3-shape": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "dev": true, "license": "MIT", "dependencies": { "@types/d3-path": "*" @@ -3061,24 +3155,28 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "dev": true, "license": "MIT" }, "node_modules/@types/d3-time-format": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "dev": true, "license": "MIT" }, "node_modules/@types/d3-timer": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "dev": true, "license": "MIT" }, "node_modules/@types/d3-transition": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "dev": true, "license": "MIT", "dependencies": { "@types/d3-selection": "*" @@ -3088,6 +3186,7 @@ "version": "3.0.8", "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "dev": true, "license": "MIT", "dependencies": { "@types/d3-interpolate": "*", @@ -3142,6 +3241,7 @@ "version": "7946.0.16", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, "license": "MIT" }, "node_modules/@types/highlight.js": { @@ -3233,13 +3333,14 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@upsetjs/venn.js": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "dev": true, "license": "MIT", "optionalDependencies": { "d3-selection": "^3.0.0", @@ -4147,6 +4248,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "dev": true, "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", @@ -4328,6 +4430,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "dev": true, "license": "MIT", "dependencies": { "layout-base": "^1.0.0" @@ -4337,6 +4440,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "dev": true, "license": "MIT" }, "node_modules/cross-dirname": { @@ -4392,6 +4496,7 @@ "version": "3.34.0", "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.0.tgz", "integrity": "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10" @@ -4401,6 +4506,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "dev": true, "license": "MIT", "dependencies": { "cose-base": "^1.0.0" @@ -4413,6 +4519,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "dev": true, "license": "MIT", "dependencies": { "cose-base": "^2.2.0" @@ -4425,6 +4532,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "dev": true, "license": "MIT", "dependencies": { "layout-base": "^2.0.0" @@ -4434,12 +4542,14 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "dev": true, "license": "MIT" }, "node_modules/d3": { "version": "7.9.0", "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "dev": true, "license": "ISC", "dependencies": { "d3-array": "3", @@ -4481,6 +4591,7 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "dev": true, "license": "ISC", "dependencies": { "internmap": "1 - 2" @@ -4493,6 +4604,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -4502,6 +4614,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "dev": true, "license": "ISC", "dependencies": { "d3-dispatch": "1 - 3", @@ -4518,6 +4631,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "dev": true, "license": "ISC", "dependencies": { "d3-path": "1 - 3" @@ -4530,6 +4644,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -4539,6 +4654,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "dev": true, "license": "ISC", "dependencies": { "d3-array": "^3.2.0" @@ -4551,6 +4667,7 @@ "version": "6.0.4", "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "dev": true, "license": "ISC", "dependencies": { "delaunator": "5" @@ -4563,6 +4680,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -4572,6 +4690,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "dev": true, "license": "ISC", "dependencies": { "d3-dispatch": "1 - 3", @@ -4585,6 +4704,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "dev": true, "license": "ISC", "dependencies": { "commander": "7", @@ -4610,6 +4730,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 10" @@ -4619,6 +4740,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=12" @@ -4628,6 +4750,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "dev": true, "license": "ISC", "dependencies": { "d3-dsv": "1 - 3" @@ -4640,6 +4763,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "dev": true, "license": "ISC", "dependencies": { "d3-dispatch": "1 - 3", @@ -4654,6 +4778,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -4663,6 +4788,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "dev": true, "license": "ISC", "dependencies": { "d3-array": "2.5.0 - 3" @@ -4675,6 +4801,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -4684,6 +4811,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "dev": true, "license": "ISC", "dependencies": { "d3-color": "1 - 3" @@ -4696,6 +4824,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -4705,6 +4834,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -4714,6 +4844,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -4723,6 +4854,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -4732,6 +4864,7 @@ "version": "0.12.3", "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "d3-array": "1 - 2", @@ -4742,6 +4875,7 @@ "version": "2.12.1", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "internmap": "^1.0.0" @@ -4751,12 +4885,14 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "dev": true, "license": "BSD-3-Clause" }, "node_modules/d3-sankey/node_modules/d3-shape": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "d3-path": "1" @@ -4766,12 +4902,14 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "dev": true, "license": "ISC" }, "node_modules/d3-scale": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "dev": true, "license": "ISC", "dependencies": { "d3-array": "2.10.0 - 3", @@ -4788,6 +4926,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "dev": true, "license": "ISC", "dependencies": { "d3-color": "1 - 3", @@ -4801,6 +4940,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -4810,6 +4950,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "dev": true, "license": "ISC", "dependencies": { "d3-path": "^3.1.0" @@ -4822,6 +4963,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "dev": true, "license": "ISC", "dependencies": { "d3-array": "2 - 3" @@ -4834,6 +4976,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "dev": true, "license": "ISC", "dependencies": { "d3-time": "1 - 3" @@ -4846,6 +4989,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -4855,6 +4999,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "dev": true, "license": "ISC", "dependencies": { "d3-color": "1 - 3", @@ -4874,6 +5019,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "dev": true, "license": "ISC", "dependencies": { "d3-dispatch": "1 - 3", @@ -4890,6 +5036,7 @@ "version": "7.0.14", "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", + "dev": true, "license": "MIT", "dependencies": { "d3": "^7.9.0", @@ -4900,6 +5047,7 @@ "version": "1.11.21", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "dev": true, "license": "MIT" }, "node_modules/debounce-fn": { @@ -5022,6 +5170,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "dev": true, "license": "ISC", "dependencies": { "robust-predicates": "^3.0.2" @@ -5121,6 +5270,7 @@ "version": "3.4.16", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz", "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==", + "dev": true, "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -5619,6 +5769,7 @@ "version": "1.50.0", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz", "integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==", + "dev": true, "license": "MIT", "workspaces": [ "docs", @@ -6342,6 +6493,7 @@ "version": "0.5.2", "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "dev": true, "license": "MIT" }, "node_modules/has-flag": { @@ -6431,6 +6583,7 @@ "version": "11.11.1", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=12.0.0" @@ -6522,6 +6675,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -6544,11 +6698,12 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", - "license": "MIT", + "dev": true, "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "license": "MIT" }, "node_modules/imurmurhash": { "version": "0.1.4", @@ -6583,6 +6738,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -6810,6 +6966,7 @@ "version": "0.17.0", "resolved": "https://registry.npmjs.org/katex/-/katex-0.17.0.tgz", "integrity": "sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw==", + "dev": true, "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" @@ -6826,6 +6983,7 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "dev": true, "license": "MIT", "engines": { "node": ">= 12" @@ -6844,12 +7002,14 @@ "node_modules/khroma": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", - "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==", + "dev": true }, "node_modules/layout-base": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "dev": true, "license": "MIT" }, "node_modules/lazy-val": { @@ -7160,6 +7320,7 @@ "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "dev": true, "license": "MIT" }, "node_modules/lodash.escaperegexp": { @@ -7199,6 +7360,7 @@ "version": "18.0.5", "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "dev": true, "license": "MIT", "bin": { "marked": "bin/marked.js" @@ -7211,6 +7373,7 @@ "version": "2.2.4", "resolved": "https://registry.npmjs.org/marked-highlight/-/marked-highlight-2.2.4.tgz", "integrity": "sha512-PZxisNMJDduSjc0q6uvjsnqqHCXc9s0eyzxDO9sB1eNGJnd/H1/Fu+z6g/liC1dfJdFW4SftMwMlLvsBhUPrqQ==", + "dev": true, "license": "MIT", "peerDependencies": { "marked": ">=4 <19" @@ -7220,6 +7383,7 @@ "version": "5.1.10", "resolved": "https://registry.npmjs.org/marked-katex-extension/-/marked-katex-extension-5.1.10.tgz", "integrity": "sha512-TuqrzguLeXXm6iBaf16leL3+dVmMj8KrBdunMVVzxMS/bwcjtQ0YG0sNytl1j7uUo8yClsXJqBbVjH1yOPurwQ==", + "dev": true, "license": "MIT", "peerDependencies": { "katex": ">=0.16 <0.18", @@ -7254,6 +7418,7 @@ "version": "11.16.1", "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.16.1.tgz", "integrity": "sha512-TQsq6u22fAn3rek5VOubrhKPo1g5hwC3FXUN9hiyupTckcYiGuuKGkNQrKYwGJkXUxZdojwRG46gsSCFZMDp4g==", + "dev": true, "license": "MIT", "dependencies": { "@braintree/sanitize-url": "^7.1.2", @@ -7283,6 +7448,7 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "dev": true, "license": "MIT", "engines": { "node": ">= 12" @@ -7292,6 +7458,7 @@ "version": "0.16.47", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "dev": true, "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" @@ -7308,6 +7475,7 @@ "version": "16.4.2", "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "dev": true, "license": "MIT", "bin": { "marked": "bin/marked.js" @@ -7780,12 +7948,14 @@ "version": "1.8.0", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz", "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", + "dev": true, "license": "MIT" }, "node_modules/path-data-parser": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "dev": true, "license": "MIT" }, "node_modules/path-exists": { @@ -8021,12 +8191,14 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "dev": true, "license": "MIT" }, "node_modules/points-on-path": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "dev": true, "license": "MIT", "dependencies": { "path-data-parser": "0.1.0", @@ -8263,6 +8435,7 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8272,6 +8445,7 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "dev": true, "license": "MIT", "dependencies": { "scheduler": "^0.27.0" @@ -8414,6 +8588,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "dev": true, "license": "Unlicense" }, "node_modules/rolldown": { @@ -8454,6 +8629,7 @@ "version": "4.6.6", "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "dev": true, "license": "MIT", "dependencies": { "hachure-fill": "^0.5.2", @@ -8466,6 +8642,7 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "dev": true, "license": "BSD-3-Clause" }, "node_modules/rxjs": { @@ -8489,6 +8666,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, "license": "MIT" }, "node_modules/sanitize-filename": { @@ -8514,6 +8692,7 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "dev": true, "license": "MIT" }, "node_modules/semver": { @@ -8774,12 +8953,14 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "dev": true, "license": "MIT" }, "node_modules/stylis": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "dev": true, "license": "MIT" }, "node_modules/sumchecker": { @@ -8891,6 +9072,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -8957,6 +9139,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz", "integrity": "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.10" @@ -9118,6 +9301,7 @@ "version": "14.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", + "dev": true, "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -9209,6 +9393,7 @@ "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "dev": true, "license": "MIT" }, "node_modules/wait-on": { diff --git a/package.json b/package.json index 9bc45c4..a3e21c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fate-markdown-viewer", - "version": "1.13.4", + "version": "1.14.0", "description": "FATE (Formatted Article & Text Editor) is a free, open source text and code editor for Windows and Linux: a Notepad++ alternative with tabs, syntax highlighting, find and replace, split view and a live Markdown preview with KaTeX math and Mermaid diagrams.", "keywords": [ "text-editor", @@ -32,6 +32,7 @@ "dev": "vite", "build": "vite build", "lint": "eslint .", + "test": "node --test", "preview": "vite preview", "electron:dev": "concurrently \"vite\" \"wait-on http://localhost:5173 && cross-env NODE_ENV=development electron .\"", "electron:build": "vite build && electron-builder", @@ -57,7 +58,7 @@ "dist/**/*", "electron/**/*" ], - "appxManifestCreated": "scripts/escape-appx-manifest.cjs", + "appxManifestCreated": "scripts/appx-manifest.cjs", "win": { "icon": "build/icon.ico", "target": [ @@ -169,17 +170,20 @@ "packageName": "fate", "priority": "optional", "afterInstall": "build/linux/after-install-deb.sh", + "afterRemove": "build/linux/after-remove-deb.sh", "fpm": [ "build/com.vaguedustin.fate.metainfo.xml=/usr/share/metainfo/com.vaguedustin.fate.metainfo.xml", - "build/fate-archive-keyring.gpg=/usr/share/keyrings/fate-archive-keyring.gpg" + "build/linux/fate-archive-keyring.gpg=/usr/share/keyrings/fate-archive-keyring.gpg" ] }, "rpm": { "packageName": "fate", "afterInstall": "build/linux/after-install-rpm.sh", + "afterRemove": "build/linux/after-remove-rpm.sh", "fpm": [ + "--rpm-posttrans=build/linux/posttrans-rpm.sh", "build/com.vaguedustin.fate.metainfo.xml=/usr/share/metainfo/com.vaguedustin.fate.metainfo.xml", - "build/fate-archive-keyring.asc=/etc/pki/rpm-gpg/RPM-GPG-KEY-fate" + "build/linux/fate-archive-keyring.asc=/etc/pki/rpm-gpg/RPM-GPG-KEY-fate" ] }, "nsis": { @@ -213,6 +217,10 @@ ] }, "dependencies": { + "electron-store": "^8.1.0", + "electron-updater": "^6.8.3" + }, + "devDependencies": { "@codemirror/autocomplete": "^6.20.3", "@codemirror/commands": "^6.10.4", "@codemirror/language": "^6.12.4", @@ -222,6 +230,7 @@ "@codemirror/search": "^6.7.1", "@codemirror/state": "^6.7.1", "@codemirror/view": "^6.43.8", + "@eslint/js": "^10.0.1", "@fontsource/cascadia-code": "^5.3.0", "@fontsource/cinzel": "^5.3.0", "@fontsource/fira-code": "^5.3.0", @@ -236,37 +245,32 @@ "@fontsource/source-serif-4": "^5.3.0", "@lezer/highlight": "^1.2.3", "@phosphor-icons/react": "^2.1.10", - "codemirror": "^6.0.2", - "dompurify": "^3.4.16", - "electron-store": "^8.1.0", - "electron-updater": "^6.8.3", - "highlight.js": "^11.11.1", - "katex": "^0.17.0", - "marked": "^18.0.5", - "marked-highlight": "^2.2.4", - "marked-katex-extension": "^5.1.10", - "mermaid": "^11.16.1", - "react": "^19.2.6", - "react-dom": "^19.2.6" - }, - "devDependencies": { - "@eslint/js": "^10.0.1", "@types/dompurify": "^3.0.5", "@types/highlight.js": "^9.12.4", "@types/marked": "^5.0.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", + "codemirror": "^6.0.2", "concurrently": "^10.0.3", "cross-env": "^10.1.0", + "dompurify": "^3.4.16", "electron": "^42.3.3", "electron-builder": "^26.8.1", "eslint": "^10.3.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", + "highlight.js": "^11.11.1", + "katex": "^0.17.0", + "marked": "^18.0.5", + "marked-highlight": "^2.2.4", + "marked-katex-extension": "^5.1.10", + "mermaid": "^11.16.1", "png-to-ico": "^3.0.2", "puppeteer-core": "^25.5.0", + "react": "^19.2.6", + "react-dom": "^19.2.6", "sharp": "^0.35.3", "vite": "^8.0.12", "wait-on": "^9.0.10" diff --git a/scripts/appx-manifest.cjs b/scripts/appx-manifest.cjs new file mode 100644 index 0000000..6b6660e --- /dev/null +++ b/scripts/appx-manifest.cjs @@ -0,0 +1,124 @@ +/** + * appx-manifest.cjs: electron-builder's appxManifestCreated hook (package.json + * build.appxManifestCreated). Two edits to the generated AppxManifest.xml before makeappx packs it. + * + * 1. File types for the Microsoft Store package. electron-builder writes one + * uap:FileTypeAssociation per entry in build.fileAssociations, and that list is shared with the + * NSIS installer, where an entry means TAKING the type's default (electron-builder's + * APP_ASSOCIATE). That is why it holds .md alone, and why up to 1.13 the Store package offered + * "Open with FATE" for .md and nothing else. The .appx gets the rest here, where the installer + * never sees it: .markdown, .txt and every code type the installer registers, read from + * build/installer.nsh so the two lists cannot drift apart. A packaged app cannot take a default + * at all: these put FATE under "Open with" and on its Default apps page, which is exactly what the + * installer's OpenWithProgids entries do. + * + * .bat and .cmd are refused even if the installer list ever regains them. Merely being listed as + * a handler is enough to make Windows show "Pick an app" instead of running a batch file, and + * its default cannot be picked back (PROTECTED_EXTENSIONS in electron/main.cjs; 1.11.5 did this). + * + * 2. Ampersands. electron-builder pastes package.json's description into the manifest as is, and + * FATE's description contains "Formatted Article & Text Editor". A bare "&" is not XML, so + * makeappx fails with "0x80080204 - The package manifest is not valid" and no Store package is + * built. Entity references that are already escaped (&, &, &) are left alone. + * + * The functions below are pure and exported for test/appx-manifest.test.cjs. + */ +const fs = require('node:fs'); +const path = require('node:path'); + +/** Never associated, whatever the installer list says. Mirrors PROTECTED_EXTENSIONS in main.cjs. */ +const PROTECTED_EXTENSIONS = ['bat', 'cmd']; + +/** + * The Markdown-side types beyond .md (which build.fileAssociations already declares). Same set as + * MARKDOWN_EXTENSIONS in main.cjs; the installer registers these through Capabilities instead of + * RegisterCodeType, so they are not in the list read from installer.nsh. + */ +const MARKDOWN_TYPES = [ + { ext: 'markdown', displayName: 'Markdown Document' }, + { ext: 'txt', displayName: 'Text Document' }, +]; + +function escapeBareAmpersands(xml) { + return xml.replace(/&(?!(?:[A-Za-z][A-Za-z0-9]*|#[0-9]+|#x[0-9A-Fa-f]+);)/g, '&'); +} + +function escapeXmlText(text) { + return text.replace(/&/g, '&').replace(//g, '>'); +} + +/** + * The code types build/installer.nsh registers, in its order: every + * `!insertmacro RegisterCodeType "ext" "Description"` line. Throws on a malformed extension rather + * than writing it into the manifest, and when the list is empty (the macro was renamed), because + * a Store package that silently loses every code type is the bug this hook exists to fix. + */ +function installerCodeTypes(nsh) { + const types = []; + const seen = new Set(); + const re = /^[ \t]*!insertmacro[ \t]+RegisterCodeType[ \t]+"([^"]*)"[ \t]+"([^"]*)"/gm; + for (const [, ext, description] of nsh.matchAll(re)) { + if (!/^[a-z0-9]+$/.test(ext)) throw new Error(`installer.nsh: unexpected extension "${ext}"`); + if (seen.has(ext)) continue; + seen.add(ext); + types.push({ ext, displayName: description }); + } + if (types.length === 0) throw new Error('installer.nsh: no RegisterCodeType lines found'); + return types; +} + +/** Everything the Store package should associate beyond build.fileAssociations. */ +function storeFileTypes(nsh) { + return [...MARKDOWN_TYPES, ...installerCodeTypes(nsh)].filter((t) => !PROTECTED_EXTENSIONS.includes(t.ext)); +} + +/** + * Add one uap:FileTypeAssociation per type to the application's , creating the element + * when electron-builder wrote none. Types the manifest already declares (.md) are skipped, so a + * file type never appears twice. The association Name is the extension, as electron-builder names + * its own, which keeps every Name unique and lower case as the manifest schema requires. + */ +function addFileTypeAssociations(xml, types) { + const declared = new Set( + [...xml.matchAll(/\s*\.([^<\s]+)\s*<\/uap:FileType>/gi)].map((m) => m[1].toLowerCase()), + ); + const blocks = types + .filter((t) => !declared.has(t.ext)) + .map( + (t) => ` + + + ${escapeXmlText(t.displayName)} + + .${t.ext} + + + `, + ) + .join(''); + if (!blocks) return xml; + if (xml.includes('')) return xml.replace('', `${blocks}`); + if (!xml.includes('')) throw new Error('AppxManifest.xml: no element'); + return xml.replace('', `${blocks}\n `); +} + +function transformManifest(xml, nsh) { + return escapeBareAmpersands(addFileTypeAssociations(xml, storeFileTypes(nsh))); +} + +module.exports = function appxManifestCreated(manifestPath) { + const nsh = fs.readFileSync(path.join(__dirname, '..', 'build', 'installer.nsh'), 'utf8'); + const xml = fs.readFileSync(manifestPath, 'utf8'); + const next = transformManifest(xml, nsh); + if (next !== xml) fs.writeFileSync(manifestPath, next); +}; + +Object.assign(module.exports, { + PROTECTED_EXTENSIONS, + MARKDOWN_TYPES, + escapeBareAmpersands, + installerCodeTypes, + storeFileTypes, + addFileTypeAssociations, + transformManifest, +}); diff --git a/scripts/escape-appx-manifest.cjs b/scripts/escape-appx-manifest.cjs deleted file mode 100644 index fff36ab..0000000 --- a/scripts/escape-appx-manifest.cjs +++ /dev/null @@ -1,17 +0,0 @@ -/** - * escape-appx-manifest.cjs: electron-builder's appxManifestCreated hook (package.json - * build.appxManifestCreated). Escapes bare ampersands in the generated AppxManifest.xml before - * makeappx packs it. - * - * electron-builder pastes package.json's description into the manifest as is, and FATE's - * description contains "Formatted Article & Text Editor". A bare "&" is not XML, so makeappx - * fails with "0x80080204 - The package manifest is not valid" and no Store package is built. - * Entity references that are already escaped (&, &, &) are left alone. - */ -const fs = require('node:fs'); - -module.exports = function escapeAppxManifest(manifestPath) { - const xml = fs.readFileSync(manifestPath, 'utf8'); - const escaped = xml.replace(/&(?!(?:[A-Za-z][A-Za-z0-9]*|#[0-9]+|#x[0-9A-Fa-f]+);)/g, '&'); - if (escaped !== xml) fs.writeFileSync(manifestPath, escaped); -}; diff --git a/scripts/setup-msstore-credentials.ps1 b/scripts/setup-msstore-credentials.ps1 index be849f4..9d1985f 100644 --- a/scripts/setup-msstore-credentials.ps1 +++ b/scripts/setup-msstore-credentials.ps1 @@ -1,11 +1,19 @@ <# .SYNOPSIS - One-time setup: store the Microsoft Partner Center API credentials as GitHub Actions secrets so - the release workflow can submit each new .appx to the Microsoft Store (msstore-cli). + One-time setup: store the Microsoft Partner Center API credentials as GitHub Actions secrets, for + submitting each new .appx to the Microsoft Store from CI (msstore-cli). .DESCRIPTION Prompts for the five values from Partner Center and stores them as repository secrets. Nothing - is written to disk. Where each value comes from: + is written to disk. + + No workflow uses these secrets yet: Build Windows (.github/workflows/build-windows.yml) builds + the .appx and keeps it on the run, and the submission is made by hand in Partner Center. This + script prepares the credentials for a submission step; it does not add one. + + Works in Windows PowerShell 5.1 (the `powershell` command below) and in PowerShell 7. + + Where each value comes from: Tenant ID, Client ID, Client secret Partner Center -> Settings (gear) -> Account settings -> User management -> @@ -30,8 +38,14 @@ param( $ErrorActionPreference = 'Stop' if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { throw 'gh is not installed or not on PATH.' } +# Windows PowerShell 5.1 turns a redirected native command's stderr into a terminating error while +# ErrorActionPreference is Stop, so a logged-out gh would end the script with its own message +# instead of this one. Judge gh by its exit code. +$ErrorActionPreference = 'Continue' gh auth status *> $null -if ($LASTEXITCODE -ne 0) { throw "gh is not logged in: run 'gh auth login' first." } +$ghLoggedIn = ($LASTEXITCODE -eq 0) +$ErrorActionPreference = 'Stop' +if (-not $ghLoggedIn) { throw "gh is not logged in: run 'gh auth login' first." } function Read-Guid([string]$label) { while ($true) { @@ -55,16 +69,32 @@ $storeId = (Read-Host 'Store ID of FATE (starts with 9N)').Trim() if ($sellerId -notmatch '^\d+$') { throw 'Seller ID should be all digits.' } if ($storeId -notmatch '^9[A-Z0-9]{11}$') { throw 'Store ID should be 12 characters starting with 9, e.g. 9NBLGGH4R315.' } -$clientSecret = ConvertFrom-SecureString -SecureString $secure -AsPlainText +# ConvertFrom-SecureString -AsPlainText exists only in PowerShell 7; the BSTR round trip works in +# 5.1 as well. The unmanaged copy is zeroed and freed straight away. +$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure) +try { + $clientSecret = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) +} finally { + [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) +} if ([string]::IsNullOrWhiteSpace($clientSecret)) { throw 'Client secret was empty.' } +# A failing native command does not stop the script by itself, so each one is checked: the +# message at the end must not claim secrets that were never stored. +$failed = @() gh secret set PARTNER_CENTER_TENANT_ID --repo $Repo --body $tenantId +if ($LASTEXITCODE -ne 0) { $failed += 'PARTNER_CENTER_TENANT_ID' } gh secret set PARTNER_CENTER_CLIENT_ID --repo $Repo --body $clientId +if ($LASTEXITCODE -ne 0) { $failed += 'PARTNER_CENTER_CLIENT_ID' } $clientSecret | gh secret set PARTNER_CENTER_CLIENT_SECRET --repo $Repo +if ($LASTEXITCODE -ne 0) { $failed += 'PARTNER_CENTER_CLIENT_SECRET' } gh secret set PARTNER_CENTER_SELLER_ID --repo $Repo --body $sellerId +if ($LASTEXITCODE -ne 0) { $failed += 'PARTNER_CENTER_SELLER_ID' } gh secret set MSSTORE_APP_ID --repo $Repo --body $storeId +if ($LASTEXITCODE -ne 0) { $failed += 'MSSTORE_APP_ID' } $clientSecret = $null +if ($failed.Count -gt 0) { throw "gh could not store $($failed -join ', ') on $Repo. Run the script again." } Write-Host '' Write-Host "Stored PARTNER_CENTER_TENANT_ID, PARTNER_CENTER_CLIENT_ID, PARTNER_CENTER_CLIENT_SECRET, PARTNER_CENTER_SELLER_ID and MSSTORE_APP_ID on $Repo." -ForegroundColor Green -Write-Host 'The release workflow will submit each new .appx to the Microsoft Store from now on.' +Write-Host 'No workflow submits to the Microsoft Store yet. Until one does, upload the .appx from the Build Windows run in Partner Center.' diff --git a/scripts/setup-signing-key.sh b/scripts/setup-signing-key.sh index 16eadb4..353d07a 100644 --- a/scripts/setup-signing-key.sh +++ b/scripts/setup-signing-key.sh @@ -1,34 +1,66 @@ #!/usr/bin/env bash # One-time setup: create FATE's package-signing key and store it as the GitHub Actions secret # FATE_GPG_PRIVATE_KEY on the repository. The Build Linux workflow imports it to sign the .rpm, -# the apt repository index (InRelease/Release.gpg) and the dnf repodata (repomd.xml.asc), and -# exports the public half into the packages so installs trust the repositories automatically. +# the apt repository index (InRelease/Release.gpg) and the dnf repodata (repomd.xml.asc). The +# public half is committed as build/linux/fate-archive-keyring.{asc,gpg}: the packages ship it, so +# installs trust the repositories automatically. # # Run once, from Git Bash, WSL, macOS or Linux, logged in to `gh` as the repository owner: # -# bash scripts/setup-signing-key.sh +# bash scripts/setup-signing-key.sh # the first key: create it, store the secret +# bash scripts/setup-signing-key.sh --new-key-only # a key rotation: create a key, touch no secret # -# Nothing is printed except the fingerprint. The private key is written ONLY to the backup -# directory (default: ~/FATE-package-signing-key) and to the GitHub secret. Move the backup into a -# password manager and delete it from disk; if the key ever leaks, revoke it, run this again with -# a new key, and ship a release (the public keyring travels inside the .deb/.rpm). +# Nothing is printed except the fingerprint and the next steps. The private key is written ONLY to +# the backup directory (default: ~/FATE-package-signing-key) and, without --new-key-only, to the +# GitHub secret. Move the backup into a password manager and delete it from disk. +# +# Replacing the key is NOT running this again: every installed system trusts only the keys its +# package shipped, so a release signed by a new key alone would never reach them through apt or +# dnf. Rotation goes through a transition release signed by both keys; SECURITY.md → Package +# signing key has the steps. That is also why this script refuses to replace an existing secret +# or an existing backup. set -euo pipefail +NEW_KEY_ONLY=false +case "${1:-}" in + '') ;; + --new-key-only) NEW_KEY_ONLY=true ;; + *) echo "usage: bash scripts/setup-signing-key.sh [--new-key-only]"; exit 2 ;; +esac + REPO="${REPO:-VagueDustin/FATE}" BACKUP_DIR="${BACKUP_DIR:-$HOME/FATE-package-signing-key}" NAME="${SIGNING_NAME:-FATE Package Signing}" EMAIL="${SIGNING_EMAIL:-enterprises@vaguedustin.com}" +PRIVATE="$BACKUP_DIR/fate-package-signing-PRIVATE.asc" command -v gpg >/dev/null || { echo "gpg not found (Git for Windows ships it; on Linux: apt install gnupg)"; exit 1; } command -v gh >/dev/null || { echo "gh not found: https://cli.github.com"; exit 1; } gh auth status >/dev/null 2>&1 || { echo "gh is not logged in: run 'gh auth login' first"; exit 1; } -if gh secret list --repo "$REPO" 2>/dev/null | grep -q '^FATE_GPG_PRIVATE_KEY'; then - echo "The secret FATE_GPG_PRIVATE_KEY already exists on $REPO." - echo "Delete it first if you really mean to rotate the key: gh secret delete FATE_GPG_PRIVATE_KEY --repo $REPO" +# A backup that is already there may be the only copy of a key in use. Never write over it. +if [ -e "$PRIVATE" ] || [ -e "$BACKUP_DIR/README.txt" ]; then + echo "$BACKUP_DIR already holds a signing key backup. Nothing was changed." + echo "Move it somewhere safe first, or choose another directory: BACKUP_DIR=~/FATE-package-signing-key-new bash $0 ${1:-}" exit 1 fi +if [ "$NEW_KEY_ONLY" = false ]; then + # Capture the list, then search it. This used to be `gh secret list | grep -q ...`: grep stopped + # reading at the first match, gh could die writing the rest, pipefail turned "found" into "not + # found", and the script went on to replace the live key. With the output discarded, a gh error + # (network, permissions) read as "no secret" the same way. Now a gh failure stops the script. + if ! SECRETS="$(gh secret list --repo "$REPO" --json name -q '.[].name')"; then + echo "Could not list the secrets of $REPO (see the gh error above). Nothing was changed." + exit 1 + fi + if grep -qx 'FATE_GPG_PRIVATE_KEY' <<< "$SECRETS"; then + echo "The secret FATE_GPG_PRIVATE_KEY already exists on $REPO. Nothing was changed." + echo "To replace the key, rotate it (SECURITY.md → Package signing key): bash $0 --new-key-only" + exit 1 + fi +fi + # An isolated keyring so nothing touches your personal GnuPG home. GNUPGHOME="$(mktemp -d)" export GNUPGHOME @@ -50,24 +82,39 @@ FPR="$(gpg --list-secret-keys --with-colons | awk -F: '/^fpr/ { print $10; exit mkdir -p "$BACKUP_DIR" chmod 700 "$BACKUP_DIR" -gpg --armor --export-secret-keys "$FPR" > "$BACKUP_DIR/fate-package-signing-PRIVATE.asc" +# noclobber + umask: the private key is never written over another file, and never readable by +# anyone else, not even for the moment before a chmod. +( + set -o noclobber + umask 077 + gpg --armor --export-secret-keys "$FPR" > "$PRIVATE" +) gpg --armor --export "$FPR" > "$BACKUP_DIR/fate-package-signing-public.asc" -chmod 600 "$BACKUP_DIR/fate-package-signing-PRIVATE.asc" cat > "$BACKUP_DIR/README.txt" < .tab-main:focus-visible) { + outline: 2px solid var(--border-emphasis); + outline-offset: -2px; +} + .tab:hover { background: var(--accent-subtle); color: var(--text-primary); @@ -951,13 +972,26 @@ kbd { flex: 1; } +/* Each entry is a + ))} + {onDismiss && ( + + )} + + + ); +} + +export default DocBar; diff --git a/src/components/ErrorBoundary.jsx b/src/components/ErrorBoundary.jsx new file mode 100644 index 0000000..47ea256 --- /dev/null +++ b/src/components/ErrorBoundary.jsx @@ -0,0 +1,80 @@ +import { Component } from 'react'; +import { flushForCrash } from '../hotExit.js'; + +/** + * Root error boundary (1.14.0, H3). + * + * Without one, any exception thrown while React renders unmounts the whole tree: the window goes + * blank and stays that way, with every unsaved buffer gone from the screen and no way back short + * of killing the app. This catches it and shows a recovery screen instead. + * + * Recovery leans on hot exit. As the error is caught, every dirty tab's backup is written at once + * (flushForCrash; App registers the writer), so Reload brings the buffers back through the normal + * startup restore, including whatever was typed since the last periodic backup. The screen says + * plainly whether that copy was made. + * + * Closing the window from here must not hang: the quit walk that normally answers the main + * process lived in the App that just unmounted. The boundary takes over 'request-close': with the + * backups written it lets the window close (the next launch restores them); without them it asks + * first. + */ +class ErrorBoundary extends Component { + constructor(props) { + super(props); + this.state = { error: null, kept: 'saving' }; + this.flushed = null; + } + + static getDerivedStateFromError(error) { + return { error }; + } + + componentDidCatch(error, info) { + console.error('FATE hit an error it could not recover from:', error, info?.componentStack); + this.flushed = flushForCrash(); + this.flushed.then((ok) => this.setState({ kept: ok ? 'saved' : 'failed' })); + + const api = window.electronAPI; + api?.onRequestClose?.(async () => { + const ok = await this.flushed; + if (ok) { + api.confirmedClose(true); + return; + } + const discard = await api.confirmDiscard?.('Close FATE without your unsaved changes?').catch(() => true); + api.confirmedClose(discard !== false); + }); + } + + reload = () => { + // The app's unsaved-changes guard went with it; nothing else should stand in the way. + window.onbeforeunload = null; + window.location.reload(); + }; + + render() { + const { error, kept } = this.state; + if (!error) return this.props.children; + + const message = { + saving: 'Keeping a copy of your unsaved changes…', + saved: 'Any unsaved changes were kept, and they will be back in their tabs after you reload.', + failed: "FATE couldn't keep a copy of your unsaved changes, so reloading loses them." + }[kept]; + + return ( +
+
+

FATE ran into a problem

+

{message}

+
{String(error?.message || error)}
+ +
+
+ ); + } +} + +export default ErrorBoundary; diff --git a/src/components/FormatStatus.jsx b/src/components/FormatStatus.jsx new file mode 100644 index 0000000..0f5ad3b --- /dev/null +++ b/src/components/FormatStatus.jsx @@ -0,0 +1,67 @@ +import StatusMenu, { StatusMenuItem } from './StatusMenu.jsx'; +import { + eolLabel, encodingLabel, toggledEol, SAVE_ENCODINGS, REOPEN_ENCODINGS, isCurrentEncoding +} from '../docFormat.js'; + +/** + * FormatStatus: the status bar's encoding and line-ending items (1.14.0). + * + * UTF-8 ▸ menu: "Save with encoding" (changes how the next save writes the file; the tab turns + * dirty until then) and "Reopen with encoding" (rereads the file decoded another way, + * for a guess that came out wrong; only on a clean tab with a file behind it, since it + * replaces the buffer) + * CRLF ▸ click: switch CRLF ↔ LF, applied on the next save + * + * The menu (positioning, keyboard, Escape) is StatusMenu's. + */ +function FormatStatus({ format, canReopen, reopenHint, onToggleEol, onSaveWithEncoding, onReopenWithEncoding }) { + return ( + <> + + + {(choose) => ( + <> +
Save with encoding
+ {SAVE_ENCODINGS.map((c) => ( + onSaveWithEncoding(c))} + > + {c.label} + + ))} + +
+
Reopen with encoding
+ {!canReopen && reopenHint &&
{reopenHint}
} + {REOPEN_ENCODINGS.map((c) => ( + onReopenWithEncoding(c.encoding))} + > + {c.label} + + ))} + + )} + + + + + ); +} + +export default FormatStatus; diff --git a/src/components/IndentStatus.jsx b/src/components/IndentStatus.jsx new file mode 100644 index 0000000..9d46543 --- /dev/null +++ b/src/components/IndentStatus.jsx @@ -0,0 +1,44 @@ +import StatusMenu, { StatusMenuItem } from './StatusMenu.jsx'; +import { INDENT_SIZES, indentLabel } from '../indentDetect.js'; + +/** + * IndentStatus: the status bar's indentation item (1.14.0, M7), "Spaces: 4" or "Tab size: 4". + * + * Its menu sets how THIS document indents from now on: spaces or tabs, and the width (of one level + * of spaces, or of a tab). Nothing already in the file is re-indented; Tab and the automatic + * indentation of new lines use the new choice. `indent` is the document's effective indentation + * ({ useTabs, size }, see indentDetect.js); `onChange` gets the whole new value. + */ +function IndentStatus({ indent, onChange }) { + return ( + <> + + + {(choose) => ( + <> + onChange({ ...indent, useTabs: false }))}> + Indent using spaces + + onChange({ ...indent, useTabs: true }))}> + Indent using tabs + +
+
{indent.useTabs ? 'Tab size' : 'Indent size'}
+ {INDENT_SIZES.map((n) => ( + onChange({ ...indent, size: n }))}> + {n} + + ))} + + )} + + + ); +} + +export default IndentStatus; diff --git a/src/components/MarkdownEditView.jsx b/src/components/MarkdownEditView.jsx new file mode 100644 index 0000000..148e489 --- /dev/null +++ b/src/components/MarkdownEditView.jsx @@ -0,0 +1,96 @@ +import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import CodeEditor from './CodeEditor.jsx'; +import { renderMarkdown } from '../markdown.js'; +import { useMermaid } from '../useMermaid.js'; +import { usePreviewScrollSync } from '../usePreviewScrollSync.js'; + +/** + * MarkdownEditView is a markdown tab's EDIT mode: CodeMirror source on the left, live preview on + * the right, re-rendered ~a third of a second after typing pauses. Its own module (it used to live + * in App.jsx) so it is never defined inside App, which would remount it every render. + * + * `savedContent` is the text as last saved to disk, which can differ from `doc.source` when the + * tab left Edit mode with unsaved changes. The editor measures "dirty" against it, so coming back + * into Edit mode never passes unsaved text off as saved. + * + * The preview follows the editor's scrolling (usePreviewScrollSync) and draws Mermaid diagrams + * like the reading view (useMermaid), a little after typing pauses so a diagram isn't redrawn on + * every keystroke. `isVisible` (the active tab or the split pane) gates the diagrams, as there; + * it falls back to `isActive` when not passed. Remote images load only with + * `remoteImagesAllowed`; this preview has no bar of its own to offer them. + */ +function MarkdownEditView({ + doc, + isActive, + isVisible = isActive, + tabSize, + cursorLabelRef, + onDirtyChange, + onSave, + registerEditor, + remoteImagesAllowed = false +}) { + const editorRef = useRef(null); + const previewRef = useRef(null); + const [preview, setPreview] = useState(() => + remoteImagesAllowed && doc.remoteImageCount > 0 + ? renderMarkdown(doc.source, doc.path, { remoteImages: true }) + : { html: doc.html, hasMermaid: doc.hasMermaid } + ); + // Stable object, or React 19 rewrites the preview on every render (see MarkdownView). + const previewMarkup = useMemo(() => ({ __html: preview.html }), [preview.html]); + const timerRef = useRef(null); + + const renderNow = useCallback(() => { + const text = editorRef.current?.getContent() ?? ''; + const { html, hasMermaid } = renderMarkdown(text, doc.path, { remoteImages: remoteImagesAllowed }); + setPreview({ html, hasMermaid }); + }, [doc.path, remoteImagesAllowed]); + + const onDocChanged = useCallback(() => { + clearTimeout(timerRef.current); + timerRef.current = setTimeout(renderNow, 350); + }, [renderNow]); + + // Remote images switched on (Always load, in another tab) while editing: show them now. + const allowedRef = useRef(remoteImagesAllowed); + useEffect(() => { + if (allowedRef.current === remoteImagesAllowed) return; + allowedRef.current = remoteImagesAllowed; + onDocChanged(); + }, [remoteImagesAllowed, onDocChanged]); + + useEffect(() => () => clearTimeout(timerRef.current), []); + + usePreviewScrollSync(editorRef, previewRef, preview.html); + useMermaid(previewRef, { html: preview.html, hasMermaid: preview.hasMermaid, enabled: isVisible, delay: 400 }); + + return ( +
+
+ { + editorRef.current = el; + registerEditor(el); + }} + fileName={doc.name} + initialContent={doc.source} + savedContent={doc.savedSource ?? doc.source} + lint={false /* markdown has no syntax errors; skip the empty lint gutter */} + wrap={true /* prose: unwrapped markdown source is unreadable */} + tabSize={tabSize} + isActive={isActive} + onDirtyChange={onDirtyChange} + onSave={onSave} + onDocChanged={onDocChanged} + cursorLabelRef={cursorLabelRef} + /> +
+
+
+
+
+ ); +} + +export default MarkdownEditView; diff --git a/src/components/MarkdownView.jsx b/src/components/MarkdownView.jsx index cb99346..38a684a 100644 --- a/src/components/MarkdownView.jsx +++ b/src/components/MarkdownView.jsx @@ -1,5 +1,9 @@ -import { useState, useEffect, useRef, useCallback } from 'react'; -import { List } from '@phosphor-icons/react'; +import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import { List, ImageBroken } from '@phosphor-icons/react'; +import { renderMarkdown } from '../markdown.js'; +import { useMermaid } from '../useMermaid.js'; +import { findFragmentTarget, scrollIntoPreview, takePendingFragment } from '../previewLinks.js'; +import PreviewFindBar from './PreviewFindBar.jsx'; /** * MarkdownView is one markdown tab's pane: TOC sidebar, resizer, and the scrolling document. @@ -9,6 +13,10 @@ import { List } from '@phosphor-icons/react'; * tabs get that for free. The pane stays mounted (hidden) while inactive, so switching tabs * preserves scroll position exactly. * + * `isActive` is the tab the user is on; `isVisible` is true for it AND for a document shown in the + * right-hand split pane. Anything that measures layout (the active heading, Mermaid) needs only + * the second; anything that writes the app's single progress bar needs the first. + * * ── Scroll performance (see AI_CONTEXT.md §5a; do not regress) ─────────────────────────────── * The rules from the single-document era carry over verbatim: * - the progress bar/label are written straight to the DOM via refs, never via state; @@ -18,12 +26,48 @@ import { List } from '@phosphor-icons/react'; * - one rAF in flight at a time; listener registered { passive: true }. * The one tab-era addition: progress writes are gated on `isActive`: the global progress bar and * "% read" belong to the visible tab, and a background tab must not fight it for the DOM node. + * + * ── Remote images ──────────────────────────────────────────────────────────────────────────── + * renderMarkdown leaves remote images out (placeholders) and counts them. A bar offers to load + * them: "Load images" re-renders just this document, here, with them in; "Always load" also asks + * App to remember the choice (`onAllowRemoteImages`), which comes back as `remoteImagesAllowed`. */ -function MarkdownView({ doc, isActive, sidebarWidth, onSidebarWidthChange, progressBarRef, progressLabelRef }) { - const [isSidebarOpen, setIsSidebarOpen] = useState(doc.toc.length > 0); +function MarkdownView({ + doc, + isActive, + isVisible = isActive, + sidebarWidth, + onSidebarWidthChange, + progressBarRef, + progressLabelRef, + remoteImagesAllowed = false, + onAllowRemoteImages +}) { + const [remoteLoadedHere, setRemoteLoadedHere] = useState(false); + const remoteCount = doc.remoteImageCount ?? 0; + const withRemoteImages = remoteCount > 0 && (remoteImagesAllowed || remoteLoadedHere) && doc.source != null; + const remoteRender = useMemo( + () => (withRemoteImages ? renderMarkdown(doc.source, doc.path, { remoteImages: true }) : null), + [withRemoteImages, doc.source, doc.path] + ); + const html = remoteRender?.html ?? doc.html; + const toc = remoteRender?.toc ?? doc.toc; + + const [isSidebarOpen, setIsSidebarOpen] = useState(toc.length > 0); const [activeHeading, setActiveHeading] = useState(''); + /* + * Stable markup objects. React 19 compares `dangerouslySetInnerHTML` by object identity and + * rewrites innerHTML whenever it changes, even to the same string, so a fresh `{ __html }` per + * render rebuilt the whole document on every App re-render (window focus, tab switches, status + * messages): mermaid diagrams vanished and a selection about to be copied was lost. + */ + const bodyMarkup = useMemo(() => ({ __html: html }), [html]); + const tocMarkup = useMemo(() => toc.map((item) => ({ __html: item.html })), [toc]); + + const layoutRef = useRef(null); const contentRef = useRef(null); + const bodyRef = useRef(null); const headingsRef = useRef([]); const activeHeadingRef = useRef(''); const scrollRafIdRef = useRef(null); @@ -37,7 +81,7 @@ function MarkdownView({ doc, isActive, sidebarWidth, onSidebarWidthChange, progr const [lastHtml, setLastHtml] = useState(doc.html); if (lastHtml !== doc.html) { setLastHtml(doc.html); - setIsSidebarOpen(doc.toc.length > 0); + setIsSidebarOpen(toc.length > 0); } useEffect(() => { @@ -57,22 +101,30 @@ function MarkdownView({ doc, isActive, sidebarWidth, onSidebarWidthChange, progr } }, []); + /* + * The sidebar's width is the pointer's distance from THIS pane's left edge, capped at half the + * pane. It used to be measured from the window's left edge, so in the right-hand split pane the + * sidebar jumped to the pane's full offset on the first move. + */ const resize = useCallback( (e) => { - if (isResizing.current) { - let newWidth = e.clientX; - if (newWidth < 200) newWidth = 200; - if (newWidth > window.innerWidth * 0.5) newWidth = window.innerWidth * 0.5; - onSidebarWidthChange(newWidth); - } + if (!isResizing.current || !layoutRef.current) return; + const pane = layoutRef.current.getBoundingClientRect(); + onSidebarWidthChange(Math.min(Math.max(e.clientX - pane.left, 200), pane.width * 0.5)); }, [onSidebarWidthChange] ); /* * Scroll progress + active-heading tracking. Runs whenever the content changes AND whenever the - * tab becomes active (deps below), the latter so the global progress bar snaps to THIS tab's - * position on switch instead of showing the previous tab's number until the first scroll. + * pane becomes active or visible (deps below): the former so the global progress bar snaps to + * THIS tab's position on switch instead of showing the previous tab's number until the first + * scroll, the latter so the contents highlight is worked out afresh from a real layout. + * + * The active heading is computed from scratch each time and only while the pane is visible. + * Measured inside display:none every heading sits at 0, which used to mark the LAST heading + * active, and a pane coming back into view kept that answer when no heading had yet passed the + * trigger point. */ useEffect(() => { const scrollContainer = contentRef.current; @@ -97,10 +149,10 @@ function MarkdownView({ doc, isActive, sidebarWidth, onSidebarWidthChange, progr if (progressLabelRef.current) progressLabelRef.current.textContent = `${Math.round(progress)}%`; } + if (!isVisible) return; const headings = headingsRef.current; const triggerPoint = window.innerHeight * 0.4; - let currentActive = activeHeadingRef.current; - + let currentActive = ''; for (const h of headings) { if (h.getBoundingClientRect().top <= triggerPoint) { currentActive = h.id; @@ -111,10 +163,7 @@ function MarkdownView({ doc, isActive, sidebarWidth, onSidebarWidthChange, progr // Before the first heading scrolls past the trigger point, highlight it anyway so the TOC // is never blank at the top of a document. - if (headings.length > 0 && currentActive === '' && - headings[0].getBoundingClientRect().top > triggerPoint) { - currentActive = headings[0].id; - } + if (currentActive === '' && headings.length > 0) currentActive = headings[0].id; if (currentActive !== activeHeadingRef.current) { activeHeadingRef.current = currentActive; @@ -133,77 +182,28 @@ function MarkdownView({ doc, isActive, sidebarWidth, onSidebarWidthChange, progr scrollRafIdRef.current = null; } }; - }, [doc.html, isActive, progressBarRef, progressLabelRef]); + }, [html, isActive, isVisible, progressBarRef, progressLabelRef]); const scrollToHeading = (id) => { - const container = contentRef.current; - const element = container?.querySelector(`#${CSS.escape(id)}`); - if (element && container) { - container.scrollTo({ top: element.offsetTop - 40, behavior: 'smooth' }); - } + const element = bodyRef.current?.querySelector(`#${CSS.escape(id)}`); + if (element) scrollIntoPreview(element); }; - /* - * Mermaid diagrams. ```mermaid fences arrive as
. This
-   * effect lazily imports the mermaid renderer (its own chunk, loaded only when a document
-   * actually contains a diagram, still fully offline) and swaps each fence for its SVG.
-   *
-   * TWO HARD-WON RULES:
-   *   1. Only render while the pane is VISIBLE (`isActive` gates and re-triggers the pass).
-   *      Mermaid measures text with getBBox(), which returns zeros inside display:none, so a
-   *      background tab's diagrams failed silently and stayed as fences.
-   *   2. Mark a fence done only AFTER its SVG lands (and mark failures separately). The pass
-   *      mutates DOM that React owns via dangerouslySetInnerHTML; any re-render that restores the
-   *      original html brings the fences back, and this pass must then happily run again.
-   */
+  // A link from another document (`readme.md#setup`) asked for a place in this one.
   useEffect(() => {
-    if (!doc.hasMermaid || !isActive || !contentRef.current) return;
-    let cancelled = false;
+    if (!isVisible || !doc.path || !bodyRef.current) return;
+    const fragment = takePendingFragment(doc.path);
+    const el = fragment && findFragmentTarget(bodyRef.current, fragment);
+    if (el) scrollIntoPreview(el, { behavior: 'instant' });
+  }, [isVisible, isActive, html, doc.path]);
 
-    (async () => {
-      const { default: mermaid } = await import('mermaid');
-      if (cancelled || !contentRef.current) return;
+  useMermaid(contentRef, { html, hasMermaid: doc.hasMermaid, enabled: isVisible });
 
-      const isLightTheme = document.documentElement.getAttribute('data-theme') === 'light';
-      mermaid.initialize({
-        startOnLoad: false,
-        securityLevel: 'strict',
-        theme: isLightTheme ? 'default' : 'dark',
-        fontFamily: 'inherit'
-      });
-
-      const fences = contentRef.current.querySelectorAll(
-        'code.language-mermaid:not([data-mermaid-done]):not([data-mermaid-failed])'
-      );
-      for (const [i, code] of [...fences].entries()) {
-        const source = code.textContent;
-        try {
-          const { svg } = await mermaid.render(`fate-mermaid-${doc.id}-${i}-${Math.floor(performance.now())}`, source);
-          if (cancelled) return;
-          const holder = document.createElement('div');
-          holder.className = 'mermaid-diagram';
-          holder.innerHTML = svg;
-          code.setAttribute('data-mermaid-done', '1');
-          code.closest('pre')?.replaceWith(holder);
-        } catch (err) {
-          // Invalid diagram source: keep the fence as highlighted text, don't retry it forever.
-          code.setAttribute('data-mermaid-failed', '1');
-          console.error('Mermaid diagram failed to render:', err?.message || err);
-        }
-      }
-    })().catch((err) => {
-      // A failed renderer load must be visible, not a silently missing diagram.
-      console.error('Mermaid failed to load:', err?.message || err);
-    });
-
-    return () => {
-      cancelled = true;
-    };
-  }, [doc.html, doc.hasMermaid, doc.id, isActive]);
+  const showReveal = toc.length > 0 && !isSidebarOpen;
 
   return (
-    
- {doc.toc.length > 0 && isSidebarOpen && ( +
+ {toc.length > 0 && isSidebarOpen && ( <>
    - {doc.toc.map((item) => ( -
  • scrollToHeading(item.id)} - dangerouslySetInnerHTML={{ __html: item.html }} - /> + {toc.map((item, i) => ( +
  • +
  • ))}
@@ -237,7 +240,7 @@ function MarkdownView({ doc, isActive, sidebarWidth, onSidebarWidthChange, progr )}
- {doc.toc.length > 0 && !isSidebarOpen && ( + {showReveal && ( )} -
-
+ {remoteCount > 0 && !withRemoteImages && ( +
+
+ )} + +
+ +
+
+
diff --git a/src/components/PreviewFindBar.jsx b/src/components/PreviewFindBar.jsx new file mode 100644 index 0000000..57aeb73 --- /dev/null +++ b/src/components/PreviewFindBar.jsx @@ -0,0 +1,223 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { CaretUp, CaretDown, X } from '@phosphor-icons/react'; +import { + MAX_PAINTED, + buildTextIndex, + clearMatches, + findMatches, + findTargetBody, + foldQuery, + isEditableTarget, + isModalOpen, + paintMatches, + rangeAt, + revealRange +} from '../previewFind.js'; + +/** + * PreviewFindBar: find in one reading view (see previewFind.js for the engine and Ctrl+F). + * + * Its own component, holding its own state, so typing a query re-renders this bar and not the + * whole MarkdownView. The text index is built once per version of the document and rebuilt when + * the document changes under it (a reload from disk, a diagram landing), which a MutationObserver + * on the body reports. + * + * Escape closes the bar and must not reach App, where Escape is bound to closing the tab: the + * input stops it, and while the bar is open a document-level listener (which runs before App's + * window listener) does the same when focus is elsewhere in this view. + */ +function PreviewFindBar({ bodyRef, scrollerRef, isVisible }) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); + const [status, setStatus] = useState({ total: 0, current: -1 }); + /** Bumped by every Ctrl+F / `fate:find` aimed at this view: (re)focus and select the query. */ + const [focusRequest, setFocusRequest] = useState(0); + const inputRef = useRef(null); + const ownerRef = useRef(Symbol('find')); + /** { index, starts, length, current } for the query on screen. */ + const searchRef = useRef(null); + const indexRef = useRef(null); + + const close = useCallback(() => { + setOpen(false); + clearMatches(ownerRef.current); + searchRef.current = null; + }, []); + + /** Paint every match, mark the current one, and optionally bring it into view. */ + const show = useCallback( + (reveal) => { + const s = searchRef.current; + if (!s) return; + const painted = s.starts.slice(0, MAX_PAINTED).map((start) => rangeAt(s.index, start, s.length)); + const current = s.current >= 0 ? rangeAt(s.index, s.starts[s.current], s.length) : null; + paintMatches(ownerRef.current, painted, current); + setStatus({ total: s.starts.length, current: s.current }); + if (reveal && current && scrollerRef.current) revealRange(current, scrollerRef.current); + }, + [scrollerRef] + ); + + /** + * Search the body for `q`. The current match starts at the first one at or below the top of the + * view, as in a browser, rather than back at the top of the document. + */ + const search = useCallback( + (q, reveal) => { + const body = bodyRef.current; + const scroller = scrollerRef.current; + if (!body || !scroller) return; + if (!indexRef.current) indexRef.current = buildTextIndex(body); + const index = indexRef.current; + const folded = foldQuery(q); + const starts = findMatches(index, folded); + // Binary search: matches are in document order, so their tops only grow. + const viewTop = scroller.getBoundingClientRect().top; + let lo = 0; + let hi = starts.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (rangeAt(index, starts[mid], folded.length).getBoundingClientRect().top >= viewTop) hi = mid; + else lo = mid + 1; + } + // Nothing below the view: wrap to the first match. + const current = starts.length ? (lo < starts.length ? lo : 0) : -1; + searchRef.current = { index, starts, length: folded.length, current }; + show(reveal); + }, + [bodyRef, scrollerRef, show] + ); + + const step = useCallback( + (dir) => { + const s = searchRef.current; + if (!s || !s.starts.length) return; + s.current = (s.current + dir + s.starts.length) % s.starts.length; + show(true); + }, + [show] + ); + + // Open (or re-focus) on Ctrl+F / `fate:find`, when this is the view being read. + useEffect(() => { + const onFind = () => { + if (!isVisible || !bodyRef.current || findTargetBody() !== bodyRef.current) return; + setOpen(true); + setFocusRequest((n) => n + 1); + }; + window.addEventListener('fate:find', onFind); + return () => window.removeEventListener('fate:find', onFind); + }, [isVisible, bodyRef]); + + // After the bar has rendered: the input only exists once `open` has been committed. + useEffect(() => { + if (!open || !focusRequest) return; + inputRef.current?.focus(); + inputRef.current?.select(); + }, [open, focusRequest]); + + // Typing: search after a short pause (a large document takes a moment to index). + useEffect(() => { + if (!open) return; + if (!query) { + // Nothing to show; the stale count is hidden while the query is empty (see below). + searchRef.current = null; + clearMatches(ownerRef.current); + return; + } + const t = setTimeout(() => search(query, true), 60); + return () => clearTimeout(t); + }, [open, query, search]); + + // The document changed under the bar: rebuild the index and search again, without scrolling. + useEffect(() => { + const body = bodyRef.current; + if (!open || !body) return; + let timer = null; + const observer = new MutationObserver(() => { + indexRef.current = null; + clearTimeout(timer); + timer = setTimeout(() => { + if (searchRef.current || inputRef.current?.value) search(inputRef.current?.value ?? '', false); + }, 150); + }); + observer.observe(body, { childList: true, subtree: true, characterData: true }); + return () => { + observer.disconnect(); + clearTimeout(timer); + }; + }, [open, bodyRef, search]); + + // A fresh index for each opening; nothing painted is left behind on unmount. + useEffect(() => { + if (!open) indexRef.current = null; + }, [open]); + useEffect(() => { + const owner = ownerRef.current; + return () => clearMatches(owner); + }, []); + + // Escape with focus elsewhere in this view (the document, the contents) closes the bar, not the tab. + useEffect(() => { + if (!open || !isVisible) return; + const onKeyDown = (e) => { + if (e.key !== 'Escape' || e.defaultPrevented || isModalOpen() || isEditableTarget(e.target)) return; + if (findTargetBody() !== bodyRef.current) return; + e.preventDefault(); + e.stopPropagation(); + close(); + }; + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [open, isVisible, bodyRef, close]); + + if (!open) return null; + + const onInputKeyDown = (e) => { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + close(); + } else if (e.key === 'Enter') { + e.preventDefault(); + // Enter right after typing, before the pause has run the search: search now. + if (!searchRef.current && query) search(query, true); + else step(e.shiftKey ? -1 : 1); + } + }; + + const total = query ? status.total : 0; + let count = ''; + if (query && total === 0) count = 'No matches'; + else if (total > 0) count = `${status.current + 1} of ${total.toLocaleString()}`; + + return ( +
+ setQuery(e.target.value)} + onKeyDown={onInputKeyDown} + /> + + {count} + + + + +
+ ); +} + +export default PreviewFindBar; diff --git a/src/components/SettingsModal.jsx b/src/components/SettingsModal.jsx index bc0ca21..4c6a557 100644 --- a/src/components/SettingsModal.jsx +++ b/src/components/SettingsModal.jsx @@ -1,14 +1,19 @@ -import { useState, useEffect, useRef, useMemo } from 'react'; +import { useState, useEffect, useRef, useMemo, useId } from 'react'; import { X, Palette, TextAa, Code, Printer, WindowsLogo, Keyboard, Info, CaretDown, Check, Trash, ArrowSquareOut, CheckCircle, Plus, Copy, ArrowCounterClockwise, - Warning + Warning, MarkdownLogo } from '@phosphor-icons/react'; import fateLogo from '../assets/FATE-Square-Icon.png'; -import { THEMES, PAGE_SIZES, SHORTCUT_ACTIONS, DEFAULT_SHORTCUTS, FIXED_SHORTCUTS } from '../settingsMeta.js'; +import { + THEMES, PAGE_SIZES, SHORTCUT_ACTIONS, DEFAULT_SHORTCUTS, FIXED_SHORTCUTS, + shortcutParts, formatShortcutLabel, normalizeBinding, + clampSidebarWidth, SIDEBAR_WIDTH_MIN, SIDEBAR_WIDTH_MAX +} from '../settingsMeta.js'; import { PROSE_FONTS, CODE_FONTS, fontById, systemFontEntry } from '../fonts.js'; import { CODE_EXTENSIONS } from '../fileKinds.js'; import { DEFAULT_CUSTOM, CUSTOM_FIELDS, customThemeCss } from '../themeCustom.js'; +import { useDialogFocus } from '../dialogFocus.js'; /** * SettingsModal: navigation rail + content pane (1.10.0 redesign, extended in 1.11.0 with the @@ -21,6 +26,19 @@ import { DEFAULT_CUSTOM, CUSTOM_FIELDS, customThemeCss } from '../themeCustom.js * - FontPicker is a custom listbox because a native onChange(parseInt(e.target.value, 10))} /> {value}{suffix} @@ -179,16 +207,90 @@ function SizeSlider({ label, value, min, max, suffix = 'px', onChange }) { ); } -/** Pretty-print a stored binding ("Control+Shift+S" → chips). */ +/** + * The toggle switch, named by its setting's label (`labelledBy`, an element id) and optionally + * described by its hint. role="switch" so it is announced as on/off rather than as a checkbox. + */ +function Switch({ checked, onChange, labelledBy, describedBy, disabled }) { + return ( + + ); +} + +/** + * Pretty-print a stored binding ("Control+Shift+S" → chips). Through shortcutParts, which knows + * the stored format: splitting on "+" here broke on a binding of the + key itself. + */ function BindingChips({ binding }) { - const parts = (binding || '').split('+').map((p) => (p === 'Control' ? 'Ctrl' : p)); return ( - {parts.map((p, i) => {p})} + {shortcutParts(binding).map((p, i) => {p})} ); } +/** A live binding inside running text: Ctrl+T. */ +function InlineBinding({ binding }) { + return shortcutParts(binding).map((p, i) => ( + {i > 0 && '+'}{p} + )); +} + +/** + * Default sidebar width. Up to 1.13 every keystroke saved straight to the store with no bounds, + * so typing "450" saved 4, then 45, then 450, and the contents sidebar of every open document + * jumped to 4 px wide on the way. Now the field keeps a draft while you type and commits on blur + * or Enter, clamped to 200–600; Escape puts the saved value back. Input that isn't a number + * (an empty field, a lone "-") is ignored. + */ +function SidebarWidthField({ value, onCommit, labelledBy, describedBy }) { + const [draft, setDraft] = useState(null); + + const commit = () => { + if (draft === null) return; + setDraft(null); + const n = parseInt(draft, 10); + if (!Number.isFinite(n)) return; + const width = clampSidebarWidth(n); + if (width !== value) onCommit(width); + }; + + return ( + setDraft(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + commit(); + } else if (e.key === 'Escape' && draft !== null) { + // First Escape reverts the edit; the next one closes Settings. + e.preventDefault(); + setDraft(null); + } + }} + /> + ); +} + function SettingsModal({ onClose, settings, @@ -200,9 +302,19 @@ function SettingsModal({ setActiveShortcutRebind, onSidebarWidthChange, runtimeInfo, - systemFonts + systemFonts, + initialSection = 'appearance' }) { - const [section, setSection] = useState('appearance'); + const [section, setSection] = useState(initialSection || 'appearance'); + /* + * A new initialSection while open (About chosen from the menu with Settings already up) moves + * to that page. Render-time adjustment rather than an effect, like CommandPalette's query. + */ + const [lastInitialSection, setLastInitialSection] = useState(initialSection); + if (lastInitialSection !== initialSection) { + setLastInitialSection(initialSection); + if (initialSection) setSection(initialSection); + } const [coverage, setCoverage] = useState(null); const [repairBusy, setRepairBusy] = useState(false); const [repairResult, setRepairResult] = useState(null); @@ -220,11 +332,47 @@ function SettingsModal({ { id: 'appearance', label: 'Appearance', icon: Palette }, { id: 'fonts', label: 'Fonts', icon: TextAa }, { id: 'editor', label: 'Code Editor', icon: Code }, + { id: 'markdown', label: 'Markdown', icon: MarkdownLogo }, { id: 'printing', label: 'Printing', icon: Printer }, ...(isWindows ? [{ id: 'windows', label: 'Windows', icon: WindowsLogo }] : []), { id: 'shortcuts', label: 'Shortcuts', icon: Keyboard }, { id: 'about', label: 'About', icon: Info } ]; + // An unknown or unavailable page (Windows, off Windows) opens Appearance instead. + const current = sections.some((s) => s.id === section) ? section : 'appearance'; + + /* + * Ids for aria-labelledby/aria-describedby: each control is named by its setting's visible + * label rather than by a second, hidden copy of the text. + */ + const uid = useId(); + const idFor = (key) => `${uid}-${key}`; + + const platform = window.electronAPI?.platform; + // Until App stores them, fall back to the 1.14.0 defaults (see the settings contract). + const spellcheckOn = typeof settings.spellcheck === 'boolean' ? settings.spellcheck : platform === 'win32'; + const remoteImagesOn = settings.remoteImages === true; + + /* ── Modal focus: in on open, trapped while open, back to the editor on close ─────────────── */ + const dialogRef = useRef(null); + useDialogFocus(dialogRef, { + initialFocus: () => dialogRef.current?.querySelector('.settings-nav-item.active'), + // While a binding is recording, Escape cancels it (and is not recorded); otherwise it closes. + onEscape: () => (activeShortcutRebind ? setActiveShortcutRebind(null) : onClose()), + // Tab may be part of the shortcut being recorded, so the recorder must get it. + trapTab: !activeShortcutRebind + }); + + /* + * Closing Settings stops the recorder. Up to 1.13 it stayed armed after Settings closed, and the + * next key typed anywhere, even a plain letter in the editor, became that action's shortcut. + * The latest setter is read through a ref so the cleanup runs once, on unmount. + */ + const stopRecordingRef = useRef(setActiveShortcutRebind); + useEffect(() => { + stopRecordingRef.current = setActiveShortcutRebind; + }); + useEffect(() => () => stopRecordingRef.current?.(null), []); const refreshCoverage = () => { if (window.electronAPI?.getAssociationCoverage) { @@ -245,8 +393,8 @@ function SettingsModal({ /* Association coverage runs a registry sweep, fetched only when its section opens. */ useEffect(() => { - if (section === 'windows') refreshCoverage(); - }, [section]); + if (current === 'windows') refreshCoverage(); + }, [current]); const repairTypes = async () => { setRepairBusy(true); @@ -271,12 +419,16 @@ function SettingsModal({ setFonts({ perType: next }); }; - /* Duplicate bindings, flagged inline rather than silently letting first-match win. */ + /* + * Duplicate bindings, flagged inline rather than silently letting first-match win. Compared in + * normalized form, so "Control+k" and "Control+K" (or a legacy "Control++" and "Control+Plus") + * count as the same binding. + */ const conflicts = useMemo(() => { const seen = {}; const dupes = new Set(); for (const { id } of SHORTCUT_ACTIONS) { - const b = settings.shortcuts[id]; + const b = normalizeBinding(settings.shortcuts[id]); if (!b) continue; if (seen[b]) { dupes.add(b); @@ -285,6 +437,7 @@ function SettingsModal({ } return dupes; }, [settings.shortcuts]); + const isConflicted = (id) => conflicts.has(normalizeBinding(settings.shortcuts[id])); const saveCustomTheme = () => { updateSetting('customTheme', customDraft); @@ -309,17 +462,26 @@ function SettingsModal({ return (
-
e.stopPropagation()} role="dialog" aria-label="Settings"> +
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby={idFor('title')} + tabIndex={-1} + >
{/* ── APPEARANCE ─────────────────────────────────────────────────────────────── */} - {section === 'appearance' && ( + {current === 'appearance' && ( <>
Theme @@ -433,60 +595,60 @@ function SettingsModal({
Layout & startup -
- Default sidebar width - +
+ Default sidebar width + + Width of a Markdown document's contents sidebar, from {SIDEBAR_WIDTH_MIN} to{' '} + {SIDEBAR_WIDTH_MAX} pixels. + +
+ { - const val = parseInt(e.target.value, 10); - if (Number.isFinite(val)) { - updateSetting('sidebarWidth', val); - onSidebarWidthChange(val); - } + labelledBy={idFor('sidebar')} + describedBy={idFor('sidebar-hint')} + onCommit={(width) => { + updateSetting('sidebarWidth', width); + onSidebarWidthChange(width); }} />
- Reopen last session's tabs on launch - + Reopen last session's tabs on launch + Off means FATE closes your tabs when you quit and starts on the home screen next time, without keeping the list of what you had open. Either way, quitting with unsaved work asks you to save or discard it first, tab by tab.
- + updateSetting('restoreSession', v)} + labelledBy={idFor('restore')} + describedBy={idFor('restore-hint')} + />
)} {/* ── FONTS ──────────────────────────────────────────────────────────────────── */} - {section === 'fonts' && ( + {current === 'fonts' && ( <>
Interface
- Application font - setFonts({ ui: id })} /> + Application font + setFonts({ ui: id })} />
Markdown documents
- Document font - setFonts({ markdown: id })} /> + Document font + setFonts({ markdown: id })} />
setFonts({ markdownSize: v })} />
@@ -494,16 +656,17 @@ function SettingsModal({
Code
- Code font - setFonts({ code: id })} /> + Code font + setFonts({ code: id })} />
setFonts({ editorSize: v })} />
- Font ligatures - + Font ligatures + setFonts({ ligatures: v })} + labelledBy={idFor('ligatures')} + />
@@ -516,9 +679,10 @@ function SettingsModal({ {overrides.map(([ext, fontId]) => (
- .{ext} + .{ext}
- Wrap long lines - + Wrap long lines + updateSetting('editorWrap', v)} + labelledBy={idFor('wrap')} + />
- Indent size - updateSetting('editorTabSize', parseInt(e.target.value, 10))} + aria-labelledby={idFor('indent')} + > @@ -566,8 +735,8 @@ function SettingsModal({
- Highlight syntax errors - + Highlight syntax errors + Underlines code the language parser can't make sense of (missing brackets, unclosed strings, stray tokens), with a marker in the gutter. Works for languages with structural parsers (JavaScript, TypeScript, HTML, @@ -575,14 +744,12 @@ function SettingsModal({ rather than guessing.
- + updateSetting('editorLint', v)} + labelledBy={idFor('lint')} + describedBy={idFor('lint-hint')} + />
@@ -590,31 +757,87 @@ function SettingsModal({ Code files open straight into the editor; markdown opens in the reading view with an Edit button (and live preview) in the header. New files - (Ctrl+N) save as any supported format. Fonts live in - the Fonts section; every shortcut is rebindable under Shortcuts. + () save as any supported + format. Fonts live in the Fonts section; every shortcut is rebindable under + Shortcuts.
)} + {/* ── MARKDOWN (1.14.0) ────────────────────────────────────────────────────────── + Spell check and remote images: both are about what leaves the machine, so each + hint says plainly what turning it on sends where. */} + {current === 'markdown' && ( + <> +
+ Editing +
+
+ Spell check while editing Markdown + + Underlines misspelled words in Edit mode. + {platform === 'linux' && ( + <> Turning it on downloads a dictionary from Google once. + )} + +
+ updateSetting('spellcheck', v)} + labelledBy={idFor('spellcheck')} + describedBy={idFor('spellcheck-hint')} + /> +
+
+ +
+ Privacy +
+
+ + Load images from the internet in Markdown documents + + + Off by default for privacy: loading a remote image tells its server that + you opened the document, and when. Images stored on this computer are not + affected. + +
+ updateSetting('remoteImages', v)} + labelledBy={idFor('remote-images')} + describedBy={idFor('remote-images-hint')} + /> +
+
+ + )} + {/* ── PRINTING ───────────────────────────────────────────────────────────────── */} - {section === 'printing' && ( + {current === 'printing' && (
- Paper size - updateSetting('printPageSize', e.target.value)} + aria-labelledby={idFor('paper')} + > {PAGE_SIZES.map((p) => ( ))}
- Landscape - + Landscape + updateSetting('printLandscape', v)} + labelledBy={idFor('landscape')} + />
@@ -631,7 +854,7 @@ function SettingsModal({ )} {/* ── WINDOWS ────────────────────────────────────────────────────────────────── */} - {section === 'windows' && isWindows && ( + {current === 'windows' && isWindows && ( <>
Markdown default @@ -740,16 +963,13 @@ function SettingsModal({
- Always show full context menus - + Always show full context menus +
{explorerRestartNeeded && (
@@ -776,7 +996,7 @@ function SettingsModal({ )} {/* ── SHORTCUTS ──────────────────────────────────────────────────────────────── */} - {section === 'shortcuts' && ( + {current === 'shortcuts' && ( <>
@@ -796,21 +1016,33 @@ function SettingsModal({ higher in this list wins. Rebind one of them.

)} - {SHORTCUT_ACTIONS.map((a) => ( -
- - {a.label} - - -
- ))} + {/* Announced when recording starts, so a screen reader user knows what to press. */} +

+ {activeShortcutRebind + ? 'Press the new shortcut: Ctrl or Alt with a key, or a function key (F1–F12). Esc cancels.' + : ''} +

+ {SHORTCUT_ACTIONS.map((a) => { + const recording = activeShortcutRebind === a.id; + const conflicted = isConflicted(a.id); + return ( +
+ + {a.label} + + +
+ ); + })}
@@ -828,7 +1060,7 @@ function SettingsModal({ )} {/* ── ABOUT ──────────────────────────────────────────────────────────────────── */} - {section === 'about' && ( + {current === 'about' && ( <>
Updates @@ -847,15 +1079,12 @@ function SettingsModal({
) : (
- Automatic updates - + Automatic updates + updateSetting('autoUpdatesEnabled', v)} + labelledBy={idFor('updates')} + />
)}
diff --git a/src/components/Starfield.jsx b/src/components/Starfield.jsx index 89de3d9..c652f1f 100644 --- a/src/components/Starfield.jsx +++ b/src/components/Starfield.jsx @@ -19,8 +19,15 @@ import { useEffect, useRef } from 'react'; * ── Cost control ────────────────────────────────────────────────────────────────────────────── * - Nothing here touches React state; it draws straight to a canvas. * - The rAF loop only runs while mounted, i.e. only on the home screen. - * - It also pauses while the window is hidden (`visibilitychange`), so a minimised or background - * window costs nothing. + * - It pauses while the window is hidden (`visibilitychange`) AND while it is unfocused + * (window `blur`/`focus`), so a minimised window, or FATE sitting behind the app you are + * actually using, costs nothing. Up to 1.13 only hiding paused it, and an open-but-unfocused + * window animated at the display's full refresh rate all day. + * - Drawing is capped at 30 fps. Twinkling dust doesn't need 144 frames a second, and rAF still + * fires at the display rate, so frames in between are skipped. Motion is scaled by the real + * time between frames, so meteors cross at the same speed on any display. + * - Gradients and the meteor sprite are built once per build() (resize or theme change), not + * allocated per link per frame. * - `prefers-reduced-motion` renders one static frame and never starts the loop. * - Device pixel ratio is capped at 2; beyond that the cost doubles for no visible gain. */ @@ -36,6 +43,15 @@ const LINK_RADIUS = 190; const LINKS_PER_ANCHOR = 2; /** Average gap between meteors, ms. Randomised per spawn so they never feel metronomic. */ const METEOR_INTERVAL = 7000; +/** Frame budget: 30 fps. */ +const FRAME_MS = 1000 / 30; +/** + * Meteor speeds and fades are in "per 60 Hz frame" units (what they were tuned at); each frame + * scales them by the time it actually covers. + */ +const TUNED_FRAME_MS = 1000 / 60; +/** Meteor tails are drawn from this one pre-rendered strip, stretched to each meteor's length. */ +const METEOR_SPRITE_W = 256; export default function Starfield({ className = '' }) { const canvasRef = useRef(null); @@ -66,15 +82,51 @@ export default function Starfield({ className = '' }) { }; let theme = readTheme(); + /** The accent as `r, g, b` for rgba(); resolved in build(), never per frame. */ + let accentRgb = '212, 175, 55'; let dust = []; let anchors = []; + /** Links between nearby anchors: { a, b, gradient }. The gradient is built once, in build(). */ let links = []; let meteors = []; + /** A meteor's tail as a pre-rendered strip: head (right end) at half opacity, fading left. */ + let meteorSprite = null; let rafId = null; + /** Animation time in ms. Advances only while the loop runs, so a pause doesn't skip ahead. */ + let elapsed = 0; + /** When the last frame was drawn, or null when the loop is (re)starting. */ + let lastFrame = null; let nextMeteorAt = METEOR_INTERVAL; + /** Convert a hex colour to `r, g, b` so it can be used inside rgba(). */ + const rgb = (hex) => { + const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex.trim()); + return m + ? `${parseInt(m[1], 16)}, ${parseInt(m[2], 16)}, ${parseInt(m[3], 16)}` + : '212, 175, 55'; + }; + + function buildMeteorSprite() { + const sprite = document.createElement('canvas'); + sprite.width = METEOR_SPRITE_W; + sprite.height = Math.ceil(1.4 * DPR) + 2; + const sctx = sprite.getContext('2d'); + if (!sctx) return null; + const g = sctx.createLinearGradient(0, 0, METEOR_SPRITE_W, 0); + g.addColorStop(0, `rgba(${accentRgb}, 0)`); + g.addColorStop(1, `rgba(${accentRgb}, 0.5)`); + sctx.strokeStyle = g; + sctx.lineWidth = 1.4 * DPR; + sctx.beginPath(); + sctx.moveTo(0, sprite.height / 2); + sctx.lineTo(METEOR_SPRITE_W, sprite.height / 2); + sctx.stroke(); + return sprite; + } + function build() { theme = readTheme(); + accentRgb = rgb(theme.accent); const { clientWidth: w, clientHeight: h } = canvas.parentElement || canvas; canvas.width = Math.max(1, Math.floor(w * DPR)); canvas.height = Math.max(1, Math.floor(h * DPR)); @@ -119,9 +171,18 @@ export default function Starfield({ className = '' }) { } near.sort((u, v) => u[1] - v[1]); for (let k = 0; k < Math.min(LINKS_PER_ANCHOR, near.length); k++) { - links.push([a, near[k][0]]); + // Anchors never move, so each link's gradient is fixed until the next build(). + const A = anchors[a]; + const B = anchors[near[k][0]]; + const gradient = ctx.createLinearGradient(A.x, A.y, B.x, B.y); + gradient.addColorStop(0, 'rgba(185, 195, 240, 0.10)'); + gradient.addColorStop(0.5, `rgba(${accentRgb}, 0.10)`); + gradient.addColorStop(1, 'rgba(185, 195, 240, 0.10)'); + links.push({ a: A, b: B, gradient }); } } + + meteorSprite = buildMeteorSprite(); } function spawnMeteor() { @@ -135,28 +196,17 @@ export default function Starfield({ className = '' }) { }); } - /** Convert a hex colour to `r, g, b` so it can be used inside rgba(). */ - const rgb = (hex) => { - const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex.trim()); - return m - ? `${parseInt(m[1], 16)}, ${parseInt(m[2], 16)}, ${parseInt(m[3], 16)}` - : '212, 175, 55'; - }; - - function draw(t) { + /** + * Paint one frame at animation time `t`. `step` is how many 60 Hz frames' worth of motion this + * frame covers (2 at 30 fps); 0 repaints without moving anything (rebuilds, the first frame). + */ + function draw(t, step) { ctx.clearRect(0, 0, canvas.width, canvas.height); - const accentRgb = rgb(theme.accent); // Links first, so stars sit on top of them. ctx.lineWidth = 0.6 * DPR; - for (const [ai, bi] of links) { - const A = anchors[ai]; - const B = anchors[bi]; - const g = ctx.createLinearGradient(A.x, A.y, B.x, B.y); - g.addColorStop(0, 'rgba(185, 195, 240, 0.10)'); - g.addColorStop(0.5, `rgba(${accentRgb}, 0.10)`); - g.addColorStop(1, 'rgba(185, 195, 240, 0.10)'); - ctx.strokeStyle = g; + for (const { a: A, b: B, gradient } of links) { + ctx.strokeStyle = gradient; ctx.beginPath(); ctx.moveTo(A.x, A.y); ctx.lineTo(B.x, B.y); @@ -194,56 +244,67 @@ export default function Starfield({ className = '' }) { ctx.fill(); } - // Meteors, newest last so they draw over the field. + // Meteors, newest last so they draw over the field: the cached tail strip, turned to the + // meteor's heading and stretched to its length, with globalAlpha carrying the fade. for (let i = meteors.length - 1; i >= 0; i--) { const m = meteors[i]; - m.x += m.vx; - m.y += m.vy; - m.life -= 0.009; + m.x += m.vx * step; + m.y += m.vy * step; + m.life -= 0.009 * step; if (m.life <= 0 || m.x > canvas.width + 60 * DPR || m.y > canvas.height + 60 * DPR) { meteors.splice(i, 1); continue; } - const tailX = m.x - m.vx * 9; - const tailY = m.y - m.vy * 9; - const g = ctx.createLinearGradient(m.x, m.y, tailX, tailY); - g.addColorStop(0, `rgba(${accentRgb}, ${0.5 * m.life})`); - g.addColorStop(1, `rgba(${accentRgb}, 0)`); - ctx.globalAlpha = 1; - ctx.strokeStyle = g; - ctx.lineWidth = 1.4 * DPR; - ctx.beginPath(); - ctx.moveTo(m.x, m.y); - ctx.lineTo(tailX, tailY); - ctx.stroke(); + if (!meteorSprite) continue; + const length = Math.hypot(m.vx, m.vy) * 9; + ctx.globalAlpha = m.life; + ctx.translate(m.x, m.y); + ctx.rotate(Math.atan2(m.vy, m.vx)); + ctx.drawImage(meteorSprite, -length, -meteorSprite.height / 2, length, meteorSprite.height); + ctx.setTransform(1, 0, 0, 1, 0, 0); } ctx.globalAlpha = 1; } - let start = null; + /* + * rAF fires at the display's rate (60, 144, 240 Hz); a frame is only drawn once FRAME_MS has + * passed since the last one, which caps drawing at 30 fps. The small allowance absorbs timer + * jitter, so a 60 Hz display reliably draws every second frame. + */ function frame(now) { - if (start === null) start = now; - const t = now - start; - if (t > nextMeteorAt) { - spawnMeteor(); - nextMeteorAt = t + METEOR_INTERVAL * (0.6 + Math.random()); + if (lastFrame === null || now - lastFrame >= FRAME_MS - 2) { + // The time this frame covers, capped so a stall (a busy main thread) can't fling meteors. + const dt = lastFrame === null ? FRAME_MS : Math.min(now - lastFrame, 4 * FRAME_MS); + lastFrame = now; + elapsed += dt; + if (elapsed > nextMeteorAt) { + spawnMeteor(); + nextMeteorAt = elapsed + METEOR_INTERVAL * (0.6 + Math.random()); + } + draw(elapsed, dt / TUNED_FRAME_MS); } - draw(t); rafId = requestAnimationFrame(frame); } + /** Whether the window is focused; kept current by the focus/blur listeners below. */ + let focused = document.hasFocus(); + const shouldAnimate = () => !reduced && !document.hidden && focused; + const stop = () => { if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null; } + lastFrame = null; // the first frame after a pause draws at once, with a normal step }; const play = () => { - if (rafId === null && !reduced) rafId = requestAnimationFrame(frame); + if (rafId === null && shouldAnimate()) rafId = requestAnimationFrame(frame); }; + const sync = () => (shouldAnimate() ? play() : stop()); + build(); /* @@ -252,16 +313,26 @@ export default function Starfield({ className = '' }) { * Two reasons this is unconditional rather than only in the reduced-motion branch: * 1. requestAnimationFrame does not fire until the next compositor frame, so without this the * canvas is blank for a frame on mount: a visible flash of empty sky. - * 2. rAF does not fire at all while the document is hidden. If the window opens minimised or - * on another desktop, the loop never starts and the sky stays blank until the window is - * focused. Drawing once here means there is always a sky, and the loop only adds motion. + * 2. rAF does not fire at all while the document is hidden, and the loop doesn't run while + * the window is unfocused. If the window opens minimised, on another desktop or behind + * another app, the sky would stay blank until it was focused. Drawing once here means + * there is always a sky, and the loop only adds motion. */ - draw(0); - if (!reduced) play(); + draw(0, 0); + play(); - // Pause entirely while the window is hidden; a minimised window should cost nothing. - const onVisibility = () => (document.hidden ? stop() : play()); - document.addEventListener('visibilitychange', onVisibility); + // Pause while the window is hidden or unfocused; resume when it is visible and focused again. + const onFocus = () => { + focused = true; + sync(); + }; + const onBlur = () => { + focused = false; + sync(); + }; + document.addEventListener('visibilitychange', sync); + window.addEventListener('focus', onFocus); + window.addEventListener('blur', onBlur); /* * Rebuild the field. Debounced with setTimeout, NOT requestAnimationFrame: rAF does not fire @@ -274,7 +345,7 @@ export default function Starfield({ className = '' }) { rebuildTimer = setTimeout(() => { rebuildTimer = null; build(); - draw(0); // repaint now; the loop may not be running (hidden window, or reduced motion) + draw(elapsed, 0); // repaint now; the loop may not be running (paused, or reduced motion) }, 80); }; @@ -301,7 +372,9 @@ export default function Starfield({ className = '' }) { if (rebuildTimer !== null) clearTimeout(rebuildTimer); observer.disconnect(); themeObserver.disconnect(); - document.removeEventListener('visibilitychange', onVisibility); + document.removeEventListener('visibilitychange', sync); + window.removeEventListener('focus', onFocus); + window.removeEventListener('blur', onBlur); }; }, []); diff --git a/src/components/StatusMenu.jsx b/src/components/StatusMenu.jsx new file mode 100644 index 0000000..42043eb --- /dev/null +++ b/src/components/StatusMenu.jsx @@ -0,0 +1,137 @@ +import { useState, useRef, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { Check } from '@phosphor-icons/react'; + +/** + * StatusMenu: a status-bar button that opens a small menu above it (1.14.0). The encoding item + * (FormatStatus) and the indentation item (IndentStatus) are both built on it. + * + * The menu is portalled to with fixed positioning: the status bar clips its overflow, so a + * popover inside it would be cut off at 30px. + * + * Keyboard: the menu takes focus on open; arrows move, Home/End jump, Enter/Space choose, Escape + * closes and hands focus back to the button, Tab closes it. Escape is consumed here so it never + * reaches the app-wide shortcut handler (which would otherwise read it as "close tab"). + * + * `children(choose)` renders the items. `choose(fn)` is an item's click handler: it closes the + * menu, puts focus back on the button, then runs `fn`. + */ +const ITEM_SELECTOR = '[role^="menuitem"]:not([disabled])'; + +function StatusMenu({ label, title, menuLabel, width = 240, children }) { + const [menuPos, setMenuPos] = useState(null); + const buttonRef = useRef(null); + const menuRef = useRef(null); + + const closeMenu = (refocus) => { + setMenuPos(null); + if (refocus) buttonRef.current?.focus(); + }; + + const toggleMenu = () => { + if (menuPos) { + setMenuPos(null); + return; + } + const r = buttonRef.current.getBoundingClientRect(); + setMenuPos({ + left: Math.max(8, Math.min(r.left, window.innerWidth - width - 8)), + bottom: window.innerHeight - r.top + 6 + }); + }; + + useEffect(() => { + if (!menuPos) return; + menuRef.current?.querySelector(ITEM_SELECTOR)?.focus(); + const onPointerDown = (e) => { + if (menuRef.current?.contains(e.target) || buttonRef.current?.contains(e.target)) return; + setMenuPos(null); + }; + const dismiss = () => setMenuPos(null); + document.addEventListener('mousedown', onPointerDown, true); + window.addEventListener('resize', dismiss); + window.addEventListener('blur', dismiss); + return () => { + document.removeEventListener('mousedown', onPointerDown, true); + window.removeEventListener('resize', dismiss); + window.removeEventListener('blur', dismiss); + }; + }, [menuPos]); + + const onMenuKeyDown = (e) => { + const items = [...(menuRef.current?.querySelectorAll(ITEM_SELECTOR) || [])]; + const i = items.indexOf(document.activeElement); + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + const step = e.key === 'ArrowDown' ? 1 : -1; + items[(i + step + items.length) % items.length]?.focus(); + } else if (e.key === 'Home' || e.key === 'End') { + e.preventDefault(); + items[e.key === 'Home' ? 0 : items.length - 1]?.focus(); + } else if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + closeMenu(true); + } else if (e.key === 'Tab') { + closeMenu(false); + } + }; + + const choose = (fn) => () => { + closeMenu(true); + fn(); + }; + + return ( + <> + + + {menuPos && + createPortal( +
+ {children(choose)} +
, + document.body + )} + + ); +} + +/** One item: a radio item when `checked` is given (ticked when true), a plain one otherwise. */ +export function StatusMenuItem({ checked, disabled = false, onClick, children }) { + const radio = checked !== undefined; + return ( + + ); +} + +export default StatusMenu; diff --git a/src/dialogFocus.js b/src/dialogFocus.js new file mode 100644 index 0000000..ccc2064 --- /dev/null +++ b/src/dialogFocus.js @@ -0,0 +1,148 @@ +import { useEffect, useRef, useState } from 'react'; + +/** + * dialogFocus.js: keyboard focus for modal dialogs (Settings, the command palette), 1.14.0. + * + * Up to 1.13 both dialogs left focus wherever it happened to be: Tab walked out of the dialog + * into the tab strip and editors behind the backdrop, and closing either left focus on , so + * the next keystroke went nowhere until you clicked back into the document. A modal has to: + * + * 1. move focus inside when it opens, + * 2. keep Tab and Shift+Tab cycling inside while it is open, with the rest of the app inert + * (unreachable for focus and hidden from screen readers), + * 3. give focus back to whatever had it before (usually the editor) when it closes. + * + * The opener is captured in a useState initializer, i.e. during the dialog's first render, which + * is before anything inside it can take focus. The Tab and Escape handlers sit on `document`, not + * on the dialog, so they still work after a click on a non-focusable part of the dialog has left + * focus on . They run after React's own handlers (React listens on #root, below document), + * so a control inside that handles a key itself and calls preventDefault keeps it. + * + * Own module (not inside a component file) because react-refresh needs component files to export + * only components, and both dialogs use it. + */ + +const FOCUSABLE = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled]):not([type="hidden"])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[contenteditable="true"]', + '[contenteditable=""]', + '[tabindex]' +].join(','); + +/** Dialogs currently open, oldest first (their refs); keyboard handling belongs to the last. */ +const openDialogs = []; + +/** Tab stops inside `container`, in DOM order: focusable, not tabindex="-1", and rendered. */ +export function focusableElements(container) { + if (!container) return []; + return Array.from(container.querySelectorAll(FOCUSABLE)).filter( + (el) => el.tabIndex >= 0 && el.getClientRects().length > 0 + ); +} + +/** + * Modal focus handling for the dialog element in `dialogRef`. + * + * initialFocus() the element to focus on open (default: the first tab stop, else the dialog) + * onEscape(e) called for an Escape that no control inside has handled (preventDefault); + * the event is preventDefault-ed, so App's own Escape handling (close the diff, + * leave focus mode, close the tab) leaves it alone + * trapTab false lets Tab through untouched, e.g. while the shortcut recorder is + * listening and Tab may be part of the shortcut being recorded + */ +export function useDialogFocus(dialogRef, { initialFocus, onEscape, trapTab = true } = {}) { + const [opener] = useState(() => (typeof document === 'undefined' ? null : document.activeElement)); + + // Latest options for the once-registered document listener. + const optionsRef = useRef({ onEscape, trapTab }); + useEffect(() => { + optionsRef.current = { onEscape, trapTab }; + }); + + const initialFocusRef = useRef(initialFocus); + + useEffect(() => { + const dialog = dialogRef.current; + + /* + * Everything beside the dialog's backdrop (its parent) goes inert while it is open: the tab + * strip, the editors, the status bar. aria-modal alone doesn't do it in Chromium; a screen + * reader could still wander into the document behind, and so could focus. Only elements this + * dialog made inert are restored, so a palette opened over Settings leaves Settings' own + * inert background alone when it closes. + */ + const backdrop = dialog?.parentElement; + const madeInert = []; + for (const el of backdrop?.parentElement?.children ?? []) { + if (el !== backdrop && !el.hasAttribute('inert')) { + el.setAttribute('inert', ''); + madeInert.push(el); + } + } + + const target = initialFocusRef.current?.() || focusableElements(dialog)[0] || dialog; + target?.focus({ preventScroll: true }); + + return () => { + for (const el of madeInert) el.removeAttribute('inert'); + /* + * Back to the opener, if it is still in the page and can take focus. A CodeMirror editor + * restores its own selection on focus. An opener that has gone (the palette's input, when + * the palette opened Settings) is skipped; whoever is showing now owns focus. + */ + if (opener && opener !== document.body && opener.isConnected && typeof opener.focus === 'function') { + opener.focus({ preventScroll: true }); + } + }; + }, [dialogRef, opener]); + + useEffect(() => { + openDialogs.push(dialogRef); + const onKeyDown = (e) => { + const { onEscape: escape, trapTab: trapping } = optionsRef.current; + // Only the topmost dialog answers: the palette can open over Settings (Ctrl+K), and + // Settings' trap would otherwise pull focus out of the palette on the first Tab. + if (e.defaultPrevented || openDialogs[openDialogs.length - 1] !== dialogRef) return; + const dialog = dialogRef.current; + if (!dialog) return; + + if (e.key === 'Escape') { + if (escape) { + e.preventDefault(); + escape(e); + } + return; + } + // Ctrl+Tab and friends are tab-switching shortcuts, not focus movement. + if (!trapping || e.key !== 'Tab' || e.ctrlKey || e.altKey || e.metaKey) return; + + const stops = focusableElements(dialog); + if (stops.length === 0) { + e.preventDefault(); + dialog.focus({ preventScroll: true }); + return; + } + const first = stops[0]; + const last = stops[stops.length - 1]; + const active = document.activeElement; + const inside = active !== dialog && dialog.contains(active); + if (e.shiftKey && (!inside || active === first)) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && (!inside || active === last)) { + e.preventDefault(); + first.focus(); + } + }; + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('keydown', onKeyDown); + const i = openDialogs.lastIndexOf(dialogRef); + if (i !== -1) openDialogs.splice(i, 1); + }; + }, [dialogRef]); +} diff --git a/src/docFormat.js b/src/docFormat.js new file mode 100644 index 0000000..b25b1d0 --- /dev/null +++ b/src/docFormat.js @@ -0,0 +1,85 @@ +/** + * docFormat.js: the renderer's view of a file's on-disk FORMAT (1.14.0). + * + * The main process owns encoding and line endings: it decodes on open, hands the renderer clean + * `\n` text without a byte-order mark, and re-encodes on save (see electron/preload.cjs). A tab + * only carries the format object, + * { encoding: 'utf8' | 'utf16le' | 'utf16be' | 'windows1252', bom: boolean, eol: '\n' | '\r\n' | '\r' } + * and passes it back with every save. This module is the labels and comparisons the status bar + * needs, kept pure so `node --test` covers it. + */ + +/** What a new Untitled buffer is written as: UTF-8 without a BOM, the platform's line break. */ +export function defaultFormat(platform) { + return { encoding: 'utf8', bom: false, eol: platform === 'win32' ? '\r\n' : '\n' }; +} + +/** Status-bar label for a line ending. */ +export function eolLabel(eol) { + if (eol === '\r\n') return 'CRLF'; + if (eol === '\r') return 'CR'; + return 'LF'; +} + +/** + * The status bar's EOL click: CRLF ↔ LF. A classic-Mac CR file goes to LF, the line break every + * current tool reads. + */ +export function toggledEol(eol) { + return eol === '\n' ? '\r\n' : '\n'; +} + +/** Status-bar label for an encoding (+ BOM). */ +export function encodingLabel(format) { + if (!format) return ''; + switch (format.encoding) { + case 'utf16le': + return 'UTF-16 LE'; + case 'utf16be': + return 'UTF-16 BE'; + case 'windows1252': + return 'Windows-1252'; + default: + return format.bom ? 'UTF-8 BOM' : 'UTF-8'; + } +} + +/** + * "Save with encoding…" choices. UTF-16 is written with its byte-order mark: that is what + * Notepad and PowerShell write, and what lets other tools tell LE from BE without guessing. + */ +export const SAVE_ENCODINGS = [ + { key: 'utf8', label: 'UTF-8', encoding: 'utf8', bom: false }, + { key: 'utf8bom', label: 'UTF-8 BOM', encoding: 'utf8', bom: true }, + { key: 'utf16le', label: 'UTF-16 LE', encoding: 'utf16le', bom: true }, + { key: 'utf16be', label: 'UTF-16 BE', encoding: 'utf16be', bom: true }, + { key: 'windows1252', label: 'Windows-1252', encoding: 'windows1252', bom: false } +]; + +/** "Reopen with encoding…" choices: how to DECODE the bytes (a BOM is detected either way). */ +export const REOPEN_ENCODINGS = [ + { key: 'utf8', label: 'UTF-8', encoding: 'utf8' }, + { key: 'utf16le', label: 'UTF-16 LE', encoding: 'utf16le' }, + { key: 'utf16be', label: 'UTF-16 BE', encoding: 'utf16be' }, + { key: 'windows1252', label: 'Windows-1252', encoding: 'windows1252' } +]; + +/** Is `choice` (a SAVE_ENCODINGS entry) the encoding `format` already has? */ +export function isCurrentEncoding(format, choice) { + return !!format && format.encoding === choice.encoding && !!format.bom === choice.bom; +} + +/** Same on-disk format? Two missing formats are the same; one missing and one known are not. */ +export function sameFormat(a, b) { + if (!a || !b) return !a && !b; + return a.encoding === b.encoding && !!a.bom === !!b.bom && a.eol === b.eol; +} + +/** + * Does saving this tab change how its file is stored, even with the text untouched? True after + * an EOL or encoding switch, until it is saved or switched back. Untitled buffers have no stored + * format to differ from. + */ +export function formatChanged(doc) { + return !!(doc && doc.savedFormat && doc.format && !sameFormat(doc.format, doc.savedFormat)); +} diff --git a/src/editorPrefs.js b/src/editorPrefs.js new file mode 100644 index 0000000..d04b485 --- /dev/null +++ b/src/editorPrefs.js @@ -0,0 +1,17 @@ +import { createContext } from 'react'; + +/** + * editorPrefs.js: per-pane editor preferences that reach a CodeEditor through context (1.14.0). + * + * spellcheck mark misspelled words. App turns it on only around a Markdown tab's Edit mode, + * and only while the `spellcheck` setting is on: code is full of words no + * dictionary knows, and underlining every identifier helps nobody. + * indent the document's indentation, { useTabs, size } (see indentDetect.js), or null for + * the editor's own `tabSize` in spaces. + * + * Context rather than props because the Markdown editor sits inside MarkdownEditView, which App + * doesn't render the CodeEditor of; App wraps each pane in a provider with that pane's values, and + * a CodeEditor with no provider above it gets the defaults (no spellcheck, `tabSize` spaces). + * A plain module, not a component file: react-refresh wants those to export components only. + */ +export const EditorPrefsContext = createContext({ spellcheck: false, indent: null }); diff --git a/src/fileKinds.js b/src/fileKinds.js index 5580d22..741de5e 100644 --- a/src/fileKinds.js +++ b/src/fileKinds.js @@ -15,8 +15,31 @@ * the per-type document icons (scripts/generate-file-icons.mjs). */ -/** These render through the markdown pipeline. Everything else FATE opens goes to the code editor. */ -export const MARKDOWN_EXTENSIONS = ['md', 'markdown', 'txt']; +/** + * These render through the markdown pipeline. Everything else FATE opens goes to the code editor. + * + * `.txt` left this list in 1.14.0. Plain text is mostly logs, exports and notes that were never + * written as Markdown: the pipeline reflowed them (single line breaks, `#` and `*` read as markup), + * and a 10 MB log overflowed the parser's stack and blanked the window. A .txt that IS Markdown is + * one "Render as Markdown" away (see couldBeMarkdown). electron/main.cjs keeps `txt` in its own + * list: there it only drives the open dialog's filter and the Windows registration. + */ +export const MARKDOWN_EXTENSIONS = ['md', 'markdown']; + +/* + * Size routing, in characters (UTF-16 units, close enough to bytes for the purpose). + * + * MARKDOWN_RENDER_LIMIT: a Markdown file larger than this opens in the editor as plain text, with + * a notice offering "Render as Markdown". Rendering runs on the UI thread in one go (marked, KaTeX, + * highlight.js and the sanitiser), so past a couple of megabytes the window freezes for seconds + * before showing anything, and pathological input can exhaust the stack. + * + * LARGE_FILE_LIMIT: past this the code editor runs in large-file mode, without the extensions + * whose cost grows with the document or the selection (syntax-error lint, bracket matching, + * selection-match highlighting, autocompletion). Big logs open and scroll like small ones. + */ +export const MARKDOWN_RENDER_LIMIT = 2 * 1024 * 1024; +export const LARGE_FILE_LIMIT = 5 * 1024 * 1024; /** The curated "Code files" filter, and the types FATE registers for on Windows. */ export const CODE_EXTENSIONS = [ @@ -44,6 +67,23 @@ export function fileKindForName(name) { return MARKDOWN_EXTENSIONS.includes(extensionOf(name)) ? 'markdown' : 'code'; } +/** Which surface a file opens in, given its name AND its size (see MARKDOWN_RENDER_LIMIT). */ +export function kindForOpen(name, length) { + return fileKindForName(name) === 'markdown' && length <= MARKDOWN_RENDER_LIMIT ? 'markdown' : 'code'; +} + +/** + * Could this code tab be Markdown, so "Render as Markdown" is worth offering? Markdown files that + * opened as plain text for their size, `.txt`, and extensionless names (README, NOTES). Dotfiles + * (.env, .gitignore) have no extension either, but they are configuration, never prose. + */ +export function couldBeMarkdown(name) { + const base = (name || '').replace(/^.*[\\/]/, ''); + if (base.startsWith('.')) return false; + const ext = extensionOf(base); + return ext === '' || ext === 'txt' || MARKDOWN_EXTENSIONS.includes(ext); +} + /** * The renderer-side twin of the main process's binary sniff: a NUL in the first 8 K characters. * Text FATE can read (UTF-8, ASCII) never contains one; executables, images and archives do, diff --git a/src/hotExit.js b/src/hotExit.js new file mode 100644 index 0000000..7a83954 --- /dev/null +++ b/src/hotExit.js @@ -0,0 +1,110 @@ +/** + * hotExit.js: the renderer's half of hot exit (1.14.0). + * + * Every tab with unsaved changes is copied into the app's data folder (main's + * `backups.write(id, data)`, one JSON file per tab) about a second after it changes, and the copy + * is removed when the tab is saved, closed, discarded or becomes clean again. A session that ends + * cleanly therefore leaves nothing behind; a crash, a kill or a power cut leaves exactly the + * unsaved buffers, which the next launch puts back. Untitled buffers included: before this they + * existed nowhere but in memory. + * + * This module holds the pure parts (ids, payloads, restore planning; covered by `node --test`) + * plus the crash-flush hook the root error boundary calls. The timing and the IPC live in App.jsx. + */ + +/** Main validates ids against exactly this. */ +export const BACKUP_ID_RE = /^[A-Za-z0-9_-]{1,64}$/; + +/** A fresh backup id for a tab: stable for the tab's life, unique across sessions. */ +export function newBackupId() { + const c = globalThis.crypto; + if (c && typeof c.randomUUID === 'function') return c.randomUUID(); + return `b${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`; +} + +/** + * The payload for backups.write (the contract in electron/preload.cjs): + * { kind, name, path, content, format, savedContent, untitled, editMode? }. + */ +export function backupPayload(doc, content, savedContent) { + const data = { + kind: doc.kind === 'markdown' ? 'markdown' : 'code', + name: doc.name, + path: doc.path || null, + content, + format: doc.format || null, + savedContent: typeof savedContent === 'string' ? savedContent : null, + untitled: !doc.path + }; + if (doc.kind === 'markdown') data.editMode = !!doc.editMode; + return data; +} + +/** A listed backup FATE can actually restore. */ +export function isRestorable(b) { + return !!b && typeof b.id === 'string' && BACKUP_ID_RE.test(b.id) && typeof b.content === 'string'; +} + +/** + * Sort a `backups.list()` result into what to do with it. + * byPath: Map pathKey → backup, the newest per file (applied once that file's tab opens) + * untitled: backups without a path, oldest first (they become Untitled tabs in their old order) + * drop: ids to delete: unreadable entries, and older copies of a file that has a newer one + */ +export function planRestore(list, keyOf) { + const byPath = new Map(); + const untitled = []; + const drop = []; + const entries = Array.isArray(list) ? list : []; + const newestFirst = [...entries].sort((a, b) => (Number(b?.savedAt) || 0) - (Number(a?.savedAt) || 0)); + for (const b of newestFirst) { + if (!isRestorable(b)) { + if (b && typeof b.id === 'string' && BACKUP_ID_RE.test(b.id)) drop.push(b.id); + continue; + } + if (b.path) { + const key = keyOf(b.path); + if (byPath.has(key)) drop.push(b.id); + else byPath.set(key, b); + } else { + untitled.push(b); + } + } + untitled.reverse(); + return { byPath, untitled, drop }; +} + +/** The N of an "Untitled-N" name, or 0. Restored Untitled tabs push the counter past theirs. */ +export function untitledNumber(name) { + const m = /^Untitled-(\d+)$/.exec(name || ''); + return m ? Number(m[1]) : 0; +} + +/** Shallow identity comparison of two backup signatures (arrays of the things a backup holds). */ +export function sameSignature(a, b) { + if (!a || !b || a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; +} + +/* + * ── Crash flush ────────────────────────────────────────────────────────────────────────────── + * The root error boundary (main.jsx) cannot see App's buffers. App registers a function here that + * writes every dirty tab's backup at once, and the boundary calls it as it catches an error, so + * the recovery screen's Reload loses nothing typed since the last periodic write. Resolves true + * when every write landed. + */ +let crashFlusher = null; + +export function setCrashFlusher(fn) { + crashFlusher = typeof fn === 'function' ? fn : null; +} + +export function flushForCrash() { + if (!crashFlusher) return Promise.resolve(false); + try { + return Promise.resolve(crashFlusher()).catch(() => false); + } catch { + return Promise.resolve(false); + } +} diff --git a/src/indentDetect.js b/src/indentDetect.js new file mode 100644 index 0000000..98cbed7 --- /dev/null +++ b/src/indentDetect.js @@ -0,0 +1,146 @@ +/** + * indentDetect.js: how a document indents, tabs or spaces and how wide (1.14.0, M7). + * + * Up to 1.13.4 the editor's indent unit was always the "Indent size" setting in spaces, so the Tab + * key put spaces into a Makefile recipe (make then fails with "missing separator") and into Go + * files (which gofmt indents with tabs), and a 2-space project edited at the default 4 came out + * mixed. Now each document gets its own indentation when it opens: + * + * 1. what the file type requires: Makefiles and Go always indent with tabs; + * 2. else the file's own convention: whichever of tabs or spaces most indented lines start + * with, and for spaces the step most indentation increases use; + * 3. else (nothing indented yet, an even split) the user's setting, spaces of "Indent size". + * + * The status bar shows the result ("Spaces: 2", "Tab size: 4") and can change it for that + * document. Detection and changes only decide what NEW indentation looks like; nothing already in + * the file is re-indented. + * + * A document's indentation is `{ useTabs: true }` (the tab WIDTH then follows the setting), + * `{ useTabs: false, size }`, or null for "use the setting". effectiveIndent() completes any of + * them into what the editor needs. Pure, for `node --test` (test/indentDetect.test.mjs). + */ + +/** The sizes the status bar offers. Detection can find others (3 spaces), which display as found. */ +export const INDENT_SIZES = [2, 4, 8]; + +/** Enough lines for any file's convention; a huge log stops here instead of walking it all. */ +const SCAN_LINES = 10000; +const SCAN_CHARS = 2 * 1024 * 1024; + +/** A step wider than this is alignment (arguments under an open paren), not an indent level. */ +const MAX_STEP = 8; + +/** + * Files whose format requires tab indentation, whatever they contain now: Makefiles (recipe lines + * must start with a tab; GNUmakefile, automake's Makefile.am / .in, *.mk and *.mak included) and + * Go (gofmt). Other `Makefile.*` names are left to detection: `Makefile.js` is JavaScript. + */ +export function requiresTabs(fileName) { + const base = String(fileName || '').split(/[\\/]/).pop().toLowerCase(); + return /^(?:gnu)?makefile(?:\.(?:am|in))?$/.test(base) || /\.(?:mk|mak|go)$/.test(base); +} + +/** + * The file's own indentation: `{ useTabs: true }`, `{ useTabs: false, size }` (size null when no + * step could be measured), or null when the text has no say (nothing indented, or as many lines + * indented with tabs as with spaces). + * + * Lines that don't speak for the convention are skipped: blank ones, one-space indents (a + * comment's alignment, ` * ` continuations of a block comment above any code), and lines whose + * text starts with `*` (block-comment bodies, wherever they sit). The width is the most common + * INCREASE in leading spaces from one line to the next: going in is one level at a time, while + * coming out of nested blocks can drop several at once. + */ +export function detectIndent(text) { + if (typeof text !== 'string' || !text) return null; + let tabLines = 0; + let spaceLines = 0; + const steps = new Map(); + /** Leading spaces of the last line that counted, or null after a tab-indented one. */ + let previous = 0; + + const limit = Math.min(text.length, SCAN_CHARS); + let lineStart = 0; + for (let n = 0; n < SCAN_LINES && lineStart < limit; n++) { + let lineEnd = text.indexOf('\n', lineStart); + if (lineEnd === -1) lineEnd = text.length; + let textStart = lineStart; + while (textStart < lineEnd && (text[textStart] === ' ' || text[textStart] === '\t')) textStart++; + const indented = textStart > lineStart; + const firstChar = text[lineStart]; + const blank = textStart >= lineEnd || text[textStart] === '\r'; + const commentBody = text[textStart] === '*'; + let spaces = 0; + while (text[lineStart + spaces] === ' ') spaces++; + lineStart = lineEnd + 1; + + if (blank) continue; + if (!indented) { + previous = 0; + continue; + } + if (commentBody) continue; + if (firstChar === '\t') { + tabLines++; + previous = null; + continue; + } + // Indented with spaces (a tab later in the run doesn't make it a tab-indented line). + if (spaces === 1) continue; + spaceLines++; + if (previous !== null && spaces > previous && spaces - previous <= MAX_STEP) { + const step = spaces - previous; + steps.set(step, (steps.get(step) || 0) + 1); + } + previous = spaces; + } + + if (tabLines === spaceLines) return null; + if (tabLines > spaceLines) return { useTabs: true }; + return { useTabs: false, size: commonStep(steps) }; +} + +/** + * The step seen most often, or null when none was. A tie goes to the smaller step (two levels at + * once look like one wider one), and a one-space step only counts when nothing else turned up: + * it is alignment far more often than a convention. + */ +function commonStep(steps) { + let best = null; + let bestCount = 0; + for (const [step, count] of steps) { + if (step === 1) continue; + if (count > bestCount || (count === bestCount && step < best)) { + best = step; + bestCount = count; + } + } + if (best === null && steps.has(1)) return 1; + return best; +} + +/** A document's indentation as it opens: the file type's requirement, else the file's own, else null. */ +export function indentForDocument(fileName, text) { + if (requiresTabs(fileName)) return { useTabs: true }; + return detectIndent(text); +} + +/** + * What the editor uses: the document's indentation completed from the user's "Indent size" + * (`settingSize`), which gives the width of tabs and of spaces nobody measured. + */ +export function effectiveIndent(docIndent, settingSize) { + const valid = (n) => Number.isInteger(n) && n >= 1 && n <= 16; + const size = valid(docIndent?.size) ? docIndent.size : valid(settingSize) ? settingSize : 4; + return { useTabs: !!docIndent?.useTabs, size }; +} + +/** One level of indentation as text: a tab, or `size` spaces (CodeMirror's indentUnit). */ +export function indentUnitText(indent) { + return indent?.useTabs ? '\t' : ' '.repeat(indent?.size || 4); +} + +/** The status bar's label: "Spaces: 4" or "Tab size: 4". */ +export function indentLabel(indent) { + return indent?.useTabs ? `Tab size: ${indent.size}` : `Spaces: ${indent?.size}`; +} diff --git a/src/languageDetect.js b/src/languageDetect.js index 6a52328..c88ed9b 100644 --- a/src/languageDetect.js +++ b/src/languageDetect.js @@ -22,6 +22,8 @@ import { languages } from '@codemirror/language-data'; export function detectLanguage(fileName, content) { const byName = fileName ? LanguageDescription.matchFilename(languages, fileName) : null; if (byName) return byName; + // JSONC names the registry doesn't know (.jsonc, .eslintrc, .code-workspace) are JSON to look at. + if (isJsonWithComments(fileName)) return byLanguageName('JSON'); return sniffLanguage(content); } @@ -69,7 +71,71 @@ export function sniffLanguage(content) { const firstMeaningful = trimmed.split(/\r?\n/).find((line) => line.trim() && !/^\s*[;#]/.test(line)) || ''; if (/^\[[A-Za-z0-9 ._:\\/-]+\]\s*$/.test(firstMeaningful.trim())) return byLanguageName('Properties files'); - if (/^[{[]/.test(trimmed)) return byLanguageName('JSON'); + if (looksLikeJson(trimmed, content)) return byLanguageName('JSON'); return null; } + +/** Files up to this size get a full JSON.parse when their first token doesn't settle it. */ +const JSON_PARSE_LIMIT = 1024 * 1024; + +/** Whitespace and JSONC comments, which may sit between JSON tokens. */ +const GAP = String.raw`(?:\s|//[^\n]*(?:\n|$)|/\*[\s\S]*?\*/)*`; +const SCALAR = String.raw`(?:-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null)`; +const OBJECT_HEAD = new RegExp(String.raw`^\{${GAP}["}]`); +const ARRAY_HEAD = new RegExp(String.raw`^\[${GAP}(?:[[{"]|${SCALAR}${GAP}[,\]]|\]${GAP}$)`); + +/** + * Is this JSON? Up to 1.13 anything starting with `[` or `{` was, so a log whose lines start + * `[2026-10-04 12:00:01] INFO` or `[INFO]` opened as JSON and syntax checking underlined hundreds + * of "errors". Now the first token after the bracket has to be one JSON allows there (comments + * may come first, for JSONC): + * + * `[` then `{` `[` `"`, a number / true / false / null followed by `,` or `]`, or `]` that + * ends the file + * `{` then `"` `}` + * + * A number has to be followed by `,` or `]` because a timestamp starts with digits too + * (`[2026-10-04` is the number 2026 followed by `-`), and an empty array has to be the whole file + * because `[ ] task` is a checklist. When the head doesn't settle it, a file under 1 MB that + * parses as a whole still counts. `head` is the trimmed start of the file. + */ +export function looksLikeJson(head, content = head) { + if (typeof head !== 'string') return false; + if (head[0] === '{') return OBJECT_HEAD.test(head) || parsesAsJson(content); + if (head[0] === '[') return ARRAY_HEAD.test(head) || parsesAsJson(content); + return false; +} + +function parsesAsJson(content) { + if (typeof content !== 'string' || content.length > JSON_PARSE_LIMIT) return false; + try { + JSON.parse(content.replace(/^\uFEFF/, '')); + return true; + } catch { + return false; + } +} + +/** Exact basenames of JSON files whose tools accept comments (lower-cased). */ +const JSONC_NAMES = new Set([ + '.eslintrc', '.eslintrc.json', + // VS Code's own files: .vscode/settings.json, keybindings.json, launch.json, tasks.json. + 'settings.json', 'keybindings.json', 'launch.json', 'tasks.json', + 'devcontainer.json', '.devcontainer.json' +]); + +/** + * Is this a JSON-with-comments file (JSONC)? Comments and trailing commas there are allowed by + * the tool that reads the file, so a syntax checker must not flag them as JSON errors: `.jsonc`, + * tsconfig*.json and jsconfig*.json (TypeScript reads them as JSONC), .eslintrc(.json), VS Code's + * settings/keybindings/launch/tasks.json and .code-workspace files, and devcontainer.json. + * Takes a bare name or a full path. + */ +export function isJsonWithComments(fileName) { + if (typeof fileName !== 'string' || !fileName) return false; + const base = fileName.split(/[\\/]/).pop().toLowerCase(); + if (base.endsWith('.jsonc') || base.endsWith('.code-workspace')) return true; + if (/^(?:ts|js)config.*\.json$/.test(base)) return true; + return JSONC_NAMES.has(base); +} diff --git a/src/main.jsx b/src/main.jsx index c9d7bc2..5f55dce 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -30,9 +30,17 @@ import '@fontsource/inter/latin-700.css' import './brand.css' import './index.css' import App from './App.jsx' +import ErrorBoundary from './components/ErrorBoundary.jsx' +/* + * The error boundary wraps everything: an exception while rendering shows a recovery screen + * instead of a blank window, after writing every unsaved buffer to the hot-exit backups (see + * components/ErrorBoundary.jsx). + */ createRoot(document.getElementById('root')).render( - + + + , ) diff --git a/src/markdown.js b/src/markdown.js index 2cd9c3d..db9ca92 100644 --- a/src/markdown.js +++ b/src/markdown.js @@ -1,4 +1,4 @@ -import { marked } from 'marked'; +import { marked, Parser } from 'marked'; import { markedHighlight } from 'marked-highlight'; import DOMPurify from 'dompurify'; // `lib/common` registers ~40 mainstream languages instead of all ~190. The full `highlight.js` @@ -6,17 +6,22 @@ import DOMPurify from 'dompurify'; // fences essentially never name; anything unregistered falls back to plaintext, unstyled but // intact. import hljs from 'highlight.js/lib/common'; +import powershell from 'highlight.js/lib/languages/powershell'; +import dos from 'highlight.js/lib/languages/dos'; +import dockerfile from 'highlight.js/lib/languages/dockerfile'; import markedKatex from 'marked-katex-extension'; // KaTeX's stylesheet is LOAD-BEARING: it hides the .katex-mathml screen-reader layer. Without it // every equation renders twice: once as maths, once as raw MathML text. import 'katex/dist/katex.min.css'; +import { withTexRepair } from './markdownMath.js'; +import { classifyImageSrc, toFateLocalUrl, createSlugger } from './previewPaths.js'; /** * markdown.js: the markdown rendering pipeline, extracted from App.jsx when tabs arrived. * - * renderMarkdown() is PURE (content in, {html, toc} out) so App.jsx can call it both when opening - * a document and when a watched file changes on disk, without the tangle of setState the old - * processMarkdown carried. + * renderMarkdown() is PURE (content in, {html, toc, …} out) so App.jsx can call it both when + * opening a document and when a watched file changes on disk, without the tangle of setState the + * old processMarkdown carried. * * ── Code fences: marked-highlight, not `marked.setOptions({ highlight })` ───────────────────── * The old `highlight` option was removed from marked in v5. FATE had carried the dead option ever @@ -28,11 +33,23 @@ import 'katex/dist/katex.min.css'; * * ── KaTeX (see AI_CONTEXT.md §1; this is the app's founding feature) ───────────────────────── * `throwOnError: false, nonStandard: true` are load-bearing: nonStandard lets equations sit tight - * against punctuation without breaking the whole parse. + * against punctuation without breaking the whole parse. FATE's own rules around the tokenizers + * (TeX repairs, `$5 and $10`) live in markdownMath.js. */ marked.setOptions({ gfm: true, breaks: true }); +/* + * Shell grammars on top of lib/common, which has bash but none of these, so a ```powershell fence + * rendered as plain text. The code blocks people copy out of a README are mostly commands. The + * aliases are the info strings people write; the grammars declare all but `batch` themselves. + */ +hljs.registerLanguage('powershell', powershell); +hljs.registerLanguage('dos', dos); +hljs.registerLanguage('dockerfile', dockerfile); +hljs.registerAliases(['ps', 'ps1', 'pwsh'], { languageName: 'powershell' }); +hljs.registerAliases(['bat', 'cmd', 'batch'], { languageName: 'dos' }); + marked.use( markedHighlight({ langPrefix: 'hljs language-', @@ -43,76 +60,335 @@ marked.use( }) ); -marked.use(markedKatex({ throwOnError: false, nonStandard: true })); +/* + * ── Telling KaTeX's markup from the document's ─────────────────────────────────────────────── + * KaTeX positions every glyph with inline styles and styles itself by class, both of which the + * sanitiser strips from everything else. Checking `closest('.katex')` alone would let raw HTML in + * the document claim the exemption (`
`), so the + * renderer stamps the root element of its own output with a token made fresh for each run of the + * app, which no document can know in advance. The sanitiser trusts only what sits under a stamped + * root, and renderMarkdown removes the stamps before the HTML goes anywhere. + */ +const KATEX_STAMP = Array.from(crypto.getRandomValues(new Uint32Array(4)), (n) => n.toString(36)).join(''); + +function stampKatexOutput(extension) { + const render = extension.renderer; + return { + ...extension, + renderer(token) { + return render.call(this, token).replace(/^ stampKatexOutput(withTexRepair(ext))) }); + +/* + * ── Source lines, for scroll sync ──────────────────────────────────────────────────────────── + * Ahead of each top-level block goes an empty marker holding the source line the block starts on + * (0-based), counted through the tokens' `raw`, which together are exactly the source. Afterwards + * annotateLines() moves each line onto the element that follows its marker, as `data-line`, and + * drops the markers. Markers rather than pairing tokens with the rendered top-level elements by + * position: raw HTML can turn one token into several elements, or none (a comment), or wrap the + * blocks after it in its own
, and a positional pairing would then put the wrong line on + * every later block. A marker labels its own block or nothing, so sync degrades gracefully. + * + * The line rides on each top-level token (processAllTokens) and the parser writes the marker in + * front of that token's HTML (provideParser). Not as extra tokens: marked's walkTokens copies its + * whole result list once per top-level token, and doubling their number made a long document + * render about 50% slower. + */ +const SOURCE_LINE = Symbol('sourceLine'); + +function countNewlines(s) { + let n = 0; + for (let i = s.indexOf('\n'); i !== -1; i = s.indexOf('\n', i + 1)) n++; + return n; +} + +marked.use({ + hooks: { + processAllTokens(tokens) { + if (!this.block) return tokens; + let line = 0; + for (const token of tokens) { + token[SOURCE_LINE] = line; + line += countNewlines(token.raw || ''); + } + return tokens; + }, + provideParser(block) { + if (!block) return false; // marked's own inline parser + return (tokens, options) => { + const parser = new Parser(options); + let html = ''; + for (const token of tokens) { + if (token[SOURCE_LINE] !== undefined && token.type !== 'space' && token.type !== 'def') { + html += ``; + } + html += parser.parse([token]); + } + return html; + }; + } + } +}); + +function annotateLines(root) { + // A `data-line` the document wrote itself would only confuse the sync. + for (const el of root.querySelectorAll('[data-line]')) el.removeAttribute('data-line'); + for (const marker of root.querySelectorAll('[data-fate-line]')) { + const next = marker.nextElementSibling; + // Two markers in a row: the first block rendered nothing (an HTML comment), so skip its line. + if (next && !next.hasAttribute('data-fate-line') && !next.hasAttribute('data-line')) { + next.setAttribute('data-line', marker.getAttribute('data-fate-line')); + } + marker.remove(); + } +} + +/* + * ── Sanitising (H2, H7) ────────────────────────────────────────────────────────────────────── + * Every preview stays mounted, so whatever one document's HTML can do, it does to the whole app: + * up to 1.13.4 a '; + assert.deepEqual(await symbolsOf('HTML', src), ['function inScript @2:10', 'rule .cls @3:17']); +}); + +test('filterSymbols ranks by match quality, then document order', () => { + const symbols = [ + { name: 'renderPane', line: 1 }, + { name: 'openRecent', line: 2 }, + { name: 'render', line: 3 }, + { name: 'onRender', line: 4 } + ]; + assert.deepEqual(filterSymbols(symbols, ''), symbols); + assert.deepEqual(filterSymbols(symbols, 'render').map((s) => s.name), ['renderPane', 'render', 'onRender']); + assert.deepEqual(filterSymbols(symbols, 'zzz'), []); + assert.ok(scoreMatch('rp', 'renderPane') > 0); +}); + +/* ── createPaletteModes with a fake App ───────────────────────────────────────────────────── */ + +async function viewFor(languageName, text) { + const support = await supportFor(languageName); + return { state: EditorState.create({ doc: text, extensions: [support] }) }; +} + +function fakeApp({ docs, activeId, views = {} }) { + const calls = []; + const ctx = { + getActiveDoc: () => docs.find((d) => d.id === activeId) ?? null, + getActiveView: () => views[activeId] ?? null, + getDocs: () => docs, + getDocText: (d) => d.text, + activateDoc: (id) => calls.push(['activate', id]), + revealLine: (id, line, col) => calls.push(['reveal', id, line, col]) + }; + return { modes: createPaletteModes(ctx), calls }; +} + +test('modes have labels, hints and placeholders for the palette', () => { + const { modes } = fakeApp({ docs: [], activeId: null }); + assert.deepEqual(Object.keys(modes), [':', '@', '#']); + assert.deepEqual(Object.values(modes).map((m) => m.hint), ['go to line', 'symbol', 'search open tabs']); + for (const m of Object.values(modes)) { + assert.equal(typeof m.label, 'string'); + assert.equal(typeof m.placeholder, 'string'); + assert.equal(typeof m.getItems, 'function'); + } +}); + +test(': mode goes to a clamped line in a reading-view tab (text only)', () => { + const doc = { id: 7, kind: 'markdown', name: 'notes.md', text: '# A\n\ntext\nlast line' }; + const { modes, calls } = fakeApp({ docs: [doc], activeId: 7 }); + const go = modes[':']; + + const [hint] = go.getItems(''); + assert.ok(hint.disabled && /1 to 4/.test(hint.label)); + const [error] = go.getItems('abc'); + assert.ok(error.disabled && error.error); + + const [item] = go.getItems('3'); + assert.equal(item.label, 'Go to line 3'); + assert.equal(item.detail, 'notes.md · 4 lines'); + item.run(); + const [far] = go.getItems('999:999'); + assert.equal(far.label, 'Go to line 4, column 10'); + far.run(); + assert.deepEqual(calls, [['reveal', 7, 3, 1], ['reveal', 7, 4, 10]]); +}); + +test(': mode with a live editor uses its document', async () => { + const doc = { id: 1, kind: 'code', name: 'a.js', text: 'stale text' }; + const view = await viewFor('JavaScript', 'let a = 1;\nlet bb = 2;\n'); + const { modes, calls } = fakeApp({ docs: [doc], activeId: 1, views: { 1: view } }); + const [item] = modes[':'].getItems('2:99'); + assert.equal(item.label, 'Go to line 2, column 12'); + item.run(); + assert.deepEqual(calls, [['reveal', 1, 2, 12]]); + const [none] = fakeApp({ docs: [], activeId: null }).modes[':'].getItems('3'); + assert.ok(none.disabled); +}); + +test('@ mode lists and filters symbols, and reveals the chosen one', async () => { + const text = 'function alpha() {}\nclass Beta { gamma() {} }\nconst delta = () => 1;\n'; + const doc = { id: 3, kind: 'code', name: 'x.js', text }; + const view = await viewFor('JavaScript', text); + const { modes, calls } = fakeApp({ docs: [doc], activeId: 3, views: { 3: view } }); + const sym = modes['@']; + + const all = sym.getItems(''); + assert.deepEqual(all.map((i) => i.label), ['alpha', 'Beta', 'gamma', 'delta']); + assert.deepEqual(all.map((i) => i.detail), ['Function · line 1', 'Class · line 2', 'Method · line 2', 'Function · line 3']); + assert.ok(all.every((i) => typeof i.icon === 'object' || typeof i.icon === 'function')); + + const filtered = sym.getItems('gam'); + assert.deepEqual(filtered.map((i) => i.label), ['gamma']); + filtered[0].run(); + assert.deepEqual(calls, [['reveal', 3, 2, 14]]); + + const [nomatch] = sym.getItems('zzz'); + assert.ok(nomatch.disabled && /No symbols match "zzz"/.test(nomatch.label)); +}); + +test('@ mode in a Markdown reading view scans the text for headings', () => { + const doc = { id: 4, kind: 'markdown', name: 'r.md', text: '# One\n\n## Two\n\nbody\n' }; + const { modes, calls } = fakeApp({ docs: [doc], activeId: 4 }); + const items = modes['@'].getItems(''); + assert.deepEqual(items.map((i) => [i.label, i.detail]), [['One', 'Heading 1 · line 1'], ['Two', 'Heading 2 · line 3']]); + items[1].run(); + assert.deepEqual(calls, [['reveal', 4, 3, 1]]); + + const empty = fakeApp({ docs: [{ ...doc, text: 'no headings' }], activeId: 4 }).modes['@'].getItems(''); + assert.equal(empty[0].label, 'This document has no headings'); +}); + +test('@ mode reuses its symbols while the document is unchanged', async () => { + const text = 'function a() {}\n'; + const view = await viewFor('JavaScript', text); + const doc = { id: 1, kind: 'code', name: 'a.js', text }; + const { modes } = fakeApp({ docs: [doc], activeId: 1, views: { 1: view } }); + const first = modes['@'].getItems(''); + view.state = view.state.update({ changes: { from: text.length, insert: 'function b() {}\n' } }).state; + const second = modes['@'].getItems(''); + assert.deepEqual(first.map((i) => i.label), ['a']); + assert.deepEqual(second.map((i) => i.label), ['a', 'b'], 'a new document state is re-read'); +}); + +test('# mode searches every open tab and switches tab before revealing', () => { + const docs = [ + { id: 1, kind: 'code', name: 'a.js', text: 'const Needle = 1;\nnothing\n needle again' }, + { id: 2, kind: 'markdown', name: 'b.md', text: '# NEEDLE heading' }, + { id: 3, kind: 'code', name: 'c.txt', text: 'no match here' } + ]; + const { modes, calls } = fakeApp({ docs, activeId: 1 }); + const find = modes['#']; + + assert.ok(find.getItems('')[0].disabled); + assert.ok(find.getItems(' ')[0].disabled, 'only spaces after "#" is still an empty query'); + const items = find.getItems(' needle'); + assert.deepEqual(items.map((i) => [i.label, i.detail]), [ + ['const Needle = 1;', 'a.js:1'], + ['needle again', 'a.js:3'], + ['# NEEDLE heading', 'b.md:1'] + ]); + items[1].run(); + items[2].run(); + assert.deepEqual(calls, [['reveal', 1, 3, 3], ['activate', 2], ['reveal', 2, 1, 3]]); + + const [none] = find.getItems('absent'); + assert.ok(none.disabled && /No matches for "absent"/.test(none.label)); + assert.ok(fakeApp({ docs: [], activeId: null }).modes['#'].getItems('x')[0].disabled); +}); diff --git a/test/previewFind.test.mjs b/test/previewFind.test.mjs new file mode 100644 index 0000000..cb2ad8b --- /dev/null +++ b/test/previewFind.test.mjs @@ -0,0 +1,27 @@ +// Query folding and matching for find in the reading view (src/previewFind.js). The index walk +// itself needs a DOM and is covered in the running app. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { foldQuery, findMatches } from '../src/previewFind.js'; + +test('foldQuery lower-cases and collapses whitespace like the index does', () => { + assert.equal(foldQuery('Filler Paragraph'), 'filler paragraph'); + assert.equal(foldQuery('a\tb c\nd'), 'a b c d'); + assert.equal(foldQuery('ÜBER'), 'über'); + // A character whose lower case is longer (İ → i̇) stays as typed, as in the index. + assert.equal(foldQuery('İx'), 'İx'); + assert.equal(foldQuery(''), ''); +}); + +test('findMatches returns every non-overlapping match', () => { + const index = { text: 'aaaa banana\u0000ana' }; + assert.deepEqual(findMatches(index, 'aa'), [0, 2]); + assert.deepEqual(findMatches(index, 'ana'), [6, 12]); + assert.deepEqual(findMatches(index, ''), []); + assert.deepEqual(findMatches(index, 'zz'), []); +}); + +test('a match never runs across a block break', () => { + // "banana" ends one paragraph and "ana" starts the next; the break is not a space. + assert.deepEqual(findMatches({ text: 'banana\u0000ana' }, foldQuery('na ana')), []); +}); diff --git a/test/previewPaths.test.mjs b/test/previewPaths.test.mjs new file mode 100644 index 0000000..a1ef34c --- /dev/null +++ b/test/previewPaths.test.mjs @@ -0,0 +1,105 @@ +// Link targets, image sources and heading slugs in the markdown preview (src/previewPaths.js). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + decodePath, + stripQueryAndHash, + resolveAgainstDoc, + classifyImageSrc, + toFateLocalUrl, + resolveLinkTarget, + slugify, + createSlugger +} from '../src/previewPaths.js'; + +const POSIX_DOC = '/home/me/docs/guide/readme.md'; +const WIN_DOC = 'C:\\Users\\me\\docs\\readme.md'; +const UNC_DOC = '\\\\server\\share\\docs\\readme.md'; + +test('decodePath undoes marked encoding and keeps a stray %', () => { + assert.equal(decodePath('my%20image.png'), 'my image.png'); + assert.equal(decodePath('%C3%9Cbersicht.png'), 'Übersicht.png'); + assert.equal(decodePath('100%.png'), '100%.png'); +}); + +test('stripQueryAndHash runs before decoding, so %23 stays part of the name', () => { + assert.equal(stripQueryAndHash('pic.png?raw=1#x'), 'pic.png'); + assert.equal(decodePath(stripQueryAndHash('a%23b.png#frag')), 'a#b.png'); +}); + +test('resolveAgainstDoc: relative, parent, absolute, per platform', () => { + assert.equal(resolveAgainstDoc('img/a.png', POSIX_DOC), '/home/me/docs/guide/img/a.png'); + assert.equal(resolveAgainstDoc('../CONTRIBUTING.md', POSIX_DOC), '/home/me/docs/CONTRIBUTING.md'); + assert.equal(resolveAgainstDoc('./a/../b.md', POSIX_DOC), '/home/me/docs/guide/b.md'); + assert.equal(resolveAgainstDoc('/etc/x.md', POSIX_DOC), '/etc/x.md'); + assert.equal(resolveAgainstDoc('../../../../../x.md', POSIX_DOC), '/x.md'); + assert.equal(resolveAgainstDoc('img/a.png', WIN_DOC), 'C:\\Users\\me\\docs\\img\\a.png'); + assert.equal(resolveAgainstDoc('..\\other.md', WIN_DOC), 'C:\\Users\\me\\other.md'); + assert.equal(resolveAgainstDoc('/abs/x.md', WIN_DOC), 'C:\\abs\\x.md'); + assert.equal(resolveAgainstDoc('D:/data/x.md', WIN_DOC), 'D:\\data\\x.md'); + assert.equal(resolveAgainstDoc('b.md', UNC_DOC), '\\\\server\\share\\docs\\b.md'); + assert.equal(resolveAgainstDoc('../../../b.md', UNC_DOC), '\\\\server\\share\\b.md'); + assert.equal(resolveAgainstDoc('b.md', null), null); + assert.equal(resolveAgainstDoc('/abs/b.png', null), '/abs/b.png'); +}); + +test('classifyImageSrc: local paths are decoded once (M6)', () => { + assert.deepEqual(classifyImageSrc('my%20image.png', POSIX_DOC), { kind: 'local', path: '/home/me/docs/guide/my image.png' }); + assert.deepEqual(classifyImageSrc('%C3%9Cbersicht.png', POSIX_DOC), { kind: 'local', path: '/home/me/docs/guide/Übersicht.png' }); + assert.deepEqual(classifyImageSrc('pic.png?raw=true#light', POSIX_DOC), { kind: 'local', path: '/home/me/docs/guide/pic.png' }); + assert.deepEqual(classifyImageSrc('C:%5Cpics%5Ca.png', POSIX_DOC), { kind: 'local', path: 'C:\\pics\\a.png' }); +}); + +test('classifyImageSrc: // is remote, never local (C5)', () => { + assert.deepEqual(classifyImageSrc('//attacker.example/share/x.png', WIN_DOC), { + kind: 'remote', + url: 'https://attacker.example/share/x.png', + host: 'attacker.example' + }); + assert.equal(classifyImageSrc('https://example.com/a.png', WIN_DOC).kind, 'remote'); + // Plain http is upgraded: the renderer's CSP only admits https images. + assert.deepEqual(classifyImageSrc('HTTP://example.com/a.png', WIN_DOC), { kind: 'remote', url: 'https://example.com/a.png', host: 'example.com' }); + // Encoded UNC paths decode to a network share: blocked outright. + assert.deepEqual(classifyImageSrc('%5C%5Cserver%5Cshare%5Cx.png', WIN_DOC), { kind: 'blocked', host: 'server' }); + assert.deepEqual(classifyImageSrc('%2F%2Fserver/x.png', POSIX_DOC), { kind: 'blocked', host: 'server' }); + assert.equal(classifyImageSrc('ftp://example.com/a.png', POSIX_DOC).kind, 'blocked'); + assert.equal(classifyImageSrc('data:image/png;base64,AAAA', POSIX_DOC).kind, 'data'); + assert.equal(classifyImageSrc('', POSIX_DOC).kind, 'none'); + assert.equal(classifyImageSrc('rel.png', null).kind, 'unresolved'); +}); + +test('toFateLocalUrl encodes each segment once', () => { + assert.equal(toFateLocalUrl('/home/me/my image.png'), 'fate-local://local/home/me/my%20image.png'); + assert.equal(toFateLocalUrl('C:\\pics\\Übersicht #1.png'), 'fate-local://local/C%3A/pics/%C3%9Cbersicht%20%231.png'); + assert.equal(toFateLocalUrl('/x/100%.png'), 'fate-local://local/x/100%25.png'); +}); + +test('resolveLinkTarget sorts links into fragment, file, external and ignore', () => { + assert.deepEqual(resolveLinkTarget('#%C3%9Cber-uns', POSIX_DOC), { kind: 'fragment', fragment: 'Über-uns' }); + assert.deepEqual(resolveLinkTarget('../CONTRIBUTING.md', POSIX_DOC), { kind: 'file', path: '/home/me/docs/CONTRIBUTING.md', fragment: '' }); + assert.deepEqual(resolveLinkTarget('other.md?plain=1#Setup', POSIX_DOC), { kind: 'file', path: '/home/me/docs/guide/other.md', fragment: 'Setup' }); + assert.deepEqual(resolveLinkTarget('my%20file.md', WIN_DOC), { kind: 'file', path: 'C:\\Users\\me\\docs\\my file.md', fragment: '' }); + assert.deepEqual(resolveLinkTarget('https://example.com/a', POSIX_DOC), { kind: 'external', url: 'https://example.com/a' }); + assert.deepEqual(resolveLinkTarget('mailto:a@example.com', POSIX_DOC), { kind: 'external', url: 'mailto:a@example.com' }); + for (const href of ['//host/x.md', '%5C%5Cserver%5Cshare%5Cx.md', 'ftp://x/y', 'tel:123', 'javascript:alert(1)', '', 'rel.md']) { + const docPath = href === 'rel.md' ? null : POSIX_DOC; + assert.deepEqual(resolveLinkTarget(href, docPath), { kind: 'ignore' }, href); + } +}); + +test('slugify matches GitHub', () => { + assert.equal(slugify('Hello, World!'), 'hello-world'); + assert.equal(slugify('Über uns'), 'über-uns'); + assert.equal(slugify('A – B'), 'a--b'); + assert.equal(slugify('snake_case and `code`'), 'snake_case-and-code'); + assert.equal(slugify('🎉 Party'), '-party'); + assert.equal(slugify('1.14.0 (2026-10-04)'), '1140-2026-10-04'); + assert.equal(slugify('\\theta and x²'), 'theta-and-x'); +}); + +test('createSlugger numbers repeats the way github-slugger does', () => { + const slug = createSlugger(); + assert.deepEqual(['Intro 1', 'Intro', 'Intro', 'Intro'].map(slug), ['intro-1', 'intro', 'intro-2', 'intro-3']); + assert.equal(slug('🎉'), 'section'); + assert.equal(slug('✨'), 'section-1'); +}); diff --git a/test/rendererDocs.test.mjs b/test/rendererDocs.test.mjs new file mode 100644 index 0000000..6fd7fc2 --- /dev/null +++ b/test/rendererDocs.test.mjs @@ -0,0 +1,250 @@ +// Unit tests for the renderer-side document helpers added in 1.14.0 (renderer-docs workstream): +// text spans for reloads, file formats, size routing, safe rendering and hot-exit planning. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { changedSpan, applySpan } from '../src/textSpan.js'; +import { + defaultFormat, eolLabel, toggledEol, encodingLabel, sameFormat, formatChanged, + SAVE_ENCODINGS, REOPEN_ENCODINGS, isCurrentEncoding +} from '../src/docFormat.js'; +import { + MARKDOWN_EXTENSIONS, fileKindForName, kindForOpen, couldBeMarkdown, + MARKDOWN_RENDER_LIMIT, LARGE_FILE_LIMIT +} from '../src/fileKinds.js'; +import { renderSafely, escapeHtml } from '../src/renderSafely.js'; +import { + BACKUP_ID_RE, newBackupId, backupPayload, planRestore, untitledNumber, sameSignature, + setCrashFlusher, flushForCrash, isRestorable +} from '../src/hotExit.js'; + +/* ── changedSpan ─────────────────────────────────────────────────────────────────────────── */ + +test('changedSpan: identical text is no change', () => { + assert.equal(changedSpan('abc', 'abc'), null); + assert.equal(changedSpan('', ''), null); +}); + +test('changedSpan: an appended log line is an insertion at the end', () => { + const old = 'line 1\nline 2\n'; + const span = changedSpan(old, `${old}line 3\n`); + assert.deepEqual(span, { from: old.length, to: old.length, insert: 'line 3\n' }); +}); + +test('changedSpan: a change in the middle keeps the prefix and suffix', () => { + const span = changedSpan('alpha beta gamma', 'alpha BETA gamma'); + assert.deepEqual(span, { from: 6, to: 10, insert: 'BETA' }); +}); + +test('changedSpan: deletions, insertions at the start, and whole replacements', () => { + assert.deepEqual(changedSpan('abcdef', 'abef'), { from: 2, to: 4, insert: '' }); + assert.deepEqual(changedSpan('world', 'hello world'), { from: 0, to: 0, insert: 'hello ' }); + assert.deepEqual(changedSpan('abc', 'xyz'), { from: 0, to: 3, insert: 'xyz' }); + assert.deepEqual(changedSpan('', 'new'), { from: 0, to: 0, insert: 'new' }); + assert.deepEqual(changedSpan('old', ''), { from: 0, to: 3, insert: '' }); +}); + +test('changedSpan: repeated characters resolve without overlapping prefix and suffix', () => { + for (const [a, b] of [['aaa', 'aaaa'], ['aaaa', 'aa'], ['abab', 'ab'], ['xx', 'xyx']]) { + const span = changedSpan(a, b); + assert.ok(span.from <= span.to, `${a} → ${b}`); + assert.equal(applySpan(a, span), b, `${a} → ${b}`); + } +}); + +test('changedSpan: never splits a surrogate pair', () => { + const cases = [ + ['x\u{1F600}y', 'x\u{1F601}y'], + ['\u{1F600}', '\u{1F601}'], + ['a\u{1F600}', 'a'], + ['a', 'a\u{1F600}'], + ['x\uD83D\uDE00', 'x\uD83E\uDE00'] + ]; + for (const [a, b] of cases) { + const span = changedSpan(a, b); + assert.equal(applySpan(a, span), b); + const isLow = (c) => c >= 0xdc00 && c <= 0xdfff; + assert.ok(!isLow(a.charCodeAt(span.from)) || span.from === a.length, `from splits a pair in ${JSON.stringify(a)}`); + assert.ok(!isLow(a.charCodeAt(span.to)) || span.to === a.length, `to splits a pair in ${JSON.stringify(a)}`); + } +}); + +test('changedSpan: random edits always round-trip', () => { + const alphabet = 'ab\n \u{1F600}'; + let seed = 7; + const rand = (n) => { + seed = (seed * 1103515245 + 12345) % 2147483648; + return seed % n; + }; + const word = (len) => Array.from({ length: len }, () => [...alphabet][rand([...alphabet].length)]).join(''); + for (let i = 0; i < 2000; i++) { + const a = word(rand(12)); + const b = word(rand(12)); + assert.equal(applySpan(a, changedSpan(a, b)), b, `${JSON.stringify(a)} → ${JSON.stringify(b)}`); + } +}); + +/* ── docFormat ───────────────────────────────────────────────────────────────────────────── */ + +test('defaultFormat: UTF-8 without BOM, CRLF only on Windows', () => { + assert.deepEqual(defaultFormat('win32'), { encoding: 'utf8', bom: false, eol: '\r\n' }); + assert.deepEqual(defaultFormat('linux'), { encoding: 'utf8', bom: false, eol: '\n' }); + assert.deepEqual(defaultFormat(undefined), { encoding: 'utf8', bom: false, eol: '\n' }); +}); + +test('eolLabel and toggledEol', () => { + assert.equal(eolLabel('\r\n'), 'CRLF'); + assert.equal(eolLabel('\n'), 'LF'); + assert.equal(eolLabel('\r'), 'CR'); + assert.equal(toggledEol('\r\n'), '\n'); + assert.equal(toggledEol('\n'), '\r\n'); + assert.equal(toggledEol('\r'), '\n'); +}); + +test('encodingLabel covers every encoding the main process reports', () => { + assert.equal(encodingLabel({ encoding: 'utf8', bom: false }), 'UTF-8'); + assert.equal(encodingLabel({ encoding: 'utf8', bom: true }), 'UTF-8 BOM'); + assert.equal(encodingLabel({ encoding: 'utf16le', bom: true }), 'UTF-16 LE'); + assert.equal(encodingLabel({ encoding: 'utf16be', bom: false }), 'UTF-16 BE'); + assert.equal(encodingLabel({ encoding: 'windows1252', bom: false }), 'Windows-1252'); + assert.equal(encodingLabel(null), ''); +}); + +test('encoding choices match the contract', () => { + assert.deepEqual(SAVE_ENCODINGS.map((e) => e.label), ['UTF-8', 'UTF-8 BOM', 'UTF-16 LE', 'UTF-16 BE', 'Windows-1252']); + const contract = ['utf8', 'utf16le', 'utf16be', 'windows1252']; + for (const e of SAVE_ENCODINGS) assert.ok(contract.includes(e.encoding)); + assert.deepEqual(REOPEN_ENCODINGS.map((e) => e.encoding), contract); + assert.ok(isCurrentEncoding({ encoding: 'utf8', bom: true, eol: '\n' }, SAVE_ENCODINGS[1])); + assert.ok(!isCurrentEncoding({ encoding: 'utf8', bom: true, eol: '\n' }, SAVE_ENCODINGS[0])); +}); + +test('sameFormat and formatChanged', () => { + const lf = { encoding: 'utf8', bom: false, eol: '\n' }; + const crlf = { ...lf, eol: '\r\n' }; + assert.ok(sameFormat(lf, { ...lf })); + assert.ok(!sameFormat(lf, crlf)); + assert.ok(!sameFormat(lf, { ...lf, bom: true })); + assert.ok(sameFormat(null, undefined)); + assert.ok(!sameFormat(lf, null)); + assert.equal(formatChanged({ format: crlf, savedFormat: lf }), true); + assert.equal(formatChanged({ format: lf, savedFormat: { ...lf } }), false); + // Untitled buffers (and files whose format main never reported) have nothing to differ from. + assert.equal(formatChanged({ format: crlf, savedFormat: null }), false); + assert.equal(formatChanged({ format: null, savedFormat: lf }), false); +}); + +/* ── fileKinds ───────────────────────────────────────────────────────────────────────────── */ + +test('.txt opens as plain text; .md and .markdown render', () => { + assert.deepEqual(MARKDOWN_EXTENSIONS, ['md', 'markdown']); + assert.equal(fileKindForName('notes.txt'), 'code'); + assert.equal(fileKindForName('README.md'), 'markdown'); + assert.equal(fileKindForName('a.MARKDOWN'), 'markdown'); +}); + +test('kindForOpen: Markdown over the render limit opens in the editor', () => { + assert.equal(kindForOpen('big.md', MARKDOWN_RENDER_LIMIT), 'markdown'); + assert.equal(kindForOpen('big.md', MARKDOWN_RENDER_LIMIT + 1), 'code'); + assert.equal(kindForOpen('log.txt', 10), 'code'); + assert.ok(LARGE_FILE_LIMIT > MARKDOWN_RENDER_LIMIT); +}); + +test('couldBeMarkdown: .md/.markdown/.txt and extensionless names, not dotfiles or code', () => { + for (const n of ['a.md', 'a.markdown', 'notes.txt', 'README', 'NOTES', 'C:\\docs\\CHANGES', '/x/LICENSE']) { + assert.ok(couldBeMarkdown(n), n); + } + for (const n of ['.env', '.gitignore', 'app.js', 'data.json', 'Untitled.log', '']) { + assert.ok(!couldBeMarkdown(n) || n === '', n); + } + assert.ok(couldBeMarkdown('Untitled-3')); +}); + +/* ── renderSafely ────────────────────────────────────────────────────────────────────────── */ + +test('renderSafely passes a good render through', () => { + const r = renderSafely((c) => ({ html: `

${c}

`, toc: [], readMins: 1, hasMermaid: false }), 'hi', null); + assert.equal(r.html, '

hi

'); + assert.equal(r.renderFailed, false); + assert.equal(r.remoteImageCount, 0); +}); + +test('renderSafely falls back to escaped text in a
 when the renderer throws', () => {
+  const r = renderSafely(() => {
+    throw new RangeError('Maximum call stack size exceeded');
+  }, ' & "x"', '/tmp/a.md');
+  assert.equal(r.renderFailed, true);
+  assert.equal(r.html, '
<script>alert(1)</script> & "x"
'); + assert.deepEqual(r.toc, []); + assert.equal(r.hasMermaid, false); + assert.ok(r.error instanceof RangeError); +}); + +test('escapeHtml', () => { + assert.equal(escapeHtml(`&`), '<a href='x'>&</a>'); +}); + +/* ── hotExit ─────────────────────────────────────────────────────────────────────────────── */ + +test('newBackupId matches what main accepts', () => { + const ids = new Set(); + for (let i = 0; i < 50; i++) { + const id = newBackupId(); + assert.match(id, BACKUP_ID_RE); + ids.add(id); + } + assert.equal(ids.size, 50); +}); + +test('backupPayload follows the preload contract', () => { + const fmt = { encoding: 'utf8', bom: false, eol: '\r\n' }; + assert.deepEqual( + backupPayload({ kind: 'markdown', name: 'a.md', path: '/x/a.md', format: fmt, editMode: true }, 'new', 'old'), + { kind: 'markdown', name: 'a.md', path: '/x/a.md', content: 'new', format: fmt, savedContent: 'old', untitled: false, editMode: true } + ); + assert.deepEqual( + backupPayload({ kind: 'code', name: 'Untitled-2', path: null, format: null, untitled: true }, 'text', undefined), + { kind: 'code', name: 'Untitled-2', path: null, content: 'text', format: null, savedContent: null, untitled: true } + ); +}); + +test('planRestore: newest backup per path wins, untitled keep their order, junk is dropped', () => { + const key = (p) => p.toLowerCase(); + const list = [ + { id: 'a1', savedAt: 100, path: '/x/A.md', content: 'old' }, + { id: 'a2', savedAt: 200, path: '/x/a.md', content: 'new' }, + { id: 'u1', savedAt: 50, path: null, name: 'Untitled-1', content: 'one' }, + { id: 'u2', savedAt: 300, path: null, name: 'Untitled-4', content: 'four' }, + { id: 'bad', savedAt: 10, path: null }, + { id: '../evil', content: 'x' }, + null + ]; + const plan = planRestore(list, key); + assert.deepEqual([...plan.byPath.keys()], ['/x/a.md']); + assert.equal(plan.byPath.get('/x/a.md').id, 'a2'); + assert.deepEqual(plan.untitled.map((b) => b.id), ['u1', 'u2']); + assert.deepEqual(plan.drop.sort(), ['a1', 'bad']); + assert.ok(!isRestorable({ id: '../evil', content: 'x' })); + assert.deepEqual(planRestore(undefined, key), { byPath: new Map(), untitled: [], drop: [] }); +}); + +test('untitledNumber and sameSignature', () => { + assert.equal(untitledNumber('Untitled-12'), 12); + assert.equal(untitledNumber('notes.md'), 0); + const t = {}; + assert.ok(sameSignature([t, 'a', null], [t, 'a', null])); + assert.ok(!sameSignature([t, 'a'], [{}, 'a'])); + assert.ok(!sameSignature(null, [t])); +}); + +test('flushForCrash resolves false without a flusher and survives a throwing one', async () => { + setCrashFlusher(null); + assert.equal(await flushForCrash(), false); + setCrashFlusher(() => { + throw new Error('boom'); + }); + assert.equal(await flushForCrash(), false); + setCrashFlusher(() => Promise.resolve(true)); + assert.equal(await flushForCrash(), true); + setCrashFlusher(null); +}); diff --git a/test/rendererSettings.test.cjs b/test/rendererSettings.test.cjs new file mode 100644 index 0000000..958e17a --- /dev/null +++ b/test/rendererSettings.test.cjs @@ -0,0 +1,69 @@ +'use strict'; +/* The renderer's settings whitelist in electron/rendererSettings.cjs. Run: node --test test/ */ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { checkRendererSetting, isRendererSettingKey } = require('../electron/rendererSettings.cjs'); + +const show = (value) => { + try { + return JSON.stringify(value)?.slice(0, 80); + } catch { + return '(unserialisable)'; + } +}; +const ok = (key, value) => assert.deepEqual(checkRendererSetting(key, value), { ok: true }, `${key} = ${show(value)}`); +const refused = (key, value) => assert.equal(checkRendererSetting(key, value).ok, false, `${key} = ${show(value)}`); + +test('every setting the renderer writes today is accepted', () => { + ok('theme', 'fate'); + ok('theme', 'custom'); + ok('autoUpdatesEnabled', false); + ok('sidebarWidth', 300); + ok('shortcuts', { openFile: 'Control+O', toggleSplit: 'Control+\\' }); + ok('printPageSize', 'A4'); + ok('printLandscape', true); + ok('editorWrap', true); + ok('editorTabSize', 4); + ok('editorLint', false); + ok('fonts', { ui: 'inter', markdown: 'system:Georgia', code: 'jetbrains-mono', markdownSize: 16, editorSize: 14, ligatures: true, perType: { ps1: 'cascadia-code' } }); + ok('restoreSession', true); + ok('customTheme', { base: '#070B1A', accent: '#D4AF37' }); + ok('customTheme', null); + ok('session', { paths: ['/home/me/a.md', 'C:\\Users\\me\\b.ps1'], active: '/home/me/a.md' }); + ok('session', { paths: [], active: null }); + ok('spellcheck', true); + ok('remoteImages', false); +}); + +test('main-only keys and unknown keys are refused', () => { + for (const key of ['recentFiles', 'registrationStamp', 'claimedTypes', 'recentFiles.0.path', 'theme.x', '__proto__', 'constructor', 'toString', '', null, 7]) { + assert.equal(isRendererSettingKey(key), false, String(key)); + refused(key, 'x'); + } +}); + +test('wrong types are refused', () => { + refused('theme', 3); + refused('theme', 'x'.repeat(65)); + refused('autoUpdatesEnabled', 'yes'); + refused('sidebarWidth', NaN); + refused('sidebarWidth', -5); + refused('sidebarWidth', '300'); + refused('shortcuts', ['Control+O']); + refused('shortcuts', { openFile: 5 }); + refused('editorTabSize', 0); + refused('fonts', 'inter'); + refused('customTheme', '#fff'); + refused('session', { paths: '/etc/passwd' }); + refused('session', { paths: [1, 2] }); + refused('session', { paths: [], active: 5 }); + refused('spellcheck', 1); + refused('remoteImages', undefined); +}); + +test('values too large or unserialisable are refused', () => { + refused('session', { paths: Array.from({ length: 5000 }, (_, i) => `/very/long/path/number/${i}/`.repeat(3)) }); + const cyclic = { ui: 'inter' }; + cyclic.self = cyclic; + refused('fonts', cyclic); +}); diff --git a/test/settingsMeta.test.mjs b/test/settingsMeta.test.mjs new file mode 100644 index 0000000..be9416a --- /dev/null +++ b/test/settingsMeta.test.mjs @@ -0,0 +1,169 @@ +// Shortcut binding helpers and the sidebar width clamp (src/settingsMeta.js). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + parseShortcut, normalizeBinding, bindingFromEvent, matchesShortcut, isAllowedShortcut, + shortcutParts, formatShortcutLabel, toElectronAccelerator, resolveShortcuts, + DEFAULT_SHORTCUTS, SHORTCUT_ACTIONS, clampSidebarWidth +} from '../src/settingsMeta.js'; + +/** A KeyboardEvent-shaped object. */ +const key = (k, mods = {}) => ({ + key: k, + ctrlKey: !!mods.ctrl, + shiftKey: !!mods.shift, + altKey: !!mods.alt, + metaKey: !!mods.meta +}); + +test('parseShortcut reads modifiers and the key', () => { + assert.deepEqual(parseShortcut('Control+Shift+S'), { ctrl: true, shift: true, alt: false, meta: false, key: 'S' }); + assert.deepEqual(parseShortcut('Alt+Home'), { ctrl: false, shift: false, alt: true, meta: false, key: 'Home' }); + assert.deepEqual(parseShortcut('Escape'), { ctrl: false, shift: false, alt: false, meta: false, key: 'Escape' }); + assert.equal(parseShortcut('Control+\\').key, '\\'); + assert.equal(parseShortcut('Control+,').key, ','); + assert.equal(parseShortcut('Meta+K').meta, true); +}); + +test('parseShortcut handles the + key in the new and the legacy form', () => { + const plus = { ctrl: true, shift: false, alt: false, meta: false, key: 'Plus' }; + assert.deepEqual(parseShortcut('Control+Plus'), plus); + assert.deepEqual(parseShortcut('Control++'), plus, 'legacy "Control++" (split on "+" gave an empty key)'); + assert.deepEqual(parseShortcut('Control+Shift++'), { ...plus, shift: true }); + assert.deepEqual(parseShortcut('+'), { ...plus, ctrl: false }); +}); + +test('parseShortcut accepts aliases and normalises key names', () => { + assert.deepEqual(parseShortcut('Ctrl+k'), parseShortcut('Control+K')); + assert.equal(parseShortcut('Control+space').key, 'Space'); + assert.equal(parseShortcut('Control+Esc').key, 'Escape'); + assert.equal(parseShortcut('Alt+Up').key, 'ArrowUp'); + assert.equal(parseShortcut('f5').key, 'F5'); + assert.equal(parseShortcut('Super+E').meta, true); +}); + +test('parseShortcut rejects bindings with no key or unknown parts', () => { + for (const bad of [undefined, null, '', ' ', 'Control', 'Control+Shift', 'Control+', 'Hyper+K', 'Control++S', 42]) { + assert.equal(parseShortcut(bad), null, String(bad)); + } +}); + +test('normalizeBinding writes the canonical stored form', () => { + assert.equal(normalizeBinding('Control++'), 'Control+Plus'); + assert.equal(normalizeBinding('Shift+Control+s'), 'Control+Shift+S'); + assert.equal(normalizeBinding('ctrl+alt+delete'), 'Control+Alt+Delete'); + assert.equal(normalizeBinding('nonsense+'), null); +}); + +test('every default binding is already in canonical form', () => { + for (const { id, default: binding } of SHORTCUT_ACTIONS) { + assert.equal(normalizeBinding(binding), binding, id); + assert.ok(isAllowedShortcut(binding), `${id} default must be allowed`); + } +}); + +test('bindingFromEvent records modifiers plus the key, in the stored form', () => { + assert.equal(bindingFromEvent(key('s', { ctrl: true })), 'Control+S'); + assert.equal(bindingFromEvent(key('S', { ctrl: true, shift: true })), 'Control+Shift+S'); + assert.equal(bindingFromEvent(key('+', { ctrl: true, shift: true })), 'Control+Shift+Plus'); + assert.equal(bindingFromEvent(key('+', { ctrl: true })), 'Control+Plus'); + assert.equal(bindingFromEvent(key(' ', { ctrl: true })), 'Control+Space'); + assert.equal(bindingFromEvent(key('Home', { alt: true })), 'Alt+Home'); + assert.equal(bindingFromEvent(key('F5')), 'F5'); + assert.equal(bindingFromEvent(key('h')), 'H'); +}); + +test('bindingFromEvent waits while only modifiers are held', () => { + for (const k of ['Control', 'Shift', 'Alt', 'Meta', 'AltGraph', 'CapsLock', 'Dead', 'Unidentified', 'Process', '']) { + assert.equal(bindingFromEvent(key(k, { ctrl: true })), null, k); + } +}); + +test('recording then matching round-trips, including the + key and Space', () => { + const presses = [ + key('k', { ctrl: true }), + key('+', { ctrl: true }), + key('+', { ctrl: true, shift: true }), + key(' ', { ctrl: true }), + key('Tab', { ctrl: true, shift: true }), + key('\\', { ctrl: true }), + key('F12') + ]; + for (const e of presses) { + const binding = bindingFromEvent(e); + assert.ok(matchesShortcut(e, binding), `${binding} should match the press that recorded it`); + } +}); + +test('matchesShortcut needs exactly the bound modifiers', () => { + assert.ok(matchesShortcut(key('s', { ctrl: true }), 'Control+S')); + assert.ok(matchesShortcut(key('S', { ctrl: true, shift: true }), 'Control+Shift+S')); + assert.ok(!matchesShortcut(key('s', { ctrl: true, shift: true }), 'Control+S')); + assert.ok(!matchesShortcut(key('s'), 'Control+S')); + assert.ok(!matchesShortcut(key('s', { ctrl: true, alt: true }), 'Control+S')); + assert.ok(matchesShortcut(key('+', { ctrl: true }), 'Control++'), 'legacy binding of the + key now fires'); + assert.ok(matchesShortcut(key('Escape'), 'Escape')); + assert.ok(!matchesShortcut(key('Escape'), '')); + assert.ok(!matchesShortcut(key('Escape'), undefined)); +}); + +test('isAllowedShortcut: Ctrl, Alt or Meta required, except F1-F12 and a lone Escape', () => { + for (const ok of ['Control+S', 'Alt+Home', 'Meta+K', 'Control+Shift+Plus', 'F1', 'F12', 'Shift+F5', 'Escape', 'Control+Escape']) { + assert.ok(isAllowedShortcut(ok), ok); + } + for (const bad of ['H', 'Shift+H', 'Space', 'Tab', 'Shift+Tab', 'Enter', 'Home', 'F13', 'Shift+Escape', 'Plus', '', 'Control']) { + assert.ok(!isAllowedShortcut(bad), bad); + } +}); + +test('shortcutParts and formatShortcutLabel read like keycaps', () => { + assert.deepEqual(shortcutParts('Control+Shift+S', 'linux'), ['Ctrl', 'Shift', 'S']); + assert.equal(formatShortcutLabel('Control+Plus', 'win32'), 'Ctrl+Plus'); + assert.equal(formatShortcutLabel('Control++', 'win32'), 'Ctrl+Plus'); + assert.equal(formatShortcutLabel('Escape', 'linux'), 'Esc'); + assert.equal(formatShortcutLabel('Control+PageDown', 'linux'), 'Ctrl+PgDn'); + assert.equal(formatShortcutLabel('Alt+ArrowUp', 'linux'), 'Alt+Up'); + assert.equal(formatShortcutLabel('Meta+E', 'win32'), 'Win+E'); + assert.equal(formatShortcutLabel('Meta+E', 'linux'), 'Super+E'); + assert.equal(formatShortcutLabel('Meta+E'), 'Meta+E', 'no platform known (plain Node)'); + assert.equal(formatShortcutLabel(DEFAULT_SHORTCUTS.newFile, 'linux'), 'Ctrl+T'); + assert.deepEqual(shortcutParts('garbage+'), []); +}); + +test('toElectronAccelerator uses Electron key names', () => { + assert.equal(toElectronAccelerator('Control+Shift+S'), 'Control+Shift+S'); + assert.equal(toElectronAccelerator('Control++'), 'Control+Plus'); + assert.equal(toElectronAccelerator('Alt+ArrowLeft'), 'Alt+Left'); + assert.equal(toElectronAccelerator('Control+,'), 'Control+,'); + assert.equal(toElectronAccelerator(''), null); +}); + +test('resolveShortcuts merges, migrates "++" and drops bindings that fire while typing', () => { + const resolved = resolveShortcuts({ + save: 'Control+Alt+S', // a normal rebind survives + closeTab: 'H', // the 1.13 recorder accepted bare letters: back to the default + palette: 'Control++', // legacy + key: migrated + print: 'Control++S', // unparseable: back to the default + goHome: 'shift+home', // Shift alone doesn't count: back to the default + removedAction: 'Control+Q' // no longer exists: dropped + }); + assert.equal(resolved.save, 'Control+Alt+S'); + assert.equal(resolved.closeTab, DEFAULT_SHORTCUTS.closeTab); + assert.equal(resolved.palette, 'Control+Plus'); + assert.equal(resolved.print, DEFAULT_SHORTCUTS.print); + assert.equal(resolved.goHome, DEFAULT_SHORTCUTS.goHome); + assert.ok(!('removedAction' in resolved)); + assert.deepEqual(resolveShortcuts(undefined), DEFAULT_SHORTCUTS); + assert.deepEqual(resolveShortcuts({ close: 'Escape' }).close, 'Escape'); +}); + +test('clampSidebarWidth keeps the width within 200-600', () => { + assert.equal(clampSidebarWidth(450), 450); + assert.equal(clampSidebarWidth(4), 200); + assert.equal(clampSidebarWidth(99999), 600); + assert.equal(clampSidebarWidth('320'), 320); + assert.equal(clampSidebarWidth(250.6), 251); + assert.equal(clampSidebarWidth(undefined), 300); + assert.equal(clampSidebarWidth('abc'), 300); + assert.equal(clampSidebarWidth(NaN, 280), 280); +}); diff --git a/test/signing-keyring.test.mjs b/test/signing-keyring.test.mjs new file mode 100644 index 0000000..ebfa470 --- /dev/null +++ b/test/signing-keyring.test.mjs @@ -0,0 +1,80 @@ +// The committed public keyring (build/linux/fate-archive-keyring.{gpg,asc}) is what every .deb and +// .rpm ships and every apt/dnf install trusts. The Build Linux workflow signs only with the keys +// listed in its SIGNING_KEYS. These tests read the OpenPGP packets directly (no gpg needed) and +// check that the two files hold the same keys and that every signing key is among them, so a +// release can never be signed with a key the packages don't carry. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const keyringPath = (ext) => join(root, 'build', 'linux', `fate-archive-keyring.${ext}`); + +function dearmor(text) { + const body = text.match(/-----BEGIN PGP PUBLIC KEY BLOCK-----\r?\n([\s\S]*?)-----END PGP PUBLIC KEY BLOCK-----/); + assert.ok(body, 'not an armored public key block'); + // Drop the armor headers (up to the first blank line, if any) and the "=XXXX" CRC line. + const lines = body[1].split(/\r?\n/); + const blank = lines.indexOf(''); + const data = (blank === -1 ? lines : lines.slice(blank + 1)).filter((l) => l && !l.startsWith('=')); + return Buffer.from(data.join(''), 'base64'); +} + +/** Fingerprints (v4: SHA-1 of 0x99, length, body) of the primary keys (packet tag 6). */ +function primaryFingerprints(buf) { + const out = []; + let i = 0; + while (i < buf.length) { + const ctb = buf[i]; + assert.ok(ctb & 0x80, `bad packet header at ${i}`); + let tag; + let len; + let start; + if (ctb & 0x40) { + tag = ctb & 0x3f; + const o = buf[i + 1]; + if (o < 192) { len = o; start = i + 2; } + else if (o < 224) { len = ((o - 192) << 8) + buf[i + 2] + 192; start = i + 3; } + else if (o === 255) { len = buf.readUInt32BE(i + 2); start = i + 6; } + else throw new Error('partial body lengths are not used in key packets'); + } else { + tag = (ctb >> 2) & 0x0f; + const type = ctb & 3; + if (type === 0) { len = buf[i + 1]; start = i + 2; } + else if (type === 1) { len = buf.readUInt16BE(i + 1); start = i + 3; } + else if (type === 2) { len = buf.readUInt32BE(i + 1); start = i + 5; } + else throw new Error('indeterminate length'); + } + const body = buf.subarray(start, start + len); + if (tag === 6) { + assert.equal(body[0], 4, 'only v4 keys are expected'); + const head = Buffer.from([0x99, (len >> 8) & 0xff, len & 0xff]); + out.push(createHash('sha1').update(head).update(body).digest('hex').toUpperCase()); + } + i = start + len; + } + return out; +} + +const binary = primaryFingerprints(readFileSync(keyringPath('gpg'))); +const armored = primaryFingerprints(dearmor(readFileSync(keyringPath('asc'), 'utf8'))); + +const workflow = readFileSync(join(root, '.github', 'workflows', 'build-linux.yml'), 'utf8'); +const signingKeys = (workflow.match(/^\s*SIGNING_KEYS:\s*['"]?([0-9A-F ]+?)['"]?\s*$/m)?.[1] ?? '').split(/\s+/).filter(Boolean); + +test('the .gpg and .asc keyrings hold the same keys', () => { + assert.ok(binary.length >= 1); + assert.deepEqual([...binary].sort(), [...armored].sort()); +}); + +test('Build Linux names its signing keys by full fingerprint', () => { + assert.ok(signingKeys.length >= 1, 'SIGNING_KEYS missing from build-linux.yml'); + for (const fpr of signingKeys) assert.match(fpr, /^[0-9A-F]{40}$/); +}); + +test('every signing key is in the keyring the packages ship', () => { + for (const fpr of signingKeys) assert.ok(binary.includes(fpr), `${fpr} is not in build/linux/fate-archive-keyring.gpg`); +}); diff --git a/test/spellcheck.test.cjs b/test/spellcheck.test.cjs new file mode 100644 index 0000000..d86fea4 --- /dev/null +++ b/test/spellcheck.test.cjs @@ -0,0 +1,134 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { EventEmitter } = require('node:events'); +const { + defaultSpellcheckEnabled, + resolveSpellcheckSetting, + pickSpellcheckLanguage, + createSpellcheckController +} = require('../electron/spellcheck.cjs'); + +// What Electron 42 reports on Linux (session.availableSpellCheckerLanguages). +const AVAILABLE = ['af', 'bg', 'ca', 'cs', 'cy', 'da', 'de', 'de-DE', 'el', 'en', 'en-AU', 'en-CA', 'en-GB', + 'en-GB-oxendict', 'en-US', 'es', 'es-419', 'es-AR', 'es-ES', 'es-MX', 'es-US', 'et', 'fa', 'fo', 'fr', + 'fr-FR', 'gl', 'he', 'hi', 'hr', 'hu', 'hy', 'id', 'it', 'it-IT', 'ko', 'lt', 'lv', 'nb', 'nl', 'pl', 'pt', + 'pt-BR', 'pt-PT', 'ro', 'ru', 'sh', 'sk', 'sl', 'sq', 'sr', 'sv', 'ta', 'tg', 'tr', 'uk', 'vi']; + +/** Records what the controller does to the session, in order. */ +function fakeSession({ available = AVAILABLE, throwOnLanguages = false } = {}) { + const ses = new EventEmitter(); + ses.calls = []; + ses.enabled = true; // Electron's default + ses.languages = ['en-US']; + ses.availableSpellCheckerLanguages = available; + ses.setSpellCheckerEnabled = (on) => { + ses.calls.push(['enabled', on]); + ses.enabled = on; + }; + ses.setSpellCheckerLanguages = (langs) => { + ses.calls.push(['languages', langs]); + if (throwOnLanguages) throw new Error('Invalid language code provided'); + ses.languages = langs; + }; + return ses; +} + +test('default: on for Windows (its own checker), off elsewhere (dictionaries download)', () => { + assert.equal(defaultSpellcheckEnabled('win32'), true); + assert.equal(defaultSpellcheckEnabled('linux'), false); + assert.equal(defaultSpellcheckEnabled('darwin'), false); +}); + +test('a stored boolean wins; anything else falls back to the default', () => { + assert.equal(resolveSpellcheckSetting(true, 'linux'), true); + assert.equal(resolveSpellcheckSetting(false, 'win32'), false); + assert.equal(resolveSpellcheckSetting(undefined, 'win32'), true); + assert.equal(resolveSpellcheckSetting(undefined, 'linux'), false); + assert.equal(resolveSpellcheckSetting('yes', 'linux'), false); + assert.equal(resolveSpellcheckSetting(1, 'linux'), false); +}); + +test('language: exact, then bare language, then regional, then US English', () => { + assert.equal(pickSpellcheckLanguage(['en-GB', 'en-US'], AVAILABLE), 'en-GB'); + assert.equal(pickSpellcheckLanguage(['EN_us'], AVAILABLE), 'en-US'); + assert.equal(pickSpellcheckLanguage(['de-AT'], AVAILABLE), 'de'); + assert.equal(pickSpellcheckLanguage(['pt-MZ'], AVAILABLE), 'pt'); + assert.equal(pickSpellcheckLanguage(['es-CO'], ['es-ES', 'es-MX', 'en-US']), 'es-ES'); + assert.equal(pickSpellcheckLanguage(['ja-JP', 'fr-CA'], AVAILABLE), 'fr'); + assert.equal(pickSpellcheckLanguage(['ja-JP'], AVAILABLE), 'en-US'); + assert.equal(pickSpellcheckLanguage([], AVAILABLE), 'en-US'); + assert.equal(pickSpellcheckLanguage(['ja-JP'], ['ko']), null); + assert.equal(pickSpellcheckLanguage([null, 7, ''], AVAILABLE), 'en-US'); + assert.equal(pickSpellcheckLanguage(['en-US'], undefined), null); +}); + +test('off: disabled AND no languages, so no dictionary is loaded or downloaded', () => { + const ses = fakeSession(); + const ctl = createSpellcheckController(ses, { enabled: false, preferredLanguages: ['en-US'], platform: 'linux' }); + assert.equal(ctl.isEnabled(), false); + assert.equal(ses.enabled, false); + assert.deepEqual(ses.languages, []); +}); + +test('on: the user language, then enabled', () => { + const ses = fakeSession(); + const ctl = createSpellcheckController(ses, { enabled: true, preferredLanguages: ['en-GB'], platform: 'linux' }); + assert.equal(ctl.isEnabled(), true); + assert.deepEqual(ses.calls, [['languages', ['en-GB']], ['enabled', true]]); +}); + +test('runtime toggles restore and clear the language list', () => { + const ses = fakeSession(); + const ctl = createSpellcheckController(ses, { enabled: false, preferredLanguages: ['de-DE'], platform: 'linux' }); + ctl.set(true); + assert.deepEqual([ses.enabled, ses.languages], [true, ['de-DE']]); + ctl.set(false); + assert.deepEqual([ses.enabled, ses.languages], [false, []]); + ctl.set('true'); // only a real boolean true turns it on + assert.equal(ses.enabled, false); +}); + +test('a dictionary that finishes loading is pushed to renderers by re-enabling', () => { + const ses = fakeSession(); + createSpellcheckController(ses, { enabled: true, preferredLanguages: ['en-US'], platform: 'linux' }); + ses.calls.length = 0; + ses.emit('spellcheck-dictionary-initialized', {}, 'en-US'); + assert.deepEqual(ses.calls, [['enabled', false], ['enabled', true]]); +}); + +test('refresh() re-sends the state to renderers (called when the page has loaded)', () => { + const ses = fakeSession(); + const ctl = createSpellcheckController(ses, { enabled: true, preferredLanguages: ['en-US'], platform: 'linux' }); + ses.calls.length = 0; + ctl.refresh(); + assert.deepEqual(ses.calls, [['enabled', false], ['enabled', true]]); + assert.equal(ses.enabled, true); +}); + +test('no re-enabling while the setting is off', () => { + const ses = fakeSession(); + const ctl = createSpellcheckController(ses, { enabled: true, preferredLanguages: ['en-US'], platform: 'linux' }); + ctl.set(false); + ses.calls.length = 0; + ses.emit('spellcheck-dictionary-initialized', {}, 'en-US'); + ctl.refresh(); + assert.deepEqual(ses.calls, []); + assert.equal(ses.enabled, false); +}); + +test('macOS: languages are never set (the system checker ignores them)', () => { + const ses = fakeSession(); + const ctl = createSpellcheckController(ses, { enabled: true, preferredLanguages: ['en-US'], platform: 'darwin' }); + ctl.set(false); + assert.equal(ses.calls.some(([kind]) => kind === 'languages'), false); +}); + +test('a language Electron rejects is reported, not thrown', () => { + const warnings = []; + const ses = fakeSession({ throwOnLanguages: true }); + const ctl = createSpellcheckController(ses, { enabled: true, preferredLanguages: ['en-US'], platform: 'linux', warn: (m) => warnings.push(m) }); + assert.equal(ctl.isEnabled(), true); + assert.equal(ses.enabled, true); + assert.equal(warnings.length, 1); +}); diff --git a/test/tempCleanup.test.cjs b/test/tempCleanup.test.cjs new file mode 100644 index 0000000..5dc8d5f --- /dev/null +++ b/test/tempCleanup.test.cjs @@ -0,0 +1,75 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { removeStaleTempFiles, STALE_AFTER_MS } = require('../electron/tempCleanup.cjs'); + +function scratchDir(t) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fate-tempclean-test-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + return dir; +} + +function touch(dir, name, ageMs) { + const file = path.join(dir, name); + fs.writeFileSync(file, 'x'); + const when = new Date(Date.now() - ageMs); + fs.utimesSync(file, when, when); + return file; +} + +const DAY = 24 * 60 * 60 * 1000; + +test('removes FATE temp files older than a day', async (t) => { + const dir = scratchDir(t); + touch(dir, 'FATE-preview-1234.pdf', 2 * DAY); + touch(dir, 'fate-ps-1234-7.ps1', 2 * DAY); + touch(dir, 'fate-registration-1234.reg', 2 * DAY); + const removed = await removeStaleTempFiles(dir); + assert.deepEqual(removed.sort(), ['FATE-preview-1234.pdf', 'fate-ps-1234-7.ps1', 'fate-registration-1234.reg']); + assert.deepEqual(fs.readdirSync(dir), []); +}); + +test('keeps young files: another FATE may have that preview open right now', async (t) => { + const dir = scratchDir(t); + touch(dir, 'FATE-preview-99.pdf', 60 * 1000); + touch(dir, 'fate-ps-99-0.ps1', STALE_AFTER_MS - 60 * 1000); + assert.deepEqual(await removeStaleTempFiles(dir), []); + assert.equal(fs.readdirSync(dir).length, 2); +}); + +test('keeps anything that is not exactly one of our names', async (t) => { + const dir = scratchDir(t); + for (const name of [ + 'FATE-preview-.pdf', + 'FATE-preview-12.pdf.bak', + 'fate-preview-12.pdf', // case matters: we write FATE-preview + 'my-FATE-preview-12.pdf', + 'fate-ps-12.ps1', + 'fate-registration-x.reg', + 'report.pdf', + 'notes.md' + ]) { + touch(dir, name, 10 * DAY); + } + assert.deepEqual(await removeStaleTempFiles(dir), []); + assert.equal(fs.readdirSync(dir).length, 8); +}); + +test('never follows or removes a symlink, and never a folder', async (t) => { + const dir = scratchDir(t); + const target = touch(dir, 'precious.txt', 10 * DAY); + fs.symlinkSync(target, path.join(dir, 'FATE-preview-1.pdf')); + fs.mkdirSync(path.join(dir, 'fate-registration-2.reg')); + const old = new Date(Date.now() - 10 * DAY); + fs.utimesSync(path.join(dir, 'fate-registration-2.reg'), old, old); + assert.deepEqual(await removeStaleTempFiles(dir), []); + assert.ok(fs.existsSync(target)); + assert.ok(fs.lstatSync(path.join(dir, 'FATE-preview-1.pdf')).isSymbolicLink()); +}); + +test('a missing folder is not an error', async () => { + assert.deepEqual(await removeStaleTempFiles(path.join(os.tmpdir(), 'fate-no-such-dir-' + process.pid)), []); +}); diff --git a/test/textFiles.test.cjs b/test/textFiles.test.cjs new file mode 100644 index 0000000..aa28613 --- /dev/null +++ b/test/textFiles.test.cjs @@ -0,0 +1,198 @@ +'use strict'; +/* Atomic saves and async reads in electron/textFiles.cjs. Run: node --test test/ */ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { readTextFile, writeFileAtomic } = require('../electron/textFiles.cjs'); + +const posix = process.platform !== 'win32'; + +function tempDir(t) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fate-textfiles-')); + t.after(() => { + try { + fs.chmodSync(dir, 0o755); + } catch { + /* already gone */ + } + fs.rmSync(dir, { recursive: true, force: true }); + }); + return dir; +} + +/** Files in `dir` other than the ones named: leftovers from a save. */ +const strays = (dir, keep) => fs.readdirSync(dir).filter((n) => !keep.includes(n)); + +test('readTextFile decodes and reports the format', async (t) => { + const dir = tempDir(t); + const file = path.join(dir, 'a.ps1'); + fs.writeFileSync(file, Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from('Write-Host hi\r\n', 'utf16le')])); + const doc = await readTextFile(file, { maxBytes: 1024 }); + assert.equal(doc.text, 'Write-Host hi\n'); + assert.deepEqual(doc.format, { encoding: 'utf16le', bom: true, eol: '\r\n' }); +}); + +test('readTextFile refuses folders, oversized and binary files with coded errors', async (t) => { + const dir = tempDir(t); + await assert.rejects(readTextFile(dir), (err) => err.code === 'NOT_FILE'); + const big = path.join(dir, 'big.txt'); + fs.writeFileSync(big, 'x'.repeat(2048)); + await assert.rejects(readTextFile(big, { maxBytes: 1024 }), (err) => err.code === 'TOO_LARGE' && err.size === 2048); + const bin = path.join(dir, 'a.bin'); + fs.writeFileSync(bin, Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01, 0x01, 0x00])); + await assert.rejects(readTextFile(bin), (err) => err.code === 'BINARY'); + await assert.rejects(readTextFile(path.join(dir, 'missing.txt')), (err) => err.code === 'ENOENT'); +}); + +test('an atomic save replaces the file and leaves nothing behind', async (t) => { + const dir = tempDir(t); + const file = path.join(dir, 'notes.md'); + fs.writeFileSync(file, 'old'); + const before = fs.statSync(file).ino; + const res = await writeFileAtomic(file, Buffer.from('new content')); + assert.equal(res.method, 'atomic'); + assert.equal(fs.readFileSync(file, 'utf8'), 'new content'); + if (posix) assert.notEqual(fs.statSync(file).ino, before, 'a new inode: the rename happened'); + assert.deepEqual(strays(dir, ['notes.md']), []); +}); + +test('a new file is created', async (t) => { + const dir = tempDir(t); + const file = path.join(dir, 'new.txt'); + await writeFileAtomic(file, Buffer.from('hello')); + assert.equal(fs.readFileSync(file, 'utf8'), 'hello'); + assert.deepEqual(strays(dir, ['new.txt']), []); +}); + +test('permission bits are preserved', { skip: !posix }, async (t) => { + const dir = tempDir(t); + const file = path.join(dir, 'run.sh'); + fs.writeFileSync(file, '#!/bin/sh\n'); + fs.chmodSync(file, 0o750); + await writeFileAtomic(file, Buffer.from('#!/bin/sh\necho hi\n')); + assert.equal(fs.statSync(file).mode & 0o777, 0o750); +}); + +test('new files get the requested mode', { skip: !posix }, async (t) => { + const dir = tempDir(t); + const file = path.join(dir, 'secret.json'); + await writeFileAtomic(file, Buffer.from('{}'), { mode: 0o600 }); + assert.equal(fs.statSync(file).mode & 0o777, 0o600); +}); + +test('saving through a symlink updates the target and keeps the link', { skip: !posix }, async (t) => { + const dir = tempDir(t); + fs.mkdirSync(path.join(dir, 'real')); + const target = path.join(dir, 'real', 'config.ini'); + const link = path.join(dir, 'link.ini'); + fs.writeFileSync(target, 'a=1'); + fs.symlinkSync(target, link); + await writeFileAtomic(link, Buffer.from('a=2')); + assert.ok(fs.lstatSync(link).isSymbolicLink(), 'still a symlink'); + assert.equal(fs.readFileSync(target, 'utf8'), 'a=2'); + assert.deepEqual(strays(path.join(dir, 'real'), ['config.ini']), []); +}); + +test('a dangling symlink is written through, not replaced', { skip: !posix }, async (t) => { + const dir = tempDir(t); + const target = path.join(dir, 'gone.txt'); + const link = path.join(dir, 'link.txt'); + fs.symlinkSync(target, link); + const res = await writeFileAtomic(link, Buffer.from('back')); + assert.equal(res.method, 'in-place'); + assert.ok(fs.lstatSync(link).isSymbolicLink()); + assert.equal(fs.readFileSync(target, 'utf8'), 'back'); +}); + +test('hard links are kept by writing in place', { skip: !posix }, async (t) => { + const dir = tempDir(t); + const a = path.join(dir, 'a.txt'); + const b = path.join(dir, 'b.txt'); + fs.writeFileSync(a, 'one'); + fs.linkSync(a, b); + const res = await writeFileAtomic(a, Buffer.from('two, and longer')); + assert.equal(res.method, 'in-place'); + assert.equal(fs.readFileSync(b, 'utf8'), 'two, and longer'); + // Shorter content truncates the old tail. + await writeFileAtomic(a, Buffer.from('3')); + assert.equal(fs.readFileSync(b, 'utf8'), '3'); +}); + +test('a folder that refuses new files falls back to an in-place write', { skip: !posix || process.getuid() === 0 }, async (t) => { + const dir = tempDir(t); + const file = path.join(dir, 'locked.txt'); + fs.writeFileSync(file, 'before'); + fs.chmodSync(dir, 0o555); // can write the file, cannot create the temporary one + const res = await writeFileAtomic(file, Buffer.from('after')); + assert.equal(res.method, 'in-place'); + assert.equal(fs.readFileSync(file, 'utf8'), 'after'); + assert.deepEqual(strays(dir, ['locked.txt']), []); +}); + +test('an unwritable file fails cleanly and keeps its content', { skip: !posix || process.getuid() === 0 }, async (t) => { + const dir = tempDir(t); + const file = path.join(dir, 'ro.txt'); + fs.writeFileSync(file, 'keep me'); + fs.chmodSync(file, 0o444); + fs.chmodSync(dir, 0o555); + await assert.rejects(writeFileAtomic(file, Buffer.from('lost')), (err) => err.code === 'EACCES'); + assert.equal(fs.readFileSync(file, 'utf8'), 'keep me'); + assert.deepEqual(strays(dir, ['ro.txt']), []); +}); + +test('a read-only file in a writable folder is not replaced', { skip: !posix || process.getuid() === 0 }, async (t) => { + const dir = tempDir(t); + const file = path.join(dir, 'protected.txt'); + fs.writeFileSync(file, 'keep me'); + fs.chmodSync(file, 0o444); + await assert.rejects(writeFileAtomic(file, Buffer.from('overwritten')), (err) => err.code === 'EACCES'); + assert.equal(fs.readFileSync(file, 'utf8'), 'keep me'); + assert.equal(fs.statSync(file).mode & 0o777, 0o444); + assert.deepEqual(strays(dir, ['protected.txt']), []); +}); + +test('a failed rename never leaves the temporary file behind', async (t) => { + const dir = tempDir(t); + const file = path.join(dir, 'x.txt'); + fs.writeFileSync(file, 'old'); + const original = fs.promises.rename; + fs.promises.rename = async () => { + const err = new Error('simulated: no space left on device'); + err.code = 'ENOSPC'; + throw err; + }; + t.after(() => { + fs.promises.rename = original; + }); + await assert.rejects(writeFileAtomic(file, Buffer.from('new')), (err) => err.code === 'ENOSPC'); + fs.promises.rename = original; + assert.equal(fs.readFileSync(file, 'utf8'), 'old', 'the original is untouched'); + assert.deepEqual(strays(dir, ['x.txt']), []); +}); + +test('a rename refused by a lock falls back to writing in place', async (t) => { + const dir = tempDir(t); + const file = path.join(dir, 'locked-by-another-app.txt'); + fs.writeFileSync(file, 'old'); + const original = fs.promises.rename; + fs.promises.rename = async () => { + const err = new Error('simulated: resource busy or locked'); + err.code = 'EBUSY'; + throw err; + }; + t.after(() => { + fs.promises.rename = original; + }); + const res = await writeFileAtomic(file, Buffer.from('new')); + fs.promises.rename = original; + assert.equal(res.method, 'in-place'); + assert.equal(fs.readFileSync(file, 'utf8'), 'new'); + assert.deepEqual(strays(dir, ['locked-by-another-app.txt']), []); +}); + +test('saving onto a folder is refused', async (t) => { + const dir = tempDir(t); + await assert.rejects(writeFileAtomic(dir, Buffer.from('x')), (err) => err.code === 'EISDIR'); +}); diff --git a/test/windows-registration.test.mjs b/test/windows-registration.test.mjs new file mode 100644 index 0000000..274f69c --- /dev/null +++ b/test/windows-registration.test.mjs @@ -0,0 +1,65 @@ +// The code-type list lives in three places that must agree (CONTRIBUTING → Where things live): +// CODE_EXTENSIONS in electron/main.cjs, CODE_EXTENSIONS in src/fileKinds.js, and the generated +// blocks in build/installer.nsh. The Store manifest reads the installer's list +// (scripts/appx-manifest.cjs), so a drift here would reach every Windows channel. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { CODE_EXTENSIONS as RENDERER_CODE, MARKDOWN_EXTENSIONS as RENDERER_MARKDOWN } from '../src/fileKinds.js'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const nsh = readFileSync(join(root, 'build', 'installer.nsh'), 'utf8'); +const mainCjs = readFileSync(join(root, 'electron', 'main.cjs'), 'utf8'); + +const macroArgs = (macro) => [...nsh.matchAll(new RegExp(`^[ \\t]*!insertmacro[ \\t]+${macro}[ \\t]+"([^"]+)"`, 'gm'))].map((m) => m[1]); + +/** A `const NAME = [ 'a', 'b' ];` array literal from main.cjs, read as text (main.cjs needs Electron). */ +function mainArray(name) { + const m = mainCjs.match(new RegExp(`const ${name} = \\[([\\s\\S]*?)\\];`)); + assert.ok(m, `electron/main.cjs no longer declares ${name} as an array literal; update this test`); + return [...m[1].matchAll(/'([^']+)'/g)].map((x) => x[1]); +} + +const sorted = (list) => [...new Set(list)].sort(); +const MAIN_CODE = mainArray('CODE_EXTENSIONS'); +const PROTECTED = mainArray('PROTECTED_EXTENSIONS'); +const INSTALLED = macroArgs('RegisterCodeType'); +const UNINSTALLED = macroArgs('UnregisterCodeType'); + +test('main.cjs and fileKinds.js list the same code and Markdown types', () => { + assert.deepEqual(sorted(RENDERER_CODE), sorted(MAIN_CODE)); + // .txt opens as plain text since 1.14.0 but keeps its Windows registration (see main.cjs). + assert.deepEqual(sorted([...RENDERER_MARKDOWN, 'txt']), sorted(mainArray('MARKDOWN_EXTENSIONS'))); +}); + +test('the installer registers exactly the associable code types', () => { + assert.deepEqual(sorted(INSTALLED), sorted(MAIN_CODE.filter((e) => !PROTECTED.includes(e)))); + assert.equal(new Set(INSTALLED).size, INSTALLED.length, 'a type is registered twice'); +}); + +test('the installer never registers a protected type', () => { + assert.deepEqual(PROTECTED.filter((e) => INSTALLED.includes(e)), []); + for (const e of PROTECTED) { + assert.ok(nsh.includes(`!insertmacro RestoreCommandProcessorType "${e}"`), `customInstall must repair .${e}`); + } +}); + +test('the uninstaller removes every type the installer (or an older one) registered', () => { + assert.deepEqual(INSTALLED.filter((e) => !UNINSTALLED.includes(e)), []); + // 1.11.5 and earlier registered .bat and .cmd; their keys still come out. + for (const e of PROTECTED) assert.ok(UNINSTALLED.includes(e), e); +}); + +test('per-user entries come out on a real uninstall, never during an update', () => { + const unregister = nsh.match(/!macro UnregisterCodeType EXT([\s\S]*?)!macroend/)[1]; + assert.match(unregister, /\$\{IfNot\} \$\{isUpdated\}[\s\S]*DeleteRegKey HKCU "Software\\Classes\\FATE\.\$\{EXT\}"[\s\S]*\$\{EndIf\}/); + const uninstall = nsh.match(/!macro customUnInstall([\s\S]*?)!macroend/)[1]; + assert.match(uninstall, /\$\{IfNot\} \$\{isUpdated\}[\s\S]*Markdown Document[\s\S]*DeleteRegKey HKCU "\$\{INSTALL_REGISTRY_KEY\}"[\s\S]*\$\{EndIf\}/); +}); + +test('preInit does nothing in the uninstaller-building pass that runs on the build machine', () => { + const preInit = nsh.match(/!macro preInit([\s\S]*?)!macroend/)[1]; + assert.match(preInit.trim(), /^!ifndef BUILD_UNINSTALLER[\s\S]*!endif$/); +}); diff --git a/vite.config.js b/vite.config.js index d40796b..a569e58 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,8 +1,52 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' +/* + * Content-Security-Policy for the packaged renderer, as a injected into dist/index.html. + * + * BUILDS ONLY: the dev server needs inline scripts (React Refresh's preamble) and a websocket for + * HMR, so `npm run dev` runs without it. Everything a document can bring in is limited here as + * a second line behind the sanitiser in src/markdown.js: + * - scripts only from the app itself, never inline, never eval (Mermaid, KaTeX and CodeMirror + * need none of it); + * - styles from the app, plus inline ones (React style props, KaTeX's glyph positioning, + * CodeMirror's injected theme, Mermaid's per-diagram