@bobfrankston/rmfmail 1.2.282 → 1.2.284

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.
package/bin/mailx.ts CHANGED
@@ -474,7 +474,17 @@ function readInstanceFile(): InstanceFile | null {
474
474
  function writeInstanceFile(pid: number, childPids: number[] = []): void {
475
475
  try {
476
476
  fs.mkdirSync(path.dirname(__instanceFile), { recursive: true });
477
- const payload: InstanceFile = { pid, version: __selfVersion, startedAt: Date.now(), childPids };
477
+ // startedAt must survive a childPids update. It was recomputed on every
478
+ // write, and since every popup and popout window rewrites this file,
479
+ // the field reported the last window event rather than when the daemon
480
+ // started — actively misleading when diagnosing "how long has this been
481
+ // up" or "which of these daemons is the old one" (it cost a wrong turn
482
+ // on 2026-08-27). Keep the existing value whenever the PID is unchanged.
483
+ const prev = readInstanceFile();
484
+ const startedAt = prev && prev.pid === pid && typeof prev.startedAt === "number"
485
+ ? prev.startedAt
486
+ : Date.now();
487
+ const payload: InstanceFile = { pid, version: __selfVersion, startedAt, childPids };
478
488
  fs.writeFileSync(__instanceFile, JSON.stringify(payload, null, 2));
479
489
  } catch { /* non-fatal */ }
480
490
  }
@@ -543,6 +553,17 @@ const __handoffStamp = path.join(path.dirname(__instanceFile), "last-handoff.sta
543
553
  function touchHandoffStamp(): void {
544
554
  try { fs.writeFileSync(__handoffStamp, String(Date.now())); } catch { /* */ }
545
555
  }
556
+
557
+ /** "Someone launched me while you are already running — show yourself."
558
+ *
559
+ * A file rather than a socket, for the same reason the mailto and share
560
+ * handoffs use one: the launching process is a fresh node that will exit in
561
+ * milliseconds, and the daemon is already watching this directory. The daemon
562
+ * side deletes it and sends the native focus command. */
563
+ const __activateFile = path.join(path.dirname(__instanceFile), "pending-activate.json");
564
+ function requestActivate(): void {
565
+ try { fs.writeFileSync(__activateFile, JSON.stringify({ at: Date.now(), fromPid: process.pid })); } catch { /* */ }
566
+ }
546
567
  if (!isDaemon && !__isCommandInvocation && !__keepOthers) {
547
568
  // Handoff launches must NOT replace a live daemon. rmfshare.exe (share
548
569
  // sheet) and rmfmailto.exe (mailto links) write a pending file and spawn
@@ -567,12 +588,36 @@ if (!isDaemon && !__isCommandInvocation && !__keepOthers) {
567
588
  }
568
589
  }
569
590
  }
570
- // Replace-on-launch: any rmfmail daemon already running gets killed and
591
+ // Activate-on-launch: launching the SAME version that is already running
592
+ // raises the window that exists instead of replacing it.
593
+ //
594
+ // Replace-on-launch (below) was written for the upgrade case — an older or
595
+ // orphaned daemon surviving a partial upgrade and leaving two windows on
596
+ // two versions. But it fired on EVERY launch, including the ordinary one:
597
+ // press the taskbar icon, the new process SIGTERMs the running daemon, its
598
+ // window vanishes, and seconds later a fresh one appears. From the outside
599
+ // that reads as the app flickering out of existence for no reason (Bob
600
+ // 2026-08-26: "why does it show and disappear... you should still put me in
601
+ // the running version when I press the icon on the task bar").
602
+ //
603
+ // instance.json already records the running version, so the two cases are
604
+ // distinguishable without guessing: same version means the user wants the
605
+ // app, so surface it; a different version means an upgrade landed and the
606
+ // old daemon must go. `-another` still opts out entirely, and every handoff
607
+ // path above still returns before reaching here.
608
+ {
609
+ const inst0 = readInstanceFile();
610
+ if (inst0 && pidIsMailx(inst0.pid) && inst0.pid !== process.pid && inst0.version === __selfVersion) {
611
+ requestActivate();
612
+ console.log(`rmfmail: already running (PID ${inst0.pid}, v${inst0.version}) — raising its window`);
613
+ process.exit(0);
614
+ }
615
+ }
616
+
617
+ // Replace-on-launch: a daemon running a DIFFERENT version gets killed and
571
618
  // the new one takes over. instance.json only tracks ONE PID — older or
572
619
  // orphaned daemons can survive a partial upgrade and leave the user with
573
- // multiple windows on different versions. Sweep PowerShell for any
574
- // rmfmail-looking node process and kill it (unconditionally — version
575
- // match alone isn't enough; we always want exactly one daemon).
620
+ // multiple windows on different versions.
576
621
  const myPid = process.pid;
577
622
  const killedPids: number[] = [];
578
623
  const inst = readInstanceFile();
@@ -633,6 +678,29 @@ if (!verbose && !isDaemon && !process.argv.slice(2).some(a => /^-/.test(a))) {
633
678
  windowsHide: true,
634
679
  });
635
680
  child.unref();
681
+ // Claim the instance slot for the child NOW, before we exit.
682
+ //
683
+ // Without this there is a multi-second hole in which no instance exists on
684
+ // paper: we spawn a detached daemon and exit immediately, but the daemon
685
+ // does not register itself until it has built the store, started the
686
+ // popout server and opened its window — several seconds later. Any launch
687
+ // arriving inside that hole reads instance.json, sees nothing alive, and
688
+ // starts ANOTHER daemon. That is how three of them ended up running at
689
+ // once on 2026-08-26, spawned 4 and 5 seconds apart (PIDs 1452, 34012,
690
+ // 94964), each with its own window, only one of them in instance.json —
691
+ // which is also why nothing came to the front and why replace-on-launch
692
+ // could never clean them up: it only ever knew about the last one to
693
+ // register (Bob: "this does not look like a single instance ... but not
694
+ // one of the windows came to the front").
695
+ //
696
+ // The child is alive from the moment spawn returns, and pidIsMailx is a
697
+ // liveness check, so the claim is valid immediately. If the child dies
698
+ // during boot the existing stale-PID check clears it on the next launch,
699
+ // and when the daemon does reach its own registration it writes the same
700
+ // PID again — no conflict. A launch that lands during boot now takes the
701
+ // activate path and drops pending-activate.json, which the daemon picks up
702
+ // when it starts watching. (Claude Code 2026-08-27)
703
+ if (typeof child.pid === "number") writeInstanceFile(child.pid, []);
636
704
  process.exit(0);
637
705
  }
638
706
 
@@ -2984,6 +3052,43 @@ RFC 5322 with CRLF line endings. Bodies are quoted-printable encoded (readable i
2984
3052
  }
2985
3053
  }
2986
3054
 
3055
+ // Activation requests. A second launch of the SAME version (taskbar icon,
3056
+ // Start menu, a double-clicked shortcut) drops pending-activate.json and
3057
+ // exits instead of replacing us; this is the half that answers it by
3058
+ // bringing the existing window to the front. Same fs.watch + native
3059
+ // control-message shape as the mailto and share handoffs below.
3060
+ //
3061
+ // Without this the launcher would have nothing to hand off TO, and
3062
+ // "already running — raising its window" would be a lie: the process would
3063
+ // exit and the user would be left staring at whatever they were looking at
3064
+ // before, which is worse than the restart it replaced.
3065
+ {
3066
+ try {
3067
+ const dir = path.dirname(__activateFile);
3068
+ const baseName = path.basename(__activateFile);
3069
+ fs.mkdirSync(dir, { recursive: true });
3070
+ // A request that landed while we were still booting — the watch
3071
+ // below can only see files that arrive after it is installed.
3072
+ if (fs.existsSync(__activateFile)) {
3073
+ try { fs.unlinkSync(__activateFile); } catch { /* */ }
3074
+ handle.send({ _msgerWindow: "focus" });
3075
+ }
3076
+ fs.watch(dir, (_event, filename) => {
3077
+ if (filename !== baseName) return;
3078
+ if (!fs.existsSync(__activateFile)) return;
3079
+ // Delete FIRST. fs.watch fires twice for one write on Windows
3080
+ // (rename + change), and a second focus command is harmless but
3081
+ // the unlink is what makes the second pass a no-op.
3082
+ try { fs.unlinkSync(__activateFile); } catch { return; }
3083
+ touchHandoffStamp();
3084
+ console.log(" [activate] second launch asked for the window — raising it");
3085
+ handle.send({ _msgerWindow: "focus" });
3086
+ });
3087
+ } catch (e: any) {
3088
+ console.error(` [activate] watch setup failed: ${e.message}`);
3089
+ }
3090
+ }
3091
+
2987
3092
  // Pending Windows-share handler (C46). Same two-path pickup as mailto
2988
3093
  // above: the client's startup consumePendingShare poll covers the
2989
3094
  // "rmfshare.exe spawned us" race; this fs.watch covers shares that land
@@ -3108,11 +3108,11 @@ async function showMessage(accountId, uid, folderId, specialUse, isRetry = false
3108
3108
  }
3109
3109
  bodyEl.innerHTML = "";
3110
3110
  const spam = msg.spamScore;
3111
- if (spam && Number.isFinite(spam.score)) {
3112
- const level = spam.flagged ? "high" : spam.score >= spam.threshold / 2 ? "mid" : "low";
3111
+ if (spam && Number.isFinite(spam.score) && (spam.flagged || spam.score >= spam.threshold / 2)) {
3112
+ const level = spam.flagged ? "high" : "mid";
3113
3113
  const chip = document.createElement("div");
3114
3114
  chip.className = `mv-spamscore mv-spamscore-${level}`;
3115
- chip.title = level === "high" ? "Your mail server scored this at or above its spam threshold and flagged it." : level === "mid" ? "Below your server's spam threshold, but more than halfway to it." : "Well below your mail server's spam threshold.";
3115
+ chip.title = level === "high" ? "Your mail server scored this at or above its spam threshold and flagged it." : "Below your server's spam threshold, but more than halfway to it.";
3116
3116
  chip.textContent = `spam score ${spam.score} of ${spam.threshold}`;
3117
3117
  bodyEl.appendChild(chip);
3118
3118
  }