Add permanent macOS app download #164
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "kanbangit-native",
|
"name": "kanbangit-native",
|
||||||
"version": "0.2.0",
|
"version": "0.2.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -24,5 +24,5 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "2.11.4"
|
"@tauri-apps/cli": "2.11.4"
|
||||||
},
|
},
|
||||||
"releaseNotes": "Update to full release v0.2.0"
|
"releaseNotes": "Add the stable Mac download for the KanbanGit website."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ export async function publishMacos({
|
|||||||
await wait(2000);
|
await wait(2000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function assertUnpublishedRelease() {
|
function assertUnpublishedRelease(allowSameVersion = false) {
|
||||||
const file = join(staging, 'published-latest.json');
|
const file = join(staging, 'published-latest.json');
|
||||||
try {
|
try {
|
||||||
s3('get-object', ['--key', latestKey, file]);
|
s3('get-object', ['--key', latestKey, file]);
|
||||||
@@ -261,7 +261,11 @@ export async function publishMacos({
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
const published = JSON.parse(readFileSync(file, 'utf8'));
|
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(
|
throw new Error(
|
||||||
'This version has already been released through the update feed; bump the app version. Published objects were not overwritten.',
|
'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');
|
putReleaseFile('update.json', latest, 'application/json');
|
||||||
await verifyPublic('update.json', latest);
|
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');
|
const latestFile = join(staging, 'latest.json');
|
||||||
writeFileSync(latestFile, latest);
|
writeFileSync(latestFile, latest);
|
||||||
function publishLatest(condition) {
|
function publishLatest(condition) {
|
||||||
|
|||||||
Generated
+1
-1
@@ -1770,7 +1770,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kanbangit-native"
|
name = "kanbangit-native"
|
||||||
version = "0.2.0"
|
version = "0.2.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "kanbangit-native"
|
name = "kanbangit-native"
|
||||||
version = "0.2.0"
|
version = "0.2.1"
|
||||||
description = "KanbanGit native application"
|
description = "KanbanGit native application"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
publish = false
|
publish = false
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "KanbanGit",
|
"productName": "KanbanGit",
|
||||||
"version": "0.2.0",
|
"version": "0.2.1",
|
||||||
"identifier": "de.kanbangit.desktop",
|
"identifier": "de.kanbangit.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../ui"
|
"frontendDist": "../ui"
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
],
|
],
|
||||||
"macOS": {
|
"macOS": {
|
||||||
"minimumSystemVersion": "14.0",
|
"minimumSystemVersion": "14.0",
|
||||||
"bundleVersion": "6",
|
"bundleVersion": "7",
|
||||||
"dmg": {
|
"dmg": {
|
||||||
"windowSize": {
|
"windowSize": {
|
||||||
"width": 660,
|
"width": 660,
|
||||||
|
|||||||
@@ -168,9 +168,9 @@ test('uploads revalidated files and writes completion record after public verifi
|
|||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
f.events.filter((event) => event.operation === 'put-object').length,
|
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(
|
for (const event of f.events.filter(
|
||||||
(event) => event.operation === 'put-object',
|
(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,
|
f.events.at(-1).key,
|
||||||
'releases/macos/aarch64-apple-darwin/latest.json',
|
'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(
|
assert.deepEqual(
|
||||||
JSON.parse(
|
JSON.parse(
|
||||||
f.objects.get(
|
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', '{}']) {
|
for (const response of ['', ' \n\t', '{}']) {
|
||||||
test(`unversioned bucket response ${JSON.stringify(response)} permits a verified upload`, async (t) => {
|
test(`unversioned bucket response ${JSON.stringify(response)} permits a verified upload`, async (t) => {
|
||||||
const f = fixture(t);
|
const f = fixture(t);
|
||||||
@@ -211,7 +316,7 @@ for (const response of ['', ' \n\t', '{}']) {
|
|||||||
return args.includes('get-bucket-versioning') ? response : result;
|
return args.includes('get-bucket-versioning') ? response : result;
|
||||||
};
|
};
|
||||||
await publishMacos(f);
|
await publishMacos(f);
|
||||||
assert.equal(f.objects.size, 8);
|
assert.equal(f.objects.size, 9);
|
||||||
assert.equal(f.events.at(-1).operation, 'public-get');
|
assert.equal(f.events.at(-1).operation, 'public-get');
|
||||||
assert(f.events.at(-1).key.endsWith('/latest.json'));
|
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.deepEqual([...f.objects.entries()], originals);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
f.events.filter((event) => event.operation === 'get-object').length,
|
f.events.filter((event) => event.operation === 'get-object').length,
|
||||||
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);
|
assert.equal(f.objects.size, 3);
|
||||||
f.run = run;
|
f.run = run;
|
||||||
await publishMacos(f);
|
await publishMacos(f);
|
||||||
assert.equal(f.objects.size, 8);
|
assert.equal(f.objects.size, 9);
|
||||||
});
|
});
|
||||||
|
|
||||||
function rebuild(f) {
|
function rebuild(f) {
|
||||||
@@ -387,7 +492,7 @@ for (const previous of [null, '1.0.0']) {
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
await publishMacos(f);
|
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)).version, '1.2.3');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
JSON.parse(f.objects.get(latestKey)).notes,
|
JSON.parse(f.objects.get(latestKey)).notes,
|
||||||
@@ -516,7 +621,7 @@ for (const listing of [
|
|||||||
!('Contents' in listing)
|
!('Contents' in listing)
|
||||||
) {
|
) {
|
||||||
await publishMacos(f);
|
await publishMacos(f);
|
||||||
assert.equal(f.objects.size, 8);
|
assert.equal(f.objects.size, 9);
|
||||||
} else {
|
} else {
|
||||||
await assert.rejects(publishMacos(f), /NoneType|AccessDenied/);
|
await assert.rejects(publishMacos(f), /NoneType|AccessDenied/);
|
||||||
assert.equal(f.objects.get(key).toString(), 'unfinished old build');
|
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);
|
await publishMacos(f);
|
||||||
assert.deepEqual(waits, [2000, 2000]);
|
assert.deepEqual(waits, [2000, 2000]);
|
||||||
assert.equal(cancelled, status === 'network' ? 0 : 2);
|
assert.equal(cancelled, status === 'network' ? 0 : 2);
|
||||||
assert.equal(f.objects.size, 8);
|
assert.equal(f.objects.size, 9);
|
||||||
assert(f.events.at(-1).key.endsWith('/latest.json'));
|
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);
|
await publishMacos(f);
|
||||||
assert.equal(failures, 3);
|
assert.equal(failures, 3);
|
||||||
assert.equal(f.objects.size, 8);
|
assert.equal(f.objects.size, 9);
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const overrides of [
|
for (const overrides of [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Link, usePage } from '@inertiajs/vue3';
|
import { Link, usePage } from '@inertiajs/vue3';
|
||||||
import { Monitor, Moon, Plus, Sun } from 'lucide-vue-next';
|
import { Download, Monitor, Moon, Plus, Sun } from 'lucide-vue-next';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import Breadcrumbs from '@/components/Breadcrumbs.vue';
|
import Breadcrumbs from '@/components/Breadcrumbs.vue';
|
||||||
import UserMenuContent from '@/components/UserMenuContent.vue';
|
import UserMenuContent from '@/components/UserMenuContent.vue';
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
} from '@/composables/useAppearance';
|
} from '@/composables/useAppearance';
|
||||||
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { useMacDownload } from '@/composables/useMacDownload';
|
||||||
import { create as createProject } from '@/routes/projects';
|
import { create as createProject } from '@/routes/projects';
|
||||||
import { index as board } from '@/routes/projects/board';
|
import { index as board } from '@/routes/projects/board';
|
||||||
import { create as createTicket } from '@/routes/projects/tickets';
|
import { create as createTicket } from '@/routes/projects/tickets';
|
||||||
@@ -35,6 +36,7 @@ const page = usePage();
|
|||||||
const { project } = useAppNavigation();
|
const { project } = useAppNavigation();
|
||||||
const { isCurrentUrl } = useCurrentUrl();
|
const { isCurrentUrl } = useCurrentUrl();
|
||||||
const { t } = useLocalization();
|
const { t } = useLocalization();
|
||||||
|
const { macDownloadUrl, showMacDownload } = useMacDownload();
|
||||||
const { appearance, updateAppearance } = useAppearance();
|
const { appearance, updateAppearance } = useAppearance();
|
||||||
const themeIcon = computed(
|
const themeIcon = computed(
|
||||||
() => ({ light: Sun, dark: Moon, system: Monitor })[appearance.value],
|
() => ({ light: Sun, dark: Moon, system: Monitor })[appearance.value],
|
||||||
@@ -69,6 +71,21 @@ const createAction = computed(() => {
|
|||||||
<span v-else class="truncate font-semibold">KanbanGit</span>
|
<span v-else class="truncate font-semibold">KanbanGit</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex shrink-0 items-center gap-1 sm:gap-2">
|
<div class="flex shrink-0 items-center gap-1 sm:gap-2">
|
||||||
|
<Button
|
||||||
|
v-if="showMacDownload"
|
||||||
|
as-child
|
||||||
|
variant="outline"
|
||||||
|
:aria-label="t('navigation.downloadAppAppleSilicon')"
|
||||||
|
:title="t('navigation.downloadAppAppleSilicon')"
|
||||||
|
class="px-3"
|
||||||
|
>
|
||||||
|
<a :href="macDownloadUrl"
|
||||||
|
><Download aria-hidden="true" /><span
|
||||||
|
class="hidden sm:inline"
|
||||||
|
>{{ t('navigation.downloadApp') }}</span
|
||||||
|
></a
|
||||||
|
>
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
v-if="createAction"
|
v-if="createAction"
|
||||||
as-child
|
as-child
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import updateSource from '../../../native/update-source.json';
|
||||||
|
|
||||||
|
export const macDownloadUrl = `${updateSource.baseUrl}/aarch64-apple-darwin/KanbanGit.dmg`;
|
||||||
|
|
||||||
|
export function isMacDownloadAvailable(
|
||||||
|
userAgent: string,
|
||||||
|
maxTouchPoints: number,
|
||||||
|
isTauri: unknown,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
userAgent.includes('Macintosh') &&
|
||||||
|
maxTouchPoints < 2 &&
|
||||||
|
isTauri !== true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMacDownload() {
|
||||||
|
const showMacDownload = ref(false);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
showMacDownload.value = isMacDownloadAvailable(
|
||||||
|
navigator.userAgent,
|
||||||
|
navigator.maxTouchPoints,
|
||||||
|
Reflect.get(window, 'isTauri'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { macDownloadUrl, showMacDownload };
|
||||||
|
}
|
||||||
@@ -1,20 +1,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Link } from '@inertiajs/vue3';
|
import { Link } from '@inertiajs/vue3';
|
||||||
import { ShieldCheck } from 'lucide-vue-next';
|
import { Download, ShieldCheck } from 'lucide-vue-next';
|
||||||
import type { Component } from 'vue';
|
import type { Component } from 'vue';
|
||||||
import AppLogo from '@/components/AppLogo.vue';
|
import AppLogo from '@/components/AppLogo.vue';
|
||||||
import AppearanceTabs from '@/components/AppearanceTabs.vue';
|
import AppearanceTabs from '@/components/AppearanceTabs.vue';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { useMacDownload } from '@/composables/useMacDownload';
|
||||||
import { home } from '@/routes/index';
|
import { home } from '@/routes/index';
|
||||||
|
|
||||||
defineProps<{ title: string; description: string; icon?: Component }>();
|
defineProps<{ title: string; description: string; icon?: Component }>();
|
||||||
const { t } = useLocalization();
|
const { t } = useLocalization();
|
||||||
|
const { macDownloadUrl, showMacDownload } = useMacDownload();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="bg-background text-foreground flex min-h-dvh flex-col">
|
<div class="bg-background text-foreground flex min-h-dvh flex-col">
|
||||||
<header
|
<header
|
||||||
class="bg-card px-page flex justify-center border-b py-4 sm:justify-start"
|
class="bg-card px-page flex items-center justify-between gap-3 border-b py-4"
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
:href="home()"
|
:href="home()"
|
||||||
@@ -22,6 +25,21 @@ const { t } = useLocalization();
|
|||||||
class="focus-visible:ring-ring/35 rounded-md outline-none focus-visible:ring-2"
|
class="focus-visible:ring-ring/35 rounded-md outline-none focus-visible:ring-2"
|
||||||
><AppLogo
|
><AppLogo
|
||||||
/></Link>
|
/></Link>
|
||||||
|
<Button
|
||||||
|
v-if="showMacDownload"
|
||||||
|
as-child
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
:aria-label="t('navigation.downloadAppAppleSilicon')"
|
||||||
|
:title="t('navigation.downloadAppAppleSilicon')"
|
||||||
|
>
|
||||||
|
<a :href="macDownloadUrl"
|
||||||
|
><Download aria-hidden="true" /><span
|
||||||
|
class="hidden sm:inline"
|
||||||
|
>{{ t('navigation.downloadApp') }}</span
|
||||||
|
></a
|
||||||
|
>
|
||||||
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
<main
|
<main
|
||||||
class="gap-column sm:px-page mx-auto flex w-full max-w-md flex-1 flex-col justify-center px-4 py-8 sm:py-12"
|
class="gap-column sm:px-page mx-auto flex w-full max-w-md flex-1 flex-col justify-center px-4 py-8 sm:py-12"
|
||||||
|
|||||||
@@ -718,6 +718,7 @@ export const en = {
|
|||||||
'welcome.action.dashboard': 'Open dashboard',
|
'welcome.action.dashboard': 'Open dashboard',
|
||||||
'welcome.action.dashboardShort': 'Dashboard',
|
'welcome.action.dashboardShort': 'Dashboard',
|
||||||
'welcome.actionHint': 'One workspace for boards, tickets, and Git context.',
|
'welcome.actionHint': 'One workspace for boards, tickets, and Git context.',
|
||||||
|
'welcome.downloadAppHint': 'For Macs with Apple Silicon.',
|
||||||
'welcome.preview.label': 'KanbanGit board preview',
|
'welcome.preview.label': 'KanbanGit board preview',
|
||||||
'welcome.preview.project': 'Product delivery board',
|
'welcome.preview.project': 'Product delivery board',
|
||||||
'welcome.preview.connected': 'Git connected',
|
'welcome.preview.connected': 'Git connected',
|
||||||
@@ -899,6 +900,9 @@ export const en = {
|
|||||||
'settings.eyebrow': 'Account settings',
|
'settings.eyebrow': 'Account settings',
|
||||||
'navigation.createTicket': 'New ticket',
|
'navigation.createTicket': 'New ticket',
|
||||||
'navigation.createProject': 'New project',
|
'navigation.createProject': 'New project',
|
||||||
|
'navigation.downloadApp': 'Download App',
|
||||||
|
'navigation.downloadAppAppleSilicon':
|
||||||
|
'Download App for Macs with Apple Silicon',
|
||||||
'navigation.open': 'Open navigation',
|
'navigation.open': 'Open navigation',
|
||||||
'navigation.close': 'Close navigation',
|
'navigation.close': 'Close navigation',
|
||||||
'navigation.expand': 'Expand sidebar',
|
'navigation.expand': 'Expand sidebar',
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, Link } from '@inertiajs/vue3';
|
import { Head, Link } from '@inertiajs/vue3';
|
||||||
import { ArrowRight, Columns3, GitBranch, ListChecks } from 'lucide-vue-next';
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
Columns3,
|
||||||
|
Download,
|
||||||
|
GitBranch,
|
||||||
|
ListChecks,
|
||||||
|
} from 'lucide-vue-next';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import AppLogo from '@/components/AppLogo.vue';
|
import AppLogo from '@/components/AppLogo.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { useMacDownload } from '@/composables/useMacDownload';
|
||||||
import { dashboard, home, login } from '@/routes/index';
|
import { dashboard, home, login } from '@/routes/index';
|
||||||
|
|
||||||
const { t } = useLocalization();
|
const { t } = useLocalization();
|
||||||
|
const { macDownloadUrl, showMacDownload } = useMacDownload();
|
||||||
|
|
||||||
const features = computed(() => [
|
const features = computed(() => [
|
||||||
{
|
{
|
||||||
@@ -112,16 +120,38 @@ const previewColumns = computed(() => [
|
|||||||
{{ t('welcome.description') }}
|
{{ t('welcome.description') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button as-child size="lg">
|
<div
|
||||||
<Link v-if="$page.props.auth.user" :href="dashboard()">
|
class="flex flex-wrap items-start justify-center gap-3"
|
||||||
{{ t('welcome.action.dashboard') }}
|
>
|
||||||
<ArrowRight aria-hidden="true" />
|
<Button as-child size="lg">
|
||||||
</Link>
|
<Link
|
||||||
<Link v-else :href="login()">
|
v-if="$page.props.auth.user"
|
||||||
{{ t('welcome.action.signIn') }}
|
:href="dashboard()"
|
||||||
<ArrowRight aria-hidden="true" />
|
>
|
||||||
</Link>
|
{{ t('welcome.action.dashboard') }}
|
||||||
</Button>
|
<ArrowRight aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
<Link v-else :href="login()">
|
||||||
|
{{ t('welcome.action.signIn') }}
|
||||||
|
<ArrowRight aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<div
|
||||||
|
v-if="showMacDownload"
|
||||||
|
class="grid justify-items-center gap-1"
|
||||||
|
>
|
||||||
|
<Button as-child size="lg" variant="outline">
|
||||||
|
<a :href="macDownloadUrl"
|
||||||
|
><Download aria-hidden="true" />{{
|
||||||
|
t('navigation.downloadApp')
|
||||||
|
}}</a
|
||||||
|
>
|
||||||
|
</Button>
|
||||||
|
<p class="text-caption text-muted-foreground">
|
||||||
|
{{ t('welcome.downloadAppHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<p class="text-caption text-muted-foreground">
|
<p class="text-caption text-muted-foreground">
|
||||||
{{ t('welcome.actionHint') }}
|
{{ t('welcome.actionHint') }}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -90,10 +90,32 @@ if (serve) {
|
|||||||
const { en } = await server.ssrLoadModule(
|
const { en } = await server.ssrLoadModule(
|
||||||
'/resources/js/locales/en.ts',
|
'/resources/js/locales/en.ts',
|
||||||
);
|
);
|
||||||
|
const { isMacDownloadAvailable, macDownloadUrl } =
|
||||||
|
await server.ssrLoadModule(
|
||||||
|
'/resources/js/composables/useMacDownload.ts',
|
||||||
|
);
|
||||||
const { default: Welcome } = await server.ssrLoadModule(
|
const { default: Welcome } = await server.ssrLoadModule(
|
||||||
'/resources/js/pages/Welcome.vue',
|
'/resources/js/pages/Welcome.vue',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
macDownloadUrl,
|
||||||
|
'https://petsch-studios.nbg1.your-objectstorage.com/releases/macos/aarch64-apple-darwin/KanbanGit.dmg',
|
||||||
|
);
|
||||||
|
const macUserAgent =
|
||||||
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15';
|
||||||
|
assert.equal(isMacDownloadAvailable(macUserAgent, 0, undefined), true);
|
||||||
|
assert.equal(isMacDownloadAvailable(macUserAgent, 0, true), false);
|
||||||
|
assert.equal(isMacDownloadAvailable(macUserAgent, 5, undefined), false);
|
||||||
|
assert.equal(
|
||||||
|
isMacDownloadAvailable(
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0)',
|
||||||
|
0,
|
||||||
|
undefined,
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
const render = async (url, localization = createLocalization()) => {
|
const render = async (url, localization = createLocalization()) => {
|
||||||
const response = await createInertiaApp({
|
const response = await createInertiaApp({
|
||||||
page: makePage(url),
|
page: makePage(url),
|
||||||
|
|||||||
Reference in New Issue
Block a user