From 20f9314659e95a7d8e03086e368a35df9df53b40 Mon Sep 17 00:00:00 2001 From: Twyco Date: Sun, 27 Sep 2026 21:40:23 +0200 Subject: [PATCH] feat(KAN-65): add signed macOS updates and DMG distribution --- .github/workflows/macos-release.yml | 9 +- native/macos-ci.mjs | 98 ++- native/macos-dmg.mjs | 25 + native/package.json | 8 +- native/publish-macos.mjs | 125 +++- native/release-macos.mjs | 42 +- native/src-tauri/Cargo.lock | 608 ++++++++++++++++-- native/src-tauri/Cargo.toml | 13 +- native/src-tauri/src/desktop.rs | 48 +- native/src-tauri/src/lib.rs | 2 + native/src-tauri/src/locale.rs | 5 + native/src-tauri/src/updates.rs | 799 ++++++++++++++++++++++++ native/src-tauri/tauri.conf.json | 33 +- native/src-tauri/tests/updater_smoke.rs | 72 +++ native/tests/macos-ci.test.js | 42 +- native/tests/macos-release.test.js | 42 +- native/tests/publish-macos.test.js | 161 +++-- native/tests/updater-fixture.mjs | 31 + native/tests/updates.test.js | 122 ++++ native/ui/index.html | 1 + native/ui/locales/en.js | 40 ++ native/ui/main.js | 7 + native/ui/updates-state.js | 17 + native/ui/updates.css | 12 + native/ui/updates.html | 43 ++ native/ui/updates.js | 70 +++ native/update-manifest.mjs | 105 ++++ native/update-source.json | 4 + 28 files changed, 2454 insertions(+), 130 deletions(-) create mode 100644 native/macos-dmg.mjs create mode 100644 native/src-tauri/src/updates.rs create mode 100644 native/src-tauri/tests/updater_smoke.rs create mode 100644 native/tests/updater-fixture.mjs create mode 100644 native/tests/updates.test.js create mode 100644 native/ui/updates-state.js create mode 100644 native/ui/updates.css create mode 100644 native/ui/updates.html create mode 100644 native/ui/updates.js create mode 100644 native/update-manifest.mjs create mode 100644 native/update-source.json diff --git a/.github/workflows/macos-release.yml b/.github/workflows/macos-release.yml index 0af4023..f930079 100644 --- a/.github/workflows/macos-release.yml +++ b/.github/workflows/macos-release.yml @@ -98,6 +98,7 @@ jobs: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_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_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }} run: | @@ -110,7 +111,7 @@ jobs: xcodebuild -version xcrun --find notarytool 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; } done @@ -177,12 +178,14 @@ jobs: env: APPLE_ID: dev@petsch-studios.de 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: projectPath: native tauriScript: pnpm exec tauri args: >- - --ci --target ${{ env.MACOS_RELEASE_TARGET }} --bundles app - --config '{"bundle":{"createUpdaterArtifacts":false,"macOS":{"hardenedRuntime":true}}}' + --ci --target ${{ env.MACOS_RELEASE_TARGET }} --bundles app,dmg + --config '{"bundle":{"createUpdaterArtifacts":true,"macOS":{"hardenedRuntime":true}}}' -- --locked uploadUpdaterJson: false uploadUpdaterSignatures: false diff --git a/native/macos-ci.mjs b/native/macos-ci.mjs index 3db0478..c71214b 100644 --- a/native/macos-ci.mjs +++ b/native/macos-ci.mjs @@ -2,6 +2,8 @@ import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { appendFileSync, + copyFileSync, + readlinkSync, existsSync, mkdirSync, mkdtempSync, @@ -12,6 +14,7 @@ import { } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import process from 'node:process'; +import { updateSource } from './update-manifest.mjs'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { appleTeamId, @@ -28,9 +31,10 @@ export function releaseMetadata( const config = JSON.parse( readFileSync(join(directory, 'src-tauri/tauri.conf.json'), 'utf8'), ); - const packageVersion = JSON.parse( + const nativePackage = JSON.parse( readFileSync(join(directory, 'package.json'), 'utf8'), - ).version; + ); + const packageVersion = nativePackage.version; const cargoVersion = readFileSync( join(directory, 'src-tauri/Cargo.toml'), 'utf8', @@ -59,6 +63,23 @@ export function releaseMetadata( '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 arch = { 'aarch64-apple-darwin': 'arm64', @@ -81,9 +102,11 @@ export function releaseMetadata( } return { version: config.version, + notes: nativePackage.releaseNotes, target, 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-')); try { const archive = join(staging, metadata.filename); - run('ditto', [ - '-c', - '-k', - '--sequesterRsrc', - '--keepParent', - app, + const bundle = join(root, metadata.target, 'release/bundle'); + copyFileSync( + join( + bundle, + 'dmg', + `KanbanGit_${metadata.version}_${metadata.arch === 'arm64' ? 'aarch64' : 'x64'}.dmg`, + ), 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') .update(readFileSync(archive)) .digest('hex'); - writeFileSync( - `${archive}.sha256`, - `${checksum} ${metadata.filename}\n`, - ); - const extracted = join(staging, 'extracted'); - run('ditto', ['-x', '-k', archive, extracted]); - verifyNotarizedApp(join(extracted, 'KanbanGit.app'), metadata, run); - rmSync(extracted, { recursive: true, force: true }); + for (const name of [metadata.filename, metadata.updaterFilename]) { + const hash = createHash('sha256') + .update(readFileSync(join(staging, name))) + .digest('hex'); + writeFileSync( + join(staging, `${name}.sha256`), + `${hash} ${name}\n`, + ); + } renameSync(staging, destination); return { ...metadata, directory: destination, checksum }; } finally { @@ -191,6 +254,7 @@ if ( appendFileSync( process.env.GITHUB_OUTPUT, Object.entries(output) + .filter(([key]) => key !== 'notes') .map(([key, value]) => `${key}=${value}\n`) .join(''), ); diff --git a/native/macos-dmg.mjs b/native/macos-dmg.mjs new file mode 100644 index 0000000..63313b2 --- /dev/null +++ b/native/macos-dmg.mjs @@ -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 }); + } +} diff --git a/native/package.json b/native/package.json index 1c2ae1f..a6f9efb 100644 --- a/native/package.json +++ b/native/package.json @@ -1,6 +1,6 @@ { "name": "kanbangit-native", - "version": "0.1.1", + "version": "0.1.2", "private": true, "type": "module", "scripts": { @@ -18,9 +18,11 @@ "build:ios:device": "tauri ios build --debug --target aarch64 --export-method debugging --archive-only", "open:ios": "tauri ios dev --open --no-dev-server", "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": { "@tauri-apps/cli": "2.11.4" - } + }, + "releaseNotes": "macOS updates can now be checked automatically or from the KanbanGit menu. Install releases from a drag-and-drop DMG. About KanbanGit shows the app version and build. Updates preserve your server settings and stored sessions and restart only after confirmation." } diff --git a/native/publish-macos.mjs b/native/publish-macos.mjs index 782a12e..a38fe02 100644 --- a/native/publish-macos.mjs +++ b/native/publish-macos.mjs @@ -13,6 +13,12 @@ import process from 'node:process'; import { setTimeout } from 'node:timers/promises'; import { pathToFileURL } from 'node:url'; import { execute, releaseMetadata } from './macos-ci.mjs'; +import { + compareVersions, + updateManifest, + updateSource, + verifyUpdateSignature, +} from './update-manifest.mjs'; export function storageConfiguration(env = process.env) { const region = env.S3_REGION; @@ -64,8 +70,15 @@ export async function publishMacos({ run = execute, fetchPublic = fetch, wait = setTimeout, + publicKey = updateSource.publicKey, + feedBase = updateSource.baseUrl, } = {}) { 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) { throw new Error( 'Set the dedicated S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY release secrets.', @@ -79,6 +92,21 @@ export async function publishMacos({ if (checksumFile.toString() !== `${checksum} ${metadata.filename}\n`) { 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 = [ '--endpoint-url', storage.endpoint, @@ -124,11 +152,11 @@ export async function publishMacos({ const staging = mkdtempSync(join(tmpdir(), 'kanbangit-s3-')); const keyPrefix = `${storage.prefix}/${metadata.target}/${metadata.version}`; const publicPrefix = `${storage.publicBase}/${keyPrefix}`; - async function verifyPublic(name, contents) { + async function verifyPublic(name, contents, prefix = publicPrefix) { for (let attempt = 1; attempt <= 5; attempt++) { let response; try { - response = await fetchPublic(`${publicPrefix}/${name}`, { + response = await fetchPublic(`${prefix}/${name}`, { redirect: 'error', signal: AbortSignal.timeout(60000), }); @@ -199,7 +227,18 @@ export async function publishMacos({ } try { 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`, checksumFile, @@ -210,7 +249,7 @@ export async function publishMacos({ putImmutable(name, contents, type); 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( JSON.stringify( { @@ -226,6 +265,84 @@ export async function publishMacos({ ); putImmutable('release.json', manifest, 'application/json'); 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 (!/\(PreconditionFailed\)|\(412\)/.test(error.message)) { + 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 { + if (!response.ETag) { + throw new Error( + 'Cannot atomically update the feed without its ETag.', + ); + } + publishLatest(['--if-match', response.ETag]); + } + } + await verifyPublic( + 'latest.json', + latest, + `${storage.publicBase}/${latestPrefix}`, + ); return `${publicPrefix}/${metadata.filename}`; } finally { rmSync(staging, { recursive: true, force: true }); diff --git a/native/release-macos.mjs b/native/release-macos.mjs index 6939daf..4207b6e 100644 --- a/native/release-macos.mjs +++ b/native/release-macos.mjs @@ -13,6 +13,7 @@ import { dirname, join, resolve } from 'node:path'; import process from 'node:process'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { parseArgs } from 'node:util'; +import { createDiskImage } from './macos-dmg.mjs'; export const appleTeamId = 'ZZX2KZGTHU'; export const bundleIdentifier = 'de.kanbangit.desktop'; @@ -105,6 +106,11 @@ export function releaseMacos({ 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 profile = env.APPLE_NOTARY_KEYCHAIN_PROFILE?.trim(); @@ -405,20 +411,42 @@ export function releaseMacos({ app, ]); - const name = `KanbanGit-${config.version}-${target}.zip`; + const name = `KanbanGit-${config.version}-${target}.dmg`; const archive = join(staging, name); - step('Packaging notarized download', 'ditto', [ - '-c', - '-k', - '--sequesterRsrc', - '--keepParent', + createDiskImage({ app, archive, - ]); + identity, + run: (command, args) => + step('Packaging notarized download', command, args), + }); const checksum = createHash('sha256') .update(readFileSync(archive)) .digest('hex'); 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 }); renameSync(staging, destination); log(`Verified release: ${join(destination, name)}`); diff --git a/native/src-tauri/Cargo.lock b/native/src-tauri/Cargo.lock index 8828bcf..ad8ddaa 100644 --- a/native/src-tauri/Cargo.lock +++ b/native/src-tauri/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "adler2" @@ -47,6 +47,15 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "atk" version = "0.18.2" @@ -347,6 +356,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -370,7 +389,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.13.2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", "foreign-types", "libc", @@ -383,7 +402,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.13.2", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -554,6 +573,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -603,7 +633,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -755,6 +785,16 @@ dependencies = [ "typeid", ] +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -780,6 +820,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.12" @@ -1306,6 +1356,21 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1324,9 +1389,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1341,7 +1408,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core", ] [[package]] @@ -1610,6 +1677,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -1673,11 +1770,14 @@ dependencies = [ [[package]] name = "kanbangit-native" -version = "0.1.1" +version = "0.1.2" dependencies = [ + "semver", + "serde", "serde_json", "tauri", "tauri-build", + "tauri-plugin-updater", ] [[package]] @@ -1749,6 +1849,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.3" @@ -1802,6 +1908,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1851,7 +1963,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.20", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2044,6 +2156,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.2", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -2059,6 +2172,18 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -2122,12 +2247,32 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.20", +] + [[package]] name = "pango" version = "0.18.3" @@ -2503,15 +2648,20 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -2523,6 +2673,20 @@ dependencies = [ "web-sys", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -2538,6 +2702,92 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -2553,6 +2803,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -2610,6 +2869,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.36.1" @@ -2821,6 +3103,22 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.3" @@ -2846,7 +3144,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2933,6 +3231,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.8" @@ -2996,6 +3300,27 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -3017,7 +3342,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ "bitflags 2.13.2", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dbus", @@ -3027,7 +3352,7 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", @@ -3044,7 +3369,7 @@ dependencies = [ "unicode-segmentation", "url", "windows", - "windows-core 0.61.2", + "windows-core", "windows-version", "x11-dl", ] @@ -3060,6 +3385,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -3083,7 +3419,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", - "jni", + "jni 0.21.1", "libc", "log", "mime", @@ -3179,6 +3515,55 @@ dependencies = [ "tauri-utils", ] +[[package]] +name = "tauri-plugin" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8d5f58bfd0cdcfdbc0a68dc08b354eea2afc551b421de91b07b69e0dd769d57" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-updater" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5cad8ed5948d988e1018ecd31e27cacedbf72ce3fb972940c3e4bf51639e4b" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.20", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip", +] + [[package]] name = "tauri-runtime" version = "2.11.3" @@ -3189,7 +3574,7 @@ dependencies = [ "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2", "objc2-ui-kit", "objc2-web-kit", @@ -3212,7 +3597,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2", "objc2-app-kit", @@ -3279,6 +3664,19 @@ dependencies = [ "toml 1.1.6+spec-1.1.0", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "tendril" version = "0.5.1" @@ -3388,6 +3786,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.19" @@ -3604,7 +4012,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.20", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3678,6 +4086,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -3921,6 +4335,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -3930,7 +4353,7 @@ dependencies = [ "webview2-com-macros", "webview2-com-sys", "windows", - "windows-core 0.61.2", + "windows-core", "windows-implement", "windows-interface", ] @@ -3954,7 +4377,7 @@ checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.20", "windows", - "windows-core 0.61.2", + "windows-core", ] [[package]] @@ -3979,7 +4402,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4010,7 +4433,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ "windows-collections", - "windows-core 0.61.2", + "windows-core", "windows-future", "windows-link 0.1.3", "windows-numerics", @@ -4022,7 +4445,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" dependencies = [ - "windows-core 0.61.2", + "windows-core", ] [[package]] @@ -4038,26 +4461,13 @@ dependencies = [ "windows-strings 0.4.2", ] -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - [[package]] name = "windows-future" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ - "windows-core 0.61.2", + "windows-core", "windows-link 0.1.3", "windows-threading", ] @@ -4102,10 +4512,21 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" dependencies = [ - "windows-core 0.61.2", + "windows-core", "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -4151,6 +4572,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -4160,6 +4590,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -4193,13 +4632,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows-threading" version = "0.1.0" @@ -4230,6 +4686,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -4242,6 +4704,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -4254,12 +4722,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -4272,6 +4752,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -4284,6 +4770,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -4296,6 +4788,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -4308,6 +4806,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.5.40" @@ -4372,7 +4876,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2", @@ -4393,7 +4897,7 @@ dependencies = [ "webkit2gtk-sys", "webview2-com", "windows", - "windows-core 0.61.2", + "windows-core", "windows-version", "x11-dl", ] @@ -4419,6 +4923,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.3" @@ -4463,6 +4977,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.5" @@ -4496,6 +5016,18 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.14.2", + "memchr", +] + [[package]] name = "zlib-rs" version = "0.6.8" diff --git a/native/src-tauri/Cargo.toml b/native/src-tauri/Cargo.toml index 694b4f5..f3bb6ae 100644 --- a/native/src-tauri/Cargo.toml +++ b/native/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kanbangit-native" -version = "0.1.1" +version = "0.1.2" description = "KanbanGit native application" edition = "2021" publish = false @@ -13,6 +13,9 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] +semver = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" tauri = { version = "2", features = [] } [dev-dependencies] @@ -27,3 +30,11 @@ custom-protocol = ["tauri/custom-protocol"] name = "macos_smoke" harness = false 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"] diff --git a/native/src-tauri/src/desktop.rs b/native/src-tauri/src/desktop.rs index 9aac30c..88d2654 100644 --- a/native/src-tauri/src/desktop.rs +++ b/native/src-tauri/src/desktop.rs @@ -2,7 +2,7 @@ use crate::locale::en; use crate::server::{validate_url, Server, DEFAULT_SERVER}; use std::{path::PathBuf, sync::Mutex, time::Duration}; use tauri::{ - menu::{MenuBuilder, SubmenuBuilder}, + menu::{AboutMetadata, MenuBuilder, MenuItem, SubmenuBuilder}, webview::{NewWindowResponse, PageLoadEvent}, 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) { let result = match destination.path() { + "/action/updates" => { + #[cfg(target_os = "macos")] + crate::updates::show(app); + Ok(()) + } "/action/open" => { let input = destination .query_pairs() @@ -294,6 +299,24 @@ pub fn builder() -> tauri::Builder { let menu = MenuBuilder::new(app) .item( &SubmenuBuilder::new(app, en::APP_NAME) + .about_with_text( + en::ABOUT, + Some(AboutMetadata { + name: Some(en::APP_NAME.into()), + short_version: Some(app.package_info().version.to_string()), + version: app.config().bundle.macos.bundle_version.clone(), + ..Default::default() + }), + ) + .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) .separator() .quit() @@ -318,7 +341,21 @@ pub fn builder() -> tauri::Builder { ) .build()?; app.set_menu(menu)?; + #[cfg(target_os = "macos")] + crate::updates::setup(app.handle())?; 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" { show_launcher(app); } else if let Some(script) = match event.id().as_ref() { @@ -363,6 +400,8 @@ pub fn builder() -> tauri::Builder { if payload.event() == PageLoadEvent::Finished { let app = window.app_handle().clone(); update_launcher(&app); + #[cfg(target_os = "macos")] + crate::updates::refresh(&app); std::thread::spawn(move || { let handle = app.clone(); let _ = app.run_on_main_thread(move || { @@ -482,7 +521,10 @@ mod tests { #[test] 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( &app, "workspace-1", @@ -491,6 +533,8 @@ mod tests { .build() .unwrap(); for command in [ + "plugin:updater|check", + "plugin:updater|download_and_install", "plugin:window|close", "plugin:webview|create_webview_window", "plugin:path|resolve_directory", diff --git a/native/src-tauri/src/lib.rs b/native/src-tauri/src/lib.rs index 1d8d55b..c08141b 100644 --- a/native/src-tauri/src/lib.rs +++ b/native/src-tauri/src/lib.rs @@ -8,6 +8,8 @@ mod ios; mod locale; #[cfg(not(any(target_os = "android", target_os = "ios")))] mod server; +#[cfg(target_os = "macos")] +mod updates; #[cfg(target_os = "android")] pub use android::{builder, context}; diff --git a/native/src-tauri/src/locale.rs b/native/src-tauri/src/locale.rs index ce2b18a..9330aa4 100644 --- a/native/src-tauri/src/locale.rs +++ b/native/src-tauri/src/locale.rs @@ -1,4 +1,9 @@ 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 SERVER_SETTINGS: &str = "Server settings…"; pub const NAVIGATION: &str = "Navigation"; diff --git a/native/src-tauri/src/updates.rs b/native/src-tauri/src/updates.rs new file mode 100644 index 0000000..653d790 --- /dev/null +++ b/native/src-tauri/src/updates.rs @@ -0,0 +1,799 @@ +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 = 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, + notes: Option, + downloaded: u64, + total: Option, + error: Option<&'static str>, + preferences: Preferences, +} +struct State { + view: View, + update: Option, + last_check: Option, +} +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::>(); + 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::>(); + 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::>() + .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(¤t.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::>(); + 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::>().lock().unwrap().view.phase = "current", + Err(error) => { + fail(&app, update_error(&error)); + return; + } + } + refresh(&app); + }); +} +fn command(program: &str, args: &[&str]) -> Result { + 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::>(); + 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::>(); + let mut state = state.lock().unwrap(); + state.view.downloaded += chunk as u64; + state.view.total = total; + } + refresh(&app); + }, + || { + app.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::>().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::>().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> { + 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::>().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::(&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: app + .config() + .bundle + .macos + .bundle_version + .clone() + .unwrap_or_else(|| app.package_info().version.to_string()), + 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(); + } +} diff --git a/native/src-tauri/tauri.conf.json b/native/src-tauri/tauri.conf.json index 055215f..aedc45a 100644 --- a/native/src-tauri/tauri.conf.json +++ b/native/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "KanbanGit", - "version": "0.1.1", + "version": "0.1.2", "identifier": "de.kanbangit.desktop", "build": { "frontendDist": "../ui" @@ -16,10 +16,35 @@ }, "bundle": { "active": true, - "targets": ["app"], - "icon": ["icons/icon.png", "icons/icon.icns"], + "targets": [ + "app" + ], + "icon": [ + "icons/icon.png", + "icons/icon.icns" + ], "macOS": { - "minimumSystemVersion": "14.0" + "minimumSystemVersion": "14.0", + "bundleVersion": "2", + "dmg": { + "windowSize": { + "width": 660, + "height": 400 + }, + "appPosition": { + "x": 180, + "y": 170 + }, + "applicationFolderPosition": { + "x": 480, + "y": 170 + } + } + } + }, + "plugins": { + "updater": { + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhBQzA0QjhENTI3OEE1QzAKUldUQXBYaFNqVXZBaXJVWmErWUZTNVp6QnllT25NWFMyemxqcFBnYnJHT3JBSWxrSkVYU1VVMFEK" } } } diff --git a/native/src-tauri/tests/updater_smoke.rs b/native/src-tauri/tests/updater_smoke.rs new file mode 100644 index 0000000..416e507 --- /dev/null +++ b/native/src-tauri/tests/updater_smoke.rs @@ -0,0 +1,72 @@ +#[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::>() + .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 plugin = tauri::plugin::Builder::::new("updater-smoke") + .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); + 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: '\nRelease notes' }); + window.updateStatus(state); + if ($('install').hidden || $('release-notes').querySelector('script') || !$('release-notes').textContent.includes(' + + +
+ +

+

+

+

+ + + + + + + +
+ + + +

+
+
+ + diff --git a/native/ui/updates.js b/native/ui/updates.js new file mode 100644 index 0000000..6aa0490 --- /dev/null +++ b/native/ui/updates.js @@ -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, + }), + ); +} diff --git a/native/update-manifest.mjs b/native/update-manifest.mjs new file mode 100644 index 0000000..ea4eafe --- /dev/null +++ b/native/update-manifest.mjs @@ -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(), + }, + }, + }; +} diff --git a/native/update-source.json b/native/update-source.json new file mode 100644 index 0000000..30fd771 --- /dev/null +++ b/native/update-source.json @@ -0,0 +1,4 @@ +{ + "baseUrl": "https://petsch-studios.nbg1.your-objectstorage.com/releases/macos", + "publicKey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhBQzA0QjhENTI3OEE1QzAKUldUQXBYaFNqVXZBaXJVWmErWUZTNVp6QnllT25NWFMyemxqcFBnYnJHT3JBSWxrSkVYU1VVMFEK" +}