Set environment variables from a file and add Electron app base

This commit is contained in:
Samuel Elliott 2022-03-29 22:57:19 +01:00
parent d548a0e37e
commit 570e3d6b29
No known key found for this signature in database
GPG Key ID: 8420C7CDE43DC4D6
18 changed files with 2956 additions and 7 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
node_modules
dist
data
.vscode/schema

17
.vscode/generate-schemas.sh vendored Executable file
View File

@ -0,0 +1,17 @@
#!/bin/sh
mkdir -p .vscode/schema/{moon,splatnet2,nooklink}
npx ts-json-schema-generator --path src/api/moon-types.ts --type DailySummary > .vscode/schema/moon/dailysummary.schema.json
npx ts-json-schema-generator --path src/api/moon-types.ts --type MonthlySummary > .vscode/schema/moon/monthlysummary.schema.json
npx ts-json-schema-generator --path src/api/splatnet2-types.ts --type Records > .vscode/schema/splatnet2/records.schema.json
npx ts-json-schema-generator --path src/api/splatnet2-types.ts --type NicknameAndIcon > .vscode/schema/splatnet2/ni.schema.json
npx ts-json-schema-generator --path src/api/splatnet2-types.ts --type Timeline > .vscode/schema/splatnet2/timeline.schema.json
npx ts-json-schema-generator --path src/api/splatnet2-types.ts --type HeroRecords > .vscode/schema/splatnet2/hero.schema.json
npx ts-json-schema-generator --path src/api/splatnet2-types.ts --type Results > .vscode/schema/splatnet2/results-summary.schema.json
npx ts-json-schema-generator --path src/api/splatnet2-types.ts --type ResultWithPlayerNicknameAndIcons > .vscode/schema/splatnet2/result.schema.json
npx ts-json-schema-generator --path src/api/splatnet2-types.ts --type CoopResults > .vscode/schema/splatnet2/coop-summary.schema.json
npx ts-json-schema-generator --path src/api/splatnet2-types.ts --type CoopResultWithPlayerNicknameAndIcons > .vscode/schema/splatnet2/coop-result.schema.json
npx ts-json-schema-generator --path src/api/nooklink-types.ts --type Newspaper > .vscode/schema/nooklink/newspaper.schema.json

22
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,22 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Debug Electron app",
"type": "node",
"request": "launch",
"cwd": "${workspaceFolder}",
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron",
"windows": {
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron.cmd"
},
"args": [
"dist/app/main/app-entry.cjs"
],
"outputCapture": "std"
}
]
}

50
.vscode/settings.json vendored
View File

@ -2,5 +2,53 @@
"files.associations": {
"**/data/persist/*": "json"
},
"typescript.tsdk": "node_modules/typescript/lib"
"json.schemas": [
{
"fileMatch": ["**/pctl-daily-*.json"],
"url": "./.vscode/schema/moon/dailysummary.schema.json"
},
{
"fileMatch": ["**/pctl-monthly-*.json"],
"url": "./.vscode/schema/moon/monthlysummary.schema.json"
},
{
"fileMatch": ["**/splatnet2-records-*.json", "!**-latest.json"],
"url": "./.vscode/schema/splatnet2/records.schema.json"
},
{
"fileMatch": ["**/splatnet2-ni-*.json"],
"url": "./.vscode/schema/splatnet2/ni.schema.json"
},
{
"fileMatch": ["**/splatnet2-timeline-*.json"],
"url": "./.vscode/schema/splatnet2/timeline.schema.json"
},
{
"fileMatch": ["**/splatnet2-hero-*.json"],
"url": "./.vscode/schema/splatnet2/hero.schema.json"
},
{
"fileMatch": ["**/splatnet2-results-summary-*.json", "!**/splatnet2-results-summary-image-*", "!**-latest.json"],
"url": "./.vscode/schema/splatnet2/results-summary.schema.json"
},
{
"fileMatch": ["**/splatnet2-result-*.json", "!**/splatnet2-result-image-*.json"],
"url": "./.vscode/schema/splatnet2/result.schema.json"
},
{
"fileMatch": ["**/splatnet2-coop-summary-*.json", "!**-latest.json"],
"url": "./.vscode/schema/splatnet2/coop-summary.schema.json"
},
{
"fileMatch": ["**/splatnet2-coop-result-*.json"],
"url": "./.vscode/schema/splatnet2/coop-result.schema.json"
},
{
"fileMatch": ["**/nooklink-newspaper-*.json"],
"url": "./.vscode/schema/nooklink/newspaper.schema.json"
},
],
"typescript.tsdk": "node_modules/typescript/lib",
}

2535
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -30,6 +30,8 @@
"cli-table": "^0.3.11",
"debug": "^4.3.3",
"discord-rpc": "^4.0.1",
"dotenv": "^16.0.0",
"dotenv-expand": "^8.0.3",
"env-paths": "^3.0.0",
"express": "^4.17.3",
"frida": "^15.1.17",
@ -43,6 +45,12 @@
"yargs": "^17.3.1"
},
"devDependencies": {
"@rollup/plugin-alias": "^3.1.9",
"@rollup/plugin-commonjs": "^21.0.3",
"@rollup/plugin-html": "^0.2.4",
"@rollup/plugin-node-resolve": "^13.1.3",
"@rollup/plugin-replace": "^4.0.0",
"@rollup/plugin-typescript": "^8.3.1",
"@types/cli-table": "^0.3.0",
"@types/debug": "^4.1.7",
"@types/discord-rpc": "^4.0.0",
@ -51,10 +59,19 @@
"@types/node": "^17.0.21",
"@types/node-notifier": "^8.0.2",
"@types/node-persist": "^3.1.2",
"@types/react": "^17.0.43",
"@types/react-native": "^0.67.3",
"@types/read": "^0.0.29",
"@types/uuid": "^8.3.4",
"@types/yargs": "^17.0.9",
"caxa": "^2.1.0",
"electron": "^17.2.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-native-web": "^0.17.7",
"rollup": "^2.70.1",
"rollup-plugin-polyfill-node": "^0.8.0",
"ts-json-schema-generator": "^1.0.0",
"typescript": "^4.7.0-dev.20220308"
}
}

67
rollup.config.js Normal file
View File

@ -0,0 +1,67 @@
import path from 'path';
import typescript from '@rollup/plugin-typescript';
import commonjs from '@rollup/plugin-commonjs';
import alias from '@rollup/plugin-alias';
import nodeResolve from '@rollup/plugin-node-resolve';
import nodePolyfill from 'rollup-plugin-polyfill-node';
import html from '@rollup/plugin-html';
const preload = {
input: 'src/app/preload/index.ts',
output: {
file: 'dist/app/bundle/preload.cjs',
format: 'cjs',
},
plugins: [
typescript({
noEmit: true,
declaration: false,
}),
commonjs({
// the ".ts" extension is required
extensions: ['.js', '.jsx', '.ts', '.tsx'],
esmExternals: true,
}),
],
external: [
'electron',
],
};
const browser = {
input: 'src/app/browser/index.ts',
output: {
file: 'dist/app/bundle/browser.js',
format: 'es',
},
plugins: [
html({
title: 'nxapi',
}),
typescript({
noEmit: true,
declaration: false,
}),
commonjs({
// the ".ts" extension is required
extensions: ['.js', '.jsx', '.ts', '.tsx'],
esmExternals: true,
}),
nodePolyfill(),
alias({
entries: [
{find: 'react-native', replacement: path.resolve(__dirname, 'node_modules', 'react-native-web')},
],
}),
nodeResolve({
browser: true,
preferBuiltins: false,
}),
],
};
export default [
preload,
browser,
];

View File

@ -93,6 +93,7 @@ export interface NintendoAccountSessionTokenJwtPayload extends JwtPayload {
jti: string;
typ: 'session_token';
iss: 'https://accounts.nintendo.com';
/** Unknown - scopes the token is valid for? */
'st:scp': number[];
/** Subject (Nintendo Account ID) */
sub: string;

65
src/app/browser/app.tsx Normal file
View File

@ -0,0 +1,65 @@
import React, { useCallback, useState } from 'react';
import { Button, Image, StyleSheet, Text, TextProps, useColorScheme, View } from 'react-native';
import { NintendoAccountUser } from '../../api/na.js';
import { SavedMoonToken, SavedToken } from '../../util.js';
import ipc from './ipc.js';
import { useAsync } from './util.js';
async function getAccounts() {
const ids = await ipc.listNintendoAccounts();
const accounts: {
user: NintendoAccountUser;
nso: SavedToken | null;
moon: SavedMoonToken | null;
}[] = [];
for (const id of ids ?? []) {
const nsotoken = await ipc.getNintendoAccountNsoToken(id);
const moontoken = await ipc.getNintendoAccountMoonToken(id);
const nso = nsotoken ? await ipc.getSavedNsoToken(nsotoken) ?? null : null;
const moon = moontoken ? await ipc.getSavedMoonToken(moontoken) ?? null : null;
if (!nso && !moon) continue;
accounts.push({user: nso?.user ?? moon!.user, nso, moon});
}
return accounts;
}
function App() {
const theme = useColorScheme() === 'light' ? light : dark;
const [users] = useAsync(useCallback(() => getAccounts(), [ipc]));
console.log(users);
return <View style={styles.app}>
<Text>Hello from React!</Text>
{users?.map(u => <Text key={u.user.id} style={theme.text}>
{u.user.id} - {u.user.nickname}
</Text>)}
</View>;
}
const styles = StyleSheet.create({
app: {
},
});
const light = StyleSheet.create({
text: {
color: '#212121',
},
});
const dark = StyleSheet.create({
text: {
color: '#f5f5f5',
},
});
export default App;

13
src/app/browser/index.ts Normal file
View File

@ -0,0 +1,13 @@
import { AppRegistry } from 'react-native';
import App from './app.jsx';
AppRegistry.registerComponent('App', () => App);
const rootTag = window.document.createElement('div');
rootTag.style.minHeight = '100vh';
window.document.body.appendChild(rootTag);
AppRegistry.runApplication('App', {
rootTag,
});

11
src/app/browser/ipc.ts Normal file
View File

@ -0,0 +1,11 @@
import type { NxapiElectronIpc } from '../preload/index.js';
declare global {
interface Window {
nxapiElectronIpc: NxapiElectronIpc;
}
}
const ipc = window.nxapiElectronIpc;
export default ipc;

81
src/app/browser/util.ts Normal file
View File

@ -0,0 +1,81 @@
import * as React from 'react';
export enum RequestState {
NOT_LOADING,
LOADING,
LOADED,
}
export function useAsync<T>(fetch: (() => Promise<T>) | null) {
const [[data, requestState, error, i], setData] =
React.useState([null as T | null, RequestState.NOT_LOADING, null as Error | null, 0]);
const [f, forceUpdate] = React.useReducer(f => !f, false);
React.useEffect(() => {
if (!fetch) {
setData(p => p[1] === RequestState.NOT_LOADING ? p : [data, RequestState.NOT_LOADING, null, p[3] + 1]);
return;
}
setData(p => [p[0], RequestState.LOADING, p[2], i + 1]);
fetch.call(null).then(data => {
setData(p => p[3] === i + 1 ? [data, RequestState.LOADED, null, i + 1] : p);
}, err => {
setData(p => p[3] === i + 1 ? [data, RequestState.LOADED, err, i + 1] : p);
});
}, [fetch, f]);
return [data, error, requestState, forceUpdate] as const;
}
export function useFetch<T>(requestInfo: RequestInfo | null, init: RequestInit | undefined, then: (res: Response) => Promise<T>): [T | null, Error | null, RequestState, React.DispatchWithoutAction]
export function useFetch(requestInfo: RequestInfo | null, init?: RequestInit): [Response | null, Error | null, RequestState, React.DispatchWithoutAction]
export function useFetch<T>(requestInfo: RequestInfo | null, init?: RequestInit, then?: (res: Response) => Promise<T>) {
const f = React.useCallback(async () => {
const response = await fetch(requestInfo!, init);
return then?.call(null, response) ?? response;
}, [requestInfo]);
return useAsync<T | Response>(requestInfo ? f : null);
}
export class ErrorResponse extends Error {
readonly data: any | undefined = undefined;
constructor(message: string, readonly response: Response, readonly body?: string) {
super(message);
try {
this.data = body ? JSON.parse(body) : undefined;
} catch (err) {}
}
}
Object.defineProperty(ErrorResponse, Symbol.hasInstance, {
configurable: true,
value: (instance: ErrorResponse) => {
return instance instanceof Error &&
'response' in instance &&
'body' in instance &&
'data' in instance;
},
});
export function useFetchJson<T>(requestInfo: RequestInfo | null, init?: RequestInit) {
return useFetch(requestInfo, init, response => {
if (response.status !== 200) {
return response.text().then(body => {
throw new ErrorResponse(
'Server returned a non-200 status code: ' + response.status + ' ' + response.statusText,
response, body);
});
}
return response.json() as Promise<T>;
});
}
export function useFetchText(requestInfo: RequestInfo | null, init?: RequestInit) {
return useFetch(requestInfo, init, response => response.text());
}

6
src/app/electron.ts Normal file
View File

@ -0,0 +1,6 @@
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const electron = require('electron');
export default electron;

View File

@ -0,0 +1,5 @@
const electron = require('electron');
// Do anything that must be run before the app is ready...
electron.app.whenReady().then(() => import('./index.js'));

44
src/app/main/index.ts Normal file
View File

@ -0,0 +1,44 @@
import electron from '../electron.js';
import * as path from 'path';
import persist from 'node-persist';
import { initStorage, paths } from '../../util.js';
const __dirname = path.join(import.meta.url.substr(7), '..');
const bundlepath = path.join(import.meta.url.substr(7), '..', '..', 'bundle');
const { app, BrowserWindow, ipcMain } = electron;
function createWindow() {
// Create the browser window.
const mainWindow = new BrowserWindow({
width: 800,
height: 600,
vibrancy: 'content',
webPreferences: {
preload: path.join(bundlepath, 'preload.cjs'),
scrollBounce: true,
},
});
mainWindow.loadFile(path.join(bundlepath, 'index.html'));
}
app.whenReady().then(async () => {
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
const storage = await initStorage(paths.data);
ipcMain.handle('nxapi:accounts:list', () => storage.getItem('NintendoAccountIds'));
ipcMain.handle('nxapi:nso:gettoken', (e, id: string) => storage.getItem('NintendoAccountToken.' + id));
ipcMain.handle('nxapi:nso:getcachedtoken', (e, token: string) => storage.getItem('NsoToken.' + token));
ipcMain.handle('nxapi:moon:gettoken', (e, id: string) => storage.getItem('NintendoAccountToken-pctl.' + id));
ipcMain.handle('nxapi:moon:getcachedtoken', (e, token: string) => storage.getItem('MoonToken.' + token));
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});

15
src/app/preload/index.ts Normal file
View File

@ -0,0 +1,15 @@
import { contextBridge, ipcRenderer } from 'electron';
import * as path from 'path';
import { SavedMoonToken, SavedToken } from '../../util.js';
const ipc = {
listNintendoAccounts: () => ipcRenderer.invoke('nxapi:accounts:list') as Promise<string[] | undefined>,
getNintendoAccountNsoToken: (id: string) => ipcRenderer.invoke('nxapi:nso:gettoken', id) as Promise<string | undefined>,
getSavedNsoToken: (token: string) => ipcRenderer.invoke('nxapi:nso:getcachedtoken', token) as Promise<SavedToken | undefined>,
getNintendoAccountMoonToken: (id: string) => ipcRenderer.invoke('nxapi:moon:gettoken', id) as Promise<string | undefined>,
getSavedMoonToken: (token: string) => ipcRenderer.invoke('nxapi:moon:getcachedtoken', token) as Promise<SavedMoonToken | undefined>,
};
export type NxapiElectronIpc = typeof ipc;
contextBridge.exposeInMainWorld('nxapiElectronIpc', ipc);

View File

@ -1,10 +1,22 @@
import * as path from 'path';
import createDebug from 'debug';
import Yargs from 'yargs';
import dotenv from 'dotenv';
import dotenvExpand from 'dotenv-expand';
import { paths, YargsArguments } from './util.js';
import * as commands from './cli/index.js';
const debug = createDebug('cli');
dotenvExpand.expand(dotenv.config({
path: path.join(paths.data, '.env'),
}));
if (process.env.NXAPI_DATA_PATH) dotenvExpand.expand(dotenv.config({
path: path.join(process.env.NXAPI_DATA_PATH, '.env'),
}));
if (process.env.DEBUG) createDebug.enable(process.env.DEBUG);
const yargs = Yargs(process.argv.slice(2)).option('data-path', {
describe: 'Data storage path',
type: 'string',

View File

@ -3,6 +3,7 @@
"strict": true,
"target": "es2015",
"module": "es2022",
"jsx": "react",
"moduleResolution": "node12",
"declaration": true,
"rootDir": "src",