You've already forked joplin
							
							
				mirror of
				https://github.com/laurent22/joplin.git
				synced 2025-10-31 00:07:48 +02:00 
			
		
		
		
	Merge branch 'release-3.1' into dev
This commit is contained in:
		| @@ -352,4 +352,12 @@ describe('MdToHtml', () => { | ||||
| 		expect(html).toContain('Inline</span>'); | ||||
| 		expect(html).toContain('Block</span>'); | ||||
| 	}); | ||||
|  | ||||
| 	it('should sanitize KaTeX errors', async () => { | ||||
| 		const markdown = '$\\a<svg>$'; | ||||
| 		const renderResult = await newTestMdToHtml().render(markdown, null, { bodyOnly: true }); | ||||
|  | ||||
| 		// Should not contain the HTML in unsanitized form | ||||
| 		expect(renderResult.html).not.toContain('<svg>'); | ||||
| 	}); | ||||
| }); | ||||
|   | ||||
| @@ -5,7 +5,7 @@ import type ShimType from '@joplin/lib/shim'; | ||||
| const shim: typeof ShimType = require('@joplin/lib/shim').default; | ||||
| import { isCallbackUrl } from '@joplin/lib/callbackUrlUtils'; | ||||
|  | ||||
| import { BrowserWindow, Tray, screen } from 'electron'; | ||||
| import { BrowserWindow, Tray, WebContents, screen } from 'electron'; | ||||
| import bridge from './bridge'; | ||||
| const url = require('url'); | ||||
| const path = require('path'); | ||||
| @@ -232,14 +232,35 @@ export default class ElectronAppWrapper { | ||||
| 			}, 3000); | ||||
| 		} | ||||
|  | ||||
| 		// will-frame-navigate is fired by clicking on a link within the BrowserWindow. | ||||
| 		this.win_.webContents.on('will-frame-navigate', event => { | ||||
| 			// If the link changes the URL of the browser window, | ||||
| 			if (event.isMainFrame) { | ||||
| 				event.preventDefault(); | ||||
| 				void bridge().openExternal(event.url); | ||||
| 			} | ||||
| 		}); | ||||
| 		const addWindowEventHandlers = (webContents: WebContents) => { | ||||
| 			// will-frame-navigate is fired by clicking on a link within the BrowserWindow. | ||||
| 			webContents.on('will-frame-navigate', event => { | ||||
| 				// If the link changes the URL of the browser window, | ||||
| 				if (event.isMainFrame) { | ||||
| 					event.preventDefault(); | ||||
| 					void bridge().openExternal(event.url); | ||||
| 				} | ||||
| 			}); | ||||
|  | ||||
| 			// Override calls to window.open and links with target="_blank": Open most in a browser instead | ||||
| 			// of Electron: | ||||
| 			webContents.setWindowOpenHandler((event) => { | ||||
| 				if (event.url === 'about:blank') { | ||||
| 					// Script-controlled pages: Used for opening notes in new windows | ||||
| 					return { | ||||
| 						action: 'allow', | ||||
| 					}; | ||||
| 				} else if (event.url.match(/^https?:\/\//)) { | ||||
| 					void bridge().openExternal(event.url); | ||||
| 				} | ||||
| 				return { action: 'deny' }; | ||||
| 			}); | ||||
|  | ||||
| 			webContents.on('did-create-window', (event) => { | ||||
| 				addWindowEventHandlers(event.webContents); | ||||
| 			}); | ||||
| 		}; | ||||
| 		addWindowEventHandlers(this.win_.webContents); | ||||
|  | ||||
| 		// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||
| 		this.win_.on('close', (event: any) => { | ||||
|   | ||||
| @@ -137,6 +137,12 @@ class Application extends BaseApplication { | ||||
| 			this.updateLanguage(); | ||||
| 		} | ||||
|  | ||||
| 		if (action.type === 'SETTING_UPDATE_ONE' && action.key === 'renderer.fileUrls' || action.type === 'SETTING_UPDATE_ALL') { | ||||
| 			bridge().electronApp().getCustomProtocolHandler().setMediaAccessEnabled( | ||||
| 				Setting.value('renderer.fileUrls'), | ||||
| 			); | ||||
| 		} | ||||
|  | ||||
| 		if (action.type === 'SETTING_UPDATE_ONE' && action.key === 'showTrayIcon' || action.type === 'SETTING_UPDATE_ALL') { | ||||
| 			this.updateTray(); | ||||
| 		} | ||||
|   | ||||
| @@ -234,7 +234,7 @@ export default function(props: Props) { | ||||
| 			return ( | ||||
| 				<CellFooter> | ||||
| 					<NeedUpgradeMessage> | ||||
| 						{PluginService.instance().describeIncompatibility(props.manifest)} | ||||
| 						{PluginService.instance().describeIncompatibility(item.manifest)} | ||||
| 					</NeedUpgradeMessage> | ||||
| 				</CellFooter> | ||||
| 			); | ||||
|   | ||||
| @@ -378,6 +378,8 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor | ||||
| 			katexEnabled: Setting.value('markdown.plugin.katex'), | ||||
| 			themeData: { | ||||
| 				...styles.globalTheme, | ||||
| 				marginLeft: 0, | ||||
| 				marginRight: 0, | ||||
| 				monospaceFont: Setting.value('style.editor.monospaceFontFamily'), | ||||
| 			}, | ||||
| 			automatchBraces: Setting.value('editor.autoMatchingBraces'), | ||||
|   | ||||
| @@ -1,10 +1,10 @@ | ||||
|  | ||||
| import { RefObject, useMemo } from 'react'; | ||||
| import { CommandValue } from '../../../utils/types'; | ||||
| import { CommandValue, DropCommandValue } from '../../../utils/types'; | ||||
| import { commandAttachFileToBody } from '../../../utils/resourceHandling'; | ||||
| import { _ } from '@joplin/lib/locale'; | ||||
| import dialogs from '../../../../dialogs'; | ||||
| import { EditorCommandType } from '@joplin/editor/types'; | ||||
| import { EditorCommandType, UserEventSource } from '@joplin/editor/types'; | ||||
| import Logger from '@joplin/utils/Logger'; | ||||
| import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl'; | ||||
| import { MarkupLanguage } from '@joplin/renderer'; | ||||
| @@ -38,13 +38,22 @@ const useEditorCommands = (props: Props) => { | ||||
| 		}; | ||||
|  | ||||
| 		return { | ||||
| 			// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||
| 			dropItems: async (cmd: any) => { | ||||
| 			dropItems: async (cmd: DropCommandValue) => { | ||||
| 				let pos = cmd.pos && editorRef.current.editor.posAtCoords({ x: cmd.pos.clientX, y: cmd.pos.clientY }); | ||||
| 				if (cmd.type === 'notes') { | ||||
| 					editorRef.current.insertText(cmd.markdownTags.join('\n')); | ||||
| 					const text = cmd.markdownTags.join('\n'); | ||||
| 					if ((pos ?? null) !== null) { | ||||
| 						editorRef.current.select(pos, pos); | ||||
| 					} | ||||
|  | ||||
| 					editorRef.current.insertText(text, UserEventSource.Drop); | ||||
| 				} else if (cmd.type === 'files') { | ||||
| 					const pos = props.selectionRange.from; | ||||
| 					const newBody = await commandAttachFileToBody(props.editorContent, cmd.paths, { createFileURL: !!cmd.createFileURL, position: pos, markupLanguage: props.contentMarkupLanguage }); | ||||
| 					pos ??= props.selectionRange.from; | ||||
| 					const newBody = await commandAttachFileToBody(props.editorContent, cmd.paths, { | ||||
| 						createFileURL: !!cmd.createFileURL, | ||||
| 						position: pos, | ||||
| 						markupLanguage: props.contentMarkupLanguage, | ||||
| 					}); | ||||
| 					editorRef.current.updateBody(newBody); | ||||
| 				} else { | ||||
| 					logger.warn('CodeMirror: unsupported drop item: ', cmd); | ||||
|   | ||||
| @@ -252,3 +252,19 @@ export interface CommandValue { | ||||
| 	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||
| 	value?: any; // For TinyMCE only | ||||
| } | ||||
|  | ||||
| type DropCommandBase = { | ||||
| 	pos: { | ||||
| 		clientX: number; | ||||
| 		clientY: number; | ||||
| 	}|undefined; | ||||
| }; | ||||
|  | ||||
| export type DropCommandValue = ({ | ||||
| 	type: 'notes'; | ||||
| 	markdownTags: string[]; | ||||
| }|{ | ||||
| 	type: 'files'; | ||||
| 	paths: string[]; | ||||
| 	createFileURL: boolean; | ||||
| }) & DropCommandBase; | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| import { useCallback } from 'react'; | ||||
| import Note from '@joplin/lib/models/Note'; | ||||
| import { DragEvent as ReactDragEvent } from 'react'; | ||||
| import { DropCommandValue } from './types'; | ||||
|  | ||||
| interface HookDependencies { | ||||
| 	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||
| @@ -19,6 +20,11 @@ export default function useDropHandler(dependencies: HookDependencies): DropHand | ||||
| 		const dt = event.dataTransfer; | ||||
| 		const createFileURL = event.altKey; | ||||
|  | ||||
| 		const eventPosition = { | ||||
| 			clientX: event.clientX, | ||||
| 			clientY: event.clientY, | ||||
| 		}; | ||||
|  | ||||
| 		if (dt.types.indexOf('text/x-jop-note-ids') >= 0) { | ||||
| 			const noteIds = JSON.parse(dt.getData('text/x-jop-note-ids')); | ||||
|  | ||||
| @@ -29,12 +35,15 @@ export default function useDropHandler(dependencies: HookDependencies): DropHand | ||||
| 					noteMarkdownTags.push(Note.markdownTag(note)); | ||||
| 				} | ||||
|  | ||||
| 				const props: DropCommandValue = { | ||||
| 					type: 'notes', | ||||
| 					pos: eventPosition, | ||||
| 					markdownTags: noteMarkdownTags, | ||||
| 				}; | ||||
|  | ||||
| 				editorRef.current.execCommand({ | ||||
| 					name: 'dropItems', | ||||
| 					value: { | ||||
| 						type: 'notes', | ||||
| 						markdownTags: noteMarkdownTags, | ||||
| 					}, | ||||
| 					value: props, | ||||
| 				}); | ||||
| 			}; | ||||
| 			void dropNotes(); | ||||
| @@ -51,13 +60,16 @@ export default function useDropHandler(dependencies: HookDependencies): DropHand | ||||
| 				paths.push(file.path); | ||||
| 			} | ||||
|  | ||||
| 			const props: DropCommandValue = { | ||||
| 				type: 'files', | ||||
| 				pos: eventPosition, | ||||
| 				paths: paths, | ||||
| 				createFileURL: createFileURL, | ||||
| 			}; | ||||
|  | ||||
| 			editorRef.current.execCommand({ | ||||
| 				name: 'dropItems', | ||||
| 				value: { | ||||
| 					type: 'files', | ||||
| 					paths: paths, | ||||
| 					createFileURL: createFileURL, | ||||
| 				}, | ||||
| 				value: props, | ||||
| 			}); | ||||
| 			return true; | ||||
| 		} | ||||
|   | ||||
| @@ -29,6 +29,7 @@ export default class NoteTextViewerComponent extends React.Component<Props, any> | ||||
| 	private webviewRef_: React.RefObject<HTMLIFrameElement>; | ||||
| 	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||
| 	private webviewListeners_: any = null; | ||||
|  | ||||
| 	private removePluginAssetsCallback_: RemovePluginAssetsCallback|null = null; | ||||
|  | ||||
| 	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||
| @@ -110,7 +111,7 @@ export default class NoteTextViewerComponent extends React.Component<Props, any> | ||||
| 		window.addEventListener('message', this.webview_message); | ||||
| 	} | ||||
|  | ||||
| 	public destroyWebview() { | ||||
| 	private destroyWebview() { | ||||
| 		const wv = this.webviewRef_.current; | ||||
| 		if (!wv || !this.initialized_) return; | ||||
|  | ||||
| @@ -194,14 +195,13 @@ export default class NoteTextViewerComponent extends React.Component<Props, any> | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||
| 	public setHtml(html: string, options: SetHtmlOptions) { | ||||
| 		const protocolHandler = bridge().electronApp().getCustomProtocolHandler(); | ||||
|  | ||||
| 		// Grant & remove asset access. | ||||
| 		if (options.pluginAssets) { | ||||
| 			this.removePluginAssetsCallback_?.(); | ||||
|  | ||||
| 			const protocolHandler = bridge().electronApp().getCustomProtocolHandler(); | ||||
|  | ||||
| 			const pluginAssetPaths: string[] = options.pluginAssets.map((asset) => asset.path); | ||||
| 			const assetAccesses = pluginAssetPaths.map( | ||||
| 				path => protocolHandler.allowReadAccessToFile(path), | ||||
| @@ -216,7 +216,10 @@ export default class NoteTextViewerComponent extends React.Component<Props, any> | ||||
| 			}; | ||||
| 		} | ||||
|  | ||||
| 		this.send('setHtml', html, options); | ||||
| 		this.send('setHtml', html, { | ||||
| 			...options, | ||||
| 			mediaAccessKey: protocolHandler.getMediaAccessKey(), | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	// ---------------------------------------------------------------- | ||||
|   | ||||
| @@ -377,6 +377,20 @@ | ||||
| 			contentElement.scrollTop = scrollTop; | ||||
| 		} | ||||
|  | ||||
| 		const rewriteFileUrls = (accessKey) => { | ||||
| 			if (!accessKey) return; | ||||
|  | ||||
| 			// To allow accessing local files from the viewer's non-file URL, file:// URLs are re-written | ||||
| 			// to joplin-content:// URLs: | ||||
| 			const mediaElements = document.querySelectorAll('video[src], audio[src], source[src], img[src]'); | ||||
| 			for (const element of mediaElements) { | ||||
| 				if (element.src?.startsWith('file:')) { | ||||
| 					const newUrl = element.src.replace(/^file:\/\//, 'joplin-content://file-media/'); | ||||
| 					element.src = `${newUrl}?access-key=${accessKey}`; | ||||
| 				} | ||||
| 			} | ||||
| 		}; | ||||
|  | ||||
| 		ipc.setHtml = (event) => { | ||||
| 			const html = event.html; | ||||
|  | ||||
| @@ -388,6 +402,10 @@ | ||||
|  | ||||
| 			contentElement.innerHTML = html; | ||||
|  | ||||
| 			if (html.includes('file://')) { | ||||
| 				rewriteFileUrls(event.options.mediaAccessKey); | ||||
| 			} | ||||
|  | ||||
| 			scrollmap.create(event.options.markupLineCount); | ||||
| 			if (typeof event.options.percent !== 'number') { | ||||
| 				restorePercentScroll(); // First, a quick treatment is applied. | ||||
|   | ||||
| @@ -136,50 +136,55 @@ test.describe('main', () => { | ||||
| 		expect(fullSize[0] / resizedSize[0]).toBeCloseTo(fullSize[1] / resizedSize[1]); | ||||
| 	}); | ||||
|  | ||||
| 	test('clicking on an external link should try to launch a browser', async ({ electronApp, mainWindow }) => { | ||||
| 		const mainScreen = new MainScreen(mainWindow); | ||||
| 		await mainScreen.waitFor(); | ||||
| 	for (const target of ['', '_blank']) { | ||||
| 		test(`clicking on an external link with target=${JSON.stringify(target)} should try to launch a browser`, async ({ electronApp, mainWindow }) => { | ||||
| 			const mainScreen = new MainScreen(mainWindow); | ||||
| 			await mainScreen.waitFor(); | ||||
|  | ||||
| 		// Mock openExternal | ||||
| 		const nextExternalUrlPromise = electronApp.evaluate(({ shell }) => { | ||||
| 			return new Promise<string>(resolve => { | ||||
| 				const openExternal = async (url: string) => { | ||||
| 					resolve(url); | ||||
| 				}; | ||||
| 				shell.openExternal = openExternal; | ||||
| 			// Mock openExternal | ||||
| 			const nextExternalUrlPromise = electronApp.evaluate(({ shell }) => { | ||||
| 				return new Promise<string>(resolve => { | ||||
| 					const openExternal = async (url: string) => { | ||||
| 						resolve(url); | ||||
| 					}; | ||||
| 					shell.openExternal = openExternal; | ||||
| 				}); | ||||
| 			}); | ||||
|  | ||||
| 			// Create a test link | ||||
| 			const testLinkTitle = 'This is a test link!'; | ||||
| 			const linkHref = 'https://joplinapp.org/'; | ||||
|  | ||||
| 			await mainWindow.evaluate(({ testLinkTitle, linkHref, target }) => { | ||||
| 				const testLink = document.createElement('a'); | ||||
| 				testLink.textContent = testLinkTitle; | ||||
| 				testLink.onclick = () => { | ||||
| 					// We need to navigate by setting location.href -- clicking on a link | ||||
| 					// directly within the main window (i.e. not in a PDF viewer) doesn't | ||||
| 					// navigate. | ||||
| 					location.href = linkHref; | ||||
| 				}; | ||||
| 				testLink.href = '#'; | ||||
|  | ||||
| 				// Display on top of everything | ||||
| 				testLink.style.zIndex = '99999'; | ||||
| 				testLink.style.position = 'fixed'; | ||||
| 				testLink.style.top = '0'; | ||||
| 				testLink.style.left = '0'; | ||||
| 				if (target) { | ||||
| 					testLink.target = target; | ||||
| 				} | ||||
|  | ||||
| 				document.body.appendChild(testLink); | ||||
| 			}, { testLinkTitle, linkHref, target }); | ||||
|  | ||||
| 			const testLink = mainWindow.getByText(testLinkTitle); | ||||
| 			await expect(testLink).toBeVisible(); | ||||
| 			await testLink.click({ noWaitAfter: true }); | ||||
|  | ||||
| 			expect(await nextExternalUrlPromise).toBe(linkHref); | ||||
| 		}); | ||||
|  | ||||
| 		// Create a test link | ||||
| 		const testLinkTitle = 'This is a test link!'; | ||||
| 		const linkHref = 'https://joplinapp.org/'; | ||||
|  | ||||
| 		await mainWindow.evaluate(({ testLinkTitle, linkHref }) => { | ||||
| 			const testLink = document.createElement('a'); | ||||
| 			testLink.textContent = testLinkTitle; | ||||
| 			testLink.onclick = () => { | ||||
| 				// We need to navigate by setting location.href -- clicking on a link | ||||
| 				// directly within the main window (i.e. not in a PDF viewer) doesn't | ||||
| 				// navigate. | ||||
| 				location.href = linkHref; | ||||
| 			}; | ||||
| 			testLink.href = '#'; | ||||
|  | ||||
| 			// Display on top of everything | ||||
| 			testLink.style.zIndex = '99999'; | ||||
| 			testLink.style.position = 'fixed'; | ||||
| 			testLink.style.top = '0'; | ||||
| 			testLink.style.left = '0'; | ||||
|  | ||||
| 			document.body.appendChild(testLink); | ||||
| 		}, { testLinkTitle, linkHref }); | ||||
|  | ||||
| 		const testLink = mainWindow.getByText(testLinkTitle); | ||||
| 		await expect(testLink).toBeVisible(); | ||||
| 		await testLink.click({ noWaitAfter: true }); | ||||
|  | ||||
| 		expect(await nextExternalUrlPromise).toBe(linkHref); | ||||
| 	}); | ||||
| 	} | ||||
|  | ||||
| 	test('should start in safe mode if profile-dir/force-safe-mode-on-next-start exists', async ({ profileDirectory }) => { | ||||
| 		await writeFile(join(profileDirectory, 'force-safe-mode-on-next-start'), 'true', 'utf8'); | ||||
|   | ||||
| @@ -42,21 +42,29 @@ const setUpProtocolHandler = () => { | ||||
| 	return { protocolHandler, onRequestListener }; | ||||
| }; | ||||
|  | ||||
| interface ExpectBlockedOptions { | ||||
| 	host?: string; | ||||
| } | ||||
|  | ||||
| // Although none of the paths in this test suite point to real files, file paths must be in | ||||
| // a certain format on Windows to avoid invalid path exceptions. | ||||
| const toPlatformPath = (path: string) => process.platform === 'win32' ? `C:/${path}` : path; | ||||
|  | ||||
| const expectPathToBeBlocked = async (onRequestListener: ProtocolHandler, filePath: string) => { | ||||
| 	const url = `joplin-content://note-viewer/${toPlatformPath(filePath)}`; | ||||
|  | ||||
| 	await expect( | ||||
| 		async () => await onRequestListener(new Request(url)), | ||||
| 	).rejects.toThrowError('Read access not granted for URL'); | ||||
| const toAccessUrl = (path: string, { host = 'note-viewer' }: ExpectBlockedOptions = {}) => { | ||||
| 	return `joplin-content://${host}/${toPlatformPath(path)}`; | ||||
| }; | ||||
|  | ||||
| const expectPathToBeUnblocked = async (onRequestListener: ProtocolHandler, filePath: string) => { | ||||
| const expectPathToBeBlocked = async (onRequestListener: ProtocolHandler, filePath: string, options?: ExpectBlockedOptions) => { | ||||
| 	const url = toAccessUrl(filePath, options); | ||||
| 	await expect( | ||||
| 		async () => await onRequestListener(new Request(url)), | ||||
| 	).rejects.toThrow(/Read access not granted for URL|Invalid or missing media access key|Media access denied/); | ||||
| }; | ||||
|  | ||||
| const expectPathToBeUnblocked = async (onRequestListener: ProtocolHandler, filePath: string, options?: ExpectBlockedOptions) => { | ||||
| 	const url = toAccessUrl(filePath, options); | ||||
| 	const handleRequestResult = await onRequestListener( | ||||
| 		new Request(`joplin-content://note-viewer/${toPlatformPath(filePath)}`), | ||||
| 		new Request(url), | ||||
| 	); | ||||
| 	expect(handleRequestResult.body).toBeTruthy(); | ||||
| }; | ||||
| @@ -107,6 +115,34 @@ describe('handleCustomProtocols', () => { | ||||
| 		await expectPathToBeBlocked(onRequestListener, '/test/path/a.txt'); | ||||
| 	}); | ||||
|  | ||||
| 	test('should only allow access to file-media/ URLs when given the correct access key', async () => { | ||||
| 		const { protocolHandler, onRequestListener } = setUpProtocolHandler(); | ||||
| 		const expectBlocked = (path: string) => { | ||||
| 			return expectPathToBeBlocked(onRequestListener, path, { host: 'file-media' }); | ||||
| 		}; | ||||
| 		const expectUnblocked = (path: string) => { | ||||
| 			return expectPathToBeUnblocked(onRequestListener, path, { host: 'file-media' }); | ||||
| 		}; | ||||
|  | ||||
| 		fetchMock.mockImplementation(async (_url: string) => { | ||||
| 			return new Response('', { headers: { 'Content-Type': 'image/jpeg' } }); | ||||
| 		}); | ||||
|  | ||||
|  | ||||
| 		const testPath = join(supportDir, 'photo.jpg'); | ||||
| 		await expectBlocked(testPath); | ||||
| 		await expectBlocked(`${testPath}?access-key=wrongKey`); | ||||
| 		await expectBlocked(`${testPath}?access-key=false`); | ||||
|  | ||||
| 		protocolHandler.setMediaAccessEnabled(true); | ||||
| 		const key = protocolHandler.getMediaAccessKey(); | ||||
| 		await expectUnblocked(`${testPath}?access-key=${key}`); | ||||
| 		await expectBlocked(`${testPath}?access-key=null`); | ||||
| 		protocolHandler.setMediaAccessEnabled(false); | ||||
|  | ||||
| 		await expectBlocked(`${testPath}?access-key=${key}`); | ||||
| 	}); | ||||
|  | ||||
| 	test('should allow requesting part of a file', async () => { | ||||
| 		const { protocolHandler, onRequestListener } = setUpProtocolHandler(); | ||||
|  | ||||
|   | ||||
| @@ -7,10 +7,20 @@ import { LoggerWrapper } from '@joplin/utils/Logger'; | ||||
| import * as fs from 'fs-extra'; | ||||
| import { createReadStream } from 'fs'; | ||||
| import { fromFilename } from '@joplin/lib/mime-utils'; | ||||
| import { createSecureRandom } from '@joplin/lib/uuid'; | ||||
|  | ||||
| export interface AccessController { | ||||
| 	remove(): void; | ||||
| } | ||||
|  | ||||
| export interface CustomProtocolHandler { | ||||
| 	// note-viewer/ URLs | ||||
| 	allowReadAccessToDirectory(path: string): void; | ||||
| 	allowReadAccessToFile(path: string): { remove(): void }; | ||||
| 	allowReadAccessToFile(path: string): AccessController; | ||||
|  | ||||
| 	// file-media/ URLs | ||||
| 	setMediaAccessEnabled(enabled: boolean): void; | ||||
| 	getMediaAccessKey(): string; | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -130,8 +140,11 @@ const handleCustomProtocols = (logger: LoggerWrapper): CustomProtocolHandler => | ||||
| 		debug: () => {}, | ||||
| 	}; | ||||
|  | ||||
| 	// Allow-listed files/directories for joplin-content://note-viewer/ | ||||
| 	const readableDirectories: string[] = []; | ||||
| 	const readableFiles = new Map<string, number>(); | ||||
| 	// Access for joplin-content://file-media/ | ||||
| 	let mediaAccessKey: string|false = false; | ||||
|  | ||||
| 	// See also the protocol.handle example: https://www.electronjs.org/docs/latest/api/protocol#protocolhandlescheme-handler | ||||
| 	protocol.handle(contentProtocolName, async request => { | ||||
| @@ -147,10 +160,9 @@ const handleCustomProtocols = (logger: LoggerWrapper): CustomProtocolHandler => | ||||
|  | ||||
| 		pathname = resolve(appBundleDirectory, pathname); | ||||
|  | ||||
| 		const allowedHosts = ['note-viewer']; | ||||
|  | ||||
| 		let canRead = false; | ||||
| 		if (allowedHosts.includes(host)) { | ||||
| 		let mediaOnly = true; | ||||
| 		if (host === 'note-viewer') { | ||||
| 			if (readableFiles.has(pathname)) { | ||||
| 				canRead = true; | ||||
| 			} else { | ||||
| @@ -161,6 +173,20 @@ const handleCustomProtocols = (logger: LoggerWrapper): CustomProtocolHandler => | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			mediaOnly = false; | ||||
| 		} else if (host === 'file-media') { | ||||
| 			if (!mediaAccessKey) { | ||||
| 				throw new Error('Media access denied. This must be enabled with .setMediaAccessEnabled'); | ||||
| 			} | ||||
|  | ||||
| 			canRead = true; | ||||
| 			mediaOnly = true; | ||||
|  | ||||
| 			const accessKey = url.searchParams.get('access-key'); | ||||
| 			if (accessKey !== mediaAccessKey) { | ||||
| 				throw new Error(`Invalid or missing media access key (was ${accessKey}). An allow-listed ?access-key= parameter must be provided.`); | ||||
| 			} | ||||
| 		} else { | ||||
| 			throw new Error(`Invalid URL ${request.url}`); | ||||
| 		} | ||||
| @@ -173,12 +199,26 @@ const handleCustomProtocols = (logger: LoggerWrapper): CustomProtocolHandler => | ||||
| 		logger.debug('protocol handler: Fetch file URL', asFileUrl); | ||||
|  | ||||
| 		const rangeHeader = request.headers.get('Range'); | ||||
| 		let response; | ||||
| 		if (!rangeHeader) { | ||||
| 			const response = await net.fetch(asFileUrl); | ||||
| 			return response; | ||||
| 			response = await net.fetch(asFileUrl); | ||||
| 		} else { | ||||
| 			return handleRangeRequest(request, pathname); | ||||
| 			response = await handleRangeRequest(request, pathname); | ||||
| 		} | ||||
|  | ||||
| 		if (mediaOnly) { | ||||
| 			// Tells the browser to avoid MIME confusion attacks. See | ||||
| 			// https://blog.mozilla.org/security/2016/08/26/mitigating-mime-confusion-attacks-in-firefox/ | ||||
| 			response.headers.set('X-Content-Type-Options', 'nosniff'); | ||||
|  | ||||
| 			// This is an extra check to prevent loading text/html and arbitrary non-media content from the URL. | ||||
| 			const contentType = response.headers.get('Content-Type'); | ||||
| 			if (!contentType || !contentType.match(/^(image|video|audio)\//)) { | ||||
| 				throw new Error(`Attempted to access non-media file from ${request.url}, which is media-only. Content type was ${contentType}.`); | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		return response; | ||||
| 	}); | ||||
|  | ||||
| 	const appBundleDirectory = dirname(dirname(__dirname)); | ||||
| @@ -210,6 +250,18 @@ const handleCustomProtocols = (logger: LoggerWrapper): CustomProtocolHandler => | ||||
| 				}, | ||||
| 			}; | ||||
| 		}, | ||||
| 		setMediaAccessEnabled: (enabled: boolean) => { | ||||
| 			if (enabled) { | ||||
| 				mediaAccessKey ||= createSecureRandom(); | ||||
| 			} else { | ||||
| 				mediaAccessKey = false; | ||||
| 			} | ||||
| 		}, | ||||
| 		// Allows access to all local media files, provided a matching ?access-key=<key> is added | ||||
| 		// to the request URL. | ||||
| 		getMediaAccessKey: () => { | ||||
| 			return mediaAccessKey || null; | ||||
| 		}, | ||||
| 	}; | ||||
| }; | ||||
|  | ||||
|   | ||||
| @@ -79,7 +79,7 @@ android { | ||||
|         applicationId "net.cozic.joplin" | ||||
|         minSdkVersion rootProject.ext.minSdkVersion | ||||
|         targetSdkVersion rootProject.ext.targetSdkVersion | ||||
| 		versionCode 2097753 | ||||
| 		versionCode 2097755 | ||||
| 		versionName "3.2.0" | ||||
| 		ndk { | ||||
| 			abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64" | ||||
|   | ||||
| @@ -167,6 +167,8 @@ class NotesScreenComponent extends BaseScreenComponent<Props, State> { | ||||
| 		}); | ||||
|  | ||||
| 		if (source === props.notesSource) return; | ||||
| 		// For now, search refresh is handled by the search screen. | ||||
| 		if (props.notesParentType === 'Search') return; | ||||
|  | ||||
| 		let notes: NoteEntity[] = []; | ||||
| 		if (props.notesParentType === 'Folder') { | ||||
|   | ||||
| @@ -551,7 +551,7 @@ | ||||
| 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | ||||
| 				CLANG_ENABLE_MODULES = YES; | ||||
| 				CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements; | ||||
| 				CURRENT_PROJECT_VERSION = 126; | ||||
| 				CURRENT_PROJECT_VERSION = 127; | ||||
| 				DEVELOPMENT_TEAM = A9BXAFS6CT; | ||||
| 				ENABLE_BITCODE = NO; | ||||
| 				INFOPLIST_FILE = Joplin/Info.plist; | ||||
| @@ -583,7 +583,7 @@ | ||||
| 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | ||||
| 				CLANG_ENABLE_MODULES = YES; | ||||
| 				CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements; | ||||
| 				CURRENT_PROJECT_VERSION = 126; | ||||
| 				CURRENT_PROJECT_VERSION = 127; | ||||
| 				DEVELOPMENT_TEAM = A9BXAFS6CT; | ||||
| 				INFOPLIST_FILE = Joplin/Info.plist; | ||||
| 				IPHONEOS_DEPLOYMENT_TARGET = 13.4; | ||||
| @@ -774,7 +774,7 @@ | ||||
| 				CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; | ||||
| 				CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; | ||||
| 				CODE_SIGN_STYLE = Automatic; | ||||
| 				CURRENT_PROJECT_VERSION = 126; | ||||
| 				CURRENT_PROJECT_VERSION = 127; | ||||
| 				DEBUG_INFORMATION_FORMAT = dwarf; | ||||
| 				DEVELOPMENT_TEAM = A9BXAFS6CT; | ||||
| 				GCC_C_LANGUAGE_STANDARD = gnu11; | ||||
| @@ -813,7 +813,7 @@ | ||||
| 				CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; | ||||
| 				CODE_SIGN_STYLE = Automatic; | ||||
| 				COPY_PHASE_STRIP = NO; | ||||
| 				CURRENT_PROJECT_VERSION = 126; | ||||
| 				CURRENT_PROJECT_VERSION = 127; | ||||
| 				DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; | ||||
| 				DEVELOPMENT_TEAM = A9BXAFS6CT; | ||||
| 				GCC_C_LANGUAGE_STANDARD = gnu11; | ||||
|   | ||||
| @@ -308,6 +308,10 @@ const appReducer = (state = appDefaultState, action: any) => { | ||||
|  | ||||
| 				newState.selectedNoteHash = ''; | ||||
|  | ||||
| 				if (action.routeName === 'Search') { | ||||
| 					newState.notesParentType = 'Search'; | ||||
| 				} | ||||
|  | ||||
| 				if ('noteId' in action) { | ||||
| 					newState.selectedNoteIds = action.noteId ? [action.noteId] : []; | ||||
| 				} | ||||
| @@ -344,6 +348,8 @@ const appReducer = (state = appDefaultState, action: any) => { | ||||
|  | ||||
| 				newState.route = action; | ||||
| 				newState.historyCanGoBack = !!navHistory.length; | ||||
|  | ||||
| 				logger.debug('Navigated to route:', newState.route?.routeName, 'with notesParentType:', newState.notesParentType); | ||||
| 			} | ||||
| 			break; | ||||
|  | ||||
|   | ||||
| @@ -6,6 +6,7 @@ import { classHighlighter } from '@lezer/highlight'; | ||||
|  | ||||
| import { | ||||
| 	EditorView, drawSelection, highlightSpecialChars, ViewUpdate, Command, rectangularSelection, | ||||
| 	dropCursor, | ||||
| } from '@codemirror/view'; | ||||
| import { history, undoDepth, redoDepth, standardKeymap, insertTab } from '@codemirror/commands'; | ||||
|  | ||||
| @@ -270,6 +271,7 @@ const createEditor = ( | ||||
|  | ||||
| 				// Apply styles to entire lines (block-display decorations) | ||||
| 				decoratorExtension, | ||||
| 				dropCursor(), | ||||
|  | ||||
| 				biDirectionalTextExtension, | ||||
| 				overwriteModeExtension, | ||||
|   | ||||
| @@ -84,6 +84,18 @@ const tableDelimiterDecoration = Decoration.line({ | ||||
| 	attributes: { class: 'cm-tableDelimiter' }, | ||||
| }); | ||||
|  | ||||
| const orderedListDecoration = Decoration.line({ | ||||
| 	attributes: { class: 'cm-orderedList' }, | ||||
| }); | ||||
|  | ||||
| const unorderedListDecoration = Decoration.line({ | ||||
| 	attributes: { class: 'cm-unorderedList' }, | ||||
| }); | ||||
|  | ||||
| const listItemDecoration = Decoration.line({ | ||||
| 	attributes: { class: 'cm-listItem' }, | ||||
| }); | ||||
|  | ||||
| const horizontalRuleDecoration = Decoration.mark({ | ||||
| 	attributes: { class: 'cm-hr' }, | ||||
| }); | ||||
| @@ -97,6 +109,10 @@ const nodeNameToLineDecoration: Record<string, Decoration> = { | ||||
| 	'CodeBlock': codeBlockDecoration, | ||||
| 	'BlockMath': mathBlockDecoration, | ||||
| 	'Blockquote': blockQuoteDecoration, | ||||
| 	'OrderedList': orderedListDecoration, | ||||
| 	'BulletList': unorderedListDecoration, | ||||
|  | ||||
| 	'ListItem': listItemDecoration, | ||||
|  | ||||
| 	'SetextHeading1': header1LineDecoration, | ||||
| 	'ATXHeading1': header1LineDecoration, | ||||
| @@ -122,6 +138,14 @@ const nodeNameToMarkDecoration: Record<string, Decoration> = { | ||||
| 	'TaskMarker': taskMarkerDecoration, | ||||
| }; | ||||
|  | ||||
| const multilineNodes = { | ||||
| 	'FencedCode': true, | ||||
| 	'CodeBlock': true, | ||||
| 	'BlockMath': true, | ||||
| 	'Blockquote': true, | ||||
| 	'OrderedList': true, | ||||
| 	'BulletList': true, | ||||
| }; | ||||
|  | ||||
| type DecorationDescription = { pos: number; length: number; decoration: Decoration }; | ||||
|  | ||||
| @@ -179,8 +203,8 @@ const computeDecorations = (view: EditorView) => { | ||||
| 					addDecorationToRange(viewFrom, viewTo, decoration); | ||||
| 				} | ||||
|  | ||||
| 				// Only block decorations will have differing first and last lines | ||||
| 				if (blockDecorated) { | ||||
| 				// Only certain block decorations will have differing first and last lines | ||||
| 				if (blockDecorated && multilineNodes.hasOwnProperty(node.name)) { | ||||
| 					// Allow different styles for the first, last lines in a block. | ||||
| 					if (viewFrom === node.from) { | ||||
| 						addDecorationToLines(viewFrom, viewFrom, regionStartDecoration); | ||||
|   | ||||
							
								
								
									
										10
									
								
								packages/editor/CodeMirror/theme.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										10
									
								
								packages/editor/CodeMirror/theme.ts
									
									
									
									
										vendored
									
									
								
							| @@ -79,6 +79,10 @@ const createTheme = (theme: EditorTheme): Extension[] => { | ||||
| 	// be at least this specific. | ||||
| 	const selectionBackgroundSelector = '&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground'; | ||||
|  | ||||
| 	// Matches the editor only when there are no gutters (e.g. line numbers) added by | ||||
| 	// plugins | ||||
| 	const editorNoGuttersSelector = '&:not(:has(> .cm-scroller > .cm-gutters))'; | ||||
|  | ||||
| 	const baseHeadingStyle = { | ||||
| 		fontWeight: 'bold', | ||||
| 		fontFamily: theme.fontFamily, | ||||
| @@ -180,6 +184,12 @@ const createTheme = (theme: EditorTheme): Extension[] => { | ||||
| 			marginRight: 'auto', | ||||
| 		} : undefined, | ||||
|  | ||||
| 		// Allows editor content to be left-aligned with the toolbar on desktop. | ||||
| 		// See https://github.com/laurent22/joplin/issues/11279 | ||||
| 		[`${editorNoGuttersSelector} .cm-line`]: theme.isDesktop ? { | ||||
| 			paddingLeft: 0, | ||||
| 		} : undefined, | ||||
|  | ||||
| 		// Override the default URL style when the URL is within a link | ||||
| 		'& .tok-url.tok-link, & .tok-link.tok-meta, & .tok-link.tok-string': { | ||||
| 			opacity: 0.6, | ||||
|   | ||||
| @@ -90,6 +90,7 @@ export interface ContentScriptData { | ||||
| // Intended to correspond with https://codemirror.net/docs/ref/#state.Transaction%5EuserEvent | ||||
| export enum UserEventSource { | ||||
| 	Paste = 'input.paste', | ||||
| 	Drop = 'input.drop', | ||||
| } | ||||
|  | ||||
| export interface EditorControl { | ||||
|   | ||||
| @@ -943,6 +943,18 @@ const builtInMetadata = (Setting: typeof SettingType) => { | ||||
| 		'markdown.plugin.insert': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable ++insert++ syntax')}${wysiwygYes}` }, | ||||
| 		'markdown.plugin.multitable': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable multimarkdown table extension')}${wysiwygNo}` }, | ||||
|  | ||||
| 		// For now, applies only to the Markdown viewer | ||||
| 		'renderer.fileUrls': { | ||||
| 			storage: SettingStorage.File, | ||||
| 			isGlobal: true, | ||||
| 			value: false, | ||||
| 			type: SettingItemType.Bool, | ||||
| 			section: 'markdownPlugins', | ||||
| 			public: true, | ||||
| 			appTypes: [AppType.Desktop], | ||||
| 			label: () => `${_('Enable file:// URLs for images and videos')}${wysiwygYes}`, | ||||
| 		}, | ||||
|  | ||||
| 		// Tray icon (called AppIndicator) doesn't work in Ubuntu | ||||
| 		// http://www.webupd8.org/2017/04/fix-appindicator-not-working-for.html | ||||
| 		// Might be fixed in Electron 18.x but no non-beta release yet. So for now | ||||
| @@ -1593,6 +1605,20 @@ const builtInMetadata = (Setting: typeof SettingType) => { | ||||
| 			isGlobal: true, | ||||
| 		}, | ||||
|  | ||||
| 		'featureFlag.linuxKeychain': { | ||||
| 			value: false, | ||||
| 			type: SettingItemType.Bool, | ||||
| 			public: true, | ||||
| 			storage: SettingStorage.File, | ||||
| 			appTypes: [AppType.Desktop], | ||||
| 			label: () => 'Enable keychain support', | ||||
| 			description: () => 'This is an experimental setting to enable keychain support on Linux', | ||||
| 			show: () => shim.isLinux(), | ||||
| 			section: 'general', | ||||
| 			isGlobal: true, | ||||
| 			advanced: true, | ||||
| 		}, | ||||
|  | ||||
|  | ||||
| 		// 'featureFlag.syncAccurateTimestamps': { | ||||
| 		// 	value: false, | ||||
|   | ||||
| @@ -34,6 +34,13 @@ export async function loadKeychainServiceAndSettings(keychainServiceDrivers: Key | ||||
| 	Setting.setKeychainService(KeychainService.instance()); | ||||
| 	await Setting.load(); | ||||
|  | ||||
| 	// Using Linux with the keychain has been observed to cause all secure settings to be lost | ||||
| 	// on Fedora 40 + GNOME. (This may have been related to running multiple Joplin instances). | ||||
| 	// For now, make saving to the keychain opt-in until more feedback is received. | ||||
| 	if (shim.isLinux() && !Setting.value('featureFlag.linuxKeychain')) { | ||||
| 		KeychainService.instance().readOnly = true; | ||||
| 	} | ||||
|  | ||||
| 	// This is part of the migration to the new sync target info. It needs to be | ||||
| 	// set as early as possible since it's used to tell if E2EE is enabled, it | ||||
| 	// contains the master keys, etc. Once it has been set, it becomes a noop | ||||
|   | ||||
| @@ -4,7 +4,7 @@ import Logger from '@joplin/utils/Logger'; | ||||
| import KvStore from '../KvStore'; | ||||
| import Setting from '../../models/Setting'; | ||||
|  | ||||
| const logger = Logger.create('KeychainServiceDriver.node'); | ||||
| const logger = Logger.create('KeychainServiceDriver.electron'); | ||||
|  | ||||
| const canUseSafeStorage = () => { | ||||
| 	return !!shim.electronBridge?.()?.safeStorage?.isEncryptionAvailable(); | ||||
|   | ||||
| @@ -1,9 +1,13 @@ | ||||
| import Logger from '@joplin/utils/Logger'; | ||||
| import { _ } from './locale'; | ||||
| import Setting from './models/Setting'; | ||||
| import { reg } from './registry'; | ||||
| import KeychainService from './services/keychain/KeychainService'; | ||||
| import { Plugins } from './services/plugins/PluginService'; | ||||
| import shim from './shim'; | ||||
|  | ||||
| const logger = Logger.create('versionInfo'); | ||||
|  | ||||
| export interface PackageInfo { | ||||
| 	name: string; | ||||
| 	version: string; | ||||
| @@ -70,15 +74,21 @@ export default function versionInfo(packageInfo: PackageInfo, plugins: Plugins) | ||||
| 		copyrightText.replace('YYYY', `${now.getFullYear()}`), | ||||
| 	]; | ||||
|  | ||||
| 	let keychainSupported = false; | ||||
| 	try { | ||||
| 		// To allow old keys to be read, certain apps allow read-only keychain access: | ||||
| 		keychainSupported = Setting.value('keychain.supported') >= 1 && !KeychainService.instance().readOnly; | ||||
| 	} catch (error) { | ||||
| 		logger.error('Failed to determine if keychain is supported', error); | ||||
| 	} | ||||
|  | ||||
| 	const body = [ | ||||
| 		_('%s %s (%s, %s)', p.name, p.version, Setting.value('env'), shim.platformName()), | ||||
| 		'', | ||||
| 		_('Client ID: %s', Setting.value('clientId')), | ||||
| 		_('Sync Version: %s', Setting.value('syncVersion')), | ||||
| 		_('Profile Version: %s', reg.db().version()), | ||||
| 		// The portable app temporarily supports read-only keychain access (but disallows | ||||
| 		// write). | ||||
| 		_('Keychain Supported: %s', (Setting.value('keychain.supported') >= 1 && !shim.isPortable()) ? _('Yes') : _('No')), | ||||
| 		_('Keychain Supported: %s', keychainSupported ? _('Yes') : _('No')), | ||||
| 	]; | ||||
|  | ||||
| 	if (gitInfo) { | ||||
|   | ||||
| @@ -310,12 +310,6 @@ function renderToStringWithCache(latex: string, katexOptions: any) { | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||
| function renderKatexError(latex: string, error: any): string { | ||||
| 	console.error('Katex error for:', latex, error); | ||||
| 	return `<div class="inline-code">${error.message}</div>`; | ||||
| } | ||||
|  | ||||
| export default { | ||||
| 	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||
| 	plugin: function(markdownIt: any, options: RuleOptions) { | ||||
| @@ -329,6 +323,10 @@ export default { | ||||
| 		katexOptions.macros = options.context.userData.__katex.macros; | ||||
| 		katexOptions.trust = true; | ||||
|  | ||||
| 		const renderKatexError = (error: Error): string => { | ||||
| 			return `<div class="inline-code">${markdownIt.utils.escapeHtml(error.message)}</div>`; | ||||
| 		}; | ||||
|  | ||||
| 		// set KaTeX as the renderer for markdown-it-simplemath | ||||
| 		const katexInline = function(latex: string) { | ||||
| 			katexOptions.displayMode = false; | ||||
| @@ -336,7 +334,7 @@ export default { | ||||
| 			try { | ||||
| 				outputHtml = renderToStringWithCache(latex, katexOptions); | ||||
| 			} catch (error) { | ||||
| 				outputHtml = renderKatexError(latex, error); | ||||
| 				outputHtml = renderKatexError(error); | ||||
| 			} | ||||
| 			return `<span class="joplin-editable"><span class="joplin-source" data-joplin-language="katex" data-joplin-source-open="$" data-joplin-source-close="$">${markdownIt.utils.escapeHtml(latex)}</span>${outputHtml}</span>`; | ||||
| 		}; | ||||
| @@ -353,7 +351,7 @@ export default { | ||||
| 			try { | ||||
| 				outputHtml = renderToStringWithCache(latex, katexOptions); | ||||
| 			} catch (error) { | ||||
| 				outputHtml = renderKatexError(latex, error); | ||||
| 				outputHtml = renderKatexError(error); | ||||
| 			} | ||||
|  | ||||
| 			return `<div class="joplin-editable"><pre class="joplin-source" data-joplin-language="katex" data-joplin-source-open="$$
" data-joplin-source-close="
$$
">${markdownIt.utils.escapeHtml(latex)}</pre>${outputHtml}</div>`; | ||||
|   | ||||
| @@ -139,3 +139,4 @@ treeitem | ||||
| qrcode | ||||
| Rocketbook | ||||
| datamatrix | ||||
| nosniff | ||||
|   | ||||
| @@ -1,5 +1,19 @@ | ||||
| # Joplin Android Changelog | ||||
|  | ||||
| ## [android-v3.1.7](https://github.com/laurent22/joplin/releases/tag/android-v3.1.7) (Pre-release) - 2024-11-04T20:27:52Z | ||||
|  | ||||
| - Fixed: Fix search result note hidden after powering on device (#11297) (#11197 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator)) | ||||
|  | ||||
| ## [android-v3.1.6](https://github.com/laurent22/joplin/releases/tag/android-v3.1.6) (Pre-release) - 2024-10-17T22:13:06Z | ||||
|  | ||||
| - Improved: Downgrade CodeMirror packages to fix various Android regressions (#11170 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator)) | ||||
| - Improved: Plugins: Name webview root attribute so that it can be styled (75b8caf) | ||||
| - Improved: Updated packages @react-native/babel-preset (v0.74.85), @react-native/metro-config (v0.74.85), glob (v10.4.5), katex (v0.16.11), react-native-document-picker (v9.3.0), react-native-safe-area-context (v4.10.7), stream (v0.0.3) | ||||
| - Fixed: Fix automatic resource download mode (#11144) (#11134 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator)) | ||||
| - Fixed: Fix incorrect list switching behavior (#11137) (#11135 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator)) | ||||
| - Fixed: Fix new note/edit buttons only work if pressed quickly (#11185) (#11183 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator)) | ||||
| - Fixed: Fix regression: Search screen not hidden when cached for search result navigation (#11131) (#11130 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator)) | ||||
|  | ||||
| ## [android-v3.1.5](https://github.com/laurent22/joplin/releases/tag/android-v3.1.5) (Pre-release) - 2024-10-11T22:11:20Z | ||||
|  | ||||
| - Improved: Downgrade CodeMirror packages to fix various Android regressions (#11170 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator)) | ||||
|   | ||||
| @@ -1,5 +1,37 @@ | ||||
| # Joplin iOS Changelog | ||||
|  | ||||
| ## [ios-v13.1.6](https://github.com/laurent22/joplin/releases/tag/ios-v13.1.6) - 2024-10-17T22:16:20Z | ||||
|  | ||||
| - Improved: Added feature flag to disable sync lock support (#10925) (#10407) | ||||
| - Improved: Automatically detect and use operating system theme by default (5beb80b) | ||||
| - Improved: Downgrade CodeMirror packages to fix various Android regressions (#11170 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator)) | ||||
| - Improved: Make feature flags advanced settings by default (700ffa2) | ||||
| - Improved: Make pressing "back" navigate to the previous note after following a link (#11086) (#11082 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator)) | ||||
| - Improved: Plugins: Name webview root attribute so that it can be styled (75b8caf) | ||||
| - Improved: Scroll dropdown to selected value when first opened (#11091 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator)) | ||||
| - Improved: Show loading indicator while loading search results (#11104 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator)) | ||||
| - Improved: Support permanent note deletion on mobile  (#10786) (#10763 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator)) | ||||
| - Improved: Updated packages @bam.tech/react-native-image-resizer (v3.0.10), @js-draw/material-icons (v1.20.3), @react-native-clipboard/clipboard (v1.14.1), @react-native-community/datetimepicker (v8.0.1), @react-native/babel-preset (v0.74.85), @react-native/metro-config (v0.74.85), @rollup/plugin-commonjs (v25.0.8), @rollup/plugin-replace (v5.0.7), async-mutex (v0.5.0), dayjs (v1.11.11), glob (v10.4.5), js-draw (v1.20.3), jsdom (v24.1.0), katex (v0.16.11), markdown-it-ins (v4), markdown-it-sup (v2), react, react-native-device-info (v10.14.0), react-native-document-picker (v9.3.0), react-native-localize (v3.1.0), react-native-safe-area-context (v4.10.7), react-native-share (v10.2.1), react-native-webview (v13.8.7), react-native-zip-archive (v6.1.2), sass (v1.77.6), sharp (v0.33.4), stream (v0.0.3), tesseract.js (v5.1.0), turndown (v7.2.0) | ||||
| - Improved: Upgrade CodeMirror packages (#11034 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator)) | ||||
| - Improved: Use fade animation for edit link dialog (#11090 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator)) | ||||
| - Fixed: Accessibility: Fix sidebar broken in right-to-left mode, improve screen reader accessibility (#11056) (#11028 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator)) | ||||
| - Fixed: Decrypt master keys only as needed (#10990) (#10856 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator)) | ||||
| - Fixed: Delete revisions on the sync target when deleted locally (#11035) (#11017 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator)) | ||||
| - Fixed: Drawing: Fix clicking "cancel" after starting a new drawing in editing mode creates an empty resource (#10986 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator)) | ||||
| - Fixed: Fix "Enable auto-updates" enabled by default and visible on unsupported platforms (#10897) (#10896 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator)) | ||||
| - Fixed: Fix BMP image rendering in the Markdown viewer (#10915) (#10914 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator)) | ||||
| - Fixed: Fix automatic resource download mode (#11144) (#11134 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator)) | ||||
| - Fixed: Fix incorrect list switching behavior (#11137) (#11135 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator)) | ||||
| - Fixed: Fix new note/edit buttons only work if pressed quickly (#11185) (#11183 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator)) | ||||
| - Fixed: Fix regression: Search screen not hidden when cached for search result navigation (#11131) (#11130 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator)) | ||||
| - Fixed: Fix toolbar overflow menu is invisible (#10871) (#10867 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator)) | ||||
| - Fixed: Fix unable to change incorrect decryption password if the same as the master password (#11026 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator)) | ||||
| - Fixed: Fixed italic support in Fountain documents (5fdd088) | ||||
| - Fixed: Improve performance when there are many selected items (#11067) (#11065 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator)) | ||||
| - Fixed: Markdown editor: Fix toggling bulleted lists when items start with asterisks (#10902) (#10891 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator)) | ||||
| - Fixed: Move accessibility focus to the first note action menu item on open (#11031) (#10253 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator)) | ||||
| - Fixed: WebDAV synchronisation not working because of URL encoding differences (#11076) (#10608 by [@pedr](https://github.com/pedr)) | ||||
|  | ||||
| ## [ios-v13.1.5](https://github.com/laurent22/joplin/releases/tag/ios-v13.1.5) - 2024-10-11T22:29:29Z | ||||
|  | ||||
| - Improved: Added feature flag to disable sync lock support (#10925) (#10407) | ||||
|   | ||||
| @@ -31,7 +31,7 @@ Here's an example: | ||||
| - Joplin checks to make sure the `joplin-content://` protocol has access to `/home/user/.config/joplin-desktop/path/here.css`. If it does, it fetches and returns the file. | ||||
|  | ||||
|  | ||||
| ## `joplin-content://` only has access to specific directories | ||||
| ## `joplin-content://note-viewer/` only has access to specific directories | ||||
|  | ||||
| When `handleCustomProtocols` creates a handler for the `joplin-content://` protocol, it returns an object that allows certain directories to be marked as readable. | ||||
|  | ||||
| @@ -41,6 +41,13 @@ By default, the list of readable directories includes: | ||||
| - The resource directory | ||||
| - The profile directory | ||||
|  | ||||
| ## `joplin-content://file-media/` can only load specific file types | ||||
|  | ||||
| To allow images and videos with `file://` URLs, Joplin maps `file://` URIs to `joplin-content://file-media/`. The `file-media/` host has the following restrictions: | ||||
| - Only files with certain extensions/content-types can be loaded. | ||||
|    - For example, `text/html` is disallowed but `image/png` is allowed. | ||||
| - A valid `?access-key=<...>` parameter must be provided with the request. | ||||
|    - A new access key is created for each render and old access keys are revoked. | ||||
|  | ||||
| ## Why not the [`sandbox`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#sandbox) property? | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user