@crossbind/extension-updater 0.0.0-stage → 2.0.0-beta.69
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/LICENSE +21 -0
- package/README.md +35 -2
- package/crossbind.extension.js +48 -0
- package/native/darwin.mm +100 -0
- package/native/linux.cpp +69 -0
- package/native/monocypher/LICENCE.md +167 -0
- package/native/monocypher/README.md +10 -0
- package/native/monocypher/monocypher-ed25519.c +503 -0
- package/native/monocypher/monocypher-ed25519.h +140 -0
- package/native/monocypher/monocypher.c +2988 -0
- package/native/monocypher/monocypher.h +321 -0
- package/native/monocypher-ed25519.cpp +2 -0
- package/native/monocypher.cpp +2 -0
- package/native/updater.cpp +461 -0
- package/native/updater.h +27 -0
- package/native/windows.cpp +195 -0
- package/package.json +40 -3
- package/page.d.ts +12 -0
- package/page.js +21 -0
- package/src/update.js +146 -0
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
// A desktop app's updates. The manifest at app.updater.url names the newest version, its file beside the manifest and
|
|
2
|
+
// the signature the key of app.updater.publicKey checks, as `crossbind app build` writes them with the key in
|
|
3
|
+
// CROSSBIND_UPDATER_PRIVATE_KEY. The file downloads into the app's cache folder and, once its signature holds, takes
|
|
4
|
+
// the app's place: a macOS or Linux app is swapped for the one the file unpacks, and a Windows app's setup program
|
|
5
|
+
// installs it once the app has quit. The app then opens anew. A desktop app with app.updater alone has the extension,
|
|
6
|
+
// whose options its build gives: app.updater, with the app's version and id.
|
|
7
|
+
#include "crossbind/app.h"
|
|
8
|
+
#include "crossbind/extension.h"
|
|
9
|
+
#include "monocypher/monocypher-ed25519.h"
|
|
10
|
+
#include "updater.h"
|
|
11
|
+
|
|
12
|
+
#include <algorithm>
|
|
13
|
+
#include <cerrno>
|
|
14
|
+
#include <cstdio>
|
|
15
|
+
#include <cstring>
|
|
16
|
+
#include <fstream>
|
|
17
|
+
#include <iterator>
|
|
18
|
+
#include <map>
|
|
19
|
+
#include <mutex>
|
|
20
|
+
#include <optional>
|
|
21
|
+
#include <stdexcept>
|
|
22
|
+
#include <string>
|
|
23
|
+
#include <string_view>
|
|
24
|
+
#include <vector>
|
|
25
|
+
|
|
26
|
+
#ifndef _WIN32
|
|
27
|
+
#include <fcntl.h>
|
|
28
|
+
#include <spawn.h>
|
|
29
|
+
#include <stdio.h>
|
|
30
|
+
#include <sys/wait.h>
|
|
31
|
+
#include <unistd.h>
|
|
32
|
+
|
|
33
|
+
#include <cstdlib>
|
|
34
|
+
|
|
35
|
+
extern char **environ;
|
|
36
|
+
#endif
|
|
37
|
+
|
|
38
|
+
namespace crossbind::updater {
|
|
39
|
+
namespace {
|
|
40
|
+
|
|
41
|
+
namespace fs = std::filesystem;
|
|
42
|
+
|
|
43
|
+
// What the build signed the update for.
|
|
44
|
+
#if defined(__APPLE__)
|
|
45
|
+
constexpr const char *PLATFORM = "darwin";
|
|
46
|
+
#elif defined(_WIN32)
|
|
47
|
+
constexpr const char *PLATFORM = "win32";
|
|
48
|
+
#else
|
|
49
|
+
constexpr const char *PLATFORM = "linux";
|
|
50
|
+
#endif
|
|
51
|
+
#if defined(__aarch64__) || defined(_M_ARM64)
|
|
52
|
+
constexpr const char *ARCH = "arm64";
|
|
53
|
+
#else
|
|
54
|
+
constexpr const char *ARCH = "x64";
|
|
55
|
+
#endif
|
|
56
|
+
constexpr size_t KEY_SIZE = 32;
|
|
57
|
+
constexpr size_t SIGNATURE_SIZE = 64;
|
|
58
|
+
constexpr size_t SIGNATURE_TEXT_SIZE = (SIGNATURE_SIZE + 2) / 3 * 4;
|
|
59
|
+
// A manifest is three short fields; a server that sends more, or an update past what any app weighs, fills the disk.
|
|
60
|
+
constexpr std::uintmax_t MAX_MANIFEST_SIZE = 64 * 1024;
|
|
61
|
+
constexpr std::uintmax_t MAX_UPDATE_SIZE = std::uintmax_t{2} << 30;
|
|
62
|
+
constexpr size_t READ_CHUNK = 64 * 1024;
|
|
63
|
+
|
|
64
|
+
// A check and an install download into the same folder, so one runs at a time and another fails meanwhile.
|
|
65
|
+
std::mutex updating;
|
|
66
|
+
|
|
67
|
+
// app.updater as the build gives it: the address of the manifest for this app's platform and architecture, the key that
|
|
68
|
+
// signs the app's updates, and the app's version and id, which a signature covers.
|
|
69
|
+
struct Updater {
|
|
70
|
+
std::string manifestUrl;
|
|
71
|
+
std::vector<uint8_t> key;
|
|
72
|
+
std::string version;
|
|
73
|
+
std::string id;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
struct Manifest {
|
|
77
|
+
std::string version;
|
|
78
|
+
std::string file;
|
|
79
|
+
std::string signature;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// A version as semver orders it: its three numbers, then its prerelease's identifiers. Build metadata does not count.
|
|
83
|
+
struct Version {
|
|
84
|
+
std::vector<std::string> numbers;
|
|
85
|
+
std::vector<std::string> prerelease;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
struct NoManifest {};
|
|
89
|
+
|
|
90
|
+
// ASCII alone, whatever locale GTK sets.
|
|
91
|
+
bool isDigit(char c) { return c >= '0' && c <= '9'; }
|
|
92
|
+
bool isWordChar(char c) { return isDigit(c) || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '-'; }
|
|
93
|
+
bool isSpace(char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; }
|
|
94
|
+
bool isNumber(const std::string &part) { return !part.empty() && std::all_of(part.begin(), part.end(), isDigit); }
|
|
95
|
+
bool isIdentifier(const std::string &part) { return !part.empty() && std::all_of(part.begin(), part.end(), isWordChar); }
|
|
96
|
+
|
|
97
|
+
// The file a manifest names beside it: characters an address carries as they are, and no leading dot.
|
|
98
|
+
bool isFileName(const std::string &name) {
|
|
99
|
+
return !name.empty() && name[0] != '.' &&
|
|
100
|
+
std::all_of(name.begin(), name.end(), [](char c) { return isWordChar(c) || c == '.' || c == '_' || c == '~'; });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
std::vector<std::string> split(const std::string &text, char separator) {
|
|
104
|
+
std::vector<std::string> parts;
|
|
105
|
+
size_t start = 0;
|
|
106
|
+
for (size_t end = text.find(separator); end != std::string::npos; end = text.find(separator, start)) {
|
|
107
|
+
parts.push_back(text.substr(start, end - start));
|
|
108
|
+
start = end + 1;
|
|
109
|
+
}
|
|
110
|
+
parts.push_back(text.substr(start));
|
|
111
|
+
return parts;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
std::optional<Version> versionOf(const std::string &text) {
|
|
115
|
+
const size_t plus = text.find('+');
|
|
116
|
+
const std::string release = text.substr(0, plus);
|
|
117
|
+
const size_t dash = release.find('-');
|
|
118
|
+
const std::vector<std::string> numbers = split(release.substr(0, dash), '.');
|
|
119
|
+
const std::vector<std::string> prerelease = dash == std::string::npos ? std::vector<std::string>() : split(release.substr(dash + 1), '.');
|
|
120
|
+
const std::vector<std::string> build = plus == std::string::npos ? std::vector<std::string>() : split(text.substr(plus + 1), '.');
|
|
121
|
+
const bool isValid = numbers.size() == 3 && std::all_of(numbers.begin(), numbers.end(), isNumber) &&
|
|
122
|
+
std::all_of(prerelease.begin(), prerelease.end(), isIdentifier) &&
|
|
123
|
+
std::all_of(build.begin(), build.end(), isIdentifier);
|
|
124
|
+
return isValid ? std::optional<Version>(Version{numbers, prerelease}) : std::nullopt;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Below, at or above zero as a sorts before, with or after b: numbers by their value and before words, words in ASCII
|
|
128
|
+
// order.
|
|
129
|
+
int compare(const std::string &a, const std::string &b) {
|
|
130
|
+
if (isNumber(a) != isNumber(b)) return isNumber(a) ? -1 : 1;
|
|
131
|
+
if (!isNumber(a)) return a.compare(b);
|
|
132
|
+
const std::string x = a.substr(std::min(a.find_first_not_of('0'), a.size()));
|
|
133
|
+
const std::string y = b.substr(std::min(b.find_first_not_of('0'), b.size()));
|
|
134
|
+
if (x.size() != y.size()) return x.size() < y.size() ? -1 : 1;
|
|
135
|
+
return x.compare(y);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
bool isNewer(const Version &a, const Version &b) {
|
|
139
|
+
for (size_t i = 0; i < a.numbers.size(); i += 1) {
|
|
140
|
+
if (const int order = compare(a.numbers[i], b.numbers[i])) return order > 0;
|
|
141
|
+
}
|
|
142
|
+
// A release comes after its prereleases.
|
|
143
|
+
if (a.prerelease.empty() || b.prerelease.empty()) return a.prerelease.empty() && !b.prerelease.empty();
|
|
144
|
+
for (size_t i = 0; i < std::min(a.prerelease.size(), b.prerelease.size()); i += 1) {
|
|
145
|
+
if (const int order = compare(a.prerelease[i], b.prerelease[i])) return order > 0;
|
|
146
|
+
}
|
|
147
|
+
return a.prerelease.size() > b.prerelease.size();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// The string fields of the JSON object a manifest is, which holds no other value and no escape.
|
|
151
|
+
std::map<std::string, std::string> fieldsOf(const std::string &text) {
|
|
152
|
+
size_t at = 0;
|
|
153
|
+
const auto skipSpace = [&] {
|
|
154
|
+
while (at < text.size() && isSpace(text[at])) at += 1;
|
|
155
|
+
};
|
|
156
|
+
const auto skip = [&](char c) {
|
|
157
|
+
skipSpace();
|
|
158
|
+
if (at == text.size() || text[at] != c) return false;
|
|
159
|
+
at += 1;
|
|
160
|
+
return true;
|
|
161
|
+
};
|
|
162
|
+
const auto expect = [&](char c) {
|
|
163
|
+
if (!skip(c)) throw NoManifest();
|
|
164
|
+
};
|
|
165
|
+
const auto string = [&] {
|
|
166
|
+
expect('"');
|
|
167
|
+
const size_t end = text.find('"', at);
|
|
168
|
+
if (end == std::string::npos || text.find('\\', at) < end) throw NoManifest();
|
|
169
|
+
std::string value = text.substr(at, end - at);
|
|
170
|
+
at = end + 1;
|
|
171
|
+
return value;
|
|
172
|
+
};
|
|
173
|
+
std::map<std::string, std::string> fields;
|
|
174
|
+
expect('{');
|
|
175
|
+
do {
|
|
176
|
+
std::string key = string();
|
|
177
|
+
expect(':');
|
|
178
|
+
fields[std::move(key)] = string();
|
|
179
|
+
} while (skip(','));
|
|
180
|
+
expect('}');
|
|
181
|
+
skipSpace();
|
|
182
|
+
if (at != text.size()) throw NoManifest();
|
|
183
|
+
return fields;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
std::string replaced(std::string text, const std::string &from, const std::string &to) {
|
|
187
|
+
for (size_t at = text.find(from); at != std::string::npos; at = text.find(from, at + to.size())) text.replace(at, from.size(), to);
|
|
188
|
+
return text;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// The bytes of base64 text, or nothing when base64 writes them otherwise: in groups of four, padded, with no bit past the
|
|
192
|
+
// last byte.
|
|
193
|
+
std::optional<std::vector<uint8_t>> fromBase64(const std::string &text) {
|
|
194
|
+
static constexpr std::string_view ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
195
|
+
if (text.size() % 4 != 0) return std::nullopt;
|
|
196
|
+
const size_t padding = text.ends_with("==") ? 2 : text.ends_with('=') ? 1 : 0;
|
|
197
|
+
std::vector<uint8_t> bytes;
|
|
198
|
+
uint32_t bits = 0;
|
|
199
|
+
int held = 0;
|
|
200
|
+
for (const char c : std::string_view(text).substr(0, text.size() - padding)) {
|
|
201
|
+
const size_t value = ALPHABET.find(c);
|
|
202
|
+
if (value == std::string_view::npos) return std::nullopt;
|
|
203
|
+
bits = bits << 6 | static_cast<uint32_t>(value);
|
|
204
|
+
held += 6;
|
|
205
|
+
if (held >= 8) {
|
|
206
|
+
held -= 8;
|
|
207
|
+
bytes.push_back(static_cast<uint8_t>(bits >> held));
|
|
208
|
+
bits &= (1u << held) - 1;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (bits != 0) return std::nullopt;
|
|
212
|
+
return bytes;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
Updater updaterOf(const Value &options) {
|
|
216
|
+
const std::string version = options["version"].asString();
|
|
217
|
+
const std::optional<std::vector<uint8_t>> key = fromBase64(options["publicKey"].asString());
|
|
218
|
+
if (!versionOf(version) || !key || key->size() != KEY_SIZE) {
|
|
219
|
+
throw std::runtime_error("crossbind: the updater's options are no app.updater, as crossbind's build gives them");
|
|
220
|
+
}
|
|
221
|
+
return {replaced(replaced(options["url"].asString(), "{platform}", PLATFORM), "{arch}", ARCH), *key, version, options["id"].asString()};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// The update's file is beside its manifest.
|
|
225
|
+
std::string fileUrl(const std::string &manifest, const std::string &file) {
|
|
226
|
+
const std::string address = manifest.substr(0, manifest.find_first_of("?#"));
|
|
227
|
+
return address.substr(0, address.rfind('/') + 1) + file;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
fs::path pathOf(const std::string &text) { return fs::path(std::u8string(text.begin(), text.end())); }
|
|
231
|
+
|
|
232
|
+
std::string textOf(const fs::path &path) {
|
|
233
|
+
const std::u8string text = path.u8string();
|
|
234
|
+
return std::string(text.begin(), text.end());
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
std::unique_lock<std::mutex> lockUpdates() {
|
|
238
|
+
std::unique_lock<std::mutex> lock(updating, std::try_to_lock);
|
|
239
|
+
if (!lock.owns_lock()) throw std::runtime_error("crossbind: the app is checking for or installing an update already");
|
|
240
|
+
return lock;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Emptied each time, so that no download outlives the next check; a setup program that still runs keeps its file.
|
|
244
|
+
fs::path updateFolder() {
|
|
245
|
+
const fs::path folder = pathOf(app::paths().cache) / "update";
|
|
246
|
+
std::error_code ignored;
|
|
247
|
+
fs::remove_all(folder, ignored);
|
|
248
|
+
fs::create_directories(folder);
|
|
249
|
+
return folder;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
Manifest fetchManifest(const Updater &updater, const fs::path &folder) {
|
|
253
|
+
const fs::path file = folder / "manifest.json";
|
|
254
|
+
download(updater.manifestUrl, file, MAX_MANIFEST_SIZE);
|
|
255
|
+
try {
|
|
256
|
+
std::ifstream in(file, std::ios::binary);
|
|
257
|
+
std::map<std::string, std::string> fields = fieldsOf(std::string(std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>()));
|
|
258
|
+
const Manifest manifest{fields["version"], fields["file"], fields["signature"]};
|
|
259
|
+
if (!versionOf(manifest.version) || !isFileName(manifest.file) || manifest.signature.size() != SIGNATURE_TEXT_SIZE) throw NoManifest();
|
|
260
|
+
return manifest;
|
|
261
|
+
} catch (const NoManifest &) {
|
|
262
|
+
throw std::runtime_error("crossbind: " + updater.manifestUrl + " is no update manifest, as `crossbind app build` writes beside an app's update");
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// The update the manifest offers when its version is newer than this app's.
|
|
267
|
+
std::optional<Manifest> newerUpdate(const Updater &updater, const fs::path &folder) {
|
|
268
|
+
const Manifest manifest = fetchManifest(updater, folder);
|
|
269
|
+
if (!isNewer(versionOf(manifest.version).value(), versionOf(updater.version).value())) return std::nullopt;
|
|
270
|
+
return manifest;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
std::string sha512Of(const fs::path &file) {
|
|
274
|
+
std::ifstream in(file, std::ios::binary);
|
|
275
|
+
crypto_sha512_ctx context;
|
|
276
|
+
crypto_sha512_init(&context);
|
|
277
|
+
std::vector<char> chunk(READ_CHUNK);
|
|
278
|
+
while (in.read(chunk.data(), static_cast<std::streamsize>(chunk.size())) || in.gcount() > 0) {
|
|
279
|
+
crypto_sha512_update(&context, reinterpret_cast<const uint8_t *>(chunk.data()), static_cast<size_t>(in.gcount()));
|
|
280
|
+
}
|
|
281
|
+
if (!in.eof()) throw std::runtime_error("crossbind: cannot read the update " + textOf(file));
|
|
282
|
+
uint8_t hash[64];
|
|
283
|
+
crypto_sha512_final(&context, hash);
|
|
284
|
+
std::string hex;
|
|
285
|
+
for (const uint8_t byte : hash) {
|
|
286
|
+
char digits[3];
|
|
287
|
+
std::snprintf(digits, sizeof digits, "%02x", byte);
|
|
288
|
+
hex += digits;
|
|
289
|
+
}
|
|
290
|
+
return hex;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// The signature covers the app's id, its platform and architecture, the version and the file's SHA-512, as the build
|
|
294
|
+
// signed them, so that no update passes for another app's, platform's or version's.
|
|
295
|
+
void verify(const Updater &updater, const Manifest &manifest, const fs::path &file) {
|
|
296
|
+
const std::optional<std::vector<uint8_t>> signature = fromBase64(manifest.signature);
|
|
297
|
+
const std::string text = "crossbind update\n" + updater.id + "\n" + PLATFORM + "-" + ARCH + "\n" + manifest.version + "\n" + sha512Of(file) + "\n";
|
|
298
|
+
const bool holds = signature && signature->size() == SIGNATURE_SIZE &&
|
|
299
|
+
crypto_ed25519_check(signature->data(), updater.key.data(), reinterpret_cast<const uint8_t *>(text.data()), text.size()) == 0;
|
|
300
|
+
if (!holds) {
|
|
301
|
+
throw std::runtime_error("crossbind: the update to " + manifest.version +
|
|
302
|
+
" is not signed with the key of app.updater.publicKey, so the app does not install it");
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
#ifndef _WIN32
|
|
307
|
+
|
|
308
|
+
#ifdef __APPLE__
|
|
309
|
+
constexpr const char *READ_ONLY_HINT = "The app updates in a folder its user can change: one opened from its disk image, or from where it was "
|
|
310
|
+
"downloaded to, updates once it is moved to Applications.";
|
|
311
|
+
#else
|
|
312
|
+
constexpr const char *READ_ONLY_HINT = "The app updates in a folder its user can change: one installed from its Debian package updates with the "
|
|
313
|
+
"package manager.";
|
|
314
|
+
#endif
|
|
315
|
+
|
|
316
|
+
[[noreturn]] void cannotInstall(const std::string &why) { throw std::runtime_error("crossbind: cannot install the update: " + why); }
|
|
317
|
+
|
|
318
|
+
// The child's id, or -1 with errno saying why there is none.
|
|
319
|
+
pid_t spawn(const std::vector<std::string> &command) {
|
|
320
|
+
std::vector<char *> argv;
|
|
321
|
+
for (const auto &arg : command) argv.push_back(const_cast<char *>(arg.c_str()));
|
|
322
|
+
argv.push_back(nullptr);
|
|
323
|
+
pid_t child = 0;
|
|
324
|
+
if (const int error = posix_spawn(&child, argv[0], nullptr, nullptr, argv.data(), environ)) {
|
|
325
|
+
errno = error;
|
|
326
|
+
return -1;
|
|
327
|
+
}
|
|
328
|
+
return child;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
bool runs(const std::vector<std::string> &command) {
|
|
332
|
+
const pid_t child = spawn(command);
|
|
333
|
+
if (child < 0) return false;
|
|
334
|
+
int status = 0;
|
|
335
|
+
pid_t waited = 0;
|
|
336
|
+
do {
|
|
337
|
+
waited = waitpid(child, &status, 0);
|
|
338
|
+
} while (waited < 0 && errno == EINTR);
|
|
339
|
+
return waited == child && WIFEXITED(status) && WEXITSTATUS(status) == 0;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// The one folder an update unpacks to, or none.
|
|
343
|
+
fs::path unpackedApp(const fs::path &staging) {
|
|
344
|
+
std::vector<fs::path> entries;
|
|
345
|
+
for (const auto &entry : fs::directory_iterator(staging)) entries.push_back(entry.path());
|
|
346
|
+
return entries.size() == 1 && fs::is_directory(fs::symlink_status(entries[0])) ? entries[0] : fs::path();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Trades the two places at once, so that no moment leaves the app missing.
|
|
350
|
+
bool swapped(const fs::path &a, const fs::path &b) {
|
|
351
|
+
#ifdef __APPLE__
|
|
352
|
+
return renamex_np(a.c_str(), b.c_str(), RENAME_SWAP) == 0;
|
|
353
|
+
#else
|
|
354
|
+
return renameat2(AT_FDCWD, a.c_str(), AT_FDCWD, b.c_str(), RENAME_EXCHANGE) == 0;
|
|
355
|
+
#endif
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Puts the app the update unpacks in place of this one. The update unpacks beside the app, on the same disk, whose file
|
|
359
|
+
// system swaps the two.
|
|
360
|
+
void replaceApp(const fs::path &update) {
|
|
361
|
+
#ifdef __APPLE__
|
|
362
|
+
// A macOS app is its bundle, which ditto unpacks with its signature and Apple's ticket.
|
|
363
|
+
const fs::path app = bundlePath();
|
|
364
|
+
const fs::path marker = fs::path("Contents") / "Info.plist";
|
|
365
|
+
const std::vector<std::string> unpack = {"/usr/bin/ditto", "-x", "-k", update.string()};
|
|
366
|
+
#else
|
|
367
|
+
// A Linux app is a folder, which tar unpacks as this user's.
|
|
368
|
+
const fs::path executable = fs::read_symlink("/proc/self/exe");
|
|
369
|
+
const fs::path app = executable.parent_path();
|
|
370
|
+
const fs::path marker = executable.filename();
|
|
371
|
+
const std::vector<std::string> unpack = {"/bin/tar", "--no-same-owner", "-xzf", update.string(), "-C"};
|
|
372
|
+
#endif
|
|
373
|
+
std::string staging = (app.parent_path() / ".crossbind-update-XXXXXX").string();
|
|
374
|
+
if (!mkdtemp(staging.data())) cannotInstall(app.parent_path().string() + ": " + std::strerror(errno) + ". " + READ_ONLY_HINT);
|
|
375
|
+
std::error_code ignored;
|
|
376
|
+
try {
|
|
377
|
+
std::vector<std::string> command = unpack;
|
|
378
|
+
command.push_back(staging);
|
|
379
|
+
if (!runs(command)) cannotInstall(command[0] + " cannot unpack " + update.string());
|
|
380
|
+
const fs::path unpacked = unpackedApp(staging);
|
|
381
|
+
if (unpacked.empty() || !fs::exists(unpacked / marker)) cannotInstall(update.string() + " holds no app");
|
|
382
|
+
checkSigner(unpacked);
|
|
383
|
+
if (!swapped(unpacked, app)) cannotInstall(app.parent_path().string() + " cannot swap the app for its update: " + std::strerror(errno));
|
|
384
|
+
} catch (...) {
|
|
385
|
+
fs::remove_all(staging, ignored);
|
|
386
|
+
throw;
|
|
387
|
+
}
|
|
388
|
+
// The old app is where the update was unpacked.
|
|
389
|
+
fs::remove_all(staging, ignored);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
#endif
|
|
393
|
+
|
|
394
|
+
// The version the manifest offers when it is newer than this app's.
|
|
395
|
+
std::optional<std::string> check(const Updater &updater) {
|
|
396
|
+
const std::unique_lock<std::mutex> lock = lockUpdates();
|
|
397
|
+
const std::optional<Manifest> manifest = newerUpdate(updater, updateFolder());
|
|
398
|
+
return manifest ? std::optional<std::string>(manifest->version) : std::nullopt;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Installs the newer version, then quits the app, which opens anew: a macOS or Linux app once it has quit, a Windows one
|
|
402
|
+
// from its setup program.
|
|
403
|
+
void install(const Updater &updater) {
|
|
404
|
+
#ifdef CROSSBIND_APP_DEV
|
|
405
|
+
// CROSSBIND_APP_DEV marks a debug host, as `crossbind app dev` builds, whose app its dev server opened; app.relaunch()
|
|
406
|
+
// refuses it as well.
|
|
407
|
+
throw std::runtime_error("crossbind: a development session does not install an update, which would take the place of the app its dev "
|
|
408
|
+
"server opened: the built app does");
|
|
409
|
+
#endif
|
|
410
|
+
const std::unique_lock<std::mutex> lock = lockUpdates();
|
|
411
|
+
const fs::path folder = updateFolder();
|
|
412
|
+
const std::optional<Manifest> manifest = newerUpdate(updater, folder);
|
|
413
|
+
if (!manifest) {
|
|
414
|
+
throw std::runtime_error("crossbind: the app is up to date: " + updater.manifestUrl + " offers no version newer than " + updater.version);
|
|
415
|
+
}
|
|
416
|
+
const fs::path file = folder / manifest->file;
|
|
417
|
+
try {
|
|
418
|
+
download(fileUrl(updater.manifestUrl, manifest->file), file, MAX_UPDATE_SIZE);
|
|
419
|
+
verify(updater, *manifest, file);
|
|
420
|
+
#ifdef _WIN32
|
|
421
|
+
checkSigner(file);
|
|
422
|
+
runSetup(file);
|
|
423
|
+
#else
|
|
424
|
+
replaceApp(file);
|
|
425
|
+
#endif
|
|
426
|
+
} catch (...) {
|
|
427
|
+
std::error_code ignored;
|
|
428
|
+
fs::remove(file, ignored);
|
|
429
|
+
throw;
|
|
430
|
+
}
|
|
431
|
+
#ifdef _WIN32
|
|
432
|
+
app::quit();
|
|
433
|
+
#else
|
|
434
|
+
std::error_code ignored;
|
|
435
|
+
fs::remove(file, ignored);
|
|
436
|
+
app::relaunch();
|
|
437
|
+
#endif
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
} // namespace
|
|
441
|
+
} // namespace crossbind::updater
|
|
442
|
+
|
|
443
|
+
void crossbind_extension_updater(crossbind::Registry ®istry) {
|
|
444
|
+
using namespace crossbind::updater;
|
|
445
|
+
using crossbind::Call;
|
|
446
|
+
using crossbind::Value;
|
|
447
|
+
const Updater updater = updaterOf(registry.options());
|
|
448
|
+
// The updater downloads, so its calls run on a thread of their own, which answers them.
|
|
449
|
+
registry.function("updater.check", [updater](Call call) {
|
|
450
|
+
crossbind::inBackground(std::move(call), [updater] {
|
|
451
|
+
const std::optional<std::string> version = check(updater);
|
|
452
|
+
return version ? Value(Value::Object{{"version", *version}}) : Value();
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
registry.function("updater.install", [updater](Call call) {
|
|
456
|
+
crossbind::inBackground(std::move(call), [updater] {
|
|
457
|
+
install(updater);
|
|
458
|
+
return Value();
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
}
|
package/native/updater.h
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#pragma once
|
|
2
|
+
|
|
3
|
+
// What each desktop platform does for the updater.
|
|
4
|
+
#include <cstdint>
|
|
5
|
+
#include <filesystem>
|
|
6
|
+
#include <string>
|
|
7
|
+
|
|
8
|
+
namespace crossbind::updater {
|
|
9
|
+
|
|
10
|
+
// Fetches an address into a file, on the thread that calls it, which is not the main one, and throws why it could not,
|
|
11
|
+
// as when the answer runs past maxBytes.
|
|
12
|
+
void download(const std::string &url, const std::filesystem::path &file, std::uintmax_t maxBytes);
|
|
13
|
+
// Throws why not when an update, the app it unpacked to or its setup program, is not signed by the app's own signer: on
|
|
14
|
+
// macOS by its code signature, on Windows by its Authenticode signature, which Windows trusts. A leaked key of
|
|
15
|
+
// app.updater's then installs nothing else. An app without such a signature, as a Linux app always is, takes any update
|
|
16
|
+
// the key signed; a Windows app whose own signature Windows does not trust takes none.
|
|
17
|
+
void checkSigner(const std::filesystem::path &update);
|
|
18
|
+
#ifdef __APPLE__
|
|
19
|
+
// The app's bundle, which an update takes the place of.
|
|
20
|
+
std::filesystem::path bundlePath();
|
|
21
|
+
#endif
|
|
22
|
+
#ifdef _WIN32
|
|
23
|
+
// Starts an update's setup program, which installs it where the app is once the app has quit, and opens it anew.
|
|
24
|
+
void runSetup(const std::filesystem::path &setup);
|
|
25
|
+
#endif
|
|
26
|
+
|
|
27
|
+
} // namespace crossbind::updater
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
#include "updater.h"
|
|
2
|
+
|
|
3
|
+
#include <windows.h>
|
|
4
|
+
|
|
5
|
+
#include <softpub.h>
|
|
6
|
+
#include <wincrypt.h>
|
|
7
|
+
#include <winhttp.h>
|
|
8
|
+
#include <wintrust.h>
|
|
9
|
+
|
|
10
|
+
#include <cstdio>
|
|
11
|
+
#include <fstream>
|
|
12
|
+
#include <memory>
|
|
13
|
+
#include <stdexcept>
|
|
14
|
+
#include <vector>
|
|
15
|
+
|
|
16
|
+
namespace crossbind::updater {
|
|
17
|
+
namespace {
|
|
18
|
+
|
|
19
|
+
constexpr DWORD READ_CHUNK = 64 * 1024;
|
|
20
|
+
// Antivirus software reads a program it has just seen written, which holds it from running for a moment.
|
|
21
|
+
constexpr int SETUP_TRIES = 4;
|
|
22
|
+
constexpr DWORD SETUP_RETRY_MS = 500;
|
|
23
|
+
|
|
24
|
+
std::wstring wide(const std::string &text) {
|
|
25
|
+
if (text.empty()) return {};
|
|
26
|
+
const int size = MultiByteToWideChar(CP_UTF8, 0, text.data(), static_cast<int>(text.size()), nullptr, 0);
|
|
27
|
+
std::wstring result(static_cast<size_t>(size), L'\0');
|
|
28
|
+
MultiByteToWideChar(CP_UTF8, 0, text.data(), static_cast<int>(text.size()), result.data(), size);
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
std::string utf8(const wchar_t *text) {
|
|
33
|
+
if (!text || !*text) return {};
|
|
34
|
+
const int size = WideCharToMultiByte(CP_UTF8, 0, text, -1, nullptr, 0, nullptr, nullptr);
|
|
35
|
+
std::string result(static_cast<size_t>(size - 1), '\0');
|
|
36
|
+
WideCharToMultiByte(CP_UTF8, 0, text, -1, result.data(), size, nullptr, nullptr);
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The app's executable, however long its path.
|
|
41
|
+
std::wstring executablePath() {
|
|
42
|
+
std::wstring path(MAX_PATH, L'\0');
|
|
43
|
+
for (;;) {
|
|
44
|
+
const DWORD length = GetModuleFileNameW(nullptr, path.data(), static_cast<DWORD>(path.size()));
|
|
45
|
+
if (length < path.size()) {
|
|
46
|
+
path.resize(length);
|
|
47
|
+
return path;
|
|
48
|
+
}
|
|
49
|
+
path.resize(path.size() * 2);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// WinHTTP's handles, closed as they go out of scope.
|
|
54
|
+
using Internet = std::unique_ptr<void, decltype(&WinHttpCloseHandle)>;
|
|
55
|
+
|
|
56
|
+
Internet internet(HINTERNET opened) { return Internet(opened, &WinHttpCloseHandle); }
|
|
57
|
+
|
|
58
|
+
std::runtime_error failed(const std::string &url) {
|
|
59
|
+
return std::runtime_error("crossbind: cannot download " + url + ": WinHTTP error " + std::to_string(GetLastError()));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
std::string hex(LONG status) {
|
|
63
|
+
char text[sizeof "0x00000000"];
|
|
64
|
+
std::snprintf(text, sizeof text, "0x%08lx", static_cast<unsigned long>(status));
|
|
65
|
+
return text;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// A file's Authenticode signature: whether Windows trusts it, whether the file has none at all, which WinVerifyTrust
|
|
69
|
+
// tells apart from one it cannot read by the thread's last error, and the common name of the certificate that made it
|
|
70
|
+
// once Windows trusts it. Windows checks no revocation for it, as a revocation server out of reach would refuse every
|
|
71
|
+
// update.
|
|
72
|
+
struct Signature {
|
|
73
|
+
LONG trust;
|
|
74
|
+
bool isUnsigned;
|
|
75
|
+
std::wstring signer;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
Signature signature(const std::wstring &file) {
|
|
79
|
+
WINTRUST_FILE_INFO info{};
|
|
80
|
+
info.cbStruct = sizeof info;
|
|
81
|
+
info.pcwszFilePath = file.c_str();
|
|
82
|
+
WINTRUST_DATA data{};
|
|
83
|
+
data.cbStruct = sizeof data;
|
|
84
|
+
data.dwUIChoice = WTD_UI_NONE;
|
|
85
|
+
data.fdwRevocationChecks = WTD_REVOKE_NONE;
|
|
86
|
+
data.dwUnionChoice = WTD_CHOICE_FILE;
|
|
87
|
+
data.pFile = &info;
|
|
88
|
+
data.dwStateAction = WTD_STATEACTION_VERIFY;
|
|
89
|
+
data.dwProvFlags = WTD_REVOCATION_CHECK_NONE;
|
|
90
|
+
GUID action = WINTRUST_ACTION_GENERIC_VERIFY_V2;
|
|
91
|
+
const HWND noWindow = static_cast<HWND>(INVALID_HANDLE_VALUE);
|
|
92
|
+
Signature result{WinVerifyTrust(noWindow, &action, &data), false, {}};
|
|
93
|
+
if (result.trust == TRUST_E_NOSIGNATURE) {
|
|
94
|
+
const auto reason = static_cast<LONG>(GetLastError());
|
|
95
|
+
result.isUnsigned = reason == TRUST_E_NOSIGNATURE || reason == TRUST_E_SUBJECT_FORM_UNKNOWN || reason == TRUST_E_PROVIDER_UNKNOWN;
|
|
96
|
+
}
|
|
97
|
+
if (result.trust == ERROR_SUCCESS) {
|
|
98
|
+
CRYPT_PROVIDER_DATA *provider = WTHelperProvDataFromStateData(data.hWVTStateData);
|
|
99
|
+
CRYPT_PROVIDER_SGNR *signer = provider ? WTHelperGetProvSignerFromChain(provider, 0, FALSE, 0) : nullptr;
|
|
100
|
+
CRYPT_PROVIDER_CERT *leaf = signer ? WTHelperGetProvCertFromChain(signer, 0) : nullptr;
|
|
101
|
+
if (leaf) {
|
|
102
|
+
void *commonName = const_cast<char *>(szOID_COMMON_NAME);
|
|
103
|
+
const DWORD length = CertGetNameStringW(leaf->pCert, CERT_NAME_ATTR_TYPE, 0, commonName, nullptr, 0);
|
|
104
|
+
result.signer.resize(length);
|
|
105
|
+
CertGetNameStringW(leaf->pCert, CERT_NAME_ATTR_TYPE, 0, commonName, result.signer.data(), length);
|
|
106
|
+
result.signer.resize(length - 1);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
data.dwStateAction = WTD_STATEACTION_CLOSE;
|
|
110
|
+
WinVerifyTrust(noWindow, &action, &data);
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
} // namespace
|
|
115
|
+
|
|
116
|
+
// A signed app's update has a setup program whose signature Windows trusts, by the app's own signer: the same common
|
|
117
|
+
// name, which a renewed certificate keeps. A signed app whose own signature Windows does not trust installs none.
|
|
118
|
+
void checkSigner(const std::filesystem::path &update) {
|
|
119
|
+
const Signature own = signature(executablePath());
|
|
120
|
+
if (own.isUnsigned) return;
|
|
121
|
+
if (own.trust != ERROR_SUCCESS) {
|
|
122
|
+
throw std::runtime_error("crossbind: cannot install the update: Windows does not trust this app's own signature (" + hex(own.trust) +
|
|
123
|
+
"), whose signer an update must carry; sign the app with a certificate Windows trusts");
|
|
124
|
+
}
|
|
125
|
+
const Signature theirs = signature(update.wstring());
|
|
126
|
+
if (theirs.trust != ERROR_SUCCESS || theirs.signer != own.signer) {
|
|
127
|
+
const std::string but = theirs.trust != ERROR_SUCCESS ? ", and Windows trusts no signature of it (" + hex(theirs.trust) + ")"
|
|
128
|
+
: ", but by " + utf8(theirs.signer.c_str());
|
|
129
|
+
throw std::runtime_error("crossbind: cannot install the update: " + utf8(update.filename().c_str()) + " is not signed by " + utf8(own.signer.c_str()) +
|
|
130
|
+
", who signs this app" + but);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// WinHTTP keeps no cache, so every check asks the server anew, and it goes through the system's proxy.
|
|
135
|
+
void download(const std::string &url, const std::filesystem::path &file, std::uintmax_t maxBytes) {
|
|
136
|
+
const std::wstring address = wide(url);
|
|
137
|
+
URL_COMPONENTS parts{};
|
|
138
|
+
parts.dwStructSize = sizeof parts;
|
|
139
|
+
parts.dwHostNameLength = parts.dwUrlPathLength = parts.dwExtraInfoLength = static_cast<DWORD>(-1);
|
|
140
|
+
if (!WinHttpCrackUrl(address.c_str(), 0, 0, &parts)) throw std::runtime_error("crossbind: " + url + " is no address");
|
|
141
|
+
const std::wstring host(parts.lpszHostName, parts.dwHostNameLength);
|
|
142
|
+
const std::wstring path = std::wstring(parts.lpszUrlPath, parts.dwUrlPathLength) + std::wstring(parts.lpszExtraInfo, parts.dwExtraInfoLength);
|
|
143
|
+
const Internet session = internet(WinHttpOpen(L"crossbind", WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0));
|
|
144
|
+
if (!session) throw failed(url);
|
|
145
|
+
const Internet connection = internet(WinHttpConnect(session.get(), host.c_str(), parts.nPort, 0));
|
|
146
|
+
if (!connection) throw failed(url);
|
|
147
|
+
const Internet request = internet(WinHttpOpenRequest(connection.get(), L"GET", path.c_str(), nullptr, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES,
|
|
148
|
+
parts.nScheme == INTERNET_SCHEME_HTTPS ? WINHTTP_FLAG_SECURE : 0));
|
|
149
|
+
DWORD status = 0;
|
|
150
|
+
DWORD size = sizeof status;
|
|
151
|
+
if (!request || !WinHttpSendRequest(request.get(), WINHTTP_NO_ADDITIONAL_HEADERS, 0, WINHTTP_NO_REQUEST_DATA, 0, 0, 0) ||
|
|
152
|
+
!WinHttpReceiveResponse(request.get(), nullptr) ||
|
|
153
|
+
!WinHttpQueryHeaders(request.get(), WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, WINHTTP_HEADER_NAME_BY_INDEX, &status, &size,
|
|
154
|
+
WINHTTP_NO_HEADER_INDEX)) {
|
|
155
|
+
throw failed(url);
|
|
156
|
+
}
|
|
157
|
+
if (status != HTTP_STATUS_OK) throw std::runtime_error("crossbind: " + url + " answered " + std::to_string(status));
|
|
158
|
+
std::ofstream out(file, std::ios::binary | std::ios::trunc);
|
|
159
|
+
std::vector<char> chunk(READ_CHUNK);
|
|
160
|
+
std::uintmax_t total = 0;
|
|
161
|
+
for (DWORD read = 0;;) {
|
|
162
|
+
if (!WinHttpReadData(request.get(), chunk.data(), READ_CHUNK, &read)) throw failed(url);
|
|
163
|
+
if (read == 0) break;
|
|
164
|
+
total += read;
|
|
165
|
+
if (total > maxBytes) throw std::runtime_error("crossbind: " + url + " answered more than " + std::to_string(maxBytes) + " bytes");
|
|
166
|
+
out.write(chunk.data(), read);
|
|
167
|
+
}
|
|
168
|
+
if (!out.flush()) throw std::runtime_error("crossbind: cannot keep the download of " + url);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// installer.nsi's /UPDATE waits for the app to quit, installs the update in the app's folder, which /D= names unquoted
|
|
172
|
+
// and last, and opens it anew. A folder this user cannot write to fails here, as the setup program shows nothing.
|
|
173
|
+
void runSetup(const std::filesystem::path &setup) {
|
|
174
|
+
const std::filesystem::path folder = std::filesystem::path(executablePath()).parent_path();
|
|
175
|
+
wchar_t probe[MAX_PATH];
|
|
176
|
+
if (!GetTempFileNameW(folder.c_str(), L"cbu", 0, probe)) {
|
|
177
|
+
throw std::runtime_error("crossbind: cannot install the update: this user cannot change " + utf8(folder.c_str()));
|
|
178
|
+
}
|
|
179
|
+
DeleteFileW(probe);
|
|
180
|
+
std::wstring command = L"\"" + setup.wstring() + L"\" /S /UPDATE /D=" + folder.wstring();
|
|
181
|
+
STARTUPINFOW startup{};
|
|
182
|
+
startup.cb = sizeof startup;
|
|
183
|
+
PROCESS_INFORMATION process{};
|
|
184
|
+
for (int tries = 1; !CreateProcessW(setup.c_str(), command.data(), nullptr, nullptr, FALSE, 0, nullptr, nullptr, &startup, &process); tries += 1) {
|
|
185
|
+
const DWORD error = GetLastError();
|
|
186
|
+
if (error != ERROR_SHARING_VIOLATION || tries == SETUP_TRIES) {
|
|
187
|
+
throw std::runtime_error("crossbind: cannot install the update: Windows cannot run its setup program, error " + std::to_string(error));
|
|
188
|
+
}
|
|
189
|
+
Sleep(SETUP_RETRY_MS);
|
|
190
|
+
}
|
|
191
|
+
CloseHandle(process.hThread);
|
|
192
|
+
CloseHandle(process.hProcess);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
} // namespace crossbind::updater
|