Skip to content

Commit c321570

Browse files
committed
Offline/online connectivity service and offline page
1 parent d53c066 commit c321570

9 files changed

Lines changed: 253 additions & 0 deletions

File tree

apps/array/src/main/di/container.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import "reflect-metadata";
22
import { Container } from "inversify";
33
import { AgentService } from "../services/agent/service.js";
4+
import { ConnectivityService } from "../services/connectivity/service.js";
45
import { ContextMenuService } from "../services/context-menu/service.js";
56
import { DeepLinkService } from "../services/deep-link/service.js";
67
import { DockBadgeService } from "../services/dock-badge/service.js";
@@ -22,6 +23,7 @@ export const container = new Container({
2223
});
2324

2425
container.bind(MAIN_TOKENS.AgentService).to(AgentService);
26+
container.bind(MAIN_TOKENS.ConnectivityService).to(ConnectivityService);
2527
container.bind(MAIN_TOKENS.ContextMenuService).to(ContextMenuService);
2628
container.bind(MAIN_TOKENS.DeepLinkService).to(DeepLinkService);
2729
container.bind(MAIN_TOKENS.DockBadgeService).to(DockBadgeService);

apps/array/src/main/di/tokens.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
export const MAIN_TOKENS = Object.freeze({
88
// Services
99
AgentService: Symbol.for("Main.AgentService"),
10+
ConnectivityService: Symbol.for("Main.ConnectivityService"),
1011
ContextMenuService: Symbol.for("Main.ContextMenuService"),
1112
DockBadgeService: Symbol.for("Main.DockBadgeService"),
1213
ExternalAppsService: Symbol.for("Main.ExternalAppsService"),
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { z } from "zod";
2+
3+
export const connectivityStatusOutput = z.object({
4+
isOnline: z.boolean(),
5+
});
6+
7+
export type ConnectivityStatusOutput = z.infer<typeof connectivityStatusOutput>;
8+
9+
export const ConnectivityEvent = {
10+
StatusChange: "status-change",
11+
} as const;
12+
13+
export interface ConnectivityEvents {
14+
[ConnectivityEvent.StatusChange]: ConnectivityStatusOutput;
15+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { net } from "electron";
2+
import { injectable, postConstruct } from "inversify";
3+
import { logger } from "../../lib/logger.js";
4+
import { TypedEventEmitter } from "../../lib/typed-event-emitter.js";
5+
import {
6+
ConnectivityEvent,
7+
type ConnectivityEvents,
8+
type ConnectivityStatusOutput,
9+
} from "./schemas.js";
10+
11+
const log = logger.scope("connectivity");
12+
13+
const CHECK_URL = "https://www.google.com/generate_204";
14+
const MIN_POLL_INTERVAL_MS = 3_000;
15+
const MAX_POLL_INTERVAL_MS = 10_000;
16+
const ONLINE_POLL_INTERVAL_MS = 3_000;
17+
18+
@injectable()
19+
export class ConnectivityService extends TypedEventEmitter<ConnectivityEvents> {
20+
private isOnline = false;
21+
private pollTimeoutId: ReturnType<typeof setTimeout> | null = null;
22+
private currentPollInterval = MIN_POLL_INTERVAL_MS;
23+
24+
@postConstruct()
25+
init(): void {
26+
this.isOnline = net.isOnline();
27+
log.info("Initial connectivity status", { isOnline: this.isOnline });
28+
29+
this.startPolling();
30+
}
31+
32+
getStatus(): ConnectivityStatusOutput {
33+
return { isOnline: this.isOnline };
34+
}
35+
36+
async checkNow(): Promise<ConnectivityStatusOutput> {
37+
await this.checkConnectivity();
38+
return { isOnline: this.isOnline };
39+
}
40+
41+
private setOnline(online: boolean): void {
42+
if (this.isOnline === online) return;
43+
44+
this.isOnline = online;
45+
log.info("Connectivity status changed", { isOnline: online });
46+
this.emit(ConnectivityEvent.StatusChange, { isOnline: online });
47+
48+
this.currentPollInterval = MIN_POLL_INTERVAL_MS;
49+
}
50+
51+
private async checkConnectivity(): Promise<void> {
52+
if (!net.isOnline()) {
53+
this.setOnline(false);
54+
return;
55+
}
56+
57+
if (!this.isOnline) {
58+
const verified = await this.verifyWithHttp();
59+
this.setOnline(verified);
60+
}
61+
}
62+
63+
private async verifyWithHttp(): Promise<boolean> {
64+
try {
65+
const response = await net.fetch(CHECK_URL, { method: "HEAD" });
66+
return response.ok || response.status === 204;
67+
} catch (error) {
68+
log.debug("HTTP connectivity check failed", { error });
69+
return false;
70+
}
71+
}
72+
73+
private startPolling(): void {
74+
if (this.pollTimeoutId) return;
75+
76+
this.currentPollInterval = MIN_POLL_INTERVAL_MS;
77+
this.schedulePoll();
78+
}
79+
80+
private schedulePoll(): void {
81+
// when online: just poll net.isOnline periodically
82+
// when offline: poll more frequently with backoff to detect recovery
83+
const interval = this.isOnline
84+
? ONLINE_POLL_INTERVAL_MS
85+
: this.currentPollInterval;
86+
87+
this.pollTimeoutId = setTimeout(async () => {
88+
this.pollTimeoutId = null;
89+
90+
const wasOffline = !this.isOnline;
91+
await this.checkConnectivity();
92+
93+
if (!this.isOnline && wasOffline) {
94+
this.currentPollInterval = Math.min(
95+
this.currentPollInterval * 1.5,
96+
MAX_POLL_INTERVAL_MS,
97+
);
98+
}
99+
100+
this.schedulePoll();
101+
}, interval);
102+
}
103+
}

apps/array/src/main/trpc/router.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { agentRouter } from "./routers/agent.js";
22
import { analyticsRouter } from "./routers/analytics.js";
3+
import { connectivityRouter } from "./routers/connectivity.js";
34
import { contextMenuRouter } from "./routers/context-menu.js";
45
import { deepLinkRouter } from "./routers/deep-link.js";
56
import { dockBadgeRouter } from "./routers/dock-badge.js";
@@ -22,6 +23,7 @@ import { router } from "./trpc.js";
2223
export const trpcRouter = router({
2324
agent: agentRouter,
2425
analytics: analyticsRouter,
26+
connectivity: connectivityRouter,
2527
contextMenu: contextMenuRouter,
2628
dockBadge: dockBadgeRouter,
2729
encryption: encryptionRouter,
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { container } from "../../di/container.js";
2+
import { MAIN_TOKENS } from "../../di/tokens.js";
3+
import {
4+
ConnectivityEvent,
5+
type ConnectivityEvents,
6+
connectivityStatusOutput,
7+
} from "../../services/connectivity/schemas.js";
8+
import type { ConnectivityService } from "../../services/connectivity/service.js";
9+
import { publicProcedure, router } from "../trpc.js";
10+
11+
const getService = () =>
12+
container.get<ConnectivityService>(MAIN_TOKENS.ConnectivityService);
13+
14+
function subscribe<K extends keyof ConnectivityEvents>(event: K) {
15+
return publicProcedure.subscription(async function* (opts) {
16+
const service = getService();
17+
const iterable = service.toIterable(event, { signal: opts.signal });
18+
for await (const data of iterable) {
19+
yield data;
20+
}
21+
});
22+
}
23+
24+
export const connectivityRouter = router({
25+
getStatus: publicProcedure.output(connectivityStatusOutput).query(() => {
26+
const service = getService();
27+
return service.getStatus();
28+
}),
29+
30+
checkNow: publicProcedure
31+
.output(connectivityStatusOutput)
32+
.mutation(async () => {
33+
const service = getService();
34+
return service.checkNow();
35+
}),
36+
37+
onStatusChange: subscribe(ConnectivityEvent.StatusChange),
38+
});

apps/array/src/renderer/App.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import { ConnectivityScreen } from "@components/ConnectivityScreen";
12
import { MainLayout } from "@components/MainLayout";
23
import { AuthScreen } from "@features/auth/components/AuthScreen";
34
import { useAuthStore } from "@features/auth/stores/authStore";
45
import { Flex, Spinner, Text } from "@radix-ui/themes";
6+
import { useConnectivity } from "@renderer/hooks/useConnectivity";
57
import { initializePostHog } from "@renderer/lib/analytics";
68
import { trpcVanilla } from "@renderer/trpc/client";
79
import { toast } from "@utils/toast";
@@ -10,6 +12,7 @@ import { useEffect, useState } from "react";
1012
function App() {
1113
const { isAuthenticated, initializeOAuth } = useAuthStore();
1214
const [isLoading, setIsLoading] = useState(true);
15+
const { isOnline, isChecking, check } = useConnectivity();
1316

1417
// Initialize PostHog analytics
1518
useEffect(() => {
@@ -30,6 +33,10 @@ function App() {
3033
initializeOAuth().finally(() => setIsLoading(false));
3134
}, [initializeOAuth]);
3235

36+
if (!isOnline) {
37+
return <ConnectivityScreen isChecking={isChecking} onRetry={check} />;
38+
}
39+
3340
if (isLoading) {
3441
return (
3542
<Flex align="center" justify="center" minHeight="100vh">
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { WifiSlash } from "@phosphor-icons/react";
2+
import { Button, Flex, Heading, Text } from "@radix-ui/themes";
3+
4+
interface ConnectivityScreenProps {
5+
isChecking: boolean;
6+
onRetry: () => void;
7+
}
8+
9+
export function ConnectivityScreen({
10+
isChecking,
11+
onRetry,
12+
}: ConnectivityScreenProps) {
13+
return (
14+
<Flex
15+
direction="column"
16+
align="center"
17+
justify="center"
18+
className="fixed inset-0 z-[100] bg-[var(--color-background)]"
19+
>
20+
<Flex direction="column" align="center" gap="4" className="max-w-sm px-4">
21+
<WifiSlash size={50} weight="light" color="var(--gray-9)" />
22+
23+
<Flex direction="column" align="center" gap="2">
24+
<Heading size="5" weight="medium">
25+
Unable to connect
26+
</Heading>
27+
<Text color="gray" align="center" size="2">
28+
Array requires an internet connection to use AI features. Please
29+
check your connection and try again.
30+
</Text>
31+
</Flex>
32+
33+
<Button
34+
size="2"
35+
variant="solid"
36+
loading={isChecking}
37+
onClick={onRetry}
38+
className="mt-2"
39+
>
40+
Try Again
41+
</Button>
42+
</Flex>
43+
</Flex>
44+
);
45+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { trpcReact, trpcVanilla } from "@renderer/trpc/client";
2+
import { useCallback, useEffect, useState } from "react";
3+
4+
export function useConnectivity() {
5+
const { data } = trpcReact.connectivity.getStatus.useQuery();
6+
const [isOnline, setIsOnline] = useState(data?.isOnline ?? true);
7+
const [isChecking, setIsChecking] = useState(false);
8+
9+
useEffect(() => {
10+
if (data) {
11+
setIsOnline(data.isOnline);
12+
}
13+
}, [data]);
14+
15+
useEffect(() => {
16+
const subscription = trpcVanilla.connectivity.onStatusChange.subscribe(
17+
undefined,
18+
{
19+
onData: (status) => {
20+
setIsOnline(status.isOnline);
21+
setIsChecking(false);
22+
},
23+
},
24+
);
25+
26+
return () => subscription.unsubscribe();
27+
}, []);
28+
29+
const check = useCallback(async () => {
30+
setIsChecking(true);
31+
try {
32+
const result = await trpcVanilla.connectivity.checkNow.mutate();
33+
setIsOnline(result.isOnline);
34+
} finally {
35+
setIsChecking(false);
36+
}
37+
}, []);
38+
39+
return { isOnline, isChecking, check };
40+
}

0 commit comments

Comments
 (0)