mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd2f941dbb | ||
|
|
2fa48e8e05 | ||
|
|
b274d4b76b | ||
|
|
d5710128fd | ||
|
|
96bddad289 |
@@ -28,9 +28,31 @@ jobs:
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
cache: gradle
|
||||
|
||||
- uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Cache Android NDK / CMake
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.android/build-cache
|
||||
/usr/local/lib/android/sdk/ndk
|
||||
/usr/local/lib/android/sdk/cmake
|
||||
key: android-sdk-${{ runner.os }}-${{ hashFiles('android/build.gradle', 'android/gradle.properties', 'android/app/build.gradle') }}
|
||||
restore-keys: android-sdk-${{ runner.os }}-
|
||||
|
||||
- name: Cache native CMake build outputs
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
android/app/.cxx
|
||||
android/app/build/intermediates/cxx
|
||||
android/app/build/intermediates/merged_native_libs
|
||||
android/app/build/intermediates/stripped_native_libs
|
||||
key: cxx-${{ runner.os }}-${{ hashFiles('package-lock.json', 'android/build.gradle', 'android/app/build.gradle') }}
|
||||
restore-keys: cxx-${{ runner.os }}-
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
|
||||
@@ -54,7 +76,9 @@ jobs:
|
||||
ORG_GRADLE_PROJECT_BULWARK_RELEASE_STORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
run: ./gradlew assembleRelease --no-daemon
|
||||
run: |
|
||||
chmod +x ./gradlew
|
||||
./gradlew assembleRelease --no-daemon
|
||||
|
||||
- name: Rename APK with version
|
||||
id: artifact
|
||||
@@ -72,9 +96,28 @@ jobs:
|
||||
path: ${{ steps.artifact.outputs.path }}
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Resolve target release tag
|
||||
id: target_tag
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
TAG="${{ github.event.release.tag_name }}"
|
||||
elif [ -n "${{ github.event.inputs.attach_to_release_tag }}" ]; then
|
||||
TAG="${{ github.event.inputs.attach_to_release_tag }}"
|
||||
else
|
||||
TAG=$(gh release list --limit 1 --json tagName --jq '.[0].tagName' || true)
|
||||
fi
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
if [ -n "$TAG" ]; then
|
||||
echo "Will attach APK to release: $TAG"
|
||||
else
|
||||
echo "No release tag resolved — APK is in workflow artifacts only."
|
||||
fi
|
||||
|
||||
- name: Attach APK to release
|
||||
if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.attach_to_release_tag != '')
|
||||
if: steps.target_tag.outputs.tag != ''
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.event.release.tag_name || github.event.inputs.attach_to_release_tag }}
|
||||
tag_name: ${{ steps.target_tag.outputs.tag }}
|
||||
files: ${{ steps.artifact.outputs.path }}
|
||||
|
||||
@@ -34,6 +34,8 @@ import { useCalendarStore } from './src/stores/calendar-store';
|
||||
import { useContactsStore } from './src/stores/contacts-store';
|
||||
import { useEmailStore } from './src/stores/email-store';
|
||||
import { useSettingsStore } from './src/stores/settings-store';
|
||||
import { useUpdatesStore } from './src/stores/updates-store';
|
||||
import { UpdateBanner } from './src/components/UpdateBanner';
|
||||
import { colors, spacing, typography } from './src/theme/tokens';
|
||||
|
||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||
@@ -64,6 +66,8 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
const inboxUnreadCount = mailboxes.find((mailbox) => mailbox.role === 'inbox')?.unreadEmails ?? 0;
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
<UpdateBanner />
|
||||
<Tab.Navigator
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
@@ -147,6 +151,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
{() => <SettingsScreen onLogout={logout} />}
|
||||
</Tab.Screen>
|
||||
</Tab.Navigator>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -171,6 +176,19 @@ export default function App() {
|
||||
void useSettingsStore.getState().hydrate();
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const store = useUpdatesStore.getState();
|
||||
await store.hydrate();
|
||||
if (cancelled) return;
|
||||
if (useUpdatesStore.getState().autoCheck) {
|
||||
await useUpdatesStore.getState().checkNow();
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// Listen for FCM messages so the app can refresh state even when woken
|
||||
// from the background — the FirebaseMessagingService also posts the system
|
||||
// notification so the user sees it regardless of RN runtime state.
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
export const UPDATE_REPO = 'bulwarkmail/native';
|
||||
|
||||
export interface ReleaseAsset {
|
||||
name: string;
|
||||
browser_download_url: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface LatestRelease {
|
||||
tag: string;
|
||||
name: string;
|
||||
htmlUrl: string;
|
||||
publishedAt: string;
|
||||
body: string;
|
||||
apkAsset: ReleaseAsset | null;
|
||||
}
|
||||
|
||||
export async function fetchLatestRelease(repo: string = UPDATE_REPO): Promise<LatestRelease | null> {
|
||||
const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
|
||||
headers: { Accept: 'application/vnd.github+json' },
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) return null;
|
||||
throw new Error(`GitHub API ${res.status}`);
|
||||
}
|
||||
const json = (await res.json()) as {
|
||||
tag_name: string;
|
||||
name: string;
|
||||
html_url: string;
|
||||
published_at: string;
|
||||
body: string;
|
||||
assets: ReleaseAsset[];
|
||||
};
|
||||
const apkAsset = json.assets.find((a) => a.name.endsWith('.apk')) ?? null;
|
||||
return {
|
||||
tag: json.tag_name,
|
||||
name: json.name || json.tag_name,
|
||||
htmlUrl: json.html_url,
|
||||
publishedAt: json.published_at,
|
||||
body: json.body || '',
|
||||
apkAsset,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { Download, X } from 'lucide-react-native';
|
||||
import { useUpdatesStore } from '../stores/updates-store';
|
||||
import { colors, spacing, radius, typography } from '../theme/tokens';
|
||||
|
||||
export function UpdateBanner(): React.ReactElement | null {
|
||||
const cachedLatest = useUpdatesStore((s) => s.cachedLatest);
|
||||
const dismissedTag = useUpdatesStore((s) => s.dismissedTag);
|
||||
const installing = useUpdatesStore((s) => s.installing);
|
||||
const installLatest = useUpdatesStore((s) => s.installLatest);
|
||||
const dismissCurrent = useUpdatesStore((s) => s.dismissCurrent);
|
||||
const hasUpdate = useUpdatesStore((s) => s.hasUpdate);
|
||||
|
||||
if (!hasUpdate()) return null;
|
||||
if (cachedLatest && dismissedTag === cachedLatest.tag) return null;
|
||||
if (!cachedLatest?.apkAsset) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.banner}>
|
||||
<Download size={16} color={colors.primaryForeground} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.title}>Update available</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
v{cachedLatest.tag} is ready to install.
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
style={styles.installButton}
|
||||
onPress={() => void installLatest()}
|
||||
disabled={installing}
|
||||
>
|
||||
<Text style={styles.installText}>{installing ? '…' : 'Install'}</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.dismiss} onPress={dismissCurrent} hitSlop={8}>
|
||||
<X size={14} color={colors.primaryForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
banner: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
title: { ...typography.bodyMedium, color: colors.primaryForeground },
|
||||
subtitle: { ...typography.caption, color: colors.primaryForeground, opacity: 0.85 },
|
||||
installButton: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 6,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||
},
|
||||
installText: { ...typography.captionMedium, color: colors.primaryForeground },
|
||||
dismiss: { padding: 4 },
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, Text, StyleSheet, Linking, Alert } from 'react-native';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import Button from '../Button';
|
||||
import { colors, spacing, radius, typography } from '../../theme/tokens';
|
||||
import { useUpdatesStore } from '../../stores/updates-store';
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
if (!ts) return 'never';
|
||||
const date = new Date(ts);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
export function UpdatesSettings() {
|
||||
const hydrated = useUpdatesStore((s) => s.hydrated);
|
||||
const hydrate = useUpdatesStore((s) => s.hydrate);
|
||||
const checking = useUpdatesStore((s) => s.checking);
|
||||
const installing = useUpdatesStore((s) => s.installing);
|
||||
const error = useUpdatesStore((s) => s.error);
|
||||
const lastCheckedAt = useUpdatesStore((s) => s.lastCheckedAt);
|
||||
const cachedLatest = useUpdatesStore((s) => s.cachedLatest);
|
||||
const autoCheck = useUpdatesStore((s) => s.autoCheck);
|
||||
const setAutoCheck = useUpdatesStore((s) => s.setAutoCheck);
|
||||
const checkNow = useUpdatesStore((s) => s.checkNow);
|
||||
const installLatest = useUpdatesStore((s) => s.installLatest);
|
||||
const currentVersion = useUpdatesStore((s) => s.currentVersion);
|
||||
const hasUpdate = useUpdatesStore((s) => s.hasUpdate);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) void hydrate();
|
||||
}, [hydrated, hydrate]);
|
||||
|
||||
const updateAvailable = hasUpdate();
|
||||
const apkAsset = cachedLatest?.apkAsset ?? null;
|
||||
|
||||
const onInstall = () => {
|
||||
if (!apkAsset) {
|
||||
if (cachedLatest?.htmlUrl) {
|
||||
void Linking.openURL(cachedLatest.htmlUrl);
|
||||
}
|
||||
return;
|
||||
}
|
||||
void installLatest();
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<SettingsSection title="App updates" description="Check for new versions and install them.">
|
||||
<SettingItem label="Current version">
|
||||
<Text style={styles.value}>v{currentVersion()}</Text>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label="Latest version"
|
||||
description={cachedLatest?.publishedAt ? `Published ${new Date(cachedLatest.publishedAt).toLocaleDateString()}` : undefined}
|
||||
>
|
||||
<Text style={styles.value}>{cachedLatest ? cachedLatest.tag : '—'}</Text>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label="Last checked"
|
||||
description={error ? `Last error: ${error}` : undefined}
|
||||
>
|
||||
<Text style={styles.value}>{formatTime(lastCheckedAt)}</Text>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label="Check automatically" description="Check for updates every few hours when the app starts.">
|
||||
<ToggleSwitch checked={autoCheck} onChange={setAutoCheck} />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={updateAvailable ? 'Update available' : 'Check for updates'}
|
||||
description={updateAvailable
|
||||
? `Tap install to download v${cachedLatest?.tag} and open it with Android's package installer.`
|
||||
: undefined}
|
||||
>
|
||||
{updateAvailable ? (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onPress={onInstall}
|
||||
disabled={installing}
|
||||
>
|
||||
{installing ? 'Downloading…' : apkAsset ? 'Install' : 'Open release'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={() => void checkNow({ force: true })}
|
||||
disabled={checking}
|
||||
>
|
||||
{checking ? 'Checking…' : 'Check now'}
|
||||
</Button>
|
||||
)}
|
||||
</SettingItem>
|
||||
|
||||
{cachedLatest?.body ? (
|
||||
<View style={styles.notesBox}>
|
||||
<Text style={styles.notesTitle}>Release notes</Text>
|
||||
<Text style={styles.notesBody} numberOfLines={20}>
|
||||
{cachedLatest.body.trim()}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</SettingsSection>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { gap: spacing.xxxl },
|
||||
value: { ...typography.bodyMedium, color: colors.text },
|
||||
notesBox: {
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.muted,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
notesTitle: { ...typography.caption, color: colors.mutedForeground, textTransform: 'uppercase' },
|
||||
notesBody: { ...typography.body, color: colors.text, lineHeight: 20 },
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Platform } from 'react-native';
|
||||
import { File, Paths } from 'expo-file-system';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import type { ReleaseAsset } from '../api/updates';
|
||||
|
||||
export async function downloadAndInstallApk(asset: ReleaseAsset): Promise<void> {
|
||||
if (Platform.OS !== 'android') {
|
||||
throw new Error('APK install is only supported on Android');
|
||||
}
|
||||
const dest = new File(Paths.cache, asset.name);
|
||||
if (dest.exists) dest.delete();
|
||||
const downloaded = await File.downloadFileAsync(asset.browser_download_url, dest);
|
||||
if (!(await Sharing.isAvailableAsync())) {
|
||||
throw new Error('Sharing is not available on this device');
|
||||
}
|
||||
await Sharing.shareAsync(downloaded.uri, {
|
||||
mimeType: 'application/vnd.android.package-archive',
|
||||
dialogTitle: 'Install update',
|
||||
UTI: 'com.android.package-archive',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export function parseVersion(v: string): [number, number, number] {
|
||||
const cleaned = v.replace(/^v/, '').split(/[-+]/)[0];
|
||||
const parts = cleaned.split('.').map((n) => parseInt(n, 10));
|
||||
return [parts[0] || 0, parts[1] || 0, parts[2] || 0];
|
||||
}
|
||||
|
||||
export function compareVersions(a: string, b: string): number {
|
||||
const [a1, a2, a3] = parseVersion(a);
|
||||
const [b1, b2, b3] = parseVersion(b);
|
||||
if (a1 !== b1) return a1 - b1;
|
||||
if (a2 !== b2) return a2 - b2;
|
||||
return a3 - b3;
|
||||
}
|
||||
|
||||
export function isNewer(latest: string, current: string): boolean {
|
||||
return compareVersions(latest, current) > 0;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
ArrowLeft, LogOut, Settings, ChevronRight,
|
||||
Palette, Mail, User, Shield, UserPen, Palmtree, Calendar,
|
||||
Filter, FileText, FolderOpen, Tags, HardDrive, Wrench,
|
||||
BookUser, KeyRound, PanelLeftClose, Bell, Puzzle,
|
||||
BookUser, KeyRound, PanelLeftClose, Bell, Puzzle, RefreshCw,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react-native';
|
||||
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
|
||||
@@ -16,13 +16,14 @@ import { IdentitySettings } from '../components/settings/IdentitySettings';
|
||||
import { VacationSettings } from '../components/settings/VacationSettings';
|
||||
import { FolderSettings } from '../components/settings/FolderSettings';
|
||||
import { AdvancedSettings } from '../components/settings/AdvancedSettings';
|
||||
import { UpdatesSettings } from '../components/settings/UpdatesSettings';
|
||||
|
||||
type Tab =
|
||||
| 'appearance' | 'email' | 'notifications'
|
||||
| 'account' | 'security' | 'identities' | 'encryption' | 'vacation'
|
||||
| 'filters' | 'templates' | 'folders' | 'keywords'
|
||||
| 'calendar' | 'contacts' | 'files' | 'sidebar_apps'
|
||||
| 'themes' | 'plugins' | 'advanced';
|
||||
| 'themes' | 'plugins' | 'updates' | 'advanced';
|
||||
|
||||
type TabGroup = 'general' | 'account' | 'organization' | 'apps' | 'system';
|
||||
|
||||
@@ -64,6 +65,7 @@ const TABS: TabDef[] = [
|
||||
{ id: 'sidebar_apps', label: 'Sidebar Apps', icon: PanelLeftClose, group: 'apps', implemented: false },
|
||||
{ id: 'themes', label: 'Themes', icon: Palette, group: 'system', experimental: true, implemented: false },
|
||||
{ id: 'plugins', label: 'Plugins', icon: Puzzle, group: 'system', experimental: true, implemented: false },
|
||||
{ id: 'updates', label: 'Updates', icon: RefreshCw, group: 'system', implemented: true },
|
||||
{ id: 'advanced', label: 'Advanced', icon: Wrench, group: 'system', implemented: true },
|
||||
];
|
||||
|
||||
@@ -74,6 +76,7 @@ const TAB_COMPONENTS: Partial<Record<Tab, React.ComponentType<any>>> = {
|
||||
identities: IdentitySettings,
|
||||
vacation: VacationSettings,
|
||||
folders: FolderSettings,
|
||||
updates: UpdatesSettings,
|
||||
advanced: AdvancedSettings,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { create } from 'zustand';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import Constants from 'expo-constants';
|
||||
import { fetchLatestRelease, type LatestRelease } from '../api/updates';
|
||||
import { isNewer } from '../lib/version-compare';
|
||||
import { downloadAndInstallApk } from '../lib/install-update';
|
||||
|
||||
const STORAGE_KEY = 'webmail:updates:v1';
|
||||
const MIN_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
interface PersistedUpdates {
|
||||
autoCheck: boolean;
|
||||
lastCheckedAt: number;
|
||||
cachedLatest: LatestRelease | null;
|
||||
dismissedTag: string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_PERSISTED: PersistedUpdates = {
|
||||
autoCheck: true,
|
||||
lastCheckedAt: 0,
|
||||
cachedLatest: null,
|
||||
dismissedTag: null,
|
||||
};
|
||||
|
||||
export interface UpdatesState extends PersistedUpdates {
|
||||
hydrated: boolean;
|
||||
checking: boolean;
|
||||
installing: boolean;
|
||||
error: string | null;
|
||||
|
||||
hydrate: () => Promise<void>;
|
||||
setAutoCheck: (enabled: boolean) => void;
|
||||
checkNow: (opts?: { force?: boolean }) => Promise<void>;
|
||||
installLatest: () => Promise<void>;
|
||||
dismissCurrent: () => void;
|
||||
currentVersion: () => string;
|
||||
hasUpdate: () => boolean;
|
||||
}
|
||||
|
||||
function persist(state: PersistedUpdates): void {
|
||||
void AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(state)).catch((err) => {
|
||||
console.warn('[updates-store] persist failed', err);
|
||||
});
|
||||
}
|
||||
|
||||
export const useUpdatesStore = create<UpdatesState>((set, get) => ({
|
||||
...DEFAULT_PERSISTED,
|
||||
hydrated: false,
|
||||
checking: false,
|
||||
installing: false,
|
||||
error: null,
|
||||
|
||||
hydrate: async () => {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as Partial<PersistedUpdates>;
|
||||
set({ ...DEFAULT_PERSISTED, ...parsed, hydrated: true });
|
||||
} else {
|
||||
set({ hydrated: true });
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[updates-store] hydrate failed', err);
|
||||
set({ hydrated: true });
|
||||
}
|
||||
},
|
||||
|
||||
setAutoCheck: (enabled) => {
|
||||
set({ autoCheck: enabled });
|
||||
const s = get();
|
||||
persist({
|
||||
autoCheck: enabled,
|
||||
lastCheckedAt: s.lastCheckedAt,
|
||||
cachedLatest: s.cachedLatest,
|
||||
dismissedTag: s.dismissedTag,
|
||||
});
|
||||
},
|
||||
|
||||
checkNow: async (opts) => {
|
||||
const s = get();
|
||||
if (s.checking) return;
|
||||
const now = Date.now();
|
||||
if (!opts?.force && now - s.lastCheckedAt < MIN_CHECK_INTERVAL_MS) return;
|
||||
set({ checking: true, error: null });
|
||||
try {
|
||||
const latest = await fetchLatestRelease();
|
||||
const next: PersistedUpdates = {
|
||||
autoCheck: s.autoCheck,
|
||||
lastCheckedAt: now,
|
||||
cachedLatest: latest,
|
||||
dismissedTag: s.dismissedTag,
|
||||
};
|
||||
set({ ...next, checking: false });
|
||||
persist(next);
|
||||
} catch (err) {
|
||||
set({ checking: false, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
|
||||
installLatest: async () => {
|
||||
const s = get();
|
||||
if (s.installing || !s.cachedLatest?.apkAsset) return;
|
||||
set({ installing: true, error: null });
|
||||
try {
|
||||
await downloadAndInstallApk(s.cachedLatest.apkAsset);
|
||||
set({ installing: false });
|
||||
} catch (err) {
|
||||
set({ installing: false, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
|
||||
dismissCurrent: () => {
|
||||
const s = get();
|
||||
const tag = s.cachedLatest?.tag ?? null;
|
||||
set({ dismissedTag: tag });
|
||||
persist({
|
||||
autoCheck: s.autoCheck,
|
||||
lastCheckedAt: s.lastCheckedAt,
|
||||
cachedLatest: s.cachedLatest,
|
||||
dismissedTag: tag,
|
||||
});
|
||||
},
|
||||
|
||||
currentVersion: () => Constants.expoConfig?.version ?? '0.0.0',
|
||||
|
||||
hasUpdate: () => {
|
||||
const s = get();
|
||||
if (!s.cachedLatest) return false;
|
||||
const current = Constants.expoConfig?.version ?? '0.0.0';
|
||||
return isNewer(s.cachedLatest.tag, current);
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user