Native Windows-Auslieferung statt Container
Docker faellt weg. Das Projekt hat keine npm-Abhaengigkeiten und Node fuehrt TypeScript direkt aus - der Container schrieb nur noch drei Programme fest, und dafuer braucht es keine Virtualisierung, sondern einen Ordner. Der Anlass war, dass ADR-0004 zwar `docker compose pull` versprach, es aber nie ein veroeffentlichtes Image gab. Wer installieren wollte, musste whisper.cpp aus dem Quelltext uebersetzen. Plattformschicht (packages/core/src/platform.ts): - Programme werden neben der Anwendung gesucht, nicht im PATH - Daten unter %LOCALAPPDATA%, damit Installation und Update ohne Administratorrechte auskommen - Schriften mitgeliefert: ohne fontsdir setzt libass wortlos eine Ersatzschrift ein - gemessen 720 statt 826 Pixel Zeilenbreite Windows-Eigenheiten, an echtem ffmpeg 8.1 durchprobiert: - Pfade im Filtergraphen brauchen alle drei Schritte (Vorwaertsschraegstriche, escapter Doppelpunkt, Anfuehrungszeichen); je zwei davon reichen nicht - `nice` gibt es nicht, ersetzt durch os.setPriority Auslieferung: - scripts/build-package.ts baut das Paket, laeuft auch unter Linux - install.ps1 ohne Administratorrechte, Autostart ueber den Aufgabenplaner - update legt neue Fassungen bereit; eingespielt wird beim naechsten Start, nie mitten im Stream - ZIP lesen und schreiben ohne Abhaengigkeiten (packages/core/src/zip.ts) Kein `irm ... | iex`-Einzeiler: git.zeasy.dev antwortete instanzweit mit einer Umleitung auf /user/login, und `iex` fuehrte die Anmeldeseite als PowerShell aus. Der Updater verfolgt Umleitungen jetzt nicht und hat keine erfundene Vorgabeadresse mehr. Ausserdem: backup.ps1 war an zwei Stellen dokumentiert, aber nie geschrieben. ADR-0007 ersetzt ADR-0004. 84 Tests, Typpruefung sauber. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AhVSfMFdWnXKCv5JfLnArZ
This commit is contained in:
1 parent
71dd660d01
commit
5f6301268a
41 files changed
+2254
-312
No files matched your search
@@ -4,14 +4,19 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { parseYaml, type YamlMap } from './yaml.ts';
|
||||
import { defaultDirs } from './platform.ts';
|
||||
|
||||
// Die Vorgaben haengen am Betriebssystem (siehe platform.ts); die
|
||||
// Umgebungsvariablen ueberschreiben sie wie bisher.
|
||||
const dirs = defaultDirs();
|
||||
|
||||
export const paths = {
|
||||
config: process.env.CONFIG_DIR || '/app/config',
|
||||
db: process.env.DB_DIR || '/db',
|
||||
work: process.env.WORK_DIR || '/work',
|
||||
clips: process.env.CLIPS_DIR || '/app/data/clips',
|
||||
replay: process.env.REPLAY_DIR || '/replay',
|
||||
models: process.env.MODEL_DIR || '/models',
|
||||
config: process.env.CONFIG_DIR || dirs.config,
|
||||
db: process.env.DB_DIR || dirs.db,
|
||||
work: process.env.WORK_DIR || dirs.work,
|
||||
clips: process.env.CLIPS_DIR || dirs.clips,
|
||||
replay: process.env.REPLAY_DIR || dirs.replay,
|
||||
models: process.env.MODEL_DIR || dirs.models,
|
||||
};
|
||||
|
||||
export type Config = {
|
||||
@@ -27,6 +32,7 @@ export type Config = {
|
||||
};
|
||||
};
|
||||
obs: { url: string; replayBufferSeconds: number; chapterScenes: string[] };
|
||||
update: { url: string };
|
||||
clipper: {
|
||||
enabled: boolean;
|
||||
alsoCreateTwitchClip: boolean;
|
||||
@@ -57,7 +63,8 @@ const DEFAULTS = {
|
||||
laughEmotes: ['KEKW', 'LUL', 'OMEGALUL', 'PogChamp', 'Pog'],
|
||||
},
|
||||
},
|
||||
obs: { url: 'ws://host.docker.internal:4455', replayBufferSeconds: 90, chapterScenes: [] },
|
||||
obs: { url: 'ws://127.0.0.1:4455', replayBufferSeconds: 90, chapterScenes: [] },
|
||||
update: { url: '' },
|
||||
clipper: {
|
||||
enabled: true,
|
||||
alsoCreateTwitchClip: true,
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// Wo die Anwendung ihre Programme, Schriften und Daten findet.
|
||||
//
|
||||
// Bis 2026-09 lief alles in einem Container: ffmpeg lag im PATH, die Daten
|
||||
// unter festen Linux-Absolutpfaden (/db, /work, /models). Nativ unter Windows
|
||||
// gibt es beides nicht - die Programme liegen neben der Anwendung, die Daten
|
||||
// unter %ProgramData%. Siehe ADR-0007.
|
||||
//
|
||||
// Die Linux-Pfade bleiben als Vorgabe stehen, damit die Tests im Container
|
||||
// unveraendert laufen und ein Betrieb unter Linux moeglich bleibt.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const isWindows = process.platform === 'win32';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/** Wurzel des Quellbaums - im Paket der Ordner `app`. */
|
||||
export const appRoot = path.resolve(HERE, '..', '..', '..');
|
||||
|
||||
/**
|
||||
* Wurzel der Installation. Im Paket liegt darin `app/`, `bin/`, `fonts/` und
|
||||
* `node.exe`; im Quell-Checkout ist es schlicht der Ordner ueber dem Repo und
|
||||
* enthaelt nichts davon - dann greift die Suche im PATH.
|
||||
*/
|
||||
export const installRoot = path.resolve(appRoot, '..');
|
||||
|
||||
/** Verzeichnisse, in denen mitgelieferte Dateien gesucht werden. */
|
||||
const bundleDirs = (sub: string): string[] =>
|
||||
[path.join(installRoot, sub), path.join(appRoot, sub)];
|
||||
|
||||
const found = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* Ein Programm auflösen: erst die Umgebungsvariable, dann das mitgelieferte
|
||||
* `bin/`, sonst der blanke Name - dann sucht das Betriebssystem im PATH.
|
||||
*
|
||||
* Die Reihenfolge ist Absicht. Die Umgebungsvariable gewinnt, damit sich eine
|
||||
* kaputte Mitlieferung ohne Neuinstallation ueberschreiben laesst.
|
||||
*/
|
||||
export function findBinary(name: string, envVar: string): string {
|
||||
const fromEnv = process.env[envVar];
|
||||
if (fromEnv) return fromEnv;
|
||||
|
||||
const cached = found.get(name);
|
||||
if (cached) return cached;
|
||||
|
||||
const exe = isWindows ? `${name}.exe` : name;
|
||||
for (const dir of bundleDirs('bin')) {
|
||||
const p = path.join(dir, exe);
|
||||
if (fs.existsSync(p)) { found.set(name, p); return p; }
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
export const binaries = {
|
||||
get ffmpeg(): string { return findBinary('ffmpeg', 'FFMPEG_BIN'); },
|
||||
get ffprobe(): string { return findBinary('ffprobe', 'FFPROBE_BIN'); },
|
||||
get whisper(): string { return findBinary('whisper-cli', 'WHISPER_BIN'); },
|
||||
};
|
||||
|
||||
/**
|
||||
* Ordner mit den mitgelieferten Schriften, oder `undefined`.
|
||||
*
|
||||
* Im Container kam DejaVu Sans aus `fonts-dejavu-core` und libass fand sie
|
||||
* ueber fontconfig. Windows hat DejaVu nicht. Ohne diesen Ordner setzt libass
|
||||
* still eine Ersatzschrift ein - der Untertitel sieht dann anders aus, ohne
|
||||
* dass irgendetwas eine Warnung ausgibt.
|
||||
*/
|
||||
export function fontsDir(): string | undefined {
|
||||
if (process.env['FONTS_DIR']) return process.env['FONTS_DIR'];
|
||||
for (const dir of bundleDirs('fonts')) if (fs.existsSync(dir)) return dir;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Vorgabe fuer die Datenverzeichnisse, je nach Betriebssystem. */
|
||||
export function defaultDirs(): Record<
|
||||
'config' | 'db' | 'work' | 'clips' | 'replay' | 'models', string
|
||||
> {
|
||||
if (!isWindows) {
|
||||
return {
|
||||
config: '/app/config', db: '/db', work: '/work',
|
||||
clips: '/app/data/clips', replay: '/replay', models: '/models',
|
||||
};
|
||||
}
|
||||
// Nicht neben die Programme: ein Update tauscht den Programmordner
|
||||
// komplett aus, und die Datenbank darf dabei nicht mitgehen.
|
||||
//
|
||||
// LOCALAPPDATA und nicht ProgramData, weil die Anwendung als angemeldeter
|
||||
// Benutzer laeuft - dieselbe Sitzung wie OBS. Damit braucht weder die
|
||||
// Installation noch ein Update Administratorrechte. Genau daran scheitern
|
||||
// Aktualisierungen sonst auf einem Rechner, an dem gerade gestreamt wird.
|
||||
const local = process.env['LOCALAPPDATA'] ??
|
||||
path.join(process.env['USERPROFILE'] ?? 'C:\\', 'AppData', 'Local');
|
||||
const base = path.join(local, 'abdulive');
|
||||
return {
|
||||
config: path.join(base, 'config'),
|
||||
db: path.join(base, 'db'),
|
||||
work: path.join(base, 'work'),
|
||||
clips: path.join(base, 'clips'),
|
||||
replay: path.join(base, 'replay'),
|
||||
models: path.join(base, 'models'),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import zlib from 'node:zlib';
|
||||
import crypto from 'node:crypto';
|
||||
import { readZip, writeZip } from './zip.ts';
|
||||
|
||||
/**
|
||||
* Baut ein ZIP von Hand. Ein Schreiber gehoert nicht ins Projekt, aber ohne
|
||||
* eigenes Archiv braeuchte der Test eine Beispieldatei im Repo - und die
|
||||
* saehe niemand mehr an.
|
||||
*/
|
||||
function makeZip(files: Array<{ name: string; body: string; deflate?: boolean }>): Buffer {
|
||||
const locals: Buffer[] = [];
|
||||
const centrals: Buffer[] = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const f of files) {
|
||||
const name = Buffer.from(f.name, 'utf8');
|
||||
const raw = Buffer.from(f.body, 'utf8');
|
||||
const data = f.deflate ? zlib.deflateRawSync(raw) : raw;
|
||||
const method = f.deflate ? 8 : 0;
|
||||
|
||||
const local = Buffer.alloc(30 + name.length);
|
||||
local.writeUInt32LE(0x04034b50, 0);
|
||||
local.writeUInt16LE(method, 8);
|
||||
local.writeUInt32LE(data.length, 18);
|
||||
local.writeUInt32LE(raw.length, 22);
|
||||
local.writeUInt16LE(name.length, 26);
|
||||
name.copy(local, 30);
|
||||
locals.push(local, data);
|
||||
|
||||
const central = Buffer.alloc(46 + name.length);
|
||||
central.writeUInt32LE(0x02014b50, 0);
|
||||
central.writeUInt16LE(method, 10);
|
||||
central.writeUInt32LE(data.length, 20);
|
||||
central.writeUInt32LE(raw.length, 24);
|
||||
central.writeUInt16LE(name.length, 28);
|
||||
central.writeUInt32LE(offset, 42);
|
||||
name.copy(central, 46);
|
||||
centrals.push(central);
|
||||
|
||||
offset += local.length + data.length;
|
||||
}
|
||||
|
||||
const dir = Buffer.concat(centrals);
|
||||
const eocd = Buffer.alloc(22);
|
||||
eocd.writeUInt32LE(0x06054b50, 0);
|
||||
eocd.writeUInt16LE(files.length, 8);
|
||||
eocd.writeUInt16LE(files.length, 10);
|
||||
eocd.writeUInt32LE(dir.length, 12);
|
||||
eocd.writeUInt32LE(offset, 16);
|
||||
return Buffer.concat([...locals, dir, eocd]);
|
||||
}
|
||||
|
||||
test('liest gespeicherte und komprimierte Eintraege', () => {
|
||||
const zip = makeZip([
|
||||
{ name: 'bin/ffmpeg.exe', body: 'MZ-so-tun-als-ob' },
|
||||
{ name: 'liesmich.txt', body: 'Hallo '.repeat(200), deflate: true },
|
||||
]);
|
||||
const entries = readZip(zip);
|
||||
|
||||
assert.equal(entries.length, 2);
|
||||
assert.equal(entries[0]?.data().toString(), 'MZ-so-tun-als-ob');
|
||||
assert.equal(entries[1]?.data().toString(), 'Hallo '.repeat(200));
|
||||
assert.equal(entries[1]?.size, 1200);
|
||||
});
|
||||
|
||||
test('erkennt Verzeichniseintraege am Schraegstrich', () => {
|
||||
const entries = readZip(makeZip([{ name: 'bin/', body: '' }, { name: 'bin/a', body: 'x' }]));
|
||||
assert.equal(entries[0]?.isDirectory, true);
|
||||
assert.equal(entries[1]?.isDirectory, false);
|
||||
});
|
||||
|
||||
test('findet das Inhaltsverzeichnis auch hinter einem Kommentar', () => {
|
||||
// Der Kommentar steht hinter dem EOCD - von hinten zu suchen reicht nicht,
|
||||
// man muss das Feld tatsaechlich pruefen.
|
||||
const zip = makeZip([{ name: 'a.txt', body: 'inhalt' }]);
|
||||
const withComment = Buffer.concat([zip, Buffer.from('# angehaengter Kommentar')]);
|
||||
withComment.writeUInt16LE(24, withComment.length - 24 - 22 + 20);
|
||||
assert.equal(readZip(withComment)[0]?.data().toString(), 'inhalt');
|
||||
});
|
||||
|
||||
test('sagt deutlich, wenn kein ZIP vorliegt', () => {
|
||||
assert.throws(() => readZip(Buffer.alloc(200)), /Inhaltsverzeichnis/);
|
||||
});
|
||||
|
||||
test('meldet ZIP64 statt still Unsinn zu liefern', () => {
|
||||
const zip = makeZip([{ name: 'a', body: 'x' }]);
|
||||
zip.writeUInt32LE(0xffffffff, zip.length - 22 + 16); // dirStart
|
||||
assert.throws(() => readZip(zip), /ZIP64/);
|
||||
});
|
||||
|
||||
test('Geschriebenes laesst sich wieder lesen', () => {
|
||||
const gross = Buffer.from('x'.repeat(50_000));
|
||||
const zip = writeZip([
|
||||
{ name: 'bin/ffmpeg.exe', data: Buffer.from([0x4d, 0x5a, 0x90, 0x00]) },
|
||||
{ name: 'app/gross.txt', data: gross },
|
||||
]);
|
||||
const back = readZip(zip);
|
||||
assert.deepEqual(back.map((e) => e.name), ['bin/ffmpeg.exe', 'app/gross.txt']);
|
||||
assert.deepEqual(back[1]?.data(), gross);
|
||||
// Gut komprimierbar - der Eintrag muss deutlich kleiner geworden sein
|
||||
assert.ok(zip.length < 5_000, `Archiv ist ${zip.length} Bytes gross`);
|
||||
});
|
||||
|
||||
test('schreibt eine gueltige CRC32 - sonst meckert der Explorer', () => {
|
||||
const data = Buffer.from('Wiedergabepuffer');
|
||||
const zip = writeZip([{ name: 'a.txt', data }]);
|
||||
// Im lokalen Kopf steht die Pruefsumme an Offset 14
|
||||
assert.equal(zip.readUInt32LE(14), zlib.crc32(data));
|
||||
});
|
||||
|
||||
test('legt bereits komprimierte Daten unveraendert ab', () => {
|
||||
// Echter Zufall laesst sich nicht packen, deflate waere groesser als das
|
||||
// Original. Eine gerechnete Folge taugt dafuer nicht - `i * k % 256`
|
||||
// wiederholt sich nach 256 Werten und ist bestens komprimierbar.
|
||||
const rauschen = crypto.randomBytes(4096);
|
||||
const zip = writeZip([{ name: 'r.bin', data: rauschen }]);
|
||||
assert.equal(zip.readUInt16LE(8), 0, 'Verfahren 0 = gespeichert');
|
||||
assert.deepEqual(readZip(zip)[0]?.data(), rauschen);
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
// ZIP lesen. Node bringt zlib mit, aber kein Archivformat, und das Projekt
|
||||
// kommt ohne Abhaengigkeiten aus (ADR-0001) - wie beim YAML-Parser und beim
|
||||
// obs-websocket-Client also selbst gebaut.
|
||||
//
|
||||
// Gebraucht wird das an zwei Stellen: beim Paketbau kommen ffmpeg,
|
||||
// whisper.cpp und Node als ZIP von ihren Herausgebern, und der Updater auf
|
||||
// Abdus Rechner packt damit das neue Paket aus - ohne PowerShell, ohne
|
||||
// externes Programm.
|
||||
|
||||
import zlib from 'node:zlib';
|
||||
|
||||
const EOCD = 0x06054b50; // End of Central Directory
|
||||
const CENTRAL = 0x02014b50; // Eintrag im Inhaltsverzeichnis
|
||||
const LOCAL = 0x04034b50; // Kopf vor den eigentlichen Daten
|
||||
|
||||
export type ZipEntry = {
|
||||
name: string;
|
||||
/** Unkomprimierte Groesse in Bytes. */
|
||||
size: number;
|
||||
/** Verzeichniseintraege haben keine Daten. */
|
||||
isDirectory: boolean;
|
||||
data: () => Buffer;
|
||||
};
|
||||
|
||||
/**
|
||||
* Inhaltsverzeichnis eines ZIP lesen.
|
||||
*
|
||||
* Die Daten werden erst beim Aufruf von `data()` entpackt - beim Paketbau
|
||||
* werden von 800 Eintraegen keine 30 gebraucht.
|
||||
*/
|
||||
export function readZip(buf: Buffer): ZipEntry[] {
|
||||
const eocd = findEocd(buf);
|
||||
const count = buf.readUInt16LE(eocd + 10);
|
||||
const dirSize = buf.readUInt32LE(eocd + 12);
|
||||
const dirStart = buf.readUInt32LE(eocd + 16);
|
||||
|
||||
if (dirStart === 0xffffffff || dirSize === 0xffffffff || count === 0xffff) {
|
||||
throw new Error('ZIP64 wird nicht unterstuetzt - das Archiv ist zu gross.');
|
||||
}
|
||||
|
||||
const entries: ZipEntry[] = [];
|
||||
let p = dirStart;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (buf.readUInt32LE(p) !== CENTRAL) {
|
||||
throw new Error(`Beschaedigtes Inhaltsverzeichnis bei Eintrag ${i}`);
|
||||
}
|
||||
const method = buf.readUInt16LE(p + 10);
|
||||
const compressed = buf.readUInt32LE(p + 20);
|
||||
const size = buf.readUInt32LE(p + 24);
|
||||
const nameLen = buf.readUInt16LE(p + 28);
|
||||
const extraLen = buf.readUInt16LE(p + 30);
|
||||
const commentLen = buf.readUInt16LE(p + 32);
|
||||
const localOffset = buf.readUInt32LE(p + 42);
|
||||
const name = buf.toString('utf8', p + 46, p + 46 + nameLen);
|
||||
|
||||
entries.push({
|
||||
name,
|
||||
size,
|
||||
isDirectory: name.endsWith('/'),
|
||||
data: () => extract(buf, localOffset, method, compressed, size, name),
|
||||
});
|
||||
|
||||
p += 46 + nameLen + extraLen + commentLen;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function extract(
|
||||
buf: Buffer, localOffset: number, method: number,
|
||||
compressed: number, size: number, name: string
|
||||
): Buffer {
|
||||
if (buf.readUInt32LE(localOffset) !== LOCAL) {
|
||||
throw new Error(`Kein lokaler Dateikopf fuer ${name}`);
|
||||
}
|
||||
// Der lokale Kopf hat eigene Laengen fuer Name und Extra-Feld; sie koennen
|
||||
// von denen im Inhaltsverzeichnis abweichen und muessen von hier kommen.
|
||||
const nameLen = buf.readUInt16LE(localOffset + 26);
|
||||
const extraLen = buf.readUInt16LE(localOffset + 28);
|
||||
const start = localOffset + 30 + nameLen + extraLen;
|
||||
const raw = buf.subarray(start, start + compressed);
|
||||
|
||||
if (method === 0) return Buffer.from(raw);
|
||||
if (method === 8) {
|
||||
const out = zlib.inflateRawSync(raw);
|
||||
if (out.length !== size) {
|
||||
throw new Error(`${name}: ${out.length} Bytes entpackt, ${size} erwartet`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
throw new Error(`${name}: unbekanntes Kompressionsverfahren ${method}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Inhaltsverzeichnis steht am Dateiende, hinter einem Kommentar
|
||||
* beliebiger Laenge. Also von hinten suchen - hoechstens 64 KB weit, mehr
|
||||
* darf der Kommentar nicht sein.
|
||||
*/
|
||||
function findEocd(buf: Buffer): number {
|
||||
const min = Math.max(0, buf.length - 0x10000 - 22);
|
||||
for (let i = buf.length - 22; i >= min; i--) {
|
||||
if (buf.readUInt32LE(i) === EOCD) return i;
|
||||
}
|
||||
throw new Error('Kein ZIP-Inhaltsverzeichnis gefunden.');
|
||||
}
|
||||
|
||||
/**
|
||||
* ZIP schreiben. Nur so viel Format, wie der Windows-Explorer und
|
||||
* `Expand-Archive` verlangen - insbesondere die CRC32-Pruefsumme, sonst
|
||||
* halten beide das Archiv fuer beschaedigt.
|
||||
*/
|
||||
export function writeZip(files: Array<{ name: string; data: Buffer }>): Buffer {
|
||||
const locals: Buffer[] = [];
|
||||
const centrals: Buffer[] = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const f of files) {
|
||||
const name = Buffer.from(f.name.replace(/\\/g, '/'), 'utf8');
|
||||
const packed = zlib.deflateRawSync(f.data, { level: 9 });
|
||||
// Groesser als das Original kommt bei schon komprimierten Dateien vor -
|
||||
// dann unveraendert ablegen.
|
||||
const useDeflate = packed.length < f.data.length;
|
||||
const body = useDeflate ? packed : f.data;
|
||||
const crc = zlib.crc32(f.data);
|
||||
|
||||
const local = Buffer.alloc(30 + name.length);
|
||||
local.writeUInt32LE(LOCAL, 0);
|
||||
local.writeUInt16LE(20, 4); // benoetigte Version: 2.0
|
||||
local.writeUInt16LE(useDeflate ? 8 : 0, 8);
|
||||
local.writeUInt32LE(crc, 14);
|
||||
local.writeUInt32LE(body.length, 18);
|
||||
local.writeUInt32LE(f.data.length, 22);
|
||||
local.writeUInt16LE(name.length, 26);
|
||||
name.copy(local, 30);
|
||||
locals.push(local, body);
|
||||
|
||||
const central = Buffer.alloc(46 + name.length);
|
||||
central.writeUInt32LE(CENTRAL, 0);
|
||||
central.writeUInt16LE(20, 4);
|
||||
central.writeUInt16LE(20, 6);
|
||||
central.writeUInt16LE(useDeflate ? 8 : 0, 10);
|
||||
central.writeUInt32LE(crc, 16);
|
||||
central.writeUInt32LE(body.length, 20);
|
||||
central.writeUInt32LE(f.data.length, 24);
|
||||
central.writeUInt16LE(name.length, 28);
|
||||
central.writeUInt32LE(offset, 42);
|
||||
name.copy(central, 46);
|
||||
centrals.push(central);
|
||||
|
||||
offset += local.length + body.length;
|
||||
}
|
||||
|
||||
const dir = Buffer.concat(centrals);
|
||||
const eocd = Buffer.alloc(22);
|
||||
eocd.writeUInt32LE(EOCD, 0);
|
||||
eocd.writeUInt16LE(files.length, 8);
|
||||
eocd.writeUInt16LE(files.length, 10);
|
||||
eocd.writeUInt32LE(dir.length, 12);
|
||||
eocd.writeUInt32LE(offset, 16);
|
||||
|
||||
const out = Buffer.concat([...locals, dir, eocd]);
|
||||
if (out.length > 0xffffffff) throw new Error('Ueber 4 GB braeuchte ZIP64.');
|
||||
return out;
|
||||
}
|
||||
Reference in new issue
Block a user