Initial commit
This commit is contained in:
Executable
+131
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import * as asar from "@electron/asar";
|
||||
|
||||
function fail(message) {
|
||||
console.error(`[nani] error: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function walk(directory) {
|
||||
const found = [];
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const entryPath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) found.push(...walk(entryPath));
|
||||
else found.push(entryPath);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
async function sha512(filePath) {
|
||||
const hash = createHash("sha512");
|
||||
await new Promise((resolve, reject) => {
|
||||
fs.createReadStream(filePath).on("data", (chunk) => hash.update(chunk)).on("end", resolve).on("error", reject);
|
||||
});
|
||||
process.stdout.write(hash.digest("base64") + "\n");
|
||||
}
|
||||
|
||||
function electronVersion(appPath) {
|
||||
const candidates = walk(path.join(appPath, "Contents", "Frameworks")).filter(
|
||||
(file) => path.basename(file) === "Info.plist" && file.includes("Electron Framework.framework"),
|
||||
);
|
||||
for (const candidate of candidates) {
|
||||
const plist = fs.readFileSync(candidate, "utf8");
|
||||
const match = plist.match(
|
||||
/<key>CFBundle(?:ShortVersionString|Version)<\/key>\s*<string>([^<]+)<\/string>/,
|
||||
);
|
||||
if (match) return match[1];
|
||||
}
|
||||
fail("could not detect Electron version from Electron Framework Info.plist");
|
||||
}
|
||||
|
||||
function sqliteVersion(asarPath) {
|
||||
try {
|
||||
const packageJson = JSON.parse(
|
||||
asar.extractFile(asarPath, "node_modules/better-sqlite3/package.json").toString("utf8"),
|
||||
);
|
||||
if (typeof packageJson.version === "string" && packageJson.version) return packageJson.version;
|
||||
} catch (error) {
|
||||
fail(`could not inspect better-sqlite3 in ASAR: ${error.message}`);
|
||||
}
|
||||
fail("better-sqlite3 package has no version");
|
||||
}
|
||||
|
||||
function sqliteAsset(releasePath, version, abi) {
|
||||
const release = JSON.parse(fs.readFileSync(releasePath, "utf8"));
|
||||
const expectedName = `better-sqlite3-v${version}-electron-v${abi}-linux-x64.tar.gz`;
|
||||
const matches = (release.assets ?? []).filter((asset) => asset.name === expectedName);
|
||||
if (matches.length !== 1) fail(`expected one ${expectedName} release asset, found ${matches.length}`);
|
||||
const asset = matches[0];
|
||||
const digest = typeof asset.digest === "string" ? asset.digest : "";
|
||||
if (!digest.startsWith("sha256:")) fail(`GitHub did not provide a SHA-256 digest for ${expectedName}`);
|
||||
if (typeof asset.browser_download_url !== "string" || !asset.browser_download_url.startsWith("https://")) {
|
||||
fail(`release asset has no HTTPS download URL: ${expectedName}`);
|
||||
}
|
||||
process.stdout.write([asset.browser_download_url, digest.slice(7), expectedName].join("\t") + "\n");
|
||||
}
|
||||
|
||||
function writeUpstreamReport(output, version, url, sha512Value, dmgPath) {
|
||||
fs.mkdirSync(path.dirname(output), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
output,
|
||||
JSON.stringify({ version, url, sha512: sha512Value, dmgPath }, null, 2) + "\n",
|
||||
);
|
||||
}
|
||||
|
||||
function checkNativePayload(root) {
|
||||
const violations = [];
|
||||
const machoMagics = new Set(["cafebabe", "bebafeca", "feedface", "cefaedfe", "feedfacf", "cffaedfe"]);
|
||||
for (const file of walk(root)) {
|
||||
const descriptor = fs.openSync(file, "r");
|
||||
const header = Buffer.alloc(20);
|
||||
const length = fs.readSync(descriptor, header, 0, header.length, 0);
|
||||
fs.closeSync(descriptor);
|
||||
if (length < 4) continue;
|
||||
const magic = header.subarray(0, 4).toString("hex");
|
||||
if (machoMagics.has(magic) || magic.startsWith("4d5a")) {
|
||||
violations.push(`${file}: non-Linux executable`);
|
||||
continue;
|
||||
}
|
||||
if (file.endsWith(".node") || file.endsWith(".so")) {
|
||||
const isElfX64 =
|
||||
magic === "7f454c46" && header[4] === 2 && header[5] === 1 && header[18] === 0x3e && header[19] === 0;
|
||||
if (!isElfX64) violations.push(`${file}: native module is not x86_64 ELF`);
|
||||
}
|
||||
}
|
||||
if (violations.length) fail(`native payload validation failed:\n${violations.join("\n")}`);
|
||||
}
|
||||
|
||||
const [command, ...args] = process.argv.slice(2);
|
||||
switch (command) {
|
||||
case "sha512":
|
||||
if (args.length !== 1) fail("usage: build-metadata.mjs sha512 FILE");
|
||||
await sha512(args[0]);
|
||||
break;
|
||||
case "electron-version":
|
||||
if (args.length !== 1) fail("usage: build-metadata.mjs electron-version APP");
|
||||
process.stdout.write(electronVersion(args[0]) + "\n");
|
||||
break;
|
||||
case "sqlite-version":
|
||||
if (args.length !== 1) fail("usage: build-metadata.mjs sqlite-version ASAR");
|
||||
process.stdout.write(sqliteVersion(args[0]) + "\n");
|
||||
break;
|
||||
case "sqlite-asset":
|
||||
if (args.length !== 3) fail("usage: build-metadata.mjs sqlite-asset RELEASE_JSON VERSION ABI");
|
||||
sqliteAsset(...args);
|
||||
break;
|
||||
case "write-upstream-report":
|
||||
if (args.length !== 5) fail("usage: build-metadata.mjs write-upstream-report OUTPUT VERSION URL SHA512 DMG");
|
||||
writeUpstreamReport(...args);
|
||||
break;
|
||||
case "check-native":
|
||||
if (args.length !== 1) fail("usage: build-metadata.mjs check-native ROOT");
|
||||
checkNativePayload(args[0]);
|
||||
break;
|
||||
default:
|
||||
fail(`unknown command: ${command ?? ""}`);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
NANI_ROOT="${NANI_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
|
||||
NANI_CACHE_DIR="${NANI_CACHE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/nani-translate-linux}"
|
||||
NANI_BUILD_DIR="${NANI_BUILD_DIR:-$NANI_ROOT/build}"
|
||||
NANI_OUTPUT_DIR="${NANI_OUTPUT_DIR:-$NANI_ROOT/nani-app}"
|
||||
NANI_MANIFEST_URL="${NANI_MANIFEST_URL:-https://nani-desktop.kiok.jp/artifacts/latest-mac.yml}"
|
||||
|
||||
log() {
|
||||
printf '[nani] %s\n' "$*" >&2
|
||||
}
|
||||
|
||||
die() {
|
||||
printf '[nani] error: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
require_command() {
|
||||
command -v "$1" >/dev/null 2>&1 || die "required command not found: $1"
|
||||
}
|
||||
|
||||
require_linux_x64() {
|
||||
[ "$(uname -s)" = Linux ] || die "only Linux is supported"
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) ;;
|
||||
*) die "only x86_64 is supported" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
seven_zip_command() {
|
||||
if command -v 7zz >/dev/null 2>&1; then
|
||||
printf '%s\n' 7zz
|
||||
elif command -v 7z >/dev/null 2>&1; then
|
||||
printf '%s\n' 7z
|
||||
else
|
||||
die "required command not found: 7zz or 7z"
|
||||
fi
|
||||
}
|
||||
|
||||
sha512_base64() {
|
||||
require_command node
|
||||
node "$NANI_ROOT/scripts/lib/build-metadata.mjs" sha512 "$1"
|
||||
}
|
||||
|
||||
sha256_hex() {
|
||||
require_command sha256sum
|
||||
sha256sum "$1" | awk '{print $1}'
|
||||
}
|
||||
|
||||
download_file() {
|
||||
local url="$1"
|
||||
local destination="$2"
|
||||
local expected_sha256="${3:-}"
|
||||
local part="${destination}.part"
|
||||
|
||||
case "$url" in
|
||||
https://*) ;;
|
||||
*) die "refusing non-HTTPS download: $url" ;;
|
||||
esac
|
||||
|
||||
require_command curl
|
||||
mkdir -p "$(dirname "$destination")"
|
||||
|
||||
if [ -f "$destination" ]; then
|
||||
if [ -z "$expected_sha256" ] || [ "$(sha256_hex "$destination")" = "$expected_sha256" ]; then
|
||||
log "using cached $(basename "$destination")"
|
||||
return 0
|
||||
fi
|
||||
log "cached checksum mismatch; downloading again"
|
||||
rm -f -- "$destination"
|
||||
fi
|
||||
|
||||
rm -f -- "$part"
|
||||
if ! curl --fail --location --retry 3 --retry-delay 1 --output "$part" "$url"; then
|
||||
rm -f -- "$part"
|
||||
die "download failed: $url"
|
||||
fi
|
||||
|
||||
if [ -n "$expected_sha256" ] && [ "$(sha256_hex "$part")" != "$expected_sha256" ]; then
|
||||
rm -f -- "$part"
|
||||
die "SHA-256 mismatch for $(basename "$destination")"
|
||||
fi
|
||||
|
||||
mv -f -- "$part" "$destination"
|
||||
}
|
||||
|
||||
write_build_info() {
|
||||
local destination="$1"
|
||||
local version="$2"
|
||||
local electron_version="$3"
|
||||
local source_url="$4"
|
||||
local source_sha512="$5"
|
||||
|
||||
{
|
||||
printf 'version=%s\n' "$version"
|
||||
printf 'electron_version=%s\n' "$electron_version"
|
||||
printf 'source_url=%s\n' "$source_url"
|
||||
printf 'source_sha512=%s\n' "$source_sha512"
|
||||
} >"$destination"
|
||||
}
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { parse } from "yaml";
|
||||
|
||||
function fail(message) {
|
||||
console.error(`[nani] error: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [manifestPath, manifestUrl] = process.argv.slice(2);
|
||||
if (!manifestPath || !manifestUrl) {
|
||||
fail("usage: resolve-upstream.mjs MANIFEST_PATH MANIFEST_URL");
|
||||
}
|
||||
|
||||
let manifest;
|
||||
try {
|
||||
manifest = parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
} catch (error) {
|
||||
fail(`could not parse upstream manifest: ${error.message}`);
|
||||
}
|
||||
|
||||
const files = Array.isArray(manifest?.files) ? manifest.files : [];
|
||||
const dmgs = files.filter(
|
||||
(file) => typeof file?.url === "string" && file.url.toLowerCase().endsWith(".dmg"),
|
||||
);
|
||||
if (dmgs.length !== 1) {
|
||||
fail(`expected exactly one DMG in manifest, found ${dmgs.length}`);
|
||||
}
|
||||
|
||||
const selected = dmgs[0];
|
||||
if (typeof manifest.version !== "string" || manifest.version.length === 0) {
|
||||
fail("manifest has no version");
|
||||
}
|
||||
if (typeof selected.sha512 !== "string" || selected.sha512.length === 0) {
|
||||
fail("DMG entry has no SHA-512");
|
||||
}
|
||||
|
||||
let resolvedUrl;
|
||||
try {
|
||||
resolvedUrl = new URL(selected.url, manifestUrl).href;
|
||||
} catch (error) {
|
||||
fail(`invalid DMG URL: ${error.message}`);
|
||||
}
|
||||
if (!resolvedUrl.startsWith("https://")) {
|
||||
fail("DMG URL must use HTTPS");
|
||||
}
|
||||
|
||||
const filename = path.basename(new URL(resolvedUrl).pathname);
|
||||
if (!filename) fail("DMG URL has no filename");
|
||||
|
||||
process.stdout.write(
|
||||
[manifest.version, resolvedUrl, selected.sha512, filename].join("\t") + "\n",
|
||||
);
|
||||
Reference in New Issue
Block a user