Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions kilo-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"expo": "~55.0.8",
"expo-application": "~55.0.10",
"expo-build-properties": "~55.0.10",
"expo-clipboard": "~55.0.9",
"expo-constants": "~55.0.9",
Expand Down
42 changes: 37 additions & 5 deletions kilo-app/src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Toaster } from 'sonner-native';

import { AuthProvider, useAuth } from '@/lib/auth/auth-context';
import { ContextProvider, useAppContext } from '@/lib/context/context-context';
import { useForceUpdate } from '@/lib/hooks/use-force-update';
import { queryClient } from '@/lib/query-client';
import { trpcClient, TRPCProvider } from '@/lib/trpc';

Expand Down Expand Up @@ -55,18 +56,35 @@ void SplashScreen.preventAutoHideAsync();
function RootLayoutNav() {
const { token, isLoading: authLoading } = useAuth();
const { context, isLoading: contextLoading } = useAppContext();
const { updateRequired, isChecking: updateChecking } = useForceUpdate();
const segments = useSegments();
const router = useRouter();

const isLoading = authLoading || contextLoading;
const isLoading = authLoading || contextLoading || updateChecking;
const inAuthGroup = segments[0] === '(auth)';
const inContextGroup = segments[0] === '(context)';
const inForceUpdate = segments[0] === 'force-update';

useEffect(() => {
if (isLoading) {
return;
}

if (updateRequired) {
if (!inForceUpdate) {
router.replace('/force-update');
} else {
void SplashScreen.hideAsync();
}
return;
}

if (inForceUpdate) {
// Version is now acceptable, leave the force-update screen
router.replace('/(app)');
return;
}

if (!token) {
if (inAuthGroup) {
void SplashScreen.hideAsync();
Expand All @@ -84,13 +102,27 @@ function RootLayoutNav() {
} else {
void SplashScreen.hideAsync();
}
}, [token, context, isLoading, inAuthGroup, inContextGroup, router]);
}, [
token,
context,
isLoading,
updateRequired,
inAuthGroup,
inContextGroup,
inForceUpdate,
router,
]);

const needsForceUpdate = updateRequired && !inForceUpdate;
const showingForceUpdate = updateRequired && inForceUpdate;
const needsAuth = !token && !inAuthGroup;
const needsContext = token != null && !context && !inContextGroup;
const needsAppRedirect =
(token != null && context != null && (inAuthGroup || inContextGroup)) || inForceUpdate;

const needsRedirect =
!isLoading &&
((!token && !inAuthGroup) ||
(token != null && !context && !inContextGroup) ||
(token != null && context != null && (inAuthGroup || inContextGroup)));
(needsForceUpdate || (!showingForceUpdate && (needsAuth || needsContext || needsAppRedirect)));

if (isLoading || needsRedirect) {
return null;
Expand Down
28 changes: 28 additions & 0 deletions kilo-app/src/app/force-update.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Download } from 'lucide-react-native';
import { Linking, Platform, View } from 'react-native';

import { Button } from '@/components/ui/button';
import { Text } from '@/components/ui/text';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';

const STORE_URL =
Platform.OS === 'ios'
? 'https://apps.apple.com/app/id6761193135'
: 'https://play.google.com/store/apps/details?id=com.kilocode.kiloapp';

export default function ForceUpdateScreen() {
const colors = useThemeColors();

return (
<View className="flex-1 items-center justify-center px-8">
<Download size={48} color={colors.foreground} />
<Text className="mt-6 text-center text-2xl font-bold">Update Required</Text>
<Text className="mt-3 text-center text-base text-muted-foreground">
A new version of Kilo is available. Please update to continue.
</Text>
<Button className="mt-8 w-full" size="lg" onPress={() => void Linking.openURL(STORE_URL)}>
<Text>Update Now</Text>
</Button>
</View>
);
}
82 changes: 82 additions & 0 deletions kilo-app/src/lib/hooks/use-force-update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import * as Application from 'expo-application';
import { useEffect, useState } from 'react';
import { Platform } from 'react-native';

import { API_BASE_URL } from '@/lib/config';

type MinVersionResponse = {
ios: string;
android: string;
};

function isVersionBelow(current: string, minimum: string): boolean {
const currentParts = current.split('.').map(Number);
const minimumParts = minimum.split('.').map(Number);

for (let i = 0; i < 3; i += 1) {
const cur = currentParts[i] ?? 0;
const min = minimumParts[i] ?? 0;
if (cur < min) {
return true;
}
if (cur > min) {
return false;
}
}
return false;
}

export function useForceUpdate() {
const [state, setState] = useState({
updateRequired: false,
isChecking: true,
});

useEffect(() => {
const controller = new AbortController();

async function check() {
try {
const response = await fetch(`${API_BASE_URL}/api/app/min-version`, {
signal: controller.signal,
headers: { Accept: 'application/json' },
});

if (!response.ok) {
setState({ updateRequired: false, isChecking: false });
return;
}

const data = (await response.json()) as MinVersionResponse;
const nativeVersion = Application.nativeApplicationVersion;

if (!nativeVersion) {
setState({ updateRequired: false, isChecking: false });
return;
}

const minVersion = Platform.OS === 'ios' ? data.ios : data.android;
const updateRequired = isVersionBelow(nativeVersion, minVersion);

setState({ updateRequired, isChecking: false });
} catch {
// Fail open — network errors should not block the user
setState({ updateRequired: false, isChecking: false });
}
}

void check();

// 5 second timeout — fail open
const timeout = setTimeout(() => {
controller.abort();
}, 5000);

return () => {
clearTimeout(timeout);
controller.abort();
};
}, []);

return state;
}
8 changes: 8 additions & 0 deletions packages/db/src/migrations/0062_charming_pet_avengers.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
CREATE TABLE "app_min_versions" (
"id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL,
"ios_min_version" text DEFAULT '1.0.0' NOT NULL,
"android_min_version" text DEFAULT '1.0.0' NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
INSERT INTO "app_min_versions" ("ios_min_version", "android_min_version") VALUES ('1.0.0', '1.0.0');
Loading
Loading