mbeditor 0.12.2 → 0.12.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +27 -0
- data/README.md +38 -8
- data/app/assets/javascripts/mbeditor/collaboration_service.js +62 -0
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +89 -0
- data/app/assets/javascripts/mbeditor/websocket_service.js +17 -0
- data/app/assets/stylesheets/mbeditor/editor.css +28 -0
- data/app/channels/mbeditor/channel_authentication.rb +31 -2
- data/lib/mbeditor/configuration.rb +11 -1
- data/lib/mbeditor/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 88d963e43c97cf6974b3e38c079ffd2dba642dc1c13a68237f1a3a4f7c21e9a0
|
|
4
|
+
data.tar.gz: 9e32a366d6ea7ce5014fff94ee89adba7f6909d09aa29f6404142b4155688a3a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 100805339d3956837f0ec5349de86b5bcf4315ef5dd261e76b80c0acb589cd9036264b70bb65677be3d27be9575007daee02e08c1cbad86ac3136bd331312a6b
|
|
7
|
+
data.tar.gz: d1d0681c8962251ceb0d9f18db0937e20f3c1ae1e2d30754a08a88ee10a47b6a6492641de84b9e83c0b2ab81963e79d1f3d86bb61a985bfa7b4a5eb579d0548d
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.12.3] - 2026-08-03
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **`config.cable_authenticate_with`** — authentication for the collaboration
|
|
14
|
+
WebSocket, falling back to `authenticate_with` when unset.
|
|
15
|
+
- **A "Pairing off" chip and diagnostics panel.** When collaboration cannot
|
|
16
|
+
work, the editor now says so and lists every condition it depends on — the
|
|
17
|
+
vendored libraries, Action Cable's JavaScript, whether the server advertises
|
|
18
|
+
it, whether the socket actually connected, and whether anyone else is here —
|
|
19
|
+
with the fix written beside each failure. Nothing is shown when everything is
|
|
20
|
+
healthy and you are simply alone.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- **A WebSocket subscribe runs no controller, and this made pairing silently
|
|
24
|
+
impossible for most authenticated apps.** `authenticate_with` is evaluated on
|
|
25
|
+
the cable against a probe exposing `session`, `cookies`, `request` and
|
|
26
|
+
`params` — so a hook reading `Current.user`, calling `UserSession.find`, or
|
|
27
|
+
using a memoised `current_user` gets `nil` or a `NameError`, denies, and the
|
|
28
|
+
socket is rejected. The hook keeps working perfectly over HTTP, so nothing
|
|
29
|
+
looks wrong; collaboration simply never connects.
|
|
30
|
+
|
|
31
|
+
Rejections are now logged as `[mbeditor] WebSocket subscription rejected: …`
|
|
32
|
+
naming the cause, `cable_authenticate_with` provides an escape hatch when one
|
|
33
|
+
proc cannot serve both contexts, and the README says this plainly instead of
|
|
34
|
+
noting that request-scoped state "may be narrower". The README's own example
|
|
35
|
+
used `UserSession.find`, which is exactly the pattern that cannot work.
|
|
36
|
+
|
|
10
37
|
## [0.12.2] - 2026-08-03
|
|
11
38
|
|
|
12
39
|
### Added
|
data/README.md
CHANGED
|
@@ -67,8 +67,12 @@ Mbeditor.configure do |config|
|
|
|
67
67
|
config.excluded_paths = %w[.git tmp log node_modules .bundle coverage vendor/bundle]
|
|
68
68
|
config.rubocop_command = "bundle exec rubocop"
|
|
69
69
|
|
|
70
|
-
# Optional authentication (runs as a before_action in the engine controllers
|
|
71
|
-
#
|
|
70
|
+
# Optional authentication (runs as a before_action in the engine controllers,
|
|
71
|
+
# and on the collaboration WebSocket subscribe). Resolve the user from
|
|
72
|
+
# `session` — a WebSocket subscribe runs no controller, so Current.user,
|
|
73
|
+
# UserSession.find and a memoised current_user are all unavailable there.
|
|
74
|
+
# config.authenticate_with = proc { redirect_to login_path unless User.find_by(id: session[:user_id]) }
|
|
75
|
+
# config.cable_authenticate_with = proc { ... } # when one proc cannot serve both
|
|
72
76
|
|
|
73
77
|
# Optional test runner (Minitest or RSpec)
|
|
74
78
|
# config.test_framework = :minitest # :minitest or :rspec — auto-detected when nil
|
|
@@ -123,6 +127,7 @@ end
|
|
|
123
127
|
| Option | Default | Description |
|
|
124
128
|
|--------|---------|-------------|
|
|
125
129
|
| `authenticate_with` | `nil` | Proc run as a `before_action` in all engine controllers. Executed via `instance_exec` inside the controller, so it has access to `session`, `cookies`, `redirect_to`, and auth-library class methods (e.g. Authlogic's `UserSession.find`) — but not helper methods from the host's `ApplicationController`. The same hook is also evaluated when the collaboration / editor **WebSocket** subscribes (see [Collaborative pairing](#collaborative-pairing-optional)); if it halts or raises, that subscription is rejected (fail-closed). Over the cable the proc runs against a request-derived probe, so request-scoped state may be narrower than over HTTP. |
|
|
130
|
+
| `cable_authenticate_with` | `nil` | Authentication for the collaboration WebSocket; `nil` falls back to `authenticate_with`. Set this when the HTTP hook cannot work on the cable — a WebSocket subscribe runs no controller, so `Current.*`, an Authlogic `UserSession` and a memoised `current_user` are `nil` or raise, and the hook then denies the socket while working perfectly over HTTP. See [Collaborative pairing](#collaborative-pairing-optional). |
|
|
126
131
|
| `authentication_cache_ttl` | `0` | Seconds to cache the auth result in the session (`0` = no caching). Set e.g. `300` to avoid calling `authenticate_with` on every request when the proc is expensive. Trade-off: after host logout, mbeditor stays accessible for up to TTL seconds. |
|
|
127
132
|
|
|
128
133
|
### Test runner
|
|
@@ -335,20 +340,45 @@ should be able to reach the port.
|
|
|
335
340
|
|
|
336
341
|
**2. Set an authentication hook — it runs on the WebSocket handshake.**
|
|
337
342
|
Configure `authenticate_with` (see the [Authentication](#authentication) options).
|
|
338
|
-
The same hook that gates the HTTP editor is
|
|
343
|
+
The same hook that gates the HTTP editor is also evaluated when the collaboration /
|
|
339
344
|
editor WebSocket subscribes: if it halts (e.g. `redirect_to`/`render`/`head`) — or raises —
|
|
340
345
|
the socket subscription is **rejected (fail-closed)**, so pairing cannot bypass your auth.
|
|
341
346
|
|
|
347
|
+
> **A WebSocket subscribe runs no controller.** This is the single most common reason
|
|
348
|
+
> pairing appears to do nothing. Anything a `before_action` populates is unavailable on
|
|
349
|
+
> the cable: `Current.user` (and any other `ActiveSupport::CurrentAttributes`) is `nil`,
|
|
350
|
+
> `UserSession.find` raises because Authlogic's controller adapter was never activated,
|
|
351
|
+
> and a memoised `current_user` is undefined. The hook then denies — correctly, given it
|
|
352
|
+
> was handed nobody — and the socket is rejected. It works perfectly over HTTP the whole
|
|
353
|
+
> time, so nothing looks wrong until you notice collaboration never connects.
|
|
354
|
+
|
|
355
|
+
The hook is evaluated against a probe exposing `session`, `cookies`, `request`, `params`
|
|
356
|
+
and the halt methods. Resolve the user from `session` so the same hook works in both
|
|
357
|
+
contexts:
|
|
358
|
+
|
|
359
|
+
```ruby
|
|
360
|
+
Mbeditor.configure do |c|
|
|
361
|
+
c.authenticate_with = proc do
|
|
362
|
+
user = Current.user || User.find_by(id: session[:user_credentials_id])
|
|
363
|
+
head :forbidden unless user&.super_admin_access?
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
When one proc genuinely cannot serve both, give the cable its own:
|
|
369
|
+
|
|
342
370
|
```ruby
|
|
343
371
|
Mbeditor.configure do |c|
|
|
344
|
-
|
|
345
|
-
c.
|
|
372
|
+
c.authenticate_with = proc { head :forbidden unless Current.user&.super_admin_access? }
|
|
373
|
+
c.cable_authenticate_with = proc do
|
|
374
|
+
head :forbidden unless User.find_by(id: session[:user_credentials_id])&.super_admin_access?
|
|
375
|
+
end
|
|
346
376
|
end
|
|
347
377
|
```
|
|
348
378
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
379
|
+
Every rejection is logged as `[mbeditor] WebSocket subscription rejected: …` with the
|
|
380
|
+
reason, so a denial is visible rather than silent. The editor also shows a **Pairing off**
|
|
381
|
+
chip when collaboration cannot work, listing each failing condition. For defence in depth, also authenticate at your host app's
|
|
352
382
|
`ApplicationCable::Connection` (the standard `identified_by` / `reject_unauthorized_connection`
|
|
353
383
|
pattern) — mbeditor's hook is an additional gate, not a replacement for securing the cable
|
|
354
384
|
connection itself.
|
|
@@ -120,6 +120,67 @@ var CollaborationService = (function () {
|
|
|
120
120
|
});
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
// Every condition collaboration depends on, reported separately.
|
|
124
|
+
//
|
|
125
|
+
// isAvailable() collapses five things into one boolean, which is useless when
|
|
126
|
+
// pairing does not work: "false" could be a missing vendored library, a cable
|
|
127
|
+
// the server never advertised, a rejected handshake, or simply nobody else
|
|
128
|
+
// being here. Someone debugging this on another machine cannot paste a console
|
|
129
|
+
// snippet back, so the editor has to be able to say which.
|
|
130
|
+
function diagnostics() {
|
|
131
|
+
var cableStatus = (typeof WebSocketService !== 'undefined' &&
|
|
132
|
+
typeof WebSocketService.cableStatus === 'function')
|
|
133
|
+
? WebSocketService.cableStatus() : 'unknown';
|
|
134
|
+
|
|
135
|
+
var checks = [
|
|
136
|
+
{
|
|
137
|
+
key: 'libraries',
|
|
138
|
+
label: 'Collaboration libraries loaded',
|
|
139
|
+
ok: typeof window.Y !== 'undefined' && typeof window.MonacoBinding !== 'undefined',
|
|
140
|
+
detail: 'vendor/assets/javascripts/yjs-collab.js is served by the asset pipeline. ' +
|
|
141
|
+
'If this fails, the host app is not serving it — try rails assets:clobber, ' +
|
|
142
|
+
'or check config.assets.precompile.'
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
key: 'actioncable',
|
|
146
|
+
label: 'Action Cable JavaScript present',
|
|
147
|
+
ok: typeof window.ActionCable !== 'undefined',
|
|
148
|
+
detail: 'The host app must make actioncable.js available to the page.'
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
key: 'advertised',
|
|
152
|
+
label: 'Server advertises Action Cable',
|
|
153
|
+
ok: typeof WebSocketService !== 'undefined' && WebSocketService.isCableAvailable(),
|
|
154
|
+
detail: 'Action Cable must be enabled and mounted in the host app routes.'
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
key: 'connected',
|
|
158
|
+
label: 'WebSocket connected',
|
|
159
|
+
ok: cableStatus === 'connected',
|
|
160
|
+
detail: cableStatus === 'rejected'
|
|
161
|
+
? 'The server rejected the subscription. Usually action_cable.allowed_request_origins ' +
|
|
162
|
+
'not listing the origin you are reaching the editor through, or an authenticate_with ' +
|
|
163
|
+
'hook halting on the cable (it fails closed). Look for "Request origin not allowed" in the log.'
|
|
164
|
+
: 'Status: ' + cableStatus + '.'
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
key: 'peers',
|
|
168
|
+
label: 'Another participant connected',
|
|
169
|
+
ok: _peerPresent,
|
|
170
|
+
detail: 'Presence is held per web process. If two people are on different Puma workers ' +
|
|
171
|
+
'they never see each other — run a single worker (WEB_CONCURRENCY=0) while pairing.'
|
|
172
|
+
}
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
ok: checks.every(function (c) { return c.ok; }),
|
|
177
|
+
cableStatus: cableStatus,
|
|
178
|
+
checks: checks,
|
|
179
|
+
// The first failing check is the one worth acting on; the rest follow from it.
|
|
180
|
+
firstProblem: checks.filter(function (c) { return !c.ok; })[0] || null
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
123
184
|
// Subscribe to availability transitions. Returns an unsubscribe function.
|
|
124
185
|
function onAvailabilityChange(fn) {
|
|
125
186
|
_availabilityListeners.push(fn);
|
|
@@ -674,6 +735,7 @@ var CollaborationService = (function () {
|
|
|
674
735
|
return {
|
|
675
736
|
isAvailable: isAvailable,
|
|
676
737
|
setPeerPresent: setPeerPresent,
|
|
738
|
+
diagnostics: diagnostics,
|
|
677
739
|
onAvailabilityChange: onAvailabilityChange,
|
|
678
740
|
isEnabledFor: isEnabledFor,
|
|
679
741
|
ensureRoom: ensureRoom,
|
|
@@ -2450,6 +2450,30 @@ var MbeditorApp = function MbeditorApp() {
|
|
|
2450
2450
|
// external-change detection for solo users. The service is told about the roster
|
|
2451
2451
|
// from the presence handler above, not from an effect here, so it hears about
|
|
2452
2452
|
// every message rather than only about a change in the participant count.
|
|
2453
|
+
var _collabDiag = useState(false);
|
|
2454
|
+
var collabDiagOpen = _collabDiag[0], setCollabDiagOpen = _collabDiag[1];
|
|
2455
|
+
var _collabTrouble = useState(null);
|
|
2456
|
+
var collabTrouble = _collabTrouble[0], setCollabTrouble = _collabTrouble[1];
|
|
2457
|
+
|
|
2458
|
+
// Re-checked on a slow interval because the failures worth reporting — a
|
|
2459
|
+
// rejected handshake, a dropped socket — happen asynchronously and nothing
|
|
2460
|
+
// else would notice. Only the first failing check's key is stored, so an
|
|
2461
|
+
// unchanged state is an identical string and React bails rather than
|
|
2462
|
+
// re-rendering the app every tick.
|
|
2463
|
+
useEffect(function () {
|
|
2464
|
+
function check() {
|
|
2465
|
+
if (typeof CollaborationService === 'undefined' ||
|
|
2466
|
+
typeof CollaborationService.diagnostics !== 'function') return;
|
|
2467
|
+
var d = CollaborationService.diagnostics();
|
|
2468
|
+
// "Nobody else is here" is not a fault; everything else is.
|
|
2469
|
+
var problem = d.firstProblem && d.firstProblem.key !== 'peers' ? d.firstProblem.key : null;
|
|
2470
|
+
setCollabTrouble(function (prev) { return prev === problem ? prev : problem; });
|
|
2471
|
+
}
|
|
2472
|
+
check();
|
|
2473
|
+
var id = setInterval(check, 10000);
|
|
2474
|
+
return function () { clearInterval(id); };
|
|
2475
|
+
}, []);
|
|
2476
|
+
|
|
2453
2477
|
var collabPeerIds = Object.keys(collabRoster);
|
|
2454
2478
|
|
|
2455
2479
|
// A labelled peer chip costs ~110px (name + filename), and the titlebar button
|
|
@@ -4217,6 +4241,28 @@ var MbeditorApp = function MbeditorApp() {
|
|
|
4217
4241
|
!toolbarIconOnly && " Git"
|
|
4218
4242
|
)
|
|
4219
4243
|
),
|
|
4244
|
+
// Collaboration trouble chip. Deliberately not shown when everything is
|
|
4245
|
+
// healthy and you are simply alone — that was the whole point of hiding
|
|
4246
|
+
// the solo chip. It appears only when a condition pairing needs has
|
|
4247
|
+
// actually failed, so silence means "nobody here" and never "quietly
|
|
4248
|
+
// broken", which is exactly the ambiguity that made a real pairing
|
|
4249
|
+
// failure impossible to diagnose from the other machine.
|
|
4250
|
+
collabTrouble && React.createElement(
|
|
4251
|
+
React.Fragment,
|
|
4252
|
+
null,
|
|
4253
|
+
React.createElement("div", { className: "statusbar-sep" }),
|
|
4254
|
+
React.createElement(
|
|
4255
|
+
"button",
|
|
4256
|
+
{
|
|
4257
|
+
type: "button",
|
|
4258
|
+
className: "statusbar-btn mbeditor-collab-trouble",
|
|
4259
|
+
title: "Pairing is not available — click for details",
|
|
4260
|
+
onClick: function () { setCollabDiagOpen(true); }
|
|
4261
|
+
},
|
|
4262
|
+
React.createElement("i", { className: "fas fa-user-slash" }),
|
|
4263
|
+
!toolbarIconOnly && " Pairing off"
|
|
4264
|
+
)
|
|
4265
|
+
),
|
|
4220
4266
|
// Your own chip appears only once someone else is actually connected.
|
|
4221
4267
|
// Alone it told you nothing — Action Cable is up in any normal dev setup,
|
|
4222
4268
|
// so it sat in the toolbar permanently announcing a session of one. While
|
|
@@ -4358,6 +4404,49 @@ var MbeditorApp = function MbeditorApp() {
|
|
|
4358
4404
|
)
|
|
4359
4405
|
);
|
|
4360
4406
|
})(),
|
|
4407
|
+
// The diagnostics panel. Every condition listed with its own state, because
|
|
4408
|
+
// the person who needs this is usually on the other machine and cannot paste
|
|
4409
|
+
// a console snippet back.
|
|
4410
|
+
collabDiagOpen && (function () {
|
|
4411
|
+
var d = CollaborationService.diagnostics();
|
|
4412
|
+
return React.createElement(
|
|
4413
|
+
'div',
|
|
4414
|
+
{ className: 'mbeditor-modal-backdrop', onClick: function () { setCollabDiagOpen(false); } },
|
|
4415
|
+
React.createElement(
|
|
4416
|
+
'div',
|
|
4417
|
+
{ className: 'mbeditor-collab-diag', onClick: function (e) { e.stopPropagation(); } },
|
|
4418
|
+
React.createElement('div', { className: 'mbeditor-collab-diag-title' }, 'Pair programming status'),
|
|
4419
|
+
React.createElement(
|
|
4420
|
+
'ul',
|
|
4421
|
+
{ className: 'mbeditor-collab-diag-list' },
|
|
4422
|
+
d.checks.map(function (c) {
|
|
4423
|
+
return React.createElement(
|
|
4424
|
+
'li',
|
|
4425
|
+
{ key: c.key, className: 'mbeditor-collab-diag-row' + (c.ok ? '' : ' is-bad') },
|
|
4426
|
+
React.createElement('i', { className: c.ok ? 'fas fa-check' : 'fas fa-times' }),
|
|
4427
|
+
React.createElement(
|
|
4428
|
+
'div',
|
|
4429
|
+
null,
|
|
4430
|
+
React.createElement('div', { className: 'mbeditor-collab-diag-label' }, c.label),
|
|
4431
|
+
!c.ok && React.createElement('div', { className: 'mbeditor-collab-diag-detail' }, c.detail)
|
|
4432
|
+
)
|
|
4433
|
+
);
|
|
4434
|
+
})
|
|
4435
|
+
),
|
|
4436
|
+
React.createElement(
|
|
4437
|
+
'div',
|
|
4438
|
+
{ className: 'mbeditor-collab-diag-foot' },
|
|
4439
|
+
d.ok
|
|
4440
|
+
? 'Pairing is working.'
|
|
4441
|
+
: 'The first failing item above is the one to fix; the rest follow from it.'
|
|
4442
|
+
),
|
|
4443
|
+
React.createElement('button', {
|
|
4444
|
+
type: 'button', className: 'ide-model-graph-btn',
|
|
4445
|
+
onClick: function () { setCollabDiagOpen(false); }
|
|
4446
|
+
}, 'Close')
|
|
4447
|
+
)
|
|
4448
|
+
);
|
|
4449
|
+
})(),
|
|
4361
4450
|
showHelp && React.createElement(ShortcutHelp, { onClose: function () { return setShowHelp(false); } }),
|
|
4362
4451
|
React.createElement(
|
|
4363
4452
|
"div",
|
|
@@ -10,6 +10,11 @@ var WebSocketService = (function () {
|
|
|
10
10
|
var _consumer = null;
|
|
11
11
|
var _subscription = null;
|
|
12
12
|
var _connected = false;
|
|
13
|
+
// Why collaboration is or is not working, so the editor can say so instead of
|
|
14
|
+
// failing silently. A rejected subscription used to just schedule a reconnect
|
|
15
|
+
// and tell nobody, which made a blocked cable indistinguishable from an empty
|
|
16
|
+
// room.
|
|
17
|
+
var _status = 'idle'; // idle | unsupported | connecting | connected | rejected | dropped
|
|
13
18
|
var _filesChangedCallbacks = [];
|
|
14
19
|
var _fileSavedCallbacks = [];
|
|
15
20
|
var _presenceCallbacks = [];
|
|
@@ -98,6 +103,7 @@ var WebSocketService = (function () {
|
|
|
98
103
|
}
|
|
99
104
|
|
|
100
105
|
_lastCableAttemptAt = Date.now();
|
|
106
|
+
if (_status !== 'rejected') _status = 'connecting';
|
|
101
107
|
|
|
102
108
|
try {
|
|
103
109
|
_consumer = _getConsumer();
|
|
@@ -106,12 +112,15 @@ var WebSocketService = (function () {
|
|
|
106
112
|
{
|
|
107
113
|
connected: function () {
|
|
108
114
|
_connected = true;
|
|
115
|
+
_status = 'connected';
|
|
109
116
|
},
|
|
110
117
|
disconnected: function () {
|
|
118
|
+
_status = _status === 'rejected' ? 'rejected' : 'dropped';
|
|
111
119
|
_cleanupConsumer();
|
|
112
120
|
_scheduleReconnect();
|
|
113
121
|
},
|
|
114
122
|
rejected: function () {
|
|
123
|
+
_status = 'rejected';
|
|
115
124
|
_cleanupConsumer();
|
|
116
125
|
_scheduleReconnect();
|
|
117
126
|
},
|
|
@@ -180,6 +189,7 @@ var WebSocketService = (function () {
|
|
|
180
189
|
function connect(serverSupportsWs) {
|
|
181
190
|
_serverSupportsWs = !!serverSupportsWs;
|
|
182
191
|
if (!_serverSupportsWs || !_isActionCableAvailable()) {
|
|
192
|
+
_status = 'unsupported';
|
|
183
193
|
return; // polling remains the only refresh mechanism
|
|
184
194
|
}
|
|
185
195
|
_installUnhandledRejectionGuard();
|
|
@@ -201,6 +211,12 @@ var WebSocketService = (function () {
|
|
|
201
211
|
return _connected;
|
|
202
212
|
}
|
|
203
213
|
|
|
214
|
+
// Coarse state of the editor's own cable subscription, for the collaboration
|
|
215
|
+
// diagnostics panel.
|
|
216
|
+
function cableStatus() {
|
|
217
|
+
return _status;
|
|
218
|
+
}
|
|
219
|
+
|
|
204
220
|
// Returns true when ActionCable is loaded and the server advertised cable
|
|
205
221
|
// support. Collaboration uses this as its up-front "is the feature available?"
|
|
206
222
|
// gate, independent of whether the EditorChannel handshake has completed yet.
|
|
@@ -290,6 +306,7 @@ var WebSocketService = (function () {
|
|
|
290
306
|
connect: connect,
|
|
291
307
|
disconnect: disconnect,
|
|
292
308
|
isConnected: isConnected,
|
|
309
|
+
cableStatus: cableStatus,
|
|
293
310
|
isCableAvailable: isCableAvailable,
|
|
294
311
|
subscribeCollaboration: subscribeCollaboration,
|
|
295
312
|
perform: perform,
|
|
@@ -3395,3 +3395,31 @@ input.ide-model-graph-search[type="search"] {
|
|
|
3395
3395
|
font-style: italic;
|
|
3396
3396
|
opacity: 0.8;
|
|
3397
3397
|
}
|
|
3398
|
+
|
|
3399
|
+
/* Pair-programming diagnostics. The chip only appears when a condition pairing
|
|
3400
|
+
needs has actually failed — an empty room is silence, not a warning. */
|
|
3401
|
+
.mbeditor-collab-trouble { color: var(--ide-warning, #cca700); }
|
|
3402
|
+
.mbeditor-modal-backdrop {
|
|
3403
|
+
position: fixed; inset: 0; z-index: 5000;
|
|
3404
|
+
background: rgba(0, 0, 0, 0.45);
|
|
3405
|
+
display: flex; align-items: center; justify-content: center;
|
|
3406
|
+
}
|
|
3407
|
+
.mbeditor-collab-diag {
|
|
3408
|
+
width: min(560px, 92vw);
|
|
3409
|
+
max-height: 80vh; overflow-y: auto;
|
|
3410
|
+
padding: 18px 20px;
|
|
3411
|
+
background: var(--ide-panel-bg, #252526);
|
|
3412
|
+
border: 1px solid var(--ide-border, #3c3c3c);
|
|
3413
|
+
border-radius: 8px;
|
|
3414
|
+
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
|
|
3415
|
+
color: var(--ide-text, #d4d4d4);
|
|
3416
|
+
font-size: 12px;
|
|
3417
|
+
}
|
|
3418
|
+
.mbeditor-collab-diag-title { font-size: 14px; font-weight: 600; margin-bottom: 12px; }
|
|
3419
|
+
.mbeditor-collab-diag-list { list-style: none; margin: 0 0 12px; padding: 0; }
|
|
3420
|
+
.mbeditor-collab-diag-row { display: flex; gap: 9px; align-items: flex-start; padding: 6px 0; }
|
|
3421
|
+
.mbeditor-collab-diag-row i { margin-top: 2px; color: var(--ide-success, #4ec9b0); }
|
|
3422
|
+
.mbeditor-collab-diag-row.is-bad i { color: var(--ide-danger, #f14c4c); }
|
|
3423
|
+
.mbeditor-collab-diag-label { font-weight: 500; }
|
|
3424
|
+
.mbeditor-collab-diag-detail { color: var(--ide-text-muted, #858585); margin-top: 3px; line-height: 1.5; }
|
|
3425
|
+
.mbeditor-collab-diag-foot { color: var(--ide-text-muted, #858585); margin-bottom: 12px; }
|
|
@@ -19,20 +19,49 @@ module Mbeditor
|
|
|
19
19
|
# True when the connection is allowed (or no hook is configured); otherwise
|
|
20
20
|
# rejects the subscription and returns false.
|
|
21
21
|
def mbeditor_authenticated?
|
|
22
|
-
hook =
|
|
22
|
+
hook = mbeditor_auth_hook
|
|
23
23
|
return true unless hook
|
|
24
24
|
|
|
25
25
|
probe = AuthProbe.new(mbeditor_connection_env)
|
|
26
26
|
probe.instance_exec(&hook)
|
|
27
27
|
return true unless probe.denied?
|
|
28
28
|
|
|
29
|
+
# Both denial paths used to be completely silent, which is what makes this
|
|
30
|
+
# so hard to diagnose: pairing simply never works and nothing anywhere says
|
|
31
|
+
# why. The commonest cause is a hook that reads state a controller filter
|
|
32
|
+
# populates — Current.user, an Authlogic session — because a WebSocket
|
|
33
|
+
# subscribe runs no controller, so that state is nil or raises here while
|
|
34
|
+
# working perfectly over HTTP.
|
|
35
|
+
mbeditor_log_denial("the authenticate_with hook denied the connection")
|
|
29
36
|
mbeditor_reject_subscription
|
|
30
37
|
false
|
|
31
|
-
rescue StandardError
|
|
38
|
+
rescue StandardError => e
|
|
39
|
+
mbeditor_log_denial("the authenticate_with hook raised #{e.class}: #{e.message}")
|
|
32
40
|
mbeditor_reject_subscription
|
|
33
41
|
false
|
|
34
42
|
end
|
|
35
43
|
|
|
44
|
+
# `cable_authenticate_with` when set, otherwise the HTTP hook. A hook that
|
|
45
|
+
# depends on controller filters cannot work here at all, and asking people to
|
|
46
|
+
# write one proc that straddles both contexts is worse than letting them
|
|
47
|
+
# supply the cable one explicitly.
|
|
48
|
+
def mbeditor_auth_hook
|
|
49
|
+
Mbeditor.configuration.cable_authenticate_with ||
|
|
50
|
+
Mbeditor.configuration.authenticate_with
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def mbeditor_log_denial(reason)
|
|
54
|
+
Rails.logger&.warn(
|
|
55
|
+
"[mbeditor] WebSocket subscription rejected: #{reason}. " \
|
|
56
|
+
"Realtime collaboration will not work. A WebSocket subscribe runs no " \
|
|
57
|
+
"controller, so Current.*, Authlogic sessions and other request-scoped " \
|
|
58
|
+
"state set by before_actions are unavailable here — resolve the user " \
|
|
59
|
+
"from `session` instead, or set config.cable_authenticate_with."
|
|
60
|
+
)
|
|
61
|
+
rescue StandardError
|
|
62
|
+
# Logging must never be the thing that breaks the socket.
|
|
63
|
+
end
|
|
64
|
+
|
|
36
65
|
private
|
|
37
66
|
|
|
38
67
|
def mbeditor_connection_env
|
|
@@ -5,7 +5,7 @@ module Mbeditor
|
|
|
5
5
|
attr_accessor :allowed_environments, :workspace_root, :excluded_paths, :rubocop_command, :rubocop_server,
|
|
6
6
|
:redmine_enabled, :redmine_url, :redmine_api_key, :redmine_ticket_source,
|
|
7
7
|
:test_framework, :test_command, :test_timeout,
|
|
8
|
-
:authenticate_with, :authentication_cache_ttl, :user_name_callback, :user_name_methods,
|
|
8
|
+
:authenticate_with, :cable_authenticate_with, :authentication_cache_ttl, :user_name_callback, :user_name_methods,
|
|
9
9
|
:lint_timeout, :base_branch_candidates, :git_timeout, :search_timeout,
|
|
10
10
|
:ruby_def_include_dirs, :related_files_custom_paths,
|
|
11
11
|
:mount_path, :resilient_routing, :js_global_identifiers,
|
|
@@ -58,6 +58,16 @@ module Mbeditor
|
|
|
58
58
|
@ruby_def_include_dirs = %w[app/models app/controllers app/helpers app/concerns]
|
|
59
59
|
@related_files_custom_paths = []
|
|
60
60
|
@authentication_cache_ttl = 0
|
|
61
|
+
# Authentication for the collaboration WebSocket. nil falls back to
|
|
62
|
+
# authenticate_with.
|
|
63
|
+
#
|
|
64
|
+
# Exists because the two contexts genuinely differ: a WebSocket subscribe
|
|
65
|
+
# runs no controller, so anything a before_action populates —
|
|
66
|
+
# ActiveSupport::CurrentAttributes, an Authlogic session, a memoised
|
|
67
|
+
# current_user — is nil or raises here, and the hook then denies the socket
|
|
68
|
+
# while working perfectly over HTTP. Set this to a proc that resolves the
|
|
69
|
+
# user from `session` directly when the HTTP hook cannot.
|
|
70
|
+
@cable_authenticate_with = nil
|
|
61
71
|
@user_name_callback = nil # proc resolved in controller context (instance_exec) → collaboration display name; nil falls through to current_user, then to the client-generated name
|
|
62
72
|
# Attributes tried on current_user, in order, when no user_name_callback
|
|
63
73
|
# is set. First non-blank one wins. Name your own column here rather than
|
data/lib/mbeditor/version.rb
CHANGED