From 3fb031078cb5cd54feca12a34e3d679bbff151f9 Mon Sep 17 00:00:00 2001 From: Crash-- Date: Tue, 17 Feb 2026 16:19:23 +0100 Subject: [PATCH 1/3] feat: Add CryptPad client-side OnlyOffice integration Enable opening and editing office documents (docx/xlsx/pptx) without a remote OnlyOffice Document Server by using CryptPad's client-side OnlyOffice wrapper and x2t-wasm converter. - Add CryptPadView component with mock server protocol handling - Add x2t-wasm converter for Office <-> OnlyOffice internal format - Add useCryptPadConfig hook for client-side document loading - Add isCryptPadEnabled() helper and feature flag toggle - Support debounced auto-save back to cozy-stack - Split Editor into CryptPad/Server editor variants --- rsbuild.config.mjs | 4 + src/lib/flags.js | 1 + src/modules/views/OnlyOffice/Editor.jsx | 39 +- src/modules/views/OnlyOffice/Editor.spec.jsx | 3 +- .../OnlyOffice/cryptpad/CryptPadView.jsx | 482 ++++++++++++++++++ .../views/OnlyOffice/cryptpad/converter.js | 233 +++++++++ .../OnlyOffice/cryptpad/useCryptPadConfig.jsx | 146 ++++++ src/modules/views/OnlyOffice/helpers.js | 18 + 8 files changed, 917 insertions(+), 9 deletions(-) create mode 100644 src/modules/views/OnlyOffice/cryptpad/CryptPadView.jsx create mode 100644 src/modules/views/OnlyOffice/cryptpad/converter.js create mode 100644 src/modules/views/OnlyOffice/cryptpad/useCryptPadConfig.jsx diff --git a/rsbuild.config.mjs b/rsbuild.config.mjs index 83a2b7385e..49e2a12ac4 100644 --- a/rsbuild.config.mjs +++ b/rsbuild.config.mjs @@ -20,6 +20,10 @@ const mergedConfig = mergeRsbuildConfig(config, { { from: 'src/assets/favicons', to: 'favicons' + }, + { + from: 'vendor/cryptpad-onlyoffice', + to: 'vendor/cryptpad-onlyoffice' } ] } diff --git a/src/lib/flags.js b/src/lib/flags.js index e13dad84d5..7c08385d4a 100644 --- a/src/lib/flags.js +++ b/src/lib/flags.js @@ -29,4 +29,5 @@ const flagsList = () => { flag('drive.hide-nextcloud-dev') flag('drive.keyboard-shortcuts.enabled', true) flag('drive.highlight-new-items.enabled', true) + flag('drive.office.cryptpad.enabled', true) } diff --git a/src/modules/views/OnlyOffice/Editor.jsx b/src/modules/views/OnlyOffice/Editor.jsx index d18750f17f..809e4915d1 100644 --- a/src/modules/views/OnlyOffice/Editor.jsx +++ b/src/modules/views/OnlyOffice/Editor.jsx @@ -10,6 +10,8 @@ import Loading from '@/modules/views/OnlyOffice/Loading' import { useOnlyOfficeContext } from '@/modules/views/OnlyOffice/OnlyOfficeProvider' import Title from '@/modules/views/OnlyOffice/Title' import View from '@/modules/views/OnlyOffice/View' +import CryptPadView from '@/modules/views/OnlyOffice/cryptpad/CryptPadView' +import useCryptPadConfig from '@/modules/views/OnlyOffice/cryptpad/useCryptPadConfig' import { FileDeletedModal } from '@/modules/views/OnlyOffice/components/FileDeletedModal' import { FileDivergedModal } from '@/modules/views/OnlyOffice/components/FileDivergedModal' import { @@ -17,6 +19,7 @@ import { DEFAULT_EDITOR_TOOLBAR_HEIGHT } from '@/modules/views/OnlyOffice/config' import useConfig from '@/modules/views/OnlyOffice/useConfig' +import { isCryptPadEnabled } from '@/modules/views/OnlyOffice/helpers' const getEditorToolbarHeight = editorToolbarHeightFlag => { if (Number.isInteger(editorToolbarHeightFlag)) { @@ -28,8 +31,7 @@ const getEditorToolbarHeight = editorToolbarHeightFlag => { } } -export const Editor = () => { - const { config, status } = useConfig() +const EditorContent = ({ config, status, isCryptPad }) => { const { isEditorModeView, hasFileDiverged, @@ -42,7 +44,7 @@ export const Editor = () => { if (status === 'error') return if (status !== 'loaded' || !config) return - const { serverUrl, apiUrl, docEditorConfig } = config + const { apiUrl, docEditorConfig } = config const editorToolbarHeight = getEditorToolbarHeight( flag('drive.onlyoffice.editorToolbarHeight') @@ -60,11 +62,15 @@ export const Editor = () => { } className="u-flex u-flex-column u-p-0" > - + {isCryptPad ? ( + + ) : ( + + )} {hasFileDiverged ? : null} {hasFileDeleted ? : null} @@ -72,4 +78,21 @@ export const Editor = () => { ) } +const CryptPadEditor = () => { + const { config, status } = useCryptPadConfig() + return +} + +const ServerEditor = () => { + const { config, status } = useConfig() + return +} + +export const Editor = () => { + if (isCryptPadEnabled()) { + return + } + return +} + export default Editor diff --git a/src/modules/views/OnlyOffice/Editor.spec.jsx b/src/modules/views/OnlyOffice/Editor.spec.jsx index f76e57af06..5a74c6eaac 100644 --- a/src/modules/views/OnlyOffice/Editor.spec.jsx +++ b/src/modules/views/OnlyOffice/Editor.spec.jsx @@ -24,7 +24,8 @@ jest.mock('cozy-client/dist/hooks/useFetchJSON', () => ({ jest.mock('modules/views/OnlyOffice/helpers', () => ({ ...jest.requireActual('modules/views/OnlyOffice/helpers'), isOfficeEnabled: jest.fn(), - isOfficeEditingEnabled: jest.fn() + isOfficeEditingEnabled: jest.fn(), + isCryptPadEnabled: jest.fn().mockReturnValue(false) })) jest.mock('cozy-ui/transpiled/react/providers/Breakpoints', () => ({ diff --git a/src/modules/views/OnlyOffice/cryptpad/CryptPadView.jsx b/src/modules/views/OnlyOffice/cryptpad/CryptPadView.jsx new file mode 100644 index 0000000000..8c042ff060 --- /dev/null +++ b/src/modules/views/OnlyOffice/cryptpad/CryptPadView.jsx @@ -0,0 +1,482 @@ +import PropTypes from 'prop-types' +import React, { useEffect, useCallback, useState, useRef } from 'react' + +import { useClient } from 'cozy-client' +import Spinner from 'cozy-ui/transpiled/react/Spinner' + +import { DOCTYPE_FILES } from '@/lib/doctypes' +import Error from '@/modules/views/OnlyOffice/Error' +import { useOnlyOfficeContext } from '@/modules/views/OnlyOffice/OnlyOfficeProvider' +import { convertFromInternal } from '@/modules/views/OnlyOffice/cryptpad/converter' + +const EDITOR_IFRAME_NAME = 'frameEditor' + +/** + * Get the internal OnlyOffice editor instance from the iframe. + * The editor lives inside the first iframe created by DocsAPI.DocEditor. + * sdkjs exposes the editor as a global variable in the iframe window. + */ +const getOOEditor = () => { + const iframe = document.getElementsByName(EDITOR_IFRAME_NAME)[0] + if (!iframe || !iframe.contentWindow) return null + + const win = iframe.contentWindow + + // OnlyOffice exposes different editor objects depending on document type. + // Log available objects for debugging. + const found = + win.editor || win.editorCell || win.editorPresentation || null + + if (!found) { + // Look for the editor API on the Asc global (sdkjs exposes it there) + const api = + win.Asc && (win.Asc.editor || win.Asc.spreadsheet_api || null) + if (api) return api + + console.warn('[CryptPad] Editor not found on iframe window. Available keys:', + Object.keys(win).filter(k => + k.toLowerCase().includes('editor') || + k.toLowerCase().includes('asc') || + k.toLowerCase().includes('api') + ) + ) + } + + return found +} + +/** + * Get the file extension from the filename. + */ +const getFileExtension = name => { + const parts = name.split('.') + return parts.length > 1 ? parts.pop().toLowerCase() : '' +} + +/** + * Intercept XMLHttpRequest to handle /downloadas/ requests locally. + * The OnlyOffice editor tries to save via HTTP to a Document Server that + * doesn't exist in CryptPad mode. We intercept these requests and return + * a fake success response to prevent 405 errors. + */ +const patchXHRForDownloadAs = () => { + const OrigXHR = window.XMLHttpRequest + const patchedOpen = OrigXHR.prototype.open + + if (patchedOpen._cryptpadPatched) return + + const originalOpen = patchedOpen + OrigXHR.prototype.open = function (method, url, ...args) { + if (typeof url === 'string' && url.includes('/downloadas/')) { + console.log('[CryptPad] Intercepted /downloadas/ request:', url) + this._isCryptPadIntercepted = true + } + return originalOpen.call(this, method, url, ...args) + } + OrigXHR.prototype.open._cryptpadPatched = true + + const originalSend = OrigXHR.prototype.send + OrigXHR.prototype.send = function (...args) { + if (this._isCryptPadIntercepted) { + // Simulate a successful empty response + Object.defineProperty(this, 'status', { value: 200 }) + Object.defineProperty(this, 'readyState', { value: 4 }) + Object.defineProperty(this, 'responseText', { value: '{}' }) + setTimeout(() => { + if (typeof this.onload === 'function') this.onload() + this.dispatchEvent(new Event('load')) + this.dispatchEvent(new Event('loadend')) + }, 0) + return + } + return originalSend.call(this, ...args) + } +} + +/** + * CryptPadView renders the OnlyOffice editor using CryptPad's client-side + * wrapper (onlyoffice-editor). It replaces the standard View.jsx when + * CryptPad mode is enabled. + * + * Key differences from legacy View.jsx: + * - Loads the SDK from local vendor/ directory instead of a remote server + * - Uses connectMockServer() to handle the OnlyOffice protocol locally + * - No server callbacks — saves directly to cozy-stack via cozy-client + */ +const CryptPadView = ({ apiUrl, docEditorConfig }) => { + const [isError, setIsError] = useState(false) + const docEditorRef = useRef(null) + const saveTimerRef = useRef(null) + const client = useClient() + + const { isEditorReady, setIsEditorReady, editorMode, fileId, file } = + useOnlyOfficeContext() + + // Use a ref to always have the current editorMode in message handlers + const editorModeRef = useRef(editorMode) + useEffect(() => { + editorModeRef.current = editorMode + }, [editorMode]) + + /** + * Save the current document back to cozy-stack. + * Gets the .bin content from the editor, converts it back to the + * original format (docx/xlsx/pptx), and uploads it. + */ + const saveDocument = useCallback(async () => { + console.log('[CryptPad] saveDocument called, mode:', editorModeRef.current) + if (editorModeRef.current !== 'edit') return + + const editor = getOOEditor() + console.log('[CryptPad] editor instance:', editor ? 'found' : 'NOT FOUND') + if (!editor) { + console.warn('[CryptPad] Cannot save: editor not available') + return + } + + try { + // Get the document content from the editor. + // asc_nativeGetFile2 returns a base64-encoded string of the internal + // DOCY/XLSY/PPTY format. We try multiple methods as fallbacks. + let exportData = null + for (const method of ['asc_nativeGetFile2', 'asc_nativeGetFile', 'asc_nativeGetFile3']) { + try { + if (typeof editor[method] !== 'function') continue + const result = editor[method]() + if (result && (result.byteLength || result.length) > 0) { + exportData = result + console.log(`[CryptPad] ${method} returned ${typeof result}, length: ${result.byteLength || result.length}`) + break + } + } catch (e) { + console.warn(`[CryptPad] ${method} failed:`, e.message) + } + } + + if (!exportData) { + console.warn('[CryptPad] Cannot save: no export method returned data') + return + } + + // The editor returns a base64-encoded string of the internal format + // (starts with "DOCY;", "XLSY;", or "PPTY;"). Decode it to binary. + let rawData + if (typeof exportData === 'string') { + const binaryString = atob(exportData) + rawData = new Uint8Array(binaryString.length) + for (let i = 0; i < binaryString.length; i++) { + rawData[i] = binaryString.charCodeAt(i) + } + console.log('[CryptPad] Decoded base64 →', rawData.byteLength, 'bytes, header:', String.fromCharCode(...rawData.slice(0, 12))) + } else { + // Typed array from iframe — copy cross-frame + const len = exportData.byteLength ?? exportData.length ?? 0 + rawData = new Uint8Array(len) + for (let i = 0; i < len; i++) { + rawData[i] = exportData[i] + } + } + + const ext = getFileExtension(file.name) + + // Convert internal format back to the original Office format + const fileData = await convertFromInternal(rawData, ext) + console.log('[CryptPad] Converted back to', ext, ':', fileData.byteLength, 'bytes') + + // Verify fileData is a valid main-frame Uint8Array + console.log('[CryptPad] fileData type:', fileData.constructor.name, + 'instanceof Uint8Array:', fileData instanceof Uint8Array, + 'byteLength:', fileData.byteLength, + 'first 4 bytes:', Array.from(fileData.slice(0, 4))) + + const blob = new Blob([fileData], { type: file.mime }) + console.log('[CryptPad] Blob created:', blob.size, 'bytes, type:', blob.type) + + // Upload back to cozy-stack, overwriting the existing file + const resp = await client + .collection(DOCTYPE_FILES) + .updateFile(blob, { + fileId, + name: file.name, + contentLength: blob.size + }) + console.log('[CryptPad] Save response:', JSON.stringify({ + id: resp?.data?.id || resp?.data?._id, + size: resp?.data?.size || resp?.data?.attributes?.size, + rev: resp?.data?._rev || resp?.data?.meta?.rev, + name: resp?.data?.name || resp?.data?.attributes?.name + })) + } catch (error) { + console.error('[CryptPad] Failed to save document:', error) + } + }, [client, file, fileId]) + + /** + * Schedule a debounced save. Called after each `saveChanges` message + * from the editor. Waits 2 seconds of inactivity before saving to + * avoid uploading on every keystroke. + */ + const debouncedSave = useCallback(() => { + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current) + } + saveTimerRef.current = setTimeout(() => { + saveTimerRef.current = null + saveDocument() + }, 2000) + }, [saveDocument]) + + /** + * Initialize the CryptPad-wrapped OnlyOffice editor. + * The wrapper's api.js replaces DocsAPI.DocEditor with its own class + * that supports connectMockServer(). + */ + const initEditor = useCallback(() => { + try { + // CryptPad's wrapper expects window.APP to exist — it sets + // window.APP.getImageURL during connectMockServer(). + if (!window.APP) { + window.APP = {} + } + + // Intercept /downloadas/ HTTP requests that the editor makes + // when trying to save through the (non-existent) Document Server. + patchXHRForDownloadAs() + + // Also patch the iframe's XHR once it's created + const patchIframeXHR = () => { + const iframe = document.getElementsByName(EDITOR_IFRAME_NAME)[0] + if (iframe && iframe.contentWindow) { + const IframeXHR = iframe.contentWindow.XMLHttpRequest + if (IframeXHR && !IframeXHR.prototype.open._cryptpadPatched) { + const origOpen = IframeXHR.prototype.open + IframeXHR.prototype.open = function (method, url, ...args) { + if (typeof url === 'string' && url.includes('/downloadas/')) { + console.log('[CryptPad] Intercepted iframe /downloadas/ request') + this._isCryptPadIntercepted = true + } + return origOpen.call(this, method, url, ...args) + } + IframeXHR.prototype.open._cryptpadPatched = true + + const origSend = IframeXHR.prototype.send + IframeXHR.prototype.send = function (...args) { + if (this._isCryptPadIntercepted) { + Object.defineProperty(this, 'status', { value: 200 }) + Object.defineProperty(this, 'readyState', { value: 4 }) + Object.defineProperty(this, 'responseText', { value: '{}' }) + setTimeout(() => { + if (typeof this.onload === 'function') this.onload() + this.dispatchEvent(new Event('load')) + this.dispatchEvent(new Event('loadend')) + }, 0) + return + } + return origSend.call(this, ...args) + } + } + } + } + + const editor = new window.DocsAPI.DocEditor( + 'onlyOfficeEditor', + docEditorConfig + ) + docEditorRef.current = editor + + // Connect the mock server that replaces OnlyOffice Document Server. + // This handles the auth handshake, document loading, and message routing. + editor.connectMockServer({ + getParticipants: () => ({ + index: 0, + list: [ + { + id: 1, + idOriginal: 'user-1', + username: 'User', + indexUser: 0, + connectionId: 'conn-1', + isCloseCoAuthoring: false, + view: editorModeRef.current === 'view' + } + ] + }), + + onAuth: () => { + // Auth completed — mark editor as ready. + setIsEditorReady(true) + // Patch iframe XHR after the iframe is fully set up + setTimeout(patchIframeXHR, 1000) + }, + + onMessage: msg => { + console.log('[CryptPad] Message from editor:', msg.type) + + // Handle messages from OnlyOffice editor + switch (msg.type) { + case 'isSaveLock': + // Respond that save is not locked + editor.sendMessageToOO({ + type: 'saveLock', + saveLock: false + }) + break + + case 'saveChanges': + // Single-user mode: acknowledge the save immediately + editor.sendMessageToOO({ + type: 'unSaveLock', + index: msg.changesIndex, + time: Date.now() + }) + // Schedule a debounced save to cozy-stack + debouncedSave() + break + + case 'getLock': + // Single-user mode: grant lock immediately + editor.sendMessageToOO({ + type: 'getLock', + locks: [] + }) + break + + case 'getMessages': + // No chat in single-user mode + editor.sendMessageToOO({ type: 'message' }) + break + + case 'unLockDocument': + // Document unlocked after editing — trigger save + saveDocument() + break + + case 'forceSaveStart': + // Manual save triggered (Ctrl+S) + saveDocument() + editor.sendMessageToOO({ + type: 'forceSave', + success: true + }) + break + + default: + break + } + } + }) + } catch (error) { + console.error('Failed to initialize CryptPad editor:', error) + setIsError(true) + } + }, [docEditorConfig, saveDocument, debouncedSave, setIsEditorReady]) + + const handleError = useCallback(() => { + setIsError(true) + }, []) + + // Load the CryptPad-wrapped OnlyOffice SDK, then initialize the editor. + useEffect(() => { + let cancelled = false + + const waitForDocsAPI = () => { + return new Promise((resolve, reject) => { + if (window.DocsAPI && window.DocsAPI.DocEditor) { + resolve() + return + } + let attempts = 0 + const maxAttempts = 100 + const interval = setInterval(() => { + if (cancelled) { + clearInterval(interval) + return + } + if (window.DocsAPI && window.DocsAPI.DocEditor) { + clearInterval(interval) + resolve() + } else if (++attempts >= maxAttempts) { + clearInterval(interval) + reject(new Error('Timed out waiting for OnlyOffice SDK')) + } + }, 100) + }) + } + + const loadAndInit = async () => { + try { + const scriptId = 'cryptpad-onlyoffice-sdk' + const scriptAlreadyLoaded = document.getElementById(scriptId) + + if (!scriptAlreadyLoaded) { + await new Promise((resolve, reject) => { + const script = document.createElement('script') + script.id = scriptId + script.src = apiUrl + script.async = true + script.onload = resolve + script.onerror = () => + reject(new Error('Failed to load OnlyOffice SDK')) + document.body.appendChild(script) + }) + } + + await waitForDocsAPI() + + if (!cancelled) { + initEditor() + } + } catch (error) { + console.error('SDK loading failed:', error) + if (!cancelled) { + handleError() + } + } + } + + loadAndInit() + + return () => { + cancelled = true + } + }, [apiUrl, initEditor, handleError]) + + // Cleanup on unmount + useEffect(() => { + return () => { + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current) + } + if (docEditorRef.current) { + try { + docEditorRef.current.destroyEditor() + } catch (e) { + // Ignore cleanup errors + } + docEditorRef.current = null + } + } + }, []) + + if (isError) return + + return ( + <> + {!isEditorReady && ( +
+ +
+ )} +
+
+
+ + ) +} + +CryptPadView.propTypes = { + apiUrl: PropTypes.string.isRequired, + docEditorConfig: PropTypes.object.isRequired +} + +export default React.memo(CryptPadView) diff --git a/src/modules/views/OnlyOffice/cryptpad/converter.js b/src/modules/views/OnlyOffice/cryptpad/converter.js new file mode 100644 index 0000000000..76f0f38afe --- /dev/null +++ b/src/modules/views/OnlyOffice/cryptpad/converter.js @@ -0,0 +1,233 @@ +/** + * x2t-wasm converter for converting between Office formats and OnlyOffice + * internal .bin format. + * + * Uses CryptPad's build of OnlyOffice x2t compiled to WebAssembly. + * The WASM module runs entirely in the browser — no server needed. + */ + +let x2tModule = null + +const FORMAT_CODES = { + // Input formats + docx: 65, + xlsx: 257, + pptx: 129, + // Legacy formats + doc: 69, + xls: 258, + ppt: 132, + // Internal OnlyOffice binary (canvas / Editor.bin) + doct: 8193, // 0x2001 — Editor.bin for word + xlst: 8194, // 0x2002 — Editor.bin for cell + pptt: 8195, // 0x2003 — Editor.bin for slide + // Open Document formats + odt: 67, + ods: 259, + odp: 130 +} + +const EXT_TO_DOC_TYPE = { + docx: 'doct', + doc: 'doct', + odt: 'doct', + xlsx: 'xlst', + xls: 'xlst', + ods: 'xlst', + pptx: 'pptt', + ppt: 'pptt', + odp: 'pptt' +} + +const DOC_TYPE_EXT = { + doct: 'bin', + xlst: 'bin', + pptt: 'bin' +} + +/** + * Lazy-load and initialize the x2t WASM module. + * The module is cached after first load. + */ +async function initX2T() { + if (x2tModule) return x2tModule + + // x2t.js is served as a static asset from the vendor directory. + // It's an Emscripten module that sets up a global `Module` variable. + // We load it in an iframe to avoid polluting the main window scope. + return new Promise((resolve, reject) => { + const iframe = document.createElement('iframe') + iframe.style.display = 'none' + iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin') + document.body.appendChild(iframe) + + const iframeWindow = iframe.contentWindow + + // Set up Module config before loading the script + iframeWindow.Module = { + noInitialRun: true, + noExitRuntime: true, + onRuntimeInitialized: () => { + const module = iframeWindow.Module + // Set up virtual filesystem directories + try { + module.FS.mkdir('/working') + } catch (e) { + // Directory may already exist + } + try { + module.FS.mkdir('/working/media') + } catch (e) { + // ignore + } + try { + module.FS.mkdir('/working/fonts') + } catch (e) { + // ignore + } + + x2tModule = { module, iframe } + resolve(x2tModule) + } + } + + const script = iframeWindow.document.createElement('script') + // Use a full absolute URL so x2t.js's `new URL(src)` call works. + // x2t.js uses `new URL(document.currentScript.src).search` to locate + // the companion .wasm file, which requires a fully qualified URL. + script.src = new URL('/vendor/cryptpad-onlyoffice/x2t/x2t.js', window.location.origin).href + script.onerror = () => { + iframe.remove() + reject(new Error('Failed to load x2t WASM module')) + } + iframeWindow.document.body.appendChild(script) + }) +} + +/** + * Build the XML params file that x2t expects for conversion. + */ +function buildParamsXML(inputPath, outputPath, inputFormat, outputFormat) { + return [ + '', + '', + `${inputPath}`, + `${outputPath}`, + `${FORMAT_CODES[inputFormat]}`, + `${FORMAT_CODES[outputFormat]}`, + 'true', + '/working/themes', + '/working/fonts', + '' + ].join('\n') +} + +/** + * Convert a file from one format to another using x2t WASM. + * + * @param {Uint8Array} inputData - The raw file bytes + * @param {string} inputFormat - Source format (e.g. 'docx', 'xlsx', 'pptx') + * @param {string} outputFormat - Target format (e.g. 'doct', 'xlst', 'pptt') + * @returns {Promise} The converted file bytes + */ +export async function convert(inputData, inputFormat, outputFormat) { + const { module } = await initX2T() + + // Use unique file paths per conversion to avoid race conditions + const conversionId = Math.random().toString(36).substring(2, 10) + const inputExt = DOC_TYPE_EXT[inputFormat] || inputFormat + const outputExt = DOC_TYPE_EXT[outputFormat] || outputFormat + const inputPath = `/working/input-${conversionId}.${inputExt}` + const outputPath = `/working/output-${conversionId}.${outputExt}` + const paramsPath = `/working/params-${conversionId}.xml` + + // Write input file to Emscripten virtual FS + module.FS.writeFile(inputPath, inputData) + + // Write conversion params + const params = buildParamsXML(inputPath, outputPath, inputFormat, outputFormat) + module.FS.writeFile(paramsPath, params) + + // Run conversion + const result = module.ccall('main1', 'number', ['string'], [paramsPath]) + + if (result !== 0) { + // Clean up + try { module.FS.unlink(inputPath) } catch (e) { /* ignore */ } + try { module.FS.unlink(paramsPath) } catch (e) { /* ignore */ } + throw new Error(`x2t conversion failed with code ${result}`) + } + + // Read output — copy to main frame to avoid cross-frame typed array issues + const iframeOutput = module.FS.readFile(outputPath) + const outputData = new Uint8Array(iframeOutput.length) + outputData.set(iframeOutput) + + // Clean up virtual FS + try { module.FS.unlink(inputPath) } catch (e) { /* ignore */ } + try { module.FS.unlink(outputPath) } catch (e) { /* ignore */ } + try { module.FS.unlink(paramsPath) } catch (e) { /* ignore */ } + + return outputData +} + +/** + * Convert an Office file (docx/xlsx/pptx) to OnlyOffice internal .bin format. + * + * @param {Uint8Array} fileData - Raw file bytes + * @param {string} fileExt - File extension without dot (e.g. 'docx') + * @returns {Promise} The .bin content + */ +export async function convertToInternal(fileData, fileExt) { + const internalFormat = EXT_TO_DOC_TYPE[fileExt] + if (!internalFormat) { + throw new Error(`Unsupported file extension: ${fileExt}`) + } + return convert(fileData, fileExt, internalFormat) +} + +/** + * Convert from OnlyOffice internal .bin format back to an Office file. + * + * @param {Uint8Array} binData - The .bin content from editor + * @param {string} targetExt - Target extension (e.g. 'docx', 'xlsx', 'pptx') + * @returns {Promise} The converted file bytes + */ +export async function convertFromInternal(binData, targetExt) { + const internalFormat = EXT_TO_DOC_TYPE[targetExt] + if (!internalFormat) { + throw new Error(`Unsupported target extension: ${targetExt}`) + } + return convert(binData, internalFormat, targetExt) +} + +/** + * Get the OnlyOffice document type from a file extension. + * + * @param {string} ext - File extension without dot + * @returns {'word'|'cell'|'slide'} The documentType for DocEditor config + */ +export function getDocumentType(ext) { + const map = { + docx: 'word', + doc: 'word', + odt: 'word', + xlsx: 'cell', + xls: 'cell', + ods: 'cell', + pptx: 'slide', + ppt: 'slide', + odp: 'slide' + } + return map[ext] || 'word' +} + +/** + * Destroy the x2t module and clean up the iframe. + */ +export function destroyConverter() { + if (x2tModule) { + x2tModule.iframe.remove() + x2tModule = null + } +} diff --git a/src/modules/views/OnlyOffice/cryptpad/useCryptPadConfig.jsx b/src/modules/views/OnlyOffice/cryptpad/useCryptPadConfig.jsx new file mode 100644 index 0000000000..d2c13562bb --- /dev/null +++ b/src/modules/views/OnlyOffice/cryptpad/useCryptPadConfig.jsx @@ -0,0 +1,146 @@ +import { useEffect, useState, useCallback, useRef } from 'react' + +import { useClient } from 'cozy-client' + +import { useOnlyOfficeContext } from '@/modules/views/OnlyOffice/OnlyOfficeProvider' +import { + convertToInternal, + getDocumentType, + destroyConverter +} from '@/modules/views/OnlyOffice/cryptpad/converter' + +/** + * Get file extension from filename. + * @param {string} name + * @returns {string} + */ +const getFileExtension = name => { + const parts = name.split('.') + return parts.length > 1 ? parts.pop().toLowerCase() : '' +} + +/** + * Hook that replaces useConfig for CryptPad mode. + * + * Instead of calling the /office API and relying on an OnlyOffice server, + * this hook: + * 1. Downloads the file directly via cozy-client + * 2. Converts it to OnlyOffice internal .bin format using x2t-wasm + * 3. Creates a blob URL for the .bin content + * 4. Builds a DocEditor config compatible with CryptPad's onlyoffice-editor wrapper + * + * Unlike the server-mode useConfig, this hook does NOT reset config when + * editor mode changes. The CryptPad-wrapped editor handles mode switching + * internally and the blob URL must remain valid for the editor's lifetime. + */ +const useCryptPadConfig = () => { + const { fileId, editorMode, setOfficeKey, file } = useOnlyOfficeContext() + + const client = useClient() + const [config, setConfig] = useState(null) + const [status, setStatus] = useState('loading') + const blobUrlRef = useRef(null) + const loadedRef = useRef(false) + + const loadDocument = useCallback(async () => { + // Only load once — the editor keeps the document in memory after that + if (!file || loadedRef.current) return + loadedRef.current = true + + try { + setStatus('loading') + + // Download file content directly from cozy-stack. + // Use cache: 'no-store' to avoid serving stale content after edits. + const downloadResp = await client.stackClient.fetch( + 'GET', + `/files/download/${fileId}`, + undefined, + { headers: { Accept: '*/*' }, cache: 'no-store' } + ) + const arrayBuffer = await downloadResp.arrayBuffer() + const fileData = new Uint8Array(arrayBuffer) + console.log('[CryptPad] Downloaded file:', fileData.byteLength, 'bytes') + + const ext = getFileExtension(file.name) + const documentType = getDocumentType(ext) + + // Convert to OnlyOffice internal .bin format + const binData = await convertToInternal(fileData, ext) + + console.log('[CryptPad] Conversion result:', { + inputSize: fileData.byteLength, + outputSize: binData.byteLength, + firstBytes: Array.from(binData.slice(0, 16)) + }) + + const binBlob = new Blob([binData], { type: 'application/octet-stream' }) + const url = URL.createObjectURL(binBlob) + blobUrlRef.current = url + + setOfficeKey(fileId) + + // Build the editor API URL (served from vendor directory) + const apiUrl = '/vendor/cryptpad-onlyoffice/editor/web-apps/apps/api/documents/api.js' + + // Build DocEditor config compatible with CryptPad's wrapper. + // Mode is set to 'edit' so the editor loads with full capabilities. + // View-only restriction is handled by the OnlyOffice UI layer. + const docEditorConfig = { + document: { + fileType: ext, + key: fileId, + title: file.name, + url, // blob URL to the .bin content + permissions: { + download: false, + print: true + } + }, + documentType, + editorConfig: { + mode: editorMode, + lang: document.documentElement.lang || 'en', + customization: { + compactHeader: true, + chat: false, + comments: false, + reviewDisplay: 'markup', + hideRightMenu: true + } + } + } + + setConfig({ + apiUrl, + docEditorConfig, + isCryptPad: true + }) + setStatus('loaded') + } catch (error) { + console.error('CryptPad config loading failed:', error) + loadedRef.current = false + setStatus('error') + } + }, [file, fileId, client, editorMode, setOfficeKey]) + + useEffect(() => { + loadDocument() + }, [loadDocument]) + + // Clean up blob URL and converter on unmount only. + // The blob URL must stay valid for the editor's entire lifetime. + useEffect(() => { + return () => { + if (blobUrlRef.current) { + URL.revokeObjectURL(blobUrlRef.current) + blobUrlRef.current = null + } + destroyConverter() + } + }, []) + + return { config, status } +} + +export default useCryptPadConfig diff --git a/src/modules/views/OnlyOffice/helpers.js b/src/modules/views/OnlyOffice/helpers.js index 83fb1d5ee5..8bc73d0371 100644 --- a/src/modules/views/OnlyOffice/helpers.js +++ b/src/modules/views/OnlyOffice/helpers.js @@ -11,6 +11,10 @@ import FileTypeTextIcon from 'cozy-ui/transpiled/react/Icons/FileTypeText' * @returns {boolean} - Returns true if the Office feature is enabled, false otherwise. */ export const isOfficeEnabled = isDesktop => { + console.log('isOfficeEnabled', flag('drive.office.cryptpad.enabled')) + // CryptPad mode enables office editing without needing a server + if (flag('drive.office.cryptpad.enabled') === true) return true + const officeEnabled = flag( `drive.office.${!isDesktop || isMobile() ? 'touchScreen.' : ''}enabled` ) @@ -25,6 +29,9 @@ export const isOfficeEnabled = isDesktop => { } export function canWriteOfficeDocument() { + // CryptPad mode supports editing + if (flag('drive.office.cryptpad.enabled') === true) return true + const officeWrite = flag('drive.office.write') if (officeWrite !== null) { return officeWrite @@ -166,6 +173,17 @@ export const makeMimeByClass = fileClass => { return mimeByClass[fileClass] } +/** + * Checks if CryptPad mode is enabled. + * When enabled, office documents are opened using CryptPad's client-side + * OnlyOffice wrapper instead of a remote OnlyOffice Document Server. + * + * @returns {boolean} + */ +export const isCryptPadEnabled = () => { + return flag('drive.office.cryptpad.enabled') === true +} + // The sharing banner need to be shown only on the first arrival // and not after browsing inside a folder // When it comes from cozy to cozy sharing, we don't want the banner at all From 36237fa77e8967865b34cd1a6b3e519556441f5f Mon Sep 17 00:00:00 2001 From: Crash-- Date: Tue, 17 Feb 2026 16:23:26 +0100 Subject: [PATCH 2/3] fix: Suppress file diverged modal in CryptPad mode In CryptPad mode, saves are done directly by the client via cozy-client, not by the OnlyOffice server. The realtime listener detects the md5sum change and incorrectly warns about external modifications. --- src/modules/views/OnlyOffice/Editor.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/views/OnlyOffice/Editor.jsx b/src/modules/views/OnlyOffice/Editor.jsx index 809e4915d1..c8b687fd04 100644 --- a/src/modules/views/OnlyOffice/Editor.jsx +++ b/src/modules/views/OnlyOffice/Editor.jsx @@ -71,7 +71,7 @@ const EditorContent = ({ config, status, isCryptPad }) => { docEditorConfig={docEditorConfig} /> )} - {hasFileDiverged ? : null} + {hasFileDiverged && !isCryptPad ? : null} {hasFileDeleted ? : null} From 1a7f6b8da269507516c1bed976b0d4ef7017d5f1 Mon Sep 17 00:00:00 2001 From: Crash-- Date: Fri, 20 Feb 2026 14:37:21 +0100 Subject: [PATCH 3/3] feat: Add script to install cryptpad --- .gitignore | 3 +++ scripts/install-cryptpad-vendor.sh | 37 ++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100755 scripts/install-cryptpad-vendor.sh diff --git a/.gitignore b/.gitignore index 1b704223b6..db98711f3a 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ konnector-dev-config.json # SWC .swc + +# CryptPad OnlyOffice vendor assets (installed via scripts/install-cryptpad-vendor.sh) +vendor/ diff --git a/scripts/install-cryptpad-vendor.sh b/scripts/install-cryptpad-vendor.sh new file mode 100755 index 0000000000..fe817d5fd7 --- /dev/null +++ b/scripts/install-cryptpad-vendor.sh @@ -0,0 +1,37 @@ +#!/bin/bash +set -euo pipefail + +EDITOR_VERSION="v8.3.3.23+5" +X2T_VERSION="v8.3.0+0" + +EDITOR_URL="https://github.com/cryptpad/onlyoffice-editor/releases/download/${EDITOR_VERSION}/onlyoffice-editor.zip" +X2T_URL="https://github.com/cryptpad/onlyoffice-x2t-wasm/releases/download/${X2T_VERSION}/x2t.zip" + +VENDOR_DIR="vendor/cryptpad-onlyoffice" + +# Resolve paths relative to project root +cd "$(dirname "$0")/.." + +if [ -d "$VENDOR_DIR/editor" ] && [ -d "$VENDOR_DIR/x2t" ]; then + echo "vendor/cryptpad-onlyoffice/ already exists. Remove it first to re-install." + exit 0 +fi + +TMP_DIR=$(mktemp -d) +trap 'rm -rf "$TMP_DIR"' EXIT + +echo "Downloading OnlyOffice editor ${EDITOR_VERSION}..." +curl -L -o "$TMP_DIR/editor.zip" "$EDITOR_URL" + +echo "Downloading x2t-wasm ${X2T_VERSION}..." +curl -L -o "$TMP_DIR/x2t.zip" "$X2T_URL" + +mkdir -p "$VENDOR_DIR" + +echo "Extracting editor..." +unzip -q "$TMP_DIR/editor.zip" -d "$VENDOR_DIR/editor" + +echo "Extracting x2t..." +unzip -q "$TMP_DIR/x2t.zip" -d "$VENDOR_DIR/x2t" + +echo "Done. Installed to $VENDOR_DIR/"