Skip to content

Commit

Permalink
Add App Metadata To Open theia:// links. #378
Browse files Browse the repository at this point in the history
* create desktop file for linux desktop integration
* add protocol for mac

Contributed on behalf of STMicroelectronics
  • Loading branch information
jfaltermeier committed Aug 29, 2024
1 parent 53ed45d commit b341482
Show file tree
Hide file tree
Showing 8 changed files with 233 additions and 14 deletions.
5 changes: 5 additions & 0 deletions applications/electron/electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ win:
mac:
icon: resources/icons/MacLauncherIcon/512-512-2.icns
category: public.app-category.developer-tools
protocols:
- name: theia
schemes: theia
darkModeSupport: true
target:
- dmg
Expand All @@ -52,6 +55,8 @@ linux:
target:
- deb
- AppImage
desktop:
MimeType: "x-scheme-handler/theia;"
publish:
provider: generic
url: "https://download.eclipse.org/theia/ide/latest/linux"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/********************************************************************************
* Copyright (C) 2022 EclipseSource and others.
* Copyright (C) 2022-2024 EclipseSource and others.
*
* This program and the accompanying materials are made available under the
* terms of the MIT License, which is available in the project root.
Expand All @@ -12,6 +12,7 @@ import { ILogger, MaybePromise } from '@theia/core/lib/common';
import { nls } from '@theia/core/lib/common/nls';
import { inject, injectable } from '@theia/core/shared/inversify';
import { LauncherService } from './launcher-service';
import { DesktopFileService } from './desktopfile-service';

@injectable()
export class CreateLauncherCommandContribution implements FrontendApplicationContribution {
Expand All @@ -24,7 +25,9 @@ export class CreateLauncherCommandContribution implements FrontendApplicationCon

@inject(LauncherService) private readonly launcherService: LauncherService;

onStart(app: FrontendApplication): MaybePromise<void> {
@inject(DesktopFileService) private readonly desktopFileService: DesktopFileService;

onStart(_app: FrontendApplication): MaybePromise<void> {
this.launcherService.isInitialized().then(async initialized => {
if (!initialized) {
const messageContainer = document.createElement('div');
Expand All @@ -47,5 +50,25 @@ export class CreateLauncherCommandContribution implements FrontendApplicationCon
this.logger.info('Application launcher was already initialized.');
}
});

this.desktopFileService.isInitialized().then(async initialized => {
if (!initialized) {
const messageContainer = document.createElement('div');
// eslint-disable-next-line max-len
messageContainer.textContent = nls.localizeByDefault('Would you like to create a .desktop file for the Theia IDE?\nThis will make it easier to open the Theia IDE directly\nfrom your applications menu and enables further features.');
messageContainer.setAttribute('style', 'white-space: pre-line');
const dialog = new ConfirmDialog({
title: nls.localizeByDefault('Create .desktop file'),
msg: messageContainer,
ok: Dialog.YES,
cancel: Dialog.NO
});
const install = await dialog.open();
this.desktopFileService.createOrUpdateDesktopfile(!!install);
this.logger.info('Created or updated .desktop file.');
} else {
this.logger.info('Desktop file was not updated or created.');
}
});
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/********************************************************************************
* Copyright (C) 2022 EclipseSource and others.
* Copyright (C) 2022-2024 EclipseSource and others.
*
* This program and the accompanying materials are made available under the
* terms of the MIT License, which is available in the project root.
Expand All @@ -10,8 +10,10 @@ import { CreateLauncherCommandContribution } from './create-launcher-contributio
import { ContainerModule } from '@theia/core/shared/inversify';
import { LauncherService } from './launcher-service';
import { FrontendApplicationContribution } from '@theia/core/lib/browser';
import { DesktopFileService } from './desktopfile-service';

export default new ContainerModule(bind => {
bind(FrontendApplicationContribution).to(CreateLauncherCommandContribution);
bind(LauncherService).toSelf().inSingletonScope();
bind(DesktopFileService).toSelf().inSingletonScope();
});
36 changes: 36 additions & 0 deletions theia-extensions/launcher/src/browser/desktopfile-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/********************************************************************************
* Copyright (C) 2024 STMicroelectronics and others.
*
* This program and the accompanying materials are made available under the
* terms of the MIT License, which is available in the project root.
*
* SPDX-License-Identifier: MIT
********************************************************************************/

import { Endpoint } from '@theia/core/lib/browser';
import { injectable } from '@theia/core/shared/inversify';

@injectable()
export class DesktopFileService {

async isInitialized(): Promise<boolean> {
const response = await fetch(new Request(`${this.endpoint()}/initialized`), {
body: undefined,
method: 'GET'
}).then(r => r.json());
return !!response?.initialized;
}

async createOrUpdateDesktopfile(create: boolean): Promise<void> {
fetch(new Request(`${this.endpoint()}`), {
body: JSON.stringify({ create }),
method: 'PUT',
headers: new Headers({ 'Content-Type': 'application/json' })
});
}

protected endpoint(): string {
const url = new Endpoint({ path: 'desktopfile' }).getRestUrl().toString();
return url.endsWith('/') ? url.slice(0, -1) : url;
}
}
136 changes: 136 additions & 0 deletions theia-extensions/launcher/src/node/desktopfile-endpoint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/********************************************************************************
* Copyright (C) 2024 STMicroelectronics and others.
*
* This program and the accompanying materials are made available under the
* terms of the MIT License, which is available in the project root.
*
* SPDX-License-Identifier: MIT
********************************************************************************/

import { BackendApplicationContribution } from '@theia/core/lib/node/backend-application';
import { Application, Router } from '@theia/core/shared/express';
import { inject, injectable } from '@theia/core/shared/inversify';
import { Request, Response } from 'express-serve-static-core';
import { json } from 'body-parser';
import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
import { getStorageFilePath } from './launcher-util';
import * as fs from 'fs-extra';
import * as path from 'path';

interface DesktopFileInformation {
appImage: string;
declined: string[];
}

@injectable()
export class TheiaDesktopFileServiceEndpoint implements BackendApplicationContribution {

protected static PATH = '/desktopfile';
protected static STORAGE_FILE_NAME = 'desktopfile.json';

@inject(EnvVariablesServer)
protected readonly envServer: EnvVariablesServer;

configure(app: Application): void {
const router = Router();
router.put('/', (request, response) => this.createOrUpdateDesktopfile(request, response));
router.get('/initialized', (request, response) => this.isInitialized(request, response));
app.use(json());
app.use(TheiaDesktopFileServiceEndpoint.PATH, router);
}

protected async isInitialized(_request: Request, response: Response): Promise<void> {
if (!process.env.APPIMAGE) {
// we only want to create Desktop Files when running as an App Image
response.json({ initialized: true });
}
if (process.env.HOME === undefined) {
// log error but assume initialized, since we can't proceed
console.error('Desktop files can only be created if there is a set HOME directory');
response.json({ initialized: true });
}
const storageFile = await getStorageFilePath(this.envServer, TheiaDesktopFileServiceEndpoint.STORAGE_FILE_NAME);
if (!storageFile) {
throw new Error('Could not resolve path to storage file.');
}
if (!fs.existsSync(storageFile)) {
response.json({ initialized: false });
return;
}
const appImageInformation = await this.readAppImageInformationFromStorage(storageFile);
if (appImageInformation === undefined) {
response.json({ initialized: false });
return;
}
if (appImageInformation.declined !== undefined && appImageInformation.declined.includes(process.env.APPIMAGE!)) {
// we don't want to create Desktop Files for this App Image
response.json({ initialized: true });
return;
}
const initialized = appImageInformation.appImage === process.env.APPIMAGE;
response.json({ initialized });
}

protected async readAppImageInformationFromStorage(storageFile: string): Promise<DesktopFileInformation | undefined> {
if (!fs.existsSync(storageFile)) {
return undefined;
}
try {
const data: DesktopFileInformation = await fs.readJSON(storageFile);
return data;
} catch (error) {
console.error('Failed to parse data from "', storageFile, '". Reason:', error);
return undefined;
}
}

protected async createOrUpdateDesktopfile(request: Request, response: Response): Promise<void> {
const storageFile = await getStorageFilePath(this.envServer, TheiaDesktopFileServiceEndpoint.STORAGE_FILE_NAME);
let appImageInformation: DesktopFileInformation | undefined = await this.readAppImageInformationFromStorage(storageFile);
if (appImageInformation === undefined) {
appImageInformation = { appImage: '', declined: [] };
}

const createOrUpdate = request.body.create;
if (createOrUpdate) {
const imagePath = path.join(process.env.HOME!, '.local', 'share', 'applications', 'theia-ide-electron-app.png');
if (!fs.existsSync(imagePath)) {
const appDir = process.env.APPDIR;
if (appDir !== undefined) {
const unpackedImagePath = path.join(appDir, 'theia-ide-electron-app.png');
if (fs.existsSync(unpackedImagePath)) {
fs.copyFileSync(unpackedImagePath, imagePath);
} else {
console.warn('Launcher Icon not Found in App Image');
}
} else {
console.warn('Path for unpacked App Image not found');
}
}

const desktopFilePath = path.join(process.env.HOME!, '.local', 'share', 'applications', 'theia-ide-launcher.desktop');
fs.outputFileSync(desktopFilePath, this.getDesktopFileContents(process.env.APPIMAGE!, imagePath));

appImageInformation.appImage = process.env.APPIMAGE!;
fs.outputJSONSync(storageFile, appImageInformation);
} else {
appImageInformation.declined.push(process.env.APPIMAGE!);
fs.outputJSONSync(storageFile, appImageInformation);
}

response.sendStatus(200);
}

protected getDesktopFileContents(appImagePath: string, imagePath: string): string {
return `[Desktop Entry]
Name=TheiaIDE
Exec=${appImagePath} %U
Terminal=false
Type=Application
Icon=${imagePath}
StartupWMClass=TheiaIDE
MimeType=x-scheme-handler/theia;
Comment=Eclipse Theia IDE product
Categories=Development;`;
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/********************************************************************************
* Copyright (C) 2022 EclipseSource and others.
* Copyright (C) 2022-2024 EclipseSource and others.
*
* This program and the accompanying materials are made available under the
* terms of the MIT License, which is available in the project root.
Expand All @@ -10,8 +10,12 @@
import { ContainerModule } from '@theia/core/shared/inversify';
import { TheiaLauncherServiceEndpoint } from './launcher-endpoint';
import { BackendApplicationContribution } from '@theia/core/lib/node/backend-application';
import { TheiaDesktopFileServiceEndpoint } from './desktopfile-endpoint';

export default new ContainerModule(bind => {
bind(TheiaLauncherServiceEndpoint).toSelf().inSingletonScope();
bind(BackendApplicationContribution).toService(TheiaLauncherServiceEndpoint);

bind(TheiaDesktopFileServiceEndpoint).toSelf().inSingletonScope();
bind(BackendApplicationContribution).toService(TheiaDesktopFileServiceEndpoint);
});
15 changes: 5 additions & 10 deletions theia-extensions/launcher/src/node/launcher-endpoint.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/********************************************************************************
* Copyright (C) 2022 EclipseSource and others.
* Copyright (C) 2022-2024 EclipseSource and others.
*
* This program and the accompanying materials are made available under the
* terms of the MIT License, which is available in the project root.
Expand All @@ -16,6 +16,7 @@ import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
import * as sudo from '@vscode/sudo-prompt';
import * as fs from 'fs-extra';
import URI from '@theia/core/lib/common/uri';
import { getStorageFilePath } from './launcher-util';

interface PathEntry {
source: string;
Expand All @@ -25,6 +26,7 @@ interface PathEntry {
@injectable()
export class TheiaLauncherServiceEndpoint implements BackendApplicationContribution {
protected static PATH = '/launcher';
protected static STORAGE_FILE_NAME = 'paths.json';
private LAUNCHER_LINK_SOURCE = '/usr/local/bin/theia';

@inject(ILogger)
Expand All @@ -47,7 +49,7 @@ export class TheiaLauncherServiceEndpoint implements BackendApplicationContribut
// return true
response.json({ initialized: true });
}
const storageFile = await this.getStorageFilePath();
const storageFile = await getStorageFilePath(this.envServer, TheiaLauncherServiceEndpoint.STORAGE_FILE_NAME);
if (!storageFile) {
throw new Error('Could not resolve path to storage file.');
}
Expand All @@ -60,13 +62,6 @@ export class TheiaLauncherServiceEndpoint implements BackendApplicationContribut
response.json({ initialized });
}

private async getStorageFilePath(): Promise<string> {
const configDirUri = await this.envServer.getConfigDirUri();
const globalStorageFolderUri = new URI(configDirUri).resolve('globalStorage/theia-ide-launcher/paths.json');
const globalStorageFolderFsPath = globalStorageFolderUri.path.fsPath();
return globalStorageFolderFsPath;
}

private async readLauncherPathsFromStorage(storageFile: string): Promise<PathEntry[]> {
if (!fs.existsSync(storageFile)) {
return [];
Expand Down Expand Up @@ -99,7 +94,7 @@ export class TheiaLauncherServiceEndpoint implements BackendApplicationContribut
sudo.exec(command, { name: 'Theia IDE' });
}

const storageFile = await this.getStorageFilePath();
const storageFile = await getStorageFilePath(this.envServer, TheiaLauncherServiceEndpoint.STORAGE_FILE_NAME);
const data = fs.existsSync(storageFile) ? await this.readLauncherPathsFromStorage(storageFile) : [];
fs.outputJSONSync(storageFile, [...data, { source: launcher, target: shouldCreateLauncher ? target : undefined }]);

Expand Down
18 changes: 18 additions & 0 deletions theia-extensions/launcher/src/node/launcher-util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/********************************************************************************
* Copyright (C) 2024 STMicroelectronics and others.
*
* This program and the accompanying materials are made available under the
* terms of the MIT License, which is available in the project root.
*
* SPDX-License-Identifier: MIT
********************************************************************************/

import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
import URI from '@theia/core/lib/common/uri';

export async function getStorageFilePath(envServer: EnvVariablesServer, fileName: string): Promise<string> {
const configDirUri = await envServer.getConfigDirUri();
const globalStorageFolderUri = new URI(configDirUri).resolve('globalStorage/theia-ide-launcher/' + fileName);
const globalStorageFolderFsPath = globalStorageFolderUri.path.fsPath();
return globalStorageFolderFsPath;
}

0 comments on commit b341482

Please sign in to comment.