Compare commits

..
Author SHA1 Message Date
Twyco 3d5ee223e8 Merge pull request 'KAN-65: Fix Hetzner feed ETags and resume published releases' (#161) from fix/KAN-65-hetzner-feed-etag into main
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
macOS checks / Native Rust tests and unsigned build (push) Canceled after 42s
Native JavaScript checks / Native launcher and release tests (push) Canceled after 0s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 22s
macOS release to Hetzner / Resume verified release publication (push) Skipped
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Failing after 3m0s
Reviewed-on: #161
2026-09-28 05:27:36 +00:00
Twyco 9dbb491dc9 fix(KAN-65): resume release publication with CI credentials
CI/CD / preconditions (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 11s
Native JavaScript checks / Native launcher and release tests (pull_request) Canceled after 0s
2026-09-28 07:27:05 +02:00
Twyco 40b66bf7ce fix(KAN-65): normalize Hetzner conditional write ETags
CI/CD / preconditions (pull_request) Successful in 1s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m5s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m34s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 5m16s
2026-09-28 07:24:12 +02:00
Twyco dc9f400f34 fix(KAN-65): recover verified S3 writes from empty error responses
CI/CD / test_and_check_app (push) Successful in 6m31s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 21s
CI/CD / Publish immutable image (push) Successful in 1m18s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Failing after 4m57s
CI/CD / preconditions (push) Successful in 2s
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m35s
Native JavaScript checks / Native launcher and release tests (push) Successful in 58s
2026-09-27 23:05:35 +02:00
Twyco 48f5fc410b Merge pull request 'KAN-65: Show the actual macOS build number in About and updates' (#160) from fix/KAN-65-about-build-number into main
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Native JavaScript checks / Native launcher and release tests (push) Canceled after 0s
macOS checks / Native Rust tests and unsigned build (push) Canceled after 11s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 21s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Failing after 4m21s
Reviewed-on: #160
2026-09-27 20:49:33 +00:00
Twyco f6725ac025 fix(KAN-65): show the installed macOS build number
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 2m0s
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 58s
CI/CD / test_and_check_app (pull_request) Successful in 6m28s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-27 22:42:01 +02:00
Twyco 8aa7bf6860 Merge pull request 'KAN-65: Fix DMG packaging without Finder automation' (#159) from fix/KAN-65-headless-dmg-build into main
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Successful in 3m13s
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m30s
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Native JavaScript checks / Native launcher and release tests (push) Canceled after 0s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 21s
Reviewed-on: #159
2026-09-27 20:13:19 +00:00
Twyco ba9c4fd2e8 fix(KAN-65): package release DMGs without Finder automation
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m5s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m39s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 5m33s
2026-09-27 22:09:09 +02:00
Twyco 74bcf5d30c Merge pull request 'KAN-65: macOS DMG distribution and signed app updates' (#158) from feature/KAN-65-macos-app-updates into main
CI/CD / preconditions (push) Successful in 2s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m0s
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m30s
CI/CD / test_and_check_app (push) Successful in 6m30s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 21s
CI/CD / Publish immutable image (push) Successful in 1m21s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Failing after 3m3s
Reviewed-on: #158
2026-09-27 19:52:50 +00:00
Twyco 20f9314659 feat(KAN-65): add signed macOS updates and DMG distribution
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m0s
CI/CD / preconditions (pull_request) Successful in 2s
CI/CD / test_and_check_app (pull_request) Successful in 7m2s
CI/CD / Publish immutable image (pull_request) Skipped
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m35s
2026-09-27 21:40:23 +02:00
31 changed files with 3003 additions and 144 deletions
+59 -3
View File
@@ -12,6 +12,14 @@ on:
description: "Existing protected macos-vMAJOR.MINOR.PATCH tag on main" description: "Existing protected macos-vMAJOR.MINOR.PATCH tag on main"
required: true required: true
type: string type: string
mode:
description: "Build a release or resume publishing existing verified artifacts"
required: true
default: build
type: choice
options:
- build
- resume
permissions: permissions:
contents: read contents: read
@@ -49,8 +57,10 @@ jobs:
env: env:
REQUESTED_TAG: ${{ inputs.tag }} REQUESTED_TAG: ${{ inputs.tag }}
MACOS_RUNNER: ${{ vars.MACOS_RUNNER }} MACOS_RUNNER: ${{ vars.MACOS_RUNNER }}
RELEASE_MODE: ${{ inputs.mode || 'build' }}
run: | run: |
set -Eeuo pipefail set -Eeuo pipefail
[[ "${RELEASE_MODE:-build}" == build || "${RELEASE_MODE:-build}" == resume ]] || { echo 'Invalid release mode.' >&2; exit 1; }
if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then
[[ "$GITHUB_REF" == refs/heads/main ]] || { echo 'Dispatch this workflow from main.' >&2; exit 1; } [[ "$GITHUB_REF" == refs/heads/main ]] || { echo 'Dispatch this workflow from main.' >&2; exit 1; }
tag="$REQUESTED_TAG" tag="$REQUESTED_TAG"
@@ -80,6 +90,7 @@ jobs:
release: release:
name: Sign, notarize, verify and publish macOS name: Sign, notarize, verify and publish macOS
needs: prepare needs: prepare
if: ${{ inputs.mode != 'resume' }}
# Gitea resolves runner labels before outputs from prepare are available. # Gitea resolves runner labels before outputs from prepare are available.
runs-on: ${{ vars.MACOS_RUNNER }} runs-on: ${{ vars.MACOS_RUNNER }}
timeout-minutes: 120 timeout-minutes: 120
@@ -98,6 +109,7 @@ jobs:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
S3_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }} S3_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
S3_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }} S3_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
run: | run: |
@@ -110,7 +122,7 @@ jobs:
xcodebuild -version xcodebuild -version
xcrun --find notarytool xcrun --find notarytool
aws --version | grep '^aws-cli/2\.' || { echo 'Install AWS CLI v2 permanently on this runner.' >&2; exit 1; } aws --version | grep '^aws-cli/2\.' || { echo 'Install AWS CLI v2 permanently on this runner.' >&2; exit 1; }
for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_PASSWORD S3_ACCESS_KEY_ID S3_SECRET_ACCESS_KEY; do for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_PASSWORD TAURI_SIGNING_PRIVATE_KEY S3_ACCESS_KEY_ID S3_SECRET_ACCESS_KEY; do
[[ -n "${!name}" ]] || { echo "Missing repository secret: $name" >&2; exit 1; } [[ -n "${!name}" ]] || { echo "Missing repository secret: $name" >&2; exit 1; }
done done
@@ -175,14 +187,19 @@ jobs:
- name: Build, sign and notarize with Tauri - name: Build, sign and notarize with Tauri
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0 uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0
env: env:
# The DMG bundler reads these variables separately from --ci.
CI: "true"
TAURI_BUNDLER_DMG_IGNORE_CI: "false"
APPLE_ID: dev@petsch-studios.de APPLE_ID: dev@petsch-studios.de
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with: with:
projectPath: native projectPath: native
tauriScript: pnpm exec tauri tauriScript: pnpm exec tauri
args: >- args: >-
--ci --target ${{ env.MACOS_RELEASE_TARGET }} --bundles app --ci --target ${{ env.MACOS_RELEASE_TARGET }} --bundles app,dmg
--config '{"bundle":{"createUpdaterArtifacts":false,"macOS":{"hardenedRuntime":true}}}' --config '{"bundle":{"createUpdaterArtifacts":true,"macOS":{"hardenedRuntime":true}}}'
-- --locked -- --locked
uploadUpdaterJson: false uploadUpdaterJson: false
uploadUpdaterSignatures: false uploadUpdaterSignatures: false
@@ -218,3 +235,42 @@ jobs:
finally: finally:
shutil.rmtree(directory) shutil.rmtree(directory)
PY PY
resume:
name: Resume verified release publication
needs: prepare
if: ${{ github.event_name == 'workflow_dispatch' && inputs.mode == 'resume' }}
runs-on: ${{ vars.MACOS_RUNNER }}
timeout-minutes: 15
env:
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
RELEASE_METADATA_DIRECTORY: release-source/native
steps:
- name: Check out current publishing tools
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.sha }}
persist-credentials: false
- name: Check out validated release metadata
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ needs.prepare.outputs.commit }}
path: release-source
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
- name: Verify existing artifacts and resume publication
env:
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
AWS_EC2_METADATA_DISABLED: "true"
AWS_REQUEST_CHECKSUM_CALCULATION: when_required
AWS_RESPONSE_CHECKSUM_VALIDATION: when_required
run: |
aws --version | grep '^aws-cli/2\.' || { echo 'Install AWS CLI v2 permanently on this runner.' >&2; exit 1; }
node native/publish-macos.mjs resume
+81 -17
View File
@@ -2,6 +2,8 @@ import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { import {
appendFileSync, appendFileSync,
copyFileSync,
readlinkSync,
existsSync, existsSync,
mkdirSync, mkdirSync,
mkdtempSync, mkdtempSync,
@@ -12,6 +14,7 @@ import {
} from 'node:fs'; } from 'node:fs';
import { dirname, join, resolve } from 'node:path'; import { dirname, join, resolve } from 'node:path';
import process from 'node:process'; import process from 'node:process';
import { updateSource } from './update-manifest.mjs';
import { fileURLToPath, pathToFileURL } from 'node:url'; import { fileURLToPath, pathToFileURL } from 'node:url';
import { import {
appleTeamId, appleTeamId,
@@ -28,9 +31,10 @@ export function releaseMetadata(
const config = JSON.parse( const config = JSON.parse(
readFileSync(join(directory, 'src-tauri/tauri.conf.json'), 'utf8'), readFileSync(join(directory, 'src-tauri/tauri.conf.json'), 'utf8'),
); );
const packageVersion = JSON.parse( const nativePackage = JSON.parse(
readFileSync(join(directory, 'package.json'), 'utf8'), readFileSync(join(directory, 'package.json'), 'utf8'),
).version; );
const packageVersion = nativePackage.version;
const cargoVersion = readFileSync( const cargoVersion = readFileSync(
join(directory, 'src-tauri/Cargo.toml'), join(directory, 'src-tauri/Cargo.toml'),
'utf8', 'utf8',
@@ -59,6 +63,23 @@ export function releaseMetadata(
'The macOS product name and bundle identifier must stay unchanged.', 'The macOS product name and bundle identifier must stay unchanged.',
); );
} }
if (config.plugins?.updater?.pubkey !== updateSource.publicKey) {
throw new Error(
'The app updater key must match the permanent release key.',
);
}
if (
!/^\d+(?:\.\d+){0,2}$/.test(config.bundle?.macOS?.bundleVersion || '')
) {
throw new Error(
'Set a numeric macOS bundleVersion for the About build number.',
);
}
if (!nativePackage.releaseNotes?.trim()) {
throw new Error(
'Add releaseNotes to native/package.json before publishing.',
);
}
const target = env.MACOS_RELEASE_TARGET || 'aarch64-apple-darwin'; const target = env.MACOS_RELEASE_TARGET || 'aarch64-apple-darwin';
const arch = { const arch = {
'aarch64-apple-darwin': 'arm64', 'aarch64-apple-darwin': 'arm64',
@@ -81,9 +102,11 @@ export function releaseMetadata(
} }
return { return {
version: config.version, version: config.version,
notes: nativePackage.releaseNotes,
target, target,
arch, arch,
filename: `KanbanGit-${config.version}-${target}.zip`, filename: `KanbanGit-${config.version}-${target}.dmg`,
updaterFilename: `KanbanGit-${config.version}-${target}.app.tar.gz`,
}; };
} }
@@ -146,25 +169,65 @@ export function packageNotarizedApp({
const staging = mkdtempSync(join(releaseRoot, '.pending-')); const staging = mkdtempSync(join(releaseRoot, '.pending-'));
try { try {
const archive = join(staging, metadata.filename); const archive = join(staging, metadata.filename);
run('ditto', [ const bundle = join(root, metadata.target, 'release/bundle');
'-c', copyFileSync(
'-k', join(
'--sequesterRsrc', bundle,
'--keepParent', 'dmg',
app, `KanbanGit_${metadata.version}_${metadata.arch === 'arm64' ? 'aarch64' : 'x64'}.dmg`,
),
archive, archive,
);
const mounted = join(staging, 'mounted');
mkdirSync(mounted);
run('hdiutil', ['verify', archive]);
run('hdiutil', [
'attach',
archive,
'-readonly',
'-nobrowse',
'-mountpoint',
mounted,
]); ]);
try {
if (
readlinkSync(join(mounted, 'Applications')) !== '/Applications'
) {
throw new Error(
'DMG must contain the Applications installation link.',
);
}
verifyNotarizedApp(join(mounted, 'KanbanGit.app'), metadata, run);
} finally {
run('hdiutil', ['detach', mounted]);
}
rmSync(mounted, { recursive: true, force: true });
const updateArchive = join(staging, metadata.updaterFilename);
copyFileSync(join(bundle, 'macos/KanbanGit.app.tar.gz'), updateArchive);
copyFileSync(
join(bundle, 'macos/KanbanGit.app.tar.gz.sig'),
`${updateArchive}.sig`,
);
if (!readFileSync(`${updateArchive}.sig`, 'utf8').trim()) {
throw new Error('The updater signature is missing.');
}
const extracted = join(staging, 'extracted');
mkdirSync(extracted);
run('tar', ['-xzf', updateArchive, '-C', extracted]);
verifyNotarizedApp(join(extracted, 'KanbanGit.app'), metadata, run);
rmSync(extracted, { recursive: true, force: true });
const checksum = createHash('sha256') const checksum = createHash('sha256')
.update(readFileSync(archive)) .update(readFileSync(archive))
.digest('hex'); .digest('hex');
writeFileSync( for (const name of [metadata.filename, metadata.updaterFilename]) {
`${archive}.sha256`, const hash = createHash('sha256')
`${checksum} ${metadata.filename}\n`, .update(readFileSync(join(staging, name)))
); .digest('hex');
const extracted = join(staging, 'extracted'); writeFileSync(
run('ditto', ['-x', '-k', archive, extracted]); join(staging, `${name}.sha256`),
verifyNotarizedApp(join(extracted, 'KanbanGit.app'), metadata, run); `${hash} ${name}\n`,
rmSync(extracted, { recursive: true, force: true }); );
}
renameSync(staging, destination); renameSync(staging, destination);
return { ...metadata, directory: destination, checksum }; return { ...metadata, directory: destination, checksum };
} finally { } finally {
@@ -191,6 +254,7 @@ if (
appendFileSync( appendFileSync(
process.env.GITHUB_OUTPUT, process.env.GITHUB_OUTPUT,
Object.entries(output) Object.entries(output)
.filter(([key]) => key !== 'notes')
.map(([key, value]) => `${key}=${value}\n`) .map(([key, value]) => `${key}=${value}\n`)
.join(''), .join(''),
); );
+25
View File
@@ -0,0 +1,25 @@
import { mkdirSync, rmSync, symlinkSync } from 'node:fs';
import { dirname, join } from 'node:path';
export function createDiskImage({ app, archive, identity, run }) {
const contents = join(dirname(archive), 'dmg-contents');
mkdirSync(contents);
try {
run('ditto', [app, join(contents, 'KanbanGit.app')]);
symlinkSync('/Applications', join(contents, 'Applications'));
run('hdiutil', [
'create',
'-volname',
'KanbanGit',
'-srcfolder',
contents,
'-format',
'UDZO',
archive,
]);
run('codesign', ['--sign', identity, '--timestamp', archive]);
run('hdiutil', ['verify', archive]);
} finally {
rmSync(contents, { recursive: true, force: true });
}
}
+5 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "kanbangit-native", "name": "kanbangit-native",
"version": "0.1.1", "version": "0.1.4",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -18,9 +18,11 @@
"build:ios:device": "tauri ios build --debug --target aarch64 --export-method debugging --archive-only", "build:ios:device": "tauri ios build --debug --target aarch64 --export-method debugging --archive-only",
"open:ios": "tauri ios dev --open --no-dev-server", "open:ios": "tauri ios dev --open --no-dev-server",
"prepare:ios": "xcodegen generate --spec src-tauri/gen/apple/project.yml", "prepare:ios": "xcodegen generate --spec src-tauri/gen/apple/project.yml",
"test:ios": "xcodebuild -project src-tauri/gen/apple/kanbangit-native.xcodeproj -scheme kanbangit-native_iOS -configuration debug -destination \"platform=iOS Simulator,id=${IOS_SIMULATOR_ID:?Set IOS_SIMULATOR_ID to an installed simulator UUID}\" -parallel-testing-enabled NO CODE_SIGNING_ALLOWED=NO test" "test:ios": "xcodebuild -project src-tauri/gen/apple/kanbangit-native.xcodeproj -scheme kanbangit-native_iOS -configuration debug -destination \"platform=iOS Simulator,id=${IOS_SIMULATOR_ID:?Set IOS_SIMULATOR_ID to an installed simulator UUID}\" -parallel-testing-enabled NO CODE_SIGNING_ALLOWED=NO test",
"smoke:updater": "cargo test --manifest-path src-tauri/Cargo.toml --locked --features smoke-test,custom-protocol --test updater_smoke"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "2.11.4" "@tauri-apps/cli": "2.11.4"
} },
"releaseNotes": "About KanbanGit and Software updates show the installed app build number correctly. Release publishing now handles Hetzner responses with empty error messages when updating the feed."
} }
+213 -17
View File
@@ -13,6 +13,12 @@ import process from 'node:process';
import { setTimeout } from 'node:timers/promises'; import { setTimeout } from 'node:timers/promises';
import { pathToFileURL } from 'node:url'; import { pathToFileURL } from 'node:url';
import { execute, releaseMetadata } from './macos-ci.mjs'; import { execute, releaseMetadata } from './macos-ci.mjs';
import {
compareVersions,
updateManifest,
updateSource,
verifyUpdateSignature,
} from './update-manifest.mjs';
export function storageConfiguration(env = process.env) { export function storageConfiguration(env = process.env) {
const region = env.S3_REGION; const region = env.S3_REGION;
@@ -57,6 +63,31 @@ export function storageConfiguration(env = process.env) {
const sha256 = (data) => createHash('sha256').update(data).digest('hex'); const sha256 = (data) => createHash('sha256').update(data).digest('hex');
export function conditionalEtag(etag) {
const value =
typeof etag === 'string' && etag.startsWith('"') && etag.endsWith('"')
? etag.slice(1, -1)
: etag;
if (
typeof value !== 'string' ||
!/^[a-f\d]{32}(?:-[1-9]\d*)?$/i.test(value)
) {
throw new Error(
'Cannot atomically update the feed without a valid S3 ETag.',
);
}
// Hetzner returns quoted ETags on GET but compares bare values on conditional PUT.
return value;
}
function requiresExistingObjectCheck(error) {
// AWS CLI can crash while formatting Hetzner's empty error Message.
// This only permits a readback; content checks and conditional writes still apply.
return /\(PreconditionFailed\)|\(412\)|argument of type 'NoneType' is not (?:a container or )?iterable/.test(
error.message,
);
}
export async function publishMacos({ export async function publishMacos({
env = process.env, env = process.env,
directory, directory,
@@ -64,8 +95,15 @@ export async function publishMacos({
run = execute, run = execute,
fetchPublic = fetch, fetchPublic = fetch,
wait = setTimeout, wait = setTimeout,
publicKey = updateSource.publicKey,
feedBase = updateSource.baseUrl,
} = {}) { } = {}) {
const storage = storageConfiguration(env); const storage = storageConfiguration(env);
if (`${storage.publicBase}/${storage.prefix}` !== feedBase) {
throw new Error(
'Release storage does not match the update source bundled in the app.',
);
}
if (!env.AWS_ACCESS_KEY_ID || !env.AWS_SECRET_ACCESS_KEY) { if (!env.AWS_ACCESS_KEY_ID || !env.AWS_SECRET_ACCESS_KEY) {
throw new Error( throw new Error(
'Set the dedicated S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY release secrets.', 'Set the dedicated S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY release secrets.',
@@ -79,6 +117,21 @@ export async function publishMacos({
if (checksumFile.toString() !== `${checksum} ${metadata.filename}\n`) { if (checksumFile.toString() !== `${checksum} ${metadata.filename}\n`) {
throw new Error('Release archive does not match its SHA-256 file.'); throw new Error('Release archive does not match its SHA-256 file.');
} }
const updater = readFileSync(join(directory, metadata.updaterFilename));
const signature = readFileSync(
join(directory, `${metadata.updaterFilename}.sig`),
'utf8',
);
verifyUpdateSignature(updater, signature, publicKey);
const updateChecksum = readFileSync(
join(directory, `${metadata.updaterFilename}.sha256`),
);
if (
updateChecksum.toString() !==
`${sha256(updater)} ${metadata.updaterFilename}\n`
) {
throw new Error('Updater archive does not match its SHA-256 file.');
}
const common = [ const common = [
'--endpoint-url', '--endpoint-url',
storage.endpoint, storage.endpoint,
@@ -88,13 +141,21 @@ export async function publishMacos({
's3api', 's3api',
]; ];
function s3(operation, args) { function s3(operation, args) {
return run('aws', [ try {
...common, return run('aws', [
operation, ...common,
'--bucket', operation,
storage.bucket, '--bucket',
...args, storage.bucket,
]); ...args,
]);
} catch (error) {
const keyIndex = args.indexOf('--key');
const key = keyIndex >= 0 ? ` ${args[keyIndex + 1]}` : '';
throw new Error(`S3 ${operation}${key}: ${error.message}`, {
cause: error,
});
}
} }
const versioningOutput = s3('get-bucket-versioning', [ const versioningOutput = s3('get-bucket-versioning', [
'--output', '--output',
@@ -124,11 +185,11 @@ export async function publishMacos({
const staging = mkdtempSync(join(tmpdir(), 'kanbangit-s3-')); const staging = mkdtempSync(join(tmpdir(), 'kanbangit-s3-'));
const keyPrefix = `${storage.prefix}/${metadata.target}/${metadata.version}`; const keyPrefix = `${storage.prefix}/${metadata.target}/${metadata.version}`;
const publicPrefix = `${storage.publicBase}/${keyPrefix}`; const publicPrefix = `${storage.publicBase}/${keyPrefix}`;
async function verifyPublic(name, contents) { async function verifyPublic(name, contents, prefix = publicPrefix) {
for (let attempt = 1; attempt <= 5; attempt++) { for (let attempt = 1; attempt <= 5; attempt++) {
let response; let response;
try { try {
response = await fetchPublic(`${publicPrefix}/${name}`, { response = await fetchPublic(`${prefix}/${name}`, {
redirect: 'error', redirect: 'error',
signal: AbortSignal.timeout(60000), signal: AbortSignal.timeout(60000),
}); });
@@ -185,7 +246,7 @@ export async function publishMacos({
`sha256=${sha256(contents)}`, `sha256=${sha256(contents)}`,
]); ]);
} catch (error) { } catch (error) {
if (!/\(PreconditionFailed\)|\(412\)/.test(error.message)) { if (!requiresExistingObjectCheck(error)) {
throw error; throw error;
} }
const existing = join(staging, `existing-${name}`); const existing = join(staging, `existing-${name}`);
@@ -199,7 +260,18 @@ export async function publishMacos({
} }
try { try {
const files = [ const files = [
[metadata.filename, archive, 'application/zip'], [metadata.filename, archive, 'application/x-apple-diskimage'],
[metadata.updaterFilename, updater, 'application/gzip'],
[
`${metadata.updaterFilename}.sig`,
Buffer.from(signature),
'text/plain; charset=utf-8',
],
[
`${metadata.updaterFilename}.sha256`,
updateChecksum,
'text/plain; charset=utf-8',
],
[ [
`${metadata.filename}.sha256`, `${metadata.filename}.sha256`,
checksumFile, checksumFile,
@@ -210,7 +282,7 @@ export async function publishMacos({
putImmutable(name, contents, type); putImmutable(name, contents, type);
await verifyPublic(name, contents); await verifyPublic(name, contents);
} }
// This completion record is written only after both public downloads match. // Completion metadata is written only after every public artifact matches.
const manifest = Buffer.from( const manifest = Buffer.from(
JSON.stringify( JSON.stringify(
{ {
@@ -226,12 +298,128 @@ export async function publishMacos({
); );
putImmutable('release.json', manifest, 'application/json'); putImmutable('release.json', manifest, 'application/json');
await verifyPublic('release.json', manifest); await verifyPublic('release.json', manifest);
const latest = Buffer.from(
JSON.stringify(
updateManifest(
metadata,
publicPrefix,
signature,
metadata.notes,
),
null,
2,
) + '\n',
);
putImmutable('update.json', latest, 'application/json');
await verifyPublic('update.json', latest);
const latestPrefix = `${storage.prefix}/${metadata.target}`;
const latestKey = `${latestPrefix}/latest.json`;
const latestFile = join(staging, 'latest.json');
writeFileSync(latestFile, latest);
function publishLatest(condition) {
s3('put-object', [
'--key',
latestKey,
'--body',
latestFile,
...condition,
'--content-type',
'application/json',
'--cache-control',
'no-cache, max-age=0, must-revalidate',
'--content-md5',
createHash('md5').update(latest).digest('base64'),
]);
}
try {
publishLatest(['--if-none-match', '*']);
} catch (error) {
if (!requiresExistingObjectCheck(error)) {
throw error;
}
const previousFile = join(staging, 'previous-latest.json');
const response = JSON.parse(
s3('get-object', [
'--key',
latestKey,
'--output',
'json',
previousFile,
]),
);
const previousBytes = readFileSync(previousFile);
const previous = JSON.parse(previousBytes);
const comparison = compareVersions(
metadata.version,
previous.version,
);
if (comparison < 0) {
throw new Error('Refusing to downgrade the update feed.');
}
if (comparison === 0) {
if (!previousBytes.equals(latest)) {
throw new Error(
'Published update metadata differs; bump the app version.',
);
}
} else {
publishLatest(['--if-match', conditionalEtag(response.ETag)]);
}
}
await verifyPublic(
'latest.json',
latest,
`${storage.publicBase}/${latestPrefix}`,
);
return `${publicPrefix}/${metadata.filename}`; return `${publicPrefix}/${metadata.filename}`;
} finally { } finally {
rmSync(staging, { recursive: true, force: true }); rmSync(staging, { recursive: true, force: true });
} }
} }
export async function resumeMacos(options = {}) {
const env = options.env ?? process.env;
const metadata = options.metadata ?? releaseMetadata(env);
const storage = storageConfiguration(env);
const feedBase = options.feedBase ?? updateSource.baseUrl;
if (`${storage.publicBase}/${storage.prefix}` !== feedBase) {
throw new Error(
'Release storage does not match the update source bundled in the app.',
);
}
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-resume-'));
const fetchPublic = options.fetchPublic ?? fetch;
const prefix = `${feedBase}/${metadata.target}/${metadata.version}`;
try {
for (const name of [
metadata.filename,
`${metadata.filename}.sha256`,
metadata.updaterFilename,
`${metadata.updaterFilename}.sha256`,
`${metadata.updaterFilename}.sig`,
]) {
const response = await fetchPublic(`${prefix}/${name}`, {
redirect: 'error',
signal: AbortSignal.timeout(60000),
});
if (!response.ok) {
await response.body?.cancel();
throw new Error(
`Cannot resume incomplete release: ${name} HTTP ${response.status}.`,
);
}
writeFileSync(
join(directory, name),
Buffer.from(await response.arrayBuffer()),
);
}
// Reuse signature, checksum, immutable content and feed concurrency checks.
return await publishMacos({ ...options, env, metadata, directory });
} finally {
rmSync(directory, { recursive: true, force: true });
}
}
if ( if (
process.argv[1] && process.argv[1] &&
import.meta.url === pathToFileURL(resolve(process.argv[1])).href import.meta.url === pathToFileURL(resolve(process.argv[1])).href
@@ -240,12 +428,20 @@ if (
if (process.argv[2] === 'config') { if (process.argv[2] === 'config') {
console.log(JSON.stringify(storageConfiguration())); console.log(JSON.stringify(storageConfiguration()));
} else if ( } else if (
process.argv[2] === 'upload' && process.argv[2] === 'resume' ||
process.env.RELEASE_DIRECTORY (process.argv[2] === 'upload' && process.env.RELEASE_DIRECTORY)
) { ) {
const url = await publishMacos({ const publish =
process.argv[2] === 'resume' ? resumeMacos : publishMacos;
const url = await publish({
directory: process.env.RELEASE_DIRECTORY, directory: process.env.RELEASE_DIRECTORY,
metadata: releaseMetadata(), metadata: releaseMetadata(
process.env,
process.argv[2] === 'resume' &&
process.env.RELEASE_METADATA_DIRECTORY
? resolve(process.env.RELEASE_METADATA_DIRECTORY)
: undefined,
),
}); });
console.log(`Verified public release: ${url}`); console.log(`Verified public release: ${url}`);
if (process.env.GITHUB_STEP_SUMMARY) { if (process.env.GITHUB_STEP_SUMMARY) {
@@ -256,7 +452,7 @@ if (
} }
} else { } else {
throw new Error( throw new Error(
'Use config or upload; upload requires RELEASE_DIRECTORY.', 'Use config, upload or resume; upload requires RELEASE_DIRECTORY.',
); );
} }
} catch (error) { } catch (error) {
+35 -7
View File
@@ -13,6 +13,7 @@ import { dirname, join, resolve } from 'node:path';
import process from 'node:process'; import process from 'node:process';
import { fileURLToPath, pathToFileURL } from 'node:url'; import { fileURLToPath, pathToFileURL } from 'node:url';
import { parseArgs } from 'node:util'; import { parseArgs } from 'node:util';
import { createDiskImage } from './macos-dmg.mjs';
export const appleTeamId = 'ZZX2KZGTHU'; export const appleTeamId = 'ZZX2KZGTHU';
export const bundleIdentifier = 'de.kanbangit.desktop'; export const bundleIdentifier = 'de.kanbangit.desktop';
@@ -105,6 +106,11 @@ export function releaseMacos({
throw new Error('--resume requires the Apple submission UUID.'); throw new Error('--resume requires the Apple submission UUID.');
} }
if (!env.TAURI_SIGNING_PRIVATE_KEY?.trim()) {
throw new Error(
'Set TAURI_SIGNING_PRIVATE_KEY to the permanent updater signing key.',
);
}
const identity = env.APPLE_SIGNING_IDENTITY?.trim(); const identity = env.APPLE_SIGNING_IDENTITY?.trim();
const profile = env.APPLE_NOTARY_KEYCHAIN_PROFILE?.trim(); const profile = env.APPLE_NOTARY_KEYCHAIN_PROFILE?.trim();
@@ -405,20 +411,42 @@ export function releaseMacos({
app, app,
]); ]);
const name = `KanbanGit-${config.version}-${target}.zip`; const name = `KanbanGit-${config.version}-${target}.dmg`;
const archive = join(staging, name); const archive = join(staging, name);
step('Packaging notarized download', 'ditto', [ createDiskImage({
'-c',
'-k',
'--sequesterRsrc',
'--keepParent',
app, app,
archive, archive,
]); identity,
run: (command, args) =>
step('Packaging notarized download', command, args),
});
const checksum = createHash('sha256') const checksum = createHash('sha256')
.update(readFileSync(archive)) .update(readFileSync(archive))
.digest('hex'); .digest('hex');
writeFileSync(`${archive}.sha256`, `${checksum} ${name}\n`); writeFileSync(`${archive}.sha256`, `${checksum} ${name}\n`);
const updaterName = `KanbanGit-${config.version}-${target}.app.tar.gz`;
const updaterArchive = join(staging, updaterName);
step('Packaging updater archive', 'tar', [
'-czf',
updaterArchive,
'-C',
dirname(app),
'KanbanGit.app',
]);
step('Signing updater archive', 'pnpm', [
'exec',
'tauri',
'signer',
'sign',
updaterArchive,
]);
const updaterChecksum = createHash('sha256')
.update(readFileSync(updaterArchive))
.digest('hex');
writeFileSync(
`${updaterArchive}.sha256`,
`${updaterChecksum} ${updaterName}\n`,
);
rmSync(submission, { force: true }); rmSync(submission, { force: true });
renameSync(staging, destination); renameSync(staging, destination);
log(`Verified release: ${join(destination, name)}`); log(`Verified release: ${join(destination, name)}`);
+570 -38
View File
File diff suppressed because it is too large Load Diff
+12 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "kanbangit-native" name = "kanbangit-native"
version = "0.1.1" version = "0.1.4"
description = "KanbanGit native application" description = "KanbanGit native application"
edition = "2021" edition = "2021"
publish = false publish = false
@@ -13,6 +13,9 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
semver = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = { version = "2", features = [] } tauri = { version = "2", features = [] }
[dev-dependencies] [dev-dependencies]
@@ -27,3 +30,11 @@ custom-protocol = ["tauri/custom-protocol"]
name = "macos_smoke" name = "macos_smoke"
harness = false harness = false
required-features = ["smoke-test"] required-features = ["smoke-test"]
[target.'cfg(target_os = "macos")'.dependencies]
tauri-plugin-updater = "=2.12.0"
[[test]]
name = "updater_smoke"
harness = false
required-features = ["smoke-test"]
+63 -2
View File
@@ -2,7 +2,7 @@ use crate::locale::en;
use crate::server::{validate_url, Server, DEFAULT_SERVER}; use crate::server::{validate_url, Server, DEFAULT_SERVER};
use std::{path::PathBuf, sync::Mutex, time::Duration}; use std::{path::PathBuf, sync::Mutex, time::Duration};
use tauri::{ use tauri::{
menu::{MenuBuilder, SubmenuBuilder}, menu::{AboutMetadata, MenuBuilder, MenuItem, SubmenuBuilder},
webview::{NewWindowResponse, PageLoadEvent}, webview::{NewWindowResponse, PageLoadEvent},
AppHandle, Manager, Url, WebviewUrl, WebviewWindowBuilder, WindowEvent, AppHandle, Manager, Url, WebviewUrl, WebviewWindowBuilder, WindowEvent,
}; };
@@ -247,6 +247,11 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
fn handle_launcher_action(app: &AppHandle, destination: &Url) { fn handle_launcher_action(app: &AppHandle, destination: &Url) {
let result = match destination.path() { let result = match destination.path() {
"/action/updates" => {
#[cfg(target_os = "macos")]
crate::updates::show(app);
Ok(())
}
"/action/open" => { "/action/open" => {
let input = destination let input = destination
.query_pairs() .query_pairs()
@@ -280,6 +285,16 @@ pub fn context<R: tauri::Runtime>() -> tauri::Context<R> {
tauri::generate_context!() tauri::generate_context!()
} }
fn about_metadata(version: String) -> AboutMetadata<'static> {
AboutMetadata {
name: Some(en::APP_NAME.into()),
version: Some(version),
#[cfg(target_os = "macos")]
short_version: Some(crate::metadata::build_number().into()),
..Default::default()
}
}
pub fn builder() -> tauri::Builder<tauri::Wry> { pub fn builder() -> tauri::Builder<tauri::Wry> {
tauri::Builder::default().setup(|app| { tauri::Builder::default().setup(|app| {
let (server, phase) = match Server::load(&configuration_path(app.handle())?) { let (server, phase) = match Server::load(&configuration_path(app.handle())?) {
@@ -294,6 +309,19 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
let menu = MenuBuilder::new(app) let menu = MenuBuilder::new(app)
.item( .item(
&SubmenuBuilder::new(app, en::APP_NAME) &SubmenuBuilder::new(app, en::APP_NAME)
.about_with_text(
en::ABOUT,
Some(about_metadata(app.package_info().version.to_string())),
)
.text("check-updates", en::CHECK_UPDATES)
.item(&MenuItem::with_id(
app,
"install-update",
en::INSTALL_UPDATE,
false,
None::<&str>,
)?)
.separator()
.text("server-settings", en::SERVER_SETTINGS) .text("server-settings", en::SERVER_SETTINGS)
.separator() .separator()
.quit() .quit()
@@ -318,7 +346,21 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
) )
.build()?; .build()?;
app.set_menu(menu)?; app.set_menu(menu)?;
#[cfg(target_os = "macos")]
crate::updates::setup(app.handle())?;
app.on_menu_event(|app, event| { app.on_menu_event(|app, event| {
#[cfg(target_os = "macos")]
match event.id().as_ref() {
"check-updates" => {
crate::updates::check(app, true);
return;
}
"install-update" => {
crate::updates::show(app);
return;
}
_ => {}
}
if event.id().as_ref() == "server-settings" { if event.id().as_ref() == "server-settings" {
show_launcher(app); show_launcher(app);
} else if let Some(script) = match event.id().as_ref() { } else if let Some(script) = match event.id().as_ref() {
@@ -363,6 +405,8 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
if payload.event() == PageLoadEvent::Finished { if payload.event() == PageLoadEvent::Finished {
let app = window.app_handle().clone(); let app = window.app_handle().clone();
update_launcher(&app); update_launcher(&app);
#[cfg(target_os = "macos")]
crate::updates::refresh(&app);
std::thread::spawn(move || { std::thread::spawn(move || {
let handle = app.clone(); let handle = app.clone();
let _ = app.run_on_main_thread(move || { let _ = app.run_on_main_thread(move || {
@@ -400,6 +444,18 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
mod tests { mod tests {
use super::*; use super::*;
#[cfg(target_os = "macos")]
#[test]
fn about_shows_app_version_followed_by_the_actual_build_number() {
let metadata = about_metadata("9.8.7".into());
assert_eq!(metadata.version.as_deref(), Some("9.8.7"));
assert_eq!(
metadata.short_version.as_deref(),
Some(crate::metadata::build_number())
);
assert_ne!(metadata.version, metadata.short_version);
}
#[test] #[test]
fn external_links_accept_only_web_urls_without_credentials() { fn external_links_accept_only_web_urls_without_credentials() {
for input in [ for input in [
@@ -482,7 +538,10 @@ mod tests {
#[test] #[test]
fn remote_pages_cannot_invoke_native_commands() { fn remote_pages_cannot_invoke_native_commands() {
let app = tauri::test::mock_builder().build(context()).unwrap(); let builder = tauri::test::mock_builder();
#[cfg(target_os = "macos")]
let builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
let app = builder.build(context()).unwrap();
let window = WebviewWindowBuilder::new( let window = WebviewWindowBuilder::new(
&app, &app,
"workspace-1", "workspace-1",
@@ -491,6 +550,8 @@ mod tests {
.build() .build()
.unwrap(); .unwrap();
for command in [ for command in [
"plugin:updater|check",
"plugin:updater|download_and_install",
"plugin:window|close", "plugin:window|close",
"plugin:webview|create_webview_window", "plugin:webview|create_webview_window",
"plugin:path|resolve_directory", "plugin:path|resolve_directory",
+4
View File
@@ -6,8 +6,12 @@ mod desktop;
mod ios; mod ios;
#[cfg(not(any(target_os = "android", target_os = "ios")))] #[cfg(not(any(target_os = "android", target_os = "ios")))]
mod locale; mod locale;
#[cfg(target_os = "macos")]
mod metadata;
#[cfg(not(any(target_os = "android", target_os = "ios")))] #[cfg(not(any(target_os = "android", target_os = "ios")))]
mod server; mod server;
#[cfg(target_os = "macos")]
mod updates;
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
pub use android::{builder, context}; pub use android::{builder, context};
+5
View File
@@ -1,4 +1,9 @@
pub mod en { pub mod en {
pub const ABOUT: &str = "About KanbanGit";
pub const CHECK_UPDATES: &str = "Check For Updates…";
pub const INSTALL_UPDATE: &str = "Install Update…";
pub const UPDATE_BADGE: &str = "Update";
pub const UPDATES: &str = "KanbanGit Updates";
pub const APP_NAME: &str = "KanbanGit"; pub const APP_NAME: &str = "KanbanGit";
pub const SERVER_SETTINGS: &str = "Server settings…"; pub const SERVER_SETTINGS: &str = "Server settings…";
pub const NAVIGATION: &str = "Navigation"; pub const NAVIGATION: &str = "Navigation";
+69
View File
@@ -0,0 +1,69 @@
use std::{path::Path, sync::LazyLock};
fn build_from_executable(executable: &Path) -> Option<String> {
let macos = executable.parent()?;
let contents = macos.parent()?;
if macos.file_name()? != "MacOS" || contents.file_name()? != "Contents" {
return None;
}
let output = std::process::Command::new("/usr/bin/plutil")
.args(["-extract", "CFBundleVersion", "raw", "-o", "-"])
.arg(contents.join("Info.plist"))
.output()
.ok()?;
if !output.status.success() {
return Some("unknown".into());
}
let build = String::from_utf8(output.stdout).ok()?;
Some(build.trim().to_owned())
}
pub fn build_number() -> &'static str {
static BUILD: LazyLock<String> = LazyLock::new(|| {
std::env::current_exe()
.ok()
.and_then(|executable| build_from_executable(&executable))
.unwrap_or_else(|| {
// Tauri omits macOS bundle settings from its generated runtime config.
let config: serde_json::Value =
serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
config["bundle"]["macOS"]["bundleVersion"]
.as_str()
.unwrap_or("unknown")
.to_owned()
})
});
&BUILD
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn installed_build_comes_from_the_app_plist_instead_of_the_app_version() {
let root = std::env::temp_dir().join(format!("kanbangit-build-{}", std::process::id()));
let contents = root.join("KanbanGit.app/Contents");
std::fs::create_dir_all(contents.join("MacOS")).unwrap();
let executable = contents.join("MacOS/kanbangit-native");
std::fs::write(contents.join("Info.plist"), r#"<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict><key>CFBundleShortVersionString</key><string>0.1.3</string><key>CFBundleVersion</key><string>42</string></dict></plist>"#).unwrap();
assert_eq!(build_from_executable(&executable).as_deref(), Some("42"));
std::fs::remove_file(contents.join("Info.plist")).unwrap();
assert_eq!(
build_from_executable(&executable).as_deref(),
Some("unknown")
);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn unbundled_execution_uses_the_configured_build() {
assert!(build_from_executable(Path::new("/tmp/target/debug/kanbangit-native")).is_none());
let config: serde_json::Value =
serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
assert_eq!(
build_number(),
config["bundle"]["macOS"]["bundleVersion"].as_str().unwrap()
);
}
}
+793
View File
@@ -0,0 +1,793 @@
use semver::Version;
use serde::{Deserialize, Serialize};
use std::{
fs,
io::Read,
path::Path,
sync::Mutex,
time::{Duration, Instant},
};
use tauri::{
webview::{NewWindowResponse, PageLoadEvent},
AppHandle, Manager, Url, WebviewUrl, WebviewWindowBuilder,
};
use tauri_plugin_updater::{Update, UpdaterExt};
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Source {
base_url: String,
public_key: String,
}
fn source() -> &'static Source {
static SOURCE: std::sync::LazyLock<Source> = std::sync::LazyLock::new(|| {
serde_json::from_str(include_str!("../../update-source.json"))
.expect("Bundled update source must be valid")
});
&SOURCE
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct Preferences {
automatic: bool,
interval_hours: u64,
}
impl Default for Preferences {
fn default() -> Self {
Self {
automatic: true,
interval_hours: 6,
}
}
}
impl Preferences {
fn valid(&self) -> bool {
[1, 6, 24].contains(&self.interval_hours)
}
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct View {
phase: &'static str,
current_version: String,
build: String,
version: Option<String>,
notes: Option<String>,
downloaded: u64,
total: Option<u64>,
error: Option<&'static str>,
preferences: Preferences,
}
struct State {
view: View,
update: Option<Update>,
last_check: Option<Instant>,
}
impl State {
fn busy(&self) -> bool {
matches!(
self.view.phase,
"checking" | "downloading" | "verifying" | "installing" | "restarting"
)
}
fn begin_check(&mut self, manual: bool, now: Instant) -> bool {
if self.busy()
|| (!manual
&& (!self.view.preferences.automatic
|| self.last_check.is_some_and(|last| {
now.duration_since(last)
< Duration::from_secs(self.view.preferences.interval_hours * 3600)
})))
{
return false;
}
self.last_check = Some(now);
self.view.phase = "checking";
self.view.error = None;
self.update = None;
self.view.version = None;
self.view.notes = None;
true
}
}
fn target() -> String {
format!("{}-apple-darwin", std::env::consts::ARCH)
}
fn feed_url() -> Url {
Url::parse(&format!("{}/{}/latest.json", source().base_url, target())).unwrap()
}
fn newer_stable(current: &str, candidate: &str) -> bool {
match (Version::parse(current), Version::parse(candidate)) {
(Ok(current), Ok(candidate)) => {
candidate.pre.is_empty() && candidate.build.is_empty() && candidate > current
}
_ => false,
}
}
fn valid_download(current: &str, version: &str, url: &Url) -> bool {
newer_stable(current, version)
&& url.as_str()
== format!(
"{}/{}/{version}/KanbanGit-{version}-{}.app.tar.gz",
source().base_url,
target(),
target()
)
}
fn trusted_navigation(url: &Url) -> bool {
url.scheme() == "tauri"
&& url.host_str() == Some("localhost")
&& url.port().is_none()
&& url.username().is_empty()
&& url.password().is_none()
}
pub fn refresh(app: &AppHandle) {
let (view, available, busy) = {
let state = app.state::<Mutex<State>>();
let state = state.lock().unwrap();
(state.view.clone(), state.update.is_some(), state.busy())
};
if let Some(menu) = app.menu().and_then(|m| m.get("install-update")) {
if let Some(item) = menu.as_menuitem() {
let _ = item.set_enabled(available && !busy);
}
}
if let Some(window) = app.get_webview_window("updates") {
let _ = window.set_badge_label(available.then(|| crate::locale::en::UPDATE_BADGE.into()));
if let Ok(json) = serde_json::to_string(&view) {
let _ = window.eval(format!("window.updateStatus?.({json})"));
}
}
if let Some(window) = app.get_webview_window("launcher") {
let _ = window.eval(format!("window.updateAvailable?.({available})"));
}
}
pub fn show(app: &AppHandle) {
if let Some(window) = app.get_webview_window("updates") {
let _ = window.show();
let _ = window.set_focus();
}
refresh(app);
}
fn fail(app: &AppHandle, message: &'static str) {
{
let state = app.state::<Mutex<State>>();
let mut state = state.lock().unwrap();
state.view.phase = "error";
state.view.error = Some(message);
}
refresh(app);
}
fn update_error(error: &tauri_plugin_updater::Error) -> &'static str {
use tauri_plugin_updater::Error;
match error {
Error::Minisign(_)
| Error::Base64(_)
| Error::SignatureUtf8(_)
| Error::SignedVersionMismatch { .. }
| Error::MissingSignedVersion => "signatureError",
Error::Serialization(_)
| Error::Semver(_)
| Error::TargetsNotFound(_)
| Error::TargetNotFound(_) => "metadataError",
_ => "networkError",
}
}
pub fn check(app: &AppHandle, manual: bool) {
if manual {
show(app);
}
let begin = app
.state::<Mutex<State>>()
.lock()
.unwrap()
.begin_check(manual, Instant::now());
if !begin {
return;
}
refresh(app);
let app = app.clone();
tauri::async_runtime::spawn(async move {
let result = async {
app.updater_builder()
.pubkey(&source().public_key)
.endpoints(vec![feed_url()])?
.timeout(Duration::from_secs(60))
.configure_client(|client| client.https_only(true))
.version_comparator(|current, release| {
newer_stable(&current.to_string(), &release.version.to_string())
})
.build()?
.check()
.await
}
.await;
match result {
Ok(Some(mut update)) => {
if !valid_download(
&update.current_version,
&update.version,
&update.download_url,
) || update.signature.trim().is_empty()
{
fail(&app, "metadataError");
return;
}
update.timeout = Some(Duration::from_secs(600));
let state = app.state::<Mutex<State>>();
let mut state = state.lock().unwrap();
state.view.phase = "available";
state.view.version = Some(update.version.clone());
state.view.notes = update.body.clone();
state.update = Some(update);
}
Ok(None) => app.state::<Mutex<State>>().lock().unwrap().view.phase = "current",
Err(error) => {
fail(&app, update_error(&error));
return;
}
}
refresh(&app);
});
}
fn command(program: &str, args: &[&str]) -> Result<String, &'static str> {
let output = std::process::Command::new(program)
.args(args)
.output()
.map_err(|_| "verificationError")?;
if !output.status.success() {
return Err("verificationError");
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
}
fn matches_architecture(header: &[u8]) -> bool {
let expected_cpu: u32 = if cfg!(target_arch = "aarch64") {
0x0100_000c
} else {
0x0100_0007
};
header.len() >= 8
&& header[..4] == [0xcf, 0xfa, 0xed, 0xfe]
&& header[4..8] == expected_cpu.to_le_bytes()
}
fn verify_bundle(archive: &Path, directory: &Path, version: &str) -> Result<(), &'static str> {
command(
"/usr/bin/tar",
&[
"-xzf",
archive.to_str().ok_or("verificationError")?,
"-C",
directory.to_str().ok_or("verificationError")?,
],
)?;
let app = directory.join("KanbanGit.app");
let plist = app.join("Contents/Info.plist");
let plist = plist.to_str().ok_or("verificationError")?;
for (field, expected) in [
("CFBundleShortVersionString", version),
("CFBundleIdentifier", "de.kanbangit.desktop"),
] {
if command(
"/usr/bin/plutil",
&["-extract", field, "raw", "-o", "-", plist],
)? != expected
{
return Err("verificationError");
}
}
let executable = app.join("Contents/MacOS/kanbangit-native");
let mut header = [0u8; 8];
fs::File::open(executable)
.and_then(|mut file| file.read_exact(&mut header))
.map_err(|_| "verificationError")?;
if !matches_architecture(&header) {
return Err("verificationError");
}
command("/usr/bin/codesign", &["--verify", "--deep", "--strict", "-R", "=anchor apple generic and identifier \"de.kanbangit.desktop\" and certificate leaf[subject.OU] = \"ZZX2KZGTHU\" and certificate leaf[field.1.2.840.113635.100.6.1.13] exists", app.to_str().ok_or("verificationError")?])?;
Ok(())
}
fn install(app: &AppHandle, confirmed_version: Option<&str>) {
let update = {
let state = app.state::<Mutex<State>>();
let mut state = state.lock().unwrap();
if state.busy() {
return;
}
let Some(update) = state.update.clone() else {
return;
};
if confirmed_version != Some(update.version.as_str()) {
return;
}
state.view.phase = "downloading";
state.view.error = None;
state.view.downloaded = 0;
state.view.total = None;
update
};
refresh(app);
let app = app.clone();
tauri::async_runtime::spawn(async move {
let bytes = update
.download(
|chunk, total| {
{
let state = app.state::<Mutex<State>>();
let mut state = state.lock().unwrap();
state.view.downloaded += chunk as u64;
state.view.total = total;
}
refresh(&app);
},
|| {
app.state::<Mutex<State>>().lock().unwrap().view.phase = "verifying";
refresh(&app);
},
)
.await;
let bytes = match bytes {
Ok(bytes) => bytes,
Err(error) => {
fail(&app, update_error(&error));
return;
}
};
let handle = app.clone();
let result = tauri::async_runtime::spawn_blocking(move || -> Result<(), &'static str> {
let cache = handle
.path()
.app_cache_dir()
.map_err(|_| "installationError")?
.join("verified-update");
if cache.exists() {
fs::remove_dir_all(&cache).map_err(|_| "installationError")?;
}
fs::create_dir_all(&cache).map_err(|_| "installationError")?;
let archive = cache.join("update.tar.gz");
let result = (|| {
fs::write(&archive, &bytes).map_err(|_| "installationError")?;
verify_bundle(&archive, &cache, &update.version)?;
handle.state::<Mutex<State>>().lock().unwrap().view.phase = "installing";
refresh(&handle);
update.install(&bytes).map_err(|_| "installationError")
})();
let _ = fs::remove_dir_all(cache);
result
})
.await;
match result {
Ok(Ok(())) => {
app.state::<Mutex<State>>().lock().unwrap().view.phase = "restarting";
refresh(&app);
app.restart();
}
Ok(Err(message)) => fail(&app, message),
Err(_) => fail(&app, "installationError"),
}
});
}
fn action(app: &AppHandle, url: &Url) {
match url.path() {
"/updates/check" => check(app, true),
"/updates/install" => install(
app,
url.query_pairs()
.find(|(key, _)| key == "version")
.as_ref()
.map(|(_, value)| value.as_ref()),
),
"/updates/preferences" => {
let params: std::collections::HashMap<_, _> = url.query_pairs().collect();
let preferences = Preferences {
automatic: params.get("automatic").is_some_and(|v| v == "true"),
interval_hours: params
.get("interval")
.and_then(|v| v.parse().ok())
.unwrap_or(0),
};
if !preferences.valid() {
fail(app, "preferencesError");
return;
}
let saved = (|| -> Result<(), Box<dyn std::error::Error>> {
let directory = app.path().app_config_dir()?;
fs::create_dir_all(&directory)?;
let pending = directory.join("update-preferences.pending");
fs::write(&pending, serde_json::to_vec(&preferences)?)?;
fs::rename(pending, directory.join("update-preferences.json"))?;
Ok(())
})();
if saved.is_err() {
fail(app, "preferencesError");
return;
}
app.state::<Mutex<State>>().lock().unwrap().view.preferences = preferences;
refresh(app);
}
_ => {}
}
}
pub fn setup(app: &AppHandle) -> tauri::Result<()> {
app.plugin(
tauri_plugin_updater::Builder::new()
.pubkey(&source().public_key)
.build(),
)?;
let path = app.path().app_config_dir()?.join("update-preferences.json");
let (preferences, error) = match fs::read(path) {
Ok(bytes) => match serde_json::from_slice::<Preferences>(&bytes) {
Ok(p) if p.valid() => (p, None),
_ => (
Preferences {
automatic: false,
..Default::default()
},
Some("preferencesError"),
),
},
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
(Preferences::default(), None)
}
Err(_) => (
Preferences {
automatic: false,
..Default::default()
},
Some("preferencesError"),
),
};
app.manage(Mutex::new(State {
view: View {
phase: if error.is_some() { "error" } else { "idle" },
current_version: app.package_info().version.to_string(),
build: crate::metadata::build_number().into(),
version: None,
notes: None,
downloaded: 0,
total: None,
error,
preferences,
},
update: None,
last_check: None,
}));
let handle = app.clone();
let window = WebviewWindowBuilder::new(app, "updates", WebviewUrl::App("updates.html".into()))
.title(crate::locale::en::UPDATES)
.inner_size(560.0, 690.0)
.min_inner_size(420.0, 580.0)
.center()
.visible(false)
.on_navigation(move |url| {
if !trusted_navigation(url) {
return false;
}
if url.path().starts_with("/updates/") {
action(&handle, url);
return false;
}
url.path() == "/updates.html"
})
.on_new_window(|_, _| NewWindowResponse::Deny)
.on_page_load(|window, payload| {
if payload.event() == PageLoadEvent::Finished {
refresh(window.app_handle());
}
})
.build()?;
let hide = window.clone();
window.on_window_event(move |event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
let _ = hide.hide();
}
});
let handle = app.clone();
std::thread::spawn(move || loop {
check(&handle, false);
std::thread::sleep(Duration::from_secs(30));
});
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn state() -> State {
State {
view: View {
phase: "idle",
current_version: "1.0.0".into(),
build: "1".into(),
version: None,
notes: None,
downloaded: 0,
total: None,
error: None,
preferences: Preferences::default(),
},
update: None,
last_check: None,
}
}
#[test]
fn accepts_only_newer_stable_versions_and_exact_release_paths() {
assert!(newer_stable("1.9.9", "1.10.0"));
for version in ["0.9.0", "1.0.0", "1.2.0-beta", "1.2.0+build", "invalid"] {
assert!(!newer_stable("1.0.0", version));
}
let good = format!(
"{}/{}/1.1.0/KanbanGit-1.1.0-{}.app.tar.gz",
source().base_url,
target(),
target()
);
assert!(valid_download(
"1.0.0",
"1.1.0",
&Url::parse(&good).unwrap()
));
for input in [
good.replace("https:", "http:"),
good.replace(".app.tar.gz", ".dmg"),
good.replace("1.1.0/", "0.9.0/"),
good.replace(&target(), "foreign-apple-darwin"),
format!("{good}?token=anything"),
format!("{good}#fragment"),
good.replace("https://", "https://user@"),
"https://untrusted.example/update.app.tar.gz".into(),
] {
assert!(!valid_download(
"1.0.0",
"1.1.0",
&Url::parse(&input).unwrap()
));
}
}
#[test]
fn automatic_checks_start_once_then_respect_interval_and_opt_out() {
let mut state = state();
let now = Instant::now();
assert!(state.begin_check(false, now));
assert!(!state.begin_check(false, now));
state.view.phase = "current";
assert!(!state.begin_check(false, now + Duration::from_secs(6 * 3600 - 1)));
assert!(state.begin_check(false, now + Duration::from_secs(6 * 3600)));
state.view.phase = "current";
state.view.preferences.automatic = false;
assert!(!state.begin_check(false, now + Duration::from_secs(24 * 3600)));
assert!(state.begin_check(true, now + Duration::from_secs(24 * 3600)));
}
#[test]
fn manual_checks_retry_errors_but_never_overlap_downloads_or_installation() {
let mut state = state();
state.view.phase = "error";
state.view.error = Some("networkError");
assert!(state.begin_check(true, Instant::now()));
assert!(state.view.error.is_none());
for phase in [
"checking",
"downloading",
"verifying",
"installing",
"restarting",
] {
state.view.phase = phase;
assert!(!state.begin_check(true, Instant::now()));
}
}
#[test]
fn preferences_validate_and_round_trip_without_server_settings() {
let p = Preferences {
automatic: false,
interval_hours: 24,
};
let json = serde_json::to_string(&p).unwrap();
let loaded: Preferences = serde_json::from_str(&json).unwrap();
assert!(!loaded.automatic);
assert_eq!(loaded.interval_hours, 24);
assert!(loaded.valid());
for interval in [0, 2, 100, u64::MAX] {
assert!(!Preferences {
automatic: true,
interval_hours: interval
}
.valid());
}
}
#[test]
fn update_actions_are_confined_to_the_local_trusted_window() {
assert!(trusted_navigation(
&Url::parse("tauri://localhost/updates/install").unwrap()
));
for address in [
"https://kanbangit.de/updates/install",
"https://localhost/updates/install",
"tauri://user@localhost/updates/install",
"tauri://localhost:443/updates/install",
"tauri://other/updates/install",
] {
assert!(!trusted_navigation(&Url::parse(address).unwrap()));
}
}
#[test]
fn rejects_foreign_or_invalid_mach_o_headers_without_developer_tools() {
let cpu: u32 = if cfg!(target_arch = "aarch64") {
0x0100_000c
} else {
0x0100_0007
};
let mut header = vec![0xcf, 0xfa, 0xed, 0xfe];
header.extend(cpu.to_le_bytes());
assert!(matches_architecture(&header));
header[4] ^= 1;
assert!(!matches_architecture(&header));
assert!(!matches_architecture(b"MZ"));
assert!(!matches_architecture(&[0; 8]));
}
#[test]
fn error_categories_offer_specific_retryable_messages() {
use tauri_plugin_updater::Error;
assert_eq!(
update_error(&Error::Network("offline".into())),
"networkError"
);
assert_eq!(
update_error(&Error::TargetNotFound("darwin-other".into())),
"metadataError"
);
assert_eq!(
update_error(&Error::SignatureUtf8("invalid".into())),
"signatureError"
);
}
}
#[cfg(test)]
mod signed_upgrade_test {
use super::*;
use std::{io::Write, net::TcpListener};
#[test]
#[ignore = "Requires KANBANGIT_UPGRADE_BASE and KANBANGIT_UPGRADE_ARCHIVE pointing to two Developer-ID-signed macOS versions"]
fn installs_a_real_signed_upgrade_and_rejects_tampering() {
let base = std::env::var("KANBANGIT_UPGRADE_BASE").expect("Set the signed base app path");
let archive = std::env::var("KANBANGIT_UPGRADE_ARCHIVE")
.expect("Set the signed updater archive path");
let base_version = env!("CARGO_PKG_VERSION");
let mut next_version = Version::parse(base_version).unwrap();
next_version.patch += 1;
let candidate_version =
std::env::var("KANBANGIT_UPGRADE_VERSION").unwrap_or_else(|_| next_version.to_string());
assert!(newer_stable(base_version, &candidate_version));
let root = std::env::temp_dir().join(format!("kanbangit-upgrade-{}", std::process::id()));
fs::create_dir(&root).unwrap();
struct Cleanup(std::path::PathBuf);
impl Drop for Cleanup {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
let _cleanup = Cleanup(root.clone());
let installed = root.join("KanbanGit.app");
command("/usr/bin/ditto", &[&base, installed.to_str().unwrap()]).unwrap();
let data = root.join("user-data");
fs::create_dir(&data).unwrap();
fs::write(data.join("active-server"), "https://workspace.example/").unwrap();
fs::write(data.join("session"), "existing-session").unwrap();
let bytes = fs::read(&archive).unwrap();
let signature = fs::read_to_string(format!("{archive}.sig")).unwrap();
let verify_dir = root.join("verify");
fs::create_dir(&verify_dir).unwrap();
verify_bundle(Path::new(&archive), &verify_dir, &candidate_version).unwrap();
assert!(verify_bundle(Path::new(&archive), &verify_dir, base_version).is_err());
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let download = bytes.clone();
let announced_version = candidate_version.clone();
let server = std::thread::spawn(move || {
for index in 0..3 {
let (mut stream, _) = listener.accept().unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.unwrap();
let mut request = [0; 4096];
let _ = stream.read(&mut request).unwrap();
let body = if index == 0 {
serde_json::to_vec(&serde_json::json!({"version":announced_version, "platforms":{(format!("darwin-{}", std::env::consts::ARCH)): {"url":format!("http://{address}/update.tar.gz"), "signature":signature.trim()}}})).unwrap()
} else if index == 1 {
b"tampered update".to_vec()
} else {
download.clone()
};
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
)
.unwrap();
stream.write_all(&body).unwrap();
}
});
let mut context = crate::desktop::context();
context.config_mut().plugins.0.insert("updater".into(), serde_json::json!({"pubkey":source().public_key, "dangerousInsecureTransportProtocol":true}));
let app = tauri::test::mock_builder()
.plugin(tauri_plugin_updater::Builder::new().build())
.build(context)
.unwrap();
let update = tauri::async_runtime::block_on(async {
app.updater_builder()
.executable_path(installed.join("Contents/MacOS/kanbangit-native"))
.endpoints(vec![
Url::parse(&format!("http://{address}/latest.json")).unwrap()
])
.unwrap()
.timeout(Duration::from_secs(20))
.build()
.unwrap()
.check()
.await
.unwrap()
.unwrap()
});
assert_eq!(update.current_version, base_version);
let rejected =
tauri::async_runtime::block_on(update.download(|_, _| {}, || {})).unwrap_err();
assert_eq!(update_error(&rejected), "signatureError");
let plist = installed.join("Contents/Info.plist");
assert_eq!(
command(
"/usr/bin/plutil",
&[
"-extract",
"CFBundleShortVersionString",
"raw",
"-o",
"-",
plist.to_str().unwrap()
]
)
.unwrap(),
base_version
);
let verified = tauri::async_runtime::block_on(update.download(|_, _| {}, || {})).unwrap();
assert_eq!(verified, bytes);
update.install(verified).unwrap();
assert_eq!(
command(
"/usr/bin/plutil",
&[
"-extract",
"CFBundleShortVersionString",
"raw",
"-o",
"-",
plist.to_str().unwrap()
]
)
.unwrap(),
candidate_version
);
command(
"/usr/bin/codesign",
&[
"--verify",
"--deep",
"--strict",
installed.to_str().unwrap(),
],
)
.unwrap();
assert_eq!(
fs::read_to_string(data.join("active-server")).unwrap(),
"https://workspace.example/"
);
assert_eq!(
fs::read_to_string(data.join("session")).unwrap(),
"existing-session"
);
server.join().unwrap();
}
}
+29 -4
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "KanbanGit", "productName": "KanbanGit",
"version": "0.1.1", "version": "0.1.4",
"identifier": "de.kanbangit.desktop", "identifier": "de.kanbangit.desktop",
"build": { "build": {
"frontendDist": "../ui" "frontendDist": "../ui"
@@ -16,10 +16,35 @@
}, },
"bundle": { "bundle": {
"active": true, "active": true,
"targets": ["app"], "targets": [
"icon": ["icons/icon.png", "icons/icon.icns"], "app"
],
"icon": [
"icons/icon.png",
"icons/icon.icns"
],
"macOS": { "macOS": {
"minimumSystemVersion": "14.0" "minimumSystemVersion": "14.0",
"bundleVersion": "4",
"dmg": {
"windowSize": {
"width": 660,
"height": 400
},
"appPosition": {
"x": 180,
"y": 170
},
"applicationFolderPosition": {
"x": 480,
"y": 170
}
}
}
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhBQzA0QjhENTI3OEE1QzAKUldUQXBYaFNqVXZBaXJVWmErWUZTNVp6QnllT25NWFMyemxqcFBnYnJHT3JBSWxrSkVYU1VVMFEK"
} }
} }
} }
+82
View File
@@ -0,0 +1,82 @@
#[cfg(target_os = "macos")]
#[tauri::command]
fn updater_smoke_report(app: tauri::AppHandle, passed: bool, reason: String) {
use tauri::Manager;
println!("Updater WebView test: passed={passed}, {reason}");
app.state::<std::sync::Arc<std::sync::atomic::AtomicBool>>()
.store(passed, std::sync::atomic::Ordering::SeqCst);
app.exit(if passed { 0 } else { 1 });
}
#[cfg(target_os = "macos")]
fn main() {
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use tauri::webview::PageLoadEvent;
let passed = Arc::new(AtomicBool::new(false));
let config: serde_json::Value =
serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
let expected_installed = format!(
"Installed version {} (build {})",
config["version"].as_str().unwrap(),
config["bundle"]["macOS"]["bundleVersion"].as_str().unwrap()
);
let plugin = tauri::plugin::Builder::<tauri::Wry>::new("updater-smoke")
.js_init_script(format!("window.expectedInstalled = {};", serde_json::to_string(&expected_installed).unwrap()))
.on_page_load(|window, payload| {
if window.label() != "updates" || payload.event() != PageLoadEvent::Finished { return; }
window.eval(r#"
(async () => {
try {
for (let i = 0; i < 100 && !window.updateStatus; i++) await new Promise(r => setTimeout(r, 50));
if (!window.updateStatus) throw new Error('Update UI did not load');
const $ = id => document.getElementById(id);
for (let i = 0; i < 100 && !$('installed').textContent; i++) await new Promise(r => setTimeout(r, 50));
if ($('installed').textContent !== window.expectedInstalled) throw new Error('Actual installed version/build failed: ' + $('installed').textContent);
const state = { phase: 'current', currentVersion: '0.1.2', build: '2', version: null, notes: null, downloaded: 0, total: null, error: null, preferences: { automatic: true, intervalHours: 6 } };
window.updateStatus(state);
if (!$('installed').textContent.includes('0.1.2 (build 2)') || !$('install').hidden || $('check').disabled) throw new Error('Current version state failed');
Object.assign(state, { phase: 'available', version: '0.1.3', notes: '<script>unsafe()</script>\nRelease notes' });
window.updateStatus(state);
if ($('install').hidden || $('release-notes').querySelector('script') || !$('release-notes').textContent.includes('<script>')) throw new Error('Available state or text escaping failed');
$('install').click();
if ($('install-confirmation').hidden || !$('install-confirmation').textContent.includes('Unsaved inputs')) throw new Error('Missing restart confirmation');
$('cancel-install').click();
if (!$('install-confirmation').hidden) throw new Error('Cancel failed');
Object.assign(state, { phase: 'downloading', downloaded: 500, total: 1000 }); window.updateStatus(state);
if (!$('check').disabled || !$('install').hidden || $('download-progress').value !== 500) throw new Error('Progress state failed');
Object.assign(state, { phase: 'error', error: 'signatureError' }); window.updateStatus(state);
if ($('install').hidden || !$('update-error').textContent.includes('Nothing was installed')) throw new Error('Retry failed');
state.preferences.automatic = false; window.updateStatus(state);
if ($('automatic').checked || !$('interval').disabled) throw new Error('Opt out failed');
await window.__TAURI_INTERNALS__.invoke('updater_smoke_report', { passed: true, reason: '' });
} catch (error) {
await window.__TAURI_INTERNALS__.invoke('updater_smoke_report', { passed: false, reason: error.message });
}
})();
"#).unwrap();
})
.build();
let mut context = kanbangit_native::context();
context.config_mut().identifier = format!("de.kanbangit.smoke.updates.{}", std::process::id());
let app = kanbangit_native::builder()
.manage(passed.clone())
.invoke_handler(tauri::generate_handler![updater_smoke_report])
.plugin(plugin)
.build(context)
.expect("App must start");
let handle = app.handle().clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(30));
handle.exit(1);
});
app.run_return(|_, _| {});
assert!(passed.load(Ordering::SeqCst), "Updater UI test must pass");
}
#[cfg(not(target_os = "macos"))]
fn main() {
panic!("Requires macOS");
}
+117
View File
@@ -0,0 +1,117 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import process from 'node:process';
import test from 'node:test';
import { conditionalEtag } from '../publish-macos.mjs';
test(
'Hetzner conditional writes accept a bare ETag and reject a stale ETag',
{
skip: process.env.KANBANGIT_S3_INTEGRATION !== '1',
},
() => {
const bucket = process.env.AWS_BUCKET;
assert(bucket, 'Set AWS_BUCKET to a test bucket');
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-s3-etag-'));
const key = `kanbangit-integration-tests/${randomUUID()}.txt`;
const file = join(directory, 'object.txt');
const downloaded = join(directory, 'downloaded.txt');
const run = (operation, args) => {
const result = spawnSync(
'aws',
[
'--debug',
'--endpoint-url',
process.env.AWS_ENDPOINT,
'--region',
process.env.AWS_DEFAULT_REGION,
'--no-cli-pager',
's3api',
operation,
'--bucket',
bucket,
'--key',
key,
...args,
],
{ encoding: 'utf8', timeout: 30000 },
);
// Never expose debug headers: they contain the signed Authorization header.
return {
status: result.status,
stdout: result.stdout,
codes: [
...(result.stderr || '').matchAll(/<Code>([^<]*)<\/Code>/g),
].map((match) => match[1]),
};
};
let created = false;
try {
writeFileSync(file, 'first');
const initial = run('put-object', [
'--body',
file,
'--if-none-match',
'*',
]);
assert.equal(initial.status, 0, `Create failed: ${initial.codes}`);
created = true;
const read = run('get-object', ['--output', 'json', downloaded]);
assert.equal(read.status, 0, `Read failed: ${read.codes}`);
const etag = JSON.parse(read.stdout).ETag;
const quoted = run('put-object', [
'--body',
file,
'--if-match',
etag,
]);
console.log(
`Quoted ETag result: exit ${quoted.status}, S3 ${quoted.codes.join(', ')}`,
);
const bare = conditionalEtag(etag);
writeFileSync(file, 'second');
const updated = run('put-object', [
'--body',
file,
'--if-match',
bare,
]);
assert.equal(
updated.status,
0,
`Bare ETag replacement failed: ${updated.codes}`,
);
writeFileSync(file, 'stale writer');
const stale = run('put-object', [
'--body',
file,
'--if-match',
bare,
]);
assert.notEqual(stale.status, 0);
assert(
stale.codes.includes('PreconditionFailed'),
`Unexpected stale error: ${stale.codes}`,
);
assert.equal(run('get-object', [downloaded]).status, 0);
assert.equal(readFileSync(downloaded, 'utf8'), 'second');
} finally {
try {
if (created) {
const cleanup = run('delete-object', []);
assert.equal(
cleanup.status,
0,
`Cleanup failed: ${cleanup.codes}`,
);
}
} finally {
rmSync(directory, { recursive: true, force: true });
}
}
},
);
+35 -7
View File
@@ -4,12 +4,14 @@ import {
mkdirSync, mkdirSync,
mkdtempSync, mkdtempSync,
readFileSync, readFileSync,
symlinkSync,
rmSync, rmSync,
writeFileSync, writeFileSync,
} from 'node:fs'; } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import test from 'node:test'; import test from 'node:test';
import { updateSource } from '../update-manifest.mjs';
import { import {
packageNotarizedApp, packageNotarizedApp,
releaseMetadata, releaseMetadata,
@@ -22,6 +24,8 @@ function fixture(t) {
mkdirSync(join(directory, 'src-tauri')); mkdirSync(join(directory, 'src-tauri'));
const config = { const config = {
version: '1.2.3', version: '1.2.3',
plugins: { updater: { pubkey: updateSource.publicKey } },
bundle: { macOS: { bundleVersion: '123' } },
productName: 'KanbanGit', productName: 'KanbanGit',
identifier: 'de.kanbangit.desktop', identifier: 'de.kanbangit.desktop',
}; };
@@ -31,7 +35,7 @@ function fixture(t) {
); );
writeFileSync( writeFileSync(
join(directory, 'package.json'), join(directory, 'package.json'),
JSON.stringify({ version: '1.2.3' }), JSON.stringify({ version: '1.2.3', releaseNotes: 'Release notes' }),
); );
writeFileSync( writeFileSync(
join(directory, 'src-tauri/Cargo.toml'), join(directory, 'src-tauri/Cargo.toml'),
@@ -42,6 +46,21 @@ function fixture(t) {
APPLE_SIGNING_IDENTITY: APPLE_SIGNING_IDENTITY:
'Developer ID Application: Example (ZZX2KZGTHU)', 'Developer ID Application: Example (ZZX2KZGTHU)',
}; };
const bundle = join(
directory,
'src-tauri/target/aarch64-apple-darwin/release/bundle',
);
mkdirSync(join(bundle, 'dmg'), { recursive: true });
mkdirSync(join(bundle, 'macos'), { recursive: true });
writeFileSync(
join(bundle, 'dmg/KanbanGit_1.2.3_aarch64.dmg'),
'notarized archive',
);
writeFileSync(
join(bundle, 'macos/KanbanGit.app.tar.gz'),
'notarized updater',
);
writeFileSync(join(bundle, 'macos/KanbanGit.app.tar.gz.sig'), 'signature');
const calls = []; const calls = [];
const run = (command, args) => { const run = (command, args) => {
calls.push({ command, args }); calls.push({ command, args });
@@ -54,8 +73,8 @@ function fixture(t) {
if (command === 'lipo') { if (command === 'lipo') {
return 'arm64'; return 'arm64';
} }
if (command === 'ditto' && args.includes('-c')) { if (command === 'hdiutil' && args[0] === 'attach') {
writeFileSync(args.at(-1), 'notarized archive'); symlinkSync('/Applications', join(args.at(-1), 'Applications'));
} }
return ''; return '';
}; };
@@ -66,9 +85,11 @@ test('release metadata binds tag, all app versions, identity and architecture',
const f = fixture(t); const f = fixture(t);
assert.deepEqual(releaseMetadata(f.env, f.directory), { assert.deepEqual(releaseMetadata(f.env, f.directory), {
version: '1.2.3', version: '1.2.3',
notes: 'Release notes',
target: 'aarch64-apple-darwin', target: 'aarch64-apple-darwin',
arch: 'arm64', arch: 'arm64',
filename: 'KanbanGit-1.2.3-aarch64-apple-darwin.zip', filename: 'KanbanGit-1.2.3-aarch64-apple-darwin.dmg',
updaterFilename: 'KanbanGit-1.2.3-aarch64-apple-darwin.app.tar.gz',
}); });
assert.equal( assert.equal(
releaseMetadata( releaseMetadata(
@@ -127,8 +148,8 @@ test('packages only a verified app and checks the extracted download again', (t)
), ),
`${output.checksum} ${output.filename}\n`, `${output.checksum} ${output.filename}\n`,
); );
assert.equal(f.calls.filter((call) => call.command === 'spctl').length, 2); assert.equal(f.calls.filter((call) => call.command === 'spctl').length, 3);
assert.equal(f.calls.filter((call) => call.command === 'xcrun').length, 2); assert.equal(f.calls.filter((call) => call.command === 'xcrun').length, 3);
assert.match( assert.match(
f.calls.findLast((call) => call.command === 'spctl').args.at(-1), f.calls.findLast((call) => call.command === 'spctl').args.at(-1),
/extracted\/KanbanGit\.app$/, /extracted\/KanbanGit\.app$/,
@@ -136,7 +157,14 @@ test('packages only a verified app and checks the extracted download again', (t)
assert.throws(() => packageNotarizedApp(f), /already exists/); assert.throws(() => packageNotarizedApp(f), /already exists/);
}); });
for (const failure of ['codesign', 'lipo', 'xcrun', 'spctl', 'ditto']) { for (const failure of [
'codesign',
'lipo',
'xcrun',
'spctl',
'hdiutil',
'tar',
]) {
test(`does not publish local output when ${failure} verification fails`, (t) => { test(`does not publish local output when ${failure} verification fails`, (t) => {
const f = fixture(t); const f = fixture(t);
const run = f.run; const run = f.run;
+34 -8
View File
@@ -40,6 +40,7 @@ function fixture(t, overrides = {}) {
arch: 'arm64', arch: 'arm64',
log() {}, log() {},
env: { env: {
TAURI_SIGNING_PRIVATE_KEY: 'test-private-key',
APPLE_SIGNING_IDENTITY: identity, APPLE_SIGNING_IDENTITY: identity,
APPLE_NOTARY_KEYCHAIN_PROFILE: 'kanbangit-notary', APPLE_NOTARY_KEYCHAIN_PROFILE: 'kanbangit-notary',
}, },
@@ -57,7 +58,11 @@ function fixture(t, overrides = {}) {
); );
} }
if (command === 'pnpm' && !overrides.missingApp) { if (
command === 'pnpm' &&
args.includes('build') &&
!overrides.missingApp
) {
const target = args[args.indexOf('--target') + 1]; const target = args[args.indexOf('--target') + 1];
mkdirSync( mkdirSync(
join( join(
@@ -137,7 +142,16 @@ function fixture(t, overrides = {}) {
stapled = true; stapled = true;
} }
if (command === 'ditto') { if (command === 'pnpm' && args.includes('signer')) {
writeFileSync(`${args.at(-1)}.sig`, 'signature');
}
if (command === 'tar') {
writeFileSync(args[1], 'updater with stapled ticket');
}
if (
(command === 'hdiutil' && args[0] === 'create') ||
command === 'ditto'
) {
writeFileSync( writeFileSync(
args.at(-1), args.at(-1),
stapled stapled
@@ -171,11 +185,14 @@ test('publishes only the stapled app archive with its exact SHA-256 checksum aft
); );
assert.equal( assert.equal(
readFileSync(`${result.archive}.sha256`, 'utf8'), readFileSync(`${result.archive}.sha256`, 'utf8'),
`${result.checksum} KanbanGit-1.2.3-aarch64-apple-darwin.zip\n`, `${result.checksum} KanbanGit-1.2.3-aarch64-apple-darwin.dmg\n`,
); );
assert.deepEqual(readdirSync(f.destination).sort(), [ assert.deepEqual(readdirSync(f.destination).sort(), [
'KanbanGit-1.2.3-aarch64-apple-darwin.zip', 'KanbanGit-1.2.3-aarch64-apple-darwin.app.tar.gz',
'KanbanGit-1.2.3-aarch64-apple-darwin.zip.sha256', 'KanbanGit-1.2.3-aarch64-apple-darwin.app.tar.gz.sha256',
'KanbanGit-1.2.3-aarch64-apple-darwin.app.tar.gz.sig',
'KanbanGit-1.2.3-aarch64-apple-darwin.dmg',
'KanbanGit-1.2.3-aarch64-apple-darwin.dmg.sha256',
]); ]);
assert.deepEqual(readdirSync(f.releaseDirectory), [ assert.deepEqual(readdirSync(f.releaseDirectory), [
'1.2.3-aarch64-apple-darwin', '1.2.3-aarch64-apple-darwin',
@@ -195,6 +212,11 @@ test('publishes only the stapled app archive with its exact SHA-256 checksum aft
}); });
for (const [name, env, expected] of [ for (const [name, env, expected] of [
[
'missing updater signing key',
{ TAURI_SIGNING_PRIVATE_KEY: '' },
/TAURI_SIGNING_PRIVATE_KEY/,
],
[ [
'missing identity', 'missing identity',
{ APPLE_SIGNING_IDENTITY: '' }, { APPLE_SIGNING_IDENTITY: '' },
@@ -343,7 +365,7 @@ test('supports an explicit Intel target with distinct output naming', (t) => {
assert.equal(result.target, 'x86_64-apple-darwin'); assert.equal(result.target, 'x86_64-apple-darwin');
assert.match( assert.match(
result.archive, result.archive,
/1\.2\.3-x86_64-apple-darwin\/KanbanGit-1\.2\.3-x86_64-apple-darwin\.zip$/, /1\.2\.3-x86_64-apple-darwin\/KanbanGit-1\.2\.3-x86_64-apple-darwin\.dmg$/,
); );
}); });
@@ -454,7 +476,9 @@ test('persists submission ID on timeout and resumes the same app without rebuild
); );
assert.equal( assert.equal(
f.calls.some( f.calls.some(
(call) => call.command === 'pnpm' || call.args[1] === 'submit', (call) =>
(call.command === 'pnpm' && call.args.includes('build')) ||
call.args[1] === 'submit',
), ),
false, false,
); );
@@ -481,7 +505,9 @@ test('resume never rebuilds a missing previously submitted app', (t) => {
); );
assert.equal( assert.equal(
f.calls.some( f.calls.some(
(call) => call.command === 'pnpm' || call.args[1] === 'submit', (call) =>
(call.command === 'pnpm' && call.args.includes('build')) ||
call.args[1] === 'submit',
), ),
false, false,
); );
+46
View File
@@ -113,7 +113,53 @@ test('manual release on main accepts an existing tag without moving it', (t) =>
assert.equal(f.git(f.checkout, 'rev-parse', 'macos-v1.2.3'), f.commit); assert.equal(f.git(f.checkout, 'rev-parse', 'macos-v1.2.3'), f.commit);
}); });
test('resume uses current tools, validated tagged metadata and release credentials without rebuilding', (t) => {
const f = fixture(t);
const result = f.run({
GITHUB_EVENT_NAME: 'workflow_dispatch',
GITHUB_REF: 'refs/heads/main',
REQUESTED_TAG: 'macos-v1.2.3',
RELEASE_MODE: 'resume',
});
assert.equal(result.status, 0, result.stderr);
const resume = workflow.split('\n resume:\n')[1];
assert.match(resume, /needs: prepare/);
assert.match(
resume,
/github.event_name == 'workflow_dispatch' && inputs.mode == 'resume'/,
);
assert.match(resume, /ref: \$\{\{ github.sha \}\}/);
assert.match(resume, /ref: \$\{\{ needs.prepare.outputs.commit \}\}/);
assert.match(resume, /RELEASE_METADATA_DIRECTORY: release-source\/native/);
assert.match(resume, /path: release-source/);
assert.match(resume, /secrets.S3_ACCESS_KEY_ID/);
assert.match(resume, /secrets.S3_SECRET_ACCESS_KEY/);
assert.match(resume, /node native\/publish-macos.mjs resume/);
assert.doesNotMatch(
resume,
/tauri-action|APPLE_PASSWORD|APPLE_CERTIFICATE|TAURI_SIGNING_PRIVATE_KEY/,
);
assert.match(
workflow.split('\n release:\n')[1],
/if: \$\{\{ inputs.mode != 'resume' \}\}/,
);
});
test('release DMG packaging disables Finder automation in the Tauri action environment', () => {
const build = workflow
.split(' - name: Build, sign and notarize with Tauri\n')[1]
.split('\n - name:')[0];
const environment = build
.split(' env:\n')[1]
.split(' with:\n')[0];
assert.match(environment, /^ {10}CI: "true"$/m);
assert.match(environment, /^ {10}TAURI_BUNDLER_DMG_IGNORE_CI: "false"$/m);
assert.match(build, /--bundles app,dmg/);
assert.match(build, /"createUpdaterArtifacts":true/);
});
for (const [name, overrides] of [ for (const [name, overrides] of [
['invalid release mode', { RELEASE_MODE: 'overwrite' }],
['web release tag', { GITHUB_REF: 'refs/tags/v1.2.3' }], ['web release tag', { GITHUB_REF: 'refs/tags/v1.2.3' }],
['missing tag', { GITHUB_REF: 'refs/tags/macos-v9.9.9' }], ['missing tag', { GITHUB_REF: 'refs/tags/macos-v9.9.9' }],
[ [
+274 -37
View File
@@ -5,15 +5,79 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import test from 'node:test'; import test from 'node:test';
import { publishMacos, storageConfiguration } from '../publish-macos.mjs'; import {
conditionalEtag,
publishMacos,
resumeMacos,
storageConfiguration,
} from '../publish-macos.mjs';
import { signedFixture } from './updater-fixture.mjs';
function fixture(t) { test('conditional writes use bare S3 ETags without weakening the condition', () => {
const etag = 'b88312b96169f4b2098a4cc43b0abb6a';
for (const value of [etag, `"${etag}"`, `${etag}-2`, `"${etag}-2"`]) {
assert.equal(conditionalEtag(value), value.replaceAll('"', ''));
}
for (const invalid of [
undefined,
null,
'',
'*',
'"*"',
`W/"${etag}"`,
`"${etag}`,
`${etag}"`,
`${etag},${etag}`,
`${etag}\r\n`,
{},
42,
]) {
assert.throws(() => conditionalEtag(invalid), /valid S3 ETag/);
}
});
test('resume verifies already published artifacts and promotes the feed without rebuilding', async (t) => {
const f = fixture(t);
await publishMacos(f);
const key = `releases/macos/${f.metadata.target}/latest.json`;
f.objects.set(key, Buffer.from(JSON.stringify({ version: '1.0.0' })));
const originals = new Map(f.objects);
await resumeMacos(f);
assert.equal(JSON.parse(f.objects.get(key)).version, f.metadata.version);
for (const [name, contents] of originals) {
if (name !== key) {
assert.deepEqual(f.objects.get(name), contents);
}
}
});
for (const failure of ['missing', 'tampered']) {
test(`resume rejects ${failure} artifacts before any S3 writes`, async (t) => {
const f = fixture(t);
await publishMacos(f);
const key = `releases/macos/${f.metadata.target}/${f.metadata.version}/${f.metadata.updaterFilename}`;
if (failure === 'missing') {
f.objects.delete(key);
} else {
f.objects.set(key, Buffer.from('tampered'));
}
f.events.length = 0;
await assert.rejects(resumeMacos(f), /incomplete release|signature/);
assert(!f.events.some((event) => event.operation === 'put-object'));
});
}
function fixture(
t,
conflictMessage = 'aws failed: (PreconditionFailed) object exists',
) {
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-upload-test-')); const directory = mkdtempSync(join(tmpdir(), 'kanbangit-upload-test-'));
t.after(() => rmSync(directory, { recursive: true, force: true })); t.after(() => rmSync(directory, { recursive: true, force: true }));
const metadata = { const metadata = {
version: '1.2.3', version: '1.2.3',
target: 'aarch64-apple-darwin', target: 'aarch64-apple-darwin',
filename: 'KanbanGit-1.2.3-aarch64-apple-darwin.zip', filename: 'KanbanGit-1.2.3-aarch64-apple-darwin.dmg',
updaterFilename: 'KanbanGit-1.2.3-aarch64-apple-darwin.app.tar.gz',
}; };
const archive = Buffer.from('verified release'); const archive = Buffer.from('verified release');
const checksum = createHash('sha256').update(archive).digest('hex'); const checksum = createHash('sha256').update(archive).digest('hex');
@@ -22,6 +86,17 @@ function fixture(t) {
join(directory, `${metadata.filename}.sha256`), join(directory, `${metadata.filename}.sha256`),
`${checksum} ${metadata.filename}\n`, `${checksum} ${metadata.filename}\n`,
); );
const updater = Buffer.from('signed update');
const signing = signedFixture(updater);
writeFileSync(join(directory, metadata.updaterFilename), updater);
writeFileSync(
join(directory, `${metadata.updaterFilename}.sig`),
signing.signature,
);
writeFileSync(
join(directory, `${metadata.updaterFilename}.sha256`),
`${createHash('sha256').update(updater).digest('hex')} ${metadata.updaterFilename}\n`,
);
const env = { const env = {
S3_BUCKET: 'release-bucket', S3_BUCKET: 'release-bucket',
S3_REGION: 'nbg1', S3_REGION: 'nbg1',
@@ -42,10 +117,16 @@ function fixture(t) {
return ''; return '';
} }
if (operation === 'put-object') { if (operation === 'put-object') {
assert.equal(value('--if-none-match'), '*'); if (!args.includes('--if-match')) {
assert.equal(value('--if-none-match'), '*');
}
if (objects.has(key)) { if (objects.has(key)) {
throw new Error( if (!args.includes('--if-match')) {
'aws failed: (PreconditionFailed) object exists', throw new Error(conflictMessage);
}
assert.equal(
value('--if-match'),
'b88312b96169f4b2098a4cc43b0abb6a',
); );
} }
objects.set(key, readFileSync(value('--body'))); objects.set(key, readFileSync(value('--body')));
@@ -53,7 +134,9 @@ function fixture(t) {
} }
if (operation === 'get-object') { if (operation === 'get-object') {
writeFileSync(args.at(-1), objects.get(key)); writeFileSync(args.at(-1), objects.get(key));
return '{}'; return JSON.stringify({
ETag: '"b88312b96169f4b2098a4cc43b0abb6a"',
});
} }
throw new Error(`Unexpected S3 operation: ${operation}`); throw new Error(`Unexpected S3 operation: ${operation}`);
}; };
@@ -72,6 +155,9 @@ function fixture(t) {
}; };
return { return {
directory, directory,
publicKey: signing.publicKey,
feedBase:
'https://release-bucket.nbg1.your-objectstorage.com/releases/macos',
metadata, metadata,
env, env,
objects, objects,
@@ -89,28 +175,32 @@ test('uploads immutable files and writes completion record after public verifica
url, url,
`https://release-bucket.nbg1.your-objectstorage.com/releases/macos/aarch64-apple-darwin/1.2.3/${f.metadata.filename}`, `https://release-bucket.nbg1.your-objectstorage.com/releases/macos/aarch64-apple-darwin/1.2.3/${f.metadata.filename}`,
); );
assert.equal(
f.events.filter((event) => event.operation === 'put-object').length,
8,
);
assert.equal(f.objects.size, 8);
assert(f.events.at(-1).key.endsWith('/latest.json'));
assert.equal(
f.events.at(-1).key,
'releases/macos/aarch64-apple-darwin/latest.json',
);
assert.deepEqual( assert.deepEqual(
f.events.map((event) => event.operation), JSON.parse(
[ f.objects.get(
'get-bucket-versioning', 'releases/macos/aarch64-apple-darwin/1.2.3/release.json',
'put-object', ),
'public-get', ),
'put-object', {
'public-get', version: '1.2.3',
'put-object', target: f.metadata.target,
'public-get', url,
], sha256: createHash('sha256')
.update('verified release')
.digest('hex'),
checksumUrl: `${url}.sha256`,
},
); );
assert.equal(f.objects.size, 3);
assert(f.events.at(-1).key.endsWith('/release.json'));
assert(![...f.objects.keys()].some((key) => key.includes('latest')));
assert.deepEqual(JSON.parse(f.objects.get(f.events.at(-1).key)), {
version: '1.2.3',
target: f.metadata.target,
url,
sha256: createHash('sha256').update('verified release').digest('hex'),
checksumUrl: `${url}.sha256`,
});
}); });
for (const response of ['', ' \n\t', '{}']) { for (const response of ['', ' \n\t', '{}']) {
@@ -122,9 +212,9 @@ for (const response of ['', ' \n\t', '{}']) {
return args.includes('get-bucket-versioning') ? response : result; return args.includes('get-bucket-versioning') ? response : result;
}; };
await publishMacos(f); await publishMacos(f);
assert.equal(f.objects.size, 3); assert.equal(f.objects.size, 8);
assert.equal(f.events.at(-1).operation, 'public-get'); assert.equal(f.events.at(-1).operation, 'public-get');
assert(f.events.at(-1).key.endsWith('/release.json')); assert(f.events.at(-1).key.endsWith('/latest.json'));
}); });
} }
@@ -148,7 +238,7 @@ test('retry accepts byte-identical objects without replacing them', async (t) =>
assert.deepEqual([...f.objects.entries()], originals); assert.deepEqual([...f.objects.entries()], originals);
assert.equal( assert.equal(
f.events.filter((event) => event.operation === 'get-object').length, f.events.filter((event) => event.operation === 'get-object').length,
3, 8,
); );
}); });
@@ -227,10 +317,10 @@ test('a partial upload can resume with the same files', async (t) => {
return run(command, args); return run(command, args);
}; };
await assert.rejects(publishMacos(f), /interrupted/); await assert.rejects(publishMacos(f), /interrupted/);
assert.equal(f.objects.size, 1); assert.equal(f.objects.size, 3);
f.run = run; f.run = run;
await publishMacos(f); await publishMacos(f);
assert.equal(f.objects.size, 3); assert.equal(f.objects.size, 8);
}); });
for (const status of [403, 404, 408, 429, 500, 502, 503, 504, 'network']) { for (const status of [403, 404, 408, 429, 500, 502, 503, 504, 'network']) {
@@ -257,8 +347,8 @@ for (const status of [403, 404, 408, 429, 500, 502, 503, 504, 'network']) {
await publishMacos(f); await publishMacos(f);
assert.deepEqual(waits, [2000, 2000]); assert.deepEqual(waits, [2000, 2000]);
assert.equal(cancelled, status === 'network' ? 0 : 2); assert.equal(cancelled, status === 'network' ? 0 : 2);
assert.equal(f.objects.size, 3); assert.equal(f.objects.size, 8);
assert(f.events.at(-1).key.endsWith('/release.json')); assert(f.events.at(-1).key.endsWith('/latest.json'));
}); });
} }
@@ -293,15 +383,15 @@ test('checksum access retries finish before publishing the completion record', a
const fetchPublic = f.fetchPublic; const fetchPublic = f.fetchPublic;
let failures = 0; let failures = 0;
f.fetchPublic = async (url, options) => { f.fetchPublic = async (url, options) => {
if (url.endsWith('.sha256') && ++failures <= 2) { if (url.endsWith(`${f.metadata.filename}.sha256`) && ++failures <= 2) {
assert.equal(f.objects.size, 2); assert.equal(f.objects.size, 5);
return { ok: false, status: 403 }; return { ok: false, status: 403 };
} }
return fetchPublic(url, options); return fetchPublic(url, options);
}; };
await publishMacos(f); await publishMacos(f);
assert.equal(failures, 3); assert.equal(failures, 3);
assert.equal(f.objects.size, 3); assert.equal(f.objects.size, 8);
}); });
for (const overrides of [ for (const overrides of [
@@ -338,3 +428,150 @@ test('derives Hetzner endpoints and supports an explicit public proxy URL', () =
assert.equal(config.publicBase, 'https://downloads.example.com'); assert.equal(config.publicBase, 'https://downloads.example.com');
} }
}); });
test('refuses a destination that differs from the built-in update source', async (t) => {
const f = fixture(t);
f.feedBase = 'https://another.example/releases/macos';
await assert.rejects(publishMacos(f), /does not match/);
assert.equal(f.objects.size, 0);
});
test('rejects a corrupt updater archive before publishing any object', async (t) => {
const f = fixture(t);
writeFileSync(join(f.directory, f.metadata.updaterFilename), 'tampered');
await assert.rejects(publishMacos(f), /signature/);
assert.equal(f.objects.size, 0);
});
test('atomically promotes a newer feed only after all immutable objects are public', async (t) => {
const f = fixture(t);
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '1.0.0' })));
await publishMacos(f);
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '1.2.3');
const immutableVerified = f.events.findIndex(
(event) =>
event.operation === 'public-get' &&
event.key.endsWith('/update.json'),
);
const latestPut = f.events.findIndex(
(event) => event.operation === 'put-object' && event.key === latestKey,
);
assert(latestPut > immutableVerified);
});
for (const previous of [
{ version: '2.0.0' },
{ version: '1.2.3', notes: 'different' },
]) {
test(`does not replace conflicting update feed ${previous.version}`, async (t) => {
const f = fixture(t);
const key = `releases/macos/${f.metadata.target}/latest.json`;
const original = Buffer.from(JSON.stringify(previous));
f.objects.set(key, original);
await assert.rejects(publishMacos(f), /downgrade|differs/);
assert.deepEqual(f.objects.get(key), original);
});
}
test('concurrent feed replacement fails without overwriting the winner', async (t) => {
const f = fixture(t);
const key = `releases/macos/${f.metadata.target}/latest.json`;
f.objects.set(key, Buffer.from(JSON.stringify({ version: '1.0.0' })));
const run = f.run;
f.run = (command, args) => {
if (args.includes('--if-match')) {
f.objects.set(
key,
Buffer.from(JSON.stringify({ version: '2.0.0' })),
);
throw new Error('(PreconditionFailed) concurrent publication');
}
return run(command, args);
};
await assert.rejects(publishMacos(f), /concurrent publication/);
assert.equal(JSON.parse(f.objects.get(key)).version, '2.0.0');
});
for (const message of [
"aws failed (exit 255). aws: [ERROR]: argument of type 'NoneType' is not a container or iterable",
"aws failed (exit 255). argument of type 'NoneType' is not iterable",
]) {
test(`empty S3 error message permits verified promotion and identical retry: ${message}`, async (t) => {
const f = fixture(t, message);
const key = `releases/macos/${f.metadata.target}/latest.json`;
f.objects.set(key, Buffer.from(JSON.stringify({ version: '1.0.0' })));
await publishMacos(f);
assert.equal(JSON.parse(f.objects.get(key)).version, '1.2.3');
const published = [...f.objects.entries()];
await publishMacos(f);
assert.deepEqual([...f.objects.entries()], published);
});
test(`empty S3 error cannot bypass immutable content checks: ${message}`, async (t) => {
const f = fixture(t, message);
const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`;
f.objects.set(key, Buffer.from('different existing release'));
await assert.rejects(publishMacos(f), /bump the app version/);
assert.equal(f.objects.size, 1);
assert.equal(
f.objects.get(key).toString(),
'different existing release',
);
});
for (const previous of [
{ version: '2.0.0' },
{ version: '1.2.3', notes: 'different' },
]) {
test(`empty S3 error cannot bypass feed conflict checks ${previous.version}: ${message}`, async (t) => {
const f = fixture(t, message);
const key = `releases/macos/${f.metadata.target}/latest.json`;
const original = Buffer.from(JSON.stringify(previous));
f.objects.set(key, original);
await assert.rejects(publishMacos(f), /downgrade|differs/);
assert.deepEqual(f.objects.get(key), original);
});
}
test(`empty S3 error aborts when existing contents cannot be read: ${message}`, async (t) => {
const f = fixture(t, message);
const run = f.run;
f.run = (command, args) => {
if (args.includes('put-object')) {
throw new Error(message);
}
if (args.includes('get-object')) {
throw new Error('AccessDenied');
}
return run(command, args);
};
await assert.rejects(publishMacos(f), /S3 get-object .*AccessDenied/);
assert.equal(f.objects.size, 0);
});
test(`empty S3 error during conditional replacement preserves a concurrent winner: ${message}`, async (t) => {
const f = fixture(t, message);
const key = `releases/macos/${f.metadata.target}/latest.json`;
f.objects.set(key, Buffer.from(JSON.stringify({ version: '1.0.0' })));
const run = f.run;
let attempts = 0;
f.run = (command, args) => {
if (args.includes('--if-match')) {
attempts++;
f.objects.set(
key,
Buffer.from(JSON.stringify({ version: '2.0.0' })),
);
throw new Error(message);
}
return run(command, args);
};
await assert.rejects(
publishMacos(f),
/S3 put-object .*latest.json: .*NoneType/,
);
assert.equal(attempts, 1);
assert.equal(JSON.parse(f.objects.get(key)).version, '2.0.0');
});
}
+31
View File
@@ -0,0 +1,31 @@
import { Buffer } from 'node:buffer';
import { createHash, generateKeyPairSync, sign } from 'node:crypto';
export function signedFixture(bytes) {
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
const keyId = Buffer.alloc(8, 7);
const key = Buffer.concat([
Buffer.from('Ed'),
keyId,
publicKey.export({ type: 'spki', format: 'der' }).subarray(-32),
]);
const signature = sign(
null,
createHash('blake2b512').update(bytes).digest(),
privateKey,
);
const trusted = 'timestamp:123456';
const globalSignature = sign(
null,
Buffer.concat([signature, Buffer.from(trusted)]),
privateKey,
);
return {
publicKey: Buffer.from(
`untrusted comment: test\n${key.toString('base64')}\n`,
).toString('base64'),
signature: Buffer.from(
`untrusted comment: test\n${Buffer.concat([Buffer.from('ED'), keyId, signature]).toString('base64')}\ntrusted comment: ${trusted}\n${globalSignature.toString('base64')}\n`,
).toString('base64'),
};
}
+122
View File
@@ -0,0 +1,122 @@
import assert from 'node:assert/strict';
import { Buffer } from 'node:buffer';
import { signedFixture } from './updater-fixture.mjs';
import test from 'node:test';
import {
compareVersions,
updateManifest,
verifyUpdateSignature,
} from '../update-manifest.mjs';
import { updatePresentation } from '../ui/updates-state.js';
test('compares stable versions numerically and rejects prerelease or malformed versions', () => {
assert.equal(compareVersions('1.10.0', '1.9.9'), 1);
assert.equal(compareVersions('1.0.0', '2.0.0'), -1);
assert.equal(compareVersions('1.0.0', '1.0.0'), 0);
for (const invalid of [
'v1.0.0',
'1.0.0-beta',
'01.0.0',
'1.0.0+2',
'',
'1.2',
]) {
assert.throws(() => compareVersions(invalid, '1.0.0'));
}
});
test('verifies minisign archive and trusted comment with the permanent key', () => {
const bytes = Buffer.from('signed app');
const fixture = signedFixture(bytes);
assert.doesNotThrow(() =>
verifyUpdateSignature(bytes, fixture.signature, fixture.publicKey),
);
assert.throws(
() =>
verifyUpdateSignature(
Buffer.from('tampered app'),
fixture.signature,
fixture.publicKey,
),
/signature/,
);
assert.throws(
() =>
verifyUpdateSignature(
bytes,
fixture.signature,
signedFixture(bytes).publicKey,
),
/signature/,
);
for (const signature of [
'',
'invalid',
Buffer.from(
Buffer.from(fixture.signature, 'base64')
.toString()
.replace('timestamp:123456', 'timestamp:999999'),
).toString('base64'),
]) {
assert.throws(
() => verifyUpdateSignature(bytes, signature, fixture.publicKey),
/signature/,
);
}
});
test('manifest selects the exact supported architecture and embeds release notes', () => {
const metadata = {
version: '1.2.3',
target: 'aarch64-apple-darwin',
updaterFilename: 'KanbanGit.app.tar.gz',
};
assert.deepEqual(
updateManifest(
metadata,
'https://release.example/1.2.3',
'signed',
'Release notes',
),
{
version: '1.2.3',
notes: 'Release notes',
platforms: {
'darwin-aarch64': {
url: 'https://release.example/1.2.3/KanbanGit.app.tar.gz',
signature: 'signed',
},
},
},
);
assert.throws(() =>
updateManifest(
{ ...metadata, target: 'android' },
'https://example.com',
'signed',
),
);
});
test('update controls prevent concurrent operations and allow failed downloads to retry', () => {
for (const phase of [
'checking',
'downloading',
'verifying',
'installing',
'restarting',
]) {
const view = updatePresentation({ phase, version: '2.0.0' });
assert.equal(view.busy, true);
assert.equal(view.canInstall, false);
}
assert.equal(
updatePresentation({ phase: 'available', version: '2.0.0' }).canInstall,
true,
);
assert.equal(
updatePresentation({ phase: 'error', version: '2.0.0' }).canInstall,
true,
);
assert.equal(
updatePresentation({ phase: 'current', version: null }).canInstall,
false,
);
});
+1
View File
@@ -13,6 +13,7 @@
<img class="brand" src="./mark.svg" alt="" width="48" height="48" /> <img class="brand" src="./mark.svg" alt="" width="48" height="48" />
<p class="eyebrow" data-i18n="appName"></p> <p class="eyebrow" data-i18n="appName"></p>
<h1 data-i18n="title"></h1> <h1 data-i18n="title"></h1>
<button id="update-notice" type="button" data-i18n="updateNotice" hidden></button>
<p class="description" data-i18n="description"></p> <p class="description" data-i18n="description"></p>
<form id="server-form" novalidate> <form id="server-form" novalidate>
<label for="server-url" data-i18n="serverLabel"></label> <label for="server-url" data-i18n="serverLabel"></label>
+40
View File
@@ -1,4 +1,44 @@
export const en = { export const en = {
updatesTitle: 'Software updates',
installedVersion: 'Installed version',
version: 'Version',
build: 'build',
checkUpdates: 'Check For Updates…',
installUpdate: 'Install Update…',
installAndRestart: 'Install and restart',
installWarning:
'Save your work before continuing. KanbanGit will download and verify the update, then install it and restart. Unsaved inputs will be lost. Your server settings and stored sessions are kept.',
updatePreferences: 'Update preferences',
automaticUpdates: 'Automatically check for updates',
checkInterval: 'Check interval',
hourly: 'Every hour',
sixHourly: 'Every 6 hours',
daily: 'Every day',
updatesPrivacy:
'Checks run at startup and at the selected interval. Downloads and restarts only happen after your confirmation.',
noReleaseNotes: 'No release notes provided.',
update_idle: 'Check for the latest stable version of KanbanGit.',
update_checking: 'Checking for updates…',
update_available: 'A new version of KanbanGit is available.',
update_current: 'KanbanGit is up to date.',
update_downloading: 'Downloading update…',
update_verifying: 'Verifying update…',
update_installing: 'Installing update…',
update_restarting: 'Restarting KanbanGit…',
update_error: 'The update could not be completed.',
networkError:
'Unable to contact the update service or finish the download. Check your connection and try again.',
metadataError:
'The update information is invalid or does not match this Mac. Try checking again later.',
signatureError:
'The update signature could not be verified. Nothing was installed. Try again or contact support.',
verificationError:
'The downloaded app does not match the announced version, architecture or publisher. Nothing was installed.',
installationError:
'The update could not be installed. Move KanbanGit to Applications, check available disk space and permissions, then try again.',
preferencesError:
'Update preferences could not be read or saved. Check your local storage permissions and select your preferences again.',
updateNotice: 'An update is available. View update…',
appName: 'KanbanGit', appName: 'KanbanGit',
title: 'Choose your workspace.', title: 'Choose your workspace.',
description: 'Connect to your KanbanGit installation over HTTPS.', description: 'Connect to your KanbanGit installation over HTTPS.',
+7
View File
@@ -98,3 +98,10 @@ document.querySelector('#appearance').addEventListener('change', (event) => {
if (window.nativeLauncher) { if (window.nativeLauncher) {
action('ready'); action('ready');
} }
window.updateAvailable = (available) => {
document.querySelector('#update-notice').hidden = !available;
};
document
.querySelector('#update-notice')
.addEventListener('click', () => action('updates'));
+17
View File
@@ -0,0 +1,17 @@
export function updatePresentation(state) {
const busy = [
'checking',
'downloading',
'verifying',
'installing',
'restarting',
].includes(state.phase);
return {
busy,
message: `update_${state.phase}`,
canInstall: Boolean(state.version) && !busy,
showProgress: ['downloading', 'verifying', 'installing'].includes(
state.phase,
),
};
}
+12
View File
@@ -0,0 +1,12 @@
body { display: block; padding: 28px; }
main { margin: 0 auto; width: min(100%, 460px); }
.brand { margin-bottom: 16px; }
h2 { font-size: 18px; }
#release-notes { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 180px; overflow: auto; }
progress { width: 100%; accent-color: var(--primary); }
fieldset { margin: 28px 0 0; padding: 16px; border: 1px solid var(--border); border-radius: 6px; }
.automatic { display: flex; align-items: center; gap: 10px; }
.automatic input { display: inline-block; width: 18px; min-height: 18px; margin: 0; }
.appearance { margin-top: 16px; }
#install-confirmation { margin-top: 16px; padding: 12px; border: 1px solid var(--border); border-radius: 6px; }
#confirm-install { background: var(--primary); color: var(--surface); }
+43
View File
@@ -0,0 +1,43 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<title>KanbanGit Updates</title>
<link rel="stylesheet" href="./styles.css" />
<link rel="stylesheet" href="./updates.css" />
<script type="module" src="./updates.js"></script>
</head>
<body>
<main>
<img class="brand" src="./mark.svg" alt="" width="48" height="48" />
<p class="eyebrow" data-i18n="appName"></p>
<h1 data-i18n="updatesTitle"></h1>
<p id="installed" class="description"></p>
<p id="update-status" role="status" aria-live="polite"></p>
<p id="update-error" class="description" role="alert" hidden></p>
<section id="release" hidden>
<h2 id="available-version"></h2>
<p id="release-notes" class="description"></p>
</section>
<progress id="download-progress" hidden></progress>
<p id="download-size" class="description" hidden></p>
<button id="check" type="button" data-i18n="checkUpdates"></button>
<button id="install" type="button" data-i18n="installUpdate" hidden></button>
<section id="install-confirmation" hidden>
<p class="description" data-i18n="installWarning"></p>
<button id="confirm-install" type="button" data-i18n="installAndRestart"></button>
<button id="cancel-install" type="button" data-i18n="cancel"></button>
</section>
<fieldset id="preferences">
<legend data-i18n="updatePreferences"></legend>
<label class="automatic"><input id="automatic" type="checkbox" checked /><span data-i18n="automaticUpdates"></span></label>
<label class="appearance" for="interval"><span data-i18n="checkInterval"></span>
<select id="interval"><option value="1" data-i18n="hourly"></option><option value="6" data-i18n="sixHourly"></option><option value="24" data-i18n="daily"></option></select>
</label>
<p class="description" data-i18n="updatesPrivacy"></p>
</fieldset>
</main>
</body>
</html>
+70
View File
@@ -0,0 +1,70 @@
import { en } from './locales/en.js';
import { updatePresentation } from './updates-state.js';
for (const element of document.querySelectorAll('[data-i18n]')) {
element.textContent = en[element.dataset.i18n];
}
const element = (id) => document.getElementById(id);
const action = (name, params = {}) => {
const url = new URL(`/updates/${name}`, location.href);
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
location.href = url.href;
};
let state;
window.updateStatus = (next) => {
state = next;
const view = updatePresentation(state);
element('installed').textContent =
`${en.installedVersion} ${state.currentVersion} (${en.build} ${state.build})`;
element('update-status').textContent = en[view.message];
element('update-error').hidden = !state.error;
element('update-error').textContent = en[state.error] ?? '';
element('check').disabled = view.busy;
element('preferences').disabled = view.busy;
element('release').hidden = !state.version;
element('available-version').textContent =
`${en.version} ${state.version ?? ''}`;
element('release-notes').textContent = state.notes || en.noReleaseNotes;
element('install').hidden = !view.canInstall;
element('download-progress').hidden = !view.showProgress;
element('download-progress').max = state.total || 1;
if (state.total) {
element('download-progress').value = state.downloaded;
} else {
element('download-progress').removeAttribute('value');
}
element('download-size').hidden = !view.showProgress;
element('download-size').textContent =
`${(state.downloaded / 1048576).toFixed(1)} MB${state.total ? ` / ${(state.total / 1048576).toFixed(1)} MB` : ''}`;
element('automatic').checked = state.preferences.automatic;
element('interval').value = String(state.preferences.intervalHours);
element('interval').disabled = !state.preferences.automatic;
if (view.busy || !view.canInstall) {
element('install-confirmation').hidden = true;
}
};
element('check').addEventListener('click', () => action('check'));
element('install').addEventListener('click', () => {
element('install-confirmation').hidden = false;
element('confirm-install').focus();
});
element('cancel-install').addEventListener('click', () => {
element('install-confirmation').hidden = true;
element('install').focus();
});
element('confirm-install').addEventListener('click', () => {
if (state && updatePresentation(state).canInstall) {
element('install-confirmation').hidden = true;
action('install', { version: state.version });
}
});
for (const id of ['automatic', 'interval']) {
element(id).addEventListener('change', () =>
action('preferences', {
automatic: String(element('automatic').checked),
interval: element('interval').value,
}),
);
}
+105
View File
@@ -0,0 +1,105 @@
import { Buffer } from 'node:buffer';
import { createHash, createPublicKey, verify } from 'node:crypto';
import { readFileSync } from 'node:fs';
export const updateSource = JSON.parse(
readFileSync(new URL('./update-source.json', import.meta.url), 'utf8'),
);
export function compareVersions(left, right) {
const pattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
if (!pattern.test(left) || !pattern.test(right)) {
throw new Error(
'Update versions must be stable MAJOR.MINOR.PATCH versions.',
);
}
const a = left.split('.').map(BigInt);
const b = right.split('.').map(BigInt);
for (let i = 0; i < 3; i++) {
if (a[i] !== b[i]) {
return a[i] > b[i] ? 1 : -1;
}
}
return 0;
}
export function verifyUpdateSignature(
bytes,
signature,
publicKey = updateSource.publicKey,
) {
try {
const key = Buffer.from(
Buffer.from(publicKey, 'base64')
.toString('utf8')
.trim()
.split('\n')[1],
'base64',
);
const lines = Buffer.from(signature.trim(), 'base64')
.toString('utf8')
.trim()
.split('\n');
const signed = Buffer.from(lines[1], 'base64');
if (
key.length !== 42 ||
signed.length !== 74 ||
!key.subarray(2, 10).equals(signed.subarray(2, 10)) ||
!lines[2].startsWith('trusted comment: ')
) {
throw new Error('Invalid signature structure');
}
const algorithm = signed.subarray(0, 2).toString();
if (!['Ed', 'ED'].includes(algorithm)) {
throw new Error('Unsupported signature');
}
const cryptoKey = createPublicKey({
key: Buffer.concat([
Buffer.from('302a300506032b6570032100', 'hex'),
key.subarray(10),
]),
format: 'der',
type: 'spki',
});
const contents =
algorithm === 'ED'
? createHash('blake2b512').update(bytes).digest()
: bytes;
if (
!verify(null, contents, cryptoKey, signed.subarray(10)) ||
!verify(
null,
Buffer.concat([
signed.subarray(10),
Buffer.from(lines[2].slice('trusted comment: '.length)),
]),
cryptoKey,
Buffer.from(lines[3], 'base64'),
)
) {
throw new Error('Signature mismatch');
}
} catch {
throw new Error(
'Updater archive signature does not match the permanent public key.',
);
}
}
export function updateManifest(metadata, publicPrefix, signature, notes) {
compareVersions(metadata.version, '0.0.0');
const platform = {
'aarch64-apple-darwin': 'darwin-aarch64',
'x86_64-apple-darwin': 'darwin-x86_64',
}[metadata.target];
if (!platform || !signature.trim()) {
throw new Error('Invalid updater platform or missing signature.');
}
return {
version: metadata.version,
notes: notes?.trim() || `KanbanGit ${metadata.version}`,
platforms: {
[platform]: {
url: `${publicPrefix}/${metadata.updaterFilename}`,
signature: signature.trim(),
},
},
};
}
+4
View File
@@ -0,0 +1,4 @@
{
"baseUrl": "https://petsch-studios.nbg1.your-objectstorage.com/releases/macos",
"publicKey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhBQzA0QjhENTI3OEE1QzAKUldUQXBYaFNqVXZBaXJVWmErWUZTNVp6QnllT25NWFMyemxqcFBnYnJHT3JBSWxrSkVYU1VVMFEK"
}