fix: restore Intel macOS release compatibility

This commit is contained in:
k9ert 2026-08-09 14:00:04 +02:00
parent 8843e2f064
commit 98c1e44186
12 changed files with 386 additions and 47 deletions

View file

@ -4,11 +4,13 @@ on:
pull_request:
paths:
- 'pyinstaller/electron/**'
- '.github/workflows/release.yml'
- '.github/workflows/electron-smoketest.yml'
push:
branches: [master]
paths:
- 'pyinstaller/electron/**'
- '.github/workflows/release.yml'
- '.github/workflows/electron-smoketest.yml'
jobs:
@ -48,10 +50,57 @@ jobs:
node --check "$f"
done
- name: Test release manifest validation
working-directory: pyinstaller/electron
run: npm test
- name: Boot electron, assert missing hash fails closed
working-directory: pyinstaller/electron
env:
NODE_ENV: development
SPECTER_DATA_FOLDER: ${{ runner.temp }}/specter_dev
run: |
mkdir -p "$RUNNER_TEMP/specter_dev"
echo '{"version":"v2.1.11-pre1","repository":"cryptoadvance/specter-desktop","sha256":{"x64":"","arm64":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}' \
> "$RUNNER_TEMP/specter_dev/version-data.json"
xvfb-run --auto-servernum --server-args='-screen 0 1280x960x24' \
./node_modules/.bin/electron . --no-sandbox > electron-invalid-manifest.log 2>&1 &
PID=$!
trap 'kill $PID 2>/dev/null || true; wait $PID 2>/dev/null || true' EXIT
EXPECTED="does not contain a valid specterd hash for x64"
for _ in $(seq 1 30); do
if grep -qF "$EXPECTED" electron-invalid-manifest.log; then
if grep -qF 'Downloading from' electron-invalid-manifest.log; then
echo "invalid manifest attempted a download"
cat electron-invalid-manifest.log
exit 1
fi
if grep -qF 'ReferenceError' electron-invalid-manifest.log; then
echo "invalid manifest raised an uncaught reference error"
cat electron-invalid-manifest.log
exit 1
fi
echo "missing hash failed closed with an actionable error"
exit 0
fi
if ! kill -0 $PID 2>/dev/null; then
echo "electron exited before reporting the invalid manifest"
cat electron-invalid-manifest.log
exit 1
fi
sleep 1
done
echo "invalid manifest error not seen within 30s"
cat electron-invalid-manifest.log
exit 1
- name: Seed dev data folder
run: |
mkdir -p "$RUNNER_TEMP/specter_dev"
echo '{"version":"","sha256":{"x64":"","arm64":""}}' \
echo '{"version":"","repository":"cryptoadvance/specter-desktop","sha256":{"x64":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","arm64":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}' \
> "$RUNNER_TEMP/specter_dev/version-data.json"
- name: Boot electron, assert splash reaches no-specterd branch
@ -71,7 +120,7 @@ jobs:
# If main.js changes this message, update both sides together.
EXPECTED="Specterd was not found and no version is configured"
for i in $(seq 1 30); do
for _ in $(seq 1 30); do
if grep -qF "$EXPECTED" electron.log; then
echo "smoketest passed (splash reached no-specterd branch)"
exit 0

View file

@ -172,14 +172,14 @@ jobs:
strategy:
matrix:
include:
# x64 builds require a paid macOS runner (macos-13 retired, macos-15-large is paid)
# Uncomment when org has paid plan:
# - runner: macos-15-large
# arch: x64
# arch_label: osx_x64
- runner: macos-15-intel
arch: x64
arch_label: osx_x64
file_arch: x86_64
- runner: macos-14 # Apple Silicon (free tier)
arch: arm64
arch_label: osx_arm64
file_arch: arm64
steps:
- uses: actions/checkout@v4
with:
@ -217,6 +217,13 @@ jobs:
pyinstaller specterd.spec
cd ..
- name: Smoke test native specterd
run: |
FILE_OUTPUT=$(file pyinstaller/dist/specterd)
echo "$FILE_OUTPUT"
echo "$FILE_OUTPUT" | grep -q '${{ matrix.file_arch }}'
"pyinstaller/dist/specterd" --help
- name: Package release
run: |
VERSION=${GITHUB_REF#refs/tags/}
@ -441,6 +448,12 @@ jobs:
name: specterd-macos-arm64
path: ./release-artifacts
- name: Download specterd macOS x64 artifact
uses: actions/download-artifact@v4
with:
name: specterd-macos-x64
path: ./release-artifacts
- name: Cache Electron binaries
uses: actions/cache@v4
with:
@ -482,13 +495,27 @@ jobs:
run: |
VERSION=${GITHUB_REF#refs/tags/}
source .buildenv/bin/activate
mkdir -p pyinstaller/dist
mkdir -p pyinstaller/dist/arm64 pyinstaller/dist/x64
cd release-artifacts
unzip specterd-${VERSION}-osx_arm64.zip -d ../pyinstaller/dist/
unzip "specterd-${VERSION}-osx_arm64.zip" -d ../pyinstaller/dist/arm64
unzip "specterd-${VERSION}-osx_x64.zip" -d ../pyinstaller/dist/x64
cd ..
cd pyinstaller/electron
npm ci
node ./set-version $VERSION ../dist/specterd
node ./set-version.js "$VERSION" ../dist/arm64/specterd arm64 "$GITHUB_REPOSITORY"
node ./set-version.js "$VERSION" ../dist/x64/specterd x64 "$GITHUB_REPOSITORY"
node -e "
const crypto = require('crypto');
const fs = require('fs');
const { isValidSha256 } = require('./src/version-data');
const data = require('./version-data.json');
for (const arch of ['arm64', 'x64']) {
if (!isValidSha256(data.sha256[arch])) throw new Error('Missing valid ' + arch + ' specterd hash');
const binary = fs.readFileSync('../dist/' + arch + '/specterd');
const actual = crypto.createHash('sha256').update(binary).digest('hex');
if (data.sha256[arch] !== actual) throw new Error('Incorrect ' + arch + ' specterd hash');
}
"
cd ../..
- name: Patch electron-builder config for CI
@ -533,17 +560,44 @@ jobs:
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Smoke test universal Electron package
run: |
APP_PATH=pyinstaller/electron/dist/mac-universal/Specter.app
ELECTRON_BINARY="$APP_PATH/Contents/MacOS/Specter"
ASAR_PATH="$APP_PATH/Contents/Resources/app.asar"
ARCHS=$(lipo -archs "$ELECTRON_BINARY")
echo "Electron architectures: $ARCHS"
echo "$ARCHS" | grep -qw x86_64
echo "$ARCHS" | grep -qw arm64
ASAR_CONTENTS=$(pyinstaller/electron/node_modules/.bin/asar list "$ASAR_PATH")
echo "$ASAR_CONTENTS" | grep -qx '/src/splash.html'
echo "$ASAR_CONTENTS" | grep -qx '/version-data.json'
node -e "
const asar = require('./pyinstaller/electron/node_modules/@electron/asar');
const crypto = require('crypto');
const fs = require('fs');
const data = JSON.parse(asar.extractFile(process.argv[1], 'version-data.json').toString());
if (data.repository !== process.env.GITHUB_REPOSITORY) throw new Error('Incorrect packaged repository');
for (const arch of ['arm64', 'x64']) {
const binary = fs.readFileSync('pyinstaller/dist/' + arch + '/specterd');
const expected = crypto.createHash('sha256').update(binary).digest('hex');
if (data.sha256[arch] !== expected) throw new Error('Incorrect packaged ' + arch + ' specterd hash');
}
" "$ASAR_PATH"
- name: Package release
run: |
VERSION=${GITHUB_REF#refs/tags/}
mkdir -p release
cp pyinstaller/electron/dist/Specter-*.dmg release/Specter-${VERSION}.dmg 2>/dev/null || true
# Fallback: if no DMG, package the .app
if [ ! -f release/Specter-${VERSION}.dmg ]; then
cd pyinstaller/electron/dist/mac-universal
zip -r ../../../../release/Specter-${VERSION}-macos.zip Specter.app
cd ../../../..
DMG_FILES=(pyinstaller/electron/dist/Specter-*.dmg)
if [ "${#DMG_FILES[@]}" -ne 1 ] || [ ! -f "${DMG_FILES[0]}" ]; then
echo "Expected exactly one universal DMG, found: ${DMG_FILES[*]}"
exit 1
fi
cp "${DMG_FILES[0]}" "release/Specter-${VERSION}.dmg"
test -s "release/Specter-${VERSION}.dmg"
- name: Upload artifact
uses: actions/upload-artifact@v4
@ -587,9 +641,21 @@ jobs:
find artifacts/pip-package -type f -name "cryptoadvance_specter-*.tar.gz" \
-exec cp {} release-files/ \; 2>/dev/null || true
# A universal macOS release is incomplete without both native daemons
# and the DMG that carries their hashes.
for REQUIRED in \
"specterd-${VERSION}-osx_x64.zip" \
"specterd-${VERSION}-osx_arm64.zip" \
"Specter-${VERSION}.dmg"; do
if [ ! -s "release-files/${REQUIRED}" ]; then
echo "Missing required macOS release artifact: ${REQUIRED}"
exit 1
fi
done
# Generate SHA256SUMS
cd release-files
sha256sum * > SHA256SUMS
sha256sum ./* > SHA256SUMS
echo "=== SHA256SUMS ==="
cat SHA256SUMS
cd ..
@ -619,7 +685,7 @@ jobs:
- name: Generate release body
run: |
VERSION=${GITHUB_REF#refs/tags/}
DL="https://github.com/cryptoadvance/specter-desktop/releases/download/${VERSION}"
DL="https://github.com/${GITHUB_REPOSITORY}/releases/download/${VERSION}"
cat > release-body.md << EOF
*Please create a full backup* before migrating or any major internal changes like switching to an electrum based installation. You can easily create a backup in Settings --> Backup Specter (zip file).
@ -647,7 +713,7 @@ jobs:
## specterd
Specterd is a command-line program that runs only the Specter server, behaving like a traditional web application.
Supported platforms: [Windows](${DL}/specterd-${VERSION}-win64.zip), [MacOS](${DL}/specterd-${VERSION}-osx_arm64.zip), [Linux (x86_64)](${DL}/specterd-${VERSION}-x86_64-linux-gnu.zip)
Supported platforms: [Windows](${DL}/specterd-${VERSION}-win64.zip), [macOS (Apple Silicon)](${DL}/specterd-${VERSION}-osx_arm64.zip), [macOS (Intel)](${DL}/specterd-${VERSION}-osx_x64.zip), [Linux (x86_64)](${DL}/specterd-${VERSION}-x86_64-linux-gnu.zip)
## PyPi Packages
@ -700,6 +766,7 @@ jobs:
files: release-files/*
body_path: release-body.md
draft: true # Draft first, review before publishing
prerelease: ${{ contains(github.ref_name, '-pre') }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -18,7 +18,7 @@ git push upstream v1.13.1
That's it. The `Release` workflow on GitHub Actions takes it from here:
- **`release-pip`** — builds the sdist/wheel and publishes to PyPI via trusted publishing.
- **`build-specterd-{linux,windows,macos}`** — builds the `specterd` binary on each platform (macOS arm64 on the free `macos-14` runner).
- **`build-specterd-{linux,windows,macos}`** — builds the `specterd` binary on each platform. macOS uses native `macos-14` (arm64) and `macos-15-intel` (x64) runners and smoke-tests each daemon before packaging.
- **`build-electron-{linux,windows,macos}`** — builds the Electron apps using each platform's `specterd` artifact. Windows uses the public `electronuserland/builder:wine` image; macOS signs + notarizes if `APPLE_CERTIFICATE_BASE64` et al. are configured.
- **`create-release`** — collects all artifacts, generates `SHA256SUMS`, signs it with the GPG key from the `GPG_PRIVATE_KEY` secret, generates a release body (with auto-generated "What's Changed" via `gh api .../generate-notes`), and creates a **draft** GitHub release.
- **`trigger-docker`** — POSTs a repository-dispatch to `lncm/docker-specter-desktop` so Aaron's Docker build picks up the new tag (needs `AARON_TRIGGER` secret; skipped otherwise).
@ -58,3 +58,38 @@ The script installs the markdown prerequisite, regenerates the GH-page and downl
If something fails mid-pipeline, re-running individual jobs is safe — they `actions/download-artifact` from prior jobs and overwrite existing release assets via `softprops/action-gh-release`. If the draft release already has assets from a stale run, delete the draft and re-run `create-release`.
macOS builds are the most likely to fail due to Apple signing/notarization glitches. The workflow falls back to unsigned builds when `APPLE_CERTIFICATE_BASE64` is empty — useful for smoke-testing the pipeline on forks.
## Validate a release candidate on a fork
Do not create or push a tag until the candidate diff and local checks have been reviewed. Once local review is approved, use immutable pre-release tags on the selected validation fork; never move a pre-tag after GitHub Actions has tested it. The release manifest records `${{ github.repository }}`, so a fork-built app downloads its fork artifacts while the real upstream release continues to download from `cryptoadvance/specter-desktop`.
```bash
git remote get-url origin
# Confirm that origin is the intended validation fork, not upstream.
git fetch upstream master
git merge-base --is-ancestor upstream/master HEAD
git status --short --branch
git diff --check upstream/master...HEAD
cd pyinstaller/electron
npm test
cd ../..
CANDIDATE_TAG=v2.1.11-pre1
if git rev-parse --verify "refs/tags/${CANDIDATE_TAG}"; then
echo "Tag already exists locally: ${CANDIDATE_TAG}"
exit 1
fi
git tag "${CANDIDATE_TAG}"
git push origin "${CANDIDATE_TAG}"
```
The `-preN` tag creates a draft GitHub prerelease. PyPI publishing is disabled outside `cryptoadvance/specter-desktop`, and absent Apple/GPG secrets exercise the unsigned fork fallback; that does not validate upstream signing or notarization.
Before accepting the candidate, record the workflow run URL and verify:
- both macOS daemon jobs report the expected native architecture and pass `specterd --help`;
- `specterd-v2.1.11-preN-osx_x64.zip`, `specterd-v2.1.11-preN-osx_arm64.zip`, and `Specter-v2.1.11-preN.dmg` are present;
- `SHA256SUMS` contains those three artifacts;
- the published prerelease and embedded daemon repository point to the selected fork;
- the unsigned DMG launches, downloads, verifies, and relaunches on both a real Intel Mac and a real Apple Silicon Mac, including an upgrade with existing settings.
If the candidate changes, create `v2.1.11-pre2` (then `pre3`, and so on) at the new reviewed commit. Upstream still requires a secret-enabled signing/notarization validation before publication.

View file

@ -1,14 +1,24 @@
function orgName() {
// This can be changed in order to make download possible from other github orgs
return "cryptoadvance"
const DEFAULT_REPOSITORY = 'cryptoadvance/specter-desktop'
const REPOSITORY_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/
function repositoryName(repository = DEFAULT_REPOSITORY) {
if (!REPOSITORY_PATTERN.test(repository)) {
throw new Error(`Invalid specterd download repository: ${repository}`)
}
return repository
}
function getDownloadLocation(version, platformname) {
function orgName() {
return DEFAULT_REPOSITORY.split('/')[0]
}
function getDownloadLocation(version, platformname, arch = process.arch, repository = DEFAULT_REPOSITORY) {
const releaseRepository = repositoryName(repository)
if (platformname != "osx") {
return `https://github.com/${orgName()}/specter-desktop/releases/download/${version}/specterd-${version}-${platformname}.zip`
return `https://github.com/${releaseRepository}/releases/download/${version}/specterd-${version}-${platformname}.zip`
}
return `https://github.com/${orgName()}/specter-desktop/releases/download/${version}/specterd-${version}-${platformname}_${process.arch}.zip`
return `https://github.com/${releaseRepository}/releases/download/${version}/specterd-${version}-${platformname}_${arch}.zip`
}
function appName() {
@ -18,6 +28,6 @@ function appName() {
module.exports = {
getDownloadLocation,
appName,
orgName
orgName,
repositoryName,
}

View file

@ -12,7 +12,8 @@ const { downloadSpecterd, destroyProgressbar } = require('./src/download.js')
const { startSpecterd, quitSpecterd } = require('./src/specterd.js')
const { getFileHash, versionData, isDev, devFolder, isMac } = require('./src/helpers.js')
const { getAppSettings } = require('./src/config.js')
const { showError, updatingLoaderMsg, initMainWindow, loadUrl, initTray } = require('./src/uiHelpers.js')
const { showError, updatingLoaderMsg, updateSpecterdStatus, initMainWindow, loadUrl, initTray } = require('./src/uiHelpers.js')
const { hashesMatch, missingHashMessage, synchronizeSpecterdSettings } = require('./src/version-data.js')
// Quit again if there is no version-data in dev
if (isDev && versionData === undefined) {
@ -136,24 +137,30 @@ app.whenReady().then(() => {
setMainMenu()
loadUrl(`file://${__dirname}/splash.html`)
loadUrl(`file://${__dirname}/src/splash.html`)
if (!fs.existsSync(specterdDirPath)) {
logger.info('Creating specterd-binaries folder:' + specterdDirPath)
fs.mkdirSync(specterdDirPath, { recursive: true })
}
if (!appSettings.versionInitialized || appSettings.versionInitialized != versionData.version) {
const settingsUpdate = synchronizeSpecterdSettings(appSettings, versionData, process.arch)
if (!settingsUpdate.hash) {
const errorMessage = missingHashMessage(versionData, process.arch)
logger.error(errorMessage)
updatingLoaderMsg(errorMessage)
updateSpecterdStatus('Failed to validate this release...')
return
}
if (settingsUpdate.changed) {
logger.info(`Updating ${appSettingsPath} : ${JSON.stringify(appSettings)}`)
appSettings.specterdVersion = versionData.version
appSettings.specterdHash = versionData.sha256[process.arch]
appSettings.versionInitialized = versionData.version
fs.writeFileSync(appSettingsPath, JSON.stringify(appSettings))
}
const specterdPath = specterdDirPath + '/' + appNameLower + 'd'
if (fs.existsSync(specterdPath + (platformName == 'win64' ? '.exe' : ''))) {
getFileHash(specterdPath + (platformName == 'win64' ? '.exe' : ''), function (specterdHash) {
if (appSettings.specterdHash.toLowerCase() == specterdHash || appSettings.specterdHash == '') {
if (hashesMatch(appSettings.specterdHash, specterdHash)) {
startSpecterd(specterdPath)
} else if (appSettings.specterdVersion != '') {
updatingLoaderMsg('Specterd version could not be validated. Trying again to download the Specter binary ...')

View file

@ -7,7 +7,8 @@
"start": "export NODE_ENV=development && cp -R ../../src/cryptoadvance/specter/static/fonts ../../src/cryptoadvance/specter/static/output.css ../../src/cryptoadvance/specter/static/typography.css . && electron .",
"pack": "electron-builder --dir",
"dist": "electron-builder --publish=never",
"postinstall": "electron-builder install-app-deps"
"postinstall": "electron-builder install-app-deps",
"test": "node --test test/*.test.js"
},
"repository": "https://github.com/cryptoadvance/specter-desktop",
"keywords": [
@ -69,4 +70,4 @@
"resolutions": {
"**/request/form-data": "^2.5.4"
}
}
}

View file

@ -1,11 +1,17 @@
const fs = require('fs');
const crypto = require('crypto');
const versionDataFile = './version-data.json';
const { isValidSha256 } = require('./src/version-data');
async function setVersion() {
const version = process.argv[2];
const file = process.argv[3];
const arch = process.argv[4] || process.arch;
const repository = process.argv[5] || process.env.GITHUB_REPOSITORY || 'cryptoadvance/specter-desktop';
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) {
throw new Error(`Invalid release repository: ${repository}`);
}
// Set version in package.json
let packageJson = require('./package.json');
@ -21,15 +27,20 @@ async function setVersion() {
if (versionData.version != version) {
console.log(`Version mismatch. Deleting ${versionDataFile} and creating anew.`);
fs.unlinkSync(versionDataFile); // Delete the existing version-data.json file
versionData = createNewVersionData(version); // Create new version data object
versionData = createNewVersionData(version, repository); // Create new version data object
}
} catch (error) {
console.log(`No ${versionDataFile} found. Creating anew.`);
versionData = createNewVersionData(version);
versionData = createNewVersionData(version, repository);
}
// Compute SHA256 hash of the provided file
versionData.sha256[arch] = await createHashFromFile(file);
const fileHash = await createHashFromFile(file);
if (!isValidSha256(fileHash)) {
throw new Error(`Could not generate a valid SHA-256 hash for ${file}`);
}
versionData.sha256[arch] = fileHash;
versionData.repository = repository;
// Write new version data to file
fs.writeFileSync(versionDataFile, JSON.stringify(versionData, undefined, 2));
console.log("version-data.js: ")
@ -41,10 +52,11 @@ async function setVersion() {
}
}
function createNewVersionData(version) {
function createNewVersionData(version, repository) {
// Return a new version data object
return {
version,
repository,
sha256: {}
};
}
@ -57,4 +69,11 @@ const createHashFromFile = filePath => new Promise((resolve, reject) => {
.on('error', reject);
});
setVersion().catch(console.error);
if (require.main === module) {
setVersion().catch(error => {
console.error(error);
process.exitCode = 1;
});
}
module.exports = { createHashFromFile, createNewVersionData, setVersion };

View file

@ -1,6 +1,7 @@
const os = require('os')
const path = require('path')
const fs = require('fs')
const { getSpecterdHash } = require('./version-data')
const downloadloc = require('../downloadloc');
@ -44,7 +45,7 @@ function getAppSettings() {
tor: false,
proxyURL: "socks5://127.0.0.1:9050",
specterdVersion: (versionData && versionData.version !== undefined) ? versionData.version : 'unknown',
specterdHash: (versionData && versionData.sha256 !== undefined) ? versionData.sha256[process.arch] : 'unknown',
specterdHash: getSpecterdHash(versionData, process.arch),
specterdCLIArgs: "",
versionInitialized: false
}
@ -107,4 +108,4 @@ module.exports = {
getAppSettings,
isDev: isDev,
devFolder,
}
}

View file

@ -3,12 +3,13 @@ const fs = require('fs')
const { app, Menu } = require('electron')
const extract = require('extract-zip')
const { getDownloadLocation } = require('../downloadloc.js')
const { appName, appSettings, platformName, appNameLower, versionDataPath } = require('./config.js')
const { appName, appSettings, platformName, appNameLower, versionData, versionDataPath } = require('./config.js')
const { isMac, getFileHash } = require('./helpers.js')
const { logger } = require('./logging.js')
const ProgressBar = require('electron-progressbar')
const { updateSpecterdStatus, updatingLoaderMsg, createProgressBar } = require('./uiHelpers.js')
const { startSpecterd } = require('./specterd.js')
const { hashesMatch, isValidSha256, missingHashMessage } = require('./version-data.js')
let progressBar
@ -29,12 +30,25 @@ const dockMenuWithforceQuit = Menu.buildFromTemplate([
])
function downloadSpecterd(specterdPath) {
if (!isValidSha256(appSettings.specterdHash)) {
const errorMessage = missingHashMessage({ version: appSettings.specterdVersion }, process.arch)
logger.error(errorMessage)
updatingLoaderMsg(errorMessage)
updateSpecterdStatus('Failed to validate this release...')
return
}
updatingLoaderMsg(`Starting download`)
updateSpecterdStatus(`Downloading the ${appName} binary...`)
// Some logging
logger.info('Using version ' + appSettings.specterdVersion)
logger.info('Using platformName ' + platformName)
download_location = getDownloadLocation(appSettings.specterdVersion, platformName)
download_location = getDownloadLocation(
appSettings.specterdVersion,
platformName,
process.arch,
versionData.repository
)
logger.info('Downloading from ' + download_location)
download(download_location, specterdPath + '.zip', function (errored, errorMsg) {
if (errored == true) {
@ -67,7 +81,7 @@ function downloadSpecterd(specterdPath) {
fs.unlinkSync(specterdPath + '.zip')
fs.rmdirSync(specterdPath + '-dir', { recursive: true })
getFileHash(specterdPath + (platformName == 'win64' ? '.exe' : ''), function (specterdHash) {
if (appSettings.specterdHash.toLowerCase() === specterdHash || appSettings.specterdHash == '') {
if (hashesMatch(appSettings.specterdHash, specterdHash)) {
startSpecterd(specterdPath)
} else {
updatingLoaderMsg('Specterd version could not be validated.')

View file

@ -0,0 +1,47 @@
const SHA256_PATTERN = /^[0-9a-f]{64}$/i
function isValidSha256(value) {
return typeof value === 'string' && SHA256_PATTERN.test(value)
}
function getSpecterdHash(versionData, arch) {
const hash = versionData && versionData.sha256 && versionData.sha256[arch]
return isValidSha256(hash) ? hash.toLowerCase() : null
}
function hashesMatch(expectedHash, actualHash) {
return isValidSha256(expectedHash) && isValidSha256(actualHash) && expectedHash.toLowerCase() === actualHash.toLowerCase()
}
function synchronizeSpecterdSettings(appSettings, versionData, arch) {
const hash = getSpecterdHash(versionData, arch)
if (!hash) {
return { changed: false, hash: null }
}
const changed =
appSettings.specterdVersion !== versionData.version ||
appSettings.versionInitialized !== versionData.version ||
appSettings.specterdHash !== hash
if (changed) {
appSettings.specterdVersion = versionData.version
appSettings.specterdHash = hash
appSettings.versionInitialized = versionData.version
}
return { changed, hash }
}
function missingHashMessage(versionData, arch) {
const version = versionData && versionData.version ? ` ${versionData.version}` : ''
return `Specter release${version} does not contain a valid specterd hash for ${arch}. Please install a complete release for this platform architecture.`
}
module.exports = {
getSpecterdHash,
hashesMatch,
isValidSha256,
missingHashMessage,
synchronizeSpecterdSettings,
}

View file

@ -0,0 +1,23 @@
const assert = require('node:assert/strict')
const test = require('node:test')
const { getDownloadLocation, repositoryName } = require('../downloadloc')
test('builds repository-relative Intel and ARM macOS daemon URLs', () => {
assert.equal(
getDownloadLocation('v2.1.11-pre1', 'osx', 'x64', 'example/specter-desktop'),
'https://github.com/example/specter-desktop/releases/download/v2.1.11-pre1/specterd-v2.1.11-pre1-osx_x64.zip'
)
assert.equal(
getDownloadLocation('v2.1.11-pre1', 'osx', 'arm64', 'example/specter-desktop'),
'https://github.com/example/specter-desktop/releases/download/v2.1.11-pre1/specterd-v2.1.11-pre1-osx_arm64.zip'
)
})
test('keeps upstream as the compatibility default', () => {
assert.equal(repositoryName(), 'cryptoadvance/specter-desktop')
})
test('rejects malformed repository metadata', () => {
assert.throws(() => repositoryName('https://example.com/attacker/repo'), /Invalid specterd download repository/)
})

View file

@ -0,0 +1,66 @@
const assert = require('node:assert/strict')
const test = require('node:test')
const {
getSpecterdHash,
hashesMatch,
isValidSha256,
missingHashMessage,
synchronizeSpecterdSettings,
} = require('../src/version-data')
const X64_HASH = 'a'.repeat(64)
const ARM64_HASH = 'B'.repeat(64)
test('selects and normalizes the hash for each Electron architecture', () => {
const versionData = { version: 'v2.1.11-pre1', sha256: { x64: X64_HASH, arm64: ARM64_HASH } }
assert.equal(getSpecterdHash(versionData, 'x64'), X64_HASH)
assert.equal(getSpecterdHash(versionData, 'arm64'), ARM64_HASH.toLowerCase())
})
test('rejects missing, empty, malformed, and non-string architecture hashes', () => {
for (const hash of [undefined, null, '', 'unknown', 'a'.repeat(63), 42]) {
assert.equal(getSpecterdHash({ sha256: { x64: hash } }, 'x64'), null)
assert.equal(isValidSha256(hash), false)
}
})
test('hash comparison fails closed unless both values are valid SHA-256 strings', () => {
assert.equal(hashesMatch(X64_HASH.toUpperCase(), X64_HASH), true)
assert.equal(hashesMatch('', ''), false)
assert.equal(hashesMatch(undefined, X64_HASH), false)
assert.equal(hashesMatch(X64_HASH, 'not-a-hash'), false)
})
test('missing hash errors identify the release and host architecture', () => {
assert.match(missingHashMessage({ version: 'v2.1.11-pre1' }, 'x64'), /v2\.1\.11-pre1/)
assert.match(missingHashMessage({ version: 'v2.1.11-pre1' }, 'x64'), /x64/)
})
test('refreshes stale launcher settings without disturbing unrelated user settings', () => {
const settings = {
basicAuthUser: 'alice',
specterdHash: null,
specterdVersion: 'v2.1.10',
versionInitialized: 'v2.1.10',
}
const versionData = { version: 'v2.1.11-pre1', sha256: { x64: X64_HASH } }
assert.deepEqual(synchronizeSpecterdSettings(settings, versionData, 'x64'), { changed: true, hash: X64_HASH })
assert.equal(settings.basicAuthUser, 'alice')
assert.equal(settings.specterdHash, X64_HASH)
assert.equal(settings.specterdVersion, 'v2.1.11-pre1')
assert.equal(settings.versionInitialized, 'v2.1.11-pre1')
})
test('does not mutate settings when the release hash is missing', () => {
const settings = { specterdHash: 'stale', specterdVersion: 'v2.1.10' }
const original = { ...settings }
assert.deepEqual(synchronizeSpecterdSettings(settings, { version: 'v2.1.11', sha256: {} }, 'x64'), {
changed: false,
hash: null,
})
assert.deepEqual(settings, original)
})