Repository navigation
Expand file tree
/
Copy pathupdater.js
More file actions
310 lines (279 loc) · 10.9 KB
/
Copy pathupdater.js
File metadata and controls
310 lines (279 loc) · 10.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
// ── Remote update ──
// electron-updater, pointed at a directory holding latest.yml + the installer.
// That directory is served either over HTTP — the LAN/Tailscale web server that
// every other self-updating app here already updates from — or straight off a
// folder / mapped drive / UNC share. The folder case needs no web server:
// ShellTab serves the directory to itself on a loopback port it picks, so
// nobody has to stand one up or remember a port number. GitHub Releases is not
// in this path at all: builds are published to the same web root as the rest,
// so an update never depends on reaching github.com or on a release existing
// there. The choice persists in update-source.json.
const { app, ipcMain, dialog } = require('electron');
const path = require('path');
const fs = require('fs');
const http = require('http');
let autoUpdater = null;
try {
({ autoUpdater } = require('electron-updater'));
} catch {
autoUpdater = null; // dependency missing — every call below degrades to a message
}
// The house update route, matching build.publish in package.json and the
// /<app>/ layout the other self-updating apps use on the same web server.
const DEFAULT_FEED = 'http://10.0.2.158/shelltab/';
// Two ways to reach a feed: a directory served over the LAN or Tailscale
// ('url', the default), or a folder, mapped drive or \\server\share this
// machine can already read ('folder').
const SOURCE_DEFAULTS = { mode: 'url', url: DEFAULT_FEED, folder: '' };
// 'github' is what installs before 1.7.0 saved, and an empty url would leave a
// box with no feed at all — both land back on the default route.
function normalizeSource(src) {
const out = { ...SOURCE_DEFAULTS, ...src };
if (out.mode !== 'folder') {
out.mode = 'url';
if (!String(out.url || '').trim()) out.url = DEFAULT_FEED;
}
return out;
}
let getWindow = () => null;
let source = { ...SOURCE_DEFAULTS };
let state = { status: 'idle', version: app.getVersion() };
// ── Loopback feed for folder mode ──
// electron-updater only speaks HTTP, so a folder feed gets a one-directory
// static server bound to 127.0.0.1 on an ephemeral port.
let folderServer = null; // { server, port, dir }
// electron-builder writes 'ShellTab Setup x.y.z.exe' with spaces while
// latest.yml records the hyphenated name (or the other way round, depending on
// how the folder was filled). Locally we can just try both instead of 404ing.
function resolveAsset(dir, name) {
const candidates = [name, name.replace(/-/g, ' '), name.replace(/ /g, '-')];
for (const candidate of candidates) {
const full = path.join(dir, candidate);
// path.join collapses '..', so re-check the result really is inside dir.
if (full !== dir && !full.startsWith(dir + path.sep)) continue;
try {
const stat = fs.statSync(full);
if (stat.isFile()) return { full, stat };
} catch {}
}
return null;
}
function serveFolder(dir) {
return (req, res) => {
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.writeHead(405).end();
return;
}
let name;
try {
name = path.basename(decodeURIComponent(new URL(req.url, 'http://127.0.0.1').pathname));
} catch {
res.writeHead(400).end();
return;
}
const hit = resolveAsset(dir, name);
if (!hit) {
res.writeHead(404).end();
return;
}
const total = hit.stat.size;
// Range matters: electron-updater asks for the tail of the installer to
// read its embedded signature block before downloading the whole thing.
const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || '');
let start = 0;
let end = total - 1;
let status = 200;
if (range) {
if (range[1] === '') {
start = Math.max(0, total - Number(range[2] || 0));
} else {
start = Number(range[1]);
if (range[2] !== '') end = Math.min(end, Number(range[2]));
}
if (!(start <= end) || start >= total) {
res.writeHead(416, { 'Content-Range': `bytes */${total}` }).end();
return;
}
status = 206;
}
const headers = {
'Content-Type': name.endsWith('.yml') ? 'text/yaml' : 'application/octet-stream',
'Content-Length': end - start + 1,
'Accept-Ranges': 'bytes',
};
if (status === 206) headers['Content-Range'] = `bytes ${start}-${end}/${total}`;
res.writeHead(status, headers);
if (req.method === 'HEAD') {
res.end();
return;
}
fs.createReadStream(hit.full, { start, end }).on('error', () => res.destroy()).pipe(res);
};
}
function stopFolderServer() {
if (!folderServer) return;
try {
folderServer.server.close();
} catch {}
folderServer = null;
}
function startFolderServer(dir) {
if (folderServer && folderServer.dir === dir) return Promise.resolve(folderServer.port);
stopFolderServer();
return new Promise((resolve, reject) => {
const server = http.createServer(serveFolder(dir));
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
folderServer = { server, port: server.address().port, dir };
resolve(folderServer.port);
});
});
}
function sourceFile() {
return path.join(app.getPath('userData'), 'update-source.json');
}
function loadSource() {
let saved = {};
try {
saved = JSON.parse(fs.readFileSync(sourceFile(), 'utf-8'));
} catch {}
source = normalizeSource(saved);
// Rewrite a migrated file now, so the next launch reads a current one.
if (saved.mode !== source.mode || saved.url !== source.url) persist();
return source;
}
async function applyFeed() {
if (!autoUpdater) return;
try {
if (source.mode === 'folder' && source.folder) {
const dir = path.resolve(source.folder);
if (!fs.statSync(dir).isDirectory()) throw new Error(`${dir} is not a folder`);
const port = await startFolderServer(dir);
// The loopback server does not do the multi-range requests a differential
// download needs, and a local copy is cheap to fetch whole anyway.
autoUpdater.disableDifferentialDownload = true;
autoUpdater.setFeedURL({ provider: 'generic', url: `http://127.0.0.1:${port}/` });
if (!resolveAsset(dir, 'latest.yml')) {
send({ status: 'error', message: `No latest.yml in ${dir}.` });
}
return;
}
stopFolderServer();
autoUpdater.disableDifferentialDownload = false;
// Trailing slash matters: electron-builder resolves latest.yml against it.
const base = String(source.url || '').trim() || DEFAULT_FEED;
const url = base.endsWith('/') ? base : `${base}/`;
autoUpdater.setFeedURL({ provider: 'generic', url });
} catch (err) {
send({ status: 'error', message: `Bad update source: ${err.message}` });
}
}
function getSource() {
return { ...source };
}
function persist() {
try {
fs.writeFileSync(sourceFile(), JSON.stringify(source, null, 2));
} catch {}
}
async function setSource(patch) {
source = normalizeSource({ ...source, ...patch });
persist();
send({ status: 'idle', message: '', source: getSource() });
await applyFeed();
return getSource();
}
// Folder mode is only usable if picking a folder is easy, so the dialog lives
// here rather than making the user type a UNC path by hand.
async function browseFolder() {
const win = getWindow();
const result = await dialog.showOpenDialog(win && !win.isDestroyed() ? win : undefined, {
title: 'Folder holding latest.yml and the installer',
properties: ['openDirectory'],
defaultPath: source.folder || undefined,
});
if (result.canceled || !result.filePaths.length) return getSource();
return setSource({ mode: 'folder', folder: result.filePaths[0] });
}
function send(patch) {
state = { ...state, ...patch, version: app.getVersion(), source: getSource() };
const win = getWindow();
if (win && !win.isDestroyed()) win.webContents.send('update:state', state);
}
// In a dev checkout there is no app-update.yml, so a check throws rather than
// reporting "up to date". Say so instead of surfacing a confusing error.
function unavailable() {
if (!autoUpdater) return 'Updater not installed in this build.';
if (!app.isPackaged) return 'Updates only work in an installed build, not a dev run.';
return null;
}
async function check({ silent = false } = {}) {
const why = unavailable();
if (why) {
if (!silent) send({ status: 'unavailable', message: why });
return state;
}
try {
send({ status: 'checking', message: '' });
const result = await autoUpdater.checkForUpdates();
// 'update-available' / 'update-not-available' fire from the listeners below;
// this only has to catch the throw.
if (!result) send({ status: 'idle' });
} catch (err) {
send({ status: 'error', message: err.message });
}
return state;
}
async function download() {
if (unavailable()) return state;
try {
send({ status: 'downloading', percent: 0 });
await autoUpdater.downloadUpdate();
} catch (err) {
send({ status: 'error', message: err.message });
}
return state;
}
function install() {
if (state.status !== 'downloaded') return state;
// isSilent=false so the NSIS installer shows progress; isForceRunAfter=true
// brings ShellTab back up once it finishes.
autoUpdater.quitAndInstall(false, true);
return state;
}
function register(windowGetter) {
getWindow = windowGetter;
ipcMain.handle('update:state', () => state);
ipcMain.handle('update:source:get', () => getSource());
ipcMain.handle('update:source:set', (event, patch) => setSource(patch || {}));
ipcMain.handle('update:source:browse', () => browseFolder());
ipcMain.handle('update:check', (event, opts) => check(opts || {}));
ipcMain.handle('update:download', () => download());
ipcMain.handle('update:install', () => install());
loadSource();
state = { ...state, source: getSource() };
app.on('will-quit', stopFolderServer);
if (!autoUpdater) return;
// Downloading is a deliberate click, and installing is a deliberate restart.
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.on('update-available', (info) => {
send({ status: 'available', latest: info.version, notes: info.releaseNotes || '' });
});
autoUpdater.on('update-not-available', () => send({ status: 'current', message: '' }));
autoUpdater.on('download-progress', (p) => {
send({ status: 'downloading', percent: Math.round(p.percent), bytesPerSecond: p.bytesPerSecond });
});
autoUpdater.on('update-downloaded', (info) => {
send({ status: 'downloaded', latest: info.version });
});
autoUpdater.on('error', (err) => send({ status: 'error', message: err?.message || String(err) }));
applyFeed();
// One quiet check a few seconds after launch, then every 6 hours, so a
// long-lived window still notices a release.
if (!unavailable()) {
setTimeout(() => check({ silent: true }), 8000);
setInterval(() => check({ silent: true }), 6 * 60 * 60 * 1000);
}
}
module.exports = { register, check, download, install, getSource, setSource, browseFolder };