@bobfrankston/msger 0.1.351 → 0.1.355

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.
Binary file
@@ -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.355",
4
4
  "description": "Fast, lightweight, cross-platform message box - Rust-powered alternative to msgview",
5
5
  "type": "module",
6
6
  "main": "./index.js",
package/shower.js CHANGED
@@ -1,4 +1,4 @@
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';
@@ -113,61 +113,139 @@ function findMsgerSource() {
113
113
  return bundled;
114
114
  return null;
115
115
  }
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. */
116
+ /** Sweep stale `.aside-*` / `.old-*` siblings of `perAppExe`. Best-effort
117
+ * files held open by an older instance throw EPERM and stay until the next
118
+ * launch when nothing has them mapped. */
119
+ function sweepAsideSiblings(perAppExe) {
120
+ try {
121
+ const dir = path.dirname(perAppExe);
122
+ const base = path.basename(perAppExe);
123
+ const pat = new RegExp(`^${base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\.(?:old|aside|tmp)-(\\d+)$`);
124
+ for (const f of fs.readdirSync(dir)) {
125
+ const m = pat.exec(f);
126
+ if (!m)
127
+ continue;
128
+ try {
129
+ fs.unlinkSync(path.join(dir, f));
130
+ }
131
+ catch { /* still locked, try next time */ }
132
+ }
133
+ }
134
+ catch { /* fall through */ }
135
+ }
136
+ /** Inject `.ico` into `exePath` as the IDI_ICON1 / RT_GROUP_ICON resource so
137
+ * the per-app exe shows the app's own icon in Explorer, Task Manager, the
138
+ * taskbar, and pinned shortcuts — every Windows context that reads the exe
139
+ * resource (which `with_window_icon` doesn't reach). Sync — invokes the
140
+ * shipped `msgernative.exe --update-icon` subcommand (no external deps;
141
+ * msger stays self-contained). Returns true on success. Failures don't
142
+ * break launch — the launcher just runs without the embedded icon. */
143
+ function injectIcon(msgerNative, exePath, icoPath) {
144
+ try {
145
+ execFileSync(msgerNative, ["--update-icon", exePath, icoPath], { stdio: "pipe" });
146
+ return true;
147
+ }
148
+ catch (e) {
149
+ const stderr = (e?.stderr ?? "").toString().trim();
150
+ console.error(` msger: --update-icon failed for ${exePath}: ${e?.message || e}${stderr ? ` (${stderr})` : ""}`);
151
+ return false;
152
+ }
153
+ }
154
+ /** Ensure a per-app exe is present at `perAppExe`, derived from `sourceExe`,
155
+ * with the per-app icon resource injected. Two modes:
156
+ *
157
+ * - `_appIcon` is set (the per-app-identity case): the per-app exe is a
158
+ * *real copy*, not a hardlink, because `--update-icon` rewrites the resource
159
+ * section and we don't want that change to bleed back into the shared
160
+ * `msgernative.exe` (which a hardlink would). Staleness compared via
161
+ * mtime of source exe + mtime of source icon. Refresh uses the
162
+ * rename-aside pattern so a running instance doesn't block the update.
163
+ *
164
+ * - `_appIcon` is unset: legacy hardlink behavior — `mailx.exe` shares an
165
+ * inode with `msgernative.exe`, no resource injection. Same as before.
166
+ */
122
167
  function ensureFresh(perAppExe, sourceExe) {
123
- if (!fs.existsSync(perAppExe)) {
124
- fs.mkdirSync(path.dirname(perAppExe), { recursive: true });
168
+ fs.mkdirSync(path.dirname(perAppExe), { recursive: true });
169
+ // Hardlink path (callers that didn't `setAppIcon`) unchanged behavior.
170
+ if (!_appIcon || !fs.existsSync(_appIcon)) {
171
+ if (!fs.existsSync(perAppExe)) {
172
+ try {
173
+ fs.linkSync(sourceExe, perAppExe);
174
+ }
175
+ catch {
176
+ fs.copyFileSync(sourceExe, perAppExe);
177
+ }
178
+ return;
179
+ }
180
+ if (fs.statSync(perAppExe).ino === fs.statSync(sourceExe).ino)
181
+ return;
182
+ const aside = `${perAppExe}.aside-${Date.now()}`;
183
+ try {
184
+ fs.renameSync(perAppExe, aside);
185
+ }
186
+ catch {
187
+ return;
188
+ }
125
189
  try {
126
190
  fs.linkSync(sourceExe, perAppExe);
127
191
  }
128
192
  catch {
129
- fs.copyFileSync(sourceExe, perAppExe); /* cross-volume fallback */
193
+ fs.copyFileSync(sourceExe, perAppExe);
130
194
  }
195
+ sweepAsideSiblings(perAppExe);
131
196
  return;
132
197
  }
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);
141
- }
142
- catch {
143
- return; /* held open by another instance — retry next launch */
198
+ // Copy + --update-icon path. Stale when the per-app exe is missing OR the source
199
+ // exe is newer (msger updated) OR the source icon is newer (icon changed).
200
+ const srcMtime = fs.statSync(sourceExe).mtimeMs;
201
+ const icoMtime = fs.statSync(_appIcon).mtimeMs;
202
+ let stale = !fs.existsSync(perAppExe);
203
+ if (!stale) {
204
+ const dstMtime = fs.statSync(perAppExe).mtimeMs;
205
+ stale = dstMtime < srcMtime || dstMtime < icoMtime;
144
206
  }
207
+ if (!stale)
208
+ return;
209
+ // Build the new exe out-of-band so a running instance never sees a
210
+ // half---update-icon'd file. Sequence: copy → --update-icon tmp → rename old aside →
211
+ // rename tmp into place. All renames work even on locked files; only
212
+ // delete is blocked, and we defer that to the next launch's sweep.
213
+ const stamp = Date.now();
214
+ const tmpExe = `${perAppExe}.tmp-${stamp}`;
215
+ const isIco = _appIcon.toLowerCase().endsWith(".ico");
145
216
  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.
153
- 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;
217
+ fs.copyFileSync(sourceExe, tmpExe);
218
+ let injected = false;
219
+ if (isIco) {
220
+ injected = injectIcon(sourceExe, tmpExe, _appIcon);
221
+ }
222
+ if (!isIco || !injected) {
223
+ // No .ico available, or the resource update failed fall back
224
+ // to a plain copy. Window-icon path still gets set via Tao at
225
+ // launch; we just don't get the EXE-level resource.
226
+ }
227
+ if (fs.existsSync(perAppExe)) {
164
228
  try {
165
- fs.unlinkSync(path.join(dir, f));
229
+ fs.renameSync(perAppExe, `${perAppExe}.aside-${stamp}`);
230
+ }
231
+ catch { /* held open — leave the old file in place; we'll retry */
232
+ try {
233
+ fs.unlinkSync(tmpExe);
234
+ }
235
+ catch { /* */ }
236
+ return;
166
237
  }
167
- catch { /* still locked, try next time */ }
168
238
  }
239
+ fs.renameSync(tmpExe, perAppExe);
240
+ sweepAsideSiblings(perAppExe);
241
+ }
242
+ catch (e) {
243
+ console.error(` msger: per-app exe provision failed (${perAppExe}): ${e?.message || e}`);
244
+ try {
245
+ fs.unlinkSync(tmpExe);
246
+ }
247
+ catch { /* */ }
169
248
  }
170
- catch { /* fall through */ }
171
249
  }
172
250
  /** Copy the per-app icon next to the per-app exe so Rust's default-icon
173
251
  * search (`load_icon` in main.rs) picks it up. Rust looks for `msger.ico`