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.
- package/dist/host/src/index.js +15 -1
- package/dist/host/src/server.js +4 -0
- package/dist/host/src/tray.js +112 -6
- package/dist/host/src/update-check.js +134 -0
- package/package.json +2 -1
package/dist/host/src/index.js
CHANGED
|
@@ -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
|
}
|
package/dist/host/src/server.js
CHANGED
|
@@ -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);
|
package/dist/host/src/tray.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
238
|
+
$tip = "stikfix - $Name - running on :$Port"
|
|
162
239
|
} else {
|
|
163
240
|
$notify.Icon = $iconStopped
|
|
164
|
-
|
|
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.
|
|
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){}\""
|