diff --git a/gui/src-tauri/tauri.conf.json b/gui/src-tauri/tauri.conf.json index 7261850..ac5139d 100644 --- a/gui/src-tauri/tauri.conf.json +++ b/gui/src-tauri/tauri.conf.json @@ -19,10 +19,7 @@ "scope": [ { "name": "../binaries/s3si", - "sidecar": true, - "args": [ - "--daemon" - ] + "sidecar": true }, { "name": "deno", @@ -30,8 +27,7 @@ "args": [ "run", "-A", - "../../s3si.ts", - "--daemon" + "../../src/daemon.ts" ] } ], diff --git a/gui/src/ipc/stdio.ts b/gui/src/ipc/stdio.ts index 84481c9..d7d8eda 100644 --- a/gui/src/ipc/stdio.ts +++ b/gui/src/ipc/stdio.ts @@ -15,7 +15,9 @@ export class IPC { child: Promise; constructor() { - const command = import.meta.env.DEV ? new Command("deno", ["run", "-A", "../../s3si.ts", "--daemon"]) : Command.sidecar('../binaries/s3si', ['--daemon']); + const command = import.meta.env.DEV + ? new Command("deno", ["run", "-A", "../../src/daemon.ts"]) + : Command.sidecar('../binaries/s3si'); command.stdout.on('data', line => { this.callback(JSON.parse(line)) }) diff --git a/s3si.ts b/s3si.ts index 5020fa3..f904804 100644 --- a/s3si.ts +++ b/s3si.ts @@ -1,12 +1,11 @@ import { App, DEFAULT_OPTS } from "./src/app.ts"; -import { runDaemon } from "./src/daemon.ts"; import { showError } from "./src/utils.ts"; import { flags } from "./deps.ts"; const parseArgs = (args: string[]) => { const parsed = flags.parse(args, { string: ["profilePath", "exporter", "skipMode"], - boolean: ["help", "noProgress", "monitor", "withSummary", "daemon"], + boolean: ["help", "noProgress", "monitor", "withSummary"], alias: { "help": "h", "profilePath": ["p", "profile-path"], @@ -39,11 +38,6 @@ Options: ); Deno.exit(0); } -if (opts.daemon) { - await runDaemon(); - - Deno.exit(0); -} const app = new App({ ...DEFAULT_OPTS, diff --git a/scripts/compile.ts b/scripts/compile.ts index cbbb346..3964a73 100644 --- a/scripts/compile.ts +++ b/scripts/compile.ts @@ -20,7 +20,7 @@ if (import.meta.main) { "-o", `../gui/binaries/s3si-${target}`, "-A", - "../s3si.ts", + "../src/daemon.ts", ], cwd: __dirname, }); diff --git a/src/daemon.ts b/src/daemon.ts index 62ffdfe..92fd8c7 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -1,20 +1,131 @@ -import { IPC } from "./ipc/mod.ts"; -import { Command } from "./ipc/types.ts"; +// deno-lint-ignore-file no-empty-interface -export async function runDaemon() { - const ipc = new IPC({ - reader: Deno.stdin, - writer: Deno.stdout, +import { + JSONRPCServer, + ResponseError, + RPCResult, + Service, +} from "./jsonrpc/mod.ts"; +import { DenoIO } from "./jsonrpc/deno.ts"; +import { loginSteps } from "./iksm.ts"; +import { DEFAULT_ENV, Env } from "./env.ts"; +import { Queue } from "./jsonrpc/channel.ts"; + +export interface S3SINetworkError extends ResponseError<100> { +} + +export interface S3SIService { + loginSteps(): Promise< + RPCResult< + { + authCodeVerifier: string; + url: string; + }, + S3SINetworkError + > + >; + loginSteps(step2: { + authCodeVerifier: string; + login: string; + }): Promise< + RPCResult< + { + sessionToken: string; + }, + S3SINetworkError + > + >; +} + +enum LoggerLevel { + Debug = "debug", + Log = "log", + Warn = "warn", + Error = "error", +} + +class S3SIServiceImplement implements S3SIService, Service { + loginMap: Map void; + promise: Promise; + }> = new Map(); + loggerQueue: Queue<{ level: LoggerLevel; msg: unknown[] }> = new Queue(); + env: Env = { + prompts: { + promptLogin: () => { + return Promise.reject("Not implemented"); + }, + prompt: () => { + return Promise.reject("Not implemented"); + }, + }, + logger: { + debug: (...msg) => + this.loggerQueue.push({ level: LoggerLevel.Debug, msg }), + log: (...msg) => this.loggerQueue.push({ level: LoggerLevel.Log, msg }), + warn: (...msg) => this.loggerQueue.push({ level: LoggerLevel.Warn, msg }), + error: (...msg) => + this.loggerQueue.push({ level: LoggerLevel.Error, msg }), + }, + newFetcher: DEFAULT_ENV.newFetcher, + }; + + loginSteps(): Promise< + RPCResult< + { + authCodeVerifier: string; + url: string; + }, + S3SINetworkError + > + >; + loginSteps(step2: { + authCodeVerifier: string; + login: string; + }): Promise< + RPCResult< + { + sessionToken: string; + }, + S3SINetworkError + > + >; + async loginSteps(step2?: { + authCodeVerifier: string; + login: string; + }): Promise< + RPCResult< + { + authCodeVerifier: string; + url: string; + } | { + sessionToken: string; + }, + S3SINetworkError + > + > { + if (!step2) { + return { + result: await loginSteps(this.env), + }; + } + return { + result: await loginSteps(this.env, step2), + }; + } + // deno-lint-ignore no-explicit-any + [key: string]: any; +} + +if (import.meta.main) { + const service = new S3SIServiceImplement(); + const server = new JSONRPCServer({ + transport: new DenoIO({ + reader: Deno.stdin, + writer: Deno.stdout, + }), + service, }); - while (true) { - const cmd = await ipc.recv(); - switch (cmd.type) { - case "hello": - await ipc.send(cmd); - break; - default: - continue; - } - } + await server.serve(); } diff --git a/src/iksm.ts b/src/iksm.ts index 0690ae4..05ef440 100644 --- a/src/iksm.ts +++ b/src/iksm.ts @@ -8,70 +8,123 @@ import { import { APIError } from "./APIError.ts"; import { Env, Fetcher } from "./env.ts"; -export async function loginManually( - { newFetcher, prompts: { promptLogin } }: Env, -): Promise { +export async function loginSteps( + env: Env, +): Promise< + { + authCodeVerifier: string; + url: string; + } +>; +export async function loginSteps( + env: Env, + step2: { + authCodeVerifier: string; + login: string; + }, +): Promise< + { + sessionToken: string; + } +>; +export async function loginSteps( + { newFetcher }: Env, + step2?: { + authCodeVerifier: string; + login: string; + }, +): Promise< + { + authCodeVerifier: string; + url: string; + } | { + sessionToken: string; + } +> { const fetch = newFetcher(); - const state = urlBase64Encode(random(36)); - const authCodeVerifier = urlBase64Encode(random(32)); - const authCvHash = await crypto.subtle.digest( - "SHA-256", - new TextEncoder().encode(authCodeVerifier), - ); - const authCodeChallenge = urlBase64Encode(authCvHash); + if (!step2) { + const state = urlBase64Encode(random(36)); + const authCodeVerifier = urlBase64Encode(random(32)); + const authCvHash = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(authCodeVerifier), + ); + const authCodeChallenge = urlBase64Encode(authCvHash); - const body = { - "state": state, - "redirect_uri": "npf71b963c1b7b6d119://auth", - "client_id": "71b963c1b7b6d119", - "scope": "openid user user.birthday user.mii user.screenName", - "response_type": "session_token_code", - "session_token_code_challenge": authCodeChallenge, - "session_token_code_challenge_method": "S256", - "theme": "login_form", - }; - const url = "https://accounts.nintendo.com/connect/1.0.0/authorize?" + - new URLSearchParams(body); + const body = { + "state": state, + "redirect_uri": "npf71b963c1b7b6d119://auth", + "client_id": "71b963c1b7b6d119", + "scope": "openid user user.birthday user.mii user.screenName", + "response_type": "session_token_code", + "session_token_code_challenge": authCodeChallenge, + "session_token_code_challenge_method": "S256", + "theme": "login_form", + }; + const url = "https://accounts.nintendo.com/connect/1.0.0/authorize?" + + new URLSearchParams(body); - const res = await fetch.get( - { - url, - headers: { - "Host": "accounts.nintendo.com", - "Connection": "keep-alive", - "Cache-Control": "max-age=0", - "Upgrade-Insecure-Requests": "1", - "User-Agent": DEFAULT_APP_USER_AGENT, - "Accept": - "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8n", - "DNT": "1", - "Accept-Encoding": "gzip,deflate,br", + const res = await fetch.get( + { + url, + headers: { + "Host": "accounts.nintendo.com", + "Connection": "keep-alive", + "Cache-Control": "max-age=0", + "Upgrade-Insecure-Requests": "1", + "User-Agent": DEFAULT_APP_USER_AGENT, + "Accept": + "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8n", + "DNT": "1", + "Accept-Encoding": "gzip,deflate,br", + }, }, - }, - ); + ); - const login = (await promptLogin(res.url)).trim(); + return { + authCodeVerifier, + url: res.url, + }; + } else { + const { login, authCodeVerifier } = step2; + const loginURL = new URL(login); + const params = new URLSearchParams(loginURL.hash.substring(1)); + const sessionTokenCode = params.get("session_token_code"); + if (!sessionTokenCode) { + throw new Error("No session token code provided"); + } + + const sessionToken = await getSessionToken({ + fetch, + sessionTokenCode, + authCodeVerifier, + }); + if (!sessionToken) { + throw new Error("No session token found"); + } + + return { sessionToken }; + } +} + +export async function loginManually( + env: Env, +): Promise { + const { prompts: { promptLogin } } = env; + + const step1 = await loginSteps(env); + + const { url, authCodeVerifier } = step1; + + const login = (await promptLogin(url)).trim(); if (!login) { throw new Error("No login URL provided"); } - const loginURL = new URL(login); - const params = new URLSearchParams(loginURL.hash.substring(1)); - const sessionTokenCode = params.get("session_token_code"); - if (!sessionTokenCode) { - throw new Error("No session token code provided"); - } - const sessionToken = await getSessionToken({ - fetch, - sessionTokenCode, - authCodeVerifier, - }); - if (!sessionToken) { - throw new Error("No session token found"); - } + const step2 = await loginSteps(env, { authCodeVerifier, login }); - return sessionToken; + return step2.sessionToken; } export async function getGToken( diff --git a/src/ipc/channel.ts b/src/ipc/channel.ts deleted file mode 100644 index a1eece1..0000000 --- a/src/ipc/channel.ts +++ /dev/null @@ -1,54 +0,0 @@ -/// -/// -/// -/// -/// - -import type { ExtractType } from "./types.ts"; - -export class WorkerChannel { - queue: T[] = []; - waiting: ((value: T) => void)[] = []; - - constructor(private worker?: Worker) { - const callback = ({ data }: { data: unknown }) => { - const waiting = this.waiting.shift(); - if (waiting) { - waiting(data as T); - } else { - this.queue.push(data as T); - } - }; - if (worker) { - worker.addEventListener("message", callback); - } else { - self.addEventListener("message", callback); - } - } - async recvType( - type: K, - ): Promise> { - const data = await this.recv(); - if (data.type !== type) { - throw new Error(`Unexpected type: ${data.type}`); - } - return data as ExtractType; - } - recv(): Promise { - return new Promise((resolve) => { - const data = this.queue.shift(); - if (data) { - resolve(data); - } else { - this.waiting.push(resolve); - } - }); - } - send(data: T) { - if (this.worker) { - this.worker.postMessage(data); - } else { - self.postMessage(data); - } - } -} diff --git a/src/ipc/mod.ts b/src/ipc/mod.ts index 39d68ba..f0cfe10 100644 --- a/src/ipc/mod.ts +++ b/src/ipc/mod.ts @@ -1,2 +1 @@ export { IPC } from "./stdio.ts"; -export { WorkerChannel } from "./channel.ts"; diff --git a/src/jsonrpc/channel.ts b/src/jsonrpc/channel.ts new file mode 100644 index 0000000..d1ef783 --- /dev/null +++ b/src/jsonrpc/channel.ts @@ -0,0 +1,50 @@ +export class Queue { + queue: T[] = []; + waiting: ((value: T | undefined) => void)[] = []; + + pop = (): Promise => { + return new Promise((resolve) => { + const data = this.queue.shift(); + if (data) { + resolve(data); + } else { + this.waiting.push(resolve); + } + }); + }; + // TODO: wait until the data is queued if queue has limit + push = (data: T): Promise => { + const waiting = this.waiting.shift(); + if (waiting) { + waiting(data); + } else { + this.queue.push(data); + } + return Promise.resolve(); + }; + close = (): Promise => { + for (const resolve of this.waiting) { + resolve(undefined); + } + return Promise.resolve(); + }; +} + +export function channel() { + const q1 = new Queue(); + const q2 = new Queue(); + const close = async () => { + await q1.close(); + await q2.close(); + }; + + return [{ + send: q1.push, + recv: q2.pop, + close, + }, { + send: q2.push, + recv: q1.pop, + close, + }] as const; +} diff --git a/src/jsonrpc/client.ts b/src/jsonrpc/client.ts new file mode 100644 index 0000000..ccfe2ea --- /dev/null +++ b/src/jsonrpc/client.ts @@ -0,0 +1,128 @@ +// deno-lint-ignore-file no-explicit-any +import { + ID, + Request, + Response, + ResponseError, + RPCResult, + Service, + Transport, +} from "./types.ts"; + +export class JSONRPCClient { + protected nextId = 1; + protected transport: Transport; + protected requestMap: Map< + ID, + (result: RPCResult) => void + > = new Map(); + protected fatal: unknown = undefined; + protected task: Promise; + + constructor( + { transport }: { transport: Transport }, + ) { + this.transport = transport; + this.task = this.run(); + } + + protected setFatal(e: unknown) { + if (!this.fatal) { + this.fatal = e; + } + } + + protected handleResponse( + resp: Response, + ) { + const { id } = resp; + const callback = this.requestMap.get(id); + if (callback) { + this.requestMap.delete(id); + callback(resp); + } else { + this.setFatal(new Error("invalid response id: " + String(id))); + } + } + + // receive response from server + protected async run() { + try { + while (true) { + const data = await this.transport.recv(); + if (data === undefined) { + this.setFatal(new Error("transport closed")); + break; + } + const result = JSON.parse(data); + if (Array.isArray(result)) { + for (const resp of result) { + this.handleResponse(resp); + } + } else { + this.handleResponse(result); + } + } + } catch (e) { + this.setFatal(e); + } + } + + makeRequest< + K extends keyof S & string, + P extends Parameters, + >( + method: K, + params: P, + ): Request { + const req = { + jsonrpc: "2.0", + id: this.nextId, + method, + params, + } as const; + this.nextId += 1; + return req; + } + + async call< + K extends keyof S & string, + P extends Parameters, + R extends ReturnType, + >( + method: K, + ...params: P + ): Promise { + if (this.fatal) { + throw this.fatal; + } + const req = this.makeRequest(method, params); + await this.transport.send(JSON.stringify(req)); + + return new Promise((res, rej) => { + this.requestMap.set(req.id, (result) => { + if (result.error) { + rej(result.error); + } else { + res(result.result); + } + }); + }); + } + + getProxy(): S { + const proxy = new Proxy({}, { + get: (_, method: string) => { + return (...params: unknown[]) => { + return this.call(method, ...params as any); + }; + }, + }); + return proxy as S; + } + + async close() { + await this.transport.close(); + await this.task; + } +} diff --git a/src/jsonrpc/deno.ts b/src/jsonrpc/deno.ts new file mode 100644 index 0000000..d984990 --- /dev/null +++ b/src/jsonrpc/deno.ts @@ -0,0 +1,32 @@ +import { io, writeAll } from "../../deps.ts"; +import { Transport } from "./types.ts"; + +export class DenoIO implements Transport { + lines: AsyncIterableIterator; + writer: Deno.Writer & Deno.Closer; + constructor({ reader, writer }: { + reader: Deno.Reader; + writer: Deno.Writer & Deno.Closer; + }) { + this.lines = io.readLines(reader); + this.writer = writer; + } + async recv(): Promise { + const result = await this.lines.next(); + + if (!result.done) { + return JSON.parse(result.value); + } + + return undefined; + } + async send(data: string) { + await writeAll( + this.writer, + new TextEncoder().encode(data + "\n"), + ); + } + async close() { + await this.writer.close(); + } +} diff --git a/src/jsonrpc/jsonrpc.test.ts b/src/jsonrpc/jsonrpc.test.ts new file mode 100644 index 0000000..b150b03 --- /dev/null +++ b/src/jsonrpc/jsonrpc.test.ts @@ -0,0 +1,43 @@ +import { channel } from "./channel.ts"; +import { JSONRPCClient } from "./client.ts"; +import { JSONRPCServer } from "./server.ts"; +import { RPCResult, Service } from "./types.ts"; +import { assertEquals } from "../../dev_deps.ts"; + +export interface SimpleService { + add(a: number, b: number): Promise< + RPCResult + >; + // deno-lint-ignore no-explicit-any + [key: string]: any; +} + +class SimpleServiceImplement implements SimpleService, Service { + async add(a: number, b: number): Promise> { + return { + result: a + b, + }; + } + // deno-lint-ignore no-explicit-any + [key: string]: any; +} + +Deno.test("jsonrpc", async () => { + const [c1, c2] = channel(); + + const service = new SimpleServiceImplement(); + const server = new JSONRPCServer({ + transport: c1, + service, + }); + const serverTask = server.serve().catch((e) => console.error(e)); + const client = new JSONRPCClient({ + transport: c2, + }); + const p = client.getProxy(); + assertEquals((await p.add(1, 2)).result, 3); + + await client.close(); + await server.close(); + await serverTask; +}); diff --git a/src/jsonrpc/mod.ts b/src/jsonrpc/mod.ts new file mode 100644 index 0000000..ccdf324 --- /dev/null +++ b/src/jsonrpc/mod.ts @@ -0,0 +1,2 @@ +export * from "./types.ts"; +export * from "./server.ts"; diff --git a/src/jsonrpc/server.ts b/src/jsonrpc/server.ts new file mode 100644 index 0000000..efdca86 --- /dev/null +++ b/src/jsonrpc/server.ts @@ -0,0 +1,113 @@ +// deno-lint-ignore-file no-explicit-any +import { + ERROR_INVALID_REQUEST, + ERROR_METHOD_NOT_FOUND, + ERROR_PARSEE_ERROR, + ID, + Request, + Response, + ResponseError, + Service, + Transport, +} from "./types.ts"; + +export class JSONRPCServer { + protected transport: Transport; + protected service: Service; + protected fatal = false; + protected task: Promise = Promise.resolve(); + + constructor( + { transport, service }: { transport: Transport; service: Service }, + ) { + this.transport = transport; + this.service = service; + } + async handleRequest( + req: Request, + ): Promise> { + const { jsonrpc, id, method, params } = req; + const res = { + jsonrpc: "2.0", + id, + } as const; + if (jsonrpc !== "2.0") { + this.fatal = true; + return { + ...res, + error: ERROR_INVALID_REQUEST, + }; + } + + const func = this.service[method]; + if (!func) { + return { + ...res, + error: ERROR_METHOD_NOT_FOUND, + }; + } + + const result = await func(...params); + + return { + ...res, + result, + }; + } + // `handle` will never throw error + async handle( + data: string, + ): Promise | Response[]> { + let req: Request; + try { + req = JSON.parse(data); + } catch (_) { + this.fatal = true; + return { + jsonrpc: "2.0", + id: null, + error: ERROR_PARSEE_ERROR, + }; + } + + const internalError: (id: ID) => ( + e: unknown, + ) => Response> = (id) => + ( + e, + ) => ({ + jsonrpc: "2.0", + id: id, + error: { + code: 32000, + message: "Internal error", + data: e, + }, + }); + + // batch request + if (Array.isArray(req)) { + return await Promise.all( + req.map((req) => this.handleRequest(req).catch(internalError(req.id))), + ); + } else { + return await this.handleRequest(req).catch(internalError(req.id)); + } + } + async serve() { + while (!this.fatal) { + const data = await this.transport.recv(); + if (data === undefined) { + break; + } + this.handle(data).then((result) => + this.transport.send(JSON.stringify(result)) + ).catch((e) => { + console.error("Failed to handle request", e); + }); + } + } + async close() { + await this.transport.close(); + } +} diff --git a/src/jsonrpc/types.ts b/src/jsonrpc/types.ts new file mode 100644 index 0000000..b8b58c9 --- /dev/null +++ b/src/jsonrpc/types.ts @@ -0,0 +1,66 @@ +export type ID = string | number | null; + +// deno-lint-ignore no-explicit-any +export type ResponseError = { + code: Code; + message: string; + data?: Data; +}; + +export type Request = { + jsonrpc: "2.0"; + method: Method; + params: Params; + id: ID; +}; + +export type Notification = { + jsonrpc: "2.0"; + method: Method; + params: Params; +}; + +// deno-lint-ignore no-explicit-any +export type Response> = { + jsonrpc: "2.0"; + id: ID; +} & RPCResult; + +export type Transport = { + send: (data: string) => Promise; + recv: () => Promise; + close: () => Promise; +}; + +export type RPCResult = { + result?: Result; + error?: Error; +}; + +export type Service = { + [P in string]: ( + // deno-lint-ignore no-explicit-any + ...args: any[] + ) => Promise>; +}; + +export const ERROR_PARSEE_ERROR: ResponseError<-32700> = { + code: -32700, + message: "Parse error", +}; +export const ERROR_INVALID_REQUEST: ResponseError<-32600> = { + code: -32600, + message: "Invalid Request", +}; +export const ERROR_METHOD_NOT_FOUND: ResponseError<-32601> = { + code: -32601, + message: "Method not found", +}; +export const ERROR_INVALID_PARAMS: ResponseError<-32602> = { + code: -32602, + message: "Invalid params", +}; +export const ERROR_INTERNAL_ERROR: ResponseError<-32603> = { + code: -32603, + message: "Internal error", +};