feat: working prototype
All checks were successful
retro / xml (push) Successful in 22s
retro / php (push) Successful in 57s
retro / nodejs (push) Successful in 55s
retro / release (push) Has been skipped

This commit is contained in:
Michel Roux 2025-01-04 22:12:47 +01:00
parent c8d5e899f7
commit 75f6656c38
9 changed files with 468 additions and 1177 deletions

View File

@ -41,7 +41,6 @@ jobs:
- run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}' - run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}'
- run: npm ci - run: npm ci
- run: npm run lint - run: npm run lint
- run: npm run stylelint
- run: npm run build - run: npm run build
release: release:

View File

@ -9,40 +9,35 @@ use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\Attribute\FrontpageRoute; use OCP\AppFramework\Http\Attribute\FrontpageRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\ContentSecurityPolicy;
use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Http\TemplateResponse;
use OCP\Files\IRootFolder;
use OCP\IRequest; use OCP\IRequest;
use OCP\IUserSession;
use OCP\Util; use OCP\Util;
class PageController extends Controller class PageController extends Controller
{ {
public function __construct( public function __construct(IRequest $request) {
IRequest $request,
private readonly IRootFolder $rootFolder,
private readonly IUserSession $userSession
) {
parent::__construct(Application::APP_ID, $request); parent::__construct(Application::APP_ID, $request);
} }
#[NoCSRFRequired] #[NoCSRFRequired]
#[NoAdminRequired] #[NoAdminRequired]
#[FrontpageRoute(verb: 'GET', url: '/{path}', requirements: ['path' => '.+'])] #[FrontpageRoute(verb: 'GET', url: '/')]
public function run(string $path): TemplateResponse { public function run(): TemplateResponse {
$user = $this->userSession->getUser();
if (!$user) {
throw new \Exception('User not logged in');
}
$folder = $this->rootFolder->getUserFolder($user->getUID());
$folder->get($path);
Util::addScript(Application::APP_ID, Application::APP_ID.'-main'); Util::addScript(Application::APP_ID, Application::APP_ID.'-main');
Util::addStyle(Application::APP_ID, Application::APP_ID.'-main');
return new TemplateResponse( $csp = new ContentSecurityPolicy();
Application::APP_ID, $csp->addAllowedConnectDomain('cdn.emulatorjs.org');
'index', $csp->addAllowedConnectDomain('blob:');
); $csp->addAllowedWorkerSrcDomain('blob:');
/** @psalm-suppress DeprecatedMethod */
$csp->allowEvalScript(true);
$response = new TemplateResponse(Application::APP_ID, 'index');
$response->setContentSecurityPolicy($csp);
return $response;
} }
} }

1232
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,9 +10,7 @@
"dev": "vite --mode development build", "dev": "vite --mode development build",
"watch": "vite --mode development build --watch", "watch": "vite --mode development build --watch",
"lint": "tsc && eslint src", "lint": "tsc && eslint src",
"lint:fix": "tsc && eslint src --fix", "lint:fix": "tsc && eslint src --fix"
"stylelint": "stylelint src/**/*.scss src/**/*.css",
"stylelint:fix": "stylelint src/**/*.scss src/**/*.css --fix"
}, },
"type": "module", "type": "module",
"browserslist": [ "browserslist": [
@ -20,6 +18,7 @@
], ],
"dependencies": { "dependencies": {
"@emulatorjs/emulatorjs": "^4.2.0", "@emulatorjs/emulatorjs": "^4.2.0",
"@nextcloud/auth": "^2.4.0",
"@nextcloud/files": "^3.10.1", "@nextcloud/files": "^3.10.1",
"@nextcloud/initial-state": "^2.2.0", "@nextcloud/initial-state": "^2.2.0",
"@nextcloud/l10n": "^3.1.0", "@nextcloud/l10n": "^3.1.0",
@ -31,7 +30,6 @@
"@nextcloud/browserslist-config": "^3.0.1", "@nextcloud/browserslist-config": "^3.0.1",
"@nextcloud/eslint-config": "^8.4.1", "@nextcloud/eslint-config": "^8.4.1",
"@nextcloud/prettier-config": "^1.1.0", "@nextcloud/prettier-config": "^1.1.0",
"@nextcloud/stylelint-config": "^3.0.1",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1", "eslint-plugin-prettier": "^5.2.1",
"typescript": "~5.5.4" "typescript": "~5.5.4"

View File

@ -1,25 +1,42 @@
import { FileAction, FileType, Node, registerFileAction } from '@nextcloud/files' import {
FileAction,
FileType,
Node,
Permission,
registerFileAction,
} from '@nextcloud/files'
import cores from './mime.json' import cores from './mime.json'
import { generateUrl } from '@nextcloud/router' import { generateUrl } from '@nextcloud/router'
import { t } from '@nextcloud/l10n' import { t } from '@nextcloud/l10n'
const getCore = (file: Node) => {
if (
!file.fileid ||
file.type !== FileType.File ||
!(file.permissions & Permission.READ)
)
return false
const extension = file.extension?.substring(1)
for (const core in cores) {
if (cores[core].types.includes(file.mime)) return core
if (cores[core].extensions.includes(extension)) return core
}
return false
}
const action = new FileAction({ const action = new FileAction({
id: 'retro', id: 'retro',
displayName: () => t('repod', 'Play'), displayName: () => t('repod', 'Play'),
iconSvgInline: () => '', iconSvgInline: () => '',
enabled: (files: Node[]) => { enabled: (files: Node[]) => {
if (files.length !== 1) return false if (files.length !== 1) return false
const file = files[0] return !!getCore(files[0])
if (!file.fileid || file.type !== FileType.File) return false
const extension = file.extension?.substring(1)
for (const core in cores) {
if (cores[core].types.includes(file.mime)) return true
if (cores[core].extensions.includes(extension)) return true
}
return false
}, },
exec: async (file: Node) => { exec: async (file: Node) => {
location.href = generateUrl(`/apps/retro/${file.path}`) location.href = generateUrl(`/apps/retro/?core={core}&file={file}`, {
core: getCore(file),
file: file.encodedSource,
})
return true return true
}, },
}) })

View File

@ -0,0 +1,323 @@
import '@emulatorjs/emulatorjs/data/src/emulator.js'
import '@emulatorjs/emulatorjs/data/src/nipplejs.js'
import '@emulatorjs/emulatorjs/data/src/shaders.js'
import '@emulatorjs/emulatorjs/data/src/storage.js'
import '@emulatorjs/emulatorjs/data/src/gamepad.js'
import '@emulatorjs/emulatorjs/data/src/GameManager.js'
import '@emulatorjs/emulatorjs/data/src/socket.io.min.js'
import '@emulatorjs/emulatorjs/data/src/compression.js'
import '@emulatorjs/emulatorjs/data/emulator.css'
import af from '@emulatorjs/emulatorjs/data/localization/af-FR.json'
import ar from '@emulatorjs/emulatorjs/data/localization/ar-AR.json'
import ben from '@emulatorjs/emulatorjs/data/localization/ben-BEN.json'
import de from '@emulatorjs/emulatorjs/data/localization/de-GER.json'
import el from '@emulatorjs/emulatorjs/data/localization/el-GR.json'
import en from '@emulatorjs/emulatorjs/data/localization/en.json'
import es from '@emulatorjs/emulatorjs/data/localization/es-ES.json'
import fa from '@emulatorjs/emulatorjs/data/localization/fa-AF.json'
import { getRequestToken } from '@nextcloud/auth'
import hi from '@emulatorjs/emulatorjs/data/localization/hi-HI.json'
import it from '@emulatorjs/emulatorjs/data/localization/it-IT.json'
import ja from '@emulatorjs/emulatorjs/data/localization/ja-JA.json'
import jv from '@emulatorjs/emulatorjs/data/localization/jv-JV.json'
import ko from '@emulatorjs/emulatorjs/data/localization/ko-KO.json'
import pt from '@emulatorjs/emulatorjs/data/localization/pt-BR.json'
import ru from '@emulatorjs/emulatorjs/data/localization/ru-RU.json'
import tr from '@emulatorjs/emulatorjs/data/localization/tr-TR.json'
import vi from '@emulatorjs/emulatorjs/data/localization/vi-VN.json'
import zh from '@emulatorjs/emulatorjs/data/localization/zh-CN.json'
type Config = {
gameUrl?: string
dataPath?: string
system?: string
biosUrl?: string
gameName?: string
color?: string
adUrl?: string
adMode?: 0 | 1 | 2
adTimer?: number
adSize?: string[]
alignStartButton?: 'top' | 'center' | 'bottom'
VirtualGamepadSettings?: object
buttonOpts?: { [key: string]: boolean }
volume?: number
defaultControllers?: { [key: number]: { [key: number]: string } }
startOnLoad?: boolean
fullscreenOnLoad?: boolean
filePaths?: { [key: string]: string }
loadState?: string
cacheLimit?: number
cheats?: { [key: string]: string }
defaultOptions?: { [key: string]: string }
gamePatchUrl?: string
gameParentUrl?: string
netplayUrl?: string
gameId?: number
backgroundImg?: string
backgroundBlur?: boolean
backgroundColor?: string
controlScheme?:
| 'nes'
| 'gb'
| 'gba'
| 'snes'
| 'n64'
| 'gba'
| 'nds'
| 'vb'
| 'segaMD'
| 'segaCD'
| 'sega32x'
| 'segaMS'
| 'segaGG'
| 'segaSaturn'
| '3do'
| 'atari2600'
| 'atari7800'
| 'lynx'
| 'jaguar'
| 'arcade'
| 'mame'
threads?: boolean
disableCue?: boolean
startBtnName?: string
softLoad?: number
screenRecording?: { [key: string]: number }
externalFiles?: { [key: string]: string }
disableDatabases?: boolean
disableLocalStorage?: boolean
forceLegacyCores?: boolean
noAutoFocus?: boolean
videoRotation?: 0 | 1 | 2 | 3
shaders?: { [key: string]: string }
language?:
| 'af'
| 'ar'
| 'ben'
| 'de'
| 'el'
| 'en'
| 'es'
| 'fa'
| 'hi'
| 'it'
| 'ja'
| 'jv'
| 'ko'
| 'pt'
| 'ru'
| 'tr'
| 'vi'
| 'zh'
langJson?: { [key: string]: string }
}
const config: Config = {}
switch (document.documentElement.lang) {
case 'af':
config.language = 'af'
config.langJson = af
break
case 'ar':
config.language = 'ar'
config.langJson = ar
break
case 'ben':
config.language = 'ben'
config.langJson = ben
break
case 'de':
config.language = 'de'
config.langJson = de
break
case 'el':
config.language = 'el'
config.langJson = el
break
case 'es':
config.language = 'es'
config.langJson = es
break
case 'fa':
config.language = 'fa'
config.langJson = fa
break
case 'hi':
config.language = 'hi'
config.langJson = hi
break
case 'it':
config.language = 'it'
config.langJson = it
break
case 'ja':
config.language = 'ja'
config.langJson = ja
break
case 'jv':
config.language = 'jv'
config.langJson = jv
break
case 'ko':
config.language = 'ko'
config.langJson = ko
break
case 'pt':
config.language = 'pt'
config.langJson = pt
break
case 'ru':
config.language = 'ru'
config.langJson = ru
break
case 'tr':
config.language = 'tr'
config.langJson = tr
break
case 'vi':
config.language = 'vi'
config.langJson = vi
break
case 'zh':
config.language = 'zh'
config.langJson = zh
break
default:
config.language = 'en'
config.langJson = en
}
interface EmulatorJS {
new (element: string, config: Config)
downloadFile(path, progressCB, notWithPath, opts)
on(event: string, func: () => void)
}
declare global {
interface Window {
EmulatorJS: EmulatorJS
}
}
class MyEmulatorJS extends window.EmulatorJS {
downloadFile(path, progressCB, notWithPath, opts) {
// eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve) => {
const data = this.toData(path) // check other data types
if (data) {
data.then((game) => {
if (opts.method === 'HEAD') {
resolve({ headers: {} })
} else {
resolve({ headers: {}, data: game })
}
})
return
}
const basePath = notWithPath ? '' : this.config.dataPath
path = basePath + path
if (
!notWithPath &&
this.config.filePaths &&
typeof this.config.filePaths[path.split('/').pop()] === 'string'
) {
path = this.config.filePaths[path.split('/').pop()]
}
let url
try {
url = new URL(path)
} catch (e) {}
if (url && !['http:', 'https:'].includes(url.protocol)) {
// Most commonly blob: urls. Not sure what else it could be
if (opts.method === 'HEAD') {
resolve({ headers: {} })
return
}
try {
const res = await fetch(path)
let ret
if (
(opts.type && opts.type.toLowerCase() === 'arraybuffer') ||
!opts.type
) {
ret = await res.arrayBuffer()
} else {
ret = await res.text()
try {
ret = JSON.parse(ret)
} catch (e) {}
}
if (path.startsWith('blob:')) URL.revokeObjectURL(path)
resolve({ data: ret, headers: {} })
} catch (e) {
resolve(-1)
}
return
}
const xhr = new XMLHttpRequest()
if (progressCB instanceof Function) {
xhr.addEventListener('progress', (e) => {
const progress = e.total
? ' ' +
Math.floor((e.loaded / e.total) * 100).toString() +
'%'
: ' ' + (e.loaded / 1048576).toFixed(2) + 'MB'
progressCB(progress)
})
}
xhr.onload = function () {
if (xhr.readyState === xhr.DONE) {
let data = xhr.response
if (
xhr.status.toString().startsWith('4') ||
xhr.status.toString().startsWith('5')
) {
resolve(-1)
return
}
try {
data = JSON.parse(data)
} catch (e) {}
resolve({
data,
headers: {
'content-length':
xhr.getResponseHeader('content-length'),
},
})
}
}
if (opts.responseType) xhr.responseType = opts.responseType
xhr.onerror = () => resolve(-1)
xhr.open(opts.method, path, true)
// => PATCH <=
if (path.includes(location.host)) {
xhr.setRequestHeader('requesttoken', getRequestToken() || '')
}
// => PATCH <=
xhr.send()
})
}
}
const gameUrl = new URL(location.href).searchParams.get('file')
if (gameUrl) {
config.gameUrl = gameUrl
}
const system = new URL(location.href).searchParams.get('core')
if (system) {
config.system = system
}
config.dataPath = 'https://cdn.emulatorjs.org/stable/data/'
config.startOnLoad = true
config.threads = !!window.SharedArrayBuffer
// eslint-disable-next-line no-new
new MyEmulatorJS('#game', config)

View File

@ -1,3 +0,0 @@
module.exports = {
extends: 'stylelint-config-recommended-vue',
}

View File

@ -1 +1 @@
<div id="retro"></div> <div id="game"></div>

View File

@ -10,8 +10,8 @@ const config = defineConfig({
export default createAppConfig( export default createAppConfig(
{ {
main: resolve(join('src', 'main.ts')),
init: resolve(join('src', 'init.ts')), init: resolve(join('src', 'init.ts')),
main: resolve(join('src', 'main.ts')),
}, },
{ {
config, config,