fix(desktop): start Gauzy Server from a read-only install and load sharp on Linux

Merged
EV
15d ago
+847-80

Summary

Two Linux start-up failures of the desktop apps:

  • Gauzy Server no longer needs to write into its install directory to tell its web UI which API to call. A .deb install (root-owned /opt/Gauzy Server) or an AppImage (read-only mount) now gets through the setup; the UI server inserts the API URL into index.html as it serves it.
  • sharp loads on Linux in Ever Gauzy Desktop, Gauzy Server and Gauzy API Server: sharp and its versioned libvips are unpacked from app.asar.

Why

  • #7735: ServerConfig.update() rewrote resources/data/ui/index.html in the install directory. ReadWriteFile.read() answers undefined when that directory is not writable, and update() then failed with Cannot read properties of undefined (reading 'replace'), rethrown by initializeConfig, which stopped the setup.
  • #9167: electron-builder's smart unpack only takes a package out of app.asar when one of its files ends in .dll/.exe/.dylib/.so/.node. sharp's Linux libvips is versioned (@img/sharp-libvips-linux-x64/lib/libvips-cpp.so.42), so it stayed inside app.asar while sharp's .node addon was unpacked, and the addon could not dlopen it (ERR_DLOPEN_FAILED). macOS (.dylib) and Windows (.dll) were already unpacked. The desktop timer does not package sharp, which is why it did not reproduce there.

What changed

packages/desktop-lib

  • New config/ui-runtime-config.ts: plain string functions with no import. buildUiRuntimeConfigScript(apiUrl) builds the window._env = { api: … } script, with the URL written as a JSON string and < escaped, so no value can end the string or the script early (before, the URL was interpolated raw between single quotes). applyUiRuntimeConfig(html, apiUrl) removes any earlier config, in the format of any release and including the duplicates older releases could leave, and inserts the current one before </body>. Applying it twice gives the same page.
  • ServerConfig.update() uses it. A writable install still gets the script written into its file, as before (kept as the fallback). An unwritable one is skipped with a warning, an unchanged file is not rewritten, and an error here is logged instead of stopping the start, because the UI server inserts the script anyway. The three private helpers it replaces are gone; update() keeps its signature.
  • UiService hands the script to the UI server process in UI_RUNTIME_CONFIG_SCRIPT, built from config.apiUrl at every start and restart, so a changed API port or host reaches the UI after "Save", the same way UI_HOST does.
  • IReadWriteFile.read() is typed string | undefined, which is what it already returned.

apps/server (Gauzy Server's UI server, preload/)

  • New index-html.ts: isIndexHtmlRequest (GET/HEAD of / or /index.html, path compared as sent, so .. and escapes stay with serve-static's checks), insertIntoIndexHtml (does not add a second copy when a writable install's file already has the script) and serveIndexHtml.
  • createStaticFileHandler(root, { indexHtmlSnippet }): with a snippet, the start page is served with it and Cache-Control: no-cache, and the file on disk is only read. Every other file, and the start page when it cannot be read (404 when missing), still goes through serve-static exactly as before. StaticFileServer reads the snippet from UI_RUNTIME_CONFIG_SCRIPT.
  • The UI server runs in a plain Node process forked from Electron's, which cannot load @gauzy/desktop-lib (it needs Electron). So desktop-lib builds the script and this side only places it, and the variable's name and the script's placement are pinned in both packages' specs.

apps/desktop, apps/server, apps/server-api (src/package.json)

  • build.asarUnpack gains **/node_modules/sharp/**/* and **/node_modules/@img/**/*, the patterns sharp's Electron docs give (the **/ prefix also covers a nested copy). desktop-timer, agent and server-mcp do not package sharp and are unchanged.

tools/electron/asar-unpack.test.js (new) + .github/workflows/static-checks.yml

  • A node:test check, needing no install, that finds every electron-builder app whose packaged dependencies reach sharp, directly or through a workspace package (@gauzy/core, @gauzy/plugin-docs), and fails when its asarUnpack lacks the patterns. A future native module with the same problem is one more entry in NATIVE_UNPACK_RULES. A control case fails if the scan stops finding desktop/server/server-api. It runs as a step of the static checks job, next to the other tools/electron cases.

Flags/settings and their defaults

None added. Writable installs keep their file rewrite. The one visible difference for them is that index.html (only that file) is now served with Cache-Control: no-cache instead of max-age=3600, so a changed API URL shows on the next page load instead of up to an hour later.

Tests

All run on Windows, Node 26.10.0:

  • NODE_OPTIONS=--experimental-vm-modules npx jest -c packages/desktop-lib/jest.config.ts --rootDir packages/desktop-lib: 15 suites, 215 tests passed. New: ui-runtime-config.spec.ts (escaping verified by running the script in node:vm, a legacy-format and duplicate replacement, idempotence, URL change, other inline scripts kept, no </body>), ui-service.spec.ts (script handed over at start, follows the host and port, re-read on restart), and server-config.spec.ts ServerConfig.update (unwritable directory: no throw and no write; writable install still written, as a control; unchanged file not rewritten; other errors logged, not thrown).
  • npx jest -c apps/server/jest.config.ts --rootDir apps/server --selectProjects server-preload: 2 suites, 33 tests passed. New: index-html.spec.ts, plus HTTP cases in static-file-server.spec.ts: /, /index.html and /?lang=fr served with the script and no-cache; HEAD; the file on disk unchanged; other files and a sub-directory index.html untouched; .. and NUL still refused; a missing start page still 404. The existing traversal/NUL cases pass unchanged.
  • node --test tools/electron/asar-unpack.test.js tools/electron/app-builder-lib-patch.test.js: 7 passed, 1 skipped (the patch case that skips from app-builder-lib 26.18.0 on). Negative control: with apps/server/src/package.json at develop, the new check fails and names the missing patterns.
  • npx tsc -p packages/desktop-lib/tsconfig.lib.json --noEmit: clean. The apps/server/src/preload sources and specs type-check with the options of tsconfig.electron.json. tsc -p apps/server/tsconfig.electron.json reports only the missing generated environments/environment.ts, the same as on develop.
  • ESLint on the touched files: no new findings. The remaining ones in touched files were already there (no-inferrable-types on UiService's constructor, unused catch (e) in ReadWriteFile).
  • Smoke test of the real UI server entry (preload/ui-server.js compiled, index.html marked read-only, UI_RUNTIME_CONFIG_SCRIPT built by desktop-lib for http://192.168.1.20:3000): GET / returned 200 with the script before </body> and Cache-Control: no-cache, main.js kept max-age=3600, the file on disk was unchanged, and "Listening on port" (the success message UiService waits for) was printed.

Manual verification still needed

I could not run these here (Windows workstation, no Linux packaging):

  • #7735: install the Gauzy Server .deb on Ubuntu (or run the AppImage), go through the setup, and confirm the server starts and the web UI at :4200 calls the configured API. Also confirm a Windows/macOS install, where the file is writable, still behaves as before.
  • #9167: build Ever Gauzy Desktop for Linux (e.g. electron-builder --linux dir) and check that resources/app.asar.unpacked/node_modules/@img/sharp-libvips-linux-x64/lib/libvips-cpp.so.* and …/@img/sharp-linux-x64/lib/*.node exist, then start it and confirm the API boots without ERR_DLOPEN_FAILED. Same for Gauzy Server / API Server.
  • The later Ubuntu 24.04 AppArmor/user-namespace error in #7735 is expected to be covered by electron-builder 26's .deb post-install AppArmor profile; worth confirming during the same .deb test.

Risk and rollback

  • #7735: low. Writable installs keep their behaviour (file rewritten, now only when it changes). The request-time insertion only touches / and /index.html and reads a fixed path. To roll back, revert the first commit; the UI server then serves the file as it is on disk again.
  • #9167: low. On macOS and Windows these packages were already unpacked by the smart unpack (their libraries end in .dylib / .dll), so only Linux gains unpacked files, plus sharp's JS and @img/colour everywhere (a few hundred KB). To roll back, revert the second commit.

Closes #7735
Closes #9167

šŸ¤– Generated with Claude Code


Summary by cubic

Fixes two Linux start-up failures of the desktop apps: Gauzy Server no longer needs to write into its install directory to point its web UI at the API, and sharp loads on Linux in Ever Gauzy Desktop, Gauzy Server, and Gauzy API Server. Writable installs keep their previous file rewrite; the visible change for them is that index.html is served with Cache-Control: no-cache instead of max-age=3600.

Bug Fixes

  • Injecting the API URL script as / and /index.html are served (instead of rewriting the file in the install directory) lets .deb and AppImage installs get through setup, which previously failed with Cannot read properties of undefined (reading 'replace') (#7735). Unwritable and unchanged files are skipped, and errors are logged instead of stopping startup.
  • Unpacking sharp and @img from app.asar fixes ERR_DLOPEN_FAILED on Linux: electron-builder's smart unpack missed sharp's versioned libvips (libvips-cpp.so.42), which stayed inside app.asar while the .node addon was unpacked (#9167). macOS and Windows were already unpacked.
  • A new node:test check in the static-checks workflow fails if any desktop app packaging sharp lacks the unpack patterns, so a future native module only needs one more rule entry.