diff --git a/electron-builder.json b/electron-builder.json index d13b62ffc..31f2853bd 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -129,7 +129,8 @@ "linux": { "icon": "build/icon.png", "target": ["AppImage"], - "category": "Development" + "category": "Development", + "mimeTypes": ["x-scheme-handler/eigent"] }, "nsis": { "oneClick": false, diff --git a/electron/main/index.ts b/electron/main/index.ts index d15ea6fa0..9d637606d 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -64,6 +64,10 @@ import { removeEnvKey, updateEnvBlock, } from './utils/envUtil'; +import { + registerLinuxProtocolHandler, + reRegisterLinuxProtocolHandler, +} from './utils/linuxProtocol'; import { createDiagnosticsZip, zipDirectories, zipFolder } from './utils/log'; import { addMcp, readMcpConfig, removeMcp, updateMcp } from './utils/mcpConfig'; import { @@ -557,6 +561,11 @@ const setupProtocolHandlers = () => { } } else { app.setAsDefaultProtocolClient('eigent'); + + // Linux: register protocol handler with proper .desktop file (%u for URL handling) + if (process.platform === 'linux') { + registerLinuxProtocolHandler(); + } } }; @@ -714,7 +723,16 @@ const setupSingleInstanceLock = () => { app.on('second-instance', (event, argv) => { log.info('second-instance', argv); const url = argv.find((arg) => arg.startsWith('eigent://')); - if (url) handleProtocolUrl(url); + if (url) { + handleProtocolUrl(url); + } else if (process.platform === 'linux') { + // Linux self-heal: second-instance fired without URL means the + // .desktop file may be missing %u. Re-register to fix it for next login attempt. + log.info( + '[LinuxProtocol] second-instance without URL, re-registering protocol handler' + ); + reRegisterLinuxProtocolHandler(); + } if (win) win.show(); }); diff --git a/electron/main/utils/linuxProtocol.ts b/electron/main/utils/linuxProtocol.ts new file mode 100644 index 000000000..6ba1a68f3 --- /dev/null +++ b/electron/main/utils/linuxProtocol.ts @@ -0,0 +1,159 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import { spawn } from 'child_process'; +import log from 'electron-log'; +import fs from 'fs'; +import fsp from 'fs/promises'; +import { homedir } from 'os'; +import path from 'path'; + +const DESKTOP_FILE_NAME = 'eigent-protocol-handler.desktop'; +const MIME_TYPE = 'x-scheme-handler/eigent'; + +function getDesktopFilePath(): string { + return path.join( + homedir(), + '.local', + 'share', + 'applications', + DESKTOP_FILE_NAME + ); +} + +function getExecPath(): string { + // Prefer APPIMAGE env var so registration survives AppImage version bumps + if (process.env.APPIMAGE) { + return process.env.APPIMAGE; + } + // Fallback to process.execPath + return process.execPath; +} + +function generateDesktopEntry(execPath: string): string { + const escapedExec = execPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + return `[Desktop Entry] +Type=Application +Name=Eigent Protocol Handler +Exec="${escapedExec}" %u +MimeType=${MIME_TYPE}; +NoDisplay=true +Terminal=false +Categories=Development; +StartupNotify=false +`; +} + +async function ensureDirectory(dir: string): Promise { + try { + await fsp.mkdir(dir, { recursive: true }); + } catch (e) { + log.warn(`[LinuxProtocol] Failed to create directory ${dir}: ${e}`); + } +} + +async function runCommand(cmd: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const proc = spawn(cmd, args, { stdio: 'ignore' }); + proc.on('close', (code) => { + if (code === 0) resolve(); + else reject(new Error(`${cmd} exited with code ${code}`)); + }); + proc.on('error', reject); + }); +} + +async function updateDesktopDatabase(): Promise { + try { + await runCommand('update-desktop-database', [ + path.join(homedir(), '.local', 'share', 'applications'), + ]); + } catch (e) { + log.warn(`[LinuxProtocol] update-desktop-database failed: ${e}`); + } +} + +async function setDefaultMimeHandler(): Promise { + try { + await runCommand('xdg-mime', ['default', DESKTOP_FILE_NAME, MIME_TYPE]); + } catch (e) { + log.warn(`[LinuxProtocol] xdg-mime default failed: ${e}`); + } +} + +/** + * Registers the eigent:// protocol handler on Linux by writing a .desktop file + * to ~/.local/share/applications/ and updating the desktop database. + * Skipped in development mode (handled by setupProtocolHandlers dev branch). + */ +export async function registerLinuxProtocolHandler(): Promise { + if (process.env.NODE_ENV === 'development') { + log.info('[LinuxProtocol] Skipping registration in development mode'); + return; + } + + if (process.platform !== 'linux') { + log.info('[LinuxProtocol] Skipping registration (not on Linux)'); + return; + } + + try { + const execPath = getExecPath(); + const desktopPath = getDesktopFilePath(); + const desktopDir = path.dirname(desktopPath); + + log.info( + `[LinuxProtocol] Registering protocol handler with exec: ${execPath}` + ); + + await ensureDirectory(desktopDir); + + const desktopEntry = generateDesktopEntry(execPath); + await fsp.writeFile(desktopPath, desktopEntry, 'utf-8'); + log.info(`[LinuxProtocol] Wrote desktop file to ${desktopPath}`); + + await updateDesktopDatabase(); + await setDefaultMimeHandler(); + + log.info('[LinuxProtocol] Protocol handler registration complete'); + } catch (error) { + log.error(`[LinuxProtocol] Registration failed: ${error}`); + } +} + +/** + * Re-registers the protocol handler (used for self-heal when second-instance fires without URL). + */ +export async function reRegisterLinuxProtocolHandler(): Promise { + log.info('[LinuxProtocol] Re-registering protocol handler (self-heal)'); + await registerLinuxProtocolHandler(); +} + +/** + * Checks if the desktop file exists and has the correct %u placeholder. + * Returns true if registration appears valid. + */ +export async function isProtocolHandlerRegistered(): Promise { + if (process.platform !== 'linux') return true; + + try { + const desktopPath = getDesktopFilePath(); + if (!fs.existsSync(desktopPath)) return false; + + const content = await fsp.readFile(desktopPath, 'utf-8'); + return content.includes('%u') && content.includes(MIME_TYPE); + } catch { + return false; + } +} diff --git a/electron/main/utils/process.ts b/electron/main/utils/process.ts index b5e2489a8..5b645cc24 100644 --- a/electron/main/utils/process.ts +++ b/electron/main/utils/process.ts @@ -362,8 +362,6 @@ function fixVenvScriptShebangs(venvPath: string): boolean { } } -const PREBUILT_FIXED_MARKER = '.prebuilt_fixed'; - /** * Ensure venv/bin/python exists - create symlink if missing or broken. */ @@ -435,6 +433,8 @@ function ensureVenvPythonSymlink(venvPath: string): boolean { /** * Get path to prebuilt venv (if available in packaged app) * All platforms use prebuilt/venv directory. + * NOTE: Does NOT run fixups on prebuilt venv (it's read-only in AppImage). + * Fixups run on the copied user venv in ensureBackendVenvAtUserPath(). */ export function getPrebuiltVenvPath(): string | null { if (!app.isPackaged) { @@ -444,21 +444,8 @@ export function getPrebuiltVenvPath(): string | null { const prebuiltDir = path.join(process.resourcesPath, 'prebuilt'); const prebuiltVenvPath = path.join(prebuiltDir, 'venv'); const pyvenvCfgPath = path.join(prebuiltVenvPath, 'pyvenv.cfg'); - const fixedMarkerPath = path.join(prebuiltDir, PREBUILT_FIXED_MARKER); - const currentVersion = app.getVersion(); if (fs.existsSync(prebuiltVenvPath) && fs.existsSync(pyvenvCfgPath)) { - const needsFix = - !fs.existsSync(fixedMarkerPath) || - fs.readFileSync(fixedMarkerPath, 'utf-8').trim() !== currentVersion; - - if (needsFix) { - fixPyvenvCfgPlaceholder(pyvenvCfgPath); - ensureVenvPythonSymlink(prebuiltVenvPath); - fixVenvScriptShebangs(prebuiltVenvPath); - fs.writeFileSync(fixedMarkerPath, currentVersion, 'utf-8'); - } - const pythonExePath = getVenvPythonPath(prebuiltVenvPath); if (fs.existsSync(pythonExePath)) { return prebuiltVenvPath; @@ -766,6 +753,8 @@ export function ensureTerminalVenvAtUserPath(version: string): void { /** * Get path to prebuilt terminal venv (if available in packaged app) + * NOTE: Does NOT run fixups on prebuilt venv (it's read-only in AppImage). + * Fixups run on the copied user venv in ensureTerminalVenvAtUserPath(). */ export function getPrebuiltTerminalVenvPath(): string | null { if (!app.isPackaged) { @@ -790,24 +779,6 @@ export function getPrebuiltTerminalVenvPath(): string | null { return null; } - // Check if already fixed for this version (avoid repeated fixes) - const fixedMarkerPath = path.join( - process.resourcesPath, - 'prebuilt', - '.terminal_venv_fixed' - ); - const currentVersion = app.getVersion(); - const needsFix = - !fs.existsSync(fixedMarkerPath) || - fs.readFileSync(fixedMarkerPath, 'utf-8').trim() !== currentVersion; - - if (needsFix) { - fixPyvenvCfgPlaceholder(pyvenvCfgPath); - ensureVenvPythonSymlink(prebuiltTerminalVenvPath); - fixVenvScriptShebangs(prebuiltTerminalVenvPath); - fs.writeFileSync(fixedMarkerPath, currentVersion, 'utf-8'); - } - const pythonExePath = getVenvPythonPath(prebuiltTerminalVenvPath); if (fs.existsSync(pythonExePath)) { diff --git a/server/app/domains/space/api/space_controller.py b/server/app/domains/space/api/space_controller.py index 99e644d0d..713d2360e 100644 --- a/server/app/domains/space/api/space_controller.py +++ b/server/app/domains/space/api/space_controller.py @@ -214,6 +214,19 @@ def update_space_project( raise HTTPException(status_code=404, detail=str(exc)) from exc +@router.delete("/{space_id}/projects/{project_id}", name="delete space project", status_code=204) +def delete_space_project( + space_id: str, + project_id: str, + db_session: Session = Depends(session), + auth: V1UserAuth = Depends(auth_must), +): + try: + SpaceService.delete_project(space_id, project_id, auth.id, db_session) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + @router.post("/{space_id}/projects/{project_id}/promote", name="promote project to folder space", response_model=ProjectOut) def promote_space_project( space_id: str, diff --git a/server/app/domains/space/service/space_service.py b/server/app/domains/space/service/space_service.py index 79ba47cde..dacf14b0b 100644 --- a/server/app/domains/space/service/space_service.py +++ b/server/app/domains/space/service/space_service.py @@ -645,6 +645,27 @@ def update_project( s.refresh(project) return project + @staticmethod + def delete_project( + space_id: str, + project_id: str, + user_id: int | str, + s: Session, + ) -> None: + canonical_user_id = SpaceService.canonical_user_id(user_id) + SpaceService._get_owned_space(space_id, canonical_user_id, s) + project = s.exec( + select(Project).where( + Project.id == project_id, + Project.user_id == canonical_user_id, + Project.space_id == space_id, + ) + ).first() + if not project: + raise ValueError("Project not found") + s.delete(project) + s.commit() + @staticmethod def promote_project( space_id: str,