feat(KAN-70): integrate native workspace and update settings
CI/CD / preconditions (pull_request) Successful in 1s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m2s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m38s
CI/CD / test_and_check_app (pull_request) Successful in 6m47s
CI/CD / Publish immutable image (pull_request) Skipped
CI/CD / preconditions (pull_request) Successful in 1s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m2s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m38s
CI/CD / test_and_check_app (pull_request) Successful in 6m47s
CI/CD / Publish immutable image (pull_request) Skipped
This commit is contained in:
@@ -3,6 +3,10 @@ import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
|
||||
import type { DefineComponent } from 'vue';
|
||||
import { createApp, h } from 'vue';
|
||||
import '../css/app.css';
|
||||
import {
|
||||
initializeNativeApp,
|
||||
registerNativeRecovery,
|
||||
} from '@/composables/useNativeApp';
|
||||
import { createAppearance } from '@/composables/useAppearance';
|
||||
import { createLocalization } from '@/lib/localization';
|
||||
|
||||
@@ -21,6 +25,11 @@ createInertiaApp({
|
||||
.use(createLocalization(props.initialPage.props.localization))
|
||||
.use(createAppearance(props.initialPage.props.appearance))
|
||||
.mount(el);
|
||||
registerNativeRecovery();
|
||||
window.addEventListener('kanban-native-ready', () => {
|
||||
void initializeNativeApp();
|
||||
});
|
||||
void initializeNativeApp();
|
||||
},
|
||||
progress: {
|
||||
color: 'var(--primary)',
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { computed, readonly, ref } from 'vue';
|
||||
import { isNativeAppState, updateIsBusy } from '@/lib/native-app';
|
||||
import type { NativeAction, NativeAppState } from '@/types/native-app';
|
||||
|
||||
const state = ref<NativeAppState | null>(null);
|
||||
const pending = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
let initializing = false;
|
||||
let refreshing: Promise<void> | null = null;
|
||||
|
||||
async function request(
|
||||
action: NativeAction,
|
||||
payload: Record<string, unknown> = {},
|
||||
) {
|
||||
if (typeof window === 'undefined' || window.kanbanNative?.version !== 1) {
|
||||
throw new Error('bridgeUnavailable');
|
||||
}
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
window.kanbanNative.request(action, payload),
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error('bridgeTimeout')),
|
||||
11000,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (refreshing) {
|
||||
return refreshing;
|
||||
}
|
||||
refreshing = (async () => {
|
||||
const result = await request('getState');
|
||||
if (!isNativeAppState(result)) {
|
||||
throw new Error('incompatibleVersion');
|
||||
}
|
||||
state.value = result;
|
||||
})();
|
||||
try {
|
||||
await refreshing;
|
||||
} finally {
|
||||
refreshing = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function initializeNativeApp() {
|
||||
if (
|
||||
initializing ||
|
||||
state.value ||
|
||||
typeof window === 'undefined' ||
|
||||
window.kanbanNative?.version !== 1
|
||||
) {
|
||||
return;
|
||||
}
|
||||
initializing = true;
|
||||
try {
|
||||
await refresh();
|
||||
await request('settingsReady');
|
||||
} catch {
|
||||
state.value = null;
|
||||
} finally {
|
||||
initializing = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function useNativeApp() {
|
||||
const busy = computed(
|
||||
() => pending.value || updateIsBusy(state.value?.updates),
|
||||
);
|
||||
async function act(
|
||||
action: NativeAction,
|
||||
payload: Record<string, unknown> = {},
|
||||
) {
|
||||
if (
|
||||
pending.value ||
|
||||
(updateIsBusy(state.value?.updates) && action !== 'openSettings')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
pending.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
await request(action, payload);
|
||||
if (action !== 'setWorkspace') {
|
||||
await refresh();
|
||||
}
|
||||
return true;
|
||||
} catch (cause) {
|
||||
error.value =
|
||||
cause instanceof Error ? cause.message : 'bridgeUnavailable';
|
||||
return false;
|
||||
} finally {
|
||||
pending.value = false;
|
||||
}
|
||||
}
|
||||
async function poll() {
|
||||
try {
|
||||
await refresh();
|
||||
} catch (cause) {
|
||||
error.value =
|
||||
cause instanceof Error ? cause.message : 'bridgeUnavailable';
|
||||
}
|
||||
}
|
||||
return { state: readonly(state), busy, error: readonly(error), act, poll };
|
||||
}
|
||||
|
||||
export async function recoverNativeWorkspace() {
|
||||
if (!state.value) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await request('openSettings');
|
||||
} catch {
|
||||
/* Native recovery remains available from the app menu or toolbar. */
|
||||
}
|
||||
}
|
||||
|
||||
export function registerNativeRecovery() {
|
||||
window.addEventListener('offline', () => {
|
||||
void recoverNativeWorkspace();
|
||||
});
|
||||
document.addEventListener('inertia:exception', () => {
|
||||
void recoverNativeWorkspace();
|
||||
});
|
||||
document.addEventListener('inertia:invalid', (event) => {
|
||||
if (event.detail.response.status >= 400) {
|
||||
void recoverNativeWorkspace();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -6,11 +6,14 @@ import AppLogo from '@/components/AppLogo.vue';
|
||||
import AppearanceTabs from '@/components/AppearanceTabs.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { useNativeApp } from '@/composables/useNativeApp';
|
||||
import { edit as editNativeApp } from '@/routes/native-app';
|
||||
import { useMacDownload } from '@/composables/useMacDownload';
|
||||
import { home } from '@/routes/index';
|
||||
|
||||
defineProps<{ title: string; description: string; icon?: Component }>();
|
||||
const { t } = useLocalization();
|
||||
const { state: nativeApp } = useNativeApp();
|
||||
const { macDownloadUrl, showMacDownload } = useMacDownload();
|
||||
</script>
|
||||
|
||||
@@ -25,6 +28,9 @@ const { macDownloadUrl, showMacDownload } = useMacDownload();
|
||||
class="focus-visible:ring-ring/35 rounded-md outline-none focus-visible:ring-2"
|
||||
><AppLogo
|
||||
/></Link>
|
||||
<Button v-if="nativeApp" as-child variant="outline" size="sm">
|
||||
<Link :href="editNativeApp()">{{ t('nativeApp.title') }}</Link>
|
||||
</Button>
|
||||
<Button
|
||||
v-if="showMacDownload"
|
||||
as-child
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import {
|
||||
AppWindow,
|
||||
GitBranch,
|
||||
KeyRound,
|
||||
Palette,
|
||||
@@ -10,6 +11,8 @@ import {
|
||||
} from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||
import { useNativeApp } from '@/composables/useNativeApp';
|
||||
import { edit as editNativeApp } from '@/routes/native-app';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { toUrl } from '@/lib/utils';
|
||||
import { edit as editAppearance } from '@/routes/appearance/index';
|
||||
@@ -20,6 +23,7 @@ import { edit as editSecurity } from '@/routes/security/index';
|
||||
import type { NavItem } from '@/types';
|
||||
|
||||
const { t } = useLocalization();
|
||||
const { state: nativeApp } = useNativeApp();
|
||||
const sidebarNavItems = computed<NavItem[]>(() => [
|
||||
{
|
||||
title: t('navigation.profile'),
|
||||
@@ -41,6 +45,15 @@ const sidebarNavItems = computed<NavItem[]>(() => [
|
||||
href: editApplications(),
|
||||
icon: KeyRound,
|
||||
},
|
||||
...(nativeApp.value
|
||||
? [
|
||||
{
|
||||
title: t('nativeApp.title'),
|
||||
href: editNativeApp(),
|
||||
icon: AppWindow,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: t('navigation.appearance'),
|
||||
href: editAppearance(),
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { NativeAppState, NativeUpdates } from '@/types/native-app';
|
||||
|
||||
export function isNativeAppState(value: unknown): value is NativeAppState {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const state = value as NativeAppState;
|
||||
if (
|
||||
state.apiVersion !== 1 ||
|
||||
!['macos', 'ios'].includes(state.platform) ||
|
||||
typeof state.version !== 'string' ||
|
||||
typeof state.workspace !== 'string' ||
|
||||
!Array.isArray(state.capabilities) ||
|
||||
!state.capabilities.includes('workspace')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if (new URL(state.workspace).protocol !== 'https:') {
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!state.capabilities.includes('updates')) {
|
||||
return state.updates === undefined;
|
||||
}
|
||||
if (state.platform !== 'macos' || !state.updates) {
|
||||
return false;
|
||||
}
|
||||
const updates = state.updates;
|
||||
return (
|
||||
[
|
||||
'idle',
|
||||
'checking',
|
||||
'available',
|
||||
'current',
|
||||
'downloading',
|
||||
'verifying',
|
||||
'installing',
|
||||
'restarting',
|
||||
'error',
|
||||
].includes(updates.phase) &&
|
||||
typeof updates.currentVersion === 'string' &&
|
||||
typeof updates.build === 'string' &&
|
||||
(updates.version === null || typeof updates.version === 'string') &&
|
||||
(updates.notes === null || typeof updates.notes === 'string') &&
|
||||
(updates.error === null || typeof updates.error === 'string') &&
|
||||
Number.isFinite(updates.downloaded) &&
|
||||
updates.downloaded >= 0 &&
|
||||
(updates.total === null ||
|
||||
(Number.isFinite(updates.total) && updates.total >= 0)) &&
|
||||
typeof updates.preferences?.automatic === 'boolean' &&
|
||||
[1, 6, 24].includes(updates.preferences.intervalHours)
|
||||
);
|
||||
}
|
||||
|
||||
export function updateIsBusy(updates?: NativeUpdates): boolean {
|
||||
return (
|
||||
!!updates &&
|
||||
[
|
||||
'checking',
|
||||
'downloading',
|
||||
'verifying',
|
||||
'installing',
|
||||
'restarting',
|
||||
].includes(updates.phase)
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,63 @@
|
||||
export const en = {
|
||||
'nativeApp.title': 'App',
|
||||
'nativeApp.description': 'Manage the KanbanGit app on this device.',
|
||||
'nativeApp.workspace': 'Workspace',
|
||||
'nativeApp.server': 'Server address',
|
||||
'nativeApp.serverHelp':
|
||||
'Use an HTTPS installation address without login details, a query or a fragment. This choice is saved on this device.',
|
||||
'nativeApp.switch': 'Change workspace',
|
||||
'nativeApp.confirmSwitch': 'Switch workspace',
|
||||
'nativeApp.switchWarning':
|
||||
'Switching closes your current workspace and discards unsaved inputs. You will sign in separately on the new server.',
|
||||
'nativeApp.cancel': 'Cancel',
|
||||
'nativeApp.unavailable':
|
||||
'App settings are available in a compatible KanbanGit app.',
|
||||
'nativeApp.updates': 'Software updates',
|
||||
'nativeApp.version': 'Installed version {version} (build {build})',
|
||||
'nativeApp.mobileVersion': 'App version {version}',
|
||||
'nativeApp.availableVersion': 'Available version: {version}',
|
||||
'nativeApp.automatic': 'Automatically check for updates',
|
||||
'nativeApp.interval': 'Check interval',
|
||||
'nativeApp.hours': 'Every {hours} hours',
|
||||
'nativeApp.updateHelp':
|
||||
'Checks run at startup and at the selected interval. Installation and restart require your confirmation.',
|
||||
'nativeApp.check': 'Check for updates now',
|
||||
'nativeApp.install': 'Install update',
|
||||
'nativeApp.installRestart': 'Install and restart',
|
||||
'nativeApp.installWarning':
|
||||
'Save your work before continuing. KanbanGit will download and verify the update, then install it and restart. Unsaved inputs will be lost. Your workspace settings and stored sessions are kept.',
|
||||
'nativeApp.progress': 'Update progress',
|
||||
'nativeApp.recovery': 'Open native server settings',
|
||||
'nativeApp.bridgeError':
|
||||
'The app connection is unavailable or incompatible. Open the native server settings to reconnect, or restart the app.',
|
||||
'nativeApp.invalidServer':
|
||||
'Enter a valid HTTPS installation address without login details, a query or a fragment.',
|
||||
'nativeApp.storageError':
|
||||
'The workspace could not be saved. Check local storage permissions and try again.',
|
||||
'nativeApp.networkError':
|
||||
'The update service or download is unreachable. Check your connection and try again.',
|
||||
'nativeApp.metadataError':
|
||||
'The update information is invalid or does not match this Mac. Try again later.',
|
||||
'nativeApp.signatureError':
|
||||
'The update signature could not be verified. Nothing was installed. Try again or contact support.',
|
||||
'nativeApp.verificationError':
|
||||
'The downloaded app does not match its announced version or publisher. Nothing was installed.',
|
||||
'nativeApp.installationError':
|
||||
'The update could not be installed. Move KanbanGit to Applications and check disk space and permissions.',
|
||||
'nativeApp.preferencesError':
|
||||
'Update preferences could not be saved. Check local storage permissions and try again.',
|
||||
'nativeApp.updateBusy':
|
||||
'An update operation is already running. Please wait.',
|
||||
'nativeApp.phase.idle': 'Check for the latest stable version of KanbanGit.',
|
||||
'nativeApp.phase.checking': 'Checking for updates…',
|
||||
'nativeApp.phase.available': 'A new version of KanbanGit is available.',
|
||||
'nativeApp.phase.current': 'KanbanGit is up to date.',
|
||||
'nativeApp.phase.downloading': 'Downloading update…',
|
||||
'nativeApp.phase.verifying': 'Verifying update…',
|
||||
'nativeApp.phase.installing': 'Installing update…',
|
||||
'nativeApp.phase.restarting': 'Restarting KanbanGit…',
|
||||
'nativeApp.phase.error': 'The update could not be completed.',
|
||||
|
||||
'projectAccess.pageTitle': '{project} · Project access',
|
||||
'projectAccess.title': 'Project access',
|
||||
'projectAccess.description':
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, Link, usePage } from '@inertiajs/vue3';
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { useNativeApp } from '@/composables/useNativeApp';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
||||
import { home } from '@/routes';
|
||||
import { edit } from '@/routes/native-app';
|
||||
import type { TranslationKey } from '@/types/localization';
|
||||
|
||||
const { t } = useLocalization();
|
||||
const { state, busy, error, act, poll } = useNativeApp();
|
||||
const page = usePage();
|
||||
const signedIn = computed(() => !!page.props.auth.user);
|
||||
const address = ref('');
|
||||
const confirmSwitch = ref(false);
|
||||
const confirmInstall = ref(false);
|
||||
const updates = computed(() => state.value?.updates);
|
||||
watch(
|
||||
() => updates.value?.version,
|
||||
() => {
|
||||
confirmInstall.value = false;
|
||||
},
|
||||
);
|
||||
const breadcrumbs = computed(() => [
|
||||
{ title: t('nativeApp.title'), href: edit() },
|
||||
]);
|
||||
watch(
|
||||
() => state.value?.workspace,
|
||||
(workspace) => {
|
||||
if (workspace) {
|
||||
address.value = workspace;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
watch(address, () => {
|
||||
confirmSwitch.value = false;
|
||||
});
|
||||
const knownErrors: Record<string, TranslationKey> = {
|
||||
invalidServer: 'nativeApp.invalidServer',
|
||||
storageError: 'nativeApp.storageError',
|
||||
networkError: 'nativeApp.networkError',
|
||||
metadataError: 'nativeApp.metadataError',
|
||||
signatureError: 'nativeApp.signatureError',
|
||||
verificationError: 'nativeApp.verificationError',
|
||||
installationError: 'nativeApp.installationError',
|
||||
preferencesError: 'nativeApp.preferencesError',
|
||||
updateBusy: 'nativeApp.updateBusy',
|
||||
};
|
||||
const errorMessage = computed(() => {
|
||||
const value = error.value ?? updates.value?.error;
|
||||
return value ? t(knownErrors[value] ?? 'nativeApp.bridgeError') : null;
|
||||
});
|
||||
const progress = computed(() =>
|
||||
updates.value?.total
|
||||
? Math.min(
|
||||
100,
|
||||
Math.round(
|
||||
(updates.value.downloaded / updates.value.total) * 100,
|
||||
),
|
||||
)
|
||||
: undefined,
|
||||
);
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let active = true;
|
||||
async function refresh() {
|
||||
if (state.value) {
|
||||
await poll();
|
||||
}
|
||||
if (active) {
|
||||
timer = setTimeout(refresh, 1500);
|
||||
}
|
||||
}
|
||||
onMounted(() => {
|
||||
void refresh();
|
||||
});
|
||||
onUnmounted(() => {
|
||||
active = false;
|
||||
clearTimeout(timer);
|
||||
});
|
||||
async function switchWorkspace() {
|
||||
if (await act('setWorkspace', { server: address.value })) {
|
||||
confirmSwitch.value = false;
|
||||
}
|
||||
}
|
||||
async function installUpdate() {
|
||||
const version = updates.value?.version;
|
||||
if (version && (await act('installUpdate', { version }))) {
|
||||
confirmInstall.value = false;
|
||||
}
|
||||
}
|
||||
function setAutomatic(event: Event) {
|
||||
void act('setUpdatePreferences', {
|
||||
automatic: (event.target as HTMLInputElement).checked,
|
||||
intervalHours: updates.value?.preferences.intervalHours,
|
||||
});
|
||||
}
|
||||
function setIntervalHours(event: Event) {
|
||||
void act('setUpdatePreferences', {
|
||||
automatic: updates.value?.preferences.automatic,
|
||||
intervalHours: Number((event.target as HTMLSelectElement).value),
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="signedIn ? AppLayout : 'main'"
|
||||
:breadcrumbs="breadcrumbs"
|
||||
class="min-h-dvh"
|
||||
>
|
||||
<Head :title="t('nativeApp.title')" />
|
||||
<component
|
||||
:is="signedIn ? SettingsLayout : 'div'"
|
||||
:class="signedIn ? '' : 'mx-auto grid max-w-3xl gap-6 p-6'"
|
||||
>
|
||||
<Link
|
||||
v-if="!signedIn"
|
||||
:href="home()"
|
||||
class="text-primary underline"
|
||||
>{{ t('auth.home') }}</Link
|
||||
>
|
||||
<header class="grid gap-2">
|
||||
<h2 class="text-title">{{ t('nativeApp.title') }}</h2>
|
||||
<p class="text-muted-foreground">
|
||||
{{ t('nativeApp.description') }}
|
||||
</p>
|
||||
</header>
|
||||
<p v-if="!state" role="status">{{ t('nativeApp.unavailable') }}</p>
|
||||
<template v-else>
|
||||
<form class="grid gap-4" @submit.prevent="confirmSwitch = true">
|
||||
<h3 class="text-lg font-semibold">
|
||||
{{ t('nativeApp.workspace') }}
|
||||
</h3>
|
||||
<Label for="native-server">{{
|
||||
t('nativeApp.server')
|
||||
}}</Label>
|
||||
<Input
|
||||
id="native-server"
|
||||
v-model="address"
|
||||
type="url"
|
||||
required
|
||||
autocomplete="url"
|
||||
:disabled="busy"
|
||||
/>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ t('nativeApp.serverHelp') }}
|
||||
</p>
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="busy || address === state.workspace"
|
||||
class="justify-self-start"
|
||||
>{{ t('nativeApp.switch') }}</Button
|
||||
>
|
||||
<div
|
||||
v-if="confirmSwitch"
|
||||
class="grid gap-3 rounded-lg border p-4"
|
||||
role="group"
|
||||
:aria-label="t('nativeApp.switch')"
|
||||
>
|
||||
<p>{{ t('nativeApp.switchWarning') }}</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
:disabled="busy"
|
||||
@click="switchWorkspace"
|
||||
>{{ t('nativeApp.confirmSwitch') }}</Button
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="busy"
|
||||
@click="confirmSwitch = false"
|
||||
>{{ t('nativeApp.cancel') }}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<section
|
||||
v-if="updates"
|
||||
class="grid gap-4 border-t pt-6"
|
||||
aria-labelledby="native-updates"
|
||||
>
|
||||
<h3 id="native-updates" class="text-lg font-semibold">
|
||||
{{ t('nativeApp.updates') }}
|
||||
</h3>
|
||||
<p>
|
||||
{{
|
||||
t('nativeApp.version', {
|
||||
version: updates.currentVersion,
|
||||
build: updates.build,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<p role="status" aria-live="polite">
|
||||
{{ t(`nativeApp.phase.${updates.phase}`) }}
|
||||
</p>
|
||||
<template
|
||||
v-if="
|
||||
['downloading', 'verifying', 'installing'].includes(
|
||||
updates.phase,
|
||||
)
|
||||
"
|
||||
>
|
||||
<progress
|
||||
:value="progress"
|
||||
max="100"
|
||||
class="w-full"
|
||||
:aria-label="t('nativeApp.progress')"
|
||||
/>
|
||||
<span v-if="progress !== undefined"
|
||||
>{{ progress }}%</span
|
||||
>
|
||||
</template>
|
||||
<Label class="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="updates.preferences.automatic"
|
||||
:disabled="busy"
|
||||
@change="setAutomatic"
|
||||
/>
|
||||
{{ t('nativeApp.automatic') }}
|
||||
</Label>
|
||||
<Label for="native-update-interval">{{
|
||||
t('nativeApp.interval')
|
||||
}}</Label>
|
||||
<select
|
||||
id="native-update-interval"
|
||||
:value="updates.preferences.intervalHours"
|
||||
:disabled="busy"
|
||||
class="bg-background rounded-md border p-2"
|
||||
@change="setIntervalHours"
|
||||
>
|
||||
<option
|
||||
v-for="hours in [1, 6, 24]"
|
||||
:key="hours"
|
||||
:value="hours"
|
||||
>
|
||||
{{ t('nativeApp.hours', { hours }) }}
|
||||
</option>
|
||||
</select>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ t('nativeApp.updateHelp') }}
|
||||
</p>
|
||||
<Button
|
||||
:disabled="busy"
|
||||
class="justify-self-start"
|
||||
@click="act('checkForUpdates')"
|
||||
>{{ t('nativeApp.check') }}</Button
|
||||
>
|
||||
<template v-if="updates.version">
|
||||
<p>
|
||||
{{
|
||||
t('nativeApp.availableVersion', {
|
||||
version: updates.version,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<p v-if="updates.notes" class="whitespace-pre-wrap">
|
||||
{{ updates.notes }}
|
||||
</p>
|
||||
<Button
|
||||
:disabled="busy"
|
||||
class="justify-self-start"
|
||||
@click="confirmInstall = true"
|
||||
>{{ t('nativeApp.install') }}</Button
|
||||
>
|
||||
<div
|
||||
v-if="confirmInstall"
|
||||
class="grid gap-3 rounded-lg border p-4"
|
||||
role="group"
|
||||
:aria-label="t('nativeApp.install')"
|
||||
>
|
||||
<p>{{ t('nativeApp.installWarning') }}</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
:disabled="busy"
|
||||
@click="installUpdate"
|
||||
>{{ t('nativeApp.installRestart') }}</Button
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="busy"
|
||||
@click="confirmInstall = false"
|
||||
>{{ t('nativeApp.cancel') }}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
<p v-else class="text-muted-foreground text-sm">
|
||||
{{
|
||||
t('nativeApp.mobileVersion', { version: state.version })
|
||||
}}
|
||||
</p>
|
||||
<p v-if="errorMessage" class="text-destructive" role="alert">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="justify-self-start"
|
||||
@click="act('openSettings')"
|
||||
>{{ t('nativeApp.recovery') }}</Button
|
||||
>
|
||||
</template>
|
||||
</component>
|
||||
</component>
|
||||
</template>
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import type { NativeBridge } from './native-app';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
kanbanNative?: NativeBridge;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export type NativeUpdatePhase =
|
||||
| 'idle'
|
||||
| 'checking'
|
||||
| 'available'
|
||||
| 'current'
|
||||
| 'downloading'
|
||||
| 'verifying'
|
||||
| 'installing'
|
||||
| 'restarting'
|
||||
| 'error';
|
||||
export type NativeUpdates = {
|
||||
phase: NativeUpdatePhase;
|
||||
currentVersion: string;
|
||||
build: string;
|
||||
version: string | null;
|
||||
notes: string | null;
|
||||
downloaded: number;
|
||||
total: number | null;
|
||||
error: string | null;
|
||||
preferences: { automatic: boolean; intervalHours: 1 | 6 | 24 };
|
||||
};
|
||||
export type NativeAppState = {
|
||||
apiVersion: 1;
|
||||
platform: 'macos' | 'ios';
|
||||
version: string;
|
||||
workspace: string;
|
||||
capabilities: string[];
|
||||
updates?: NativeUpdates;
|
||||
};
|
||||
export type NativeAction =
|
||||
| 'getState'
|
||||
| 'settingsReady'
|
||||
| 'openSettings'
|
||||
| 'setWorkspace'
|
||||
| 'checkForUpdates'
|
||||
| 'setUpdatePreferences'
|
||||
| 'installUpdate';
|
||||
export type NativeBridge = {
|
||||
version: number;
|
||||
request: (
|
||||
action: NativeAction,
|
||||
payload?: Record<string, unknown>,
|
||||
) => Promise<unknown>;
|
||||
};
|
||||
Reference in New Issue
Block a user