mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-09 18:57:08 +08:00
add-teams: app-package assets as data — manifest template, mascot icons, extracted zip writer
The manifest is now a checked-in manifest.template.json (code only patches the per-install fields: app id, name, domain, optional RSC block), the two icons are committed PNGs rendered from the NanoClaw mascot instead of a 160-line in-process PNG encoder, and the minimal stored-zip writer moves to setup/lib/zip.ts. buildTeamsAppPackage's interface is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.16/MicrosoftTeams.schema.json",
|
||||
"manifestVersion": "1.16",
|
||||
"version": "1.0.0",
|
||||
"id": "00000000-0000-0000-0000-000000000000",
|
||||
"packageName": "com.nanoclaw.bot",
|
||||
"developer": {
|
||||
"name": "NanoClaw",
|
||||
"websiteUrl": "https://nanoclaw.invalid",
|
||||
"privacyUrl": "https://nanoclaw.invalid",
|
||||
"termsOfUseUrl": "https://nanoclaw.invalid"
|
||||
},
|
||||
"name": {
|
||||
"short": "NanoClaw",
|
||||
"full": "NanoClaw Assistant"
|
||||
},
|
||||
"description": {
|
||||
"short": "Your personal assistant in Teams.",
|
||||
"full": "NanoClaw personal assistant."
|
||||
},
|
||||
"icons": {
|
||||
"outline": "outline.png",
|
||||
"color": "color.png"
|
||||
},
|
||||
"accentColor": "#4A90D9",
|
||||
"bots": [
|
||||
{
|
||||
"botId": "00000000-0000-0000-0000-000000000000",
|
||||
"scopes": ["personal", "team", "groupchat"],
|
||||
"supportsFiles": false,
|
||||
"isNotificationOnly": false
|
||||
}
|
||||
],
|
||||
"permissions": ["identity", "messageTeamMembers"],
|
||||
"validDomains": ["nanoclaw.invalid"]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 392 B |
@@ -113,6 +113,12 @@ describe('buildTeamsAppPackage', () => {
|
||||
expect(manifest.icons).toEqual({ outline: 'outline.png', color: 'color.png' });
|
||||
});
|
||||
|
||||
it('leaves no template placeholder values in the rendered manifest', () => {
|
||||
const raw = entries[0].data.toString('utf8');
|
||||
expect(raw).not.toContain('00000000-0000-0000-0000-000000000000');
|
||||
expect(raw).not.toContain('nanoclaw.invalid');
|
||||
});
|
||||
|
||||
it('emits real PNGs for both icons', () => {
|
||||
expect(entries[1].data.subarray(0, 8).equals(PNG_SIG)).toBe(true);
|
||||
expect(entries[2].data.subarray(0, 8).equals(PNG_SIG)).toBe(true);
|
||||
|
||||
+54
-286
@@ -7,23 +7,19 @@
|
||||
* - outline.png — 32×32 transparent outline icon
|
||||
* - color.png — 192×192 full-color icon
|
||||
*
|
||||
* Icons are generated in-process using a minimal PNG encoder, and the zip is
|
||||
* written in-process too, so we need neither ImageMagick nor a `zip` binary
|
||||
* on the host, nor vendored binary blobs in the repo. The outline icon is a
|
||||
* simple rounded square outline; the color icon is a brand-blue filled
|
||||
* square with a small white "N" blocked in by pixel setting. Good enough
|
||||
* for a working sideload — teams admins who care can replace the icons
|
||||
* later.
|
||||
*
|
||||
* The manifest is pinned to schema v1.16 to match the skill doc.
|
||||
* The static parts live in setup/assets/teams/ — manifest.template.json
|
||||
* (pinned to schema v1.16 to match the skill doc) plus the two icons — so
|
||||
* the manifest is reviewable as plain JSON. This module only fills in the
|
||||
* per-install fields (app id, name, domain, optional RSC block) and zips the
|
||||
* three files in-process via ./zip.ts, so no `zip` binary is needed on the
|
||||
* host.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import zlib from 'zlib';
|
||||
|
||||
const MANIFEST_SCHEMA =
|
||||
'https://developer.microsoft.com/en-us/json-schemas/teams/v1.16/MicrosoftTeams.schema.json';
|
||||
const MANIFEST_VERSION = '1.16';
|
||||
import { buildZip } from './zip.js';
|
||||
|
||||
const ASSETS_DIR = new URL('../assets/teams/', import.meta.url);
|
||||
|
||||
export interface ManifestOptions {
|
||||
/** The Azure AD app ID (same value used for `bots[0].botId`). */
|
||||
@@ -50,6 +46,19 @@ export interface ManifestResult {
|
||||
colorPath: string;
|
||||
}
|
||||
|
||||
/** The manifest.template.json fields this module rewrites per install. */
|
||||
interface ManifestTemplate {
|
||||
version: string;
|
||||
id: string;
|
||||
developer: { name: string; websiteUrl: string; privacyUrl: string; termsOfUseUrl: string };
|
||||
name: { short: string; full: string };
|
||||
description: { short: string; full: string };
|
||||
bots: [{ botId: string }];
|
||||
validDomains: string[];
|
||||
webApplicationInfo?: { id: string; resource: string };
|
||||
authorization?: { permissions: { resourceSpecific: Array<{ name: string; type: string }> } };
|
||||
}
|
||||
|
||||
/** Build the full app package zip and return the paths. */
|
||||
export function buildTeamsAppPackage(opts: ManifestOptions): ManifestResult {
|
||||
fs.mkdirSync(opts.outDir, { recursive: true });
|
||||
@@ -60,8 +69,8 @@ export function buildTeamsAppPackage(opts: ManifestOptions): ManifestResult {
|
||||
const zipPath = path.join(opts.outDir, 'teams-app-package.zip');
|
||||
|
||||
const manifest = Buffer.from(renderManifest(opts));
|
||||
const outline = encodeOutlineIcon();
|
||||
const color = encodeColorIcon();
|
||||
const outline = fs.readFileSync(new URL('outline.png', ASSETS_DIR));
|
||||
const color = fs.readFileSync(new URL('color.png', ASSETS_DIR));
|
||||
|
||||
fs.writeFileSync(manifestPath, manifest);
|
||||
fs.writeFileSync(outlinePath, outline);
|
||||
@@ -78,280 +87,39 @@ export function buildTeamsAppPackage(opts: ManifestOptions): ManifestResult {
|
||||
return { zipPath, manifestPath, outlinePath, colorPath };
|
||||
}
|
||||
|
||||
// ─── Minimal ZIP writer (no external deps, no `zip` binary) ───────────────
|
||||
//
|
||||
// Entries are stored uncompressed (method 0): the package is three tiny
|
||||
// files, and stored entries keep this trivially small and byte-deterministic
|
||||
// (fixed 1980-01-01 timestamp), which the test relies on.
|
||||
|
||||
interface ZipEntry {
|
||||
name: string;
|
||||
data: Buffer;
|
||||
}
|
||||
|
||||
// DOS-format date for 1980-01-01: bits 15–9 year-1980, 8–5 month, 4–0 day.
|
||||
const ZIP_DOS_DATE = (1 << 5) | 1;
|
||||
|
||||
function buildZip(entries: ZipEntry[]): Buffer {
|
||||
const locals: Buffer[] = [];
|
||||
const centrals: Buffer[] = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const { name, data } of entries) {
|
||||
const nameBuf = Buffer.from(name, 'ascii');
|
||||
const crc = crc32(data);
|
||||
|
||||
const local = Buffer.alloc(30);
|
||||
local.writeUInt32LE(0x04034b50, 0); // local file header signature
|
||||
local.writeUInt16LE(20, 4); // version needed to extract (2.0)
|
||||
local.writeUInt16LE(0, 8); // compression method: stored
|
||||
local.writeUInt16LE(ZIP_DOS_DATE, 12);
|
||||
local.writeUInt32LE(crc, 14);
|
||||
local.writeUInt32LE(data.length, 18); // compressed size
|
||||
local.writeUInt32LE(data.length, 22); // uncompressed size
|
||||
local.writeUInt16LE(nameBuf.length, 26);
|
||||
locals.push(local, nameBuf, data);
|
||||
|
||||
const central = Buffer.alloc(46);
|
||||
central.writeUInt32LE(0x02014b50, 0); // central directory header signature
|
||||
central.writeUInt16LE(20, 4); // version made by
|
||||
central.writeUInt16LE(20, 6); // version needed to extract
|
||||
central.writeUInt16LE(0, 10); // compression method: stored
|
||||
central.writeUInt16LE(ZIP_DOS_DATE, 14);
|
||||
central.writeUInt32LE(crc, 16);
|
||||
central.writeUInt32LE(data.length, 20); // compressed size
|
||||
central.writeUInt32LE(data.length, 24); // uncompressed size
|
||||
central.writeUInt16LE(nameBuf.length, 28);
|
||||
central.writeUInt32LE(offset, 42); // offset of local header
|
||||
centrals.push(central, nameBuf);
|
||||
|
||||
offset += 30 + nameBuf.length + data.length;
|
||||
}
|
||||
|
||||
const centralDir = Buffer.concat(centrals);
|
||||
const eocd = Buffer.alloc(22);
|
||||
eocd.writeUInt32LE(0x06054b50, 0); // end-of-central-directory signature
|
||||
eocd.writeUInt16LE(entries.length, 8); // entries on this disk
|
||||
eocd.writeUInt16LE(entries.length, 10); // entries total
|
||||
eocd.writeUInt32LE(centralDir.length, 12);
|
||||
eocd.writeUInt32LE(offset, 16); // central directory offset
|
||||
|
||||
return Buffer.concat([...locals, centralDir, eocd]);
|
||||
}
|
||||
|
||||
function renderManifest(opts: ManifestOptions): string {
|
||||
const manifest = {
|
||||
$schema: MANIFEST_SCHEMA,
|
||||
manifestVersion: MANIFEST_VERSION,
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(new URL('manifest.template.json', ASSETS_DIR), 'utf8'),
|
||||
) as ManifestTemplate;
|
||||
|
||||
manifest.id = opts.appId;
|
||||
manifest.bots[0].botId = opts.appId;
|
||||
manifest.name.short = opts.shortName.slice(0, 30);
|
||||
manifest.name.full = `${opts.shortName} Assistant`;
|
||||
manifest.description.full = opts.longDescription;
|
||||
manifest.developer.websiteUrl = opts.websiteUrl;
|
||||
manifest.developer.privacyUrl = opts.websiteUrl;
|
||||
manifest.developer.termsOfUseUrl = opts.websiteUrl;
|
||||
manifest.validDomains = [new URL(opts.websiteUrl).host];
|
||||
|
||||
if (opts.rsc) {
|
||||
// Teams app-update flows want a higher version than the already-uploaded
|
||||
// package, so the RSC variant (typically a re-upload) bumps it.
|
||||
version: opts.rsc ? '1.1.0' : '1.0.0',
|
||||
id: opts.appId,
|
||||
packageName: 'com.nanoclaw.bot',
|
||||
developer: {
|
||||
name: 'NanoClaw',
|
||||
websiteUrl: opts.websiteUrl,
|
||||
privacyUrl: opts.websiteUrl,
|
||||
termsOfUseUrl: opts.websiteUrl,
|
||||
},
|
||||
name: {
|
||||
short: opts.shortName.slice(0, 30),
|
||||
full: `${opts.shortName} Assistant`,
|
||||
},
|
||||
description: {
|
||||
short: 'Your personal assistant in Teams.',
|
||||
full: opts.longDescription,
|
||||
},
|
||||
icons: { outline: 'outline.png', color: 'color.png' },
|
||||
accentColor: '#4A90D9',
|
||||
bots: [
|
||||
{
|
||||
botId: opts.appId,
|
||||
scopes: ['personal', 'team', 'groupchat'],
|
||||
supportsFiles: false,
|
||||
isNotificationOnly: false,
|
||||
manifest.version = '1.1.0';
|
||||
// RSC grants bind to webApplicationInfo.id, not bots[].botId — without
|
||||
// this block the permissions are never attached to the app and the bot
|
||||
// silently keeps requiring @-mention. `resource` must be non-empty but
|
||||
// its value is unused for RSC-only apps.
|
||||
manifest.webApplicationInfo = { id: opts.appId, resource: 'https://notapplicable' };
|
||||
manifest.authorization = {
|
||||
permissions: {
|
||||
resourceSpecific: [
|
||||
{ name: 'ChannelMessage.Read.Group', type: 'Application' },
|
||||
{ name: 'ChatMessage.Read.Chat', type: 'Application' },
|
||||
],
|
||||
},
|
||||
],
|
||||
permissions: ['identity', 'messageTeamMembers'],
|
||||
validDomains: [new URL(opts.websiteUrl).host],
|
||||
...(opts.rsc && {
|
||||
// RSC grants bind to webApplicationInfo.id, not bots[].botId — without
|
||||
// this block the permissions are never attached to the app and the bot
|
||||
// silently keeps requiring @-mention. `resource` must be non-empty but
|
||||
// its value is unused for RSC-only apps.
|
||||
webApplicationInfo: { id: opts.appId, resource: 'https://notapplicable' },
|
||||
authorization: {
|
||||
permissions: {
|
||||
resourceSpecific: [
|
||||
{ name: 'ChannelMessage.Read.Group', type: 'Application' },
|
||||
{ name: 'ChatMessage.Read.Chat', type: 'Application' },
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
return JSON.stringify(manifest, null, 2) + '\n';
|
||||
}
|
||||
|
||||
// ─── Minimal PNG encoder (solid color, no external deps) ──────────────────
|
||||
|
||||
const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
// Precompute the CRC-32 table per the PNG spec. Node doesn't expose CRC32
|
||||
// directly (zlib.crc32 isn't part of the public API), so we roll our own.
|
||||
const CRC_TABLE = (() => {
|
||||
const table = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) {
|
||||
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
}
|
||||
table[n] = c >>> 0;
|
||||
}
|
||||
return table;
|
||||
})();
|
||||
|
||||
function crc32(buf: Buffer): number {
|
||||
let c = 0xffffffff;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
||||
}
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function chunk(type: string, data: Buffer): Buffer {
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(data.length, 0);
|
||||
const typeBuf = Buffer.from(type, 'ascii');
|
||||
const crcBuf = Buffer.alloc(4);
|
||||
crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
|
||||
return Buffer.concat([len, typeBuf, data, crcBuf]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a solid-color RGBA image as a PNG. `pixels` is a width*height*4
|
||||
* byte array (R, G, B, A per pixel, row-major, top-to-bottom).
|
||||
*/
|
||||
function encodePng(width: number, height: number, pixels: Uint8Array): Buffer {
|
||||
// IHDR
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(width, 0);
|
||||
ihdr.writeUInt32BE(height, 4);
|
||||
ihdr[8] = 8; // bit depth
|
||||
ihdr[9] = 6; // color type: RGBA
|
||||
ihdr[10] = 0; // compression
|
||||
ihdr[11] = 0; // filter
|
||||
ihdr[12] = 0; // interlace
|
||||
|
||||
// IDAT: scanlines with filter byte 0 (None) prepended per row.
|
||||
const rowBytes = width * 4;
|
||||
const raw = Buffer.alloc(height * (rowBytes + 1));
|
||||
for (let y = 0; y < height; y++) {
|
||||
raw[y * (rowBytes + 1)] = 0;
|
||||
for (let x = 0; x < rowBytes; x++) {
|
||||
raw[y * (rowBytes + 1) + 1 + x] = pixels[y * rowBytes + x];
|
||||
}
|
||||
}
|
||||
const idat = zlib.deflateSync(raw);
|
||||
|
||||
return Buffer.concat([
|
||||
PNG_SIG,
|
||||
chunk('IHDR', ihdr),
|
||||
chunk('IDAT', idat),
|
||||
chunk('IEND', Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Outline icon: 32×32 transparent background with a simple white rounded-
|
||||
* square outline. Teams renders it against a colored background so the
|
||||
* outline needs to be visible on both light and dark.
|
||||
*/
|
||||
function encodeOutlineIcon(): Buffer {
|
||||
const size = 32;
|
||||
const pixels = new Uint8Array(size * size * 4);
|
||||
const inset = 4;
|
||||
const stroke = 2;
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
const onBorder =
|
||||
((x >= inset && x < inset + stroke) || (x >= size - inset - stroke && x < size - inset)) &&
|
||||
y >= inset &&
|
||||
y < size - inset;
|
||||
const onTopBot =
|
||||
((y >= inset && y < inset + stroke) || (y >= size - inset - stroke && y < size - inset)) &&
|
||||
x >= inset &&
|
||||
x < size - inset;
|
||||
const i = (y * size + x) * 4;
|
||||
if (onBorder || onTopBot) {
|
||||
pixels[i] = 255;
|
||||
pixels[i + 1] = 255;
|
||||
pixels[i + 2] = 255;
|
||||
pixels[i + 3] = 255;
|
||||
} else {
|
||||
pixels[i] = 0;
|
||||
pixels[i + 1] = 0;
|
||||
pixels[i + 2] = 0;
|
||||
pixels[i + 3] = 0; // transparent
|
||||
}
|
||||
}
|
||||
}
|
||||
return encodePng(size, size, pixels);
|
||||
}
|
||||
|
||||
/**
|
||||
* Color icon: 192×192 brand-blue filled square with a white "N" shape drawn
|
||||
* with simple bars (left vertical, right vertical, diagonal from top-right
|
||||
* to bottom-left). Crude but recognizable at a glance.
|
||||
*/
|
||||
function encodeColorIcon(): Buffer {
|
||||
const size = 192;
|
||||
const pixels = new Uint8Array(size * size * 4);
|
||||
// Brand blue #4A90D9
|
||||
const BG_R = 0x4a;
|
||||
const BG_G = 0x90;
|
||||
const BG_B = 0xd9;
|
||||
const thickness = 24;
|
||||
const margin = 40;
|
||||
const leftBarX = margin;
|
||||
const rightBarX = size - margin - thickness;
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
const i = (y * size + x) * 4;
|
||||
pixels[i] = BG_R;
|
||||
pixels[i + 1] = BG_G;
|
||||
pixels[i + 2] = BG_B;
|
||||
pixels[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
// Vertical bars
|
||||
for (let y = margin; y < size - margin; y++) {
|
||||
for (let dx = 0; dx < thickness; dx++) {
|
||||
setWhite(pixels, size, leftBarX + dx, y);
|
||||
setWhite(pixels, size, rightBarX + dx, y);
|
||||
}
|
||||
}
|
||||
// Diagonal from top-right of left bar to bottom-left of right bar
|
||||
const diagSteps = size - margin * 2;
|
||||
for (let s = 0; s < diagSteps; s++) {
|
||||
const t = s / (diagSteps - 1);
|
||||
const cx = Math.round(leftBarX + thickness + t * (rightBarX - leftBarX - thickness));
|
||||
const cy = Math.round(margin + t * (size - margin * 2 - 1));
|
||||
for (let dx = -Math.floor(thickness / 2); dx < Math.ceil(thickness / 2); dx++) {
|
||||
for (let dy = -2; dy <= 2; dy++) {
|
||||
setWhite(pixels, size, cx + dx, cy + dy);
|
||||
}
|
||||
}
|
||||
}
|
||||
return encodePng(size, size, pixels);
|
||||
}
|
||||
|
||||
function setWhite(pixels: Uint8Array, size: number, x: number, y: number): void {
|
||||
if (x < 0 || x >= size || y < 0 || y >= size) return;
|
||||
const i = (y * size + x) * 4;
|
||||
pixels[i] = 255;
|
||||
pixels[i + 1] = 255;
|
||||
pixels[i + 2] = 255;
|
||||
pixels[i + 3] = 255;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Minimal in-process ZIP writer — needs no external dep and no `zip` binary
|
||||
* on the host (minimal Linux images often lack one).
|
||||
*
|
||||
* Entries are stored uncompressed (method 0): callers package a few tiny
|
||||
* files, and stored entries keep the output trivially small and
|
||||
* byte-deterministic (fixed 1980-01-01 timestamp), which tests rely on.
|
||||
* ASCII entry names only; no zip64 — fine below 4 GB and 65k entries.
|
||||
*/
|
||||
|
||||
export interface ZipEntry {
|
||||
name: string;
|
||||
data: Buffer;
|
||||
}
|
||||
|
||||
// DOS-format date for 1980-01-01: bits 15–9 year-1980, 8–5 month, 4–0 day.
|
||||
const ZIP_DOS_DATE = (1 << 5) | 1;
|
||||
|
||||
export function buildZip(entries: ZipEntry[]): Buffer {
|
||||
const locals: Buffer[] = [];
|
||||
const centrals: Buffer[] = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const { name, data } of entries) {
|
||||
const nameBuf = Buffer.from(name, 'ascii');
|
||||
const crc = crc32(data);
|
||||
|
||||
const local = Buffer.alloc(30);
|
||||
local.writeUInt32LE(0x04034b50, 0); // local file header signature
|
||||
local.writeUInt16LE(20, 4); // version needed to extract (2.0)
|
||||
local.writeUInt16LE(0, 8); // compression method: stored
|
||||
local.writeUInt16LE(ZIP_DOS_DATE, 12);
|
||||
local.writeUInt32LE(crc, 14);
|
||||
local.writeUInt32LE(data.length, 18); // compressed size
|
||||
local.writeUInt32LE(data.length, 22); // uncompressed size
|
||||
local.writeUInt16LE(nameBuf.length, 26);
|
||||
locals.push(local, nameBuf, data);
|
||||
|
||||
const central = Buffer.alloc(46);
|
||||
central.writeUInt32LE(0x02014b50, 0); // central directory header signature
|
||||
central.writeUInt16LE(20, 4); // version made by
|
||||
central.writeUInt16LE(20, 6); // version needed to extract
|
||||
central.writeUInt16LE(0, 10); // compression method: stored
|
||||
central.writeUInt16LE(ZIP_DOS_DATE, 14);
|
||||
central.writeUInt32LE(crc, 16);
|
||||
central.writeUInt32LE(data.length, 20); // compressed size
|
||||
central.writeUInt32LE(data.length, 24); // uncompressed size
|
||||
central.writeUInt16LE(nameBuf.length, 28);
|
||||
central.writeUInt32LE(offset, 42); // offset of local header
|
||||
centrals.push(central, nameBuf);
|
||||
|
||||
offset += 30 + nameBuf.length + data.length;
|
||||
}
|
||||
|
||||
const centralDir = Buffer.concat(centrals);
|
||||
const eocd = Buffer.alloc(22);
|
||||
eocd.writeUInt32LE(0x06054b50, 0); // end-of-central-directory signature
|
||||
eocd.writeUInt16LE(entries.length, 8); // entries on this disk
|
||||
eocd.writeUInt16LE(entries.length, 10); // entries total
|
||||
eocd.writeUInt32LE(centralDir.length, 12);
|
||||
eocd.writeUInt32LE(offset, 16); // central directory offset
|
||||
|
||||
return Buffer.concat([...locals, centralDir, eocd]);
|
||||
}
|
||||
|
||||
// Precompute the CRC-32 table per the ZIP spec. zlib.crc32 only became
|
||||
// public API in Node 20.15/22.2, so we roll our own rather than gamble on
|
||||
// the host's Node version.
|
||||
const CRC_TABLE = (() => {
|
||||
const table = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) {
|
||||
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
}
|
||||
table[n] = c >>> 0;
|
||||
}
|
||||
return table;
|
||||
})();
|
||||
|
||||
function crc32(buf: Buffer): number {
|
||||
let c = 0xffffffff;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
||||
}
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
Reference in New Issue
Block a user