refactor: migrate PDF export to desktop runtime abstraction
- Extract desktop-specific logic into src/services/desktop.ts - Add runtime detection for Electron vs Tauri environments - Simplify NoteEditor by delegating PDF export to desktop service - Update printExport.ts to remove unused storage helpers - Add Electron main process files in electron/ directory - Update vite config and types for Electron integration - Update .gitignore and package.json for Electron workflow
This commit is contained in:
139
src/services/desktop.ts
Normal file
139
src/services/desktop.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
buildPrintDocument,
|
||||
PrintExportPayload,
|
||||
PRINT_EXPORT_QUERY_PARAM,
|
||||
} from '../printExport';
|
||||
|
||||
export interface DesktopMessageOptions {
|
||||
title?: string;
|
||||
kind?: 'info' | 'warning' | 'error';
|
||||
}
|
||||
|
||||
interface ExportPdfResult {
|
||||
canceled: boolean;
|
||||
filePath?: string;
|
||||
}
|
||||
|
||||
const isElectronRuntime = () =>
|
||||
typeof window !== 'undefined' && typeof window.electronDesktop !== 'undefined';
|
||||
|
||||
const isTauriRuntime = () =>
|
||||
typeof window !== 'undefined' &&
|
||||
(
|
||||
typeof (window as Window & { __TAURI__?: unknown }).__TAURI__ !== 'undefined' ||
|
||||
typeof (window as Window & { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__ !== 'undefined'
|
||||
);
|
||||
|
||||
export const getDesktopRuntime = () => {
|
||||
if (isElectronRuntime()) return 'electron';
|
||||
if (isTauriRuntime()) return 'tauri';
|
||||
return 'browser';
|
||||
};
|
||||
|
||||
export const showDesktopMessage = async (
|
||||
messageText: string,
|
||||
options: DesktopMessageOptions = {}
|
||||
) => {
|
||||
if (window.electronDesktop) {
|
||||
await window.electronDesktop.showMessage({
|
||||
message: messageText,
|
||||
...options,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTauriRuntime()) {
|
||||
const { message } = await import('@tauri-apps/plugin-dialog');
|
||||
await message(messageText, options);
|
||||
return;
|
||||
}
|
||||
|
||||
window.alert(messageText);
|
||||
};
|
||||
|
||||
export const exportPdfDocument = async (
|
||||
payload: PrintExportPayload
|
||||
): Promise<ExportPdfResult> => {
|
||||
if (window.electronDesktop) {
|
||||
return window.electronDesktop.exportPdf({
|
||||
...payload,
|
||||
documentHtml: buildPrintDocument(payload),
|
||||
});
|
||||
}
|
||||
|
||||
if (isTauriRuntime()) {
|
||||
await exportPdfDocumentWithTauri(payload);
|
||||
return { canceled: false };
|
||||
}
|
||||
|
||||
throw new Error('PDF export is only available in a desktop runtime.');
|
||||
};
|
||||
|
||||
async function exportPdfDocumentWithTauri(payload: PrintExportPayload) {
|
||||
const [{ emitTo, listen }, { WebviewWindow }] = await Promise.all([
|
||||
import('@tauri-apps/api/event'),
|
||||
import('@tauri-apps/api/webviewWindow'),
|
||||
]);
|
||||
|
||||
const jobId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const windowLabel = `print-export-${jobId}`;
|
||||
let printWindow: any = null;
|
||||
let unlistenReady: any = null;
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
unlistenReady?.();
|
||||
reject(new Error('Print view initialization timed out.'));
|
||||
}, 10000);
|
||||
|
||||
listen<{ jobId: string }>('print-export-ready', (event) => {
|
||||
if (settled || event.payload.jobId !== jobId) return;
|
||||
|
||||
settled = true;
|
||||
window.clearTimeout(timeoutId);
|
||||
unlistenReady?.();
|
||||
|
||||
void emitTo(windowLabel, 'print-export-payload', payload)
|
||||
.then(() => resolve())
|
||||
.catch(reject);
|
||||
})
|
||||
.then((unlisten) => {
|
||||
unlistenReady = unlisten;
|
||||
printWindow = new WebviewWindow(windowLabel, {
|
||||
url: `/?${PRINT_EXPORT_QUERY_PARAM}=${encodeURIComponent(jobId)}`,
|
||||
title: 'Opening Print Dialog',
|
||||
width: 960,
|
||||
height: 1100,
|
||||
center: true,
|
||||
visible: true,
|
||||
focus: true,
|
||||
skipTaskbar: true,
|
||||
resizable: false,
|
||||
parent: 'main',
|
||||
});
|
||||
|
||||
void printWindow.once('tauri://error', (event: { payload?: unknown }) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
window.clearTimeout(timeoutId);
|
||||
unlistenReady?.();
|
||||
reject(new Error(String(event.payload ?? 'Failed to create print window.')));
|
||||
});
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
} catch (error) {
|
||||
if (printWindow) {
|
||||
void printWindow.close().catch(() => undefined);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (typeof unlistenReady === 'function') {
|
||||
unlistenReady();
|
||||
}
|
||||
}
|
||||
}
|
||||
155
src/services/runtimeFetch.ts
Normal file
155
src/services/runtimeFetch.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
interface ElectronHttpRequest {
|
||||
url: string;
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
bodyText?: string;
|
||||
bodyBase64?: string;
|
||||
}
|
||||
|
||||
interface ElectronHttpResponse {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: Record<string, string>;
|
||||
bodyBase64: string;
|
||||
}
|
||||
|
||||
interface RuntimeResponse {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: {
|
||||
get(name: string): string | null;
|
||||
};
|
||||
text(): Promise<string>;
|
||||
json<T>(): Promise<T>;
|
||||
arrayBuffer(): Promise<ArrayBuffer>;
|
||||
blob(): Promise<Blob>;
|
||||
}
|
||||
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
const bytesToBase64 = (bytes: Uint8Array) => {
|
||||
let binary = '';
|
||||
|
||||
for (let index = 0; index < bytes.length; index += 0x8000) {
|
||||
const chunk = bytes.subarray(index, index + 0x8000);
|
||||
binary += String.fromCharCode(...chunk);
|
||||
}
|
||||
|
||||
return btoa(binary);
|
||||
};
|
||||
|
||||
const base64ToBytes = (value: string) => {
|
||||
if (!value) {
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
|
||||
const binary = atob(value);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
};
|
||||
|
||||
const normalizeHeaders = (headers: Record<string, string>) => {
|
||||
const normalized = new Map<string, string>();
|
||||
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
normalized.set(key.toLowerCase(), value);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const createRuntimeResponse = (response: ElectronHttpResponse): RuntimeResponse => {
|
||||
const headers = normalizeHeaders(response.headers);
|
||||
const bytes = base64ToBytes(response.bodyBase64);
|
||||
const contentType = headers.get('content-type') || '';
|
||||
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: {
|
||||
get(name: string) {
|
||||
return headers.get(name.toLowerCase()) ?? null;
|
||||
},
|
||||
},
|
||||
async text() {
|
||||
return textDecoder.decode(bytes);
|
||||
},
|
||||
async json<T>() {
|
||||
return JSON.parse(textDecoder.decode(bytes)) as T;
|
||||
},
|
||||
async arrayBuffer() {
|
||||
return bytes.buffer.slice(
|
||||
bytes.byteOffset,
|
||||
bytes.byteOffset + bytes.byteLength,
|
||||
) as ArrayBuffer;
|
||||
},
|
||||
async blob() {
|
||||
return new Blob([bytes], { type: contentType });
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const headersToObject = (headers?: HeadersInit) => {
|
||||
if (!headers) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return Object.fromEntries(new Headers(headers).entries());
|
||||
};
|
||||
|
||||
const serializeBody = async (body?: BodyInit | null) => {
|
||||
if (body == null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (typeof body === 'string') {
|
||||
return { bodyText: body };
|
||||
}
|
||||
|
||||
if (body instanceof ArrayBuffer) {
|
||||
return { bodyBase64: bytesToBase64(new Uint8Array(body)) };
|
||||
}
|
||||
|
||||
if (ArrayBuffer.isView(body)) {
|
||||
return {
|
||||
bodyBase64: bytesToBase64(
|
||||
new Uint8Array(body.buffer, body.byteOffset, body.byteLength),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (body instanceof Blob) {
|
||||
return {
|
||||
bodyBase64: bytesToBase64(new Uint8Array(await body.arrayBuffer())),
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('Unsupported request body for Electron runtime.');
|
||||
};
|
||||
|
||||
export const runtimeFetch = async (
|
||||
url: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<Response | RuntimeResponse> => {
|
||||
if (!window.electronDesktop?.httpRequest) {
|
||||
return fetch(url, init);
|
||||
}
|
||||
|
||||
const payload: ElectronHttpRequest = {
|
||||
url,
|
||||
method: init.method,
|
||||
headers: headersToObject(init.headers),
|
||||
...(await serializeBody(init.body)),
|
||||
};
|
||||
|
||||
const response = await window.electronDesktop.httpRequest(payload);
|
||||
return createRuntimeResponse(response);
|
||||
};
|
||||
Reference in New Issue
Block a user