stikfix 1.6.2 → 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);
@@ -57,6 +57,13 @@ export const TRAY_PS1 = String.raw `param(
57
57
 
58
58
  $ErrorActionPreference = 'SilentlyContinue'
59
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
+
60
67
  try {
61
68
  Add-Type -AssemblyName System.Windows.Forms
62
69
  Add-Type -AssemblyName System.Drawing
@@ -100,6 +107,45 @@ Set-SafeText "stikfix - $Name - starting on :$Port"
100
107
  # --- Context menu -----------------------------------------------------------
101
108
  $menu = New-Object System.Windows.Forms.ContextMenuStrip
102
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
+
103
149
  $miOpen = New-Object System.Windows.Forms.ToolStripMenuItem
104
150
  $miOpen.Text = 'Open notes folder'
105
151
  $miOpen.Add_Click({
@@ -125,6 +171,14 @@ $miQuit.Add_Click({
125
171
  [void]$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator))
126
172
  [void]$menu.Items.Add($miStop)
127
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
+
128
182
  $notify.ContextMenuStrip = $menu
129
183
 
130
184
  # --- Status polling ---------------------------------------------------------
@@ -134,17 +188,35 @@ function Test-HostAlive {
134
188
  return [bool]$proc
135
189
  }
136
190
 
137
- 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 {
138
195
  try {
139
196
  $req = [System.Net.WebRequest]::Create("http://127.0.0.1:$Port/status")
140
197
  $req.Method = 'GET'
141
198
  $req.Timeout = 1500
142
199
  $resp = $req.GetResponse()
143
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 { }
144
208
  $resp.Close()
145
- 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 }
146
218
  } catch {
147
- return $false
219
+ return @{ Ok = $false; Update = $null }
148
220
  }
149
221
  }
150
222
 
@@ -156,13 +228,47 @@ function Update-State {
156
228
  return
157
229
  }
158
230
 
159
- 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) {
160
237
  $notify.Icon = $iconRunning
161
- Set-SafeText "stikfix - $Name - running on :$Port"
238
+ $tip = "stikfix - $Name - running on :$Port"
162
239
  } else {
163
240
  $notify.Icon = $iconStopped
164
- Set-SafeText "stikfix - $Name - not responding"
241
+ $tip = "stikfix - $Name - not responding"
165
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
166
272
  }
167
273
 
168
274
  $timer = New-Object System.Windows.Forms.Timer
@@ -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.2",
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){}\""