-
Notifications
You must be signed in to change notification settings - Fork 5
/
Copy pathapp-main.js
847 lines (783 loc) · 34 KB
/
app-main.js
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
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
/*
** Live Video Experience (LiVE)
** Copyright (c) 2020-2022 Dr. Ralf S. Engelschall <[email protected]>
** Licensed under GPL 3.0 <https://spdx.org/licenses/GPL-3.0-only>
*/
/* standard requirements */
const os = require("os")
const fs = require("fs")
const path = require("path")
/* external requirements */
const electron = require("electron")
const electronLog = require("electron-log")
const imageDataURI = require("image-data-uri")
const throttle = require("throttle-debounce").throttle
const dayjs = require("dayjs")
const syspath = require("syspath")
const UUID = require("pure-uuid")
const mkdirp = require("mkdirp")
const jsYAML = require("js-yaml")
const FFmpeg = require("@rse/ffmpeg")
/* internal requirements */
const Settings = require("./app-main-settings")
const VideoStream = require("./app-main-relay-videostream")
const EventStream = require("./app-main-relay-eventstream")
const Recording = require("./app-main-recording")
const Update = require("./app-main-update")
const pkg = require("./package.json")
/* control run-time debugging (increase tracing or even avoid warnings) */
if (typeof process.env.DEBUG !== "undefined")
process.traceProcessWarnings = true
else
process.noDeprecation = true
/* enter an asynchronous environment in main process */
const app = electron.app
;(async () => {
/* initialize global information */
app.win = null
app.connected = false
/* provide APIs for communication */
app.ipc = electron.ipcMain
/* provide logging facility */
app.log = electronLog
if (typeof process.env.DEBUG !== "undefined") {
app.log.transports.file.level = "debug"
app.log.transports.console.level = "debug"
}
else {
app.log.transports.file.level = "info"
app.log.transports.console.level = false
}
app.log.transports.remote.level = false
app.log.transports.ipc.level = false
app.log.transports.console.format = "{h}:{i}:{s}.{ms} › [{level}] {text}"
app.log.transports.file.format = "[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] {text}"
app.log.debug(`(persistent log under ${app.log.transports.file.getFile()})`)
app.log.info("main: starting up")
/* track the readyness of the UI */
app.uiReady = false
app.ipc.handle("ui-ready", (event) => {
app.log.info("main: UI ready")
app.uiReady = true
})
/* react on "live://<live-relay-server>/<live-access-token>" deep-links */
const deepLinkURL = (url) => {
/* parse deep-link URL */
const m = url.match(/^live:\/\/([^/]+)\/([^/]+)$/)
if (m === null)
return
const [ , liveRelayServer, liveAccessToken ] = m
/* send event to application window
(once the UI is really ready) */
const deliverEvent = (attempts) => {
if (app.win !== null && app.uiReady) {
app.log.info("main: send deep-link event to UI")
app.win.webContents.send("deep-link", { liveRelayServer, liveAccessToken })
}
else if (--attempts > 0)
setTimeout(() => deliverEvent(attempts), 250)
else
throw Error("failed to deliver deep-link event to application window")
}
deliverEvent(5 * 10)
}
/* hook into macOS-only protocol handling */
if (os.platform() === "darwin") {
app.setAsDefaultProtocolClient("live")
app.on("open-url", (event, data) => {
event.preventDefault()
/* notify about deep-linking */
deepLinkURL(data)
})
}
/* hook into macOS/Windows single-instance handling */
const isPrimary = app.requestSingleInstanceLock()
if (!isPrimary) {
/* stop secondary instances */
app.quit()
return
}
app.on("second-instance", (event, argv, cwd) => {
/* raise window on primary instance */
if (app.win !== null) {
if (app.win.isMinimized())
app.win.restore()
app.win.focus()
}
/* notify about deep-linking under Windows
(in case we are the primary instance and a second instance was executed) */
if (os.platform() === "win32" && argv.length > 0)
deepLinkURL(argv[argv.length - 1])
})
/* notify about deep-linking under Windows
(in case we are the primary instance and were just initially started) */
if (os.platform() === "win32" && process.argv.length > 0)
deepLinkURL(process.argv[process.argv.length - 1])
/* under Linux prevent trouble by disabling
the hardware acceleration through the GPU */
if (os.platform() === "linux")
app.disableHardwareAcceleration()
/* start startup procedure once Electron is ready */
app.on("ready", async () => {
/* establish update process */
app.update = new Update()
/* establish settings and their default values */
const clientId = (new UUID(1)).format("std")
const settings = new Settings({ appId: "LiVE-Receiver", flushAfter: 1 * 1000 })
settings.load()
app.clientId = settings.get("client-id", clientId)
app.x = settings.get("window-x", 100)
app.y = settings.get("window-y", 100)
app.w = settings.get("window-width", 975)
app.h = settings.get("window-height", 550 + 2 * 42)
app.personPortrait = settings.get("person-portrait", "")
app.personName = settings.get("person-name", "")
app.personPrivacy = settings.get("person-privacy", "private")
app.liveRelayServer = settings.get("live-relay-server", "")
app.liveAccessToken = settings.get("live-access-token", "")
app.liveStreamBuffering = settings.get("live-stream-buffering", 2000)
app.recordingHours = settings.get("recording-hours", 0)
app.audioInputDevice = settings.get("audio-input-device", "")
app.audioOutputDevice = settings.get("audio-output-device", "")
app.language = settings.get("language", "en")
/* ensure to-be-restored window position is still valid
(because if external dispays are used, they can be no longer connected) */
const visible = electron.screen.getAllDisplays().some((display) => {
return (
app.x >= display.bounds.x
&& app.y >= display.bounds.y
&& app.x + app.w <= display.bounds.x + display.bounds.width
&& app.y + app.h <= display.bounds.y + display.bounds.height
)
})
if (!visible) {
app.x = 100
app.y = 100
app.w = 975
app.h = 550 + 2 * 42
}
/* save back the settings once at startup */
settings.set("client-id", app.clientId)
settings.set("window-x", app.x)
settings.set("window-y", app.y)
settings.set("window-width", app.w)
settings.set("window-height", app.h)
settings.set("person-portrait", app.personPortrait)
settings.set("person-name", app.personName)
settings.set("person-privacy", app.personPrivacy)
settings.set("live-relay-server", app.liveRelayServer)
settings.set("live-access-token", app.liveAccessToken)
settings.set("live-stream-buffering", app.liveStreamBuffering)
settings.set("recording-hours", app.recordingHours)
settings.set("audio-input-device", app.audioInputDevice)
settings.set("audio-output-device", app.audioOutputDevice)
settings.set("language", app.language)
settings.save()
/* provide helper functions for renderer */
app.ipc.handle("settings", async (event, ...args) => {
const old = settings.get(args[0])
if (args.length === 2)
settings.set(args[0], args[1])
return old
})
app.ipc.handle("imageEncodeFromFile", async (event, filename) => {
const data = await imageDataURI.encodeFromFile(path.resolve(__dirname, filename))
return data
})
app.ipc.handle("screen-scale-factor", async (event) => {
const display = electron.screen.getPrimaryDisplay()
return display.scaleFactor
})
/* provide generic function bridge for renderer */
const fnb = { electron }
const fns = [ "electron.dialog.showOpenDialog" ]
for (const fn of fns) {
const p = fn.split(".")
let f = fnb[p[0]]
for (let i = 1; i < p.length; i++)
f = f[p[i]]
app.ipc.handle(fn, (...args) => {
return f(...args)
})
}
/* redirect exception error boxes to the console */
electron.dialog.showErrorBox = (title, content) => {
app.log.info(`main: UI: exception: ${title}: ${content}`)
}
/* create application window */
app.win = new electron.BrowserWindow({
icon: path.join(__dirname, "app-res-icon.png"),
backgroundColor: "#222222",
useContentSize: true,
frame: false,
transparent: false,
show: false,
x: app.x,
y: app.y,
width: app.w,
height: app.h,
minWidth: 975,
minHeight: 550 + 2 * 42,
resizable: true,
webPreferences: {
devTools: (typeof process.env.DEBUG !== "undefined"),
nodeIntegration: true,
nodeIntegrationInWorker: true,
contextIsolation: false,
worldSafeExecuteJavaScript: true,
disableDialogs: true,
enableRemoteModule: true,
autoplayPolicy: "no-user-gesture-required",
spellcheck: false
}
})
app.win.setHasShadow(true)
app.win.setContentProtection(!(typeof process.env.DEBUG !== "undefined"))
app.win.loadURL("file://" + path.join(__dirname, "app-ui.html"))
if (typeof process.env.DEBUG !== "undefined") {
setTimeout(() => {
app.win.webContents.openDevTools()
}, 1000)
}
app.win.on("ready-to-show", () => {
app.win.show()
app.win.focus()
})
app.win.webContents.on("did-finish-load", () => {
app.win.webContents.setZoomFactor(1.0)
app.win.webContents.setZoomLevel(0)
app.win.webContents.setVisualZoomLevelLimits(1, 1)
})
/* configure application menu
(actually only relevant under macOS where even frameless windows have a menu) */
const openURL = (url) =>
async () => { await electron.shell.openExternal(url) }
const menuTemplate = [
{
label: app.name,
submenu: [
{ role: "about" },
{ type: "separator" },
{ role: "hide" },
{ role: "hideothers" },
{ role: "unhide" },
{ type: "separator" },
{ role: "quit" }
]
}, {
label: "Edit",
submenu: [
{ role: "cut" },
{ role: "copy" },
{ role: "paste" }
]
}, {
role: "window",
submenu: [
{ role: "minimize" },
{ role: "zoom" },
{ role: "togglefullscreen" },
{ role: "front" }
]
}, {
role: "help",
submenu: [
{ label: "More about LiVE", click: openURL("https://video-experience.live") },
{ label: "More about LiVE Receiver", click: openURL("https://github.com/rse/live-receiver") }
]
}
]
const menu = electron.Menu.buildFromTemplate(menuTemplate)
electron.Menu.setApplicationMenu(menu)
/* react on explicit window close */
app.ipc.handle("quit", (event) => {
settings.save()
app.quit()
})
/* react on implicit window close */
app.win.on("closed", () => {
settings.save()
app.quit()
})
/* react on all windows closed */
app.on("window-all-closed", () => {
settings.save()
app.quit()
})
/* handle window minimize functionality */
let minimized = false
app.win.on("minimize", () => {
minimized = true
})
app.win.on("restore", () => {
minimized = false
})
app.ipc.handle("minimize", (event) => {
if (minimized) {
/* notice: there is no "unminimize()" method, so we have to restore
the previous state and give the window the focus again instead */
app.win.restore()
app.win.focus()
}
else
app.win.minimize()
})
/* handle window maximize functionality */
let maximized = false
const maximizeFixate = () => {
/* in Electron and our tracking got out of sync... */
if (!maximized && app.win.isMaximized()) {
maximized = true
app.win.webContents.send("maximized", true)
}
else if (maximized && !app.win.isMaximized()) {
maximized = false
app.win.webContents.send("maximized", false)
}
}
app.win.on("maximize", () => {
maximized = true
app.win.webContents.send("maximized", true)
})
app.win.on("unmaximize", () => {
maximized = false
app.win.webContents.send("maximized", false)
})
app.ipc.handle("maximize", (event) => {
maximizeFixate()
if (maximized)
app.win.unmaximize()
else {
const display = electron.screen.getDisplayNearestPoint({ x: app.x, y: app.y })
const dim = display.workAreaSize
if (app.w === dim.width && app.h === dim.height) {
/* Electron seems to have a bug: it does not correctly restore the size and position
on subsequent "unmaximize()" if the size (by accident) before "maximize()" is
already the maximum size (this can happen if one moves but not resizes the window
after a "maximize()" where Electron does not even raise an "unmaximize" event but
"isMaximized()" tell it implicitly unmaximized. Hence, we perform an implicit
resize before the "maximize()" call to workaroun the issue! */
app.w -= 50 /* NOTICE 1: 1-10 pixels is not enough */
app.h -= 50
app.win.setSize(app.w, app.h)
setTimeout(() => { app.win.maximize() }, 1500) /* NOTICE 2: resizing needs some time */
}
else
app.win.maximize()
}
})
/* handle window fullscreen functionality */
let fullscreened = false
app.win.on("enter-full-screen", () => {
fullscreened = true
app.win.webContents.send("fullscreened", true)
})
app.win.on("leave-full-screen", () => {
fullscreened = false
app.win.webContents.send("fullscreened", false)
})
app.ipc.handle("fullscreen", (event) => {
if (fullscreened)
app.win.setFullScreen(false)
else
app.win.setFullScreen(true)
})
/* track application window changes */
const updateBounds = () => {
const bounds = app.win.getBounds()
app.x = bounds.x
app.y = bounds.y
app.w = bounds.width
app.h = bounds.height
settings.set("window-x", app.x)
settings.set("window-y", app.y)
settings.set("window-width", app.w)
settings.set("window-height", app.h)
}
app.win.on("resize", throttle(1000, () => {
maximizeFixate()
updateBounds()
}))
app.win.on("move", throttle(1000, () => {
maximizeFixate()
updateBounds()
}))
/* handle window resizing functionality */
app.ipc.handle("set-resizable", (event, resizable) => {
app.win.setResizable(resizable)
})
app.ipc.handle("set-size", (event, size) => {
app.w = size.w
app.h = size.h
app.win.setSize(app.w, app.h)
})
/* handle screenshot creation */
app.ipc.handle("screenshot", async (event, rect) => {
const nativeImage = await app.win.capturePage(rect)
const buffer = nativeImage.toPNG()
const timestamp = dayjs().format("YYYY-MM-DD-HH-mm-ss")
const filename = path.join(app.getPath("pictures"),
`LiVE-Receiver-Screenshot-${timestamp}.png`)
app.log.info(`saving screenshot ${rect.width}x${rect.height}@${rect.x}+${rect.y} to "${filename}"`)
await fs.promises.writeFile(filename, buffer, { encoding: null })
})
/* handle recording creation */
app.ipc.handle("recording", async (event) => {
if (app.vs === null)
return
const timestamp = dayjs().subtract(20, "second").format("YYYY-MM-DD-HH-mm-ss")
const filename = path.join(app.getPath("videos"),
`LiVE-Receiver-Recording-${timestamp}.m4v`)
app.log.info(`saving last video recording to "${filename}"`)
await app.vs.record(filename)
})
/* provide FFmpeg version information */
app.ipc.handle("ffmpeg-version", (event, ...args) => {
return FFmpeg.info.version
})
/* establish recording mechanism */
const { dataDir } = syspath({ appName: "LiVE-Receiver" })
const basedir = path.join(dataDir, "recordings")
await mkdirp(basedir, { mode: 0o755 })
const recording = new Recording({
basedir: basedir,
log: (level, message) => { app.log[level](`recording: ${message}`) }
})
app.ipc.handle("recordings", async (event) => {
return recording.recordings()
})
app.ipc.handle("recording-play", async (event, id) => {
app.log.info(`begin playing recording "${id}"`)
const url = recording.url(id)
app.win.webContents.send("play-begin", { recording: id, url })
})
app.ipc.handle("recording-unplay", async (event) => {
app.log.info("stop playing recording")
app.win.webContents.send("play-end")
})
app.ipc.handle("recording-delete", async (event, id) => {
app.log.info(`delete recording "${id}"`)
await recording.delete(id)
app.win.webContents.send("recordings-renew")
})
app.ipc.handle("recording-artifact", async (event, id, file, type) => {
return recording.load(id, file, type)
})
app.ipc.handle("recording-info", async (event, id) => {
return recording.info(id)
})
setInterval(async () => {
await recording.prune(app.recordingHours)
app.win.webContents.send("recordings-update")
}, 1 * 60 * 60 * 1000)
await recording.prune(app.recordingHours)
app.win.webContents.send("recordings-renew")
/* the LiVE Relay VideoStream/EventStream communication establishment */
app.es = null
app.vs = null
const credentials = {
client: app.clientId,
agent: `${pkg.name}/${pkg.version}`
}
const liveReachability = async () => {
app.log.info("main: LiVE-Relay: checking reachability")
/* check reachability of LiVE Relay EventStream and VideoStream */
const es = new EventStream({ ...credentials })
const vs = new VideoStream({ ...credentials, ffmpeg: FFmpeg.binary })
const result = await Promise.all([ es.reachable(), vs.reachable() ])
if (result[0].error)
return result[0]
if (result[1].error)
return result[1]
return { success: true }
}
const liveAuth = async () => {
app.log.info("main: LiVE-Relay: authenticate")
/* connect to LiVE Relay EventStream */
const es = new EventStream({
...credentials,
log: (level, message) => { app.log[level](message) }
})
const result = await (es.preauth().then(() => ({ success: true })).catch((err) => {
return { error: `Failed to authenticate at LiVE Relay service: ${err.message}` }
}))
if (result.error)
return result
return { success: true }
}
const liveConnect = async () => {
if (app.connected) {
app.log.error("main: LiVE-Relay: connect (ALREADY CONNECTED)")
return { error: "invalid use -- already connected" }
}
app.log.info("main: LiVE-Relay: connect (begin)")
/* give UI some time to start stream processing */
app.win.webContents.send("stream-begin")
await new Promise((resolve) => setTimeout(resolve, 1 * 1000))
/* connect to LiVE Relay EventStream */
const es = new EventStream({
...credentials,
image: app.personPortrait,
name: app.personName,
privacy: app.personPrivacy,
log: (level, message) => { app.log[level](message) }
})
let result = await es.start().then(() => ({ success: true })).catch((err) => {
return { error: `EventStream: MQTTS: start: ${err}` }
})
if (result.error)
return result
app.es = es
/* receive receiver control messages via LiVE-Relay EventStream */
app.es.on("message", async (scope, message) => {
app.log.debug(`main: LiVE-Relay: message: scope=${scope} message=${JSON.stringify(message)}`)
if (!(typeof message === "object"
&& typeof message.id === "string" && message.id === "live-receiver"
&& typeof message.event === "string" && message.event !== ""
&& typeof message.data === "object")) {
app.log.error(`main: LiVE-Relay: message: invalid message: ${JSON.stringify(message)}`)
return
}
if (message.event === "reconnect") {
app.win.webContents.send("relogin", {
liveRelayServer: app.liveRelayServer,
liveAccessToken: app.liveAccessToken
})
}
else if (message.event === "disconnect") {
app.win.webContents.send("logout")
}
else if (message.event === "voting-begin")
app.win.webContents.send("voting-begin")
else if (message.event === "voting-type")
app.win.webContents.send("voting-type", message.data)
else if (message.event === "voting-end")
app.win.webContents.send("voting-end")
else
app.log.error(`main: LiVE-Relay: message: invalid event: "${message.event}`)
})
/* connect to LiVE Relay VideoStream */
const vs = new VideoStream({
...credentials,
ffmpeg: FFmpeg.binary,
log: (level, message) => { app.log[level](message) }
})
let numLast = -1
vs.on("segment", (num, id, user, buffer) => {
if (!app.connected)
return
app.log.debug(`main: LiVE-Relay: RTMPS segment #${num}: ${id} @ ${user.mimeCodec} ` +
`(${buffer.byteLength} bytes)`)
if (num <= numLast)
app.win.webContents.send("stream-reset")
numLast = num
app.win.webContents.send("stream-data", { num, id, user, buffer })
})
vs.on("fragment", (fragment) => {
if (app.recordingHours > 0)
recording.store(fragment)
})
vs.on("error", (err) => {
if (!app.connected)
return
app.log.error(`main: LiVE-Relay: RTMPS: ERROR: ${err}`)
app.win.webContents.send("stream-end")
})
vs.on("fatal", (err) => {
app.log.error(`main: LiVE-Relay: VideoStream: FATAL: ${err}`)
app.win.webContents.send("fatal-error", err)
})
result = await vs.start().then(() => ({ success: true })).catch((err) => {
return { error: `VideoStream: RTMPS: start: ${err}` }
})
if (result.error)
return result
app.vs = vs
/* indicate success */
app.connected = true
app.log.info("main: LiVE-Relay: connect (end)")
return { success: true }
}
const liveDisconnect = async () => {
if (!app.connected) {
app.log.error("main: LiVE-Relay: disconnect (STILL NOT CONNECTED)")
return { error: "invalid use -- still not connected" }
}
app.log.info("main: LiVE Relay: disconnect (begin)")
/* disconnect from LiVE Relay EventStream */
let result
if (app.es !== null) {
result = await app.es.stop().then(() => ({ success: true })).catch((err) => {
return { error: `EventStream: MQTTS: stop: ${err}` }
})
if (result.error)
return result
app.es = null
}
/* disconnect from LiVE Relay VideoStream */
if (app.vs !== null) {
result = await app.vs.stop().then(() => ({ success: true })).catch((err) => {
return { error: `VideoStream: RTMPS: stop: ${err}` }
})
if (result.error)
return result
app.vs = null
}
/* give UI some time to stop stream processing */
app.win.webContents.send("stream-end")
await new Promise((resolve) => setTimeout(resolve, 1 * 1000))
/* prune to free space (just in case) and especially update UI for new recording */
await recording.prune(app.recordingHours)
app.win.webContents.send("recordings-renew")
/* indicate success */
app.connected = false
app.log.info("main: LiVE Relay: disconnect (end)")
return { success: true }
}
app.ipc.handle("save-settings", async (event, {
personPortrait, personName, personPrivacy, liveStreamBuffering,
recordingHours, audioInputDevice, audioOutputDevice, language
}) => {
/* take parameters */
app.personPortrait = personPortrait
app.personName = personName
app.personPrivacy = personPrivacy
app.liveStreamBuffering = liveStreamBuffering
app.recordingHours = recordingHours
app.audioInputDevice = audioInputDevice
app.audioOutputDevice = audioOutputDevice
app.language = language
settings.set("person-portrait", app.personPortrait)
settings.set("person-name", app.personName)
settings.set("person-privacy", app.personPrivacy)
settings.set("live-stream-buffering", app.liveStreamBuffering)
settings.set("recording-hours", app.recordingHours)
settings.set("audio-input-device", app.audioInputDevice)
settings.set("audio-output-device", app.audioOutputDevice)
settings.set("language", app.language)
/* prune in case the recording hours were changed */
recording.prune(app.recordingHours)
app.win.webContents.send("recordings-renew")
})
app.ipc.handle("login", async (event, {
liveRelayServer, liveAccessToken
}) => {
/* take login parameters */
app.liveRelayServer = liveRelayServer
app.liveAccessToken = liveAccessToken
settings.set("live-relay-server", app.liveRelayServer)
settings.set("live-access-token", app.liveAccessToken)
/* parse access token */
const m = app.liveAccessToken.match(/^(.+?)-([^-]+)-([^-]+)$/)
if (m === null)
return { error: "invalid access token format" }
const [ , channel, token1, token2 ] = m
/* update LiVE Relay communication credentials */
credentials.server = app.liveRelayServer
credentials.channel = channel
credentials.token1 = token1
credentials.token2 = token2
credentials.buffering = app.liveStreamBuffering
/* establish communication */
let result = await liveReachability()
if (result.error)
return result
result = await liveAuth()
if (result.error)
return result
result = await liveConnect()
if (result.error)
return result
return { success: true }
})
app.ipc.handle("logout", async (event) => {
const result = await liveDisconnect()
if (result.error)
return result
return { success: true }
})
/* the LiVE Relay EventStream communication: messages */
app.ipc.handle("message", (event, message) => {
if (app.es === null)
return
app.es.send({
id: "live-sender",
event: "message",
data: {
client: app.clientId,
text: message.text,
...(message.audio ? { audio: message.audio } : {})
}
})
})
/* the LiVE Relay EventStream communication: feedback */
app.ipc.handle("feedback", (event, type) => {
if (app.es === null)
return
app.es.send({
id: "live-sender",
event: "feedback",
data: {
client: app.clientId,
type: type
}
})
})
/* the LiVE Relay EventStream communication: feeling */
app.ipc.handle("feeling", (event, feeling) => {
if (app.es === null)
return
app.es.send({
id: "live-sender",
event: "feeling",
data: {
client: app.clientId,
challenge: feeling.challenge,
mood: feeling.mood
}
})
})
/* handle update check request from UI */
app.ipc.handle("update-check", async () => {
/* check whether we are updateable at all */
const updateable = await app.update.updateable()
app.win.webContents.send("update-updateable", updateable)
/* check for update versions */
const versions = await app.update.check(throttle(1000 / 60, (task, completed) => {
app.win.webContents.send("update-progress", { task, completed })
}))
setTimeout(() => {
app.win.webContents.send("update-progress", null)
}, 2 * (1000 / 60))
app.win.webContents.send("update-versions", versions)
})
/* handle update request from UI */
app.ipc.handle("update-to-version", (event, version) => {
app.update.update(version, throttle(1000 / 60, (task, completed) => {
app.win.webContents.send("update-progress", { task, completed })
})).catch((err) => {
app.win.webContents.send("update-error", err)
app.log.error(`update: ERROR: ${err}`)
})
})
/* cleanup from old update */
await app.update.cleanup()
/* handle stealth-mode */
app.ipc.handle("stealth-mode", (event, enabled) => {
if (app.es !== null)
app.es.stealth(enabled)
})
/* provide helper functions for YAML loading */
app.ipc.handle("load-yaml", async (event, file) => {
const filename = path.resolve(path.join(app.getAppPath(), file))
const yaml = await fs.promises.readFile(filename, { encoding: "utf8" })
const obj = jsYAML.load(yaml)
return obj
})
})
})().catch((err) => {
if (app.log)
app.log.error(`main: ERROR: ${err}`)
else
console.log(`main: ERROR: ${err}`)
})