pakstr 0.8.7 → 0.9.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/android-template/app/build.gradle.kts +11 -3
- package/bin/cli.js +11 -6
- package/dist/cli.js +53 -16
- package/dist/commands/build.js +42 -114
- package/dist/commands/init.js +191 -0
- package/dist/commands/publish.js +137 -0
- package/dist/commands/run.js +95 -0
- package/dist/commands/sign.js +41 -0
- package/dist/core/androidProject.js +70 -0
- package/dist/core/appIdentity.js +7 -0
- package/dist/core/blossom.js +122 -0
- package/dist/core/dotenv.js +51 -0
- package/dist/core/nostr.js +153 -0
- package/dist/core/pakstrConfig.js +206 -0
- package/dist/core/zapStore.js +221 -0
- package/dist/runners/DockerGradleRunner.js +38 -128
- package/dist/runners/DockerSignRunner.js +182 -0
- package/dist/runners/LocalGradleRunner.js +26 -129
- package/dist/runners/createRunner.js +4 -4
- package/package.json +7 -1
- package/dist/android/releaseVerification.js +0 -164
- package/dist/config.js +0 -4
- package/dist/core/buildContext.js +0 -102
- package/dist/core/config.js +0 -24
- package/dist/core/copyFile.js +0 -17
- package/dist/core/manifest.js +0 -47
- package/dist/core/releaseSigning.js +0 -134
- package/dist/core/resolveProjectInputs.js +0 -20
- package/dist/core/validateProject.js +0 -21
- package/dist/core/zeroConfig.js +0 -97
- package/dist/runtime/runtimeConfig.js +0 -22
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.ConfigError = exports.APP_ID_PATTERN = exports.PERMISSION_ALIASES = exports.PAKSTR_CONFIG_FILENAME = void 0;
|
|
7
|
+
exports.sanitizeAppIdSegment = sanitizeAppIdSegment;
|
|
8
|
+
exports.loadPakstrConfig = loadPakstrConfig;
|
|
9
|
+
exports.validatePakstrConfig = validatePakstrConfig;
|
|
10
|
+
const fs_1 = __importDefault(require("fs"));
|
|
11
|
+
const path_1 = __importDefault(require("path"));
|
|
12
|
+
const js_yaml_1 = __importDefault(require("js-yaml"));
|
|
13
|
+
/**
|
|
14
|
+
* Pakstr configuration model — the single `pakstr.yaml` file.
|
|
15
|
+
* Spec: docs/SPEC.md §3. Secrets are NEVER placed in this file.
|
|
16
|
+
*/
|
|
17
|
+
exports.PAKSTR_CONFIG_FILENAME = "pakstr.yaml";
|
|
18
|
+
exports.PERMISSION_ALIASES = [
|
|
19
|
+
"INTERNET",
|
|
20
|
+
"CAMERA",
|
|
21
|
+
"NOTIFICATIONS",
|
|
22
|
+
"VIBRATE",
|
|
23
|
+
];
|
|
24
|
+
const HEX_COLOR_RE = /^#[0-9A-Fa-f]{6}$/;
|
|
25
|
+
/**
|
|
26
|
+
* A valid Android / Java package name: dot-separated segments, each starting
|
|
27
|
+
* with a lower-case letter and containing only lower-case letters, digits, and
|
|
28
|
+
* underscores. Hyphens are NOT allowed (AAPT rejects them in `<manifest
|
|
29
|
+
* package>`). Used by both `pakstr init` (to sanitize inferred names) and
|
|
30
|
+
* config validation (to reject bad values).
|
|
31
|
+
*/
|
|
32
|
+
exports.APP_ID_PATTERN = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$/;
|
|
33
|
+
/**
|
|
34
|
+
* Sanitize an arbitrary string (e.g. an npm package name like `my-pakstr-app`)
|
|
35
|
+
* into a valid Android package-name segment: lower-case, hyphens → underscores,
|
|
36
|
+
* strip anything other than [a-z0-9_], and prefix with `x` if it starts with a
|
|
37
|
+
* digit. Returns `null` if nothing usable remains.
|
|
38
|
+
*/
|
|
39
|
+
function sanitizeAppIdSegment(input) {
|
|
40
|
+
let s = input.toLowerCase().replace(/[^a-z0-9_-]/g, "");
|
|
41
|
+
s = s.replace(/-/g, "_");
|
|
42
|
+
s = s.replace(/^[^a-z]+/, ""); // drop leading non-letters (digits, underscores)
|
|
43
|
+
return s.length > 0 ? s : null;
|
|
44
|
+
}
|
|
45
|
+
class ConfigError extends Error {
|
|
46
|
+
configPath;
|
|
47
|
+
constructor(message, configPath) {
|
|
48
|
+
super(message);
|
|
49
|
+
this.configPath = configPath;
|
|
50
|
+
this.name = "ConfigError";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
exports.ConfigError = ConfigError;
|
|
54
|
+
function fail(message, configPath) {
|
|
55
|
+
throw new ConfigError(message, configPath);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Load and fully validate `pakstr.yaml` from `configPath` (defaults to
|
|
59
|
+
* `pakstr.yaml` in `cwd`). Returns paths resolved relative to the yaml file.
|
|
60
|
+
*/
|
|
61
|
+
function loadPakstrConfig(configPath) {
|
|
62
|
+
const resolvedPath = path_1.default.resolve(configPath ?? path_1.default.join(process.cwd(), exports.PAKSTR_CONFIG_FILENAME));
|
|
63
|
+
if (!fs_1.default.existsSync(resolvedPath) || !fs_1.default.lstatSync(resolvedPath).isFile()) {
|
|
64
|
+
fail(`pakstr.yaml not found: ${resolvedPath}`);
|
|
65
|
+
}
|
|
66
|
+
let raw;
|
|
67
|
+
try {
|
|
68
|
+
raw = js_yaml_1.default.load(fs_1.default.readFileSync(resolvedPath, "utf8"), {
|
|
69
|
+
filename: resolvedPath,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
fail(`pakstr.yaml is not valid YAML: ${error instanceof Error ? error.message : "parse error"}`, resolvedPath);
|
|
74
|
+
}
|
|
75
|
+
if (raw === null || raw === undefined || typeof raw !== "object" || Array.isArray(raw)) {
|
|
76
|
+
fail("pakstr.yaml must be a mapping with `app`, `build`, and optionally `publish` sections", resolvedPath);
|
|
77
|
+
}
|
|
78
|
+
return resolveAndValidate(raw, resolvedPath);
|
|
79
|
+
}
|
|
80
|
+
function validatePakstrConfig(config, configPath) {
|
|
81
|
+
resolveAndValidate(config, configPath ?? "");
|
|
82
|
+
}
|
|
83
|
+
function resolveAndValidate(config, configPath) {
|
|
84
|
+
const configDir = path_1.default.dirname(configPath);
|
|
85
|
+
const where = (field) => configPath ? `${field} (in ${configPath})` : field;
|
|
86
|
+
const app = config.app;
|
|
87
|
+
if (!app || typeof app !== "object")
|
|
88
|
+
fail(`Missing required section: app`, configPath);
|
|
89
|
+
// Required app fields
|
|
90
|
+
requireString(app.appId, "app.appId", configPath);
|
|
91
|
+
requireString(app.appName, "app.appName", configPath);
|
|
92
|
+
requireString(app.versionName, "app.versionName", configPath);
|
|
93
|
+
requirePositiveInt(app.versionCode, "app.versionCode", configPath);
|
|
94
|
+
// appId rules (§3.3): non-empty, ≤255 UTF-8 bytes, equals trimmed form,
|
|
95
|
+
// and must be a valid Android/Java package name (segments of lower-case
|
|
96
|
+
// alphanumerics + underscores separated by dots; no hyphens). AAPT rejects
|
|
97
|
+
// hyphens in the `package` attribute with "not a valid Android package name".
|
|
98
|
+
if (app.appId !== app.appId.trim()) {
|
|
99
|
+
fail(`${where("app.appId")} must not have leading or trailing whitespace`, configPath);
|
|
100
|
+
}
|
|
101
|
+
if (Buffer.byteLength(app.appId, "utf8") > 255) {
|
|
102
|
+
fail(`${where("app.appId")} must be ≤ 255 UTF-8 bytes`, configPath);
|
|
103
|
+
}
|
|
104
|
+
if (!exports.APP_ID_PATTERN.test(app.appId)) {
|
|
105
|
+
fail(`${where("app.appId")} must be a valid Android package name: dot-separated segments of` +
|
|
106
|
+
` lower-case letters, digits, and underscores (no hyphens). Got: "${app.appId}"`, configPath);
|
|
107
|
+
}
|
|
108
|
+
// appName / versionName basic sanity
|
|
109
|
+
if (/[\r\n]/.test(app.appName)) {
|
|
110
|
+
fail(`${where("app.appName")} must not contain line breaks`, configPath);
|
|
111
|
+
}
|
|
112
|
+
if (/[\r\n"\\]/.test(app.versionName)) {
|
|
113
|
+
fail(`${where("app.versionName")} must not contain line breaks, quotes, or backslashes`, configPath);
|
|
114
|
+
}
|
|
115
|
+
if (app.description !== undefined && typeof app.description !== "string") {
|
|
116
|
+
fail(`${where("app.description")} must be a string`, configPath);
|
|
117
|
+
}
|
|
118
|
+
// Optional color fields
|
|
119
|
+
if (app.backgroundColor !== undefined) {
|
|
120
|
+
assertHexColor(app.backgroundColor, "app.backgroundColor", configPath);
|
|
121
|
+
}
|
|
122
|
+
if (app.splash?.background !== undefined) {
|
|
123
|
+
assertHexColor(app.splash.background, "app.splash.background", configPath);
|
|
124
|
+
}
|
|
125
|
+
if (app.splash && !app.splash.image) {
|
|
126
|
+
fail(`${where("app.splash.image")} is required when splash is configured`, configPath);
|
|
127
|
+
}
|
|
128
|
+
// Permissions
|
|
129
|
+
if (app.permissions !== undefined) {
|
|
130
|
+
if (!Array.isArray(app.permissions) || app.permissions.some(p => typeof p !== "string")) {
|
|
131
|
+
fail(`${where("app.permissions")} must be a list of strings`, configPath);
|
|
132
|
+
}
|
|
133
|
+
for (const perm of app.permissions) {
|
|
134
|
+
const normalized = perm.trim().toUpperCase();
|
|
135
|
+
if (!exports.PERMISSION_ALIASES.includes(normalized)) {
|
|
136
|
+
fail(`${where("app.permissions")} has unknown entry "${perm}"; allowed: ${exports.PERMISSION_ALIASES.join(", ")}`, configPath);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// build section
|
|
141
|
+
const build = config.build;
|
|
142
|
+
if (!build || typeof build !== "object")
|
|
143
|
+
fail(`Missing required section: build`, configPath);
|
|
144
|
+
requireString(build.web, "build.web", configPath);
|
|
145
|
+
const webAbs = path_1.default.resolve(configDir, build.web);
|
|
146
|
+
if (!fs_1.default.existsSync(webAbs) || !fs_1.default.lstatSync(webAbs).isDirectory()) {
|
|
147
|
+
fail(`${where("build.web")} does not exist or is not a directory: ${webAbs}`, configPath);
|
|
148
|
+
}
|
|
149
|
+
if (!fs_1.default.existsSync(path_1.default.join(webAbs, "index.html"))) {
|
|
150
|
+
fail(`${where("build.web")} must contain index.html: ${webAbs}`, configPath);
|
|
151
|
+
}
|
|
152
|
+
if (build.builder !== undefined && build.builder !== "docker") {
|
|
153
|
+
fail(`${where("build.builder")} must be "docker" (only the docker builder is specified)`, configPath);
|
|
154
|
+
}
|
|
155
|
+
const builder = build.builder ?? "docker";
|
|
156
|
+
const outDefault = path_1.default.join(configDir, "build", `${app.appId}.apk`);
|
|
157
|
+
const out = build.out ? path_1.default.resolve(configDir, build.out) : outDefault;
|
|
158
|
+
// publish section
|
|
159
|
+
const publish = config.publish;
|
|
160
|
+
let zapstoreEnabled = true;
|
|
161
|
+
let publishKey = undefined;
|
|
162
|
+
if (publish !== undefined) {
|
|
163
|
+
if (publish.zapstore !== undefined) {
|
|
164
|
+
if (publish.zapstore.enabled !== undefined && typeof publish.zapstore.enabled !== "boolean") {
|
|
165
|
+
fail(`${where("publish.zapstore.enabled")} must be a boolean`, configPath);
|
|
166
|
+
}
|
|
167
|
+
zapstoreEnabled = publish.zapstore.enabled ?? true;
|
|
168
|
+
}
|
|
169
|
+
if (publish.publishKey !== undefined) {
|
|
170
|
+
if (publish.publishKey !== null && typeof publish.publishKey !== "string") {
|
|
171
|
+
fail(`${where("publish.publishKey")} must be a string or empty (bare key)`, configPath);
|
|
172
|
+
}
|
|
173
|
+
if (typeof publish.publishKey === "string" && publish.publishKey.trim().length === 0) {
|
|
174
|
+
// Treat empty string like a bare key.
|
|
175
|
+
publishKey = null;
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
publishKey = publish.publishKey;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const relay = publish?.relay ?? "wss://relay.zapstore.dev";
|
|
183
|
+
const blossom = publish?.blossom ?? "https://cdn.zapstore.dev";
|
|
184
|
+
return {
|
|
185
|
+
configDir,
|
|
186
|
+
configPath,
|
|
187
|
+
app,
|
|
188
|
+
build: { web: webAbs, out, builder },
|
|
189
|
+
publish: { zapstoreEnabled, publishKey, relay, blossom },
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
function requireString(value, field, configPath) {
|
|
193
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
194
|
+
fail(`Missing required field: ${field}`, configPath);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function requirePositiveInt(value, field, configPath) {
|
|
198
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
|
|
199
|
+
fail(`${field} must be a positive integer`, configPath);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function assertHexColor(value, field, configPath) {
|
|
203
|
+
if (typeof value !== "string" || !HEX_COLOR_RE.test(value)) {
|
|
204
|
+
fail(`${field} must be a hex color of the form #RRGGBB`, configPath);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MockRelayTransport = exports.WebsocketRelayTransport = exports.KIND_ASSET = exports.KIND_RELEASE = exports.KIND_APP_METADATA = exports.DEFAULT_COMMUNITY = void 0;
|
|
4
|
+
exports.buildSignedPublishEvents = buildSignedPublishEvents;
|
|
5
|
+
exports.publishToZapStore = publishToZapStore;
|
|
6
|
+
exports.resolvePublishSecret = resolvePublishSecret;
|
|
7
|
+
exports.publishEventToRelay = publishEventToRelay;
|
|
8
|
+
exports.verifyPublish = verifyPublish;
|
|
9
|
+
exports.queryRelayForEvent = queryRelayForEvent;
|
|
10
|
+
const nostr_1 = require("./nostr");
|
|
11
|
+
/** Default Zap Store catalog community (h tag). */
|
|
12
|
+
exports.DEFAULT_COMMUNITY = "acfeaea6e51420e8068fac446ca9d17d7a9ef6a5d20d93894e50fee3d4902a84";
|
|
13
|
+
/** NIP-82 event kinds. */
|
|
14
|
+
exports.KIND_APP_METADATA = 32267;
|
|
15
|
+
exports.KIND_RELEASE = 30063;
|
|
16
|
+
exports.KIND_ASSET = 3063;
|
|
17
|
+
/** Android platform identifier for the template's generic APK. */
|
|
18
|
+
const PLATFORM = "android-arm64-v8a";
|
|
19
|
+
const MIN_SDK = 28;
|
|
20
|
+
const TARGET_SDK = 36;
|
|
21
|
+
/** Build the three NIP-82 events (unsigned → signed) for an app release. */
|
|
22
|
+
async function buildSignedPublishEvents(input) {
|
|
23
|
+
const pubkeyHex = await (0, nostr_1.getNpubHex)(input.publishNsec.bytes);
|
|
24
|
+
const npub = (0, nostr_1.pubkeyHexToNpub)(pubkeyHex);
|
|
25
|
+
const now = Math.floor(Date.now() / 1000);
|
|
26
|
+
const communities = input.communities?.length ? input.communities : [exports.DEFAULT_COMMUNITY];
|
|
27
|
+
// kind 32267 — Software Application (app metadata)
|
|
28
|
+
const appMetadata = await (0, nostr_1.signNostrEvent)({
|
|
29
|
+
kind: exports.KIND_APP_METADATA,
|
|
30
|
+
created_at: now,
|
|
31
|
+
tags: [
|
|
32
|
+
["d", input.app.appId],
|
|
33
|
+
["name", input.app.appName],
|
|
34
|
+
["f", PLATFORM],
|
|
35
|
+
...communities.map(c => ["h", c]),
|
|
36
|
+
],
|
|
37
|
+
content: input.app.description ?? "",
|
|
38
|
+
}, input.publishNsec.bytes);
|
|
39
|
+
// kind 3063 — Software Asset (the APK)
|
|
40
|
+
const asset = await (0, nostr_1.signNostrEvent)({
|
|
41
|
+
kind: exports.KIND_ASSET,
|
|
42
|
+
created_at: now,
|
|
43
|
+
tags: [
|
|
44
|
+
["i", input.app.appId],
|
|
45
|
+
["x", input.apk.apkSha256],
|
|
46
|
+
["version", input.app.versionName],
|
|
47
|
+
["url", input.apk.blossomUrl],
|
|
48
|
+
["m", "application/vnd.android.package-archive"],
|
|
49
|
+
["size", String(input.apk.apkSize)],
|
|
50
|
+
["f", PLATFORM],
|
|
51
|
+
["min_platform_version", String(MIN_SDK)],
|
|
52
|
+
["target_platform_version", String(TARGET_SDK)],
|
|
53
|
+
["filename", input.apk.filename],
|
|
54
|
+
["version_code", String(input.app.versionCode)],
|
|
55
|
+
["apk_certificate_hash", input.apk.signerCertificateSha256],
|
|
56
|
+
],
|
|
57
|
+
content: "",
|
|
58
|
+
}, input.publishNsec.bytes);
|
|
59
|
+
// kind 30063 — Software Release (references the asset event)
|
|
60
|
+
const release = await (0, nostr_1.signNostrEvent)({
|
|
61
|
+
kind: exports.KIND_RELEASE,
|
|
62
|
+
created_at: now,
|
|
63
|
+
tags: [
|
|
64
|
+
["i", input.app.appId],
|
|
65
|
+
["version", input.app.versionName],
|
|
66
|
+
["d", `${input.app.appId}@${input.app.versionName}`],
|
|
67
|
+
["c", "main"],
|
|
68
|
+
["f", PLATFORM],
|
|
69
|
+
["e", asset.id, input.target.relayUrl],
|
|
70
|
+
],
|
|
71
|
+
content: "",
|
|
72
|
+
}, input.publishNsec.bytes);
|
|
73
|
+
return { npub, pubkeyHex, appMetadata, release, asset };
|
|
74
|
+
}
|
|
75
|
+
/** Publish all three events to the relay via the given transport. */
|
|
76
|
+
async function publishToZapStore(input, transport) {
|
|
77
|
+
const built = await buildSignedPublishEvents(input);
|
|
78
|
+
await transport.publish(built.asset, input.target.relayUrl);
|
|
79
|
+
await transport.publish(built.release, input.target.relayUrl);
|
|
80
|
+
await transport.publish(built.appMetadata, input.target.relayUrl);
|
|
81
|
+
return {
|
|
82
|
+
npub: built.npub,
|
|
83
|
+
pubkeyHex: built.pubkeyHex,
|
|
84
|
+
appMetadataEventId: built.appMetadata.id,
|
|
85
|
+
releaseEventId: built.release.id,
|
|
86
|
+
assetEventId: built.asset.id,
|
|
87
|
+
events: {
|
|
88
|
+
appMetadata: built.appMetadata,
|
|
89
|
+
release: built.release,
|
|
90
|
+
asset: built.asset,
|
|
91
|
+
},
|
|
92
|
+
relayUrl: input.target.relayUrl,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/** Resolve the publish nsec for a loaded pakstr config per §4.1. */
|
|
96
|
+
function resolvePublishSecret(publishKey, env = process.env) {
|
|
97
|
+
return (0, nostr_1.resolvePublishNsec)(publishKey, env);
|
|
98
|
+
}
|
|
99
|
+
/** Real websocket transport: sends ["EVENT", e] and expects ["OK", id, true, …]. */
|
|
100
|
+
class WebsocketRelayTransport {
|
|
101
|
+
WebSocketCtor;
|
|
102
|
+
constructor(webSocketCtor) {
|
|
103
|
+
this.WebSocketCtor = (webSocketCtor ?? WebSocket);
|
|
104
|
+
}
|
|
105
|
+
async publish(event, relayUrl) {
|
|
106
|
+
await publishEventToRelay(this.WebSocketCtor, event, relayUrl);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
exports.WebsocketRelayTransport = WebsocketRelayTransport;
|
|
110
|
+
/** A no-op transport for tests / dry-runs (no network). */
|
|
111
|
+
class MockRelayTransport {
|
|
112
|
+
published = [];
|
|
113
|
+
async publish(event, _relayUrl) {
|
|
114
|
+
this.published.push(event);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
exports.MockRelayTransport = MockRelayTransport;
|
|
118
|
+
/** Low-level: publish one event to a relay over websocket, awaiting OK. */
|
|
119
|
+
function publishEventToRelay(WebSocketCtor, event, relayUrl, timeoutMs = 30000) {
|
|
120
|
+
return new Promise((resolve, reject) => {
|
|
121
|
+
let settled = false;
|
|
122
|
+
const ws = new WebSocketCtor(relayUrl);
|
|
123
|
+
const timer = setTimeout(() => {
|
|
124
|
+
if (settled)
|
|
125
|
+
return;
|
|
126
|
+
settled = true;
|
|
127
|
+
try {
|
|
128
|
+
ws.close();
|
|
129
|
+
}
|
|
130
|
+
catch { /* ignore */ }
|
|
131
|
+
reject(new Error(`Relay publish timed out: ${relayUrl}`));
|
|
132
|
+
}, timeoutMs);
|
|
133
|
+
ws.onopen = () => {
|
|
134
|
+
ws.send(JSON.stringify(["EVENT", event]));
|
|
135
|
+
};
|
|
136
|
+
ws.onmessage = (msg) => {
|
|
137
|
+
let data;
|
|
138
|
+
try {
|
|
139
|
+
data = JSON.parse(typeof msg.data === "string" ? msg.data : msg.data.toString());
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (Array.isArray(data) && data[0] === "OK" && data[1] === event.id) {
|
|
145
|
+
settled = true;
|
|
146
|
+
clearTimeout(timer);
|
|
147
|
+
try {
|
|
148
|
+
ws.close();
|
|
149
|
+
}
|
|
150
|
+
catch { /* ignore */ }
|
|
151
|
+
if (data[2] === true)
|
|
152
|
+
resolve();
|
|
153
|
+
else
|
|
154
|
+
reject(new Error(`Relay rejected event ${event.id}: ${data[3] ?? "unknown"}`));
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
ws.onerror = (err) => {
|
|
158
|
+
if (settled)
|
|
159
|
+
return;
|
|
160
|
+
settled = true;
|
|
161
|
+
clearTimeout(timer);
|
|
162
|
+
reject(new Error(`Relay connection error: ${relayUrl} ${err?.message ?? ""}`));
|
|
163
|
+
};
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Verify a publish by querying the relay for the asset event and confirming
|
|
168
|
+
* the APK is retrievable from Blossom. Returns true if both check out.
|
|
169
|
+
*/
|
|
170
|
+
async function verifyPublish(input) {
|
|
171
|
+
const WebSocketCtor = input.WebSocketCtor ?? WebSocket;
|
|
172
|
+
const fetchFn = input.fetchImpl ?? fetch;
|
|
173
|
+
const eventFound = await queryRelayForEvent(WebSocketCtor, input.relayUrl, input.assetEventId);
|
|
174
|
+
const head = await fetchFn(input.blossomUrl, { method: "HEAD" });
|
|
175
|
+
return { eventFound, apkDownloadable: head.status === 200 };
|
|
176
|
+
}
|
|
177
|
+
/** Open a relay, send a REQ for a single event id, wait for it. */
|
|
178
|
+
function queryRelayForEvent(WebSocketCtor, relayUrl, eventId, timeoutMs = 30000) {
|
|
179
|
+
return new Promise((resolve) => {
|
|
180
|
+
let settled = false;
|
|
181
|
+
const ws = new WebSocketCtor(relayUrl);
|
|
182
|
+
const timer = setTimeout(() => {
|
|
183
|
+
if (settled)
|
|
184
|
+
return;
|
|
185
|
+
settled = true;
|
|
186
|
+
try {
|
|
187
|
+
ws.close();
|
|
188
|
+
}
|
|
189
|
+
catch { /* ignore */ }
|
|
190
|
+
resolve(false);
|
|
191
|
+
}, timeoutMs);
|
|
192
|
+
ws.onopen = () => {
|
|
193
|
+
ws.send(JSON.stringify(["REQ", "verify-" + eventId.slice(0, 8), { ids: [eventId] }]));
|
|
194
|
+
};
|
|
195
|
+
ws.onmessage = (msg) => {
|
|
196
|
+
let data;
|
|
197
|
+
try {
|
|
198
|
+
data = JSON.parse(typeof msg.data === "string" ? msg.data : msg.data.toString());
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (Array.isArray(data) && data[0] === "EVENT" && data[2]?.id === eventId) {
|
|
204
|
+
settled = true;
|
|
205
|
+
clearTimeout(timer);
|
|
206
|
+
try {
|
|
207
|
+
ws.close();
|
|
208
|
+
}
|
|
209
|
+
catch { /* ignore */ }
|
|
210
|
+
resolve(true);
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
ws.onerror = () => {
|
|
214
|
+
if (settled)
|
|
215
|
+
return;
|
|
216
|
+
settled = true;
|
|
217
|
+
clearTimeout(timer);
|
|
218
|
+
resolve(false);
|
|
219
|
+
};
|
|
220
|
+
});
|
|
221
|
+
}
|