@bobfrankston/mailx 1.0.457 → 1.0.459

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,17 +1,25 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Wrap client/icon.png into client/icon.ico using the ICO-with-embedded-PNG
4
- * format (Windows Vista+). No image decoding neededWindows accepts a PNG
5
- * bitstream inside an ICONDIR + ICONDIRENTRY prelude, so we can hand-roll the
6
- * binary without any imaging dependency.
3
+ * Build client/icon.ico from client/icon.png with proper format coverage:
4
+ * - 16, 32, 48, 64, 128 px entries: BMP-DIB (BI_RGB) required by the
5
+ * Windows Shell taskbar/AUMID code path. PNG-in-ICO at small sizes is
6
+ * silently rejected, leaving the taskbar showing the generic "document"
7
+ * icon (the bug behind "icon shows as a piece of paper").
8
+ * - 256 px entry: PNG (Vista+ accepts it; smaller file than the 256 KB
9
+ * a 256x256 BMP DIB would be).
7
10
  *
8
11
  * Run: node bin/build-icon-ico.js
9
12
  *
10
- * Output: client/icon.ico derived from client/icon.png. Re-run whenever the
11
- * source PNG changes.
13
+ * Source: client/icon.png. Re-run whenever the source PNG changes.
14
+ *
15
+ * Hand-rolled — no image deps available in the workspace. Decodes 8-bit
16
+ * RGBA / RGB non-interlaced PNG (which is what every standard tool emits),
17
+ * downsamples by box-averaging, encodes BMP DIBs with the trailing AND mask
18
+ * the ICO format requires.
12
19
  */
13
20
  import fs from "node:fs";
14
21
  import path from "node:path";
22
+ import zlib from "node:zlib";
15
23
 
16
24
  const root = path.resolve(import.meta.dirname, "..");
17
25
  const src = path.join(root, "client", "icon.png");
@@ -22,38 +30,254 @@ if (!fs.existsSync(src)) {
22
30
  process.exit(1);
23
31
  }
24
32
 
25
- const png = fs.readFileSync(src);
33
+ const pngBuf = fs.readFileSync(src);
26
34
 
27
- // Parse PNG IHDR for width / height. PNG magic (8 bytes) + IHDR chunk length
28
- // (4 bytes) + "IHDR" (4 bytes), then width (4) + height (4) at offsets 16/20.
29
- if (png.length < 24 || png.toString("ascii", 12, 16) !== "IHDR") {
30
- console.error("build-icon-ico: source is not a PNG (missing IHDR)");
31
- process.exit(1);
35
+ // ── PNG decoder (8-bit RGBA / RGB, no interlace) ─────────────────────────
36
+ function decodePNG(buf) {
37
+ const sigExpected = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
38
+ if (buf.length < 8 || sigExpected.some((b, i) => buf[i] !== b)) {
39
+ throw new Error("not a PNG (bad signature)");
40
+ }
41
+ let pos = 8;
42
+ let width = 0, height = 0, bitDepth = 0, colorType = 0;
43
+ const idat = [];
44
+ while (pos < buf.length) {
45
+ const len = buf.readUInt32BE(pos); pos += 4;
46
+ const type = buf.toString("ascii", pos, pos + 4); pos += 4;
47
+ const data = buf.subarray(pos, pos + len); pos += len + 4; // skip CRC
48
+ if (type === "IHDR") {
49
+ width = data.readUInt32BE(0);
50
+ height = data.readUInt32BE(4);
51
+ bitDepth = data.readUInt8(8);
52
+ colorType = data.readUInt8(9);
53
+ if (data.readUInt8(12) !== 0) {
54
+ throw new Error("interlaced PNG not supported");
55
+ }
56
+ } else if (type === "IDAT") {
57
+ idat.push(data);
58
+ } else if (type === "IEND") {
59
+ break;
60
+ }
61
+ }
62
+ if (bitDepth !== 8 && bitDepth !== 16) {
63
+ throw new Error(`bit depth ${bitDepth} not supported (need 8 or 16)`);
64
+ }
65
+ if (colorType !== 6 && colorType !== 2) {
66
+ throw new Error(`color type ${colorType} not supported (need 2=RGB or 6=RGBA)`);
67
+ }
68
+ const samplesPerPixel = colorType === 6 ? 4 : 3;
69
+ const bytesPerSample = bitDepth / 8; // 1 or 2
70
+ const bpp = samplesPerPixel * bytesPerSample;
71
+ const inflated = zlib.inflateSync(Buffer.concat(idat));
72
+ const stride = width * bpp;
73
+ const rgba = Buffer.alloc(width * height * 4);
74
+ let inPos = 0;
75
+ let prev = Buffer.alloc(stride);
76
+ let cur = Buffer.alloc(stride);
77
+ for (let y = 0; y < height; y++) {
78
+ const filter = inflated.readUInt8(inPos); inPos += 1;
79
+ inflated.copy(cur, 0, inPos, inPos + stride); inPos += stride;
80
+ switch (filter) {
81
+ case 0: break; // None
82
+ case 1: // Sub
83
+ for (let x = bpp; x < stride; x++) cur[x] = (cur[x] + cur[x - bpp]) & 0xFF;
84
+ break;
85
+ case 2: // Up
86
+ for (let x = 0; x < stride; x++) cur[x] = (cur[x] + prev[x]) & 0xFF;
87
+ break;
88
+ case 3: // Avg
89
+ for (let x = 0; x < stride; x++) {
90
+ const left = x >= bpp ? cur[x - bpp] : 0;
91
+ cur[x] = (cur[x] + ((left + prev[x]) >> 1)) & 0xFF;
92
+ }
93
+ break;
94
+ case 4: { // Paeth
95
+ for (let x = 0; x < stride; x++) {
96
+ const a = x >= bpp ? cur[x - bpp] : 0;
97
+ const b = prev[x];
98
+ const c = x >= bpp ? prev[x - bpp] : 0;
99
+ const p = a + b - c;
100
+ const pa = Math.abs(p - a);
101
+ const pb = Math.abs(p - b);
102
+ const pc = Math.abs(p - c);
103
+ const pred = pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
104
+ cur[x] = (cur[x] + pred) & 0xFF;
105
+ }
106
+ break;
107
+ }
108
+ default: throw new Error(`unknown PNG filter ${filter}`);
109
+ }
110
+ // 16-bit samples are big-endian; we keep only the high byte (eyeball-
111
+ // identical to dividing by 257 for icon use).
112
+ for (let x = 0; x < width; x++) {
113
+ const di = (y * width + x) * 4;
114
+ const si = x * bpp;
115
+ if (bitDepth === 8) {
116
+ rgba[di + 0] = cur[si + 0];
117
+ rgba[di + 1] = cur[si + 1];
118
+ rgba[di + 2] = cur[si + 2];
119
+ rgba[di + 3] = samplesPerPixel === 4 ? cur[si + 3] : 255;
120
+ } else {
121
+ rgba[di + 0] = cur[si + 0]; // hi byte of R
122
+ rgba[di + 1] = cur[si + 2]; // hi byte of G
123
+ rgba[di + 2] = cur[si + 4]; // hi byte of B
124
+ rgba[di + 3] = samplesPerPixel === 4 ? cur[si + 6] : 255;
125
+ }
126
+ }
127
+ const swap = prev; prev = cur; cur = swap;
128
+ }
129
+ return { width, height, rgba };
130
+ }
131
+
132
+ // ── Box-filter downsample (good enough for icon sizes) ───────────────────
133
+ function downsample(src, srcW, srcH, dstW, dstH) {
134
+ if (srcW === dstW && srcH === dstH) return Buffer.from(src);
135
+ const dst = Buffer.alloc(dstW * dstH * 4);
136
+ const sx = srcW / dstW;
137
+ const sy = srcH / dstH;
138
+ for (let y = 0; y < dstH; y++) {
139
+ const y0 = Math.floor(y * sy);
140
+ const y1 = Math.min(srcH, Math.ceil((y + 1) * sy));
141
+ for (let x = 0; x < dstW; x++) {
142
+ const x0 = Math.floor(x * sx);
143
+ const x1 = Math.min(srcW, Math.ceil((x + 1) * sx));
144
+ let r = 0, g = 0, b = 0, a = 0, n = 0;
145
+ for (let yy = y0; yy < y1; yy++) {
146
+ for (let xx = x0; xx < x1; xx++) {
147
+ const i = (yy * srcW + xx) * 4;
148
+ r += src[i]; g += src[i + 1]; b += src[i + 2]; a += src[i + 3];
149
+ n++;
150
+ }
151
+ }
152
+ const di = (y * dstW + x) * 4;
153
+ dst[di] = (r / n) | 0;
154
+ dst[di + 1] = (g / n) | 0;
155
+ dst[di + 2] = (b / n) | 0;
156
+ dst[di + 3] = (a / n) | 0;
157
+ }
158
+ }
159
+ return dst;
160
+ }
161
+
162
+ // ── BMP DIB encoder for ICO entries (32bpp BGRA + AND mask) ──────────────
163
+ function encodeDIB(rgba, w, h) {
164
+ const colorBytes = w * h * 4;
165
+ const maskRowSize = ((w + 31) >> 5) << 2; // 1bpp, 4-byte aligned
166
+ const maskBytes = maskRowSize * h;
167
+ const out = Buffer.alloc(40 + colorBytes + maskBytes);
168
+ // BITMAPINFOHEADER
169
+ out.writeUInt32LE(40, 0); // biSize
170
+ out.writeInt32LE(w, 4); // biWidth
171
+ out.writeInt32LE(h * 2, 8); // biHeight = 2*h (color + mask)
172
+ out.writeUInt16LE(1, 12); // biPlanes
173
+ out.writeUInt16LE(32, 14); // biBitCount
174
+ out.writeUInt32LE(0, 16); // biCompression (BI_RGB)
175
+ // remaining BITMAPINFOHEADER fields stay zero
176
+ // Color: 32bpp BGRA, bottom-up
177
+ let off = 40;
178
+ for (let y = h - 1; y >= 0; y--) {
179
+ for (let x = 0; x < w; x++) {
180
+ const i = (y * w + x) * 4;
181
+ out[off++] = rgba[i + 2]; // B
182
+ out[off++] = rgba[i + 1]; // G
183
+ out[off++] = rgba[i + 0]; // R
184
+ out[off++] = rgba[i + 3]; // A
185
+ }
186
+ }
187
+ // AND mask stays all-zero — alpha channel handles transparency. Required
188
+ // by ICO format though, so we allocate the bytes (already zeroed).
189
+ return out;
190
+ }
191
+
192
+ // ── ICO container ────────────────────────────────────────────────────────
193
+ function buildICO(entries) {
194
+ const dirSize = 6 + 16 * entries.length;
195
+ const dir = Buffer.alloc(dirSize);
196
+ dir.writeUInt16LE(0, 0); // reserved
197
+ dir.writeUInt16LE(1, 2); // type = icon
198
+ dir.writeUInt16LE(entries.length, 4); // count
199
+ let dataOff = dirSize;
200
+ for (let i = 0; i < entries.length; i++) {
201
+ const e = entries[i];
202
+ const o = 6 + i * 16;
203
+ dir.writeUInt8(e.size === 256 ? 0 : e.size, o);
204
+ dir.writeUInt8(e.size === 256 ? 0 : e.size, o + 1);
205
+ dir.writeUInt8(0, o + 2); // ncolors
206
+ dir.writeUInt8(0, o + 3); // reserved
207
+ dir.writeUInt16LE(1, o + 4); // planes
208
+ dir.writeUInt16LE(32, o + 6); // bpp
209
+ dir.writeUInt32LE(e.data.length, o + 8);
210
+ dir.writeUInt32LE(dataOff, o + 12);
211
+ dataOff += e.data.length;
212
+ }
213
+ return Buffer.concat([dir, ...entries.map(e => e.data)]);
32
214
  }
33
- let width = png.readUInt32BE(16);
34
- let height = png.readUInt32BE(20);
35
- if (width > 256) width = 256;
36
- if (height > 256) height = 256;
37
-
38
- // ICONDIR (6 bytes): reserved=0, type=1 (icon), count=1
39
- // ICONDIRENTRY (16 bytes per image):
40
- // width (1 byte, 0 = 256), height (1 byte, 0 = 256), palette (0), reserved (0),
41
- // planes (1), bpp (32), size (4), offset (4)
42
- const dir = Buffer.alloc(6);
43
- dir.writeUInt16LE(0, 0);
44
- dir.writeUInt16LE(1, 2);
45
- dir.writeUInt16LE(1, 4);
46
-
47
- const entry = Buffer.alloc(16);
48
- entry.writeUInt8(width === 256 ? 0 : width, 0);
49
- entry.writeUInt8(height === 256 ? 0 : height, 1);
50
- entry.writeUInt8(0, 2); // palette
51
- entry.writeUInt8(0, 3); // reserved
52
- entry.writeUInt16LE(1, 4); // color planes
53
- entry.writeUInt16LE(32, 6); // bpp
54
- entry.writeUInt32LE(png.length, 8); // image data size
55
- entry.writeUInt32LE(22, 12); // offset = 6 (dir) + 16 (entry)
56
-
57
- const ico = Buffer.concat([dir, entry, png]);
215
+
216
+ // ── Main ─────────────────────────────────────────────────────────────────
217
+ const decoded = decodePNG(pngBuf);
218
+ console.log(`build-icon-ico: source ${decoded.width}x${decoded.height} ${decoded.rgba.length} bytes RGBA`);
219
+
220
+ const sizes = [16, 32, 48, 64, 128, 256];
221
+ const entries = [];
222
+ for (const sz of sizes) {
223
+ const scaled = downsample(decoded.rgba, decoded.width, decoded.height, sz, sz);
224
+ if (sz === 256) {
225
+ // 256x256 BMP would be 256KB — store as PNG instead. Vista+ Shell
226
+ // accepts PNG entries at >=96px; <96px still has to be BMP for the
227
+ // taskbar code path on older configs.
228
+ const pngEntry = encodePNGEntry(scaled, sz);
229
+ entries.push({ size: sz, data: pngEntry });
230
+ } else {
231
+ entries.push({ size: sz, data: encodeDIB(scaled, sz, sz) });
232
+ }
233
+ }
234
+
235
+ // Re-encode an RGBA buffer back to a PNG (used only for the 256x256 entry).
236
+ function encodePNGEntry(rgba, sz) {
237
+ function chunk(type, data) {
238
+ const len = Buffer.alloc(4);
239
+ len.writeUInt32BE(data.length, 0);
240
+ const head = Buffer.concat([Buffer.from(type, "ascii"), data]);
241
+ const crc = Buffer.alloc(4);
242
+ crc.writeInt32BE(crc32(head), 0);
243
+ return Buffer.concat([len, head, crc]);
244
+ }
245
+ const sig = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
246
+ const ihdr = Buffer.alloc(13);
247
+ ihdr.writeUInt32BE(sz, 0);
248
+ ihdr.writeUInt32BE(sz, 4);
249
+ ihdr[8] = 8; // bit depth
250
+ ihdr[9] = 6; // RGBA
251
+ ihdr[10] = 0; // compression
252
+ ihdr[11] = 0; // filter
253
+ ihdr[12] = 0; // interlace
254
+ // Filter byte 0 (None) per scanline + RGBA pixels
255
+ const stride = sz * 4;
256
+ const filtered = Buffer.alloc(sz * (stride + 1));
257
+ for (let y = 0; y < sz; y++) {
258
+ filtered[y * (stride + 1)] = 0;
259
+ rgba.copy(filtered, y * (stride + 1) + 1, y * stride, y * stride + stride);
260
+ }
261
+ const idat = zlib.deflateSync(filtered, { level: 9 });
262
+ return Buffer.concat([sig, chunk("IHDR", ihdr), chunk("IDAT", idat), chunk("IEND", Buffer.alloc(0))]);
263
+ }
264
+
265
+ // Standard PNG-spec CRC32.
266
+ function crc32(buf) {
267
+ let c, table = crc32.table;
268
+ if (!table) {
269
+ table = crc32.table = new Int32Array(256);
270
+ for (let n = 0; n < 256; n++) {
271
+ c = n;
272
+ for (let k = 0; k < 8; k++) c = c & 1 ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
273
+ table[n] = c;
274
+ }
275
+ }
276
+ c = -1;
277
+ for (let i = 0; i < buf.length; i++) c = table[(c ^ buf[i]) & 0xFF] ^ (c >>> 8);
278
+ return c ^ -1;
279
+ }
280
+
281
+ const ico = buildICO(entries);
58
282
  fs.writeFileSync(dst, ico);
59
- console.log(`build-icon-ico: wrote ${dst} (${ico.length} bytes, ${width}×${height} PNG-embedded)`);
283
+ console.log(`build-icon-ico: wrote ${dst} (${ico.length} bytes, sizes: ${sizes.join(", ")})`);
package/bin/mailx.js CHANGED
@@ -83,6 +83,27 @@ function pidAlive(pid) {
83
83
  return false;
84
84
  }
85
85
  }
86
+ /** True only if `pid` is alive AND its command line looks like a mailx node
87
+ * process. Windows reuses PIDs aggressively — a mailx that exited without
88
+ * running its cleanup handler can leave behind an instance.json whose PID
89
+ * has since been recycled by some other app (e.g. Creative Cloud UI Helper),
90
+ * making bare pidAlive() return true and wedging every subsequent `mailx`
91
+ * with "already running". Verify it's actually us. Non-Windows: fall back
92
+ * to pidAlive — POSIX PID reuse is rare enough that we don't bother. */
93
+ function pidIsMailx(pid) {
94
+ if (!pidAlive(pid))
95
+ return false;
96
+ if (process.platform !== "win32")
97
+ return true;
98
+ try {
99
+ const { execSync } = require("node:child_process");
100
+ const out = execSync(`powershell -NoProfile -Command "(Get-CimInstance Win32_Process -Filter \\"ProcessId=${pid}\\").CommandLine"`, { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], windowsHide: true }).toString();
101
+ return /mailx[\\\/](?:bin|packages|app)|mailx-server/i.test(out);
102
+ }
103
+ catch {
104
+ return false;
105
+ }
106
+ }
86
107
  // Version-mismatch upgrade: if a daemon from an older version is running when
87
108
  // the user types `mailx`, kill it so the new one can take over. Without this,
88
109
  // a second invocation would silently no-op (daemon exists), leaving the user
@@ -93,7 +114,7 @@ const __commandFlags = ["kill", "v", "version", "setup", "add", "test", "rebuild
93
114
  const __isCommandInvocation = process.argv.slice(2).some(a => __commandFlags.includes(a.replace(/^--?/, "")));
94
115
  if (!isDaemon && !__isCommandInvocation) {
95
116
  const inst = readInstanceFile();
96
- if (inst && pidAlive(inst.pid)) {
117
+ if (inst && pidIsMailx(inst.pid)) {
97
118
  if (inst.version !== __selfVersion) {
98
119
  console.log(`mailx: upgrading running daemon (PID ${inst.pid}) from v${inst.version} → v${__selfVersion}`);
99
120
  try {
@@ -276,6 +297,12 @@ if (hasFlag("kill")) {
276
297
  }
277
298
  catch { /* */ }
278
299
  }
300
+ // Always clear instance.json — the daemon's exit handler does this on a
301
+ // clean shutdown, but if it crashed (or was force-killed before the
302
+ // handler ran) the file lingers and wedges every future `mailx` with
303
+ // "already running" because Windows recycles PIDs aggressively. -kill
304
+ // is the user's escape hatch; leave no lock behind.
305
+ clearInstanceFile();
279
306
  if (killed === 0)
280
307
  console.log("No mailx processes found");
281
308
  process.exit(0);