stable stopping point with initial webrtc attempts

This commit is contained in:
Seth Call 2026-03-02 08:34:45 -06:00
parent 983c690451
commit 61a7ed223b
15 changed files with 652 additions and 26 deletions

View File

@ -232,3 +232,8 @@ Determine whether frontend code can support `await context.jamClient.method()` f
- `session_utils.js` join-source trace now writes to buffer (`join-source.session-utils.joinSession`) while downgrading console severity. - `session_utils.js` join-source trace now writes to buffer (`join-source.session-utils.joinSession`) while downgrading console severity.
- Added a hard gate in `SessionStoreModern.es6` so the modern Reflux store only installs when `window.__JK_SKIP_LEGACY_SESSION_STORE__` is true. - Added a hard gate in `SessionStoreModern.es6` so the modern Reflux store only installs when `window.__JK_SKIP_LEGACY_SESSION_STORE__` is true.
- In legacy bundle loads, modern store now emits `join-source.session-store.modern-skipped` and does not register listeners; this prevents hidden second `onJoinSession` listeners from racing the legacy store. - In legacy bundle loads, modern store now emits `join-source.session-store.modern-skipped` and does not register listeners; this prevents hidden second `onJoinSession` listeners from racing the legacy store.
- Added targeted leave-source instrumentation to diagnose immediate post-join session departure:
- `SessionActions.leaveSession` now emits `leave-source.session-action` (call + trigger wrappers, with stack) in `react-components/actions/SessionActions.js.coffee`.
- `SessionStore.js.coffee#onLeaveSession` now emits `leave-source.session-store.legacy-onLeaveSession`.
- `SessionStoreModern.es6#onLeaveSession` now emits `leave-source.session-store.modern-onLeaveSession`.
- This is intended to identify the exact caller path behind immediate `DELETE /api/participants/:client_id` after successful join.

View File

@ -7,7 +7,7 @@ Build a web-client-backed `jamClient` compatibility path plus a simulator/test h
- [x] Create task tracking and record the initial plan - [x] Create task tracking and record the initial plan
- [ ] Complete async bridge contract spike (`await context.jamClient.method()` viability and blast radius) - [ ] Complete async bridge contract spike (`await context.jamClient.method()` viability and blast radius)
- [ ] Complete async bridge contract spike (`await context.jamClient.method()` viability and blast radius) (dual-bundle parser split + `SessionStore` legacy/modern split in progress) - [ ] Complete async bridge contract spike (`await context.jamClient.method()` viability and blast radius) (dual-bundle parser split + `SessionStore` legacy/modern split in progress)
- [ ] Map protocol surface (jamClient methods, backend callbacks, REST APIs, websocket messages, shared-object usage) - [x] Map protocol surface for join/mute/volume flows using native 2-party recordings
- [ ] Add instrumentation/tape-recording hooks (frontend/backend) - [ ] Add instrumentation/tape-recording hooks (frontend/backend)
- [ ] Define comparison assertions for compatibility tests - [ ] Define comparison assertions for compatibility tests
- [ ] Implement web `jamClient` shim compatibility layer - [ ] Implement web `jamClient` shim compatibility layer
@ -15,6 +15,15 @@ Build a web-client-backed `jamClient` compatibility path plus a simulator/test h
- [ ] Build MVP web-to-web session flow - [ ] Build MVP web-to-web session flow
- [ ] Build simulator/replay harness and automated specs - [ ] Build simulator/replay harness and automated specs
## 2026-03-01 WebRTC Planning Update
- Captured native reference logs for 2-party mute/volume scenario in:
- `web/ai/native-client-2p-recording/20260228-210957-seth-native-2p-mute-volume.log`
- `web/ai/native-client-2p-recording/20260228-211006-david-native-2p-mute-volume.log`
- Confirmed these are sufficient as baseline artifacts for bridge-call and REST/websocket pattern parity while implementing web-client media with selective behavioral divergence.
- Added execution plan in `agent-tasks/client-simulator/webrtc-web-client-plan/progress.md`.
- Added Tier A/B/C contract matrix from the captured logs in:
- `web/ai/native-client-2p-recording/contract-matrix.md`
## Notes ## Notes
- Native recording execution is intentionally out of scope for this effort (native path is being deprecated). - Native recording execution is intentionally out of scope for this effort (native path is being deprecated).
- Recording/comparison scope must include REST API requests/responses in addition to bridge/events/websocket/DB behavior. - Recording/comparison scope must include REST API requests/responses in addition to bridge/events/websocket/DB behavior.

View File

@ -54,6 +54,7 @@
//= require AAA_Log //= require AAA_Log
//= require globals //= require globals
//= require debug_log_collector //= require debug_log_collector
//= require mode_flags_overlay
//= require AAB_message_factory //= require AAB_message_factory
//= require jam_rest //= require jam_rest
//= require ga //= require ga

View File

@ -54,6 +54,7 @@
//= require AAA_Log //= require AAA_Log
//= require globals //= require globals
//= require debug_log_collector //= require debug_log_collector
//= require mode_flags_overlay
//= require AAB_message_factory //= require AAB_message_factory
//= require jam_rest //= require jam_rest
//= require ga //= require ga

View File

@ -28,14 +28,26 @@
$(this).removeClass('hover'); $(this).removeClass('hover');
} }
function switchClientMode(e) { function setWebClientWebRtcFlag(enabled) {
// ctrl + shift + 0 try {
if(e.ctrlKey && e.shiftKey && e.keyCode == 48) { if (!window.localStorage) { return; }
logger.debug("switch client mode!"); if (enabled) {
var act_as_native_client = $.cookie('act_as_native_client'); window.localStorage.setItem('jk.webClient.webrtc', '1');
}
else {
window.localStorage.removeItem('jk.webClient.webrtc');
}
}
catch(err) {
logger.warn("unable to update jk.webClient.webrtc localStorage flag", err);
}
}
logger.debug("currently: " + act_as_native_client); function toggleActAsNativeClient() {
if(act_as_native_client == null || act_as_native_client != "true") { var act_as_native_client = $.cookie('act_as_native_client');
var enable = (act_as_native_client == null || act_as_native_client != "true");
if(enable) {
logger.debug("forcing act as native client!"); logger.debug("forcing act as native client!");
$.cookie('act_as_native_client', 'true', { expires: 120, path: '/' }); $.cookie('act_as_native_client', 'true', { expires: 120, path: '/' });
} }
@ -43,6 +55,29 @@
logger.debug("remove act as native client!"); logger.debug("remove act as native client!");
$.removeCookie('act_as_native_client'); $.removeCookie('act_as_native_client');
} }
return enable;
}
function switchClientMode(e) {
if(!(e.ctrlKey && e.shiftKey)) { return; }
// ctrl + shift + 0
if(e.keyCode == 48) {
logger.debug("switch client mode!");
toggleActAsNativeClient();
window.location.reload();
return;
}
// ctrl + shift + 9
// One-step toggle for web client mode:
// - act_as_native_client cookie
// - localStorage flag enabling WebRTC path in WebJamClient
if(e.keyCode == 57) {
logger.debug("switch web client mode (native-cookie + webrtc flag)!");
var enabled = toggleActAsNativeClient();
setWebClientWebRtcFlag(enabled);
window.location.reload(); window.location.reload();
} }
} }

View File

@ -594,6 +594,12 @@
} }
function deleteParticipant(clientId) { function deleteParticipant(clientId) {
if (context.JK && context.JK.DebugLogCollector && context.JK.DebugLogCollector.push) {
context.JK.DebugLogCollector.push("leave-source.rest.deleteParticipant", {
client_id: clientId,
stack: (new Error("jam_rest.deleteParticipant")).stack
});
}
var url = "/api/participants/" + clientId; var url = "/api/participants/" + clientId;
return $.ajax({ return $.ajax({
type: "DELETE", type: "DELETE",

View File

@ -51,3 +51,29 @@ if @SessionActions?.joinSession?
originalTrigger.apply(originalJoinAction, arguments) originalTrigger.apply(originalJoinAction, arguments)
@SessionActions.joinSession = wrappedJoinAction @SessionActions.joinSession = wrappedJoinAction
pushLeaveActionTrace = (source, argsLike) ->
return unless context.JK?.DebugLogCollector?.push
args = Array::slice.call(argsLike ? [])
context.JK.DebugLogCollector.push("leave-source.session-action", {
source: source
args: args
stack: (new Error("SessionActions.leaveSession")).stack
})
if @SessionActions?.leaveSession?
originalLeaveAction = @SessionActions.leaveSession
wrappedLeaveAction = ->
pushLeaveActionTrace('call', arguments)
originalLeaveAction.apply(this, arguments)
for own key, value of originalLeaveAction
wrappedLeaveAction[key] = value
if originalLeaveAction.trigger?
originalLeaveTrigger = originalLeaveAction.trigger
wrappedLeaveAction.trigger = ->
pushLeaveActionTrace('trigger', arguments)
originalLeaveTrigger.apply(originalLeaveAction, arguments)
@SessionActions.leaveSession = wrappedLeaveAction

View File

@ -65,6 +65,7 @@ MIDI_TRACK = context.JK.MIDI_TRACK
# find any VST info # find any VST info
if hasMixer && @configureTracks? if hasMixer && @configureTracks?
vstAssignments = @configureTracks?.vstTrackAssignments?.vsts ? []
# bug in the backend; track is wrong for personal mixers (always 1), but correct for master mix # bug in the backend; track is wrong for personal mixers (always 1), but correct for master mix
trackAssignment = -1 trackAssignment = -1
@ -79,7 +80,7 @@ MIDI_TRACK = context.JK.MIDI_TRACK
else else
trackAssignment = mixerData.oppositeMixer?.track trackAssignment = mixerData.oppositeMixer?.track
for vst in @configureTracks.vstTrackAssignments.vsts for vst in vstAssignments
if vst.track == trackAssignment - 1 && vst.name != 'NONE' if vst.track == trackAssignment - 1 && vst.name != 'NONE'
logger.debug("found VST on track", vst, track) logger.debug("found VST on track", vst, track)
associatedVst = vst associatedVst = vst

View File

@ -242,6 +242,8 @@ void removeSearchPath(int typeId, QString pathToRemove);
# the backend does not have a consistent way of tracking assigned inputs for midi. # the backend does not have a consistent way of tracking assigned inputs for midi.
# let's make it seem consistent # let's make it seem consistent
injectMidiToTrackAssignments: () -> injectMidiToTrackAssignments: () ->
return unless @trackAssignments?.inputs?.assigned?
return unless @vstTrackAssignments?.vsts?
if @vstTrackAssignments? if @vstTrackAssignments?
for vst in @vstTrackAssignments.vsts for vst in @vstTrackAssignments.vsts
if vst.track == MIDI_TRACK - 1 if vst.track == MIDI_TRACK - 1
@ -263,26 +265,28 @@ void removeSearchPath(int typeId, QString pathToRemove);
changed: () -> changed: () ->
@injectMidiToTrackAssignments() @injectMidiToTrackAssignments()
assignedInputs = @trackAssignments?.inputs?.assigned ? []
vstAssignments = @vstTrackAssignments?.vsts ? []
@editingTrack = [] @editingTrack = []
@editingTrack.assignment = @trackNumber @editingTrack.assignment = @trackNumber
if @trackNumber? if @trackNumber?
for inputsForTrack in @trackAssignments.inputs.assigned for inputsForTrack in assignedInputs
if inputsForTrack.assignment == @trackNumber if inputsForTrack.assignment == @trackNumber
@editingTrack = inputsForTrack @editingTrack = inputsForTrack
break break
# slap on vst, if any, from list of vst assignments # slap on vst, if any, from list of vst assignments
for vst in @vstTrackAssignments.vsts for vst in vstAssignments
if vst.track == @editingTrack.assignment - 1 if vst.track == @editingTrack.assignment - 1
@editingTrack.vst = vst @editingTrack.vst = vst
@editingTrack.midiDeviceIndex = vst.midiDeviceIndex @editingTrack.midiDeviceIndex = vst.midiDeviceIndex
break break
for inputsForTrack in @trackAssignments.inputs.assigned for inputsForTrack in assignedInputs
if vst.track == inputsForTrack.assignment - 1 if vst.track == inputsForTrack.assignment - 1
inputsForTrack.vst = vst inputsForTrack.vst = vst
@ -290,7 +294,7 @@ void removeSearchPath(int typeId, QString pathToRemove);
logger.debug("current track has a VST assigned:" + @editingTrack.vst.file) logger.debug("current track has a VST assigned:" + @editingTrack.vst.file)
unscanned = !@scannedBefore && @vstPluginList.vsts.length <= 1 unscanned = !@scannedBefore && ((@vstPluginList?.vsts?.length ? 0) <= 1)
@item = { @item = {
unscanned: unscanned, unscanned: unscanned,

View File

@ -1169,6 +1169,12 @@ if context.JK?.DebugLogCollector?.push
# called by anyone wanting to leave the session with a certain behavior # called by anyone wanting to leave the session with a certain behavior
onLeaveSession: (behavior) -> onLeaveSession: (behavior) ->
logger.debug("attempting to leave session", behavior) logger.debug("attempting to leave session", behavior)
if context.JK?.DebugLogCollector?.push
context.JK.DebugLogCollector.push("leave-source.session-store.legacy-onLeaveSession", {
behavior: behavior
current_session_id: @currentSessionId
stack: (new Error("SessionStoreLegacy.onLeaveSession")).stack
})
if behavior.notify if behavior.notify
@app.layout.notify(behavior.notify) @app.layout.notify(behavior.notify)

View File

@ -809,6 +809,23 @@ if (shouldInstallModernSessionStore) {
}, },
async onJoinSessionDone(musicSession) { async onJoinSessionDone(musicSession) {
const pushJoinAbort = (reason, detail) => {
context.__jkJoinAborts = context.__jkJoinAborts || [];
context.__jkJoinAborts.push({
ts: (new Date()).toISOString(),
reason,
detail,
current_session_id: this.currentSessionId
});
if (context.JK && context.JK.DebugLogCollector && context.JK.DebugLogCollector.push) {
context.JK.DebugLogCollector.push("join-source.session-store.modern-abort", {
reason,
detail,
current_session_id: this.currentSessionId,
stack: (new Error("SessionStoreModern.onJoinSessionDone.abort")).stack
});
}
};
const musicianAccessOnJoin = musicSession.musician_access; const musicianAccessOnJoin = musicSession.musician_access;
const shouldVerifyNetwork = musicSession.musician_access; const shouldVerifyNetwork = musicSession.musician_access;
@ -827,6 +844,7 @@ if (shouldInstallModernSessionStore) {
try { try {
await this.gearUtils.guardAgainstInvalidConfiguration(this.app, shouldVerifyNetwork); await this.gearUtils.guardAgainstInvalidConfiguration(this.app, shouldVerifyNetwork);
} catch (e) { } catch (e) {
pushJoinAbort("guardAgainstInvalidConfiguration", {error: String(e)});
SessionActions.leaveSession.trigger({location: '/client#/home'}); SessionActions.leaveSession.trigger({location: '/client#/home'});
return; return;
} }
@ -848,6 +866,7 @@ if (shouldInstallModernSessionStore) {
leaveBehavior.location = '/client#/home'; leaveBehavior.location = '/client#/home';
} }
pushJoinAbort("guardAgainstActiveProfileMissing", data);
SessionActions.leaveSession.trigger(leaveBehavior); SessionActions.leaveSession.trigger(leaveBehavior);
return; return;
} }
@ -864,6 +883,7 @@ if (shouldInstallModernSessionStore) {
context.JK.alertSupportedNeeded('Unable to determine configured tracks due to reason: ' + data); context.JK.alertSupportedNeeded('Unable to determine configured tracks due to reason: ' + data);
} }
pushJoinAbort("waitForSessionPageEnterDone", {error: data});
SessionActions.leaveSession.trigger({location: '/client#/home'}); SessionActions.leaveSession.trigger({location: '/client#/home'});
return; return;
} }
@ -1250,6 +1270,13 @@ if (shouldInstallModernSessionStore) {
leaveSessionRest() { leaveSessionRest() {
if (context.JK && context.JK.DebugLogCollector && context.JK.DebugLogCollector.push) {
context.JK.DebugLogCollector.push("leave-source.session-store.modern-leaveSessionRest", {
current_session_id: this.currentSessionId,
client_id: this.app && this.app.clientId,
stack: (new Error("SessionStoreModern.leaveSessionRest")).stack
});
}
return rest.deleteParticipant(this.app.clientId); return rest.deleteParticipant(this.app.clientId);
}, },
@ -1436,6 +1463,13 @@ if (shouldInstallModernSessionStore) {
// called by anyone wanting to leave the session with a certain behavior // called by anyone wanting to leave the session with a certain behavior
onLeaveSession(behavior) { onLeaveSession(behavior) {
logger.debug("attempting to leave session", behavior); logger.debug("attempting to leave session", behavior);
if (context.JK && context.JK.DebugLogCollector && context.JK.DebugLogCollector.push) {
context.JK.DebugLogCollector.push("leave-source.session-store.modern-onLeaveSession", {
behavior: behavior,
current_session_id: this.currentSessionId,
stack: (new Error("SessionStoreModern.onLeaveSession")).stack
});
}
if (behavior.notify) { if (behavior.notify) {
this.app.layout.notify(behavior.notify); this.app.layout.notify(behavior.notify);

View File

@ -631,6 +631,13 @@
} }
function leaveSessionRest(sessionId) { function leaveSessionRest(sessionId) {
if (context.JK && context.JK.DebugLogCollector && context.JK.DebugLogCollector.push) {
context.JK.DebugLogCollector.push("leave-source.session-model.leaveSessionRest", {
session_id: sessionId,
client_id: clientId,
stack: (new Error("SessionModel.leaveSessionRest")).stack
});
}
var url = "/api/participants/" + clientId; var url = "/api/participants/" + clientId;
return $.ajax({ return $.ajax({
type: "DELETE", type: "DELETE",

View File

@ -29,6 +29,20 @@
stream: null, stream: null,
error: null, error: null,
autoStartEnabled: false autoStartEnabled: false
},
controlState: {
master: {},
personal: {},
initialized: {
master: false,
personal: false
}
},
participants: {},
webrtc: {
enabled: false,
peerConnections: {},
localClientId: null
} }
}; };
@ -228,6 +242,352 @@
pushRecord({kind: "webClientMediaStatus", status: state.localMedia.status}); pushRecord({kind: "webClientMediaStatus", status: state.localMedia.status});
} }
function ensureLocalMediaForWebRtc() {
if (!state.webrtc.enabled) { return; }
if (!context.navigator || !context.navigator.mediaDevices || !context.navigator.mediaDevices.getUserMedia) {
return;
}
if (state.localMedia.status === "requesting" || state.localMedia.status === "active") {
return;
}
state.localMedia.status = "requesting";
state.localMedia.error = null;
pushRecord({kind: "webClientMediaStatus", status: state.localMedia.status, reason: "webrtc"});
context.navigator.mediaDevices.getUserMedia({audio: true, video: false})
.then(function(stream) {
state.localMedia.stream = stream;
state.localMedia.status = "active";
state.localMedia.error = null;
pushRecord({
kind: "webClientMediaStatus",
status: state.localMedia.status,
reason: "webrtc",
tracks: stream && stream.getAudioTracks ? stream.getAudioTracks().length : 0
});
Object.keys(state.webrtc.peerConnections).forEach(function(clientId) {
var entry = state.webrtc.peerConnections[clientId];
if (entry && entry.pc) {
addLocalTracksToPeerConnection(entry.pc);
}
});
})
.catch(function(err) {
state.localMedia.stream = null;
state.localMedia.status = "failed";
state.localMedia.error = (err && err.name) ? err.name : String(err);
pushRecord({
kind: "webClientMediaStatus",
status: state.localMedia.status,
reason: "webrtc",
error: state.localMedia.error
});
});
}
function modeKey(isMasterOrPersonal) {
return isMasterOrPersonal ? "master" : "personal";
}
function deepClone(value) {
return safeSerialize(value);
}
function getJamServer() {
return context.JK && context.JK.JamServer ? context.JK.JamServer : null;
}
function getSelfClientId() {
if (state.webrtc.localClientId) { return state.webrtc.localClientId; }
if (state.app && state.app.clientId) { return state.app.clientId; }
var server = getJamServer();
if (server && server.clientID) { return server.clientID; }
return null;
}
function readWebRtcEnabledFlag() {
return !!context.JK_WEB_CLIENT_WEBRTC_ENABLED ||
readLocalStorageFlag("jk.webClient.webrtc") ||
!!(context.gon && context.gon.web_client_webrtc_enabled);
}
function ensureControlStateCache(isMasterOrPersonal) {
var key = modeKey(isMasterOrPersonal);
if (state.controlState.initialized[key]) {
return;
}
var delegateState = invokeDelegate("SessionGetAllControlState", [isMasterOrPersonal]);
var byId = {};
if (delegateState && delegateState.length) {
delegateState.forEach(function(mixer) {
if (!mixer || !mixer.id) { return; }
byId[mixer.id] = deepClone(mixer);
});
}
state.controlState[key] = byId;
state.controlState.initialized[key] = true;
}
var syncTracksTimer = null;
function scheduleTrackSync() {
if (!context.MixerActions || !context.MixerActions.syncTracks) { return; }
if (syncTracksTimer) {
context.clearTimeout(syncTracksTimer);
}
syncTracksTimer = context.setTimeout(function() {
syncTracksTimer = null;
try {
context.MixerActions.syncTracks();
pushRecord({kind: "webClientTrackSync", source: "SessionSetControlState"});
} catch (e) {
pushRecord({
kind: "webClientTrackSyncError",
error: (e && e.message) ? e.message : String(e)
});
}
}, 50);
}
function applyTrackVolumeToCache(mixerId, isMasterOrPersonal) {
ensureControlStateCache(isMasterOrPersonal);
var key = modeKey(isMasterOrPersonal);
var cache = state.controlState[key];
var existing = cache[mixerId] || {};
var trackVolume = context.trackVolumeObject || {};
var updated = deepClone(existing) || {};
updated.id = mixerId;
updated.master = !!isMasterOrPersonal;
updated.monitor = !isMasterOrPersonal;
if (trackVolume.clientID !== undefined) { updated.client_id = trackVolume.clientID; }
if (trackVolume.mute !== undefined) { updated.mute = !!trackVolume.mute; }
if (trackVolume.volL !== undefined) { updated.volume_left = trackVolume.volL; }
if (trackVolume.volR !== undefined) { updated.volume_right = trackVolume.volR; }
if (trackVolume.pan !== undefined) { updated.pan = trackVolume.pan; }
if (trackVolume.loop !== undefined) { updated.loop = !!trackVolume.loop; }
if (trackVolume.name !== undefined) { updated.name = trackVolume.name; }
if (trackVolume.record !== undefined) { updated.record = !!trackVolume.record; }
if (updated.range_low === undefined) { updated.range_low = -80; }
if (updated.range_high === undefined) { updated.range_high = 20; }
cache[mixerId] = updated;
return updated;
}
function updateLocalTrackMuteFromControlState(controlState) {
if (!controlState) { return; }
if (!state.localMedia.stream || !state.localMedia.stream.getAudioTracks) { return; }
var trackClientId = controlState.client_id || "";
var selfClientId = getSelfClientId();
var isSelfControl = !trackClientId || (selfClientId && trackClientId === selfClientId);
if (!isSelfControl) { return; }
var shouldMute = !!controlState.mute;
state.localMedia.stream.getAudioTracks().forEach(function(track) {
try {
track.enabled = !shouldMute;
} catch (e) {}
});
}
function getPeerConnectionCtor() {
return context.RTCPeerConnection || context.webkitRTCPeerConnection || context.mozRTCPeerConnection;
}
function sendWebRtcSignal(receiverClientId, payload) {
var server = getJamServer();
if (!server || typeof server.sendP2PMessage !== "function") {
pushRecord({kind: "webrtc.signalDropped", reason: "jamServerUnavailable", to: receiverClientId});
return;
}
var envelope = {
jk_webclient_webrtc: true,
session_id: state.currentSessionId,
from_client_id: getSelfClientId(),
payload: payload
};
try {
server.sendP2PMessage(receiverClientId, JSON.stringify(envelope));
pushRecord({
kind: "webrtc.signalSent",
to: receiverClientId,
signal_type: payload && payload.type ? payload.type : "candidate"
});
} catch (e) {
pushRecord({
kind: "webrtc.signalSendError",
to: receiverClientId,
error: (e && e.message) ? e.message : String(e)
});
}
}
function addLocalTracksToPeerConnection(peerConnection) {
if (!peerConnection || !state.localMedia.stream || !state.localMedia.stream.getTracks) {
return;
}
var stream = state.localMedia.stream;
stream.getTracks().forEach(function(track) {
try {
peerConnection.addTrack(track, stream);
} catch (e) {}
});
}
function closePeerConnection(clientId) {
var entry = state.webrtc.peerConnections[clientId];
if (!entry) { return; }
try {
if (entry.pc && typeof entry.pc.close === "function") {
entry.pc.close();
}
} catch (e) {}
delete state.webrtc.peerConnections[clientId];
pushRecord({kind: "webrtc.peerClosed", client_id: clientId});
}
function ensurePeerConnection(clientId) {
if (!state.webrtc.enabled || !clientId) { return null; }
if (state.webrtc.peerConnections[clientId]) {
return state.webrtc.peerConnections[clientId];
}
var RTCPeerConnectionCtor = getPeerConnectionCtor();
if (!RTCPeerConnectionCtor) {
pushRecord({kind: "webrtc.unavailable", reason: "RTCPeerConnection"});
return null;
}
var iceServers = (context.gon && context.gon.web_client_ice_servers) || [];
var peerConnection = null;
try {
peerConnection = new RTCPeerConnectionCtor({iceServers: iceServers});
} catch (e) {
pushRecord({
kind: "webrtc.peerCreateError",
client_id: clientId,
error: (e && e.message) ? e.message : String(e)
});
return null;
}
var entry = {
client_id: clientId,
pc: peerConnection
};
state.webrtc.peerConnections[clientId] = entry;
addLocalTracksToPeerConnection(peerConnection);
peerConnection.onicecandidate = function(evt) {
if (!evt || !evt.candidate) { return; }
sendWebRtcSignal(clientId, {
type: "candidate",
candidate: evt.candidate
});
};
peerConnection.onconnectionstatechange = function() {
pushRecord({
kind: "webrtc.connectionState",
client_id: clientId,
state: peerConnection.connectionState
});
};
peerConnection.ontrack = function(evt) {
entry.remoteStream = evt && evt.streams && evt.streams.length ? evt.streams[0] : null;
pushRecord({
kind: "webrtc.remoteTrack",
client_id: clientId,
has_stream: !!entry.remoteStream
});
};
pushRecord({kind: "webrtc.peerCreated", client_id: clientId});
return entry;
}
function maybeCreateOffer(clientId) {
var entry = ensurePeerConnection(clientId);
if (!entry || !entry.pc || typeof entry.pc.createOffer !== "function") { return; }
entry.pc.createOffer()
.then(function(offer) {
return entry.pc.setLocalDescription(offer).then(function() {
sendWebRtcSignal(clientId, {type: "offer", sdp: offer});
});
})
.catch(function(err) {
pushRecord({
kind: "webrtc.offerError",
client_id: clientId,
error: (err && err.message) ? err.message : String(err)
});
});
}
function onWebRtcSignal(fromClientId, signalPayload) {
if (!state.webrtc.enabled || !signalPayload || !fromClientId) { return false; }
var entry = ensurePeerConnection(fromClientId);
if (!entry || !entry.pc) { return true; }
var pc = entry.pc;
if (signalPayload.type === "offer") {
pc.setRemoteDescription(signalPayload.sdp)
.then(function() {
return pc.createAnswer();
})
.then(function(answer) {
return pc.setLocalDescription(answer).then(function() {
sendWebRtcSignal(fromClientId, {type: "answer", sdp: answer});
});
})
.catch(function(err) {
pushRecord({
kind: "webrtc.answerError",
client_id: fromClientId,
error: (err && err.message) ? err.message : String(err)
});
});
return true;
}
if (signalPayload.type === "answer") {
pc.setRemoteDescription(signalPayload.sdp).catch(function(err) {
pushRecord({
kind: "webrtc.setAnswerError",
client_id: fromClientId,
error: (err && err.message) ? err.message : String(err)
});
});
return true;
}
if (signalPayload.type === "candidate") {
if (signalPayload.candidate && typeof pc.addIceCandidate === "function") {
pc.addIceCandidate(signalPayload.candidate).catch(function(err) {
pushRecord({
kind: "webrtc.addCandidateError",
client_id: fromClientId,
error: (err && err.message) ? err.message : String(err)
});
});
}
return true;
}
return false;
}
function installInstrumentationWrappers(target) { function installInstrumentationWrappers(target) {
Object.keys(target).forEach(function(key) { Object.keys(target).forEach(function(key) {
var original = target[key]; var original = target[key];
@ -309,6 +669,11 @@
error: state.localMedia.error, error: state.localMedia.error,
hasStream: !!state.localMedia.stream, hasStream: !!state.localMedia.stream,
autoStartEnabled: !!state.localMedia.autoStartEnabled autoStartEnabled: !!state.localMedia.autoStartEnabled
},
webrtc: {
enabled: !!state.webrtc.enabled,
localClientId: getSelfClientId(),
peerCount: Object.keys(state.webrtc.peerConnections).length
} }
}, },
recorder: { recorder: {
@ -384,6 +749,9 @@
} }
pushRecord({kind: "webClientSessionState", event: "JoinSession", session: safeSerialize(sessionDescriptor)}); pushRecord({kind: "webClientSessionState", event: "JoinSession", session: safeSerialize(sessionDescriptor)});
startLocalMediaIfEnabled(); startLocalMediaIfEnabled();
if (state.webrtc.enabled) {
ensureLocalMediaForWebRtc();
}
return invokeDelegate("JoinSession", arguments); return invokeDelegate("JoinSession", arguments);
}; };
@ -391,17 +759,132 @@
state.inSession = false; state.inSession = false;
state.currentSessionId = null; state.currentSessionId = null;
pushRecord({kind: "webClientSessionState", event: "LeaveSession", session: safeSerialize(sessionDescriptor)}); pushRecord({kind: "webClientSessionState", event: "LeaveSession", session: safeSerialize(sessionDescriptor)});
Object.keys(state.webrtc.peerConnections).forEach(function(clientId) {
closePeerConnection(clientId);
});
if (!state.inSessionPage) { if (!state.inSessionPage) {
stopLocalMedia(); stopLocalMedia();
} }
return invokeDelegate("LeaveSession", arguments); return invokeDelegate("LeaveSession", arguments);
}; };
this.SessionGetAllControlState = function(isMasterOrPersonal) {
ensureControlStateCache(isMasterOrPersonal);
var key = modeKey(isMasterOrPersonal);
var values = Object.keys(state.controlState[key]).map(function(mixerId) {
return deepClone(state.controlState[key][mixerId]);
});
pushRecord({
kind: "webClientControlStateRead",
mode: key,
count: values.length
});
return values;
};
this.SessionSetControlState = function(mixerId, isMasterOrPersonal) {
var updated = applyTrackVolumeToCache(mixerId, isMasterOrPersonal);
updateLocalTrackMuteFromControlState(updated);
if (context.trackVolumeObject && context.trackVolumeObject.broadcast) {
scheduleTrackSync();
}
pushRecord({
kind: "webClientControlStateSet",
mixer_id: mixerId,
mode: modeKey(isMasterOrPersonal),
state: deepClone(updated),
broadcast: !!(context.trackVolumeObject && context.trackVolumeObject.broadcast)
});
return invokeDelegate("SessionSetControlState", arguments);
};
this.UpdateSessionInfo = function(sessionInfo) {
if (sessionInfo && sessionInfo.id) {
state.currentSessionId = sessionInfo.id;
}
if (sessionInfo && sessionInfo.participants && sessionInfo.participants.length) {
var nextParticipants = {};
sessionInfo.participants.forEach(function(participant) {
if (!participant || !participant.client_id) { return; }
nextParticipants[participant.client_id] = deepClone(participant);
});
state.participants = nextParticipants;
}
return invokeDelegate("UpdateSessionInfo", arguments);
};
this.ParticipantJoined = function(session, participant) {
if (participant && participant.client_id) {
state.participants[participant.client_id] = deepClone(participant);
if (state.webrtc.enabled) {
var selfClientId = getSelfClientId();
if (participant.client_id !== selfClientId) {
maybeCreateOffer(participant.client_id);
}
}
}
return invokeDelegate("ParticipantJoined", arguments);
};
this.ParticipantLeft = function(session, participant) {
if (participant && participant.client_id) {
delete state.participants[participant.client_id];
closePeerConnection(participant.client_id);
}
return invokeDelegate("ParticipantLeft", arguments);
};
this.ClientJoinedSession = function(sourceUserId, clientId, sessionId) {
if (clientId) {
state.participants[clientId] = state.participants[clientId] || {client_id: clientId};
if (state.webrtc.enabled) {
var selfClientId = getSelfClientId();
if (clientId !== selfClientId) {
maybeCreateOffer(clientId);
}
}
}
return invokeDelegate("ClientJoinedSession", arguments);
};
this.ClientLeftSession = function(sourceUserId, clientId, sessionId) {
if (clientId) {
delete state.participants[clientId];
closePeerConnection(clientId);
}
return invokeDelegate("ClientLeftSession", arguments);
};
this.P2PMessageReceived = function(from, payload) {
var parsedPayload = payload;
if (typeof parsedPayload === "string") {
try {
parsedPayload = JSON.parse(parsedPayload);
} catch (e) {}
}
if (parsedPayload && parsedPayload.jk_webclient_webrtc && parsedPayload.payload) {
onWebRtcSignal(from, parsedPayload.payload);
}
return invokeDelegate("P2PMessageReceived", [from, payload]);
};
// Keep a handle to the delegate for incremental replacement/testing. // Keep a handle to the delegate for incremental replacement/testing.
this.__delegate = inner; this.__delegate = inner;
this.__state = state; this.__state = state;
this.__recorder = recorder; this.__recorder = recorder;
state.webrtc.enabled = readWebRtcEnabledFlag();
if (state.webrtc.enabled) {
pushRecord({kind: "webrtc.enabled"});
}
recorder.enabled = recorderEnabledByDefault(); recorder.enabled = recorderEnabledByDefault();
if (recorder.enabled) { if (recorder.enabled) {
pushRecord({kind: "webClientRecorder", action: "auto-enable"}); pushRecord({kind: "webClientRecorder", action: "auto-enable"});

View File

@ -93,5 +93,7 @@ module ClientHelper
gon.stripe_publishable_key = Rails.application.config.stripe[:publishable_key] gon.stripe_publishable_key = Rails.application.config.stripe[:publishable_key]
gon.spa_origin_url = Rails.application.config.spa_origin_url gon.spa_origin_url = Rails.application.config.spa_origin_url
gon.log_to_server = (ENV['LOG_TO_SERVER'].to_s == '1') gon.log_to_server = (ENV['LOG_TO_SERVER'].to_s == '1')
gon.legacy_qt_webkit_client = legacy_qt_webkit_client?
gon.show_mode_flags_overlay = Rails.env.development?
end end
end end

View File

@ -1,16 +1,22 @@
{ {
"name": "jam-web", "name": "jam-web",
"dependencies" : { "scripts": {
"test:playwright": "npx playwright test -c spec/playwright/playwright.config.js",
"test:playwright:headed": "HEADLESS=false npx playwright test -c spec/playwright/playwright.config.js"
},
"dependencies": {
"browserify": "~> 6.3", "browserify": "~> 6.3",
"browserify-incremental": "^1.4.0", "browserify-incremental": "^1.4.0",
"coffeeify": "~> 0.6",
"reactify": "^0.17.1",
"coffee-reactify": "~>3.0.0", "coffee-reactify": "~>3.0.0",
"coffeeify": "~> 0.6",
"react": "^0.12.0", "react": "^0.12.0",
"react-tools": "^0.12.1" "react-tools": "^0.12.1",
"reactify": "^0.17.1"
}, },
"engines": { "engines": {
"node": ">= 0.10" "node": ">= 0.10"
},
"devDependencies": {
"@playwright/test": "^1.58.2"
} }
} }