/
html.ts
342 lines (282 loc) · 10.5 KB
/
html.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
import {EventEmitter} from "events";
import * as btoa from "btoa";
import * as ospath from "path";
import * as invariant from "invariant";
import * as querystring from "querystring";
import {app, BrowserWindow, shell} from "../../electron";
import spawn from "../../util/spawn";
import url from "../../util/url";
import fetch from "../../util/fetch";
import pathmaker from "../../util/pathmaker";
import debugBrowserWindow from "../../util/debug-browser-window";
import Connection from "../../capsule/connection";
import {capsule} from "../../capsule/messages_generated";
const {messages} = capsule;
const noPreload = process.env.LEAVE_TWINY_ALONE === "1";
const WEBGAME_PROTOCOL = "itch-cave";
import mklog from "../../util/log";
const log = mklog("launch/html");
import {IStartTaskOpts} from "../../types";
interface IBeforeSendHeadersDetails {
url: string;
}
interface IBeforeSendHeadersCallbackOpts {
cancel: boolean;
}
interface IBeforeSendHeadersCallback {
(opts: IBeforeSendHeadersCallbackOpts): void;
}
interface IRegisteredProtocols {
[key: string]: boolean;
}
const registeredProtocols: IRegisteredProtocols = {};
interface IRegisterProtocolOpts {
partition: string;
fileRoot: string;
}
async function registerProtocol (opts: IRegisterProtocolOpts) {
const {partition, fileRoot} = opts;
if (registeredProtocols[partition]) {
return;
}
const {session} = require("electron");
const caveSession = session.fromPartition(partition, {cache: false});
await new Promise((resolve, reject) => {
caveSession.protocol.registerFileProtocol(WEBGAME_PROTOCOL, (request, callback) => {
const urlPath = url.parse(request.url).pathname;
const filePath = ospath.join(fileRoot, urlPath.replace(/^\//, ""));
callback(filePath);
}, (error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
});
const handled = await new Promise((resolve, reject) => {
caveSession.protocol.isProtocolHandled(WEBGAME_PROTOCOL, (result) => {
resolve(result);
});
});
if (!handled) {
throw new Error(`could not register custom protocol ${WEBGAME_PROTOCOL}`);
}
registeredProtocols[partition] = true;
}
export default async function launch (out: EventEmitter, opts: IStartTaskOpts) {
const {cave, market, credentials, args, env} = opts;
invariant(cave, "launch-html has cave");
invariant(market, "launch-html has market");
invariant(credentials, "launch-html has credentials");
invariant(env, "launch-html has env");
invariant(args, "launch-html has args");
const game = await fetch.gameLazily(market, credentials, cave.gameId, {game: cave.game});
const injectPath = ospath.resolve(__dirname, "..", "..", "inject", "game.js");
const appPath = pathmaker.appPath(cave);
const entryPoint = ospath.join(appPath, cave.gamePath);
log(opts, `entry point: ${entryPoint}`);
const {width, height} = cave.windowSize;
log(opts, `starting at resolution ${width}x${height}`);
const partition = `persist:gamesession_${cave.gameId}`;
let win = new BrowserWindow({
title: game.title,
icon: `./static/images/tray/${app.getName()}.png`,
width, height,
center: true,
show: true,
/* used to be black, but that didn't work for everything */
backgroundColor: "#fff",
/* the width x height we give is content size, window will be slightly larger */
useContentSize: true,
webPreferences: {
/* don't let web code control the OS */
nodeIntegration: false,
/* don't enforce same-origin policy (to allow API requests) */
webSecurity: false,
allowRunningInsecureContent: true,
/* hook up a few keyboard shortcuts of our own */
preload: noPreload ? null : injectPath,
/* stores cookies etc. in persistent session to save progress */
partition,
},
});
const itchObject = {
env,
args,
};
// open dev tools immediately if requested
if (process.env.IMMEDIATE_NOSE_DIVE === "1") {
debugBrowserWindow(`game ${game.title}`, win);
win.webContents.openDevTools({mode: "detach"});
}
// hide menu, cf. https://github.com/itchio/itch/issues/232
win.setMenuBarVisibility(false);
win.setMenu(null);
// strip 'Electron' from user agent so some web games stop being confused
let userAgent = win.webContents.getUserAgent();
userAgent = userAgent.replace(/Electron\/[0-9.]+\s/, "");
win.webContents.setUserAgent(userAgent);
// requests to 'itch-internal' are used to communicate between web content & the app
let internalFilter = {
urls: ["https://itch-internal/*"],
};
win.webContents.session.webRequest.onBeforeRequest({urls: ["itch-cave://*"]}, (details, callback) => {
let parsed = url.parse(details.url);
// resources in `//` will be loaded using itch-cave, we need to
// redirect them to https for it to work - note this only happens with games
// that aren't fully offline-mode compliant
if (parsed.protocol === "itch-cave:" && parsed.host !== "game.itch") {
callback({
redirectURL: details.url.replace(/^itch-cave:/, "https:"),
});
} else {
callback({});
}
});
win.webContents.session.webRequest.onBeforeSendHeaders(
internalFilter, (details: IBeforeSendHeadersDetails, callback: IBeforeSendHeadersCallback) => {
callback({cancel: true});
let parsed = url.parse(details.url);
switch (parsed.pathname.replace(/^\//, "")) {
case "exit-fullscreen":
win.setFullScreen(false);
break;
case "toggle-fullscreen":
win.setFullScreen(!win.isFullScreen());
break;
case "open-devtools":
win.webContents.openDevTools({mode: "detach"});
break;
default:
break;
}
});
win.webContents.on("new-window", (ev: Event, url: string) => {
ev.preventDefault();
shell.openExternal(url);
});
// serve files
let fileRoot = ospath.dirname(entryPoint);
let indexName = ospath.basename(entryPoint);
await registerProtocol({partition, fileRoot});
// nasty hack to pass in the itchObject
const itchObjectBase64 = btoa(JSON.stringify(itchObject));
const query = querystring.stringify({itchObject: itchObjectBase64});
// don't use the HTTP cache, we already have everything on disk!
const options = {
extraHeaders: "pragma: no-cache\n",
};
win.loadURL(`itch-cave://game.itch/${indexName}?${query}`, options);
let capsulePromise: Promise<number>;
let connection: Connection;
const capsulerunPath = process.env.CAPSULERUN_PATH;
const capsuleEmitter = new EventEmitter();
if (capsulerunPath) {
log(opts, `Launching capsule...`);
const pipeName = "capsule_html5";
connection = new Connection(pipeName);
capsulePromise = spawn({
command: capsulerunPath,
args: [
"--pipe",
pipeName,
"--headless",
],
onToken: (tok) => {
log(opts, `[capsule out] ${tok}`);
},
onErrToken: async (tok) => {
log(opts, `[capsule err] ${tok}`);
},
emitter: capsuleEmitter,
logger: opts.logger,
});
capsulePromise.catch((reason) => {
// tslint:disable-next-line
console.log(`capsule threw an error: ${reason}`);
});
try {
await new Promise((resolve, reject) => {
setTimeout(resolve, 1000);
});
await connection.connect();
log(opts, `Should be connected now, will send videosetup soon`);
await new Promise((resolve, reject) => {
setTimeout(resolve, 1000);
});
const [contentWidth, contentHeight] = win.getContentSize();
log(opts, `framebuffer size: ${contentWidth}x${contentHeight}`);
const components = 4;
const pitch = contentWidth * components;
const shoom = require("shoom");
const shmPath = "capsule_html5.shm";
const shmSize = pitch * contentHeight;
const shm = new shoom.Shm({
path: shmPath,
size: shmSize,
});
shm.create();
connection.writePacket((builder) => {
const offset = messages.VideoSetup.createOffsetVector(builder, [builder.createLong(0)]);
const linesize = messages.VideoSetup.createLinesizeVector(builder, [builder.createLong(pitch)]);
const shmemPath = builder.createString(shmPath);
const shmemSize = builder.createLong(shmSize);
messages.Shmem.startShmem(builder);
messages.Shmem.addPath(builder, shmemPath);
messages.Shmem.addSize(builder, shmemSize);
const shmem = messages.Shmem.endShmem(builder);
messages.VideoSetup.startVideoSetup(builder);
messages.VideoSetup.addWidth(builder, contentWidth);
messages.VideoSetup.addHeight(builder, contentHeight);
messages.VideoSetup.addPixFmt(builder, messages.PixFmt.BGRA);
messages.VideoSetup.addVflip(builder, false);
messages.VideoSetup.addOffset(builder, offset);
messages.VideoSetup.addLinesize(builder, linesize);
messages.VideoSetup.addShmem(builder, shmem);
const vs = messages.VideoSetup.endVideoSetup(builder);
messages.Packet.startPacket(builder);
messages.Packet.addMessageType(builder, messages.Message.VideoSetup);
messages.Packet.addMessage(builder, vs);
const pkt = messages.Packet.endPacket(builder);
builder.finish(pkt);
});
const wc = win.webContents;
wc.beginFrameSubscription(function (frameBuffer) {
shm.write(0, frameBuffer);
const timestamp = Date.now() * 1000;
if (connection.closed) {
wc.endFrameSubscription();
}
connection.writePacket((builder) => {
const frameTimestamp = builder.createLong(timestamp);
messages.VideoFrameCommitted.startVideoFrameCommitted(builder);
messages.VideoFrameCommitted.addTimestamp(builder, frameTimestamp);
messages.VideoFrameCommitted.addIndex(builder, 0);
const vfc = messages.VideoFrameCommitted.endVideoFrameCommitted(builder);
messages.Packet.startPacket(builder);
messages.Packet.addMessageType(builder, messages.Message.VideoFrameCommitted);
messages.Packet.addMessage(builder, vfc);
const pkt = messages.Packet.endPacket(builder);
builder.finish(pkt);
});
});
} catch (e) {
log(opts, `While attempting to connect capsule: ${e.stack}`);
}
}
await new Promise((resolve, reject) => {
win.on("close", async () => {
if (connection) {
connection.close();
}
if (capsulePromise) {
await capsulePromise;
}
win.webContents.session.clearCache(resolve);
});
out.once("cancel", () => {
win.close();
});
});
}