stikfix 1.6.1 → 1.7.0

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.
@@ -13,11 +13,12 @@ import { writeFileSync, readFileSync, existsSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
14
  import { parseArgs } from 'node:util';
15
15
  import { pathToFileURL } from 'node:url';
16
- import { resolveConfig, resolveConfigValues, ensureNotesDir, writeTokenFile } from './config.js';
16
+ import { resolveConfig, resolveConfigValues, ensureNotesDir, writeTokenFile, VERSION } from './config.js';
17
17
  import { createHostServer } from './server.js';
18
18
  import { bindServer, BIND_HOST } from './bind.js';
19
19
  import { probeExistingHost } from './probe.js';
20
20
  import { startTray } from './tray.js';
21
+ import { runUpdateCheck } from './update-check.js';
21
22
  /**
22
23
  * Start the HTTP host. Extracted from the former top-level module body so the
23
24
  * single-executable dispatcher (exe-main.ts) can call it as `serve`, while
@@ -132,6 +133,15 @@ export async function startHttpHost(argv = process.argv.slice(2)) {
132
133
  notesDir: cfg.notesDir,
133
134
  hostPid: process.pid,
134
135
  });
136
+ // Host auto-update: check GitHub for a newer release on startup and every 6h.
137
+ // Fire-and-forget + fully swallowed — a failed check must never crash or block
138
+ // the host. runUpdateCheck updates the module state surfaced on GET /status.
139
+ void runUpdateCheck(VERSION).catch(() => { });
140
+ const updateTimer = setInterval(() => {
141
+ void runUpdateCheck(VERSION).catch(() => { });
142
+ }, 6 * 60 * 60 * 1000);
143
+ // .unref() so the interval never keeps the process alive on its own.
144
+ updateTimer.unref();
135
145
  // Kill the tray child when the host shuts down so a dead host has no tray.
136
146
  // (No prior signal handling existed here; these are additive.)
137
147
  let shuttingDown = false;
@@ -139,6 +149,10 @@ export async function startHttpHost(argv = process.argv.slice(2)) {
139
149
  if (shuttingDown)
140
150
  return;
141
151
  shuttingDown = true;
152
+ try {
153
+ clearInterval(updateTimer);
154
+ }
155
+ catch { /* best-effort */ }
142
156
  try {
143
157
  tray?.kill();
144
158
  }
@@ -17,6 +17,7 @@ import { writeNote } from './write-note.js';
17
17
  import { listAnnotations, editNote, deleteNote } from './read-note.js';
18
18
  import { validateChosenFolder } from './validate-folder.js';
19
19
  import { isGitRepo, getLastGitSyncStatus, gitSyncNote } from './git-sync.js';
20
+ import { getUpdateState } from './update-check.js';
20
21
  // ---------------------------------------------------------------------------
21
22
  // Per-request notes-dir resolution (D-04 — targetDir support)
22
23
  // ---------------------------------------------------------------------------
@@ -95,6 +96,9 @@ async function handleStatus(req, res, cfg) {
95
96
  // result of the most recent git-sync attempt (null if none yet).
96
97
  gitRepo: await isGitRepo(cfg.root),
97
98
  gitSyncStatus: getLastGitSyncStatus(),
99
+ // Host auto-update surface (public, non-secret): whether a newer release is
100
+ // available and where to get it. Consumed by the tray for 1-click apply.
101
+ update: getUpdateState(),
98
102
  });
99
103
  res.writeHead(200, { 'Content-Type': 'application/json' });
100
104
  res.end(body);
@@ -20,7 +20,7 @@
20
20
  */
21
21
  import { spawn } from 'node:child_process';
22
22
  import { writeFileSync, mkdirSync } from 'node:fs';
23
- import { join } from 'node:path';
23
+ import { dirname, join } from 'node:path';
24
24
  import { homedir } from 'node:os';
25
25
  /**
26
26
  * Directory + path where the embedded tray script is written on each start.
@@ -48,6 +48,7 @@ export function trayScriptPath() {
48
48
  */
49
49
  export const TRAY_PS1 = String.raw `param(
50
50
  [int]$Port,
51
+ [string]$IconPath,
51
52
  [string]$Root,
52
53
  [string]$Name,
53
54
  [string]$NotesDir,
@@ -56,6 +57,13 @@ export const TRAY_PS1 = String.raw `param(
56
57
 
57
58
  $ErrorActionPreference = 'SilentlyContinue'
58
59
 
60
+ # --- Update state (script scope) --------------------------------------------
61
+ # Set when GET /status reports an available update; consumed by the update menu
62
+ # item's click handler. UpdateShown gates the one-time "update available" balloon.
63
+ $script:UpdateShown = $false
64
+ $script:PendingUrl = ''
65
+ $script:PendingSha = ''
66
+
59
67
  try {
60
68
  Add-Type -AssemblyName System.Windows.Forms
61
69
  Add-Type -AssemblyName System.Drawing
@@ -67,6 +75,7 @@ try {
67
75
  # --- Resolve a custom app icon, else fall back to a stock icon -------------
68
76
  function Get-BaseIcon {
69
77
  $candidates = @(
78
+ $IconPath,
70
79
  (Join-Path $PSScriptRoot 'stikfix.ico'),
71
80
  (Join-Path $Root '.output\chrome-mv3\icon\stikfix.ico'),
72
81
  (Join-Path $Root 'public\icon\stikfix.ico')
@@ -98,6 +107,45 @@ Set-SafeText "stikfix - $Name - starting on :$Port"
98
107
  # --- Context menu -----------------------------------------------------------
99
108
  $menu = New-Object System.Windows.Forms.ContextMenuStrip
100
109
 
110
+ # Update item — first in the menu, hidden until an update is detected. Clicking
111
+ # it downloads the new installer, verifies its SHA-256, and runs it (one UAC
112
+ # prompt). The elevated installer stops the old host, swaps files, and restarts
113
+ # it; this tray self-disposes when the old host process dies.
114
+ $miUpdate = New-Object System.Windows.Forms.ToolStripMenuItem
115
+ $miUpdate.Text = 'Update Stikfix'
116
+ $miUpdate.Add_Click({
117
+ $url = $script:PendingUrl
118
+ $sha = $script:PendingSha
119
+ if ([string]::IsNullOrEmpty($url) -or [string]::IsNullOrEmpty($sha)) {
120
+ try { $notify.ShowBalloonTip(5000, 'Stikfix', 'Update details unavailable', [System.Windows.Forms.ToolTipIcon]::Warning) } catch { }
121
+ return
122
+ }
123
+ try { $notify.ShowBalloonTip(5000, 'Stikfix', 'Downloading Stikfix update...', [System.Windows.Forms.ToolTipIcon]::Info) } catch { }
124
+ $tmp = Join-Path $env:TEMP 'stikfix-update-setup.exe'
125
+ try {
126
+ $wc = New-Object System.Net.WebClient
127
+ $wc.DownloadFile($url, $tmp)
128
+ $wc.Dispose()
129
+ } catch {
130
+ try { $notify.ShowBalloonTip(5000, 'Stikfix', 'Download failed', [System.Windows.Forms.ToolTipIcon]::Error) } catch { }
131
+ return
132
+ }
133
+ # Security gate: verify SHA-256 before executing the installer.
134
+ $actual = ''
135
+ try { $actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $tmp).Hash } catch { }
136
+ if ([string]::IsNullOrEmpty($actual) -or ($actual -ine $sha)) {
137
+ try { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue } catch { }
138
+ try { $notify.ShowBalloonTip(5000, 'Stikfix', 'Update verification failed - not installing.', [System.Windows.Forms.ToolTipIcon]::Error) } catch { }
139
+ return
140
+ }
141
+ try {
142
+ $notify.ShowBalloonTip(5000, 'Stikfix', "Installing update - you'll see a permission prompt.", [System.Windows.Forms.ToolTipIcon]::Info)
143
+ Start-Process -FilePath $tmp -ArgumentList '/SILENT','/SUPPRESSMSGBOXES','/NORESTART'
144
+ } catch {
145
+ try { $notify.ShowBalloonTip(5000, 'Stikfix', 'Could not launch the update installer.', [System.Windows.Forms.ToolTipIcon]::Error) } catch { }
146
+ }
147
+ })
148
+
101
149
  $miOpen = New-Object System.Windows.Forms.ToolStripMenuItem
102
150
  $miOpen.Text = 'Open notes folder'
103
151
  $miOpen.Add_Click({
@@ -123,6 +171,14 @@ $miQuit.Add_Click({
123
171
  [void]$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator))
124
172
  [void]$menu.Items.Add($miStop)
125
173
  [void]$menu.Items.Add($miQuit)
174
+
175
+ # Update item + its separator go FIRST (index 0/1), hidden until an update lands.
176
+ $sepUpdate = New-Object System.Windows.Forms.ToolStripSeparator
177
+ $menu.Items.Insert(0, $sepUpdate)
178
+ $menu.Items.Insert(0, $miUpdate)
179
+ $miUpdate.Visible = $false
180
+ $sepUpdate.Visible = $false
181
+
126
182
  $notify.ContextMenuStrip = $menu
127
183
 
128
184
  # --- Status polling ---------------------------------------------------------
@@ -132,17 +188,35 @@ function Test-HostAlive {
132
188
  return [bool]$proc
133
189
  }
134
190
 
135
- function Test-StatusOk {
191
+ # Fetch /status, read + parse the JSON body, and return a hashtable:
192
+ # @{ Ok = <bool health>; Update = <the .update object or $null> }
193
+ # Any failure (throw, non-200, unparseable body) => @{ Ok = $false; Update = $null }.
194
+ function Get-HostStatus {
136
195
  try {
137
196
  $req = [System.Net.WebRequest]::Create("http://127.0.0.1:$Port/status")
138
197
  $req.Method = 'GET'
139
198
  $req.Timeout = 1500
140
199
  $resp = $req.GetResponse()
141
200
  $code = [int]$resp.StatusCode
201
+ $body = ''
202
+ try {
203
+ $stream = $resp.GetResponseStream()
204
+ $reader = New-Object System.IO.StreamReader($stream)
205
+ $body = $reader.ReadToEnd()
206
+ $reader.Close()
207
+ } catch { }
142
208
  $resp.Close()
143
- return ($code -eq 200)
209
+ if ($code -ne 200) { return @{ Ok = $false; Update = $null } }
210
+ $update = $null
211
+ try {
212
+ $json = $body | ConvertFrom-Json
213
+ if ($json -and ($json.PSObject.Properties.Name -contains 'update')) {
214
+ $update = $json.update
215
+ }
216
+ } catch { }
217
+ return @{ Ok = $true; Update = $update }
144
218
  } catch {
145
- return $false
219
+ return @{ Ok = $false; Update = $null }
146
220
  }
147
221
  }
148
222
 
@@ -154,13 +228,47 @@ function Update-State {
154
228
  return
155
229
  }
156
230
 
157
- if (Test-StatusOk) {
231
+ $status = Get-HostStatus
232
+ $ok = $status.Ok
233
+ $update = $status.Update
234
+
235
+ # Compute the base running/stopped tooltip; the update suffix is appended below.
236
+ if ($ok) {
158
237
  $notify.Icon = $iconRunning
159
- Set-SafeText "stikfix - $Name - running on :$Port"
238
+ $tip = "stikfix - $Name - running on :$Port"
160
239
  } else {
161
240
  $notify.Icon = $iconStopped
162
- Set-SafeText "stikfix - $Name - not responding"
241
+ $tip = "stikfix - $Name - not responding"
163
242
  }
243
+
244
+ # Surface an available update (guarded — $update is $null on old hosts).
245
+ $hasUpdate = $false
246
+ try { $hasUpdate = ($update -ne $null) -and ($update.available -eq $true) } catch { $hasUpdate = $false }
247
+
248
+ if ($hasUpdate) {
249
+ $latest = $update.latestVersion
250
+ $script:PendingUrl = [string]$update.url
251
+ $script:PendingSha = [string]$update.sha256
252
+ try {
253
+ $miUpdate.Text = "Update Stikfix (v$latest)"
254
+ $miUpdate.Visible = $true
255
+ $sepUpdate.Visible = $true
256
+ } catch { }
257
+ $tip = "$tip - update v$latest available"
258
+ if ($script:UpdateShown -eq $false) {
259
+ try {
260
+ $notify.ShowBalloonTip(5000, 'Stikfix update available', "Version $latest is ready. Right-click the tray icon then Update Stikfix.", [System.Windows.Forms.ToolTipIcon]::Info)
261
+ } catch { }
262
+ $script:UpdateShown = $true
263
+ }
264
+ } else {
265
+ try {
266
+ $miUpdate.Visible = $false
267
+ $sepUpdate.Visible = $false
268
+ } catch { }
269
+ }
270
+
271
+ Set-SafeText $tip
164
272
  }
165
273
 
166
274
  $timer = New-Object System.Windows.Forms.Timer
@@ -193,6 +301,8 @@ export function startTray(opts) {
193
301
  const ps1Path = trayScriptPath();
194
302
  mkdirSync(join(homedir(), '.local', 'share', 'stikfix'), { recursive: true });
195
303
  writeFileSync(ps1Path, TRAY_PS1, { encoding: 'utf8' });
304
+ // Installed host ships stikfix.ico next to the exe ({app}\stikfix.ico). For npx/dev this points at node's dir and simply won't resolve — Get-BaseIcon falls back to a stock icon.
305
+ const iconPath = join(dirname(process.execPath), 'stikfix.ico');
196
306
  const child = spawn('powershell.exe', [
197
307
  '-NoProfile',
198
308
  '-ExecutionPolicy',
@@ -203,6 +313,8 @@ export function startTray(opts) {
203
313
  ps1Path,
204
314
  '-Port',
205
315
  String(opts.port),
316
+ '-IconPath',
317
+ iconPath,
206
318
  '-Root',
207
319
  opts.root,
208
320
  '-Name',
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Host auto-update check for stikfix-host.
3
+ *
4
+ * The host periodically asks GitHub for the latest published release manifest
5
+ * (`latest.json`) and, if a newer version is available, advertises it on
6
+ * GET /status (`update.available` / `latestVersion` / `url` / `sha256`) so the
7
+ * system-tray helper can offer a one-click apply.
8
+ *
9
+ * Design mirrors the git-sync module singleton pattern (getLastGitSyncStatus):
10
+ * a module-level UpdateState is mutated by runUpdateCheck and read by
11
+ * getUpdateState. All network I/O is fully defensive — fetchLatestManifest and
12
+ * runUpdateCheck NEVER throw (a failed check must never crash the host).
13
+ *
14
+ * Node builtins only — uses the Node 20 global `fetch` (follows redirects),
15
+ * consistent with lib/discovery.ts. No new npm dependency.
16
+ */
17
+ // ---------------------------------------------------------------------------
18
+ // Semver compare (plain MAJOR.MINOR.PATCH, no external dep)
19
+ // ---------------------------------------------------------------------------
20
+ /**
21
+ * Compare two dotted-integer version strings.
22
+ *
23
+ * Each segment is parsed with parseInt so a non-numeric suffix (e.g. "1.7.0-rc1")
24
+ * contributes only its leading integer. Missing segments count as 0, so
25
+ * "1.7" === "1.7.0". A malformed/empty string parses every segment as 0.
26
+ *
27
+ * @returns -1 if a < b, 0 if equal, 1 if a > b.
28
+ */
29
+ export function compareSemver(a, b) {
30
+ const parse = (v) => String(v ?? '')
31
+ .split('.')
32
+ .map((seg) => {
33
+ const n = parseInt(seg, 10);
34
+ return Number.isNaN(n) ? 0 : n;
35
+ });
36
+ const pa = parse(a);
37
+ const pb = parse(b);
38
+ const len = Math.max(pa.length, pb.length);
39
+ for (let i = 0; i < len; i++) {
40
+ const x = pa[i] ?? 0;
41
+ const y = pb[i] ?? 0;
42
+ if (x < y)
43
+ return -1;
44
+ if (x > y)
45
+ return 1;
46
+ }
47
+ return 0;
48
+ }
49
+ let updateState = {
50
+ available: false,
51
+ latestVersion: null,
52
+ url: null,
53
+ sha256: null,
54
+ checkedAt: 0,
55
+ error: null,
56
+ };
57
+ /** Read the most recent update-check result (for GET /status). */
58
+ export function getUpdateState() {
59
+ return updateState;
60
+ }
61
+ /** Test-only: reset the module singleton back to its initial shape. */
62
+ export function __resetUpdateStateForTests() {
63
+ updateState = {
64
+ available: false,
65
+ latestVersion: null,
66
+ url: null,
67
+ sha256: null,
68
+ checkedAt: 0,
69
+ error: null,
70
+ };
71
+ }
72
+ export const UPDATE_MANIFEST_URL = process.env.STIKFIX_UPDATE_MANIFEST_URL ||
73
+ 'https://github.com/omernesh/stikfix/releases/latest/download/latest.json';
74
+ /**
75
+ * GET the latest-release manifest and validate its shape.
76
+ *
77
+ * Returns null on any non-2xx, network error, JSON-parse error, or an invalid
78
+ * shape (missing/blank version|url, or a sha256 that is not 64 hex chars).
79
+ * NEVER throws — the caller treats null as "no usable manifest this time".
80
+ */
81
+ export async function fetchLatestManifest(fetchImpl = fetch, url = UPDATE_MANIFEST_URL) {
82
+ try {
83
+ const resp = await fetchImpl(url, { signal: AbortSignal.timeout(8000) });
84
+ if (!resp.ok)
85
+ return null;
86
+ const data = (await resp.json());
87
+ if (!data || typeof data !== 'object')
88
+ return null;
89
+ const { version, url: dlUrl, sha256 } = data;
90
+ if (typeof version !== 'string' || version.length === 0 ||
91
+ typeof dlUrl !== 'string' || dlUrl.length === 0 ||
92
+ typeof sha256 !== 'string' || !/^[0-9a-f]{64}$/i.test(sha256)) {
93
+ return null;
94
+ }
95
+ return { version, url: dlUrl, sha256 };
96
+ }
97
+ catch {
98
+ return null;
99
+ }
100
+ }
101
+ // ---------------------------------------------------------------------------
102
+ // runUpdateCheck — refresh the module state
103
+ // ---------------------------------------------------------------------------
104
+ /**
105
+ * Fetch the manifest, compare against the running version, and update the
106
+ * module-level UpdateState.
107
+ *
108
+ * On a failed fetch/parse: keep any prior successful result's
109
+ * available/latestVersion/url/sha256 fields, set error = 'update check failed',
110
+ * and do NOT bump checkedAt.
111
+ *
112
+ * On success: available = remote > current; latestVersion always set to the
113
+ * remote version; url/sha256 set only when an update is available; error null;
114
+ * checkedAt = now.
115
+ *
116
+ * Safe to call with no network (inject fetchImpl in tests). NEVER throws.
117
+ */
118
+ export async function runUpdateCheck(currentVersion, fetchImpl = fetch) {
119
+ const manifest = await fetchLatestManifest(fetchImpl);
120
+ if (manifest === null) {
121
+ updateState = { ...updateState, error: 'update check failed' };
122
+ return updateState;
123
+ }
124
+ const avail = compareSemver(manifest.version, currentVersion) > 0;
125
+ updateState = {
126
+ available: avail,
127
+ latestVersion: manifest.version,
128
+ url: avail ? manifest.url : null,
129
+ sha256: avail ? manifest.sha256 : null,
130
+ checkedAt: Date.now(),
131
+ error: null,
132
+ };
133
+ return updateState;
134
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stikfix",
3
- "version": "1.6.1",
3
+ "version": "1.7.0",
4
4
  "description": "Pin sticky notes on any page — your AI reads them.",
5
5
  "license": "MIT",
6
6
  "author": "Omer Nesher <omernesher@gmail.com>",
@@ -53,6 +53,7 @@
53
53
  "check": "tsc --noEmit && tsc --noEmit -p tsconfig.host.json && node scripts/clean-room-check.mjs && node scripts/host-smoke-test.mjs && npm run test:lib && npm test",
54
54
  "pack:crx": "node scripts/pack-crx.mjs",
55
55
  "gen:update-xml": "node scripts/gen-update-xml.mjs",
56
+ "gen:latest-json": "node scripts/gen-latest-json.mjs",
56
57
  "build:sea": "node scripts/build-sea.mjs",
57
58
  "build:installer": "node scripts/build-installer.mjs",
58
59
  "postinstall": "node -e \"try{require.resolve('wxt');require('node:child_process').execSync('wxt prepare',{stdio:'inherit'})}catch(e){}\""