1
0
mirror of https://github.com/laurent22/joplin.git synced 2026-04-18 19:42:23 +02:00

Compare commits

..

2 Commits

Author SHA1 Message Date
Laurent Cozic
0b7e43cb5d update 2026-04-14 11:11:30 +01:00
Laurent Cozic
7b5111d916 update 2026-04-13 19:07:34 +01:00
20 changed files with 46 additions and 280 deletions

View File

@@ -201,8 +201,6 @@ 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

2
.gitignore vendored
View File

@@ -174,8 +174,6 @@ 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

View File

@@ -939,7 +939,6 @@ 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', () => {

View File

@@ -150,10 +150,6 @@ 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');
}

View File

@@ -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, globalShortcut } from 'electron';
import { BrowserWindow, nativeTheme, nativeImage, shell, dialog, MessageBoxSyncOptions, safeStorage, Menu, MenuItemConstructorOptions, MenuItem, BrowserWindowConstructorOptions, FileFilter, SaveDialogOptions } from 'electron';
import { dirname, toSystemSlashes } from '@joplin/lib/path-utils';
import { fileUriToPath } from '@joplin/utils/url';
import { urlDecode } from '@joplin/lib/string-utils';
@@ -46,7 +46,6 @@ 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;
@@ -208,54 +207,6 @@ 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

View File

@@ -1,24 +0,0 @@
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: '' });
});
});

View File

@@ -1,53 +0,0 @@
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}
/>
);
}

View File

@@ -3,7 +3,6 @@ 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';
@@ -12,10 +11,8 @@ import * as pathUtils from '@joplin/lib/path-utils';
import SettingLabel from './SettingLabel';
import SettingDescription from './SettingDescription';
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Each control component has different prop types
const settingKeyToControl: Record<string, React.FC<any>> = {
const settingKeyToControl: Record<string, typeof control_PluginsStates> = {
'plugins.states': control_PluginsStates,
'globalHotkey': control_GlobalHotkeyInput,
};
export interface UpdateSettingValueEvent {

View File

@@ -15,14 +15,9 @@ 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, skipKeymapValidation, autoFocus = true }: ShortcutRecorderProps) => {
export const ShortcutRecorder = ({ onSave, onReset, onCancel, onError, initialAccelerator, commandName, themeId }: ShortcutRecorderProps) => {
const styles = styles_(themeId);
const [accelerator, setAccelerator] = useState(initialAccelerator);
@@ -34,9 +29,7 @@ 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);
if (!skipKeymapValidation) {
keymapService.validateKeymap({ accelerator, command: commandName });
}
keymapService.validateKeymap({ accelerator, command: commandName });
}
// Discard previous errors
@@ -93,7 +86,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 })}>

View File

@@ -38,9 +38,6 @@ const useStyle = (themeId: number) => {
right: 0,
bottom: 0,
},
profileList: {
flex: 1,
},
profileListItem: {
paddingLeft: theme.margin,
paddingRight: theme.margin,
@@ -209,15 +206,15 @@ export default (props: Props) => {
return (
<View style={style.root}>
<ScreenHeader title={_('Profiles')} showSaveButton={false} showSideMenuButton={false} showSearchButton={false} />
<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 }}
/>
<View>
<FlatList
data={profiles}
renderItem={renderProfileItem}
keyExtractor={profile => profile.id}
// Needed so that the list rerenders when its dependencies change:
extraData={extraListItemData}
/>
</View>
<FAB
icon="plus"
accessibilityLabel={_('New profile')}

View File

@@ -46,18 +46,12 @@ 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(scriptFilePath)},
${JSON.stringify(plugin.scriptText)},
${JSON.stringify(messageChannelId)},
${JSON.stringify(plugin.id)},
${JSON.stringify(plugin.scriptText)},
);
`);

View File

@@ -186,7 +186,6 @@ const PluginRunnerWebViewComponent: React.FC<Props> = props => {
html={html}
injectedJavaScript={injectedJs}
hasPluginScripts={true}
allowFileAccessFromJs={true}
onMessage={pluginRunner.onWebviewMessage}
onLoadEnd={onLoadEnd}
onLoadStart={onLoadStart}

View File

@@ -26,29 +26,14 @@ export const stopPlugin = async (pluginId: string) => {
delete loadedPlugins[pluginId];
};
export const runPlugin = async (
pluginBackgroundScript: string, scriptFilePath: string, messageChannelId: string, pluginId: string, scriptText = '',
export const runPlugin = (
pluginBackgroundScript: string, pluginScript: string, messageChannelId: string, pluginId: string,
) => {
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";

View File

@@ -404,29 +404,6 @@ 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',

View File

@@ -586,18 +586,14 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
if (sharedData) {
reg.logger().info('Received shared data');
const activeFolder = await Folder.getValidActiveFolder();
if (activeFolder) {
// 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) {
logger.info('Sharing: handleShareData: Processing...');
await handleShared(sharedData, activeFolder.id, this.props.dispatch);
await handleShared(sharedData, targetFolder, this.props.dispatch);
} else {
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();
reg.logger().info('Cannot handle share - default folder id is not set');
}
} else {
logger.info('Sharing: received empty share data.');

View File

@@ -5,6 +5,9 @@ import { WebViewControl } from '../../components/ExtendedWebView/types';
import { RefObject } from 'react';
import { OnMessageEvent } from '../../components/ExtendedWebView/types';
import { Platform } from 'react-native';
import Logger from '@joplin/utils/Logger';
const logger = Logger.create('RNToWebViewMessenger');
const canUseOptimizedPostMessage = Platform.OS === 'web';
@@ -41,10 +44,22 @@ export default class RNToWebViewMessenger<LocalInterface, RemoteInterface> exten
public onWebViewMessage = (event: OnMessageEvent) => {
if (!this.hasBeenClosed()) {
let data;
if (canUseOptimizedPostMessage) {
void this.onMessage(event.nativeEvent.data);
data = event.nativeEvent.data;
} else {
void this.onMessage(JSON.parse(event.nativeEvent.data));
try {
data = JSON.parse(event.nativeEvent.data);
} catch {
logger.warn('Failed to parse message:', event.nativeEvent.data);
return;
}
}
if (typeof data === 'object' && data !== null && typeof data.kind === 'string') {
void this.onMessage(data);
} else {
logger.info('Unknown message format:', event.nativeEvent.data);
}
}
};

View File

@@ -304,10 +304,7 @@ shared.reloadNote = async (comp: BaseNoteScreenComponent) => {
const fromShare = !!comp.props.sharedData;
if (note) {
let folder = Folder.byId(comp.props.folders, note.parent_id);
if (!folder && note.parent_id) {
folder = await Folder.load(note.parent_id);
}
const folder = Folder.byId(comp.props.folders, note.parent_id);
comp.setState({
lastSavedNote: { ...note },
note: note,
@@ -340,24 +337,12 @@ shared.reloadNote = async (comp: BaseNoteScreenComponent) => {
shared.initState = async function(comp: BaseNoteScreenComponent) {
const note = await shared.reloadNote(comp);
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) {
if (comp.props.sharedData.title) {
updatedNote.title = comp.props.sharedData.title;
fieldsToSave.title = comp.props.sharedData.title;
this.noteComponent_change(comp, 'title', comp.props.sharedData.title);
}
if (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 });
this.noteComponent_change(comp, 'body', comp.props.sharedData.text);
}
if (comp.props.sharedData.resources) {
for (let i = 0; i < comp.props.sharedData.resources.length; i++) {

View File

@@ -1177,25 +1177,6 @@ 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 },

View File

@@ -162,17 +162,7 @@ export default class ExternalEditWatcher {
return;
}
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;
}
let noteContent = await shim.fsDriver().readFile(path, 'utf-8');
// 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

View File

@@ -350,16 +350,8 @@ 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);