@qpjoy/electron-launcher 2.0.0 → 2.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/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/local-ports.d.ts +32 -0
- package/dist/local-ports.js +56 -0
- package/dist/release-update-executor.d.ts +94 -0
- package/dist/release-update-executor.js +295 -0
- package/dist/release-updater.d.ts +12 -0
- package/dist/release-updater.js +71 -1
- package/package.json +13 -5
package/dist/index.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ export { classifyLauncherIpv4Address, diagnoseLauncherHostResolution, type Elect
|
|
|
6
6
|
export { buildElectronLauncherNetworkOwnershipRegistry, mergedElectronLauncherDnsZones, mergedElectronLauncherReverseProxyRoutes, resolveElectronLauncherDnsOwner, type ElectronLauncherNetworkOwner, type ElectronLauncherNetworkOwnerState, type ElectronLauncherNetworkOwnershipClaim, type ElectronLauncherNetworkOwnershipConflict, type ElectronLauncherNetworkOwnershipEntry, type ElectronLauncherNetworkOwnershipRegistry, type ElectronLauncherNetworkOwnershipResource, type ElectronLauncherNetworkOwnershipRoute } from './network-ownership-registry.js';
|
|
7
7
|
export { applyElectronLauncherStandaloneDataPlane, buildElectronLauncherStandaloneOwnershipClaim, diagnoseElectronLauncherStandaloneDataPlane, readElectronLauncherStandaloneOwnershipState, releaseElectronLauncherStandaloneOwnershipClaim, stopElectronLauncherStandaloneDataPlane, upsertElectronLauncherStandaloneOwnershipClaim, type ElectronLauncherStandaloneDataPlaneApplyInput, type ElectronLauncherStandaloneDataPlaneApplyResult, type ElectronLauncherStandaloneDataPlaneDiagnostics, type ElectronLauncherStandaloneDataPlaneInput, type ElectronLauncherStandaloneDataPlaneProbeTarget, type ElectronLauncherStandaloneDataPlaneSeverity, type ElectronLauncherStandaloneDataPlaneStopInput, type ElectronLauncherStandaloneDataPlaneStopResult, type ElectronLauncherStandaloneDataPlaneState, type ElectronLauncherStandaloneEndpointProbe, type ElectronLauncherStandaloneOwnershipInput, type ElectronLauncherStandaloneOwnershipState, type ElectronLauncherStandaloneRouteProbe } from './standalone-data-plane.js';
|
|
8
8
|
export { createElectronLauncherReleaseUpdater, downloadElectronLauncherReleaseArtifactToFile, type ElectronLauncherArtifactDownloadInput, type ElectronLauncherArtifactDownloadResult, type ElectronLauncherReleaseActivationMode, type ElectronLauncherReleaseArtifactRef, type ElectronLauncherReleasePlan, type ElectronLauncherReleasePolicyDecision, type ElectronLauncherReleaseReportInput, type ElectronLauncherReleaseUpdateMode, type ElectronLauncherReleaseUpdater, type ElectronLauncherReleaseUpdaterOptions, type ElectronLauncherUpdateCheckInput, type ElectronLauncherUpdateCheckResult } from './release-updater.js';
|
|
9
|
+
export { allocateElectronLauncherLocalPort, electronLauncherDefaultBasePort, type ElectronLauncherLocalPortLease, type ElectronLauncherLocalPortRequest } from './local-ports.js';
|
|
10
|
+
export { adoptPendingElectronLauncherPackages, classifyElectronLauncherUpdateArtifact, createElectronLauncherReleaseUpdateExecutor, reportElectronLauncherInstallCompletionIfUpgraded, type ElectronLauncherArtifactActivation, type ElectronLauncherArtifactExecution, type ElectronLauncherNetworkGateState, type ElectronLauncherReleaseUpdateExecutor, type ElectronLauncherUpdateArtifactClass, type ElectronLauncherUpdateExecutionResult, type ElectronLauncherUpdateExecutorOptions, type ElectronLauncherUpdateExecutorPhase } from './release-update-executor.js';
|
|
9
11
|
export declare const ELECTRON_LAUNCHER_PACKAGE_NAME = "@qpjoy/electron-launcher";
|
|
10
12
|
export type ElectronLauncherMode = LauncherProductMode;
|
|
11
13
|
export interface ElectronLauncherOptions extends LauncherClientOptions {
|
package/dist/index.js
CHANGED
|
@@ -6,6 +6,8 @@ export { classifyLauncherIpv4Address, diagnoseLauncherHostResolution } from './n
|
|
|
6
6
|
export { buildElectronLauncherNetworkOwnershipRegistry, mergedElectronLauncherDnsZones, mergedElectronLauncherReverseProxyRoutes, resolveElectronLauncherDnsOwner } from './network-ownership-registry.js';
|
|
7
7
|
export { applyElectronLauncherStandaloneDataPlane, buildElectronLauncherStandaloneOwnershipClaim, diagnoseElectronLauncherStandaloneDataPlane, readElectronLauncherStandaloneOwnershipState, releaseElectronLauncherStandaloneOwnershipClaim, stopElectronLauncherStandaloneDataPlane, upsertElectronLauncherStandaloneOwnershipClaim } from './standalone-data-plane.js';
|
|
8
8
|
export { createElectronLauncherReleaseUpdater, downloadElectronLauncherReleaseArtifactToFile } from './release-updater.js';
|
|
9
|
+
export { allocateElectronLauncherLocalPort, electronLauncherDefaultBasePort } from './local-ports.js';
|
|
10
|
+
export { adoptPendingElectronLauncherPackages, classifyElectronLauncherUpdateArtifact, createElectronLauncherReleaseUpdateExecutor, reportElectronLauncherInstallCompletionIfUpgraded } from './release-update-executor.js';
|
|
9
11
|
export const ELECTRON_LAUNCHER_PACKAGE_NAME = '@qpjoy/electron-launcher';
|
|
10
12
|
export function createElectronLauncher(options) {
|
|
11
13
|
const mode = options.mode ?? 'embed';
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local port allocation for standalone launcher products (docs/19 §4.3).
|
|
3
|
+
*
|
|
4
|
+
* Fixed defaults like mihomo 23457/23458 collide as soon as two standalone
|
|
5
|
+
* products (MX-H2I + Luopan) run the same runtime on one machine. Each
|
|
6
|
+
* product/service pair instead derives a stable base port from a hash, probes
|
|
7
|
+
* it, and scans upward past ports that are already in use. The result is
|
|
8
|
+
* stable per machine as long as the same product asks first, and never
|
|
9
|
+
* silently reuses a port another product is listening on.
|
|
10
|
+
*/
|
|
11
|
+
export interface ElectronLauncherLocalPortRequest {
|
|
12
|
+
/** Product that owns the listener, e.g. `mx-h2i`, `luopan`. */
|
|
13
|
+
productId: string;
|
|
14
|
+
/** Service label inside the product, e.g. `mihomo-mixed`, `mihomo-controller`, `pac-edge`. */
|
|
15
|
+
service: string;
|
|
16
|
+
/** Previously persisted port; reused when still free. */
|
|
17
|
+
preferredPort?: number | null;
|
|
18
|
+
/** Override the hash-derived base port. */
|
|
19
|
+
basePort?: number | null;
|
|
20
|
+
/** How many candidate ports to probe before failing. Default 50. */
|
|
21
|
+
maxAttempts?: number | null;
|
|
22
|
+
/** Bind host used for probing. Default 127.0.0.1. */
|
|
23
|
+
host?: string | null;
|
|
24
|
+
}
|
|
25
|
+
export interface ElectronLauncherLocalPortLease {
|
|
26
|
+
port: number;
|
|
27
|
+
source: 'preferred' | 'base' | 'scan';
|
|
28
|
+
basePort: number;
|
|
29
|
+
attempts: number;
|
|
30
|
+
}
|
|
31
|
+
export declare function electronLauncherDefaultBasePort(productId: string, service: string): number;
|
|
32
|
+
export declare function allocateElectronLauncherLocalPort(request: ElectronLauncherLocalPortRequest): Promise<ElectronLauncherLocalPortLease>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { createServer } from 'node:net';
|
|
3
|
+
const PORT_RANGE_START = 21000;
|
|
4
|
+
const PORT_RANGE_SIZE = 20000; // 21000-40999, clear of common dev servers and ephemeral churn
|
|
5
|
+
export function electronLauncherDefaultBasePort(productId, service) {
|
|
6
|
+
const digest = createHash('sha256').update(`${productId}:${service}`).digest();
|
|
7
|
+
const offset = digest.readUInt32BE(0) % PORT_RANGE_SIZE;
|
|
8
|
+
return PORT_RANGE_START + offset;
|
|
9
|
+
}
|
|
10
|
+
export async function allocateElectronLauncherLocalPort(request) {
|
|
11
|
+
const productId = request.productId?.trim();
|
|
12
|
+
const service = request.service?.trim();
|
|
13
|
+
if (!productId || !service)
|
|
14
|
+
throw new Error('allocateElectronLauncherLocalPort requires productId and service');
|
|
15
|
+
const host = request.host?.trim() || '127.0.0.1';
|
|
16
|
+
const maxAttempts = normalizeAttempts(request.maxAttempts);
|
|
17
|
+
const basePort = normalizePort(request.basePort) ?? electronLauncherDefaultBasePort(productId, service);
|
|
18
|
+
const preferred = normalizePort(request.preferredPort);
|
|
19
|
+
let attempts = 0;
|
|
20
|
+
if (preferred) {
|
|
21
|
+
attempts += 1;
|
|
22
|
+
if (await portIsFree(host, preferred)) {
|
|
23
|
+
return { port: preferred, source: 'preferred', basePort, attempts };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
for (let i = 0; i < maxAttempts; i += 1) {
|
|
27
|
+
const candidate = PORT_RANGE_START + ((basePort - PORT_RANGE_START + i) % PORT_RANGE_SIZE);
|
|
28
|
+
if (candidate === preferred)
|
|
29
|
+
continue;
|
|
30
|
+
attempts += 1;
|
|
31
|
+
if (await portIsFree(host, candidate)) {
|
|
32
|
+
return { port: candidate, source: i === 0 ? 'base' : 'scan', basePort, attempts };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
throw new Error(`No free local port for ${productId}/${service} after ${attempts} attempts (base ${basePort})`);
|
|
36
|
+
}
|
|
37
|
+
function portIsFree(host, port) {
|
|
38
|
+
return new Promise((resolve) => {
|
|
39
|
+
const server = createServer();
|
|
40
|
+
server.unref();
|
|
41
|
+
server.once('error', () => resolve(false));
|
|
42
|
+
server.listen({ host, port, exclusive: true }, () => {
|
|
43
|
+
server.close(() => resolve(true));
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
function normalizePort(value) {
|
|
48
|
+
if (typeof value !== 'number' || !Number.isInteger(value))
|
|
49
|
+
return null;
|
|
50
|
+
return value >= 1 && value <= 65535 ? value : null;
|
|
51
|
+
}
|
|
52
|
+
function normalizeAttempts(value) {
|
|
53
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1)
|
|
54
|
+
return 50;
|
|
55
|
+
return Math.min(value, 2000);
|
|
56
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { type ElectronLauncherReleaseArtifactRef, type ElectronLauncherReleaseUpdater, type ElectronLauncherUpdateCheckResult } from './release-updater.js';
|
|
2
|
+
/**
|
|
3
|
+
* Update executor for standalone launcher products (docs/17 state machine,
|
|
4
|
+
* docs/19 §5 pipelines). Electron-free: the product injects the callbacks that
|
|
5
|
+
* touch its runtime (reload renderer, apply config, open the installer).
|
|
6
|
+
*
|
|
7
|
+
* idle -> checking -> downloading -> verifying -> staged -> activating -> reported
|
|
8
|
+
*
|
|
9
|
+
* Activation never runs while the product network is connecting, recovering,
|
|
10
|
+
* or waiting for a permission prompt; installer activation is always manual.
|
|
11
|
+
*/
|
|
12
|
+
export type ElectronLauncherUpdateExecutorPhase = 'idle' | 'checking' | 'downloading' | 'verifying' | 'staged' | 'activating' | 'reported' | 'failed';
|
|
13
|
+
export type ElectronLauncherNetworkGateState = 'idle' | 'connected' | 'connecting' | 'recovering' | 'permission-required';
|
|
14
|
+
export type ElectronLauncherUpdateArtifactClass = 'config' | 'renderer' | 'npm-package' | 'asar' | 'installer' | 'unknown';
|
|
15
|
+
export interface ElectronLauncherUpdateExecutorOptions {
|
|
16
|
+
updater: ElectronLauncherReleaseUpdater;
|
|
17
|
+
/** Product data dir, e.g. Electron `app.getPath('userData')`. */
|
|
18
|
+
baseDir: string;
|
|
19
|
+
installId?: string | null;
|
|
20
|
+
/** Blocks activation while the connection path is busy. Default: always idle. */
|
|
21
|
+
networkGate?: () => ElectronLauncherNetworkGateState | Promise<ElectronLauncherNetworkGateState>;
|
|
22
|
+
onPhase?: (phase: ElectronLauncherUpdateExecutorPhase, detail: Record<string, unknown>) => void;
|
|
23
|
+
/** Push a newly activated config/feature-flag snapshot into the runtime. */
|
|
24
|
+
applyConfig?: (activePath: string) => void | Promise<void>;
|
|
25
|
+
/** Reload the renderer from the newly activated bundle path. */
|
|
26
|
+
applyRenderer?: (activePath: string) => void | Promise<void>;
|
|
27
|
+
/** Open a downloaded installer through the OS (e.g. shell.openPath). */
|
|
28
|
+
openInstaller?: (filePath: string) => void | Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
export interface ElectronLauncherArtifactExecution {
|
|
31
|
+
artifactId: string;
|
|
32
|
+
artifactClass: ElectronLauncherUpdateArtifactClass;
|
|
33
|
+
phase: ElectronLauncherUpdateExecutorPhase;
|
|
34
|
+
stagedPath: string | null;
|
|
35
|
+
activated: boolean;
|
|
36
|
+
deferredReason: string | null;
|
|
37
|
+
error: string | null;
|
|
38
|
+
}
|
|
39
|
+
export interface ElectronLauncherUpdateExecutionResult {
|
|
40
|
+
releaseId: string | null;
|
|
41
|
+
executed: boolean;
|
|
42
|
+
reason: string;
|
|
43
|
+
artifacts: ElectronLauncherArtifactExecution[];
|
|
44
|
+
}
|
|
45
|
+
interface SlotPointer {
|
|
46
|
+
version: string;
|
|
47
|
+
path: string;
|
|
48
|
+
activatedAt: string;
|
|
49
|
+
}
|
|
50
|
+
export declare function classifyElectronLauncherUpdateArtifact(kind: string | null | undefined): ElectronLauncherUpdateArtifactClass;
|
|
51
|
+
export interface ElectronLauncherArtifactActivation {
|
|
52
|
+
artifactClass: ElectronLauncherUpdateArtifactClass;
|
|
53
|
+
activated: boolean;
|
|
54
|
+
deferredReason: string | null;
|
|
55
|
+
activePath: string | null;
|
|
56
|
+
}
|
|
57
|
+
export interface ElectronLauncherReleaseUpdateExecutor {
|
|
58
|
+
execute(check: ElectronLauncherUpdateCheckResult): Promise<ElectronLauncherUpdateExecutionResult>;
|
|
59
|
+
/**
|
|
60
|
+
* Activate an artifact that was already downloaded and digest-verified by
|
|
61
|
+
* the product's own download path. Applies the same gate and slot rules as
|
|
62
|
+
* `execute`; installer-class artifacts always defer to manual confirmation.
|
|
63
|
+
*/
|
|
64
|
+
activateStaged(artifact: ElectronLauncherReleaseArtifactRef, stagedPath: string, context?: {
|
|
65
|
+
releaseId?: string | null;
|
|
66
|
+
}): Promise<ElectronLauncherArtifactActivation>;
|
|
67
|
+
/** Manual confirmation step for installer-class artifacts. */
|
|
68
|
+
openStagedInstaller(artifact: ElectronLauncherReleaseArtifactRef): Promise<string>;
|
|
69
|
+
/** Roll the given slot back to its previous pointer. */
|
|
70
|
+
rollback(slot: 'config' | 'renderer'): Promise<SlotPointer | null>;
|
|
71
|
+
}
|
|
72
|
+
export declare function createElectronLauncherReleaseUpdateExecutor(options: ElectronLauncherUpdateExecutorOptions): ElectronLauncherReleaseUpdateExecutor;
|
|
73
|
+
/**
|
|
74
|
+
* Boot-time adoption of npm-package/asar artifacts staged with
|
|
75
|
+
* `activatedAt: next-start`. Call before wiring module paths; returns the
|
|
76
|
+
* packages whose pending pointer was promoted to current.
|
|
77
|
+
*/
|
|
78
|
+
export declare function adoptPendingElectronLauncherPackages(baseDir: string): Promise<SlotPointer[]>;
|
|
79
|
+
/**
|
|
80
|
+
* Closes the installer loop (docs/19 §5 pipeline 4): on startup, compare the
|
|
81
|
+
* running version with the last recorded one; when it changed, report
|
|
82
|
+
* `installer-completed` so Release Center can mark this install as upgraded.
|
|
83
|
+
*/
|
|
84
|
+
export declare function reportElectronLauncherInstallCompletionIfUpgraded(input: {
|
|
85
|
+
updater: ElectronLauncherReleaseUpdater;
|
|
86
|
+
baseDir: string;
|
|
87
|
+
currentVersion: string;
|
|
88
|
+
installId?: string | null;
|
|
89
|
+
}): Promise<{
|
|
90
|
+
upgraded: boolean;
|
|
91
|
+
from: string | null;
|
|
92
|
+
to: string;
|
|
93
|
+
}>;
|
|
94
|
+
export {};
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { copyFile, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
import { downloadElectronLauncherReleaseArtifactToFile } from './release-updater.js';
|
|
5
|
+
const SLOTS_DIR = 'update-slots';
|
|
6
|
+
const STAGING_DIR = 'updates';
|
|
7
|
+
export function classifyElectronLauncherUpdateArtifact(kind) {
|
|
8
|
+
const value = (kind || '').toLowerCase();
|
|
9
|
+
if (!value)
|
|
10
|
+
return 'unknown';
|
|
11
|
+
if (/installer|dmg|msi|exe-full/.test(value))
|
|
12
|
+
return 'installer';
|
|
13
|
+
if (/config|feature-flag|snapshot/.test(value))
|
|
14
|
+
return 'config';
|
|
15
|
+
if (/renderer|ui-bundle/.test(value))
|
|
16
|
+
return 'renderer';
|
|
17
|
+
if (/asar/.test(value))
|
|
18
|
+
return 'asar';
|
|
19
|
+
if (/npm|launcher-package|package-dist/.test(value))
|
|
20
|
+
return 'npm-package';
|
|
21
|
+
return 'unknown';
|
|
22
|
+
}
|
|
23
|
+
export function createElectronLauncherReleaseUpdateExecutor(options) {
|
|
24
|
+
const baseDir = options.baseDir;
|
|
25
|
+
if (!baseDir?.trim())
|
|
26
|
+
throw new Error('release update executor requires baseDir');
|
|
27
|
+
const phase = (value, detail = {}) => {
|
|
28
|
+
options.onPhase?.(value, detail);
|
|
29
|
+
};
|
|
30
|
+
const report = async (status, metadata) => {
|
|
31
|
+
try {
|
|
32
|
+
await options.updater.report({ status, installId: options.installId ?? null, metadata });
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// Reporting must never break the update path; Internal aggregates gaps as missing evidence.
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
const gateState = async () => options.networkGate ? options.networkGate() : 'idle';
|
|
39
|
+
const activationBlocked = async () => {
|
|
40
|
+
const state = await gateState();
|
|
41
|
+
return state === 'connecting' || state === 'recovering' || state === 'permission-required'
|
|
42
|
+
? `network state is ${state}`
|
|
43
|
+
: null;
|
|
44
|
+
};
|
|
45
|
+
async function stageArtifact(releaseId, artifact) {
|
|
46
|
+
const fileName = artifact.url ? basename(new URL(artifact.url).pathname) || artifact.artifactId : artifact.artifactId;
|
|
47
|
+
const targetPath = join(baseDir, STAGING_DIR, releaseId, fileName);
|
|
48
|
+
phase('downloading', { artifactId: artifact.artifactId, targetPath });
|
|
49
|
+
await report('download-started', { artifactId: artifact.artifactId, releaseId });
|
|
50
|
+
phase('verifying', { artifactId: artifact.artifactId });
|
|
51
|
+
await downloadElectronLauncherReleaseArtifactToFile({ artifact, targetPath });
|
|
52
|
+
return targetPath;
|
|
53
|
+
}
|
|
54
|
+
async function activateSlot(slot, artifact, stagedPath) {
|
|
55
|
+
const slotDir = join(baseDir, SLOTS_DIR, slot);
|
|
56
|
+
await mkdir(slotDir, { recursive: true });
|
|
57
|
+
const activePath = join(slotDir, `active-${artifact.version}-${basename(stagedPath)}`);
|
|
58
|
+
const tempPath = `${activePath}.next`;
|
|
59
|
+
await copyFile(stagedPath, tempPath);
|
|
60
|
+
await rm(activePath, { force: true });
|
|
61
|
+
await rename(tempPath, activePath);
|
|
62
|
+
const current = await readPointer(join(slotDir, 'current.json'));
|
|
63
|
+
if (current)
|
|
64
|
+
await writePointer(join(slotDir, 'previous.json'), current);
|
|
65
|
+
await writePointer(join(slotDir, 'current.json'), {
|
|
66
|
+
version: artifact.version,
|
|
67
|
+
path: activePath,
|
|
68
|
+
activatedAt: new Date().toISOString()
|
|
69
|
+
});
|
|
70
|
+
return activePath;
|
|
71
|
+
}
|
|
72
|
+
async function performActivation(artifact, stagedPath, releaseId) {
|
|
73
|
+
const artifactClass = classifyElectronLauncherUpdateArtifact(artifact.kind);
|
|
74
|
+
const result = {
|
|
75
|
+
artifactClass,
|
|
76
|
+
activated: false,
|
|
77
|
+
deferredReason: null,
|
|
78
|
+
activePath: null
|
|
79
|
+
};
|
|
80
|
+
if (artifactClass === 'installer') {
|
|
81
|
+
result.deferredReason = 'installer activation is manual';
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
const blocked = await activationBlocked();
|
|
85
|
+
if (blocked) {
|
|
86
|
+
result.deferredReason = blocked;
|
|
87
|
+
await report('artifact-activation-deferred', { artifactId: artifact.artifactId, releaseId, reason: blocked });
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
phase('activating', { artifactId: artifact.artifactId, artifactClass });
|
|
91
|
+
if (artifactClass === 'config') {
|
|
92
|
+
result.activePath = await activateSlot('config', artifact, stagedPath);
|
|
93
|
+
await options.applyConfig?.(result.activePath);
|
|
94
|
+
result.activated = true;
|
|
95
|
+
await report('artifact-applied', { artifactId: artifact.artifactId, releaseId, artifactClass, path: result.activePath });
|
|
96
|
+
}
|
|
97
|
+
else if (artifactClass === 'renderer') {
|
|
98
|
+
result.activePath = await activateSlot('renderer', artifact, stagedPath);
|
|
99
|
+
await options.applyRenderer?.(result.activePath);
|
|
100
|
+
result.activated = true;
|
|
101
|
+
await report('artifact-applied', { artifactId: artifact.artifactId, releaseId, artifactClass, path: result.activePath });
|
|
102
|
+
}
|
|
103
|
+
else if (artifactClass === 'npm-package' || artifactClass === 'asar') {
|
|
104
|
+
const pendingDir = join(baseDir, 'launcher-packages', artifact.componentId || 'unknown', artifact.version);
|
|
105
|
+
await mkdir(pendingDir, { recursive: true });
|
|
106
|
+
const packagePath = join(pendingDir, basename(stagedPath));
|
|
107
|
+
await copyFile(stagedPath, packagePath);
|
|
108
|
+
await writePointer(join(baseDir, 'launcher-packages', `${artifact.componentId || 'unknown'}.pending.json`), {
|
|
109
|
+
version: artifact.version,
|
|
110
|
+
path: packagePath,
|
|
111
|
+
activatedAt: 'next-start'
|
|
112
|
+
});
|
|
113
|
+
result.activePath = packagePath;
|
|
114
|
+
result.deferredReason = artifact.restartRequired ? 'restart required' : 'applies on next start';
|
|
115
|
+
await report('artifact-staged-pending-restart', {
|
|
116
|
+
artifactId: artifact.artifactId,
|
|
117
|
+
releaseId,
|
|
118
|
+
componentId: artifact.componentId,
|
|
119
|
+
version: artifact.version
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
result.deferredReason = `unknown artifact kind ${artifact.kind}`;
|
|
124
|
+
}
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
async execute(check) {
|
|
129
|
+
phase('checking', { status: check.status });
|
|
130
|
+
if (check.status !== 'update-available' || !check.plan) {
|
|
131
|
+
phase('idle', { reason: check.reason });
|
|
132
|
+
return { releaseId: check.plan?.releaseId ?? null, executed: false, reason: check.reason, artifacts: [] };
|
|
133
|
+
}
|
|
134
|
+
const releaseId = check.plan.releaseId;
|
|
135
|
+
const executions = [];
|
|
136
|
+
for (const artifact of check.artifacts) {
|
|
137
|
+
const artifactClass = classifyElectronLauncherUpdateArtifact(artifact.kind);
|
|
138
|
+
const execution = {
|
|
139
|
+
artifactId: artifact.artifactId,
|
|
140
|
+
artifactClass,
|
|
141
|
+
phase: 'idle',
|
|
142
|
+
stagedPath: null,
|
|
143
|
+
activated: false,
|
|
144
|
+
deferredReason: null,
|
|
145
|
+
error: null
|
|
146
|
+
};
|
|
147
|
+
executions.push(execution);
|
|
148
|
+
try {
|
|
149
|
+
const stagedPath = await stageArtifact(releaseId, artifact);
|
|
150
|
+
execution.stagedPath = stagedPath;
|
|
151
|
+
execution.phase = 'staged';
|
|
152
|
+
await report(artifactClass === 'installer' ? 'installer-downloaded' : 'artifact-staged', {
|
|
153
|
+
artifactId: artifact.artifactId,
|
|
154
|
+
releaseId,
|
|
155
|
+
artifactClass,
|
|
156
|
+
path: stagedPath
|
|
157
|
+
});
|
|
158
|
+
if (artifactClass === 'installer')
|
|
159
|
+
continue; // installer activation is always manual
|
|
160
|
+
if (!artifact.autoApply) {
|
|
161
|
+
execution.deferredReason = 'manual activation required';
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const activation = await performActivation(artifact, stagedPath, releaseId);
|
|
165
|
+
execution.activated = activation.activated;
|
|
166
|
+
execution.deferredReason = activation.deferredReason;
|
|
167
|
+
execution.phase = 'reported';
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
execution.phase = 'failed';
|
|
171
|
+
execution.error = error instanceof Error ? error.message : String(error);
|
|
172
|
+
await report('download-failed', { artifactId: artifact.artifactId, releaseId, error: execution.error });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
phase('reported', { releaseId });
|
|
176
|
+
return { releaseId, executed: true, reason: check.reason, artifacts: executions };
|
|
177
|
+
},
|
|
178
|
+
async activateStaged(artifact, stagedPath, context) {
|
|
179
|
+
if (!existsSync(stagedPath))
|
|
180
|
+
throw new Error(`staged artifact not found: ${stagedPath}`);
|
|
181
|
+
return performActivation(artifact, stagedPath, context?.releaseId ?? null);
|
|
182
|
+
},
|
|
183
|
+
async openStagedInstaller(artifact) {
|
|
184
|
+
const fileName = artifact.url ? basename(new URL(artifact.url).pathname) || artifact.artifactId : artifact.artifactId;
|
|
185
|
+
const candidates = [join(baseDir, STAGING_DIR)];
|
|
186
|
+
// staged as updates/<releaseId>/<file>; find the newest match
|
|
187
|
+
const path = await findStagedFile(candidates[0], fileName);
|
|
188
|
+
if (!path)
|
|
189
|
+
throw new Error(`installer not staged: ${fileName}`);
|
|
190
|
+
await options.openInstaller?.(path);
|
|
191
|
+
await report('installer-opened', { artifactId: artifact.artifactId, path });
|
|
192
|
+
return path;
|
|
193
|
+
},
|
|
194
|
+
async rollback(slot) {
|
|
195
|
+
const slotDir = join(baseDir, SLOTS_DIR, slot);
|
|
196
|
+
const previous = await readPointer(join(slotDir, 'previous.json'));
|
|
197
|
+
if (!previous)
|
|
198
|
+
return null;
|
|
199
|
+
const current = await readPointer(join(slotDir, 'current.json'));
|
|
200
|
+
await writePointer(join(slotDir, 'current.json'), previous);
|
|
201
|
+
if (current)
|
|
202
|
+
await writePointer(join(slotDir, 'previous.json'), current);
|
|
203
|
+
if (slot === 'config')
|
|
204
|
+
await options.applyConfig?.(previous.path);
|
|
205
|
+
if (slot === 'renderer')
|
|
206
|
+
await options.applyRenderer?.(previous.path);
|
|
207
|
+
await report('artifact-rolled-back', { slot, version: previous.version, path: previous.path });
|
|
208
|
+
return previous;
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Boot-time adoption of npm-package/asar artifacts staged with
|
|
214
|
+
* `activatedAt: next-start`. Call before wiring module paths; returns the
|
|
215
|
+
* packages whose pending pointer was promoted to current.
|
|
216
|
+
*/
|
|
217
|
+
export async function adoptPendingElectronLauncherPackages(baseDir) {
|
|
218
|
+
const dir = join(baseDir, 'launcher-packages');
|
|
219
|
+
if (!existsSync(dir))
|
|
220
|
+
return [];
|
|
221
|
+
const { readdir } = await import('node:fs/promises');
|
|
222
|
+
const adopted = [];
|
|
223
|
+
for (const entry of await readdir(dir)) {
|
|
224
|
+
if (!entry.endsWith('.pending.json'))
|
|
225
|
+
continue;
|
|
226
|
+
const pendingPath = join(dir, entry);
|
|
227
|
+
const pointer = await readPointer(pendingPath);
|
|
228
|
+
if (!pointer)
|
|
229
|
+
continue;
|
|
230
|
+
const currentPath = join(dir, entry.replace(/\.pending\.json$/, '.current.json'));
|
|
231
|
+
await writePointer(currentPath, { ...pointer, activatedAt: new Date().toISOString() });
|
|
232
|
+
await rm(pendingPath, { force: true });
|
|
233
|
+
adopted.push(pointer);
|
|
234
|
+
}
|
|
235
|
+
return adopted;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Closes the installer loop (docs/19 §5 pipeline 4): on startup, compare the
|
|
239
|
+
* running version with the last recorded one; when it changed, report
|
|
240
|
+
* `installer-completed` so Release Center can mark this install as upgraded.
|
|
241
|
+
*/
|
|
242
|
+
export async function reportElectronLauncherInstallCompletionIfUpgraded(input) {
|
|
243
|
+
const statePath = join(input.baseDir, SLOTS_DIR, 'app-version.json');
|
|
244
|
+
let previous = null;
|
|
245
|
+
try {
|
|
246
|
+
previous = JSON.parse(await readFile(statePath, 'utf8'))?.version ?? null;
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
previous = null;
|
|
250
|
+
}
|
|
251
|
+
await mkdir(join(input.baseDir, SLOTS_DIR), { recursive: true });
|
|
252
|
+
await writeFile(statePath, JSON.stringify({ version: input.currentVersion, recordedAt: new Date().toISOString() }, null, 2));
|
|
253
|
+
if (previous && previous !== input.currentVersion) {
|
|
254
|
+
try {
|
|
255
|
+
await input.updater.report({
|
|
256
|
+
status: 'installer-completed',
|
|
257
|
+
installId: input.installId ?? null,
|
|
258
|
+
metadata: { from: previous, to: input.currentVersion }
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
// Missing completion reports surface as gray-health gaps server-side.
|
|
263
|
+
}
|
|
264
|
+
return { upgraded: true, from: previous, to: input.currentVersion };
|
|
265
|
+
}
|
|
266
|
+
return { upgraded: false, from: previous, to: input.currentVersion };
|
|
267
|
+
}
|
|
268
|
+
async function findStagedFile(stagingRoot, fileName) {
|
|
269
|
+
if (!existsSync(stagingRoot))
|
|
270
|
+
return null;
|
|
271
|
+
const { readdir, stat } = await import('node:fs/promises');
|
|
272
|
+
let newest = null;
|
|
273
|
+
for (const release of await readdir(stagingRoot)) {
|
|
274
|
+
const candidate = join(stagingRoot, release, fileName);
|
|
275
|
+
if (!existsSync(candidate))
|
|
276
|
+
continue;
|
|
277
|
+
const info = await stat(candidate);
|
|
278
|
+
if (!newest || info.mtimeMs > newest.mtime)
|
|
279
|
+
newest = { path: candidate, mtime: info.mtimeMs };
|
|
280
|
+
}
|
|
281
|
+
return newest?.path ?? null;
|
|
282
|
+
}
|
|
283
|
+
async function readPointer(path) {
|
|
284
|
+
try {
|
|
285
|
+
const parsed = JSON.parse(await readFile(path, 'utf8'));
|
|
286
|
+
return parsed && typeof parsed.path === 'string' ? parsed : null;
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
async function writePointer(path, pointer) {
|
|
293
|
+
await mkdir(join(path, '..'), { recursive: true });
|
|
294
|
+
await writeFile(path, JSON.stringify(pointer, null, 2));
|
|
295
|
+
}
|
|
@@ -96,6 +96,18 @@ export interface ElectronLauncherUpdateCheckResult {
|
|
|
96
96
|
decision: ElectronLauncherReleasePolicyDecision;
|
|
97
97
|
artifacts: ElectronLauncherReleaseArtifactRef[];
|
|
98
98
|
reason: string;
|
|
99
|
+
/** Markdown release notes from the server-side decision, when provided. */
|
|
100
|
+
releaseNotes?: string | null;
|
|
101
|
+
/** Feature keys granted to this install by the matched plan. */
|
|
102
|
+
featureFlags?: string[];
|
|
103
|
+
/** Why this install did (not) receive the release; shown in the update panel. */
|
|
104
|
+
rollout?: {
|
|
105
|
+
matchedBy?: string | null;
|
|
106
|
+
bucket?: number | null;
|
|
107
|
+
percentage?: number | null;
|
|
108
|
+
} | null;
|
|
109
|
+
/** Which check path produced this result. */
|
|
110
|
+
checkSource?: 'release-check' | 'plans-legacy';
|
|
99
111
|
}
|
|
100
112
|
export interface ElectronLauncherReleaseUpdaterOptions {
|
|
101
113
|
baseUrl: string;
|
package/dist/release-updater.js
CHANGED
|
@@ -11,6 +11,28 @@ export function createElectronLauncherReleaseUpdater(options) {
|
|
|
11
11
|
return {
|
|
12
12
|
async check(input) {
|
|
13
13
|
const checkedAt = new Date().toISOString();
|
|
14
|
+
// Preferred path: server-side single-install decision (docs/19 §6). The
|
|
15
|
+
// server owns target lists and rollout bucketing; the full plans list is
|
|
16
|
+
// being withdrawn to admin-only. Fall back to the legacy flow against
|
|
17
|
+
// older servers that do not expose /release/check yet.
|
|
18
|
+
const installId = input.installId ?? options.reportInstallId ?? null;
|
|
19
|
+
if (installId) {
|
|
20
|
+
try {
|
|
21
|
+
const payload = await requestJson(fetchImpl, joinUrl(baseUrl, '/internal/v1/release/check'), 'POST', {
|
|
22
|
+
installId,
|
|
23
|
+
userId: input.userId ?? null,
|
|
24
|
+
channel: input.channel,
|
|
25
|
+
platform: input.platform ?? null,
|
|
26
|
+
components: { [input.componentId]: input.currentVersion }
|
|
27
|
+
});
|
|
28
|
+
if (payload && typeof payload.status === 'string') {
|
|
29
|
+
return mapReleaseCheckPayload(payload, input, baseUrl, checkedAt);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// Older server without /release/check; use the legacy plans flow.
|
|
34
|
+
}
|
|
35
|
+
}
|
|
14
36
|
const plansPayload = await requestJson(fetchImpl, joinUrl(baseUrl, '/internal/v1/release-management/plans'), 'GET');
|
|
15
37
|
const plans = Array.isArray(plansPayload.plans) ? plansPayload.plans : [];
|
|
16
38
|
const plan = selectReleasePlan(plans, input);
|
|
@@ -42,7 +64,8 @@ export function createElectronLauncherReleaseUpdater(options) {
|
|
|
42
64
|
artifacts,
|
|
43
65
|
reason: status === 'blocked'
|
|
44
66
|
? plan?.test?.gate?.reason || `release gate is ${gateVerdict}`
|
|
45
|
-
: decision.reason
|
|
67
|
+
: decision.reason,
|
|
68
|
+
checkSource: 'plans-legacy'
|
|
46
69
|
};
|
|
47
70
|
},
|
|
48
71
|
async report(input) {
|
|
@@ -96,6 +119,53 @@ export async function downloadElectronLauncherReleaseArtifactToFile(input) {
|
|
|
96
119
|
bytes
|
|
97
120
|
};
|
|
98
121
|
}
|
|
122
|
+
function mapReleaseCheckPayload(payload, input, baseUrl, checkedAt) {
|
|
123
|
+
const decision = payload.decision ?? {
|
|
124
|
+
componentKind: input.componentKind || 'app-managed',
|
|
125
|
+
componentId: input.componentId,
|
|
126
|
+
currentVersion: input.currentVersion,
|
|
127
|
+
targetVersion: input.currentVersion,
|
|
128
|
+
updateAvailable: false,
|
|
129
|
+
updateMode: 'none',
|
|
130
|
+
canSkip: true,
|
|
131
|
+
canDefer: true,
|
|
132
|
+
requiresGate: false,
|
|
133
|
+
rollbackRequired: false,
|
|
134
|
+
reason: payload.reason
|
|
135
|
+
};
|
|
136
|
+
const plan = payload.planId && payload.releaseId
|
|
137
|
+
? {
|
|
138
|
+
planId: payload.planId,
|
|
139
|
+
releaseId: payload.releaseId,
|
|
140
|
+
environment: 'internal',
|
|
141
|
+
channel: payload.channel,
|
|
142
|
+
installId: input.installId ?? null,
|
|
143
|
+
userId: input.userId ?? null,
|
|
144
|
+
createdBy: 'release-check',
|
|
145
|
+
components: decision.componentKind === 'app-managed' ? { app: decision } : { launcher: decision },
|
|
146
|
+
artifacts: payload.artifacts ?? [],
|
|
147
|
+
rollout: {
|
|
148
|
+
percentage: payload.rollout?.percentage ?? undefined,
|
|
149
|
+
featureKeys: payload.featureFlags ?? []
|
|
150
|
+
},
|
|
151
|
+
activation: payload.activation ?? undefined,
|
|
152
|
+
createdAt: payload.signedAt
|
|
153
|
+
}
|
|
154
|
+
: null;
|
|
155
|
+
return {
|
|
156
|
+
checkedAt,
|
|
157
|
+
baseUrl,
|
|
158
|
+
status: payload.status,
|
|
159
|
+
plan,
|
|
160
|
+
decision,
|
|
161
|
+
artifacts: payload.artifacts ?? [],
|
|
162
|
+
reason: payload.reason,
|
|
163
|
+
releaseNotes: payload.releaseNotes ?? null,
|
|
164
|
+
featureFlags: payload.featureFlags ?? [],
|
|
165
|
+
rollout: payload.rollout ?? null,
|
|
166
|
+
checkSource: 'release-check'
|
|
167
|
+
};
|
|
168
|
+
}
|
|
99
169
|
function selectReleasePlan(plans, input) {
|
|
100
170
|
return plans.find((plan) => {
|
|
101
171
|
if (plan.channel !== input.channel)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qpjoy/electron-launcher",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Product-facing Electron Launcher client for MX Launcher release, update, rollout, AppCenter, and network integration.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -34,6 +34,14 @@
|
|
|
34
34
|
"types": "./dist/release-updater.d.ts",
|
|
35
35
|
"import": "./dist/release-updater.js"
|
|
36
36
|
},
|
|
37
|
+
"./local-ports": {
|
|
38
|
+
"types": "./dist/local-ports.d.ts",
|
|
39
|
+
"import": "./dist/local-ports.js"
|
|
40
|
+
},
|
|
41
|
+
"./release-update-executor": {
|
|
42
|
+
"types": "./dist/release-update-executor.d.ts",
|
|
43
|
+
"import": "./dist/release-update-executor.js"
|
|
44
|
+
},
|
|
37
45
|
"./package.json": "./package.json"
|
|
38
46
|
},
|
|
39
47
|
"files": [
|
|
@@ -45,10 +53,10 @@
|
|
|
45
53
|
"access": "public"
|
|
46
54
|
},
|
|
47
55
|
"dependencies": {
|
|
48
|
-
"@qpjoy/
|
|
49
|
-
"@qpjoy/
|
|
50
|
-
"@qpjoy/mx-launcher-
|
|
51
|
-
"@qpjoy/mx-launcher-
|
|
56
|
+
"@qpjoy/mx-launcher-core": "^2.2.0",
|
|
57
|
+
"@qpjoy/electron-core-wireguard": "^2.1.0",
|
|
58
|
+
"@qpjoy/mx-launcher-standalone": "^2.2.0",
|
|
59
|
+
"@qpjoy/mx-launcher-embed-sdk": "^2.2.0"
|
|
52
60
|
},
|
|
53
61
|
"devDependencies": {
|
|
54
62
|
"@types/node": "^22.10.7",
|