fix(claude_desktop): persist sign-in by opting into Electron safeStorage (#2922)

* fix(claude_desktop): persist sign-in by opting into Electron safeStorage

Claude Desktop asked the user to sign in again on every start. The v1.35 fix
was inert: --password-store=basic did reach the process (confirmed on a live
install's /proc/<pid>/cmdline), but the app still logged "safeStorage not
available, tokens will not persist" on every launch.

Electron refuses its built-in basic_text backend unless the application calls
safeStorage.setUsePlainTextEncryption(true) before the ready event, and Claude
Desktop never calls it - the symbol is present in the shipped Electron binary
but absent from resources/app.asar. So isEncryptionAvailable() stayed false and
the auth token was never persisted. Verified against a standalone Electron of
the same generation: without the opt-in it is false; with it, true, and a
separate later process decrypts a blob written by an earlier one.

There is no equivalent command-line switch, and NODE_OPTIONS=--require is
ignored by packaged Electron apps (verified against the real binary), so the
opt-in is injected into the app's main bundle inside app.asar. gnome-keyring
stays out of the image: its first-boot password prompt blocks the app from
launching at all.

The patcher fails closed, rebuilds the archive preserving unpacked/symlink
entries, recomputes the changed entry's SHA-256 integrity record, and fully
re-validates the result from disk before renaming it into place. It re-runs on
every boot after 81-claude_update.sh, since an apt upgrade ships a fresh
unpatched app.asar, and is marker-guarded so an unchanged app is a no-op.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(claude_desktop): harden the safeStorage hook against review findings

- Sweep stale .app.asar.addon-tmp.* from the shell hook. `timeout` kills the
  patcher outright, so a run that hits the 120s cap never executes its own
  cleanup; the live archive stays unpatched, so every later boot would retry
  under a new pid and strand another archive-sized file.
- End the hook with an explicit `exit 0`. The logging `while` loop's status
  became the script's status, so an empty last line could exit non-zero and
  fail cont-init - the opposite of the documented "never block startup".

Both raised in review on #2922.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Alexandre
2026-07-28 17:06:44 +02:00
committed by GitHub
parent dc1044e453
commit da50d9b178
7 changed files with 432 additions and 19 deletions

View File

@@ -1,3 +1,12 @@
## 1.37 (28-07-2026)
- Fix the recurring "For your security, sign in again to keep using Claude." prompt for real. The v1.35 fix was ineffective: `--password-store=basic` was reaching the process (confirmed on a live install's `/proc/<pid>/cmdline`), yet the app kept logging `safeStorage not available, tokens will not persist` and `Encryption not available, returning empty env vars` on every launch. The missing half is an application-side opt-in — Electron refuses its built-in `basic_text` backend unless the app calls `safeStorage.setUsePlainTextEncryption(true)` before `ready`, and Claude Desktop never calls it (the symbol is present in the Electron binary but absent from `resources/app.asar`). So `isEncryptionAvailable()` stayed `false` and the auth token was never persisted, exactly as when the keyring backend was forced without a keyring daemon. Verified against a standalone Electron of the same generation: with `--password-store=basic` and no opt-in `isEncryptionAvailable()` is `false`; with the opt-in it is `true`, and a *separate later process* decrypts a blob written by an earlier one — which is the restart survival this add-on needs.
- **The patch (`86-claude_safestorage.sh` + `claude-safestorage-patch.js`, new).** A small Node script injects that one opt-in call into the app's main bundle (`.vite/build/index.pre.js`) inside `app.asar`. Insertion is placed *after* the bundle's leading `"use strict";` rather than before it, because a directive prologue only takes effect as the first statement — prepending ahead of it would silently drop the entire main process out of strict mode. The archive is rebuilt rather than edited in place, since an asar's header stores a byte offset and length per file: entries flagged `unpacked` (which live in `app.asar.unpacked/`, not in the archive body) and symlink entries are carried through untouched, so the native-module layout is preserved, and the per-file SHA-256 `integrity` record is recomputed for the one file that changed. The injector fails closed: anything that does not open with an unambiguously terminated `"use strict"` directive is refused rather than patched by guesswork, since `"use strict" + x` is an expression and injecting into it would yield a syntax error. The rebuild is written to a temporary file, `fsync`ed, and then fully re-validated from disk — entry count, every offset and length parsed as an exact in-bounds integer, the last body ending precisely at EOF, and the patched entry's recomputed SHA-256 — before it is renamed over the original and the directory synced; the temporary file is removed on every failure path. So a torn, truncated or short write can never replace a working archive. Verified byte-for-byte against the real archive: same 211 entries, no `unpacked`/symlink flag drift, no trailing slack, every integrity hash valid, and content differing in exactly one file.
- **Why it re-runs every boot.** `81-claude_update.sh` apt-upgrades `claude-desktop` on startup, and a new package ships a fresh, unpatched `app.asar`; a one-shot patch at image build time would therefore be undone by the first update. The script is marker-guarded, so a boot where the app did not change is a no-op, and it is numbered `86-` to land after both the update check and `85-openbox_autostart.sh`, while still completing before any s6 service — i.e. before the desktop launches the app. A failure is logged and swallowed rather than propagated, and the patcher is run under a 120s `timeout`: an unpatched app still runs, it just forgets the sign-in, which is not worth blocking startup over. The hook ends in an explicit `exit 0` so no logging branch can turn a non-fatal patch failure into a failed cont-init, and it sweeps stale temporary archives itself — `timeout` kills the patcher outright, so the run that hits the cap cannot execute its own cleanup, and each stranded file is archive-sized.
- **`gnome-keyring` stays out of the image.** Re-adding it would reintroduce the first-boot keyring password prompt that blocks Claude Desktop from launching at all. This route needs no keyring, no D-Bus Secret Service, no daemon and no password, so no prompt can appear. The trade-off is unchanged from v1.35 and is inherent to the `basic` backend: its key is fixed rather than gated by a keyring, so any process that can read the persistent `$HOME/.config/Claude` profile can recover the stored credentials.
- **One-time step after upgrading.** The previously stored session is already stale, so a single sign-in is still needed once after this update; it then persists across restarts.
## 1.36.4 (28-07-2026)
- Fix Selkies dying with a Rust `RuntimeDirNotSet` unwrap panic just after `Data WebSocket Server listening on port 8081`, and the data websocket then being proxied to the wrong port. Upstream relies on s6-rc ordering: `init-selkies-config` publishes `XDG_RUNTIME_DIR` and `CUSTOM_WS_PORT` into the s6 envdir and `svc-selkies` starts afterwards. The add-on entrypoint replaces s6-overlay and starts every `s6-rc.d` run script in parallel with no dependency graph, so Selkies can snapshot the envdir before that oneshot has written to it -- which is why it bound port 8081 (its own default) instead of the 8082 nginx proxies to, and why its Wayland compositor found no runtime directory to bind a socket in. `20-folders.sh` now exports both variables inside each run script, where no start ordering can lose them, and corrects the base image's `$HOME/.XDG` override where that write happens instead of appending a correction after the `exit 0` that the oneshot-tolerance block adds -- which meant the correction never ran on any boot after the first.

View File

@@ -105,7 +105,10 @@ RUN if [ ! -f /bin/sh ] && [ -f /usr/bin/sh ]; then ln -s /usr/bin/sh /bin/sh; f
# Install Claude Desktop, Claude Code, Python tooling, and lightweight local validators.
# gnome-keyring is intentionally NOT installed: it prompts for a keyring password on first
# boot, which blocks Claude Desktop from launching. Sign-in persistence instead uses Electron's
# built-in --password-store=basic (rootfs/defaults/autostart) — no daemon, no prompt.
# built-in --password-store=basic (rootfs/defaults/autostart) — no daemon, no prompt — together
# with rootfs/etc/cont-init.d/86-claude_safestorage.sh, which patches app.asar to opt into that
# backend. Electron ignores --password-store=basic without the app-side opt-in, so both are
# required for the sign-in to survive a restart.
# The cowork virtualization stack (qemu-system-x86 + ovmf firmware) lets Claude Code launch
# its sandbox microVM; libseccomp2 and libcap-ng0 are the shared libraries the source-built
# virtiofsd daemon links against at runtime. Docker itself is NOT installed here: this base

View File

@@ -9,11 +9,12 @@ streamed desktop.
v1.24-ish removed `gnome-keyring` again because it prompts for a keyring password on first
boot, which blocked Claude Desktop from ever launching — but the launch flag was left
forcing the now-daemonless libsecret backend, so `safeStorage` silently went unavailable
again (recurring "sign in again", and — new in this round — the Claude app's dispatch tab
showing the desktop as offline until a fresh sign-in was done from a computer). Fixed in
v1.35: switched to `--password-store=basic` (Electron's built-in store, no keyring
involved at all) plus a cont-init script that re-syncs the persistent openbox `autostart`
from the image on every boot, so the fix reaches existing installs, not just fresh ones.
again (recurring "sign in again", and the Claude app's dispatch tab showing the desktop as
offline until a fresh sign-in was done from a computer). v1.35 switched to
`--password-store=basic` plus a cont-init script that re-syncs the persistent openbox
`autostart` from the image on every boot — **but that flag alone does nothing**, and the bug
survived it untouched. Actually fixed in v1.37, which adds the application-side opt-in the
`basic` backend requires; see "Why v1.35 did not work" below.
- **Planned only:** Problem A (in-desktop browser for OAuth) is intentionally not implemented.
The image ships no browser; complete the login with the user-side workaround below.
@@ -102,14 +103,60 @@ Re-adding gnome-keyring would just reintroduce the original launch-blocking prom
a dead end without also solving *that* — hence the shipped fix below avoids keyring
entirely.
### Fix (shipped, v1.35)
1. `claude_desktop/rootfs/defaults/autostart` launches with
`--password-store=basic` instead of `gnome-libsecret`. `basic` is Electron's built-in
fixed-key store: `isEncryptionAvailable()` is always `true`, no daemon, no prompt. Secrets
land under `$HOME/.config/Claude`, and `HOME=/data/data` is persistent add-on storage, so
the session survives restarts. `basic` trades away OS-backed at-rest protection: unlike
### Why v1.35 did not work
v1.35 assumed `--password-store=basic` makes `isEncryptionAvailable()` "always true". It does
not. On a live install the flag was confirmed on the running process:
```
$ tr '\0' ' ' < /proc/2247/cmdline
/usr/lib/claude-desktop/claude-desktop --no-sandbox --disable-dev-shm-usage --password-store=basic
```
and the app still logged, on every launch:
```
[warn] safeStorage not available, tokens will not persist
[warn] Encryption not available, returning empty env vars
[error] Electron safeStorage encryption is not available on this system, cannot store allowlist cache
```
Electron deliberately refuses its own `basic_text` backend unless the **application** opts in
by calling `safeStorage.setUsePlainTextEncryption(true)` before the `ready` event. The symbol
is present in the shipped Electron binary but absent from `resources/app.asar` — Claude Desktop
never calls it. So v1.35 replaced one unavailable backend with another.
Confirmed against a standalone Electron of the same generation, all with
`--password-store=basic`:
| case | `isEncryptionAvailable()` |
| --- | --- |
| no opt-in (= Claude Desktop as shipped) | `false` |
| `setUsePlainTextEncryption(true)` | `true`, `encryptString` works |
| fresh process, decrypting the earlier process's blob | `true`, plaintext recovered |
The third row is the one that matters: it is the restart survival this add-on needs.
### Fix (shipped, v1.37)
1. `claude_desktop/rootfs/defaults/autostart` launches with `--password-store=basic` instead of
`gnome-libsecret` — Electron's built-in fixed-key store: no daemon, no prompt. Secrets land
under `$HOME/.config/Claude`, and `HOME=/data/data` is persistent add-on storage, so the
session survives restarts. `basic` trades away OS-backed at-rest protection: unlike
`gnome-libsecret`, its encryption key isn't gated by a keyring daemon, so any process able
to read the persistent `$HOME/.config/Claude` profile can recover the saved credentials.
1b. `claude_desktop/rootfs/etc/cont-init.d/86-claude_safestorage.sh` +
`claude_desktop/rootfs/usr/local/bin/claude-safestorage-patch.js` (new) supply the opt-in
the flag depends on, by injecting `safeStorage.setUsePlainTextEncryption(true)` into the
app's main bundle inside `app.asar`. The injection goes *after* the bundle's leading
`"use strict";` — a directive prologue only counts as the first statement, so prepending
ahead of it would drop the main process out of strict mode. The archive is rebuilt (asar
headers store per-file offsets, so content cannot simply grow in place), preserving
`unpacked` and symlink entries and recomputing the per-file SHA-256 `integrity` record for
the single changed file; the result is verified and only then renamed over the original.
It re-runs every boot after `81-claude_update.sh`, because an apt upgrade of
`claude-desktop` ships a fresh unpatched `app.asar`; it is marker-guarded, so an unchanged
app is a no-op, and a failure is logged rather than propagated (an unpatched app still
runs, it just forgets the sign-in).
2. A passwordless keyring was considered instead (keeps libsecret encryption-at-rest without
a prompt) and rejected: the keyring DB would live in the same persistent volume as the
ciphertext it's "protecting," so it adds ~no real confidentiality in this single-user
@@ -133,9 +180,12 @@ normally and dispatch stays online regardless of which device connects first aft
## Files this plan touched
- `claude_desktop/rootfs/defaults/autostart` — drop the keyring bootstrap; launch with
`--password-store=basic`.
- `claude_desktop/rootfs/etc/cont-init.d/85-openbox_autostart.sh` — new; syncs the persistent
autostart from the image on every boot.
- `claude_desktop/rootfs/etc/cont-init.d/85-openbox_autostart.sh` — new in v1.35; syncs the
persistent autostart from the image on every boot.
- `claude_desktop/rootfs/etc/cont-init.d/86-claude_safestorage.sh` and
`claude_desktop/rootfs/usr/local/bin/claude-safestorage-patch.js` — new in v1.37; the
app-side `safeStorage` opt-in that makes `--password-store=basic` actually take effect.
- `claude_desktop/Dockerfile` — corrected stale comment (gnome-keyring is not installed).
- `claude_desktop/CHANGELOG.md` / `config.yaml` — v1.35.
- `claude_desktop/CHANGELOG.md` / `config.yaml` — v1.35, then v1.37.
Problem A (in-desktop browser for OAuth) remains planned-only; not touched by this change.

View File

@@ -122,5 +122,5 @@ slug: claude_desktop
tmpfs: true
udev: true
url: https://github.com/alexbelgium/hassio-addons
version: "1.36.4"
version: "1.37"
video: true

View File

@@ -8,9 +8,15 @@
# surfaced as recurring "sign in again" prompts and (because the stale session also fails the
# elevated-access OAuth check) the Claude app's dispatch tab showing this desktop as offline.
#
# --password-store=basic uses Electron's built-in fixed-key store instead: no daemon, no
# prompt, and isEncryptionAvailable() is always true. Secrets land under $HOME/.config/Claude,
# and HOME=/data/data is persistent add-on storage, so the saved sign-in survives restarts.
# --password-store=basic uses Electron's built-in fixed-key store instead: no daemon and no
# prompt. Secrets land under $HOME/.config/Claude, and HOME=/data/data is persistent add-on
# storage, so the saved sign-in survives restarts.
#
# This flag is only half of it. Electron refuses the basic backend unless the application opts
# in via safeStorage.setUsePlainTextEncryption(true), and Claude Desktop never calls it — with
# the flag alone, isEncryptionAvailable() stays false and the sign-in is still lost on every
# restart. /etc/cont-init.d/86-claude_safestorage.sh injects that opt-in into app.asar before
# this runs; do not drop one without the other.
# Headroom is intentionally not injected into the Desktop process: Claude Desktop
# force-overrides ANTHROPIC_BASE_URL (headroom #869), so Desktop uses the registered Headroom
# MCP tools instead.

View File

@@ -0,0 +1,61 @@
#!/usr/bin/with-contenv bashio
# shellcheck shell=bash
set -e
# Claude Desktop is launched with --password-store=basic (see /defaults/autostart) so that no
# system keyring — and therefore no keyring password prompt — is ever needed. Electron does not
# accept that backend unless the application itself calls
# safeStorage.setUsePlainTextEncryption(true), and Claude Desktop never does, so without this
# patch safeStorage.isEncryptionAvailable() stays false: the auth token is never persisted and
# the user is asked to sign in again on every start.
#
# claude-safestorage-patch.js injects that opt-in into the app's main bundle inside app.asar.
# It runs on every boot, and after 81-claude_update.sh, on purpose: an apt upgrade of
# claude-desktop replaces app.asar with a fresh unpatched copy. The patcher is marker-guarded,
# so a boot where the app did not change is a cheap no-op.
#
# A failure here must never block startup — the app still runs fine unpatched, it just forgets
# the sign-in — so the patcher's exit status is reported, not propagated, and it is capped with
# a timeout so a pathological archive cannot stall the boot indefinitely.
ASAR="/usr/lib/claude-desktop/resources/app.asar"
PATCHER="/usr/local/bin/claude-safestorage-patch.js"
if [ ! -f "$ASAR" ]; then
bashio::log.warning "Claude Desktop app.asar not found; skipping the safeStorage patch"
exit 0
fi
if [ ! -f "$PATCHER" ]; then
bashio::log.warning "$PATCHER not found; skipping the safeStorage patch"
exit 0
fi
# The patcher removes its own temporary archive on every failure path, but `timeout` kills it
# outright, so a run that hits the cap leaves one behind. Those are archive-sized, and the live
# archive stays unpatched, so every later boot would retry under a new pid and strand another
# copy until the container runs out of space.
cleanup_tmp() {
find "$(dirname "$ASAR")" -maxdepth 1 -name ".$(basename "$ASAR").addon-tmp.*" -delete 2>/dev/null || true
}
if output=$(timeout 120 node "$PATCHER" "$ASAR" 2>&1); then
bashio::log.info "safeStorage: ${output}"
else
rc=$?
if [ "$rc" -eq 124 ]; then
bashio::log.warning "safeStorage patch timed out after 120s; continuing unpatched."
else
bashio::log.warning "safeStorage patch failed (exit ${rc}); the sign-in will not persist."
fi
while IFS= read -r line; do
if [ -n "$line" ]; then
bashio::log.warning "${line}"
fi
done <<< "${output}"
cleanup_tmp
fi
# Explicitly successful: a failed patch is never fatal, so the status of whatever ran last above
# must not become this script's status and abort the boot.
exit 0

View File

@@ -0,0 +1,284 @@
#!/usr/bin/env node
/*
* Enable Electron safeStorage for Claude Desktop without a system keyring.
*
* Claude Desktop persists its auth token with Electron's safeStorage. On Linux that is gated
* on a backend: the libsecret backend needs a running Secret Service (gnome-keyring), which is
* intentionally not installed here because it prompts for a keyring password on first boot and
* blocks the app from launching. The app is therefore launched with --password-store=basic
* (Electron's built-in fixed-key store: no daemon, no prompt).
*
* That alone is not enough. Electron refuses the `basic_text` backend unless the *application*
* explicitly opts in by calling safeStorage.setUsePlainTextEncryption(true) before the app is
* ready, and Claude Desktop never calls it. So isEncryptionAvailable() stays false, the token is
* never persisted, and the user is asked to sign in again on every start. There is no equivalent
* command-line switch, and NODE_OPTIONS=--require is ignored by packaged Electron apps, so the
* opt-in has to be injected into the app's own main bundle.
*
* This script does that inside app.asar. It is idempotent (marker-guarded) and re-applied on
* every boot, because 81-claude_update.sh apt-upgrades claude-desktop and a new package ships a
* fresh, unpatched app.asar.
*
* Failure policy: refuse rather than guess. An unpatched app still runs, it just forgets the
* sign-in; a corrupted app.asar would not start at all. Every unexpected shape is a hard exit
* that leaves the original archive untouched.
*
* asar layout (all little-endian):
* [0] uint32 = 4 size of the next field
* [4] uint32 = headerBufLen size of the header pickle
* [8] uint32 = payloadSize 4 + headerString length, 4-byte aligned
* [12] uint32 = headerStrLen exact JSON length
* [16] utf8 = headerString JSON file tree, padded to a 4-byte boundary
* then file bodies; each node's "offset" is relative to the end of the header.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const ASAR = process.argv[2] || '/usr/lib/claude-desktop/resources/app.asar';
const MARKER = 'CLAUDE_ADDON_SAFESTORAGE_PATCH';
const PATCH =
`/*${MARKER}*/try{require("electron").safeStorage.setUsePlainTextEncryption(true);}` +
`catch(e){try{console.error("[claude_desktop addon] safeStorage opt-in failed:",e&&e.message);}catch(_){}}`;
const log = (m) => process.stdout.write(`${m}\n`);
const fail = (m) => {
process.stderr.write(`${m}\n`);
process.exit(1);
};
const alignInt = (i, a) => i + ((a - (i % a)) % a);
const sha256 = (b) => crypto.createHash('sha256').update(b).digest('hex');
function readArchive(file) {
const buf = fs.readFileSync(file);
if (buf.length < 16 || buf.readUInt32LE(0) !== 4) fail(`Not an asar archive: ${file}`);
const headerBufLen = buf.readUInt32LE(4);
const payloadSize = buf.readUInt32LE(8);
const headerStrLen = buf.readUInt32LE(12);
const contentBase = 8 + headerBufLen;
if (headerBufLen !== 4 + payloadSize || payloadSize !== 4 + alignInt(headerStrLen, 4)) {
fail(`Corrupt asar header pickle in ${file}`);
}
if (16 + headerStrLen > buf.length || contentBase > buf.length) {
fail(`Corrupt asar: header extends past end of ${file}`);
}
let header;
try {
header = JSON.parse(buf.toString('utf8', 16, 16 + headerStrLen));
} catch (e) {
fail(`Corrupt asar: header is not valid JSON (${e.message})`);
}
return { buf, header, contentBase };
}
/* Every packed leaf, as [path, node]. Nodes flagged `unpacked` live in app.asar.unpacked/ and
* `link` nodes are symlinks — neither has bytes inside the archive, so both are carried through
* untouched and skipped here. */
function packedLeaves(header) {
const out = [];
(function walk(dir, prefix) {
for (const [name, node] of Object.entries(dir.files)) {
const p = prefix ? `${prefix}/${name}` : name;
if (node.files) walk(node, p);
else if (!node.unpacked && typeof node.link !== 'string') out.push([p, node]);
}
})(header, '');
return out;
}
/* Offsets are decimal strings and can exceed 2^31; reject anything that is not a plain, exact,
* in-bounds extent rather than letting parseInt("12junk") or NaN silently slice the wrong bytes. */
function extentOf(ar, node, where) {
if (!/^\d+$/.test(String(node.offset))) fail(`Bad offset for ${where}: ${node.offset}`);
const off = Number(node.offset);
const size = node.size;
if (!Number.isSafeInteger(off)) fail(`Offset out of safe range for ${where}`);
if (!Number.isSafeInteger(size) || size < 0) fail(`Bad size for ${where}: ${size}`);
const start = ar.contentBase + off;
const end = start + size;
if (end > ar.buf.length) fail(`Extent of ${where} runs past end of archive`);
return { start, end };
}
const bodyOf = (ar, node, where) => {
const { start, end } = extentOf(ar, node, where);
return ar.buf.subarray(start, end);
};
function resolve(header, relPath) {
let node = header;
for (const part of relPath.split('/')) {
if (!node.files || !node.files[part]) return null;
node = node.files[part];
}
return node;
}
/* Matches @electron/asar: whole-file hash plus one hash per blockSize chunk. An empty file has
* an empty block list, not a single block over zero bytes. */
function integrityOf(buf, blockSize) {
const blocks = [];
for (let i = 0; i < buf.length; i += blockSize) {
blocks.push(sha256(buf.subarray(i, Math.min(i + blockSize, buf.length))));
}
return { algorithm: 'SHA256', hash: sha256(buf), blockSize, blocks };
}
/* Insert the opt-in after the bundle's leading "use strict" directive. It must go *after* it: a
* directive prologue only takes effect as the very first statement, so prepending would silently
* drop the whole main process out of strict mode.
*
* Returns null — meaning "refuse to patch" — for anything that is not unambiguously a directive.
* `"use strict" + x` is an expression, not a directive, and injecting into it would produce a
* syntax error, so the directive is only accepted when it is terminated by its own semicolon, a
* line break, or end of input. */
function applyPatch(source) {
const m = /^\s*(['"])use strict\1(;?)/.exec(source);
if (!m) return null;
const rest = source.slice(m[0].length);
const terminated = m[2] === ';' || rest === '' || /^[\r\n]/.test(rest);
if (!terminated) return null;
// Supply the terminator when the directive relied on ASI; without it the injected code would
// continue the string-literal expression instead of following it.
const sep = m[2] === ';' ? '' : ';';
return source.slice(0, m[0].length) + sep + PATCH + rest;
}
function writeAll(fd, buf) {
let off = 0;
while (off < buf.length) off += fs.writeSync(fd, buf, off, buf.length - off);
}
/* Re-read the rebuilt archive from disk and prove it is sound before it replaces a working one.
* A short or truncated write late in the file would otherwise still pass a marker-only check,
* because the main bundle sits near the front. */
function verifyRebuilt(file, mainRel, expectedLeafCount) {
const ar = readArchive(file);
const leaves = packedLeaves(ar.header);
if (leaves.length !== expectedLeafCount) {
fail(`Rebuilt archive has ${leaves.length} packed entries, expected ${expectedLeafCount}`);
}
let maxEnd = ar.contentBase;
for (const [p, node] of leaves) {
const { end } = extentOf(ar, node, p);
if (end > maxEnd) maxEnd = end;
}
if (maxEnd !== ar.buf.length) {
fail(`Rebuilt archive is truncated or has ${ar.buf.length - maxEnd} trailing bytes`);
}
const mainNode = resolve(ar.header, mainRel);
if (!mainNode) fail(`Rebuilt archive lost its main entry ${mainRel}`);
const body = bodyOf(ar, mainNode, mainRel);
if (!body.toString('utf8').includes(MARKER)) fail('Rebuilt archive is missing the patch marker');
if (mainNode.integrity && sha256(body) !== mainNode.integrity.hash) {
fail('Rebuilt archive has a stale integrity hash for the main entry');
}
}
function main() {
if (!fs.existsSync(ASAR)) fail(`Missing ${ASAR}`);
const ar = readArchive(ASAR);
const pkgNode = resolve(ar.header, 'package.json');
if (!pkgNode) fail('app.asar has no package.json');
const pkg = JSON.parse(bodyOf(ar, pkgNode, 'package.json').toString('utf8'));
const mainRel = pkg.main;
if (!mainRel) fail('package.json has no "main" entry');
const mainNode = resolve(ar.header, mainRel);
if (!mainNode) fail(`main entry not found in archive: ${mainRel}`);
if (mainNode.unpacked) fail(`main entry ${mainRel} is unpacked; refusing to patch`);
if (pkg.type === 'module') fail(`main entry ${mainRel} is ESM; this patcher emits CommonJS`);
const original = bodyOf(ar, mainNode, mainRel).toString('utf8');
if (original.includes(MARKER)) {
log(`Already patched: ${mainRel}`);
return;
}
const patchedSource = applyPatch(original);
if (patchedSource === null) {
fail(`${mainRel} does not begin with a recognized "use strict" directive; refusing to patch`);
}
const patched = Buffer.from(patchedSource, 'utf8');
/* Rebuild: copy every packed body in tree order, substituting the patched main entry, and
* reassign offsets as we go. asar headers store a byte offset and length per file, so content
* cannot simply grow in place. */
const leaves = packedLeaves(ar.header);
const chunks = [];
let offset = 0;
let patchedCount = 0;
for (const [p, node] of leaves) {
const isMain = node === mainNode;
const body = isMain ? patched : bodyOf(ar, node, p);
node.offset = String(offset);
node.size = body.length;
if (isMain && node.integrity) {
node.integrity = integrityOf(body, node.integrity.blockSize || 4 * 1024 * 1024);
}
offset += body.length;
chunks.push(body);
if (isMain) patchedCount++;
}
if (patchedCount !== 1) fail(`expected to rewrite exactly 1 main entry, rewrote ${patchedCount}`);
const headerString = JSON.stringify(ar.header);
const strLen = Buffer.byteLength(headerString);
const payloadSize = 4 + alignInt(strLen, 4);
const headerBufLen = 4 + payloadSize;
const prefix = Buffer.alloc(16 + alignInt(strLen, 4));
prefix.writeUInt32LE(4, 0);
prefix.writeUInt32LE(headerBufLen, 4);
prefix.writeUInt32LE(payloadSize, 8);
prefix.writeUInt32LE(strLen, 12);
prefix.write(headerString, 16, 'utf8');
/* Write beside the target and rename, so an interrupted run can never leave a torn app.asar.
* The temp name carries the pid so two runs cannot share it, and it is removed on every
* failure path before the rename. */
const dir = path.dirname(ASAR);
const tmp = path.join(dir, `.${path.basename(ASAR)}.addon-tmp.${process.pid}`);
const mode = fs.statSync(ASAR).mode & 0o7777;
try {
const out = fs.openSync(tmp, 'wx', mode);
try {
writeAll(out, prefix);
for (const c of chunks) writeAll(out, c);
fs.fsyncSync(out); // durable before it becomes the live archive
} finally {
fs.closeSync(out);
}
fs.chmodSync(tmp, mode);
verifyRebuilt(tmp, mainRel, leaves.length);
fs.renameSync(tmp, ASAR);
} catch (e) {
try {
fs.unlinkSync(tmp);
} catch (_) {
/* nothing to clean up */
}
fail(`Rebuild failed, original left untouched: ${e.message}`);
}
/* Sync the directory so the rename itself survives a crash, not just the file's contents. */
try {
const dfd = fs.openSync(dir, 'r');
fs.fsyncSync(dfd);
fs.closeSync(dfd);
} catch (_) {
/* best effort */
}
log(`Patched ${mainRel} in ${ASAR} (safeStorage plain-text opt-in)`);
}
main();