@bobfrankston/msger 0.1.351 → 0.1.353

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.
@@ -197,3 +197,19 @@ if (!isWindows) {
197
197
  }
198
198
  }
199
199
  }
200
+
201
+ // Friendly message about the EPERM warnings npm itself emits during a
202
+ // subsequent global upgrade. They come from npm trying to delete its own
203
+ // staging directory while the previously-running msgernative.exe inside
204
+ // still holds a file handle. msger's rename-aside scheme above has already
205
+ // handled the actual upgrade safely; the warnings are cosmetic.
206
+ if (isWindows) {
207
+ console.log("");
208
+ console.log(" msger: install OK.");
209
+ console.log(" msger: if a future `npm i -g @bobfrankston/msger` prints");
210
+ console.log(" EPERM/ENOTEMPTY warnings about removing a staging");
211
+ console.log(" directory, those are expected when an older msger");
212
+ console.log(" is still running — the upgrade has already taken");
213
+ console.log(" effect via rename-aside. Safe to ignore.");
214
+ console.log("");
215
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/msger",
3
- "version": "0.1.351",
3
+ "version": "0.1.353",
4
4
  "description": "Fast, lightweight, cross-platform message box - Rust-powered alternative to msgview",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -51,7 +51,8 @@
51
51
  "dependencies": {
52
52
  "@bobfrankston/msgcommon": "^0.1.23",
53
53
  "ansi-to-html": "^0.7.2",
54
- "json5": "^2.2.3"
54
+ "json5": "^2.2.3",
55
+ "rcedit": "^4.0.1"
55
56
  },
56
57
  "files": [
57
58
  "*.js",
package/shower.js CHANGED
@@ -1,7 +1,8 @@
1
- import { spawn } from 'child_process';
1
+ import { spawn, execFileSync } from 'child_process';
2
2
  import { platform } from 'os';
3
3
  import path from 'path';
4
4
  import { pathToFileURL } from 'url';
5
+ import { createRequire } from 'module';
5
6
  import fs from 'fs';
6
7
  import AnsiToHtml from 'ansi-to-html';
7
8
  // Load package.json for version
@@ -113,61 +114,174 @@ function findMsgerSource() {
113
114
  return bundled;
114
115
  return null;
115
116
  }
116
- /** Ensure a per-app exe is present at `perAppExe` and points at the same inode
117
- * as `sourceExe`. On Windows, hardlinks share inodes so a `stat().ino`
118
- * comparison detects when msger's postinstall has rename-asided the source
119
- * and written a new file (which leaves our hardlink dangling on the OLD
120
- * inode). Re-link in that case, using the same rename-aside trick to avoid
121
- * EBUSY when the per-app exe is itself being executed by another instance. */
117
+ /** Sweep stale `.aside-*` / `.old-*` siblings of `perAppExe`. Best-effort
118
+ * files held open by an older instance throw EPERM and stay until the next
119
+ * launch when nothing has them mapped. */
120
+ function sweepAsideSiblings(perAppExe) {
121
+ try {
122
+ const dir = path.dirname(perAppExe);
123
+ const base = path.basename(perAppExe);
124
+ const pat = new RegExp(`^${base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\.(?:old|aside|tmp)-(\\d+)$`);
125
+ for (const f of fs.readdirSync(dir)) {
126
+ const m = pat.exec(f);
127
+ if (!m)
128
+ continue;
129
+ try {
130
+ fs.unlinkSync(path.join(dir, f));
131
+ }
132
+ catch { /* still locked, try next time */ }
133
+ }
134
+ }
135
+ catch { /* fall through */ }
136
+ }
137
+ /** Locate the rcedit-<arch>.exe shipped with the `rcedit` npm package
138
+ * (Electron-team maintained, published unscoped). Returns null on
139
+ * non-Windows or if the package isn't installed. We resolve the path
140
+ * manually so we can call the binary with `execFileSync` and keep the
141
+ * caller synchronous. */
142
+ function findRceditBinary() {
143
+ if (platform() !== "win32")
144
+ return null;
145
+ try {
146
+ // The package ships rcedit-x64.exe / rcedit-ia32.exe / rcedit-arm64.exe
147
+ // in its `bin/` dir. Resolve via require.resolve('rcedit') → walk up
148
+ // to the package root, then into bin/.
149
+ const pkgEntry = createRequire(import.meta.url).resolve("rcedit");
150
+ const pkgRoot = path.dirname(pkgEntry);
151
+ const archMap = {
152
+ x64: "rcedit-x64.exe",
153
+ ia32: "rcedit-ia32.exe",
154
+ arm64: "rcedit-arm64.exe",
155
+ };
156
+ const exeName = archMap[process.arch] || "rcedit-x64.exe";
157
+ const candidates = [
158
+ path.join(pkgRoot, "bin", exeName),
159
+ path.join(pkgRoot, "..", "bin", exeName),
160
+ ];
161
+ for (const c of candidates) {
162
+ if (fs.existsSync(c))
163
+ return c;
164
+ }
165
+ }
166
+ catch { /* not installed — caller will fall back to plain copy */ }
167
+ return null;
168
+ }
169
+ /** Inject `.ico` into `exePath` as the IDI_ICON1 / RT_GROUP_ICON resource so
170
+ * the per-app exe shows the app's own icon in Explorer, Task Manager, the
171
+ * taskbar, and pinned shortcuts — every Windows context that reads the exe
172
+ * resource (which `with_window_icon` doesn't reach). Sync — fires the
173
+ * bundled rcedit.exe via `execFileSync`. Returns true on success. Failures
174
+ * don't break launch — the launcher just runs without the embedded icon. */
175
+ function injectIcon(exePath, icoPath) {
176
+ const rcedit = findRceditBinary();
177
+ if (!rcedit) {
178
+ console.error(` msger: rcedit binary not found — per-app icon not injected (install \`rcedit\` npm package)`);
179
+ return false;
180
+ }
181
+ try {
182
+ execFileSync(rcedit, [exePath, "--set-icon", icoPath], { stdio: "pipe" });
183
+ return true;
184
+ }
185
+ catch (e) {
186
+ console.error(` msger: rcedit failed for ${exePath}: ${e?.message || e}`);
187
+ return false;
188
+ }
189
+ }
190
+ /** Ensure a per-app exe is present at `perAppExe`, derived from `sourceExe`,
191
+ * with the per-app icon resource injected. Two modes:
192
+ *
193
+ * - `_appIcon` is set (the per-app-identity case): the per-app exe is a
194
+ * *real copy*, not a hardlink, because `rcedit` rewrites the resource
195
+ * section and we don't want that change to bleed back into the shared
196
+ * `msgernative.exe` (which a hardlink would). Staleness compared via
197
+ * mtime of source exe + mtime of source icon. Refresh uses the
198
+ * rename-aside pattern so a running instance doesn't block the update.
199
+ *
200
+ * - `_appIcon` is unset: legacy hardlink behavior — `mailx.exe` shares an
201
+ * inode with `msgernative.exe`, no resource injection. Same as before.
202
+ */
122
203
  function ensureFresh(perAppExe, sourceExe) {
123
- if (!fs.existsSync(perAppExe)) {
124
- fs.mkdirSync(path.dirname(perAppExe), { recursive: true });
204
+ fs.mkdirSync(path.dirname(perAppExe), { recursive: true });
205
+ // Hardlink path (callers that didn't `setAppIcon`) unchanged behavior.
206
+ if (!_appIcon || !fs.existsSync(_appIcon)) {
207
+ if (!fs.existsSync(perAppExe)) {
208
+ try {
209
+ fs.linkSync(sourceExe, perAppExe);
210
+ }
211
+ catch {
212
+ fs.copyFileSync(sourceExe, perAppExe);
213
+ }
214
+ return;
215
+ }
216
+ if (fs.statSync(perAppExe).ino === fs.statSync(sourceExe).ino)
217
+ return;
218
+ const aside = `${perAppExe}.aside-${Date.now()}`;
219
+ try {
220
+ fs.renameSync(perAppExe, aside);
221
+ }
222
+ catch {
223
+ return;
224
+ }
125
225
  try {
126
226
  fs.linkSync(sourceExe, perAppExe);
127
227
  }
128
228
  catch {
129
- fs.copyFileSync(sourceExe, perAppExe); /* cross-volume fallback */
229
+ fs.copyFileSync(sourceExe, perAppExe);
130
230
  }
231
+ sweepAsideSiblings(perAppExe);
131
232
  return;
132
233
  }
133
- if (fs.statSync(perAppExe).ino === fs.statSync(sourceExe).ino)
134
- return;
135
- // Stale hardlink — source was rename-asided + replaced. Rename-aside the
136
- // per-app exe (a running mailx keeps its file handle to the OLD inode via
137
- // the aside name) and re-link to the new source.
138
- const aside = `${perAppExe}.old-${Date.now()}`;
139
- try {
140
- fs.renameSync(perAppExe, aside);
234
+ // Copy + rcedit path. Stale when the per-app exe is missing OR the source
235
+ // exe is newer (msger updated) OR the source icon is newer (icon changed).
236
+ const srcMtime = fs.statSync(sourceExe).mtimeMs;
237
+ const icoMtime = fs.statSync(_appIcon).mtimeMs;
238
+ let stale = !fs.existsSync(perAppExe);
239
+ if (!stale) {
240
+ const dstMtime = fs.statSync(perAppExe).mtimeMs;
241
+ stale = dstMtime < srcMtime || dstMtime < icoMtime;
141
242
  }
142
- catch {
143
- return; /* held open by another instance — retry next launch */
144
- }
145
- try {
146
- fs.linkSync(sourceExe, perAppExe);
147
- }
148
- catch {
149
- fs.copyFileSync(sourceExe, perAppExe);
150
- }
151
- // Best-effort sweep of `.old-*` siblings older than 24 h. Same pattern as
152
- // postinstall.js. Skip silently — they'll get cleaned up next time.
243
+ if (!stale)
244
+ return;
245
+ // Build the new exe out-of-band so a running instance never sees a
246
+ // half-rcedit'd file. Sequence: copy → rcedit tmp → rename old aside →
247
+ // rename tmp into place. All renames work even on locked files; only
248
+ // delete is blocked, and we defer that to the next launch's sweep.
249
+ const stamp = Date.now();
250
+ const tmpExe = `${perAppExe}.tmp-${stamp}`;
251
+ const isIco = _appIcon.toLowerCase().endsWith(".ico");
153
252
  try {
154
- const dir = path.dirname(perAppExe);
155
- const base = path.basename(perAppExe);
156
- const oldPat = new RegExp(`^${base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\.old-(\\d+)$`);
157
- const cutoff = Date.now() - 24 * 60 * 60 * 1000;
158
- for (const f of fs.readdirSync(dir)) {
159
- const m = oldPat.exec(f);
160
- if (!m)
161
- continue;
162
- if (Number(m[1]) > cutoff)
163
- continue;
253
+ fs.copyFileSync(sourceExe, tmpExe);
254
+ let rceditOk = false;
255
+ if (isIco) {
256
+ rceditOk = injectIcon(tmpExe, _appIcon);
257
+ }
258
+ if (!isIco || !rceditOk) {
259
+ // No .ico available, or rcedit couldn't inject — fall back to a
260
+ // plain copy. Window-icon path still gets set via Tao at launch;
261
+ // we just don't get the EXE-level resource.
262
+ }
263
+ if (fs.existsSync(perAppExe)) {
164
264
  try {
165
- fs.unlinkSync(path.join(dir, f));
265
+ fs.renameSync(perAppExe, `${perAppExe}.aside-${stamp}`);
266
+ }
267
+ catch { /* held open — leave the old file in place; we'll retry */
268
+ try {
269
+ fs.unlinkSync(tmpExe);
270
+ }
271
+ catch { /* */ }
272
+ return;
166
273
  }
167
- catch { /* still locked, try next time */ }
168
274
  }
275
+ fs.renameSync(tmpExe, perAppExe);
276
+ sweepAsideSiblings(perAppExe);
277
+ }
278
+ catch (e) {
279
+ console.error(` msger: per-app exe provision failed (${perAppExe}): ${e?.message || e}`);
280
+ try {
281
+ fs.unlinkSync(tmpExe);
282
+ }
283
+ catch { /* */ }
169
284
  }
170
- catch { /* fall through */ }
171
285
  }
172
286
  /** Copy the per-app icon next to the per-app exe so Rust's default-icon
173
287
  * search (`load_icon` in main.rs) picks it up. Rust looks for `msger.ico`