diff --git a/.codex/noteconnection_release_runbook.md b/.codex/noteconnection_release_runbook.md index 49696a679..6b97a91a8 100644 --- a/.codex/noteconnection_release_runbook.md +++ b/.codex/noteconnection_release_runbook.md @@ -8,8 +8,7 @@ Provide a stable, repeatable release procedure for NoteConnection so that GitHub ### Preconditions -- Local branch is `main` and synced: - - `rtk git pull --rebase origin main` +- Work on the reviewed release branch. Keep the source PR open until candidate validation and the remaining release work are complete. - Working tree is clean except intentionally ignored build artifacts. - Version files are aligned: - `package.json` @@ -30,22 +29,30 @@ Provide a stable, repeatable release procedure for NoteConnection so that GitHub - `npm run docs:diataxis:check` - `npm run docs:site:build` - `npx jest --runInBand` (recommended for CI parity on Windows). -4. Commit and push to main: +4. Commit and push the release branch for review: - `rtk git add ` - `rtk git commit -m "docs(release): ..."` or `release: vX.Y.Z` - - `rtk git push origin main` + - `rtk git push origin ` + - Keep the source PR unmerged while the candidate is being validated. 5. Tag and push tag: - `rtk git tag vX.Y.Z` - `rtk git push origin refs/tags/vX.Y.Z` -6. Create GitHub Release with explicit notes: - - `rtk gh release create vX.Y.Z --title "vX.Y.Z" --notes-file docs/release_notes_vX.Y.Z.md` -7. Monitor CI: - - `Release Desktop Multi-OS` should pass and upload assets. - - `Publish to npm` should pass. npm is expected to auto-publish when CI succeeds. -8. Verify outputs: - - GitHub release assets include Windows/macOS/Linux and APK (if workflow enables Android). - - npm registry: - - `npm view noteconnection version dist-tags --json` +6. Keep the GitHub Release in draft with explicit notes: + - CI creates the draft from the checked-out release source and rejects an already public release. + - If creating it manually: `rtk gh release create vX.Y.Z --draft --target --title "vX.Y.Z" --notes-file docs/release_notes_vX.Y.Z.md`. +7. Monitor candidate CI: + - `Release Desktop Multi-OS` should pass and upload assets to the draft. + - Android builds run only when native-device evidence is explicitly requested; signed-device gates remain required. + - npm publication does not run for the tag or draft release. +8. Verify the exact candidate artifacts: + - Download the CI AppImage and its separate original-server workflow artifact, run `npm run verify:appimage -- `, and complete baseline KVM/runtime acceptance. + - Record the tested hashes and actual platform coverage in the bilingual release notes before publishing. +9. After validation and all remaining work are complete, merge the source PR into `main` and publish the existing draft: + - `rtk gh release edit vX.Y.Z --draft=false --latest --notes-file docs/release_notes_vX.Y.Z.md` +10. Verify published outputs: + - GitHub release assets match the accepted bytes and the documented platform coverage. + - `Publish to npm` runs only for published product `v*` releases; Godot mirror releases are excluded. + - Verify the npm registry: `npm view noteconnection version dist-tags --json`. ### EdgeOne Docs Deploy (Optional but Recommended for docs releases) @@ -77,8 +84,7 @@ Provide a stable, repeatable release procedure for NoteConnection so that GitHub ### 前置条件 -- 当前分支为 `main` 且已同步: - - `rtk git pull --rebase origin main` +- 在经过审查的发布分支上工作;候选制品验收和其余发布工作完成前,源码 PR 保持未合并。 - 工作区干净(仅允许明确忽略的构建产物残留)。 - 版本文件保持一致: - `package.json` @@ -99,22 +105,30 @@ Provide a stable, repeatable release procedure for NoteConnection so that GitHub - `npm run docs:diataxis:check` - `npm run docs:site:build` - `npx jest --runInBand`(Windows 上更接近 CI,稳定性更高)。 -4. 提交并推送主干: +4. 提交并推送发布分支供审查: - `rtk git add <变更文件>` - `rtk git commit -m "docs(release): ..."` 或 `release: vX.Y.Z` - - `rtk git push origin main` + - `rtk git push origin ` + - 候选制品验收期间保持源码 PR 未合并。 5. 打标签并推送: - `rtk git tag vX.Y.Z` - `rtk git push origin refs/tags/vX.Y.Z` -6. 创建 GitHub Release(必须带完整更新日志): - - `rtk gh release create vX.Y.Z --title "vX.Y.Z" --notes-file docs/release_notes_vX.Y.Z.md` -7. 观察 CI: - - `Release Desktop Multi-OS` 通过并上传平台资产。 - - `Publish to npm` 通过并自动发布 npm。 -8. 发布后验收: - - GitHub Release 资产包含 Windows/macOS/Linux(以及启用时的 APK)。 - - npm 版本核验: - - `npm view noteconnection version dist-tags --json` +6. 保持 GitHub Release 为草稿并提供完整更新日志: + - CI 从检出的发布源码创建草稿,并拒绝复用已公开的发布。 + - 如需手动创建:`rtk gh release create vX.Y.Z --draft --target --title "vX.Y.Z" --notes-file docs/release_notes_vX.Y.Z.md`。 +7. 观察候选制品 CI: + - `Release Desktop Multi-OS` 通过并将平台资产上传至草稿。 + - 仅在明确请求原生真机证据时执行 Android 构建,仍保留签名和真机门禁。 + - tag 或草稿发布不会触发 npm 发布。 +8. 验收候选制品的精确字节: + - 下载 CI AppImage 及单独保存原始 server 的 workflow artifact,执行 `npm run verify:appimage -- `,完成基线 KVM/运行时验收。 + - 正式公开前,在双语发布说明中记录实际测试散列和平台覆盖范围。 +9. 验收和其余工作全部完成后,将源码 PR 合并到 `main`,再公开已有草稿: + - `rtk gh release edit vX.Y.Z --draft=false --latest --notes-file docs/release_notes_vX.Y.Z.md` +10. 核验已发布产物: + - GitHub Release 资产与已验收字节及文档中的平台覆盖一致。 + - `Publish to npm` 仅由正式公开的产品 `v*` release 触发,Godot 镜像发布除外。 + - npm 版本核验:`npm view noteconnection version dist-tags --json`。 ### EdgeOne 文档发布(文档类版本建议执行) diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 657b62370..105ad4da6 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -3,9 +3,6 @@ name: Publish to npm on: release: types: [published] - push: - tags: - - "v*" concurrency: group: npm-publish-${{ github.event.release.tag_name || github.ref_name }} @@ -28,6 +25,7 @@ env: jobs: publish: + if: ${{ startsWith(github.event.release.tag_name, 'v') }} runs-on: ubuntu-latest steps: diff --git a/.github/workflows/release-desktop-multi-os.yml b/.github/workflows/release-desktop-multi-os.yml index 50952054b..3c3819269 100644 --- a/.github/workflows/release-desktop-multi-os.yml +++ b/.github/workflows/release-desktop-multi-os.yml @@ -46,6 +46,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 with: + ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.source_ref != '' && github.event.inputs.source_ref || github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }} fetch-depth: 0 - name: Resolve tag name @@ -70,10 +71,14 @@ jobs: shell: bash run: | TAG_NAME="${{ steps.tag.outputs.tag_name }}" - if gh release view "$TAG_NAME" >/dev/null 2>&1; then - echo "Release already exists for $TAG_NAME." + if RELEASE_IS_DRAFT="$(gh release view "$TAG_NAME" --json isDraft --jq '.isDraft' 2>/dev/null)"; then + if [ "$RELEASE_IS_DRAFT" != "true" ]; then + echo "::error::Release $TAG_NAME is already public; refusing to replace published assets." + exit 1 + fi + echo "Draft release already exists for $TAG_NAME." else - gh release create "$TAG_NAME" --title "$TAG_NAME" --notes "Automated desktop multi-OS release assets." + gh release create "$TAG_NAME" --draft --target "$(git rev-parse HEAD)" --title "$TAG_NAME" --notes-file "docs/release_notes_${TAG_NAME}.md" fi ensure-godot-mirror-assets: @@ -102,6 +107,7 @@ jobs: gh release create "$GODOT_MIRROR_TAG" \ --repo "$GITHUB_REPOSITORY" \ --target "$GITHUB_SHA" \ + --latest=false \ --title "$GODOT_MIRROR_TAG" \ --notes "Project-controlled mirror for Godot desktop bootstrap archives." fi @@ -178,7 +184,8 @@ jobs: files: | src-tauri/target/release/bundle/**/*.exe src-tauri/target/release/bundle/**/*.msi - - runner: ubuntu-latest + # The AppImage catalog runs Ubuntu 22.04 (glibc 2.35); build against that baseline. + - runner: ubuntu-22.04 platform_label: linux files: | src-tauri/target/release/bundle/**/*.AppImage @@ -226,6 +233,9 @@ jobs: libgtk-3-dev \ librsvg2-dev \ patchelf \ + desktop-file-utils \ + libgdk-pixbuf2.0-bin \ + squashfs-tools \ "${APPINDICATOR_PKG}" echo "Installed Linux native dependencies with ${WEBKIT_PKG} and ${APPINDICATOR_PKG}." @@ -336,10 +346,36 @@ jobs: fi cp "${GODOT_BIN}" "${TARGET_BIN}" chmod +x "${TARGET_BIN}" + # The upstream signature binds Godot.app's Info.plist; the standalone sidecar needs its own signature. + codesign --force --sign - "${TARGET_BIN}" + codesign --verify --strict --verbose=4 "${TARGET_BIN}" + "${TARGET_BIN}" --version - name: Build desktop bundle run: npm run tauri:build:mini + - name: Verify portable AppImage desktop integration + if: runner.os == 'Linux' + shell: bash + run: | + set -euo pipefail + mapfile -d '' artifacts < <(find src-tauri/target/release/bundle -type f -name '*.AppImage' -print0) + if [ "${#artifacts[@]}" -eq 0 ]; then + echo "No AppImage artifacts were produced." >&2 + exit 1 + fi + for artifact in "${artifacts[@]}"; do + npm run verify:appimage -- "${artifact}" src-tauri/bin/server-x86_64-unknown-linux-gnu + done + + - name: Upload Linux AppImage verification input + if: runner.os == 'Linux' + uses: actions/upload-artifact@v4 + with: + name: release-${{ needs.ensure-release.outputs.tag_name }}-linux-verification-inputs + if-no-files-found: error + path: src-tauri/bin/server-x86_64-unknown-linux-gnu + - name: Upload workflow artifacts uses: actions/upload-artifact@v4 with: @@ -351,6 +387,7 @@ jobs: uses: softprops/action-gh-release@v2 with: tag_name: ${{ needs.ensure-release.outputs.tag_name }} + draft: true files: ${{ matrix.files }} fail_on_unmatched_files: true env: @@ -359,6 +396,7 @@ jobs: build-and-upload-android: name: Build and Upload (android-apk) needs: ensure-release + if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.run_android_device_evidence == 'true' }} runs-on: ubuntu-latest steps: @@ -589,6 +627,7 @@ jobs: uses: softprops/action-gh-release@v2 with: tag_name: ${{ needs.ensure-release.outputs.tag_name }} + draft: true files: build/release/mobile/* fail_on_unmatched_files: true env: diff --git a/.trellis/spec/backend/quality-guidelines.md b/.trellis/spec/backend/quality-guidelines.md index 3c08a2166..65211c2e1 100644 --- a/.trellis/spec/backend/quality-guidelines.md +++ b/.trellis/spec/backend/quality-guidelines.md @@ -69,3 +69,37 @@ Jest with `ts-jest` (configured in `jest.config.js`). Node.js 20 is the CI targe 5. Is database access through the store interface? 6. Does the change avoid `any` in public API signatures? 7. For performance-sensitive code: is `PerformanceLogger` used for timing? + +## Linux AppImage Packaging Contract + +### Scope + +Changes to the Tauri CLI, sidecars, Linux build runner, or release workflow must preserve the final artifact's portability. Build Linux releases on Ubuntu 22.04 (glibc 2.35), the catalog baseline. + +### Commands + +Use `npm run tauri:build:mini`, then `npm run verify:appimage -- `. Both verifier arguments are required and must come from the same build. + +### Boundary and environment + +The Linux runner selects `scripts/appimage-patchelf.js` through `PATCHELF`. `NOTE_CONNECTION_APPIMAGE_PATCHELF` identifies the original executable; `NOTE_CONNECTION_APPIMAGE_SERVER_SUFFIX` and `NOTE_CONNECTION_APPIMAGE_SERVER_SHA256` identify the protected pkg sidecar. Only its `--set-rpath` write is suppressed; dependency queries and other ELF operations remain enabled. AppRun supplies the sidecar's library search path. Never rewrite pkg's completed ELF payload offsets. + +### Validation and errors + +The final verifier rejects inaccessible stored SquashFS modes, invalid integration symlinks, missing desktop executables/icons, invalid image decoding, and any server hash mismatch. A matching path with changed bytes makes the patchelf adapter fail. Metadata success alone does not prove that the ELF loader, WebView, or backend starts. + +### Acceptance cases + +- Good: the Ubuntu 22.04 artifact launches as a normal user, serves authenticated graph/reader requests, and shuts down its sidecars. +- Base: `.DirIcon` resolves inside the image, `AppRun.wrapped` is executable by other users, and the packaged server equals the original file. +- Bad: testing only on the newer build host, resolving links against the CI workspace, or accepting a visible shell window while its backend has crashed. + +### Required tests + +Run the portability and patchelf behavioral suites. Verify the final image on the baseline OS and a newer host, retaining its SHA-256, logs, and screenshots. The installed Markdown worker must be discovered under Tauri's suffixless name and report `engine: pulldown` without a missing-worker fallback. + +Run the offline simulation worker suite and load a graph with external networking disabled, keeping loopback available for the sidecar. Graph layout dependencies must be bundled; an initial window and successful graph API do not establish that worker-produced node positions render. + +### Wrong and correct evidence + +An extracted directory with manually repaired links is diagnostic evidence. A fresh image produced by the corrected build, checked outside the build directory and exercised through the catalog worker and native runtime, establishes release acceptance. Use `unsquashfs` for stored-mode checks: runtime `--appimage-extract` can alter directory permissions. diff --git a/docs/diataxis/en/how-to/test-linux-appimage.md b/docs/diataxis/en/how-to/test-linux-appimage.md new file mode 100644 index 000000000..3294391fe --- /dev/null +++ b/docs/diataxis/en/how-to/test-linux-appimage.md @@ -0,0 +1,53 @@ +# How-To: Test the Linux AppImage + +Build release AppImages on Ubuntu 22.04 with glibc 2.35, matching the AppImage catalog's Linux baseline and the artifact architecture. Use a normal desktop user and an isolated test profile for application testing. The Linux desktop release matrix pins `ubuntu-22.04` so a moving runner image cannot silently raise the minimum glibc version. + +## Build and verify the final artifact + +Install the usual Tauri Linux build dependencies and the artifact verifier tools: + +```bash +sudo apt-get install desktop-file-utils libgdk-pixbuf2.0-bin squashfs-tools +npm ci +npm run tauri:build:mini +npm run verify:appimage -- src-tauri/target/release/bundle/appimage/NoteConnection_1.8.0_amd64.AppImage src-tauri/bin/server-x86_64-unknown-linux-gnu +sha256sum src-tauri/target/release/bundle/appimage/*.AppImage +``` + +Substitute the actual release filename. `verify:appimage` reads the final SquashFS manifest, checks that packaged files are readable and executables/directories usable by users other than the build owner, then extracts into a fresh temporary directory with `unsquashfs`. This preserves stored permissions; the AppImage runtime's `--appimage-extract` can replace directory modes with 0700. + +The verifier rejects absolute, escaping, dangling, or cyclic integration links, checks `.DirIcon`, the root desktop entry, `AppRun`, `AppRun.wrapped`, the desktop `Exec` target and themed icon, validates the desktop file with `desktop-file-validate`, and decodes the icon with GDK Pixbuf. The required second argument is the original server sidecar from the same build: the packaged server must match its complete SHA-256, including the appended pkg payload. It deletes its temporary extraction on success or failure. The Linux release workflow runs it before either artifact upload. + +Run the regression suite with: + +```bash +npm test -- --runInBand src/appimage.portability.test.ts src/appimage.patchelf.test.ts src/simulation.worker.offline.test.ts +``` + +## Verify application behavior + +The artifact gate checks packaging. Also launch the actual final AppImage on Ubuntu 22.04 from a directory outside the repository as a normal user. Use isolated XDG config/data directories, `NOTE_CONNECTION_CONFIG_PATH`, and disposable Markdown notes; keep the operating-system home and toolchain caches in their normal locations. Confirm a real visible window, successful graph loading, note reading, switching into and out of Path mode, and clean shutdown of the server and Godot sidecars. Retain the exact artifact SHA-256, source commit, installed Tauri CLI version, command output, and screenshots with the test report. + +For catalog acceptance, repeat launch as a different user from the packaged file owner under the catalog's Xvfb/firejail environment. A launch as the build owner can hide mode 0770 defects. A process remaining alive does not establish that a window rendered. Classify missing FUSE, display, or firejail support separately from failures in the artifact. + +Load a graph with external network access disabled while preserving loopback access to the local backend. Confirm separated node positions in force and DAG layouts. The simulation worker uses the existing bundled D3 library; a CDN import can leave all nodes at their initial positions even though graph APIs and the initial catalog screenshot pass. The offline worker test loads the real packaged scripts, rejects external origins, and verifies simulation output. + +## Keep the glibc build baseline compatible + +Native Ubuntu 22.04 testing showed that both the published image and a packaging-corrected image built on Ubuntu 24.04 failed before opening a window: the main executable required `GLIBC_2.39`, and bundled libraries required `GLIBC_2.38`. Ubuntu 22.04 provides glibc 2.35. Correct icons, launcher permissions, and intact sidecar bytes do not establish compatibility with that runtime. + +Rebuild the executable and bundled libraries on the supported Ubuntu 22.04 baseline, then launch the resulting final artifact there. Preserve the target OS and `ldd --version` output with native acceptance evidence. A successful launch on a newer build host cannot prove this older runtime requirement. + +## Why the CLI version matters + +[AppImage catalog PR 8838](https://github.com/AppImage/appimage.github.io/pull/8838) exposed two defects in the packaging chain used by the published 1.8.0 image: `.DirIcon` pointed at an absolute CI build path, and `AppRun.wrapped` had mode 0770. The image contained the icon bytes, but another machine could not resolve the link; a user outside the stored owner/group could not execute the wrapped launcher. + +The npm Tauri CLI embeds the bundler. CLI 2.11.4 includes the [relative symlink fix](https://github.com/tauri-apps/tauri/pull/15596); CLI 2.12.0 updates linuxdeploy to [normalize launcher permissions](https://github.com/tauri-apps/tauri/issues/16155). The repository requires CLI 2.12.1 and locks its platform packages to that version, incorporating both corrections. `Exec=npm` names the existing bundled Cargo executable and is valid. Repacking the published image by hand is useful for diagnosis but does not verify a corrected release build. + +## Preserve the pkg sidecar during packaging + +Native testing also exposed a separate failure after the window opened: linuxdeploy rewrote the server ELF's RPATH, moving pkg's appended payload while leaving its embedded offsets unchanged. Node then crashed before application startup. Upgrading the CLI alone does not prevent this transformation. + +The existing Linux build runner selects `scripts/appimage-patchelf.js` through linuxdeploy's supported `PATCHELF` setting. The adapter suppresses only `--set-rpath` for the exact product AppDir's `usr/bin/server`, after verifying its bytes match the original sidecar. It forwards read queries and operations on other executables/libraries to the resolved original patchelf, including a caller-specified `PATCHELF`. A changed server fails immediately. Keep using `npm run tauri:build:mini` (or its existing runner); invoking `npx tauri build` directly bypasses this packaging boundary. + +Dependency discovery remains enabled. The bundled AppRun provides `LD_LIBRARY_PATH`, inherited by the server, so the sidecar can load the libraries linuxdeploy collects without rewriting the completed pkg executable. Final-artifact byte comparison prevents future packaging changes from silently corrupting it. Verify healthy backend responses through a real AppImage launch as well as the byte comparison. diff --git a/docs/release_notes_v1.9.0.md b/docs/release_notes_v1.9.0.md new file mode 100644 index 000000000..9fbf07c2a --- /dev/null +++ b/docs/release_notes_v1.9.0.md @@ -0,0 +1,185 @@ +# NoteConnection v1.9.0 + +## English + +### Release scope + +- Release date: **2026-10-05 (UTC)**. +- Version: **1.9.0**. Release source: [`ff749c2559974c36e5c7f3bb30faf00ee5887fb4`](https://github.com/Jacobinwwey/NoteConnection/commit/ff749c2559974c36e5c7f3bb30faf00ee5887fb4). +- Compare baseline: **`v1.8.0..v1.9.0`** ([full comparison](https://github.com/Jacobinwwey/NoteConnection/compare/v1.8.0...v1.9.0)). +- Scope: **125 upstream commits** through `3d68da8398b59fb3d0c35444d19261a5342155b7`, followed by Linux packaging/runtime fixes, corrections to four test assumptions, v1.9.0 release metadata and draft-release workflow changes, the macOS standalone Godot signature fix, and the native About version fix. The source range includes archived qualification evidence as well as implementation changes. +- v1.8.0 already introduced the RSE/document-augmented graph RAG pipeline, Agent Workspace, sidecar architecture and foundation runtime checks. The changes below extend and correct those existing capabilities. + +### Highlights + +- Answers now plan coverage from grounded graph evidence, admit sources against the question before reading them, and better preserve comparison operands, complementary claims, measurement conditions and uncertainty. +- Agent Workspace adds slim/full answers and adaptive full-response budgets, with cancellation, bounded source reads and bounded JSON/SSE delivery across the request lifecycle. +- Storage and ingestion protect complete state transactions; portable resource identities and versioned mobile projections gain replay, import recovery and explicit resource limits. +- Desktop bundles include the Godot resource pack and gain installed-startup/installer qualification. Linux AppImage fixes address the six distinct failures found while reproducing [AppImage catalog PR #8838](https://github.com/AppImage/appimage.github.io/pull/8838). + +### RAG, answer quality and response controls + +- **Coverage-driven answer planning:** adds explicit grounded answer plans and clause-level evidence selection; connects the plan to retrieval context, composition and final answer review. Multilingual comparison evidence and independent complementary claims survive deduplication, while repeated claims and unrelated variable glossaries are filtered. +- **Question-connected source admission:** excludes sources that only share an unrelated word with the query before document reads and citation construction. Rejected sources cannot return through graph-neighbor recovery. Navigation-only wiki links no longer become factual assertions. +- **Comparable evidence and readable answers:** handles subject, unit, tolerance and time/environment/version/platform scope when comparing measurements; retains unresolved uncertainty and conflicting storage claims. Distinguishes source presentation labels from factual content, preserves step order and equations, and normalizes bilingual comparison identities. +- **Slim/full and budget controls:** adds compact and fuller answer modes, an adaptive full-response budget informed by browser capability/workload hints, budget diagnostics and cache separation. The optional “unbounded” product setting still obeys runtime time, source, fragment and serialization limits. Mobile stays on the bounded standard profile. +- **Cancellation and delivery:** limits concurrent turns and cumulative source work, propagates cancellation through retrieval, evidence assembly and the provider-backed sufficiency judge, and waits for provider work to settle. Bounded response serialization and SSE backpressure preserve a readable answer with explicit truncation rather than allowing unbounded response framing. +- **Quality evidence:** adds frozen bilingual calibration/evaluation and follow-up confirmation corpora, plus runtime measurements and regression records. These are reproducible quality checks, not a claim of universal factual accuracy, production ANN performance or measured learner outcomes. + +### Storage, identities and mobile projections + +- **Consistent state commits:** makes file snapshot replacement atomic and cache-coherent, serializes overlapping saves, and isolates concurrent learning-state mutations. Ingest requests validate alias ownership and roll back the complete request on failure; acknowledged writes are not silently absorbed into a later failed transaction. +- **SQLite and provider selection:** restores SQLite-backed storage selection, strengthens provider resolution and improves native storage diagnostics. +- **Portable identities and selected roots:** introduces workspace-relative source identity/canonical-ID metadata with content revisions and compatibility aliases across host boundaries. Ambiguous legacy IDs and escaping paths are rejected. Graph loading retains the user-selected knowledge root. Versioned identity corpora and a readiness gate support future migration without declaring a global public-ID cutover. +- **Bounded local mobile analysis:** introduces versioned graph projections, app-local projection storage and replay, and exact local analysis without allocating a desktop-style full graph on Android. Shared budgets bound documents, input bytes, graph size and projection serialization across hosts. +- **Android import recovery:** adds Storage Access Framework folder import into app-local storage, transaction journals, staging/backup recovery and process-restart handling. Follow-up fixes retain the backup when rollback fails and keep recovery retries non-destructive. +- **Mobile release boundaries:** aligns Tauri Android as the active release route and Capacitor as a compatibility path, adds signed arm64 artifact checks and shared size/RSS budget definitions, and expands host semantic-parity/recovery probes. **No Android APK/AAB is released in v1.9.0:** signing credentials and a configured device acceptance environment were unavailable, so Android assets are excluded from this release and require signing/device acceptance before a future mobile publication. The host-side changes do not establish signed-device or RSS acceptance. + +### Desktop, Godot and installed application behavior + +- **About uses the configured release version:** the native menu dialog previously hardcoded `v1.6.0`. It now reads `app_handle.package_info().version`, which Tauri generates from the application configuration, so the displayed version follows the configured `1.9.0` without a second version string. Source review and strict Rust validation passed. The rebuilt AppImage displays `1.9.0` in the actual native About dialog in both FUSE and extract-and-run modes. +- Packages `path_mode.pck` with the desktop application and launches Godot from the installed resource pack instead of depending on the checkout or launcher's working directory. Explicit development overrides validate the Godot project marker. +- Selects distinct browser-compatible private loopback ports for the HTTP server and bridge. Missing credentials are rejected when sidecar authentication is configured. +- Adds native-window checks that prove the required tests actually ran and exercise Tauri/Godot window lifecycle behavior. Window screenshots, runtime logs and source/artifact identities are retained with the acceptance evidence. +- Adds Windows NSIS/MSI installation, installed startup, Godot entry/return and uninstall qualification on fresh runners, with installer receipts, corrected MSI quoting, WebView2 handling and failure-log retention. Upstream archived Windows acceptance is part of this source range; it is separate from the Linux validation recorded below. + +### Linux AppImage corrections + +| Failure observed in the published or rebuilt AppImage | Behavior in v1.9.0 | +| --- | --- | +| `.DirIcon` points to the CI build directory, causing the catalog to report a missing icon. | Tauri CLI 2.12.1 includes the relative metadata-link fix first released in CLI 2.11.4. | +| `AppRun.wrapped` has root-owned mode 0770, preventing non-owner launch in catalog/install environments. | The updated linuxdeploy chain produces mode 0755; stored permissions are checked before upload. | +| RPATH rewriting moves the pkg server's appended payload while its embedded offsets remain unchanged, causing a bootstrap `SyntaxError`. | An adapter at linuxdeploy's supported `PATCHELF` boundary skips only `--set-rpath` for the exact source-hash-matching pkg server. Queries and operations on other files are forwarded, preserving dependency discovery. The final image must contain the exact source-sidecar bytes. | +| Ubuntu 24.04-built binaries/libraries require newer glibc than the catalog's Ubuntu 22.04 host. | Linux desktop release builds target Ubuntu 22.04 and its glibc 2.35 baseline. | +| The native Markdown worker is installed without a target suffix but the resolver searches build filenames only. | The existing resolver also discovers installed `markdown-worker`/`markdown-worker.exe` names, retaining override/build-name precedence. Linux packaged indexes use pulldown without fallback. | +| The simulation worker downloads D3 from an external origin, preventing graph layout without network access. | Force and DAG layout load the existing bundled D3 library and work with only loopback networking. | + +`Exec=npm` remains valid because `npm` is the existing packaged Cargo executable. The fix does not rename the product, rewrite pkg offsets after packaging, replace libc or manually repack the public release. + +### macOS Godot sidecar preparation + +- The first versioned macOS build exposed a separate packaging failure: the official Godot executable runs inside `Godot.app`, but its embedded signature still binds the app's `Info.plist` after copying it to a standalone sidecar. macOS rejects that copy with `SIGKILL`, and signature verification reports `invalid Info.plist`. +- The macOS preparation step now ad-hoc signs the copied standalone sidecar, verifies its signature and runs `--version` before preparing the Godot resource pack. The checksummed upstream archive and the source app remain untouched. +- An isolated native macOS arm64 diagnostic reproduced the failure for fresh destinations, the release target, overwritten stubs and replaced inodes. All five failing copy cases passed strict signature verification and both Python and Node launch probes after signing their separate control copies. The final macOS CI build also passed its sidecar preparation and packaging steps. These results establish copied-sidecar signature/startup behavior and successful packaging; they do not establish full macOS application GUI acceptance, Developer ID signing or notarization. + +### CI, maintainability and documentation + +- Adds a final AppImage gate before either artifact upload. It reads stored SquashFS permissions, verifies portable metadata links and executable access, validates desktop fields and icon decoding, and compares the packaged server with the original build output. +- Keeps product releases and asset uploads in draft until the exact CI artifacts are accepted, refuses to overwrite an already public release, and loads the bilingual notes from the checked-out release source. The original Linux server is retained as a separate workflow artifact for the later AppImage integrity check. +- Runs Android builds only when native-device evidence is explicitly requested; signing and device acceptance remain required before mobile assets can be uploaded. npm publication starts only when a product `v*` release is published, while Godot mirror releases neither trigger npm publication nor replace the product’s Latest release. +- Binds foundation SQLite/reference-connector qualification to source fingerprints, exact artifact bytes, workload definitions and independent host identity. Corrects portable fingerprinting and adds Windows/Linux qualification jobs; a passing record for one artifact cannot stand in for another. +- Moves registered NoteMD routes to ownership of complete operations, restores route behavior checks, and removes duplicated graph-window/progress code while preserving matching semantics. +- Splits the top-level README into English and Chinese entrypoints, restores the feature tour, and updates paired architecture/build-flow documentation, progress audits and acceptance records. Adds a local Linux AppImage testing guide. +- Corrects **four test-only assumptions**: binary-upload tests wait for the request to finish writing before teardown; resource-root fixtures use native host paths; the mobile identity corpus tests Windows-style reference normalization without treating a Windows path as a native Linux root; elapsed-time checks accept a valid zero-millisecond result. These test changes do not change production behavior. + +### Builds and validation + +- **Windows, macOS and Linux desktop builds all passed** in [CI run 37250352656](https://github.com/Jacobinwwey/NoteConnection/actions/runs/37250352656), from release source `ff749c2559974c36e5c7f3bb30faf00ee5887fb4`, including the About fix. The [version gate](https://github.com/Jacobinwwey/NoteConnection/actions/runs/37250352645) passed. All five downloaded release assets match this run's artifacts by SHA-256 and size. + +| Platform | Architecture | Release assets | Final CI result | +| --- | --- | --- | --- | +| Windows | x64 | `NoteConnection_1.9.0_x64-setup.exe`, `NoteConnection_1.9.0_x64_en-US.msi` | Build and upload passed | +| macOS | Apple Silicon / aarch64 | `NoteConnection_1.9.0_aarch64.dmg` | Build and upload passed | +| Linux | amd64 / x86_64 | `NoteConnection_1.9.0_amd64.AppImage`, `NoteConnection_1.9.0_amd64.deb` | Build, upload and AppImage integrity gate passed | + +- **Complete Jest validation of the source including the About fix:** **176/176 suites and 1,797/1,797 tests passed**, with zero failures, skipped tests or todo tests. The full run used no suite exclusions, forced exit or fixed clock and exited naturally. Independent review confirmed that every suite and assertion passed. +- **Strict Tauri/Rust validation of the About change:** `NOTE_CONNECTION_TAURI_TEST_STRICT=1 npm run test:tauri -- --locked` passed with **34 passed, zero failures, one existing ignored mobile cross-host probe and zero filtered tests**. Independent review verified the Tauri version source, unchanged surrounding menu behavior, scoped formatting and documentation checks. +- **Final Linux artifact identity:** AppImage SHA-256 is `1ac08976a86f77ba9e45005c30de7379b870c97a3cc103e760540703177519c3`. The packaged server matches the original server from the same CI build byte for byte, SHA-256 `59b8bfebc2d059e1c53097bf43bf328da992e9ae2b66213a6003aebb12c7bdcf`. The AppImage integrity gate passed in CI and against the downloaded release artifact; the local `verify:appimage` command exited 0. +- **Linux KVM end-to-end acceptance passed** on Ubuntu 22.04 x86_64 using the exact AppImage hash above. The catalog worker exited 0 and reported glibc 2.35. Both native FUSE and extract-and-run modes passed authenticated API and native Markdown/pulldown checks with only loopback networking, displayed `1.9.0` in the native About dialog, showed force/DAG layouts with directed edges, rendered actual Markdown and Mermaid in the reader, and entered and returned from Godot Path Mode with a two-node, one-edge graph. +- **Normal exit and cleanup passed:** after returning from Godot, both modes closed through the window manager and exited 0. All 26 observed application PIDs, the test namespace and its support processes were gone, and the native FUSE mount was removed. Extraction caches persisted after application exit and were then removed manually after an open-file check; runtime manifests were preserved as evidence. The guest powered off normally, with serial-console and host-side confirmation at `2026-10-05T02:36:11Z`; QEMU and its control sockets were gone, while the VM disk was retained. +- **Verification attachments:** [Linux KVM validation summary and screenshots](https://github.com/Jacobinwwey/NoteConnection/releases/download/v1.9.0/NoteConnection_1.9.0_linux-kvm-validation.zip), [SHA256SUMS.txt](https://github.com/Jacobinwwey/NoteConnection/releases/download/v1.9.0/SHA256SUMS.txt), [source-validation.json](https://github.com/Jacobinwwey/NoteConnection/releases/download/v1.9.0/source-validation.json) and [public-ci-manifest.json](https://github.com/Jacobinwwey/NoteConnection/releases/download/v1.9.0/public-ci-manifest.json). +- **Platform boundary:** successful Windows/macOS CI builds establish packaging for their recorded inputs. The macOS signature/startup diagnosis covers the copied Godot executable. Archived upstream Windows install/start/Godot/uninstall and host-mobile records remain separate from final Linux KVM end-to-end acceptance. They do not establish fresh full-application acceptance for every desktop platform. Android signing/device/RSS acceptance, hardware GPU/audio, production ANN and large-corpus performance are outside this release validation. +- **Known observations from earlier Linux diagnosis:** reader loading/outline placeholders, some Mermaid/DAG label presentation issues and a sequential worker fallback for small graphs remain documented. + +--- + +## 中文 + +### 发布范围 + +- 发布日期:**2026-10-05(UTC)**。 +- 版本:**1.9.0**。发布源码:[`ff749c2559974c36e5c7f3bb30faf00ee5887fb4`](https://github.com/Jacobinwwey/NoteConnection/commit/ff749c2559974c36e5c7f3bb30faf00ee5887fb4)。 +- 对比基线:**`v1.8.0..v1.9.0`**([完整比较](https://github.com/Jacobinwwey/NoteConnection/compare/v1.8.0...v1.9.0))。 +- 范围包括截止 `3d68da8398b59fb3d0c35444d19261a5342155b7` 的 **125 个上游提交**,以及后续 Linux 打包/运行时修复、4 处测试假设修正、v1.9.0 版本元数据和草稿发布流程调整、macOS 独立 Godot 签名修复,以及原生 About 版本显示修复。源码范围同时包含实现变化和验收归档。 +- v1.8.0 已引入 RSE/文档增强图 RAG、Agent Workspace、sidecar 架构和基础运行时检查。以下内容描述这些能力之后的增量与修复。 + +### 主要变化 + +- 回答从有依据的图证据规划覆盖范围,在读取来源前检查其与问题的关联,更完整地保留比较对象、互补论点、测量条件和不确定性。 +- Agent Workspace 增加精简/完整回答及完整回答的自适应预算;取消信号、源读取预算和 JSON/SSE 输出限制贯穿请求生命周期。 +- 存储与摄取保护完整状态事务;跨宿主资源身份和版本化移动投影增加回放、导入恢复和明确的资源上限。 +- 桌面包携带 Godot 资源包,并增加安装后启动及安装器验证。Linux AppImage 修复覆盖复现 [AppImage 目录 PR #8838](https://github.com/AppImage/appimage.github.io/pull/8838) 时发现的 6 个独立问题。 + +### RAG、回答质量与响应控制 + +- **按覆盖范围规划回答:** 增加明确的、有来源依据的回答计划和语句级证据选择,将计划贯穿检索上下文、组织回答和最终审查。多语言比较证据及独立互补论点能够在去重后保留,同时过滤重复断言和不相关的变量词汇表。 +- **先检查问题关联,再读取来源:** 在文档读取和引用生成前排除仅因同一个多义词而命中的无关来源;被拒绝的来源不能经图邻居恢复重新进入。仅起导航作用的 wiki 链接不再被当成事实断言。 +- **比较条件与可读性:** 比较测量时考虑主体、单位、公差及时间/环境/版本/平台作用域,保留无法判定的不确定性和冲突的存储证据。区分来源排版标签与事实内容,保持步骤顺序、公式和引用,并统一双语比较证据身份。 +- **精简/完整及预算设置:** 增加精简与完整回答,完整模式按浏览器能力和工作量提示选择自适应预算,并提供预算诊断与缓存隔离。“不限制”选项仅取消产品层预算,仍受运行时间、源读取、片段数量和序列化上限约束;移动端保留有界的标准档位。 +- **取消与传输:** 限制并发轮次和累计源处理量,将取消信号传递到检索、证据组装和 provider 充分性评审,并等待 provider 停止工作。响应序列化和 SSE 背压控制保留可读答案及明确的截断原因,限制输出占用。 +- **质量证据:** 增加冻结的双语校准/评估集、后续确认集、运行时测量和回归记录。这些材料用于复现质量检查,不代表所有回答都正确、生产 ANN 已达标,或学习效果已获实测证明。 + +### 存储、资源身份与移动投影 + +- **一致的状态提交:** 文件快照替换保持原子性和缓存一致性,重叠保存串行执行,学习状态的并发修改相互隔离。摄取请求先验证别名归属,失败时回滚完整请求,避免已确认写入被后续失败事务覆盖。 +- **SQLite 与 provider 选择:** 恢复 SQLite 存储选择,加固 provider 解析与原生存储诊断。 +- **可移植身份与知识库根目录:** 增加跨宿主传递的工作区相对来源身份/canonical-ID 元数据、内容修订和兼容别名,拒绝歧义旧 ID 和越界路径,保留用户选择的知识库根目录。版本化身份语料及就绪门禁用于支持后续迁移,不宣称已全局切换公开 ID。 +- **有界移动端本地分析:** 增加版本化图投影、应用内投影存储与回放,以及精确本地分析,避免 Android 分配桌面式完整图对象。各宿主共用预算,约束文档数、输入字节、图规模和投影序列化。 +- **Android 导入恢复:** 通过 Storage Access Framework 选择文件夹并导入应用本地存储,加入事务日志、暂存/备份恢复和进程重启处理。后续修复在回滚失败时保留备份,使恢复重试不破坏已有数据。 +- **移动发布边界:** 明确 Tauri Android 为当前发布路线、Capacitor 为兼容路线;增加签名 arm64 制品检查、共用体积/RSS 预算,以及宿主语义一致性和恢复探针。**v1.9.0 不发布 Android APK/AAB:** 本次缺少签名凭据及已配置的真机验收环境,因此本次发布不包含 Android 资产;后续移动发布仍需完成签名和真机验收。宿主侧变化不代表已完成签名真机或 RSS 验收。 + +### 桌面、Godot 与安装后行为 + +- **About 使用配置中的发布版本:** 原生菜单对话框此前硬编码 `v1.6.0`,现改为读取 `app_handle.package_info().version`,由 Tauri 从应用配置生成版本信息,使显示内容跟随配置中的 `1.9.0`,无需维护第二份版本字符串。源码审查和严格 Rust 验证已通过;重建 AppImage 的 FUSE 与解包运行模式均已实际打开原生 About 对话框,确认显示 `1.9.0`。 +- 桌面应用打包 `path_mode.pck`,从已安装资源包启动 Godot,不再依赖源码目录或启动器工作目录。显式开发覆盖路径需要包含有效 Godot 项目标记。 +- 为 HTTP 服务和 bridge 选择不同且浏览器可用的私有回环端口;配置 sidecar 认证后,缺失凭据的请求会被拒绝。 +- 原生窗口验证会核对必需测试是否真正执行,并检查 Tauri/Godot 窗口生命周期;截图、运行日志和源码/制品身份随证据归档。 +- 增加 Windows NSIS/MSI 在全新 runner 上的安装、安装后启动、Godot 进入/返回及卸载验证,记录安装器来源,修正 MSI 引号、WebView2 处理和失败日志保留。该范围包含上游已归档的 Windows 验收;它与下述 Linux 验证分别记录。 + +### Linux AppImage 修复 + +| 在已发布或重建制品中观察到的问题 | v1.9.0 行为 | +| --- | --- | +| `.DirIcon` 指向 CI 构建目录,目录检查报图标缺失。 | Tauri CLI 2.12.1 包含首次于 CLI 2.11.4 发布的相对元数据链接修复。 | +| `AppRun.wrapped` 属于 root 且权限为 0770,目录/安装环境中的其他用户无法执行。 | 更新后的 linuxdeploy 生成权限为 0755 的启动器,上传前检查制品内实际权限。 | +| RPATH 改写移动 pkg server 的附加数据,而内嵌偏移未更新,启动报 bootstrap `SyntaxError`。 | 在 linuxdeploy 支持的 `PATCHELF` 边界,仅对散列与本次构建原件精确匹配的 pkg server 跳过 `--set-rpath`;查询及其他文件操作继续转发,保留依赖收集。最终包内 server 必须与构建原件逐字节一致。 | +| Ubuntu 24.04 构建的二进制/库需要比目录 Ubuntu 22.04 更高的 glibc。 | Linux 桌面发布构建固定 Ubuntu 22.04,采用其 glibc 2.35 基线。 | +| 原生 Markdown worker 安装后没有 target 后缀,解析器却只查找构建文件名。 | 现有解析器补充查找安装名 `markdown-worker`/`markdown-worker.exe`,保留显式覆盖和构建名优先级;Linux 包内索引使用 pulldown,无 fallback。 | +| simulation worker 从外部站点加载 D3,无网络时图布局失败。 | Force 和 DAG 布局改用已有的随包 D3,仅有回环网络时也能工作。 | + +`Exec=npm` 仍有效,因为 `npm` 是既有 Cargo 可执行文件名且已打包。本修复没有更名产品、在打包后补写 pkg 偏移、替换 libc 或手工重打包公开制品。 + +### macOS Godot sidecar 准备 + +- 首次版本化 macOS 构建暴露了独立的打包问题:官方 Godot 可执行文件在 `Godot.app` 内正常运行,但复制为独立 sidecar 后,内嵌签名仍绑定原应用的 `Info.plist`。macOS 以 `SIGKILL` 拒绝启动,验签明确报告 `invalid Info.plist`。 +- macOS 准备步骤现在对复制出的独立 sidecar 执行 ad-hoc 签名、验签和 `--version` 启动检查,再准备 Godot 资源包。上游归档的散列校验和原始 app 保持不变。 +- 隔离的原生 macOS arm64 诊断覆盖全新目标、发布目标、覆盖 stub 和更换 inode,均复现失败;五组失败用例另制对照副本,签名后均通过严格验签及 Python、Node 启动探针。最终 macOS CI 构建也已通过 sidecar 准备和打包步骤。这些结果证明了独立 sidecar 的签名/启动行为及打包成功,不等于完整 macOS 应用 GUI 验收、Developer ID 签名或公证。 + +### CI、可维护性与文档 + +- 在两类制品上传之前执行最终 AppImage 门禁:读取 SquashFS 实际权限,验证元数据链接和可执行权限,检查 desktop 字段、图标解码,并比对包内 server 与构建原件。 +- 在精确 CI 制品验收前,产品发布和资产上传均保持草稿,拒绝覆盖已公开发布,并从检出的发布源码读取双语说明。Linux 原始 server 另存为 workflow artifact,供后续 AppImage 完整性复核。 +- 仅在明确请求原生真机证据时启动 Android 构建;移动资产上传仍必须通过签名和真机验收。npm 仅在产品 `v*` release 正式公开时发布;Godot 镜像发布既不触发 npm,也不取代产品的 Latest 发布。 +- 将基础 SQLite/参考连接器验收绑定到源码指纹、精确制品字节、工作负载和独立宿主身份;修正跨平台指纹并增加 Windows/Linux 验证任务,避免拿一份制品的合格记录替代另一份。 +- 让已注册 NoteMD 路由拥有完整操作,恢复路由行为检查,去除重复的图窗口提取及进度代码,同时保持匹配语义。 +- 顶层 README 分为英文、中文入口,恢复功能导览;同步双语架构/构建流程文档、进度审计与验收档案,并增加 Linux AppImage 本地测试指南。 +- 修正 **4 处仅测试问题**:二进制上传测试等待请求写完才允许清理;资源根目录 fixture 使用宿主原生路径;移动身份语料直接验证 Windows 风格引用归一化,不把 Windows 路径误当作 Linux 原生根目录;耗时断言允许合法的 0 毫秒结果。这些测试修改不改变生产行为。 + +### 构建与验证 + +- **Windows、macOS、Linux 三个平台桌面构建全部通过:** [CI 运行 37250352656](https://github.com/Jacobinwwey/NoteConnection/actions/runs/37250352656) 使用包含 About 修复的发布源码 `ff749c2559974c36e5c7f3bb30faf00ee5887fb4`。[版本门禁](https://github.com/Jacobinwwey/NoteConnection/actions/runs/37250352645)已通过。全部 5 个下载后的发布资产均按 SHA-256 和大小确认与本次运行制品一致。 + +| 平台 | 架构 | 发布资产 | 最终 CI 结果 | +| --- | --- | --- | --- | +| Windows | x64 | `NoteConnection_1.9.0_x64-setup.exe`、`NoteConnection_1.9.0_x64_en-US.msi` | 构建、上传通过 | +| macOS | Apple Silicon / aarch64 | `NoteConnection_1.9.0_aarch64.dmg` | 构建、上传通过 | +| Linux | amd64 / x86_64 | `NoteConnection_1.9.0_amd64.AppImage`、`NoteConnection_1.9.0_amd64.deb` | 构建、上传及 AppImage 完整性门禁通过 | + +- **包含 About 修复的源码完整 Jest 验证:176/176 套件、1,797/1,797 测试通过**,0 失败、跳过或 todo。完整运行未排除套件、强制退出或固定时钟,测试自然退出。独立审查逐项确认所有 suite/assertion 均通过。 +- **About 修改的严格 Tauri/Rust 验证:** `NOTE_CONNECTION_TAURI_TEST_STRICT=1 npm run test:tauri -- --locked` 通过,**34 通过、0 失败、1 个既有移动跨宿主探针忽略、0 筛除**。独立审查核对了 Tauri 版本来源、周边菜单行为保持不变、修改片段格式及文档检查。 +- **最终 Linux 制品身份:** AppImage SHA-256 为 `1ac08976a86f77ba9e45005c30de7379b870c97a3cc103e760540703177519c3`。包内 server 与同次 CI 构建原件逐字节一致,SHA-256 为 `59b8bfebc2d059e1c53097bf43bf328da992e9ae2b66213a6003aebb12c7bdcf`。CI 和下载后的发布制品均通过 AppImage 完整性门禁,本地 `verify:appimage` 命令真实退出码为 0。 +- **Linux KVM 端到端验收已通过:** 在 Ubuntu 22.04 x86_64 上使用前述精确 AppImage 字节。目录 worker 退出 0,报告 glibc 2.35。原生 FUSE 与解包运行两种模式均在仅有回环网络时通过认证 API 和原生 Markdown/pulldown 检查,原生 About 实际显示 `1.9.0`,Force/DAG 布局显示有向边,阅读器实际渲染 Markdown 和 Mermaid,并以两节点、一条边的图谱进入和返回 Godot Path Mode。 +- **正常退出与清理已通过:** 从 Godot 返回后,两种模式均通过窗口管理器关闭,退出码均为 0。观测到的 26 个应用 PID、测试命名空间及辅助进程均无残留,原生 FUSE 挂载已消失。解包缓存会在应用退出后保留,测试在检查无打开文件后另外手动删除;runtime manifest 保留为证据。客体正常关机,串口与宿主侧证据在 `2026-10-05T02:36:11Z` 确认 QEMU 及其控制套接字均消失,虚拟机磁盘保留。 +- **验证附件:** [Linux KVM 验证摘要与截图](https://github.com/Jacobinwwey/NoteConnection/releases/download/v1.9.0/NoteConnection_1.9.0_linux-kvm-validation.zip)、[SHA256SUMS.txt](https://github.com/Jacobinwwey/NoteConnection/releases/download/v1.9.0/SHA256SUMS.txt)、[source-validation.json](https://github.com/Jacobinwwey/NoteConnection/releases/download/v1.9.0/source-validation.json)、[public-ci-manifest.json](https://github.com/Jacobinwwey/NoteConnection/releases/download/v1.9.0/public-ci-manifest.json)。 +- **平台边界:** Windows/macOS CI 构建成功只证明对应输入的打包结果;macOS 验签/启动诊断覆盖复制后的 Godot 可执行文件。上游归档的 Windows 安装/启动/Godot/卸载和移动宿主记录与最终 Linux KVM 端到端验收分别记录,不等于每个平台的新制品均已完成完整应用验收。Android 签名/真机/RSS、硬件 GPU/音频、生产 ANN 和大语料性能不在本次发布验证范围内。 +- **此前 Linux 诊断保留的已知观察:** 阅读器加载/大纲占位文字、部分 Mermaid/DAG 标签显示问题,以及小图分析的串行 worker fallback。 diff --git a/package-lock.json b/package-lock.json index 32663d4b2..f57b0ef66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "noteconnection", - "version": "1.8.0", + "version": "1.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "noteconnection", - "version": "1.8.0", + "version": "1.9.0", "license": "ISC", "dependencies": { "@capacitor/android": "^8.0.0", @@ -30,7 +30,7 @@ "noteconnection": "dist/src/server.js" }, "devDependencies": { - "@tauri-apps/cli": "^2.10.0", + "@tauri-apps/cli": "^2.12.1", "@types/jest": "^30.0.0", "@types/jsdom": "^21.1.7", "@types/node": "^25.0.3", @@ -2382,9 +2382,9 @@ } }, "node_modules/@tauri-apps/cli": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.0.tgz", - "integrity": "sha512-ZwT0T+7bw4+DPCSWzmviwq5XbXlM0cNoleDKOYPFYqcZqeKY31KlpoMW/MOON/tOFBPgi31a2v3w9gliqwL2+Q==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.12.1.tgz", + "integrity": "sha512-kEDEiGzG+yAc5FeLxtXpES/VN+F2C8H0r4gDVtfRLKxT9np9101d9REG/Kgo2lr0hKi0IpDsODiHnU3+naOmLg==", "dev": true, "license": "Apache-2.0 OR MIT", "bin": { @@ -2398,23 +2398,23 @@ "url": "https://opencollective.com/tauri" }, "optionalDependencies": { - "@tauri-apps/cli-darwin-arm64": "2.10.0", - "@tauri-apps/cli-darwin-x64": "2.10.0", - "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.0", - "@tauri-apps/cli-linux-arm64-gnu": "2.10.0", - "@tauri-apps/cli-linux-arm64-musl": "2.10.0", - "@tauri-apps/cli-linux-riscv64-gnu": "2.10.0", - "@tauri-apps/cli-linux-x64-gnu": "2.10.0", - "@tauri-apps/cli-linux-x64-musl": "2.10.0", - "@tauri-apps/cli-win32-arm64-msvc": "2.10.0", - "@tauri-apps/cli-win32-ia32-msvc": "2.10.0", - "@tauri-apps/cli-win32-x64-msvc": "2.10.0" + "@tauri-apps/cli-darwin-arm64": "2.12.1", + "@tauri-apps/cli-darwin-x64": "2.12.1", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.12.1", + "@tauri-apps/cli-linux-arm64-gnu": "2.12.1", + "@tauri-apps/cli-linux-arm64-musl": "2.12.1", + "@tauri-apps/cli-linux-riscv64-gnu": "2.12.1", + "@tauri-apps/cli-linux-x64-gnu": "2.12.1", + "@tauri-apps/cli-linux-x64-musl": "2.12.1", + "@tauri-apps/cli-win32-arm64-msvc": "2.12.1", + "@tauri-apps/cli-win32-ia32-msvc": "2.12.1", + "@tauri-apps/cli-win32-x64-msvc": "2.12.1" } }, "node_modules/@tauri-apps/cli-darwin-arm64": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.0.tgz", - "integrity": "sha512-avqHD4HRjrMamE/7R/kzJPcAJnZs0IIS+1nkDP5b+TNBn3py7N2aIo9LIpy+VQq0AkN8G5dDpZtOOBkmWt/zjA==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.12.1.tgz", + "integrity": "sha512-8bMnbpJ2jKO/ufrOPL65EMHC3ptw7fzeraoQkwibyfGLqIl+UCWsC0152kmNbW6r9NvVQduXqAZF5mH1QgTWhw==", "cpu": [ "arm64" ], @@ -2429,9 +2429,9 @@ } }, "node_modules/@tauri-apps/cli-darwin-x64": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.0.tgz", - "integrity": "sha512-keDmlvJRStzVFjZTd0xYkBONLtgBC9eMTpmXnBXzsHuawV2q9PvDo2x6D5mhuoMVrJ9QWjgaPKBBCFks4dK71Q==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.12.1.tgz", + "integrity": "sha512-G+2qte8GNqNlEZ9ftd21rbu/APVkl3r/nzNDI7LVxEoqJWVNe9GbEgQUMH1r4PHT20Z2UxR3KjCkltIb0e/Wjg==", "cpu": [ "x64" ], @@ -2446,9 +2446,9 @@ } }, "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.0.tgz", - "integrity": "sha512-e5u0VfLZsMAC9iHaOEANumgl6lfnJx0Dtjkd8IJpysZ8jp0tJ6wrIkto2OzQgzcYyRCKgX72aKE0PFgZputA8g==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.12.1.tgz", + "integrity": "sha512-5b3n4DaTW+A1Zntnh4Yn44VyLrlPEpB8kMOBRfWkzNnzolBk+AYhKIfKjCLmoC8Mob3ETodxAEOMkE2YPKEWzQ==", "cpu": [ "arm" ], @@ -2463,13 +2463,16 @@ } }, "node_modules/@tauri-apps/cli-linux-arm64-gnu": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.0.tgz", - "integrity": "sha512-YrYYk2dfmBs5m+OIMCrb+JH/oo+4FtlpcrTCgiFYc7vcs6m3QDd1TTyWu0u01ewsCtK2kOdluhr/zKku+KP7HA==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.12.1.tgz", + "integrity": "sha512-APArHhDu+8pXSCkCQmvaz1nD3d8WTmlCoSS/oG475TJ6AATBvyQOAmf5qELgsQ4s2+8MOsop0Zd699RKELF1Cw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -2480,13 +2483,16 @@ } }, "node_modules/@tauri-apps/cli-linux-arm64-musl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.0.tgz", - "integrity": "sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.12.1.tgz", + "integrity": "sha512-JaCxcLoV0oGtllVkLr4UAHWG/wTfkEunnUqnsEsGYOp0VRxnUwHg63wFBD0jU2ul5eFfSLIDS4RF2gfzsSo5Jg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -2497,13 +2503,16 @@ } }, "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.0.tgz", - "integrity": "sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.12.1.tgz", + "integrity": "sha512-K5+VXM7+SGVDwZleA2+lMgvbTVuA5e1tkiVEawu1Yxj26ZyEfO8h8xTC1rBdRX2xgJDSwCk6vPUBCS6i+Z5slQ==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -2514,13 +2523,16 @@ } }, "node_modules/@tauri-apps/cli-linux-x64-gnu": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.0.tgz", - "integrity": "sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.12.1.tgz", + "integrity": "sha512-Z1QGPr49HJZMktu+Spu6Q1b/f9ANadXvqZZZt2645ua1x1ev6prOgp85eiGv2jRzohFEMLo6aJyaLjLUX5KIvA==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -2531,13 +2543,16 @@ } }, "node_modules/@tauri-apps/cli-linux-x64-musl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.0.tgz", - "integrity": "sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.12.1.tgz", + "integrity": "sha512-nHuEhRPOMSJ/8RRhtMRxB9B9lVr7xxpe4DapBz8x1GIau3vdzjHHJ1hAfmw+PyayPLZ6XoaVIHPgI17rX6ii8w==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -2548,9 +2563,9 @@ } }, "node_modules/@tauri-apps/cli-win32-arm64-msvc": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.0.tgz", - "integrity": "sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.12.1.tgz", + "integrity": "sha512-I0oPk2uGh86R8dAs2X5OxSP+/GqSc8jh1Px3l8EqrocSNivsRHDTh2k3PVOqdv163dlV6PIVvmhZAbH/lPfZrA==", "cpu": [ "arm64" ], @@ -2565,9 +2580,9 @@ } }, "node_modules/@tauri-apps/cli-win32-ia32-msvc": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.0.tgz", - "integrity": "sha512-EHyQ1iwrWy1CwMalEm9z2a6L5isQ121pe7FcA2xe4VWMJp+GHSDDGvbTv/OPdkt2Lyr7DAZBpZHM6nvlHXEc4A==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.12.1.tgz", + "integrity": "sha512-baNZRBfepAJ5qNFRMwzgBk94xQmkcmTZrNDjZjnzrq2vAaTC3NAhallb/zo3hyoCGQlMS13sdovktrg46NZz6Q==", "cpu": [ "ia32" ], @@ -2582,9 +2597,9 @@ } }, "node_modules/@tauri-apps/cli-win32-x64-msvc": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.0.tgz", - "integrity": "sha512-NTpyQxkpzGmU6ceWBTY2xRIEaS0ZLbVx1HE1zTA3TY/pV3+cPoPPOs+7YScr4IMzXMtOw7tLw5LEXo5oIG3qaQ==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.12.1.tgz", + "integrity": "sha512-dIFaKslrzcbesb+bBh+E1R9km241pHyj32w6D9FPtvQeYpRUFbiCLEa/2S+S0UnIytfxepp4P+X8IerGqnXeYg==", "cpu": [ "x64" ], diff --git a/package.json b/package.json index 55ada4c73..64901ed74 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "noteconnection", - "version": "1.8.0", + "version": "1.9.0", "description": "Hierarchical Knowledge Graph Visualization System", "main": "dist/src/server.js", "bin": { @@ -52,6 +52,7 @@ "dev:vite": "vite --config vite.config.ts", "prepare:godot:bin": "node scripts/ensure-godot-sidecar.js", "verify:tauri:bin": "node scripts/validate-tauri-sidecars.js", + "verify:appimage": "node scripts/verify-appimage.js", "build:sidecar": "node scripts/copy-reader-runtime-assets.js && tsc && node scripts/build-sidecar.js && npm run prepare:godot:bin && npm run verify:tauri:bin", "build:sidecar:all": "node scripts/copy-reader-runtime-assets.js && tsc && node scripts/build-sidecar.js --all && npm run prepare:godot:bin && node scripts/validate-tauri-sidecars.js --all", "ensure:sidecar:dev": "node scripts/ensure-sidecar-ready.js", @@ -187,7 +188,7 @@ "license": "GPL-3.0-only", "type": "commonjs", "devDependencies": { - "@tauri-apps/cli": "^2.10.0", + "@tauri-apps/cli": "^2.12.1", "@types/jest": "^30.0.0", "@types/jsdom": "^21.1.7", "@types/node": "^25.0.3", diff --git a/scripts/appimage-patchelf.js b/scripts/appimage-patchelf.js new file mode 100755 index 000000000..ed0f34e85 --- /dev/null +++ b/scripts/appimage-patchelf.js @@ -0,0 +1,31 @@ +#!/usr/bin/env node + +const path = require('path'); +const { spawnSync } = require('child_process'); +const { sha256File } = require('./sidecar-build-fingerprint'); + +try { + const args = process.argv.slice(2); + const originalPatchelf = process.env.NOTE_CONNECTION_APPIMAGE_PATCHELF; + const serverSuffix = process.env.NOTE_CONNECTION_APPIMAGE_SERVER_SUFFIX; + const expectedSha256 = process.env.NOTE_CONNECTION_APPIMAGE_SERVER_SHA256; + if (!originalPatchelf || !serverSuffix || !/^[a-f0-9]{64}$/.test(expectedSha256 || '')) { + throw new Error('AppImage patchelf requires the Linux build runner environment'); + } + + if (args.length === 3 && args[0] === '--set-rpath' && path.resolve(args[2]).endsWith(`${path.sep}${serverSuffix}`)) { + if (sha256File(args[2]) !== expectedSha256) { + throw new Error(`Packaged server differs from the original pkg sidecar: ${args[2]}`); + } + // patchelf moves pkg's appended payload without updating its embedded offsets. + // AppRun supplies LD_LIBRARY_PATH; linuxdeploy still discovers dependencies. + } else { + const command = spawnSync(originalPatchelf, args, { stdio: 'inherit' }); + if (command.error) throw command.error; + if (command.signal) throw new Error(`patchelf terminated by ${command.signal}`); + process.exitCode = command.status; + } +} catch (error) { + console.error(`[appimage-patchelf] ${error.message}`); + process.exitCode = 1; +} diff --git a/scripts/run-tauri-build.js b/scripts/run-tauri-build.js index 6f34a85a6..e5b141d13 100644 --- a/scripts/run-tauri-build.js +++ b/scripts/run-tauri-build.js @@ -1,7 +1,11 @@ #!/usr/bin/env node -const { spawnSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const { execFileSync, spawnSync } = require('child_process'); const { prepareDesktopGodotPack } = require('./prepare-desktop-godot-pack'); +const { resolveHostServerBinaryName } = require('./tauri-sidecar-utils'); +const { sha256File } = require('./sidecar-build-fingerprint'); function extractFrontendBuildMode(argv) { const passthroughArgs = []; @@ -62,6 +66,21 @@ function main() { const isWindows = process.platform === 'win32'; const execCommand = isWindows ? 'cmd.exe' : 'npx'; const execArgs = isWindows ? ['/d', '/s', '/c', 'npx', ...tauriArgs] : tauriArgs; + const appImageEnvironment = {}; + if (process.platform === 'linux') { + const adapter = fs.realpathSync(path.join(__dirname, 'appimage-patchelf.js')); + const originalPatchelf = fs.realpathSync(execFileSync('which', [process.env.PATCHELF || 'patchelf'], { + encoding: 'utf8' + }).trim()); + if (originalPatchelf === adapter) throw new Error('PATCHELF must name the original patchelf executable'); + const tauriConfig = require('../src-tauri/tauri.conf.json'); + appImageEnvironment.PATCHELF = adapter; + appImageEnvironment.NOTE_CONNECTION_APPIMAGE_PATCHELF = originalPatchelf; + appImageEnvironment.NOTE_CONNECTION_APPIMAGE_SERVER_SUFFIX = path.join(`${tauriConfig.productName}.AppDir`, 'usr', 'bin', 'server'); + appImageEnvironment.NOTE_CONNECTION_APPIMAGE_SERVER_SHA256 = sha256File( + path.join(__dirname, '..', 'src-tauri', 'bin', resolveHostServerBinaryName()) + ); + } console.log(`[Tauri Build Runner] Cargo jobs: ${cargoBuildJobs}`); console.log(`[Tauri Build Runner] Cargo release opt-level: ${cargoReleaseOptLevel}`); @@ -78,6 +97,7 @@ function main() { stdio: 'inherit', env: { ...process.env, + ...appImageEnvironment, NOTE_CONNECTION_TAURI_FRONTEND_BUILD_MODE: frontendBuildMode || process.env.NOTE_CONNECTION_TAURI_FRONTEND_BUILD_MODE || '', CARGO_BUILD_JOBS: String(cargoBuildJobs), CARGO_PROFILE_RELEASE_OPT_LEVEL: cargoReleaseOptLevel, diff --git a/scripts/verify-appimage.js b/scripts/verify-appimage.js new file mode 100644 index 000000000..4733f65f6 --- /dev/null +++ b/scripts/verify-appimage.js @@ -0,0 +1,145 @@ +#!/usr/bin/env node + +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); +const { sha256File } = require('./sidecar-build-fingerprint'); + +function verifySquashfsPermissions(manifest) { + const entries = manifest.split('\n').filter((line) => /^[dl-][rwxstST-]{9}\s/.test(line)); + if (entries.length === 0) throw new Error('SquashFS manifest contains no file modes'); + for (const entry of entries) { + const mode = entry.slice(0, 10); + if (mode[0] === 'l') continue; + if (mode[7] !== 'r' || ((mode[0] === 'd' || /[xs]/.test(mode[3])) && !/[xt]/.test(mode[9]))) { + throw new Error(`SquashFS entry is inaccessible to other users: ${entry}`); + } + } +} + +function resolvePortableFile(appDir, entry) { + let pending = entry.split(path.sep); + let current = appDir; + let links = 0; + while (pending.length > 0) { + current = path.resolve(current, pending.shift()); + const relative = path.relative(appDir, current); + if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw new Error(`${entry}: path leaves the AppImage`); + } + const stat = fs.lstatSync(current); + if (stat.isSymbolicLink()) { + const target = fs.readlinkSync(current); + if (path.isAbsolute(target)) { + throw new Error(`${entry}: absolute symlink is not portable: ${target}`); + } + links += 1; + if (links > 40) throw new Error(`${entry}: symlink loop`); + pending = [...path.relative(appDir, path.dirname(current)).split(path.sep), ...target.split(path.sep), ...pending]; + current = appDir; + } else if (stat.isDirectory() && (stat.mode & 0o001) === 0) { + throw new Error(`${entry}: directory is not traversable by other users: ${current}`); + } + } + const stat = fs.statSync(current); + if (!stat.isFile() || (stat.mode & 0o004) === 0) { + throw new Error(`${entry}: expected a file readable by other users`); + } + fs.accessSync(current, fs.constants.R_OK); + return current; +} + +function verifyPortableExecutable(appDir, entry) { + const executable = resolvePortableFile(appDir, entry); + if ((fs.statSync(executable).mode & 0o001) === 0) { + throw new Error(`${entry}: not executable by other users`); + } + fs.accessSync(executable, fs.constants.X_OK); + return executable; +} + +function verifyAppDir(appDirPath, sourceServerPath) { + const appDir = fs.realpathSync(appDirPath); + const iconPath = resolvePortableFile(appDir, '.DirIcon'); + verifyPortableExecutable(appDir, 'AppRun'); + // Tauri's linuxdeploy launcher delegates to this bundled executable. + verifyPortableExecutable(appDir, 'AppRun.wrapped'); + const desktopEntries = fs.readdirSync(appDir).filter((entry) => entry.endsWith('.desktop')); + if (desktopEntries.length !== 1) throw new Error('Expected exactly one root desktop entry'); + const desktopPath = resolvePortableFile(appDir, desktopEntries[0]); + resolvePortableFile(appDir, `${path.basename(desktopEntries[0], '.desktop')}.png`); + const desktopFields = new Map(); + let section = ''; + for (const line of fs.readFileSync(desktopPath, 'utf8').split(/\r?\n/)) { + if (line.startsWith('[')) section = line; + if (section !== '[Desktop Entry]' || line.startsWith('#')) continue; + const separator = line.indexOf('='); + if (separator > 0) desktopFields.set(line.slice(0, separator), line.slice(separator + 1)); + } + for (const key of ['Name', 'Exec', 'Icon']) { + if (!desktopFields.get(key)) throw new Error(`Desktop entry is missing ${key}`); + } + if (desktopFields.get('Type') !== 'Application') throw new Error('Desktop Type must be Application'); + // Tauri generates a basename, optionally followed by desktop field-code arguments. + const command = desktopFields.get('Exec').match(/^([A-Za-z0-9._+-]+)(?:\s|$)/); + if (!command) throw new Error('Desktop Exec must name a bundled executable'); + const executablePath = verifyPortableExecutable(appDir, path.join('usr', 'bin', command[1])); + const serverPath = verifyPortableExecutable(appDir, path.join('usr', 'bin', 'server')); + const serverSha256 = sha256File(serverPath); + if (serverSha256 !== sha256File(sourceServerPath)) { + throw new Error('Packaged server differs from the original pkg sidecar; packaging must preserve its appended payload'); + } + const iconName = desktopFields.get('Icon'); + if (!/^[A-Za-z0-9._+-]+$/.test(iconName)) throw new Error('Desktop Icon must name a bundled theme icon'); + const iconTheme = path.join(appDir, 'usr', 'share', 'icons', 'hicolor'); + const themeIcons = fs.readdirSync(iconTheme).flatMap((size) => ['png', 'svg'].map((extension) => + path.join('usr', 'share', 'icons', 'hicolor', size, 'apps', `${iconName}.${extension}`) + )).filter((entry) => fs.existsSync(path.join(appDir, entry))); + if (themeIcons.length === 0) throw new Error(`Desktop Icon has no bundled theme image: ${iconName}`); + for (const entry of themeIcons) resolvePortableFile(appDir, entry); + return { desktopPath, iconPath, executablePath, serverSha256, name: desktopFields.get('Name') }; +} + +function verifyAppImage(artifactPath, sourceServerPath) { + const artifact = fs.realpathSync(artifactPath); + const sourceServer = fs.realpathSync(sourceServerPath); + const extractionDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'noteconnection-appimage-')); + try { + const offset = execFileSync(artifact, ['--appimage-offset'], { encoding: 'utf8', timeout: 30000 }).trim(); + if (!/^\d+$/.test(offset)) throw new Error('AppImage runtime returned an invalid SquashFS offset'); + verifySquashfsPermissions(execFileSync('unsquashfs', ['-lln', '-o', offset, artifact], { + encoding: 'utf8', + maxBuffer: 16 * 1024 * 1024, + timeout: 30000, + })); + // Runtime --appimage-extract can replace stored directory modes with 0700. + // unsquashfs preserves them so relocation tests use the packaged permissions. + const appDir = path.join(extractionDirectory, 'squashfs-root'); + execFileSync('unsquashfs', ['-no-progress', '-no-xattrs', '-d', appDir, '-o', offset, artifact], { + stdio: ['ignore', 'ignore', 'pipe'], + timeout: 120000, + }); + const integration = verifyAppDir(appDir, sourceServer); + execFileSync('desktop-file-validate', [integration.desktopPath], { stdio: 'pipe', timeout: 30000 }); + execFileSync('gdk-pixbuf-thumbnailer', ['-s', '64', integration.iconPath, path.join(extractionDirectory, 'icon.png')], { + stdio: 'pipe', + timeout: 30000, + }); + return { artifact, name: integration.name, executable: path.basename(integration.executablePath), serverSha256: integration.serverSha256 }; + } finally { + fs.rmSync(extractionDirectory, { recursive: true, force: true }); + } +} + +if (require.main === module) { + try { + if (process.argv.length !== 4) throw new Error('Usage: node scripts/verify-appimage.js '); + console.log(`[verify-appimage] Passed: ${JSON.stringify(verifyAppImage(process.argv[2], process.argv[3]))}`); + } catch (error) { + console.error(`[verify-appimage] ${error.message}`); + process.exitCode = 1; + } +} + +module.exports = { verifyAppDir, verifyAppImage, verifySquashfsPermissions }; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8e2cd9ad8..33f135fca 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2873,7 +2873,10 @@ pub fn run() { "about" => { println!("Action: About"); let _ = tauri_plugin_dialog::DialogExt::dialog(app_handle) - .message("NoteConnection v1.6.0\n\nDeveloped by Jacob\nGitHub: https://github.com/Jacobinwwey") + .message(format!( + "NoteConnection v{}\n\nDeveloped by Jacob\nGitHub: https://github.com/Jacobinwwey", + app_handle.package_info().version + )) .title("About NoteConnection") .show(|_| {}); } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 728f59a1e..ee1f7a240 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "NoteConnection", - "version": "1.8.0", + "version": "1.9.0", "identifier": "com.jacobinwwey.noteconnection", "build": { "beforeDevCommand": "", diff --git a/src/appimage.patchelf.test.ts b/src/appimage.patchelf.test.ts new file mode 100644 index 000000000..3ff5b2003 --- /dev/null +++ b/src/appimage.patchelf.test.ts @@ -0,0 +1,89 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { spawnSync } from 'child_process'; +const { sha256File } = require('../scripts/sidecar-build-fingerprint'); + +const describeLinux = process.platform === 'linux' ? describe : describe.skip; + +describeLinux('AppImage pkg sidecar patchelf boundary', () => { + let directory: string; + let server: string; + let transcript: string; + let buildEnvironment: NodeJS.ProcessEnv; + const adapter = path.resolve(__dirname, '../scripts/appimage-patchelf.js'); + + beforeEach(() => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), 'appimage patchelf ')); + server = path.join(directory, 'NoteConnection.AppDir/usr/bin/server'); + transcript = path.join(directory, 'patchelf-calls.jsonl'); + fs.mkdirSync(path.dirname(server), { recursive: true }); + fs.writeFileSync(server, Buffer.from('\x7fELF embedded offsets\0appended pkg payload')); + const originalPatchelf = path.join(directory, 'actual-patchelf'); + fs.writeFileSync(originalPatchelf, `#!${process.execPath}\n +const fs = require('fs'); +const args = process.argv.slice(2); +fs.appendFileSync(process.env.APPIMAGE_PATCHELF_TEST_LOG, JSON.stringify(args) + '\\n'); +if (args[0] === '--set-rpath') fs.appendFileSync(args[2], ' ELF rewrite'); +process.stdout.write('original stdout\\n'); +process.stderr.write('original stderr\\n'); +process.exit(args[0] === '--fail' ? 23 : 0); +`, { mode: 0o755 }); + buildEnvironment = { + ...process.env, + NOTE_CONNECTION_APPIMAGE_PATCHELF: originalPatchelf, + NOTE_CONNECTION_APPIMAGE_SERVER_SUFFIX: 'NoteConnection.AppDir/usr/bin/server', + NOTE_CONNECTION_APPIMAGE_SERVER_SHA256: sha256File(server), + APPIMAGE_PATCHELF_TEST_LOG: transcript, + }; + }); + + afterEach(() => fs.rmSync(directory, { recursive: true, force: true })); + + test('preserves the complete pkg sidecar through repeated RPATH writes', () => { + for (let scan = 0; scan < 2; scan += 1) { + const command = spawnSync(adapter, ['--set-rpath', '$ORIGIN/../lib', server], { env: buildEnvironment, encoding: 'utf8' }); + expect(command.status).toBe(0); + expect(command.stderr).toBe(''); + } + expect(sha256File(server)).toBe(buildEnvironment.NOTE_CONNECTION_APPIMAGE_SERVER_SHA256); + expect(fs.existsSync(transcript)).toBe(false); + }); + + test('forwards read queries and preserves stdout, stderr, and argument boundaries', () => { + const args = ['--print-rpath', server]; + const command = spawnSync(adapter, args, { env: buildEnvironment, encoding: 'utf8' }); + expect(command.status).toBe(0); + expect(command.stdout).toBe('original stdout\n'); + expect(command.stderr).toBe('original stderr\n'); + expect(JSON.parse(fs.readFileSync(transcript, 'utf8'))).toEqual(args); + expect(sha256File(server)).toBe(buildEnvironment.NOTE_CONNECTION_APPIMAGE_SERVER_SHA256); + }); + + test.each(['other.AppDir/usr/bin/server', 'usr/bin/server', 'NoteConnection.AppDir/usr/bin/npm', 'NoteConnection.AppDir/usr/lib/libexample.so'])( + 'forwards RPATH writes for %s', (relative) => { + const executable = path.join(directory, relative); + fs.mkdirSync(path.dirname(executable), { recursive: true }); + fs.writeFileSync(executable, 'other ELF'); + const args = ['--set-rpath', '$ORIGIN/path with spaces', executable]; + const command = spawnSync(adapter, args, { env: buildEnvironment, encoding: 'utf8' }); + expect(command.status).toBe(0); + expect(JSON.parse(fs.readFileSync(transcript, 'utf8'))).toEqual(args); + expect(fs.readFileSync(executable, 'utf8')).toBe('other ELF ELF rewrite'); + } + ); + + test('rejects an already changed packaged server without rewriting it again', () => { + fs.appendFileSync(server, 'changed'); + const changedSha256 = sha256File(server); + const command = spawnSync(adapter, ['--set-rpath', '$ORIGIN/../lib', server], { env: buildEnvironment, encoding: 'utf8' }); + expect(command.status).toBe(1); + expect(command.stderr).toMatch(/differs from the original pkg sidecar/); + expect(sha256File(server)).toBe(changedSha256); + expect(fs.existsSync(transcript)).toBe(false); + }); + + test('preserves a delegated failure exit status', () => { + expect(spawnSync(adapter, ['--fail'], { env: buildEnvironment }).status).toBe(23); + }); +}); diff --git a/src/appimage.portability.test.ts b/src/appimage.portability.test.ts new file mode 100644 index 000000000..dfcbc1643 --- /dev/null +++ b/src/appimage.portability.test.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +const { verifyAppDir, verifySquashfsPermissions } = require('../scripts/verify-appimage'); +const { sha256File } = require('../scripts/sidecar-build-fingerprint'); + +describe('stored AppImage permissions', () => { + test('accepts portable files, directories, and symlinks regardless of stored owner', () => { + expect(() => verifySquashfsPermissions([ + 'drwxr-xr-x 0/0 64 2026-10-03 00:00 squashfs-root', + '-rwxr-xr-x 0/0 64 2026-10-03 00:00 squashfs-root/AppRun.wrapped', + '-rw-r--r-- 1001/1001 64 2026-10-03 00:00 squashfs-root/NoteConnection.png', + 'lrwxrwxrwx 0/0 18 2026-10-03 00:00 squashfs-root/.DirIcon -> NoteConnection.png', + ].join('\n'))).not.toThrow(); + }); + + test.each(['-rwxrwx---', '-rwxr--r--', '-rw-rw----', 'drwx------', 'drwxr--r--'])( + 'rejects stored mode %s even when the build owner could use it', (mode) => { + expect(() => verifySquashfsPermissions(`${mode} 0/0 64 2026-10-03 00:00 squashfs-root/entry`)) + .toThrow(/inaccessible to other users/); + } + ); + + test('rejects missing manifest output', () => { + expect(() => verifySquashfsPermissions('')).toThrow(/contains no file modes/); + }); +}); + +const describeLinux = process.platform === 'linux' ? describe : describe.skip; + +describeLinux('AppImage desktop integration portability', () => { + let directory: string; + let appDir: string; + let sourceServer: string; + const desktop = '[Desktop Entry]\nName=NoteConnection\nType=Application\nExec=npm %U\nIcon=npm\nTerminal=false\n'; + + beforeEach(() => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), 'appimage portability ')); + appDir = path.join(directory, 'NoteConnection.AppDir'); + fs.mkdirSync(path.join(appDir, 'usr/bin'), { recursive: true }); + fs.mkdirSync(path.join(appDir, 'usr/share/applications'), { recursive: true }); + fs.mkdirSync(path.join(appDir, 'usr/share/icons/hicolor/128x128/apps'), { recursive: true }); + fs.writeFileSync(path.join(appDir, 'AppRun'), '#!/bin/sh\nexec "$APPDIR/AppRun.wrapped" "$@"\n', { mode: 0o755 }); + fs.writeFileSync(path.join(appDir, 'AppRun.wrapped'), '#!/bin/sh\nexit 0\n', { mode: 0o755 }); + fs.writeFileSync(path.join(appDir, 'usr/bin/npm'), '#!/bin/sh\nexit 0\n', { mode: 0o755 }); + sourceServer = path.join(directory, 'original-server'); + fs.writeFileSync(sourceServer, Buffer.from('\x7fELF embedded offsets\0pkg payload'), { mode: 0o755 }); + fs.copyFileSync(sourceServer, path.join(appDir, 'usr/bin/server')); + fs.writeFileSync(path.join(appDir, 'usr/share/applications/NoteConnection.desktop'), desktop); + fs.writeFileSync(path.join(appDir, 'NoteConnection.png'), 'icon fixture'); + fs.symlinkSync('usr/share/applications/NoteConnection.desktop', path.join(appDir, 'NoteConnection.desktop')); + fs.symlinkSync('NoteConnection.png', path.join(appDir, '.DirIcon')); + fs.symlinkSync('../../../../../../NoteConnection.png', path.join(appDir, 'usr/share/icons/hicolor/128x128/apps/npm.png')); + }); + + afterEach(() => fs.rmSync(directory, { recursive: true, force: true })); + + test('accepts relative integration links after moving the complete image', () => { + const relocated = path.join(directory, 'relocated image'); + fs.renameSync(appDir, relocated); + expect(verifyAppDir(relocated, sourceServer)).toEqual({ + desktopPath: path.join(relocated, 'usr/share/applications/NoteConnection.desktop'), + iconPath: path.join(relocated, 'NoteConnection.png'), + executablePath: path.join(relocated, 'usr/bin/npm'), + serverSha256: sha256File(sourceServer), + name: 'NoteConnection', + }); + }); + + test('rejects the original absolute .DirIcon link even while its build-host target exists', () => { + fs.unlinkSync(path.join(appDir, '.DirIcon')); + fs.symlinkSync(path.join(appDir, 'NoteConnection.png'), path.join(appDir, '.DirIcon')); + expect(fs.readFileSync(path.join(appDir, '.DirIcon'), 'utf8')).toBe('icon fixture'); + expect(() => verifyAppDir(appDir, sourceServer)).toThrow(/\.DirIcon: absolute symlink/); + }); + + test.each(['.DirIcon', 'NoteConnection.desktop', 'AppRun', 'AppRun.wrapped', 'usr/bin/npm', 'usr/bin/server'])( + 'rejects dangling integration entry %s', (entry) => { + fs.unlinkSync(path.join(appDir, entry)); + fs.symlinkSync('missing-file', path.join(appDir, entry)); + expect(() => verifyAppDir(appDir, sourceServer)).toThrow(/ENOENT/); + } + ); + + test('rejects a relative link outside the image even when the target exists', () => { + fs.writeFileSync(path.join(directory, 'outside.png'), 'external icon'); + fs.unlinkSync(path.join(appDir, '.DirIcon')); + fs.symlinkSync('../outside.png', path.join(appDir, '.DirIcon')); + expect(() => verifyAppDir(appDir, sourceServer)).toThrow(/path leaves the AppImage/); + }); + + test('rejects an absolute link hidden in a relative icon chain', () => { + fs.unlinkSync(path.join(appDir, 'NoteConnection.png')); + fs.writeFileSync(path.join(directory, 'outside.png'), 'external icon'); + fs.symlinkSync(path.join(directory, 'outside.png'), path.join(appDir, 'NoteConnection.png')); + expect(() => verifyAppDir(appDir, sourceServer)).toThrow(/absolute symlink/); + }); + + test('rejects a symlinked ancestor directory leading outside the image', () => { + fs.renameSync(path.join(appDir, 'usr'), path.join(directory, 'usr')); + fs.symlinkSync('../usr', path.join(appDir, 'usr')); + expect(() => verifyAppDir(appDir, sourceServer)).toThrow(/path leaves the AppImage/); + }); + + test('rejects a symlink cycle', () => { + fs.unlinkSync(path.join(appDir, '.DirIcon')); + fs.symlinkSync('.DirIcon', path.join(appDir, '.DirIcon')); + expect(() => verifyAppDir(appDir, sourceServer)).toThrow(/symlink loop/); + }); + + test.each(['AppRun', 'AppRun.wrapped', 'usr/bin/npm'])( + 'rejects %s when only the build owner and group can execute it', (entry) => { + fs.chmodSync(path.join(appDir, entry), 0o770); + expect(() => verifyAppDir(appDir, sourceServer)).toThrow(/readable by other users|executable by other users/); + } + ); + + test('rejects an unreadable icon', () => { + fs.chmodSync(path.join(appDir, 'NoteConnection.png'), 0o600); + expect(() => verifyAppDir(appDir, sourceServer)).toThrow(/readable by other users/); + }); + + test('rejects a sidecar whose appended payload moved during packaging', () => { + const bytes = fs.readFileSync(sourceServer); + fs.writeFileSync(path.join(appDir, 'usr/bin/server'), Buffer.concat([bytes.subarray(0, 21), Buffer.alloc(4096), bytes.subarray(21)])); + expect(() => verifyAppDir(appDir, sourceServer)).toThrow(/Packaged server differs/); + }); + + test('rejects same-size sidecar corruption', () => { + const bytes = fs.readFileSync(sourceServer); + bytes[bytes.length - 1] ^= 1; + fs.writeFileSync(path.join(appDir, 'usr/bin/server'), bytes); + expect(() => verifyAppDir(appDir, sourceServer)).toThrow(/Packaged server differs/); + }); + + test.each([ + ['Name=NoteConnection\n', '', /missing Name/], + ['Type=Application', 'Type=Link', /Type must be Application/], + ['Exec=npm %U', 'Exec=missing', /ENOENT/], + ['Exec=npm %U', 'Exec=/usr/bin/npm', /must name a bundled executable/], + ['Icon=npm', 'Icon=missing', /no bundled theme image/], + ])('rejects invalid desktop metadata (%s)', (before, after, message) => { + fs.writeFileSync(path.join(appDir, 'usr/share/applications/NoteConnection.desktop'), desktop.replace(before as string, after as string)); + expect(() => verifyAppDir(appDir, sourceServer)).toThrow(message as RegExp); + }); +}); diff --git a/src/backend/ResourceIdentity.test.ts b/src/backend/ResourceIdentity.test.ts index 5ed2c8108..d298d5e62 100644 --- a/src/backend/ResourceIdentity.test.ts +++ b/src/backend/ResourceIdentity.test.ts @@ -1,3 +1,4 @@ +import * as path from 'path'; import { assertUniqueLegacyResourceIds, createResourceIdentity, @@ -6,14 +7,16 @@ import { describe('Resource identity boundary', () => { test('normalizes workspace-relative paths to POSIX separators', () => { - const root = 'C:\\workspace\\Knowledge_Base'; - const filePath = 'C:\\workspace\\Knowledge_Base\\algebra\\index.md'; + const root = path.resolve('workspace', 'Knowledge_Base'); + const filePath = path.join(root, 'algebra', 'index.md'); expect(normalizeResourceRelativePath(root, filePath)).toBe('algebra/index.md'); }); test('rejects a path outside the workspace root', () => { - expect(() => normalizeResourceRelativePath('C:\\workspace\\Knowledge_Base', 'C:\\workspace\\other.md')) + const root = path.resolve('workspace', 'Knowledge_Base'); + + expect(() => normalizeResourceRelativePath(root, path.resolve(root, '..', 'other.md'))) .toThrow(/outside workspace root/i); }); diff --git a/src/frontend/simulationWorker.js b/src/frontend/simulationWorker.js index 2ea7e3df0..639fa189b 100644 --- a/src/frontend/simulationWorker.js +++ b/src/frontend/simulationWorker.js @@ -1,4 +1,4 @@ -importScripts("https://d3js.org/d3.v6.min.js", "libs/gpu-browser.min.js", "layout_gpu.js"); +importScripts("libs/d3.v7.min.js", "libs/gpu-browser.min.js", "layout_gpu.js"); let simulation; let nodes = []; diff --git a/src/markdown/MarkdownGateway.test.ts b/src/markdown/MarkdownGateway.test.ts index 8c692be43..2202c2e85 100644 --- a/src/markdown/MarkdownGateway.test.ts +++ b/src/markdown/MarkdownGateway.test.ts @@ -2,6 +2,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { MarkdownGateway } from './MarkdownGateway'; +const { resolveHostMarkdownWorkerBinaryName } = require('../../scripts/tauri-sidecar-utils'); class TempDir { public readonly path: string; @@ -217,4 +218,62 @@ describe('MarkdownGateway', () => { } } }); + + const testUnix = process.platform === 'win32' ? test.skip : test; + testUnix.each(['beside-server', 'Resources', 'explicit-override', 'build-filename'])( + 'buildIndex discovers the %s worker and preserves lookup priority', async (location) => { + const installedDirectory = ctx.temp.mkdir('installed/bin'); + const originalExecPath = process.execPath; + const originalWorkerPath = process.env.NOTE_CONNECTION_MARKDOWN_WORKER_PATH; + const writeWorker = (workerPath: string, anchorId: string) => { + fs.mkdirSync(path.dirname(workerPath), { recursive: true }); + fs.writeFileSync(workerPath, `#!/usr/bin/env node +const fs = require('fs'); +let input = ''; +process.stdin.on('data', chunk => { input += chunk; }); +process.stdin.on('end', () => { + const request = JSON.parse(input); + if (request.kind !== 'build_index') process.exit(1); + const content = fs.readFileSync(request.filePath); + const block = { id: 0, type: 'heading', startByte: 0, endByte: content.length, startLine: 1, endLine: 1, anchorId: ${JSON.stringify(anchorId)} }; + process.stdout.write(JSON.stringify({ ok: true, index: { totalBytes: content.length, totalLines: 1, blocks: [block], anchors: [], wikiLinks: [] } })); +}); +`, { mode: 0o755 }); + }; + try { + process.execPath = path.join(installedDirectory, 'server'); + delete process.env.NOTE_CONNECTION_MARKDOWN_WORKER_PATH; + const installedWorker = location === 'Resources' + ? path.join(installedDirectory, '..', 'Resources', 'markdown-worker') + : path.join(installedDirectory, 'markdown-worker'); + writeWorker(installedWorker, 'installed-worker'); + if (location === 'explicit-override' || location === 'build-filename') { + writeWorker(path.join(ctx.projectRoot, 'src-tauri', 'bin', resolveHostMarkdownWorkerBinaryName()), 'build-worker'); + } + if (location === 'explicit-override') { + const explicitWorker = path.join(ctx.temp.path, 'explicit-worker'); + writeWorker(explicitWorker, 'explicit-worker'); + process.env.NOTE_CONNECTION_MARKDOWN_WORKER_PATH = explicitWorker; + } + const filePath = ctx.temp.file(path.join('project', 'Knowledge_Base', 'installed.md'), '# Installed markdown worker'); + const index = await ctx.gateway.buildIndex({ filePath }, { + markdownEngine: 'pulldown', + chunkBlockSize: 8, + prefetchBlocks: 2, + indexCacheTtlSec: 1800, + maxDocBytes: 32 * 1024 * 1024, + }); + expect(index.engine).toBe('pulldown'); + expect(index.fallbackReason).toBeUndefined(); + const chunk = await ctx.gateway.getChunk({ indexId: index.indexId, startBlock: 0, blockCount: 1 }); + const expectedAnchor = location === 'explicit-override' ? 'explicit-worker' + : location === 'build-filename' ? 'build-worker' : 'installed-worker'; + expect(chunk.blocks[0].anchorId).toBe(expectedAnchor); + } finally { + process.execPath = originalExecPath; + if (originalWorkerPath === undefined) delete process.env.NOTE_CONNECTION_MARKDOWN_WORKER_PATH; + else process.env.NOTE_CONNECTION_MARKDOWN_WORKER_PATH = originalWorkerPath; + } + } + ); }); diff --git a/src/markdown/MarkdownGateway.ts b/src/markdown/MarkdownGateway.ts index 3511c14b4..c17c3a139 100644 --- a/src/markdown/MarkdownGateway.ts +++ b/src/markdown/MarkdownGateway.ts @@ -949,6 +949,10 @@ export class MarkdownGateway { candidates.push(path.join(path.dirname(process.execPath), binaryName)); candidates.push(path.join(path.dirname(process.execPath), '..', 'Resources', binaryName)); } + // Tauri removes the target triple from installed external binary names. + const installedBinaryName = process.platform === 'win32' ? 'markdown-worker.exe' : 'markdown-worker'; + candidates.push(path.join(path.dirname(process.execPath), installedBinaryName)); + candidates.push(path.join(path.dirname(process.execPath), '..', 'Resources', installedBinaryName)); for (const candidate of candidates) { try { diff --git a/src/mobile.identity.corpus.test.ts b/src/mobile.identity.corpus.test.ts index 32b3969fa..7d580c564 100644 --- a/src/mobile.identity.corpus.test.ts +++ b/src/mobile.identity.corpus.test.ts @@ -1,7 +1,6 @@ import { createResourceIdentity, assertUniqueLegacyResourceIds, - normalizeResourceRelativePath, } from './backend/ResourceIdentity'; import { Graph } from './core/Graph'; @@ -10,7 +9,7 @@ describe('mobile identity migration corpus', () => { const first = createResourceIdentity('notes/first.md', 'First', 'same body'); const second = createResourceIdentity('notes/second.md', 'Second', 'same body'); const fromWindows = createResourceIdentity( - normalizeResourceRelativePath('C:\\workspace\\Knowledge_Base', 'C:\\workspace\\Knowledge_Base\\Notes\\Cafe\u0301.md'), + 'Notes\\Cafe\u0301.md', 'Cafe', 'body', ); diff --git a/src/notemd.workflow.pipeline.test.ts b/src/notemd.workflow.pipeline.test.ts index acf527cae..e0eba947a 100644 --- a/src/notemd.workflow.pipeline.test.ts +++ b/src/notemd.workflow.pipeline.test.ts @@ -85,7 +85,7 @@ describe('Notemd Workflow Pipeline', () => { // Summary should have valid numbers expect(typeof result.summary.conceptsExtracted).toBe('number'); expect(typeof result.summary.totalElapsedMs).toBe('number'); - expect(result.summary.totalElapsedMs).toBeGreaterThan(0); + expect(result.summary.totalElapsedMs).toBeGreaterThanOrEqual(0); }); test('runWorkflow with addWikiLinks injects wiki-links and writes _wikified file', async () => { diff --git a/src/server.migration.test.ts b/src/server.migration.test.ts index d045e2bda..8e2596bc2 100644 --- a/src/server.migration.test.ts +++ b/src/server.migration.test.ts @@ -237,11 +237,17 @@ function requestBinary( parsed = text; } } - resolve({ + const response = { status: res.statusCode || 0, body: parsed, headers: res.headers - }); + }; + // A 413 response can precede upload completion; teardown must wait until the request finishes writing. + if (req.writableFinished) { + resolve(response); + } else { + req.once('finish', () => resolve(response)); + } }); } ); diff --git a/src/simulation.worker.offline.test.ts b/src/simulation.worker.offline.test.ts new file mode 100644 index 000000000..bfcec44ff --- /dev/null +++ b/src/simulation.worker.offline.test.ts @@ -0,0 +1,73 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as vm from 'vm'; + +type Position = { id: string; x: number; y: number }; +type TickMessage = { type: 'tick'; nodes: Position[]; alpha: number; tickMode: string }; + +describe('offline graph simulation worker', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + test.each(['force', 'dag'])('computes %s layout using only packaged dependencies', (mode) => { + const frontend = path.join(__dirname, 'frontend'); + const ticks: TickMessage[] = []; + const worker = vm.createContext({ + console: { log() {}, warn() {}, error() {} }, + Date, + performance: { now: () => Date.now() }, + setTimeout, + clearTimeout, + setInterval, + clearInterval, + postMessage(message: TickMessage) { + if (message.type === 'tick') ticks.push(message); + }, + }); + worker.self = worker; + worker.importScripts = (...scripts: string[]) => { + for (const script of scripts) { + const url = new URL(script, 'http://appimage.local/simulationWorker.js'); + if (url.origin !== 'http://appimage.local') { + throw new Error(`External network is disabled: ${url.href}`); + } + const filename = path.join(frontend, url.pathname); + vm.runInContext(fs.readFileSync(filename, 'utf8'), worker, { filename }); + } + }; + vm.runInContext(fs.readFileSync(path.join(frontend, 'simulationWorker.js'), 'utf8'), worker); + worker.onmessage({ data: { type: 'init', payload: { + nodes: [{ id: 'start', rank: 0 }, { id: 'middle', rank: 1 }, { id: 'end', rank: 2 }], + links: [{ source: 'start', target: 'middle' }, { source: 'middle', target: 'end' }], + width: 800, + height: 600, + settings: { gpuRendering: false }, + } } }); + if (mode === 'dag') worker.onmessage({ data: { type: 'updateLayout', payload: { mode, width: 800 } } }); + jest.advanceTimersByTime(2500); + worker.onmessage({ data: { type: 'stop' } }); + + expect(ticks.length).toBeGreaterThan(5); + const first = ticks[0]; + const last = ticks[ticks.length - 1]; + expect(last.tickMode).toBe('full'); + expect(last.nodes.map(node => node.id)).toEqual(['start', 'middle', 'end']); + expect(Number.isFinite(last.alpha)).toBe(true); + expect(last.alpha).toBeLessThan(first.alpha); + for (const node of last.nodes) { + expect(Number.isFinite(node.x)).toBe(true); + expect(Number.isFinite(node.y)).toBe(true); + for (const other of last.nodes.filter(other => other.id !== node.id)) { + expect(Math.hypot(node.x - other.x, node.y - other.y)).toBeGreaterThan(1); + } + } + expect(last.nodes.some((node, index) => Math.hypot(node.x - first.nodes[index].x, node.y - first.nodes[index].y) > 1)).toBe(true); + if (mode === 'dag') { + expect(last.nodes[0].y).toBeLessThan(last.nodes[1].y); + expect(last.nodes[1].y).toBeLessThan(last.nodes[2].y); + } + }); +});