Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/MUDLET_API.md
Original file line number Diff line number Diff line change
Expand Up @@ -420,8 +420,8 @@ Mudlet Web-specific extras (not on the wiki): `getMapMode`/`setMapMode("viewing"
| `killKey(id)` | ✅ | |
| `killTimer(id)` | ✅ | |
| `killTrigger(name\|id)` | ✅ | String → name-based delete; numeric → temp-trigger disposer |
| `loadProfile(name)` | ✅ | Opens the named profile in a NEW browser tab (`?profile=<id>&connect=1`) and connects — each profile lives in its own tab (per-profile lock), so the calling profile stays open alongside, matching Mudlet's multi-profile model. Returns `false` for an unknown name, the profile already open in this tab, or a blocked popup. `window.open` needs a user gesture: works from a key/button/alias, may be blocked from a trigger |
| `setActiveProfile(name)` | ✅ | `true`, or `false` plus why not (empty name, no such profile, not loaded); the name matches case-insensitively. A page can't switch browser tabs, so for a profile open in another tab this answers `true` without bringing it forward |
| `loadProfile(name)` | ✅ | Opens the named profile in a NEW browser tab (`?profile=<id>&connect=1`) and connects — each profile lives in its own tab (per-profile lock), so the calling profile stays open alongside, matching Mudlet's multi-profile model. Returns `nil` plus a message for an unknown name, a profile already open, or a blocked popup. A browser opens one new tab per user gesture, so the second `loadProfile` in one alias, and any from a trigger/timer/event, is blocked; the refusal then leaves an **Open profile _name_** link in the main console that opens it on click (`knownDivergences.ts`, #453) |
| `setActiveProfile(name)` | ✅ | `true` for this tab's own profile (asks for window focus), or `false` plus why not (empty name, no such profile, not loaded); the name matches case-insensitively. A page can't switch browser tabs, so for a profile open in another tab it returns `false` ("is open in another browser tab…") and that tab flashes its title until the user switches to it (`knownDivergences.ts`, #453) |
| `permAlias(name, parent, pattern, code)` | ✅ | Pattern is a single PCRE string (Mudlet TAlias.mRegexCode). Returns the new id, or -1 |
| `permGroup(name, type [, parent])` | ✅ | Creates a group node in the requested family |
| `permPromptTrigger(name, parent, code)` | ✅ | Persistent trigger firing on every server prompt (GA/EOR); single `prompt`-type pattern, never a group. Returns the new id or -1 |
Expand All @@ -434,7 +434,7 @@ Mudlet Web-specific extras (not on the wiki): `getMapMode`/`setMapMode("viewing"
| `permKey(name, parent, modifier, key, code)` | ✅ | `modifier` is the Qt::KeyboardModifier int (1=shift, 2=ctrl, 4=alt, 8=meta; -1 → none). `key` accepts a Qt::Key int or a KeyboardEvent.code string |
| `printCmdLine([name,] text)` | ✅ | Routes to overlay cmd lines, per-userwindow cmd lines, or the main bar |
| `raiseEvent(name, ...)` | ✅ | |
| `raiseGlobalEvent(name, ...)` | ✅ | Cross-tab via `BroadcastChannel` (`GlobalEventChannel`). Fires the event in every OTHER open profile (each runs in its own browser tab), never the sender; incoming events dispatch through `emitEvent` so `registerAnonymousEventHandler` handlers fire. Args limited to string/number/boolean/nil (matches Mudlet); the sender's profile name is appended as the last arg. No per-profile opt-in (matches Mudlet's `postInterHostEvent`) |
| `raiseGlobalEvent(name, ...)` | ✅ | Cross-tab via `BroadcastChannel` (`GlobalEventChannel`). Fires the event in every OTHER open profile (each runs in its own browser tab), never the sender; incoming events dispatch through `emitEvent` so `registerAnonymousEventHandler` handlers fire. Args limited to string/number/boolean/nil (matches Mudlet); the sender's profile name is appended as the last arg. No per-profile opt-in (matches Mudlet's `postInterHostEvent`). **Asynchronous:** the other profiles' handlers run a task later, not before `raiseGlobalEvent` returns as on desktop — to ask another profile for a value, have it reply with an event of its own and act on the value in that handler, not on the next line (`knownDivergences.ts`, #453) |
| `registerNamedTimer(parent, name, delay, code)` | ✅ | IDManager.lua |
| `registerNamedTrigger(parent, name, pattern, code)` | ✅ | IDManager.lua |
| `remainingTime(id)` | ✅ | JS-exposed |
Expand Down
40 changes: 36 additions & 4 deletions e2e/knownDivergences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -716,15 +716,47 @@ export const PLATFORM_DIVERGENCES: PlatformDivergence[] = [
+ 'to rewrite it. Closing it means validating every argument up front in every shim.',
issue: '#276',
},
{
api: 'loadProfile(name)',
behaviour:
'Opens the profile in a new browser tab. Desktop opens every profile a script asks for; here the '
+ 'browser allows one new tab per user gesture, so the second loadProfile() in one alias, and any '
+ 'loadProfile() from a trigger, timer or event handler, answers nil plus "the browser blocked the new '
+ 'tab". That refusal also leaves a line in the main console with an "Open profile <name>" link, '
+ 'which opens the profile when clicked.',
reason:
'Each profile lives in its own browser tab, and a page may only open a tab while handling a click or '
+ 'key press - once per gesture, and never from code the game\'s output started. No page can lift '
+ 'that, so the link turns the refused call into one click instead of a silent failure.',
issue: '#453',
},
{
api: 'setActiveProfile(name)',
behaviour:
'For a profile open in another browser tab it answers true, as desktop does, but that tab is not '
+ 'brought to the front. For this tab\'s own profile it asks for window focus.',
'For a profile open in another browser tab it answers false plus "is open in another browser tab, '
+ 'which a page cannot bring to the front", where desktop switches to it and answers true; the other '
+ 'tab flashes its title until the user switches to it, and sysProfileFocusChangeEvent fires when '
+ 'they do. For this tab\'s own profile it asks for window focus and answers true.',
reason:
'Each profile lives in its own browser tab, and browsers only let the user switch tabs - a page '
+ 'cannot focus another tab. The refusals (empty name, no such profile, not loaded) match.',
issue: '#276',
+ 'cannot focus another tab. Answering true would tell a "jump to the profile that needs attention" '
+ 'script it had worked when nothing moved. The refusals (empty name, no such profile, not loaded) '
+ 'match.',
issue: '#453',
},
{
api: 'raiseGlobalEvent(name, ...)',
behaviour:
'Other profiles\' handlers run a task later, not during the call: desktop has run them all by the '
+ 'time raiseGlobalEvent returns, so a profile that asks another for a value and reads the reply on '
+ 'the next line sees it there, and sees the old value here. Have the reply raise an event of its '
+ 'own and act on it in that handler.',
reason:
'Desktop\'s profiles share one process, and HostManager::postInterHostEvent calls raiseEvent on each '
+ 'host in turn. Here each profile is a separate browser tab with its own Lua state, and the only way '
+ 'between tabs is a BroadcastChannel message, which the receiving tab handles on a later task. One '
+ 'tab cannot run another tab\'s Lua synchronously.',
issue: '#453',
},
{
api: 'rex.config()',
Expand Down
4 changes: 4 additions & 0 deletions src/scripting/GlobalEventChannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ export class GlobalEventChannel {
) {
if (typeof BroadcastChannel === 'undefined') return;
this.channel = new BroadcastChannel('mudlet:global-events');
// Dispatched straight from the message event — no timer, await or
// batching. The BroadcastChannel hop is the only delay between tabs
// (mudlet-web#453; desktop runs other profiles' handlers inline), so
// nothing here may add another.
this.channel.onmessage = (e: MessageEvent) => {
const msg = e.data as GlobalEventMessage | null;
if (!msg || typeof msg.name !== 'string') return;
Expand Down
90 changes: 72 additions & 18 deletions src/scripting/ScriptingAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1170,6 +1170,14 @@ export class ScriptingAPI {
this.presence = new ProfilesPresence(connectionId, () => this.session.status === 'connected');
// Another tab's closeProfile(<this profile>).
this.presence.onCloseRequested = () => { this.closeMudlet(); };
// Another tab's setActiveProfile(<this profile>). No page can bring
// itself forward either, so ask for focus (honoured where the browser
// allows it) and flash the tab title until the user switches here —
// which is when sysProfileFocusChangeEvent fires, as on desktop.
this.presence.onAttentionRequested = () => {
try { window.focus(); } catch { /* not focusable here */ }
flashTitle();
};
// Re-announce this tab's connected state to other tabs on connect/
// disconnect (for their getProfiles). Deferred to a microtask so the
// session's own status handler has run before we read session.status.
Expand Down Expand Up @@ -1539,21 +1547,64 @@ export class ScriptingAPI {
* Returns null on success, or the message for Mudlet's `nil, message`
* refusal: an unknown name, a profile already open (in this tab or any
* other — the loaded set is the same one getProfiles() reports), or a popup
* the browser blocked. NOTE: `window.open` needs a user gesture, so this
* works from a key/button/alias but a browser may block it from a trigger
* (no Mudlet equivalent to that limitation). */
* the browser blocked. `window.open` needs a user gesture and a browser
* allows one new tab per gesture, so a trigger (no gesture) or the second
* loadProfile in one alias is refused (mudlet-web#453). That can't be
* lifted from a page, so a refusal leaves a link in the main console that
* opens the profile on click — the click is the gesture the call lacked. */
loadProfile(name: string): string | null {
const target = name ?? '';
const conn = useAppStore.getState().connections.find(c => c.name === target);
if (!conn) return `loadProfile: profile '${target}' does not exist`;
if (conn.id === this.connectionId || this.presence.loadedIds().includes(conn.id)) {
return `loadProfile: profile '${target}' is already loaded`;
}
if (this.isProfileLoaded(conn.id)) return `loadProfile: profile '${target}' is already loaded`;
if (this.openProfileTab(conn.id)) return null;
this.echoOpenProfileLink(conn.id, target);
return `loadProfile: could not open profile '${target}', the browser blocked the new tab; `
+ 'click the link in the main window to open it';
}

/** True when profile `id` is open in this tab or any other. */
private isProfileLoaded(id: string): boolean {
return id === this.connectionId || this.presence.loadedIds().includes(id);
}

/** Open profile `id` in a new tab and dial it. False when the browser
* refused the tab. */
private openProfileTab(id: string): boolean {
const url = new URL(window.location.href);
url.searchParams.set('profile', conn.id);
url.searchParams.set('profile', id);
url.searchParams.set('connect', '1');
const w = window.open(url.toString(), '_blank');
return w ? null : `loadProfile: could not open profile '${target}', the browser blocked the new tab`;
return !!window.open(url.toString(), '_blank');
}

/** The fallback for a loadProfile the browser refused: a line of its own
* in the main console carrying a link that opens the profile. A click on
* it that finds the profile open by then (another link, the user) just
* says so rather than opening a second tab the lock would park. */
private echoOpenProfileLink(id: string, name: string): void {
const hyperlink: FormatHyperlink = {
onClick: () => {
if (this.isProfileLoaded(id)) {
this.echoInfoLine(`Profile '${name}' is already open.`);
} else if (!this.openProfileTab(id)) {
this.echoInfoLine(`The browser blocked opening profile '${name}'; allow pop-ups for this site and try again.`);
}
},
title: `Open profile '${name}' in a new tab`,
};
const lead = this.echoOnMatchedLine || this.mainConsole.currentPartial.text.length > 0 ? '\n' : '';
this.echoMain(`${lead}The browser blocked opening profile '${name}' from a script. `);
this.echoMain(`Open profile ${name}`, this.standardLinkState('main', hyperlink));
this.echoMain('\n');
this.drainMain();
}

/** A plain line of client feedback in the main console, on a line of its
* own. */
private echoInfoLine(text: string): void {
const lead = this.echoOnMatchedLine || this.mainConsole.currentPartial.text.length > 0 ? '\n' : '';
this.echoMain(`${lead}${text}\n`);
this.drainMain();
}

/** Mudlet `closeProfile(name)`. Closes the named open profile — this one,
Expand Down Expand Up @@ -1584,11 +1635,13 @@ export class ScriptingAPI {
* case-insensitively against the profiles, then against the bundled games
* (a game with no profile is "not loaded").
*
* Returns null on success or the refusal message. Each profile has a tab
* of its own here, and a page can't bring another tab forward — browsers
* only let the user switch tabs — so for a profile open elsewhere this
* answers true, as desktop does, without the switch; for this tab's own
* profile it asks for the window's focus. */
* Returns null on success or the refusal message. For this tab's own
* profile it asks for the window's focus and succeeds. Every other profile
* has a tab of its own, and a page can't bring another tab forward —
* browsers only let the user switch tabs — so that is refused rather than
* reported as done (mudlet-web#453): the target tab is asked to flash its
* title instead, and the message says so, so a script can tell the user
* where to look. */
setActiveProfile(name: string): string | null {
const requested = name ?? '';
if (requested === '') return 'setActiveProfile: profile name cannot be empty';
Expand All @@ -1598,13 +1651,14 @@ export class ScriptingAPI {
.find(c => c.name.toLowerCase() === lower);
const canonical = conn?.name ?? BUNDLED_GAMES.find(g => g.name.toLowerCase() === lower)?.name;
if (canonical === undefined) return `setActiveProfile: profile '${requested}' does not exist`;
const loaded = conn !== undefined
&& (conn.id === this.connectionId || this.presence.loadedIds().includes(conn.id));
if (!loaded) return `setActiveProfile: profile '${canonical}' is not loaded`;
if (!conn || !this.isProfileLoaded(conn.id)) return `setActiveProfile: profile '${canonical}' is not loaded`;
if (conn.id === this.connectionId) {
try { window.focus(); } catch { /* not focusable here */ }
return null;
}
return null;
const flagged = this.presence.requestAttention(conn.id);
return `setActiveProfile: profile '${canonical}' is open in another browser tab, which a page cannot bring `
+ (flagged ? 'to the front; that tab\'s title is flashing instead' : 'to the front');
}

/** Mudlet `getCommandSeparator()`. Returns the profile's command separator
Expand Down
4 changes: 4 additions & 0 deletions src/scripting/lua/bindings/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,10 @@ export function installSessionBindings(
// the sending profile's name as the final arg; args are limited to
// string/number/boolean/nil. The channel itself is constructed by the
// runtime and handed in, since it needs the runtime's event emitter.
// The other tabs handle it on a later task, where desktop has run every
// other profile's handlers before this returns — a known divergence
// (e2e/knownDivergences.ts, mudlet-web#453): one tab cannot run another's
// Lua synchronously.
lua.global.set('raiseGlobalEvent', (event: unknown, ...args: unknown[]) => {
if (typeof event !== 'string' || event.length === 0) {
throw new Error('raiseGlobalEvent: missing argument #1 (eventName as a string expected!)');
Expand Down
Loading
Loading