Skip to content
Draft
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
2 changes: 2 additions & 0 deletions .github/workflows/tag.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,5 +72,7 @@ jobs:
TAG="v$NEW_VERSION"
echo "Creating tag: $TAG (from base tag $LATEST_TAG + $PATCH commits)"

git config user.email "github-actions[bot]@users.noreply.github.com"
git config user.name "github-actions[bot]"
git tag -a "$TAG" -m "Release $TAG"
git push "https://x-access-token:${GH_TOKEN}@github.com/$REPOSITORY" "$TAG"
2 changes: 2 additions & 0 deletions apps/array/src/main/di/container.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import "reflect-metadata";
import { Container } from "inversify";
import { AgentService } from "../services/agent/service.js";
import { AppLifecycleService } from "../services/app-lifecycle/service.js";
import { ConnectivityService } from "../services/connectivity/service.js";
import { ContextMenuService } from "../services/context-menu/service.js";
import { DeepLinkService } from "../services/deep-link/service.js";
Expand All @@ -23,6 +24,7 @@ export const container = new Container({
});

container.bind(MAIN_TOKENS.AgentService).to(AgentService);
container.bind(MAIN_TOKENS.AppLifecycleService).to(AppLifecycleService);
container.bind(MAIN_TOKENS.ConnectivityService).to(ConnectivityService);
container.bind(MAIN_TOKENS.ContextMenuService).to(ContextMenuService);
container.bind(MAIN_TOKENS.DeepLinkService).to(DeepLinkService);
Expand Down
1 change: 1 addition & 0 deletions apps/array/src/main/di/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
export const MAIN_TOKENS = Object.freeze({
// Services
AgentService: Symbol.for("Main.AgentService"),
AppLifecycleService: Symbol.for("Main.AppLifecycleService"),
ConnectivityService: Symbol.for("Main.ConnectivityService"),
ContextMenuService: Symbol.for("Main.ContextMenuService"),
DockBadgeService: Symbol.for("Main.DockBadgeService"),
Expand Down
42 changes: 27 additions & 15 deletions apps/array/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,18 @@ import "./lib/logger";
import { ANALYTICS_EVENTS } from "../types/analytics.js";
import { container } from "./di/container.js";
import { MAIN_TOKENS } from "./di/tokens.js";
import type { AgentService } from "./services/agent/service.js";
import type { DockBadgeService } from "./services/dock-badge/service.js";
import type { UIService } from "./services/ui/service.js";
import { setMainWindowGetter } from "./trpc/context.js";
import { trpcRouter } from "./trpc/index.js";

import "./services/index.js";
import type { AppLifecycleService } from "./services/app-lifecycle/service.js";
import type { DeepLinkService } from "./services/deep-link/service.js";
import type { ExternalAppsService } from "./services/external-apps/service.js";
import type { OAuthService } from "./services/oauth/service.js";
import {
initializePostHog,
shutdownPostHog,
trackAppEvent,
} from "./services/posthog-analytics.js";
import type { TaskLinkService } from "./services/task-link/service";
Expand Down Expand Up @@ -357,19 +356,26 @@ app.whenReady().then(() => {

app.on("window-all-closed", async () => {
if (process.platform !== "darwin") {
trackAppEvent(ANALYTICS_EVENTS.APP_QUIT);
await shutdownPostHog();
const lifecycleService = container.get<AppLifecycleService>(
MAIN_TOKENS.AppLifecycleService,
);
await lifecycleService.shutdown();
app.quit();
}
});

app.on("before-quit", async (event) => {
const lifecycleService = container.get<AppLifecycleService>(
MAIN_TOKENS.AppLifecycleService,
);

// If quitting to install an update, don't block - let the updater handle it
if (lifecycleService.isQuittingForUpdate) {
return;
}

event.preventDefault();
const agentService = container.get<AgentService>(MAIN_TOKENS.AgentService);
await agentService.cleanupAll();
trackAppEvent(ANALYTICS_EVENTS.APP_QUIT);
await shutdownPostHog();
app.exit(0);
await lifecycleService.shutdownAndExit();
});

app.on("activate", () => {
Expand All @@ -381,8 +387,10 @@ app.on("activate", () => {
// Handle process signals to ensure clean shutdown
const handleShutdownSignal = async (_signal: string) => {
try {
const agentService = container.get<AgentService>(MAIN_TOKENS.AgentService);
await agentService.cleanupAll();
const lifecycleService = container.get<AppLifecycleService>(
MAIN_TOKENS.AppLifecycleService,
);
await lifecycleService.shutdown();
} catch (_err) {}
process.exit(0);
};
Expand All @@ -394,16 +402,20 @@ process.on("SIGHUP", () => handleShutdownSignal("SIGHUP"));
// Handle uncaught exceptions to attempt cleanup before crash
process.on("uncaughtException", async (_error) => {
try {
const agentService = container.get<AgentService>(MAIN_TOKENS.AgentService);
await agentService.cleanupAll();
const lifecycleService = container.get<AppLifecycleService>(
MAIN_TOKENS.AppLifecycleService,
);
await lifecycleService.shutdown();
} catch (_cleanupErr) {}
process.exit(1);
});

process.on("unhandledRejection", async (_reason) => {
try {
const agentService = container.get<AgentService>(MAIN_TOKENS.AgentService);
await agentService.cleanupAll();
const lifecycleService = container.get<AppLifecycleService>(
MAIN_TOKENS.AppLifecycleService,
);
await lifecycleService.shutdown();
} catch (_cleanupErr) {}
process.exit(1);
});
151 changes: 151 additions & 0 deletions apps/array/src/main/services/app-lifecycle/service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const { mockApp, mockAgentService, mockTrackAppEvent, mockShutdownPostHog } =
vi.hoisted(() => ({
mockApp: {
exit: vi.fn(),
},
mockAgentService: {
cleanupAll: vi.fn(() => Promise.resolve()),
},
mockTrackAppEvent: vi.fn(),
mockShutdownPostHog: vi.fn(() => Promise.resolve()),
}));

vi.mock("electron", () => ({
app: mockApp,
}));

vi.mock("../../lib/logger.js", () => ({
logger: {
scope: () => ({
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
}),
},
}));

vi.mock("../posthog-analytics.js", () => ({
trackAppEvent: mockTrackAppEvent,
shutdownPostHog: mockShutdownPostHog,
}));

vi.mock("../../di/tokens.js", () => ({
MAIN_TOKENS: {
AgentService: Symbol.for("AgentService"),
},
}));

vi.mock("../../../types/analytics.js", () => ({
ANALYTICS_EVENTS: {
APP_QUIT: "app_quit",
},
}));

import { AppLifecycleService } from "./service.js";

describe("AppLifecycleService", () => {
let service: AppLifecycleService;

beforeEach(() => {
vi.clearAllMocks();

service = new AppLifecycleService();
(
service as unknown as { agentService: typeof mockAgentService }
).agentService = mockAgentService;
});

describe("isQuittingForUpdate", () => {
it("returns false by default", () => {
expect(service.isQuittingForUpdate).toBe(false);
});

it("returns true after setQuittingForUpdate is called", () => {
service.setQuittingForUpdate();
expect(service.isQuittingForUpdate).toBe(true);
});
});

describe("shutdown", () => {
it("cleans up agents", async () => {
await service.shutdown();
expect(mockAgentService.cleanupAll).toHaveBeenCalled();
});

it("tracks app quit event", async () => {
await service.shutdown();
expect(mockTrackAppEvent).toHaveBeenCalledWith("app_quit");
});

it("shuts down PostHog", async () => {
await service.shutdown();
expect(mockShutdownPostHog).toHaveBeenCalled();
});

it("calls cleanup steps in order", async () => {
const callOrder: string[] = [];

mockAgentService.cleanupAll.mockImplementation(async () => {
callOrder.push("cleanupAll");
});
mockTrackAppEvent.mockImplementation(() => {
callOrder.push("trackAppEvent");
});
mockShutdownPostHog.mockImplementation(async () => {
callOrder.push("shutdownPostHog");
});

await service.shutdown();

expect(callOrder).toEqual([
"cleanupAll",
"trackAppEvent",
"shutdownPostHog",
]);
});

it("continues shutdown if agent cleanup fails", async () => {
mockAgentService.cleanupAll.mockRejectedValue(
new Error("cleanup failed"),
);

await service.shutdown();

expect(mockTrackAppEvent).toHaveBeenCalled();
expect(mockShutdownPostHog).toHaveBeenCalled();
});

it("continues shutdown if PostHog shutdown fails", async () => {
mockShutdownPostHog.mockRejectedValue(new Error("posthog failed"));

// Should not throw
await expect(service.shutdown()).resolves.toBeUndefined();
});
});

describe("shutdownAndExit", () => {
it("calls shutdown before exit", async () => {
const callOrder: string[] = [];

mockAgentService.cleanupAll.mockImplementation(async () => {
callOrder.push("cleanupAll");
});
mockApp.exit.mockImplementation(() => {
callOrder.push("exit");
});

await service.shutdownAndExit();

expect(callOrder[0]).toBe("cleanupAll");
expect(callOrder[callOrder.length - 1]).toBe("exit");
});

it("exits with code 0", async () => {
await service.shutdownAndExit();
expect(mockApp.exit).toHaveBeenCalledWith(0);
});
});
});
50 changes: 50 additions & 0 deletions apps/array/src/main/services/app-lifecycle/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { app } from "electron";
import { inject, injectable } from "inversify";
import { ANALYTICS_EVENTS } from "../../../types/analytics.js";
import { MAIN_TOKENS } from "../../di/tokens.js";
import { logger } from "../../lib/logger.js";
import type { AgentService } from "../agent/service.js";
import { shutdownPostHog, trackAppEvent } from "../posthog-analytics.js";

const log = logger.scope("app-lifecycle");

@injectable()
export class AppLifecycleService {
@inject(MAIN_TOKENS.AgentService)
private agentService!: AgentService;

private _isQuittingForUpdate = false;

get isQuittingForUpdate(): boolean {
return this._isQuittingForUpdate;
}

setQuittingForUpdate(): void {
this._isQuittingForUpdate = true;
}

async shutdown(): Promise<void> {
log.info("Performing graceful shutdown...");

try {
await this.agentService.cleanupAll();
} catch (error) {
log.error("Error cleaning up agents during shutdown", error);
}

trackAppEvent(ANALYTICS_EVENTS.APP_QUIT);

try {
await shutdownPostHog();
} catch (error) {
log.error("Error shutting down PostHog", error);
}

log.info("Graceful shutdown complete");
}

async shutdownAndExit(): Promise<void> {
await this.shutdown();
app.exit(0);
}
}
Loading
Loading