From d0d8fa97d76b8ad3436cc2d35de154da08a145e6 Mon Sep 17 00:00:00 2001 From: Avi Peltz Date: Fri, 13 Feb 2026 15:59:54 -0800 Subject: [PATCH 1/4] feat(desktop): add Linux build pipeline and stabilize dev auth callback flow Enable Linux AppImage build/release artifacts and Linux updater support, and make desktop OAuth callback reliable in Linux development by using a localhost callback fallback with matching CORS/trusted-origin updates. Co-authored-by: Cursor --- .github/workflows/build-desktop.yml | 97 ++++++++++++++++++- .github/workflows/release-desktop-canary.yml | 20 ++++ .github/workflows/release-desktop.yml | 16 +++ .../src/app/api/auth/desktop/connect/route.ts | 18 ++++ apps/api/src/proxy.ts | 10 ++ apps/desktop/BUILDING.md | 28 +++++- apps/desktop/RELEASE.md | 14 ++- apps/desktop/electron-builder.canary.ts | 12 ++- apps/desktop/electron-builder.ts | 12 ++- .../src/lib/trpc/routers/auth/index.ts | 5 + apps/desktop/src/main/lib/auto-updater.ts | 16 +-- .../src/main/lib/notifications/server.ts | 34 +++++++ .../WindowControls/WindowControls.tsx | 15 +-- .../DesktopRedirect/DesktopRedirect.tsx | 69 ++++++++++++- .../web/src/app/auth/desktop/success/page.tsx | 21 +++- packages/auth/src/server.ts | 9 ++ 16 files changed, 363 insertions(+), 33 deletions(-) diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 8991cdce09f..fb1a454575f 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -29,7 +29,7 @@ on: default: 30 jobs: - build: + build-macos: name: Build - macOS (${{ matrix.arch }}) runs-on: macos-latest environment: production @@ -140,3 +140,98 @@ jobs: path: apps/desktop/release/*-mac.yml retention-days: ${{ inputs.artifact_retention_days }} if-no-files-found: error + + build-linux: + name: Build - Linux (x64) + runs-on: ubuntu-latest + environment: production + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: "1.3.2" + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: | + ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-bun- + + - name: Install dependencies + run: bun install --frozen + + - name: Set version suffix + if: inputs.version_suffix != '' + working-directory: apps/desktop + run: | + CURRENT_VERSION=$(node -p "require('./package.json').version") + NEW_VERSION="${CURRENT_VERSION}${{ inputs.version_suffix }}" + echo "Setting version to: $NEW_VERSION" + node -e " + const fs = require('fs'); + const pkg = require('./package.json'); + pkg.version = '$NEW_VERSION'; + fs.writeFileSync('./package.json', JSON.stringify(pkg, null, '\t') + '\n'); + " + echo "Updated package.json version to $NEW_VERSION" + + - name: Clean dev folder + working-directory: apps/desktop + run: bun run clean:dev + + - name: Compile app with electron-vite + working-directory: apps/desktop + env: + NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }} + NEXT_PUBLIC_POSTHOG_HOST: ${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }} + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + GH_CLIENT_ID: ${{ secrets.GH_CLIENT_ID }} + NEXT_PUBLIC_WEB_URL: ${{ secrets.NEXT_PUBLIC_WEB_URL }} + NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} + NEXT_PUBLIC_DOCS_URL: ${{ secrets.NEXT_PUBLIC_DOCS_URL }} + NEXT_PUBLIC_STREAMS_URL: ${{ secrets.NEXT_PUBLIC_STREAMS_URL }} + NEXT_PUBLIC_ELECTRIC_URL: ${{ secrets.NEXT_PUBLIC_ELECTRIC_URL }} + SENTRY_DSN_DESKTOP: ${{ secrets.SENTRY_DSN_DESKTOP }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SUPERSET_WORKSPACE_NAME: superset + run: bun run compile:app + + - name: Build Electron app + working-directory: apps/desktop + run: bun run package -- --publish never --config ${{ inputs.electron_builder_config }} + + - name: Verify Linux AppImage + update manifest exist + working-directory: apps/desktop + run: | + ls -la release + test -n "$(ls -1 release/*.AppImage 2>/dev/null)" || { + echo "::error::No AppImage artifact generated in apps/desktop/release" + exit 1 + } + test -n "$(ls -1 release/*-linux.yml 2>/dev/null)" || { + echo "::error::No Linux auto-update manifest generated in apps/desktop/release" + exit 1 + } + + - name: Upload AppImage artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.artifact_prefix }}-linux-appimage + path: apps/desktop/release/*.AppImage + retention-days: ${{ inputs.artifact_retention_days }} + if-no-files-found: error + + - name: Upload Linux auto-update manifest + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.artifact_prefix }}-linux-update-manifest + path: apps/desktop/release/*-linux.yml + retention-days: ${{ inputs.artifact_retention_days }} + if-no-files-found: error diff --git a/.github/workflows/release-desktop-canary.yml b/.github/workflows/release-desktop-canary.yml index b0f1fb002e8..ecb77ceea7c 100644 --- a/.github/workflows/release-desktop-canary.yml +++ b/.github/workflows/release-desktop-canary.yml @@ -96,6 +96,26 @@ jobs: pattern: desktop-canary-* merge-multiple: true + - name: Create canary-named copies for updater URLs + run: | + cd release-artifacts + for file in *.AppImage; do + if [[ -f "$file" ]]; then + arch=$(echo "$file" | sed -E 's/.*-([^-]+)\.AppImage$/\1/') + cp "$file" "Superset-Canary-${arch}.AppImage" + echo "Created canary copy: Superset-Canary-${arch}.AppImage" + fi + done + # Prerelease builds may request canary-linux.yml; keep latest-linux.yml as fallback. + for file in *-linux.yml; do + if [[ -f "$file" ]]; then + cp "$file" "canary-linux.yml" + cp "$file" "latest-linux.yml" + echo "Created canary manifests: canary-linux.yml, latest-linux.yml" + break + fi + done + - name: List artifacts run: | echo "Release artifacts:" diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 6858fabf5de..566a6280fa4 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -78,6 +78,22 @@ jobs: echo "Created stable copy: Superset-${arch}-mac.zip" fi done + for file in *.AppImage; do + if [[ -f "$file" ]]; then + # Extract architecture from filename (e.g., superset-0.0.1-x64.AppImage -> x64) + arch=$(echo "$file" | sed -E 's/.*-([^-]+)\.AppImage$/\1/') + cp "$file" "Superset-${arch}.AppImage" + echo "Created stable copy: Superset-${arch}.AppImage" + fi + done + # Keep Linux updater manifest at a stable filename for generic provider lookups. + for file in *-linux.yml; do + if [[ -f "$file" && "$file" != "latest-linux.yml" ]]; then + cp "$file" "latest-linux.yml" + echo "Created stable copy: latest-linux.yml" + break + fi + done echo "Release artifacts:" ls -la diff --git a/apps/api/src/app/api/auth/desktop/connect/route.ts b/apps/api/src/app/api/auth/desktop/connect/route.ts index 166160298d7..14f1e3ad27e 100644 --- a/apps/api/src/app/api/auth/desktop/connect/route.ts +++ b/apps/api/src/app/api/auth/desktop/connect/route.ts @@ -8,6 +8,7 @@ export async function GET(request: Request) { const provider = url.searchParams.get("provider"); const state = url.searchParams.get("state"); const protocol = url.searchParams.get("protocol"); + const localCallback = url.searchParams.get("local_callback"); if (!provider || !state) { return new Response("Missing provider or state", { status: 400 }); @@ -22,6 +23,23 @@ export async function GET(request: Request) { if (protocol) { successUrl.searchParams.set("desktop_protocol", protocol); } + if (localCallback) { + try { + const callbackUrl = new URL(localCallback); + const isLoopback = + callbackUrl.protocol === "http:" && + (callbackUrl.hostname === "127.0.0.1" || + callbackUrl.hostname === "localhost"); + if (isLoopback && callbackUrl.pathname === "/auth/callback") { + successUrl.searchParams.set( + "desktop_local_callback", + callbackUrl.toString(), + ); + } + } catch { + // Ignore invalid callback URLs and continue with deep-link flow. + } + } const result = await auth.api.signInSocial({ body: { diff --git a/apps/api/src/proxy.ts b/apps/api/src/proxy.ts index 7a9ca82b245..bf7ae228c62 100644 --- a/apps/api/src/proxy.ts +++ b/apps/api/src/proxy.ts @@ -2,10 +2,20 @@ import { type NextRequest, NextResponse } from "next/server"; import { env } from "./env"; +const desktopDevPort = process.env.DESKTOP_VITE_PORT || "5173"; +const desktopDevOrigins = + process.env.NODE_ENV === "development" + ? [ + `http://localhost:${desktopDevPort}`, + `http://127.0.0.1:${desktopDevPort}`, + ] + : []; + const allowedOrigins = [ env.NEXT_PUBLIC_WEB_URL, env.NEXT_PUBLIC_ADMIN_URL, env.NEXT_PUBLIC_DESKTOP_URL, + ...desktopDevOrigins, ].filter(Boolean); function getCorsHeaders(origin: string | null) { diff --git a/apps/desktop/BUILDING.md b/apps/desktop/BUILDING.md index ba5d48da336..74347999166 100644 --- a/apps/desktop/BUILDING.md +++ b/apps/desktop/BUILDING.md @@ -10,4 +10,30 @@ This skips environment variable validation and the sign-in screen, useful for lo # Release -When building for release, make sure node-pty is built for the correct architecture with `bun install:deps` and then run `bun release` \ No newline at end of file +When building for release, make sure `node-pty` is built for the correct architecture with `bun run install:deps`, then run `bun run release`. + +# Linux (AppImage) local build + +From `apps/desktop`: + +```bash +bun run clean:dev +bun run compile:app +bun run package -- --publish never --config electron-builder.ts +``` + +Expected outputs in `apps/desktop/release/`: + +- `*.AppImage` +- `*-linux.yml` (Linux auto-update manifest) + +# Linux auto-update verification (local) + +From `apps/desktop` after packaging: + +```bash +ls -la release/*.AppImage +ls -la release/*-linux.yml +``` + +If both files exist, packaging produced the Linux artifact + updater metadata that `electron-updater` expects. \ No newline at end of file diff --git a/apps/desktop/RELEASE.md b/apps/desktop/RELEASE.md index 8fea11efb60..e05de8d8736 100644 --- a/apps/desktop/RELEASE.md +++ b/apps/desktop/RELEASE.md @@ -55,8 +55,10 @@ This creates a draft release. Publish it manually at GitHub Releases. The app checks for updates at launch and every x hours using: -- **Manifest**: `https://github.com/superset-sh/superset/releases/latest/download/latest-mac.yml` -- **Installer**: `https://github.com/superset-sh/superset/releases/latest/download/Superset-arm64.dmg` +- **macOS manifest**: `https://github.com/superset-sh/superset/releases/latest/download/latest-mac.yml` +- **Linux manifest**: `https://github.com/superset-sh/superset/releases/latest/download/latest-linux.yml` +- **macOS installer**: `https://github.com/superset-sh/superset/releases/latest/download/Superset-arm64.dmg` +- **Linux installer**: `https://github.com/superset-sh/superset/releases/latest/download/Superset-x64.AppImage` The workflow creates stable-named copies (without version) so these URLs always point to the latest build. @@ -78,7 +80,13 @@ bun run package Output: `apps/desktop/release/` +Linux output should include: + +- `*.AppImage` +- `*-linux.yml` (auto-update manifest) + ## Troubleshooting -- **Build fails**: Check `src/resources/build/icons/icon.icns` exists +- **Linux auto-update not working**: Verify `release/*-linux.yml` is uploaded to the GitHub release +- **Build icon warnings/failures**: Add icons under `src/resources/build/icons/` (`icon.icns`, `icon.ico`, optional Linux `.png`) - **Native module errors**: Ensure `node-pty` is in externals in both `electron.vite.config.ts` and `electron-builder.ts` diff --git a/apps/desktop/electron-builder.canary.ts b/apps/desktop/electron-builder.canary.ts index fe20ad0d2c1..52cddcebc04 100644 --- a/apps/desktop/electron-builder.canary.ts +++ b/apps/desktop/electron-builder.canary.ts @@ -8,11 +8,15 @@ */ import { join } from "node:path"; +import { existsSync } from "node:fs"; import type { Configuration } from "electron-builder"; import baseConfig from "./electron-builder"; import pkg from "./package.json"; const productName = "Superset Canary"; +const canaryMacIconPath = join(pkg.resources, "build/icons/icon-canary.icns"); +const canaryLinuxIconPath = join(pkg.resources, "build/icons/icon-canary.png"); +const canaryWinIconPath = join(pkg.resources, "build/icons/icon-canary.ico"); const config: Configuration = { ...baseConfig, @@ -28,7 +32,7 @@ const config: Configuration = { mac: { ...baseConfig.mac, - icon: join(pkg.resources, "build/icons/icon-canary.icns"), + ...(existsSync(canaryMacIconPath) ? { icon: canaryMacIconPath } : {}), artifactName: `Superset-Canary-\${version}-\${arch}.\${ext}`, extendInfo: { CFBundleName: productName, @@ -38,14 +42,16 @@ const config: Configuration = { linux: { ...baseConfig.linux, - icon: join(pkg.resources, "build/icons/icon-canary.png"), + ...(existsSync(canaryLinuxIconPath) + ? { icon: canaryLinuxIconPath } + : {}), synopsis: `${pkg.description} (Canary)`, artifactName: `superset-canary-\${version}-\${arch}.\${ext}`, }, win: { ...baseConfig.win, - icon: join(pkg.resources, "build/icons/icon-canary.ico"), + ...(existsSync(canaryWinIconPath) ? { icon: canaryWinIconPath } : {}), artifactName: `Superset-Canary-\${version}-\${arch}.\${ext}`, }, }; diff --git a/apps/desktop/electron-builder.ts b/apps/desktop/electron-builder.ts index aa9fab3d21e..11e5beb7784 100644 --- a/apps/desktop/electron-builder.ts +++ b/apps/desktop/electron-builder.ts @@ -3,6 +3,7 @@ * @see https://www.electron.build/configuration/configuration */ +import { existsSync } from "node:fs"; import { join } from "node:path"; import type { Configuration } from "electron-builder"; import pkg from "./package.json"; @@ -10,6 +11,9 @@ import pkg from "./package.json"; const currentYear = new Date().getFullYear(); const author = pkg.author?.name ?? pkg.author; const productName = pkg.productName; +const macIconPath = join(pkg.resources, "build/icons/icon.icns"); +const linuxIconPath = join(pkg.resources, "build/icons"); +const winIconPath = join(pkg.resources, "build/icons/icon.ico"); const config: Configuration = { appId: "com.superset.desktop", @@ -113,7 +117,7 @@ const config: Configuration = { // macOS mac: { - icon: join(pkg.resources, "build/icons/icon.icns"), + ...(existsSync(macIconPath) ? { icon: macIconPath } : {}), category: "public.app-category.utilities", target: [ { @@ -143,16 +147,16 @@ const config: Configuration = { // Linux linux: { - icon: join(pkg.resources, "build/icons"), + ...(existsSync(linuxIconPath) ? { icon: linuxIconPath } : {}), category: "Utility", synopsis: pkg.description, - target: ["AppImage", "deb"], + target: ["AppImage"], artifactName: `superset-\${version}-\${arch}.\${ext}`, }, // Windows win: { - icon: join(pkg.resources, "build/icons/icon.ico"), + ...(existsSync(winIconPath) ? { icon: winIconPath } : {}), target: [ { target: "nsis", diff --git a/apps/desktop/src/lib/trpc/routers/auth/index.ts b/apps/desktop/src/lib/trpc/routers/auth/index.ts index 3c755528f7a..2cb56f042e2 100644 --- a/apps/desktop/src/lib/trpc/routers/auth/index.ts +++ b/apps/desktop/src/lib/trpc/routers/auth/index.ts @@ -5,6 +5,7 @@ import { observable } from "@trpc/server/observable"; import { shell } from "electron"; import { env } from "main/env.main"; import { getDeviceName, getHashedDeviceId } from "main/lib/device-info"; +import { env as sharedEnv } from "shared/env.shared"; import { PROTOCOL_SCHEME } from "shared/constants"; import { z } from "zod"; import { publicProcedure, router } from "../.."; @@ -87,6 +88,10 @@ export const createAuthRouter = () => { connectUrl.searchParams.set("provider", input.provider); connectUrl.searchParams.set("state", state); connectUrl.searchParams.set("protocol", PROTOCOL_SCHEME); + connectUrl.searchParams.set( + "local_callback", + `http://127.0.0.1:${sharedEnv.DESKTOP_NOTIFICATIONS_PORT}/auth/callback`, + ); await shell.openExternal(connectUrl.toString()); return { success: true }; } catch (err) { diff --git a/apps/desktop/src/main/lib/auto-updater.ts b/apps/desktop/src/main/lib/auto-updater.ts index 9a4c509d068..1d95ca60805 100644 --- a/apps/desktop/src/main/lib/auto-updater.ts +++ b/apps/desktop/src/main/lib/auto-updater.ts @@ -21,8 +21,10 @@ function isPrereleaseBuild(): boolean { } const IS_PRERELEASE = isPrereleaseBuild(); +const IS_AUTO_UPDATE_PLATFORM = PLATFORM.IS_MAC || PLATFORM.IS_LINUX; -// Use explicit feed URLs to ensure we always fetch latest-mac.yml from the correct release +// Use explicit feed URLs to ensure we always fetch platform-specific manifests +// (for example latest-mac.yml and latest-linux.yml) from the correct release. // - Stable: fetches from /releases/latest/download/ (latest non-prerelease) // - Canary: fetches from /releases/download/desktop-canary/ (rolling canary tag) const UPDATE_FEED_URL = IS_PRERELEASE @@ -100,7 +102,7 @@ export function dismissUpdate(): void { } export function checkForUpdates(): void { - if (env.NODE_ENV === "development" || !PLATFORM.IS_MAC) { + if (env.NODE_ENV === "development" || !IS_AUTO_UPDATE_PLATFORM) { return; } isDismissed = false; @@ -125,11 +127,11 @@ export function checkForUpdatesInteractive(): void { }); return; } - if (!PLATFORM.IS_MAC) { + if (!IS_AUTO_UPDATE_PLATFORM) { dialog.showMessageBox({ type: "info", title: "Updates", - message: "Auto-updates are only available on macOS.", + message: "Auto-updates are only available on macOS and Linux.", }); return; } @@ -198,7 +200,7 @@ export function simulateError(): void { } export function setupAutoUpdater(): void { - if (env.NODE_ENV === "development" || !PLATFORM.IS_MAC) { + if (env.NODE_ENV === "development" || !IS_AUTO_UPDATE_PLATFORM) { return; } @@ -209,8 +211,8 @@ export function setupAutoUpdater(): void { // Allow downgrade for prerelease builds so users can switch back to stable autoUpdater.allowDowngrade = IS_PRERELEASE; - // Use generic provider with explicit feed URL - // This ensures we always fetch latest-mac.yml from the correct GitHub release + // Use generic provider with explicit feed URL so electron-updater can request + // the correct manifest for the current platform from GitHub release assets. autoUpdater.setFeedURL({ provider: "generic", url: UPDATE_FEED_URL, diff --git a/apps/desktop/src/main/lib/notifications/server.ts b/apps/desktop/src/main/lib/notifications/server.ts index 7f4e849d50b..f156b6f2647 100644 --- a/apps/desktop/src/main/lib/notifications/server.ts +++ b/apps/desktop/src/main/lib/notifications/server.ts @@ -1,8 +1,10 @@ import { EventEmitter } from "node:events"; import express from "express"; +import { BrowserWindow } from "electron"; import { NOTIFICATION_EVENTS } from "shared/constants"; import { env } from "shared/env.shared"; import type { AgentLifecycleEvent } from "shared/notification-types"; +import { handleAuthCallback } from "lib/trpc/routers/auth/utils/auth-functions"; import { appState } from "../app-state"; import { HOOK_PROTOCOL_VERSION } from "../terminal/env"; @@ -176,6 +178,38 @@ app.get("/health", (_req, res) => { res.json({ status: "ok" }); }); +// OAuth callback fallback for Linux/dev environments where custom URI handlers +// are unreliable. Browser can hit localhost directly to complete sign-in. +app.get("/auth/callback", async (req, res) => { + const token = req.query.token; + const expiresAt = req.query.expiresAt; + const state = req.query.state; + + if ( + typeof token !== "string" || + typeof expiresAt !== "string" || + typeof state !== "string" + ) { + return res.status(400).json({ success: false, error: "Missing auth params" }); + } + + const result = await handleAuthCallback({ token, expiresAt, state }); + if (!result.success) { + return res.status(400).json(result); + } + + const mainWindow = BrowserWindow.getAllWindows()[0]; + if (mainWindow) { + if (mainWindow.isMinimized()) { + mainWindow.restore(); + } + mainWindow.show(); + mainWindow.focus(); + } + + return res.json({ success: true }); +}); + // 404 app.use((_req, res) => { res.status(404).json({ error: "Not found" }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/WindowControls/WindowControls.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/WindowControls/WindowControls.tsx index a7f0a36fb12..6af2b9a800a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/WindowControls/WindowControls.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/WindowControls/WindowControls.tsx @@ -19,27 +19,30 @@ export function WindowControls() { }; return ( -
+
); diff --git a/apps/web/src/app/auth/desktop/success/components/DesktopRedirect/DesktopRedirect.tsx b/apps/web/src/app/auth/desktop/success/components/DesktopRedirect/DesktopRedirect.tsx index 678b3ae4849..40b96c702da 100644 --- a/apps/web/src/app/auth/desktop/success/components/DesktopRedirect/DesktopRedirect.tsx +++ b/apps/web/src/app/auth/desktop/success/components/DesktopRedirect/DesktopRedirect.tsx @@ -2,12 +2,68 @@ import Image from "next/image"; import Link from "next/link"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; + +export function DesktopRedirect({ + url, + localCallbackUrl, +}: { + url: string; + localCallbackUrl?: string; +}) { + const [status, setStatus] = useState<"redirecting" | "connected">("redirecting"); -export function DesktopRedirect({ url }: { url: string }) { useEffect(() => { - window.location.href = url; - }, [url]); + let isCancelled = false; + + const runRedirect = async () => { + if (localCallbackUrl) { + for (let attempt = 0; attempt < 6; attempt++) { + try { + const response = await fetch(localCallbackUrl, { + method: "GET", + credentials: "omit", + }); + if (response.ok && !isCancelled) { + setStatus("connected"); + return; + } + } catch { + // Retry shortly in case desktop server is still initializing. + } + await new Promise((resolve) => window.setTimeout(resolve, 250)); + if (isCancelled) return; + } + + // If localhost callback cannot be reached, fall back to URI scheme. + if (!isCancelled) { + window.location.href = url; + return; + } + } else if (!isCancelled) { + window.location.href = url; + return; + } + }; + + void runRedirect(); + + return () => { + isCancelled = true; + }; + }, [url, localCallbackUrl]); + + if (status === "connected") { + return ( +
+ Superset +

Signed in successfully.

+

+ You can return to the desktop app now. +

+
+ ); + } return (
@@ -21,6 +77,11 @@ export function DesktopRedirect({ url }: { url: string }) { > Click here if not redirected + {localCallbackUrl ? ( +

+ If this page stays open, return to the desktop app manually. +

+ ) : null}
); } diff --git a/apps/web/src/app/auth/desktop/success/page.tsx b/apps/web/src/app/auth/desktop/success/page.tsx index 773440c2893..c1f7bd52479 100644 --- a/apps/web/src/app/auth/desktop/success/page.tsx +++ b/apps/web/src/app/auth/desktop/success/page.tsx @@ -8,10 +8,17 @@ import { DesktopRedirect } from "./components/DesktopRedirect"; export default async function DesktopSuccessPage({ searchParams, }: { - searchParams: Promise<{ desktop_state?: string; desktop_protocol?: string }>; + searchParams: Promise<{ + desktop_state?: string; + desktop_protocol?: string; + desktop_local_callback?: string; + }>; }) { - const { desktop_state: state, desktop_protocol = "superset" } = - await searchParams; + const { + desktop_state: state, + desktop_protocol = "superset", + desktop_local_callback: localCallbackBase, + } = await searchParams; if (!state) { return ( @@ -73,10 +80,16 @@ export default async function DesktopSuccessPage({ updatedAt: now, }); const desktopUrl = `${desktop_protocol}://auth/callback?token=${encodeURIComponent(token)}&expiresAt=${encodeURIComponent(expiresAt.toISOString())}&state=${encodeURIComponent(state)}`; + const localCallbackUrl = localCallbackBase + ? `${localCallbackBase}?token=${encodeURIComponent(token)}&expiresAt=${encodeURIComponent(expiresAt.toISOString())}&state=${encodeURIComponent(state)}` + : undefined; return (
- +
); } diff --git a/packages/auth/src/server.ts b/packages/auth/src/server.ts index 6522e21219b..bbc7ba8b035 100644 --- a/packages/auth/src/server.ts +++ b/packages/auth/src/server.ts @@ -45,6 +45,14 @@ import { const qstash = new Client({ token: env.QSTASH_TOKEN }); const NOTIFY_SLACK_URL = `${env.NEXT_PUBLIC_API_URL}/api/integrations/stripe/jobs/notify-slack`; +const desktopDevPort = process.env.DESKTOP_VITE_PORT || "5173"; +const desktopDevOrigins = + process.env.NODE_ENV === "development" + ? [ + `http://localhost:${desktopDevPort}`, + `http://127.0.0.1:${desktopDevPort}`, + ] + : []; export const auth = betterAuth({ baseURL: env.NEXT_PUBLIC_API_URL, @@ -61,6 +69,7 @@ export const auth = betterAuth({ env.NEXT_PUBLIC_MARKETING_URL, env.NEXT_PUBLIC_ADMIN_URL, ...(env.NEXT_PUBLIC_DESKTOP_URL ? [env.NEXT_PUBLIC_DESKTOP_URL] : []), + ...desktopDevOrigins, "superset://app", "superset://", ...(process.env.NODE_ENV === "development" From 1ce0c7bdd64ee831ae15f3032eb6e51b124f4d7b Mon Sep 17 00:00:00 2001 From: Avi Peltz Date: Fri, 13 Feb 2026 17:19:26 -0800 Subject: [PATCH 2/4] chore(desktop): polish release script and apply lint fixes Add release-script checks for jq and robust workflow run lookup by tag SHA, print Linux AppImage download URL, and include lint formatting cleanups in updated desktop auth callback files. Co-authored-by: Cursor --- apps/desktop/create-release.sh | 13 ++++++++++++- apps/desktop/electron-builder.canary.ts | 6 ++---- apps/desktop/src/lib/trpc/routers/auth/index.ts | 2 +- apps/desktop/src/main/lib/notifications/server.ts | 8 +++++--- .../components/DesktopRedirect/DesktopRedirect.tsx | 12 ++++++++++-- apps/web/src/app/auth/desktop/success/page.tsx | 5 +---- 6 files changed, 31 insertions(+), 15 deletions(-) diff --git a/apps/desktop/create-release.sh b/apps/desktop/create-release.sh index 6219d133af1..c71dcf3b7d0 100755 --- a/apps/desktop/create-release.sh +++ b/apps/desktop/create-release.sh @@ -179,6 +179,11 @@ if ! command -v gh &> /dev/null; then error "GitHub CLI (gh) is required but not installed.\nInstall it from: https://cli.github.com/" fi +# Check if jq is installed (required for package.json version updates) +if ! command -v jq &> /dev/null; then + error "jq is required but not installed.\nInstall it with your package manager (e.g. sudo apt install jq)" +fi + # Check if authenticated with gh if ! gh auth status &> /dev/null; then error "Not authenticated with GitHub CLI.\nRun: gh auth login" @@ -333,6 +338,7 @@ REPO=$(git remote get-url origin | sed 's/.*github.com[:/]\(.*\)\.git/\1/') # 6. Monitor the workflow info "Monitoring GitHub Actions workflow..." echo " Waiting for workflow to start (this may take a few seconds)..." +TAG_SHA=$(git rev-list -n 1 "${TAG_NAME}") # Wait and retry to find the workflow run MAX_RETRIES=6 @@ -341,7 +347,11 @@ WORKFLOW_RUN="" while [ $RETRY_COUNT -lt $MAX_RETRIES ] && [ -z "$WORKFLOW_RUN" ]; do sleep 5 - WORKFLOW_RUN=$(gh run list --workflow=release-desktop.yml --json databaseId,headBranch,status --jq ".[] | select(.headBranch == \"${TAG_NAME}\") | .databaseId" | head -1) + WORKFLOW_RUN=$(gh run list \ + --workflow=release-desktop.yml \ + --json databaseId,headSha,event,createdAt \ + --jq ".[] | select(.headSha == \"${TAG_SHA}\" and .event == \"push\") | .databaseId" \ + | head -1) RETRY_COUNT=$((RETRY_COUNT + 1)) if [ -z "$WORKFLOW_RUN" ] && [ $RETRY_COUNT -lt $MAX_RETRIES ]; then @@ -430,6 +440,7 @@ else echo "" echo -e "${BLUE}Direct download:${NC}" echo " • ${LATEST_URL}/download/Superset-arm64.dmg" + echo " • ${LATEST_URL}/download/Superset-x64.AppImage" echo "" else success "Draft release created!" diff --git a/apps/desktop/electron-builder.canary.ts b/apps/desktop/electron-builder.canary.ts index 52cddcebc04..6f461f4ae42 100644 --- a/apps/desktop/electron-builder.canary.ts +++ b/apps/desktop/electron-builder.canary.ts @@ -7,8 +7,8 @@ * @see https://www.electron.build/configuration/configuration */ -import { join } from "node:path"; import { existsSync } from "node:fs"; +import { join } from "node:path"; import type { Configuration } from "electron-builder"; import baseConfig from "./electron-builder"; import pkg from "./package.json"; @@ -42,9 +42,7 @@ const config: Configuration = { linux: { ...baseConfig.linux, - ...(existsSync(canaryLinuxIconPath) - ? { icon: canaryLinuxIconPath } - : {}), + ...(existsSync(canaryLinuxIconPath) ? { icon: canaryLinuxIconPath } : {}), synopsis: `${pkg.description} (Canary)`, artifactName: `superset-canary-\${version}-\${arch}.\${ext}`, }, diff --git a/apps/desktop/src/lib/trpc/routers/auth/index.ts b/apps/desktop/src/lib/trpc/routers/auth/index.ts index 2cb56f042e2..d60a4cddd0e 100644 --- a/apps/desktop/src/lib/trpc/routers/auth/index.ts +++ b/apps/desktop/src/lib/trpc/routers/auth/index.ts @@ -5,8 +5,8 @@ import { observable } from "@trpc/server/observable"; import { shell } from "electron"; import { env } from "main/env.main"; import { getDeviceName, getHashedDeviceId } from "main/lib/device-info"; -import { env as sharedEnv } from "shared/env.shared"; import { PROTOCOL_SCHEME } from "shared/constants"; +import { env as sharedEnv } from "shared/env.shared"; import { z } from "zod"; import { publicProcedure, router } from "../.."; import { diff --git a/apps/desktop/src/main/lib/notifications/server.ts b/apps/desktop/src/main/lib/notifications/server.ts index f156b6f2647..18182d0b30f 100644 --- a/apps/desktop/src/main/lib/notifications/server.ts +++ b/apps/desktop/src/main/lib/notifications/server.ts @@ -1,10 +1,10 @@ import { EventEmitter } from "node:events"; -import express from "express"; import { BrowserWindow } from "electron"; +import express from "express"; +import { handleAuthCallback } from "lib/trpc/routers/auth/utils/auth-functions"; import { NOTIFICATION_EVENTS } from "shared/constants"; import { env } from "shared/env.shared"; import type { AgentLifecycleEvent } from "shared/notification-types"; -import { handleAuthCallback } from "lib/trpc/routers/auth/utils/auth-functions"; import { appState } from "../app-state"; import { HOOK_PROTOCOL_VERSION } from "../terminal/env"; @@ -190,7 +190,9 @@ app.get("/auth/callback", async (req, res) => { typeof expiresAt !== "string" || typeof state !== "string" ) { - return res.status(400).json({ success: false, error: "Missing auth params" }); + return res + .status(400) + .json({ success: false, error: "Missing auth params" }); } const result = await handleAuthCallback({ token, expiresAt, state }); diff --git a/apps/web/src/app/auth/desktop/success/components/DesktopRedirect/DesktopRedirect.tsx b/apps/web/src/app/auth/desktop/success/components/DesktopRedirect/DesktopRedirect.tsx index 40b96c702da..be953f5041c 100644 --- a/apps/web/src/app/auth/desktop/success/components/DesktopRedirect/DesktopRedirect.tsx +++ b/apps/web/src/app/auth/desktop/success/components/DesktopRedirect/DesktopRedirect.tsx @@ -11,7 +11,9 @@ export function DesktopRedirect({ url: string; localCallbackUrl?: string; }) { - const [status, setStatus] = useState<"redirecting" | "connected">("redirecting"); + const [status, setStatus] = useState<"redirecting" | "connected">( + "redirecting", + ); useEffect(() => { let isCancelled = false; @@ -56,7 +58,13 @@ export function DesktopRedirect({ if (status === "connected") { return (
- Superset + Superset

Signed in successfully.

You can return to the desktop app now. diff --git a/apps/web/src/app/auth/desktop/success/page.tsx b/apps/web/src/app/auth/desktop/success/page.tsx index c1f7bd52479..23c353d76f1 100644 --- a/apps/web/src/app/auth/desktop/success/page.tsx +++ b/apps/web/src/app/auth/desktop/success/page.tsx @@ -86,10 +86,7 @@ export default async function DesktopSuccessPage({ return (

- +
); } From 103a560f4b7a167fdb062e89693685c80c8a0fbf Mon Sep 17 00:00:00 2001 From: Avi Peltz Date: Fri, 13 Feb 2026 17:34:44 -0800 Subject: [PATCH 3/4] fix(streams): use stable zod import in chunk routes Switch to a namespace zod import so the chunks route schema initializes reliably under the Vitest SSR runtime, unblocking the streams test suite in CI. Co-authored-by: Cursor --- apps/streams/src/routes/chunks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/streams/src/routes/chunks.ts b/apps/streams/src/routes/chunks.ts index 1e2f930c33c..927b1c2206d 100644 --- a/apps/streams/src/routes/chunks.ts +++ b/apps/streams/src/routes/chunks.ts @@ -1,5 +1,5 @@ import { type Context, Hono } from "hono"; -import { z } from "zod"; +import * as z from "zod"; import type { AIDBSessionProtocol } from "../protocol"; import type { StreamChunk } from "../types"; From 397b900b6cd95c032e3aa992d0296121f4292058 Mon Sep 17 00:00:00 2001 From: Avi Peltz Date: Fri, 13 Feb 2026 19:35:31 -0800 Subject: [PATCH 4/4] fix(desktop): use full-page redirect for auth callback instead of fetch Replace the fetch()-based localhost callback with a full browser redirect. Browsers block mixed-content fetch (HTTPS->HTTP) but allow full-page navigations, fixing auth on Linux production builds where the success page is served from https://app.superset.sh. Co-authored-by: Cursor --- .../src/main/lib/notifications/server.ts | 11 ++- .../DesktopRedirect/DesktopRedirect.tsx | 76 +++---------------- 2 files changed, 20 insertions(+), 67 deletions(-) diff --git a/apps/desktop/src/main/lib/notifications/server.ts b/apps/desktop/src/main/lib/notifications/server.ts index 18182d0b30f..81b1c8962cb 100644 --- a/apps/desktop/src/main/lib/notifications/server.ts +++ b/apps/desktop/src/main/lib/notifications/server.ts @@ -209,7 +209,16 @@ app.get("/auth/callback", async (req, res) => { mainWindow.focus(); } - return res.json({ success: true }); + // Return HTML since the browser navigated here directly (not fetch). + res.setHeader("Content-Type", "text/html"); + return res.send(` +Superset + +
+

Signed in successfully

+

You can close this tab and return to the desktop app.

+
+`); }); // 404 diff --git a/apps/web/src/app/auth/desktop/success/components/DesktopRedirect/DesktopRedirect.tsx b/apps/web/src/app/auth/desktop/success/components/DesktopRedirect/DesktopRedirect.tsx index be953f5041c..263b9234b63 100644 --- a/apps/web/src/app/auth/desktop/success/components/DesktopRedirect/DesktopRedirect.tsx +++ b/apps/web/src/app/auth/desktop/success/components/DesktopRedirect/DesktopRedirect.tsx @@ -2,7 +2,7 @@ import Image from "next/image"; import Link from "next/link"; -import { useEffect, useState } from "react"; +import { useEffect } from "react"; export function DesktopRedirect({ url, @@ -11,68 +11,17 @@ export function DesktopRedirect({ url: string; localCallbackUrl?: string; }) { - const [status, setStatus] = useState<"redirecting" | "connected">( - "redirecting", - ); - useEffect(() => { - let isCancelled = false; - - const runRedirect = async () => { - if (localCallbackUrl) { - for (let attempt = 0; attempt < 6; attempt++) { - try { - const response = await fetch(localCallbackUrl, { - method: "GET", - credentials: "omit", - }); - if (response.ok && !isCancelled) { - setStatus("connected"); - return; - } - } catch { - // Retry shortly in case desktop server is still initializing. - } - await new Promise((resolve) => window.setTimeout(resolve, 250)); - if (isCancelled) return; - } - - // If localhost callback cannot be reached, fall back to URI scheme. - if (!isCancelled) { - window.location.href = url; - return; - } - } else if (!isCancelled) { - window.location.href = url; - return; - } - }; - - void runRedirect(); - - return () => { - isCancelled = true; - }; + if (localCallbackUrl) { + // Full-page redirect to localhost — not blocked by mixed content. + // Browsers only block mixed-content subresources (fetch, XHR), not navigations. + window.location.href = localCallbackUrl; + } else { + // Fallback to deep link (macOS, or when local server unavailable) + window.location.href = url; + } }, [url, localCallbackUrl]); - if (status === "connected") { - return ( -
- Superset -

Signed in successfully.

-

- You can return to the desktop app now. -

-
- ); - } - return (
Superset @@ -80,16 +29,11 @@ export function DesktopRedirect({ Redirecting to desktop app...

Click here if not redirected - {localCallbackUrl ? ( -

- If this page stays open, return to the desktop app manually. -

- ) : null}
); }