Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion electron-builder.json
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,8 @@
"linux": {
"icon": "build/icon.png",
"target": ["AppImage"],
"category": "Development"
"category": "Development",
"mimeTypes": ["x-scheme-handler/eigent"]
},
"nsis": {
"oneClick": false,
Expand Down
20 changes: 19 additions & 1 deletion electron/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
}
}
};

Expand Down Expand Up @@ -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();
});

Expand Down
159 changes: 159 additions & 0 deletions electron/main/utils/linuxProtocol.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<boolean> {
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;
}
}
37 changes: 4 additions & 33 deletions electron/main/utils/process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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)) {
Expand Down
13 changes: 13 additions & 0 deletions server/app/domains/space/api/space_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
21 changes: 21 additions & 0 deletions server/app/domains/space/service/space_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading