@qpjoy/electron-launcher 0.2.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/README.md +164 -0
- package/dist/index.d.ts +79 -0
- package/dist/index.js +62 -0
- package/dist/network-diagnostics.d.ts +42 -0
- package/dist/network-diagnostics.js +254 -0
- package/dist/network-ownership-registry.d.ts +68 -0
- package/dist/network-ownership-registry.js +286 -0
- package/dist/release-updater.d.ts +129 -0
- package/dist/release-updater.js +195 -0
- package/dist/standalone-data-plane.d.ts +119 -0
- package/dist/standalone-data-plane.js +441 -0
- package/dist/system-domain-proxy.d.ts +91 -0
- package/dist/system-domain-proxy.js +2729 -0
- package/dist/wireguard.d.ts +289 -0
- package/dist/wireguard.js +902 -0
- package/package.json +61 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export type ElectronLauncherNetworkOwnerState = 'connecting' | 'active' | 'stale' | 'released';
|
|
2
|
+
export type ElectronLauncherNetworkOwnershipResource = 'dns-host' | 'dns-zone' | 'route-cidr' | 'reverse-proxy-route';
|
|
3
|
+
export interface ElectronLauncherNetworkOwnershipRoute {
|
|
4
|
+
routeId?: string | null;
|
|
5
|
+
host: string;
|
|
6
|
+
dnsTarget?: string | null;
|
|
7
|
+
targetUrl?: string | null;
|
|
8
|
+
tlsMode?: string | null;
|
|
9
|
+
authRequired?: boolean | null;
|
|
10
|
+
enabled?: boolean | null;
|
|
11
|
+
}
|
|
12
|
+
export interface ElectronLauncherNetworkOwnershipClaim {
|
|
13
|
+
ownerId: string;
|
|
14
|
+
productId?: string | null;
|
|
15
|
+
instanceId?: string | null;
|
|
16
|
+
displayName?: string | null;
|
|
17
|
+
state?: ElectronLauncherNetworkOwnerState | null;
|
|
18
|
+
priority?: number | null;
|
|
19
|
+
leaseIp?: string | null;
|
|
20
|
+
gatewayIp?: string | null;
|
|
21
|
+
dnsHosts?: string[] | null;
|
|
22
|
+
dnsZones?: string[] | null;
|
|
23
|
+
routeCidrs?: string[] | null;
|
|
24
|
+
reverseProxyRoutes?: ElectronLauncherNetworkOwnershipRoute[] | null;
|
|
25
|
+
metadata?: Record<string, unknown> | null;
|
|
26
|
+
updatedAt?: string | null;
|
|
27
|
+
}
|
|
28
|
+
export interface ElectronLauncherNetworkOwner {
|
|
29
|
+
ownerId: string;
|
|
30
|
+
productId: string | null;
|
|
31
|
+
instanceId: string | null;
|
|
32
|
+
displayName: string | null;
|
|
33
|
+
state: ElectronLauncherNetworkOwnerState;
|
|
34
|
+
priority: number;
|
|
35
|
+
leaseIp: string | null;
|
|
36
|
+
gatewayIp: string | null;
|
|
37
|
+
metadata: Record<string, unknown> | null;
|
|
38
|
+
updatedAt: string | null;
|
|
39
|
+
}
|
|
40
|
+
export interface ElectronLauncherNetworkOwnershipEntry {
|
|
41
|
+
resource: ElectronLauncherNetworkOwnershipResource;
|
|
42
|
+
key: string;
|
|
43
|
+
ownerId: string;
|
|
44
|
+
productId: string | null;
|
|
45
|
+
priority: number;
|
|
46
|
+
state: ElectronLauncherNetworkOwnerState;
|
|
47
|
+
value: string;
|
|
48
|
+
target?: string | null;
|
|
49
|
+
route?: ElectronLauncherNetworkOwnershipRoute | null;
|
|
50
|
+
}
|
|
51
|
+
export interface ElectronLauncherNetworkOwnershipConflict {
|
|
52
|
+
resource: ElectronLauncherNetworkOwnershipResource;
|
|
53
|
+
key: string;
|
|
54
|
+
owners: string[];
|
|
55
|
+
reason: string;
|
|
56
|
+
}
|
|
57
|
+
export interface ElectronLauncherNetworkOwnershipRegistry {
|
|
58
|
+
owners: ElectronLauncherNetworkOwner[];
|
|
59
|
+
dnsHosts: ElectronLauncherNetworkOwnershipEntry[];
|
|
60
|
+
dnsZones: ElectronLauncherNetworkOwnershipEntry[];
|
|
61
|
+
routeCidrs: ElectronLauncherNetworkOwnershipEntry[];
|
|
62
|
+
reverseProxyRoutes: ElectronLauncherNetworkOwnershipEntry[];
|
|
63
|
+
conflicts: ElectronLauncherNetworkOwnershipConflict[];
|
|
64
|
+
}
|
|
65
|
+
export declare function buildElectronLauncherNetworkOwnershipRegistry(claims: ElectronLauncherNetworkOwnershipClaim[]): ElectronLauncherNetworkOwnershipRegistry;
|
|
66
|
+
export declare function resolveElectronLauncherDnsOwner(registry: ElectronLauncherNetworkOwnershipRegistry, hostname: string): ElectronLauncherNetworkOwnershipEntry | null;
|
|
67
|
+
export declare function mergedElectronLauncherDnsZones(registry: ElectronLauncherNetworkOwnershipRegistry): string[];
|
|
68
|
+
export declare function mergedElectronLauncherReverseProxyRoutes(registry: ElectronLauncherNetworkOwnershipRegistry): ElectronLauncherNetworkOwnershipRoute[];
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { isIP } from 'node:net';
|
|
2
|
+
export function buildElectronLauncherNetworkOwnershipRegistry(claims) {
|
|
3
|
+
const normalized = claims
|
|
4
|
+
.map(normalizeClaim)
|
|
5
|
+
.filter((claim) => Boolean(claim));
|
|
6
|
+
const owners = normalized.map((claim) => claim.owner);
|
|
7
|
+
const dnsHosts = sortEntries(normalized.flatMap((claim) => claim.dnsHosts));
|
|
8
|
+
const dnsZones = sortEntries(normalized.flatMap((claim) => claim.dnsZones));
|
|
9
|
+
const routeCidrs = sortEntries(normalized.flatMap((claim) => claim.routeCidrs));
|
|
10
|
+
const reverseProxyRoutes = sortEntries(normalized.flatMap((claim) => claim.reverseProxyRoutes));
|
|
11
|
+
const conflicts = [
|
|
12
|
+
...duplicateKeyConflicts('dns-host', dnsHosts),
|
|
13
|
+
...duplicateKeyConflicts('dns-zone', dnsZones),
|
|
14
|
+
...duplicateRouteConflicts(routeCidrs),
|
|
15
|
+
...duplicateReverseProxyRouteConflicts(reverseProxyRoutes)
|
|
16
|
+
];
|
|
17
|
+
return {
|
|
18
|
+
owners,
|
|
19
|
+
dnsHosts,
|
|
20
|
+
dnsZones,
|
|
21
|
+
routeCidrs,
|
|
22
|
+
reverseProxyRoutes,
|
|
23
|
+
conflicts
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function resolveElectronLauncherDnsOwner(registry, hostname) {
|
|
27
|
+
const host = normalizeDomain(hostname);
|
|
28
|
+
if (!host)
|
|
29
|
+
return null;
|
|
30
|
+
const exact = sortEntries(registry.dnsHosts.filter((entry) => entry.key === host))[0];
|
|
31
|
+
if (exact)
|
|
32
|
+
return exact;
|
|
33
|
+
const zones = registry.dnsZones
|
|
34
|
+
.filter((entry) => host === entry.key || host.endsWith(`.${entry.key}`))
|
|
35
|
+
.sort((left, right) => right.key.length - left.key.length || entrySort(left, right));
|
|
36
|
+
return zones[0] || null;
|
|
37
|
+
}
|
|
38
|
+
export function mergedElectronLauncherDnsZones(registry) {
|
|
39
|
+
return uniqueStrings(registry.dnsZones.map((entry) => entry.key));
|
|
40
|
+
}
|
|
41
|
+
export function mergedElectronLauncherReverseProxyRoutes(registry) {
|
|
42
|
+
const byHost = new Map();
|
|
43
|
+
for (const entry of sortEntries(registry.reverseProxyRoutes)) {
|
|
44
|
+
if (!entry.route || byHost.has(entry.key))
|
|
45
|
+
continue;
|
|
46
|
+
byHost.set(entry.key, entry.route);
|
|
47
|
+
}
|
|
48
|
+
return [...byHost.values()];
|
|
49
|
+
}
|
|
50
|
+
function normalizeClaim(input) {
|
|
51
|
+
const ownerId = normalizeOwnerId(input.ownerId);
|
|
52
|
+
if (!ownerId)
|
|
53
|
+
return null;
|
|
54
|
+
const state = normalizeState(input.state);
|
|
55
|
+
if (state === 'released')
|
|
56
|
+
return null;
|
|
57
|
+
const priority = normalizePriority(input.priority);
|
|
58
|
+
const productId = nullableString(input.productId);
|
|
59
|
+
const owner = {
|
|
60
|
+
ownerId,
|
|
61
|
+
productId,
|
|
62
|
+
instanceId: nullableString(input.instanceId),
|
|
63
|
+
displayName: nullableString(input.displayName),
|
|
64
|
+
state,
|
|
65
|
+
priority,
|
|
66
|
+
leaseIp: normalizeIpv4(input.leaseIp),
|
|
67
|
+
gatewayIp: normalizeIpv4(input.gatewayIp),
|
|
68
|
+
metadata: input.metadata && typeof input.metadata === 'object' ? input.metadata : null,
|
|
69
|
+
updatedAt: nullableString(input.updatedAt)
|
|
70
|
+
};
|
|
71
|
+
const entryBase = {
|
|
72
|
+
ownerId,
|
|
73
|
+
productId,
|
|
74
|
+
priority,
|
|
75
|
+
state
|
|
76
|
+
};
|
|
77
|
+
return {
|
|
78
|
+
owner,
|
|
79
|
+
dnsHosts: uniqueStrings((input.dnsHosts || []).map(normalizeDomain))
|
|
80
|
+
.map((host) => ({
|
|
81
|
+
...entryBase,
|
|
82
|
+
resource: 'dns-host',
|
|
83
|
+
key: host,
|
|
84
|
+
value: host,
|
|
85
|
+
target: owner.gatewayIp || owner.leaseIp
|
|
86
|
+
})),
|
|
87
|
+
dnsZones: uniqueStrings((input.dnsZones || []).map(normalizeDomain))
|
|
88
|
+
.map((zone) => ({
|
|
89
|
+
...entryBase,
|
|
90
|
+
resource: 'dns-zone',
|
|
91
|
+
key: zone,
|
|
92
|
+
value: zone,
|
|
93
|
+
target: owner.gatewayIp || owner.leaseIp
|
|
94
|
+
})),
|
|
95
|
+
routeCidrs: uniqueStrings((input.routeCidrs || []).map(normalizeIpv4Cidr))
|
|
96
|
+
.map((cidr) => ({
|
|
97
|
+
...entryBase,
|
|
98
|
+
resource: 'route-cidr',
|
|
99
|
+
key: cidr,
|
|
100
|
+
value: cidr,
|
|
101
|
+
target: owner.leaseIp
|
|
102
|
+
})),
|
|
103
|
+
reverseProxyRoutes: normalizeRoutes(input.reverseProxyRoutes).map((route) => ({
|
|
104
|
+
...entryBase,
|
|
105
|
+
resource: 'reverse-proxy-route',
|
|
106
|
+
key: route.host,
|
|
107
|
+
value: route.host,
|
|
108
|
+
target: route.targetUrl || route.dnsTarget || owner.gatewayIp || owner.leaseIp,
|
|
109
|
+
route
|
|
110
|
+
}))
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function duplicateKeyConflicts(resource, entries) {
|
|
114
|
+
const conflicts = [];
|
|
115
|
+
for (const group of groupEntries(entries).values()) {
|
|
116
|
+
const owners = uniqueStrings(group.map((entry) => entry.ownerId));
|
|
117
|
+
if (owners.length <= 1)
|
|
118
|
+
continue;
|
|
119
|
+
const highest = group[0]?.priority ?? 0;
|
|
120
|
+
const samePriorityOwners = uniqueStrings(group.filter((entry) => entry.priority === highest).map((entry) => entry.ownerId));
|
|
121
|
+
if (samePriorityOwners.length > 1) {
|
|
122
|
+
conflicts.push({
|
|
123
|
+
resource,
|
|
124
|
+
key: group[0].key,
|
|
125
|
+
owners,
|
|
126
|
+
reason: 'same-priority owners claim the same resource'
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return conflicts;
|
|
131
|
+
}
|
|
132
|
+
function duplicateRouteConflicts(entries) {
|
|
133
|
+
const conflicts = duplicateKeyConflicts('route-cidr', entries);
|
|
134
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
135
|
+
for (let j = i + 1; j < entries.length; j += 1) {
|
|
136
|
+
const left = entries[i];
|
|
137
|
+
const right = entries[j];
|
|
138
|
+
if (left.ownerId === right.ownerId || left.key === right.key)
|
|
139
|
+
continue;
|
|
140
|
+
if (cidrOverlaps(left.key, right.key)) {
|
|
141
|
+
conflicts.push({
|
|
142
|
+
resource: 'route-cidr',
|
|
143
|
+
key: `${left.key} <> ${right.key}`,
|
|
144
|
+
owners: uniqueStrings([left.ownerId, right.ownerId]),
|
|
145
|
+
reason: 'route CIDRs overlap'
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return conflicts;
|
|
151
|
+
}
|
|
152
|
+
function duplicateReverseProxyRouteConflicts(entries) {
|
|
153
|
+
const conflicts = [];
|
|
154
|
+
for (const group of groupEntries(entries).values()) {
|
|
155
|
+
const owners = uniqueStrings(group.map((entry) => entry.ownerId));
|
|
156
|
+
if (owners.length <= 1)
|
|
157
|
+
continue;
|
|
158
|
+
const targets = uniqueStrings(group
|
|
159
|
+
.map((entry) => nullableString(entry.target))
|
|
160
|
+
.filter((target) => Boolean(target)));
|
|
161
|
+
if (targets.length > 1) {
|
|
162
|
+
conflicts.push({
|
|
163
|
+
resource: 'reverse-proxy-route',
|
|
164
|
+
key: group[0].key,
|
|
165
|
+
owners,
|
|
166
|
+
reason: 'same host maps to different upstream targets'
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return conflicts;
|
|
171
|
+
}
|
|
172
|
+
function groupEntries(entries) {
|
|
173
|
+
const groups = new Map();
|
|
174
|
+
for (const entry of sortEntries(entries)) {
|
|
175
|
+
const group = groups.get(entry.key) || [];
|
|
176
|
+
group.push(entry);
|
|
177
|
+
groups.set(entry.key, group);
|
|
178
|
+
}
|
|
179
|
+
return groups;
|
|
180
|
+
}
|
|
181
|
+
function sortEntries(entries) {
|
|
182
|
+
return [...entries].sort(entrySort);
|
|
183
|
+
}
|
|
184
|
+
function entrySort(left, right) {
|
|
185
|
+
return right.priority - left.priority
|
|
186
|
+
|| stateRank(right.state) - stateRank(left.state)
|
|
187
|
+
|| left.key.localeCompare(right.key)
|
|
188
|
+
|| left.ownerId.localeCompare(right.ownerId);
|
|
189
|
+
}
|
|
190
|
+
function stateRank(state) {
|
|
191
|
+
if (state === 'active')
|
|
192
|
+
return 3;
|
|
193
|
+
if (state === 'connecting')
|
|
194
|
+
return 2;
|
|
195
|
+
if (state === 'stale')
|
|
196
|
+
return 1;
|
|
197
|
+
return 0;
|
|
198
|
+
}
|
|
199
|
+
function normalizeRoutes(routes) {
|
|
200
|
+
return (routes || [])
|
|
201
|
+
.map((route) => {
|
|
202
|
+
const host = normalizeDomain(route?.host);
|
|
203
|
+
if (!host || route.enabled === false)
|
|
204
|
+
return null;
|
|
205
|
+
return {
|
|
206
|
+
...route,
|
|
207
|
+
host,
|
|
208
|
+
dnsTarget: normalizeIpv4(route.dnsTarget) || nullableString(route.dnsTarget),
|
|
209
|
+
targetUrl: nullableString(route.targetUrl),
|
|
210
|
+
tlsMode: nullableString(route.tlsMode),
|
|
211
|
+
authRequired: route.authRequired === true,
|
|
212
|
+
enabled: true
|
|
213
|
+
};
|
|
214
|
+
})
|
|
215
|
+
.filter((route) => route !== null);
|
|
216
|
+
}
|
|
217
|
+
function normalizeOwnerId(value) {
|
|
218
|
+
const text = nullableString(value);
|
|
219
|
+
if (!text)
|
|
220
|
+
return null;
|
|
221
|
+
return text.replace(/[^a-zA-Z0-9_.:-]/g, '-').slice(0, 160) || null;
|
|
222
|
+
}
|
|
223
|
+
function normalizeState(value) {
|
|
224
|
+
if (value === 'connecting' || value === 'active' || value === 'stale' || value === 'released')
|
|
225
|
+
return value;
|
|
226
|
+
return 'active';
|
|
227
|
+
}
|
|
228
|
+
function normalizePriority(value) {
|
|
229
|
+
const number = typeof value === 'number' ? value : Number(value);
|
|
230
|
+
return Number.isFinite(number) ? Math.trunc(number) : 100;
|
|
231
|
+
}
|
|
232
|
+
function normalizeDomain(value) {
|
|
233
|
+
return typeof value === 'string'
|
|
234
|
+
? value.trim().toLowerCase().replace(/^\.+|\.+$/g, '')
|
|
235
|
+
: '';
|
|
236
|
+
}
|
|
237
|
+
function normalizeIpv4(value) {
|
|
238
|
+
const text = nullableString(value);
|
|
239
|
+
return text && isIP(text) === 4 ? text : null;
|
|
240
|
+
}
|
|
241
|
+
function normalizeIpv4Cidr(value) {
|
|
242
|
+
const text = nullableString(value);
|
|
243
|
+
if (!text)
|
|
244
|
+
return '';
|
|
245
|
+
const [rawIp, rawPrefix] = text.split('/');
|
|
246
|
+
if (isIP(rawIp) !== 4)
|
|
247
|
+
return '';
|
|
248
|
+
const prefix = rawPrefix === undefined ? 32 : Number(rawPrefix);
|
|
249
|
+
if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32)
|
|
250
|
+
return '';
|
|
251
|
+
return `${rawIp}/${prefix}`;
|
|
252
|
+
}
|
|
253
|
+
function cidrOverlaps(left, right) {
|
|
254
|
+
const leftRange = cidrRange(left);
|
|
255
|
+
const rightRange = cidrRange(right);
|
|
256
|
+
if (!leftRange || !rightRange)
|
|
257
|
+
return false;
|
|
258
|
+
return leftRange.start <= rightRange.end && rightRange.start <= leftRange.end;
|
|
259
|
+
}
|
|
260
|
+
function cidrRange(cidr) {
|
|
261
|
+
const [ip, rawPrefix] = cidr.split('/');
|
|
262
|
+
if (isIP(ip) !== 4)
|
|
263
|
+
return null;
|
|
264
|
+
const prefix = Number(rawPrefix);
|
|
265
|
+
if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32)
|
|
266
|
+
return null;
|
|
267
|
+
const base = ipv4ToNumber(ip);
|
|
268
|
+
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
|
|
269
|
+
const start = (base & mask) >>> 0;
|
|
270
|
+
const hostMask = (~mask) >>> 0;
|
|
271
|
+
return {
|
|
272
|
+
start,
|
|
273
|
+
end: (start | hostMask) >>> 0
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
function ipv4ToNumber(ip) {
|
|
277
|
+
return ip.split('.')
|
|
278
|
+
.map((part) => Number(part))
|
|
279
|
+
.reduce((acc, part) => ((acc << 8) + part) >>> 0, 0);
|
|
280
|
+
}
|
|
281
|
+
function uniqueStrings(values) {
|
|
282
|
+
return [...new Set(values.filter(Boolean))];
|
|
283
|
+
}
|
|
284
|
+
function nullableString(value) {
|
|
285
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
286
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { FetchLike } from '@qpjoy/mx-launcher-core';
|
|
2
|
+
export type ElectronLauncherReleaseUpdateMode = 'none' | 'automatic' | 'manual' | 'mandatory';
|
|
3
|
+
export type ElectronLauncherReleaseActivationMode = 'hot-auto' | 'hot-manual' | 'restart-auto' | 'restart-manual' | 'installer-manual';
|
|
4
|
+
export interface ElectronLauncherReleasePolicyDecision {
|
|
5
|
+
componentKind: string;
|
|
6
|
+
componentId: string;
|
|
7
|
+
currentVersion: string;
|
|
8
|
+
targetVersion: string;
|
|
9
|
+
updateAvailable: boolean;
|
|
10
|
+
updateMode: ElectronLauncherReleaseUpdateMode;
|
|
11
|
+
canSkip: boolean;
|
|
12
|
+
canDefer: boolean;
|
|
13
|
+
requiresGate: boolean;
|
|
14
|
+
rollbackRequired: boolean;
|
|
15
|
+
reason: string;
|
|
16
|
+
}
|
|
17
|
+
export interface ElectronLauncherReleaseArtifactRef {
|
|
18
|
+
artifactId: string;
|
|
19
|
+
kind: string;
|
|
20
|
+
componentId: string;
|
|
21
|
+
version: string;
|
|
22
|
+
source: string;
|
|
23
|
+
url: string | null;
|
|
24
|
+
digest: string | null;
|
|
25
|
+
signature: string | null;
|
|
26
|
+
sizeBytes: number | null;
|
|
27
|
+
platform?: string | null;
|
|
28
|
+
activation: ElectronLauncherReleaseActivationMode;
|
|
29
|
+
autoApply: boolean;
|
|
30
|
+
restartRequired: boolean;
|
|
31
|
+
requiredAppRestart: boolean;
|
|
32
|
+
notes: string[];
|
|
33
|
+
}
|
|
34
|
+
export interface ElectronLauncherReleasePlan {
|
|
35
|
+
planId: string;
|
|
36
|
+
releaseId: string;
|
|
37
|
+
environment: string;
|
|
38
|
+
channel: string;
|
|
39
|
+
installId: string | null;
|
|
40
|
+
userId: string | null;
|
|
41
|
+
createdBy: string;
|
|
42
|
+
components: {
|
|
43
|
+
launcher?: ElectronLauncherReleasePolicyDecision;
|
|
44
|
+
app?: ElectronLauncherReleasePolicyDecision;
|
|
45
|
+
};
|
|
46
|
+
artifacts: ElectronLauncherReleaseArtifactRef[];
|
|
47
|
+
rollout?: {
|
|
48
|
+
strategy?: string;
|
|
49
|
+
percentage?: number;
|
|
50
|
+
segmentId?: string;
|
|
51
|
+
rings?: string[];
|
|
52
|
+
featureKeys?: string[];
|
|
53
|
+
channels?: string[];
|
|
54
|
+
allowAutoPromote?: boolean;
|
|
55
|
+
canaryMetricGate?: string;
|
|
56
|
+
};
|
|
57
|
+
activation?: {
|
|
58
|
+
checkSource?: string;
|
|
59
|
+
hotUpdateAuto?: boolean;
|
|
60
|
+
hotUpdateToast?: boolean;
|
|
61
|
+
majorUpdateRequiresInstaller?: boolean;
|
|
62
|
+
restartAfterApply?: boolean;
|
|
63
|
+
manualConfirmRequired?: boolean;
|
|
64
|
+
connectionSafeMode?: boolean;
|
|
65
|
+
};
|
|
66
|
+
test?: {
|
|
67
|
+
suiteId?: string;
|
|
68
|
+
gate?: {
|
|
69
|
+
verdict?: string;
|
|
70
|
+
reason?: string;
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
decisions?: {
|
|
74
|
+
readyToPromote?: boolean;
|
|
75
|
+
requiresApproval?: boolean;
|
|
76
|
+
canaryAllowed?: boolean;
|
|
77
|
+
rollbackRequired?: boolean;
|
|
78
|
+
nextActions?: string[];
|
|
79
|
+
};
|
|
80
|
+
createdAt: string;
|
|
81
|
+
}
|
|
82
|
+
export interface ElectronLauncherUpdateCheckInput {
|
|
83
|
+
componentId: string;
|
|
84
|
+
componentKind?: string;
|
|
85
|
+
currentVersion: string;
|
|
86
|
+
channel: string;
|
|
87
|
+
installId?: string | null;
|
|
88
|
+
userId?: string | null;
|
|
89
|
+
platform?: string | null;
|
|
90
|
+
}
|
|
91
|
+
export interface ElectronLauncherUpdateCheckResult {
|
|
92
|
+
checkedAt: string;
|
|
93
|
+
baseUrl: string;
|
|
94
|
+
status: 'up-to-date' | 'update-available' | 'blocked' | 'failed';
|
|
95
|
+
plan: ElectronLauncherReleasePlan | null;
|
|
96
|
+
decision: ElectronLauncherReleasePolicyDecision;
|
|
97
|
+
artifacts: ElectronLauncherReleaseArtifactRef[];
|
|
98
|
+
reason: string;
|
|
99
|
+
}
|
|
100
|
+
export interface ElectronLauncherReleaseUpdaterOptions {
|
|
101
|
+
baseUrl: string;
|
|
102
|
+
fetchImpl?: FetchLike;
|
|
103
|
+
reportInstallId?: string | null;
|
|
104
|
+
}
|
|
105
|
+
export interface ElectronLauncherReleaseReportInput {
|
|
106
|
+
taskId?: string | null;
|
|
107
|
+
installId?: string | null;
|
|
108
|
+
status: string;
|
|
109
|
+
error?: string | null;
|
|
110
|
+
metadata?: Record<string, unknown>;
|
|
111
|
+
}
|
|
112
|
+
export interface ElectronLauncherArtifactDownloadInput {
|
|
113
|
+
artifact: ElectronLauncherReleaseArtifactRef;
|
|
114
|
+
targetPath: string;
|
|
115
|
+
maxRedirects?: number;
|
|
116
|
+
}
|
|
117
|
+
export interface ElectronLauncherArtifactDownloadResult {
|
|
118
|
+
ok: boolean;
|
|
119
|
+
targetPath: string;
|
|
120
|
+
digest: string | null;
|
|
121
|
+
expectedDigest: string | null;
|
|
122
|
+
bytes: number;
|
|
123
|
+
}
|
|
124
|
+
export interface ElectronLauncherReleaseUpdater {
|
|
125
|
+
check(input: ElectronLauncherUpdateCheckInput): Promise<ElectronLauncherUpdateCheckResult>;
|
|
126
|
+
report(input: ElectronLauncherReleaseReportInput): Promise<Record<string, unknown>>;
|
|
127
|
+
}
|
|
128
|
+
export declare function createElectronLauncherReleaseUpdater(options: ElectronLauncherReleaseUpdaterOptions): ElectronLauncherReleaseUpdater;
|
|
129
|
+
export declare function downloadElectronLauncherReleaseArtifactToFile(input: ElectronLauncherArtifactDownloadInput): Promise<ElectronLauncherArtifactDownloadResult>;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { createWriteStream } from 'node:fs';
|
|
3
|
+
import { mkdir, rename, rm } from 'node:fs/promises';
|
|
4
|
+
import { get as httpGet } from 'node:http';
|
|
5
|
+
import { get as httpsGet } from 'node:https';
|
|
6
|
+
import { dirname } from 'node:path';
|
|
7
|
+
import { pipeline } from 'node:stream/promises';
|
|
8
|
+
export function createElectronLauncherReleaseUpdater(options) {
|
|
9
|
+
const baseUrl = normalizeBaseUrl(options.baseUrl);
|
|
10
|
+
const fetchImpl = options.fetchImpl ?? globalFetch();
|
|
11
|
+
return {
|
|
12
|
+
async check(input) {
|
|
13
|
+
const checkedAt = new Date().toISOString();
|
|
14
|
+
const plansPayload = await requestJson(fetchImpl, joinUrl(baseUrl, '/internal/v1/release-management/plans'), 'GET');
|
|
15
|
+
const plans = Array.isArray(plansPayload.plans) ? plansPayload.plans : [];
|
|
16
|
+
const plan = selectReleasePlan(plans, input);
|
|
17
|
+
const planDecision = plan ? selectPlanDecision(plan, input) : null;
|
|
18
|
+
const fallbackTargetVersion = planDecision?.targetVersion || input.currentVersion;
|
|
19
|
+
const decisionPayload = await requestJson(fetchImpl, joinUrl(baseUrl, '/internal/v1/releases/policy/evaluate'), 'POST', {
|
|
20
|
+
componentKind: planDecision?.componentKind || input.componentKind || 'app-managed',
|
|
21
|
+
componentId: input.componentId,
|
|
22
|
+
currentVersion: input.currentVersion,
|
|
23
|
+
targetVersion: fallbackTargetVersion,
|
|
24
|
+
channel: input.channel,
|
|
25
|
+
installId: input.installId ?? null,
|
|
26
|
+
userId: input.userId ?? null
|
|
27
|
+
});
|
|
28
|
+
const decision = decisionPayload.decision;
|
|
29
|
+
const artifacts = plan ? matchingArtifacts(plan, input.componentId, input.platform) : [];
|
|
30
|
+
const gateVerdict = plan?.test?.gate?.verdict;
|
|
31
|
+
const status = !decision.updateAvailable
|
|
32
|
+
? 'up-to-date'
|
|
33
|
+
: gateVerdict && gateVerdict !== 'passed'
|
|
34
|
+
? 'blocked'
|
|
35
|
+
: 'update-available';
|
|
36
|
+
return {
|
|
37
|
+
checkedAt,
|
|
38
|
+
baseUrl,
|
|
39
|
+
status,
|
|
40
|
+
plan,
|
|
41
|
+
decision,
|
|
42
|
+
artifacts,
|
|
43
|
+
reason: status === 'blocked'
|
|
44
|
+
? plan?.test?.gate?.reason || `release gate is ${gateVerdict}`
|
|
45
|
+
: decision.reason
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
async report(input) {
|
|
49
|
+
const installId = input.installId ?? options.reportInstallId ?? null;
|
|
50
|
+
return requestJson(fetchImpl, joinUrl(baseUrl, '/internal/v1/release/reports'), 'POST', {
|
|
51
|
+
taskId: input.taskId ?? undefined,
|
|
52
|
+
installId: installId ?? undefined,
|
|
53
|
+
status: input.status,
|
|
54
|
+
error: input.error ?? null,
|
|
55
|
+
metadata: input.metadata ?? {}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export async function downloadElectronLauncherReleaseArtifactToFile(input) {
|
|
61
|
+
const url = input.artifact.url?.trim();
|
|
62
|
+
if (!url)
|
|
63
|
+
throw new Error(`Release artifact ${input.artifact.artifactId} has no URL`);
|
|
64
|
+
await mkdir(dirname(input.targetPath), { recursive: true });
|
|
65
|
+
const tempPath = `${input.targetPath}.download`;
|
|
66
|
+
await rm(tempPath, { force: true });
|
|
67
|
+
const hash = createHash('sha256');
|
|
68
|
+
let bytes = 0;
|
|
69
|
+
let digest = null;
|
|
70
|
+
const expectedDigest = normalizeDigest(input.artifact.digest);
|
|
71
|
+
try {
|
|
72
|
+
const response = await openDownloadStream(url, input.maxRedirects ?? 3);
|
|
73
|
+
response.stream.on('data', (chunk) => {
|
|
74
|
+
bytes += chunk.length;
|
|
75
|
+
hash.update(chunk);
|
|
76
|
+
});
|
|
77
|
+
await pipeline(response.stream, createWriteStream(tempPath, { flags: 'wx' }));
|
|
78
|
+
digest = `sha256:${hash.digest('hex')}`;
|
|
79
|
+
if (Number.isFinite(input.artifact.sizeBytes) && bytes !== input.artifact.sizeBytes) {
|
|
80
|
+
throw new Error(`Release artifact size mismatch: expected ${input.artifact.sizeBytes}, got ${bytes}`);
|
|
81
|
+
}
|
|
82
|
+
if (expectedDigest && digest !== expectedDigest) {
|
|
83
|
+
throw new Error(`Release artifact digest mismatch: expected ${expectedDigest}, got ${digest}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
await rm(tempPath, { force: true });
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
await rename(tempPath, input.targetPath);
|
|
91
|
+
return {
|
|
92
|
+
ok: true,
|
|
93
|
+
targetPath: input.targetPath,
|
|
94
|
+
digest,
|
|
95
|
+
expectedDigest,
|
|
96
|
+
bytes
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function selectReleasePlan(plans, input) {
|
|
100
|
+
return plans.find((plan) => {
|
|
101
|
+
if (plan.channel !== input.channel)
|
|
102
|
+
return false;
|
|
103
|
+
if (plan.installId && plan.installId !== input.installId)
|
|
104
|
+
return false;
|
|
105
|
+
if (plan.userId && plan.userId !== input.userId)
|
|
106
|
+
return false;
|
|
107
|
+
return Boolean(selectPlanDecision(plan, input));
|
|
108
|
+
}) ?? null;
|
|
109
|
+
}
|
|
110
|
+
function selectPlanDecision(plan, input) {
|
|
111
|
+
const decisions = [plan.components?.launcher, plan.components?.app].filter(Boolean);
|
|
112
|
+
return decisions.find((decision) => {
|
|
113
|
+
if (!decision)
|
|
114
|
+
return false;
|
|
115
|
+
if (decision.componentId !== input.componentId)
|
|
116
|
+
return false;
|
|
117
|
+
if (input.componentKind && decision.componentKind !== input.componentKind)
|
|
118
|
+
return false;
|
|
119
|
+
if (decision.currentVersion && decision.currentVersion !== input.currentVersion)
|
|
120
|
+
return true;
|
|
121
|
+
return decision.targetVersion !== input.currentVersion;
|
|
122
|
+
}) ?? null;
|
|
123
|
+
}
|
|
124
|
+
function matchingArtifacts(plan, componentId, platform) {
|
|
125
|
+
const normalizedPlatform = platform?.trim() || null;
|
|
126
|
+
return (Array.isArray(plan.artifacts) ? plan.artifacts : [])
|
|
127
|
+
.filter((artifact) => artifact.componentId === componentId || !artifact.componentId)
|
|
128
|
+
.filter((artifact) => !artifact.platform || !normalizedPlatform || artifact.platform === normalizedPlatform);
|
|
129
|
+
}
|
|
130
|
+
async function requestJson(fetchImpl, url, method, body) {
|
|
131
|
+
const response = await fetchImpl(url, {
|
|
132
|
+
method,
|
|
133
|
+
headers: {
|
|
134
|
+
accept: 'application/json',
|
|
135
|
+
...(body ? { 'content-type': 'application/json' } : {})
|
|
136
|
+
},
|
|
137
|
+
...(body ? { body: JSON.stringify(body) } : {})
|
|
138
|
+
});
|
|
139
|
+
const text = await response.text();
|
|
140
|
+
const payload = text.trim() ? JSON.parse(text) : null;
|
|
141
|
+
if (!response.ok) {
|
|
142
|
+
throw new Error(`Release Center request failed: ${response.status}${response.statusText ? ` ${response.statusText}` : ''}`);
|
|
143
|
+
}
|
|
144
|
+
return payload;
|
|
145
|
+
}
|
|
146
|
+
async function openDownloadStream(url, redirectsLeft) {
|
|
147
|
+
const parsed = new URL(url);
|
|
148
|
+
const getter = parsed.protocol === 'https:' ? httpsGet : parsed.protocol === 'http:' ? httpGet : null;
|
|
149
|
+
if (!getter)
|
|
150
|
+
throw new Error(`Unsupported release artifact URL protocol: ${parsed.protocol}`);
|
|
151
|
+
return new Promise((resolve, reject) => {
|
|
152
|
+
const req = getter(parsed, (response) => {
|
|
153
|
+
const status = response.statusCode || 0;
|
|
154
|
+
const location = response.headers.location;
|
|
155
|
+
if (status >= 300 && status < 400 && location) {
|
|
156
|
+
response.resume();
|
|
157
|
+
if (redirectsLeft <= 0) {
|
|
158
|
+
reject(new Error(`Too many redirects while downloading release artifact: ${url}`));
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const redirected = new URL(location, parsed).toString();
|
|
162
|
+
openDownloadStream(redirected, redirectsLeft - 1).then(resolve, reject);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (status < 200 || status >= 300) {
|
|
166
|
+
response.resume();
|
|
167
|
+
reject(new Error(`Release artifact download failed: HTTP ${status}`));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
resolve({ stream: response });
|
|
171
|
+
});
|
|
172
|
+
req.on('error', reject);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
function normalizeBaseUrl(value) {
|
|
176
|
+
const trimmed = value.trim().replace(/\/+$/, '');
|
|
177
|
+
if (!trimmed)
|
|
178
|
+
throw new Error('Release Center baseUrl is required');
|
|
179
|
+
return trimmed;
|
|
180
|
+
}
|
|
181
|
+
function joinUrl(baseUrl, pathName) {
|
|
182
|
+
return `${baseUrl}${pathName.startsWith('/') ? pathName : `/${pathName}`}`;
|
|
183
|
+
}
|
|
184
|
+
function normalizeDigest(value) {
|
|
185
|
+
const normalized = value?.trim().toLowerCase();
|
|
186
|
+
if (!normalized)
|
|
187
|
+
return null;
|
|
188
|
+
return normalized.startsWith('sha256:') ? normalized : `sha256:${normalized}`;
|
|
189
|
+
}
|
|
190
|
+
function globalFetch() {
|
|
191
|
+
const maybeFetch = globalThis.fetch;
|
|
192
|
+
if (!maybeFetch)
|
|
193
|
+
throw new Error('No fetch implementation available for Electron Launcher updater');
|
|
194
|
+
return maybeFetch.bind(globalThis);
|
|
195
|
+
}
|