mirror of
https://github.com/laurent22/joplin.git
synced 2025-01-08 13:06:15 +02:00
This commit is contained in:
parent
8630c8e630
commit
f899c97c4c
@ -1022,6 +1022,10 @@ packages/lib/services/plugins/utils/executeSandboxCall.js
|
||||
packages/lib/services/plugins/utils/getPluginNamespacedSettingKey.js
|
||||
packages/lib/services/plugins/utils/getPluginSettingKeyPrefix.js
|
||||
packages/lib/services/plugins/utils/getPluginSettingValue.js
|
||||
packages/lib/services/plugins/utils/isCompatible/index.test.js
|
||||
packages/lib/services/plugins/utils/isCompatible/index.js
|
||||
packages/lib/services/plugins/utils/isCompatible/minVersionForPlatform.js
|
||||
packages/lib/services/plugins/utils/isCompatible/types.js
|
||||
packages/lib/services/plugins/utils/loadContentScripts.js
|
||||
packages/lib/services/plugins/utils/makeListener.js
|
||||
packages/lib/services/plugins/utils/manifestFromObject.js
|
||||
@ -1029,6 +1033,8 @@ packages/lib/services/plugins/utils/mapEventHandlersToIds.js
|
||||
packages/lib/services/plugins/utils/types.js
|
||||
packages/lib/services/plugins/utils/validatePluginId.test.js
|
||||
packages/lib/services/plugins/utils/validatePluginId.js
|
||||
packages/lib/services/plugins/utils/validatePluginPlatforms.test.js
|
||||
packages/lib/services/plugins/utils/validatePluginPlatforms.js
|
||||
packages/lib/services/plugins/utils/validatePluginVersion.test.js
|
||||
packages/lib/services/plugins/utils/validatePluginVersion.js
|
||||
packages/lib/services/profileConfig/index.test.js
|
||||
|
6
.gitignore
vendored
6
.gitignore
vendored
@ -1002,6 +1002,10 @@ packages/lib/services/plugins/utils/executeSandboxCall.js
|
||||
packages/lib/services/plugins/utils/getPluginNamespacedSettingKey.js
|
||||
packages/lib/services/plugins/utils/getPluginSettingKeyPrefix.js
|
||||
packages/lib/services/plugins/utils/getPluginSettingValue.js
|
||||
packages/lib/services/plugins/utils/isCompatible/index.test.js
|
||||
packages/lib/services/plugins/utils/isCompatible/index.js
|
||||
packages/lib/services/plugins/utils/isCompatible/minVersionForPlatform.js
|
||||
packages/lib/services/plugins/utils/isCompatible/types.js
|
||||
packages/lib/services/plugins/utils/loadContentScripts.js
|
||||
packages/lib/services/plugins/utils/makeListener.js
|
||||
packages/lib/services/plugins/utils/manifestFromObject.js
|
||||
@ -1009,6 +1013,8 @@ packages/lib/services/plugins/utils/mapEventHandlersToIds.js
|
||||
packages/lib/services/plugins/utils/types.js
|
||||
packages/lib/services/plugins/utils/validatePluginId.test.js
|
||||
packages/lib/services/plugins/utils/validatePluginId.js
|
||||
packages/lib/services/plugins/utils/validatePluginPlatforms.test.js
|
||||
packages/lib/services/plugins/utils/validatePluginPlatforms.js
|
||||
packages/lib/services/plugins/utils/validatePluginVersion.test.js
|
||||
packages/lib/services/plugins/utils/validatePluginVersion.js
|
||||
packages/lib/services/profileConfig/index.test.js
|
||||
|
@ -7,7 +7,7 @@ import Setting from '@joplin/lib/models/Setting';
|
||||
import * as fs from 'fs-extra';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import { expectNotThrow, setupDatabaseAndSynchronizer, switchClient, expectThrow, createTempDir, supportDir } from '@joplin/lib/testing/test-utils';
|
||||
import { expectNotThrow, setupDatabaseAndSynchronizer, switchClient, expectThrow, createTempDir, supportDir, mockMobilePlatform } from '@joplin/lib/testing/test-utils';
|
||||
import { newPluginScript } from '../../testUtils';
|
||||
|
||||
const testPluginDir = `${supportDir}/plugins`;
|
||||
@ -262,6 +262,68 @@ describe('services_PluginService', () => {
|
||||
}
|
||||
}));
|
||||
|
||||
it.each([
|
||||
{
|
||||
manifestPlatforms: ['desktop'],
|
||||
isDesktop: true,
|
||||
appVersion: '3.0.0',
|
||||
shouldRun: true,
|
||||
},
|
||||
{
|
||||
manifestPlatforms: ['desktop'],
|
||||
isDesktop: false,
|
||||
appVersion: '3.0.6',
|
||||
shouldRun: false,
|
||||
},
|
||||
{
|
||||
manifestPlatforms: ['desktop', 'mobile'],
|
||||
isDesktop: false,
|
||||
appVersion: '3.0.6',
|
||||
shouldRun: true,
|
||||
},
|
||||
{
|
||||
manifestPlatforms: [],
|
||||
isDesktop: false,
|
||||
appVersion: '3.0.8',
|
||||
shouldRun: true,
|
||||
},
|
||||
])('should enable and disable plugins depending on what platform(s) they support (case %#: %j)', async ({ manifestPlatforms, isDesktop, appVersion, shouldRun }) => {
|
||||
const pluginScript = `
|
||||
/* joplin-manifest:
|
||||
{
|
||||
"id": "org.joplinapp.plugins.PluginTest",
|
||||
"manifest_version": 1,
|
||||
"app_min_version": "1.0.0",
|
||||
"platforms": ${JSON.stringify(manifestPlatforms)},
|
||||
"name": "JS Bundle test",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
*/
|
||||
|
||||
joplin.plugins.register({
|
||||
onStart: async function() { },
|
||||
});
|
||||
`;
|
||||
|
||||
let resetPlatformMock = () => {};
|
||||
if (!isDesktop) {
|
||||
resetPlatformMock = mockMobilePlatform('android').reset;
|
||||
}
|
||||
|
||||
try {
|
||||
const service = newPluginService(appVersion);
|
||||
const plugin = await service.loadPluginFromJsBundle('', pluginScript);
|
||||
|
||||
if (shouldRun) {
|
||||
await expect(service.runPlugin(plugin)).resolves.toBeUndefined();
|
||||
} else {
|
||||
await expect(service.runPlugin(plugin)).rejects.toThrow(/disabled/);
|
||||
}
|
||||
} finally {
|
||||
resetPlatformMock();
|
||||
}
|
||||
});
|
||||
|
||||
it('should install a plugin', (async () => {
|
||||
const service = newPluginService();
|
||||
const pluginPath = `${testPluginDir}/jpl_test/org.joplinapp.FirstJplPlugin.jpl`;
|
||||
|
@ -1,9 +1,14 @@
|
||||
import RepositoryApi, { InstallMode } from '@joplin/lib/services/plugins/RepositoryApi';
|
||||
import { AppType } from '@joplin/lib/models/Setting';
|
||||
import RepositoryApi, { AppInfo, InstallMode } from '@joplin/lib/services/plugins/RepositoryApi';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { setupDatabaseAndSynchronizer, switchClient, supportDir, createTempDir } from '@joplin/lib/testing/test-utils';
|
||||
import { remove } from 'fs-extra';
|
||||
|
||||
async function newRepoApi(): Promise<RepositoryApi> {
|
||||
const repo = new RepositoryApi(`${supportDir}/pluginRepo`, await createTempDir(), InstallMode.Default);
|
||||
let tempDirs: string[] = [];
|
||||
async function newRepoApi(appInfo: AppInfo = { type: AppType.Desktop, version: '3.0.0' }): Promise<RepositoryApi> {
|
||||
const tempDir = await createTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
const repo = new RepositoryApi(`${supportDir}/pluginRepo`, tempDir, appInfo, InstallMode.Default);
|
||||
await repo.initialize();
|
||||
return repo;
|
||||
}
|
||||
@ -14,6 +19,12 @@ describe('services_plugins_RepositoryApi', () => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
});
|
||||
afterEach(async () => {
|
||||
for (const tempDir of tempDirs) {
|
||||
await remove(tempDir);
|
||||
}
|
||||
tempDirs = [];
|
||||
});
|
||||
|
||||
it('should get the manifests', (async () => {
|
||||
const api = await newRepoApi();
|
||||
@ -46,13 +57,14 @@ describe('services_plugins_RepositoryApi', () => {
|
||||
expect(await shim.fsDriver().exists(pluginPath)).toBe(true);
|
||||
}));
|
||||
|
||||
it('should tell if a plugin can be updated', (async () => {
|
||||
const api = await newRepoApi();
|
||||
|
||||
expect(await api.pluginCanBeUpdated('org.joplinapp.plugins.ToggleSidebars', '1.0.0', '3.0.0')).toBe(true);
|
||||
expect(await api.pluginCanBeUpdated('org.joplinapp.plugins.ToggleSidebars', '1.0.0', '1.0.0')).toBe(false);
|
||||
expect(await api.pluginCanBeUpdated('org.joplinapp.plugins.ToggleSidebars', '1.0.2', '3.0.0')).toBe(false);
|
||||
expect(await api.pluginCanBeUpdated('does.not.exist', '1.0.0', '3.0.0')).toBe(false);
|
||||
it.each([
|
||||
{ id: 'org.joplinapp.plugins.ToggleSidebars', installedVersion: '1.0.0', appVersion: '3.0.0', shouldBeUpdatable: true },
|
||||
{ id: 'org.joplinapp.plugins.ToggleSidebars', installedVersion: '1.0.0', appVersion: '1.0.0', shouldBeUpdatable: false },
|
||||
{ id: 'org.joplinapp.plugins.ToggleSidebars', installedVersion: '1.0.2', appVersion: '3.0.0', shouldBeUpdatable: false },
|
||||
{ id: 'does.not.exist', installedVersion: '1.0.0', appVersion: '3.0.0', shouldBeUpdatable: false },
|
||||
])('should tell if a plugin can be updated (case %#)', (async ({ id, installedVersion, appVersion, shouldBeUpdatable }) => {
|
||||
const api = await newRepoApi({ version: appVersion, type: AppType.Desktop });
|
||||
expect(await api.pluginCanBeUpdated(id, installedVersion)).toBe(shouldBeUpdatable);
|
||||
}));
|
||||
|
||||
});
|
||||
|
@ -16,6 +16,7 @@
|
||||
"manifest_version": 1,
|
||||
"id": "org.joplinapp.plugins.ToggleSidebars",
|
||||
"app_min_version": "1.6",
|
||||
"platforms": ["desktop"],
|
||||
"version": "1.0.2",
|
||||
"name": "Note list and side bar toggle buttons",
|
||||
"description": "Adds buttons to toggle note list and sidebar",
|
||||
|
@ -2,6 +2,7 @@
|
||||
"manifest_version": 1,
|
||||
"id": "org.joplinapp.plugins.ToggleSidebars",
|
||||
"app_min_version": "1.6",
|
||||
"platforms": ["desktop"],
|
||||
"version": "1.0.2",
|
||||
"name": "Note list and side bar toggle buttons",
|
||||
"description": "Adds buttons to toggle note list and sidebar",
|
||||
|
@ -7,6 +7,7 @@ import Button, { ButtonLevel } from '../../../Button/Button';
|
||||
import { PluginManifest } from '@joplin/lib/services/plugins/utils/types';
|
||||
import bridge from '../../../../services/bridge';
|
||||
import { ItemEvent, PluginItem } from '@joplin/lib/components/shared/config/plugins/types';
|
||||
import PluginService from '@joplin/lib/services/plugins/PluginService';
|
||||
|
||||
export enum InstallState {
|
||||
NotInstalled = 1,
|
||||
@ -230,7 +231,7 @@ export default function(props: Props) {
|
||||
return (
|
||||
<CellFooter>
|
||||
<NeedUpgradeMessage>
|
||||
{_('Please upgrade Joplin to use this plugin')}
|
||||
{PluginService.instance().describeIncompatibility(props.manifest)}
|
||||
</NeedUpgradeMessage>
|
||||
</CellFooter>
|
||||
);
|
||||
|
@ -11,7 +11,7 @@ import produce from 'immer';
|
||||
import { OnChangeEvent } from '../../../lib/SearchInput/SearchInput';
|
||||
import { PluginItem, ItemEvent, OnPluginSettingChangeEvent } from '@joplin/lib/components/shared/config/plugins/types';
|
||||
import RepositoryApi, { InstallMode } from '@joplin/lib/services/plugins/RepositoryApi';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import Setting, { AppType } from '@joplin/lib/models/Setting';
|
||||
import useOnInstallHandler from '@joplin/lib/components/shared/config/plugins/useOnInstallHandler';
|
||||
import useOnDeleteHandler from '@joplin/lib/components/shared/config/plugins/useOnDeleteHandler';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
@ -60,7 +60,8 @@ let repoApi_: RepositoryApi = null;
|
||||
|
||||
function repoApi(): RepositoryApi {
|
||||
if (repoApi_) return repoApi_;
|
||||
repoApi_ = RepositoryApi.ofDefaultJoplinRepo(Setting.value('tempDir'), InstallMode.Default);
|
||||
const appInfo = { type: AppType.Desktop, version: PluginService.instance().appVersion };
|
||||
repoApi_ = RepositoryApi.ofDefaultJoplinRepo(Setting.value('tempDir'), appInfo, InstallMode.Default);
|
||||
// repoApi_ = new RepositoryApi('/Users/laurent/src/joplin-plugins-test', Setting.value('tempDir'));
|
||||
return repoApi_;
|
||||
}
|
||||
@ -154,7 +155,7 @@ export default function(props: Props) {
|
||||
.filter(plugin => !plugin.builtIn)
|
||||
.map(p => p.manifest);
|
||||
|
||||
const pluginIds = await repoApi().canBeUpdatedPlugins(nonDefaultPlugins, pluginService.appVersion);
|
||||
const pluginIds = await repoApi().canBeUpdatedPlugins(nonDefaultPlugins);
|
||||
if (cancelled) return;
|
||||
|
||||
const conv: Record<string, boolean> = {};
|
||||
@ -256,7 +257,7 @@ export default function(props: Props) {
|
||||
item={item}
|
||||
themeId={props.themeId}
|
||||
updateState={updateState}
|
||||
isCompatible={PluginService.instance().isCompatible(item.manifest.app_min_version)}
|
||||
isCompatible={PluginService.instance().isCompatible(item.manifest)}
|
||||
onDelete={onDelete}
|
||||
onToggle={onToggle}
|
||||
onUpdate={onUpdateHandler}
|
||||
|
@ -86,7 +86,7 @@ export default function(props: Props) {
|
||||
key={manifest.id}
|
||||
manifest={manifest}
|
||||
themeId={props.themeId}
|
||||
isCompatible={PluginService.instance().isCompatible(manifest.app_min_version)}
|
||||
isCompatible={PluginService.instance().isCompatible(manifest)}
|
||||
onInstall={onInstall}
|
||||
installState={installState(manifest.id)}
|
||||
/>);
|
||||
|
@ -3,6 +3,8 @@ import { Icon, Button, Card, Chip } from 'react-native-paper';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { View } from 'react-native';
|
||||
import { ItemEvent, PluginItem } from '@joplin/lib/components/shared/config/plugins/types';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import PluginService from '@joplin/lib/services/plugins/PluginService';
|
||||
|
||||
export enum InstallState {
|
||||
NotInstalled,
|
||||
@ -100,7 +102,7 @@ const PluginBox: React.FC<Props> = props => {
|
||||
};
|
||||
|
||||
const renderRecommendedChip = () => {
|
||||
if (!props.item.manifest._recommended) {
|
||||
if (!props.item.manifest._recommended || !props.isCompatible) {
|
||||
return null;
|
||||
}
|
||||
return <Chip icon='crown' mode='outlined'>{_('Recommended')}</Chip>;
|
||||
@ -113,10 +115,26 @@ const PluginBox: React.FC<Props> = props => {
|
||||
return <Chip icon='code-tags-check' mode='outlined'>{_('Built-in')}</Chip>;
|
||||
};
|
||||
|
||||
const renderIncompatibleChip = () => {
|
||||
if (props.isCompatible) return null;
|
||||
return (
|
||||
<Chip
|
||||
icon='alert'
|
||||
mode='outlined'
|
||||
onPress={() => {
|
||||
void shim.showMessageBox(
|
||||
PluginService.instance().describeIncompatibility(props.item.manifest),
|
||||
{ buttons: [_('OK')] },
|
||||
);
|
||||
}}
|
||||
>{_('Incompatible')}</Chip>
|
||||
);
|
||||
};
|
||||
|
||||
const updateStateIsIdle = props.updateState !== UpdateState.Idle;
|
||||
|
||||
return (
|
||||
<Card style={{ margin: 8 }} testID='plugin-card'>
|
||||
<Card style={{ margin: 8, opacity: props.isCompatible ? undefined : 0.75 }} testID='plugin-card'>
|
||||
<Card.Title
|
||||
title={manifest.name}
|
||||
subtitle={manifest.description}
|
||||
@ -124,6 +142,7 @@ const PluginBox: React.FC<Props> = props => {
|
||||
/>
|
||||
<Card.Content>
|
||||
<View style={{ flexDirection: 'row' }}>
|
||||
{renderIncompatibleChip()}
|
||||
{renderErrorsChip()}
|
||||
{renderRecommendedChip()}
|
||||
{renderBuiltInChip()}
|
||||
|
@ -51,8 +51,8 @@ const mockRepositoryApiConstructor = async () => {
|
||||
}
|
||||
repoTempDir = await createTempDir();
|
||||
|
||||
RepositoryApi.ofDefaultJoplinRepo = jest.fn((_tempDirPath: string, installMode) => {
|
||||
return new RepositoryApi(`${supportDir}/pluginRepo`, repoTempDir, installMode);
|
||||
RepositoryApi.ofDefaultJoplinRepo = jest.fn((_tempDirPath: string, appType, installMode) => {
|
||||
return new RepositoryApi(`${supportDir}/pluginRepo`, repoTempDir, appType, installMode);
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -47,7 +47,7 @@ const PluginStates: React.FC<Props> = props => {
|
||||
.map(plugin => {
|
||||
return plugin.manifest;
|
||||
});
|
||||
const updatablePluginIds = await repoApi.canBeUpdatedPlugins(manifests, PluginService.instance().appVersion);
|
||||
const updatablePluginIds = await repoApi.canBeUpdatedPlugins(manifests);
|
||||
|
||||
const conv: Record<string, boolean> = {};
|
||||
for (const id of updatablePluginIds) {
|
||||
|
@ -86,7 +86,7 @@ const PluginToggle: React.FC<Props> = props => {
|
||||
}, [plugin, pluginId, pluginSettings]);
|
||||
|
||||
const isCompatible = useMemo(() => {
|
||||
return PluginService.instance().isCompatible(plugin.manifest.app_min_version);
|
||||
return PluginService.instance().isCompatible(plugin.manifest);
|
||||
}, [plugin]);
|
||||
|
||||
return (
|
||||
|
@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import RepositoryApi, { InstallMode } from '@joplin/lib/services/plugins/RepositoryApi';
|
||||
import { afterAllCleanUp, afterEachCleanUp, setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
import { afterAllCleanUp, afterEachCleanUp, mockMobilePlatform, setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
|
||||
import { render, screen, userEvent, waitFor } from '@testing-library/react-native';
|
||||
import '@testing-library/react-native/extend-expect';
|
||||
@ -104,4 +104,25 @@ describe('SearchPlugins', () => {
|
||||
expect(screen.getByText(/ABC Sheet Music/i)).toBeTruthy();
|
||||
expect(screen.queryByText(/backlink/i)).toBeNull();
|
||||
});
|
||||
|
||||
it('should mark incompatible plugins as incompatible', async () => {
|
||||
const mock = mockMobilePlatform('android');
|
||||
const repoApi = await newRepoApi(InstallMode.Default);
|
||||
render(<SearchWrapper repoApi={repoApi}/>);
|
||||
|
||||
const searchBox = screen.queryByPlaceholderText('Search');
|
||||
const user = userEvent.setup();
|
||||
await user.type(searchBox, 'abc');
|
||||
|
||||
await expectSearchResultCountToBe(1);
|
||||
expect(screen.queryByText('Incompatible')).toBeNull();
|
||||
|
||||
await user.clear(searchBox);
|
||||
await user.type(searchBox, 'side bar toggle');
|
||||
await expectSearchResultCountToBe(1);
|
||||
expect(await screen.findByText(/Note list and side bar/i)).toBeVisible();
|
||||
expect(await screen.findByText('Incompatible')).toBeVisible();
|
||||
|
||||
mock.reset();
|
||||
});
|
||||
});
|
||||
|
@ -93,7 +93,7 @@ const PluginSearch: React.FC<Props> = props => {
|
||||
key={manifest.id}
|
||||
item={item.item}
|
||||
installState={item.installState}
|
||||
isCompatible={PluginService.instance().isCompatible(manifest.app_min_version)}
|
||||
isCompatible={PluginService.instance().isCompatible(manifest)}
|
||||
onInstall={installPlugin}
|
||||
onAboutPress={onOpenWebsiteForPluginPress}
|
||||
/>
|
||||
|
@ -1,9 +1,11 @@
|
||||
import { AppType } from '@joplin/lib/models/Setting';
|
||||
import RepositoryApi, { InstallMode } from '@joplin/lib/services/plugins/RepositoryApi';
|
||||
import { createTempDir, supportDir } from '@joplin/lib/testing/test-utils';
|
||||
|
||||
|
||||
const newRepoApi = async (installMode: InstallMode): Promise<RepositoryApi> => {
|
||||
const repo = new RepositoryApi(`${supportDir}/pluginRepo`, await createTempDir(), installMode);
|
||||
const newRepoApi = async (installMode: InstallMode, appVersion = '3.0.0'): Promise<RepositoryApi> => {
|
||||
const appInfo = { type: AppType.Mobile, version: appVersion };
|
||||
const repo = new RepositoryApi(`${supportDir}/pluginRepo`, await createTempDir(), appInfo, installMode);
|
||||
await repo.initialize();
|
||||
return repo;
|
||||
};
|
||||
|
@ -1,9 +1,10 @@
|
||||
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import RepositoryApi, { InstallMode } from '@joplin/lib/services/plugins/RepositoryApi';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import Setting, { AppType } from '@joplin/lib/models/Setting';
|
||||
import { useMemo } from 'react';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import PluginService from '@joplin/lib/services/plugins/PluginService';
|
||||
|
||||
const logger = Logger.create('useRepoApi');
|
||||
|
||||
@ -22,7 +23,8 @@ export const resetRepoApi = () => {
|
||||
const useRepoApi = ({ reloadRepoCounter, setRepoApiError, onRepoApiLoaded }: Props) => {
|
||||
const repoApi = useMemo(() => {
|
||||
const installMode = shim.mobilePlatform() === 'ios' ? InstallMode.Restricted : InstallMode.Default;
|
||||
repoApi_ ??= RepositoryApi.ofDefaultJoplinRepo(Setting.value('tempDir'), installMode);
|
||||
const appInfo = { type: AppType.Mobile, version: PluginService.instance().appVersion };
|
||||
repoApi_ ??= RepositoryApi.ofDefaultJoplinRepo(Setting.value('tempDir'), appInfo, installMode);
|
||||
return repoApi_;
|
||||
}, []);
|
||||
|
||||
|
@ -9,7 +9,11 @@ import Setting from '../../models/Setting';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import RepositoryApi from './RepositoryApi';
|
||||
import produce from 'immer';
|
||||
import { compareVersions } from 'compare-versions';
|
||||
import { PluginManifest } from './utils/types';
|
||||
import isCompatible from './utils/isCompatible';
|
||||
import { AppType } from './api/types';
|
||||
import minVersionForPlatform from './utils/isCompatible/minVersionForPlatform';
|
||||
import { _ } from '../../locale';
|
||||
const uslug = require('@joplin/fork-uslug');
|
||||
|
||||
const logger = Logger.create('PluginService');
|
||||
@ -437,8 +441,29 @@ export default class PluginService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
public isCompatible(pluginVersion: string): boolean {
|
||||
return compareVersions(this.appVersion_, pluginVersion) >= 0;
|
||||
private get appType_() {
|
||||
return shim.mobilePlatform() ? AppType.Mobile : AppType.Desktop;
|
||||
}
|
||||
|
||||
public isCompatible(manifest: PluginManifest): boolean {
|
||||
return isCompatible(this.appVersion_, this.appType_, manifest);
|
||||
}
|
||||
|
||||
public describeIncompatibility(manifest: PluginManifest) {
|
||||
if (this.isCompatible(manifest)) return null;
|
||||
|
||||
const minVersion = minVersionForPlatform(this.appType_, manifest);
|
||||
if (minVersion) {
|
||||
return _('Please upgrade Joplin to version %s or later to use this plugin.', minVersion);
|
||||
} else {
|
||||
let platformDescription = 'Unknown';
|
||||
if (this.appType_ === AppType.Mobile) {
|
||||
platformDescription = _('Joplin Mobile');
|
||||
} else if (this.appType_ === AppType.Desktop) {
|
||||
platformDescription = _('Joplin Desktop');
|
||||
}
|
||||
return _('This plugin doesn\'t support %s.', platformDescription);
|
||||
}
|
||||
}
|
||||
|
||||
public get allPluginsStarted(): boolean {
|
||||
@ -451,8 +476,8 @@ export default class PluginService extends BaseService {
|
||||
public async runPlugin(plugin: Plugin) {
|
||||
if (this.isSafeMode) throw new Error(`Plugin was not started due to safe mode: ${plugin.manifest.id}`);
|
||||
|
||||
if (!this.isCompatible(plugin.manifest.app_min_version)) {
|
||||
throw new Error(`Plugin "${plugin.id}" was disabled because it requires Joplin version ${plugin.manifest.app_min_version} and current version is ${this.appVersion_}.`);
|
||||
if (!this.isCompatible(plugin.manifest)) {
|
||||
throw new Error(`Plugin "${plugin.id}" was disabled: ${this.describeIncompatibility(plugin.manifest)}`);
|
||||
} else {
|
||||
this.store_.dispatch({
|
||||
type: 'PLUGIN_ADD',
|
||||
|
@ -3,6 +3,8 @@ import shim from '../../shim';
|
||||
import { PluginManifest } from './utils/types';
|
||||
const md5 = require('md5');
|
||||
import { compareVersions } from 'compare-versions';
|
||||
import isCompatible from './utils/isCompatible';
|
||||
import { AppType } from '../../models/Setting';
|
||||
|
||||
const logger = Logger.create('RepositoryApi');
|
||||
|
||||
@ -21,6 +23,11 @@ export enum InstallMode {
|
||||
Default,
|
||||
}
|
||||
|
||||
export interface AppInfo {
|
||||
version: string;
|
||||
type: AppType;
|
||||
}
|
||||
|
||||
const findWorkingGitHubUrl = async (defaultContentUrl: string): Promise<string> => {
|
||||
// From: https://github.com/laurent22/joplin/issues/5161#issuecomment-921642721
|
||||
|
||||
@ -63,20 +70,24 @@ export default class RepositoryApi {
|
||||
private baseUrl_: string;
|
||||
private tempDir_: string;
|
||||
private readonly installMode_: InstallMode;
|
||||
private readonly appType_: AppType;
|
||||
private readonly appVersion_: string;
|
||||
private release_: Release = null;
|
||||
private manifests_: PluginManifest[] = null;
|
||||
private githubApiUrl_: string;
|
||||
private contentBaseUrl_: string;
|
||||
private isUsingDefaultContentUrl_ = true;
|
||||
|
||||
public constructor(baseUrl: string, tempDir: string, installMode: InstallMode) {
|
||||
public constructor(baseUrl: string, tempDir: string, appInfo: AppInfo, installMode: InstallMode) {
|
||||
this.installMode_ = installMode;
|
||||
this.appType_ = appInfo.type;
|
||||
this.appVersion_ = appInfo.version;
|
||||
this.baseUrl_ = baseUrl;
|
||||
this.tempDir_ = tempDir;
|
||||
}
|
||||
|
||||
public static ofDefaultJoplinRepo(tempDirPath: string, installMode: InstallMode) {
|
||||
return new RepositoryApi('https://github.com/joplin/plugins', tempDirPath, installMode);
|
||||
public static ofDefaultJoplinRepo(tempDirPath: string, appInfo: AppInfo, installMode: InstallMode) {
|
||||
return new RepositoryApi('https://github.com/joplin/plugins', tempDirPath, appInfo, installMode);
|
||||
}
|
||||
|
||||
public async initialize() {
|
||||
@ -204,6 +215,10 @@ export default class RepositoryApi {
|
||||
}
|
||||
|
||||
output.sort((m1, m2) => {
|
||||
const m1Compatible = isCompatible(this.appVersion_, this.appType_, m1);
|
||||
const m2Compatible = isCompatible(this.appVersion_, this.appType_, m2);
|
||||
if (m1Compatible && !m2Compatible) return -1;
|
||||
if (!m1Compatible && m2Compatible) return 1;
|
||||
if (m1._recommended && !m2._recommended) return -1;
|
||||
if (!m1._recommended && m2._recommended) return +1;
|
||||
return m1.name.toLowerCase() < m2.name.toLowerCase() ? -1 : +1;
|
||||
@ -242,22 +257,22 @@ export default class RepositoryApi {
|
||||
return this.manifests_;
|
||||
}
|
||||
|
||||
public async canBeUpdatedPlugins(installedManifests: PluginManifest[], appVersion: string): Promise<string[]> {
|
||||
public async canBeUpdatedPlugins(installedManifests: PluginManifest[]): Promise<string[]> {
|
||||
const output = [];
|
||||
|
||||
for (const manifest of installedManifests) {
|
||||
const canBe = await this.pluginCanBeUpdated(manifest.id, manifest.version, appVersion);
|
||||
const canBe = await this.pluginCanBeUpdated(manifest.id, manifest.version);
|
||||
if (canBe) output.push(manifest.id);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
public async pluginCanBeUpdated(pluginId: string, installedVersion: string, appVersion: string): Promise<boolean> {
|
||||
public async pluginCanBeUpdated(pluginId: string, installedVersion: string): Promise<boolean> {
|
||||
const manifest = (await this.manifests()).find(m => m.id === pluginId);
|
||||
if (!manifest) return false;
|
||||
|
||||
const supportsCurrentAppVersion = compareVersions(installedVersion, manifest.version) < 0 && compareVersions(appVersion, manifest.app_min_version) >= 0;
|
||||
const supportsCurrentAppVersion = compareVersions(installedVersion, manifest.version) < 0 && isCompatible(this.appVersion_, this.appType_, manifest);
|
||||
return supportsCurrentAppVersion && !this.isBlockedByInstallMode(manifest);
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,91 @@
|
||||
import { AppType } from '../../../../models/Setting';
|
||||
import isCompatible from '../isCompatible';
|
||||
|
||||
describe('isCompatible', () => {
|
||||
test.each([
|
||||
// Should support the case where no platform is provided
|
||||
{
|
||||
manifest: { app_min_version: '2.0' },
|
||||
appVersion: '2.1.0',
|
||||
shouldSupportDesktop: true,
|
||||
shouldSupportMobile: true,
|
||||
},
|
||||
{
|
||||
manifest: { app_min_version: '2.0' },
|
||||
appVersion: '1.9.0',
|
||||
shouldSupportDesktop: false,
|
||||
shouldSupportMobile: false,
|
||||
},
|
||||
{
|
||||
manifest: { app_min_version: '3.0.2' },
|
||||
appVersion: '3.0.2',
|
||||
shouldSupportDesktop: true,
|
||||
shouldSupportMobile: true,
|
||||
},
|
||||
|
||||
// Should support the case where only one platform is provided, with no version
|
||||
{
|
||||
manifest: { app_min_version: '3.0.2', platforms: ['mobile'] },
|
||||
appVersion: '3.0.2',
|
||||
shouldSupportDesktop: false,
|
||||
shouldSupportMobile: true,
|
||||
},
|
||||
{
|
||||
manifest: { app_min_version: '2.0', platforms: ['desktop'] },
|
||||
appVersion: '2.1.0',
|
||||
shouldSupportDesktop: true,
|
||||
shouldSupportMobile: false,
|
||||
},
|
||||
{
|
||||
manifest: { app_min_version: '3.0.2', platforms: ['mobile'] },
|
||||
appVersion: '3.0.0',
|
||||
shouldSupportDesktop: false,
|
||||
shouldSupportMobile: false,
|
||||
},
|
||||
|
||||
// Should support the case where two platforms are specified
|
||||
{
|
||||
manifest: { app_min_version: '3.0.2', platforms: ['mobile', 'desktop'] },
|
||||
appVersion: '3.0.2',
|
||||
shouldSupportDesktop: true,
|
||||
shouldSupportMobile: true,
|
||||
},
|
||||
{
|
||||
manifest: { app_min_version: '31.0.2', platforms: ['mobile', 'desktop'] },
|
||||
appVersion: '3.0.2',
|
||||
shouldSupportDesktop: false,
|
||||
shouldSupportMobile: false,
|
||||
},
|
||||
{
|
||||
manifest: { app_min_version: '1.0.2', platforms: ['desktop', 'mobile'] },
|
||||
appVersion: '3.1.5',
|
||||
shouldSupportDesktop: true,
|
||||
shouldSupportMobile: true,
|
||||
},
|
||||
|
||||
// Should support the case where the mobile min_version is different from the desktop
|
||||
{
|
||||
manifest: { app_min_version: '6.0', app_min_version_mobile: '2.0', platforms: ['desktop', 'mobile'] },
|
||||
appVersion: '2.1.0',
|
||||
shouldSupportDesktop: false,
|
||||
shouldSupportMobile: true,
|
||||
},
|
||||
{
|
||||
manifest: { app_min_version: '2.0', app_min_version_mobile: '3.0' },
|
||||
appVersion: '2.1.0',
|
||||
shouldSupportDesktop: true,
|
||||
shouldSupportMobile: false,
|
||||
},
|
||||
{
|
||||
manifest: { app_min_version: '3.0.2', app_min_version_mobile: '3.0.3', platforms: ['mobile'] },
|
||||
appVersion: '3.0.4',
|
||||
shouldSupportDesktop: false,
|
||||
shouldSupportMobile: true,
|
||||
},
|
||||
])('should correctly return whether a plugin is compatible with a given version of Joplin (case %#: %j)', ({ manifest, appVersion, shouldSupportDesktop, shouldSupportMobile }) => {
|
||||
const mobileCompatible = isCompatible(appVersion, AppType.Mobile, manifest);
|
||||
expect(mobileCompatible).toBe(shouldSupportMobile);
|
||||
const desktopCompatible = isCompatible(appVersion, AppType.Desktop, manifest);
|
||||
expect(desktopCompatible).toBe(shouldSupportDesktop);
|
||||
});
|
||||
});
|
15
packages/lib/services/plugins/utils/isCompatible/index.ts
Normal file
15
packages/lib/services/plugins/utils/isCompatible/index.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { compareVersions } from 'compare-versions';
|
||||
import minVersionForPlatform from './minVersionForPlatform';
|
||||
import { ManifestSlice } from './types';
|
||||
import { AppType } from '../../../../models/Setting';
|
||||
|
||||
const isVersionCompatible = (appVersion: string, manifestMinVersion: string) => {
|
||||
return compareVersions(appVersion, manifestMinVersion) >= 0;
|
||||
};
|
||||
|
||||
const isCompatible = (appVersion: string, appType: AppType, manifest: ManifestSlice): boolean => {
|
||||
const minVersion = minVersionForPlatform(appType, manifest);
|
||||
return minVersion && isVersionCompatible(appVersion, minVersion);
|
||||
};
|
||||
|
||||
export default isCompatible;
|
@ -0,0 +1,20 @@
|
||||
import { AppType } from '../../../../models/Setting';
|
||||
import { ManifestSlice } from './types';
|
||||
|
||||
// Returns false if the platform isn't supported at all,
|
||||
const minVersionForPlatform = (appPlatform: AppType, manifest: ManifestSlice): string|false => {
|
||||
const platforms = manifest.platforms ?? [];
|
||||
// If platforms is not specified (or empty), default to supporting all platforms.
|
||||
const supported = platforms.length === 0 || platforms.includes(appPlatform);
|
||||
if (!supported) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (appPlatform === AppType.Mobile && !!manifest.app_min_version_mobile) {
|
||||
return manifest.app_min_version_mobile;
|
||||
}
|
||||
|
||||
return manifest.app_min_version;
|
||||
};
|
||||
|
||||
export default minVersionForPlatform;
|
@ -0,0 +1,3 @@
|
||||
import { PluginManifest } from '../types';
|
||||
|
||||
export type ManifestSlice = Pick<PluginManifest, 'app_min_version'|'app_min_version_mobile'|'platforms'>;
|
@ -1,5 +1,6 @@
|
||||
import { PluginManifest, PluginPermission, Image, Icons } from './types';
|
||||
import validatePluginId from './validatePluginId';
|
||||
import validatePluginPlatforms from './validatePluginPlatforms';
|
||||
|
||||
export default function manifestFromObject(o: any): PluginManifest {
|
||||
|
||||
@ -56,6 +57,8 @@ export default function manifestFromObject(o: any): PluginManifest {
|
||||
name: getString('name', true),
|
||||
version: getString('version', true),
|
||||
app_min_version: getString('app_min_version', true),
|
||||
app_min_version_mobile: getString('app_min_version', false),
|
||||
platforms: getStrings('platforms', false),
|
||||
|
||||
author: getString('author', false),
|
||||
description: getString('description', false),
|
||||
@ -72,6 +75,7 @@ export default function manifestFromObject(o: any): PluginManifest {
|
||||
};
|
||||
|
||||
validatePluginId(manifest.id);
|
||||
validatePluginPlatforms(manifest.platforms);
|
||||
|
||||
if (o.permissions) {
|
||||
for (const p of o.permissions) {
|
||||
|
@ -20,6 +20,8 @@ export interface PluginManifest {
|
||||
name: string;
|
||||
version: string;
|
||||
app_min_version: string;
|
||||
app_min_version_mobile?: string;
|
||||
platforms?: string[];
|
||||
author?: string;
|
||||
description?: string;
|
||||
homepage_url?: string;
|
||||
|
@ -0,0 +1,16 @@
|
||||
import validatePluginPlatforms from './validatePluginPlatforms';
|
||||
|
||||
describe('validatePluginPlatforms', () => {
|
||||
test.each([
|
||||
[['mobile', 'desktop'], true],
|
||||
['not-an-array', false],
|
||||
[[3, 4, 5], false],
|
||||
])('should throw when given an invalid list of supported plugin platforms (case %#)', (platforms: any, shouldSupport) => {
|
||||
const callback = () => validatePluginPlatforms(platforms);
|
||||
if (shouldSupport) {
|
||||
expect(callback).not.toThrow();
|
||||
} else {
|
||||
expect(callback).toThrow();
|
||||
}
|
||||
});
|
||||
});
|
@ -0,0 +1,12 @@
|
||||
|
||||
const validatePluginPlatforms = (platforms: string[]) => {
|
||||
if (!platforms) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(platforms) || platforms.some(p => typeof p !== 'string')) {
|
||||
throw new Error('If specified, platforms must be a string array');
|
||||
}
|
||||
};
|
||||
|
||||
export default validatePluginPlatforms;
|
@ -1044,8 +1044,18 @@ export const newOcrService = () => {
|
||||
};
|
||||
|
||||
export const mockMobilePlatform = (platform: string) => {
|
||||
const originalMobilePlatform = shim.mobilePlatform;
|
||||
const originalIsNode = shim.isNode;
|
||||
|
||||
shim.mobilePlatform = () => platform;
|
||||
shim.isNode = () => false;
|
||||
|
||||
return {
|
||||
reset: () => {
|
||||
shim.mobilePlatform = originalMobilePlatform;
|
||||
shim.isNode = originalIsNode;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export { supportDir, createNoteAndResource, createTempFile, createTestShareData, simulateReadOnlyShareEnv, waitForFolderCount, afterAllCleanUp, exportDir, synchronizerStart, afterEachCleanUp, syncTargetName, setSyncTargetName, syncDir, createTempDir, isNetworkSyncTarget, kvStore, expectThrow, logger, expectNotThrow, resourceService, resourceFetcher, tempFilePath, allSyncTargetItemsEncrypted, msleep, setupDatabase, revisionService, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync, checkThrow, encryptionService, loadEncryptionMasterKey, fileContentEqual, decryptionWorker, currentClientId, id, ids, sortedIds, at, createNTestNotes, createNTestFolders, createNTestTags, TestApp };
|
||||
|
@ -57,6 +57,28 @@ describe('validateUntrustedManifest', () => {
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test('should only allow valid plugin platforms', () => {
|
||||
const badManifest = {
|
||||
id: 'com.example.a-plugin-for-a-fake-platform',
|
||||
_npm_package_name: 'joplin-plugin-plugin-for-an-invalid-version',
|
||||
version: '1.2.3',
|
||||
platforms: [3, 4, 5],
|
||||
};
|
||||
|
||||
expect(
|
||||
() => validateUntrustedManifest(badManifest, originalManifests),
|
||||
).toThrow();
|
||||
|
||||
const goodManifest = {
|
||||
...badManifest,
|
||||
platforms: ['mobile', 'desktop'],
|
||||
};
|
||||
|
||||
expect(
|
||||
() => validateUntrustedManifest(goodManifest, originalManifests),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test('should not allow plugin authors to mark their own plugins as recommended', () => {
|
||||
const newManifest1 = {
|
||||
id: 'joplin-plugin.this.is.another.test',
|
||||
|
@ -1,5 +1,6 @@
|
||||
import validatePluginId from '@joplin/lib/services/plugins/utils/validatePluginId';
|
||||
import validatePluginVersion from '@joplin/lib/services/plugins/utils/validatePluginVersion';
|
||||
import validatePluginPlatforms from '@joplin/lib/services/plugins/utils/validatePluginPlatforms';
|
||||
import checkIfPluginCanBeAdded from './checkIfPluginCanBeAdded';
|
||||
|
||||
// Assumes that
|
||||
@ -12,6 +13,7 @@ const validateUntrustedManifest = (manifest: any, existingManifests: any) => {
|
||||
// manifest properties are checked when the plugin is loaded into the app.
|
||||
validatePluginId(manifest.id);
|
||||
validatePluginVersion(manifest.version);
|
||||
validatePluginPlatforms(manifest.platforms);
|
||||
|
||||
// This prevents a plugin author from marking their own plugin as _recommended.
|
||||
if (typeof manifest._recommended !== 'undefined') {
|
||||
|
@ -8,6 +8,8 @@ Name | Type | Required? | Description
|
||||
`name` | string | **Yes** | Name of the plugin. Should be a user-friendly string, as it will be displayed in the UI.
|
||||
`version` | string | **Yes** | Version number such as "1.0.0".
|
||||
`app_min_version` | string | **Yes** | Minimum version of Joplin that the plugin is compatible with. In general it should be whatever version you are using to develop the plugin.
|
||||
`app_min_version_mobile` | string | No | Minimum version of Joplin on mobile platforms, if different from `app_min_version`
|
||||
`platforms` | string[] | No | List of platforms supported by the plugin. For example, `["desktop", "mobile"]`.
|
||||
`description` | string | No | Detailed description of the plugin.
|
||||
`author` | string | No | Plugin author name.
|
||||
`keywords` | string[] | No | Keywords associated with the plugins. They are used in search in particular.
|
||||
@ -18,6 +20,10 @@ Name | Type | Required? | Description
|
||||
`icons` | Icons | No | If [Icons](#Icons) is not supplied, a standard plugin icon will be used by default. You should supply at least a main icon, ideally 48x48 px in size. This is the icon that will be used in various plugin pages. You may, however, supply icons of any size and Joplin will attempt to find the best icon to display in different components. Only PNG icons are allowed.
|
||||
`promo_tile` | Image | No | [Promo tile](#promo-tile) is an optional image that is used to promote your plugin on the Joplin Plugins website.
|
||||
|
||||
## Platforms
|
||||
|
||||
A list that can contain `"desktop"` and/or `"mobile"`.
|
||||
|
||||
## Categories
|
||||
|
||||
| Category | Description |
|
||||
@ -75,6 +81,8 @@ If no promo tile is provided, your plugin icon will be displayed instead.
|
||||
"version": "1.0.0",
|
||||
"author": "John Smith",
|
||||
"app_min_version": "1.4",
|
||||
"app_min_version_mobile": "3.0.3",
|
||||
"platforms": ["mobile", "desktop"],
|
||||
"homepage_url": "https://joplinapp.org",
|
||||
"screenshots": [
|
||||
{
|
||||
|
Loading…
Reference in New Issue
Block a user