diff --git a/REMOTE.md b/REMOTE.md index 9dc15ed1fe973912c4a73c0fdfd277d282f61e02..f1748387a1fada6e3033cc0ae80a2ec954078ab6 100644 --- a/REMOTE.md +++ b/REMOTE.md @@ -1,68 +1,72 @@ -# Remote Access Setup +# Remote Access -Use this when you want to open T3 Code from another device (phone, tablet, another laptop). +Use this when you want to connect to a T3 Code server from another device such as a phone, tablet, or separate desktop app. -## CLI ↔ Env option map +## Recommended Setup -The T3 Code CLI accepts the following configuration options, available either as CLI flags or environment variables: +Use a trusted private network that meshes your devices together, such as a tailnet. -| CLI flag | Env var | Notes | -| ----------------------- | --------------------- | ------------------------------------------------------------------------------------ | -| `--mode ` | `T3CODE_MODE` | Runtime mode. | -| `--port ` | `T3CODE_PORT` | HTTP/WebSocket port. | -| `--host
` | `T3CODE_HOST` | Bind interface/address. | -| `--base-dir ` | `T3CODE_HOME` | Base directory. | -| `--dev-url ` | `VITE_DEV_SERVER_URL` | Dev web URL redirect/proxy target. | -| `--no-browser` | `T3CODE_NO_BROWSER` | Disable auto-open browser. | -| `--auth-token ` | `T3CODE_AUTH_TOKEN` | WebSocket auth token. Use this for standard CLI and remote-server flows. | -| `--bootstrap-fd ` | `T3CODE_BOOTSTRAP_FD` | Read a one-shot bootstrap envelope from an inherited file descriptor during startup. | +That gives you: -> TIP: Use the `--help` flag to see all available options and their descriptions. +- a stable address to connect to +- transport security at the network layer +- less exposure than opening the server to the public internet -## Security First +## Headless Server Flow -- Always set `--auth-token` before exposing the server outside localhost. - - When you control the process launcher, prefer sending the auth token in a JSON envelope via `--bootstrap-fd `. - With `--bootstrap-fd `, the launcher starts the server first, then sends a one-shot JSON envelope over the inherited file descriptor. This allows the auth token to be delivered without putting it in process environment or command line arguments. -- Treat the token like a password. -- Prefer binding to trusted interfaces (LAN IP or Tailnet IP) instead of opening all interfaces unless needed. - -## 1) Build + run server for remote access - -Remote access should use the built web app (not local Vite redirect mode). +Run the server with `t3 serve`. ```bash -bun run build -TOKEN="$(openssl rand -hex 24)" -bun run --cwd apps/server start -- --host 0.0.0.0 --port 3773 --auth-token "$TOKEN" --no-browser +npx t3 serve --host "$(tailscale ip -4)" ``` -Then open on your phone: +`t3 serve` starts the server without opening a browser and prints: -`http://:3773` +- a connection string +- a pairing token +- a pairing URL +- a QR code for the pairing URL -Example: +From there, connect from another device in either of these ways: -`http://192.168.1.42:3773` +- scan the QR code on your phone +- in the desktop app, enter the full pairing URL +- in the desktop app, enter the host and token separately -Notes: +Use `t3 serve --help` for the full flag reference. It supports the same general startup options as the normal server command, including an optional `cwd` argument. -- `--host 0.0.0.0` listens on all IPv4 interfaces. -- `--no-browser` prevents local auto-open, which is usually better for headless/remote sessions. -- Ensure your OS firewall allows inbound TCP on the selected port. +> Note +> The GUIs do not currently support adding projects on remote environments. +> For now, use `t3 project ...` on the server machine instead. +> Full GUI support for remote project management is coming soon. -## 2) Tailnet / Tailscale access +## How Pairing Works -If you use Tailscale, you can bind directly to your Tailnet address. +The remote device does not need a long-lived secret up front. -```bash -TAILNET_IP="$(tailscale ip -4)" -TOKEN="$(openssl rand -hex 24)" -bun run --cwd apps/server start -- --host "$(tailscale ip -4)" --port 3773 --auth-token "$TOKEN" --no-browser -``` +Instead: + +1. `t3 serve` issues a one-time owner pairing token. +2. The remote device exchanges that token with the server. +3. The server creates an authenticated session for that device. + +After pairing, future access is session-based. You do not need to keep reusing the original token unless you are pairing a new device. + +## Managing Access Later + +Use `t3 auth` to manage access after the initial pairing flow. + +Typical uses: + +- issue additional pairing credentials +- inspect active sessions +- revoke old pairing links or sessions -Open from any device in your tailnet: +Use `t3 auth --help` and the nested subcommand help pages for the full reference. -`http://:3773` +## Security Notes -You can also bind `--host 0.0.0.0` and connect through the Tailnet IP, but binding directly to the Tailnet IP limits exposure. +- Treat pairing URLs and pairing tokens like passwords. +- Prefer binding `--host` to a trusted private address, such as a Tailnet IP, instead of exposing the server broadly. +- Anyone with a valid pairing credential can create a session until that credential expires or is revoked. +- Use `t3 auth` to revoke credentials or sessions you no longer trust. diff --git a/apps/server/src/auth/Layers/ServerAuthPolicy.ts b/apps/server/src/auth/Layers/ServerAuthPolicy.ts index eaddf968f347fd2e9259831135c6240dd32cd2ad..9f952cc9ecb1033c51fc5acb7d6215227dacbcd9 100644 --- a/apps/server/src/auth/Layers/ServerAuthPolicy.ts +++ b/apps/server/src/auth/Layers/ServerAuthPolicy.ts @@ -4,23 +4,7 @@ import { Effect, Layer } from "effect"; import { ServerConfig } from "../../config.ts"; import { ServerAuthPolicy, type ServerAuthPolicyShape } from "../Services/ServerAuthPolicy.ts"; import { SESSION_COOKIE_NAME } from "../utils.ts"; - -const isWildcardHost = (host: string | undefined): boolean => - host === "0.0.0.0" || host === "::" || host === "[::]"; - -const isLoopbackHost = (host: string | undefined): boolean => { - if (!host || host.length === 0) { - return true; - } - - return ( - host === "localhost" || - host === "127.0.0.1" || - host === "::1" || - host === "[::1]" || - host.startsWith("127.") - ); -}; +import { isLoopbackHost, isWildcardHost } from "../../startupAccess.ts"; export const makeServerAuthPolicy = Effect.gen(function* () { const config = yield* ServerConfig; diff --git a/apps/server/src/auth/Layers/SessionCredentialService.ts b/apps/server/src/auth/Layers/SessionCredentialService.ts index e8d034aff2167aa11c9349f7f6e2fb744e0538c7..53b6ae2abf188b16e873726cec46852b8bdbb613 100644 --- a/apps/server/src/auth/Layers/SessionCredentialService.ts +++ b/apps/server/src/auth/Layers/SessionCredentialService.ts @@ -46,6 +46,9 @@ const WebSocketClaims = Schema.Struct({ }); type WebSocketClaims = typeof WebSocketClaims.Type; +const decodeSessionClaims = Schema.decodeUnknownEffect(Schema.fromJsonString(SessionClaims)); +const decodeWebSocketClaims = Schema.decodeUnknownEffect(Schema.fromJsonString(WebSocketClaims)); + function createDefaultClientMetadata(): AuthClientMetadata { return { deviceType: "unknown", @@ -259,15 +262,15 @@ export const makeSessionCredentialService = Effect.gen(function* () { }); } - const claims = yield* Effect.try({ - try: () => - Schema.decodeUnknownSync(SessionClaims)(JSON.parse(base64UrlDecodeUtf8(encodedPayload))), - catch: (cause) => - new SessionCredentialError({ - message: "Invalid session token payload.", - cause, - }), - }); + const claims = yield* decodeSessionClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( + Effect.mapError( + (cause) => + new SessionCredentialError({ + message: "Invalid session token payload.", + cause, + }), + ), + ); const now = yield* Clock.currentTimeMillis; if (claims.exp <= now) { @@ -348,17 +351,15 @@ export const makeSessionCredentialService = Effect.gen(function* () { }); } - const claims = yield* Effect.try({ - try: () => - Schema.decodeUnknownSync(WebSocketClaims)( - JSON.parse(base64UrlDecodeUtf8(encodedPayload)), - ), - catch: (cause) => - new SessionCredentialError({ - message: "Invalid websocket token payload.", - cause, - }), - }); + const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( + Effect.mapError( + (cause) => + new SessionCredentialError({ + message: "Invalid websocket token payload.", + cause, + }), + ), + ); const now = yield* Clock.currentTimeMillis; if (claims.exp <= now) { diff --git a/apps/server/src/cli-config.test.ts b/apps/server/src/cli-config.test.ts index 6dd48c5d259bb8562c6aea455aad73952c14b1ac..a49d9ac5ac57c24c7d660bbeb37017105c3329aa 100644 --- a/apps/server/src/cli-config.test.ts +++ b/apps/server/src/cli-config.test.ts @@ -83,6 +83,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { staticDir: undefined, devUrl: new URL("http://127.0.0.1:5173"), noBrowser: true, + startupPresentation: "browser", desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: true, @@ -144,6 +145,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { staticDir: undefined, devUrl: new URL("http://127.0.0.1:4173"), noBrowser: true, + startupPresentation: "browser", desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: true, logWebSocketEvents: true, @@ -212,6 +214,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { staticDir: undefined, devUrl: new URL("http://127.0.0.1:5173"), noBrowser: true, + startupPresentation: "browser", desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: true, @@ -329,6 +332,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { staticDir: undefined, devUrl: new URL("http://127.0.0.1:4173"), noBrowser: true, + startupPresentation: "browser", desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: true, logWebSocketEvents: true, @@ -392,6 +396,66 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { staticDir: resolved.staticDir, devUrl: undefined, noBrowser: true, + startupPresentation: "browser", + desktopBootstrapToken: undefined, + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: false, + }); + }), + ); + + it.effect("forces noBrowser and disables auto-bootstrap for headless startup presentation", () => + Effect.gen(function* () { + const { join } = yield* Path.Path; + const baseDir = join(os.tmpdir(), "t3-cli-config-headless-base"); + const derivedPaths = yield* deriveServerPaths(baseDir, undefined); + + const resolved = yield* resolveServerConfig( + { + mode: Option.some("web"), + port: Option.some(3773), + host: Option.none(), + baseDir: Option.some(baseDir), + cwd: Option.none(), + devUrl: Option.none(), + noBrowser: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + }, + Option.none(), + { + startupPresentation: "headless", + }, + ).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + T3CODE_NO_BROWSER: "false", + T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "true", + }, + }), + ), + NetService.layer, + ), + ), + ); + + expect(resolved).toEqual({ + logLevel: "Info", + ...defaultObservabilityConfig, + mode: "web", + port: 3773, + cwd: process.cwd(), + baseDir, + ...derivedPaths, + host: undefined, + staticDir: resolved.staticDir, + devUrl: undefined, + noBrowser: true, + startupPresentation: "headless", desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, diff --git a/apps/server/src/cli.test.ts b/apps/server/src/cli.test.ts index 4840fecc9aee71e2945b78446f149ed3b2a592f8..e739684c6b19fa351746a361b17567f23884b7b1 100644 --- a/apps/server/src/cli.test.ts +++ b/apps/server/src/cli.test.ts @@ -1,17 +1,38 @@ +import * as NodeHttp from "node:http"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { NetService } from "@t3tools/shared/Net"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as HttpRouter from "effect/unstable/http/HttpRouter"; +import * as HttpServer from "effect/unstable/http/HttpServer"; import * as CliError from "effect/unstable/cli/CliError"; import * as TestConsole from "effect/testing/TestConsole"; import { Command } from "effect/unstable/cli"; import { cli } from "./cli.ts"; +import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config.ts"; +import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; +import { + orchestrationDispatchRouteLayer, + orchestrationSnapshotRouteLayer, +} from "./orchestration/http.ts"; +import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; +import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts"; +import { + makePersistedServerRuntimeState, + persistServerRuntimeState, +} from "./serverRuntimeState.ts"; +import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; +import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; +import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); @@ -28,6 +49,104 @@ const captureStdout = (effect: Effect.Effect) => return { result, output }; }).pipe(Effect.provide(Layer.mergeAll(CliRuntimeLayer, TestConsole.layer))); +const makeCliTestServerConfig = (baseDir: string) => + Effect.gen(function* () { + const derivedPaths = yield* deriveServerPaths(baseDir, undefined); + return { + logLevel: "Info", + traceMinLevel: "Info", + traceTimingEnabled: true, + traceBatchWindowMs: 200, + traceMaxBytes: 10 * 1024 * 1024, + traceMaxFiles: 10, + otlpTracesUrl: undefined, + otlpMetricsUrl: undefined, + otlpExportIntervalMs: 10_000, + otlpServiceName: "t3-server", + mode: "web", + port: 0, + host: "127.0.0.1", + cwd: process.cwd(), + baseDir, + ...derivedPaths, + staticDir: undefined, + devUrl: undefined, + noBrowser: true, + startupPresentation: "browser", + desktopBootstrapToken: undefined, + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: false, + } satisfies ServerConfigShape; + }); + +const makeProjectPersistenceLayer = (config: ServerConfigShape) => + Layer.mergeAll( + OrchestrationLayerLive.pipe( + Layer.provideMerge(RepositoryIdentityResolverLive), + Layer.provideMerge(SqlitePersistenceLayerLive), + ), + WorkspacePathsLive, + ).pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provide(Layer.succeed(ServerConfig, config)), + ); + +const readPersistedSnapshot = (baseDir: string) => + Effect.gen(function* () { + const config = yield* makeCliTestServerConfig(baseDir); + return yield* Effect.gen(function* () { + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + return yield* projectionSnapshotQuery.getSnapshot(); + }).pipe(Effect.provide(makeProjectPersistenceLayer(config))); + }); + +const withLiveProjectCliServer = (baseDir: string, run: () => Effect.Effect) => + Effect.gen(function* () { + const config = yield* makeCliTestServerConfig(baseDir); + const routesLayer = Layer.mergeAll( + orchestrationSnapshotRouteLayer, + orchestrationDispatchRouteLayer, + ); + const appLayer = HttpRouter.serve(routesLayer, { + disableListenLog: true, + disableLogger: true, + }).pipe( + Layer.provideMerge( + ServerAuthLive.pipe( + Layer.provideMerge(SqlitePersistenceLayerLive), + Layer.provide(ServerSecretStoreLive), + ), + ), + Layer.provideMerge(makeProjectPersistenceLayer(config)), + Layer.provideMerge( + NodeHttpServer.layer(NodeHttp.createServer, { + host: "127.0.0.1", + port: 0, + }), + ), + Layer.provideMerge(NodeServices.layer), + Layer.provide(Layer.succeed(ServerConfig, config)), + ); + + return yield* Effect.scoped( + Effect.gen(function* () { + const server = yield* HttpServer.HttpServer; + const address = server.address; + if (typeof address === "string" || !("port" in address)) { + assert.fail(`Expected TCP address, got ${address}`); + } + yield* persistServerRuntimeState({ + path: config.serverRuntimeStatePath, + state: makePersistedServerRuntimeState({ + config, + port: address.port, + }), + }); + return yield* run(); + }).pipe(Effect.provide(Layer.mergeAll(appLayer, NodeServices.layer))), + ); + }); + it.layer(NodeServices.layer)("cli log-level parsing", (it) => { it.effect("accepts the built-in lowercase log-level flag values", () => runCliWithRuntime(["--log-level", "debug", "--version"]), @@ -130,4 +249,104 @@ it.layer(NodeServices.layer)("cli log-level parsing", (it) => { assert.isTrue(ttlError.message.includes("5m, 1h, 30d, or 15 minutes")); }), ); + + it.effect("adds, renames, and removes projects offline through the orchestration engine", () => + Effect.gen(function* () { + const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-projects-offline-test-")); + const workspaceRoot = mkdtempSync(join(tmpdir(), "t3-cli-projects-workspace-")); + + yield* runCliWithRuntime([ + "project", + "add", + workspaceRoot, + "--title", + "Alpha", + "--base-dir", + baseDir, + ]); + const afterAdd = yield* readPersistedSnapshot(baseDir); + const addedProject = afterAdd.projects.find( + (project) => project.workspaceRoot === workspaceRoot && project.deletedAt === null, + ); + assert.isTrue(addedProject !== undefined); + assert.equal(addedProject?.title, "Alpha"); + + yield* runCliWithRuntime(["project", "rename", workspaceRoot, "Beta", "--base-dir", baseDir]); + const afterRename = yield* readPersistedSnapshot(baseDir); + const renamedProject = afterRename.projects.find( + (project) => project.id === addedProject?.id, + ); + assert.equal(renamedProject?.title, "Beta"); + assert.equal(renamedProject?.deletedAt, null); + + yield* runCliWithRuntime([ + "project", + "remove", + addedProject?.id ?? "", + "--base-dir", + baseDir, + ]); + const afterRemove = yield* readPersistedSnapshot(baseDir); + const removedProject = afterRemove.projects.find( + (project) => project.id === addedProject?.id, + ); + assert.isTrue((removedProject?.deletedAt ?? null) !== null); + }), + ); + + it.effect("routes project commands through a running server when runtime state is present", () => + Effect.gen(function* () { + const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-projects-live-test-")); + const workspaceRoot = mkdtempSync(join(tmpdir(), "t3-cli-projects-live-workspace-")); + + yield* withLiveProjectCliServer(baseDir, () => + Effect.gen(function* () { + yield* runCliWithRuntime([ + "project", + "add", + workspaceRoot, + "--title", + "Live Project", + "--base-dir", + baseDir, + ]); + const orchestrationEngine = yield* OrchestrationEngineService; + const readModel = yield* orchestrationEngine.getReadModel(); + const addedProject = readModel.projects.find( + (project) => project.workspaceRoot === workspaceRoot && project.deletedAt === null, + ); + assert.isTrue(addedProject !== undefined); + assert.equal(addedProject?.title, "Live Project"); + }), + ); + }), + ); + + it.effect("rejects dev-url on project commands", () => + Effect.gen(function* () { + const workspaceRoot = mkdtempSync( + join(tmpdir(), "t3-cli-projects-unknown-option-workspace-"), + ); + const error = yield* runCliWithRuntime([ + "project", + "add", + workspaceRoot, + "--dev-url", + "http://127.0.0.1:5173", + ]).pipe(Effect.flip); + + if (!CliError.isCliError(error)) { + assert.fail(`Expected CliError, got ${String(error)}`); + } + if (error._tag !== "ShowHelp") { + assert.fail(`Expected ShowHelp, got ${error._tag}`); + } + assert.deepEqual(error.commandPath, ["t3", "project", "add"]); + const optionError = error.errors[0] as CliError.CliError | undefined; + if (!optionError || optionError._tag !== "UnrecognizedOption") { + assert.fail(`Expected UnrecognizedOption, got ${String(optionError?._tag)}`); + } + assert.equal(optionError.option, "--dev-url"); + }), + ); }); diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts index 01fd3005422a30f40d13c4eed4bbbf5c423bbdbc..4843419ba9e8294e5c633a53970bfa5492b4d84d 100644 --- a/apps/server/src/cli.ts +++ b/apps/server/src/cli.ts @@ -1,12 +1,20 @@ import { NetService } from "@t3tools/shared/Net"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; -import { AuthSessionId } from "@t3tools/contracts"; +import { + AuthSessionId, + CommandId, + OrchestrationReadModel, + ProjectId, + type ClientOrchestrationCommand, +} from "@t3tools/contracts"; import { Config, Console, Duration, Effect, + Exit, FileSystem, + Layer, LogLevel, Option, Path, @@ -16,6 +24,12 @@ import { SchemaTransformation, } from "effect"; import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + HttpClientResponse, +} from "effect/unstable/http"; import { DEFAULT_PORT, @@ -25,6 +39,7 @@ import { ServerConfig, RuntimeMode, type ServerConfigShape, + type StartupPresentation, } from "./config"; import { readBootstrapEnvelope } from "./bootstrap"; import { expandHomePath, resolveBaseDir } from "./os-jank"; @@ -37,6 +52,18 @@ import { formatSessionList, } from "./cliAuthFormat"; import { AuthControlPlane, AuthControlPlaneShape } from "./auth/Services/AuthControlPlane.ts"; +import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import { OrchestrationLayerLive } from "./orchestration/runtimeLayer"; +import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; +import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts"; +import { getAutoBootstrapDefaultModelSelection } from "./serverRuntimeStartup"; +import { + clearPersistedServerRuntimeState, + readPersistedServerRuntimeState, +} from "./serverRuntimeState"; +import { WorkspacePaths } from "./workspace/Services/WorkspacePaths"; +import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths"; const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })); @@ -163,7 +190,7 @@ interface CliServerFlags { interface CliAuthLocationFlags { readonly baseDir: Option.Option; - readonly devUrl: Option.Option; + readonly devUrl?: Option.Option; } const resolveBooleanFlag = (flag: Option.Option, envValue: boolean) => @@ -187,13 +214,29 @@ const loadPersistedObservabilitySettings = Effect.fn(function* (settingsPath: st export const resolveServerConfig = ( flags: CliServerFlags, cliLogLevel: Option.Option, + options?: { + readonly startupPresentation?: StartupPresentation; + readonly forceAutoBootstrapProjectFromCwd?: boolean; + }, ) => Effect.gen(function* () { const { findAvailablePort } = yield* NetService; const path = yield* Path.Path; const fs = yield* FileSystem.FileSystem; const env = yield* EnvServerConfig; - const bootstrapFd = Option.getOrUndefined(flags.bootstrapFd) ?? env.bootstrapFd; + const normalizedFlags = { + mode: flags.mode ?? Option.none(), + port: flags.port ?? Option.none(), + host: flags.host ?? Option.none(), + baseDir: flags.baseDir ?? Option.none(), + cwd: flags.cwd ?? Option.none(), + devUrl: flags.devUrl ?? Option.none(), + noBrowser: flags.noBrowser ?? Option.none(), + bootstrapFd: flags.bootstrapFd ?? Option.none(), + autoBootstrapProjectFromCwd: flags.autoBootstrapProjectFromCwd ?? Option.none(), + logWebSocketEvents: flags.logWebSocketEvents ?? Option.none(), + } satisfies CliServerFlags; + const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd; const bootstrapEnvelope = bootstrapFd !== undefined ? yield* readBootstrapEnvelope(BootstrapEnvelopeSchema, bootstrapFd) @@ -201,7 +244,7 @@ export const resolveServerConfig = ( const mode: RuntimeMode = Option.getOrElse( resolveOptionPrecedence( - flags.mode, + normalizedFlags.mode, Option.fromUndefinedOr(env.mode), Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.mode)), ), @@ -210,7 +253,7 @@ export const resolveServerConfig = ( const port = yield* Option.match( resolveOptionPrecedence( - flags.port, + normalizedFlags.port, Option.fromUndefinedOr(env.port), Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.port)), ), @@ -226,7 +269,7 @@ export const resolveServerConfig = ( ); const devUrl = Option.getOrElse( resolveOptionPrecedence( - flags.devUrl, + normalizedFlags.devUrl, Option.fromUndefinedOr(env.devUrl), Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.devUrl)), ), @@ -235,7 +278,7 @@ export const resolveServerConfig = ( const baseDir = yield* resolveBaseDir( Option.getOrUndefined( resolveOptionPrecedence( - flags.baseDir, + normalizedFlags.baseDir, Option.fromUndefinedOr(env.t3Home), Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.t3Home), @@ -243,7 +286,7 @@ export const resolveServerConfig = ( ), ), ); - const rawCwd = Option.getOrElse(flags.cwd, () => process.cwd()); + const rawCwd = Option.getOrElse(normalizedFlags.cwd, () => process.cwd()); const cwd = path.resolve(yield* expandHomePath(rawCwd.trim())); yield* fs.makeDirectory(cwd, { recursive: true }); const derivedPaths = yield* deriveServerPaths(baseDir, devUrl); @@ -253,37 +296,45 @@ export const resolveServerConfig = ( ); const serverTracePath = env.traceFile ?? derivedPaths.serverTracePath; yield* fs.makeDirectory(path.dirname(serverTracePath), { recursive: true }); - const noBrowser = resolveBooleanFlag( - flags.noBrowser, - Option.getOrElse( - resolveOptionPrecedence( - Option.fromUndefinedOr(env.noBrowser), - Option.flatMap(bootstrapEnvelope, (bootstrap) => - Option.fromUndefinedOr(bootstrap.noBrowser), - ), - ), - () => mode === "desktop", - ), - ); + const startupPresentation = options?.startupPresentation ?? "browser"; + const noBrowser = + startupPresentation === "headless" + ? true + : resolveBooleanFlag( + normalizedFlags.noBrowser, + Option.getOrElse( + resolveOptionPrecedence( + Option.fromUndefinedOr(env.noBrowser), + Option.flatMap(bootstrapEnvelope, (bootstrap) => + Option.fromUndefinedOr(bootstrap.noBrowser), + ), + ), + () => mode === "desktop", + ), + ); const desktopBootstrapToken = Option.getOrUndefined( Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.desktopBootstrapToken), ), ); - const autoBootstrapProjectFromCwd = resolveBooleanFlag( - flags.autoBootstrapProjectFromCwd, - Option.getOrElse( - resolveOptionPrecedence( - Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd), - Option.flatMap(bootstrapEnvelope, (bootstrap) => - Option.fromUndefinedOr(bootstrap.autoBootstrapProjectFromCwd), - ), - ), - () => mode === "web", - ), - ); + const autoBootstrapProjectFromCwd = + options?.forceAutoBootstrapProjectFromCwd ?? + (startupPresentation === "headless" + ? false + : resolveBooleanFlag( + normalizedFlags.autoBootstrapProjectFromCwd, + Option.getOrElse( + resolveOptionPrecedence( + Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd), + Option.flatMap(bootstrapEnvelope, (bootstrap) => + Option.fromUndefinedOr(bootstrap.autoBootstrapProjectFromCwd), + ), + ), + () => mode === "web", + ), + )); const logWebSocketEvents = resolveBooleanFlag( - flags.logWebSocketEvents, + normalizedFlags.logWebSocketEvents, Option.getOrElse( resolveOptionPrecedence( Option.fromUndefinedOr(env.logWebSocketEvents), @@ -297,7 +348,7 @@ export const resolveServerConfig = ( const staticDir = devUrl ? undefined : yield* resolveStaticDir(); const host = Option.getOrElse( resolveOptionPrecedence( - flags.host, + normalizedFlags.host, Option.fromUndefinedOr(env.host), Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.host)), ), @@ -340,6 +391,7 @@ export const resolveServerConfig = ( staticDir, devUrl, noBrowser, + startupPresentation, desktopBootstrapToken, autoBootstrapProjectFromCwd, logWebSocketEvents, @@ -359,7 +411,7 @@ const resolveCliAuthConfig = ( host: Option.none(), baseDir: flags.baseDir, cwd: Option.none(), - devUrl: flags.devUrl, + devUrl: flags.devUrl ?? Option.none(), noBrowser: Option.none(), bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), @@ -446,13 +498,289 @@ const runWithAuthControlPlane = ( const authControlPlane = yield* AuthControlPlane; return yield* run(authControlPlane); }).pipe( - Effect.provide(AuthControlPlaneRuntimeLive), - Effect.provideService(ServerConfig, config), - Effect.provideService(References.MinimumLogLevel, minimumLogLevel), + Effect.provide( + Layer.mergeAll(AuthControlPlaneRuntimeLive).pipe( + Layer.provide(Layer.succeed(ServerConfig, config)), + Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), + ), + ), ); }); -const commandFlags = { +type ProjectMutationTarget = { + readonly id: ProjectId; + readonly title: string; + readonly workspaceRoot: string; +}; + +type ProjectCommandExecutionMode = "live" | "offline"; +type ProjectCliDispatchCommand = Extract< + ClientOrchestrationCommand, + { type: "project.create" | "project.meta.update" | "project.delete" } +>; + +const ProjectCliRuntimeLive = Layer.mergeAll( + WorkspacePathsLive, + OrchestrationLayerLive.pipe( + Layer.provideMerge(RepositoryIdentityResolverLive), + Layer.provideMerge(SqlitePersistenceLayerLive), + ), +); + +const PROJECT_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(1); +const OrchestrationHttpErrorResponse = Schema.Struct({ + error: Schema.String, +}); + +const withProjectCliSessionToken = ( + authControlPlane: AuthControlPlaneShape, + run: (token: string) => Effect.Effect, +) => + Effect.acquireUseRelease( + authControlPlane.issueSession({ + role: "owner", + label: "t3 project cli", + }), + (issued) => run(issued.token), + (issued) => authControlPlane.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })), + ); + +const withProjectCliLiveServerTimeout = (effect: Effect.Effect) => + effect.pipe(Effect.timeout(PROJECT_CLI_LIVE_SERVER_TIMEOUT)); + +const runLiveServerRequest = ( + request: HttpClientRequest.HttpClientRequest, + handle: (response: HttpClientResponse.HttpClientResponse) => Effect.Effect, +) => + Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + const response = yield* httpClient.execute(request); + return yield* handle(response); + }).pipe(withProjectCliLiveServerTimeout); + +const decodeOrchestrationReadModelResponse = (response: HttpClientResponse.HttpClientResponse) => + HttpClientResponse.schemaBodyJson(OrchestrationReadModel)(response); + +const readErrorMessageFromResponse = (response: HttpClientResponse.HttpClientResponse) => + HttpClientResponse.schemaBodyJson(OrchestrationHttpErrorResponse)(response).pipe( + Effect.map((body) => body.error), + Effect.catch(() => Effect.succeed(null)), + Effect.map((body) => { + if (typeof body === "string" && body.trim().length > 0) { + return body; + } + return `Server request failed with status ${response.status}.`; + }), + ); + +const normalizeWorkspaceRootForProjectCommand = Effect.fn( + "normalizeWorkspaceRootForProjectCommand", +)(function* (workspaceRoot: string) { + const workspacePaths = yield* WorkspacePaths; + return yield* workspacePaths.normalizeWorkspaceRoot(workspaceRoot); +}); + +const resolveProjectTitle = Effect.fn("resolveProjectTitle")(function* ( + workspaceRoot: string, + explicitTitle?: string, +) { + if (explicitTitle !== undefined) { + const trimmed = explicitTitle.trim(); + if (trimmed.length > 0) { + return trimmed; + } + return yield* Effect.fail(new Error("Project title cannot be empty.")); + } + + const path = yield* Path.Path; + const basename = path.basename(workspaceRoot).trim(); + return basename.length > 0 ? basename : "project"; +}); + +const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* (input: { + readonly snapshot: OrchestrationReadModel; + readonly identifier: string; +}) { + const trimmedIdentifier = input.identifier.trim(); + if (trimmedIdentifier.length === 0) { + return yield* Effect.fail(new Error("Project identifier cannot be empty.")); + } + + const activeProjects = input.snapshot.projects.filter((project) => project.deletedAt === null); + const exactIdMatch = activeProjects.find((project) => project.id === trimmedIdentifier); + if (exactIdMatch) { + return { + id: exactIdMatch.id, + title: exactIdMatch.title, + workspaceRoot: exactIdMatch.workspaceRoot, + } satisfies ProjectMutationTarget; + } + + const normalizedWorkspaceRootResult = yield* Effect.exit( + normalizeWorkspaceRootForProjectCommand(trimmedIdentifier), + ); + const normalizedWorkspaceRoot = Exit.isSuccess(normalizedWorkspaceRootResult) + ? normalizedWorkspaceRootResult.value + : null; + + const exactWorkspaceMatch = + normalizedWorkspaceRoot === null + ? undefined + : activeProjects.find((project) => project.workspaceRoot === normalizedWorkspaceRoot); + + const resolved = exactWorkspaceMatch; + if (!resolved) { + return yield* Effect.fail(new Error(`No active project found for '${trimmedIdentifier}'.`)); + } + + return { + id: resolved.id, + title: resolved.title, + workspaceRoot: resolved.workspaceRoot, + } satisfies ProjectMutationTarget; +}); + +const fetchLiveOrchestrationSnapshot = (origin: string, bearerToken: string) => + runLiveServerRequest( + HttpClientRequest.get(`${origin}/api/orchestration/snapshot`).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.bearerToken(bearerToken), + ), + HttpClientResponse.matchStatus({ + "2xx": decodeOrchestrationReadModelResponse, + orElse: (response) => + readErrorMessageFromResponse(response).pipe( + Effect.flatMap((message) => Effect.fail(new Error(message))), + ), + }), + ); + +const dispatchLiveOrchestrationCommand = ( + origin: string, + bearerToken: string, + command: ProjectCliDispatchCommand, +) => + HttpClientRequest.post(`${origin}/api/orchestration/dispatch`).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.bearerToken(bearerToken), + HttpClientRequest.bodyJson(command), + Effect.flatMap((request) => + runLiveServerRequest( + request, + HttpClientResponse.matchStatus({ + "2xx": () => Effect.void, + orElse: (response) => + readErrorMessageFromResponse(response).pipe( + Effect.flatMap((message) => Effect.fail(new Error(message))), + ), + }), + ), + ), + ); + +const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () { + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + return yield* projectionSnapshotQuery.getSnapshot(); +}); + +const tryResolveLiveProjectExecutionMode = Effect.fn("tryResolveLiveProjectExecutionMode")( + function* (authControlPlane: AuthControlPlaneShape, config: ServerConfigShape) { + const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath); + if (Option.isNone(runtimeState)) { + return Option.none<{ readonly origin: string }>(); + } + + const attempt = withProjectCliSessionToken(authControlPlane, (token) => + fetchLiveOrchestrationSnapshot(runtimeState.value.origin, token).pipe( + Effect.as({ + origin: runtimeState.value.origin, + }), + ), + ); + + const attempted = yield* Effect.exit(attempt); + if (Exit.isSuccess(attempted)) { + return Option.some(attempted.value); + } + + yield* clearPersistedServerRuntimeState(config.serverRuntimeStatePath); + return Option.none<{ readonly origin: string }>(); + }, +); + +const runProjectMutation = Effect.fn("runProjectMutation")(function* ( + flags: CliAuthLocationFlags, + run: (input: { + readonly snapshot: OrchestrationReadModel; + readonly dispatch: ( + command: ProjectCliDispatchCommand, + ) => Effect.Effect; + readonly mode: ProjectCommandExecutionMode; + }) => Effect.Effect< + string, + Error, + FileSystem.FileSystem | HttpClient.HttpClient | Path.Path | WorkspacePaths + >, +) { + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveCliAuthConfig(flags, logLevel); + const minimumLogLevel = config.logLevel; + + return yield* Effect.gen(function* () { + const authControlPlane = yield* AuthControlPlane; + const liveMode = yield* tryResolveLiveProjectExecutionMode(authControlPlane, config); + + if (Option.isSome(liveMode)) { + return yield* withProjectCliSessionToken(authControlPlane, (token) => + Effect.gen(function* () { + const snapshot = yield* fetchLiveOrchestrationSnapshot(liveMode.value.origin, token); + const output = yield* run({ + snapshot, + dispatch: (command) => + dispatchLiveOrchestrationCommand(liveMode.value.origin, token, command), + mode: "live", + }); + yield* Console.log(output); + }), + ); + } + + const offlineRuntimeLayer = ProjectCliRuntimeLive.pipe( + Layer.provide(Layer.succeed(ServerConfig, config)), + Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), + ); + + return yield* Effect.gen(function* () { + const snapshot = yield* getOfflineSnapshot(); + const orchestrationEngine = yield* OrchestrationEngineService; + const output = yield* run({ + snapshot, + dispatch: (command) => orchestrationEngine.dispatch(command), + mode: "offline", + }); + yield* Console.log(output); + }).pipe(Effect.provide(offlineRuntimeLayer)); + }).pipe( + Effect.provide( + Layer.mergeAll(AuthControlPlaneRuntimeLive, WorkspacePathsLive).pipe( + Layer.provideMerge(FetchHttpClient.layer), + Layer.provide(Layer.succeed(ServerConfig, config)), + Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), + ), + ), + ); +}); + +const sharedServerLocationFlags = { + baseDir: baseDirFlag, + devUrl: devUrlFlag, +} as const; + +const projectLocationFlags = { + baseDir: baseDirFlag, +} as const; + +const sharedServerCommandFlags = { mode: modeFlag, port: portFlag, host: hostFlag, @@ -470,10 +798,7 @@ const commandFlags = { logWebSocketEvents: logWebSocketEventsFlag, } as const; -const authLocationFlags = { - baseDir: baseDirFlag, - devUrl: devUrlFlag, -} as const; +const authLocationFlags = sharedServerLocationFlags; const ttlFlag = Flag.string("ttl").pipe( Flag.withSchema(DurationFromString), @@ -674,25 +999,165 @@ const authCommand = Command.make("auth").pipe( Command.withSubcommands([pairingCommand, sessionCommand]), ); -const startCommand = Command.make("start", commandFlags).pipe( - Command.withDescription("Run the T3 Code server."), +const projectAddCommand = Command.make("add", { + ...projectLocationFlags, + workspaceRoot: Argument.string("path").pipe( + Argument.withDescription("Workspace root to add as a project."), + ), + title: Flag.string("title").pipe(Flag.withDescription("Optional project title."), Flag.optional), +}).pipe( + Command.withDescription("Add a project."), Command.withHandler((flags) => - Effect.gen(function* () { - const logLevel = yield* GlobalFlag.LogLevel; - const config = yield* resolveServerConfig(flags, logLevel); - return yield* runServer.pipe(Effect.provideService(ServerConfig, config)); - }), + runProjectMutation( + flags, + Effect.fn("projectAddMutation")(function* ({ + snapshot, + dispatch, + }: { + readonly snapshot: OrchestrationReadModel; + readonly dispatch: ( + command: ProjectCliDispatchCommand, + ) => Effect.Effect; + }) { + const workspaceRoot = yield* normalizeWorkspaceRootForProjectCommand(flags.workspaceRoot); + const existingProject = snapshot.projects.find( + (project) => project.deletedAt === null && project.workspaceRoot === workspaceRoot, + ); + if (existingProject) { + return yield* Effect.fail( + new Error(`An active project already exists for '${workspaceRoot}'.`), + ); + } + + const title = yield* resolveProjectTitle(workspaceRoot, Option.getOrUndefined(flags.title)); + const projectId = ProjectId.makeUnsafe(crypto.randomUUID()); + yield* dispatch({ + type: "project.create", + commandId: CommandId.makeUnsafe(crypto.randomUUID()), + projectId, + title, + workspaceRoot, + defaultModelSelection: getAutoBootstrapDefaultModelSelection(), + createdAt: new Date().toISOString(), + }); + return `Added project ${projectId} (${title}) at ${workspaceRoot}.`; + }), + ), + ), +); + +const projectRemoveCommand = Command.make("remove", { + ...projectLocationFlags, + project: Argument.string("project").pipe( + Argument.withDescription("Project id or workspace root to remove."), + ), +}).pipe( + Command.withDescription("Remove a project."), + Command.withHandler((flags) => + runProjectMutation( + flags, + Effect.fn("projectRemoveMutation")(function* ({ + snapshot, + dispatch, + }: { + readonly snapshot: OrchestrationReadModel; + readonly dispatch: ( + command: ProjectCliDispatchCommand, + ) => Effect.Effect; + }) { + const project = yield* findActiveProjectTarget({ + snapshot, + identifier: flags.project, + }); + yield* dispatch({ + type: "project.delete", + commandId: CommandId.makeUnsafe(crypto.randomUUID()), + projectId: project.id, + }); + return `Removed project ${project.id} (${project.title}).`; + }), + ), + ), +); + +const projectRenameCommand = Command.make("rename", { + ...projectLocationFlags, + project: Argument.string("project").pipe( + Argument.withDescription("Project id or workspace root to rename."), + ), + title: Argument.string("title").pipe(Argument.withDescription("New project title.")), +}).pipe( + Command.withDescription("Rename a project."), + Command.withHandler((flags) => + runProjectMutation( + flags, + Effect.fn("projectRenameMutation")(function* ({ + snapshot, + dispatch, + }: { + readonly snapshot: OrchestrationReadModel; + readonly dispatch: ( + command: ProjectCliDispatchCommand, + ) => Effect.Effect; + }) { + const project = yield* findActiveProjectTarget({ + snapshot, + identifier: flags.project, + }); + const nextTitle = yield* resolveProjectTitle(project.workspaceRoot, flags.title); + if (nextTitle === project.title) { + return `Project ${project.id} is already named ${nextTitle}.`; + } + + yield* dispatch({ + type: "project.meta.update", + commandId: CommandId.makeUnsafe(crypto.randomUUID()), + projectId: project.id, + title: nextTitle, + }); + return `Renamed project ${project.id} to ${nextTitle}.`; + }), + ), ), ); -export const cli = Command.make("t3", commandFlags).pipe( +const projectCommand = Command.make("project").pipe( + Command.withDescription("Manage projects."), + Command.withSubcommands([projectAddCommand, projectRemoveCommand, projectRenameCommand]), +); + +const runServerCommand = ( + flags: CliServerFlags, + options?: { + readonly startupPresentation?: StartupPresentation; + readonly forceAutoBootstrapProjectFromCwd?: boolean; + }, +) => + Effect.gen(function* () { + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveServerConfig(flags, logLevel, options); + return yield* runServer.pipe(Effect.provideService(ServerConfig, config)); + }); + +const startCommand = Command.make("start", { ...sharedServerCommandFlags }).pipe( Command.withDescription("Run the T3 Code server."), + Command.withHandler((flags) => runServerCommand(flags)), +); + +const serveCommand = Command.make("serve", { ...sharedServerCommandFlags }).pipe( + Command.withDescription( + "Run the T3 Code server without opening a browser and print headless pairing details.", + ), Command.withHandler((flags) => - Effect.gen(function* () { - const logLevel = yield* GlobalFlag.LogLevel; - const config = yield* resolveServerConfig(flags, logLevel); - return yield* runServer.pipe(Effect.provideService(ServerConfig, config)); + runServerCommand(flags, { + startupPresentation: "headless", + forceAutoBootstrapProjectFromCwd: false, }), ), - Command.withSubcommands([startCommand, authCommand]), +); + +export const cli = Command.make("t3", { ...sharedServerCommandFlags }).pipe( + Command.withDescription("Run the T3 Code server."), + Command.withHandler((flags) => runServerCommand(flags)), + Command.withSubcommands([startCommand, serveCommand, authCommand, projectCommand]), ); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 14c34b833626a9392f168a528e7b674cf6ded9d7..980b9330bf9bf976b692ec768a6c6405aa16d24a 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -13,6 +13,9 @@ export const DEFAULT_PORT = 3773; export const RuntimeMode = Schema.Literals(["web", "desktop"]); export type RuntimeMode = typeof RuntimeMode.Type; +export const StartupPresentation = Schema.Literals(["browser", "headless"]); +export type StartupPresentation = typeof StartupPresentation.Type; + /** * ServerDerivedPaths - Derived paths from the base directory. */ @@ -31,6 +34,7 @@ export interface ServerDerivedPaths { readonly terminalLogsDir: string; readonly anonymousIdPath: string; readonly environmentIdPath: string; + readonly serverRuntimeStatePath: string; readonly secretsDir: string; } @@ -56,6 +60,7 @@ export interface ServerConfigShape extends ServerDerivedPaths { readonly staticDir: string | undefined; readonly devUrl: URL | undefined; readonly noBrowser: boolean; + readonly startupPresentation: StartupPresentation; readonly desktopBootstrapToken: string | undefined; readonly autoBootstrapProjectFromCwd: boolean; readonly logWebSocketEvents: boolean; @@ -86,6 +91,7 @@ export const deriveServerPaths = Effect.fn(function* ( terminalLogsDir: join(logsDir, "terminals"), anonymousIdPath: join(stateDir, "anonymous-id"), environmentIdPath: join(stateDir, "environment-id"), + serverRuntimeStatePath: join(stateDir, "server-runtime.json"), secretsDir: join(stateDir, "secrets"), }; }); @@ -105,6 +111,7 @@ export const ensureServerDirectories = Effect.fn(function* (derivedPaths: Server fs.makeDirectory(path.dirname(derivedPaths.keybindingsConfigPath), { recursive: true }), fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }), fs.makeDirectory(path.dirname(derivedPaths.anonymousIdPath), { recursive: true }), + fs.makeDirectory(path.dirname(derivedPaths.serverRuntimeStatePath), { recursive: true }), ], { concurrency: "unbounded" }, ); @@ -153,6 +160,7 @@ export class ServerConfig extends ServiceMap.Service + Effect.gen(function* () { + if (error._tag === "OrchestrationGetSnapshotError") { + yield* Effect.logError("orchestration http route failed", { + message: error.message, + cause: error.cause, + }); + return HttpServerResponse.jsonUnsafe({ error: error.message }, { status: 500 }); + } + + return HttpServerResponse.jsonUnsafe({ error: error.message }, { status: 400 }); + }); + +const authenticateOwnerSession = Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const serverAuth = yield* ServerAuth; + const session = yield* serverAuth.authenticateHttpRequest(request); + if (session.role !== "owner") { + return yield* new OrchestrationDispatchCommandError({ + message: "Only owner sessions can manage projects.", + }); + } + return session; +}); + +export const orchestrationSnapshotRouteLayer = HttpRouter.add( + "GET", + "/api/orchestration/snapshot", + Effect.gen(function* () { + yield* authenticateOwnerSession; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const snapshot = yield* projectionSnapshotQuery.getSnapshot().pipe( + Effect.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: "Failed to load orchestration snapshot.", + cause, + }), + ), + ); + return HttpServerResponse.jsonUnsafe(snapshot satisfies OrchestrationReadModel, { + status: 200, + }); + }).pipe( + Effect.catchTag("OrchestrationDispatchCommandError", respondToOrchestrationHttpError), + Effect.catchTag("OrchestrationGetSnapshotError", respondToOrchestrationHttpError), + ), +); + +export const orchestrationDispatchRouteLayer = HttpRouter.add( + "POST", + "/api/orchestration/dispatch", + Effect.gen(function* () { + yield* authenticateOwnerSession; + const orchestrationEngine = yield* OrchestrationEngineService; + const command = yield* HttpServerRequest.schemaBodyJson(ClientOrchestrationCommand).pipe( + Effect.mapError( + (cause) => + new OrchestrationDispatchCommandError({ + message: "Invalid orchestration command payload.", + cause, + }), + ), + ); + const normalizedCommand = yield* normalizeDispatchCommand(command); + const result = yield* orchestrationEngine.dispatch(normalizedCommand).pipe( + Effect.mapError( + (cause) => + new OrchestrationDispatchCommandError({ + message: "Failed to dispatch orchestration command.", + cause, + }), + ), + ); + return HttpServerResponse.jsonUnsafe(result, { status: 200 }); + }).pipe(Effect.catchTag("OrchestrationDispatchCommandError", respondToOrchestrationHttpError)), +); diff --git a/apps/server/src/orchestration/runtimeLayer.ts b/apps/server/src/orchestration/runtimeLayer.ts new file mode 100644 index 0000000000000000000000000000000000000000..1b964709aecb5614163b23ff92170c4d2dc6b1b4 --- /dev/null +++ b/apps/server/src/orchestration/runtimeLayer.ts @@ -0,0 +1,27 @@ +import { Layer } from "effect"; + +import { OrchestrationCommandReceiptRepositoryLive } from "../persistence/Layers/OrchestrationCommandReceipts.ts"; +import { OrchestrationEventStoreLive } from "../persistence/Layers/OrchestrationEventStore.ts"; +import { OrchestrationEngineLive } from "./Layers/OrchestrationEngine.ts"; +import { OrchestrationProjectionPipelineLive } from "./Layers/ProjectionPipeline.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "./Layers/ProjectionSnapshotQuery.ts"; + +export const OrchestrationEventInfrastructureLayerLive = Layer.mergeAll( + OrchestrationEventStoreLive, + OrchestrationCommandReceiptRepositoryLive, +); + +export const OrchestrationProjectionPipelineLayerLive = OrchestrationProjectionPipelineLive.pipe( + Layer.provide(OrchestrationEventStoreLive), +); + +export const OrchestrationInfrastructureLayerLive = Layer.mergeAll( + OrchestrationProjectionSnapshotQueryLive, + OrchestrationEventInfrastructureLayerLive, + OrchestrationProjectionPipelineLayerLive, +); + +export const OrchestrationLayerLive = Layer.mergeAll( + OrchestrationInfrastructureLayerLive, + OrchestrationEngineLive.pipe(Layer.provide(OrchestrationInfrastructureLayerLive)), +); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 4e683637e7d5b30c5c2acdc3ed3eeaf2d127e6c3..98e1395e13dbce5b30c1849bbb7a8e54de1a0aa7 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -331,6 +331,7 @@ const buildAppUnderTest = (options?: { staticDir: undefined, devUrl, noBrowser: true, + startupPresentation: "browser", desktopBootstrapToken: defaultDesktopBootstrapToken, autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index c1581b9382ea61c5172c8e029373f7aeaaae9c9d..23c53ad07fd54e00fd9f360bf1b609571f6445f4 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -23,12 +23,7 @@ import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; -import { OrchestrationEngineLive } from "./orchestration/Layers/OrchestrationEngine"; -import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline"; -import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore"; -import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts"; import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery"; -import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery"; import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; import { GitCoreLive } from "./git/Layers/GitCore"; import { GitHubCliLive } from "./git/Layers/GitHubCli"; @@ -67,6 +62,16 @@ import { } from "./auth/http"; import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore"; import { ServerAuthLive } from "./auth/Layers/ServerAuth"; +import { OrchestrationLayerLive } from "./orchestration/runtimeLayer"; +import { + clearPersistedServerRuntimeState, + makePersistedServerRuntimeState, + persistServerRuntimeState, +} from "./serverRuntimeState"; +import { + orchestrationDispatchRouteLayer, + orchestrationSnapshotRouteLayer, +} from "./orchestration/http"; const PtyAdapterLive = Layer.unwrap( Effect.gen(function* () { @@ -124,26 +129,6 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(RuntimeReceiptBusLive), ); -const OrchestrationEventInfrastructureLayerLive = Layer.mergeAll( - OrchestrationEventStoreLive, - OrchestrationCommandReceiptRepositoryLive, -); - -const OrchestrationProjectionPipelineLayerLive = OrchestrationProjectionPipelineLive.pipe( - Layer.provide(OrchestrationEventStoreLive), -); - -const OrchestrationInfrastructureLayerLive = Layer.mergeAll( - OrchestrationProjectionSnapshotQueryLive, - OrchestrationEventInfrastructureLayerLive, - OrchestrationProjectionPipelineLayerLive, -); - -const OrchestrationLayerLive = Layer.mergeAll( - OrchestrationInfrastructureLayerLive, - OrchestrationEngineLive.pipe(Layer.provide(OrchestrationInfrastructureLayerLive)), -); - const CheckpointingLayerLive = Layer.empty.pipe( Layer.provideMerge(CheckpointDiffQueryLive), Layer.provideMerge(CheckpointStoreLive), @@ -248,6 +233,8 @@ export const makeRoutesLayer = Layer.mergeAll( authSessionRouteLayer, authWebSocketTokenRouteLayer, attachmentsRouteLayer, + orchestrationDispatchRouteLayer, + orchestrationSnapshotRouteLayer, otlpTracesProxyRouteLayer, projectFaviconRouteLayer, serverEnvironmentRouteLayer, @@ -268,12 +255,34 @@ export const makeServerLayer = Layer.unwrap( yield* startup.markHttpListening; }), ); + const runtimeStateLayer = Layer.effectDiscard( + Effect.acquireRelease( + Effect.gen(function* () { + const server = yield* HttpServer.HttpServer; + const address = server.address; + if (typeof address === "string" || !("port" in address)) { + return; + } + + const state = makePersistedServerRuntimeState({ + config, + port: address.port, + }); + yield* persistServerRuntimeState({ + path: config.serverRuntimeStatePath, + state, + }); + }), + () => clearPersistedServerRuntimeState(config.serverRuntimeStatePath), + ), + ); const serverApplicationLayer = Layer.mergeAll( HttpRouter.serve(makeRoutesLayer, { disableLogger: !config.logWebSocketEvents, }), httpListeningLayer, + runtimeStateLayer, ); return serverApplicationLayer.pipe( diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index fd43c6b3594ffb99d17e67f8ac12e88aa2733a00..5d68761f0976825852c44236e5c47ed26614bdcb 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -18,6 +18,7 @@ import { Ref, Scope, ServiceMap, + Console, } from "effect"; import { ServerConfig } from "./config"; @@ -31,12 +32,12 @@ import { ServerSettingsService } from "./serverSettings"; import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; import { ServerAuth } from "./auth/Services/ServerAuth"; - -const isWildcardHost = (host: string | undefined): boolean => - host === "0.0.0.0" || host === "::" || host === "[::]"; - -const formatHostForUrl = (host: string): string => - host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; +import { + formatHeadlessServeOutput, + formatHostForUrl, + isWildcardHost, + issueHeadlessServeAccessInfo, +} from "./startupAccess"; export class ServerRuntimeStartupError extends Data.TaggedError("ServerRuntimeStartupError")<{ readonly message: string; @@ -383,14 +384,23 @@ const makeServerRuntimeStartup = Effect.gen(function* () { yield* Effect.logDebug("startup phase: recording startup heartbeat"); yield* launchStartupHeartbeat; - yield* Effect.logDebug("startup phase: browser open check"); - const startupBrowserTarget = yield* resolveStartupBrowserTarget; - if (serverConfig.mode !== "desktop") { - yield* Effect.logInfo("Authentication required. Open T3 Code using the pairing URL.").pipe( - Effect.annotateLogs({ pairingUrl: startupBrowserTarget }), + if (serverConfig.startupPresentation === "headless") { + yield* Effect.logDebug("startup phase: headless access info"); + const accessInfo = yield* issueHeadlessServeAccessInfo(); + yield* runStartupPhase( + "headless.output", + Console.log(formatHeadlessServeOutput(accessInfo)), ); + } else { + yield* Effect.logDebug("startup phase: browser open check"); + const startupBrowserTarget = yield* resolveStartupBrowserTarget; + if (serverConfig.mode !== "desktop") { + yield* Effect.logInfo( + "Authentication required. Open T3 Code using the pairing URL.", + ).pipe(Effect.annotateLogs({ pairingUrl: startupBrowserTarget })); + } + yield* runStartupPhase("browser.open", maybeOpenBrowser(startupBrowserTarget)); } - yield* runStartupPhase("browser.open", maybeOpenBrowser(startupBrowserTarget)); yield* Effect.logDebug("startup phase: complete"); }), ); diff --git a/apps/server/src/serverRuntimeState.ts b/apps/server/src/serverRuntimeState.ts new file mode 100644 index 0000000000000000000000000000000000000000..00c83844682490d80f9e4e8b1a9ca5788678e846 --- /dev/null +++ b/apps/server/src/serverRuntimeState.ts @@ -0,0 +1,77 @@ +import { Effect, FileSystem, Option, Path, Schema } from "effect"; + +import { type ServerConfigShape } from "./config"; +import { formatHostForUrl, isWildcardHost } from "./startupAccess"; + +export const PersistedServerRuntimeState = Schema.Struct({ + version: Schema.Literal(1), + pid: Schema.Int, + host: Schema.optional(Schema.String), + port: Schema.Int, + origin: Schema.String, + startedAt: Schema.String, +}); +export type PersistedServerRuntimeState = typeof PersistedServerRuntimeState.Type; + +const decodePersistedServerRuntimeState = Schema.decodeUnknownEffect( + Schema.fromJsonString(PersistedServerRuntimeState), +); + +const runtimeOriginForConfig = ( + config: Pick, + port: number, +): PersistedServerRuntimeState["origin"] => { + const hostname = + config.host && !isWildcardHost(config.host) ? formatHostForUrl(config.host) : "127.0.0.1"; + return `http://${hostname}:${port}`; +}; + +export const makePersistedServerRuntimeState = (input: { + readonly config: Pick; + readonly port: number; +}): PersistedServerRuntimeState => ({ + version: 1, + pid: process.pid, + ...(input.config.host ? { host: input.config.host } : {}), + port: input.port, + origin: runtimeOriginForConfig(input.config, input.port), + startedAt: new Date().toISOString(), +}); + +export const persistServerRuntimeState = (input: { + readonly path: string; + readonly state: PersistedServerRuntimeState; +}) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const tempPath = `${input.path}.${process.pid}.${Date.now()}.tmp`; + return yield* fs.makeDirectory(pathService.dirname(input.path), { recursive: true }).pipe( + Effect.flatMap(() => fs.writeFileString(tempPath, `${JSON.stringify(input.state)}\n`)), + Effect.flatMap(() => fs.rename(tempPath, input.path)), + Effect.ensuring(fs.remove(tempPath, { force: true }).pipe(Effect.ignore({ log: true }))), + ); + }); + +export const clearPersistedServerRuntimeState = (path: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(path, { force: true }).pipe(Effect.ignore({ log: true })); + }); + +export const readPersistedServerRuntimeState = (path: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exists = yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + return Option.none(); + } + + const raw = yield* fs.readFileString(path).pipe(Effect.orElseSucceed(() => "")); + const trimmed = raw.trim(); + if (trimmed.length === 0) { + return Option.none(); + } + + return yield* decodePersistedServerRuntimeState(trimmed).pipe(Effect.option); + }); diff --git a/apps/server/src/startupAccess.test.ts b/apps/server/src/startupAccess.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ef6ece31e2850741e87dfda0ffe2be622aaed48f --- /dev/null +++ b/apps/server/src/startupAccess.test.ts @@ -0,0 +1,79 @@ +import { assert, expect, it } from "@effect/vitest"; + +import { + buildPairingUrl, + formatHeadlessServeOutput, + renderTerminalQrCode, + resolveHeadlessConnectionHost, + resolveHeadlessConnectionString, + resolveListeningPort, +} from "./startupAccess"; + +it("prefers localhost when no explicit host is configured", () => { + expect(resolveHeadlessConnectionHost(undefined)).toBe("localhost"); + expect(resolveHeadlessConnectionString(undefined, 3773)).toBe("http://localhost:3773"); +}); + +it("keeps explicit bind hosts in the connection string", () => { + expect(resolveHeadlessConnectionString("127.0.0.1", 3773)).toBe("http://127.0.0.1:3773"); + expect(resolveHeadlessConnectionString("::1", 3773)).toBe("http://[::1]:3773"); +}); + +it("resolves wildcard hosts to a concrete external interface when one is available", () => { + const connectionString = resolveHeadlessConnectionString("0.0.0.0", 3773, { + en0: [ + { + address: "192.168.1.42", + netmask: "255.255.255.0", + family: "IPv4", + mac: "00:00:00:00:00:00", + internal: false, + cidr: "192.168.1.42/24", + }, + ], + lo0: [ + { + address: "127.0.0.1", + netmask: "255.0.0.0", + family: "IPv4", + mac: "00:00:00:00:00:00", + internal: true, + cidr: "127.0.0.1/8", + }, + ], + }); + + expect(connectionString).toBe("http://192.168.1.42:3773"); +}); + +it("prefers the actual bound port when an http server address is available", () => { + expect(resolveListeningPort({ port: 4123 }, 3773)).toBe(4123); + expect(resolveListeningPort("pipe", 3773)).toBe(3773); + expect(resolveListeningPort(null, 3773)).toBe(3773); +}); + +it("builds a pairing URL that embeds the token in the hash", () => { + expect(buildPairingUrl("http://192.168.1.42:3773", "PAIRCODE")).toBe( + "http://192.168.1.42:3773/pair#token=PAIRCODE", + ); +}); + +it("renders terminal QR codes as a multi-line unicode block grid", () => { + const qrCode = renderTerminalQrCode("http://192.168.1.42:3773/pair#token=PAIRCODE"); + + assert.isTrue(qrCode.includes("█")); + assert.isTrue(qrCode.split("\n").length > 10); +}); + +it("formats headless serve output with the connection string, token, pairing url, and qr code", () => { + const output = formatHeadlessServeOutput({ + connectionString: "http://192.168.1.42:3773", + token: "PAIRCODE", + pairingUrl: "http://192.168.1.42:3773/pair#token=PAIRCODE", + }); + + expect(output).toContain("Connection string: http://192.168.1.42:3773"); + expect(output).toContain("Token: PAIRCODE"); + expect(output).toContain("Pairing URL: http://192.168.1.42:3773/pair#token=PAIRCODE"); + assert.isTrue(output.includes("█") || output.includes("▀") || output.includes("▄")); +}); diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts new file mode 100644 index 0000000000000000000000000000000000000000..a350d729d016cecd513e30a326b083d1bc0e37e4 --- /dev/null +++ b/apps/server/src/startupAccess.ts @@ -0,0 +1,148 @@ +import { networkInterfaces } from "node:os"; + +import { QrCode } from "@t3tools/shared/qrCode"; +import { Effect } from "effect"; +import { HttpServer } from "effect/unstable/http"; + +import { ServerConfig } from "./config"; +import { ServerAuth } from "./auth/Services/ServerAuth"; + +export interface HeadlessServeAccessInfo { + readonly connectionString: string; + readonly token: string; + readonly pairingUrl: string; +} + +type NetworkInterfacesMap = ReturnType; + +export const isLoopbackHost = (host: string | undefined): boolean => { + if (!host || host.length === 0) { + return true; + } + + return ( + host === "localhost" || + host === "127.0.0.1" || + host === "::1" || + host === "[::1]" || + host.startsWith("127.") + ); +}; + +export const isWildcardHost = (host: string | undefined): boolean => + host === "0.0.0.0" || host === "::" || host === "[::]"; + +export const formatHostForUrl = (host: string): string => + host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; + +const normalizeHost = (host: string): string => + host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host; + +const isIpv4Family = (family: string | number): boolean => family === "IPv4" || family === 4; + +const isIpv6Family = (family: string | number): boolean => family === "IPv6" || family === 6; + +export const resolveHeadlessConnectionHost = ( + host: string | undefined, + interfaces: NetworkInterfacesMap = networkInterfaces(), +): string => { + if (!host) { + return "localhost"; + } + + if (!isWildcardHost(host)) { + return normalizeHost(host); + } + + const interfaceEntries = Object.values(interfaces).flatMap((entries) => entries ?? []); + const externalIpv4 = interfaceEntries.find( + (entry) => !entry.internal && isIpv4Family(entry.family), + ); + if (externalIpv4) { + return externalIpv4.address; + } + + const externalIpv6 = interfaceEntries.find( + (entry) => !entry.internal && isIpv6Family(entry.family), + ); + return externalIpv6 ? normalizeHost(externalIpv6.address) : "localhost"; +}; + +export const resolveHeadlessConnectionString = ( + host: string | undefined, + port: number, + interfaces: NetworkInterfacesMap = networkInterfaces(), +): string => { + const connectionHost = resolveHeadlessConnectionHost(host, interfaces); + return `http://${formatHostForUrl(connectionHost)}:${port}`; +}; + +export const resolveListeningPort = (address: unknown, fallbackPort: number): number => { + if ( + typeof address === "object" && + address !== null && + "port" in address && + typeof address.port === "number" + ) { + return address.port; + } + return fallbackPort; +}; + +export const buildPairingUrl = (connectionString: string, token: string): string => { + const url = new URL(connectionString); + url.pathname = "/pair"; + url.searchParams.delete("token"); + url.hash = new URLSearchParams([["token", token]]).toString(); + return url.toString(); +}; + +export const renderTerminalQrCode = (value: string, margin = 2): string => { + const qrCode = QrCode.encodeText(value, QrCode.Ecc.MEDIUM); + const rows: Array = []; + const isDark = (x: number, y: number): boolean => + x >= 0 && x < qrCode.size && y >= 0 && y < qrCode.size && qrCode.getModule(x, y); + + for (let y = -margin; y < qrCode.size + margin; y += 2) { + let row = ""; + + for (let x = -margin; x < qrCode.size + margin; x += 1) { + const topDark = isDark(x, y); + const bottomDark = isDark(x, y + 1); + + row += topDark ? (bottomDark ? "█" : "▀") : bottomDark ? "▄" : " "; + } + + rows.push(row); + } + + return rows.join("\n"); +}; + +export const formatHeadlessServeOutput = (accessInfo: HeadlessServeAccessInfo): string => + [ + "T3 Code server is ready.", + `Connection string: ${accessInfo.connectionString}`, + `Token: ${accessInfo.token}`, + `Pairing URL: ${accessInfo.pairingUrl}`, + "", + renderTerminalQrCode(accessInfo.pairingUrl), + "", + ].join("\n"); + +export const issueHeadlessServeAccessInfo = Effect.fn("issueHeadlessServeAccessInfo")(function* () { + const serverConfig = yield* ServerConfig; + const httpServer = yield* HttpServer.HttpServer; + const serverAuth = yield* ServerAuth; + const connectionString = resolveHeadlessConnectionString( + serverConfig.host, + resolveListeningPort(httpServer.address, serverConfig.port), + ); + const issued = yield* serverAuth.issuePairingCredential({ role: "owner" }); + + return { + connectionString, + token: issued.credential, + pairingUrl: buildPairingUrl(connectionString, issued.credential), + } satisfies HeadlessServeAccessInfo; +}); diff --git a/apps/web/src/components/ui/qr-code.tsx b/apps/web/src/components/ui/qr-code.tsx index 9b474f76e0755499ab69a603f093e1e0c2e6b543..222fb8cc21bbb01ac45c29b52d8a0fd417468def 100644 --- a/apps/web/src/components/ui/qr-code.tsx +++ b/apps/web/src/components/ui/qr-code.tsx @@ -1,5 +1,5 @@ import { memo } from "react"; -import { QrCode } from "../../lib/vendor/qrcodegen"; +import { QrCode } from "@t3tools/shared/qrCode"; type QRCodeSvgProps = { value: string; diff --git a/apps/web/src/lib/vendor/qrcodegen.ts b/apps/web/src/lib/vendor/qrcodegen.ts deleted file mode 100644 index 5a5532f0dd62f674838fde3475d843269f2fc302..0000000000000000000000000000000000000000 --- a/apps/web/src/lib/vendor/qrcodegen.ts +++ /dev/null @@ -1,996 +0,0 @@ -// @ts-nocheck -/* oxlint-disable eslint/no-useless-escape */ -/* - * QR Code generator library (TypeScript) - * - * Copyright (c) Project Nayuki. (MIT License) - * https://www.nayuki.io/page/qr-code-generator-library - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to - * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of - * the Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - The Software is provided "as is", without warranty of any kind, express or - * implied, including but not limited to the warranties of merchantability, - * fitness for a particular purpose and noninfringement. In no event shall the - * authors or copyright holders be liable for any claim, damages or other - * liability, whether in an action of contract, tort or otherwise, arising from, - * out of or in connection with the Software or the use or other dealings in the - * Software. - */ - -"use strict"; - - -namespace qrcodegen { - - type bit = number; - type byte = number; - type int = number; - - - /*---- QR Code symbol class ----*/ - - /* - * A QR Code symbol, which is a type of two-dimension barcode. - * Invented by Denso Wave and described in the ISO/IEC 18004 standard. - * Instances of this class represent an immutable square grid of dark and light cells. - * The class provides static factory functions to create a QR Code from text or binary data. - * The class covers the QR Code Model 2 specification, supporting all versions (sizes) - * from 1 to 40, all 4 error correction levels, and 4 character encoding modes. - * - * Ways to create a QR Code object: - * - High level: Take the payload data and call QrCode.encodeText() or QrCode.encodeBinary(). - * - Mid level: Custom-make the list of segments and call QrCode.encodeSegments(). - * - Low level: Custom-make the array of data codeword bytes (including - * segment headers and final padding, excluding error correction codewords), - * supply the appropriate version number, and call the QrCode() constructor. - * (Note that all ways require supplying the desired error correction level.) - */ - export class QrCode { - - /*-- Static factory functions (high level) --*/ - - // Returns a QR Code representing the given Unicode text string at the given error correction level. - // As a conservative upper bound, this function is guaranteed to succeed for strings that have 738 or fewer - // Unicode code points (not UTF-16 code units) if the low error correction level is used. The smallest possible - // QR Code version is automatically chosen for the output. The ECC level of the result may be higher than the - // ecl argument if it can be done without increasing the version. - public static encodeText(text: string, ecl: QrCode.Ecc): QrCode { - const segs: Array = qrcodegen.QrSegment.makeSegments(text); - return QrCode.encodeSegments(segs, ecl); - } - - - // Returns a QR Code representing the given binary data at the given error correction level. - // This function always encodes using the binary segment mode, not any text mode. The maximum number of - // bytes allowed is 2953. The smallest possible QR Code version is automatically chosen for the output. - // The ECC level of the result may be higher than the ecl argument if it can be done without increasing the version. - public static encodeBinary(data: Readonly>, ecl: QrCode.Ecc): QrCode { - const seg: QrSegment = qrcodegen.QrSegment.makeBytes(data); - return QrCode.encodeSegments([seg], ecl); - } - - - /*-- Static factory functions (mid level) --*/ - - // Returns a QR Code representing the given segments with the given encoding parameters. - // The smallest possible QR Code version within the given range is automatically - // chosen for the output. Iff boostEcl is true, then the ECC level of the result - // may be higher than the ecl argument if it can be done without increasing the - // version. The mask number is either between 0 to 7 (inclusive) to force that - // mask, or -1 to automatically choose an appropriate mask (which may be slow). - // This function allows the user to create a custom sequence of segments that switches - // between modes (such as alphanumeric and byte) to encode text in less space. - // This is a mid-level API; the high-level API is encodeText() and encodeBinary(). - public static encodeSegments(segs: Readonly>, ecl: QrCode.Ecc, - minVersion: int = 1, maxVersion: int = 40, - mask: int = -1, boostEcl: boolean = true): QrCode { - - if (!(QrCode.MIN_VERSION <= minVersion && minVersion <= maxVersion && maxVersion <= QrCode.MAX_VERSION) - || mask < -1 || mask > 7) - throw new RangeError("Invalid value"); - - // Find the minimal version number to use - let version: int; - let dataUsedBits: int; - for (version = minVersion; ; version++) { - const dataCapacityBits: int = QrCode.getNumDataCodewords(version, ecl) * 8; // Number of data bits available - const usedBits: number = QrSegment.getTotalBits(segs, version); - if (usedBits <= dataCapacityBits) { - dataUsedBits = usedBits; - break; // This version number is found to be suitable - } - if (version >= maxVersion) // All versions in the range could not fit the given data - throw new RangeError("Data too long"); - } - - // Increase the error correction level while the data still fits in the current version number - for (const newEcl of [QrCode.Ecc.MEDIUM, QrCode.Ecc.QUARTILE, QrCode.Ecc.HIGH]) { // From low to high - if (boostEcl && dataUsedBits <= QrCode.getNumDataCodewords(version, newEcl) * 8) - ecl = newEcl; - } - - // Concatenate all segments to create the data bit string - let bb: Array = [] - for (const seg of segs) { - appendBits(seg.mode.modeBits, 4, bb); - appendBits(seg.numChars, seg.mode.numCharCountBits(version), bb); - for (const b of seg.getData()) - bb.push(b); - } - assert(bb.length == dataUsedBits); - - // Add terminator and pad up to a byte if applicable - const dataCapacityBits: int = QrCode.getNumDataCodewords(version, ecl) * 8; - assert(bb.length <= dataCapacityBits); - appendBits(0, Math.min(4, dataCapacityBits - bb.length), bb); - appendBits(0, (8 - bb.length % 8) % 8, bb); - assert(bb.length % 8 == 0); - - // Pad with alternating bytes until data capacity is reached - for (let padByte = 0xEC; bb.length < dataCapacityBits; padByte ^= 0xEC ^ 0x11) - appendBits(padByte, 8, bb); - - // Pack bits into bytes in big endian - let dataCodewords: Array = []; - while (dataCodewords.length * 8 < bb.length) - dataCodewords.push(0); - bb.forEach((b: bit, i: int) => - dataCodewords[i >>> 3] |= b << (7 - (i & 7))); - - // Create the QR Code object - return new QrCode(version, ecl, dataCodewords, mask); - } - - - /*-- Fields --*/ - - // The width and height of this QR Code, measured in modules, between - // 21 and 177 (inclusive). This is equal to version * 4 + 17. - public readonly size: int; - - // The index of the mask pattern used in this QR Code, which is between 0 and 7 (inclusive). - // Even if a QR Code is created with automatic masking requested (mask = -1), - // the resulting object still has a mask value between 0 and 7. - public readonly mask: int; - - // The modules of this QR Code (false = light, true = dark). - // Immutable after constructor finishes. Accessed through getModule(). - private readonly modules : Array> = []; - - // Indicates function modules that are not subjected to masking. Discarded when constructor finishes. - private readonly isFunction: Array> = []; - - - /*-- Constructor (low level) and fields --*/ - - // Creates a new QR Code with the given version number, - // error correction level, data codeword bytes, and mask number. - // This is a low-level API that most users should not use directly. - // A mid-level API is the encodeSegments() function. - public constructor( - // The version number of this QR Code, which is between 1 and 40 (inclusive). - // This determines the size of this barcode. - public readonly version: int, - - // The error correction level used in this QR Code. - public readonly errorCorrectionLevel: QrCode.Ecc, - - dataCodewords: Readonly>, - - msk: int) { - - // Check scalar arguments - if (version < QrCode.MIN_VERSION || version > QrCode.MAX_VERSION) - throw new RangeError("Version value out of range"); - if (msk < -1 || msk > 7) - throw new RangeError("Mask value out of range"); - this.size = version * 4 + 17; - - // Initialize both grids to be size*size arrays of Boolean false - let row: Array = []; - for (let i = 0; i < this.size; i++) - row.push(false); - for (let i = 0; i < this.size; i++) { - this.modules .push(row.slice()); // Initially all light - this.isFunction.push(row.slice()); - } - - // Compute ECC, draw modules - this.drawFunctionPatterns(); - const allCodewords: Array = this.addEccAndInterleave(dataCodewords); - this.drawCodewords(allCodewords); - - // Do masking - if (msk == -1) { // Automatically choose best mask - let minPenalty: int = 1000000000; - for (let i = 0; i < 8; i++) { - this.applyMask(i); - this.drawFormatBits(i); - const penalty: int = this.getPenaltyScore(); - if (penalty < minPenalty) { - msk = i; - minPenalty = penalty; - } - this.applyMask(i); // Undoes the mask due to XOR - } - } - assert(0 <= msk && msk <= 7); - this.mask = msk; - this.applyMask(msk); // Apply the final choice of mask - this.drawFormatBits(msk); // Overwrite old format bits - - this.isFunction = []; - } - - - /*-- Accessor methods --*/ - - // Returns the color of the module (pixel) at the given coordinates, which is false - // for light or true for dark. The top left corner has the coordinates (x=0, y=0). - // If the given coordinates are out of bounds, then false (light) is returned. - public getModule(x: int, y: int): boolean { - return 0 <= x && x < this.size && 0 <= y && y < this.size && this.modules[y][x]; - } - - - /*-- Private helper methods for constructor: Drawing function modules --*/ - - // Reads this object's version field, and draws and marks all function modules. - private drawFunctionPatterns(): void { - // Draw horizontal and vertical timing patterns - for (let i = 0; i < this.size; i++) { - this.setFunctionModule(6, i, i % 2 == 0); - this.setFunctionModule(i, 6, i % 2 == 0); - } - - // Draw 3 finder patterns (all corners except bottom right; overwrites some timing modules) - this.drawFinderPattern(3, 3); - this.drawFinderPattern(this.size - 4, 3); - this.drawFinderPattern(3, this.size - 4); - - // Draw numerous alignment patterns - const alignPatPos: Array = this.getAlignmentPatternPositions(); - const numAlign: int = alignPatPos.length; - for (let i = 0; i < numAlign; i++) { - for (let j = 0; j < numAlign; j++) { - // Don't draw on the three finder corners - if (!(i == 0 && j == 0 || i == 0 && j == numAlign - 1 || i == numAlign - 1 && j == 0)) - this.drawAlignmentPattern(alignPatPos[i], alignPatPos[j]); - } - } - - // Draw configuration data - this.drawFormatBits(0); // Dummy mask value; overwritten later in the constructor - this.drawVersion(); - } - - - // Draws two copies of the format bits (with its own error correction code) - // based on the given mask and this object's error correction level field. - private drawFormatBits(mask: int): void { - // Calculate error correction code and pack bits - const data: int = this.errorCorrectionLevel.formatBits << 3 | mask; // errCorrLvl is uint2, mask is uint3 - let rem: int = data; - for (let i = 0; i < 10; i++) - rem = (rem << 1) ^ ((rem >>> 9) * 0x537); - const bits = (data << 10 | rem) ^ 0x5412; // uint15 - assert(bits >>> 15 == 0); - - // Draw first copy - for (let i = 0; i <= 5; i++) - this.setFunctionModule(8, i, getBit(bits, i)); - this.setFunctionModule(8, 7, getBit(bits, 6)); - this.setFunctionModule(8, 8, getBit(bits, 7)); - this.setFunctionModule(7, 8, getBit(bits, 8)); - for (let i = 9; i < 15; i++) - this.setFunctionModule(14 - i, 8, getBit(bits, i)); - - // Draw second copy - for (let i = 0; i < 8; i++) - this.setFunctionModule(this.size - 1 - i, 8, getBit(bits, i)); - for (let i = 8; i < 15; i++) - this.setFunctionModule(8, this.size - 15 + i, getBit(bits, i)); - this.setFunctionModule(8, this.size - 8, true); // Always dark - } - - - // Draws two copies of the version bits (with its own error correction code), - // based on this object's version field, iff 7 <= version <= 40. - private drawVersion(): void { - if (this.version < 7) - return; - - // Calculate error correction code and pack bits - let rem: int = this.version; // version is uint6, in the range [7, 40] - for (let i = 0; i < 12; i++) - rem = (rem << 1) ^ ((rem >>> 11) * 0x1F25); - const bits: int = this.version << 12 | rem; // uint18 - assert(bits >>> 18 == 0); - - // Draw two copies - for (let i = 0; i < 18; i++) { - const color: boolean = getBit(bits, i); - const a: int = this.size - 11 + i % 3; - const b: int = Math.floor(i / 3); - this.setFunctionModule(a, b, color); - this.setFunctionModule(b, a, color); - } - } - - - // Draws a 9*9 finder pattern including the border separator, - // with the center module at (x, y). Modules can be out of bounds. - private drawFinderPattern(x: int, y: int): void { - for (let dy = -4; dy <= 4; dy++) { - for (let dx = -4; dx <= 4; dx++) { - const dist: int = Math.max(Math.abs(dx), Math.abs(dy)); // Chebyshev/infinity norm - const xx: int = x + dx; - const yy: int = y + dy; - if (0 <= xx && xx < this.size && 0 <= yy && yy < this.size) - this.setFunctionModule(xx, yy, dist != 2 && dist != 4); - } - } - } - - - // Draws a 5*5 alignment pattern, with the center module - // at (x, y). All modules must be in bounds. - private drawAlignmentPattern(x: int, y: int): void { - for (let dy = -2; dy <= 2; dy++) { - for (let dx = -2; dx <= 2; dx++) - this.setFunctionModule(x + dx, y + dy, Math.max(Math.abs(dx), Math.abs(dy)) != 1); - } - } - - - // Sets the color of a module and marks it as a function module. - // Only used by the constructor. Coordinates must be in bounds. - private setFunctionModule(x: int, y: int, isDark: boolean): void { - this.modules[y][x] = isDark; - this.isFunction[y][x] = true; - } - - - /*-- Private helper methods for constructor: Codewords and masking --*/ - - // Returns a new byte string representing the given data with the appropriate error correction - // codewords appended to it, based on this object's version and error correction level. - private addEccAndInterleave(data: Readonly>): Array { - const ver: int = this.version; - const ecl: QrCode.Ecc = this.errorCorrectionLevel; - if (data.length != QrCode.getNumDataCodewords(ver, ecl)) - throw new RangeError("Invalid argument"); - - // Calculate parameter numbers - const numBlocks: int = QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver]; - const blockEccLen: int = QrCode.ECC_CODEWORDS_PER_BLOCK [ecl.ordinal][ver]; - const rawCodewords: int = Math.floor(QrCode.getNumRawDataModules(ver) / 8); - const numShortBlocks: int = numBlocks - rawCodewords % numBlocks; - const shortBlockLen: int = Math.floor(rawCodewords / numBlocks); - - // Split data into blocks and append ECC to each block - let blocks: Array> = []; - const rsDiv: Array = QrCode.reedSolomonComputeDivisor(blockEccLen); - for (let i = 0, k = 0; i < numBlocks; i++) { - let dat: Array = data.slice(k, k + shortBlockLen - blockEccLen + (i < numShortBlocks ? 0 : 1)); - k += dat.length; - const ecc: Array = QrCode.reedSolomonComputeRemainder(dat, rsDiv); - if (i < numShortBlocks) - dat.push(0); - blocks.push(dat.concat(ecc)); - } - - // Interleave (not concatenate) the bytes from every block into a single sequence - let result: Array = []; - for (let i = 0; i < blocks[0].length; i++) { - blocks.forEach((block, j) => { - // Skip the padding byte in short blocks - if (i != shortBlockLen - blockEccLen || j >= numShortBlocks) - result.push(block[i]); - }); - } - assert(result.length == rawCodewords); - return result; - } - - - // Draws the given sequence of 8-bit codewords (data and error correction) onto the entire - // data area of this QR Code. Function modules need to be marked off before this is called. - private drawCodewords(data: Readonly>): void { - if (data.length != Math.floor(QrCode.getNumRawDataModules(this.version) / 8)) - throw new RangeError("Invalid argument"); - let i: int = 0; // Bit index into the data - // Do the funny zigzag scan - for (let right = this.size - 1; right >= 1; right -= 2) { // Index of right column in each column pair - if (right == 6) - right = 5; - for (let vert = 0; vert < this.size; vert++) { // Vertical counter - for (let j = 0; j < 2; j++) { - const x: int = right - j; // Actual x coordinate - const upward: boolean = ((right + 1) & 2) == 0; - const y: int = upward ? this.size - 1 - vert : vert; // Actual y coordinate - if (!this.isFunction[y][x] && i < data.length * 8) { - this.modules[y][x] = getBit(data[i >>> 3], 7 - (i & 7)); - i++; - } - // If this QR Code has any remainder bits (0 to 7), they were assigned as - // 0/false/light by the constructor and are left unchanged by this method - } - } - } - assert(i == data.length * 8); - } - - - // XORs the codeword modules in this QR Code with the given mask pattern. - // The function modules must be marked and the codeword bits must be drawn - // before masking. Due to the arithmetic of XOR, calling applyMask() with - // the same mask value a second time will undo the mask. A final well-formed - // QR Code needs exactly one (not zero, two, etc.) mask applied. - private applyMask(mask: int): void { - if (mask < 0 || mask > 7) - throw new RangeError("Mask value out of range"); - for (let y = 0; y < this.size; y++) { - for (let x = 0; x < this.size; x++) { - let invert: boolean; - switch (mask) { - case 0: invert = (x + y) % 2 == 0; break; - case 1: invert = y % 2 == 0; break; - case 2: invert = x % 3 == 0; break; - case 3: invert = (x + y) % 3 == 0; break; - case 4: invert = (Math.floor(x / 3) + Math.floor(y / 2)) % 2 == 0; break; - case 5: invert = x * y % 2 + x * y % 3 == 0; break; - case 6: invert = (x * y % 2 + x * y % 3) % 2 == 0; break; - case 7: invert = ((x + y) % 2 + x * y % 3) % 2 == 0; break; - default: throw new Error("Unreachable"); - } - if (!this.isFunction[y][x] && invert) - this.modules[y][x] = !this.modules[y][x]; - } - } - } - - - // Calculates and returns the penalty score based on state of this QR Code's current modules. - // This is used by the automatic mask choice algorithm to find the mask pattern that yields the lowest score. - private getPenaltyScore(): int { - let result: int = 0; - - // Adjacent modules in row having same color, and finder-like patterns - for (let y = 0; y < this.size; y++) { - let runColor = false; - let runX = 0; - let runHistory = [0,0,0,0,0,0,0]; - for (let x = 0; x < this.size; x++) { - if (this.modules[y][x] == runColor) { - runX++; - if (runX == 5) - result += QrCode.PENALTY_N1; - else if (runX > 5) - result++; - } else { - this.finderPenaltyAddHistory(runX, runHistory); - if (!runColor) - result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3; - runColor = this.modules[y][x]; - runX = 1; - } - } - result += this.finderPenaltyTerminateAndCount(runColor, runX, runHistory) * QrCode.PENALTY_N3; - } - // Adjacent modules in column having same color, and finder-like patterns - for (let x = 0; x < this.size; x++) { - let runColor = false; - let runY = 0; - let runHistory = [0,0,0,0,0,0,0]; - for (let y = 0; y < this.size; y++) { - if (this.modules[y][x] == runColor) { - runY++; - if (runY == 5) - result += QrCode.PENALTY_N1; - else if (runY > 5) - result++; - } else { - this.finderPenaltyAddHistory(runY, runHistory); - if (!runColor) - result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3; - runColor = this.modules[y][x]; - runY = 1; - } - } - result += this.finderPenaltyTerminateAndCount(runColor, runY, runHistory) * QrCode.PENALTY_N3; - } - - // 2*2 blocks of modules having same color - for (let y = 0; y < this.size - 1; y++) { - for (let x = 0; x < this.size - 1; x++) { - const color: boolean = this.modules[y][x]; - if ( color == this.modules[y][x + 1] && - color == this.modules[y + 1][x] && - color == this.modules[y + 1][x + 1]) - result += QrCode.PENALTY_N2; - } - } - - // Balance of dark and light modules - let dark: int = 0; - for (const row of this.modules) - dark = row.reduce((sum, color) => sum + (color ? 1 : 0), dark); - const total: int = this.size * this.size; // Note that size is odd, so dark/total != 1/2 - // Compute the smallest integer k >= 0 such that (45-5k)% <= dark/total <= (55+5k)% - const k: int = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1; - assert(0 <= k && k <= 9); - result += k * QrCode.PENALTY_N4; - assert(0 <= result && result <= 2568888); // Non-tight upper bound based on default values of PENALTY_N1, ..., N4 - return result; - } - - - /*-- Private helper functions --*/ - - // Returns an ascending list of positions of alignment patterns for this version number. - // Each position is in the range [0,177), and are used on both the x and y axes. - // This could be implemented as lookup table of 40 variable-length lists of integers. - private getAlignmentPatternPositions(): Array { - if (this.version == 1) - return []; - else { - const numAlign: int = Math.floor(this.version / 7) + 2; - const step: int = (this.version == 32) ? 26 : - Math.ceil((this.version * 4 + 4) / (numAlign * 2 - 2)) * 2; - let result: Array = [6]; - for (let pos = this.size - 7; result.length < numAlign; pos -= step) - result.splice(1, 0, pos); - return result; - } - } - - - // Returns the number of data bits that can be stored in a QR Code of the given version number, after - // all function modules are excluded. This includes remainder bits, so it might not be a multiple of 8. - // The result is in the range [208, 29648]. This could be implemented as a 40-entry lookup table. - private static getNumRawDataModules(ver: int): int { - if (ver < QrCode.MIN_VERSION || ver > QrCode.MAX_VERSION) - throw new RangeError("Version number out of range"); - let result: int = (16 * ver + 128) * ver + 64; - if (ver >= 2) { - const numAlign: int = Math.floor(ver / 7) + 2; - result -= (25 * numAlign - 10) * numAlign - 55; - if (ver >= 7) - result -= 36; - } - assert(208 <= result && result <= 29648); - return result; - } - - - // Returns the number of 8-bit data (i.e. not error correction) codewords contained in any - // QR Code of the given version number and error correction level, with remainder bits discarded. - // This stateless pure function could be implemented as a (40*4)-cell lookup table. - private static getNumDataCodewords(ver: int, ecl: QrCode.Ecc): int { - return Math.floor(QrCode.getNumRawDataModules(ver) / 8) - - QrCode.ECC_CODEWORDS_PER_BLOCK [ecl.ordinal][ver] * - QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver]; - } - - - // Returns a Reed-Solomon ECC generator polynomial for the given degree. This could be - // implemented as a lookup table over all possible parameter values, instead of as an algorithm. - private static reedSolomonComputeDivisor(degree: int): Array { - if (degree < 1 || degree > 255) - throw new RangeError("Degree out of range"); - // Polynomial coefficients are stored from highest to lowest power, excluding the leading term which is always 1. - // For example the polynomial x^3 + 255x^2 + 8x + 93 is stored as the uint8 array [255, 8, 93]. - let result: Array = []; - for (let i = 0; i < degree - 1; i++) - result.push(0); - result.push(1); // Start off with the monomial x^0 - - // Compute the product polynomial (x - r^0) * (x - r^1) * (x - r^2) * ... * (x - r^{degree-1}), - // and drop the highest monomial term which is always 1x^degree. - // Note that r = 0x02, which is a generator element of this field GF(2^8/0x11D). - let root = 1; - for (let i = 0; i < degree; i++) { - // Multiply the current product by (x - r^i) - for (let j = 0; j < result.length; j++) { - result[j] = QrCode.reedSolomonMultiply(result[j], root); - if (j + 1 < result.length) - result[j] ^= result[j + 1]; - } - root = QrCode.reedSolomonMultiply(root, 0x02); - } - return result; - } - - - // Returns the Reed-Solomon error correction codeword for the given data and divisor polynomials. - private static reedSolomonComputeRemainder(data: Readonly>, divisor: Readonly>): Array { - let result: Array = divisor.map(_ => 0); - for (const b of data) { // Polynomial division - const factor: byte = b ^ (result.shift() as byte); - result.push(0); - divisor.forEach((coef, i) => - result[i] ^= QrCode.reedSolomonMultiply(coef, factor)); - } - return result; - } - - - // Returns the product of the two given field elements modulo GF(2^8/0x11D). The arguments and result - // are unsigned 8-bit integers. This could be implemented as a lookup table of 256*256 entries of uint8. - private static reedSolomonMultiply(x: byte, y: byte): byte { - if (x >>> 8 != 0 || y >>> 8 != 0) - throw new RangeError("Byte out of range"); - // Russian peasant multiplication - let z: int = 0; - for (let i = 7; i >= 0; i--) { - z = (z << 1) ^ ((z >>> 7) * 0x11D); - z ^= ((y >>> i) & 1) * x; - } - assert(z >>> 8 == 0); - return z as byte; - } - - - // Can only be called immediately after a light run is added, and - // returns either 0, 1, or 2. A helper function for getPenaltyScore(). - private finderPenaltyCountPatterns(runHistory: Readonly>): int { - const n: int = runHistory[1]; - assert(n <= this.size * 3); - const core: boolean = n > 0 && runHistory[2] == n && runHistory[3] == n * 3 && runHistory[4] == n && runHistory[5] == n; - return (core && runHistory[0] >= n * 4 && runHistory[6] >= n ? 1 : 0) - + (core && runHistory[6] >= n * 4 && runHistory[0] >= n ? 1 : 0); - } - - - // Must be called at the end of a line (row or column) of modules. A helper function for getPenaltyScore(). - private finderPenaltyTerminateAndCount(currentRunColor: boolean, currentRunLength: int, runHistory: Array): int { - if (currentRunColor) { // Terminate dark run - this.finderPenaltyAddHistory(currentRunLength, runHistory); - currentRunLength = 0; - } - currentRunLength += this.size; // Add light border to final run - this.finderPenaltyAddHistory(currentRunLength, runHistory); - return this.finderPenaltyCountPatterns(runHistory); - } - - - // Pushes the given value to the front and drops the last value. A helper function for getPenaltyScore(). - private finderPenaltyAddHistory(currentRunLength: int, runHistory: Array): void { - if (runHistory[0] == 0) - currentRunLength += this.size; // Add light border to initial run - runHistory.pop(); - runHistory.unshift(currentRunLength); - } - - - /*-- Constants and tables --*/ - - // The minimum version number supported in the QR Code Model 2 standard. - public static readonly MIN_VERSION: int = 1; - // The maximum version number supported in the QR Code Model 2 standard. - public static readonly MAX_VERSION: int = 40; - - // For use in getPenaltyScore(), when evaluating which mask is best. - private static readonly PENALTY_N1: int = 3; - private static readonly PENALTY_N2: int = 3; - private static readonly PENALTY_N3: int = 40; - private static readonly PENALTY_N4: int = 10; - - private static readonly ECC_CODEWORDS_PER_BLOCK: Array> = [ - // Version: (note that index 0 is for padding, and is set to an illegal value) - //0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level - [-1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], // Low - [-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28], // Medium - [-1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], // Quartile - [-1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], // High - ]; - - private static readonly NUM_ERROR_CORRECTION_BLOCKS: Array> = [ - // Version: (note that index 0 is for padding, and is set to an illegal value) - //0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level - [-1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25], // Low - [-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49], // Medium - [-1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68], // Quartile - [-1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81], // High - ]; - - } - - - // Appends the given number of low-order bits of the given value - // to the given buffer. Requires 0 <= len <= 31 and 0 <= val < 2^len. - function appendBits(val: int, len: int, bb: Array): void { - if (len < 0 || len > 31 || val >>> len != 0) - throw new RangeError("Value out of range"); - for (let i = len - 1; i >= 0; i--) // Append bit by bit - bb.push((val >>> i) & 1); - } - - - // Returns true iff the i'th bit of x is set to 1. - function getBit(x: int, i: int): boolean { - return ((x >>> i) & 1) != 0; - } - - - // Throws an exception if the given condition is false. - function assert(cond: boolean): void { - if (!cond) - throw new Error("Assertion error"); - } - - - - /*---- Data segment class ----*/ - - /* - * A segment of character/binary/control data in a QR Code symbol. - * Instances of this class are immutable. - * The mid-level way to create a segment is to take the payload data - * and call a static factory function such as QrSegment.makeNumeric(). - * The low-level way to create a segment is to custom-make the bit buffer - * and call the QrSegment() constructor with appropriate values. - * This segment class imposes no length restrictions, but QR Codes have restrictions. - * Even in the most favorable conditions, a QR Code can only hold 7089 characters of data. - * Any segment longer than this is meaningless for the purpose of generating QR Codes. - */ - export class QrSegment { - - /*-- Static factory functions (mid level) --*/ - - // Returns a segment representing the given binary data encoded in - // byte mode. All input byte arrays are acceptable. Any text string - // can be converted to UTF-8 bytes and encoded as a byte mode segment. - public static makeBytes(data: Readonly>): QrSegment { - let bb: Array = [] - for (const b of data) - appendBits(b, 8, bb); - return new QrSegment(QrSegment.Mode.BYTE, data.length, bb); - } - - - // Returns a segment representing the given string of decimal digits encoded in numeric mode. - public static makeNumeric(digits: string): QrSegment { - if (!QrSegment.isNumeric(digits)) - throw new RangeError("String contains non-numeric characters"); - let bb: Array = [] - for (let i = 0; i < digits.length; ) { // Consume up to 3 digits per iteration - const n: int = Math.min(digits.length - i, 3); - appendBits(parseInt(digits.substring(i, i + n), 10), n * 3 + 1, bb); - i += n; - } - return new QrSegment(QrSegment.Mode.NUMERIC, digits.length, bb); - } - - - // Returns a segment representing the given text string encoded in alphanumeric mode. - // The characters allowed are: 0 to 9, A to Z (uppercase only), space, - // dollar, percent, asterisk, plus, hyphen, period, slash, colon. - public static makeAlphanumeric(text: string): QrSegment { - if (!QrSegment.isAlphanumeric(text)) - throw new RangeError("String contains unencodable characters in alphanumeric mode"); - let bb: Array = [] - let i: int; - for (i = 0; i + 2 <= text.length; i += 2) { // Process groups of 2 - let temp: int = QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)) * 45; - temp += QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i + 1)); - appendBits(temp, 11, bb); - } - if (i < text.length) // 1 character remaining - appendBits(QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)), 6, bb); - return new QrSegment(QrSegment.Mode.ALPHANUMERIC, text.length, bb); - } - - - // Returns a new mutable list of zero or more segments to represent the given Unicode text string. - // The result may use various segment modes and switch modes to optimize the length of the bit stream. - public static makeSegments(text: string): Array { - // Select the most efficient segment encoding automatically - if (text == "") - return []; - else if (QrSegment.isNumeric(text)) - return [QrSegment.makeNumeric(text)]; - else if (QrSegment.isAlphanumeric(text)) - return [QrSegment.makeAlphanumeric(text)]; - else - return [QrSegment.makeBytes(QrSegment.toUtf8ByteArray(text))]; - } - - - // Returns a segment representing an Extended Channel Interpretation - // (ECI) designator with the given assignment value. - public static makeEci(assignVal: int): QrSegment { - let bb: Array = [] - if (assignVal < 0) - throw new RangeError("ECI assignment value out of range"); - else if (assignVal < (1 << 7)) - appendBits(assignVal, 8, bb); - else if (assignVal < (1 << 14)) { - appendBits(0b10, 2, bb); - appendBits(assignVal, 14, bb); - } else if (assignVal < 1000000) { - appendBits(0b110, 3, bb); - appendBits(assignVal, 21, bb); - } else - throw new RangeError("ECI assignment value out of range"); - return new QrSegment(QrSegment.Mode.ECI, 0, bb); - } - - - // Tests whether the given string can be encoded as a segment in numeric mode. - // A string is encodable iff each character is in the range 0 to 9. - public static isNumeric(text: string): boolean { - return QrSegment.NUMERIC_REGEX.test(text); - } - - - // Tests whether the given string can be encoded as a segment in alphanumeric mode. - // A string is encodable iff each character is in the following set: 0 to 9, A to Z - // (uppercase only), space, dollar, percent, asterisk, plus, hyphen, period, slash, colon. - public static isAlphanumeric(text: string): boolean { - return QrSegment.ALPHANUMERIC_REGEX.test(text); - } - - - /*-- Constructor (low level) and fields --*/ - - // Creates a new QR Code segment with the given attributes and data. - // The character count (numChars) must agree with the mode and the bit buffer length, - // but the constraint isn't checked. The given bit buffer is cloned and stored. - public constructor( - // The mode indicator of this segment. - public readonly mode: QrSegment.Mode, - - // The length of this segment's unencoded data. Measured in characters for - // numeric/alphanumeric/kanji mode, bytes for byte mode, and 0 for ECI mode. - // Always zero or positive. Not the same as the data's bit length. - public readonly numChars: int, - - // The data bits of this segment. Accessed through getData(). - private readonly bitData: Array) { - - if (numChars < 0) - throw new RangeError("Invalid argument"); - this.bitData = bitData.slice(); // Make defensive copy - } - - - /*-- Methods --*/ - - // Returns a new copy of the data bits of this segment. - public getData(): Array { - return this.bitData.slice(); // Make defensive copy - } - - - // (Package-private) Calculates and returns the number of bits needed to encode the given segments at - // the given version. The result is infinity if a segment has too many characters to fit its length field. - public static getTotalBits(segs: Readonly>, version: int): number { - let result: number = 0; - for (const seg of segs) { - const ccbits: int = seg.mode.numCharCountBits(version); - if (seg.numChars >= (1 << ccbits)) - return Infinity; // The segment's length doesn't fit the field's bit width - result += 4 + ccbits + seg.bitData.length; - } - return result; - } - - - // Returns a new array of bytes representing the given string encoded in UTF-8. - private static toUtf8ByteArray(str: string): Array { - str = encodeURI(str); - let result: Array = []; - for (let i = 0; i < str.length; i++) { - if (str.charAt(i) != "%") - result.push(str.charCodeAt(i)); - else { - result.push(parseInt(str.substring(i + 1, i + 3), 16)); - i += 2; - } - } - return result; - } - - - /*-- Constants --*/ - - // Describes precisely all strings that are encodable in numeric mode. - private static readonly NUMERIC_REGEX: RegExp = /^[0-9]*$/; - - // Describes precisely all strings that are encodable in alphanumeric mode. - private static readonly ALPHANUMERIC_REGEX: RegExp = /^[A-Z0-9 $%*+.\/:-]*$/; - - // The set of all legal characters in alphanumeric mode, - // where each character value maps to the index in the string. - private static readonly ALPHANUMERIC_CHARSET: string = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:"; - - } - -} - - - -/*---- Public helper enumeration ----*/ - -namespace qrcodegen.QrCode { - - type int = number; - - - /* - * The error correction level in a QR Code symbol. Immutable. - */ - export class Ecc { - - /*-- Constants --*/ - - public static readonly LOW = new Ecc(0, 1); // The QR Code can tolerate about 7% erroneous codewords - public static readonly MEDIUM = new Ecc(1, 0); // The QR Code can tolerate about 15% erroneous codewords - public static readonly QUARTILE = new Ecc(2, 3); // The QR Code can tolerate about 25% erroneous codewords - public static readonly HIGH = new Ecc(3, 2); // The QR Code can tolerate about 30% erroneous codewords - - - /*-- Constructor and fields --*/ - - private constructor( - // In the range 0 to 3 (unsigned 2-bit integer). - public readonly ordinal: int, - // (Package-private) In the range 0 to 3 (unsigned 2-bit integer). - public readonly formatBits: int) {} - - } -} - - - -/*---- Public helper enumeration ----*/ - -namespace qrcodegen.QrSegment { - - type int = number; - - - /* - * Describes how a segment's data bits are interpreted. Immutable. - */ - export class Mode { - - /*-- Constants --*/ - - public static readonly NUMERIC = new Mode(0x1, [10, 12, 14]); - public static readonly ALPHANUMERIC = new Mode(0x2, [ 9, 11, 13]); - public static readonly BYTE = new Mode(0x4, [ 8, 16, 16]); - public static readonly KANJI = new Mode(0x8, [ 8, 10, 12]); - public static readonly ECI = new Mode(0x7, [ 0, 0, 0]); - - - /*-- Constructor and fields --*/ - - private constructor( - // The mode indicator bits, which is a uint4 value (range 0 to 15). - public readonly modeBits: int, - // Number of character count bits for three different version ranges. - private readonly numBitsCharCount: [int,int,int]) {} - - - /*-- Method --*/ - - // (Package-private) Returns the bit width of the character count field for a segment in - // this mode in a QR Code at the given version number. The result is in the range [0, 16]. - public numCharCountBits(ver: int): int { - return this.numBitsCharCount[Math.floor((ver + 7) / 17)]; - } - - } -} - -export const QrCode = qrcodegen.QrCode; -export const QrSegment = qrcodegen.QrSegment; diff --git a/packages/shared/package.json b/packages/shared/package.json index b71d52d70026e3e2d093c7960ba7d30a715a7b9a..bc103dab71be7d91777f92f6024ac81c62d6d709 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -51,6 +51,10 @@ "./projectScripts": { "types": "./src/projectScripts.ts", "import": "./src/projectScripts.ts" + }, + "./qrCode": { + "types": "./src/qrCode.ts", + "import": "./src/qrCode.ts" } }, "scripts": { diff --git a/packages/shared/src/qrCode.ts b/packages/shared/src/qrCode.ts new file mode 100644 index 0000000000000000000000000000000000000000..678d38c11141571c79b569f7afba51ddd7dc77bb --- /dev/null +++ b/packages/shared/src/qrCode.ts @@ -0,0 +1,984 @@ +// @ts-nocheck +/* oxlint-disable eslint/no-useless-escape */ +/* + * QR Code generator library (TypeScript) + * + * Copyright (c) Project Nayuki. (MIT License) + * https://www.nayuki.io/page/qr-code-generator-library + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * - The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * - The Software is provided "as is", without warranty of any kind, express or + * implied, including but not limited to the warranties of merchantability, + * fitness for a particular purpose and noninfringement. In no event shall the + * authors or copyright holders be liable for any claim, damages or other + * liability, whether in an action of contract, tort or otherwise, arising from, + * out of or in connection with the Software or the use or other dealings in the + * Software. + */ + +"use strict"; + +namespace qrcodegen { + type bit = number; + type byte = number; + type int = number; + + /*---- QR Code symbol class ----*/ + + /* + * A QR Code symbol, which is a type of two-dimension barcode. + * Invented by Denso Wave and described in the ISO/IEC 18004 standard. + * Instances of this class represent an immutable square grid of dark and light cells. + * The class provides static factory functions to create a QR Code from text or binary data. + * The class covers the QR Code Model 2 specification, supporting all versions (sizes) + * from 1 to 40, all 4 error correction levels, and 4 character encoding modes. + * + * Ways to create a QR Code object: + * - High level: Take the payload data and call QrCode.encodeText() or QrCode.encodeBinary(). + * - Mid level: Custom-make the list of segments and call QrCode.encodeSegments(). + * - Low level: Custom-make the array of data codeword bytes (including + * segment headers and final padding, excluding error correction codewords), + * supply the appropriate version number, and call the QrCode() constructor. + * (Note that all ways require supplying the desired error correction level.) + */ + export class QrCode { + /*-- Static factory functions (high level) --*/ + + // Returns a QR Code representing the given Unicode text string at the given error correction level. + // As a conservative upper bound, this function is guaranteed to succeed for strings that have 738 or fewer + // Unicode code points (not UTF-16 code units) if the low error correction level is used. The smallest possible + // QR Code version is automatically chosen for the output. The ECC level of the result may be higher than the + // ecl argument if it can be done without increasing the version. + public static encodeText(text: string, ecl: QrCode.Ecc): QrCode { + const segs: Array = qrcodegen.QrSegment.makeSegments(text); + return QrCode.encodeSegments(segs, ecl); + } + + // Returns a QR Code representing the given binary data at the given error correction level. + // This function always encodes using the binary segment mode, not any text mode. The maximum number of + // bytes allowed is 2953. The smallest possible QR Code version is automatically chosen for the output. + // The ECC level of the result may be higher than the ecl argument if it can be done without increasing the version. + public static encodeBinary(data: Readonly>, ecl: QrCode.Ecc): QrCode { + const seg: QrSegment = qrcodegen.QrSegment.makeBytes(data); + return QrCode.encodeSegments([seg], ecl); + } + + /*-- Static factory functions (mid level) --*/ + + // Returns a QR Code representing the given segments with the given encoding parameters. + // The smallest possible QR Code version within the given range is automatically + // chosen for the output. Iff boostEcl is true, then the ECC level of the result + // may be higher than the ecl argument if it can be done without increasing the + // version. The mask number is either between 0 to 7 (inclusive) to force that + // mask, or -1 to automatically choose an appropriate mask (which may be slow). + // This function allows the user to create a custom sequence of segments that switches + // between modes (such as alphanumeric and byte) to encode text in less space. + // This is a mid-level API; the high-level API is encodeText() and encodeBinary(). + public static encodeSegments( + segs: Readonly>, + ecl: QrCode.Ecc, + minVersion: int = 1, + maxVersion: int = 40, + mask: int = -1, + boostEcl: boolean = true, + ): QrCode { + if ( + !( + QrCode.MIN_VERSION <= minVersion && + minVersion <= maxVersion && + maxVersion <= QrCode.MAX_VERSION + ) || + mask < -1 || + mask > 7 + ) + throw new RangeError("Invalid value"); + + // Find the minimal version number to use + let version: int; + let dataUsedBits: int; + for (version = minVersion; ; version++) { + const dataCapacityBits: int = QrCode.getNumDataCodewords(version, ecl) * 8; // Number of data bits available + const usedBits: number = QrSegment.getTotalBits(segs, version); + if (usedBits <= dataCapacityBits) { + dataUsedBits = usedBits; + break; // This version number is found to be suitable + } + if (version >= maxVersion) + // All versions in the range could not fit the given data + throw new RangeError("Data too long"); + } + + // Increase the error correction level while the data still fits in the current version number + for (const newEcl of [QrCode.Ecc.MEDIUM, QrCode.Ecc.QUARTILE, QrCode.Ecc.HIGH]) { + // From low to high + if (boostEcl && dataUsedBits <= QrCode.getNumDataCodewords(version, newEcl) * 8) + ecl = newEcl; + } + + // Concatenate all segments to create the data bit string + let bb: Array = []; + for (const seg of segs) { + appendBits(seg.mode.modeBits, 4, bb); + appendBits(seg.numChars, seg.mode.numCharCountBits(version), bb); + for (const b of seg.getData()) bb.push(b); + } + assert(bb.length == dataUsedBits); + + // Add terminator and pad up to a byte if applicable + const dataCapacityBits: int = QrCode.getNumDataCodewords(version, ecl) * 8; + assert(bb.length <= dataCapacityBits); + appendBits(0, Math.min(4, dataCapacityBits - bb.length), bb); + appendBits(0, (8 - (bb.length % 8)) % 8, bb); + assert(bb.length % 8 == 0); + + // Pad with alternating bytes until data capacity is reached + for (let padByte = 0xec; bb.length < dataCapacityBits; padByte ^= 0xec ^ 0x11) + appendBits(padByte, 8, bb); + + // Pack bits into bytes in big endian + let dataCodewords: Array = []; + while (dataCodewords.length * 8 < bb.length) dataCodewords.push(0); + bb.forEach((b: bit, i: int) => (dataCodewords[i >>> 3] |= b << (7 - (i & 7)))); + + // Create the QR Code object + return new QrCode(version, ecl, dataCodewords, mask); + } + + /*-- Fields --*/ + + // The width and height of this QR Code, measured in modules, between + // 21 and 177 (inclusive). This is equal to version * 4 + 17. + public readonly size: int; + + // The index of the mask pattern used in this QR Code, which is between 0 and 7 (inclusive). + // Even if a QR Code is created with automatic masking requested (mask = -1), + // the resulting object still has a mask value between 0 and 7. + public readonly mask: int; + + // The modules of this QR Code (false = light, true = dark). + // Immutable after constructor finishes. Accessed through getModule(). + private readonly modules: Array> = []; + + // Indicates function modules that are not subjected to masking. Discarded when constructor finishes. + private readonly isFunction: Array> = []; + + /*-- Constructor (low level) and fields --*/ + + // Creates a new QR Code with the given version number, + // error correction level, data codeword bytes, and mask number. + // This is a low-level API that most users should not use directly. + // A mid-level API is the encodeSegments() function. + public constructor( + // The version number of this QR Code, which is between 1 and 40 (inclusive). + // This determines the size of this barcode. + public readonly version: int, + + // The error correction level used in this QR Code. + public readonly errorCorrectionLevel: QrCode.Ecc, + + dataCodewords: Readonly>, + + msk: int, + ) { + // Check scalar arguments + if (version < QrCode.MIN_VERSION || version > QrCode.MAX_VERSION) + throw new RangeError("Version value out of range"); + if (msk < -1 || msk > 7) throw new RangeError("Mask value out of range"); + this.size = version * 4 + 17; + + // Initialize both grids to be size*size arrays of Boolean false + let row: Array = []; + for (let i = 0; i < this.size; i++) row.push(false); + for (let i = 0; i < this.size; i++) { + this.modules.push(row.slice()); // Initially all light + this.isFunction.push(row.slice()); + } + + // Compute ECC, draw modules + this.drawFunctionPatterns(); + const allCodewords: Array = this.addEccAndInterleave(dataCodewords); + this.drawCodewords(allCodewords); + + // Do masking + if (msk == -1) { + // Automatically choose best mask + let minPenalty: int = 1000000000; + for (let i = 0; i < 8; i++) { + this.applyMask(i); + this.drawFormatBits(i); + const penalty: int = this.getPenaltyScore(); + if (penalty < minPenalty) { + msk = i; + minPenalty = penalty; + } + this.applyMask(i); // Undoes the mask due to XOR + } + } + assert(0 <= msk && msk <= 7); + this.mask = msk; + this.applyMask(msk); // Apply the final choice of mask + this.drawFormatBits(msk); // Overwrite old format bits + + this.isFunction = []; + } + + /*-- Accessor methods --*/ + + // Returns the color of the module (pixel) at the given coordinates, which is false + // for light or true for dark. The top left corner has the coordinates (x=0, y=0). + // If the given coordinates are out of bounds, then false (light) is returned. + public getModule(x: int, y: int): boolean { + return 0 <= x && x < this.size && 0 <= y && y < this.size && this.modules[y][x]; + } + + /*-- Private helper methods for constructor: Drawing function modules --*/ + + // Reads this object's version field, and draws and marks all function modules. + private drawFunctionPatterns(): void { + // Draw horizontal and vertical timing patterns + for (let i = 0; i < this.size; i++) { + this.setFunctionModule(6, i, i % 2 == 0); + this.setFunctionModule(i, 6, i % 2 == 0); + } + + // Draw 3 finder patterns (all corners except bottom right; overwrites some timing modules) + this.drawFinderPattern(3, 3); + this.drawFinderPattern(this.size - 4, 3); + this.drawFinderPattern(3, this.size - 4); + + // Draw numerous alignment patterns + const alignPatPos: Array = this.getAlignmentPatternPositions(); + const numAlign: int = alignPatPos.length; + for (let i = 0; i < numAlign; i++) { + for (let j = 0; j < numAlign; j++) { + // Don't draw on the three finder corners + if ( + !((i == 0 && j == 0) || (i == 0 && j == numAlign - 1) || (i == numAlign - 1 && j == 0)) + ) + this.drawAlignmentPattern(alignPatPos[i], alignPatPos[j]); + } + } + + // Draw configuration data + this.drawFormatBits(0); // Dummy mask value; overwritten later in the constructor + this.drawVersion(); + } + + // Draws two copies of the format bits (with its own error correction code) + // based on the given mask and this object's error correction level field. + private drawFormatBits(mask: int): void { + // Calculate error correction code and pack bits + const data: int = (this.errorCorrectionLevel.formatBits << 3) | mask; // errCorrLvl is uint2, mask is uint3 + let rem: int = data; + for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537); + const bits = ((data << 10) | rem) ^ 0x5412; // uint15 + assert(bits >>> 15 == 0); + + // Draw first copy + for (let i = 0; i <= 5; i++) this.setFunctionModule(8, i, getBit(bits, i)); + this.setFunctionModule(8, 7, getBit(bits, 6)); + this.setFunctionModule(8, 8, getBit(bits, 7)); + this.setFunctionModule(7, 8, getBit(bits, 8)); + for (let i = 9; i < 15; i++) this.setFunctionModule(14 - i, 8, getBit(bits, i)); + + // Draw second copy + for (let i = 0; i < 8; i++) this.setFunctionModule(this.size - 1 - i, 8, getBit(bits, i)); + for (let i = 8; i < 15; i++) this.setFunctionModule(8, this.size - 15 + i, getBit(bits, i)); + this.setFunctionModule(8, this.size - 8, true); // Always dark + } + + // Draws two copies of the version bits (with its own error correction code), + // based on this object's version field, iff 7 <= version <= 40. + private drawVersion(): void { + if (this.version < 7) return; + + // Calculate error correction code and pack bits + let rem: int = this.version; // version is uint6, in the range [7, 40] + for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25); + const bits: int = (this.version << 12) | rem; // uint18 + assert(bits >>> 18 == 0); + + // Draw two copies + for (let i = 0; i < 18; i++) { + const color: boolean = getBit(bits, i); + const a: int = this.size - 11 + (i % 3); + const b: int = Math.floor(i / 3); + this.setFunctionModule(a, b, color); + this.setFunctionModule(b, a, color); + } + } + + // Draws a 9*9 finder pattern including the border separator, + // with the center module at (x, y). Modules can be out of bounds. + private drawFinderPattern(x: int, y: int): void { + for (let dy = -4; dy <= 4; dy++) { + for (let dx = -4; dx <= 4; dx++) { + const dist: int = Math.max(Math.abs(dx), Math.abs(dy)); // Chebyshev/infinity norm + const xx: int = x + dx; + const yy: int = y + dy; + if (0 <= xx && xx < this.size && 0 <= yy && yy < this.size) + this.setFunctionModule(xx, yy, dist != 2 && dist != 4); + } + } + } + + // Draws a 5*5 alignment pattern, with the center module + // at (x, y). All modules must be in bounds. + private drawAlignmentPattern(x: int, y: int): void { + for (let dy = -2; dy <= 2; dy++) { + for (let dx = -2; dx <= 2; dx++) + this.setFunctionModule(x + dx, y + dy, Math.max(Math.abs(dx), Math.abs(dy)) != 1); + } + } + + // Sets the color of a module and marks it as a function module. + // Only used by the constructor. Coordinates must be in bounds. + private setFunctionModule(x: int, y: int, isDark: boolean): void { + this.modules[y][x] = isDark; + this.isFunction[y][x] = true; + } + + /*-- Private helper methods for constructor: Codewords and masking --*/ + + // Returns a new byte string representing the given data with the appropriate error correction + // codewords appended to it, based on this object's version and error correction level. + private addEccAndInterleave(data: Readonly>): Array { + const ver: int = this.version; + const ecl: QrCode.Ecc = this.errorCorrectionLevel; + if (data.length != QrCode.getNumDataCodewords(ver, ecl)) + throw new RangeError("Invalid argument"); + + // Calculate parameter numbers + const numBlocks: int = QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver]; + const blockEccLen: int = QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal][ver]; + const rawCodewords: int = Math.floor(QrCode.getNumRawDataModules(ver) / 8); + const numShortBlocks: int = numBlocks - (rawCodewords % numBlocks); + const shortBlockLen: int = Math.floor(rawCodewords / numBlocks); + + // Split data into blocks and append ECC to each block + let blocks: Array> = []; + const rsDiv: Array = QrCode.reedSolomonComputeDivisor(blockEccLen); + for (let i = 0, k = 0; i < numBlocks; i++) { + let dat: Array = data.slice( + k, + k + shortBlockLen - blockEccLen + (i < numShortBlocks ? 0 : 1), + ); + k += dat.length; + const ecc: Array = QrCode.reedSolomonComputeRemainder(dat, rsDiv); + if (i < numShortBlocks) dat.push(0); + blocks.push(dat.concat(ecc)); + } + + // Interleave (not concatenate) the bytes from every block into a single sequence + let result: Array = []; + for (let i = 0; i < blocks[0].length; i++) { + blocks.forEach((block, j) => { + // Skip the padding byte in short blocks + if (i != shortBlockLen - blockEccLen || j >= numShortBlocks) result.push(block[i]); + }); + } + assert(result.length == rawCodewords); + return result; + } + + // Draws the given sequence of 8-bit codewords (data and error correction) onto the entire + // data area of this QR Code. Function modules need to be marked off before this is called. + private drawCodewords(data: Readonly>): void { + if (data.length != Math.floor(QrCode.getNumRawDataModules(this.version) / 8)) + throw new RangeError("Invalid argument"); + let i: int = 0; // Bit index into the data + // Do the funny zigzag scan + for (let right = this.size - 1; right >= 1; right -= 2) { + // Index of right column in each column pair + if (right == 6) right = 5; + for (let vert = 0; vert < this.size; vert++) { + // Vertical counter + for (let j = 0; j < 2; j++) { + const x: int = right - j; // Actual x coordinate + const upward: boolean = ((right + 1) & 2) == 0; + const y: int = upward ? this.size - 1 - vert : vert; // Actual y coordinate + if (!this.isFunction[y][x] && i < data.length * 8) { + this.modules[y][x] = getBit(data[i >>> 3], 7 - (i & 7)); + i++; + } + // If this QR Code has any remainder bits (0 to 7), they were assigned as + // 0/false/light by the constructor and are left unchanged by this method + } + } + } + assert(i == data.length * 8); + } + + // XORs the codeword modules in this QR Code with the given mask pattern. + // The function modules must be marked and the codeword bits must be drawn + // before masking. Due to the arithmetic of XOR, calling applyMask() with + // the same mask value a second time will undo the mask. A final well-formed + // QR Code needs exactly one (not zero, two, etc.) mask applied. + private applyMask(mask: int): void { + if (mask < 0 || mask > 7) throw new RangeError("Mask value out of range"); + for (let y = 0; y < this.size; y++) { + for (let x = 0; x < this.size; x++) { + let invert: boolean; + switch (mask) { + case 0: + invert = (x + y) % 2 == 0; + break; + case 1: + invert = y % 2 == 0; + break; + case 2: + invert = x % 3 == 0; + break; + case 3: + invert = (x + y) % 3 == 0; + break; + case 4: + invert = (Math.floor(x / 3) + Math.floor(y / 2)) % 2 == 0; + break; + case 5: + invert = ((x * y) % 2) + ((x * y) % 3) == 0; + break; + case 6: + invert = (((x * y) % 2) + ((x * y) % 3)) % 2 == 0; + break; + case 7: + invert = (((x + y) % 2) + ((x * y) % 3)) % 2 == 0; + break; + default: + throw new Error("Unreachable"); + } + if (!this.isFunction[y][x] && invert) this.modules[y][x] = !this.modules[y][x]; + } + } + } + + // Calculates and returns the penalty score based on state of this QR Code's current modules. + // This is used by the automatic mask choice algorithm to find the mask pattern that yields the lowest score. + private getPenaltyScore(): int { + let result: int = 0; + + // Adjacent modules in row having same color, and finder-like patterns + for (let y = 0; y < this.size; y++) { + let runColor = false; + let runX = 0; + let runHistory = [0, 0, 0, 0, 0, 0, 0]; + for (let x = 0; x < this.size; x++) { + if (this.modules[y][x] == runColor) { + runX++; + if (runX == 5) result += QrCode.PENALTY_N1; + else if (runX > 5) result++; + } else { + this.finderPenaltyAddHistory(runX, runHistory); + if (!runColor) + result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3; + runColor = this.modules[y][x]; + runX = 1; + } + } + result += + this.finderPenaltyTerminateAndCount(runColor, runX, runHistory) * QrCode.PENALTY_N3; + } + // Adjacent modules in column having same color, and finder-like patterns + for (let x = 0; x < this.size; x++) { + let runColor = false; + let runY = 0; + let runHistory = [0, 0, 0, 0, 0, 0, 0]; + for (let y = 0; y < this.size; y++) { + if (this.modules[y][x] == runColor) { + runY++; + if (runY == 5) result += QrCode.PENALTY_N1; + else if (runY > 5) result++; + } else { + this.finderPenaltyAddHistory(runY, runHistory); + if (!runColor) + result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3; + runColor = this.modules[y][x]; + runY = 1; + } + } + result += + this.finderPenaltyTerminateAndCount(runColor, runY, runHistory) * QrCode.PENALTY_N3; + } + + // 2*2 blocks of modules having same color + for (let y = 0; y < this.size - 1; y++) { + for (let x = 0; x < this.size - 1; x++) { + const color: boolean = this.modules[y][x]; + if ( + color == this.modules[y][x + 1] && + color == this.modules[y + 1][x] && + color == this.modules[y + 1][x + 1] + ) + result += QrCode.PENALTY_N2; + } + } + + // Balance of dark and light modules + let dark: int = 0; + for (const row of this.modules) + dark = row.reduce((sum, color) => sum + (color ? 1 : 0), dark); + const total: int = this.size * this.size; // Note that size is odd, so dark/total != 1/2 + // Compute the smallest integer k >= 0 such that (45-5k)% <= dark/total <= (55+5k)% + const k: int = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1; + assert(0 <= k && k <= 9); + result += k * QrCode.PENALTY_N4; + assert(0 <= result && result <= 2568888); // Non-tight upper bound based on default values of PENALTY_N1, ..., N4 + return result; + } + + /*-- Private helper functions --*/ + + // Returns an ascending list of positions of alignment patterns for this version number. + // Each position is in the range [0,177), and are used on both the x and y axes. + // This could be implemented as lookup table of 40 variable-length lists of integers. + private getAlignmentPatternPositions(): Array { + if (this.version == 1) return []; + else { + const numAlign: int = Math.floor(this.version / 7) + 2; + const step: int = + this.version == 32 ? 26 : Math.ceil((this.version * 4 + 4) / (numAlign * 2 - 2)) * 2; + let result: Array = [6]; + for (let pos = this.size - 7; result.length < numAlign; pos -= step) + result.splice(1, 0, pos); + return result; + } + } + + // Returns the number of data bits that can be stored in a QR Code of the given version number, after + // all function modules are excluded. This includes remainder bits, so it might not be a multiple of 8. + // The result is in the range [208, 29648]. This could be implemented as a 40-entry lookup table. + private static getNumRawDataModules(ver: int): int { + if (ver < QrCode.MIN_VERSION || ver > QrCode.MAX_VERSION) + throw new RangeError("Version number out of range"); + let result: int = (16 * ver + 128) * ver + 64; + if (ver >= 2) { + const numAlign: int = Math.floor(ver / 7) + 2; + result -= (25 * numAlign - 10) * numAlign - 55; + if (ver >= 7) result -= 36; + } + assert(208 <= result && result <= 29648); + return result; + } + + // Returns the number of 8-bit data (i.e. not error correction) codewords contained in any + // QR Code of the given version number and error correction level, with remainder bits discarded. + // This stateless pure function could be implemented as a (40*4)-cell lookup table. + private static getNumDataCodewords(ver: int, ecl: QrCode.Ecc): int { + return ( + Math.floor(QrCode.getNumRawDataModules(ver) / 8) - + QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal][ver] * + QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver] + ); + } + + // Returns a Reed-Solomon ECC generator polynomial for the given degree. This could be + // implemented as a lookup table over all possible parameter values, instead of as an algorithm. + private static reedSolomonComputeDivisor(degree: int): Array { + if (degree < 1 || degree > 255) throw new RangeError("Degree out of range"); + // Polynomial coefficients are stored from highest to lowest power, excluding the leading term which is always 1. + // For example the polynomial x^3 + 255x^2 + 8x + 93 is stored as the uint8 array [255, 8, 93]. + let result: Array = []; + for (let i = 0; i < degree - 1; i++) result.push(0); + result.push(1); // Start off with the monomial x^0 + + // Compute the product polynomial (x - r^0) * (x - r^1) * (x - r^2) * ... * (x - r^{degree-1}), + // and drop the highest monomial term which is always 1x^degree. + // Note that r = 0x02, which is a generator element of this field GF(2^8/0x11D). + let root = 1; + for (let i = 0; i < degree; i++) { + // Multiply the current product by (x - r^i) + for (let j = 0; j < result.length; j++) { + result[j] = QrCode.reedSolomonMultiply(result[j], root); + if (j + 1 < result.length) result[j] ^= result[j + 1]; + } + root = QrCode.reedSolomonMultiply(root, 0x02); + } + return result; + } + + // Returns the Reed-Solomon error correction codeword for the given data and divisor polynomials. + private static reedSolomonComputeRemainder( + data: Readonly>, + divisor: Readonly>, + ): Array { + let result: Array = divisor.map((_) => 0); + for (const b of data) { + // Polynomial division + const factor: byte = b ^ (result.shift() as byte); + result.push(0); + divisor.forEach((coef, i) => (result[i] ^= QrCode.reedSolomonMultiply(coef, factor))); + } + return result; + } + + // Returns the product of the two given field elements modulo GF(2^8/0x11D). The arguments and result + // are unsigned 8-bit integers. This could be implemented as a lookup table of 256*256 entries of uint8. + private static reedSolomonMultiply(x: byte, y: byte): byte { + if (x >>> 8 != 0 || y >>> 8 != 0) throw new RangeError("Byte out of range"); + // Russian peasant multiplication + let z: int = 0; + for (let i = 7; i >= 0; i--) { + z = (z << 1) ^ ((z >>> 7) * 0x11d); + z ^= ((y >>> i) & 1) * x; + } + assert(z >>> 8 == 0); + return z as byte; + } + + // Can only be called immediately after a light run is added, and + // returns either 0, 1, or 2. A helper function for getPenaltyScore(). + private finderPenaltyCountPatterns(runHistory: Readonly>): int { + const n: int = runHistory[1]; + assert(n <= this.size * 3); + const core: boolean = + n > 0 && + runHistory[2] == n && + runHistory[3] == n * 3 && + runHistory[4] == n && + runHistory[5] == n; + return ( + (core && runHistory[0] >= n * 4 && runHistory[6] >= n ? 1 : 0) + + (core && runHistory[6] >= n * 4 && runHistory[0] >= n ? 1 : 0) + ); + } + + // Must be called at the end of a line (row or column) of modules. A helper function for getPenaltyScore(). + private finderPenaltyTerminateAndCount( + currentRunColor: boolean, + currentRunLength: int, + runHistory: Array, + ): int { + if (currentRunColor) { + // Terminate dark run + this.finderPenaltyAddHistory(currentRunLength, runHistory); + currentRunLength = 0; + } + currentRunLength += this.size; // Add light border to final run + this.finderPenaltyAddHistory(currentRunLength, runHistory); + return this.finderPenaltyCountPatterns(runHistory); + } + + // Pushes the given value to the front and drops the last value. A helper function for getPenaltyScore(). + private finderPenaltyAddHistory(currentRunLength: int, runHistory: Array): void { + if (runHistory[0] == 0) currentRunLength += this.size; // Add light border to initial run + runHistory.pop(); + runHistory.unshift(currentRunLength); + } + + /*-- Constants and tables --*/ + + // The minimum version number supported in the QR Code Model 2 standard. + public static readonly MIN_VERSION: int = 1; + // The maximum version number supported in the QR Code Model 2 standard. + public static readonly MAX_VERSION: int = 40; + + // For use in getPenaltyScore(), when evaluating which mask is best. + private static readonly PENALTY_N1: int = 3; + private static readonly PENALTY_N2: int = 3; + private static readonly PENALTY_N3: int = 40; + private static readonly PENALTY_N4: int = 10; + + private static readonly ECC_CODEWORDS_PER_BLOCK: Array> = [ + // Version: (note that index 0 is for padding, and is set to an illegal value) + //0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level + [ + -1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, + 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, + ], // Low + [ + -1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, + 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, + ], // Medium + [ + -1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, + 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, + ], // Quartile + [ + -1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, + 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, + ], // High + ]; + + private static readonly NUM_ERROR_CORRECTION_BLOCKS: Array> = [ + // Version: (note that index 0 is for padding, and is set to an illegal value) + //0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level + [ + -1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, + 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25, + ], // Low + [ + -1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, + 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49, + ], // Medium + [ + -1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, + 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68, + ], // Quartile + [ + -1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, + 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81, + ], // High + ]; + } + + // Appends the given number of low-order bits of the given value + // to the given buffer. Requires 0 <= len <= 31 and 0 <= val < 2^len. + function appendBits(val: int, len: int, bb: Array): void { + if (len < 0 || len > 31 || val >>> len != 0) throw new RangeError("Value out of range"); + for ( + let i = len - 1; + i >= 0; + i-- // Append bit by bit + ) + bb.push((val >>> i) & 1); + } + + // Returns true iff the i'th bit of x is set to 1. + function getBit(x: int, i: int): boolean { + return ((x >>> i) & 1) != 0; + } + + // Throws an exception if the given condition is false. + function assert(cond: boolean): void { + if (!cond) throw new Error("Assertion error"); + } + + /*---- Data segment class ----*/ + + /* + * A segment of character/binary/control data in a QR Code symbol. + * Instances of this class are immutable. + * The mid-level way to create a segment is to take the payload data + * and call a static factory function such as QrSegment.makeNumeric(). + * The low-level way to create a segment is to custom-make the bit buffer + * and call the QrSegment() constructor with appropriate values. + * This segment class imposes no length restrictions, but QR Codes have restrictions. + * Even in the most favorable conditions, a QR Code can only hold 7089 characters of data. + * Any segment longer than this is meaningless for the purpose of generating QR Codes. + */ + export class QrSegment { + /*-- Static factory functions (mid level) --*/ + + // Returns a segment representing the given binary data encoded in + // byte mode. All input byte arrays are acceptable. Any text string + // can be converted to UTF-8 bytes and encoded as a byte mode segment. + public static makeBytes(data: Readonly>): QrSegment { + let bb: Array = []; + for (const b of data) appendBits(b, 8, bb); + return new QrSegment(QrSegment.Mode.BYTE, data.length, bb); + } + + // Returns a segment representing the given string of decimal digits encoded in numeric mode. + public static makeNumeric(digits: string): QrSegment { + if (!QrSegment.isNumeric(digits)) + throw new RangeError("String contains non-numeric characters"); + let bb: Array = []; + for (let i = 0; i < digits.length; ) { + // Consume up to 3 digits per iteration + const n: int = Math.min(digits.length - i, 3); + appendBits(parseInt(digits.substring(i, i + n), 10), n * 3 + 1, bb); + i += n; + } + return new QrSegment(QrSegment.Mode.NUMERIC, digits.length, bb); + } + + // Returns a segment representing the given text string encoded in alphanumeric mode. + // The characters allowed are: 0 to 9, A to Z (uppercase only), space, + // dollar, percent, asterisk, plus, hyphen, period, slash, colon. + public static makeAlphanumeric(text: string): QrSegment { + if (!QrSegment.isAlphanumeric(text)) + throw new RangeError("String contains unencodable characters in alphanumeric mode"); + let bb: Array = []; + let i: int; + for (i = 0; i + 2 <= text.length; i += 2) { + // Process groups of 2 + let temp: int = QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)) * 45; + temp += QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i + 1)); + appendBits(temp, 11, bb); + } + if (i < text.length) + // 1 character remaining + appendBits(QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)), 6, bb); + return new QrSegment(QrSegment.Mode.ALPHANUMERIC, text.length, bb); + } + + // Returns a new mutable list of zero or more segments to represent the given Unicode text string. + // The result may use various segment modes and switch modes to optimize the length of the bit stream. + public static makeSegments(text: string): Array { + // Select the most efficient segment encoding automatically + if (text == "") return []; + else if (QrSegment.isNumeric(text)) return [QrSegment.makeNumeric(text)]; + else if (QrSegment.isAlphanumeric(text)) return [QrSegment.makeAlphanumeric(text)]; + else return [QrSegment.makeBytes(QrSegment.toUtf8ByteArray(text))]; + } + + // Returns a segment representing an Extended Channel Interpretation + // (ECI) designator with the given assignment value. + public static makeEci(assignVal: int): QrSegment { + let bb: Array = []; + if (assignVal < 0) throw new RangeError("ECI assignment value out of range"); + else if (assignVal < 1 << 7) appendBits(assignVal, 8, bb); + else if (assignVal < 1 << 14) { + appendBits(0b10, 2, bb); + appendBits(assignVal, 14, bb); + } else if (assignVal < 1000000) { + appendBits(0b110, 3, bb); + appendBits(assignVal, 21, bb); + } else throw new RangeError("ECI assignment value out of range"); + return new QrSegment(QrSegment.Mode.ECI, 0, bb); + } + + // Tests whether the given string can be encoded as a segment in numeric mode. + // A string is encodable iff each character is in the range 0 to 9. + public static isNumeric(text: string): boolean { + return QrSegment.NUMERIC_REGEX.test(text); + } + + // Tests whether the given string can be encoded as a segment in alphanumeric mode. + // A string is encodable iff each character is in the following set: 0 to 9, A to Z + // (uppercase only), space, dollar, percent, asterisk, plus, hyphen, period, slash, colon. + public static isAlphanumeric(text: string): boolean { + return QrSegment.ALPHANUMERIC_REGEX.test(text); + } + + /*-- Constructor (low level) and fields --*/ + + // Creates a new QR Code segment with the given attributes and data. + // The character count (numChars) must agree with the mode and the bit buffer length, + // but the constraint isn't checked. The given bit buffer is cloned and stored. + public constructor( + // The mode indicator of this segment. + public readonly mode: QrSegment.Mode, + + // The length of this segment's unencoded data. Measured in characters for + // numeric/alphanumeric/kanji mode, bytes for byte mode, and 0 for ECI mode. + // Always zero or positive. Not the same as the data's bit length. + public readonly numChars: int, + + // The data bits of this segment. Accessed through getData(). + private readonly bitData: Array, + ) { + if (numChars < 0) throw new RangeError("Invalid argument"); + this.bitData = bitData.slice(); // Make defensive copy + } + + /*-- Methods --*/ + + // Returns a new copy of the data bits of this segment. + public getData(): Array { + return this.bitData.slice(); // Make defensive copy + } + + // (Package-private) Calculates and returns the number of bits needed to encode the given segments at + // the given version. The result is infinity if a segment has too many characters to fit its length field. + public static getTotalBits(segs: Readonly>, version: int): number { + let result: number = 0; + for (const seg of segs) { + const ccbits: int = seg.mode.numCharCountBits(version); + if (seg.numChars >= 1 << ccbits) return Infinity; // The segment's length doesn't fit the field's bit width + result += 4 + ccbits + seg.bitData.length; + } + return result; + } + + // Returns a new array of bytes representing the given string encoded in UTF-8. + private static toUtf8ByteArray(str: string): Array { + str = encodeURI(str); + let result: Array = []; + for (let i = 0; i < str.length; i++) { + if (str.charAt(i) != "%") result.push(str.charCodeAt(i)); + else { + result.push(parseInt(str.substring(i + 1, i + 3), 16)); + i += 2; + } + } + return result; + } + + /*-- Constants --*/ + + // Describes precisely all strings that are encodable in numeric mode. + private static readonly NUMERIC_REGEX: RegExp = /^[0-9]*$/; + + // Describes precisely all strings that are encodable in alphanumeric mode. + private static readonly ALPHANUMERIC_REGEX: RegExp = /^[A-Z0-9 $%*+.\/:-]*$/; + + // The set of all legal characters in alphanumeric mode, + // where each character value maps to the index in the string. + private static readonly ALPHANUMERIC_CHARSET: string = + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:"; + } +} + +/*---- Public helper enumeration ----*/ + +namespace qrcodegen.QrCode { + type int = number; + + /* + * The error correction level in a QR Code symbol. Immutable. + */ + export class Ecc { + /*-- Constants --*/ + + public static readonly LOW = new Ecc(0, 1); // The QR Code can tolerate about 7% erroneous codewords + public static readonly MEDIUM = new Ecc(1, 0); // The QR Code can tolerate about 15% erroneous codewords + public static readonly QUARTILE = new Ecc(2, 3); // The QR Code can tolerate about 25% erroneous codewords + public static readonly HIGH = new Ecc(3, 2); // The QR Code can tolerate about 30% erroneous codewords + + /*-- Constructor and fields --*/ + + private constructor( + // In the range 0 to 3 (unsigned 2-bit integer). + public readonly ordinal: int, + // (Package-private) In the range 0 to 3 (unsigned 2-bit integer). + public readonly formatBits: int, + ) {} + } +} + +/*---- Public helper enumeration ----*/ + +namespace qrcodegen.QrSegment { + type int = number; + + /* + * Describes how a segment's data bits are interpreted. Immutable. + */ + export class Mode { + /*-- Constants --*/ + + public static readonly NUMERIC = new Mode(0x1, [10, 12, 14]); + public static readonly ALPHANUMERIC = new Mode(0x2, [9, 11, 13]); + public static readonly BYTE = new Mode(0x4, [8, 16, 16]); + public static readonly KANJI = new Mode(0x8, [8, 10, 12]); + public static readonly ECI = new Mode(0x7, [0, 0, 0]); + + /*-- Constructor and fields --*/ + + private constructor( + // The mode indicator bits, which is a uint4 value (range 0 to 15). + public readonly modeBits: int, + // Number of character count bits for three different version ranges. + private readonly numBitsCharCount: [int, int, int], + ) {} + + /*-- Method --*/ + + // (Package-private) Returns the bit width of the character count field for a segment in + // this mode in a QR Code at the given version number. The result is in the range [0, 16]. + public numCharCountBits(ver: int): int { + return this.numBitsCharCount[Math.floor((ver + 7) / 17)]; + } + } +} + +export const QrCode = qrcodegen.QrCode; +export const QrSegment = qrcodegen.QrSegment;