You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2026-04-21 19:45:16 +02:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 04389e6c87 | |||
| 20b5e02802 | |||
| 204b653422 | |||
| 222bb002c8 | |||
| af8eb30844 | |||
| cb009cb084 |
+2
-1
@@ -201,6 +201,8 @@ packages/app-desktop/gui/ConfigScreen/ButtonBar.js
|
||||
packages/app-desktop/gui/ConfigScreen/ConfigScreen.js
|
||||
packages/app-desktop/gui/ConfigScreen/Sidebar.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/FontSearch.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/GlobalHotkeyInput.test.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/GlobalHotkeyInput.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/MissingPasswordHelpLink.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/SettingComponent.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/SettingDescription.js
|
||||
@@ -1896,7 +1898,6 @@ packages/tools/buildServerDocker.js
|
||||
packages/tools/checkIgnoredFiles.js
|
||||
packages/tools/checkLibPaths.test.js
|
||||
packages/tools/checkLibPaths.js
|
||||
packages/tools/checkYarnPatches.js
|
||||
packages/tools/convertThemesToCss.js
|
||||
packages/tools/fuzzer/ActionRunner.js
|
||||
packages/tools/fuzzer/Fuzzer.js
|
||||
|
||||
@@ -172,12 +172,6 @@ if [ "$RUN_TESTS" == "1" ]; then
|
||||
if [ $testResult -ne 0 ]; then
|
||||
exit $testResult
|
||||
fi
|
||||
|
||||
yarn checkYarnPatches
|
||||
testResult=$?
|
||||
if [ $testResult -ne 0 ]; then
|
||||
exit $testResult
|
||||
fi
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
|
||||
+2
-1
@@ -174,6 +174,8 @@ packages/app-desktop/gui/ConfigScreen/ButtonBar.js
|
||||
packages/app-desktop/gui/ConfigScreen/ConfigScreen.js
|
||||
packages/app-desktop/gui/ConfigScreen/Sidebar.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/FontSearch.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/GlobalHotkeyInput.test.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/GlobalHotkeyInput.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/MissingPasswordHelpLink.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/SettingComponent.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/SettingDescription.js
|
||||
@@ -1869,7 +1871,6 @@ packages/tools/buildServerDocker.js
|
||||
packages/tools/checkIgnoredFiles.js
|
||||
packages/tools/checkLibPaths.test.js
|
||||
packages/tools/checkLibPaths.js
|
||||
packages/tools/checkYarnPatches.js
|
||||
packages/tools/convertThemesToCss.js
|
||||
packages/tools/fuzzer/ActionRunner.js
|
||||
packages/tools/fuzzer/Fuzzer.js
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
diff --git a/src/RNCamera.js b/src/RNCamera.js
|
||||
index b7a271ad64771c0f654dbd5fe3c0d9e0d2e2c4ef..1182a40ace081a32fbaefe2bc4a499b79c2e7dac 100644
|
||||
--- a/src/RNCamera.js
|
||||
+++ b/src/RNCamera.js
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
findNodeHandle,
|
||||
Platform,
|
||||
NativeModules,
|
||||
- ViewPropTypes,
|
||||
requireNativeComponent,
|
||||
View,
|
||||
ActivityIndicator,
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
PermissionsAndroid,
|
||||
} from 'react-native';
|
||||
|
||||
+import ViewPropTypes from 'deprecated-react-native-prop-types';
|
||||
import type { FaceFeature } from './FaceDetector';
|
||||
|
||||
const Rationale = PropTypes.shape({
|
||||
@@ -1,34 +0,0 @@
|
||||
diff --git a/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedModule.kt b/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedModule.kt
|
||||
index 3bc9143d7ed7923c11e5d73853a285f648e9745e..eb69fdc38be63bcf887cec5294e68b3128255083 100644
|
||||
--- a/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedModule.kt
|
||||
+++ b/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedModule.kt
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
package com.facebook.react.animated
|
||||
|
||||
+import android.os.Build
|
||||
import androidx.annotation.AnyThread
|
||||
import androidx.annotation.UiThread
|
||||
import com.facebook.common.logging.FLog
|
||||
@@ -34,6 +35,7 @@ import com.facebook.react.uimanager.common.ViewUtil
|
||||
import java.util.ArrayList
|
||||
import java.util.Queue
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
+import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import kotlin.concurrent.Volatile
|
||||
|
||||
@@ -130,7 +132,12 @@ public class NativeAnimatedModule(reactContext: ReactApplicationContext) :
|
||||
}
|
||||
|
||||
private inner class ConcurrentOperationQueue {
|
||||
- private val queue: Queue<UIThreadOperation> = ConcurrentLinkedQueue()
|
||||
+ // Patch: Use LinkedBlockingQueue instead of ConcurrentLinkedQueue on Android 12.
|
||||
+ // In some versions of Android, ConcurrentLinkedQueue is known to drop
|
||||
+ // items, causing crashes. See https://github.com/laurent22/joplin/issues/8425
|
||||
+ private val queue: Queue<UIThreadOperation> = if (
|
||||
+ Build.VERSION.SDK_INT == 31 || Build.VERSION.SDK_INT == 32
|
||||
+ ) LinkedBlockingQueue() else ConcurrentLinkedQueue()
|
||||
private var peekedOperation: UIThreadOperation? = null
|
||||
|
||||
@get:AnyThread
|
||||
+5
-2
@@ -25,7 +25,6 @@
|
||||
"buildWebsiteTranslations": "node packages/tools/website/buildTranslations.js",
|
||||
"checkIgnoredFiles": "node ./packages/tools/checkIgnoredFiles.js",
|
||||
"checkLibPaths": "node ./packages/tools/checkLibPaths.js",
|
||||
"checkYarnPatches": "node ./packages/tools/checkYarnPatches.js",
|
||||
"circularDependencyCheck": "madge --warning --circular --extensions js ./",
|
||||
"clean": "npm run clean --workspaces --if-present && node packages/tools/clean && yarn cache clean",
|
||||
"crowdin": "crowdin",
|
||||
@@ -106,15 +105,19 @@
|
||||
"@codemirror/state": "6.6.0",
|
||||
"@codemirror/view": "6.41.0",
|
||||
"@codemirror/language": "6.12.3",
|
||||
"react-native-camera@4.2.1": "patch:react-native-camera@npm%3A4.2.1#./.yarn/patches/react-native-camera-npm-4.2.1-24b2600a7e.patch",
|
||||
"eslint": "patch:eslint@8.57.1#./.yarn/patches/eslint-npm-8.39.0-d92bace04d.patch",
|
||||
"app-builder-lib@24.4.0": "patch:app-builder-lib@npm%3A24.4.0#./.yarn/patches/app-builder-lib-npm-24.4.0-05322ff057.patch",
|
||||
"nanoid": "patch:nanoid@npm%3A3.3.7#./.yarn/patches/nanoid-npm-3.3.7-98824ba130.patch",
|
||||
"pdfjs-dist": "patch:pdfjs-dist@npm%3A3.11.174#./.yarn/patches/pdfjs-dist-npm-3.11.174-67f2fee6d6.patch",
|
||||
"chokidar@^2.0.0": "3.5.3",
|
||||
"rn-fetch-blob@0.12.0": "patch:rn-fetch-blob@npm%3A0.12.0#./.yarn/patches/rn-fetch-blob-npm-0.12.0-cf02e3c544.patch",
|
||||
"app-builder-lib@26.0.0-alpha.7": "patch:app-builder-lib@npm%3A26.0.0-alpha.7#./.yarn/patches/app-builder-lib-npm-26.0.0-alpha.7-e1b3dca119.patch",
|
||||
"app-builder-lib@24.13.3": "patch:app-builder-lib@npm%3A24.13.3#./.yarn/patches/app-builder-lib-npm-24.13.3-86a66c0bf3.patch",
|
||||
"react-native-sqlite-storage@6.0.1": "patch:react-native-sqlite-storage@npm%3A6.0.1#./.yarn/patches/react-native-sqlite-storage-npm-6.0.1-8369d747bd.patch",
|
||||
"react-native@0.81.6": "patch:react-native@npm%3A0.81.6#./.yarn/patches/react-native-npm-0.81.6-e0b80aa8aa.patch",
|
||||
"react-native-paper@5.13.1": "patch:react-native-paper@npm%3A5.13.1#./.yarn/patches/react-native-paper-npm-5.13.1-f153e542e2.patch",
|
||||
"react-native-popup-menu@0.17.0": "patch:react-native-popup-menu@npm%3A0.17.0#./.yarn/patches/react-native-popup-menu-npm-0.17.0-8b745d88dd.patch",
|
||||
"react-native@0.79.2": "patch:react-native@npm%3A0.79.2#./.yarn/patches/react-native-npm-0.79.2-9db13eddfe.patch",
|
||||
"pdfjs-dist@2.16.105": "patch:pdfjs-dist@npm%3A3.11.174#./.yarn/patches/pdfjs-dist-npm-3.11.174-67f2fee6d6.patch",
|
||||
"pdfjs-dist@*": "patch:pdfjs-dist@npm%3A3.11.174#./.yarn/patches/pdfjs-dist-npm-3.11.174-67f2fee6d6.patch",
|
||||
"pdfjs-dist@3.11.174": "patch:pdfjs-dist@npm%3A3.11.174#./.yarn/patches/pdfjs-dist-npm-3.11.174-67f2fee6d6.patch",
|
||||
|
||||
@@ -939,6 +939,7 @@ export default class ElectronAppWrapper {
|
||||
this.electronApp_.on('before-quit', () => {
|
||||
this.appLogger_.info('[appClose] before-quit event fired, setting willQuitApp_ = true');
|
||||
this.willQuitApp_ = true;
|
||||
bridge().unregisterGlobalHotkey();
|
||||
});
|
||||
|
||||
this.electronApp_.on('window-all-closed', () => {
|
||||
|
||||
@@ -150,6 +150,10 @@ class Application extends BaseApplication {
|
||||
bridge().extraAllowedOpenExtensions = Setting.value('linking.extraAllowedExtensions');
|
||||
}
|
||||
|
||||
if ((action.type === 'SETTING_UPDATE_ONE' && action.key === 'globalHotkey') || action.type === 'SETTING_UPDATE_ALL') {
|
||||
bridge().updateGlobalHotkey(Setting.value('globalHotkey'));
|
||||
}
|
||||
|
||||
if (['EVENT_NOTE_ALARM_FIELD_CHANGE', 'NOTE_DELETE'].indexOf(action.type) >= 0) {
|
||||
await AlarmService.updateNoteNotification(action.id, action.type === 'NOTE_DELETE');
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import ElectronAppWrapper from './ElectronAppWrapper';
|
||||
import shim, { MessageBoxType } from '@joplin/lib/shim';
|
||||
import { _, setLocale } from '@joplin/lib/locale';
|
||||
import { BrowserWindow, nativeTheme, nativeImage, shell, dialog, MessageBoxSyncOptions, safeStorage, Menu, MenuItemConstructorOptions, MenuItem, BrowserWindowConstructorOptions, FileFilter, SaveDialogOptions } from 'electron';
|
||||
import { BrowserWindow, nativeTheme, nativeImage, shell, dialog, MessageBoxSyncOptions, safeStorage, Menu, MenuItemConstructorOptions, MenuItem, BrowserWindowConstructorOptions, FileFilter, SaveDialogOptions, globalShortcut } from 'electron';
|
||||
import { dirname, toSystemSlashes } from '@joplin/lib/path-utils';
|
||||
import { fileUriToPath } from '@joplin/utils/url';
|
||||
import { urlDecode } from '@joplin/lib/string-utils';
|
||||
@@ -46,6 +46,7 @@ export class Bridge {
|
||||
|
||||
private extraAllowedExtensions_: string[] = [];
|
||||
private onAllowedExtensionsChangeListener_: OnAllowedExtensionsChange = ()=>{};
|
||||
private registeredGlobalHotkey_ = '';
|
||||
|
||||
public constructor(electronWrapper: ElectronAppWrapper, appId: string, appName: string, rootProfileDir: string, autoUploadCrashDumps: boolean, altInstanceId: string) {
|
||||
this.electronWrapper_ = electronWrapper;
|
||||
@@ -207,6 +208,54 @@ export class Bridge {
|
||||
this.onAllowedExtensionsChangeListener_ = listener;
|
||||
}
|
||||
|
||||
public updateGlobalHotkey(accelerator: string) {
|
||||
// Skip if the accelerator hasn't changed
|
||||
if (accelerator === this.registeredGlobalHotkey_) return;
|
||||
|
||||
// Unregister the previous shortcut (only Joplin's own)
|
||||
this.unregisterGlobalHotkey();
|
||||
|
||||
if (!accelerator) return;
|
||||
|
||||
try {
|
||||
const registered = globalShortcut.register(accelerator, () => {
|
||||
const win = this.mainWindow();
|
||||
if (!win) return;
|
||||
|
||||
if (win.isVisible() && win.isFocused()) {
|
||||
win.hide();
|
||||
} else {
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.show();
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
win.focus();
|
||||
}
|
||||
});
|
||||
|
||||
if (registered) {
|
||||
this.registeredGlobalHotkey_ = accelerator;
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`Bridge: Failed to register global shortcut: ${accelerator}`);
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Bridge: Error registering global shortcut "${accelerator}":`, error);
|
||||
}
|
||||
}
|
||||
|
||||
public unregisterGlobalHotkey() {
|
||||
if (this.registeredGlobalHotkey_) {
|
||||
try {
|
||||
globalShortcut.unregister(this.registeredGlobalHotkey_);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('Bridge: Error removing global shortcut:', error);
|
||||
}
|
||||
this.registeredGlobalHotkey_ = '';
|
||||
}
|
||||
}
|
||||
|
||||
public async captureException(error: unknown) {
|
||||
Sentry.captureException(error);
|
||||
// We wait to give the "beforeSend" event handler time to process the crash dump and write
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
|
||||
import GlobalHotkeyInput from './GlobalHotkeyInput';
|
||||
|
||||
describe('GlobalHotkeyInput', () => {
|
||||
test('should render ShortcutRecorder with Save and Restore buttons', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<GlobalHotkeyInput value="CommandOrControl+Shift+J" themeId={1} onChange={onChange} />);
|
||||
|
||||
// ShortcutRecorder is always visible with its built-in buttons
|
||||
expect(screen.getByText('Save')).toBeTruthy();
|
||||
expect(screen.getByText('Restore')).toBeTruthy();
|
||||
expect(screen.getByText('Cancel')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should clear value when Restore is clicked', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<GlobalHotkeyInput value="CommandOrControl+Shift+J" themeId={1} onChange={onChange} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Restore'));
|
||||
expect(onChange).toHaveBeenCalledWith({ value: '' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import * as React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { ShortcutRecorder } from '../../KeymapConfig/ShortcutRecorder';
|
||||
|
||||
interface OnChangeEvent {
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
themeId: number;
|
||||
onChange: (event: OnChangeEvent)=> void;
|
||||
}
|
||||
|
||||
// A thin wrapper around ShortcutRecorder for the global hotkey setting.
|
||||
// Reuses ShortcutRecorder directly instead of maintaining a separate display mode.
|
||||
export default function GlobalHotkeyInput(props: Props) {
|
||||
const value = props.value || '';
|
||||
|
||||
const onSave = useCallback((event: { commandName: string; accelerator: string }) => {
|
||||
// Normalize platform-specific modifiers to CommandOrControl for
|
||||
// consistent cross-platform storage.
|
||||
const accelerator = event.accelerator
|
||||
.replace(/\bCmd\b/, 'CommandOrControl')
|
||||
.replace(/\bCtrl\b/, 'CommandOrControl');
|
||||
props.onChange({ value: accelerator });
|
||||
}, [props.onChange]);
|
||||
|
||||
const onReset = useCallback(() => {
|
||||
props.onChange({ value: '' });
|
||||
}, [props.onChange]);
|
||||
|
||||
// No-op: global hotkeys don't have a separate editing mode to cancel out of.
|
||||
const onCancel = useCallback(() => {}, []);
|
||||
|
||||
// No-op: ShortcutRecorder validates against the keymap (command
|
||||
// conflicts), which doesn't apply to global hotkeys.
|
||||
const onError = useCallback((_event: { recorderError: Error }) => {}, []);
|
||||
|
||||
return (
|
||||
<ShortcutRecorder
|
||||
onSave={onSave}
|
||||
onReset={onReset}
|
||||
onCancel={onCancel}
|
||||
onError={onError}
|
||||
initialAccelerator={value}
|
||||
commandName="globalHotkey"
|
||||
themeId={props.themeId}
|
||||
skipKeymapValidation
|
||||
autoFocus={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { themeStyle } from '@joplin/lib/theme';
|
||||
import * as React from 'react';
|
||||
import { useCallback, useId } from 'react';
|
||||
import control_PluginsStates from './plugins/PluginsStates';
|
||||
import control_GlobalHotkeyInput from './GlobalHotkeyInput';
|
||||
import bridge from '../../../services/bridge';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import Button, { ButtonLevel, ButtonSize } from '../../Button/Button';
|
||||
@@ -11,8 +12,10 @@ import * as pathUtils from '@joplin/lib/path-utils';
|
||||
import SettingLabel from './SettingLabel';
|
||||
import SettingDescription from './SettingDescription';
|
||||
|
||||
const settingKeyToControl: Record<string, typeof control_PluginsStates> = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Each control component has different prop types
|
||||
const settingKeyToControl: Record<string, React.FC<any>> = {
|
||||
'plugins.states': control_PluginsStates,
|
||||
'globalHotkey': control_GlobalHotkeyInput,
|
||||
};
|
||||
|
||||
export interface UpdateSettingValueEvent {
|
||||
|
||||
@@ -15,9 +15,14 @@ export interface ShortcutRecorderProps {
|
||||
initialAccelerator: string;
|
||||
commandName: string;
|
||||
themeId: number;
|
||||
// When true, skip keymap conflict validation (useful for global hotkeys
|
||||
// that aren't part of the internal command keymap).
|
||||
skipKeymapValidation?: boolean;
|
||||
// Controls whether the input auto-focuses on mount. Defaults to true.
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
export const ShortcutRecorder = ({ onSave, onReset, onCancel, onError, initialAccelerator, commandName, themeId }: ShortcutRecorderProps) => {
|
||||
export const ShortcutRecorder = ({ onSave, onReset, onCancel, onError, initialAccelerator, commandName, themeId, skipKeymapValidation, autoFocus = true }: ShortcutRecorderProps) => {
|
||||
const styles = styles_(themeId);
|
||||
|
||||
const [accelerator, setAccelerator] = useState(initialAccelerator);
|
||||
@@ -29,7 +34,9 @@ export const ShortcutRecorder = ({ onSave, onReset, onCancel, onError, initialAc
|
||||
// Otherwise performing a save means that it's going to be disabled
|
||||
if (accelerator) {
|
||||
keymapService.validateAccelerator(accelerator);
|
||||
keymapService.validateKeymap({ accelerator, command: commandName });
|
||||
if (!skipKeymapValidation) {
|
||||
keymapService.validateKeymap({ accelerator, command: commandName });
|
||||
}
|
||||
}
|
||||
|
||||
// Discard previous errors
|
||||
@@ -86,7 +93,7 @@ export const ShortcutRecorder = ({ onSave, onReset, onCancel, onError, initialAc
|
||||
|
||||
onKeyDown={handleKeyDown}
|
||||
readOnly
|
||||
autoFocus
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
|
||||
<button style={styles.inlineButton} disabled={!saveAllowed} onClick={() => onSave({ commandName, accelerator })}>
|
||||
|
||||
@@ -38,6 +38,9 @@ const useStyle = (themeId: number) => {
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
profileList: {
|
||||
flex: 1,
|
||||
},
|
||||
profileListItem: {
|
||||
paddingLeft: theme.margin,
|
||||
paddingRight: theme.margin,
|
||||
@@ -206,15 +209,15 @@ export default (props: Props) => {
|
||||
return (
|
||||
<View style={style.root}>
|
||||
<ScreenHeader title={_('Profiles')} showSaveButton={false} showSideMenuButton={false} showSearchButton={false} />
|
||||
<View>
|
||||
<FlatList
|
||||
data={profiles}
|
||||
renderItem={renderProfileItem}
|
||||
keyExtractor={profile => profile.id}
|
||||
// Needed so that the list rerenders when its dependencies change:
|
||||
extraData={extraListItemData}
|
||||
/>
|
||||
</View>
|
||||
<FlatList
|
||||
style={style.profileList}
|
||||
data={profiles}
|
||||
renderItem={renderProfileItem}
|
||||
keyExtractor={profile => profile.id}
|
||||
// Needed so that the list rerenders when its dependencies change:
|
||||
extraData={extraListItemData}
|
||||
contentContainerStyle={{ paddingBottom: 80 }}
|
||||
/>
|
||||
<FAB
|
||||
icon="plus"
|
||||
accessibilityLabel={_('New profile')}
|
||||
|
||||
@@ -46,12 +46,18 @@ export default class PluginRunner extends BasePluginRunner {
|
||||
return false;
|
||||
});
|
||||
|
||||
// On native mobile, pass a file path so the WebView can load the
|
||||
// script directly from the filesystem (avoids transferring the full
|
||||
// script text across the React Native bridge). On web, file:// URLs
|
||||
// are blocked by CSP so we pass the script text directly.
|
||||
const scriptFilePath = plugin.scriptText ? '' : `${plugin.baseDir}/index.js`;
|
||||
this.webviewRef.current.injectJS(`
|
||||
pluginBackgroundPage.runPlugin(
|
||||
${JSON.stringify(shim.injectedJs('pluginBackgroundPage'))},
|
||||
${JSON.stringify(plugin.scriptText)},
|
||||
${JSON.stringify(scriptFilePath)},
|
||||
${JSON.stringify(messageChannelId)},
|
||||
${JSON.stringify(plugin.id)},
|
||||
${JSON.stringify(plugin.scriptText)},
|
||||
);
|
||||
`);
|
||||
|
||||
|
||||
@@ -186,6 +186,7 @@ const PluginRunnerWebViewComponent: React.FC<Props> = props => {
|
||||
html={html}
|
||||
injectedJavaScript={injectedJs}
|
||||
hasPluginScripts={true}
|
||||
allowFileAccessFromJs={true}
|
||||
onMessage={pluginRunner.onWebviewMessage}
|
||||
onLoadEnd={onLoadEnd}
|
||||
onLoadStart={onLoadStart}
|
||||
|
||||
@@ -26,14 +26,29 @@ export const stopPlugin = async (pluginId: string) => {
|
||||
delete loadedPlugins[pluginId];
|
||||
};
|
||||
|
||||
export const runPlugin = (
|
||||
pluginBackgroundScript: string, pluginScript: string, messageChannelId: string, pluginId: string,
|
||||
export const runPlugin = async (
|
||||
pluginBackgroundScript: string, scriptFilePath: string, messageChannelId: string, pluginId: string, scriptText = '',
|
||||
) => {
|
||||
if (loadedPlugins[pluginId]) {
|
||||
console.warn(`Plugin already running ${pluginId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// When scriptText is provided (web), use it directly. Otherwise load
|
||||
// the plugin script from the filesystem (native mobile). We use
|
||||
// XMLHttpRequest because fetch() doesn't support file:// URLs on
|
||||
// Android WebView.
|
||||
let pluginScript = scriptText;
|
||||
if (!pluginScript) {
|
||||
pluginScript = await new Promise<string>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', `file://${scriptFilePath}`, true);
|
||||
xhr.onload = () => resolve(xhr.responseText);
|
||||
xhr.onerror = () => reject(new Error(`Failed to load plugin script: ${scriptFilePath}`));
|
||||
xhr.send();
|
||||
});
|
||||
}
|
||||
|
||||
const bodyHtml = '';
|
||||
const initialJavaScript = `
|
||||
"use strict";
|
||||
|
||||
@@ -404,6 +404,29 @@ describe('screens/Note', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should set title, body, and parent_id correctly when a note is created via share', async () => {
|
||||
const folder = await Folder.save({ title: 'Share target folder', parent_id: '' });
|
||||
const note = await Note.save({ parent_id: folder.id }, { provisional: true });
|
||||
|
||||
store.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Note',
|
||||
noteId: note.id,
|
||||
sharedData: { title: 'Shared title', text: 'https://example.com' },
|
||||
});
|
||||
store.dispatch({ type: 'NOTE_UPDATE_ONE', note: { ...note }, provisional: true });
|
||||
|
||||
const { unmount } = render(<WrappedNoteScreen />);
|
||||
|
||||
await waitForNoteToMatch(note.id, {
|
||||
title: 'Shared title',
|
||||
body: 'https://example.com',
|
||||
parent_id: folder.id,
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should always start in edit mode for provisional notes regardless of noteVisiblePanes', async () => {
|
||||
store.dispatch({
|
||||
type: 'NOTE_VISIBLE_PANES_SET',
|
||||
|
||||
@@ -586,14 +586,18 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
|
||||
if (sharedData) {
|
||||
reg.logger().info('Received shared data');
|
||||
|
||||
// selectedFolderId can be null if no screens other than "All notes"
|
||||
// have been opened.
|
||||
const targetFolder = this.props.selectedFolderId ?? (await Folder.defaultFolder())?.id;
|
||||
if (targetFolder) {
|
||||
const activeFolder = await Folder.getValidActiveFolder();
|
||||
if (activeFolder) {
|
||||
logger.info('Sharing: handleShareData: Processing...');
|
||||
await handleShared(sharedData, targetFolder, this.props.dispatch);
|
||||
await handleShared(sharedData, activeFolder.id, this.props.dispatch);
|
||||
} else {
|
||||
reg.logger().info('Cannot handle share - default folder id is not set');
|
||||
reg.logger().warn('Cannot handle share - no valid active folder found');
|
||||
void this.dropdownAlert_({
|
||||
type: 'error',
|
||||
title: _('Cannot share'),
|
||||
message: _('No valid notebook is available. Please create or select a notebook and try again.'),
|
||||
});
|
||||
ShareExtension.close();
|
||||
}
|
||||
} else {
|
||||
logger.info('Sharing: received empty share data.');
|
||||
|
||||
@@ -74,6 +74,7 @@ const buildSharedConfig = (hotReload: boolean): webpack.Configuration => {
|
||||
'react-native-quick-actions': emptyLibraryMock,
|
||||
'uglifycss': emptyLibraryMock,
|
||||
'react-native-share': emptyLibraryMock,
|
||||
'react-native-camera': emptyLibraryMock,
|
||||
'react-native-zip-archive': emptyLibraryMock,
|
||||
'@react-native-documents/picker': emptyLibraryMock,
|
||||
'react-native-exit-app': emptyLibraryMock,
|
||||
|
||||
@@ -304,7 +304,10 @@ shared.reloadNote = async (comp: BaseNoteScreenComponent) => {
|
||||
|
||||
const fromShare = !!comp.props.sharedData;
|
||||
if (note) {
|
||||
const folder = Folder.byId(comp.props.folders, note.parent_id);
|
||||
let folder = Folder.byId(comp.props.folders, note.parent_id);
|
||||
if (!folder && note.parent_id) {
|
||||
folder = await Folder.load(note.parent_id);
|
||||
}
|
||||
comp.setState({
|
||||
lastSavedNote: { ...note },
|
||||
note: note,
|
||||
@@ -337,12 +340,24 @@ shared.reloadNote = async (comp: BaseNoteScreenComponent) => {
|
||||
shared.initState = async function(comp: BaseNoteScreenComponent) {
|
||||
const note = await shared.reloadNote(comp);
|
||||
|
||||
if (comp.props.sharedData) {
|
||||
if (comp.props.sharedData && note) {
|
||||
// Use the note returned by reloadNote directly to avoid a race condition where
|
||||
// comp.state.note is still the initial empty note (Note.new() with parent_id='')
|
||||
// because React hasn't flushed reloadNote's setState yet. Without this, the
|
||||
// scheduled save would overwrite parent_id with an empty string in the DB.
|
||||
const updatedNote = { ...note };
|
||||
const fieldsToSave: NoteEntity = { id: note.id };
|
||||
if (comp.props.sharedData.title) {
|
||||
this.noteComponent_change(comp, 'title', comp.props.sharedData.title);
|
||||
updatedNote.title = comp.props.sharedData.title;
|
||||
fieldsToSave.title = comp.props.sharedData.title;
|
||||
}
|
||||
if (comp.props.sharedData.text) {
|
||||
this.noteComponent_change(comp, 'body', comp.props.sharedData.text);
|
||||
updatedNote.body = comp.props.sharedData.text;
|
||||
fieldsToSave.body = comp.props.sharedData.text;
|
||||
}
|
||||
if (fieldsToSave.title !== undefined || fieldsToSave.body !== undefined) {
|
||||
await Note.save(fieldsToSave);
|
||||
comp.setState({ note: updatedNote, lastSavedNote: updatedNote });
|
||||
}
|
||||
if (comp.props.sharedData.resources) {
|
||||
for (let i = 0; i < comp.props.sharedData.resources.length; i++) {
|
||||
|
||||
@@ -1177,6 +1177,25 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
|
||||
startMinimized: { value: false, type: SettingItemType.Bool, storage: SettingStorage.File, isGlobal: true, section: 'application', public: true, appTypes: [AppType.Desktop], label: () => _('Start application minimised in the tray icon'), show: settings => !!settings['showTrayIcon'] },
|
||||
|
||||
'globalHotkey': {
|
||||
value: '',
|
||||
type: SettingItemType.String,
|
||||
section: 'application',
|
||||
public: true,
|
||||
appTypes: [AppType.Desktop],
|
||||
label: () => _('Global shortcut to show/hide Joplin'),
|
||||
description: () => _('A system-wide keyboard shortcut that toggles the Joplin window. Works even when Joplin is not focused. Example: CommandOrControl+Shift+J. Leave empty to disable.'),
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
autoSave: true,
|
||||
// Electron's globalShortcut API does not yet work under Wayland,
|
||||
// so we hide this option when running on a Wayland session.
|
||||
show: () => {
|
||||
if (platform !== 'linux') return true;
|
||||
return process.env.XDG_SESSION_TYPE !== 'wayland' && !process.env.WAYLAND_DISPLAY;
|
||||
},
|
||||
},
|
||||
|
||||
collapsedFolderIds: { value: [] as string[], type: SettingItemType.Array, public: false },
|
||||
|
||||
'keychain.supported': { value: -1, type: SettingItemType.Int, public: false },
|
||||
|
||||
@@ -162,7 +162,17 @@ export default class ExternalEditWatcher {
|
||||
return;
|
||||
}
|
||||
|
||||
let noteContent = await shim.fsDriver().readFile(path, 'utf-8');
|
||||
let noteContent: string;
|
||||
try {
|
||||
noteContent = await shim.fsDriver().readFile(path, 'utf-8');
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
this.logger().warn(`ExternalEditWatcher: Watched file no longer exists: ${path}`);
|
||||
void this.stopWatching(id);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// In some very rare cases, the "change" event is going to be emitted but the file will be empty.
|
||||
// This is likely to be the editor that first clears the file, then writes the content to it, so if
|
||||
|
||||
@@ -350,8 +350,16 @@ export default class PluginService extends BaseService {
|
||||
|
||||
logger.info(`Loading plugin from ${path}`);
|
||||
|
||||
const scriptText = await fsDriver.readFile(`${distPath}/index.js`);
|
||||
const manifestText = await fsDriver.readFile(`${distPath}/manifest.json`);
|
||||
// On mobile, plugin scripts are loaded directly by the WebView
|
||||
// from the filesystem, so we don't need to read them here.
|
||||
const indexPath = `${distPath}/index.js`;
|
||||
if (shim.mobilePlatform()) {
|
||||
if (!(await fsDriver.exists(indexPath))) {
|
||||
throw new Error(`Plugin bundle not found at: ${indexPath}`);
|
||||
}
|
||||
}
|
||||
const scriptText = shim.mobilePlatform() ? '' : await fsDriver.readFile(indexPath);
|
||||
const pluginId = makePluginId(filename(path));
|
||||
|
||||
return this.loadPlugin(distPath, manifestText, scriptText, pluginId);
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { getRootDir } from '@joplin/utils';
|
||||
import { readFile } from 'fs-extra';
|
||||
import { join } from 'path';
|
||||
|
||||
// Checks that all patch resolutions in package.json are actually applied in
|
||||
// yarn.lock. Catches the case where a dependency version is upgraded but the
|
||||
// resolution still targets the old version, causing the patch to silently not
|
||||
// apply.
|
||||
|
||||
const main = async () => {
|
||||
const rootDir = await getRootDir();
|
||||
const packageJson = JSON.parse(await readFile(join(rootDir, 'package.json'), 'utf8'));
|
||||
const yarnLock = await readFile(join(rootDir, 'yarn.lock'), 'utf8');
|
||||
|
||||
const resolutions: Record<string, string> = packageJson.resolutions ?? {};
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(resolutions)) {
|
||||
if (!value.startsWith('patch:')) continue;
|
||||
|
||||
// Extract the patch target, e.g. "patch:nanoid@npm%3A3.3.11#..." -> "nanoid@npm:3.3.11"
|
||||
const patchTarget = value
|
||||
.replace(/^patch:/, '')
|
||||
.replace(/#.*$/, '')
|
||||
.replace(/%3A/g, ':');
|
||||
|
||||
// Extract package name and version from the patch target.
|
||||
// Supports both "pkg@npm:version" and "pkg@version" formats.
|
||||
const match = patchTarget.match(/^(.+)@(?:npm:)?(.+)$/);
|
||||
if (!match) {
|
||||
errors.push(
|
||||
`Invalid patch format for "${key}": "${patchTarget}" does not match ` +
|
||||
'expected pattern "packageName@npm:version" or "packageName@version".',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const [, packageName, patchVersion] = match;
|
||||
const hasNpmPrefix = patchTarget.includes('@npm:');
|
||||
|
||||
// Check that yarn.lock contains a resolved entry for this exact
|
||||
// patch. The lockfile entry looks like:
|
||||
// "pkg@patch:pkg@npm%3Aversion#path::..." (with npm prefix)
|
||||
// "pkg@patch:pkg@version#path::..." (without npm prefix)
|
||||
const versionPart = hasNpmPrefix ? `@npm%3A${patchVersion}` : `@${patchVersion}`;
|
||||
const patchPattern = `"${packageName}@patch:${packageName}${versionPart}#`;
|
||||
if (!yarnLock.includes(patchPattern)) {
|
||||
errors.push(
|
||||
`Resolution "${key}" patches ${packageName}@${patchVersion}, ` +
|
||||
'but yarn.lock has no matching entry. The dependency was likely ' +
|
||||
'upgraded — update the patch to target the current version.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Yarn patch validation failed:\n\n${errors.join('\n\n')}`);
|
||||
}
|
||||
|
||||
console.log(`All ${Object.values(resolutions).filter(v => v.startsWith('patch:')).length} patch resolutions are applied.`);
|
||||
};
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user