Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
0bd915e
electron app base
FranciscoMoretti Mar 8, 2026
3d1e643
Refactor Electron app to improve titlebar height handling
FranciscoMoretti Mar 8, 2026
a2d2917
Update layout and preload scripts for improved titlebar height handling
FranciscoMoretti Mar 8, 2026
29acc87
Add OAuth flow for Electron app with deep link handling
FranciscoMoretti Mar 9, 2026
f8ad993
Enhance Electron app build configuration and icon generation
FranciscoMoretti Mar 9, 2026
5a6501d
fixes
FranciscoMoretti Mar 11, 2026
5002b94
Refactor prompts.ts for improved code readability
FranciscoMoretti Mar 11, 2026
eedbbba
Refactor API routes and enhance Electron documentation
FranciscoMoretti Mar 11, 2026
b8f2142
Merge branch 'main' into electron-app
FranciscoMoretti Mar 11, 2026
e874bcd
Implement Electron authentication flow and enhance UI components
FranciscoMoretti Mar 12, 2026
6f678f4
Add GitHub Actions workflow for Electron app release
FranciscoMoretti Mar 12, 2026
a45d23a
Refactor UI components for improved readability and functionality
FranciscoMoretti Mar 12, 2026
5f85617
Add WebkitAppRegion property to CSSProperties for Electron compatibility
FranciscoMoretti Mar 12, 2026
a825e73
Add Electron desktop app support to CLI
FranciscoMoretti Mar 12, 2026
2f29156
Update README links for Zustand and AI Elements documentation
FranciscoMoretti Mar 12, 2026
de0ab08
Enhance Electron app configuration and documentation
FranciscoMoretti Mar 12, 2026
40b3f22
Refactor Electron app components and improve scaffolding logic
FranciscoMoretti Mar 14, 2026
54f5c68
Enhance Electron release workflow and improve scaffolding logic
FranciscoMoretti Mar 14, 2026
4d9b5f0
Refactor Electron configuration and enhance branding management
FranciscoMoretti Mar 14, 2026
4c2e106
Refactor Electron scaffolding and template synchronization
FranciscoMoretti Mar 14, 2026
6e1216e
Update Electron documentation to reflect configuration changes
FranciscoMoretti Mar 14, 2026
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
5 changes: 5 additions & 0 deletions .changeset/electron-desktop-app.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chat-js/cli": minor
---

Add Electron desktop app scaffolding. The `create` command now prompts `Include an Electron desktop app?` and, when accepted, copies a pre-configured `electron/` subfolder into the new project. The folder includes the main process, preload script (context isolation), system tray, deep-link OAuth flow, auto-updater (GitHub Releases), and `electron-builder` config for macOS, Windows, and Linux targets.
106 changes: 106 additions & 0 deletions .github/workflows/electron-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
name: Electron Release

on:
push:
tags:
- "electron-v*"
Comment thread
FranciscoMoretti marked this conversation as resolved.

permissions:
contents: write

jobs:
build-mac:
name: Build macOS
runs-on: macos-latest
steps:
- uses: actions/checkout@v4

- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.1

- uses: actions/cache@v3
with:
path: |
~/.bun
**/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: ${{ runner.os }}-bun-

- run: bun install --frozen-lockfile

- name: Set version from tag
working-directory: apps/electron
run: |
VERSION="${GITHUB_REF_NAME#electron-v}"
npm version "$VERSION" --no-git-tag-version
Comment on lines +35 to +36
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Mar 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: In build-win, this version extraction uses bash syntax even though windows-latest runs run steps in PowerShell by default, so the Windows release job will fail before publishing.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .github/workflows/electron-release.yml, line 35:

<comment>In `build-win`, this version extraction uses bash syntax even though `windows-latest` runs `run` steps in PowerShell by default, so the Windows release job will fail before publishing.</comment>

<file context>
@@ -29,6 +29,12 @@ jobs:
+      - name: Set version from tag
+        working-directory: apps/electron
+        run: |
+          VERSION="${GITHUB_REF_NAME#electron-v}"
+          npm version "$VERSION" --no-git-tag-version
+
</file context>
Suggested change
VERSION="${GITHUB_REF_NAME#electron-v}"
npm version "$VERSION" --no-git-tag-version
$VERSION = $env:GITHUB_REF_NAME -replace '^electron-v', ''
npm version $VERSION --no-git-tag-version
Fix with Cubic


- name: Build and publish macOS
working-directory: apps/electron
run: bun run publish
Comment thread
FranciscoMoretti marked this conversation as resolved.
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

build-win:
name: Build Windows
runs-on: windows-latest
steps:
- uses: actions/checkout@v4

- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.1

- uses: actions/cache@v3
with:
path: |
~/.bun
**/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: ${{ runner.os }}-bun-

- run: bun install --frozen-lockfile

- name: Set version from tag
working-directory: apps/electron
run: |
VERSION="${GITHUB_REF_NAME#electron-v}"
npm version "$VERSION" --no-git-tag-version

- name: Build and publish Windows
working-directory: apps/electron
run: bun run publish
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

build-linux:
name: Build Linux
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.1

- uses: actions/cache@v3
with:
path: |
~/.bun
**/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: ${{ runner.os }}-bun-

- run: bun install --frozen-lockfile

- name: Set version from tag
working-directory: apps/electron
run: |
VERSION="${GITHUB_REF_NAME#electron-v}"
npm version "$VERSION" --no-git-tag-version

- name: Build and publish Linux
working-directory: apps/electron
run: bun run publish
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ apps/*/evals/db/
# Neon
.neon

# Electron
apps/electron/dist/
apps/electron/release/
apps/electron/branding.json

/.skillz
/AGENTS.md
/AGENTS.md.bak
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ The CLI walks you through gateway, features, and auth choices, generates `chat.c
- **Image Generation**: AI-powered image creation
- **Code Execution**: Run code snippets in sandbox
- **MCP**: Model Context Protocol support
- **Desktop App**: Package as a native macOS, Windows, or Linux app with Electron

## Stack

Expand All @@ -52,7 +53,7 @@ The CLI walks you through gateway, features, and auth choices, generates `chat.c
- [Tailwind CSS](https://tailwindcss.com) - Styling
- [tRPC](https://trpc.io) - End-to-end type-safe APIs
- [Zod](https://zod.dev) - Schema validation
- [Zustand](https://docs.pmnd.rs/zustand) - State management
- [Zustand](https://zustand.docs.pmnd.rs/) - State management
- [Motion](https://motion.dev) - Animations
- [t3-env](https://env.t3.gg) - Environment variables
- [Pino](https://getpino.io) - Structured Logging
Expand All @@ -61,7 +62,7 @@ The CLI walks you through gateway, features, and auth choices, generates `chat.c
- [Biome](https://biomejs.dev) - Code linting and formatting
- [Ultracite](https://ultracite.ai) - Biome preset for humans and AI
- [Streamdown](https://streamdown.ai/) - Markdown for AI streaming
- [AI Elements](https://ai-sdk.dev/elements/overview) - AI-native Components
- [AI Elements](https://elements.ai-sdk.dev/overview) - AI-native Components
- [AI SDK Tools](https://ai-sdk-tools.dev/) - Developer tools for AI SDK

## Monorepo Layout
Expand Down
31 changes: 31 additions & 0 deletions apps/chat/app/api/auth/electron-callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import crypto from "node:crypto";
import { type NextRequest, NextResponse } from "next/server";
import { env } from "@/lib/env";

// Creates a short-lived signed token encoding the session token.
// Stateless: signed with AUTH_SECRET so no DB or shared-memory store is needed.
function createSignedToken(sessionToken: string): string {
const payload = Buffer.from(
JSON.stringify({ s: sessionToken, exp: Date.now() + 60_000 })
Comment thread
FranciscoMoretti marked this conversation as resolved.
).toString("base64url");
const sig = crypto
.createHmac("sha256", env.AUTH_SECRET)
.update(payload)
.digest("base64url");
return `${payload}.${sig}`;
}

export function GET(request: NextRequest) {
const sessionToken = request.cookies.get("better-auth.session_token")?.value;

if (!sessionToken) {
return new NextResponse("No active session found after OAuth.", {
status: 400,
});
}

const token = createSignedToken(sessionToken);
Comment thread
FranciscoMoretti marked this conversation as resolved.
const successUrl = new URL("/electron-auth/success", request.url);
successUrl.searchParams.set("token", token);
return NextResponse.redirect(successUrl);
}
58 changes: 58 additions & 0 deletions apps/chat/app/api/auth/electron-exchange/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import crypto from "node:crypto";
import { type NextRequest, NextResponse } from "next/server";
import { env } from "@/lib/env";

function verifySignedToken(token: string): string | null {
const dotIndex = token.lastIndexOf(".");
if (dotIndex === -1) {
return null;
}

const payload = token.slice(0, dotIndex);
const sig = token.slice(dotIndex + 1);

const expectedSig = crypto
.createHmac("sha256", env.AUTH_SECRET)
.update(payload)
.digest("base64url");

// Constant-time comparison to prevent timing attacks
if (
sig.length !== expectedSig.length ||
!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))
) {
return null;
}

let parsed: { s: string; exp: number };
try {
parsed = JSON.parse(Buffer.from(payload, "base64url").toString());
} catch {
return null;
}

if (!parsed.s || typeof parsed.exp !== "number" || parsed.exp < Date.now()) {
return null;
}

return parsed.s;
}

export function GET(request: NextRequest) {
const token = request.nextUrl.searchParams.get("token");

if (!token) {
return NextResponse.json({ error: "Missing token" }, { status: 400 });
}

const sessionToken = verifySignedToken(token);

if (!sessionToken) {
return NextResponse.json(
{ error: "Invalid or expired token" },
{ status: 401 }
);
}

return NextResponse.json({ sessionToken });
}
42 changes: 42 additions & 0 deletions apps/chat/app/electron-auth/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { SocialAuthProviders } from "@/components/auth-providers";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { auth } from "@/lib/auth";

// Opened in the user's default browser by the Electron app.
// If already authenticated, immediately exchanges the session for a token and
// returns the user to the app. Otherwise shows provider selection so the user
// can sign in — OAuth state cookies are stored here (not in Electron), which
// prevents the state_mismatch error.
export default async function ElectronAuth() {
const session = await auth.api.getSession({ headers: await headers() });

if (session) {
redirect("/api/auth/electron-callback");
}

return (
<div className="flex min-h-screen items-center justify-center">
<div className="w-full max-w-sm px-4">
<Card>
<CardHeader className="text-center">
<CardTitle className="text-xl">Sign in</CardTitle>
<CardDescription>
You&apos;ll be returned to the app after signing in
</CardDescription>
</CardHeader>
<CardContent>
<SocialAuthProviders callbackURL="/api/auth/electron-callback" />
</CardContent>
</Card>
</div>
</div>
);
}
45 changes: 45 additions & 0 deletions apps/chat/app/electron-auth/success/client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"use client";

import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";

export function ElectronAuthSuccessClient({
appScheme,
}: {
appScheme: string;
}) {
const searchParams = useSearchParams();
const [launched, setLaunched] = useState(false);

const openApp = useCallback(() => {
const token = searchParams.get("token");
if (!token) {
return;
}
window.location.href = `${appScheme}:///auth/callback?token=${encodeURIComponent(token)}`;
setLaunched(true);
Comment thread
FranciscoMoretti marked this conversation as resolved.
}, [searchParams, appScheme]);

useEffect(() => {
// Small delay so the page renders before the browser shows the open-app dialog.
const t = setTimeout(openApp, 600);
return () => clearTimeout(t);
}, [openApp]);

return (
<div className="flex min-h-screen flex-col items-center justify-center gap-6 text-center">
<div className="space-y-2">
<h1 className="font-semibold text-2xl">Signed in successfully</h1>
<p className="text-muted-foreground text-sm">
{launched ? "You can close this tab." : "Opening the app\u2026"}
</p>
</div>
{launched && (
<Button onClick={openApp} size="sm" variant="outline">
Open app again
</Button>
)}
</div>
);
}
11 changes: 11 additions & 0 deletions apps/chat/app/electron-auth/success/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Suspense } from "react";
import { config } from "@/lib/config";
import { ElectronAuthSuccessClient } from "./client";

export default function ElectronAuthSuccess() {
return (
<Suspense>
<ElectronAuthSuccessClient appScheme={config.appPrefix} />
</Suspense>
);
}
8 changes: 7 additions & 1 deletion apps/chat/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import Script from "next/script";
import "./globals.css";
import { NuqsAdapter } from "nuqs/adapters/next/app";
import { Toaster } from "sonner";
import { ElectronTitlebarOffset } from "@/components/electron-titlebar-offset";
import { ThemeProvider } from "@/components/theme-provider";
import { config } from "@/lib/config";

Expand Down Expand Up @@ -85,7 +86,12 @@ export default async function RootLayout({
/>
) : null}
</head>
<body className="antialiased">
<body
className="antialiased"
style={{ paddingTop: "var(--electron-titlebar-height, 0px)" }}
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This duplicates the existing Electron titlebar offset logic and creates two sources of truth for the same body padding.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/chat/app/layout.tsx, line 91:

<comment>This duplicates the existing Electron titlebar offset logic and creates two sources of truth for the same body padding.</comment>

<file context>
@@ -85,7 +86,12 @@ export default async function RootLayout({
-      <body className="antialiased">
+      <body
+        className="antialiased"
+        style={{ paddingTop: "var(--electron-titlebar-height, 0px)" }}
+        suppressHydrationWarning
+      >
</file context>
Fix with Cubic

suppressHydrationWarning
>
<ElectronTitlebarOffset />
<Script
src="https://cdn.jsdelivr.net/pyodide/v0.23.4/full/pyodide.js"
strategy="afterInteractive"
Expand Down
Loading
Loading