Add parental controls

This commit is contained in:
Samuel Elliott 2022-03-16 20:58:15 +00:00
parent 1b71e8b8fe
commit 3919ceeb21
No known key found for this signature in database
GPG Key ID: 8420C7CDE43DC4D6
23 changed files with 1137 additions and 32 deletions

View File

@ -1,6 +1,6 @@
{
"files.associations": {
"**/data/*": "json"
"**/data/persist/*": "json"
},
"typescript.tsdk": "node_modules/typescript/lib"
}

36
package-lock.json generated
View File

@ -13,6 +13,7 @@
"discord-rpc": "^4.0.1",
"env-paths": "^3.0.0",
"express": "^4.17.3",
"mkdirp": "^1.0.4",
"node-fetch": "^3.2.2",
"node-notifier": "^10.0.1",
"node-persist": "^3.1.0",
@ -28,6 +29,7 @@
"@types/debug": "^4.1.7",
"@types/discord-rpc": "^4.0.0",
"@types/express": "^4.17.13",
"@types/mkdirp": "^1.0.2",
"@types/node": "^17.0.21",
"@types/node-notifier": "^8.0.2",
"@types/node-persist": "^3.1.2",
@ -142,6 +144,15 @@
"integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==",
"dev": true
},
"node_modules/@types/mkdirp": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@types/mkdirp/-/mkdirp-1.0.2.tgz",
"integrity": "sha512-o0K1tSO0Dx5X6xlU5F1D6625FawhC3dU3iqr25lluNv/+/QIVH8RLNEiVokgIZo+mz+87w/3Mkg/VvQS+J51fQ==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/ms": {
"version": "0.7.31",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz",
@ -1514,6 +1525,17 @@
"node": "*"
}
},
"node_modules/mkdirp": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
"integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
"bin": {
"mkdirp": "bin/cmd.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@ -2419,6 +2441,15 @@
"integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==",
"dev": true
},
"@types/mkdirp": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@types/mkdirp/-/mkdirp-1.0.2.tgz",
"integrity": "sha512-o0K1tSO0Dx5X6xlU5F1D6625FawhC3dU3iqr25lluNv/+/QIVH8RLNEiVokgIZo+mz+87w/3Mkg/VvQS+J51fQ==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/ms": {
"version": "0.7.31",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz",
@ -3485,6 +3516,11 @@
"brace-expansion": "^1.1.7"
}
},
"mkdirp": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
"integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="
},
"ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",

View File

@ -26,6 +26,7 @@
"discord-rpc": "^4.0.1",
"env-paths": "^3.0.0",
"express": "^4.17.3",
"mkdirp": "^1.0.4",
"node-fetch": "^3.2.2",
"node-notifier": "^10.0.1",
"node-persist": "^3.1.0",
@ -38,6 +39,7 @@
"@types/debug": "^4.1.7",
"@types/discord-rpc": "^4.0.0",
"@types/express": "^4.17.13",
"@types/mkdirp": "^1.0.2",
"@types/node": "^17.0.21",
"@types/node-notifier": "^8.0.2",
"@types/node-persist": "^3.1.2",

276
src/api/moon-types.ts Normal file
View File

@ -0,0 +1,276 @@
export interface MoonError {
type: string; // "https://moon.nintendo.com/errors/v1/401/invalid_token"
status: number; // 401
errorCode: string; // "invalid_token"
title: string; // "UnauthorizedException"
detail: string; // ""
instance: string;
}
/** GET /v1/users/{nintendoAccountId} */
export interface User {
nintendoAccountId: string;
nickname: string;
country: string; // "GB"
language: string; // "en-GB"
miiUri: {
extraLarge: string;
large: string;
medium: string;
small: string;
extraSmall: string;
};
analyticsOptedIn: boolean;
acceptedNotification: {
all: boolean;
};
notices: unknown[];
createdAt: number;
updatedAt: number;
}
/** GET /v1/users/{nintendoAccountId}/smart_devices */
export interface SmartDevices {
count: number;
items: SmartDevice[];
}
export interface SmartDevice {
/** UUID v4, for iOS devices this is uppercase hex, for Android devices this is lowercase hex?? */
id: string;
nintendoAccountId: string;
bundleId: string;
os: SmartDeviceOS;
osVersion: string;
modelName: string;
timeZone: string;
appVersion: {
displayedVersion: string; // "1.15.1", "1.16.0"
internalVersion: number; // 305, 247
};
osLanguage: string;
appLanguage: string;
notificationToken: string | null;
updateRequired: boolean;
createdAt: number;
updatedAt: number;
}
export enum SmartDeviceOS {
IOS = 'IOS',
ANDROID = 'ANDROID',
}
/** GET /v1/users/{nintendoAccountId}/devices */
export interface Devices {
count: number;
items: PairedDevice[];
}
export interface PairedDevice {
deviceId: string;
device: Device;
nintendoAccountId: string;
label: string;
parentalControlSettingState: {
deviceId: string;
targetEtag: string;
synchronizationStatus: 'SYNCHRONIZED';
createdAt: number;
updatedAt: number;
};
hasNewMonthlySummary: boolean;
hasFirstDailySummary: boolean;
createdAt: number;
updatedAt: number;
}
export interface Device {
id: string;
notificationToken: string;
timeZone: string;
language: string;
region: string; // "EUROPE"
serialNumber: string;
firmwareVersion: {
displayedVersion: string; // "13.2.1"
internalVersion: number; // 852481
};
links: {
pairingCode: {
code: string;
createdAt: number;
expiresAt: number;
};
};
activated: boolean;
synchronizedUnlockCode: string;
synchronizedParentalControlSetting: {
synchronizedEtag: string;
synchronizedAt: number;
};
lastOnlineCheckedAt: number;
alarmSetting: {
visibility: 'VISIBLE';
invisibleUntil: number;
};
createdAt: number;
updatedAt: number;
}
/** GET /v1/devices/{deviceId}/daily_summaries */
export interface DailySummaries {
count: number;
items: DailySummary[];
updatedRecently: boolean;
}
export interface DailySummary {
deviceId: string;
date: string; // "2022-03-14"
result: DailySummaryResult;
playingTime: number;
exceededTime: null;
disabledTime: number;
miscTime: number;
importantInfos: ImportantInfo[];
notices: unknown[];
observations: Observation[];
playedApps: PlayedTitle[];
anonymousPlayer: AnonymousPlayer | null;
devicePlayers: DevicePlayer[];
timeZoneUtcOffsetSeconds: number;
lastPlayedAt: number | null;
createdAt: number;
updatedAt: number;
}
export enum DailySummaryResult {
CALCULATING = 'CALCULATING',
ACHIEVED = 'ACHIEVED',
}
export enum ImportantInfo {
DID_WRONG_UNLOCK_CODE = 'DID_WRONG_UNLOCK_CODE',
}
export interface Title {
applicationId: string;
title: string;
imageUri: {
extraSmall: string;
small: string;
medium: string;
large: string;
extraLarge: string;
};
hasUgc: boolean;
shopUri: string;
firstPlayDate: string | null;
}
export interface ObservationTitleDownloaded {
type: 'DID_APP_DOWNLOAD_START';
applications: ObservationTitleDownloadedTitle[];
}
export interface ObservationTitleDownloadedTitle extends Title {
firstPlayDate: null;
}
export type Observation = ObservationTitleDownloaded;
export interface PlayedTitle extends Title {
firstPlayDate: string;
}
export interface AnonymousPlayer {
playingTime: number;
playedApps: DevicePlayerTitle[];
}
export interface DevicePlayer extends AnonymousPlayer {
playerId: string;
nickname: string;
imageUri: string;
}
export interface DevicePlayerTitle {
applicationId: string;
firstPlayDate: string;
playingTime: number;
}
/** GET /v1/devices/{deviceId}/monthly_summaries */
export interface MonthlySummaries {
count: number;
indexes: string[]; // ["2022-02", ...]
items: MonthlySummariesMonthlySummary[];
}
export interface MonthlySummariesMonthlySummary {
deviceId: string;
month: string; // "2022-02"
}
/** GET /v1/devices/{deviceId}/monthly_summaries/{month} */
export interface MonthlySummary {
deviceId: string;
month: string; // "2022-02"
dailySummaries: Record<string, MonthlySummaryDailySummary>;
playingDays: number;
playedApps: MonthlySummaryPlayedTitle[];
insights: MonthlySummaryInsights;
devicePlayers: MonthlySummaryDevicePlayer[];
includedMajorVersions: number[];
createdAt: number;
updatedAt: number;
}
export interface MonthlySummaryDevicePlayer {
playerId: string;
nickname: string;
imageUri: string;
dailySummaries: Record<string, MonthlySummaryDailySummary>;
insights: MonthlySummaryInsights;
}
export interface MonthlySummaryDailySummary {
date: string;
result: 'ACHIEVED';
playingTime: number;
}
export interface MonthlySummaryPlayedTitle extends PlayedTitle {
playingDays: number;
position: MonthlySummaryTitleRankingPosition;
}
export interface MonthlySummaryInsights {
thisMonth: {
averagePlayingTime: number;
playingDays: number;
playingTime: number;
};
previousMonth: {
averagePlayingTime: number;
playingDays: number;
playingTime: number;
} | null;
rankings: {
byDay: MonthlySummaryTitleRanking[];
byTime: MonthlySummaryTitleRanking[];
};
}
export interface MonthlySummaryTitleRanking {
applicationId: string;
units: number;
position: MonthlySummaryTitleRankingPosition;
ratio: number;
}
export enum MonthlySummaryTitleRankingPosition {
UP = 'UP',
STAY = 'STAY',
DOWN = 'DOWN',
NEW = 'NEW',
}
/** GET /v1/devices/{deviceId}/parental_control_setting_state */
export interface ParentalControlSettingState {
deviceId: string;
targetEtag: string;
synchronizationStatus: 'SYNCHRONIZED';
createdAt: number;
updatedAt: number;
}

108
src/api/moon.ts Normal file
View File

@ -0,0 +1,108 @@
import fetch from 'node-fetch';
import createDebug from 'debug';
import { getNintendoAccountToken, getNintendoAccountUser } from './na.js';
import { ErrorResponse } from './util.js';
import { DailySummaries, Devices, MonthlySummaries, MonthlySummary, MoonError, SmartDevices, User } from './moon-types.js';
const debug = createDebug('api:moon');
const MOON_URL = 'https://api-lp1.pctl.srv.nintendo.net/moon';
export const MOON_CLIENT_ID = '54789befb391a838';
export default class MoonApi {
constructor(
public token: string,
public naId: string
) {}
async fetch<T = unknown>(url: string, method = 'GET', body?: string, headers?: object) {
const response = await fetch(MOON_URL + url, {
method: method,
headers: Object.assign({
'Authorization': 'Bearer ' + this.token,
'Cache-Control': 'no-store',
'Content-Type': 'application/json; charset=utf-8',
'X-Moon-App-Id': 'com.nintendo.znma',
'X-Moon-Os': 'ANDROID',
'X-Moon-Os-Version': '26',
'X-Moon-Model': '',
'X-Moon-TimeZone': 'Europe/London',
'X-Moon-Os-Language': 'en-GB',
'X-Moon-App-Language': 'en-GB',
'X-Moon-App-Display-Version': '1.16.0',
'X-Moon-App-Internal-Version': '247',
'User-Agent': 'moon_ANDROID/1.16.0 (com.nintendo.znma; build:247; ANDROID 26)',
}, headers),
body: body,
});
debug('fetch %s %s, response %s', method, url, response.status);
const data = await response.json() as T | MoonError;
if ('errorCode' in data) {
throw new ErrorResponse('[moon] ' + data.title, response, data);
}
return data;
}
async getUser() {
return this.fetch<User>('/v1/users/' + this.naId);
}
async getSmartDevices() {
return this.fetch<SmartDevices>('/v1/users/' + this.naId + '/smart_devices');
}
async getDevices() {
return this.fetch<Devices>('/v1/users/' + this.naId + '/devices');
}
async getDailySummaries(id: string) {
return this.fetch<DailySummaries>('/v1/devices/' + id + '/daily_summaries');
}
async getMonthlySummaries(id: string) {
return this.fetch<MonthlySummaries>('/v1/devices/' + id + '/monthly_summaries');
}
async getMonthlySummary(id: string, month: string) {
return this.fetch<MonthlySummary>('/v1/devices/' + id + '/monthly_summaries/' + month);
}
async getParentalControlSettingState(id: string) {
return this.fetch<unknown>('/v1/devices/' + id + '/parental_control_setting_state');
}
static async createWithSessionToken(token: string) {
const data = await this.loginWithSessionToken(token);
return {
moon: new this(data.nintendoAccountToken.access_token!, data.user.id),
data,
};
}
async renewToken(token: string) {
const data = await MoonApi.loginWithSessionToken(token);
this.token = data.nintendoAccountToken.access_token!;
this.naId = data.user.id;
return data;
}
static async loginWithSessionToken(token: string) {
// Nintendo Account token
const nintendoAccountToken = await getNintendoAccountToken(token, MOON_CLIENT_ID);
// Nintendo Account user data
const user = await getNintendoAccountUser(nintendoAccountToken);
return {
nintendoAccountToken,
user,
};
}
}

View File

@ -33,7 +33,7 @@ export async function getNintendoAccountSessionToken(code: string, verifier: str
return token;
}
export async function getNintendoAccountToken(token: string) {
export async function getNintendoAccountToken(token: string, client_id: string) {
debug('Getting Nintendo Account token');
const response = await fetch('https://accounts.nintendo.com/connect/1.0.0/api/token', {
@ -44,7 +44,7 @@ export async function getNintendoAccountToken(token: string) {
'User-Agent': 'Dalvik/2.1.0 (Linux; U; Android 8.0.0)',
},
body: JSON.stringify({
client_id: '71b963c1b7b6d119',
client_id,
session_token: token,
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer-session-token',
}),
@ -91,7 +91,7 @@ export interface NintendoAccountSessionToken {
}
export interface NintendoAccountToken {
scope: ['openid', 'user', 'user.birthday', 'user.mii', 'user.screenName'];
scope: string[];
token_type: 'Bearer';
id_token: string;
access_token?: string;

View File

@ -14,6 +14,7 @@ const ZNCA_VERSION = '2.0.0';
const ZNCA_USER_AGENT = `com.nintendo.znca/${ZNCA_VERSION}(${ZNCA_PLATFORM}/${ZNCA_PLATFORM_VERSION})`;
const ZNC_URL = 'https://api-lp1.znc.srv.nintendo.net';
export const ZNCA_CLIENT_ID = '71b963c1b7b6d119';
export default class ZncApi {
constructor(
@ -112,7 +113,7 @@ export default class ZncApi {
const timestamp = '' + Math.floor(Date.now() / 1000);
// Nintendo Account token
const nintendoAccountToken = await getNintendoAccountToken(token);
const nintendoAccountToken = await getNintendoAccountToken(token, ZNCA_CLIENT_ID);
// Nintendo Account user data
const user = await getNintendoAccountUser(nintendoAccountToken);

View File

@ -4,6 +4,7 @@ import * as crypto from 'crypto';
import type { Arguments as ParentArguments } from '../cli.js';
import { ArgumentsCamelCase, Argv, getToken, initStorage, YargsArguments } from '../util.js';
import { getNintendoAccountSessionToken } from '../api/na.js';
import { ZNCA_CLIENT_ID } from '../api/znc.js';
const debug = createDebug('cli:auth');
@ -31,7 +32,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const params = {
state,
redirect_uri: 'npf71b963c1b7b6d119://auth',
client_id: '71b963c1b7b6d119',
client_id: ZNCA_CLIENT_ID,
scope: 'openid user user.birthday user.mii user.screenName',
response_type: 'session_token_code',
session_token_code_challenge: challenge,
@ -71,8 +72,8 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const authorisedparams = new URLSearchParams(authorisedurl.hash.substr(1));
debug('Redirect URL parameters', [...authorisedparams.entries()]);
const token = await getNintendoAccountSessionToken(
authorisedparams.get('session_token_code')!, verifier, '71b963c1b7b6d119');
const code = authorisedparams.get('session_token_code')!;
const token = await getNintendoAccountSessionToken(code, verifier, ZNCA_CLIENT_ID);
console.log('Session token', token);

View File

@ -3,7 +3,7 @@ import createDebug from 'debug';
import Table from 'cli-table/lib/index.js';
import { PresenceState } from '../api/znc-types.js';
import type { Arguments as ParentArguments } from '../cli.js';
import { ArgumentsCamelCase, Argv, getToken, initStorage, YargsArguments } from '../util.js';
import { ArgumentsCamelCase, Argv, getToken, hrduration, initStorage, YargsArguments } from '../util.js';
const debug = createDebug('cli:friends');
@ -67,8 +67,6 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
for (const friend of friends.result.friends) {
const online = friend.presence.state === PresenceState.ONLINE ||
friend.presence.state === PresenceState.PLAYING;
const hours = 'name' in friend.presence.game ? Math.floor(friend.presence.game.totalPlayTime / 60) : 0;
const minutes = 'name' in friend.presence.game ? friend.presence.game.totalPlayTime - (hours * 60) : 0;
table.push([
friend.id,
@ -76,10 +74,9 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
friend.name,
online ?
'name' in friend.presence.game ?
'Playing ' + friend.presence.game.name +
'; played for ' + (hours || !minutes ? hours + ' hour' + (hours === 1 ? '' : 's') : '') +
(minutes ? ', ' + minutes + ' minute' + (minutes === 1 ? '' : 's'): '') +
' since ' + new Date(friend.presence.game.firstPlayedAt * 1000).toLocaleDateString('en-GB') :
'Playing ' + friend.presence.game.name + '; played for ' +
hrduration(friend.presence.game.totalPlayTime) + ' since ' +
new Date(friend.presence.game.firstPlayedAt * 1000).toLocaleDateString('en-GB') :
'Online' :
friend.presence.logoutAt ?
'Last seen ' + new Date(friend.presence.logoutAt * 1000).toISOString() :

View File

@ -9,3 +9,4 @@ export * as friends from './friends.js';
export * as presence from './presence.js';
export * as notify from './notify.js';
export * as httpServer from './http-server.js';
export * as pctl from './pctl.js';

16
src/cli/pctl.ts Normal file
View File

@ -0,0 +1,16 @@
import createDebug from 'debug';
import type { Arguments as ParentArguments } from '../cli.js';
import { Argv } from '../util.js';
import * as commands from './pctl/index.js';
const debug = createDebug('cli:pctl');
export const command = 'pctl <command>';
export const desc = 'Nintendo Switch Parental Controls';
export function builder(yargs: Argv<ParentArguments>) {
for (const command of Object.values(commands)) {
// @ts-expect-error
yargs.command(command);
}
}

114
src/cli/pctl/auth.ts Normal file
View File

@ -0,0 +1,114 @@
import * as util from 'util';
import createDebug from 'debug';
import * as crypto from 'crypto';
import type { Arguments as ParentArguments } from '../../cli.js';
import { ArgumentsCamelCase, Argv, getPctlToken, initStorage, YargsArguments } from '../../util.js';
import { getNintendoAccountSessionToken } from '../../api/na.js';
import { MOON_CLIENT_ID } from '../../api/moon.js';
const debug = createDebug('cli:pctl:auth');
export const command = 'auth';
export const desc = 'Generate a link to login to a Nintendo Account';
export function builder(yargs: Argv<ParentArguments>) {
return yargs.option('auth', {
describe: 'Authenticate immediately',
type: 'boolean',
default: true,
}).option('select', {
describe: 'Set as default user (default: true if only user)',
type: 'boolean',
});
}
type Arguments = YargsArguments<ReturnType<typeof builder>>;
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const state = crypto.randomBytes(36).toString('base64url');
const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto.createHash('sha256').update(verifier).digest().toString('base64url');
const params = {
state,
redirect_uri: 'npf54789befb391a838://auth',
client_id: MOON_CLIENT_ID,
scope: [
'openid',
'user',
'user.mii',
'moonUser:administration',
'moonDevice:create',
'moonOwnedDevice:administration',
'moonParentalControlSetting',
'moonParentalControlSetting:update',
'moonParentalControlSettingState',
'moonPairingState',
'moonSmartDevice:administration',
'moonDailySummary',
'moonMonthlySummary',
].join(' '),
response_type: 'session_token_code',
session_token_code_challenge: challenge,
session_token_code_challenge_method: 'S256',
};
const authoriseurl = 'https://accounts.nintendo.com/connect/1.0.0/authorize?' +
new URLSearchParams(params).toString();
debug('Authentication parameters', {
state,
verifier,
challenge,
}, params);
console.log('1. Open this URL and login to your Nintendo Account:');
console.log('');
console.log(authoriseurl);
console.log('');
console.log('2. On the "Linking an External Account" page, right click "Select this person" and copy the link. It should start with "npf54789befb391a838://auth".');
console.log('');
const read = await import('read');
// @ts-expect-error
const prompt = util.promisify(read.default as typeof read);
const applink = await prompt({
prompt: `Paste the link: `,
// silent: true,
output: process.stderr,
});
console.log('');
const authorisedurl = new URL(applink);
const authorisedparams = new URLSearchParams(authorisedurl.hash.substr(1));
debug('Redirect URL parameters', [...authorisedparams.entries()]);
const code = authorisedparams.get('session_token_code')!;
const token = await getNintendoAccountSessionToken(code, verifier, MOON_CLIENT_ID);
console.log('Session token', token);
if (argv.auth) {
const storage = await initStorage(argv.dataPath);
const {moon, data} = await getPctlToken(storage, token.session_token);
console.log('Authenticated as Nintendo Account %s (%s)',
data.user.nickname, data.user.id);
await storage.setItem('NintendoAccountToken-pctl.' + data.user.id, token.session_token);
const users = new Set(await storage.getItem('NintendoAccountIds') ?? []);
users.add(data.user.id);
await storage.setItem('NintendoAccountIds', [...users]);
if ('select' in argv ? argv.select : users.size === 1) {
await storage.setItem('SelectedUser', data.user.id);
console.log('Set as default user');
}
}
}

View File

@ -0,0 +1,79 @@
import createDebug from 'debug';
// @ts-expect-error
import Table from 'cli-table/lib/index.js';
import type { Arguments as ParentArguments } from '../../cli.js';
import { ArgumentsCamelCase, Argv, getPctlToken, hrduration, initStorage, YargsArguments } from '../../util.js';
const debug = createDebug('cli:pctl:daily-summaries');
export const command = 'daily-summaries <device>';
export const desc = 'Show daily summaries';
export function builder(yargs: Argv<ParentArguments>) {
return yargs.positional('device', {
describe: 'Nintendo Switch device ID',
type: 'string',
demandOption: true,
}).option('user', {
describe: 'Nintendo Account ID',
type: 'string',
}).option('token', {
describe: 'Nintendo Account session token',
type: 'string',
}).option('json', {
describe: 'Output raw JSON',
type: 'boolean',
}).option('json-pretty-print', {
describe: 'Output pretty-printed JSON',
type: 'boolean',
});
}
type Arguments = YargsArguments<ReturnType<typeof builder>>;
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const storage = await initStorage(argv.dataPath);
const usernsid = argv.user ?? await storage.getItem('SelectedUser');
const token: string = argv.token ||
await storage.getItem('NintendoAccountToken-pctl.' + usernsid);
const {moon, data} = await getPctlToken(storage, token);
const summaries = await moon.getDailySummaries(argv.device);
if (argv.jsonPrettyPrint) {
console.log(JSON.stringify(summaries, null, 4));
return;
}
if (argv.json) {
console.log(JSON.stringify(summaries));
return;
}
const table = new Table({
head: [
'Date',
'Status',
'Play time',
'Misc. time',
'Titles played',
'Users played',
'Notices',
],
});
for (const summary of summaries.items) {
table.push([
summary.date,
summary.result,
hrduration(summary.playingTime / 60, true),
hrduration(summary.miscTime / 60, true),
summary.playedApps.map(t => t.title).join('\n'),
summary.devicePlayers.map(p => p.nickname)
.concat(summary.anonymousPlayer ? ['Unknown user'] : []).join('\n'),
[...summary.importantInfos, ...summary.observations.map(o => o.type)].join('\n'),
]);
}
console.log(table.toString());
}

59
src/cli/pctl/devices.ts Normal file
View File

@ -0,0 +1,59 @@
import createDebug from 'debug';
// @ts-expect-error
import Table from 'cli-table/lib/index.js';
import type { Arguments as ParentArguments } from '../../cli.js';
import { ArgumentsCamelCase, Argv, getPctlToken, initStorage, YargsArguments } from '../../util.js';
const debug = createDebug('cli:pctl:devices');
export const command = 'devices';
export const desc = 'List Nintendo Switch consoles';
export function builder(yargs: Argv<ParentArguments>) {
return yargs.option('user', {
describe: 'Nintendo Account ID',
type: 'string',
}).option('token', {
describe: 'Nintendo Account session token',
type: 'string',
});
}
type Arguments = YargsArguments<ReturnType<typeof builder>>;
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
console.warn('Listing devices');
const storage = await initStorage(argv.dataPath);
const usernsid = argv.user ?? await storage.getItem('SelectedUser');
const token: string = argv.token ||
await storage.getItem('NintendoAccountToken-pctl.' + usernsid);
const {moon, data} = await getPctlToken(storage, token);
const devices = await moon.getDevices();
const table = new Table({
head: [
'ID',
'Label',
'Serial number',
'Software version',
'PIN',
'Last synchronised',
],
});
for (const device of devices.items) {
table.push([
device.deviceId,
device.label,
device.device.serialNumber,
device.device.firmwareVersion.displayedVersion + ' (' + device.device.firmwareVersion.internalVersion + ')',
device.device.synchronizedUnlockCode,
new Date(device.device.synchronizedParentalControlSetting.synchronizedAt * 1000).toISOString(),
]);
}
console.log(table.toString());
}

View File

@ -0,0 +1,95 @@
import createDebug from 'debug';
import * as fs from 'fs/promises';
import mkdirp from 'mkdirp';
import type { Arguments as ParentArguments } from '../../cli.js';
import { ArgumentsCamelCase, Argv, getPctlToken, initStorage, YargsArguments } from '../../util.js';
import * as path from 'path';
import { DailySummaryResult } from '../../api/moon-types.js';
import MoonApi from '../../api/moon.js';
const debug = createDebug('cli:pctl:dump-summaries');
export const command = 'dump-summaries <directory>';
export const desc = 'Download all daily and monthly summaries';
export function builder(yargs: Argv<ParentArguments>) {
return yargs.positional('directory', {
describe: 'Directory to write summary data to',
type: 'string',
demandOption: true,
}).option('user', {
describe: 'Nintendo Account ID',
type: 'string',
}).option('token', {
describe: 'Nintendo Account session token',
type: 'string',
}).option('device', {
describe: 'Nintendo Switch device ID',
type: 'array',
});
}
type Arguments = YargsArguments<ReturnType<typeof builder>>;
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const storage = await initStorage(argv.dataPath);
const usernsid = argv.user ?? await storage.getItem('SelectedUser');
const token: string = argv.token ||
await storage.getItem('NintendoAccountToken-pctl.' + usernsid);
const {moon, data} = await getPctlToken(storage, token);
await mkdirp(argv.directory);
const devices = await moon.getDevices();
for (const device of argv.device ?? devices.items.map(d => d.deviceId)) {
console.warn('Downloading summaries for device %s', device);
await dumpMonthlySummariesForDevice(moon, argv.directory, '' + device);
await dumpDailySummariesForDevice(moon, argv.directory, '' + device);
}
}
async function dumpMonthlySummariesForDevice(moon: MoonApi, directory: string, device: string) {
debug('Fetching monthly summaries for device %s', device);
const monthlySummaries = await moon.getMonthlySummaries(device);
for (const item of monthlySummaries.items) {
const filename = 'pctl-monthly-' + item.deviceId + '-' + item.month + '.json';
const file = path.join(directory, filename);
try {
await fs.stat(file);
debug('Skipping monthly summary %s for device %s, file already exists', item.month, item.deviceId);
continue;
} catch (err) {}
debug('Fetching monthly summary %s for device %s', item.month, item.deviceId);
const summary = await moon.getMonthlySummary(item.deviceId, item.month);
debug('Writing %s', filename);
await fs.writeFile(file, JSON.stringify(summary, null, 4) + '\n', 'utf-8');
}
}
async function dumpDailySummariesForDevice(moon: MoonApi, directory: string, device: string) {
debug('Fetching daily summaries for device %s', device);
const summaries = await moon.getDailySummaries(device);
const timestamp = Date.now();
for (const summary of summaries.items) {
const filename = 'pctl-daily-' + summary.deviceId + '-' + summary.date +
(summary.result === DailySummaryResult.ACHIEVED ? '' : '-' + timestamp) + '.json';
const file = path.join(directory, filename);
try {
await fs.stat(file);
debug('Skipping daily summary %s for device %s, file already exists', summary.date, summary.deviceId);
continue;
} catch (err) {}
debug('Writing %s', filename);
await fs.writeFile(file, JSON.stringify(summary, null, 4) + '\n', 'utf-8');
}
}

8
src/cli/pctl/index.ts Normal file
View File

@ -0,0 +1,8 @@
export * as token from './token.js';
export * as auth from './auth.js';
export * as devices from './devices.js';
export * as dailySummaries from './daily-summaries.js';
export * as monthlySummaries from './monthly-summaries.js';
export * as monthlySummary from './monthly-summary.js';
export * as settings from './settings.js';
export * as dumpSummaries from './dump-summaries.js';

View File

@ -0,0 +1,51 @@
import createDebug from 'debug';
// @ts-expect-error
import Table from 'cli-table/lib/index.js';
import type { Arguments as ParentArguments } from '../../cli.js';
import { ArgumentsCamelCase, Argv, getPctlToken, initStorage, YargsArguments } from '../../util.js';
const debug = createDebug('cli:pctl:monthly-summaries');
export const command = 'monthly-summaries <device>';
export const desc = 'List monthly summaries';
export function builder(yargs: Argv<ParentArguments>) {
return yargs.positional('device', {
describe: 'Nintendo Switch device ID',
type: 'string',
demandOption: true,
}).option('user', {
describe: 'Nintendo Account ID',
type: 'string',
}).option('token', {
describe: 'Nintendo Account session token',
type: 'string',
});
}
type Arguments = YargsArguments<ReturnType<typeof builder>>;
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const storage = await initStorage(argv.dataPath);
const usernsid = argv.user ?? await storage.getItem('SelectedUser');
const token: string = argv.token ||
await storage.getItem('NintendoAccountToken-pctl.' + usernsid);
const {moon, data} = await getPctlToken(storage, token);
const summaries = await moon.getMonthlySummaries(argv.device);
const table = new Table({
head: [
'Month',
],
});
for (const summary of summaries.items) {
table.push([
summary.month,
]);
}
console.log(table.toString());
}

View File

@ -0,0 +1,76 @@
import createDebug from 'debug';
// @ts-expect-error
import Table from 'cli-table/lib/index.js';
import type { Arguments as ParentArguments } from '../../cli.js';
import { ArgumentsCamelCase, Argv, getPctlToken, initStorage, YargsArguments } from '../../util.js';
const debug = createDebug('cli:pctl:monthly-summary');
export const command = 'monthly-summary <device> <month>';
export const desc = 'Show monthly summary data';
export function builder(yargs: Argv<ParentArguments>) {
return yargs.positional('device', {
describe: 'Nintendo Switch device ID',
type: 'string',
demandOption: true,
}).positional('month', {
describe: 'Report month',
type: 'string',
demandOption: true,
}).option('user', {
describe: 'Nintendo Account ID',
type: 'string',
}).option('token', {
describe: 'Nintendo Account session token',
type: 'string',
}).option('json', {
describe: 'Output raw JSON',
type: 'boolean',
}).option('json-pretty-print', {
describe: 'Output pretty-printed JSON',
type: 'boolean',
});
}
type Arguments = YargsArguments<ReturnType<typeof builder>>;
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const storage = await initStorage(argv.dataPath);
const usernsid = argv.user ?? await storage.getItem('SelectedUser');
const token: string = argv.token ||
await storage.getItem('NintendoAccountToken-pctl.' + usernsid);
const {moon, data} = await getPctlToken(storage, token);
const summary = await moon.getMonthlySummary(argv.device, argv.month);
if (argv.jsonPrettyPrint) {
console.log(JSON.stringify(summary, null, 4));
return;
}
if (argv.json) {
console.log(JSON.stringify(summary));
return;
}
const titles = new Table({
head: [
'Title',
'First played',
'Days',
'Ranking',
],
});
for (const title of summary.playedApps) {
titles.push([
title.title,
title.firstPlayDate,
title.playingDays,
title.position,
]);
}
console.log(titles.toString());
}

37
src/cli/pctl/settings.ts Normal file
View File

@ -0,0 +1,37 @@
import createDebug from 'debug';
import type { Arguments as ParentArguments } from '../../cli.js';
import { ArgumentsCamelCase, Argv, getPctlToken, initStorage, YargsArguments } from '../../util.js';
const debug = createDebug('cli:pctl:settings');
export const command = 'settings <device>';
export const desc = 'Show parental control setting state';
export function builder(yargs: Argv<ParentArguments>) {
return yargs.positional('device', {
describe: 'Nintendo Switch device ID',
type: 'string',
demandOption: true,
}).option('user', {
describe: 'Nintendo Account ID',
type: 'string',
}).option('token', {
describe: 'Nintendo Account session token',
type: 'string',
});
}
type Arguments = YargsArguments<ReturnType<typeof builder>>;
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const storage = await initStorage(argv.dataPath);
const usernsid = argv.user ?? await storage.getItem('SelectedUser');
const token: string = argv.token ||
await storage.getItem('NintendoAccountToken-pctl.' + usernsid);
const {moon, data} = await getPctlToken(storage, token);
const d = await moon.getParentalControlSettingState(argv.device);
console.log(d);
}

56
src/cli/pctl/token.ts Normal file
View File

@ -0,0 +1,56 @@
import * as util from 'util';
import createDebug from 'debug';
import type { Arguments as ParentArguments } from '../../cli.js';
import { ArgumentsCamelCase, Argv, getPctlToken, initStorage, YargsArguments } from '../../util.js';
const debug = createDebug('cli:pctl:token');
export const command = 'token [token]';
export const desc = 'Authenticate with a Nintendo Account session token';
export function builder(yargs: Argv<ParentArguments>) {
return yargs.positional('token', {
describe: 'Nintendo Account session token (it is recommended this is not set and you enter it interactively)',
type: 'string',
}).option('select', {
describe: 'Set as default user (default: true if only user)',
type: 'boolean',
});
}
type Arguments = YargsArguments<ReturnType<typeof builder>>;
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const storage = await initStorage(argv.dataPath);
if (!argv.token) {
const read = await import('read');
// @ts-expect-error
const prompt = util.promisify(read.default as typeof read);
argv.token = await prompt({
prompt: `Token: `,
silent: true,
output: process.stderr,
});
}
const {moon, data} = await getPctlToken(storage, argv.token);
console.warn('Authenticated as Nintendo Account %s (%s)',
data.user.nickname, data.user.id);
await storage.setItem('NintendoAccountToken-pctl.' + data.user.id, argv.token);
const users = new Set(await storage.getItem('NintendoAccountIds') ?? []);
users.add(data.user.id);
await storage.setItem('NintendoAccountIds', [...users]);
console.log('Saved token');
if ('select' in argv ? argv.select : users.size === 1) {
await storage.setItem('SelectedUser', data.user.id);
console.log('Set as default user');
}
}

31
src/cli/pctl/user.ts Normal file
View File

@ -0,0 +1,31 @@
import createDebug from 'debug';
import type { Arguments as ParentArguments } from '../../cli.js';
import { ArgumentsCamelCase, Argv, getPctlToken, initStorage, YargsArguments } from '../../util.js';
const debug = createDebug('cli:pctl:user');
export const command = 'user';
export const desc = 'Get the authenticated Nintendo Account';
export function builder(yargs: Argv<ParentArguments>) {
return yargs.option('user', {
describe: 'Nintendo Account ID',
type: 'string',
}).option('token', {
describe: 'Nintendo Account session token',
type: 'string',
});
}
type Arguments = YargsArguments<ReturnType<typeof builder>>;
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const storage = await initStorage(argv.dataPath);
const usernsid = argv.user ?? await storage.getItem('SelectedUser');
const token: string = argv.token ||
await storage.getItem('NintendoAccountToken-pctl.' + usernsid);
const {moon, data} = await getPctlToken(storage, token);
console.log('Nintendo Account', data.user);
}

View File

@ -2,7 +2,7 @@ import createDebug from 'debug';
// @ts-expect-error
import Table from 'cli-table/lib/index.js';
import type { Arguments as ParentArguments } from '../cli.js';
import { Argv, initStorage, SavedToken } from '../util.js';
import { Argv, initStorage, SavedMoonToken, SavedToken } from '../util.js';
const debug = createDebug('cli:users');
@ -24,22 +24,27 @@ export function builder(yargs: Argv<ParentArguments>) {
'Country',
'Nintendo Switch ID',
'Nintendo Switch username',
'Parental Controls',
],
});
for (const userid of users ?? []) {
const token: string | undefined = await storage.getItem('NintendoAccountToken.' + userid);
if (!token) continue;
const cache: SavedToken | undefined = await storage.getItem('NsoToken.' + token);
if (!cache) continue;
const nsoCache: SavedToken | undefined = token ? await storage.getItem('NsoToken.' + token) : undefined;
const moonToken: string | undefined = await storage.getItem('NintendoAccountToken-pctl.' + userid);
const moonCache: SavedMoonToken | undefined = moonToken ? await storage.getItem('MoonToken.' + moonToken) : undefined;
const user = nsoCache?.user ?? moonCache?.user;
if (!user) continue;
table.push([
cache.user.id + (selected === cache.user.id ? ' *' : ''),
cache.user.screenName,
cache.user.nickname,
cache.user.country,
cache.nsoAccount.user.nsaId,
cache.nsoAccount.user.name,
user.id + (selected === user.id ? ' *' : ''),
user.screenName,
user.nickname,
user.country,
nsoCache?.nsoAccount.user.nsaId ?? 'Not signed in',
nsoCache?.nsoAccount.user.name ?? 'Not signed in',
moonCache ? 'Signed in' : 'Not signed in',
]);
}

View File

@ -1,3 +1,4 @@
import * as path from 'path';
import * as yargs from 'yargs';
import type * as yargstypes from '../node_modules/@types/yargs/index.js';
import createDebug from 'debug';
@ -10,6 +11,7 @@ import { AccountLogin, CurrentUser, Game } from './api/znc-types.js';
import ZncApi from './api/znc.js';
import titles, { defaultTitle } from './titles.js';
import ZncProxyApi from './api/znc-proxy.js';
import MoonApi from './api/moon.js';
const debug = createDebug('cli');
@ -32,9 +34,16 @@ export interface SavedToken {
proxy_url?: string;
}
export interface SavedMoonToken {
nintendoAccountToken: NintendoAccountToken;
user: NintendoAccountUser;
expires_at: number;
}
export async function initStorage(dir: string) {
const storage = persist.create({
dir,
dir: path.join(dir, 'persist'),
stringify: data => JSON.stringify(data, null, 4) + '\n',
});
await storage.init();
@ -79,6 +88,40 @@ export async function getToken(storage: persist.LocalStorage, token: string, pro
};
}
export async function getPctlToken(storage: persist.LocalStorage, token: string) {
if (!token) {
console.error('No token set. Set a Nintendo Account session token using the `--token` option or by running `nintendo-znc pctl users add`.');
throw new Error('Invalid token');
}
const existingToken: SavedMoonToken | undefined = await storage.getItem('MoonToken.' + token);
if (!existingToken || existingToken.expires_at <= Date.now()) {
console.warn('Authenticating to Nintendo Switch Parental Controls app');
debug('Authenticating to pctl with session token');
const {moon, data} = await MoonApi.createWithSessionToken(token);
const existingToken: SavedMoonToken = {
...data,
expires_at: Date.now() + (data.nintendoAccountToken.expires_in * 1000),
};
await storage.setItem('MoonToken.' + token, existingToken);
await storage.setItem('NintendoAccountToken-pctl.' + data.user.id, token);
return {moon, data: existingToken};
}
debug('Using existing token');
await storage.setItem('NintendoAccountToken-pctl.' + existingToken.user.id, token);
return {
moon: new MoonApi(existingToken.nintendoAccountToken.access_token!, existingToken.user.id),
data: existingToken,
};
}
export function getTitleIdFromEcUrl(url: string) {
const match = url.match(/^https:\/\/ec\.nintendo\.com\/apps\/([0-9a-f]{16})\//);
return match?.[1] ?? null;
@ -93,9 +136,6 @@ export function getDiscordPresence(game: Game, friendcode?: CurrentUser['links']
const titleid = getTitleIdFromEcUrl(game.shopUri);
const title = titles.find(t => t.id === titleid) || defaultTitle;
const hours = Math.floor(game.totalPlayTime / 60);
const minutes = game.totalPlayTime - (hours * 60);
const text = [];
if (title.titleName === true) text.push(game.name);
@ -103,9 +143,10 @@ export function getDiscordPresence(game: Game, friendcode?: CurrentUser['links']
if (game.sysDescription) text.push(game.sysDescription);
if (hours >= 1) text.push('Played for ' + hours + ' hour' + (hours === 1 ? '' : 's') +
(minutes ? ', ' + minutes + ' minute' + (minutes === 1 ? '' : 's'): '') +
' since ' + new Date(game.firstPlayedAt * 1000).toLocaleDateString('en-GB'));
if (game.totalPlayTime >= 60) {
text.push('Played for ' + hrduration(game.totalPlayTime) +
' since ' + new Date(game.firstPlayedAt * 1000).toLocaleDateString('en-GB'));
}
return {
id: title.client || defaultTitle.client,
@ -138,3 +179,18 @@ export interface Title {
smallImageKey?: string;
showTimestamp?: boolean;
}
export function hrduration(duration: number, short = false) {
const hours = Math.floor(duration / 60);
const minutes = duration - (hours * 60);
const hour_str = short ? 'hr' : 'hour';
const minute_str = short ? 'min' : 'minute';
if (hours >= 1) {
return hours + ' ' + hour_str + (hours === 1 ? '' : 's') +
(minutes ? ', ' + minutes + ' ' + minute_str + (minutes === 1 ? '' : 's') : '');
} else {
return minutes + ' ' + minute_str + (minutes === 1 ? '' : 's');
}
}