From d18fffe6654b65640418f9424000dcb248de5e32 Mon Sep 17 00:00:00 2001 From: Twyco Date: Mon, 28 Sep 2026 09:12:37 +0200 Subject: [PATCH] feat: add stable macOS app download to website --- native/package.json | 4 +- native/publish-macos.mjs | 57 ++++++++- native/src-tauri/Cargo.lock | 2 +- native/src-tauri/Cargo.toml | 2 +- native/src-tauri/tauri.conf.json | 4 +- native/tests/publish-macos.test.js | 123 +++++++++++++++++-- resources/js/components/AppSidebarHeader.vue | 19 ++- resources/js/composables/useMacDownload.ts | 30 +++++ resources/js/layouts/auth/AuthFlowLayout.vue | 22 +++- resources/js/locales/en.ts | 4 + resources/js/pages/Welcome.vue | 52 ++++++-- tests/Fixtures/welcome.mjs | 22 ++++ 12 files changed, 310 insertions(+), 31 deletions(-) create mode 100644 resources/js/composables/useMacDownload.ts diff --git a/native/package.json b/native/package.json index 5bdc4a9..d273e5e 100644 --- a/native/package.json +++ b/native/package.json @@ -1,6 +1,6 @@ { "name": "kanbangit-native", - "version": "0.2.0", + "version": "0.2.1", "private": true, "type": "module", "scripts": { @@ -24,5 +24,5 @@ "devDependencies": { "@tauri-apps/cli": "2.11.4" }, - "releaseNotes": "Update to full release v0.2.0" + "releaseNotes": "Add the stable Mac download for the KanbanGit website." } diff --git a/native/publish-macos.mjs b/native/publish-macos.mjs index e71f875..52fa89e 100644 --- a/native/publish-macos.mjs +++ b/native/publish-macos.mjs @@ -228,7 +228,7 @@ export async function publishMacos({ await wait(2000); } } - function assertUnpublishedRelease() { + function assertUnpublishedRelease(allowSameVersion = false) { const file = join(staging, 'published-latest.json'); try { s3('get-object', ['--key', latestKey, file]); @@ -261,7 +261,11 @@ export async function publishMacos({ throw error; } const published = JSON.parse(readFileSync(file, 'utf8')); - if (compareVersions(metadata.version, published.version) <= 0) { + const comparison = compareVersions(metadata.version, published.version); + if (allowSameVersion && comparison < 0) { + throw new Error('Refusing to downgrade the stable download.'); + } + if (comparison < 0 || (!allowSameVersion && comparison === 0)) { throw new Error( 'This version has already been released through the update feed; bump the app version. Published objects were not overwritten.', ); @@ -365,6 +369,55 @@ export async function publishMacos({ ); putReleaseFile('update.json', latest, 'application/json'); await verifyPublic('update.json', latest); + // An older release must not populate an absent download alias either. + assertUnpublishedRelease(true); + const downloadName = 'KanbanGit.dmg'; + const downloadKey = `${latestPrefix}/${downloadName}`; + const downloadFile = join(staging, downloadName); + writeFileSync(downloadFile, archive); + function publishDownload(condition) { + s3('put-object', [ + '--key', + downloadKey, + '--body', + downloadFile, + ...condition, + '--content-type', + 'application/x-apple-diskimage', + '--cache-control', + 'no-cache, max-age=0, must-revalidate', + '--content-md5', + createHash('md5').update(archive).digest('base64'), + '--metadata', + `sha256=${checksum}`, + ]); + } + try { + publishDownload(['--if-none-match', '*']); + } catch (error) { + if (!requiresExistingObjectCheck(error)) { + throw error; + } + const previousFile = join(staging, 'previous-download.dmg'); + const response = JSON.parse( + s3('get-object', [ + '--key', + downloadKey, + '--output', + 'json', + previousFile, + ]), + ); + if (sha256(readFileSync(previousFile)) !== checksum) { + assertUnpublishedRelease(); + publishDownload(['--if-match', conditionalEtag(response.ETag)]); + } + } + await verifyPublic( + downloadName, + archive, + `${storage.publicBase}/${latestPrefix}`, + ); const latestFile = join(staging, 'latest.json'); writeFileSync(latestFile, latest); function publishLatest(condition) { diff --git a/native/src-tauri/Cargo.lock b/native/src-tauri/Cargo.lock index 5fe9ed7..8f2b08a 100644 --- a/native/src-tauri/Cargo.lock +++ b/native/src-tauri/Cargo.lock @@ -1770,7 +1770,7 @@ dependencies = [ [[package]] name = "kanbangit-native" -version = "0.2.0" +version = "0.2.1" dependencies = [ "semver", "serde", diff --git a/native/src-tauri/Cargo.toml b/native/src-tauri/Cargo.toml index 2b28c0c..3e26a15 100644 --- a/native/src-tauri/Cargo.toml +++ b/native/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kanbangit-native" -version = "0.2.0" +version = "0.2.1" description = "KanbanGit native application" edition = "2021" publish = false diff --git a/native/src-tauri/tauri.conf.json b/native/src-tauri/tauri.conf.json index f32bd27..78170d0 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.2.0", + "version": "0.2.1", "identifier": "de.kanbangit.desktop", "build": { "frontendDist": "../ui" @@ -25,7 +25,7 @@ ], "macOS": { "minimumSystemVersion": "14.0", - "bundleVersion": "6", + "bundleVersion": "7", "dmg": { "windowSize": { "width": 660, diff --git a/native/tests/publish-macos.test.js b/native/tests/publish-macos.test.js index bc78a4f..41f3da4 100644 --- a/native/tests/publish-macos.test.js +++ b/native/tests/publish-macos.test.js @@ -168,9 +168,9 @@ test('uploads revalidated files and writes completion record after public verifi ); assert.equal( f.events.filter((event) => event.operation === 'put-object').length, - 8, + 9, ); - assert.equal(f.objects.size, 8); + assert.equal(f.objects.size, 9); for (const event of f.events.filter( (event) => event.operation === 'put-object', )) { @@ -184,6 +184,26 @@ test('uploads revalidated files and writes completion record after public verifi f.events.at(-1).key, 'releases/macos/aarch64-apple-darwin/latest.json', ); + const downloadKey = 'releases/macos/aarch64-apple-darwin/KanbanGit.dmg'; + assert.deepEqual( + f.objects.get(downloadKey), + readFileSync(join(f.directory, f.metadata.filename)), + ); + const downloadPut = f.events.findIndex( + (event) => + event.operation === 'put-object' && event.key === downloadKey, + ); + const downloadVerified = f.events.findIndex( + (event) => + event.operation === 'public-get' && event.key === downloadKey, + ); + const feedPut = f.events.findIndex( + (event) => + event.operation === 'put-object' && + event.key.endsWith('/latest.json'), + ); + assert(downloadPut > 0 && downloadVerified > downloadPut); + assert(feedPut > downloadVerified); assert.deepEqual( JSON.parse( f.objects.get( @@ -202,6 +222,91 @@ test('uploads revalidated files and writes completion record after public verifi ); }); +test('replaces the stable download only for a newer verified release', async (t) => { + const f = fixture(t); + const downloadKey = `releases/macos/${f.metadata.target}/KanbanGit.dmg`; + const latestKey = `releases/macos/${f.metadata.target}/latest.json`; + f.objects.set(downloadKey, Buffer.from('previous download')); + f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '1.0.0' }))); + + await publishMacos(f); + + assert.deepEqual( + f.objects.get(downloadKey), + readFileSync(join(f.directory, f.metadata.filename)), + ); + assert.equal(JSON.parse(f.objects.get(latestKey)).version, '1.2.3'); + const replacement = f.events.find( + (event) => + event.operation === 'put-object' && + event.key === downloadKey && + event.args.includes('--if-match'), + ); + assert(replacement); +}); + +test('a newer published version protects the stable download even when its alias is missing', async (t) => { + const f = fixture(t); + const downloadKey = `releases/macos/${f.metadata.target}/KanbanGit.dmg`; + const latestKey = `releases/macos/${f.metadata.target}/latest.json`; + f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '2.0.0' }))); + + await assert.rejects(publishMacos(f), /downgrade/); + + assert.equal(f.objects.has(downloadKey), false); + assert.equal(JSON.parse(f.objects.get(latestKey)).version, '2.0.0'); +}); + +test('a published version cannot replace a different stable download', async (t) => { + const f = fixture(t); + const downloadKey = `releases/macos/${f.metadata.target}/KanbanGit.dmg`; + const latestKey = `releases/macos/${f.metadata.target}/latest.json`; + const original = Buffer.from('published download'); + f.objects.set(downloadKey, original); + f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '1.2.3' }))); + + await assert.rejects(publishMacos(f), /bump the app version/); + + assert.deepEqual(f.objects.get(downloadKey), original); +}); + +test('a failed public stable download blocks feed promotion', async (t) => { + const f = fixture(t); + const fetchPublic = f.fetchPublic; + f.fetchPublic = (url, options) => + url.endsWith('/KanbanGit.dmg') + ? Promise.resolve({ ok: false, status: 403 }) + : fetchPublic(url, options); + + await assert.rejects(publishMacos(f), /KanbanGit\.dmg: HTTP 403/); + + assert.equal( + f.objects.has(`releases/macos/${f.metadata.target}/latest.json`), + false, + ); +}); + +test('concurrent stable download replacement preserves the winner', async (t) => { + const f = fixture(t); + const downloadKey = `releases/macos/${f.metadata.target}/KanbanGit.dmg`; + const latestKey = `releases/macos/${f.metadata.target}/latest.json`; + f.objects.set(downloadKey, Buffer.from('previous download')); + f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '1.0.0' }))); + const run = f.run; + f.run = (command, args) => { + if (args.includes('--if-match') && args.includes(downloadKey)) { + f.objects.set(downloadKey, Buffer.from('concurrent winner')); + throw new Error('(PreconditionFailed) concurrent replacement'); + } + return run(command, args); + }; + + await assert.rejects(publishMacos(f), /concurrent replacement/); + + assert.equal(f.objects.get(downloadKey).toString(), 'concurrent winner'); + assert.equal(JSON.parse(f.objects.get(latestKey)).version, '1.0.0'); +}); + for (const response of ['', ' \n\t', '{}']) { test(`unversioned bucket response ${JSON.stringify(response)} permits a verified upload`, async (t) => { const f = fixture(t); @@ -211,7 +316,7 @@ for (const response of ['', ' \n\t', '{}']) { return args.includes('get-bucket-versioning') ? response : result; }; await publishMacos(f); - assert.equal(f.objects.size, 8); + assert.equal(f.objects.size, 9); assert.equal(f.events.at(-1).operation, 'public-get'); assert(f.events.at(-1).key.endsWith('/latest.json')); }); @@ -237,7 +342,7 @@ test('retry accepts byte-identical objects without replacing them', async (t) => assert.deepEqual([...f.objects.entries()], originals); assert.equal( f.events.filter((event) => event.operation === 'get-object').length, - 8, + 11, ); }); @@ -325,7 +430,7 @@ test('a partial upload can be retried with the same files', async (t) => { assert.equal(f.objects.size, 3); f.run = run; await publishMacos(f); - assert.equal(f.objects.size, 8); + assert.equal(f.objects.size, 9); }); function rebuild(f) { @@ -387,7 +492,7 @@ for (const previous of [null, '1.0.0']) { return result; }; await publishMacos(f); - assert.equal(f.objects.size, 8); + assert.equal(f.objects.size, 9); assert.equal(JSON.parse(f.objects.get(latestKey)).version, '1.2.3'); assert.equal( JSON.parse(f.objects.get(latestKey)).notes, @@ -516,7 +621,7 @@ for (const listing of [ !('Contents' in listing) ) { await publishMacos(f); - assert.equal(f.objects.size, 8); + assert.equal(f.objects.size, 9); } else { await assert.rejects(publishMacos(f), /NoneType|AccessDenied/); assert.equal(f.objects.get(key).toString(), 'unfinished old build'); @@ -549,7 +654,7 @@ for (const status of [403, 404, 408, 429, 500, 502, 503, 504, 'network']) { await publishMacos(f); assert.deepEqual(waits, [2000, 2000]); assert.equal(cancelled, status === 'network' ? 0 : 2); - assert.equal(f.objects.size, 8); + assert.equal(f.objects.size, 9); assert(f.events.at(-1).key.endsWith('/latest.json')); }); } @@ -593,7 +698,7 @@ test('checksum access retries finish before publishing the completion record', a }; await publishMacos(f); assert.equal(failures, 3); - assert.equal(f.objects.size, 8); + assert.equal(f.objects.size, 9); }); for (const overrides of [ diff --git a/resources/js/components/AppSidebarHeader.vue b/resources/js/components/AppSidebarHeader.vue index 9120c68..6f4f82a 100644 --- a/resources/js/components/AppSidebarHeader.vue +++ b/resources/js/components/AppSidebarHeader.vue @@ -1,6 +1,6 @@