From f899c97c4c6be4e0613df33856c00135c0e82300 Mon Sep 17 00:00:00 2001 From: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com> Date: Wed, 3 Apr 2024 10:51:09 -0700 Subject: [PATCH] Mobile,Desktop: Resolves #10206: Allow marking a plugin as mobile-only or desktop-only (#10229) --- .eslintignore | 6 ++ .gitignore | 6 ++ .../tests/services/plugins/PluginService.ts | 64 ++++++++++++- .../tests/services/plugins/RepositoryApi.ts | 32 +++++-- .../tests/support/pluginRepo/manifests.json | 1 + .../manifest.json | 1 + .../controls/plugins/PluginBox.tsx | 3 +- .../controls/plugins/PluginsStates.tsx | 9 +- .../controls/plugins/SearchPlugins.tsx | 2 +- .../ConfigScreen/plugins/PluginBox.tsx | 23 ++++- .../plugins/PluginStates.test.tsx | 4 +- .../ConfigScreen/plugins/PluginStates.tsx | 2 +- .../ConfigScreen/plugins/PluginToggle.tsx | 2 +- .../plugins/SearchPlugins.test.tsx | 23 ++++- .../ConfigScreen/plugins/SearchPlugins.tsx | 2 +- .../plugins/testUtils/newRepoApi.ts | 6 +- .../ConfigScreen/plugins/utils/useRepoApi.ts | 6 +- .../lib/services/plugins/PluginService.ts | 35 ++++++- .../lib/services/plugins/RepositoryApi.ts | 29 ++++-- .../plugins/utils/isCompatible/index.test.ts | 91 +++++++++++++++++++ .../plugins/utils/isCompatible/index.ts | 15 +++ .../isCompatible/minVersionForPlatform.ts | 20 ++++ .../plugins/utils/isCompatible/types.ts | 3 + .../plugins/utils/manifestFromObject.ts | 4 + packages/lib/services/plugins/utils/types.ts | 2 + .../utils/validatePluginPlatforms.test.ts | 16 ++++ .../plugins/utils/validatePluginPlatforms.ts | 12 +++ packages/lib/testing/test-utils.ts | 10 ++ .../lib/validateUntrustedManifest.test.ts | 22 +++++ .../lib/validateUntrustedManifest.ts | 2 + readme/api/references/plugin_manifest.md | 8 ++ 31 files changed, 420 insertions(+), 41 deletions(-) create mode 100644 packages/lib/services/plugins/utils/isCompatible/index.test.ts create mode 100644 packages/lib/services/plugins/utils/isCompatible/index.ts create mode 100644 packages/lib/services/plugins/utils/isCompatible/minVersionForPlatform.ts create mode 100644 packages/lib/services/plugins/utils/isCompatible/types.ts create mode 100644 packages/lib/services/plugins/utils/validatePluginPlatforms.test.ts create mode 100644 packages/lib/services/plugins/utils/validatePluginPlatforms.ts diff --git a/.eslintignore b/.eslintignore index 8eed64aae..1f4d4a7da 100644 --- a/.eslintignore +++ b/.eslintignore @@ -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 diff --git a/.gitignore b/.gitignore index e921c3a3d..e58c84621 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/packages/app-cli/tests/services/plugins/PluginService.ts b/packages/app-cli/tests/services/plugins/PluginService.ts index 9bdb062c7..74e8bf048 100644 --- a/packages/app-cli/tests/services/plugins/PluginService.ts +++ b/packages/app-cli/tests/services/plugins/PluginService.ts @@ -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`; diff --git a/packages/app-cli/tests/services/plugins/RepositoryApi.ts b/packages/app-cli/tests/services/plugins/RepositoryApi.ts index 86d6bdb96..b04742bfb 100644 --- a/packages/app-cli/tests/services/plugins/RepositoryApi.ts +++ b/packages/app-cli/tests/services/plugins/RepositoryApi.ts @@ -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 { - 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 { + 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); })); }); diff --git a/packages/app-cli/tests/support/pluginRepo/manifests.json b/packages/app-cli/tests/support/pluginRepo/manifests.json index d78068df2..470d98459 100644 --- a/packages/app-cli/tests/support/pluginRepo/manifests.json +++ b/packages/app-cli/tests/support/pluginRepo/manifests.json @@ -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", diff --git a/packages/app-cli/tests/support/pluginRepo/plugins/org.joplinapp.plugins.ToggleSidebars/manifest.json b/packages/app-cli/tests/support/pluginRepo/plugins/org.joplinapp.plugins.ToggleSidebars/manifest.json index 0af320fd9..a7c770e28 100644 --- a/packages/app-cli/tests/support/pluginRepo/plugins/org.joplinapp.plugins.ToggleSidebars/manifest.json +++ b/packages/app-cli/tests/support/pluginRepo/plugins/org.joplinapp.plugins.ToggleSidebars/manifest.json @@ -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", diff --git a/packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.tsx b/packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.tsx index 30bed7d5c..021808319 100644 --- a/packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.tsx +++ b/packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.tsx @@ -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 ( - {_('Please upgrade Joplin to use this plugin')} + {PluginService.instance().describeIncompatibility(props.manifest)} ); diff --git a/packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.tsx b/packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.tsx index 5394dfc34..46face17f 100644 --- a/packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.tsx +++ b/packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.tsx @@ -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 = {}; @@ -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} diff --git a/packages/app-desktop/gui/ConfigScreen/controls/plugins/SearchPlugins.tsx b/packages/app-desktop/gui/ConfigScreen/controls/plugins/SearchPlugins.tsx index 741c4b54b..922b4145d 100644 --- a/packages/app-desktop/gui/ConfigScreen/controls/plugins/SearchPlugins.tsx +++ b/packages/app-desktop/gui/ConfigScreen/controls/plugins/SearchPlugins.tsx @@ -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)} />); diff --git a/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox.tsx b/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox.tsx index a504cf50d..695e631fb 100644 --- a/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox.tsx +++ b/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox.tsx @@ -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 => { }; const renderRecommendedChip = () => { - if (!props.item.manifest._recommended) { + if (!props.item.manifest._recommended || !props.isCompatible) { return null; } return {_('Recommended')}; @@ -113,10 +115,26 @@ const PluginBox: React.FC = props => { return {_('Built-in')}; }; + const renderIncompatibleChip = () => { + if (props.isCompatible) return null; + return ( + { + void shim.showMessageBox( + PluginService.instance().describeIncompatibility(props.item.manifest), + { buttons: [_('OK')] }, + ); + }} + >{_('Incompatible')} + ); + }; + const updateStateIsIdle = props.updateState !== UpdateState.Idle; return ( - + = props => { /> + {renderIncompatibleChip()} {renderErrorsChip()} {renderRecommendedChip()} {renderBuiltInChip()} diff --git a/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.test.tsx b/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.test.tsx index d8acc54c5..d0a8f49a9 100644 --- a/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.test.tsx +++ b/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.test.tsx @@ -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); }); }; diff --git a/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.tsx b/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.tsx index a9dcd4832..138ccfeff 100644 --- a/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.tsx +++ b/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.tsx @@ -47,7 +47,7 @@ const PluginStates: React.FC = props => { .map(plugin => { return plugin.manifest; }); - const updatablePluginIds = await repoApi.canBeUpdatedPlugins(manifests, PluginService.instance().appVersion); + const updatablePluginIds = await repoApi.canBeUpdatedPlugins(manifests); const conv: Record = {}; for (const id of updatablePluginIds) { diff --git a/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginToggle.tsx b/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginToggle.tsx index 84083b38f..f0ca73951 100644 --- a/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginToggle.tsx +++ b/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginToggle.tsx @@ -86,7 +86,7 @@ const PluginToggle: React.FC = props => { }, [plugin, pluginId, pluginSettings]); const isCompatible = useMemo(() => { - return PluginService.instance().isCompatible(plugin.manifest.app_min_version); + return PluginService.instance().isCompatible(plugin.manifest); }, [plugin]); return ( diff --git a/packages/app-mobile/components/screens/ConfigScreen/plugins/SearchPlugins.test.tsx b/packages/app-mobile/components/screens/ConfigScreen/plugins/SearchPlugins.test.tsx index 9d8635685..20d28cba5 100644 --- a/packages/app-mobile/components/screens/ConfigScreen/plugins/SearchPlugins.test.tsx +++ b/packages/app-mobile/components/screens/ConfigScreen/plugins/SearchPlugins.test.tsx @@ -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(); + + 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(); + }); }); diff --git a/packages/app-mobile/components/screens/ConfigScreen/plugins/SearchPlugins.tsx b/packages/app-mobile/components/screens/ConfigScreen/plugins/SearchPlugins.tsx index 1166b7a8e..94deb5e3c 100644 --- a/packages/app-mobile/components/screens/ConfigScreen/plugins/SearchPlugins.tsx +++ b/packages/app-mobile/components/screens/ConfigScreen/plugins/SearchPlugins.tsx @@ -93,7 +93,7 @@ const PluginSearch: React.FC = 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} /> diff --git a/packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/newRepoApi.ts b/packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/newRepoApi.ts index d09c8908f..a7f989a3e 100644 --- a/packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/newRepoApi.ts +++ b/packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/newRepoApi.ts @@ -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 => { - const repo = new RepositoryApi(`${supportDir}/pluginRepo`, await createTempDir(), installMode); +const newRepoApi = async (installMode: InstallMode, appVersion = '3.0.0'): Promise => { + const appInfo = { type: AppType.Mobile, version: appVersion }; + const repo = new RepositoryApi(`${supportDir}/pluginRepo`, await createTempDir(), appInfo, installMode); await repo.initialize(); return repo; }; diff --git a/packages/app-mobile/components/screens/ConfigScreen/plugins/utils/useRepoApi.ts b/packages/app-mobile/components/screens/ConfigScreen/plugins/utils/useRepoApi.ts index aaa451613..7d7dda782 100644 --- a/packages/app-mobile/components/screens/ConfigScreen/plugins/utils/useRepoApi.ts +++ b/packages/app-mobile/components/screens/ConfigScreen/plugins/utils/useRepoApi.ts @@ -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_; }, []); diff --git a/packages/lib/services/plugins/PluginService.ts b/packages/lib/services/plugins/PluginService.ts index 0b7f6ba24..345dabf42 100644 --- a/packages/lib/services/plugins/PluginService.ts +++ b/packages/lib/services/plugins/PluginService.ts @@ -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', diff --git a/packages/lib/services/plugins/RepositoryApi.ts b/packages/lib/services/plugins/RepositoryApi.ts index 161d507b7..d91d4f74f 100644 --- a/packages/lib/services/plugins/RepositoryApi.ts +++ b/packages/lib/services/plugins/RepositoryApi.ts @@ -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 => { // 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 { + public async canBeUpdatedPlugins(installedManifests: PluginManifest[]): Promise { 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 { + public async pluginCanBeUpdated(pluginId: string, installedVersion: string): Promise { 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); } diff --git a/packages/lib/services/plugins/utils/isCompatible/index.test.ts b/packages/lib/services/plugins/utils/isCompatible/index.test.ts new file mode 100644 index 000000000..8df5c9d20 --- /dev/null +++ b/packages/lib/services/plugins/utils/isCompatible/index.test.ts @@ -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); + }); +}); diff --git a/packages/lib/services/plugins/utils/isCompatible/index.ts b/packages/lib/services/plugins/utils/isCompatible/index.ts new file mode 100644 index 000000000..5eb388f35 --- /dev/null +++ b/packages/lib/services/plugins/utils/isCompatible/index.ts @@ -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; diff --git a/packages/lib/services/plugins/utils/isCompatible/minVersionForPlatform.ts b/packages/lib/services/plugins/utils/isCompatible/minVersionForPlatform.ts new file mode 100644 index 000000000..8012f7102 --- /dev/null +++ b/packages/lib/services/plugins/utils/isCompatible/minVersionForPlatform.ts @@ -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; diff --git a/packages/lib/services/plugins/utils/isCompatible/types.ts b/packages/lib/services/plugins/utils/isCompatible/types.ts new file mode 100644 index 000000000..b400ffe6a --- /dev/null +++ b/packages/lib/services/plugins/utils/isCompatible/types.ts @@ -0,0 +1,3 @@ +import { PluginManifest } from '../types'; + +export type ManifestSlice = Pick; diff --git a/packages/lib/services/plugins/utils/manifestFromObject.ts b/packages/lib/services/plugins/utils/manifestFromObject.ts index d509cbce2..b68fa6f6d 100644 --- a/packages/lib/services/plugins/utils/manifestFromObject.ts +++ b/packages/lib/services/plugins/utils/manifestFromObject.ts @@ -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) { diff --git a/packages/lib/services/plugins/utils/types.ts b/packages/lib/services/plugins/utils/types.ts index 01e9c5a6e..9051bd8a2 100644 --- a/packages/lib/services/plugins/utils/types.ts +++ b/packages/lib/services/plugins/utils/types.ts @@ -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; diff --git a/packages/lib/services/plugins/utils/validatePluginPlatforms.test.ts b/packages/lib/services/plugins/utils/validatePluginPlatforms.test.ts new file mode 100644 index 000000000..65ec5962f --- /dev/null +++ b/packages/lib/services/plugins/utils/validatePluginPlatforms.test.ts @@ -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(); + } + }); +}); diff --git a/packages/lib/services/plugins/utils/validatePluginPlatforms.ts b/packages/lib/services/plugins/utils/validatePluginPlatforms.ts new file mode 100644 index 000000000..bdbf7c272 --- /dev/null +++ b/packages/lib/services/plugins/utils/validatePluginPlatforms.ts @@ -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; diff --git a/packages/lib/testing/test-utils.ts b/packages/lib/testing/test-utils.ts index f16ff5074..6a1a3fc7f 100644 --- a/packages/lib/testing/test-utils.ts +++ b/packages/lib/testing/test-utils.ts @@ -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 }; diff --git a/packages/plugin-repo-cli/lib/validateUntrustedManifest.test.ts b/packages/plugin-repo-cli/lib/validateUntrustedManifest.test.ts index e63846a86..9f0056e0b 100644 --- a/packages/plugin-repo-cli/lib/validateUntrustedManifest.test.ts +++ b/packages/plugin-repo-cli/lib/validateUntrustedManifest.test.ts @@ -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', diff --git a/packages/plugin-repo-cli/lib/validateUntrustedManifest.ts b/packages/plugin-repo-cli/lib/validateUntrustedManifest.ts index ffe5af79e..696238963 100644 --- a/packages/plugin-repo-cli/lib/validateUntrustedManifest.ts +++ b/packages/plugin-repo-cli/lib/validateUntrustedManifest.ts @@ -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') { diff --git a/readme/api/references/plugin_manifest.md b/readme/api/references/plugin_manifest.md index 0b928ab22..afd93d3f1 100644 --- a/readme/api/references/plugin_manifest.md +++ b/readme/api/references/plugin_manifest.md @@ -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": [ {