Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/decisions/0001-shell-and-codebase.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ So the question is no longer only "which shell". It is:

Every number below was measured on 2026-08-16, on this machine, with the command named beside it. Where a number could not be measured, that is said instead of guessed.

> Note, 2026-08-29: the measurements below stand as they were taken, and the 7-Zip ones no longer describe the tree. PR #274 removed `node-7z` and `7zip-bin`, so the six `7za` binaries and the 9.4 MB `app.asar.unpacked` line are gone, a Linux `--dir` build is 9.5 MiB smaller, and the `7za l -slt` reader that Option A's cost paragraph leans on no longer exists: backups are gzipped tar read by `tar`, and the zips written before 1.7.0-beta.4 are read by `yauzl`. The test and coverage figures have moved a long way too. Nothing here is restated, because the argument was put to the deciders with the numbers of the day and rewriting it after the fact would misrepresent what was actually weighed.

## What was measured

### The fork
Expand Down
4 changes: 2 additions & 2 deletions docs/vintage-story-quirks.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ Field knowledge for anyone touching the game catalog, archive extraction, the in

## Archive

**A single wrapping folder gets flattened.** `contentRoot()` in `src/ipc/workers/extraction.ts:112-131` steps into an archive's contents only when the root holds exactly one entry and it is a directory; anything else (including an already-flat server archive) is left untouched. It is applied only to game archives, not server archives (`extraction.ts:261`: `copyTree(isGameArchive ? contentRoot(extractionRoot) : extractionRoot, outputPath)`). Covered by `tests/ipc/extraction.test.ts` ("unpacks a wrapped archive straight into the target folder" / "leaves a flat archive flat").
**A single wrapping folder gets flattened.** `contentRoot()` in `src/ipc/workers/extraction.ts:112-131` steps into an archive's contents only when the root holds exactly one entry and it is a directory; anything else (including an already-flat server archive) is left untouched. It is applied only when the caller asks for it (`unwrapSingleRootFolder`, which the game version install passes and the backup restore does not: `copyTree(unwrapSingleRootFolder ? contentRoot(extractionRoot) : extractionRoot, outputPath)`). Covered by `tests/ipc/extraction.test.ts` ("unpacks a wrapped archive straight into the target folder" / "leaves a flat archive flat").

**Zero-size entries have to survive extraction as zero-size files, not be treated as errors or skipped.** `tests/ipc/extraction.test.ts:132-142` builds an archive containing an empty `assets/version-1.22.6.txt` (the version marker from the Catalog section above) and asserts the extracted file exists with `size === 0`. `validateTree` in `src/ipc/workers/extraction.ts` counts zero-byte files explicitly rather than special-casing them away.

**Vintage Story's tars have NUL bytes in their numeric header fields, which the bundled p7zip rejects; node-tar (and GNU tar) accept them, which is why `.tar.gz` extraction goes through the `tar` npm package instead of 7-Zip.** The reasoning is spelled out in `src/ipc/workers/extraction.ts:151-158`: "7-Zip is not used here. It needs two passes for a `.tar.gz`, and the bundled p7zip 16.02 cannot read the tar Vintage Story ships at all: its headers leave the numeric fields as NUL bytes, which GNU tar and node-tar accept and 7-Zip rejects with 'Is not archive'." Every other archive format goes through `node-7z`/`7zip-bin`; only tar.gz is routed to `tar.extract` (`extractTarGz`, same file).
**Vintage Story's tars have NUL bytes in their numeric header fields, which p7zip rejects and node-tar (and GNU tar) accept.** This is one of the reasons the launcher reads `.tar.gz` with the `tar` npm package, and it was the reason it never routed game archives through 7-Zip even while 7-Zip was still bundled. Since the launcher dropped `node-7z` and `7zip-bin` (issue #222), only two formats are read at all: gzipped tar through `tar` (`extractTarGz` in `src/ipc/workers/extraction.ts`, for game builds and for the backups the launcher writes) and zip through `yauzl` (`extractZip`, same file, for the backups made before that change). Anything else is refused by `validateArchive` in `src/ipc/archiveValidation.ts` rather than handed to a reader that would have to guess at it.

## Installer

Expand Down
1 change: 0 additions & 1 deletion electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ files:
- "!node_modules/@napi-rs/lzma-*-musl{,/**/*}"
asarUnpack:
- resources/**
- node_modules/7zip-bin/**
- node_modules/@napi-rs/lzma-*/**
win:
executableName: RiftLauncher
Expand Down
76 changes: 0 additions & 76 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 0 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,10 @@
"dependencies": {
"@electron-toolkit/utils": "^3.0.0",
"@napi-rs/lzma": "1.5.1",
"7zip-bin": "^5.2.0",
"electron-log": "^5.2.3",
"electron-updater": "^6.8.9",
"fs-extra": "^11.2.0",
"json5": "^2.2.3",
"node-7z": "^3.0.0",
"tar": "7.5.22",
"write-file-atomic": "^8.0.0",
"yauzl": "^3.4.0"
Expand All @@ -54,7 +52,6 @@
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.4",
"@types/node": "^20.14.8",
"@types/node-7z": "^2.1.11",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/semver": "^7.5.8",
Expand Down
49 changes: 11 additions & 38 deletions scripts/fix-native-deps.js
Original file line number Diff line number Diff line change
@@ -1,49 +1,23 @@
#!/usr/bin/env node
/**
* `npm ci` on this project leaves two native-tooling gaps that have nothing
* to do with the code being worked on. Both are dev/test-only: packaged
* builds are unaffected because electron-builder and electron-vite handle
* them on their own.
* `npm ci` on this project leaves one native-tooling gap that has nothing to do
* with the code being worked on. It is dev/test-only: packaged builds are
* unaffected because electron-builder and electron-vite handle it on their own.
*
* 1. node_modules/7zip-bin/<platform>/<arch>/7za(.exe) sometimes loses its
* executable bit in transit (registries and some npm/tar combinations do
* not reliably preserve unix permissions while packing/unpacking). Any
* code path that spawns it then fails with `EACCES`. See #30.
* As of Electron 42, the `electron` package shipped its own `postinstall`
* (`node install.js`) that downloaded the platform binary; that hook was
* removed from its package.json, so a plain `npm ci` no longer fetches
* node_modules/electron/dist at all. `npm run dev` and anything that spawns
* Electron then fails until install.js is run by hand. Cheap to close here
* since this script already runs on install.
*
* 2. As of Electron 42, the `electron` package shipped its own
* `postinstall` (`node install.js`) that downloaded the platform binary;
* that hook was removed from its package.json, so a plain `npm ci` no
* longer fetches node_modules/electron/dist at all. `npm run dev` and
* anything that spawns Electron then fails until install.js is run by
* hand. Cheap to close here since this script already runs on install.
*
* Both fixes are idempotent and safe to run on every `npm install`.
* The fix is idempotent and safe to run on every `npm install`.
*/

const { chmodSync, existsSync, statSync } = require("node:fs")
const { existsSync } = require("node:fs")
const { join } = require("node:path")
const { spawnSync } = require("node:child_process")

function restoreSevenZipExecutableBit() {
// .exe needs no unix executable bit, and Windows has no such concept.
if (process.platform === "win32") return

let path7za
try {
;({ path7za } = require("7zip-bin"))
} catch {
return // 7zip-bin is not installed; nothing to fix.
}

if (!path7za || !existsSync(path7za)) return

const isExecutable = (statSync(path7za).mode & 0o111) !== 0
if (isExecutable) return

chmodSync(path7za, 0o755)
console.log(`[fix-native-deps] restored executable bit on ${path7za}`)
}

function ensureElectronBinaryIsDownloaded() {
const electronDir = join(__dirname, "..", "node_modules", "electron")
const installScript = join(electronDir, "install.js")
Expand All @@ -63,5 +37,4 @@ function ensureElectronBinaryIsDownloaded() {
}
}

restoreSevenZipExecutableBit()
ensureElectronBinaryIsDownloaded()
6 changes: 4 additions & 2 deletions src/domain/config/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,12 @@ export const DEFAULT_CONFIG_BASE: Omit<ConfigType, "schemaVersion" | "defaultIns
}

/**
* The 7-Zip compression level a backup uses when nobody picked one.
* The compression level a backup uses when nobody picked one.
*
* 6 is what every Installation created in the launcher gets, since it is what the add form
* proposes, and 6 is also 7-Zip's own default. It used to be spelled 4 in the config normalizer
* proposes, and 6 is also zlib's own default, as it was 7-Zip's back when a backup was a zip
* written by one. The 0 to 9 scale means the same thing to both, so the number carried over
* untouched. It used to be spelled 4 in the config normalizer
* and in the compress IPC handler, which meant an Installation from before the setting existed
* quietly compressed at a different level than the form said it would.
*/
Expand Down
6 changes: 4 additions & 2 deletions src/domain/installations/backup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,10 +156,12 @@ export async function makeInstallationBackup(ports: MakeInstallationBackupPorts,
const date = ports.clock.now()
// Falls back to a slice of the installation id when the name sanitises
// away to nothing (e.g. "***"), so the archive never ends up as a bare
// "_<stamp>.zip".
// "_<stamp>.tar.gz".
const cleanInstallationName = cleanFolderName(installation.name) || installation.id.slice(0, 8)
const dateStamp = formatTimestampForFilename(date)
const fileName = `${cleanInstallationName}_${dateStamp}.zip`
// Backups were zips up to 1.7.0-beta.4 and the restore still reads those, by
// the extension recorded with each one. New ones are gzipped tar.
const fileName = `${cleanInstallationName}_${dateStamp}.tar.gz`

const outputFolder = await ports.paths.join([backupsFolder, INSTALLATIONS_BACKUP_SUBFOLDER, cleanInstallationName])
const archivePath = await ports.paths.join([outputFolder, fileName])
Expand Down
Loading
Loading