Skip to content

Commit f0e4f78

Browse files
committed
Add support for pausing and unpausing the emulator
When the emulator polls for input, if we're paused, we block until it is unpaused. In the case of the SharedArrayBuffer input mode, we can use Atomics.wait and Atomics.notify to do this relatively efficiently. Even for the fallback mode we can avoid polling by not resolving the command fetching request until the input in unpaused. Use this to expose pause/unpause functionality in the embedded mode in few ways: - The frame will listen for emulator_pause and emulator_unpause postMessage events. - The embed URL can have a paused=true query parameter to allow it to start paused. - The embed URL can also have an auto_pause=true query parameter to automatically pause when the emulator is not visible. This is done by checking the visibility of the iframe document, as well as the visibility of the screen canvas (via IntersectionObserver). Updates #382
1 parent 7000046 commit f0e4f78

9 files changed

Lines changed: 331 additions & 12 deletions

public/embed-testbed.html

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<title>Embed Testbed</title>
5+
</head>
6+
<body>
7+
<h1>Embed Testbed</h1>
8+
<form action="/embed" method="get" target="frame">
9+
<select name="disk">
10+
<option value="System 1.0">System 1.0</option>
11+
<option value="System 3.0">System 3.0</option>
12+
<option value="System 6.0.4">System 6.0.4</option>
13+
<option value="System 7.0">System 7.0</option>
14+
<option value="System 7.1">System 7.1</option>
15+
<option value="System 7.5">System 7.5</option>
16+
</select>
17+
18+
<label>
19+
<input
20+
type="checkbox"
21+
name="infinite_hd"
22+
value="true"
23+
checked
24+
/>
25+
Infinite HD
26+
</label>
27+
28+
<label>
29+
<input
30+
type="checkbox"
31+
name="screen_update_messages"
32+
value="true"
33+
checked
34+
/>
35+
Get screen updates
36+
</label>
37+
38+
<label>
39+
<input type="checkbox" name="paused" value="true" />
40+
Start paused
41+
</label>
42+
43+
<label>
44+
<input type="checkbox" name="auto_pause" value="true" />
45+
Auto-pause
46+
</label>
47+
48+
<label>
49+
<input type="checkbox" name="debug_fallback" value="true" />
50+
Use fallback mode
51+
</label>
52+
53+
<input type="submit" value="Load" />
54+
</form>
55+
56+
<div style="height: 600px"></div>
57+
58+
<iframe
59+
allow="cross-origin-isolated"
60+
style="border: 2px solid red"
61+
height="342"
62+
width="512"
63+
name="frame"
64+
></iframe>
65+
66+
<div>
67+
<button id="pause">Pause</button>
68+
<button id="unpause">Unpause</button>
69+
</div>
70+
71+
<div style="height: 600px"></div>
72+
73+
<script>
74+
const frame = document.querySelector("iframe");
75+
76+
onload = function () {
77+
if (location.search.includes("autoload")) {
78+
document.querySelector("form").submit();
79+
}
80+
};
81+
82+
onmessage = function (e) {
83+
const data = e.data;
84+
switch (data.type) {
85+
case "emulator_screen":
86+
const {data: screenData, width, height} = data;
87+
console.log(
88+
`${width}x${height} screen received (${screenData.length.toLocaleString()} bytes)`
89+
);
90+
}
91+
};
92+
93+
document.getElementById("pause").onclick = function () {
94+
frame.contentWindow.postMessage({type: "emulator_pause"}, "*");
95+
};
96+
document.getElementById("unpause").onclick = function () {
97+
frame.contentWindow.postMessage(
98+
{type: "emulator_unpause"},
99+
"*"
100+
);
101+
};
102+
</script>
103+
</body>
104+
</html>

src/Mac.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ export type MacProps = {
6767
screenSize: ScreenSize;
6868
screenScale?: number;
6969
screenUpdateMessages?: boolean;
70+
startPaused?: boolean;
71+
autoPause?: boolean;
72+
listenForControlMessages?: boolean;
7073
ethernetProvider?: EmulatorEthernetProvider;
7174
customDate?: Date;
7275
debugFallback?: boolean;
@@ -90,6 +93,9 @@ export default function Mac({
9093
ramSize,
9194
screenSize: screenSizeProp,
9295
screenScale: screenScaleProp,
96+
startPaused,
97+
autoPause,
98+
listenForControlMessages,
9399
screenUpdateMessages,
94100
ethernetProvider,
95101
customDate,
@@ -232,6 +238,8 @@ export default function Mac({
232238
cdroms,
233239
ethernetProvider,
234240
customDate,
241+
startPaused,
242+
autoPause,
235243
debugAudio,
236244
debugLog,
237245
debugTrackpad,
@@ -368,10 +376,31 @@ export default function Mac({
368376
}
369377
varz.incrementMulti(startVarz);
370378

379+
let messageListener: (e: MessageEvent) => void;
380+
if (listenForControlMessages) {
381+
messageListener = e => {
382+
switch (e.data.type) {
383+
case "emulator_pause":
384+
emulator.pause();
385+
break;
386+
case "emulator_unpause":
387+
emulator.unpause();
388+
break;
389+
default:
390+
console.warn("Unknown message from parent:", e.data);
391+
break;
392+
}
393+
};
394+
window.addEventListener("message", messageListener);
395+
}
396+
371397
return () => {
372398
emulator.stop();
373399
emulatorRef.current = undefined;
374400
ethernetProvider?.close?.();
401+
if (messageListener) {
402+
window.removeEventListener("message", messageListener);
403+
}
375404
};
376405
}, [
377406
disks,
@@ -394,6 +423,10 @@ export default function Mac({
394423
libraryDownloadURLs,
395424
handleMacLibraryRun,
396425
handleMacLibraryProgress,
426+
startPaused,
427+
autoPause,
428+
screenUpdateMessages,
429+
listenForControlMessages,
397430
]);
398431

399432
useEffect(() => {

src/RunDefMac.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,10 @@ export default function RunDefMac({runDef, onDone}: RunDefMacProps) {
4242
ramSize={runDef.ramSize}
4343
screenSize={runDef.screenSize}
4444
screenScale={runDef.screenScale}
45-
screenUpdateMessages={runDef.embedScreenUpdateMessages}
45+
screenUpdateMessages={runDef.screenUpdateMessages}
46+
startPaused={runDef.startPaused}
47+
autoPause={runDef.autoPause}
48+
listenForControlMessages={runDef.isEmbed}
4649
ethernetProvider={runDef.ethernetProvider}
4750
customDate={runDef.customDate ?? runDef.disks[0]?.customDate}
4851
debugFallback={runDef.debugFallback}

src/emulator/emulator-common.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ export const InputBufferAddresses = {
2828

2929
useMouseDeltasFlagAddr: 17,
3030
useMouseDeltasAddr: 18,
31+
32+
pausedAddr: 19,
3133
};
3234

3335
export type EmulatorMouseEvent =
@@ -65,6 +67,14 @@ export type EmulatorSetUseMouseDeltas = {
6567
useMouseDeltas: boolean;
6668
};
6769

70+
export type EmulatorPauseEvent = {
71+
type: "pause";
72+
};
73+
74+
export type EmulatorUnpauseEvent = {
75+
type: "unpause";
76+
};
77+
6878
export type EmulatorInputEvent =
6979
| EmulatorMouseEvent
7080
| EmulatorKeyboardEvent
@@ -73,7 +83,9 @@ export type EmulatorInputEvent =
7383
| EmulatorEthernetInterruptEvent
7484
| EmulatorAudioContextRunningEvent
7585
| EmulatorSetSpeedEvent
76-
| EmulatorSetUseMouseDeltas;
86+
| EmulatorSetUseMouseDeltas
87+
| EmulatorPauseEvent
88+
| EmulatorUnpauseEvent;
7789

7890
export enum LockStates {
7991
READY_FOR_UI_THREAD,
@@ -387,6 +399,13 @@ export function updateInputBufferWithEvents(
387399
] = 1;
388400
inputBufferView[InputBufferAddresses.useMouseDeltasAddr] =
389401
inputEvent.useMouseDeltas ? 1 : 0;
402+
break;
403+
case "pause":
404+
inputBufferView[InputBufferAddresses.pausedAddr] = 1;
405+
break;
406+
case "unpause":
407+
console.warn("Unpause event should be handled directly");
408+
break;
390409
}
391410
}
392411
if (hasMousePosition) {

src/emulator/emulator-service-worker.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
declare const self: ServiceWorkerGlobalScope;
99

1010
let workerCommands: EmulatorFallbackCommand[] = [];
11+
let isPaused = false;
12+
let onUnpauseCallbacks: ((response: Response) => void)[] = [];
1113

1214
function constructJsonResponse(json: any) {
1315
return new Response(JSON.stringify(json), {
@@ -25,7 +27,25 @@ const diskCacheSpecs: EmulatorChunkedFileSpec[] = [];
2527
self.addEventListener("message", event => {
2628
const {data} = event;
2729
if (data.type === "worker-command") {
28-
workerCommands.push(data.command);
30+
const command = data.command as EmulatorFallbackCommand;
31+
let shouldPush = true;
32+
if (command.type === "input") {
33+
if (command.event.type === "pause") {
34+
isPaused = true;
35+
shouldPush = false;
36+
}
37+
if (command.event.type === "unpause") {
38+
isPaused = false;
39+
shouldPush = false;
40+
for (const callback of onUnpauseCallbacks) {
41+
callback(prepareCommandsFetchResponse());
42+
}
43+
onUnpauseCallbacks = [];
44+
}
45+
}
46+
if (shouldPush) {
47+
workerCommands.push(data.command);
48+
}
2949
} else if (data.type === "init-disk-cache") {
3050
const diskFileSpec = data.spec as EmulatorChunkedFileSpec;
3151
diskCacheSpecs.push(diskFileSpec);
@@ -71,9 +91,29 @@ self.addEventListener("fetch", (event: FetchEvent) => {
7191
});
7292

7393
function handleWorkerCommands(event: FetchEvent) {
94+
if (isPaused) {
95+
const unpausePromise = new Promise<Response>(resolve => {
96+
console.log("Emulator paused, waiting for input");
97+
const startTime = performance.now();
98+
onUnpauseCallbacks.push(response => {
99+
console.log(
100+
"Emulator unpaused after",
101+
((performance.now() - startTime) / 1000).toFixed(1),
102+
"seconds"
103+
);
104+
resolve(response);
105+
});
106+
});
107+
event.respondWith(unpausePromise);
108+
return;
109+
}
110+
event.respondWith(prepareCommandsFetchResponse());
111+
}
112+
113+
function prepareCommandsFetchResponse(): Response {
74114
const fetchResponse = constructJsonResponse(workerCommands);
75115
workerCommands = [];
76-
event.respondWith(fetchResponse);
116+
return fetchResponse;
77117
}
78118

79119
function handleIdleWait(event: FetchEvent) {

src/emulator/emulator-ui-input.ts

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ import {
77
InputBufferAddresses,
88
LockStates,
99
} from "./emulator-common";
10-
import {type EmulatorFallbackCommandSender} from "./emulator-ui";
10+
import {
11+
type EmulatorConfig,
12+
type EmulatorFallbackCommandSender,
13+
} from "./emulator-ui";
1114

1215
const INPUT_BUFFER_SIZE = 100;
1316

@@ -20,6 +23,14 @@ export class SharedMemoryEmulatorInput implements EmulatorInput {
2023
#inputBuffer = new SharedArrayBuffer(INPUT_BUFFER_SIZE * 4);
2124
#inputBufferView = new Int32Array(this.#inputBuffer);
2225
#inputQueue: EmulatorInputEvent[] = [];
26+
#isPaused = false;
27+
28+
constructor(config: EmulatorConfig) {
29+
if (config.startPaused) {
30+
this.#inputBufferView[InputBufferAddresses.pausedAddr] = 1;
31+
this.#isPaused = true;
32+
}
33+
}
2334

2435
workerConfig(): EmulatorWorkerSharedMemoryInputConfig {
2536
return {
@@ -30,6 +41,23 @@ export class SharedMemoryEmulatorInput implements EmulatorInput {
3041
}
3142

3243
handleInput(inputEvent: EmulatorInputEvent) {
44+
if (inputEvent.type === "unpause") {
45+
this.#isPaused = false;
46+
// If the emulator is paused, it currently holds the lock, so we
47+
// can't acquire it. Signal it directly to unpause.
48+
this.#inputBufferView[InputBufferAddresses.pausedAddr] = 0;
49+
Atomics.notify(
50+
this.#inputBufferView,
51+
InputBufferAddresses.pausedAddr
52+
);
53+
return;
54+
}
55+
if (inputEvent.type === "pause") {
56+
this.#isPaused = true;
57+
}
58+
if (this.#isPaused && isEventIgnoredDuringPause(inputEvent)) {
59+
return;
60+
}
3361
this.#inputQueue.push(inputEvent);
3462
this.#tryToSendInput();
3563
}
@@ -88,15 +116,38 @@ function releaseLock(bufferView: Int32Array, lockIndex: number) {
88116

89117
export class FallbackEmulatorInput implements EmulatorInput {
90118
#commandSender: EmulatorFallbackCommandSender;
119+
#isPaused = false;
91120

92-
constructor(commandSender: EmulatorFallbackCommandSender) {
121+
constructor(
122+
config: EmulatorConfig,
123+
commandSender: EmulatorFallbackCommandSender
124+
) {
93125
this.#commandSender = commandSender;
126+
if (config.startPaused) {
127+
this.#isPaused = true;
128+
this.#commandSender({type: "input", event: {type: "pause"}});
129+
}
94130
}
95131
workerConfig(): EmulatorWorkerFallbackInputConfig {
96132
return {type: "fallback", inputBufferSize: INPUT_BUFFER_SIZE};
97133
}
98134

99135
handleInput(inputEvent: EmulatorInputEvent): void {
136+
if (inputEvent.type === "unpause") {
137+
this.#isPaused = false;
138+
}
139+
if (inputEvent.type === "pause") {
140+
this.#isPaused = true;
141+
}
142+
if (this.#isPaused && isEventIgnoredDuringPause(inputEvent)) {
143+
return;
144+
}
100145
this.#commandSender({type: "input", event: inputEvent});
101146
}
102147
}
148+
149+
function isEventIgnoredDuringPause(inputEvent: EmulatorInputEvent): boolean {
150+
return ["mousemove", "mousedown", "mouseup", "keydown", "keyup"].includes(
151+
inputEvent.type
152+
);
153+
}

0 commit comments

Comments
 (0)