diff --git a/README.md b/README.md index 9537c9bc..2bac56bb 100644 --- a/README.md +++ b/README.md @@ -1,104 +1,242 @@ -# Open Swarm — Agent Orchestrator +

+ Open Swarm +

-A locally-running React + FastAPI application for managing multiple Claude Code instances in parallel. Designed for power users who run multiple agents simultaneously and need a unified interface to monitor, control, and coordinate them. +

Open Swarm

+ +

+ An Army of AI Agents at Your Fingertips +
+ A locally-running orchestrator for managing multiple Claude Code instances in parallel. +
+ Launch, monitor, and coordinate entire swarms of coding agents from a single interface. +

+ +

+ License + Platform + GitHub Stars + PRs Welcome +

+ +
+ +

+ Open Swarm Dashboard +

+ +
+ +## Why Open Swarm? + +Running Claude Code in a terminal works fine for one task. But when you're juggling five agents across different branches, approving tool calls in separate windows, and losing track of who's doing what — it falls apart fast. + +- **Parallel agents, one screen** — Launch as many Claude Code instances as you need, arranged on a spatial canvas you can pan and zoom freely +- **Unified approval workflow** — Every tool-use request from every agent surfaces in one place. Approve or deny with a click or a keyboard shortcut. +- **Full conversation control** — Edit prior messages to fork conversations, navigate between branches, resume closed sessions +- **100% local** — Everything runs on your machine. No cloud relay, no telemetry, no third-party backend. + +
## Features -- **Multi-agent management** — Launch and monitor multiple Claude Code instances side by side -- **Git worktree isolation** — Each agent works on its own git worktree/branch to avoid conflicts -- **Real-time streaming** — WebSocket-based streaming of agent messages and status updates -- **HITL approvals** — Approve or deny tool usage requests from the dashboard or within each chat -- **Message branching** — Edit prior messages to fork conversations, navigate between branches -- **Prompt template library** — Reusable prompt templates with structured input fields, invoked via `/` commands -- **Skills library** — Manage skills synced to the native `~/.claude/skills/` directory -- **Tools library** — Define custom tool configurations (bash, MCP, Python) -- **Keyboard shortcuts** — Navigate between agents and approve/deny requests without a mouse -- **Diff viewer** — View uncommitted changes in each agent's worktree +**Spatial Dashboard** — Infinite canvas with drag-and-drop agent cards, view cards, and embedded browser cards. Create multiple dashboards for different workspaces. + +**Agent Chat** — Full streaming chat interface powered by WebSockets. Real-time token output, cost tracking per session, and persistent history that survives restarts. + +**Human-in-the-Loop Approvals** — Agents request permission before executing tools. Approve or deny individually, or batch-approve from the dashboard. Configurable per-tool permissions (always allow, ask, deny). + +**Message Branching** — Edit any prior message to fork the conversation. Navigate freely between branches without losing context. + +**Prompt Templates** — Build reusable templates with structured input fields. Invoke them inline via `/` slash commands. + +**Skills Library** — Manage skills that sync directly to `~/.claude/skills/`. Browse and install from the official Anthropic skills marketplace. + +**Tools Library** — Configure MCP tool servers (stdio, HTTP, SSE) with automatic tool discovery. Browse the MCP registry and Google's catalog with GitHub star counts. Includes Google Workspace OAuth integration. + +**Agent Modes** — Five built-in modes (Agent, Ask, Plan, View Builder, Skill Builder) plus custom user-defined modes with configurable system prompts and tool restrictions. + +**Views & Outputs** — Create interactive HTML/JS/CSS artifacts rendered in iframes. Supports vibe coding (LLM-generates the view), backend Python execution, auto-run with LLM-generated data, and agent-driven data gathering. + +**Git Worktree Isolation** — Each agent operates in its own git worktree and branch, preventing conflicts between parallel workstreams. + +**Diff Viewer** — Inspect uncommitted changes in any agent's worktree without leaving the app. + +**Cost Tracking** — Real-time USD spend tracking per agent session. + +**Dark & Light Themes** — Full theme support with Claude-inspired design tokens. + +**Keyboard Shortcuts** — Navigate between agents, approve/deny requests, and switch pages without touching a mouse. + +
+ +## Quick Start + +### Desktop App + +Download the latest release for macOS from [GitHub Releases](https://github.com/openswarm-ai/openswarm/releases). + +> Windows and Linux builds are planned but not yet available. + +### Development Setup + +**Prerequisites:** Python 3.11+, Node.js 18+, Git + +```bash +git clone https://github.com/openswarm-ai/openswarm.git +cd openswarm +bash run.sh +``` + +This starts the backend (port 8324), frontend (port 3000), and Electron shell together. Once running, set your Anthropic API key in the in-app Settings page. + +To run services individually: + +```bash +bash backend/run.sh # API at http://localhost:8324 — docs at /docs +bash frontend/run.sh # App at http://localhost:3000 +``` + +
## Architecture ``` -Frontend (React/TypeScript :3000) Backend (FastAPI/Python :8324) -┌─────────────────────────────┐ ┌──────────────────────────────┐ -│ Dashboard │◄────►│ REST API (/api/*) │ -│ Agent Chat (per session) │ │ WebSocket (/ws/*) │ -│ Templates / Skills / Tools │ │ Agent Manager │ -│ Slash Command Picker │ │ └─ Claude Agent SDK │ -│ Keyboard Shortcuts │ │ Worktree Manager │ -│ Diff Viewer │ │ Library Storage (JSON/files)│ -└─────────────────────────────┘ └──────────────────────────────┘ +Electron Shell (desktop wrapper, auto-updater) +├─────────────────────────────────────────────────────────────────────┐ +│ │ +│ Frontend (React/TypeScript :3000) Backend (FastAPI :8324) │ +│ ┌───────────────────────────────┐ ┌───────────────────────┐ │ +│ │ Spatial Dashboard Canvas │◄────►│ REST API (/api/*) │ │ +│ │ Agent Chat (streaming) │ │ WebSocket (/ws/*) │ │ +│ │ Templates / Skills / Tools │ WS │ Agent Manager │ │ +│ │ Modes / Views / Commands │◄────►│ └─ claude-agent-sdk│ │ +│ │ Settings │ │ MCP Tool Discovery │ │ +│ │ Redux Toolkit (state) │ │ JSON File Storage │ │ +│ └───────────────────────────────┘ └───────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ ``` -## Quick Start +
-### Prerequisites +## Configuration -- Python 3.11+ -- Node.js 18+ -- Git -- An Anthropic API key (for real agent usage) — `export ANTHROPIC_API_KEY=...` +The Anthropic API key is configured in-app via the **Settings** page — no environment variable needed for normal usage. -### Backend +For advanced configuration, copy `backend/.env.example` to `backend/.env`: -```bash -bash backend/run.sh -# API runs at http://localhost:8324 -# Docs at http://localhost:8324/docs -``` +| Variable | Purpose | +|----------|---------| +| `BACKEND_PORT` | Backend server port (default: `8324`) | +| `GOOGLE_OAUTH_CLIENT_ID` | Google Workspace integration (Gmail, Calendar, Drive) | +| `GOOGLE_OAUTH_CLIENT_SECRET` | Google Workspace integration | +| `APPLE_ID` | macOS code signing & notarization (release builds only) | +| `APPLE_APP_SPECIFIC_PASSWORD` | macOS notarization (release builds only) | +| `APPLE_TEAM_ID` | macOS code signing (release builds only) | +| `GH_TOKEN` | GitHub Releases publishing (release builds only) | -### Frontend - -```bash -bash frontend/run.sh -# App runs at http://localhost:3000 -``` - -### Mock Mode - -If `claude-agent-sdk` is not installed, the backend runs in **mock mode** — agents simulate tool calls and responses so you can develop and test the UI without an API key. +
## Keyboard Shortcuts | Key | Action | |-----|--------| -| `d` | Go to Dashboard | -| `t` | Go to Templates | -| `1`–`9` | Open agent by position | +| `D` | Go to Dashboard | +| `T` | Go to Templates | +| `1` – `9` | Open agent by position | | `Shift+A` | Approve all pending requests | | `Shift+D` | Deny all pending requests | | `?` | Show shortcuts help | -## Slash Commands +Type `/` in the chat input to invoke prompt templates and skills as slash commands. -In the chat input, type `/` to invoke templates and skills: -- `/template-name` — Opens the template's input modal -- `/skill-name` — Inserts the skill content into the message +
## Project Structure ``` backend/ apps/ - agents/ — Agent lifecycle, WebSocket, worktree management - templates/ — Prompt template CRUD (JSON file storage) - skills/ — Skills CRUD (synced to ~/.claude/skills/) - tools_lib/ — Tool definitions CRUD (JSON file storage) - health/ — Health check endpoint - config/ — FastAPI app configuration - data/ — Persistent JSON file storage (sessions, dashboards, settings, templates, tools, etc.) + agents/ Agent lifecycle, streaming, worktree management + dashboards/ Dashboard CRUD and layout persistence + dashboard_layout/ Card positions and spatial canvas state + templates/ Prompt template CRUD + skills/ Skills CRUD (synced to ~/.claude/skills/) + tools_lib/ MCP tool configuration and discovery + modes/ Agent mode definitions + outputs/ Views/outputs, vibe coding, Python executor + settings/ App settings and file browser + health/ Health check endpoint + mcp_registry/ MCP server registry proxy + skill_registry/ Anthropic skills marketplace proxy + config/ FastAPI app configuration + data/ Persistent JSON file storage frontend/ src/ app/ - components/ — AppShell, NewAgentModal, SlashCommandPicker, KeyboardShortcutsHelp + components/ AppShell, Layout, shared UI pages/ - Dashboard/ — Agent overview grid with live status - AgentChat/ — Full chat UI with streaming, HITL, branching, diff viewer - Templates/ — Template library with editor - Skills/ — Skills library with editor - Tools/ — Tools library with editor + Dashboard/ Spatial canvas with agent/view/browser cards + AgentChat/ Streaming chat, HITL approvals, branching, diff viewer + Templates/ Template library with structured input fields + Skills/ Skills library, skill builder, registry browser + Tools/ Tool config, MCP discovery, OAuth, registry browser + Modes/ Mode definitions with system prompts + Views/ Output artifacts, code editor, vibe coding + Commands/ Keyboard shortcuts reference + Settings/ App configuration shared/ - state/ — Redux slices (agents, templates, skills, tools) - ws/ — WebSocket manager - hooks/ — Custom hooks (keyboard shortcuts) + state/ Redux slices (agents, dashboards, templates, skills, tools, modes, etc.) + ws/ WebSocket manager + hooks/ Custom hooks + styles/ Theme tokens, global styles + +electron/ + main.js Electron main process, auto-updater, Python env management + scripts/ Build and notarization scripts + +scripts/ + build-app.sh Desktop app packaging (electron-builder) + build-python-env.sh Standalone Python 3.13 environment bundler ``` + +
+ +## Tech Stack + +**Frontend** — React 18, TypeScript, Redux Toolkit, Material UI v7, CodeMirror 6, Framer Motion, React Router v7, Webpack 5 + +**Backend** — FastAPI, Python 3.11+, Pydantic v2, claude-agent-sdk, Anthropic SDK, WebSockets, httpx + +**Desktop** — Electron 33, electron-builder, electron-updater (auto-updates via GitHub Releases) + +**Bundled Runtime** — Standalone Python 3.13 (via python-build-standalone) so end users don't need Python installed + +
+ +## Contributing + +Contributions are welcome. To get started: + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/your-feature`) +3. Make your changes +4. Submit a pull request + +Please open an issue first for larger changes so we can discuss the approach. + +
+ +## Community + +- [Twitter / X](https://twitter.com/openswarm_ai) +- [Discord](https://discord.gg/openswarm) +- [Website](https://openswarm.ai) + +
+ +## License + +MIT — see [LICENSE](LICENSE) for details. diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 00000000..8b9f8611 Binary files /dev/null and b/assets/icon.png differ diff --git a/assets/screenshot.png b/assets/screenshot.png new file mode 100644 index 00000000..4448cbe7 Binary files /dev/null and b/assets/screenshot.png differ diff --git a/backend/.env.example b/backend/.env.example index 4d293e35..ded015af 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -3,6 +3,17 @@ # ============================================================================= BACKEND_PORT=8324 - GOOGLE_OAUTH_CLIENT_ID=your-google-oauth-client-id.apps.googleusercontent.com GOOGLE_OAUTH_CLIENT_SECRET=your-google-oauth-client-secret + +# ============================================================================= +# macOS Code Signing & Notarization (required for ./scripts/build-app.sh --publish) +# ============================================================================= +APPLE_ID=your-apple-id@example.com +APPLE_APP_SPECIFIC_PASSWORD=xxxx-xxxx-xxxx-xxxx +APPLE_TEAM_ID=ABCDE12345 + +# ============================================================================= +# GitHub Releases (required for --publish) +# ============================================================================= +GH_TOKEN=ghp_your-github-personal-access-token diff --git a/backend/ENV_README.md b/backend/ENV_README.md new file mode 100644 index 00000000..ec04f287 --- /dev/null +++ b/backend/ENV_README.md @@ -0,0 +1,227 @@ +# Environment Variables Setup Guide + +Copy `.env.example` to `.env` and fill in the values below. This guide walks you through getting every single one. + +```bash +cp .env.example .env +``` + +--- + +## `BACKEND_PORT` + +The port the backend server runs on. The default is fine — only change it if something else is already using port 8324. + +``` +BACKEND_PORT=8324 +``` + +--- + +## `GOOGLE_OAUTH_CLIENT_ID` & `GOOGLE_OAUTH_CLIENT_SECRET` + +These let users sign in with their Google account. + +### Step 1 — Go to Google Cloud Console + +1. Open https://console.cloud.google.com/ +2. Sign in with your Google account (or create one). + +### Step 2 — Create a project + +1. Click the project dropdown at the very top of the page (it says "Select a project" or shows your current project name). +2. Click **New Project** in the top-right of the popup. +3. Name it something like `OpenSwarm`. +4. Click **Create**. +5. Wait a few seconds, then click the project dropdown again and select your new `OpenSwarm` project. + +### Step 3 — Enable the Google+ API (required for OAuth) + +1. In the left sidebar, click **APIs & Services** > **Library**. +2. Search for `Google+ API` (or `Google Identity`). +3. Click on it, then click **Enable**. + +### Step 4 — Configure the OAuth consent screen + +1. In the left sidebar, click **APIs & Services** > **OAuth consent screen**. +2. Select **External** (unless you're inside a Google Workspace org and only want internal users). +3. Click **Create**. +4. Fill in the required fields: + - **App name**: `OpenSwarm` + - **User support email**: your email + - **Developer contact email**: your email +5. Click **Save and Continue**. +6. On the **Scopes** page, click **Add or Remove Scopes**. + - Check `email` and `profile` (the `openid` scope is added automatically). + - Click **Update**, then **Save and Continue**. +7. On the **Test users** page, click **Add Users**, enter your own email, click **Add**, then **Save and Continue**. +8. Click **Back to Dashboard**. + +### Step 5 — Create OAuth credentials + +1. In the left sidebar, click **APIs & Services** > **Credentials**. +2. Click **+ Create Credentials** at the top. +3. Select **OAuth client ID**. +4. For **Application type**, select **Web application**. +5. **Name**: `OpenSwarm` (or anything you want). +6. Under **Authorized redirect URIs**, click **+ Add URI** and add: + ``` + http://localhost:8324/api/auth/google/callback + ``` + (Replace `8324` with your `BACKEND_PORT` if you changed it.) +7. Click **Create**. + +### Step 6 — Copy the values + +A popup appears with your credentials: + +- **Client ID** — copy this into `GOOGLE_OAUTH_CLIENT_ID` +- **Client Secret** — copy this into `GOOGLE_OAUTH_CLIENT_SECRET` + +``` +GOOGLE_OAUTH_CLIENT_ID=123456789-xxxxxxxxx.apps.googleusercontent.com +GOOGLE_OAUTH_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxxxxx +``` + +--- + +## `APPLE_ID` + +This is the email address you use to sign in to your Apple Developer account. + +1. If you don't have one, go to https://developer.apple.com/programs/ and click **Enroll**. It costs $99/year. +2. Once enrolled, your `APPLE_ID` is just the email you signed up with. + +``` +APPLE_ID=you@example.com +``` + +--- + +## `APPLE_TEAM_ID` + +Your 10-character Apple Developer team identifier. + +1. Go to https://developer.apple.com/account +2. Sign in. +3. Look at the top-right — your name is shown. Click it, or scroll down. +4. Under **Membership Details** (or at https://developer.apple.com/account#MembershipDetailsCard), you'll see **Team ID**. +5. It looks like `ABCDE12345`. Copy it. + +``` +APPLE_TEAM_ID=ABCDE12345 +``` + +--- + +## `APPLE_APP_SPECIFIC_PASSWORD` + +Apple doesn't let you use your regular password for automated tools. You need to generate a special one-time password. + +### Step 1 — Turn on two-factor authentication (if you haven't already) + +1. On your Mac, go to **System Settings** > **[your name]** > **Sign-In & Security** > **Two-Factor Authentication**. +2. Turn it on and follow the prompts. + +### Step 2 — Generate the app-specific password + +1. Go to https://account.apple.com/ +2. Sign in with your Apple ID. +3. In the **Sign-In and Security** section, click **App-Specific Passwords**. +4. Click **Generate an app-specific password** (or the **+** button). +5. Enter a label like `OpenSwarm Notarization`. +6. Click **Create**. +7. Apple shows you a password in the format `xxxx-xxxx-xxxx-xxxx`. **Copy it now** — you can't see it again. + +``` +APPLE_APP_SPECIFIC_PASSWORD=abcd-efgh-ijkl-mnop +``` + +--- + +## macOS Signing Certificate (no env var, but required) + +Before you can sign and notarize, you need a **Developer ID Application** certificate installed in your macOS Keychain. This is what Apple uses to verify that the app was built by you. + +### Step 1 — Open Xcode + +1. Open **Xcode** on your Mac (install it from the Mac App Store if you don't have it). +2. Go to **Xcode** menu > **Settings** (or **Preferences** on older versions). +3. Click the **Accounts** tab. +4. Click **+** in the bottom-left and sign in with your Apple ID. + +### Step 2 — Create the certificate + +1. Select your account in the list, then click **Manage Certificates...** in the bottom-right. +2. Click the **+** in the bottom-left of the popup. +3. Select **Developer ID Application**. +4. Click **Create**. + +That's it — the certificate is now in your macOS Keychain. `electron-builder` will auto-discover it during builds. You don't need to set any env var for this. + +### Alternative — manual method (without Xcode) + +1. Go to https://developer.apple.com/account/resources/certificates/list +2. Click the **+** button. +3. Select **Developer ID Application**, click **Continue**. +4. You'll be asked to upload a **Certificate Signing Request (CSR)**: + - Open **Keychain Access** on your Mac. + - In the menu bar: **Keychain Access** > **Certificate Assistant** > **Request a Certificate From a Certificate Authority**. + - Enter your email, leave CA Email blank, select **Saved to disk**, click **Continue**. + - Save the `.certSigningRequest` file. +5. Upload that file on the Apple Developer page, click **Continue**. +6. Download the `.cer` file. +7. Double-click it — it installs into your Keychain. + +--- + +## `GH_TOKEN` + +A GitHub Personal Access Token that lets the build script upload release artifacts to GitHub Releases. + +### Step 1 — Go to GitHub token settings + +1. Go to https://github.com/settings/tokens +2. Sign in if needed. + +### Step 2 — Create a token + +1. Click **Generate new token** > **Generate new token (classic)**. +2. **Note**: `OpenSwarm Releases` (or whatever you want). +3. **Expiration**: pick a duration (90 days, or "No expiration" if you don't want to rotate it). +4. **Scopes**: check the **`repo`** checkbox (this gives full access to your repositories, which is needed to create releases and upload assets). +5. Click **Generate token** at the bottom. +6. **Copy the token now** — it starts with `ghp_` and you won't be able to see it again. + +``` +GH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +--- + +## Final `.env` example + +``` +BACKEND_PORT=8324 + +GOOGLE_OAUTH_CLIENT_ID=123456789-xxxxxxxxx.apps.googleusercontent.com +GOOGLE_OAUTH_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxxxxx + +APPLE_ID=you@example.com +APPLE_APP_SPECIFIC_PASSWORD=abcd-efgh-ijkl-mnop +APPLE_TEAM_ID=ABCDE12345 + +GH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +--- + +## Running a production build + +Once your `.env` is filled in, just run: + +```bash +./scripts/build-app.sh --publish +``` + +The build script automatically loads `backend/.env`, so you don't need to source it yourself. This will build the app, sign it with your certificate, notarize it with Apple, and upload the `.dmg` and `.zip` to a GitHub Release. diff --git a/backend/apps/dashboards/dashboards.py b/backend/apps/dashboards/dashboards.py index 64824afb..9e137e25 100644 --- a/backend/apps/dashboards/dashboards.py +++ b/backend/apps/dashboards/dashboards.py @@ -13,6 +13,7 @@ from backend.apps.dashboards.models import ( DashboardLayout, CardPosition, ViewCardPosition, + BrowserCardPosition, ) from fastapi import HTTPException @@ -112,6 +113,7 @@ async def list_dashboards(): items.append({ "id": dumped["id"], "name": dumped.get("name", "Untitled"), + "auto_named": dumped.get("auto_named", False), "created_at": dumped.get("created_at"), "updated_at": dumped.get("updated_at"), }) @@ -125,6 +127,69 @@ async def create_dashboard(body: DashboardCreate): return dashboard.model_dump(mode="json") +@dashboards.router.post("/{dashboard_id}/generate-name") +async def generate_name(dashboard_id: str): + dashboard = _load(dashboard_id) + + if not dashboard.auto_named and dashboard.name != "Untitled Dashboard": + return {"name": dashboard.name, "auto_named": dashboard.auto_named} + + from backend.apps.agents.agent_manager import agent_manager + + prompts = [] + for session in agent_manager.sessions.values(): + if getattr(session, "dashboard_id", None) != dashboard_id: + continue + for msg in session.messages: + if msg.role == "user" and isinstance(msg.content, str) and msg.content.strip(): + prompts.append(msg.content.strip()[:200]) + break + + if not prompts: + return {"name": dashboard.name, "auto_named": dashboard.auto_named} + + fallback = prompts[0][:40] + try: + import anthropic + from backend.apps.settings.settings import load_settings + global_settings = load_settings() + if not global_settings.anthropic_api_key: + raise ValueError("API key not configured") + + client = anthropic.AsyncAnthropic(api_key=global_settings.anthropic_api_key) + + if len(prompts) == 1: + system = ( + "Generate a concise 2-5 word workspace name for a project based on this task. " + "Return only the name, nothing else." + ) + user_content = prompts[0] + else: + system = ( + "Generate a concise 2-5 word workspace name that captures the overall theme of these tasks. " + "Return only the name, nothing else." + ) + user_content = "\n".join(f"- {p}" for p in prompts) + + resp = await client.messages.create( + model="claude-haiku-4-20250414", + max_tokens=30, + system=system, + messages=[{"role": "user", "content": user_content}], + ) + generated = resp.content[0].text.strip().strip('"\'') + if generated: + fallback = generated + except Exception as e: + logger.warning(f"Dashboard name generation failed, using fallback: {e}") + + dashboard.name = fallback + dashboard.auto_named = True + dashboard.updated_at = datetime.now() + _save(dashboard) + return {"name": dashboard.name, "auto_named": True} + + @dashboards.router.get("/{dashboard_id}") async def get_dashboard(dashboard_id: str): dashboard = _load(dashboard_id) @@ -136,6 +201,7 @@ async def update_dashboard(dashboard_id: str, body: DashboardUpdate): dashboard = _load(dashboard_id) if body.name is not None: dashboard.name = body.name + dashboard.auto_named = False if body.layout is not None: dashboard.layout = body.layout dashboard.updated_at = datetime.now() @@ -188,7 +254,11 @@ async def duplicate_dashboard(dashboard_id: str): "name": f"{source_data.get('name', 'Untitled')} (copy)", "created_at": now, "updated_at": now, - "layout": {"cards": {}, "view_cards": source_data.get("layout", {}).get("view_cards", {})}, + "layout": { + "cards": {}, + "view_cards": source_data.get("layout", {}).get("view_cards", {}), + "browser_cards": source_data.get("layout", {}).get("browser_cards", {}), + }, } with open(os.path.join(DATA_DIR, f"{new_id}.json"), "w") as f: json.dump(new_dashboard, f, indent=2) diff --git a/backend/apps/dashboards/models.py b/backend/apps/dashboards/models.py index 149f2069..2d5f6d00 100644 --- a/backend/apps/dashboards/models.py +++ b/backend/apps/dashboards/models.py @@ -20,14 +20,26 @@ class ViewCardPosition(BaseModel): height: float = 360 +class BrowserCardPosition(BaseModel): + browser_id: str + url: str = "" + x: float = 0 + y: float = 0 + width: float = 640 + height: float = 480 + + class DashboardLayout(BaseModel): cards: dict[str, CardPosition] = Field(default_factory=dict) view_cards: dict[str, ViewCardPosition] = Field(default_factory=dict) + browser_cards: dict[str, BrowserCardPosition] = Field(default_factory=dict) + expanded_session_ids: list[str] = Field(default_factory=list) class Dashboard(BaseModel): id: str = Field(default_factory=lambda: uuid4().hex) name: str = "Untitled Dashboard" + auto_named: bool = False created_at: datetime = Field(default_factory=datetime.now) updated_at: datetime = Field(default_factory=datetime.now) layout: DashboardLayout = Field(default_factory=DashboardLayout) diff --git a/electron/main.js b/electron/main.js index 1b02b736..7d4168a1 100644 --- a/electron/main.js +++ b/electron/main.js @@ -11,6 +11,7 @@ let backendPort = null; const isPackaged = app.isPackaged; const isDev = process.env.ELECTRON_DEV === '1'; +const iconPath = path.join(__dirname, 'build', 'icon.png'); function getResourcePath(...segments) { if (isPackaged) { @@ -118,6 +119,7 @@ function createWindow() { minWidth: 800, minHeight: 600, title: 'OpenSwarm', + icon: iconPath, titleBarStyle: 'hiddenInset', webPreferences: { preload: path.join(__dirname, 'preload.js'), @@ -185,6 +187,10 @@ function killBackend() { } app.whenReady().then(async () => { + if (process.platform === 'darwin' && !isPackaged) { + try { app.dock.setIcon(iconPath); } catch (_) {} + } + try { if (isDev) { backendPort = parseInt(process.env.OPENSWARM_PORT || '8324', 10); diff --git a/electron/package-lock.json b/electron/package-lock.json index 6453e6d8..5ec26fd8 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -12,6 +12,7 @@ "get-port": "^5.1.1" }, "devDependencies": { + "@electron/notarize": "^3.1.1", "electron": "^33.0.0", "electron-builder": "^25.1.0" } @@ -106,57 +107,17 @@ } }, "node_modules/@electron/notarize": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", - "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-3.1.1.tgz", + "integrity": "sha512-uQQSlOiJnqRkTL1wlEBAxe90nVN/Fc/hEmk0bqpKk8nKjV1if/tXLHKUPePtv9Xsx90PtZU8aidx5lAiOpjkQQ==", "dev": true, "license": "MIT", "dependencies": { - "debug": "^4.1.1", - "fs-extra": "^9.0.1", + "debug": "^4.4.0", "promise-retry": "^2.0.1" }, "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/@electron/notarize/node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@electron/notarize/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@electron/notarize/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" + "node": ">= 22.12.0" } }, "node_modules/@electron/osx-sign": { @@ -956,6 +917,37 @@ "electron-builder-squirrel-windows": "25.1.8" } }, + "node_modules/app-builder-lib/node_modules/@electron/notarize": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", + "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/app-builder-lib/node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", diff --git a/electron/package.json b/electron/package.json index d521a652..3c31d9a1 100644 --- a/electron/package.json +++ b/electron/package.json @@ -7,6 +7,7 @@ "start": "electron .", "dev": "ELECTRON_DEV=1 electron .", "dist": "electron-builder --mac --publish never", + "dist:publish": "electron-builder --mac --publish always", "dist:all": "electron-builder --mac --win --linux" }, "dependencies": { @@ -14,6 +15,7 @@ "get-port": "^5.1.1" }, "devDependencies": { + "@electron/notarize": "^3.1.1", "electron": "^33.0.0", "electron-builder": "^25.1.0" }, @@ -27,9 +29,19 @@ "mac": { "icon": "build/icon.icns", "target": [ + { + "target": "dmg", + "arch": [ + "arm64", + "x64" + ] + }, { "target": "zip", - "arch": ["arm64"] + "arch": [ + "arm64", + "x64" + ] } ], "category": "public.app-category.developer-tools", @@ -40,37 +52,63 @@ "dmg": { "title": "OpenSwarm", "contents": [ - { "x": 130, "y": 220 }, - { "x": 410, "y": 220, "type": "link", "path": "/Applications" } + { + "x": 130, + "y": 220 + }, + { + "x": 410, + "y": 220, + "type": "link", + "path": "/Applications" + } ] }, "extraResources": [ { "from": "../frontend/dist", "to": "frontend", - "filter": ["**/*"] + "filter": [ + "**/*" + ] }, { "from": "../backend", "to": "backend", - "filter": ["**/*", "!__pycache__/**", "!**/__pycache__/**", "!.venv/**", "!*.pyc"] + "filter": [ + "**/*", + "!__pycache__/**", + "!**/__pycache__/**", + "!.venv/**", + "!*.pyc" + ] }, { "from": "../debugger", "to": "debugger", - "filter": ["**/*", "!__pycache__/**", "!**/__pycache__/**", "!*.pyc", "!.venv/**", "!**/node_modules/**"] + "filter": [ + "**/*", + "!__pycache__/**", + "!**/__pycache__/**", + "!*.pyc", + "!.venv/**", + "!**/.venv/**", + "!**/node_modules/**" + ] }, { "from": "python-env", "to": "python-env", - "filter": ["**/*"] + "filter": [ + "**/*" + ] } ], "publish": { "provider": "github", - "owner": "clusterlabs", - "repo": "openswarm" + "owner": "openswarm-ai", + "repo": "production" }, - "afterSign": null + "afterSign": "scripts/notarize.js" } } diff --git a/electron/scripts/notarize.js b/electron/scripts/notarize.js new file mode 100644 index 00000000..76bf1c05 --- /dev/null +++ b/electron/scripts/notarize.js @@ -0,0 +1,31 @@ +const { notarize } = require('@electron/notarize'); + +exports.default = async function notarizing(context) { + const { electronPlatformName, appOutDir } = context; + if (electronPlatformName !== 'darwin') return; + + if (process.env.CSC_IDENTITY_AUTO_DISCOVERY === 'false') { + console.log('Skipping notarization (CSC_IDENTITY_AUTO_DISCOVERY=false)'); + return; + } + + if (!process.env.APPLE_ID || !process.env.APPLE_TEAM_ID) { + console.log('Skipping notarization (APPLE_ID or APPLE_TEAM_ID not set)'); + return; + } + + const appName = context.packager.appInfo.productFilename; + const appPath = `${appOutDir}/${appName}.app`; + + console.log(`Notarizing ${appPath}...`); + + await notarize({ + appBundleId: 'com.clusterlabs.openswarm', + appPath, + appleId: process.env.APPLE_ID, + appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD, + teamId: process.env.APPLE_TEAM_ID, + }); + + console.log('Notarization complete.'); +}; diff --git a/frontend/src/app/pages/Dashboard/Dashboard.tsx b/frontend/src/app/pages/Dashboard/Dashboard.tsx index 7a278d18..4b44d41f 100644 --- a/frontend/src/app/pages/Dashboard/Dashboard.tsx +++ b/frontend/src/app/pages/Dashboard/Dashboard.tsx @@ -13,6 +13,7 @@ import { launchAndSendFirstMessage, generateTitle, resumeSession, + setExpandedSessionIds, } from '@/shared/state/agentsSlice'; import type { AgentConfig } from '@/shared/state/agentsSlice'; import { @@ -26,6 +27,7 @@ import { resetLayout, } from '@/shared/state/dashboardLayoutSlice'; import { fetchOutputs } from '@/shared/state/outputsSlice'; +import { generateDashboardName } from '@/shared/state/dashboardsSlice'; import { dashboardWs } from '@/shared/ws/WebSocketManager'; import AgentCard from './AgentCard'; import DashboardViewCard from './DashboardViewCard'; @@ -70,6 +72,7 @@ const DashboardInner: React.FC = () => { const viewCards = useAppSelector((state) => state.dashboardLayout.viewCards); const browserCards = useAppSelector((state) => state.dashboardLayout.browserCards); const layoutInitialized = useAppSelector((state) => state.dashboardLayout.initialized); + const persistedExpandedSessionIds = useAppSelector((state) => state.dashboardLayout.persistedExpandedSessionIds); const zoomSensitivity = useAppSelector((state) => state.settings.data.zoom_sensitivity); const newAgentShortcut = useAppSelector((state) => state.settings.data.new_agent_shortcut); const browserHomepage = useAppSelector((state) => state.settings.data.browser_homepage); @@ -88,6 +91,7 @@ const DashboardInner: React.FC = () => { const [toolbarOpen, setToolbarOpen] = useState(false); const spawnOriginsRef = useRef>({}); const hasFittedRef = useRef(false); + const restoredExpandedRef = useRef(false); // ---- Multi-drag coordination ---- const [multiDragDelta, setMultiDragDelta] = useState<{ dx: number; dy: number } | null>(null); @@ -157,6 +161,7 @@ const DashboardInner: React.FC = () => { useEffect(() => { if (!dashboardId) return; hasFittedRef.current = false; + restoredExpandedRef.current = false; dispatch(resetLayout()); dispatch(fetchSessions({ dashboardId })); dispatch(fetchHistory({ dashboardId })); @@ -173,6 +178,14 @@ const DashboardInner: React.FC = () => { return () => clearTimeout(timer); }, [layoutInitialized, canvas.actions]); + useEffect(() => { + if (!layoutInitialized || restoredExpandedRef.current) return; + restoredExpandedRef.current = true; + if (persistedExpandedSessionIds.length > 0) { + dispatch(setExpandedSessionIds(persistedExpandedSessionIds)); + } + }, [layoutInitialized, persistedExpandedSessionIds, dispatch]); + const prevSessionIdsRef = useRef(''); useEffect(() => { @@ -189,6 +202,7 @@ const DashboardInner: React.FC = () => { const cardsJson = JSON.stringify(cards); const viewCardsJson = JSON.stringify(viewCards); const browserCardsJson = JSON.stringify(browserCards); + const expandedJson = JSON.stringify(expandedSessionIds); const skipInitialSave = useRef(true); useEffect(() => { if (!layoutInitialized || !dashboardId) return; @@ -196,8 +210,8 @@ const DashboardInner: React.FC = () => { skipInitialSave.current = false; return; } - dispatch(saveLayout({ dashboardId, cards, viewCards, browserCards })); - }, [cardsJson, viewCardsJson, browserCardsJson, layoutInitialized, dashboardId]); + dispatch(saveLayout({ dashboardId, cards, viewCards, browserCards, expandedSessionIds })); + }, [cardsJson, viewCardsJson, browserCardsJson, expandedJson, layoutInitialized, dashboardId]); useEffect(() => { const parts = newAgentShortcut.toLowerCase().split('+'); @@ -276,6 +290,22 @@ const DashboardInner: React.FC = () => { dispatch(generateTitle({ sessionId: realId, prompt })); spawnOriginsRef.current[realId] = spawnOriginsRef.current[draftId]; delete spawnOriginsRef.current[draftId]; + + if (dashboardId) { + const currentSessions = store.getState().agents.sessions; + const agentCount = Object.values(currentSessions).filter( + (s) => s.status !== 'draft' && s.dashboard_id === dashboardId, + ).length; + const NAME_GEN_TRIGGERS = [1, 3, 6]; + const currentDash = store.getState().dashboards.items[dashboardId]; + const canAutoName = + currentDash && + (currentDash.auto_named || currentDash.name === 'Untitled Dashboard'); + + if (NAME_GEN_TRIGGERS.includes(agentCount) && canAutoName) { + dispatch(generateDashboardName(dashboardId)); + } + } } else { delete spawnOriginsRef.current[draftId]; } diff --git a/frontend/src/app/pages/Tools/Tools.tsx b/frontend/src/app/pages/Tools/Tools.tsx index c04eb01f..be5d842f 100644 --- a/frontend/src/app/pages/Tools/Tools.tsx +++ b/frontend/src/app/pages/Tools/Tools.tsx @@ -681,8 +681,7 @@ const Tools: React.FC = () => { try { parsedConfig = JSON.parse(mcpConfigJson); } catch { setMcpConfigError('Invalid JSON'); return; } const f = serverToToolForm(mcpConfigServer); - const isStdioConfig = parsedConfig.type === 'stdio' || !!parsedConfig.command; - const authStatus = (mcpAuthType !== 'none' || isStdioConfig) ? 'configured' : 'none'; + const authStatus = 'configured'; await dispatch(createTool({ name: f.name, @@ -1038,7 +1037,7 @@ const Tools: React.FC = () => { const isExpanded = expandedToolId === tool.id; const isMcp = tool.mcp_config && Object.keys(tool.mcp_config).length > 0; const isStdio = isMcp && (tool.mcp_config.type === 'stdio' || !!tool.mcp_config.command); - const canDiscover = isMcp && (isStdio || tool.auth_status !== 'none'); + const canDiscover = isMcp; const perms = tool.tool_permissions || {}; const services = perms._services as Record | undefined; const descriptions = (perms._tool_descriptions || {}) as Record; @@ -1303,7 +1302,7 @@ const Tools: React.FC = () => { Discover Tools {!canDiscover && ( - Connect the tool first to discover available permissions + Add an MCP configuration to enable tool discovery )} ) : ( diff --git a/frontend/src/shared/state/agentsSlice.ts b/frontend/src/shared/state/agentsSlice.ts index 0969df04..2186a419 100644 --- a/frontend/src/shared/state/agentsSlice.ts +++ b/frontend/src/shared/state/agentsSlice.ts @@ -455,6 +455,10 @@ const agentsSlice = createSlice({ state.expandedSessionIds = []; }, + setExpandedSessionIds(state, action: PayloadAction) { + state.expandedSessionIds = action.payload; + }, + updateSessionName(state, action: PayloadAction<{ sessionId: string; name: string }>) { const session = state.sessions[action.payload.sessionId]; if (session) { @@ -839,6 +843,7 @@ export const { expandSession, collapseSession, collapseAllSessions, + setExpandedSessionIds, updateSessionName, updateGroupMeta, setDraftSystemPrompt, diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index e7540286..3c458517 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -43,6 +43,7 @@ export interface DashboardLayoutState { cards: Record; viewCards: Record; browserCards: Record; + persistedExpandedSessionIds: string[]; loading: boolean; initialized: boolean; } @@ -51,6 +52,7 @@ const initialState: DashboardLayoutState = { cards: {}, viewCards: {}, browserCards: {}, + persistedExpandedSessionIds: [], loading: false, initialized: false, }; @@ -59,6 +61,7 @@ interface LayoutPayload { cards: Record; viewCards: Record; browserCards: Record; + expandedSessionIds: string[]; } export const fetchLayout = createAsyncThunk( @@ -71,6 +74,7 @@ export const fetchLayout = createAsyncThunk( cards: (layout.cards ?? {}) as Record, viewCards: (layout.view_cards ?? {}) as Record, browserCards: (layout.browser_cards ?? {}) as Record, + expandedSessionIds: (layout.expanded_session_ids ?? []) as string[], } satisfies LayoutPayload; }, ); @@ -91,7 +95,12 @@ export const saveLayout = createAsyncThunk( method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - layout: { cards: payload.cards, view_cards: payload.viewCards, browser_cards: payload.browserCards }, + layout: { + cards: payload.cards, + view_cards: payload.viewCards, + browser_cards: payload.browserCards, + expanded_session_ids: payload.expandedSessionIds, + }, }), }); resolve(payload); @@ -379,6 +388,7 @@ const dashboardLayoutSlice = createSlice({ state.cards = {}; state.viewCards = {}; state.browserCards = {}; + state.persistedExpandedSessionIds = []; state.initialized = false; }, @@ -394,6 +404,7 @@ const dashboardLayoutSlice = createSlice({ state.cards = action.payload.cards; state.viewCards = action.payload.viewCards; state.browserCards = action.payload.browserCards; + state.persistedExpandedSessionIds = action.payload.expandedSessionIds; }) .addCase(fetchLayout.rejected, (state) => { state.loading = false; diff --git a/frontend/src/shared/state/dashboardsSlice.ts b/frontend/src/shared/state/dashboardsSlice.ts index 5f8bb4fa..16d31de8 100644 --- a/frontend/src/shared/state/dashboardsSlice.ts +++ b/frontend/src/shared/state/dashboardsSlice.ts @@ -6,6 +6,7 @@ const DASHBOARDS_API = `${API_BASE}/dashboards`; export interface Dashboard { id: string; name: string; + auto_named: boolean; created_at: string; updated_at: string; } @@ -66,6 +67,17 @@ export const duplicateDashboard = createAsyncThunk( }, ); +export const generateDashboardName = createAsyncThunk( + 'dashboards/generateName', + async (dashboardId: string) => { + const res = await fetch(`${DASHBOARDS_API}/${dashboardId}/generate-name`, { + method: 'POST', + }); + const data = await res.json(); + return { id: dashboardId, name: data.name as string, auto_named: data.auto_named as boolean }; + }, +); + const dashboardsSlice = createSlice({ name: 'dashboards', initialState, @@ -92,7 +104,12 @@ const dashboardsSlice = createSlice({ .addCase(renameDashboard.fulfilled, (state, action) => { const d = action.payload; if (state.items[d.id]) { - state.items[d.id] = { ...state.items[d.id], name: d.name, updated_at: d.updated_at }; + state.items[d.id] = { + ...state.items[d.id], + name: d.name, + auto_named: d.auto_named ?? false, + updated_at: d.updated_at, + }; } }) .addCase(deleteDashboard.fulfilled, (state, action) => { @@ -100,6 +117,13 @@ const dashboardsSlice = createSlice({ }) .addCase(duplicateDashboard.fulfilled, (state, action) => { state.items[action.payload.id] = action.payload; + }) + .addCase(generateDashboardName.fulfilled, (state, action) => { + const { id, name, auto_named } = action.payload; + if (state.items[id]) { + state.items[id].name = name; + state.items[id].auto_named = auto_named; + } }); }, }); diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 6a8d0a50..4453332d 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -3,21 +3,50 @@ set -euo pipefail # Master build script for the OpenSwarm desktop app. # -# Steps: -# 1. Build the React frontend (webpack production build) -# 2. Set up the embedded Python environment -# 3. Package everything with electron-builder -# -# Output: electron/dist/OpenSwarm-.dmg +# Usage: +# bash scripts/build-app.sh Local dev build (unsigned) +# bash scripts/build-app.sh --publish Production build (signed, notarized, published to GitHub Releases) SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +ENV_FILE="$PROJECT_ROOT/backend/.env" +if [[ -f "$ENV_FILE" ]]; then + set -a + source "$ENV_FILE" + set +a +fi + +PUBLISH_MODE=false +if [[ "${1:-}" == "--publish" ]]; then + PUBLISH_MODE=true +fi + echo "========================================" echo " OpenSwarm Desktop App Builder" +if $PUBLISH_MODE; then + echo " Mode: PRODUCTION (sign + notarize + publish)" +else + echo " Mode: LOCAL (unsigned)" +fi echo "========================================" echo "" +if $PUBLISH_MODE; then + missing_vars=() + [[ -z "${APPLE_ID:-}" ]] && missing_vars+=("APPLE_ID") + [[ -z "${APPLE_APP_SPECIFIC_PASSWORD:-}" ]] && missing_vars+=("APPLE_APP_SPECIFIC_PASSWORD") + [[ -z "${APPLE_TEAM_ID:-}" ]] && missing_vars+=("APPLE_TEAM_ID") + [[ -z "${GH_TOKEN:-}" ]] && missing_vars+=("GH_TOKEN") + if [[ ${#missing_vars[@]} -gt 0 ]]; then + echo "ERROR: Missing required environment variables for --publish mode:" + printf ' - %s\n' "${missing_vars[@]}" + echo "" + echo "See script header for details." + exit 1 + fi +fi + # Step 1: Build frontend echo "[1/3] Building frontend..." cd "$PROJECT_ROOT/frontend" @@ -47,10 +76,12 @@ echo "[3/3] Packaging with electron-builder..." cd "$PROJECT_ROOT/electron" npm install -# Skip code signing for local builds (set CSC_LINK for production signing) -export CSC_IDENTITY_AUTO_DISCOVERY=false - -npx electron-builder --mac --publish never +if $PUBLISH_MODE; then + npx electron-builder --mac --publish always +else + export CSC_IDENTITY_AUTO_DISCOVERY=false + npx electron-builder --mac --publish never +fi echo "" echo "========================================" @@ -58,5 +89,6 @@ echo " Build Complete!" echo "========================================" echo "" echo "Output files:" -ls -lh "$PROJECT_ROOT/electron/dist/"*.zip 2>/dev/null || echo " (no .zip found — check electron/dist/)" +ls -lh "$PROJECT_ROOT/electron/dist/"*.dmg 2>/dev/null || true +ls -lh "$PROJECT_ROOT/electron/dist/"*.zip 2>/dev/null || true echo ""