feat: Initial clean release of CHM
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
这个提交包含在:
@@ -0,0 +1,105 @@
|
||||
/** @jest-environment jsdom */
|
||||
import { DialogWebViewApi } from '../types';
|
||||
import initializeDialogWebView from './initializeDialogWebView';
|
||||
|
||||
let mockDialogApi: DialogWebViewApi;
|
||||
jest.mock('../../../utils/ipc/WebViewToRNMessenger', () => {
|
||||
return class {
|
||||
public remoteApi = {
|
||||
postMessage: jest.fn(),
|
||||
onMessage: jest.fn(),
|
||||
onError: jest.fn(),
|
||||
onLog: jest.fn(),
|
||||
};
|
||||
|
||||
public constructor(_messageChannelId: string, localApi: DialogWebViewApi) {
|
||||
mockDialogApi = localApi;
|
||||
}
|
||||
};
|
||||
});
|
||||
jest.mock('./utils/reportUnhandledErrors', () => jest.fn());
|
||||
jest.mock('./utils/wrapConsoleLog', () => jest.fn());
|
||||
|
||||
class MockResizeObserver {
|
||||
public static instances: MockResizeObserver[] = [];
|
||||
public observe = jest.fn();
|
||||
public disconnect = jest.fn();
|
||||
|
||||
public constructor(private callback: ResizeObserverCallback) {
|
||||
MockResizeObserver.instances.push(this);
|
||||
}
|
||||
|
||||
public notify() {
|
||||
this.callback([], this as unknown as ResizeObserver);
|
||||
}
|
||||
}
|
||||
|
||||
describe('initializeDialogWebView', () => {
|
||||
let contentWidth = 0;
|
||||
let contentHeight = 0;
|
||||
let viewportWidth = 0;
|
||||
let innerWidth = 0;
|
||||
|
||||
beforeEach(() => {
|
||||
MockResizeObserver.instances = [];
|
||||
global.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
|
||||
document.body.innerHTML = '<div id="joplin-plugin-content"></div>';
|
||||
|
||||
const content = document.getElementById('joplin-plugin-content');
|
||||
Object.defineProperty(content, 'clientWidth', { configurable: true, get: () => contentWidth });
|
||||
Object.defineProperty(content, 'clientHeight', { configurable: true, get: () => contentHeight });
|
||||
Object.defineProperty(document.documentElement, 'clientWidth', { configurable: true, get: () => viewportWidth });
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, get: () => innerWidth });
|
||||
|
||||
contentWidth = 320;
|
||||
contentHeight = 200;
|
||||
viewportWidth = 1000;
|
||||
innerWidth = 1000;
|
||||
initializeDialogWebView('test-channel');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.dispatchEvent(new Event('pagehide'));
|
||||
});
|
||||
|
||||
test('should report width reductions observed without DOM mutations', async () => {
|
||||
expect(await mockDialogApi.getContentSize(true)).toMatchObject({ width: 320, height: 200 });
|
||||
|
||||
contentWidth = 220;
|
||||
MockResizeObserver.instances[0].notify();
|
||||
|
||||
expect(await mockDialogApi.getContentSize(true)).toMatchObject({ width: 220, height: 200 });
|
||||
});
|
||||
|
||||
test('should remain stable while dialog text changes continuously', async () => {
|
||||
contentWidth = 300;
|
||||
viewportWidth = 300;
|
||||
innerWidth = 315;
|
||||
expect(await mockDialogApi.getContentSize(true)).toMatchObject({ width: 315, height: 200 });
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
document.getElementById('joplin-plugin-content').textContent = `Update ${i}`;
|
||||
contentHeight++;
|
||||
MockResizeObserver.instances[0].notify();
|
||||
expect(await mockDialogApi.getContentSize(true)).toMatchObject({ width: 315, height: 201 + i });
|
||||
}
|
||||
});
|
||||
|
||||
test('should only observe while watching for size changes', async () => {
|
||||
await mockDialogApi.getContentSize(false);
|
||||
expect(MockResizeObserver.instances).toHaveLength(0);
|
||||
|
||||
await mockDialogApi.getContentSize(true);
|
||||
expect(MockResizeObserver.instances[0].observe).toHaveBeenCalledWith(
|
||||
document.getElementById('joplin-plugin-content'),
|
||||
{ box: 'border-box' },
|
||||
);
|
||||
|
||||
await mockDialogApi.getContentSize(false);
|
||||
expect(MockResizeObserver.instances[0].disconnect).toHaveBeenCalledTimes(1);
|
||||
|
||||
await mockDialogApi.getContentSize(true);
|
||||
window.dispatchEvent(new Event('pagehide'));
|
||||
expect(MockResizeObserver.instances[1].disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { DialogWebViewApi, DialogMainProcessApi, WebViewPostMessageCallback, DialogSetOnMessageListenerCallback } from '../types';
|
||||
import reportUnhandledErrors from './utils/reportUnhandledErrors';
|
||||
import wrapConsoleLog from './utils/wrapConsoleLog';
|
||||
import WebViewToRNMessenger from '../../../utils/ipc/WebViewToRNMessenger';
|
||||
import getFormData from './utils/getFormData';
|
||||
|
||||
interface ExtendedWindow extends Window {
|
||||
webviewApi: {
|
||||
postMessage: WebViewPostMessageCallback;
|
||||
onMessage: DialogSetOnMessageListenerCallback;
|
||||
};
|
||||
exports: Record<string, unknown>;
|
||||
}
|
||||
|
||||
declare const window: ExtendedWindow;
|
||||
|
||||
let themeCssElement: HTMLStyleElement|null = null;
|
||||
|
||||
const initializeDialogWebView = (messageChannelId: string) => {
|
||||
const loadedPaths: Set<string> = new Set();
|
||||
let contentResizeObserver: ResizeObserver|null = null;
|
||||
let observedElement: HTMLElement|null = null;
|
||||
let observedContentSize: { width: number; height: number }|null = null;
|
||||
let pageHideListenerRegistered = false;
|
||||
|
||||
const measureContent = (element: HTMLElement) => {
|
||||
const viewportWidth = document.documentElement.clientWidth;
|
||||
const scrollbarWidth = Math.max(0, window.innerWidth - viewportWidth);
|
||||
// Include the scrollbar gutter when the content fills the viewport so feeding this
|
||||
// measurement back into the iframe does not repeatedly shrink it.
|
||||
const width = element.clientWidth === viewportWidth ? element.clientWidth + scrollbarWidth : element.clientWidth;
|
||||
return { width, height: element.clientHeight };
|
||||
};
|
||||
const stopObservingContentSize = () => {
|
||||
contentResizeObserver?.disconnect();
|
||||
contentResizeObserver = null;
|
||||
observedElement = null;
|
||||
observedContentSize = null;
|
||||
if (pageHideListenerRegistered) {
|
||||
window.removeEventListener('pagehide', stopObservingContentSize);
|
||||
pageHideListenerRegistered = false;
|
||||
}
|
||||
};
|
||||
const observeContentSize = (element: HTMLElement) => {
|
||||
if (observedElement === element && contentResizeObserver) return;
|
||||
|
||||
stopObservingContentSize();
|
||||
observedElement = element;
|
||||
observedContentSize = measureContent(element);
|
||||
contentResizeObserver = new ResizeObserver(() => {
|
||||
if (observedElement === element) observedContentSize = measureContent(element);
|
||||
});
|
||||
contentResizeObserver.observe(element, { box: 'border-box' });
|
||||
window.addEventListener('pagehide', stopObservingContentSize);
|
||||
pageHideListenerRegistered = true;
|
||||
};
|
||||
|
||||
type ScriptType = 'js'|'css';
|
||||
const includeScriptsOrStyles = (type: ScriptType, paths: string[]) => {
|
||||
for (const path of paths) {
|
||||
if (loadedPaths.has(path)) {
|
||||
continue;
|
||||
}
|
||||
loadedPaths.add(path);
|
||||
|
||||
if (type === 'css') {
|
||||
const stylesheetLink = document.createElement('link');
|
||||
stylesheetLink.rel = 'stylesheet';
|
||||
stylesheetLink.href = path;
|
||||
document.head.appendChild(stylesheetLink);
|
||||
} else {
|
||||
const script = document.createElement('script');
|
||||
script.src = path;
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const localApi: DialogWebViewApi = {
|
||||
includeCssFiles: async (paths: string[]) => {
|
||||
return includeScriptsOrStyles('css', paths);
|
||||
},
|
||||
includeJsFiles: async (paths: string[]) => {
|
||||
return includeScriptsOrStyles('js', paths);
|
||||
},
|
||||
runScript: async (key: string, scriptData: string) => {
|
||||
if (loadedPaths.has(key)) {
|
||||
return;
|
||||
}
|
||||
loadedPaths.add(key);
|
||||
|
||||
if (key.endsWith('.css')) {
|
||||
const stylesheetLink = document.createElement('style');
|
||||
stylesheetLink.appendChild(document.createTextNode(scriptData));
|
||||
document.head.appendChild(stylesheetLink);
|
||||
} else {
|
||||
const script = document.createElement('script');
|
||||
script.appendChild(document.createTextNode(scriptData));
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
},
|
||||
getFormData: async () => {
|
||||
return getFormData();
|
||||
},
|
||||
setThemeCss: async (css: string) => {
|
||||
themeCssElement?.remove?.();
|
||||
const styleElement = document.createElement('style');
|
||||
styleElement.appendChild(document.createTextNode(css));
|
||||
document.body.appendChild(styleElement);
|
||||
themeCssElement = styleElement;
|
||||
},
|
||||
getContentSize: async (watchForSizeChanges = false) => {
|
||||
// To convert to React Native pixel units from browser pixel units,
|
||||
// we need to multiply by the devicePixelRatio:
|
||||
const dpr = window.devicePixelRatio ?? 1;
|
||||
|
||||
const element = document.getElementById('joplin-plugin-content') ?? document.body;
|
||||
if (watchForSizeChanges) {
|
||||
observeContentSize(element);
|
||||
} else {
|
||||
stopObservingContentSize();
|
||||
}
|
||||
const contentSize = observedContentSize ?? measureContent(element);
|
||||
return {
|
||||
width: contentSize.width * dpr,
|
||||
height: contentSize.height * dpr,
|
||||
};
|
||||
},
|
||||
};
|
||||
const messenger = new WebViewToRNMessenger<DialogWebViewApi, DialogMainProcessApi>(messageChannelId, localApi);
|
||||
|
||||
window.webviewApi = {
|
||||
postMessage: messenger.remoteApi.postMessage,
|
||||
onMessage: messenger.remoteApi.onMessage,
|
||||
};
|
||||
|
||||
reportUnhandledErrors(messenger.remoteApi.onError);
|
||||
wrapConsoleLog(messenger.remoteApi.onLog);
|
||||
|
||||
// If dialog content scripts were bundled with Webpack for NodeJS,
|
||||
// they may expect a global "exports" to be present.
|
||||
window.exports ??= {};
|
||||
};
|
||||
|
||||
export default initializeDialogWebView;
|
||||
@@ -0,0 +1,18 @@
|
||||
import WindowMessenger from '@joplin/lib/utils/ipc/WindowMessenger';
|
||||
import { PluginMainProcessApi, PluginWebViewApi } from '../types';
|
||||
import reportUnhandledErrors from './utils/reportUnhandledErrors';
|
||||
import wrapConsoleLog from './utils/wrapConsoleLog';
|
||||
|
||||
|
||||
export const initializePluginBackgroundIframe = async (messageChannelId: string) => {
|
||||
const localApi = { };
|
||||
const messenger = new WindowMessenger<PluginWebViewApi, PluginMainProcessApi>(messageChannelId, parent, localApi);
|
||||
await messenger.awaitRemoteReady();
|
||||
|
||||
(window as Window & { joplin?: unknown }).joplin = messenger.remoteApi.api.joplin;
|
||||
|
||||
reportUnhandledErrors(messenger.remoteApi.onError);
|
||||
wrapConsoleLog(messenger.remoteApi.onLog);
|
||||
};
|
||||
|
||||
export default initializePluginBackgroundIframe;
|
||||
@@ -0,0 +1,47 @@
|
||||
export { runPlugin, stopPlugin } from './startStopPlugin';
|
||||
|
||||
// Old plugins allowed to import legacy APIs
|
||||
const legacyPluginIds = [
|
||||
'outline',
|
||||
'ylc395.noteLinkSystem',
|
||||
'com.github.joplin.kanban',
|
||||
];
|
||||
|
||||
const pathLibrary = require('path');
|
||||
const punycode = require('punycode/');
|
||||
|
||||
export const requireModule = (moduleName: string, fromPluginId: string) => {
|
||||
if (moduleName === 'path') {
|
||||
return pathLibrary;
|
||||
}
|
||||
|
||||
if (legacyPluginIds.includes(fromPluginId)) {
|
||||
if (moduleName === 'punycode') {
|
||||
console.warn('Requiring punycode is deprecated. Please transition to a newer API.');
|
||||
return punycode;
|
||||
}
|
||||
if (moduleName === 'fs' || moduleName === 'fs-extra') {
|
||||
console.warn('The fs library is unavailable to mobile plugins. A non-functional mock will be returned.');
|
||||
return {
|
||||
existsSync: () => false,
|
||||
pathExists: () => false,
|
||||
readFileSync: () => '',
|
||||
readFile: () => '',
|
||||
writeFileSync: () => '',
|
||||
writeFile: () => '',
|
||||
appendFile: () => '',
|
||||
};
|
||||
}
|
||||
if (moduleName === 'process') {
|
||||
return {};
|
||||
}
|
||||
if (moduleName === 'url') {
|
||||
return { parse: (u: string) => new URL(u) };
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Unable to require module ${moduleName} on mobile.`);
|
||||
};
|
||||
|
||||
export { default as initializePluginBackgroundIframe } from './initializePluginBackgroundIframe';
|
||||
export { default as initializeDialogWebView } from './initializeDialogWebView';
|
||||
@@ -0,0 +1,98 @@
|
||||
import RemoteMessenger from '@joplin/lib/utils/ipc/RemoteMessenger';
|
||||
import { PluginMainProcessApi, PluginWebViewApi } from '../types';
|
||||
import WebViewToRNMessenger from '../../../utils/ipc/WebViewToRNMessenger';
|
||||
import WindowMessenger from '@joplin/lib/utils/ipc/WindowMessenger';
|
||||
import makeSandboxedIframe from '@joplin/lib/utils/dom/makeSandboxedIframe';
|
||||
|
||||
type PluginRecord = {
|
||||
iframe: HTMLIFrameElement;
|
||||
connectionToParent: RemoteMessenger<PluginWebViewApi, PluginMainProcessApi>|null;
|
||||
connectionToIframe: RemoteMessenger<PluginMainProcessApi, PluginWebViewApi>|null;
|
||||
};
|
||||
|
||||
const loadedPlugins: Record<string, PluginRecord> = Object.create(null);
|
||||
|
||||
export const stopPlugin = async (pluginId: string) => {
|
||||
if (!loadedPlugins[pluginId]) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadedPlugins[pluginId].connectionToIframe?.closeConnection?.();
|
||||
loadedPlugins[pluginId].connectionToParent?.closeConnection?.();
|
||||
|
||||
const iframe = loadedPlugins[pluginId].iframe;
|
||||
iframe.srcdoc = '';
|
||||
iframe.remove();
|
||||
delete loadedPlugins[pluginId];
|
||||
};
|
||||
|
||||
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";
|
||||
${pluginBackgroundScript}
|
||||
|
||||
(async () => {
|
||||
window.require = function(module) {
|
||||
return pluginBackgroundPage.requireModule(module, ${JSON.stringify(pluginId)});
|
||||
};
|
||||
window.exports = window.exports || {};
|
||||
|
||||
await pluginBackgroundPage.initializePluginBackgroundIframe(${JSON.stringify(messageChannelId)});
|
||||
|
||||
${pluginScript}
|
||||
})();
|
||||
`;
|
||||
const backgroundIframe = makeSandboxedIframe({ bodyHtml, headHtml: '', scripts: [initialJavaScript] }).iframe;
|
||||
|
||||
loadedPlugins[pluginId] = {
|
||||
iframe: backgroundIframe,
|
||||
connectionToParent: null,
|
||||
connectionToIframe: null,
|
||||
};
|
||||
|
||||
backgroundIframe.addEventListener('load', async () => {
|
||||
if (!loadedPlugins[pluginId]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const connectionToParent = new WebViewToRNMessenger<PluginWebViewApi, PluginMainProcessApi>(messageChannelId, null);
|
||||
const connectionToIframe = new WindowMessenger<PluginMainProcessApi, PluginWebViewApi>(
|
||||
messageChannelId, backgroundIframe.contentWindow, connectionToParent.remoteApi,
|
||||
);
|
||||
|
||||
// The two messengers are intermediate links in a chain (they serve to forward messages
|
||||
// between the parent and the iframe).
|
||||
connectionToParent.setIsChainedMessenger(true);
|
||||
connectionToIframe.setIsChainedMessenger(true);
|
||||
|
||||
connectionToParent.setLocalInterface(connectionToIframe.remoteApi);
|
||||
|
||||
loadedPlugins[pluginId].connectionToIframe = connectionToIframe;
|
||||
loadedPlugins[pluginId].connectionToParent = connectionToParent;
|
||||
}, { once: true });
|
||||
|
||||
document.body.appendChild(backgroundIframe);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
/** @jest-environment jsdom */
|
||||
import getFormData from './getFormData';
|
||||
|
||||
describe('getFormData', () => {
|
||||
afterEach(() => {
|
||||
// Remove all forms to prevent tests from conflicting -- getFormData
|
||||
// uses document.querySelectorAll to select all forms.
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
const addTextInputWithValue = (parent: HTMLElement, value: string, name: string) => {
|
||||
const input = document.createElement('input');
|
||||
input.name = name;
|
||||
input.value = value;
|
||||
parent.appendChild(input);
|
||||
};
|
||||
|
||||
test('should return data for all forms', () => {
|
||||
const testForm1 = document.createElement('form');
|
||||
testForm1.setAttribute('name', 'test-form-1');
|
||||
addTextInputWithValue(testForm1, 'Test', 'input');
|
||||
addTextInputWithValue(testForm1, 'Test2', 'another-input');
|
||||
document.body.appendChild(testForm1);
|
||||
|
||||
const testForm2 = document.createElement('form');
|
||||
testForm2.setAttribute('name', 'another-test-form');
|
||||
addTextInputWithValue(testForm2, 'Test2', 'text-input');
|
||||
document.body.appendChild(testForm2);
|
||||
|
||||
expect(getFormData()).toMatchObject({
|
||||
'test-form-1': {
|
||||
'input': 'Test',
|
||||
'another-input': 'Test2',
|
||||
},
|
||||
'another-test-form': {
|
||||
'text-input': 'Test2',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('should auto-number forms without a name', () => {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const testForm = document.createElement('form');
|
||||
addTextInputWithValue(testForm, `Form ${i}`, 'input');
|
||||
document.body.appendChild(testForm);
|
||||
}
|
||||
|
||||
const formData = getFormData();
|
||||
expect(Object.keys(formData)).toHaveLength(3);
|
||||
expect(formData['form-0']).toMatchObject({ 'input': 'Form 0' });
|
||||
expect(formData['form-1']).toMatchObject({ 'input': 'Form 1' });
|
||||
expect(formData['form-2']).toMatchObject({ 'input': 'Form 2' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
|
||||
const getFormData = () => {
|
||||
const forms = document.querySelectorAll('form');
|
||||
if (forms.length === 0) return null;
|
||||
|
||||
const serializeForm = (form: HTMLFormElement) => {
|
||||
const formData = new FormData(form);
|
||||
const serializedData: Record<string, FormDataEntryValue | null> = {};
|
||||
for (const key of formData.keys()) {
|
||||
serializedData[key] = formData.get(key);
|
||||
}
|
||||
return serializedData;
|
||||
};
|
||||
|
||||
const result = Object.create(null);
|
||||
let untitledFormId = 0;
|
||||
for (const form of forms) {
|
||||
const formId = form.getAttribute('name') || `form-${untitledFormId++}`;
|
||||
result[formId] = serializeForm(form);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
export default getFormData;
|
||||
@@ -0,0 +1,14 @@
|
||||
|
||||
type OnErrorCallback = (errorMessage: string)=> void;
|
||||
|
||||
const reportUnhandledErrors = (onError: OnErrorCallback) => {
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
onError(`Unhandled promise rejection: ${event.reason}. Promise: ${event.promise}.`);
|
||||
});
|
||||
|
||||
window.addEventListener('error', (event) => {
|
||||
onError(`Error: ${event.message}. ${event.error?.stack ?? ''}`);
|
||||
});
|
||||
};
|
||||
|
||||
export default reportUnhandledErrors;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { LogLevel } from '../../types';
|
||||
|
||||
/* eslint-disable no-console */
|
||||
|
||||
type OnLogCallback = (level: LogLevel, message: string)=> void;
|
||||
|
||||
const wrapConsoleLog = (onLog: OnLogCallback) => {
|
||||
// inOnLogCallback keeps track of whether onLog is currently being called or not.
|
||||
// This helps prevent StackOverflows if onLog itself calls console.log/console.error.
|
||||
let inOnLogCallback = false;
|
||||
|
||||
const wrapLogFunction = (key: keyof typeof console, logLevel: LogLevel) => {
|
||||
const originalLog = (console[key] as ((...args: unknown[])=> void) | undefined) ?? (()=>{});
|
||||
|
||||
console[key] = function(...args: unknown[]) {
|
||||
originalLog.call(this, ...args);
|
||||
if (inOnLogCallback) return;
|
||||
|
||||
// Work around
|
||||
// Uncaught (in promise) TypeError: Cannot convert object to primitive value
|
||||
// when console.log is called with an object that
|
||||
// can't be converted to a string.
|
||||
let argsString;
|
||||
try {
|
||||
argsString = args.join(' ');
|
||||
} catch (error) {
|
||||
argsString = `Error converting console arguments to string: ${error}`;
|
||||
}
|
||||
|
||||
// Long messages can be slow to transfer over IPC and make other messages difficult
|
||||
// to find/read in React Native's output.
|
||||
const maxLength = 4096;
|
||||
if (argsString.length > maxLength) {
|
||||
argsString = `${argsString.substring(0, maxLength)}...`;
|
||||
}
|
||||
|
||||
try {
|
||||
inOnLogCallback = true;
|
||||
onLog(logLevel, argsString);
|
||||
} finally {
|
||||
inOnLogCallback = false;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Assigning a generic (...args: unknown[]) function to a specific console method (which is the union of all method types) requires a cast through `any`
|
||||
} as any;
|
||||
};
|
||||
|
||||
wrapLogFunction('timeLog', LogLevel.Debug);
|
||||
wrapLogFunction('log', LogLevel.Debug);
|
||||
wrapLogFunction('warn', LogLevel.Warn);
|
||||
wrapLogFunction('info', LogLevel.Info);
|
||||
wrapLogFunction('error', LogLevel.Error);
|
||||
};
|
||||
|
||||
export default wrapConsoleLog;
|
||||
在新工单中引用
屏蔽一个用户