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/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 fe20ad0d2c1..6f461f4ae42 100644 --- a/apps/desktop/electron-builder.canary.ts +++ b/apps/desktop/electron-builder.canary.ts @@ -7,12 +7,16 @@ * @see https://www.electron.build/configuration/configuration */ +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"; 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,14 @@ 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..d60a4cddd0e 100644 --- a/apps/desktop/src/lib/trpc/routers/auth/index.ts +++ b/apps/desktop/src/lib/trpc/routers/auth/index.ts @@ -6,6 +6,7 @@ import { shell } from "electron"; import { env } from "main/env.main"; import { getDeviceName, getHashedDeviceId } from "main/lib/device-info"; import { PROTOCOL_SCHEME } from "shared/constants"; +import { env as sharedEnv } from "shared/env.shared"; import { z } from "zod"; import { publicProcedure, router } from "../.."; import { @@ -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..81b1c8962cb 100644 --- a/apps/desktop/src/main/lib/notifications/server.ts +++ b/apps/desktop/src/main/lib/notifications/server.ts @@ -1,5 +1,7 @@ import { EventEmitter } from "node:events"; +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"; @@ -176,6 +178,49 @@ 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 HTML since the browser navigated here directly (not fetch). + res.setHeader("Content-Type", "text/html"); + return res.send(` +
You can close this tab and return to the desktop app.
+