feat(notes): make rtChannel module from office apps

This commit is contained in:
yflory 2026-09-04 17:08:07 +02:00
parent 444ea527d3
commit 37e6dbc632
16 changed files with 671 additions and 424 deletions

View File

@ -6,7 +6,7 @@ const factory = (UserObject, ProxyManager,
Migrate, Hash, Util, Constants, Feedback,
Realtime, Messaging, Pinpad, Rpc, Cryptget, Cache,
SF, AccountTS, DriveTS, PadTS, Form, Cursor,
Support, Integration, OnlyOffice,
Support, Integration, RtChannel,
Mailbox, Profile, Team, Messenger, History,
Calendar, BadgeTS, LinkedTS, Block, NetConfig,
Crypto, ChainPad, CpNetflux, Listmap,
@ -1590,14 +1590,6 @@ const factory = (UserObject, ProxyManager,
});
};
// OnlyOffice
Store.onlyoffice = {
execCommand: function (clientId, data, cb) {
if (!store.onlyoffice) { return void cb({error: 'OnlyOffice is disabled'}); }
store.onlyoffice.execCommand(clientId, data, cb);
}
};
// Mailbox
Store.mailbox = {
execCommand: function (clientId, data, cb) {
@ -2230,18 +2222,6 @@ const factory = (UserObject, ProxyManager,
Store.pad?.removeClient?.(clientId);
};
var loadOnlyOffice = function () {
if (store.onlyoffice) { return; }
store.onlyoffice = OnlyOffice.init(store, function (ev, data, clients) {
clients.forEach(function (cId) {
postMessage(cId, 'OO_EVENT', {
ev: ev,
data: data
});
});
});
};
var loadMailbox = function (waitFor) {
store.mailbox = Mailbox.init({
Store: Store,
@ -2432,7 +2412,7 @@ const factory = (UserObject, ProxyManager,
loadUniversal(History, 'history', waitFor);
loadUniversal(Badge, 'badge', waitFor);
loadUniversal(LinkedDoc, 'linked-doc', waitFor);
loadOnlyOffice();
loadUniversal(RtChannel, 'rtchannel', waitFor);
if (store) {
store.messenger = store.modules['messenger'];
}
@ -3123,7 +3103,7 @@ module.exports = factory(
require('./modules/cursor'),
require('./modules/support'),
require('./modules/integration'),
require('./modules/onlyoffice'),
require('./modules/rtchannel'),
require('./modules/mailbox'),
require('./modules/profile'),
require('./modules/team'),

View File

@ -462,7 +462,6 @@ const alwaysOnline = (ctx, chanId) => {
const dropChannel = (ctx, chanId) => {
const store = ctx.store;
store.messenger?.leavePad?.(chanId);
store.onlyoffice?.leavePad?.(chanId);
Object.keys(store.modules).forEach(key => {
store.modules[key]?.leavePad?.(chanId);
});

View File

@ -59,8 +59,6 @@ const factory = AStore => {
SEND_FRIEND_REQUEST: Store.sendFriendRequest,
// Team invitation
ANON_GET_PREVIEW_CONTENT: Store.anonGetPreviewContent,
// OnlyOffice
OO_COMMAND: Store.onlyoffice.execCommand,
// Mailbox
MAILBOX_COMMAND: Store.mailbox.execCommand,
// Universal

View File

@ -3,7 +3,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
const factory = (Feedback) => {
var OO = {};
var SP = {};
var getHistory = function (ctx, client, cb) {
var c = ctx.clients[client];
@ -29,22 +29,35 @@ const factory = (Feedback) => {
var c = ctx.clients[client];
var chan = ctx.channels[channel];
const onError = (err) => {
// Callback all connecting clients
ctx.channels[channel].clients.forEach(c => {
ctx.client[c]?.cb(err);
});
};
const onReady = () => {
// Callback all connecting clients
ctx.channels[channel].clients.forEach(c => {
ctx.client[c]?.cb({ clients: chan.clients });
});
};
if (!c) { // new tab
c = ctx.clients[client] = {
channel: channel,
cb: Util.once(cb),
channel
};
} else if (c?.channel !== channel) { // new channel on existing tab
// Remove client from existing chan
// and disconnect from chan if needed
c.cb({ error: 'EINVAL' }); // cancel previous attempt
ctx.removeClient(client, true);
c = ctx.clients[client] = {
channel: channel,
cb: Util.once(cb),
channel
};
} else { // same channel existing tab
setTimeout(() => {
ctx.emit('READY', chan.clients, [client]);
});
return void cb();
return void cb({ clients: chan.clients });
}
if (chan) {
@ -54,12 +67,12 @@ const factory = (Feedback) => {
if (!c.id) { c.id = chan.wc.myID + '-' + client; }
getHistory(ctx, client, function () {
ctx.emit('READY', chan.clients, [client]);
cb({ clients: chan.clients });
});
// ==> And push the new tab to the list
chan.clients.push(client);
return void cb();
return;
}
var txid = Math.floor(Math.random() * 1000000);
@ -115,7 +128,6 @@ const factory = (Feedback) => {
chan.clients = [client];
chan.lastCpHash = obj.lastCpHash;
first = false;
cb();
}
var hk = network.historyKeeper;
@ -164,10 +176,9 @@ const factory = (Feedback) => {
}
return;
}
// End of history: emit READY
// End of history: callback as ready
if (parsed.state && parsed.state === 1 && parsed.channel) {
ctx.emit('READY', chan.clients, chan.clients);
return;
return void onReady();
}
if (parsed.error && parsed.channel) {
if (parsed.error === "EDELETED" && parsed.message) {
@ -175,7 +186,7 @@ const factory = (Feedback) => {
// document read-only
chan.wc?.leave();
Feedback.send('RTCHANNEL_DELETED', true);
return ctx.emit('ERROR', { error: 'EDELETED', reason: parsed.message }, chan.clients);
return onError({ error: 'EDELETED', reason: parsed.message });
}
if (parsed.error === "EUNKNOWN") {
let hk = network.historyKeeper;
@ -183,8 +194,7 @@ const factory = (Feedback) => {
network.sendto(hk, JSON.stringify(msg));
return;
}
ctx.emit('READY', chan.clients, chan.clients);
return;
return void onReady();
}
// If there is a txid, make sure it's ours or abort
@ -301,11 +311,11 @@ const factory = (Feedback) => {
var leaveChannel = function (ctx, padChan) {
// Leave channel and prevent reconnect when we leave a pad
Object.keys(ctx.channels).some(function (ooChan) {
var channel = ctx.channels[ooChan];
Object.keys(ctx.channels).some(function (rtChan) {
var channel = ctx.channels[rtChan];
if (channel.padChan !== padChan) { return; }
if (channel.wc) { channel.wc.leave(); }
delete ctx.channels[ooChan];
delete ctx.channels[rtChan];
return true;
});
};
@ -339,22 +349,22 @@ const factory = (Feedback) => {
OO.init = function (store, emit) {
var oo = {};
SP.init = function (cfg, waitFor, emit) {
var sp = {};
var ctx = {
store: store,
store: cfg.store,
emit: emit,
channels: {},
clients: {}
};
oo.removeClient = ctx.removeClient = function (clientId, newChan) {
sp.removeClient = ctx.removeClient = function (clientId, newChan) {
removeClient(ctx, clientId, newChan);
};
oo.leavePad = function (padChan) {
sp.leavePad = function (padChan) {
leaveChannel(ctx, padChan);
};
oo.execCommand = function (clientId, obj, cb) {
sp.execCommand = function (clientId, obj, cb) {
var cmd = obj.cmd;
var data = obj.data;
if (cmd === 'SEND_MESSAGE') {
@ -371,10 +381,10 @@ const factory = (Feedback) => {
}
};
return oo;
return sp;
};
return OO;
return SP;
};
module.exports = factory(

View File

@ -42,7 +42,7 @@ import * as Mailbox from './modules/mailbox.js';
import * as Cursor from './modules/cursor.js';
import * as Support from './modules/support.js';
import * as Integration from './modules/integration.js';
import * as OnlyOffice from './modules/onlyoffice.js';
import * as RtChannel from './modules/rtchannel.js';
import * as Profile from './modules/profile.js';
import * as Team from './modules/team.js';
import * as Messenger from './modules/messenger.js';

View File

@ -1307,13 +1307,6 @@ define([
postMessage("ANON_GET_PREVIEW_CONTENT", data, cb);
};
// Onlyoffice
var onlyoffice = common.onlyoffice = {};
onlyoffice.execCommand = function (data, cb) {
postMessage("OO_COMMAND", data, cb);
};
onlyoffice.onEvent = Util.mkEvent();
// Mailbox
var mailbox = common.mailbox = {};
mailbox.execCommand = function (data, cb) {
@ -2536,8 +2529,6 @@ define([
common.onNetworkReconnect.fire(data);
});
},
// OnlyOffice
OO_EVENT: common.onlyoffice.onEvent.fire,
// Mailbox
MAILBOX_EVENT: common.mailbox.onEvent.fire,
// Universal

View File

@ -0,0 +1,249 @@
// SPDX-FileCopyrightText: 2026 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
/**
* This module is used on realtime applications using non-ChainPad patches
* (Office, Notes, etc.). These apps use static checkpoints (blobs) paired
* with a separate channel to store patches.
* With a JSON "content, the checkpoints (blob + channel) are stored in
* the value "content.hashes" listing all this document's checkpoints.
*/
define([
'jquery',
'/components/nthen/index.js',
'/common/common-hash.js',
'/common/common-util.js',
'/common/common-ui-elements.js',
'/common/hyperscript.js',
'/api/config',
'/customize/application_config.js',
'/customize/messages.js',
'/common/onlyoffice/history.js',
'/components/file-saver/FileSaver.min.js',
], function (
$,
nThen,
Hash,
Util,
UIElements,
h,
ApiConfig,
AppConfig,
Messages,
History)
{
let content;
// Call init once sframe-common is initialized
const init = (APP, common) => {
const tools = {};
const onRTCEvent = Util.mkEvent();
const sframeChan = common.getSframeChannel();
const linkedModule = common.makeUniversal('linked-doc');
const rtcModule = common.makeUniversal('rtchannel', {
onEvent: obj => {
onRTCEvent.fire(obj);
}
});
const sortCpIndex = History.sortCpIndex;
// LINKED DOCUMENTS
const addLinkedCheckpoint = (cpData, cb) => {
let parsed = Hash.parsePadUrl(cpData.file);
let secret = Hash.getSecrets('file', parsed.hash);
linkedModule.execCommand('ADD_LINKED_DATA', {
content: {
type: 'checkpoints',
data: {
rtChannel: cpData.rtChannel,
blob: secret.channel
}
}
}, (obj) => {
if (obj?.error) { console.error(obj.error); }
const last = obj?.[0]?.checkpoints?.pop();
if (last?.blob === secret.channel && last?.time) {
cpData.time = last.time;
APP.onLocal();
}
cb();
});
};
const checkLinkedDocs = () => {
const value = {
checkpoints: []
};
// Get last 10 cps
let hashes = content.hashes || {}; // checkpoints
let sortedCp = sortCpIndex(hashes).slice(-10);
sortedCp.forEach(cpIdx => {
const cpData = hashes[cpIdx];
let parsed = Hash.parsePadUrl(cpData.file);
let secret = Hash.getSecrets('file', parsed.hash);
if (!secret.channel) { return; }
value.checkpoints.push({
blob: secret.channel,
rtChannel: cpData.rtChannel || content.channel
});
});
// If < 10, add initial channel
if (sortedCp.length < 10 && content.channel) {
value.checkpoints.unshift({
blob: 0,
rtChannel: content.channel
});
}
linkedModule.execCommand('CHECK_CURRENT_DOC', {
// channel & signKey added in outer
expectedJSON: value
}, (obj) => {
if (obj?.error) { console.error(obj.error); }
});
};
// CHECKPOINTS
const getLastCpId = (oldHashes) => {
const hashes = oldHashes || content.hashes;
if (!hashes || !Object.keys(hashes).length) { return 0; }
const allIdx = sortCpIndex(hashes);
return allIdx[allIdx.length - 1];
};
const getLastCp = () => {
const hashes = content.hashes;
if (!hashes || !Object.keys(hashes).length) { return {}; }
const idx = sortCpIndex(hashes);
const lastIndex = idx[idx.length - 1];
if (typeof(lastIndex) === "undefined" || !hashes[lastIndex]) {
return {};
}
return JSON.parse(JSON.stringify(hashes[lastIndex]));
};
const deleteLastCp = (i) => {
const hashes = content.hashes;
if (!hashes || !Object.keys(hashes).length) { return {}; }
i = i || 0;
const idx = sortCpIndex(hashes);
const lastIndex = idx[idx.length - 1 - i];
if (typeof(lastIndex) === "undefined" || !hashes[lastIndex]) {
return;
}
delete hashes[lastIndex];
APP.onLocal();
APP.realtime.onSettle(function () {
UI.log(Messages.saved);
});
};
// RT_CHANNEL
const onRTCLeave = Util.mkEvent();
const onRTCMessage = Util.mkEvent();
const onRTCHistorySynced = Util.mkEvent();
const openRtChannel = (cpData, cb) => {
const channel = cpData?.rtChannel || content.channel;
const lastCpHash = cpData?.hash;
sframeChan.query('Q_RTC_OPENCHANNEL', {
channel, lastCpHash
}, function (err, obj) {
if (obj?.error) {
console.error(obj.error);
return void cb(obj.error);
}
cb(void 0, obj?.clients);
});
onRTCEvent.reg(obj => {
switch (obj.ev) {
case 'LEAVE':
onRTCLeave.fire(obj.data);
break;
case 'MESSAGE':
onRTCMessage.fire(obj.data);
break;
case 'HISTORY_SYNCED':
onRTCHistorySynced.fire()
break;
}
});
};
const sendCmd = (data, cb) => {
if (APP.history) { return; }
sframeChan.query('Q_RTC_COMMAND', data, cb);
};
const rtChannel = {
getHistory: function (cb) {
sendCmd({
cmd: 'GET_HISTORY',
data: {}
}, cb);
},
sendMsg: function (msg, cp, cb) {
sendCmd({
cmd: 'SEND_MESSAGE',
data: {
msg: msg,
isCp: cp
}
}, cb);
},
};
// XXX to add
// onUploaded
// fmConfig / APP.FM
// new function "uploadCheckpoint" which calls APP.FM.handleFile
// TODO/NOTE/MAYBE manage content.saveLock?
// restoreLastCp
// checkCheckpoint
// loadLastDocument ? (download static file + decrypt)
// loadDocument?
// $historyButton, $snapshotButton
// Now or later?
// openVersionHash
// loadTemplate / openTemplatePicker
// XXX ???
// EV_OO_DOC_READY
// Integration channel?
// Call setContent each time the content variable is overriden
const setContent = _content => {
content = _content;
};
tools.rtcModule = rtcModule;
// Linked docs
tools.addLinkedCheckpoint = addLinkedCheckpoint;
tools.checkLinkedDocs = checkLinkedDocs;
// Checkpoint
tools.getLastCpId = getLastCpId;
tools.getLastCp = getLastCp;
tools.deleteLastCp = deleteLastCp;
// RtChannel
tools.openRtChannel = openRtChannel;
tools.onRTCLeave = onRTCLeave;
tools.onRTCMessage = onRTCMessage;
tools.onRTCHistorySynced = onRTCHistorySynced;
tools.rtChannel = rtChannel;
tools.setContent = setContent;
return tools;
};
return { init };
});

View File

@ -19,6 +19,7 @@ define([
'/support/ui.js',
'/components/chainpad/chainpad.dist.js',
'/file/file-crypto.js',
'/common/inner/rtchannel.js',
'/common/onlyoffice/history.js',
'/common/onlyoffice/oocell_base.js',
'/common/onlyoffice/oodoc_base.js',
@ -49,6 +50,7 @@ define([
Support,
ChainPad,
FileCrypto,
RtChannel,
History,
EmptyCell,
EmptyDoc,
@ -81,6 +83,7 @@ define([
var stringify = Util.sortify;
var toolbar;
var cursor;
const onRTCEvent = Util.mkEvent();
var andThen = function (common) {
var Title;
@ -98,6 +101,7 @@ define([
mediasSources: {},
version: privateData.ooForceVersion ? Number(privateData.ooForceVersion) : OOCurrentVersion.currentVersionNumber,
};
APP.rtcTools.setContent(content);
content.originalVersion = content.version;
var oldHashes = {};
var oldIds = {};
@ -255,80 +259,6 @@ define([
});
};
var sortCpIndex = History.sortCpIndex;
const getOriginalVersion = () => {
const hashes = content.hashes || {};
const allIdx = sortCpIndex(hashes);
let version;
// Find the first checkpoint with a version.
// If this version is smaller than content.version, use it
// as original version (Math.min below)
allIdx.some((id) => {
const v = hashes[id]?.version;
if (v) {
version = v;
return true;
}
});
if (version) { version = Math.min(version, content.version || 1); }
return version || content.version || 1;
};
const addLinkedCheckpoint = (cpData, cb) => {
let parsed = Hash.parsePadUrl(cpData.file);
let secret = Hash.getSecrets('file', parsed.hash);
APP.linkedModule.execCommand('ADD_LINKED_DATA', {
content: {
type: 'checkpoints',
data: {
rtChannel: cpData.rtChannel,
blob: secret.channel
}
}
}, (obj) => {
if (obj?.error) { console.error(obj.error); }
const last = obj?.[0]?.checkpoints?.pop();
if (last?.blob === secret.channel && last?.time) {
cpData.time = last.time;
APP.onLocal();
}
cb();
});
};
const checkLinkedDocs = () => {
const value = {
checkpoints: []
};
// Get last 10 cps
let hashes = content.hashes || {}; // checkpoints
let sortedCp = sortCpIndex(hashes).slice(-10);
sortedCp.forEach(cpIdx => {
const cpData = hashes[cpIdx];
let parsed = Hash.parsePadUrl(cpData.file);
let secret = Hash.getSecrets('file', parsed.hash);
if (!secret.channel) { return; }
value.checkpoints.push({
blob: secret.channel,
rtChannel: cpData.rtChannel || content.channel
});
});
// If < 10, add initial channel
if (sortedCp.length < 10 && content.channel) {
value.checkpoints.unshift({
blob: 0,
rtChannel: content.channel
});
}
APP.linkedModule.execCommand('CHECK_CURRENT_DOC', {
// channel & signKey added in outer
expectedJSON: value
}, (obj) => {
if (obj?.error) { console.error(obj.error); }
});
};
var getFileType = function () {
var priv = common.getMetadataMgr().getPrivateData();
var type = priv.ooType;
@ -366,71 +296,24 @@ define([
var now = function () { return +new Date(); };
const getLastCpId = (old) => {
const hashes = old ? oldHashes : content.hashes;
if (!hashes || !Object.keys(hashes).length) { return 0; }
const allIdx = sortCpIndex(hashes);
return allIdx[allIdx.length - 1];
};
var getLastCp = function (old, i) {
var hashes = old ? oldHashes : content.hashes;
if (!hashes || !Object.keys(hashes).length) { return {}; }
i = i || 0;
var idx = sortCpIndex(hashes);
var lastIndex = idx[idx.length - 1 - i];
if (typeof(lastIndex) === "undefined" || !hashes[lastIndex]) {
return {};
}
var last = JSON.parse(JSON.stringify(hashes[lastIndex]));
return last;
};
var deleteLastCp = function (i) {
var hashes = content.hashes;
if (!hashes || !Object.keys(hashes).length) { return {}; }
i = i || 0;
var idx = sortCpIndex(hashes);
var lastIndex = idx[idx.length - 1 - i];
if (typeof(lastIndex) === "undefined" || !hashes[lastIndex]) {
return;
}
delete hashes[lastIndex];
APP.onLocal();
APP.realtime.onSettle(function () {
UI.log(Messages.saved);
});
};
var sortCpIndex = History.sortCpIndex;
var rtChannel = {
ready: false,
readyCb: undefined,
sendCmd: function (data, cb) {
if (APP.history) { return; }
sframeChan.query('Q_OO_COMMAND', data, cb);
},
getHistory: function (cb) {
rtChannel.sendCmd({
cmd: 'GET_HISTORY',
data: {}
}, function () {
APP.onHistorySynced = cb;
});
},
sendMsg: function (msg, cp, cb) {
evOnPatch.fire();
rtChannel.sendCmd({
cmd: 'SEND_MESSAGE',
data: {
msg: msg,
isCp: cp
}
}, function (err, h) {
if (!err) {
evOnSync.fire();
evIntegrationSave.fire();
}
cb(err, h);
});
},
const getOriginalVersion = () => {
const hashes = content.hashes || {};
const allIdx = sortCpIndex(hashes);
let version;
// Find the first checkpoint with a version.
// If this version is smaller than content.version, use it
// as original version (Math.min below)
allIdx.some((id) => {
const v = hashes[id]?.version;
if (v) {
version = v;
return true;
}
});
if (version) { version = Math.min(version, content.version || 1); }
return version || content.version || 1;
};
var ooChannel = {
@ -511,6 +394,47 @@ define([
]))
};
const {
addLinkedCheckpoint, checkLinkedDocs,
getLastCpId, getLastCp, deleteLastCp, openRtChannel,
onRTCLeave, onRTCMessage, onRTCHistorySynced,
rtChannel,
} = APP.rtcTools;
const sendRTCMessage = (msg, cp, cb) => {
evOnPatch.fire();
rtChannel.sendMsg(msg, cp, (err, h) => {
if (!err) {
evOnSync.fire();
evIntegrationSave.fire();
}
cb(err, h);
});
};
onRTCLeave.reg(removeClient);
onRTCHistorySynced.reg(data => {
if (typeof(APP.onHistorySynced) !== "function") { return; }
APP.onHistorySynced();
delete APP.onHistorySynced;
});
onRTCMessage.reg(data => {
if (APP.history) {
ooChannel.historyLastHash = data.hash;
ooChannel.currentIndex++;
return;
}
if (ooChannel.ready) {
ooChannel.send(data.msg);
ooChannel.lastHash = data.hash;
ooChannel.cpIndex++;
common.notify();
} else {
ooChannel.queue.push(data);
}
});
var onUploaded = function (ev, data, err) {
if (!ev && err) {
console.error(err);
@ -615,131 +539,6 @@ define([
});
};
/*
const sendDebugSupportTicket = (message) => {
const title = "[Automatic] Office document locked";
APP.supportModule.execCommand('MAKE_TICKET', {
channel: Hash.createChannelId(),
title,
ticket: APP.support.getDebuggingData({
title,
message
})
}, () => {});
};
const onRtChannelError = (err, channel) => {
const wasReadOnly = readOnly;
readOnly = true;
offline = true;
const message = JSON.stringify({
error: err?.error,
reason: err?.reason,
channel: privateData.channel,
rtChannel: channel
}, 0, 2);
let txt = Messages.oo_rtChannelMissing;
let value;
let f = UI.confirm;
let cb = (yes) => {
if (!yes) { return; }
// Set flag if support has already been contacted
content.missingRtChannel = +new Date();
readOnly = wasReadOnly;
APP.onLocal();
readOnly = true;
sendDebugSupportTicket(message);
};
let btnText = content.missingRtChannel ? Messages.sent : Messages.support_formButton;
let opts = {
ok: [
Icons.get('send'),
h('span', btnText)
],
cancel: Messages.filePicker_close
};
if (content.missingRtChannel) {
value = h('strong', Messages._getKey('oo_rtChannelMissingDate', [
new Date(content.missingRtChannel).toLocaleDateString()
]));
setTimeout(() => {
const $b = UI.findOKButton();
$b.attr('disabled', 'disabled');
});
}
if (!ApiConfig.supportMailboxKey) {
txt = Messages.oo_rtChannelMissingNoSupport;
value = UI.getPreCopy(message);
f = UI.alert;
opts = undefined;
cb = undefined;
}
let div = h('div', [
h('p', txt),
value
]);
f(div, cb, opts);
};*/
var openRtChannel = function (cpData, cb) {
const channel = cpData?.rtChannel || content.channel;
const lastCpHash = cpData?.hash;
sframeChan.query('Q_OO_OPENCHANNEL', {
channel, lastCpHash
}, function (err, obj) {
if (obj?.error) { console.error(obj.error); }
// XXX an error loading a checkpoint was ignored, causing a sheet
// to load incorrectly. There's a risk of a new checkpoint being created
// with the resulting (incorrect) state. Errors like this should be reported
// to the user so they realize something is wrong.
});
sframeChan.on('EV_OO_EVENT', function (obj) {
switch (obj.ev) {
case 'ERROR':
//onRtChannelError(obj.data, channel);
cb();
break;
case 'READY':
checkClients(obj.data);
cb();
break;
case 'LEAVE':
removeClient(obj.data);
break;
case 'MESSAGE':
if (APP.history) {
ooChannel.historyLastHash = obj.data.hash;
ooChannel.currentIndex++;
return;
}
if (ooChannel.ready) {
ooChannel.send(obj.data.msg);
ooChannel.lastHash = obj.data.hash;
ooChannel.cpIndex++;
common.notify();
} else {
ooChannel.queue.push(obj.data);
}
break;
case 'HISTORY_SYNCED':
if (typeof(APP.onHistorySynced) !== "function") { return; }
APP.onHistorySynced();
delete APP.onHistorySynced;
break;
}
});
};
var fmConfig = {
noHandlers: true,
noStore: true,
@ -792,7 +591,8 @@ define([
return void startOO(blob, type, true);
}
openRtChannel(cpData, Util.once(() => {
openRtChannel(cpData, Util.once((err, data) => {
if (!err) { checkClients(data); }
startOO(blob, type, true);
}));
};
@ -1434,7 +1234,7 @@ define([
// Send the changes
content.locks = content.locks || {};
rtChannel.sendMsg({
sendRTCMessage({
type: "saveChanges",
changes: parseChanges(changes, obj.type === "cp_theme"),
changesIndex: ooChannel.cpIndex || 0,
@ -1548,7 +1348,7 @@ define([
};
// Send the patch
rtChannel.sendMsg(msg, null, function (err, hash) {
sendRTCMessage(msg, null, function (err, hash) {
if (err) {
return void console.error(err);
}
@ -2159,7 +1959,7 @@ define([
if (APP.unsavedChanges) {
var unsaved = APP.unsavedChanges;
delete APP.unsavedChanges;
rtChannel.sendMsg(unsaved, null, function (err, hash) {
sendRTCMessage(unsaved, null, function (err, hash) {
if (err) { return void UI.alert(Messages.oo_lostEdits); }
// This is supposed to be a "send" function to tell our OO
// to unlock the cell. We use this to know that the patch was
@ -3075,7 +2875,7 @@ Uncaught TypeError: Cannot read property 'calculatedType' of null
var type = common.getMetadataMgr().getPrivateData().ooType;
var file = getFileType();
if (!noCp) {
var lastCp = getLastCp(false);
var lastCp = getLastCp();
// If the last checkpoint is empty, load the "initial" doc instead
if (!lastCp?.file) {
return void loadDocument(true, useNewDefault, cb);
@ -3145,7 +2945,7 @@ Uncaught TypeError: Cannot read property 'calculatedType' of null
// Extract the channel id from the source href
return src.slice(src.lastIndexOf('/') + 1);
}).filter(Boolean);
sframeChan.query('EV_OO_PIN_IMAGES', toPin);
sframeChan.query('EV_RTC_PIN_IMAGES', toPin);
}
};
@ -3320,6 +3120,7 @@ Uncaught TypeError: Cannot read property 'calculatedType' of null
return void sframeChan.event('EV_OOIFRAME_DONE', '');
}
content = json.content;
APP.rtcTools.setContent(content);
readOnly = true;
if (!content.version || content.version <= 7) {
return void sframeChan.event('EV_OOIFRAME_DONE', {
@ -3456,8 +3257,10 @@ Uncaught TypeError: Cannot read property 'calculatedType' of null
// Fill the queue and then load the last CP
rtChannel.getHistory(function () {
var lastCp = getLastCp();
loadCheckpoint(lastCp, true);
APP.onHistorySynced = () => {
var lastCp = getLastCp();
loadCheckpoint(lastCp, true);
};
});
};
@ -3491,7 +3294,7 @@ Uncaught TypeError: Cannot read property 'calculatedType' of null
APP.realtime.onSettle(cb);
};
var loadSnapshot = function (hash) {
sframeChan.event('EV_OO_OPENVERSION', {
sframeChan.event('EV_RTC_OPENVERSION', {
hash: hash
});
};
@ -3704,6 +3507,7 @@ Uncaught TypeError: Cannot read property 'calculatedType' of null
throw new Error(errorText);
}
content = hjson.content || content;
APP.rtcTools.setContent(content);
// Support old checkpoints
var newLatest = getLastCp();
@ -3797,7 +3601,8 @@ Uncaught TypeError: Cannot read property 'calculatedType' of null
var useNewDefault = content.version && content.version >= 2;
loadDocument(newDoc, useNewDefault, (cpObj, cpData) => {
openRtChannel(cpData, Util.once(function () {
openRtChannel(cpData, Util.once(function (err, data) {
if (!err) { checkClients(data); }
setMyId();
oldHashes = JSON.parse(JSON.stringify(content.hashes));
initializing = false;
@ -4104,6 +3909,7 @@ Uncaught TypeError: Cannot read property 'calculatedType' of null
//var integrationSave = content.integrationSave;
content = json.content;
APP.rtcTools.setContent(content);
if (APP.history) { return; }
@ -4125,7 +3931,7 @@ Uncaught TypeError: Cannot read property 'calculatedType' of null
var editor = getEditor();
if (content.hashes) {
var oldLastCp = getLastCpId(true);
var oldLastCp = getLastCpId(oldHashes);
var newLastCp = getLastCpId();
if (newLastCp > oldLastCp) {
ooChannel.queue = [];
@ -4215,6 +4021,9 @@ Uncaught TypeError: Cannot read property 'calculatedType' of null
}));
SFCommon.create(waitFor(function (c) { APP.common = common = c; }));
}).nThen(function (waitFor) {
APP.rtcTools = RtChannel.init(APP, common);
}).nThen(function (waitFor) {
// XXX
common.getSframeChannel().on('EV_OO_TEMPLATE', function (data) {
APP.startWithTemplate = data;
});
@ -4222,9 +4031,6 @@ Uncaught TypeError: Cannot read property 'calculatedType' of null
//noTemplates: true
});
}).nThen(function (/*waitFor*/) {
APP.supportModule = common.makeUniversal('support');
APP.support = Support.create(common, false);
APP.linkedModule = common.makeUniversal('linked-doc');
andThen(common);
});
};

View File

@ -8,8 +8,9 @@ define([
'/api/config',
'/common/dom-ready.js',
'/common/common-hash.js',
'/common/sframe-common-outer.js'
], function (nThen, ApiConfig, DomReady, Hash, SFCommonO) {
'/common/sframe-common-outer.js',
'/common/outer/rtchannel.js'
], function (nThen, ApiConfig, DomReady, Hash, SFCommonO, RTC) {
var isIntegration = Boolean(window.CP_integration_outer);
var integration = window.CP_integration_outer || {};
@ -54,87 +55,7 @@ define([
Cryptpad.setPadAttribute('lastVersion', data.url, cb);
});
sframeChan.on('Q_OO_OPENCHANNEL', function (data, cb) {
// If we don't have "channels" values, it means we're not
// loading an "old" checkpoint so we can clean attributes
if (!channels?.lastVersion) {
channels.linked = true;
Cryptpad.setPadAttribute('linked', true, () => {});
Cryptpad.setPadAttribute('rtChannel', void 0, () => {});
Cryptpad.setPadAttribute('lastVersion', void 0, () => {});
Cryptpad.setPadAttribute('lastCpHash', void 0, () => {});
}
Cryptpad.onlyoffice.execCommand({
cmd: 'OPEN_CHANNEL',
data: {
channel: data.channel,
lastCpHash: data.lastCpHash,
padChan: Utils.secret.channel, // metadata inherited from this pad
validateKey: Utils.secret.keys.validateKey
}
}, cb);
});
sframeChan.on('EV_OO_PIN_IMAGES', function (list) {
Cryptpad.getPadAttribute('ooImages', function (err, res) {
if (err) { return; }
if (!res || !Array.isArray(res)) { res = []; }
var toPin = [];
var toUnpin = [];
res.forEach(function (id) {
if (list.indexOf(id) === -1) {
toUnpin.push(id);
}
});
list.forEach(function (id) {
if (res.indexOf(id) === -1) {
toPin.push(id);
}
});
toPin = Utils.Util.deduplicateString(toPin);
toUnpin = Utils.Util.deduplicateString(toUnpin);
if (toPin.length) { Cryptpad.pinPads(toPin, function () {}); }
if (toUnpin.length) { Cryptpad.unpinPads(toUnpin, function () {}); }
if (!toPin.length && !toUnpin.length) { return; }
Cryptpad.setPadAttribute('ooImages', list, function (err) {
if (err) { console.error(err); }
});
});
});
sframeChan.on('Q_OO_COMMAND', function (obj, cb) {
if (obj.cmd === 'SEND_MESSAGE') {
obj.data.msg = Utils.crypto.encrypt(JSON.stringify(obj.data.msg));
var hash = obj.data.msg.slice(0,64);
var _cb = cb;
cb = function () {
_cb(hash);
};
}
Cryptpad.onlyoffice.execCommand(obj, cb);
});
sframeChan.on('EV_OO_OPENVERSION', function (obj) {
if (!obj || !obj.hash) { return; }
var parsed = Hash.parsePadUrl(window.location.href);
var opts = parsed.getOptions();
opts.versionHash = obj.hash;
window.open(parsed.getUrl(opts));
});
Cryptpad.onlyoffice.onEvent.reg(function (obj) {
if (obj.ev === 'MESSAGE' && !/^cp\|/.test(obj.data)) {
try {
var validateKey = obj.data.validateKey || true;
var skipCheck = validateKey === true;
var msg = obj.data.msg;
obj.data = {
msg: JSON.parse(Utils.crypto.decrypt(msg, validateKey, skipCheck)),
hash: msg.slice(0,64)
};
} catch (e) {
console.error(e);
}
}
sframeChan.event('EV_OO_EVENT', obj);
});
RTC.addRpc(sframeChan, Cryptpad, Utils, channels);
// X2T
sframeChan.on('Q_OO_CONVERT', function (obj, cb) {

View File

@ -0,0 +1,113 @@
// SPDX-FileCopyrightText: 2026 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
// Load #1, load as little as possible because we are in a race to get the loading screen up.
define([
'/components/nthen/index.js',
'/api/config',
], function (nThen, ApiConfig) {
const RTC = {};
RTC.addRpc = (sframeChan, Cryptpad, Utils, channels) => {
const execCommand = (obj, cb) => {
Cryptpad.universal.execCommand({
type: 'rtchannel',
data: obj,
}, cb);
};
sframeChan.on('Q_RTC_OPENCHANNEL', function (data, cb) {
// If we don't have "channels" values, it means we're not
// loading an "old" checkpoint so we can clean attributes
if (!channels?.lastVersion) {
channels.linked = true;
Cryptpad.setPadAttribute('linked', true, () => {});
Cryptpad.setPadAttribute('rtChannel', void 0, () => {});
Cryptpad.setPadAttribute('lastVersion', void 0, () => {});
Cryptpad.setPadAttribute('lastCpHash', void 0, () => {});
}
execCommand({
cmd: 'OPEN_CHANNEL',
data: {
channel: data.channel,
lastCpHash: data.lastCpHash,
padChan: Utils.secret.channel, // metadata inherited
validateKey: Utils.secret.keys.validateKey
}
}, cb);
});
sframeChan.on('Q_RTC_COMMAND', function (obj, cb) {
if (obj.cmd === 'SEND_MESSAGE') {
obj.data.msg = Utils.crypto.encrypt(JSON.stringify(obj.data.msg));
var hash = obj.data.msg.slice(0,64);
var _cb = cb;
cb = function () {
_cb(hash);
};
}
execCommand(obj, cb);
});
Cryptpad.universal.onEvent.reg(function (data) {
if (data?.type !== 'rtchannel') { return; }
const obj = data?.data || {};
if (obj.ev === 'MESSAGE' && !/^cp\|/.test(obj.data)) {
try {
let validateKey = obj.data.validateKey || true;
let skipCheck = validateKey === true;
let msg = obj.data.msg;
let str = Utils.crypto.decrypt(msg, validateKey, skipCheck);
obj.data = {
msg: JSON.parse(str),
hash: msg.slice(0,64)
};
} catch (e) {
console.error(e);
}
}
sframeChan.event('EV_UNIVERSAL_EVENT', data);
});
sframeChan.on('EV_RTC_OPENVERSION', function (obj) {
if (!obj || !obj.hash) { return; }
var parsed = Hash.parsePadUrl(window.location.href);
var opts = parsed.getOptions();
opts.versionHash = obj.hash;
window.open(parsed.getUrl(opts));
});
sframeChan.on('EV_RTC_PIN_IMAGES', function (list) {
Cryptpad.getPadAttribute('ooImages', function (err, res) {
if (err) { return; }
if (!res || !Array.isArray(res)) { res = []; }
var toPin = [];
var toUnpin = [];
res.forEach(function (id) {
if (list.indexOf(id) === -1) {
toUnpin.push(id);
}
});
list.forEach(function (id) {
if (res.indexOf(id) === -1) {
toPin.push(id);
}
});
toPin = Utils.Util.deduplicateString(toPin);
toUnpin = Utils.Util.deduplicateString(toUnpin);
if (toPin.length) { Cryptpad.pinPads(toPin, function () {}); }
if (toUnpin.length) { Cryptpad.unpinPads(toUnpin, function () {}); }
if (!toPin.length && !toUnpin.length) { return; }
Cryptpad.setPadAttribute('ooImages', list, function (err) {
if (err) { console.error(err); }
});
});
});
};
return RTC;
});

View File

@ -955,6 +955,7 @@ define([
});
Cryptpad.universal.onEvent.reg(function (data) {
if (data?.type === 'rtchannel') { return; }
sframeChan.event('EV_UNIVERSAL_EVENT', data);
});
sframeChan.on('Q_UNIVERSAL_COMMAND', function (content, cb) {
@ -2427,6 +2428,9 @@ define([
if (burnAfterReading) {
nThen(w => {
Cryptpad.padRpc.onReadyEvent.reg(w());
// XXX XXX XXX
// XXX XXX XXX
// XXX XXX XXX
if (isOO) { sframeChan.on('EV_OO_DOC_READY', w()); }
}).nThen(() => {
Cryptpad.burnPad({

File diff suppressed because one or more lines are too long

35
www/notes/app-notes.less Normal file
View File

@ -0,0 +1,35 @@
/*
* SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
@import (reference) "../../customize/src/less2/include/framework.less";
@import (reference) "../../customize/src/less2/include/tools.less";
@import (reference) "../../customize/src/less2/include/avatar.less";
// body
&.cp-app-notes {
.framework_main(
@bg-color: @colortheme_apps[notes],
);
display: flex;
flex-flow: column;
max-height: 100%;
min-height: auto;
color: @cp_app-fg;
background-color: @cp_app-bg;
#cp-app-notes-editor {
display: flex;
#cp-notes-content {
flex: 1;
textarea {
height: 300px;
width: 800px;
}
}
}
}

29
www/notes/index.html Normal file
View File

@ -0,0 +1,29 @@
<!--
SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
SPDX-License-Identifier: AGPL-3.0-or-later
-->
<!DOCTYPE html>
<html>
<head>
<title>CryptPad</title>
<meta content="text/html; charset=utf-8" http-equiv="content-type" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="referrer" content="no-referrer" />
<script src="/customize/pre-loading.js?ver=1.1"></script>
<link href="/customize/src/pre-loading.css?ver=1.0" rel="stylesheet" type="text/css">
<script async data-bootload="/common/sframe-app-outer.js" data-main="/common/boot.js?ver=1.0" src="/components/requirejs/require.js?ver=2.3.7"></script>
<link href="/customize/src/outer.css?ver=1.3.2" rel="stylesheet" type="text/css">
<link id="favicon-ico" type="image/x-icon" rel="icon"
data-main-favicon="/customize/favicon/main-favicon.ico"
data-alt-favicon="/customize/favicon/alt-favicon.ico"
href="/customize/favicon/main-favicon.ico">
<link id="favicon" type="image/png" rel="icon"
data-main-favicon="/customize/favicon/main-favicon.png"
data-alt-favicon="/customize/favicon/alt-favicon.png"
href="/customize/favicon/main-favicon.png">
</head>
<body>
<noscript></noscript>
<iframe-placeholder>

27
www/notes/inner.html Normal file
View File

@ -0,0 +1,27 @@
<!--
SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
SPDX-License-Identifier: AGPL-3.0-or-later
-->
<!DOCTYPE html>
<html class="cp-app-noscroll">
<head>
<meta content="text/html; charset=utf-8" http-equiv="content-type" />
<script src="/customize/pre-loading.js?ver=1.1"></script>
<link href="/customize/src/pre-loading.css?ver=1.0" rel="stylesheet" type="text/css">
<script async data-bootload="/notes/inner.js" data-main="/common/sframe-boot.js?ver=1.11" src="/components/requirejs/require.js?ver=2.3.7"></script>
<style>
.loading-hidden {
display: none;
}
</style>
</head>
<body class="cp-app-notes">
<div id="cp-toolbar" class="cp-toolbar-container"></div>
<div id="cp-app-notes-editor"></div>
</body>
</html>

85
www/notes/inner.js Normal file
View File

@ -0,0 +1,85 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
// This is the initialization loading the CryptPad libraries
define([
'jquery',
'/common/sframe-app-framework.js',
'/customize/messages.js', // translation keys
'/common/hyperscript.js',
'less!/notes/app-notes.less'
/* Here you can add your own javascript or css to load */
], function (
$,
Framework,
Messages,
h,
) {
// This is the main initialization loop
let onFrameworkReady = function (framework) {
let $container = $('#cp-app-notes-editor');
let $content = $(h('div#cp-notes-content')).appendTo($container);
let $textarea = $(h('textarea'));
$content.append($textarea);
let oldVal = '';
$textarea.on('change keyup paste', function () {
var currentVal = $textarea.val();
if (currentVal === oldVal) { return; } // Nothing to do
oldVal = currentVal;
framework.localChange();
});
let getContent = () => {
return $textarea.val();
};
let setContent = (value) => {
return $textarea.val(value);
};
let content = {};
// OPTIONAL: cursor management
framework.setCursorGetter(() => {
let myCursor = {};
// Get your cursor position here
return myCursor;
});
framework.onCursorUpdate(data => {
console.log("Other user cursor", data);
});
framework.onContentUpdate(function (newContent) {
console.log('New content received from others', newContent.content);
content = newContent.content;
setContent(content);
});
framework.setContentGetter(function () {
let content = getContent();
console.log('Sync my content with others', content);
return {
content: content
};
});
framework.onReady(function () {
// Document is ready, you can initialize your app
console.log('Document is ready:', content);
});
// Start the framework
framework.start();
};
// Framework initialization
Framework.create({
toolbarContainer: '#cp-toolbar',
contentContainer: '#cp-app-notes-editor'
}, (framework) => {
onFrameworkReady(framework);
});
});