first commit

This commit is contained in:
René Preuß
2023-03-01 19:24:20 +01:00
commit 4ab5e76d85
45 changed files with 1963 additions and 0 deletions

11
electron/electron-env.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
/// <reference types="vite-plugin-electron/electron-env" />
declare namespace NodeJS {
interface ProcessEnv {
VSCODE_DEBUG?: 'true'
DIST_ELECTRON: string
DIST: string
/** /dist/ or /public/ */
PUBLIC: string
}
}

8
electron/main/helpers.ts Normal file
View File

@@ -0,0 +1,8 @@
import {BrowserWindow} from "electron";
export function emit(event: any, ...args: any) {
// Send a message to all windows
BrowserWindow.getAllWindows().forEach((win) => {
win.webContents.send(event, ...args)
});
}

171
electron/main/index.ts Normal file
View File

@@ -0,0 +1,171 @@
import {app, BrowserWindow, ipcMain, shell} from 'electron'
import {release} from 'node:os'
import {join} from 'node:path'
import {Settings} from "../../shared/schema";
import {SettingsRepository} from "../rerun-manager/settings-repository";
import {Encoder, EncoderOptions} from "../rerun-manager/encoder";
import IpcMainInvokeEvent = Electron.IpcMainInvokeEvent;
import {InternalServer} from "../rerun-manager/internal-server";
import {emit} from "./helpers";
import {platform} from "node:process";
// The built directory structure
//
// ├─┬ dist-electron
// │ ├─┬ main
// │ │ └── index.js > Electron-Main
// │ └─┬ preload
// │ └── index.js > Preload-Scripts
// ├─┬ dist
// │ └── index.html > Electron-Renderer
//
process.env.DIST_ELECTRON = join(__dirname, '..')
process.env.DIST = join(process.env.DIST_ELECTRON, '../dist')
process.env.PUBLIC = process.env.VITE_DEV_SERVER_URL
? join(process.env.DIST_ELECTRON, '../public')
: process.env.DIST
// Disable GPU Acceleration for Windows 7
if (release().startsWith('6.1')) app.disableHardwareAcceleration()
// Set application name for Windows 10+ notifications
if (process.platform === 'win32') app.setAppUserModelId(app.getName())
if (!app.requestSingleInstanceLock()) {
app.quit()
process.exit(0)
}
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
if (require('electron-squirrel-startup')) {
app.quit()
}
require('update-electron-app')()
// Remove electron security warnings
// This warning only shows in development mode
// Read more on https://www.electronjs.org/docs/latest/tutorial/security
// process.env['ELECTRON_DISABLE_SECURITY_WARNINGS'] = 'true'
let win: BrowserWindow | null = null
// Here, you can also use other preload
const preload = join(__dirname, '../preload/index.js')
const url = process.env.VITE_DEV_SERVER_URL
const indexHtml = join(process.env.DIST, 'index.html')
async function createWindow(settings: Settings) {
win = new BrowserWindow({
title: 'Rerun Manager - Encoder',
icon: join(process.env.PUBLIC, 'logo.ico'),
frame: false,
maximizable: false,
resizable: false,
width: 560,
height: 300,
backgroundColor: '#0c0c0e',
webPreferences: {
preload,
// Warning: Enable nodeIntegration and disable contextIsolation is not secure in production
// Consider using contextBridge.exposeInMainWorld
// Read more on https://www.electronjs.org/docs/latest/tutorial/context-isolation
nodeIntegration: true,
contextIsolation: false,
},
})
if (process.env.VITE_DEV_SERVER_URL) { // electron-vite-vue#298
win.loadURL(url)
// Open devTool if the app is not packaged
// win.webContents.openDevTools()
} else {
win.loadFile(indexHtml)
}
// Test actively push message to the Electron-Renderer
win.webContents.on('did-finish-load', () => {
win?.webContents.send('main-process-message', new Date().toLocaleString())
})
// Make all links open with the browser, not with the application
win.webContents.setWindowOpenHandler(({url}) => {
if (url.startsWith('https:')) shell.openExternal(url)
return {action: 'deny'}
})
// win.webContents.on('will-navigate', (event, url) => { }) #344
}
const settingsRepository = new SettingsRepository();
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.on('ready', () => {
settingsRepository.restore().then((settings: Settings) => {
createWindow(settings)
})
})
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on('window-all-closed', () => {
if (platform !== 'darwin') {
app.quit()
}
})
app.on('second-instance', () => {
if (win) {
// Focus on the main window if the user tried to open another
if (win.isMinimized()) win.restore()
win.focus()
}
})
app.on('activate', () => {
// On OS X it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) {
createWindow(settingsRepository.getSettings())
}
})
// New window example arg: new windows url
ipcMain.handle('open-win', (_, arg) => {
const childWindow = new BrowserWindow({
webPreferences: {
preload,
nodeIntegration: true,
contextIsolation: false,
},
})
if (process.env.VITE_DEV_SERVER_URL) {
childWindow.loadURL(`${url}#${arg}`)
} else {
childWindow.loadFile(indexHtml, {hash: arg})
}
})
ipcMain.handle('version', async () => app.getVersion())
ipcMain.handle('settings', async () => settingsRepository.getSettings())
ipcMain.handle('logout', async () => settingsRepository.logout())
ipcMain.handle('quit', async () => app.quit())
ipcMain.handle('minimize', async () => win.minimize())
ipcMain.handle('encode', async (event: IpcMainInvokeEvent, ...args: any[]) => {
const arg = args[0] as { id: string, input: string, options: EncoderOptions };
const encoder = new Encoder(arg.id, arg.input, arg.options, {
onStart: (id) => event.sender.send('encode-start', id),
onProgress: (id, progress) => event.sender.send('encode-progress', id, progress),
onUploadProgress: (id, progress) => event.sender.send('encode-upload-progress', id, progress),
onUploadComplete: (id, video) => event.sender.send('encode-upload-complete', id, video),
onError: (id, error) => event.sender.send('encode-error', id, error),
}, settingsRepository.getSettings());
return await encoder.encode()
})
ipcMain.handle('commitSettings', async (event: IpcMainInvokeEvent, ...args: any[]) => settingsRepository.commitSettings(args[0]))
settingsRepository.watch((settings: Settings) => emit('settings', settings));
const internalServer = new InternalServer();
internalServer.listen(settingsRepository);

176
electron/preload/index.ts Normal file
View File

@@ -0,0 +1,176 @@
import {EncoderOptions} from "../rerun-manager/encoder";
function domReady(condition: DocumentReadyState[] = ['complete', 'interactive']) {
return new Promise((resolve) => {
if (condition.includes(document.readyState)) {
resolve(true)
} else {
document.addEventListener('readystatechange', () => {
if (condition.includes(document.readyState)) {
resolve(true)
}
})
}
})
}
const safeDOM = {
append(parent: HTMLElement, child: HTMLElement) {
if (!Array.from(parent.children).find(e => e === child)) {
return parent.appendChild(child)
}
},
remove(parent: HTMLElement, child: HTMLElement) {
if (Array.from(parent.children).find(e => e === child)) {
return parent.removeChild(child)
}
},
}
/**
* https://tobiasahlin.com/spinkit
* https://connoratherton.com/loaders
* https://projects.lukehaas.me/css-loaders
* https://matejkustec.github.io/SpinThatShit
*/
function useLoading() {
const className = `loaders-css__square-spin`
const styleContent = `
@keyframes square-spin {
25% { transform: perspective(100px) rotateX(180deg) rotateY(0); }
50% { transform: perspective(100px) rotateX(180deg) rotateY(180deg); }
75% { transform: perspective(100px) rotateX(0) rotateY(180deg); }
100% { transform: perspective(100px) rotateX(0) rotateY(0); }
}
.${className} > div {
animation-fill-mode: both;
width: 50px;
height: 50px;
background: #fff;
animation: square-spin 3s 0s cubic-bezier(0.09, 0.57, 0.49, 0.9) infinite;
}
.app-loading-wrap {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #0c0c0e;
z-index: 9;
}
`
const oStyle = document.createElement('style')
const oDiv = document.createElement('div')
oStyle.id = 'app-loading-style'
oStyle.innerHTML = styleContent
oDiv.className = 'app-loading-wrap'
oDiv.innerHTML = `<div class="${className}"><div></div></div>`
return {
appendLoading() {
safeDOM.append(document.head, oStyle)
safeDOM.append(document.body, oDiv)
},
removeLoading() {
safeDOM.remove(document.head, oStyle)
safeDOM.remove(document.body, oDiv)
},
}
}
// ----------------------------------------------------------------------
const {appendLoading, removeLoading} = useLoading()
domReady().then(appendLoading)
window.onmessage = (ev) => {
ev.data.payload === 'removeLoading' && removeLoading()
}
setTimeout(removeLoading, 4999)
// ----------------------------------------------------------------------
// See the Electron documentation for details on how to use preload scripts:
// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts
// boilerplate code for electron...
import {Settings} from "../../shared/schema";
const {
contextBridge,
ipcRenderer
} = require('electron')
// All the Node.js APIs are available in the preload process.
// It has the same sandbox as a Chrome extension.
window.addEventListener('DOMContentLoaded', () => {
const replaceText = (selector: string, text: string) => {
const element = document.getElementById(selector)
if (element) element.innerText = text
}
for (const type of ['chrome', 'node', 'electron']) {
replaceText(`${type}-version`, process.versions[type])
}
})
// end boilerplate code, on to your stuff..
window.api = {
invoke: (channel: string, data: any) => {
let validChannels = [
'encode-start',
'encode-progress',
'encode-upload-progress',
'encode-upload-complete',
'encode-error',
] // list of ipcMain.handle channels you want access in frontend to
if (validChannels.includes(channel)) {
// ipcRenderer.invoke accesses ipcMain.handle channels like 'myfunc'
// make sure to include this return statement or you won't get your Promise back
return ipcRenderer.invoke(channel, data)
}
},
on(channel: string, func: any) {
let validChannels = [
'encode-start',
'encode-progress',
'encode-upload-progress',
'encode-upload-complete',
'encode-error',
] // list of ipcMain.on channels you want access in frontend to
if (validChannels.includes(channel)) {
// ipcRenderer.on accesses ipcMain.on channels like 'myevent'
// make sure to include this return statement or you won't get your Promise back
return ipcRenderer.on(channel, func)
}
},
minimize() {
return ipcRenderer.invoke('minimize')
},
quit() {
return ipcRenderer.invoke('quit')
},
getVersion() {
return ipcRenderer.invoke('version')
},
logout: () => {
return ipcRenderer.invoke('logout')
},
encode: (id: string, input: string, options: EncoderOptions) => {
return ipcRenderer.invoke('encode', {id, input, options})
},
commitSettings: (settings: Settings) => {
return ipcRenderer.invoke('commitSettings', settings)
},
getSettings: () => {
return ipcRenderer.invoke('settings')
},
onSettingsChanged: (callback: (settings: Settings) => void) => {
return ipcRenderer.on('settings', (event: any, ...args: any) => callback(args[0]))
},
}

View File

@@ -0,0 +1,147 @@
import {EncoderListeners, EncoderOptions, Settings, User, Video} from "../../shared/schema";
import * as fs from "fs";
import axios, {AxiosInstance} from "axios";
const ffmpeg = require('fluent-ffmpeg')
export class Encoder {
private readonly id: string;
private readonly input: string;
private readonly output: string;
private readonly options: EncoderOptions;
private readonly listeners: EncoderListeners;
private readonly settings: Settings;
private api: AxiosInstance;
private s3: AxiosInstance;
constructor(
id: string,
input: string,
options: EncoderOptions,
listeners: EncoderListeners,
settings: Settings
) {
this.id = id;
this.input = input;
this.output = this.input.replace(/\.mp4$/, '.flv')
this.options = options;
this.listeners = listeners;
this.settings = settings;
this.api = axios.create({
baseURL: settings.endpoint,
headers: {
Authorization: `${settings.credentials.token_type} ${settings.credentials.access_token}`
}
})
this.s3 = axios.create({});
}
async encode(): Promise<void> {
this.listeners.onStart(this.id)
ffmpeg(this.input)
.outputOptions(this.getOutputOptions())
.output(this.output)
.on('start', () => {
console.log('start')
})
.on('progress', (progress) => {
console.log('progress', progress)
this.listeners.onProgress(this.id, progress.percent)
})
.on('end', async () => {
console.log('end')
// @ts-ignore
try {
const video = await this.requestUploadUrl(this.settings.credentials.user)
await this.upload(video)
} catch (error) {
console.log('error', error)
this.listeners.onError(this.id, error.message)
}
})
.on('error', (error) => {
console.log('error', error)
this.listeners.onError(this.id, error.message)
})
.run()
}
private getOutputOptions() {
return [
'-c:v libx264',
'-preset veryfast',
'-crf 23',
'-maxrate 6000k',
'-bufsize 6000k',
'-c:a aac',
'-b:a 128k',
'-ac 2',
'-f flv',
'-movflags +faststart',
'-y'
]
}
private async requestUploadUrl(user: User): Promise<Video> {
const response = await this.api.post(`channels/${user.id}/videos`, {
title: this.options.title,
size: fs.statSync(this.output).size,
})
return response.data as Video
}
private async confirmUpload(video: Video): Promise<Video> {
const response = await this.api.post(`videos/${video.id}/confirm`, {
encoded: true,
})
console.log('confirm', response.data)
return response.data as Video;
}
private async cancelUpload(video: Video): Promise<void> {
await this.api.delete(`videos/${video.id}/cancel`)
}
private async upload(video: Video) {
if (!video.upload_url) {
return
}
const stream = fs.createReadStream(this.output)
stream.on('error', (error) => {
console.log('upload error', error)
this.listeners.onError(this.id, error.message)
})
const progress = (progress: any) => {
const progressCompleted = progress.loaded / fs.statSync(this.output).size * 100
this.listeners.onUploadProgress(this.id, progressCompleted)
}
try {
await this.s3.put(video.upload_url, stream, {
onUploadProgress: progress,
headers: {
'Content-Type': 'video/x-flv',
}
})
this.listeners.onUploadComplete(this.id, await this.confirmUpload(video))
} catch (error) {
console.log('upload error', error)
try {
await this.cancelUpload(video)
} catch (error) {
console.log('cancel error', error)
}
this.listeners.onError(this.id, `Upload Error: ${error.message}`)
}
}
}

View File

@@ -0,0 +1,101 @@
import Application from 'koa';
import Router from 'koa-router';
import KoaLogger from 'koa-logger';
import json from 'koa-json';
import koaCors from "koa-cors";
import bodyParser from "koa-bodyparser";
import axios, {AxiosInstance} from "axios";
import {app} from "electron"
import {Credentials, User} from "../../shared/schema";
import {SettingsRepository} from "./settings-repository";
import * as fs from "fs";
import {join} from "node:path";
export class InternalServer {
private readonly app: Application;
private readonly axios: AxiosInstance;
constructor() {
this.axios = axios.create();
this.app = new Application();
}
listen(settingsRepository: SettingsRepository) {
const router = new Router()
router.get("/health", (ctx) => {
const settings = settingsRepository.getSettings();
ctx.body = {
status: 'ok',
version: app.getVersion(),
has_credentials: settings.credentials !== null,
}
});
router.get('/oauth', (ctx) => {
if(process.env.VITE_DEV_SERVER_URL) {
ctx.body = fs.readFileSync(join(__dirname, '..', '..', 'public', 'callback.html'), {encoding: 'utf8'})
} else {
ctx.body = fs.readFileSync(join(process.env.DIST, 'callback.html'), {encoding: 'utf8'})
}
});
router.post('/callback', async (ctx) => {
// @ts-ignore
const params = new URLSearchParams(ctx.request.body.hash);
const credentials: Credentials = {
access_token: params.get('access_token'),
token_type: params.get('token_type'),
expires_in: params.get('expires_in'),
expires_at: this.calculateExpiresAt(params.get('expires_in')),
state: params.get('state'),
user: await this.resolveUser(params.get('access_token'), params.get('token_type')),
}
console.log('credentials', credentials);
settingsRepository.setCredentials(credentials);
await settingsRepository.save();
ctx.body = {
status: 'ok',
}
});
this.app.use(json());
this.app.use(KoaLogger());
this.app.use(koaCors());
this.app.use(bodyParser());
this.app.use(router.routes());
this.app.listen(8361, () => {
console.log(`Server listening http://127.0.0.1:8361/`);
});
}
private async resolveUser(accessToken: string, tokenType: string): Promise<User> {
const response = await this.axios.get('https://api.rerunmanager.com/v1/channels/me', {
headers: {
'Accept': 'application/json',
'Authorization': `${tokenType} ${accessToken}`,
}
});
return {
id: response.data.id,
name: response.data.name,
config: response.data.config,
avatar_url: response.data.avatar_url,
}
}
private calculateExpiresAt(expiresIn: string) {
const now = new Date();
const expiresAt = new Date(now.getTime() + parseInt(expiresIn) * 1000);
return expiresAt.toISOString();
}
}

View File

@@ -0,0 +1,124 @@
import defu from 'defu'
import {platform} from 'node:process'
import * as fs from 'fs'
import {Credentials, Settings} from "../../shared/schema";
const defaults: Settings = {
version: '1.0.0',
credentials: null,
endpoint: 'https://api.rerunmanager.com/v1/',
}
export class SettingsRepository {
private settings: Settings | null
private readonly path: string
private readonly listeners: Array<(settings: Settings) => void>;
private readonly directory: string;
constructor() {
this.listeners = []
this.settings = defaults
this.directory = this.appdata('/rerunmanager')
if (!fs.existsSync(this.directory)) {
console.log('Creating directory', this.directory)
fs.mkdirSync(this.directory)
}
this.path = `${this.directory}/encoder.json`
}
async restore() {
// load settings from path
// using node's fs module if file does not exist, create it with defaults
// if file exists, load it into this.settings
try {
const data = await fs.promises.readFile(this.path, {encoding: 'utf-8'})
if (data) {
const settings = JSON.parse(data) as Settings;
if (settings.version !== defaults.version) {
console.log('Settings version mismatch, resetting to defaults');
this.settings = defaults
} else {
console.log('Settings version match, merge with defaults');
this.settings = defu.defu(settings, defaults)
}
} else {
console.log('Settings file empty, resetting to defaults')
this.settings = defaults
}
} catch (e) {
console.log('Settings file not found, resetting to defaults', e)
this.settings = defaults
}
// check if settings.credentials.expires_at is in the past
// if so, set settings.credentials to null
if (this.isExpired()) {
console.log('Credentials expired!');
}
await this.save()
return this.settings
}
async save() {
// call all listeners with the current settings
this.listeners.forEach(
(listener: (settings: Settings) => void) => listener(this.settings)
)
const pretty = JSON.stringify(this.settings, null, 2)
await fs.promises.writeFile(this.path, pretty, {encoding: 'utf-8'})
}
appdata(path: string): string {
if (process.env.APPDATA) {
return process.env.APPDATA + path
}
if (platform === 'darwin') {
return process.env.HOME + '/Library/Preferences' + path
}
return process.env.HOME + "/.local/share" + path
}
getSettings() {
return this.settings
}
watch(callback: (settings: Settings) => void) {
// add callback to list of listeners
this.listeners.push(callback)
}
setCredentials(credentials: Credentials) {
this.settings.credentials = credentials
}
commitSettings(settings: Settings) {
this.settings = defu(settings, this.settings)
this.save()
}
async logout() {
this.settings.credentials = null
await this.save()
}
private isExpired() {
if (this.settings.credentials) {
const expiresAt = new Date(this.settings.credentials.expires_at)
const now = new Date()
return expiresAt < now
}
return true;
}
}