latex-stickies 1.3.6 → 1.3.8

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/README.md CHANGED
@@ -24,7 +24,10 @@ latex-stickies
24
24
  ```
25
25
 
26
26
  Requires Node 22.12 or newer. The first run downloads the Electron runtime
27
- (~230 MB), so give it a minute; later launches are instant. On a Mac you can
27
+ (~230 MB), so give it a minute; later launches are instant. On a Mac it also
28
+ keeps a copy of the runtime carrying this app's name and icon in
29
+ `~/Library/Application Support/latex-stickies/` -- on APFS that is a
30
+ copy-on-write clone, so it costs almost no disk. On a Mac you can
28
31
  download a `.dmg` from [Releases](https://github.com/kev-nj/latex-stickies/releases)
29
32
  instead and skip Node entirely.
30
33
 
@@ -146,6 +149,7 @@ npm test # unit suites
146
149
  npm run vendor # rebuild the bundled libraries
147
150
  npm run icon # rebuild the app icon from assets/icon-master.png
148
151
  npm run demo # re-record assets/demo.gif from the real app (needs ffmpeg)
152
+ npm run verify # install the packed tarball and check what a user gets
149
153
  npm run dist # build a macOS .app and .dmg
150
154
  ```
151
155
 
@@ -158,6 +162,7 @@ node scripts/smoke.js --lifecycle # closing every note behaves per platform
158
162
  node scripts/render-check.js # the first-run note renders correctly
159
163
  node scripts/ghost-check.js # autocomplete suggests, and Tab accepts
160
164
  node scripts/snapshot-check.js # a long note is captured whole
165
+ node scripts/verify-install.js # the install path, end to end (macOS)
161
166
  ```
162
167
 
163
168
  The renderer loads over `file://` under a strict content-security policy, so
@@ -10,26 +10,32 @@
10
10
  const { spawn } = require('child_process');
11
11
  const path = require('path');
12
12
 
13
- let electron;
13
+ // Brand before resolving anything: this returns the path to a copy of the
14
+ // Electron shell carrying our name and icon, kept outside node_modules so it
15
+ // survives npx cache churn and npm ci. Falls back to the plain shell, which
16
+ // runs identically and only looks wrong in the Dock.
17
+ //
18
+ // postinstall cannot be relied on for this -- npm 11 warns about install
19
+ // scripts by default and blocks them outright for global installs, and plenty
20
+ // of people and companies turn them off.
21
+ let electron = null;
14
22
  try {
15
- electron = require('electron');
16
- } catch (_) {
17
- console.error(
18
- 'Could not find the Electron runtime.\n' +
19
- 'Reinstall with: npm install -g latex-stickies'
20
- );
21
- process.exit(1);
23
+ electron = require('../scripts/brand-electron.js').ensure();
24
+ } catch (err) {
25
+ console.error(`could not brand the app: ${err.message}`);
22
26
  }
23
27
 
24
- // Name the Electron shell before launching it, not only at install time.
25
- // postinstall scripts are increasingly blocked -- npm warns about them by
26
- // default now, and plenty of people and companies set ignore-scripts -- and a
27
- // user who installs with them off gets "Electron" in their Dock. Doing it here
28
- // as well means the name is right by the time macOS reads the bundle, whatever
29
- // happened during the install. It returns immediately once done.
30
- try {
31
- require('../scripts/brand-electron.js');
32
- } catch (_) { /* cosmetic: never block a launch over the name */ }
28
+ if (!electron) {
29
+ try {
30
+ electron = require('electron');
31
+ } catch (_) {
32
+ console.error(
33
+ 'Could not find the Electron runtime.\n' +
34
+ 'Reinstall with: npm install -g latex-stickies'
35
+ );
36
+ process.exit(1);
37
+ }
38
+ }
33
39
 
34
40
  const appDir = path.join(__dirname, '..');
35
41
  const child = spawn(electron, [appDir, ...process.argv.slice(2)], {
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "latex-stickies",
3
- "version": "1.3.6",
3
+ "version": "1.3.8",
4
4
  "description": "Sticky notes for your desktop that render LaTeX and Markdown.",
5
5
  "main": "src/main.js",
6
6
  "scripts": {
7
7
  "start": "electron .",
8
8
  "test": "node scripts/test.js",
9
+ "verify": "node scripts/verify-install.js",
9
10
  "vendor": "node scripts/vendor.js",
10
11
  "icon": "node scripts/make-icon.js",
11
12
  "demo": "node scripts/make-demo.js",
@@ -1,152 +1,198 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Gives the Electron shell this app's name and icon, on macOS.
3
+ * Gives this app its own macOS bundle, so the Dock shows our name and icon.
4
4
  *
5
- * Run from source or from npm there is no .app bundle of our own, so macOS
6
- * takes the Dock name and icon from the shell being launched -- which is called
7
- * "Electron" and wears the atom logo. app.setName() cannot change that: the
8
- * Dock reads it from the bundle before any of our code runs.
5
+ * Run from source or from npm there is no .app of our own: macOS takes the
6
+ * Dock name and icon from the Electron shell being launched, which is called
7
+ * "Electron" and wears the atom logo. app.setName() cannot help -- the Dock
8
+ * reads the bundle before any of our code runs.
9
9
  *
10
- * Editing Info.plist invalidates the bundle's ad-hoc signature, so it is
11
- * re-signed afterwards. That matters: an unsigned bundle will not launch on
12
- * Apple Silicon at all, which is a far worse outcome than a wrong name. If
13
- * anything here fails, the original Info.plist goes back and the install
14
- * continues quietly -- a cosmetic nicety must never break someone's install.
10
+ * So the shell is cloned to a stable path of our own and branded there:
11
+ *
12
+ * ~/Library/Application Support/latex-stickies/LaTeX Stickies.app
13
+ *
14
+ * Cloning rather than editing node_modules in place, for three reasons. npx
15
+ * installs under a fresh cache hash every time, so in-place work is redone
16
+ * constantly and thrown away by any cache prune or npm ci. A path macOS has
17
+ * already registered as "Electron" tends to keep serving that name back from
18
+ * the LaunchServices database however correct the plist becomes -- a path it
19
+ * has never seen has no such record. And `npm update electron` would silently
20
+ * revert every rename we made.
21
+ *
22
+ * On APFS the clone is copy-on-write: near-instant, and it costs almost no
23
+ * disk until the files diverge.
24
+ *
25
+ * Everything here is best-effort. A wrong name in the Dock is a blemish; a
26
+ * failed launch is not, so any error falls back to the unbranded shell.
15
27
  */
16
28
  const { execFileSync } = require('child_process');
29
+ const crypto = require('crypto');
17
30
  const fs = require('fs');
31
+ const os = require('os');
18
32
  const path = require('path');
19
33
 
20
34
  const NAME = 'LaTeX Stickies';
21
35
  /**
22
36
  * Our own bundle identifier, matching the packaged builds.
23
37
  *
24
- * The Electron shell ships as com.github.Electron, and LaunchServices keys its
25
- * database by identifier rather than by path. So on a machine that has seen any
26
- * other Electron -- another project's node_modules, an old npx copy, a
27
- * different Electron app entirely -- the Dock can serve that record's name,
28
- * "Electron", however carefully this script renames our copy. Taking our own
29
- * identifier is what stops the collision; renaming alone cannot.
38
+ * The shell ships as com.github.Electron, and LaunchServices keys its database
39
+ * by identifier as well as by path -- so on a machine that has seen any other
40
+ * Electron, that shared identifier can resolve to someone else's record and
41
+ * serve its name, "Electron", however carefully this bundle is renamed.
30
42
  */
31
43
  const APP_ID = 'com.kevinjusak.latexsticky';
32
44
 
33
- /**
34
- * Makes LaunchServices re-read a bundle.
35
- *
36
- * It caches an app's name from the first time it sees the path, and npm
37
- * extracts Electron under a directory macOS indexes -- so it can record
38
- * "Electron" before this script ever runs, and then keep showing that in the
39
- * Dock however right the plist is. Renaming alone is not enough.
40
- */
41
- function refreshLaunchServices(appPath) {
45
+ const ROOT = path.join(__dirname, '..');
46
+ const ICON = path.join(ROOT, 'build', 'icon.icns');
47
+
48
+ /** Overridable so a test can brand somewhere disposable. */
49
+ const HOME = process.env.LATEX_STICKIES_APP_DIR
50
+ || path.join(os.homedir(), 'Library', 'Application Support', 'latex-stickies');
51
+ const TARGET = path.join(HOME, `${NAME}.app`);
52
+ const STAMP = path.join(HOME, '.branded.json');
53
+
54
+ const LSREGISTER = '/System/Library/Frameworks/CoreServices.framework/Frameworks'
55
+ + '/LaunchServices.framework/Support/lsregister';
56
+
57
+ const run = (cmd, args, opts = {}) =>
58
+ execFileSync(cmd, args, { stdio: 'ignore', timeout: 120000, ...opts });
59
+
60
+ /** The Electron shell npm installed, and the version we would be cloning. */
61
+ function source() {
62
+ const dir = path.dirname(require.resolve('electron/package.json', { paths: [ROOT] }));
63
+ const binary = require(path.join(dir, 'index.js'));
64
+ // .../Electron.app/Contents/MacOS/Electron -> .../Electron.app
65
+ const app = path.resolve(path.dirname(binary), '..', '..');
66
+ const { version } = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
67
+ return { app, version };
68
+ }
69
+
70
+ const sha = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
71
+
72
+ /** What the branded copy was built from, so it can be rebuilt when that moves. */
73
+ function stamp(version) {
74
+ return JSON.stringify({
75
+ electron: version,
76
+ icon: fs.existsSync(ICON) ? sha(ICON) : '',
77
+ name: NAME,
78
+ id: APP_ID,
79
+ });
80
+ }
81
+
82
+ function isCurrent(version) {
42
83
  try {
43
- execFileSync(
44
- '/System/Library/Frameworks/CoreServices.framework/Frameworks'
45
- + '/LaunchServices.framework/Support/lsregister',
46
- ['-f', appPath],
47
- { stdio: 'ignore', timeout: 10000 }
48
- );
49
- } catch (_) { /* the name is still right; only the cache is stale */ }
84
+ return fs.existsSync(path.join(TARGET, 'Contents', 'MacOS', NAME))
85
+ && fs.readFileSync(STAMP, 'utf8') === stamp(version);
86
+ } catch (_) {
87
+ return false;
88
+ }
89
+ }
90
+
91
+ function setPlist(plist, key, value) {
92
+ try {
93
+ run('plutil', ['-replace', key, '-string', value, plist]);
94
+ } catch (_) {
95
+ run('plutil', ['-insert', key, '-string', value, plist]);
96
+ }
50
97
  }
51
98
 
52
- function main() {
53
- if (process.platform !== 'darwin') return; // only macOS names apps this way
99
+ /**
100
+ * Builds the branded copy.
101
+ *
102
+ * The executable is renamed as well as the plist keys: macOS takes the Dock
103
+ * label from the running executable, which is why packaged builds have always
104
+ * been right -- electron-builder renames it -- and every npm install was not.
105
+ */
106
+ function build(app, version) {
107
+ fs.mkdirSync(HOME, { recursive: true });
108
+ fs.rmSync(TARGET, { recursive: true, force: true });
54
109
 
55
- let appPath;
110
+ // -c asks APFS for a copy-on-write clone; plain copy on any filesystem
111
+ // that cannot, which is slower but correct.
56
112
  try {
57
- const binary = require(require.resolve('electron', { paths: [path.join(__dirname, '..')] }));
58
- // .../Electron.app/Contents/MacOS/Electron -> .../Electron.app
59
- appPath = path.resolve(path.dirname(binary), '..', '..');
113
+ run('cp', ['-cR', app, TARGET]);
60
114
  } catch (_) {
61
- return; // no runtime installed; nothing to brand
115
+ run('cp', ['-R', app, TARGET]);
62
116
  }
63
117
 
64
- const plist = path.join(appPath, 'Contents', 'Info.plist');
65
- if (!fs.existsSync(plist)) return;
66
-
67
- const read = (key) => {
68
- try {
69
- return execFileSync('plutil', ['-extract', key, 'raw', plist]).toString().trim();
70
- } catch (_) {
71
- return '';
72
- }
73
- };
74
-
75
- const binary = path.join(appPath, 'Contents', 'MacOS', NAME);
76
- if (read('CFBundleName') === NAME
77
- && read('CFBundleIdentifier') === APP_ID
78
- && fs.existsSync(binary)) {
79
- // Already named, so a wrong name in the Dock can only be a stale cache.
80
- refreshLaunchServices(appPath);
81
- // Say so rather than exiting in silence. A user reporting "the Dock still
82
- // says Electron" needs to know whether the rename failed or whether they
83
- // are looking at an older instance still holding the single-instance lock.
84
- if (process.env.LATEX_STICKIES_VERBOSE) {
85
- console.log(`the Electron shell is already named "${NAME}"`);
86
- }
87
- return;
118
+ const plist = path.join(TARGET, 'Contents', 'Info.plist');
119
+ setPlist(plist, 'CFBundleName', NAME);
120
+ setPlist(plist, 'CFBundleDisplayName', NAME);
121
+ setPlist(plist, 'CFBundleIdentifier', APP_ID);
122
+ setPlist(plist, 'CFBundleExecutable', NAME);
123
+
124
+ const from = path.join(TARGET, 'Contents', 'MacOS', 'Electron');
125
+ const to = path.join(TARGET, 'Contents', 'MacOS', NAME);
126
+ if (fs.existsSync(from)) fs.renameSync(from, to);
127
+
128
+ // The bundle icon is what Finder, Cmd-Tab and the Dock show before the app
129
+ // is running; app.dock.setIcon() cannot stand in for it.
130
+ const icon = path.join(TARGET, 'Contents', 'Resources', 'electron.icns');
131
+ if (fs.existsSync(ICON) && fs.existsSync(icon)) fs.copyFileSync(ICON, icon);
132
+
133
+ // Re-signing is required: any edit under Contents/ invalidates the ad-hoc
134
+ // signature, and macOS will not launch a bundle whose signature does not
135
+ // match its contents. Only the outer bundle was touched, so the helpers keep
136
+ // their own signatures -- and --preserve-metadata keeps whatever
137
+ // entitlements the stock build shipped with, which --deep would discard.
138
+ run('codesign', [
139
+ '--force',
140
+ '--preserve-metadata=entitlements,requirements,flags',
141
+ '--sign', '-', TARGET,
142
+ ]);
143
+ run('codesign', ['--verify', TARGET]);
144
+
145
+ // Evict any record for this path before registering it, since lsregister -f
146
+ // refreshes an entry but does not replace a stale one.
147
+ try { run(LSREGISTER, ['-u', TARGET]); } catch (_) { /* nothing registered */ }
148
+ try { run(LSREGISTER, ['-f', TARGET]); } catch (_) { /* only the cache */ }
149
+
150
+ fs.writeFileSync(STAMP, stamp(version));
151
+ return to;
152
+ }
153
+
154
+ /**
155
+ * The binary to launch: the branded copy, or null to fall back to the shell.
156
+ */
157
+ function ensure() {
158
+ if (process.platform !== 'darwin') return null;
159
+
160
+ let app;
161
+ let version;
162
+ try {
163
+ ({ app, version } = source());
164
+ } catch (err) {
165
+ report(`could not find the Electron runtime to brand (${first(err)})`);
166
+ return null;
88
167
  }
89
168
 
90
- const backup = fs.readFileSync(plist);
91
169
  try {
92
- const fields = [
93
- ['CFBundleName', NAME],
94
- ['CFBundleDisplayName', NAME],
95
- ['CFBundleIdentifier', APP_ID],
96
- ['CFBundleExecutable', NAME],
97
- ];
98
- for (const [key, value] of fields) {
99
- try {
100
- execFileSync('plutil', ['-replace', key, '-string', value, plist]);
101
- } catch (_) {
102
- execFileSync('plutil', ['-insert', key, '-string', value, plist]);
103
- }
104
- }
105
-
106
- // Rename the executable too. macOS takes the Dock label from the running
107
- // executable, not only from the plist -- which is why the packaged builds
108
- // have always shown the right name (electron-builder renames it) while
109
- // every npm install showed "Electron" however the plist was rewritten.
110
- const oldBinary = path.join(appPath, 'Contents', 'MacOS', 'Electron');
111
- if (fs.existsSync(oldBinary) && !fs.existsSync(binary)) {
112
- fs.renameSync(oldBinary, binary);
113
- }
114
- // require('electron') reads this file for the path it returns, so it has
115
- // to follow the rename or every launcher breaks.
116
- const pathTxt = path.join(path.dirname(appPath), '..', 'path.txt');
117
- if (fs.existsSync(pathTxt)) {
118
- fs.writeFileSync(pathTxt, `Electron.app/Contents/MacOS/${NAME}`);
119
- }
120
-
121
- // The bundle icon is what shows before the app can set its own.
122
- const icon = path.join(__dirname, '..', 'build', 'icon.icns');
123
- const target = path.join(appPath, 'Contents', 'Resources', 'electron.icns');
124
- if (fs.existsSync(icon) && fs.existsSync(target)) fs.copyFileSync(icon, target);
125
-
126
- // Required: the edits above invalidate the ad-hoc signature, and macOS
127
- // refuses to launch a bundle whose signature does not match its contents.
128
- execFileSync('codesign', ['--force', '--deep', '--sign', '-', appPath], {
129
- stdio: 'ignore',
130
- });
131
- execFileSync('codesign', ['--verify', appPath], { stdio: 'ignore' });
132
-
133
- refreshLaunchServices(appPath);
134
-
135
- console.log(`named the Electron shell "${NAME}"`);
170
+ if (isCurrent(version)) return path.join(TARGET, 'Contents', 'MacOS', NAME);
171
+ const binary = build(app, version);
172
+ report(`branded ${TARGET}`, true);
173
+ return binary;
136
174
  } catch (err) {
137
- fs.writeFileSync(plist, backup);
138
- try {
139
- execFileSync('codesign', ['--force', '--deep', '--sign', '-', appPath], {
140
- stdio: 'ignore',
141
- });
142
- } catch (_) { /* leave it as found */ }
143
- console.log(`could not rename the Electron shell (${err.message.split('\n')[0]})`);
175
+ // Leave nothing half-built: a bundle with a broken signature will not
176
+ // launch at all, which is far worse than the wrong name.
177
+ fs.rmSync(TARGET, { recursive: true, force: true });
178
+ report(`could not brand the app (${first(err)})`);
179
+ return null;
144
180
  }
145
181
  }
146
182
 
147
- try {
148
- main();
149
- } catch (err) {
150
- // Never fail an install over the name in the Dock.
151
- console.log('skipped naming the Electron shell:', err.message.split('\n')[0]);
183
+ const first = (err) => String(err && err.message).split('\n')[0];
184
+
185
+ /**
186
+ * Failures are reported, successes only when asked.
187
+ *
188
+ * A silent failure is what made this so hard to diagnose from a user's
189
+ * terminal: "the Dock still says Electron" looked identical whether branding
190
+ * had failed, had never run, or had worked on a bundle nobody was launching.
191
+ */
192
+ function report(message, quiet = false) {
193
+ if (!quiet || process.env.LATEX_STICKIES_VERBOSE) console.log(message);
152
194
  }
195
+
196
+ module.exports = { ensure, TARGET, NAME, APP_ID };
197
+
198
+ if (require.main === module) ensure();
package/src/main.js CHANGED
@@ -473,11 +473,23 @@ let quitting = false;
473
473
  */
474
474
  function prepareToExit() {
475
475
  quitting = true;
476
- if (stopWatchingNotes) {
477
- stopWatchingNotes();
478
- stopWatchingNotes = null;
476
+ // Nothing here may throw. The caller's next statement is process.exit(), and
477
+ // an exception thrown on the way out skips it -- leaving exactly the
478
+ // invisible, unreachable process this whole path exists to prevent. Saving
479
+ // is worth attempting; it is not worth staying alive for.
480
+ try {
481
+ if (stopWatchingNotes) {
482
+ stopWatchingNotes();
483
+ stopWatchingNotes = null;
484
+ }
485
+ } catch (err) {
486
+ console.error('could not stop watching the notes folder', err);
487
+ }
488
+ try {
489
+ store.saveNow();
490
+ } catch (err) {
491
+ console.error('could not save on the way out', err);
479
492
  }
480
- store.saveNow();
481
493
  }
482
494
 
483
495
  function watchNotesFolder() {
@@ -490,12 +502,16 @@ function watchNotesFolder() {
490
502
  }
491
503
 
492
504
  app.whenReady().then(async () => {
493
- // Run from npm there is no .app bundle to carry the icon, so macOS would show
494
- // the generic Electron atom in the Dock. Set it explicitly.
495
- // Only when running from source or from npm. A packaged app already carries
496
- // its icon in the bundle, and build/ is not shipped inside it -- trying
497
- // anyway logged a missing-file error on every launch of the built app.
498
- if (!app.isPackaged && process.platform === 'darwin' && app.dock) {
505
+ // Run from npm there is no .app bundle of our own to carry the icon, so
506
+ // macOS would show the generic Electron atom in the Dock. Set it explicitly.
507
+ //
508
+ // Keyed on the file existing, not on app.isPackaged. Naming the Dock entry
509
+ // means renaming the executable inside the vendored Electron.app, and
510
+ // isPackaged is derived from that executable's name -- so an npm install
511
+ // started reporting itself as packaged, this branch stopped running, and
512
+ // fixing the name cost the icon. A packaged build has no build/ directory
513
+ // inside it, which is the same condition, tested directly.
514
+ if (process.platform === 'darwin' && app.dock && fs.existsSync(ICON)) {
499
515
  try {
500
516
  const image = nativeImage.createFromBuffer(fs.readFileSync(ICON));
501
517
  if (!image.isEmpty()) app.dock.setIcon(image);
package/src/store.js CHANGED
@@ -20,7 +20,16 @@ const { app } = require('electron');
20
20
  const fs = require('fs');
21
21
  const path = require('path');
22
22
 
23
- const DIR = path.join(app.getPath('documents'), 'LaTeX Stickies');
23
+ /**
24
+ * Where the notes live.
25
+ *
26
+ * The override exists for harnesses. On macOS Electron resolves the documents
27
+ * folder through the OS rather than $HOME, so a test that redirects HOME still
28
+ * reads and writes the real notes -- which is how a verification run ended up
29
+ * touching them.
30
+ */
31
+ const DIR = process.env.LATEX_STICKIES_NOTES_DIR
32
+ || path.join(app.getPath('documents'), 'LaTeX Stickies');
24
33
  const INDEX = path.join(DIR, '.stickies.json');
25
34
  /** The old single-file store, migrated from once and then left alone. */
26
35
  const LEGACY = path.join(app.getPath('userData'), 'notes.json');