-
Notifications
You must be signed in to change notification settings - Fork 110
electron app #106
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
electron app #106
Changes from all commits
0bd915e
3d1e643
a2d2917
29acc87
f8ad993
5a6501d
5002b94
eedbbba
b8f2142
e874bcd
6f678f4
a45d23a
5f85617
a825e73
2f29156
de0ab08
40b3f22
54f5c68
4d9b5f0
4c2e106
6e1216e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,106 @@ | ||||||||||
| name: Electron Release | ||||||||||
|
|
||||||||||
| on: | ||||||||||
| push: | ||||||||||
| tags: | ||||||||||
| - "electron-v*" | ||||||||||
|
|
||||||||||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: In Prompt for AI agents
Suggested change
|
||||||||||
|
|
||||||||||
| - name: Build and publish macOS | ||||||||||
| working-directory: apps/electron | ||||||||||
| run: bun run publish | ||||||||||
|
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 }} | ||||||||||
| 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 }) | ||
|
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); | ||
|
FranciscoMoretti marked this conversation as resolved.
|
||
| const successUrl = new URL("/electron-auth/success", request.url); | ||
| successUrl.searchParams.set("token", token); | ||
| return NextResponse.redirect(successUrl); | ||
| } | ||
| 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 }); | ||
| } |
| 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'll be returned to the app after signing in | ||
| </CardDescription> | ||
| </CardHeader> | ||
| <CardContent> | ||
| <SocialAuthProviders callbackURL="/api/auth/electron-callback" /> | ||
| </CardContent> | ||
| </Card> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } |
| 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); | ||
|
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> | ||
| ); | ||
| } | ||
| 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> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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"; | ||
|
|
||
|
|
@@ -85,7 +86,12 @@ export default async function RootLayout({ | |
| /> | ||
| ) : null} | ||
| </head> | ||
| <body className="antialiased"> | ||
| <body | ||
| className="antialiased" | ||
| style={{ paddingTop: "var(--electron-titlebar-height, 0px)" }} | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| suppressHydrationWarning | ||
| > | ||
| <ElectronTitlebarOffset /> | ||
| <Script | ||
| src="https://cdn.jsdelivr.net/pyodide/v0.23.4/full/pyodide.js" | ||
| strategy="afterInteractive" | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.