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/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/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/"
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..c8b687fd04 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,16 +62,37 @@ export const Editor = () => {
}
className="u-flex u-flex-column u-p-0"
>
-
- {hasFileDiverged ? : null}
+ {isCryptPad ? (
+
+ ) : (
+
+ )}
+ {hasFileDiverged && !isCryptPad ? : null}
{hasFileDeleted ? : null}
)
}
+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