authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-10 00:12:52-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-10 00:12:52-07:00
logcf9f236c80aaa8ea4c3827d98362b752759d1ec6
tree98586ad047f67b20505022f9b4847cff545735ae
parente82b98738697e58644fef8d0836bf730c2b45613
signature Signed by PGP key B5690EEEBB952194

Add headless `t3 serve` pairing output (#1871)


20 files changed, 2355 insertions(+), 1173 deletions(-)

REMOTE.md+50-46
......@@ -1,68 +1,72 @@
1# Remote Access Setup
1# Remote Access
22
3Use this when you want to open T3 Code from another device (phone, tablet, another laptop).
3Use this when you want to connect to a T3 Code server from another device such as a phone, tablet, or separate desktop app.
44
5## CLI ↔ Env option map
5## Recommended Setup
66
7The T3 Code CLI accepts the following configuration options, available either as CLI flags or environment variables:
7Use a trusted private network that meshes your devices together, such as a tailnet.
88
9| CLI flag | Env var | Notes |
10| ----------------------- | --------------------- | ------------------------------------------------------------------------------------ |
11| `--mode <web\|desktop>` | `T3CODE_MODE` | Runtime mode. |
12| `--port <number>` | `T3CODE_PORT` | HTTP/WebSocket port. |
13| `--host <address>` | `T3CODE_HOST` | Bind interface/address. |
14| `--base-dir <path>` | `T3CODE_HOME` | Base directory. |
15| `--dev-url <url>` | `VITE_DEV_SERVER_URL` | Dev web URL redirect/proxy target. |
16| `--no-browser` | `T3CODE_NO_BROWSER` | Disable auto-open browser. |
17| `--auth-token <token>` | `T3CODE_AUTH_TOKEN` | WebSocket auth token. Use this for standard CLI and remote-server flows. |
18| `--bootstrap-fd <fd>` | `T3CODE_BOOTSTRAP_FD` | Read a one-shot bootstrap envelope from an inherited file descriptor during startup. |
9That gives you:
1910
20> TIP: Use the `--help` flag to see all available options and their descriptions.
11- a stable address to connect to
12- transport security at the network layer
13- less exposure than opening the server to the public internet
2114
22## Security First
15## Headless Server Flow
2316
24- Always set `--auth-token` before exposing the server outside localhost.
25 - When you control the process launcher, prefer sending the auth token in a JSON envelope via `--bootstrap-fd <fd>`.
26 With `--bootstrap-fd <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.
27- Treat the token like a password.
28- Prefer binding to trusted interfaces (LAN IP or Tailnet IP) instead of opening all interfaces unless needed.
29
30## 1) Build + run server for remote access
31
32Remote access should use the built web app (not local Vite redirect mode).
17Run the server with `t3 serve`.
3318
3419```bash
35bun run build
36TOKEN="$(openssl rand -hex 24)"
37bun run --cwd apps/server start -- --host 0.0.0.0 --port 3773 --auth-token "$TOKEN" --no-browser
20npx t3 serve --host "$(tailscale ip -4)"
3821```
3922
40Then open on your phone:
23`t3 serve` starts the server without opening a browser and prints:
4124
42`http://<your-machine-ip>:3773`
25- a connection string
26- a pairing token
27- a pairing URL
28- a QR code for the pairing URL
4329
44Example:
30From there, connect from another device in either of these ways:
4531
46`http://192.168.1.42:3773`
32- scan the QR code on your phone
33- in the desktop app, enter the full pairing URL
34- in the desktop app, enter the host and token separately
4735
48Notes:
36Use `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.
4937
50- `--host 0.0.0.0` listens on all IPv4 interfaces.
51- `--no-browser` prevents local auto-open, which is usually better for headless/remote sessions.
52- Ensure your OS firewall allows inbound TCP on the selected port.
38> Note
39> The GUIs do not currently support adding projects on remote environments.
40> For now, use `t3 project ...` on the server machine instead.
41> Full GUI support for remote project management is coming soon.
5342
54## 2) Tailnet / Tailscale access
43## How Pairing Works
5544
56If you use Tailscale, you can bind directly to your Tailnet address.
45The remote device does not need a long-lived secret up front.
5746
58```bash
59TAILNET_IP="$(tailscale ip -4)"
60TOKEN="$(openssl rand -hex 24)"
61bun run --cwd apps/server start -- --host "$(tailscale ip -4)" --port 3773 --auth-token "$TOKEN" --no-browser
62```
47Instead:
48
491. `t3 serve` issues a one-time owner pairing token.
502. The remote device exchanges that token with the server.
513. The server creates an authenticated session for that device.
52
53After pairing, future access is session-based. You do not need to keep reusing the original token unless you are pairing a new device.
54
55## Managing Access Later
56
57Use `t3 auth` to manage access after the initial pairing flow.
58
59Typical uses:
60
61- issue additional pairing credentials
62- inspect active sessions
63- revoke old pairing links or sessions
6364
64Open from any device in your tailnet:
65Use `t3 auth --help` and the nested subcommand help pages for the full reference.
6566
66`http://<tailnet-ip>:3773`
67## Security Notes
6768
68You can also bind `--host 0.0.0.0` and connect through the Tailnet IP, but binding directly to the Tailnet IP limits exposure.
69- Treat pairing URLs and pairing tokens like passwords.
70- Prefer binding `--host` to a trusted private address, such as a Tailnet IP, instead of exposing the server broadly.
71- Anyone with a valid pairing credential can create a session until that credential expires or is revoked.
72- Use `t3 auth` to revoke credentials or sessions you no longer trust.
apps/server/src/auth/Layers/ServerAuthPolicy.ts+1-17
......@@ -4,23 +4,7 @@ import { Effect, Layer } from "effect";
44import { ServerConfig } from "../../config.ts";
55import { ServerAuthPolicy, type ServerAuthPolicyShape } from "../Services/ServerAuthPolicy.ts";
66import { SESSION_COOKIE_NAME } from "../utils.ts";
7
8const isWildcardHost = (host: string | undefined): boolean =>
9 host === "0.0.0.0" || host === "::" || host === "[::]";
10
11const isLoopbackHost = (host: string | undefined): boolean => {
12 if (!host || host.length === 0) {
13 return true;
14 }
15
16 return (
17 host === "localhost" ||
18 host === "127.0.0.1" ||
19 host === "::1" ||
20 host === "[::1]" ||
21 host.startsWith("127.")
22 );
23};
7import { isLoopbackHost, isWildcardHost } from "../../startupAccess.ts";
248
259export const makeServerAuthPolicy = Effect.gen(function* () {
2610 const config = yield* ServerConfig;
apps/server/src/auth/Layers/SessionCredentialService.ts+21-20
......@@ -46,6 +46,9 @@ const WebSocketClaims = Schema.Struct({
4646});
4747type WebSocketClaims = typeof WebSocketClaims.Type;
4848
49const decodeSessionClaims = Schema.decodeUnknownEffect(Schema.fromJsonString(SessionClaims));
50const decodeWebSocketClaims = Schema.decodeUnknownEffect(Schema.fromJsonString(WebSocketClaims));
51
4952function createDefaultClientMetadata(): AuthClientMetadata {
5053 return {
5154 deviceType: "unknown",
......@@ -259,15 +262,15 @@ export const makeSessionCredentialService = Effect.gen(function* () {
259262 });
260263 }
261264
262 const claims = yield* Effect.try({
263 try: () =>
264 Schema.decodeUnknownSync(SessionClaims)(JSON.parse(base64UrlDecodeUtf8(encodedPayload))),
265 catch: (cause) =>
266 new SessionCredentialError({
267 message: "Invalid session token payload.",
268 cause,
269 }),
270 });
265 const claims = yield* decodeSessionClaims(base64UrlDecodeUtf8(encodedPayload)).pipe(
266 Effect.mapError(
267 (cause) =>
268 new SessionCredentialError({
269 message: "Invalid session token payload.",
270 cause,
271 }),
272 ),
273 );
271274
272275 const now = yield* Clock.currentTimeMillis;
273276 if (claims.exp <= now) {
......@@ -348,17 +351,15 @@ export const makeSessionCredentialService = Effect.gen(function* () {
348351 });
349352 }
350353
351 const claims = yield* Effect.try({
352 try: () =>
353 Schema.decodeUnknownSync(WebSocketClaims)(
354 JSON.parse(base64UrlDecodeUtf8(encodedPayload)),
355 ),
356 catch: (cause) =>
357 new SessionCredentialError({
358 message: "Invalid websocket token payload.",
359 cause,
360 }),
361 });
354 const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe(
355 Effect.mapError(
356 (cause) =>
357 new SessionCredentialError({
358 message: "Invalid websocket token payload.",
359 cause,
360 }),
361 ),
362 );
362363
363364 const now = yield* Clock.currentTimeMillis;
364365 if (claims.exp <= now) {
apps/server/src/cli-config.test.ts+64
......@@ -83,6 +83,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
8383 staticDir: undefined,
8484 devUrl: new URL("http://127.0.0.1:5173"),
8585 noBrowser: true,
86 startupPresentation: "browser",
8687 desktopBootstrapToken: undefined,
8788 autoBootstrapProjectFromCwd: false,
8889 logWebSocketEvents: true,
......@@ -144,6 +145,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
144145 staticDir: undefined,
145146 devUrl: new URL("http://127.0.0.1:4173"),
146147 noBrowser: true,
148 startupPresentation: "browser",
147149 desktopBootstrapToken: undefined,
148150 autoBootstrapProjectFromCwd: true,
149151 logWebSocketEvents: true,
......@@ -212,6 +214,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
212214 staticDir: undefined,
213215 devUrl: new URL("http://127.0.0.1:5173"),
214216 noBrowser: true,
217 startupPresentation: "browser",
215218 desktopBootstrapToken: undefined,
216219 autoBootstrapProjectFromCwd: false,
217220 logWebSocketEvents: true,
......@@ -329,6 +332,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
329332 staticDir: undefined,
330333 devUrl: new URL("http://127.0.0.1:4173"),
331334 noBrowser: true,
335 startupPresentation: "browser",
332336 desktopBootstrapToken: undefined,
333337 autoBootstrapProjectFromCwd: true,
334338 logWebSocketEvents: true,
......@@ -392,6 +396,66 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
392396 staticDir: resolved.staticDir,
393397 devUrl: undefined,
394398 noBrowser: true,
399 startupPresentation: "browser",
400 desktopBootstrapToken: undefined,
401 autoBootstrapProjectFromCwd: false,
402 logWebSocketEvents: false,
403 });
404 }),
405 );
406
407 it.effect("forces noBrowser and disables auto-bootstrap for headless startup presentation", () =>
408 Effect.gen(function* () {
409 const { join } = yield* Path.Path;
410 const baseDir = join(os.tmpdir(), "t3-cli-config-headless-base");
411 const derivedPaths = yield* deriveServerPaths(baseDir, undefined);
412
413 const resolved = yield* resolveServerConfig(
414 {
415 mode: Option.some("web"),
416 port: Option.some(3773),
417 host: Option.none(),
418 baseDir: Option.some(baseDir),
419 cwd: Option.none(),
420 devUrl: Option.none(),
421 noBrowser: Option.none(),
422 bootstrapFd: Option.none(),
423 autoBootstrapProjectFromCwd: Option.none(),
424 logWebSocketEvents: Option.none(),
425 },
426 Option.none(),
427 {
428 startupPresentation: "headless",
429 },
430 ).pipe(
431 Effect.provide(
432 Layer.mergeAll(
433 ConfigProvider.layer(
434 ConfigProvider.fromEnv({
435 env: {
436 T3CODE_NO_BROWSER: "false",
437 T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "true",
438 },
439 }),
440 ),
441 NetService.layer,
442 ),
443 ),
444 );
445
446 expect(resolved).toEqual({
447 logLevel: "Info",
448 ...defaultObservabilityConfig,
449 mode: "web",
450 port: 3773,
451 cwd: process.cwd(),
452 baseDir,
453 ...derivedPaths,
454 host: undefined,
455 staticDir: resolved.staticDir,
456 devUrl: undefined,
457 noBrowser: true,
458 startupPresentation: "headless",
395459 desktopBootstrapToken: undefined,
396460 autoBootstrapProjectFromCwd: false,
397461 logWebSocketEvents: false,
apps/server/src/cli.test.ts+219
......@@ -1,17 +1,38 @@
1import * as NodeHttp from "node:http";
12import { mkdtempSync } from "node:fs";
23import { tmpdir } from "node:os";
34import { join } from "node:path";
45
6import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
57import * as NodeServices from "@effect/platform-node/NodeServices";
68import { NetService } from "@t3tools/shared/Net";
79import { assert, it } from "@effect/vitest";
810import * as Effect from "effect/Effect";
911import * as Layer from "effect/Layer";
12import * as HttpRouter from "effect/unstable/http/HttpRouter";
13import * as HttpServer from "effect/unstable/http/HttpServer";
1014import * as CliError from "effect/unstable/cli/CliError";
1115import * as TestConsole from "effect/testing/TestConsole";
1216import { Command } from "effect/unstable/cli";
1317
1418import { cli } from "./cli.ts";
19import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config.ts";
20import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts";
21import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts";
22import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";
23import {
24 orchestrationDispatchRouteLayer,
25 orchestrationSnapshotRouteLayer,
26} from "./orchestration/http.ts";
27import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts";
28import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts";
29import {
30 makePersistedServerRuntimeState,
31 persistServerRuntimeState,
32} from "./serverRuntimeState.ts";
33import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
34import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts";
35import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts";
1536
1637const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);
1738
......@@ -28,6 +49,104 @@ const captureStdout = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
2849 return { result, output };
2950 }).pipe(Effect.provide(Layer.mergeAll(CliRuntimeLayer, TestConsole.layer)));
3051
52const makeCliTestServerConfig = (baseDir: string) =>
53 Effect.gen(function* () {
54 const derivedPaths = yield* deriveServerPaths(baseDir, undefined);
55 return {
56 logLevel: "Info",
57 traceMinLevel: "Info",
58 traceTimingEnabled: true,
59 traceBatchWindowMs: 200,
60 traceMaxBytes: 10 * 1024 * 1024,
61 traceMaxFiles: 10,
62 otlpTracesUrl: undefined,
63 otlpMetricsUrl: undefined,
64 otlpExportIntervalMs: 10_000,
65 otlpServiceName: "t3-server",
66 mode: "web",
67 port: 0,
68 host: "127.0.0.1",
69 cwd: process.cwd(),
70 baseDir,
71 ...derivedPaths,
72 staticDir: undefined,
73 devUrl: undefined,
74 noBrowser: true,
75 startupPresentation: "browser",
76 desktopBootstrapToken: undefined,
77 autoBootstrapProjectFromCwd: false,
78 logWebSocketEvents: false,
79 } satisfies ServerConfigShape;
80 });
81
82const makeProjectPersistenceLayer = (config: ServerConfigShape) =>
83 Layer.mergeAll(
84 OrchestrationLayerLive.pipe(
85 Layer.provideMerge(RepositoryIdentityResolverLive),
86 Layer.provideMerge(SqlitePersistenceLayerLive),
87 ),
88 WorkspacePathsLive,
89 ).pipe(
90 Layer.provideMerge(NodeServices.layer),
91 Layer.provide(Layer.succeed(ServerConfig, config)),
92 );
93
94const readPersistedSnapshot = (baseDir: string) =>
95 Effect.gen(function* () {
96 const config = yield* makeCliTestServerConfig(baseDir);
97 return yield* Effect.gen(function* () {
98 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
99 return yield* projectionSnapshotQuery.getSnapshot();
100 }).pipe(Effect.provide(makeProjectPersistenceLayer(config)));
101 });
102
103const withLiveProjectCliServer = <A, E, R>(baseDir: string, run: () => Effect.Effect<A, E, R>) =>
104 Effect.gen(function* () {
105 const config = yield* makeCliTestServerConfig(baseDir);
106 const routesLayer = Layer.mergeAll(
107 orchestrationSnapshotRouteLayer,
108 orchestrationDispatchRouteLayer,
109 );
110 const appLayer = HttpRouter.serve(routesLayer, {
111 disableListenLog: true,
112 disableLogger: true,
113 }).pipe(
114 Layer.provideMerge(
115 ServerAuthLive.pipe(
116 Layer.provideMerge(SqlitePersistenceLayerLive),
117 Layer.provide(ServerSecretStoreLive),
118 ),
119 ),
120 Layer.provideMerge(makeProjectPersistenceLayer(config)),
121 Layer.provideMerge(
122 NodeHttpServer.layer(NodeHttp.createServer, {
123 host: "127.0.0.1",
124 port: 0,
125 }),
126 ),
127 Layer.provideMerge(NodeServices.layer),
128 Layer.provide(Layer.succeed(ServerConfig, config)),
129 );
130
131 return yield* Effect.scoped(
132 Effect.gen(function* () {
133 const server = yield* HttpServer.HttpServer;
134 const address = server.address;
135 if (typeof address === "string" || !("port" in address)) {
136 assert.fail(`Expected TCP address, got ${address}`);
137 }
138 yield* persistServerRuntimeState({
139 path: config.serverRuntimeStatePath,
140 state: makePersistedServerRuntimeState({
141 config,
142 port: address.port,
143 }),
144 });
145 return yield* run();
146 }).pipe(Effect.provide(Layer.mergeAll(appLayer, NodeServices.layer))),
147 );
148 });
149
31150it.layer(NodeServices.layer)("cli log-level parsing", (it) => {
32151 it.effect("accepts the built-in lowercase log-level flag values", () =>
33152 runCliWithRuntime(["--log-level", "debug", "--version"]),
......@@ -130,4 +249,104 @@ it.layer(NodeServices.layer)("cli log-level parsing", (it) => {
130249 assert.isTrue(ttlError.message.includes("5m, 1h, 30d, or 15 minutes"));
131250 }),
132251 );
252
253 it.effect("adds, renames, and removes projects offline through the orchestration engine", () =>
254 Effect.gen(function* () {
255 const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-projects-offline-test-"));
256 const workspaceRoot = mkdtempSync(join(tmpdir(), "t3-cli-projects-workspace-"));
257
258 yield* runCliWithRuntime([
259 "project",
260 "add",
261 workspaceRoot,
262 "--title",
263 "Alpha",
264 "--base-dir",
265 baseDir,
266 ]);
267 const afterAdd = yield* readPersistedSnapshot(baseDir);
268 const addedProject = afterAdd.projects.find(
269 (project) => project.workspaceRoot === workspaceRoot && project.deletedAt === null,
270 );
271 assert.isTrue(addedProject !== undefined);
272 assert.equal(addedProject?.title, "Alpha");
273
274 yield* runCliWithRuntime(["project", "rename", workspaceRoot, "Beta", "--base-dir", baseDir]);
275 const afterRename = yield* readPersistedSnapshot(baseDir);
276 const renamedProject = afterRename.projects.find(
277 (project) => project.id === addedProject?.id,
278 );
279 assert.equal(renamedProject?.title, "Beta");
280 assert.equal(renamedProject?.deletedAt, null);
281
282 yield* runCliWithRuntime([
283 "project",
284 "remove",
285 addedProject?.id ?? "",
286 "--base-dir",
287 baseDir,
288 ]);
289 const afterRemove = yield* readPersistedSnapshot(baseDir);
290 const removedProject = afterRemove.projects.find(
291 (project) => project.id === addedProject?.id,
292 );
293 assert.isTrue((removedProject?.deletedAt ?? null) !== null);
294 }),
295 );
296
297 it.effect("routes project commands through a running server when runtime state is present", () =>
298 Effect.gen(function* () {
299 const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-projects-live-test-"));
300 const workspaceRoot = mkdtempSync(join(tmpdir(), "t3-cli-projects-live-workspace-"));
301
302 yield* withLiveProjectCliServer(baseDir, () =>
303 Effect.gen(function* () {
304 yield* runCliWithRuntime([
305 "project",
306 "add",
307 workspaceRoot,
308 "--title",
309 "Live Project",
310 "--base-dir",
311 baseDir,
312 ]);
313 const orchestrationEngine = yield* OrchestrationEngineService;
314 const readModel = yield* orchestrationEngine.getReadModel();
315 const addedProject = readModel.projects.find(
316 (project) => project.workspaceRoot === workspaceRoot && project.deletedAt === null,
317 );
318 assert.isTrue(addedProject !== undefined);
319 assert.equal(addedProject?.title, "Live Project");
320 }),
321 );
322 }),
323 );
324
325 it.effect("rejects dev-url on project commands", () =>
326 Effect.gen(function* () {
327 const workspaceRoot = mkdtempSync(
328 join(tmpdir(), "t3-cli-projects-unknown-option-workspace-"),
329 );
330 const error = yield* runCliWithRuntime([
331 "project",
332 "add",
333 workspaceRoot,
334 "--dev-url",
335 "http://127.0.0.1:5173",
336 ]).pipe(Effect.flip);
337
338 if (!CliError.isCliError(error)) {
339 assert.fail(`Expected CliError, got ${String(error)}`);
340 }
341 if (error._tag !== "ShowHelp") {
342 assert.fail(`Expected ShowHelp, got ${error._tag}`);
343 }
344 assert.deepEqual(error.commandPath, ["t3", "project", "add"]);
345 const optionError = error.errors[0] as CliError.CliError | undefined;
346 if (!optionError || optionError._tag !== "UnrecognizedOption") {
347 assert.fail(`Expected UnrecognizedOption, got ${String(optionError?._tag)}`);
348 }
349 assert.equal(optionError.option, "--dev-url");
350 }),
351 );
133352});
apps/server/src/cli.ts+521-56
......@@ -1,12 +1,20 @@
11import { NetService } from "@t3tools/shared/Net";
22import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
3import { AuthSessionId } from "@t3tools/contracts";
3import {
4 AuthSessionId,
5 CommandId,
6 OrchestrationReadModel,
7 ProjectId,
8 type ClientOrchestrationCommand,
9} from "@t3tools/contracts";
410import {
511 Config,
612 Console,
713 Duration,
814 Effect,
15 Exit,
916 FileSystem,
17 Layer,
1018 LogLevel,
1119 Option,
1220 Path,
......@@ -16,6 +24,12 @@ import {
1624 SchemaTransformation,
1725} from "effect";
1826import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli";
27import {
28 FetchHttpClient,
29 HttpClient,
30 HttpClientRequest,
31 HttpClientResponse,
32} from "effect/unstable/http";
1933
2034import {
2135 DEFAULT_PORT,
......@@ -25,6 +39,7 @@ import {
2539 ServerConfig,
2640 RuntimeMode,
2741 type ServerConfigShape,
42 type StartupPresentation,
2843} from "./config";
2944import { readBootstrapEnvelope } from "./bootstrap";
3045import { expandHomePath, resolveBaseDir } from "./os-jank";
......@@ -37,6 +52,18 @@ import {
3752 formatSessionList,
3853} from "./cliAuthFormat";
3954import { AuthControlPlane, AuthControlPlaneShape } from "./auth/Services/AuthControlPlane.ts";
55import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts";
56import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts";
57import { OrchestrationLayerLive } from "./orchestration/runtimeLayer";
58import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts";
59import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts";
60import { getAutoBootstrapDefaultModelSelection } from "./serverRuntimeStartup";
61import {
62 clearPersistedServerRuntimeState,
63 readPersistedServerRuntimeState,
64} from "./serverRuntimeState";
65import { WorkspacePaths } from "./workspace/Services/WorkspacePaths";
66import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths";
4067
4168const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }));
4269
......@@ -163,7 +190,7 @@ interface CliServerFlags {
163190
164191interface CliAuthLocationFlags {
165192 readonly baseDir: Option.Option<string>;
166 readonly devUrl: Option.Option<URL>;
193 readonly devUrl?: Option.Option<URL>;
167194}
168195
169196const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) =>
......@@ -187,13 +214,29 @@ const loadPersistedObservabilitySettings = Effect.fn(function* (settingsPath: st
187214export const resolveServerConfig = (
188215 flags: CliServerFlags,
189216 cliLogLevel: Option.Option<LogLevel.LogLevel>,
217 options?: {
218 readonly startupPresentation?: StartupPresentation;
219 readonly forceAutoBootstrapProjectFromCwd?: boolean;
220 },
190221) =>
191222 Effect.gen(function* () {
192223 const { findAvailablePort } = yield* NetService;
193224 const path = yield* Path.Path;
194225 const fs = yield* FileSystem.FileSystem;
195226 const env = yield* EnvServerConfig;
196 const bootstrapFd = Option.getOrUndefined(flags.bootstrapFd) ?? env.bootstrapFd;
227 const normalizedFlags = {
228 mode: flags.mode ?? Option.none(),
229 port: flags.port ?? Option.none(),
230 host: flags.host ?? Option.none(),
231 baseDir: flags.baseDir ?? Option.none(),
232 cwd: flags.cwd ?? Option.none(),
233 devUrl: flags.devUrl ?? Option.none(),
234 noBrowser: flags.noBrowser ?? Option.none(),
235 bootstrapFd: flags.bootstrapFd ?? Option.none(),
236 autoBootstrapProjectFromCwd: flags.autoBootstrapProjectFromCwd ?? Option.none(),
237 logWebSocketEvents: flags.logWebSocketEvents ?? Option.none(),
238 } satisfies CliServerFlags;
239 const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd;
197240 const bootstrapEnvelope =
198241 bootstrapFd !== undefined
199242 ? yield* readBootstrapEnvelope(BootstrapEnvelopeSchema, bootstrapFd)
......@@ -201,7 +244,7 @@ export const resolveServerConfig = (
201244
202245 const mode: RuntimeMode = Option.getOrElse(
203246 resolveOptionPrecedence(
204 flags.mode,
247 normalizedFlags.mode,
205248 Option.fromUndefinedOr(env.mode),
206249 Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.mode)),
207250 ),
......@@ -210,7 +253,7 @@ export const resolveServerConfig = (
210253
211254 const port = yield* Option.match(
212255 resolveOptionPrecedence(
213 flags.port,
256 normalizedFlags.port,
214257 Option.fromUndefinedOr(env.port),
215258 Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.port)),
216259 ),
......@@ -226,7 +269,7 @@ export const resolveServerConfig = (
226269 );
227270 const devUrl = Option.getOrElse(
228271 resolveOptionPrecedence(
229 flags.devUrl,
272 normalizedFlags.devUrl,
230273 Option.fromUndefinedOr(env.devUrl),
231274 Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.devUrl)),
232275 ),
......@@ -235,7 +278,7 @@ export const resolveServerConfig = (
235278 const baseDir = yield* resolveBaseDir(
236279 Option.getOrUndefined(
237280 resolveOptionPrecedence(
238 flags.baseDir,
281 normalizedFlags.baseDir,
239282 Option.fromUndefinedOr(env.t3Home),
240283 Option.flatMap(bootstrapEnvelope, (bootstrap) =>
241284 Option.fromUndefinedOr(bootstrap.t3Home),
......@@ -243,7 +286,7 @@ export const resolveServerConfig = (
243286 ),
244287 ),
245288 );
246 const rawCwd = Option.getOrElse(flags.cwd, () => process.cwd());
289 const rawCwd = Option.getOrElse(normalizedFlags.cwd, () => process.cwd());
247290 const cwd = path.resolve(yield* expandHomePath(rawCwd.trim()));
248291 yield* fs.makeDirectory(cwd, { recursive: true });
249292 const derivedPaths = yield* deriveServerPaths(baseDir, devUrl);
......@@ -253,37 +296,45 @@ export const resolveServerConfig = (
253296 );
254297 const serverTracePath = env.traceFile ?? derivedPaths.serverTracePath;
255298 yield* fs.makeDirectory(path.dirname(serverTracePath), { recursive: true });
256 const noBrowser = resolveBooleanFlag(
257 flags.noBrowser,
258 Option.getOrElse(
259 resolveOptionPrecedence(
260 Option.fromUndefinedOr(env.noBrowser),
261 Option.flatMap(bootstrapEnvelope, (bootstrap) =>
262 Option.fromUndefinedOr(bootstrap.noBrowser),
263 ),
264 ),
265 () => mode === "desktop",
266 ),
267 );
299 const startupPresentation = options?.startupPresentation ?? "browser";
300 const noBrowser =
301 startupPresentation === "headless"
302 ? true
303 : resolveBooleanFlag(
304 normalizedFlags.noBrowser,
305 Option.getOrElse(
306 resolveOptionPrecedence(
307 Option.fromUndefinedOr(env.noBrowser),
308 Option.flatMap(bootstrapEnvelope, (bootstrap) =>
309 Option.fromUndefinedOr(bootstrap.noBrowser),
310 ),
311 ),
312 () => mode === "desktop",
313 ),
314 );
268315 const desktopBootstrapToken = Option.getOrUndefined(
269316 Option.flatMap(bootstrapEnvelope, (bootstrap) =>
270317 Option.fromUndefinedOr(bootstrap.desktopBootstrapToken),
271318 ),
272319 );
273 const autoBootstrapProjectFromCwd = resolveBooleanFlag(
274 flags.autoBootstrapProjectFromCwd,
275 Option.getOrElse(
276 resolveOptionPrecedence(
277 Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd),
278 Option.flatMap(bootstrapEnvelope, (bootstrap) =>
279 Option.fromUndefinedOr(bootstrap.autoBootstrapProjectFromCwd),
280 ),
281 ),
282 () => mode === "web",
283 ),
284 );
320 const autoBootstrapProjectFromCwd =
321 options?.forceAutoBootstrapProjectFromCwd ??
322 (startupPresentation === "headless"
323 ? false
324 : resolveBooleanFlag(
325 normalizedFlags.autoBootstrapProjectFromCwd,
326 Option.getOrElse(
327 resolveOptionPrecedence(
328 Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd),
329 Option.flatMap(bootstrapEnvelope, (bootstrap) =>
330 Option.fromUndefinedOr(bootstrap.autoBootstrapProjectFromCwd),
331 ),
332 ),
333 () => mode === "web",
334 ),
335 ));
285336 const logWebSocketEvents = resolveBooleanFlag(
286 flags.logWebSocketEvents,
337 normalizedFlags.logWebSocketEvents,
287338 Option.getOrElse(
288339 resolveOptionPrecedence(
289340 Option.fromUndefinedOr(env.logWebSocketEvents),
......@@ -297,7 +348,7 @@ export const resolveServerConfig = (
297348 const staticDir = devUrl ? undefined : yield* resolveStaticDir();
298349 const host = Option.getOrElse(
299350 resolveOptionPrecedence(
300 flags.host,
351 normalizedFlags.host,
301352 Option.fromUndefinedOr(env.host),
302353 Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.host)),
303354 ),
......@@ -340,6 +391,7 @@ export const resolveServerConfig = (
340391 staticDir,
341392 devUrl,
342393 noBrowser,
394 startupPresentation,
343395 desktopBootstrapToken,
344396 autoBootstrapProjectFromCwd,
345397 logWebSocketEvents,
......@@ -359,7 +411,7 @@ const resolveCliAuthConfig = (
359411 host: Option.none(),
360412 baseDir: flags.baseDir,
361413 cwd: Option.none(),
362 devUrl: flags.devUrl,
414 devUrl: flags.devUrl ?? Option.none(),
363415 noBrowser: Option.none(),
364416 bootstrapFd: Option.none(),
365417 autoBootstrapProjectFromCwd: Option.none(),
......@@ -446,13 +498,289 @@ const runWithAuthControlPlane = <A, E>(
446498 const authControlPlane = yield* AuthControlPlane;
447499 return yield* run(authControlPlane);
448500 }).pipe(
449 Effect.provide(AuthControlPlaneRuntimeLive),
450 Effect.provideService(ServerConfig, config),
451 Effect.provideService(References.MinimumLogLevel, minimumLogLevel),
501 Effect.provide(
502 Layer.mergeAll(AuthControlPlaneRuntimeLive).pipe(
503 Layer.provide(Layer.succeed(ServerConfig, config)),
504 Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
505 ),
506 ),
452507 );
453508 });
454509
455const commandFlags = {
510type ProjectMutationTarget = {
511 readonly id: ProjectId;
512 readonly title: string;
513 readonly workspaceRoot: string;
514};
515
516type ProjectCommandExecutionMode = "live" | "offline";
517type ProjectCliDispatchCommand = Extract<
518 ClientOrchestrationCommand,
519 { type: "project.create" | "project.meta.update" | "project.delete" }
520>;
521
522const ProjectCliRuntimeLive = Layer.mergeAll(
523 WorkspacePathsLive,
524 OrchestrationLayerLive.pipe(
525 Layer.provideMerge(RepositoryIdentityResolverLive),
526 Layer.provideMerge(SqlitePersistenceLayerLive),
527 ),
528);
529
530const PROJECT_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(1);
531const OrchestrationHttpErrorResponse = Schema.Struct({
532 error: Schema.String,
533});
534
535const withProjectCliSessionToken = <A, E, R>(
536 authControlPlane: AuthControlPlaneShape,
537 run: (token: string) => Effect.Effect<A, E, R>,
538) =>
539 Effect.acquireUseRelease(
540 authControlPlane.issueSession({
541 role: "owner",
542 label: "t3 project cli",
543 }),
544 (issued) => run(issued.token),
545 (issued) => authControlPlane.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })),
546 );
547
548const withProjectCliLiveServerTimeout = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
549 effect.pipe(Effect.timeout(PROJECT_CLI_LIVE_SERVER_TIMEOUT));
550
551const runLiveServerRequest = <A, E extends Error, R>(
552 request: HttpClientRequest.HttpClientRequest,
553 handle: (response: HttpClientResponse.HttpClientResponse) => Effect.Effect<A, E, R>,
554) =>
555 Effect.gen(function* () {
556 const httpClient = yield* HttpClient.HttpClient;
557 const response = yield* httpClient.execute(request);
558 return yield* handle(response);
559 }).pipe(withProjectCliLiveServerTimeout);
560
561const decodeOrchestrationReadModelResponse = (response: HttpClientResponse.HttpClientResponse) =>
562 HttpClientResponse.schemaBodyJson(OrchestrationReadModel)(response);
563
564const readErrorMessageFromResponse = (response: HttpClientResponse.HttpClientResponse) =>
565 HttpClientResponse.schemaBodyJson(OrchestrationHttpErrorResponse)(response).pipe(
566 Effect.map((body) => body.error),
567 Effect.catch(() => Effect.succeed(null)),
568 Effect.map((body) => {
569 if (typeof body === "string" && body.trim().length > 0) {
570 return body;
571 }
572 return `Server request failed with status ${response.status}.`;
573 }),
574 );
575
576const normalizeWorkspaceRootForProjectCommand = Effect.fn(
577 "normalizeWorkspaceRootForProjectCommand",
578)(function* (workspaceRoot: string) {
579 const workspacePaths = yield* WorkspacePaths;
580 return yield* workspacePaths.normalizeWorkspaceRoot(workspaceRoot);
581});
582
583const resolveProjectTitle = Effect.fn("resolveProjectTitle")(function* (
584 workspaceRoot: string,
585 explicitTitle?: string,
586) {
587 if (explicitTitle !== undefined) {
588 const trimmed = explicitTitle.trim();
589 if (trimmed.length > 0) {
590 return trimmed;
591 }
592 return yield* Effect.fail(new Error("Project title cannot be empty."));
593 }
594
595 const path = yield* Path.Path;
596 const basename = path.basename(workspaceRoot).trim();
597 return basename.length > 0 ? basename : "project";
598});
599
600const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* (input: {
601 readonly snapshot: OrchestrationReadModel;
602 readonly identifier: string;
603}) {
604 const trimmedIdentifier = input.identifier.trim();
605 if (trimmedIdentifier.length === 0) {
606 return yield* Effect.fail(new Error("Project identifier cannot be empty."));
607 }
608
609 const activeProjects = input.snapshot.projects.filter((project) => project.deletedAt === null);
610 const exactIdMatch = activeProjects.find((project) => project.id === trimmedIdentifier);
611 if (exactIdMatch) {
612 return {
613 id: exactIdMatch.id,
614 title: exactIdMatch.title,
615 workspaceRoot: exactIdMatch.workspaceRoot,
616 } satisfies ProjectMutationTarget;
617 }
618
619 const normalizedWorkspaceRootResult = yield* Effect.exit(
620 normalizeWorkspaceRootForProjectCommand(trimmedIdentifier),
621 );
622 const normalizedWorkspaceRoot = Exit.isSuccess(normalizedWorkspaceRootResult)
623 ? normalizedWorkspaceRootResult.value
624 : null;
625
626 const exactWorkspaceMatch =
627 normalizedWorkspaceRoot === null
628 ? undefined
629 : activeProjects.find((project) => project.workspaceRoot === normalizedWorkspaceRoot);
630
631 const resolved = exactWorkspaceMatch;
632 if (!resolved) {
633 return yield* Effect.fail(new Error(`No active project found for '${trimmedIdentifier}'.`));
634 }
635
636 return {
637 id: resolved.id,
638 title: resolved.title,
639 workspaceRoot: resolved.workspaceRoot,
640 } satisfies ProjectMutationTarget;
641});
642
643const fetchLiveOrchestrationSnapshot = (origin: string, bearerToken: string) =>
644 runLiveServerRequest(
645 HttpClientRequest.get(`${origin}/api/orchestration/snapshot`).pipe(
646 HttpClientRequest.acceptJson,
647 HttpClientRequest.bearerToken(bearerToken),
648 ),
649 HttpClientResponse.matchStatus({
650 "2xx": decodeOrchestrationReadModelResponse,
651 orElse: (response) =>
652 readErrorMessageFromResponse(response).pipe(
653 Effect.flatMap((message) => Effect.fail(new Error(message))),
654 ),
655 }),
656 );
657
658const dispatchLiveOrchestrationCommand = (
659 origin: string,
660 bearerToken: string,
661 command: ProjectCliDispatchCommand,
662) =>
663 HttpClientRequest.post(`${origin}/api/orchestration/dispatch`).pipe(
664 HttpClientRequest.acceptJson,
665 HttpClientRequest.bearerToken(bearerToken),
666 HttpClientRequest.bodyJson(command),
667 Effect.flatMap((request) =>
668 runLiveServerRequest(
669 request,
670 HttpClientResponse.matchStatus({
671 "2xx": () => Effect.void,
672 orElse: (response) =>
673 readErrorMessageFromResponse(response).pipe(
674 Effect.flatMap((message) => Effect.fail(new Error(message))),
675 ),
676 }),
677 ),
678 ),
679 );
680
681const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () {
682 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
683 return yield* projectionSnapshotQuery.getSnapshot();
684});
685
686const tryResolveLiveProjectExecutionMode = Effect.fn("tryResolveLiveProjectExecutionMode")(
687 function* (authControlPlane: AuthControlPlaneShape, config: ServerConfigShape) {
688 const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath);
689 if (Option.isNone(runtimeState)) {
690 return Option.none<{ readonly origin: string }>();
691 }
692
693 const attempt = withProjectCliSessionToken(authControlPlane, (token) =>
694 fetchLiveOrchestrationSnapshot(runtimeState.value.origin, token).pipe(
695 Effect.as({
696 origin: runtimeState.value.origin,
697 }),
698 ),
699 );
700
701 const attempted = yield* Effect.exit(attempt);
702 if (Exit.isSuccess(attempted)) {
703 return Option.some(attempted.value);
704 }
705
706 yield* clearPersistedServerRuntimeState(config.serverRuntimeStatePath);
707 return Option.none<{ readonly origin: string }>();
708 },
709);
710
711const runProjectMutation = Effect.fn("runProjectMutation")(function* (
712 flags: CliAuthLocationFlags,
713 run: (input: {
714 readonly snapshot: OrchestrationReadModel;
715 readonly dispatch: (
716 command: ProjectCliDispatchCommand,
717 ) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>;
718 readonly mode: ProjectCommandExecutionMode;
719 }) => Effect.Effect<
720 string,
721 Error,
722 FileSystem.FileSystem | HttpClient.HttpClient | Path.Path | WorkspacePaths
723 >,
724) {
725 const logLevel = yield* GlobalFlag.LogLevel;
726 const config = yield* resolveCliAuthConfig(flags, logLevel);
727 const minimumLogLevel = config.logLevel;
728
729 return yield* Effect.gen(function* () {
730 const authControlPlane = yield* AuthControlPlane;
731 const liveMode = yield* tryResolveLiveProjectExecutionMode(authControlPlane, config);
732
733 if (Option.isSome(liveMode)) {
734 return yield* withProjectCliSessionToken(authControlPlane, (token) =>
735 Effect.gen(function* () {
736 const snapshot = yield* fetchLiveOrchestrationSnapshot(liveMode.value.origin, token);
737 const output = yield* run({
738 snapshot,
739 dispatch: (command) =>
740 dispatchLiveOrchestrationCommand(liveMode.value.origin, token, command),
741 mode: "live",
742 });
743 yield* Console.log(output);
744 }),
745 );
746 }
747
748 const offlineRuntimeLayer = ProjectCliRuntimeLive.pipe(
749 Layer.provide(Layer.succeed(ServerConfig, config)),
750 Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
751 );
752
753 return yield* Effect.gen(function* () {
754 const snapshot = yield* getOfflineSnapshot();
755 const orchestrationEngine = yield* OrchestrationEngineService;
756 const output = yield* run({
757 snapshot,
758 dispatch: (command) => orchestrationEngine.dispatch(command),
759 mode: "offline",
760 });
761 yield* Console.log(output);
762 }).pipe(Effect.provide(offlineRuntimeLayer));
763 }).pipe(
764 Effect.provide(
765 Layer.mergeAll(AuthControlPlaneRuntimeLive, WorkspacePathsLive).pipe(
766 Layer.provideMerge(FetchHttpClient.layer),
767 Layer.provide(Layer.succeed(ServerConfig, config)),
768 Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
769 ),
770 ),
771 );
772});
773
774const sharedServerLocationFlags = {
775 baseDir: baseDirFlag,
776 devUrl: devUrlFlag,
777} as const;
778
779const projectLocationFlags = {
780 baseDir: baseDirFlag,
781} as const;
782
783const sharedServerCommandFlags = {
456784 mode: modeFlag,
457785 port: portFlag,
458786 host: hostFlag,
......@@ -470,10 +798,7 @@ const commandFlags = {
470798 logWebSocketEvents: logWebSocketEventsFlag,
471799} as const;
472800
473const authLocationFlags = {
474 baseDir: baseDirFlag,
475 devUrl: devUrlFlag,
476} as const;
801const authLocationFlags = sharedServerLocationFlags;
477802
478803const ttlFlag = Flag.string("ttl").pipe(
479804 Flag.withSchema(DurationFromString),
......@@ -674,25 +999,165 @@ const authCommand = Command.make("auth").pipe(
674999 Command.withSubcommands([pairingCommand, sessionCommand]),
6751000);
6761001
677const startCommand = Command.make("start", commandFlags).pipe(
678 Command.withDescription("Run the T3 Code server."),
1002const projectAddCommand = Command.make("add", {
1003 ...projectLocationFlags,
1004 workspaceRoot: Argument.string("path").pipe(
1005 Argument.withDescription("Workspace root to add as a project."),
1006 ),
1007 title: Flag.string("title").pipe(Flag.withDescription("Optional project title."), Flag.optional),
1008}).pipe(
1009 Command.withDescription("Add a project."),
6791010 Command.withHandler((flags) =>
680 Effect.gen(function* () {
681 const logLevel = yield* GlobalFlag.LogLevel;
682 const config = yield* resolveServerConfig(flags, logLevel);
683 return yield* runServer.pipe(Effect.provideService(ServerConfig, config));
684 }),
1011 runProjectMutation(
1012 flags,
1013 Effect.fn("projectAddMutation")(function* ({
1014 snapshot,
1015 dispatch,
1016 }: {
1017 readonly snapshot: OrchestrationReadModel;
1018 readonly dispatch: (
1019 command: ProjectCliDispatchCommand,
1020 ) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>;
1021 }) {
1022 const workspaceRoot = yield* normalizeWorkspaceRootForProjectCommand(flags.workspaceRoot);
1023 const existingProject = snapshot.projects.find(
1024 (project) => project.deletedAt === null && project.workspaceRoot === workspaceRoot,
1025 );
1026 if (existingProject) {
1027 return yield* Effect.fail(
1028 new Error(`An active project already exists for '${workspaceRoot}'.`),
1029 );
1030 }
1031
1032 const title = yield* resolveProjectTitle(workspaceRoot, Option.getOrUndefined(flags.title));
1033 const projectId = ProjectId.makeUnsafe(crypto.randomUUID());
1034 yield* dispatch({
1035 type: "project.create",
1036 commandId: CommandId.makeUnsafe(crypto.randomUUID()),
1037 projectId,
1038 title,
1039 workspaceRoot,
1040 defaultModelSelection: getAutoBootstrapDefaultModelSelection(),
1041 createdAt: new Date().toISOString(),
1042 });
1043 return `Added project ${projectId} (${title}) at ${workspaceRoot}.`;
1044 }),
1045 ),
1046 ),
1047);
1048
1049const projectRemoveCommand = Command.make("remove", {
1050 ...projectLocationFlags,
1051 project: Argument.string("project").pipe(
1052 Argument.withDescription("Project id or workspace root to remove."),
1053 ),
1054}).pipe(
1055 Command.withDescription("Remove a project."),
1056 Command.withHandler((flags) =>
1057 runProjectMutation(
1058 flags,
1059 Effect.fn("projectRemoveMutation")(function* ({
1060 snapshot,
1061 dispatch,
1062 }: {
1063 readonly snapshot: OrchestrationReadModel;
1064 readonly dispatch: (
1065 command: ProjectCliDispatchCommand,
1066 ) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>;
1067 }) {
1068 const project = yield* findActiveProjectTarget({
1069 snapshot,
1070 identifier: flags.project,
1071 });
1072 yield* dispatch({
1073 type: "project.delete",
1074 commandId: CommandId.makeUnsafe(crypto.randomUUID()),
1075 projectId: project.id,
1076 });
1077 return `Removed project ${project.id} (${project.title}).`;
1078 }),
1079 ),
1080 ),
1081);
1082
1083const projectRenameCommand = Command.make("rename", {
1084 ...projectLocationFlags,
1085 project: Argument.string("project").pipe(
1086 Argument.withDescription("Project id or workspace root to rename."),
1087 ),
1088 title: Argument.string("title").pipe(Argument.withDescription("New project title.")),
1089}).pipe(
1090 Command.withDescription("Rename a project."),
1091 Command.withHandler((flags) =>
1092 runProjectMutation(
1093 flags,
1094 Effect.fn("projectRenameMutation")(function* ({
1095 snapshot,
1096 dispatch,
1097 }: {
1098 readonly snapshot: OrchestrationReadModel;
1099 readonly dispatch: (
1100 command: ProjectCliDispatchCommand,
1101 ) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>;
1102 }) {
1103 const project = yield* findActiveProjectTarget({
1104 snapshot,
1105 identifier: flags.project,
1106 });
1107 const nextTitle = yield* resolveProjectTitle(project.workspaceRoot, flags.title);
1108 if (nextTitle === project.title) {
1109 return `Project ${project.id} is already named ${nextTitle}.`;
1110 }
1111
1112 yield* dispatch({
1113 type: "project.meta.update",
1114 commandId: CommandId.makeUnsafe(crypto.randomUUID()),
1115 projectId: project.id,
1116 title: nextTitle,
1117 });
1118 return `Renamed project ${project.id} to ${nextTitle}.`;
1119 }),
1120 ),
6851121 ),
6861122);
6871123
688export const cli = Command.make("t3", commandFlags).pipe(
1124const projectCommand = Command.make("project").pipe(
1125 Command.withDescription("Manage projects."),
1126 Command.withSubcommands([projectAddCommand, projectRemoveCommand, projectRenameCommand]),
1127);
1128
1129const runServerCommand = (
1130 flags: CliServerFlags,
1131 options?: {
1132 readonly startupPresentation?: StartupPresentation;
1133 readonly forceAutoBootstrapProjectFromCwd?: boolean;
1134 },
1135) =>
1136 Effect.gen(function* () {
1137 const logLevel = yield* GlobalFlag.LogLevel;
1138 const config = yield* resolveServerConfig(flags, logLevel, options);
1139 return yield* runServer.pipe(Effect.provideService(ServerConfig, config));
1140 });
1141
1142const startCommand = Command.make("start", { ...sharedServerCommandFlags }).pipe(
6891143 Command.withDescription("Run the T3 Code server."),
1144 Command.withHandler((flags) => runServerCommand(flags)),
1145);
1146
1147const serveCommand = Command.make("serve", { ...sharedServerCommandFlags }).pipe(
1148 Command.withDescription(
1149 "Run the T3 Code server without opening a browser and print headless pairing details.",
1150 ),
6901151 Command.withHandler((flags) =>
691 Effect.gen(function* () {
692 const logLevel = yield* GlobalFlag.LogLevel;
693 const config = yield* resolveServerConfig(flags, logLevel);
694 return yield* runServer.pipe(Effect.provideService(ServerConfig, config));
1152 runServerCommand(flags, {
1153 startupPresentation: "headless",
1154 forceAutoBootstrapProjectFromCwd: false,
6951155 }),
6961156 ),
697 Command.withSubcommands([startCommand, authCommand]),
1157);
1158
1159export const cli = Command.make("t3", { ...sharedServerCommandFlags }).pipe(
1160 Command.withDescription("Run the T3 Code server."),
1161 Command.withHandler((flags) => runServerCommand(flags)),
1162 Command.withSubcommands([startCommand, serveCommand, authCommand, projectCommand]),
6981163);
apps/server/src/config.ts+8
......@@ -13,6 +13,9 @@ export const DEFAULT_PORT = 3773;
1313export const RuntimeMode = Schema.Literals(["web", "desktop"]);
1414export type RuntimeMode = typeof RuntimeMode.Type;
1515
16export const StartupPresentation = Schema.Literals(["browser", "headless"]);
17export type StartupPresentation = typeof StartupPresentation.Type;
18
1619/**
1720 * ServerDerivedPaths - Derived paths from the base directory.
1821 */
......@@ -31,6 +34,7 @@ export interface ServerDerivedPaths {
3134 readonly terminalLogsDir: string;
3235 readonly anonymousIdPath: string;
3336 readonly environmentIdPath: string;
37 readonly serverRuntimeStatePath: string;
3438 readonly secretsDir: string;
3539}
3640
......@@ -56,6 +60,7 @@ export interface ServerConfigShape extends ServerDerivedPaths {
5660 readonly staticDir: string | undefined;
5761 readonly devUrl: URL | undefined;
5862 readonly noBrowser: boolean;
63 readonly startupPresentation: StartupPresentation;
5964 readonly desktopBootstrapToken: string | undefined;
6065 readonly autoBootstrapProjectFromCwd: boolean;
6166 readonly logWebSocketEvents: boolean;
......@@ -86,6 +91,7 @@ export const deriveServerPaths = Effect.fn(function* (
8691 terminalLogsDir: join(logsDir, "terminals"),
8792 anonymousIdPath: join(stateDir, "anonymous-id"),
8893 environmentIdPath: join(stateDir, "environment-id"),
94 serverRuntimeStatePath: join(stateDir, "server-runtime.json"),
8995 secretsDir: join(stateDir, "secrets"),
9096 };
9197});
......@@ -105,6 +111,7 @@ export const ensureServerDirectories = Effect.fn(function* (derivedPaths: Server
105111 fs.makeDirectory(path.dirname(derivedPaths.keybindingsConfigPath), { recursive: true }),
106112 fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }),
107113 fs.makeDirectory(path.dirname(derivedPaths.anonymousIdPath), { recursive: true }),
114 fs.makeDirectory(path.dirname(derivedPaths.serverRuntimeStatePath), { recursive: true }),
108115 ],
109116 { concurrency: "unbounded" },
110117 );
......@@ -153,6 +160,7 @@ export class ServerConfig extends ServiceMap.Service<ServerConfig, ServerConfigS
153160 staticDir: undefined,
154161 devUrl,
155162 noBrowser: false,
163 startupPresentation: "browser",
156164 } satisfies ServerConfigShape;
157165 }),
158166 );
apps/server/src/environment/Layers/ServerEnvironment.test.ts+1
......@@ -36,6 +36,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) {
3636 staticDir: undefined,
3737 devUrl: undefined,
3838 noBrowser: false,
39 startupPresentation: "browser",
3940 } satisfies ServerConfigShape;
4041});
4142
apps/server/src/orchestration/http.ts created+93
......@@ -0,0 +1,93 @@
1import {
2 ClientOrchestrationCommand,
3 OrchestrationDispatchCommandError,
4 OrchestrationGetSnapshotError,
5 type OrchestrationReadModel,
6} from "@t3tools/contracts";
7import { Effect } from "effect";
8import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
9
10import { ServerAuth } from "../auth/Services/ServerAuth.ts";
11import { normalizeDispatchCommand } from "./Normalizer.ts";
12import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts";
13import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts";
14
15const respondToOrchestrationHttpError = (
16 error: OrchestrationDispatchCommandError | OrchestrationGetSnapshotError,
17) =>
18 Effect.gen(function* () {
19 if (error._tag === "OrchestrationGetSnapshotError") {
20 yield* Effect.logError("orchestration http route failed", {
21 message: error.message,
22 cause: error.cause,
23 });
24 return HttpServerResponse.jsonUnsafe({ error: error.message }, { status: 500 });
25 }
26
27 return HttpServerResponse.jsonUnsafe({ error: error.message }, { status: 400 });
28 });
29
30const authenticateOwnerSession = Effect.gen(function* () {
31 const request = yield* HttpServerRequest.HttpServerRequest;
32 const serverAuth = yield* ServerAuth;
33 const session = yield* serverAuth.authenticateHttpRequest(request);
34 if (session.role !== "owner") {
35 return yield* new OrchestrationDispatchCommandError({
36 message: "Only owner sessions can manage projects.",
37 });
38 }
39 return session;
40});
41
42export const orchestrationSnapshotRouteLayer = HttpRouter.add(
43 "GET",
44 "/api/orchestration/snapshot",
45 Effect.gen(function* () {
46 yield* authenticateOwnerSession;
47 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
48 const snapshot = yield* projectionSnapshotQuery.getSnapshot().pipe(
49 Effect.mapError(
50 (cause) =>
51 new OrchestrationGetSnapshotError({
52 message: "Failed to load orchestration snapshot.",
53 cause,
54 }),
55 ),
56 );
57 return HttpServerResponse.jsonUnsafe(snapshot satisfies OrchestrationReadModel, {
58 status: 200,
59 });
60 }).pipe(
61 Effect.catchTag("OrchestrationDispatchCommandError", respondToOrchestrationHttpError),
62 Effect.catchTag("OrchestrationGetSnapshotError", respondToOrchestrationHttpError),
63 ),
64);
65
66export const orchestrationDispatchRouteLayer = HttpRouter.add(
67 "POST",
68 "/api/orchestration/dispatch",
69 Effect.gen(function* () {
70 yield* authenticateOwnerSession;
71 const orchestrationEngine = yield* OrchestrationEngineService;
72 const command = yield* HttpServerRequest.schemaBodyJson(ClientOrchestrationCommand).pipe(
73 Effect.mapError(
74 (cause) =>
75 new OrchestrationDispatchCommandError({
76 message: "Invalid orchestration command payload.",
77 cause,
78 }),
79 ),
80 );
81 const normalizedCommand = yield* normalizeDispatchCommand(command);
82 const result = yield* orchestrationEngine.dispatch(normalizedCommand).pipe(
83 Effect.mapError(
84 (cause) =>
85 new OrchestrationDispatchCommandError({
86 message: "Failed to dispatch orchestration command.",
87 cause,
88 }),
89 ),
90 );
91 return HttpServerResponse.jsonUnsafe(result, { status: 200 });
92 }).pipe(Effect.catchTag("OrchestrationDispatchCommandError", respondToOrchestrationHttpError)),
93);
apps/server/src/orchestration/runtimeLayer.ts created+27
......@@ -0,0 +1,27 @@
1import { Layer } from "effect";
2
3import { OrchestrationCommandReceiptRepositoryLive } from "../persistence/Layers/OrchestrationCommandReceipts.ts";
4import { OrchestrationEventStoreLive } from "../persistence/Layers/OrchestrationEventStore.ts";
5import { OrchestrationEngineLive } from "./Layers/OrchestrationEngine.ts";
6import { OrchestrationProjectionPipelineLive } from "./Layers/ProjectionPipeline.ts";
7import { OrchestrationProjectionSnapshotQueryLive } from "./Layers/ProjectionSnapshotQuery.ts";
8
9export const OrchestrationEventInfrastructureLayerLive = Layer.mergeAll(
10 OrchestrationEventStoreLive,
11 OrchestrationCommandReceiptRepositoryLive,
12);
13
14export const OrchestrationProjectionPipelineLayerLive = OrchestrationProjectionPipelineLive.pipe(
15 Layer.provide(OrchestrationEventStoreLive),
16);
17
18export const OrchestrationInfrastructureLayerLive = Layer.mergeAll(
19 OrchestrationProjectionSnapshotQueryLive,
20 OrchestrationEventInfrastructureLayerLive,
21 OrchestrationProjectionPipelineLayerLive,
22);
23
24export const OrchestrationLayerLive = Layer.mergeAll(
25 OrchestrationInfrastructureLayerLive,
26 OrchestrationEngineLive.pipe(Layer.provide(OrchestrationInfrastructureLayerLive)),
27);
apps/server/src/server.test.ts+1
......@@ -331,6 +331,7 @@ const buildAppUnderTest = (options?: {
331331 staticDir: undefined,
332332 devUrl,
333333 noBrowser: true,
334 startupPresentation: "browser",
334335 desktopBootstrapToken: defaultDesktopBootstrapToken,
335336 autoBootstrapProjectFromCwd: false,
336337 logWebSocketEvents: false,
apps/server/src/server.ts+34-25
......@@ -23,12 +23,7 @@ import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter";
2323import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter";
2424import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry";
2525import { makeProviderServiceLive } from "./provider/Layers/ProviderService";
26import { OrchestrationEngineLive } from "./orchestration/Layers/OrchestrationEngine";
27import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline";
28import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore";
29import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts";
3026import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery";
31import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery";
3227import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore";
3328import { GitCoreLive } from "./git/Layers/GitCore";
3429import { GitHubCliLive } from "./git/Layers/GitHubCli";
......@@ -67,6 +62,16 @@ import {
6762} from "./auth/http";
6863import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore";
6964import { ServerAuthLive } from "./auth/Layers/ServerAuth";
65import { OrchestrationLayerLive } from "./orchestration/runtimeLayer";
66import {
67 clearPersistedServerRuntimeState,
68 makePersistedServerRuntimeState,
69 persistServerRuntimeState,
70} from "./serverRuntimeState";
71import {
72 orchestrationDispatchRouteLayer,
73 orchestrationSnapshotRouteLayer,
74} from "./orchestration/http";
7075
7176const PtyAdapterLive = Layer.unwrap(
7277 Effect.gen(function* () {
......@@ -124,26 +129,6 @@ const ReactorLayerLive = Layer.empty.pipe(
124129 Layer.provideMerge(RuntimeReceiptBusLive),
125130);
126131
127const OrchestrationEventInfrastructureLayerLive = Layer.mergeAll(
128 OrchestrationEventStoreLive,
129 OrchestrationCommandReceiptRepositoryLive,
130);
131
132const OrchestrationProjectionPipelineLayerLive = OrchestrationProjectionPipelineLive.pipe(
133 Layer.provide(OrchestrationEventStoreLive),
134);
135
136const OrchestrationInfrastructureLayerLive = Layer.mergeAll(
137 OrchestrationProjectionSnapshotQueryLive,
138 OrchestrationEventInfrastructureLayerLive,
139 OrchestrationProjectionPipelineLayerLive,
140);
141
142const OrchestrationLayerLive = Layer.mergeAll(
143 OrchestrationInfrastructureLayerLive,
144 OrchestrationEngineLive.pipe(Layer.provide(OrchestrationInfrastructureLayerLive)),
145);
146
147132const CheckpointingLayerLive = Layer.empty.pipe(
148133 Layer.provideMerge(CheckpointDiffQueryLive),
149134 Layer.provideMerge(CheckpointStoreLive),
......@@ -248,6 +233,8 @@ export const makeRoutesLayer = Layer.mergeAll(
248233 authSessionRouteLayer,
249234 authWebSocketTokenRouteLayer,
250235 attachmentsRouteLayer,
236 orchestrationDispatchRouteLayer,
237 orchestrationSnapshotRouteLayer,
251238 otlpTracesProxyRouteLayer,
252239 projectFaviconRouteLayer,
253240 serverEnvironmentRouteLayer,
......@@ -268,12 +255,34 @@ export const makeServerLayer = Layer.unwrap(
268255 yield* startup.markHttpListening;
269256 }),
270257 );
258 const runtimeStateLayer = Layer.effectDiscard(
259 Effect.acquireRelease(
260 Effect.gen(function* () {
261 const server = yield* HttpServer.HttpServer;
262 const address = server.address;
263 if (typeof address === "string" || !("port" in address)) {
264 return;
265 }
266
267 const state = makePersistedServerRuntimeState({
268 config,
269 port: address.port,
270 });
271 yield* persistServerRuntimeState({
272 path: config.serverRuntimeStatePath,
273 state,
274 });
275 }),
276 () => clearPersistedServerRuntimeState(config.serverRuntimeStatePath),
277 ),
278 );
271279
272280 const serverApplicationLayer = Layer.mergeAll(
273281 HttpRouter.serve(makeRoutesLayer, {
274282 disableLogger: !config.logWebSocketEvents,
275283 }),
276284 httpListeningLayer,
285 runtimeStateLayer,
277286 );
278287
279288 return serverApplicationLayer.pipe(
apps/server/src/serverRuntimeStartup.ts+22-12
......@@ -18,6 +18,7 @@ import {
1818 Ref,
1919 Scope,
2020 ServiceMap,
21 Console,
2122} from "effect";
2223
2324import { ServerConfig } from "./config";
......@@ -31,12 +32,12 @@ import { ServerSettingsService } from "./serverSettings";
3132import { ServerEnvironment } from "./environment/Services/ServerEnvironment";
3233import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
3334import { ServerAuth } from "./auth/Services/ServerAuth";
34
35const isWildcardHost = (host: string | undefined): boolean =>
36 host === "0.0.0.0" || host === "::" || host === "[::]";
37
38const formatHostForUrl = (host: string): string =>
39 host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
35import {
36 formatHeadlessServeOutput,
37 formatHostForUrl,
38 isWildcardHost,
39 issueHeadlessServeAccessInfo,
40} from "./startupAccess";
4041
4142export class ServerRuntimeStartupError extends Data.TaggedError("ServerRuntimeStartupError")<{
4243 readonly message: string;
......@@ -383,14 +384,23 @@ const makeServerRuntimeStartup = Effect.gen(function* () {
383384
384385 yield* Effect.logDebug("startup phase: recording startup heartbeat");
385386 yield* launchStartupHeartbeat;
386 yield* Effect.logDebug("startup phase: browser open check");
387 const startupBrowserTarget = yield* resolveStartupBrowserTarget;
388 if (serverConfig.mode !== "desktop") {
389 yield* Effect.logInfo("Authentication required. Open T3 Code using the pairing URL.").pipe(
390 Effect.annotateLogs({ pairingUrl: startupBrowserTarget }),
387 if (serverConfig.startupPresentation === "headless") {
388 yield* Effect.logDebug("startup phase: headless access info");
389 const accessInfo = yield* issueHeadlessServeAccessInfo();
390 yield* runStartupPhase(
391 "headless.output",
392 Console.log(formatHeadlessServeOutput(accessInfo)),
391393 );
394 } else {
395 yield* Effect.logDebug("startup phase: browser open check");
396 const startupBrowserTarget = yield* resolveStartupBrowserTarget;
397 if (serverConfig.mode !== "desktop") {
398 yield* Effect.logInfo(
399 "Authentication required. Open T3 Code using the pairing URL.",
400 ).pipe(Effect.annotateLogs({ pairingUrl: startupBrowserTarget }));
401 }
402 yield* runStartupPhase("browser.open", maybeOpenBrowser(startupBrowserTarget));
392403 }
393 yield* runStartupPhase("browser.open", maybeOpenBrowser(startupBrowserTarget));
394404 yield* Effect.logDebug("startup phase: complete");
395405 }),
396406 );
apps/server/src/serverRuntimeState.ts created+77
......@@ -0,0 +1,77 @@
1import { Effect, FileSystem, Option, Path, Schema } from "effect";
2
3import { type ServerConfigShape } from "./config";
4import { formatHostForUrl, isWildcardHost } from "./startupAccess";
5
6export const PersistedServerRuntimeState = Schema.Struct({
7 version: Schema.Literal(1),
8 pid: Schema.Int,
9 host: Schema.optional(Schema.String),
10 port: Schema.Int,
11 origin: Schema.String,
12 startedAt: Schema.String,
13});
14export type PersistedServerRuntimeState = typeof PersistedServerRuntimeState.Type;
15
16const decodePersistedServerRuntimeState = Schema.decodeUnknownEffect(
17 Schema.fromJsonString(PersistedServerRuntimeState),
18);
19
20const runtimeOriginForConfig = (
21 config: Pick<ServerConfigShape, "host">,
22 port: number,
23): PersistedServerRuntimeState["origin"] => {
24 const hostname =
25 config.host && !isWildcardHost(config.host) ? formatHostForUrl(config.host) : "127.0.0.1";
26 return `http://${hostname}:${port}`;
27};
28
29export const makePersistedServerRuntimeState = (input: {
30 readonly config: Pick<ServerConfigShape, "host">;
31 readonly port: number;
32}): PersistedServerRuntimeState => ({
33 version: 1,
34 pid: process.pid,
35 ...(input.config.host ? { host: input.config.host } : {}),
36 port: input.port,
37 origin: runtimeOriginForConfig(input.config, input.port),
38 startedAt: new Date().toISOString(),
39});
40
41export const persistServerRuntimeState = (input: {
42 readonly path: string;
43 readonly state: PersistedServerRuntimeState;
44}) =>
45 Effect.gen(function* () {
46 const fs = yield* FileSystem.FileSystem;
47 const pathService = yield* Path.Path;
48 const tempPath = `${input.path}.${process.pid}.${Date.now()}.tmp`;
49 return yield* fs.makeDirectory(pathService.dirname(input.path), { recursive: true }).pipe(
50 Effect.flatMap(() => fs.writeFileString(tempPath, `${JSON.stringify(input.state)}\n`)),
51 Effect.flatMap(() => fs.rename(tempPath, input.path)),
52 Effect.ensuring(fs.remove(tempPath, { force: true }).pipe(Effect.ignore({ log: true }))),
53 );
54 });
55
56export const clearPersistedServerRuntimeState = (path: string) =>
57 Effect.gen(function* () {
58 const fs = yield* FileSystem.FileSystem;
59 yield* fs.remove(path, { force: true }).pipe(Effect.ignore({ log: true }));
60 });
61
62export const readPersistedServerRuntimeState = (path: string) =>
63 Effect.gen(function* () {
64 const fs = yield* FileSystem.FileSystem;
65 const exists = yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false));
66 if (!exists) {
67 return Option.none<PersistedServerRuntimeState>();
68 }
69
70 const raw = yield* fs.readFileString(path).pipe(Effect.orElseSucceed(() => ""));
71 const trimmed = raw.trim();
72 if (trimmed.length === 0) {
73 return Option.none<PersistedServerRuntimeState>();
74 }
75
76 return yield* decodePersistedServerRuntimeState(trimmed).pipe(Effect.option);
77 });
apps/server/src/startupAccess.test.ts created+79
......@@ -0,0 +1,79 @@
1import { assert, expect, it } from "@effect/vitest";
2
3import {
4 buildPairingUrl,
5 formatHeadlessServeOutput,
6 renderTerminalQrCode,
7 resolveHeadlessConnectionHost,
8 resolveHeadlessConnectionString,
9 resolveListeningPort,
10} from "./startupAccess";
11
12it("prefers localhost when no explicit host is configured", () => {
13 expect(resolveHeadlessConnectionHost(undefined)).toBe("localhost");
14 expect(resolveHeadlessConnectionString(undefined, 3773)).toBe("http://localhost:3773");
15});
16
17it("keeps explicit bind hosts in the connection string", () => {
18 expect(resolveHeadlessConnectionString("127.0.0.1", 3773)).toBe("http://127.0.0.1:3773");
19 expect(resolveHeadlessConnectionString("::1", 3773)).toBe("http://[::1]:3773");
20});
21
22it("resolves wildcard hosts to a concrete external interface when one is available", () => {
23 const connectionString = resolveHeadlessConnectionString("0.0.0.0", 3773, {
24 en0: [
25 {
26 address: "192.168.1.42",
27 netmask: "255.255.255.0",
28 family: "IPv4",
29 mac: "00:00:00:00:00:00",
30 internal: false,
31 cidr: "192.168.1.42/24",
32 },
33 ],
34 lo0: [
35 {
36 address: "127.0.0.1",
37 netmask: "255.0.0.0",
38 family: "IPv4",
39 mac: "00:00:00:00:00:00",
40 internal: true,
41 cidr: "127.0.0.1/8",
42 },
43 ],
44 });
45
46 expect(connectionString).toBe("http://192.168.1.42:3773");
47});
48
49it("prefers the actual bound port when an http server address is available", () => {
50 expect(resolveListeningPort({ port: 4123 }, 3773)).toBe(4123);
51 expect(resolveListeningPort("pipe", 3773)).toBe(3773);
52 expect(resolveListeningPort(null, 3773)).toBe(3773);
53});
54
55it("builds a pairing URL that embeds the token in the hash", () => {
56 expect(buildPairingUrl("http://192.168.1.42:3773", "PAIRCODE")).toBe(
57 "http://192.168.1.42:3773/pair#token=PAIRCODE",
58 );
59});
60
61it("renders terminal QR codes as a multi-line unicode block grid", () => {
62 const qrCode = renderTerminalQrCode("http://192.168.1.42:3773/pair#token=PAIRCODE");
63
64 assert.isTrue(qrCode.includes("█"));
65 assert.isTrue(qrCode.split("\n").length > 10);
66});
67
68it("formats headless serve output with the connection string, token, pairing url, and qr code", () => {
69 const output = formatHeadlessServeOutput({
70 connectionString: "http://192.168.1.42:3773",
71 token: "PAIRCODE",
72 pairingUrl: "http://192.168.1.42:3773/pair#token=PAIRCODE",
73 });
74
75 expect(output).toContain("Connection string: http://192.168.1.42:3773");
76 expect(output).toContain("Token: PAIRCODE");
77 expect(output).toContain("Pairing URL: http://192.168.1.42:3773/pair#token=PAIRCODE");
78 assert.isTrue(output.includes("█") || output.includes("▀") || output.includes("▄"));
79});
apps/server/src/startupAccess.ts created+148
......@@ -0,0 +1,148 @@
1import { networkInterfaces } from "node:os";
2
3import { QrCode } from "@t3tools/shared/qrCode";
4import { Effect } from "effect";
5import { HttpServer } from "effect/unstable/http";
6
7import { ServerConfig } from "./config";
8import { ServerAuth } from "./auth/Services/ServerAuth";
9
10export interface HeadlessServeAccessInfo {
11 readonly connectionString: string;
12 readonly token: string;
13 readonly pairingUrl: string;
14}
15
16type NetworkInterfacesMap = ReturnType<typeof networkInterfaces>;
17
18export const isLoopbackHost = (host: string | undefined): boolean => {
19 if (!host || host.length === 0) {
20 return true;
21 }
22
23 return (
24 host === "localhost" ||
25 host === "127.0.0.1" ||
26 host === "::1" ||
27 host === "[::1]" ||
28 host.startsWith("127.")
29 );
30};
31
32export const isWildcardHost = (host: string | undefined): boolean =>
33 host === "0.0.0.0" || host === "::" || host === "[::]";
34
35export const formatHostForUrl = (host: string): string =>
36 host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
37
38const normalizeHost = (host: string): string =>
39 host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
40
41const isIpv4Family = (family: string | number): boolean => family === "IPv4" || family === 4;
42
43const isIpv6Family = (family: string | number): boolean => family === "IPv6" || family === 6;
44
45export const resolveHeadlessConnectionHost = (
46 host: string | undefined,
47 interfaces: NetworkInterfacesMap = networkInterfaces(),
48): string => {
49 if (!host) {
50 return "localhost";
51 }
52
53 if (!isWildcardHost(host)) {
54 return normalizeHost(host);
55 }
56
57 const interfaceEntries = Object.values(interfaces).flatMap((entries) => entries ?? []);
58 const externalIpv4 = interfaceEntries.find(
59 (entry) => !entry.internal && isIpv4Family(entry.family),
60 );
61 if (externalIpv4) {
62 return externalIpv4.address;
63 }
64
65 const externalIpv6 = interfaceEntries.find(
66 (entry) => !entry.internal && isIpv6Family(entry.family),
67 );
68 return externalIpv6 ? normalizeHost(externalIpv6.address) : "localhost";
69};
70
71export const resolveHeadlessConnectionString = (
72 host: string | undefined,
73 port: number,
74 interfaces: NetworkInterfacesMap = networkInterfaces(),
75): string => {
76 const connectionHost = resolveHeadlessConnectionHost(host, interfaces);
77 return `http://${formatHostForUrl(connectionHost)}:${port}`;
78};
79
80export const resolveListeningPort = (address: unknown, fallbackPort: number): number => {
81 if (
82 typeof address === "object" &&
83 address !== null &&
84 "port" in address &&
85 typeof address.port === "number"
86 ) {
87 return address.port;
88 }
89 return fallbackPort;
90};
91
92export const buildPairingUrl = (connectionString: string, token: string): string => {
93 const url = new URL(connectionString);
94 url.pathname = "/pair";
95 url.searchParams.delete("token");
96 url.hash = new URLSearchParams([["token", token]]).toString();
97 return url.toString();
98};
99
100export const renderTerminalQrCode = (value: string, margin = 2): string => {
101 const qrCode = QrCode.encodeText(value, QrCode.Ecc.MEDIUM);
102 const rows: Array<string> = [];
103 const isDark = (x: number, y: number): boolean =>
104 x >= 0 && x < qrCode.size && y >= 0 && y < qrCode.size && qrCode.getModule(x, y);
105
106 for (let y = -margin; y < qrCode.size + margin; y += 2) {
107 let row = "";
108
109 for (let x = -margin; x < qrCode.size + margin; x += 1) {
110 const topDark = isDark(x, y);
111 const bottomDark = isDark(x, y + 1);
112
113 row += topDark ? (bottomDark ? "█" : "▀") : bottomDark ? "▄" : " ";
114 }
115
116 rows.push(row);
117 }
118
119 return rows.join("\n");
120};
121
122export const formatHeadlessServeOutput = (accessInfo: HeadlessServeAccessInfo): string =>
123 [
124 "T3 Code server is ready.",
125 `Connection string: ${accessInfo.connectionString}`,
126 `Token: ${accessInfo.token}`,
127 `Pairing URL: ${accessInfo.pairingUrl}`,
128 "",
129 renderTerminalQrCode(accessInfo.pairingUrl),
130 "",
131 ].join("\n");
132
133export const issueHeadlessServeAccessInfo = Effect.fn("issueHeadlessServeAccessInfo")(function* () {
134 const serverConfig = yield* ServerConfig;
135 const httpServer = yield* HttpServer.HttpServer;
136 const serverAuth = yield* ServerAuth;
137 const connectionString = resolveHeadlessConnectionString(
138 serverConfig.host,
139 resolveListeningPort(httpServer.address, serverConfig.port),
140 );
141 const issued = yield* serverAuth.issuePairingCredential({ role: "owner" });
142
143 return {
144 connectionString,
145 token: issued.credential,
146 pairingUrl: buildPairingUrl(connectionString, issued.credential),
147 } satisfies HeadlessServeAccessInfo;
148});
apps/web/src/components/ui/qr-code.tsx+1-1
......@@ -1,5 +1,5 @@
11import { memo } from "react";
2import { QrCode } from "../../lib/vendor/qrcodegen";
2import { QrCode } from "@t3tools/shared/qrCode";
33
44type QRCodeSvgProps = {
55 value: string;
apps/web/src/lib/vendor/qrcodegen.ts deleted-996
......@@ -1,996 +0,0 @@
1// @ts-nocheck
2/* oxlint-disable eslint/no-useless-escape */
3/*
4 * QR Code generator library (TypeScript)
5 *
6 * Copyright (c) Project Nayuki. (MIT License)
7 * https://www.nayuki.io/page/qr-code-generator-library
8 *
9 * Permission is hereby granted, free of charge, to any person obtaining a copy of
10 * this software and associated documentation files (the "Software"), to deal in
11 * the Software without restriction, including without limitation the rights to
12 * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
13 * the Software, and to permit persons to whom the Software is furnished to do so,
14 * subject to the following conditions:
15 * - The above copyright notice and this permission notice shall be included in
16 * all copies or substantial portions of the Software.
17 * - The Software is provided "as is", without warranty of any kind, express or
18 * implied, including but not limited to the warranties of merchantability,
19 * fitness for a particular purpose and noninfringement. In no event shall the
20 * authors or copyright holders be liable for any claim, damages or other
21 * liability, whether in an action of contract, tort or otherwise, arising from,
22 * out of or in connection with the Software or the use or other dealings in the
23 * Software.
24 */
25
26"use strict";
27
28
29namespace qrcodegen {
30
31 type bit = number;
32 type byte = number;
33 type int = number;
34
35
36 /*---- QR Code symbol class ----*/
37
38 /*
39 * A QR Code symbol, which is a type of two-dimension barcode.
40 * Invented by Denso Wave and described in the ISO/IEC 18004 standard.
41 * Instances of this class represent an immutable square grid of dark and light cells.
42 * The class provides static factory functions to create a QR Code from text or binary data.
43 * The class covers the QR Code Model 2 specification, supporting all versions (sizes)
44 * from 1 to 40, all 4 error correction levels, and 4 character encoding modes.
45 *
46 * Ways to create a QR Code object:
47 * - High level: Take the payload data and call QrCode.encodeText() or QrCode.encodeBinary().
48 * - Mid level: Custom-make the list of segments and call QrCode.encodeSegments().
49 * - Low level: Custom-make the array of data codeword bytes (including
50 * segment headers and final padding, excluding error correction codewords),
51 * supply the appropriate version number, and call the QrCode() constructor.
52 * (Note that all ways require supplying the desired error correction level.)
53 */
54 export class QrCode {
55
56 /*-- Static factory functions (high level) --*/
57
58 // Returns a QR Code representing the given Unicode text string at the given error correction level.
59 // As a conservative upper bound, this function is guaranteed to succeed for strings that have 738 or fewer
60 // Unicode code points (not UTF-16 code units) if the low error correction level is used. The smallest possible
61 // QR Code version is automatically chosen for the output. The ECC level of the result may be higher than the
62 // ecl argument if it can be done without increasing the version.
63 public static encodeText(text: string, ecl: QrCode.Ecc): QrCode {
64 const segs: Array<QrSegment> = qrcodegen.QrSegment.makeSegments(text);
65 return QrCode.encodeSegments(segs, ecl);
66 }
67
68
69 // Returns a QR Code representing the given binary data at the given error correction level.
70 // This function always encodes using the binary segment mode, not any text mode. The maximum number of
71 // bytes allowed is 2953. The smallest possible QR Code version is automatically chosen for the output.
72 // The ECC level of the result may be higher than the ecl argument if it can be done without increasing the version.
73 public static encodeBinary(data: Readonly<Array<byte>>, ecl: QrCode.Ecc): QrCode {
74 const seg: QrSegment = qrcodegen.QrSegment.makeBytes(data);
75 return QrCode.encodeSegments([seg], ecl);
76 }
77
78
79 /*-- Static factory functions (mid level) --*/
80
81 // Returns a QR Code representing the given segments with the given encoding parameters.
82 // The smallest possible QR Code version within the given range is automatically
83 // chosen for the output. Iff boostEcl is true, then the ECC level of the result
84 // may be higher than the ecl argument if it can be done without increasing the
85 // version. The mask number is either between 0 to 7 (inclusive) to force that
86 // mask, or -1 to automatically choose an appropriate mask (which may be slow).
87 // This function allows the user to create a custom sequence of segments that switches
88 // between modes (such as alphanumeric and byte) to encode text in less space.
89 // This is a mid-level API; the high-level API is encodeText() and encodeBinary().
90 public static encodeSegments(segs: Readonly<Array<QrSegment>>, ecl: QrCode.Ecc,
91 minVersion: int = 1, maxVersion: int = 40,
92 mask: int = -1, boostEcl: boolean = true): QrCode {
93
94 if (!(QrCode.MIN_VERSION <= minVersion && minVersion <= maxVersion && maxVersion <= QrCode.MAX_VERSION)
95 || mask < -1 || mask > 7)
96 throw new RangeError("Invalid value");
97
98 // Find the minimal version number to use
99 let version: int;
100 let dataUsedBits: int;
101 for (version = minVersion; ; version++) {
102 const dataCapacityBits: int = QrCode.getNumDataCodewords(version, ecl) * 8; // Number of data bits available
103 const usedBits: number = QrSegment.getTotalBits(segs, version);
104 if (usedBits <= dataCapacityBits) {
105 dataUsedBits = usedBits;
106 break; // This version number is found to be suitable
107 }
108 if (version >= maxVersion) // All versions in the range could not fit the given data
109 throw new RangeError("Data too long");
110 }
111
112 // Increase the error correction level while the data still fits in the current version number
113 for (const newEcl of [QrCode.Ecc.MEDIUM, QrCode.Ecc.QUARTILE, QrCode.Ecc.HIGH]) { // From low to high
114 if (boostEcl && dataUsedBits <= QrCode.getNumDataCodewords(version, newEcl) * 8)
115 ecl = newEcl;
116 }
117
118 // Concatenate all segments to create the data bit string
119 let bb: Array<bit> = []
120 for (const seg of segs) {
121 appendBits(seg.mode.modeBits, 4, bb);
122 appendBits(seg.numChars, seg.mode.numCharCountBits(version), bb);
123 for (const b of seg.getData())
124 bb.push(b);
125 }
126 assert(bb.length == dataUsedBits);
127
128 // Add terminator and pad up to a byte if applicable
129 const dataCapacityBits: int = QrCode.getNumDataCodewords(version, ecl) * 8;
130 assert(bb.length <= dataCapacityBits);
131 appendBits(0, Math.min(4, dataCapacityBits - bb.length), bb);
132 appendBits(0, (8 - bb.length % 8) % 8, bb);
133 assert(bb.length % 8 == 0);
134
135 // Pad with alternating bytes until data capacity is reached
136 for (let padByte = 0xEC; bb.length < dataCapacityBits; padByte ^= 0xEC ^ 0x11)
137 appendBits(padByte, 8, bb);
138
139 // Pack bits into bytes in big endian
140 let dataCodewords: Array<byte> = [];
141 while (dataCodewords.length * 8 < bb.length)
142 dataCodewords.push(0);
143 bb.forEach((b: bit, i: int) =>
144 dataCodewords[i >>> 3] |= b << (7 - (i & 7)));
145
146 // Create the QR Code object
147 return new QrCode(version, ecl, dataCodewords, mask);
148 }
149
150
151 /*-- Fields --*/
152
153 // The width and height of this QR Code, measured in modules, between
154 // 21 and 177 (inclusive). This is equal to version * 4 + 17.
155 public readonly size: int;
156
157 // The index of the mask pattern used in this QR Code, which is between 0 and 7 (inclusive).
158 // Even if a QR Code is created with automatic masking requested (mask = -1),
159 // the resulting object still has a mask value between 0 and 7.
160 public readonly mask: int;
161
162 // The modules of this QR Code (false = light, true = dark).
163 // Immutable after constructor finishes. Accessed through getModule().
164 private readonly modules : Array<Array<boolean>> = [];
165
166 // Indicates function modules that are not subjected to masking. Discarded when constructor finishes.
167 private readonly isFunction: Array<Array<boolean>> = [];
168
169
170 /*-- Constructor (low level) and fields --*/
171
172 // Creates a new QR Code with the given version number,
173 // error correction level, data codeword bytes, and mask number.
174 // This is a low-level API that most users should not use directly.
175 // A mid-level API is the encodeSegments() function.
176 public constructor(
177 // The version number of this QR Code, which is between 1 and 40 (inclusive).
178 // This determines the size of this barcode.
179 public readonly version: int,
180
181 // The error correction level used in this QR Code.
182 public readonly errorCorrectionLevel: QrCode.Ecc,
183
184 dataCodewords: Readonly<Array<byte>>,
185
186 msk: int) {
187
188 // Check scalar arguments
189 if (version < QrCode.MIN_VERSION || version > QrCode.MAX_VERSION)
190 throw new RangeError("Version value out of range");
191 if (msk < -1 || msk > 7)
192 throw new RangeError("Mask value out of range");
193 this.size = version * 4 + 17;
194
195 // Initialize both grids to be size*size arrays of Boolean false
196 let row: Array<boolean> = [];
197 for (let i = 0; i < this.size; i++)
198 row.push(false);
199 for (let i = 0; i < this.size; i++) {
200 this.modules .push(row.slice()); // Initially all light
201 this.isFunction.push(row.slice());
202 }
203
204 // Compute ECC, draw modules
205 this.drawFunctionPatterns();
206 const allCodewords: Array<byte> = this.addEccAndInterleave(dataCodewords);
207 this.drawCodewords(allCodewords);
208
209 // Do masking
210 if (msk == -1) { // Automatically choose best mask
211 let minPenalty: int = 1000000000;
212 for (let i = 0; i < 8; i++) {
213 this.applyMask(i);
214 this.drawFormatBits(i);
215 const penalty: int = this.getPenaltyScore();
216 if (penalty < minPenalty) {
217 msk = i;
218 minPenalty = penalty;
219 }
220 this.applyMask(i); // Undoes the mask due to XOR
221 }
222 }
223 assert(0 <= msk && msk <= 7);
224 this.mask = msk;
225 this.applyMask(msk); // Apply the final choice of mask
226 this.drawFormatBits(msk); // Overwrite old format bits
227
228 this.isFunction = [];
229 }
230
231
232 /*-- Accessor methods --*/
233
234 // Returns the color of the module (pixel) at the given coordinates, which is false
235 // for light or true for dark. The top left corner has the coordinates (x=0, y=0).
236 // If the given coordinates are out of bounds, then false (light) is returned.
237 public getModule(x: int, y: int): boolean {
238 return 0 <= x && x < this.size && 0 <= y && y < this.size && this.modules[y][x];
239 }
240
241
242 /*-- Private helper methods for constructor: Drawing function modules --*/
243
244 // Reads this object's version field, and draws and marks all function modules.
245 private drawFunctionPatterns(): void {
246 // Draw horizontal and vertical timing patterns
247 for (let i = 0; i < this.size; i++) {
248 this.setFunctionModule(6, i, i % 2 == 0);
249 this.setFunctionModule(i, 6, i % 2 == 0);
250 }
251
252 // Draw 3 finder patterns (all corners except bottom right; overwrites some timing modules)
253 this.drawFinderPattern(3, 3);
254 this.drawFinderPattern(this.size - 4, 3);
255 this.drawFinderPattern(3, this.size - 4);
256
257 // Draw numerous alignment patterns
258 const alignPatPos: Array<int> = this.getAlignmentPatternPositions();
259 const numAlign: int = alignPatPos.length;
260 for (let i = 0; i < numAlign; i++) {
261 for (let j = 0; j < numAlign; j++) {
262 // Don't draw on the three finder corners
263 if (!(i == 0 && j == 0 || i == 0 && j == numAlign - 1 || i == numAlign - 1 && j == 0))
264 this.drawAlignmentPattern(alignPatPos[i], alignPatPos[j]);
265 }
266 }
267
268 // Draw configuration data
269 this.drawFormatBits(0); // Dummy mask value; overwritten later in the constructor
270 this.drawVersion();
271 }
272
273
274 // Draws two copies of the format bits (with its own error correction code)
275 // based on the given mask and this object's error correction level field.
276 private drawFormatBits(mask: int): void {
277 // Calculate error correction code and pack bits
278 const data: int = this.errorCorrectionLevel.formatBits << 3 | mask; // errCorrLvl is uint2, mask is uint3
279 let rem: int = data;
280 for (let i = 0; i < 10; i++)
281 rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
282 const bits = (data << 10 | rem) ^ 0x5412; // uint15
283 assert(bits >>> 15 == 0);
284
285 // Draw first copy
286 for (let i = 0; i <= 5; i++)
287 this.setFunctionModule(8, i, getBit(bits, i));
288 this.setFunctionModule(8, 7, getBit(bits, 6));
289 this.setFunctionModule(8, 8, getBit(bits, 7));
290 this.setFunctionModule(7, 8, getBit(bits, 8));
291 for (let i = 9; i < 15; i++)
292 this.setFunctionModule(14 - i, 8, getBit(bits, i));
293
294 // Draw second copy
295 for (let i = 0; i < 8; i++)
296 this.setFunctionModule(this.size - 1 - i, 8, getBit(bits, i));
297 for (let i = 8; i < 15; i++)
298 this.setFunctionModule(8, this.size - 15 + i, getBit(bits, i));
299 this.setFunctionModule(8, this.size - 8, true); // Always dark
300 }
301
302
303 // Draws two copies of the version bits (with its own error correction code),
304 // based on this object's version field, iff 7 <= version <= 40.
305 private drawVersion(): void {
306 if (this.version < 7)
307 return;
308
309 // Calculate error correction code and pack bits
310 let rem: int = this.version; // version is uint6, in the range [7, 40]
311 for (let i = 0; i < 12; i++)
312 rem = (rem << 1) ^ ((rem >>> 11) * 0x1F25);
313 const bits: int = this.version << 12 | rem; // uint18
314 assert(bits >>> 18 == 0);
315
316 // Draw two copies
317 for (let i = 0; i < 18; i++) {
318 const color: boolean = getBit(bits, i);
319 const a: int = this.size - 11 + i % 3;
320 const b: int = Math.floor(i / 3);
321 this.setFunctionModule(a, b, color);
322 this.setFunctionModule(b, a, color);
323 }
324 }
325
326
327 // Draws a 9*9 finder pattern including the border separator,
328 // with the center module at (x, y). Modules can be out of bounds.
329 private drawFinderPattern(x: int, y: int): void {
330 for (let dy = -4; dy <= 4; dy++) {
331 for (let dx = -4; dx <= 4; dx++) {
332 const dist: int = Math.max(Math.abs(dx), Math.abs(dy)); // Chebyshev/infinity norm
333 const xx: int = x + dx;
334 const yy: int = y + dy;
335 if (0 <= xx && xx < this.size && 0 <= yy && yy < this.size)
336 this.setFunctionModule(xx, yy, dist != 2 && dist != 4);
337 }
338 }
339 }
340
341
342 // Draws a 5*5 alignment pattern, with the center module
343 // at (x, y). All modules must be in bounds.
344 private drawAlignmentPattern(x: int, y: int): void {
345 for (let dy = -2; dy <= 2; dy++) {
346 for (let dx = -2; dx <= 2; dx++)
347 this.setFunctionModule(x + dx, y + dy, Math.max(Math.abs(dx), Math.abs(dy)) != 1);
348 }
349 }
350
351
352 // Sets the color of a module and marks it as a function module.
353 // Only used by the constructor. Coordinates must be in bounds.
354 private setFunctionModule(x: int, y: int, isDark: boolean): void {
355 this.modules[y][x] = isDark;
356 this.isFunction[y][x] = true;
357 }
358
359
360 /*-- Private helper methods for constructor: Codewords and masking --*/
361
362 // Returns a new byte string representing the given data with the appropriate error correction
363 // codewords appended to it, based on this object's version and error correction level.
364 private addEccAndInterleave(data: Readonly<Array<byte>>): Array<byte> {
365 const ver: int = this.version;
366 const ecl: QrCode.Ecc = this.errorCorrectionLevel;
367 if (data.length != QrCode.getNumDataCodewords(ver, ecl))
368 throw new RangeError("Invalid argument");
369
370 // Calculate parameter numbers
371 const numBlocks: int = QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver];
372 const blockEccLen: int = QrCode.ECC_CODEWORDS_PER_BLOCK [ecl.ordinal][ver];
373 const rawCodewords: int = Math.floor(QrCode.getNumRawDataModules(ver) / 8);
374 const numShortBlocks: int = numBlocks - rawCodewords % numBlocks;
375 const shortBlockLen: int = Math.floor(rawCodewords / numBlocks);
376
377 // Split data into blocks and append ECC to each block
378 let blocks: Array<Array<byte>> = [];
379 const rsDiv: Array<byte> = QrCode.reedSolomonComputeDivisor(blockEccLen);
380 for (let i = 0, k = 0; i < numBlocks; i++) {
381 let dat: Array<byte> = data.slice(k, k + shortBlockLen - blockEccLen + (i < numShortBlocks ? 0 : 1));
382 k += dat.length;
383 const ecc: Array<byte> = QrCode.reedSolomonComputeRemainder(dat, rsDiv);
384 if (i < numShortBlocks)
385 dat.push(0);
386 blocks.push(dat.concat(ecc));
387 }
388
389 // Interleave (not concatenate) the bytes from every block into a single sequence
390 let result: Array<byte> = [];
391 for (let i = 0; i < blocks[0].length; i++) {
392 blocks.forEach((block, j) => {
393 // Skip the padding byte in short blocks
394 if (i != shortBlockLen - blockEccLen || j >= numShortBlocks)
395 result.push(block[i]);
396 });
397 }
398 assert(result.length == rawCodewords);
399 return result;
400 }
401
402
403 // Draws the given sequence of 8-bit codewords (data and error correction) onto the entire
404 // data area of this QR Code. Function modules need to be marked off before this is called.
405 private drawCodewords(data: Readonly<Array<byte>>): void {
406 if (data.length != Math.floor(QrCode.getNumRawDataModules(this.version) / 8))
407 throw new RangeError("Invalid argument");
408 let i: int = 0; // Bit index into the data
409 // Do the funny zigzag scan
410 for (let right = this.size - 1; right >= 1; right -= 2) { // Index of right column in each column pair
411 if (right == 6)
412 right = 5;
413 for (let vert = 0; vert < this.size; vert++) { // Vertical counter
414 for (let j = 0; j < 2; j++) {
415 const x: int = right - j; // Actual x coordinate
416 const upward: boolean = ((right + 1) & 2) == 0;
417 const y: int = upward ? this.size - 1 - vert : vert; // Actual y coordinate
418 if (!this.isFunction[y][x] && i < data.length * 8) {
419 this.modules[y][x] = getBit(data[i >>> 3], 7 - (i & 7));
420 i++;
421 }
422 // If this QR Code has any remainder bits (0 to 7), they were assigned as
423 // 0/false/light by the constructor and are left unchanged by this method
424 }
425 }
426 }
427 assert(i == data.length * 8);
428 }
429
430
431 // XORs the codeword modules in this QR Code with the given mask pattern.
432 // The function modules must be marked and the codeword bits must be drawn
433 // before masking. Due to the arithmetic of XOR, calling applyMask() with
434 // the same mask value a second time will undo the mask. A final well-formed
435 // QR Code needs exactly one (not zero, two, etc.) mask applied.
436 private applyMask(mask: int): void {
437 if (mask < 0 || mask > 7)
438 throw new RangeError("Mask value out of range");
439 for (let y = 0; y < this.size; y++) {
440 for (let x = 0; x < this.size; x++) {
441 let invert: boolean;
442 switch (mask) {
443 case 0: invert = (x + y) % 2 == 0; break;
444 case 1: invert = y % 2 == 0; break;
445 case 2: invert = x % 3 == 0; break;
446 case 3: invert = (x + y) % 3 == 0; break;
447 case 4: invert = (Math.floor(x / 3) + Math.floor(y / 2)) % 2 == 0; break;
448 case 5: invert = x * y % 2 + x * y % 3 == 0; break;
449 case 6: invert = (x * y % 2 + x * y % 3) % 2 == 0; break;
450 case 7: invert = ((x + y) % 2 + x * y % 3) % 2 == 0; break;
451 default: throw new Error("Unreachable");
452 }
453 if (!this.isFunction[y][x] && invert)
454 this.modules[y][x] = !this.modules[y][x];
455 }
456 }
457 }
458
459
460 // Calculates and returns the penalty score based on state of this QR Code's current modules.
461 // This is used by the automatic mask choice algorithm to find the mask pattern that yields the lowest score.
462 private getPenaltyScore(): int {
463 let result: int = 0;
464
465 // Adjacent modules in row having same color, and finder-like patterns
466 for (let y = 0; y < this.size; y++) {
467 let runColor = false;
468 let runX = 0;
469 let runHistory = [0,0,0,0,0,0,0];
470 for (let x = 0; x < this.size; x++) {
471 if (this.modules[y][x] == runColor) {
472 runX++;
473 if (runX == 5)
474 result += QrCode.PENALTY_N1;
475 else if (runX > 5)
476 result++;
477 } else {
478 this.finderPenaltyAddHistory(runX, runHistory);
479 if (!runColor)
480 result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3;
481 runColor = this.modules[y][x];
482 runX = 1;
483 }
484 }
485 result += this.finderPenaltyTerminateAndCount(runColor, runX, runHistory) * QrCode.PENALTY_N3;
486 }
487 // Adjacent modules in column having same color, and finder-like patterns
488 for (let x = 0; x < this.size; x++) {
489 let runColor = false;
490 let runY = 0;
491 let runHistory = [0,0,0,0,0,0,0];
492 for (let y = 0; y < this.size; y++) {
493 if (this.modules[y][x] == runColor) {
494 runY++;
495 if (runY == 5)
496 result += QrCode.PENALTY_N1;
497 else if (runY > 5)
498 result++;
499 } else {
500 this.finderPenaltyAddHistory(runY, runHistory);
501 if (!runColor)
502 result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3;
503 runColor = this.modules[y][x];
504 runY = 1;
505 }
506 }
507 result += this.finderPenaltyTerminateAndCount(runColor, runY, runHistory) * QrCode.PENALTY_N3;
508 }
509
510 // 2*2 blocks of modules having same color
511 for (let y = 0; y < this.size - 1; y++) {
512 for (let x = 0; x < this.size - 1; x++) {
513 const color: boolean = this.modules[y][x];
514 if ( color == this.modules[y][x + 1] &&
515 color == this.modules[y + 1][x] &&
516 color == this.modules[y + 1][x + 1])
517 result += QrCode.PENALTY_N2;
518 }
519 }
520
521 // Balance of dark and light modules
522 let dark: int = 0;
523 for (const row of this.modules)
524 dark = row.reduce((sum, color) => sum + (color ? 1 : 0), dark);
525 const total: int = this.size * this.size; // Note that size is odd, so dark/total != 1/2
526 // Compute the smallest integer k >= 0 such that (45-5k)% <= dark/total <= (55+5k)%
527 const k: int = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1;
528 assert(0 <= k && k <= 9);
529 result += k * QrCode.PENALTY_N4;
530 assert(0 <= result && result <= 2568888); // Non-tight upper bound based on default values of PENALTY_N1, ..., N4
531 return result;
532 }
533
534
535 /*-- Private helper functions --*/
536
537 // Returns an ascending list of positions of alignment patterns for this version number.
538 // Each position is in the range [0,177), and are used on both the x and y axes.
539 // This could be implemented as lookup table of 40 variable-length lists of integers.
540 private getAlignmentPatternPositions(): Array<int> {
541 if (this.version == 1)
542 return [];
543 else {
544 const numAlign: int = Math.floor(this.version / 7) + 2;
545 const step: int = (this.version == 32) ? 26 :
546 Math.ceil((this.version * 4 + 4) / (numAlign * 2 - 2)) * 2;
547 let result: Array<int> = [6];
548 for (let pos = this.size - 7; result.length < numAlign; pos -= step)
549 result.splice(1, 0, pos);
550 return result;
551 }
552 }
553
554
555 // Returns the number of data bits that can be stored in a QR Code of the given version number, after
556 // all function modules are excluded. This includes remainder bits, so it might not be a multiple of 8.
557 // The result is in the range [208, 29648]. This could be implemented as a 40-entry lookup table.
558 private static getNumRawDataModules(ver: int): int {
559 if (ver < QrCode.MIN_VERSION || ver > QrCode.MAX_VERSION)
560 throw new RangeError("Version number out of range");
561 let result: int = (16 * ver + 128) * ver + 64;
562 if (ver >= 2) {
563 const numAlign: int = Math.floor(ver / 7) + 2;
564 result -= (25 * numAlign - 10) * numAlign - 55;
565 if (ver >= 7)
566 result -= 36;
567 }
568 assert(208 <= result && result <= 29648);
569 return result;
570 }
571
572
573 // Returns the number of 8-bit data (i.e. not error correction) codewords contained in any
574 // QR Code of the given version number and error correction level, with remainder bits discarded.
575 // This stateless pure function could be implemented as a (40*4)-cell lookup table.
576 private static getNumDataCodewords(ver: int, ecl: QrCode.Ecc): int {
577 return Math.floor(QrCode.getNumRawDataModules(ver) / 8) -
578 QrCode.ECC_CODEWORDS_PER_BLOCK [ecl.ordinal][ver] *
579 QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver];
580 }
581
582
583 // Returns a Reed-Solomon ECC generator polynomial for the given degree. This could be
584 // implemented as a lookup table over all possible parameter values, instead of as an algorithm.
585 private static reedSolomonComputeDivisor(degree: int): Array<byte> {
586 if (degree < 1 || degree > 255)
587 throw new RangeError("Degree out of range");
588 // Polynomial coefficients are stored from highest to lowest power, excluding the leading term which is always 1.
589 // For example the polynomial x^3 + 255x^2 + 8x + 93 is stored as the uint8 array [255, 8, 93].
590 let result: Array<byte> = [];
591 for (let i = 0; i < degree - 1; i++)
592 result.push(0);
593 result.push(1); // Start off with the monomial x^0
594
595 // Compute the product polynomial (x - r^0) * (x - r^1) * (x - r^2) * ... * (x - r^{degree-1}),
596 // and drop the highest monomial term which is always 1x^degree.
597 // Note that r = 0x02, which is a generator element of this field GF(2^8/0x11D).
598 let root = 1;
599 for (let i = 0; i < degree; i++) {
600 // Multiply the current product by (x - r^i)
601 for (let j = 0; j < result.length; j++) {
602 result[j] = QrCode.reedSolomonMultiply(result[j], root);
603 if (j + 1 < result.length)
604 result[j] ^= result[j + 1];
605 }
606 root = QrCode.reedSolomonMultiply(root, 0x02);
607 }
608 return result;
609 }
610
611
612 // Returns the Reed-Solomon error correction codeword for the given data and divisor polynomials.
613 private static reedSolomonComputeRemainder(data: Readonly<Array<byte>>, divisor: Readonly<Array<byte>>): Array<byte> {
614 let result: Array<byte> = divisor.map(_ => 0);
615 for (const b of data) { // Polynomial division
616 const factor: byte = b ^ (result.shift() as byte);
617 result.push(0);
618 divisor.forEach((coef, i) =>
619 result[i] ^= QrCode.reedSolomonMultiply(coef, factor));
620 }
621 return result;
622 }
623
624
625 // Returns the product of the two given field elements modulo GF(2^8/0x11D). The arguments and result
626 // are unsigned 8-bit integers. This could be implemented as a lookup table of 256*256 entries of uint8.
627 private static reedSolomonMultiply(x: byte, y: byte): byte {
628 if (x >>> 8 != 0 || y >>> 8 != 0)
629 throw new RangeError("Byte out of range");
630 // Russian peasant multiplication
631 let z: int = 0;
632 for (let i = 7; i >= 0; i--) {
633 z = (z << 1) ^ ((z >>> 7) * 0x11D);
634 z ^= ((y >>> i) & 1) * x;
635 }
636 assert(z >>> 8 == 0);
637 return z as byte;
638 }
639
640
641 // Can only be called immediately after a light run is added, and
642 // returns either 0, 1, or 2. A helper function for getPenaltyScore().
643 private finderPenaltyCountPatterns(runHistory: Readonly<Array<int>>): int {
644 const n: int = runHistory[1];
645 assert(n <= this.size * 3);
646 const core: boolean = n > 0 && runHistory[2] == n && runHistory[3] == n * 3 && runHistory[4] == n && runHistory[5] == n;
647 return (core && runHistory[0] >= n * 4 && runHistory[6] >= n ? 1 : 0)
648 + (core && runHistory[6] >= n * 4 && runHistory[0] >= n ? 1 : 0);
649 }
650
651
652 // Must be called at the end of a line (row or column) of modules. A helper function for getPenaltyScore().
653 private finderPenaltyTerminateAndCount(currentRunColor: boolean, currentRunLength: int, runHistory: Array<int>): int {
654 if (currentRunColor) { // Terminate dark run
655 this.finderPenaltyAddHistory(currentRunLength, runHistory);
656 currentRunLength = 0;
657 }
658 currentRunLength += this.size; // Add light border to final run
659 this.finderPenaltyAddHistory(currentRunLength, runHistory);
660 return this.finderPenaltyCountPatterns(runHistory);
661 }
662
663
664 // Pushes the given value to the front and drops the last value. A helper function for getPenaltyScore().
665 private finderPenaltyAddHistory(currentRunLength: int, runHistory: Array<int>): void {
666 if (runHistory[0] == 0)
667 currentRunLength += this.size; // Add light border to initial run
668 runHistory.pop();
669 runHistory.unshift(currentRunLength);
670 }
671
672
673 /*-- Constants and tables --*/
674
675 // The minimum version number supported in the QR Code Model 2 standard.
676 public static readonly MIN_VERSION: int = 1;
677 // The maximum version number supported in the QR Code Model 2 standard.
678 public static readonly MAX_VERSION: int = 40;
679
680 // For use in getPenaltyScore(), when evaluating which mask is best.
681 private static readonly PENALTY_N1: int = 3;
682 private static readonly PENALTY_N2: int = 3;
683 private static readonly PENALTY_N3: int = 40;
684 private static readonly PENALTY_N4: int = 10;
685
686 private static readonly ECC_CODEWORDS_PER_BLOCK: Array<Array<int>> = [
687 // Version: (note that index 0 is for padding, and is set to an illegal value)
688 //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
689 [-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
690 [-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
691 [-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
692 [-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
693 ];
694
695 private static readonly NUM_ERROR_CORRECTION_BLOCKS: Array<Array<int>> = [
696 // Version: (note that index 0 is for padding, and is set to an illegal value)
697 //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
698 [-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
699 [-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
700 [-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
701 [-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
702 ];
703
704 }
705
706
707 // Appends the given number of low-order bits of the given value
708 // to the given buffer. Requires 0 <= len <= 31 and 0 <= val < 2^len.
709 function appendBits(val: int, len: int, bb: Array<bit>): void {
710 if (len < 0 || len > 31 || val >>> len != 0)
711 throw new RangeError("Value out of range");
712 for (let i = len - 1; i >= 0; i--) // Append bit by bit
713 bb.push((val >>> i) & 1);
714 }
715
716
717 // Returns true iff the i'th bit of x is set to 1.
718 function getBit(x: int, i: int): boolean {
719 return ((x >>> i) & 1) != 0;
720 }
721
722
723 // Throws an exception if the given condition is false.
724 function assert(cond: boolean): void {
725 if (!cond)
726 throw new Error("Assertion error");
727 }
728
729
730
731 /*---- Data segment class ----*/
732
733 /*
734 * A segment of character/binary/control data in a QR Code symbol.
735 * Instances of this class are immutable.
736 * The mid-level way to create a segment is to take the payload data
737 * and call a static factory function such as QrSegment.makeNumeric().
738 * The low-level way to create a segment is to custom-make the bit buffer
739 * and call the QrSegment() constructor with appropriate values.
740 * This segment class imposes no length restrictions, but QR Codes have restrictions.
741 * Even in the most favorable conditions, a QR Code can only hold 7089 characters of data.
742 * Any segment longer than this is meaningless for the purpose of generating QR Codes.
743 */
744 export class QrSegment {
745
746 /*-- Static factory functions (mid level) --*/
747
748 // Returns a segment representing the given binary data encoded in
749 // byte mode. All input byte arrays are acceptable. Any text string
750 // can be converted to UTF-8 bytes and encoded as a byte mode segment.
751 public static makeBytes(data: Readonly<Array<byte>>): QrSegment {
752 let bb: Array<bit> = []
753 for (const b of data)
754 appendBits(b, 8, bb);
755 return new QrSegment(QrSegment.Mode.BYTE, data.length, bb);
756 }
757
758
759 // Returns a segment representing the given string of decimal digits encoded in numeric mode.
760 public static makeNumeric(digits: string): QrSegment {
761 if (!QrSegment.isNumeric(digits))
762 throw new RangeError("String contains non-numeric characters");
763 let bb: Array<bit> = []
764 for (let i = 0; i < digits.length; ) { // Consume up to 3 digits per iteration
765 const n: int = Math.min(digits.length - i, 3);
766 appendBits(parseInt(digits.substring(i, i + n), 10), n * 3 + 1, bb);
767 i += n;
768 }
769 return new QrSegment(QrSegment.Mode.NUMERIC, digits.length, bb);
770 }
771
772
773 // Returns a segment representing the given text string encoded in alphanumeric mode.
774 // The characters allowed are: 0 to 9, A to Z (uppercase only), space,
775 // dollar, percent, asterisk, plus, hyphen, period, slash, colon.
776 public static makeAlphanumeric(text: string): QrSegment {
777 if (!QrSegment.isAlphanumeric(text))
778 throw new RangeError("String contains unencodable characters in alphanumeric mode");
779 let bb: Array<bit> = []
780 let i: int;
781 for (i = 0; i + 2 <= text.length; i += 2) { // Process groups of 2
782 let temp: int = QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)) * 45;
783 temp += QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i + 1));
784 appendBits(temp, 11, bb);
785 }
786 if (i < text.length) // 1 character remaining
787 appendBits(QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)), 6, bb);
788 return new QrSegment(QrSegment.Mode.ALPHANUMERIC, text.length, bb);
789 }
790
791
792 // Returns a new mutable list of zero or more segments to represent the given Unicode text string.
793 // The result may use various segment modes and switch modes to optimize the length of the bit stream.
794 public static makeSegments(text: string): Array<QrSegment> {
795 // Select the most efficient segment encoding automatically
796 if (text == "")
797 return [];
798 else if (QrSegment.isNumeric(text))
799 return [QrSegment.makeNumeric(text)];
800 else if (QrSegment.isAlphanumeric(text))
801 return [QrSegment.makeAlphanumeric(text)];
802 else
803 return [QrSegment.makeBytes(QrSegment.toUtf8ByteArray(text))];
804 }
805
806
807 // Returns a segment representing an Extended Channel Interpretation
808 // (ECI) designator with the given assignment value.
809 public static makeEci(assignVal: int): QrSegment {
810 let bb: Array<bit> = []
811 if (assignVal < 0)
812 throw new RangeError("ECI assignment value out of range");
813 else if (assignVal < (1 << 7))
814 appendBits(assignVal, 8, bb);
815 else if (assignVal < (1 << 14)) {
816 appendBits(0b10, 2, bb);
817 appendBits(assignVal, 14, bb);
818 } else if (assignVal < 1000000) {
819 appendBits(0b110, 3, bb);
820 appendBits(assignVal, 21, bb);
821 } else
822 throw new RangeError("ECI assignment value out of range");
823 return new QrSegment(QrSegment.Mode.ECI, 0, bb);
824 }
825
826
827 // Tests whether the given string can be encoded as a segment in numeric mode.
828 // A string is encodable iff each character is in the range 0 to 9.
829 public static isNumeric(text: string): boolean {
830 return QrSegment.NUMERIC_REGEX.test(text);
831 }
832
833
834 // Tests whether the given string can be encoded as a segment in alphanumeric mode.
835 // A string is encodable iff each character is in the following set: 0 to 9, A to Z
836 // (uppercase only), space, dollar, percent, asterisk, plus, hyphen, period, slash, colon.
837 public static isAlphanumeric(text: string): boolean {
838 return QrSegment.ALPHANUMERIC_REGEX.test(text);
839 }
840
841
842 /*-- Constructor (low level) and fields --*/
843
844 // Creates a new QR Code segment with the given attributes and data.
845 // The character count (numChars) must agree with the mode and the bit buffer length,
846 // but the constraint isn't checked. The given bit buffer is cloned and stored.
847 public constructor(
848 // The mode indicator of this segment.
849 public readonly mode: QrSegment.Mode,
850
851 // The length of this segment's unencoded data. Measured in characters for
852 // numeric/alphanumeric/kanji mode, bytes for byte mode, and 0 for ECI mode.
853 // Always zero or positive. Not the same as the data's bit length.
854 public readonly numChars: int,
855
856 // The data bits of this segment. Accessed through getData().
857 private readonly bitData: Array<bit>) {
858
859 if (numChars < 0)
860 throw new RangeError("Invalid argument");
861 this.bitData = bitData.slice(); // Make defensive copy
862 }
863
864
865 /*-- Methods --*/
866
867 // Returns a new copy of the data bits of this segment.
868 public getData(): Array<bit> {
869 return this.bitData.slice(); // Make defensive copy
870 }
871
872
873 // (Package-private) Calculates and returns the number of bits needed to encode the given segments at
874 // the given version. The result is infinity if a segment has too many characters to fit its length field.
875 public static getTotalBits(segs: Readonly<Array<QrSegment>>, version: int): number {
876 let result: number = 0;
877 for (const seg of segs) {
878 const ccbits: int = seg.mode.numCharCountBits(version);
879 if (seg.numChars >= (1 << ccbits))
880 return Infinity; // The segment's length doesn't fit the field's bit width
881 result += 4 + ccbits + seg.bitData.length;
882 }
883 return result;
884 }
885
886
887 // Returns a new array of bytes representing the given string encoded in UTF-8.
888 private static toUtf8ByteArray(str: string): Array<byte> {
889 str = encodeURI(str);
890 let result: Array<byte> = [];
891 for (let i = 0; i < str.length; i++) {
892 if (str.charAt(i) != "%")
893 result.push(str.charCodeAt(i));
894 else {
895 result.push(parseInt(str.substring(i + 1, i + 3), 16));
896 i += 2;
897 }
898 }
899 return result;
900 }
901
902
903 /*-- Constants --*/
904
905 // Describes precisely all strings that are encodable in numeric mode.
906 private static readonly NUMERIC_REGEX: RegExp = /^[0-9]*$/;
907
908 // Describes precisely all strings that are encodable in alphanumeric mode.
909 private static readonly ALPHANUMERIC_REGEX: RegExp = /^[A-Z0-9 $%*+.\/:-]*$/;
910
911 // The set of all legal characters in alphanumeric mode,
912 // where each character value maps to the index in the string.
913 private static readonly ALPHANUMERIC_CHARSET: string = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:";
914
915 }
916
917}
918
919
920
921/*---- Public helper enumeration ----*/
922
923namespace qrcodegen.QrCode {
924
925 type int = number;
926
927
928 /*
929 * The error correction level in a QR Code symbol. Immutable.
930 */
931 export class Ecc {
932
933 /*-- Constants --*/
934
935 public static readonly LOW = new Ecc(0, 1); // The QR Code can tolerate about 7% erroneous codewords
936 public static readonly MEDIUM = new Ecc(1, 0); // The QR Code can tolerate about 15% erroneous codewords
937 public static readonly QUARTILE = new Ecc(2, 3); // The QR Code can tolerate about 25% erroneous codewords
938 public static readonly HIGH = new Ecc(3, 2); // The QR Code can tolerate about 30% erroneous codewords
939
940
941 /*-- Constructor and fields --*/
942
943 private constructor(
944 // In the range 0 to 3 (unsigned 2-bit integer).
945 public readonly ordinal: int,
946 // (Package-private) In the range 0 to 3 (unsigned 2-bit integer).
947 public readonly formatBits: int) {}
948
949 }
950}
951
952
953
954/*---- Public helper enumeration ----*/
955
956namespace qrcodegen.QrSegment {
957
958 type int = number;
959
960
961 /*
962 * Describes how a segment's data bits are interpreted. Immutable.
963 */
964 export class Mode {
965
966 /*-- Constants --*/
967
968 public static readonly NUMERIC = new Mode(0x1, [10, 12, 14]);
969 public static readonly ALPHANUMERIC = new Mode(0x2, [ 9, 11, 13]);
970 public static readonly BYTE = new Mode(0x4, [ 8, 16, 16]);
971 public static readonly KANJI = new Mode(0x8, [ 8, 10, 12]);
972 public static readonly ECI = new Mode(0x7, [ 0, 0, 0]);
973
974
975 /*-- Constructor and fields --*/
976
977 private constructor(
978 // The mode indicator bits, which is a uint4 value (range 0 to 15).
979 public readonly modeBits: int,
980 // Number of character count bits for three different version ranges.
981 private readonly numBitsCharCount: [int,int,int]) {}
982
983
984 /*-- Method --*/
985
986 // (Package-private) Returns the bit width of the character count field for a segment in
987 // this mode in a QR Code at the given version number. The result is in the range [0, 16].
988 public numCharCountBits(ver: int): int {
989 return this.numBitsCharCount[Math.floor((ver + 7) / 17)];
990 }
991
992 }
993}
994
995export const QrCode = qrcodegen.QrCode;
996export const QrSegment = qrcodegen.QrSegment;
packages/shared/package.json+4
......@@ -51,6 +51,10 @@
5151 "./projectScripts": {
5252 "types": "./src/projectScripts.ts",
5353 "import": "./src/projectScripts.ts"
54 },
55 "./qrCode": {
56 "types": "./src/qrCode.ts",
57 "import": "./src/qrCode.ts"
5458 }
5559 },
5660 "scripts": {
packages/shared/src/qrCode.ts created+984
......@@ -0,0 +1,984 @@
1// @ts-nocheck
2/* oxlint-disable eslint/no-useless-escape */
3/*
4 * QR Code generator library (TypeScript)
5 *
6 * Copyright (c) Project Nayuki. (MIT License)
7 * https://www.nayuki.io/page/qr-code-generator-library
8 *
9 * Permission is hereby granted, free of charge, to any person obtaining a copy of
10 * this software and associated documentation files (the "Software"), to deal in
11 * the Software without restriction, including without limitation the rights to
12 * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
13 * the Software, and to permit persons to whom the Software is furnished to do so,
14 * subject to the following conditions:
15 * - The above copyright notice and this permission notice shall be included in
16 * all copies or substantial portions of the Software.
17 * - The Software is provided "as is", without warranty of any kind, express or
18 * implied, including but not limited to the warranties of merchantability,
19 * fitness for a particular purpose and noninfringement. In no event shall the
20 * authors or copyright holders be liable for any claim, damages or other
21 * liability, whether in an action of contract, tort or otherwise, arising from,
22 * out of or in connection with the Software or the use or other dealings in the
23 * Software.
24 */
25
26"use strict";
27
28namespace qrcodegen {
29 type bit = number;
30 type byte = number;
31 type int = number;
32
33 /*---- QR Code symbol class ----*/
34
35 /*
36 * A QR Code symbol, which is a type of two-dimension barcode.
37 * Invented by Denso Wave and described in the ISO/IEC 18004 standard.
38 * Instances of this class represent an immutable square grid of dark and light cells.
39 * The class provides static factory functions to create a QR Code from text or binary data.
40 * The class covers the QR Code Model 2 specification, supporting all versions (sizes)
41 * from 1 to 40, all 4 error correction levels, and 4 character encoding modes.
42 *
43 * Ways to create a QR Code object:
44 * - High level: Take the payload data and call QrCode.encodeText() or QrCode.encodeBinary().
45 * - Mid level: Custom-make the list of segments and call QrCode.encodeSegments().
46 * - Low level: Custom-make the array of data codeword bytes (including
47 * segment headers and final padding, excluding error correction codewords),
48 * supply the appropriate version number, and call the QrCode() constructor.
49 * (Note that all ways require supplying the desired error correction level.)
50 */
51 export class QrCode {
52 /*-- Static factory functions (high level) --*/
53
54 // Returns a QR Code representing the given Unicode text string at the given error correction level.
55 // As a conservative upper bound, this function is guaranteed to succeed for strings that have 738 or fewer
56 // Unicode code points (not UTF-16 code units) if the low error correction level is used. The smallest possible
57 // QR Code version is automatically chosen for the output. The ECC level of the result may be higher than the
58 // ecl argument if it can be done without increasing the version.
59 public static encodeText(text: string, ecl: QrCode.Ecc): QrCode {
60 const segs: Array<QrSegment> = qrcodegen.QrSegment.makeSegments(text);
61 return QrCode.encodeSegments(segs, ecl);
62 }
63
64 // Returns a QR Code representing the given binary data at the given error correction level.
65 // This function always encodes using the binary segment mode, not any text mode. The maximum number of
66 // bytes allowed is 2953. The smallest possible QR Code version is automatically chosen for the output.
67 // The ECC level of the result may be higher than the ecl argument if it can be done without increasing the version.
68 public static encodeBinary(data: Readonly<Array<byte>>, ecl: QrCode.Ecc): QrCode {
69 const seg: QrSegment = qrcodegen.QrSegment.makeBytes(data);
70 return QrCode.encodeSegments([seg], ecl);
71 }
72
73 /*-- Static factory functions (mid level) --*/
74
75 // Returns a QR Code representing the given segments with the given encoding parameters.
76 // The smallest possible QR Code version within the given range is automatically
77 // chosen for the output. Iff boostEcl is true, then the ECC level of the result
78 // may be higher than the ecl argument if it can be done without increasing the
79 // version. The mask number is either between 0 to 7 (inclusive) to force that
80 // mask, or -1 to automatically choose an appropriate mask (which may be slow).
81 // This function allows the user to create a custom sequence of segments that switches
82 // between modes (such as alphanumeric and byte) to encode text in less space.
83 // This is a mid-level API; the high-level API is encodeText() and encodeBinary().
84 public static encodeSegments(
85 segs: Readonly<Array<QrSegment>>,
86 ecl: QrCode.Ecc,
87 minVersion: int = 1,
88 maxVersion: int = 40,
89 mask: int = -1,
90 boostEcl: boolean = true,
91 ): QrCode {
92 if (
93 !(
94 QrCode.MIN_VERSION <= minVersion &&
95 minVersion <= maxVersion &&
96 maxVersion <= QrCode.MAX_VERSION
97 ) ||
98 mask < -1 ||
99 mask > 7
100 )
101 throw new RangeError("Invalid value");
102
103 // Find the minimal version number to use
104 let version: int;
105 let dataUsedBits: int;
106 for (version = minVersion; ; version++) {
107 const dataCapacityBits: int = QrCode.getNumDataCodewords(version, ecl) * 8; // Number of data bits available
108 const usedBits: number = QrSegment.getTotalBits(segs, version);
109 if (usedBits <= dataCapacityBits) {
110 dataUsedBits = usedBits;
111 break; // This version number is found to be suitable
112 }
113 if (version >= maxVersion)
114 // All versions in the range could not fit the given data
115 throw new RangeError("Data too long");
116 }
117
118 // Increase the error correction level while the data still fits in the current version number
119 for (const newEcl of [QrCode.Ecc.MEDIUM, QrCode.Ecc.QUARTILE, QrCode.Ecc.HIGH]) {
120 // From low to high
121 if (boostEcl && dataUsedBits <= QrCode.getNumDataCodewords(version, newEcl) * 8)
122 ecl = newEcl;
123 }
124
125 // Concatenate all segments to create the data bit string
126 let bb: Array<bit> = [];
127 for (const seg of segs) {
128 appendBits(seg.mode.modeBits, 4, bb);
129 appendBits(seg.numChars, seg.mode.numCharCountBits(version), bb);
130 for (const b of seg.getData()) bb.push(b);
131 }
132 assert(bb.length == dataUsedBits);
133
134 // Add terminator and pad up to a byte if applicable
135 const dataCapacityBits: int = QrCode.getNumDataCodewords(version, ecl) * 8;
136 assert(bb.length <= dataCapacityBits);
137 appendBits(0, Math.min(4, dataCapacityBits - bb.length), bb);
138 appendBits(0, (8 - (bb.length % 8)) % 8, bb);
139 assert(bb.length % 8 == 0);
140
141 // Pad with alternating bytes until data capacity is reached
142 for (let padByte = 0xec; bb.length < dataCapacityBits; padByte ^= 0xec ^ 0x11)
143 appendBits(padByte, 8, bb);
144
145 // Pack bits into bytes in big endian
146 let dataCodewords: Array<byte> = [];
147 while (dataCodewords.length * 8 < bb.length) dataCodewords.push(0);
148 bb.forEach((b: bit, i: int) => (dataCodewords[i >>> 3] |= b << (7 - (i & 7))));
149
150 // Create the QR Code object
151 return new QrCode(version, ecl, dataCodewords, mask);
152 }
153
154 /*-- Fields --*/
155
156 // The width and height of this QR Code, measured in modules, between
157 // 21 and 177 (inclusive). This is equal to version * 4 + 17.
158 public readonly size: int;
159
160 // The index of the mask pattern used in this QR Code, which is between 0 and 7 (inclusive).
161 // Even if a QR Code is created with automatic masking requested (mask = -1),
162 // the resulting object still has a mask value between 0 and 7.
163 public readonly mask: int;
164
165 // The modules of this QR Code (false = light, true = dark).
166 // Immutable after constructor finishes. Accessed through getModule().
167 private readonly modules: Array<Array<boolean>> = [];
168
169 // Indicates function modules that are not subjected to masking. Discarded when constructor finishes.
170 private readonly isFunction: Array<Array<boolean>> = [];
171
172 /*-- Constructor (low level) and fields --*/
173
174 // Creates a new QR Code with the given version number,
175 // error correction level, data codeword bytes, and mask number.
176 // This is a low-level API that most users should not use directly.
177 // A mid-level API is the encodeSegments() function.
178 public constructor(
179 // The version number of this QR Code, which is between 1 and 40 (inclusive).
180 // This determines the size of this barcode.
181 public readonly version: int,
182
183 // The error correction level used in this QR Code.
184 public readonly errorCorrectionLevel: QrCode.Ecc,
185
186 dataCodewords: Readonly<Array<byte>>,
187
188 msk: int,
189 ) {
190 // Check scalar arguments
191 if (version < QrCode.MIN_VERSION || version > QrCode.MAX_VERSION)
192 throw new RangeError("Version value out of range");
193 if (msk < -1 || msk > 7) throw new RangeError("Mask value out of range");
194 this.size = version * 4 + 17;
195
196 // Initialize both grids to be size*size arrays of Boolean false
197 let row: Array<boolean> = [];
198 for (let i = 0; i < this.size; i++) row.push(false);
199 for (let i = 0; i < this.size; i++) {
200 this.modules.push(row.slice()); // Initially all light
201 this.isFunction.push(row.slice());
202 }
203
204 // Compute ECC, draw modules
205 this.drawFunctionPatterns();
206 const allCodewords: Array<byte> = this.addEccAndInterleave(dataCodewords);
207 this.drawCodewords(allCodewords);
208
209 // Do masking
210 if (msk == -1) {
211 // Automatically choose best mask
212 let minPenalty: int = 1000000000;
213 for (let i = 0; i < 8; i++) {
214 this.applyMask(i);
215 this.drawFormatBits(i);
216 const penalty: int = this.getPenaltyScore();
217 if (penalty < minPenalty) {
218 msk = i;
219 minPenalty = penalty;
220 }
221 this.applyMask(i); // Undoes the mask due to XOR
222 }
223 }
224 assert(0 <= msk && msk <= 7);
225 this.mask = msk;
226 this.applyMask(msk); // Apply the final choice of mask
227 this.drawFormatBits(msk); // Overwrite old format bits
228
229 this.isFunction = [];
230 }
231
232 /*-- Accessor methods --*/
233
234 // Returns the color of the module (pixel) at the given coordinates, which is false
235 // for light or true for dark. The top left corner has the coordinates (x=0, y=0).
236 // If the given coordinates are out of bounds, then false (light) is returned.
237 public getModule(x: int, y: int): boolean {
238 return 0 <= x && x < this.size && 0 <= y && y < this.size && this.modules[y][x];
239 }
240
241 /*-- Private helper methods for constructor: Drawing function modules --*/
242
243 // Reads this object's version field, and draws and marks all function modules.
244 private drawFunctionPatterns(): void {
245 // Draw horizontal and vertical timing patterns
246 for (let i = 0; i < this.size; i++) {
247 this.setFunctionModule(6, i, i % 2 == 0);
248 this.setFunctionModule(i, 6, i % 2 == 0);
249 }
250
251 // Draw 3 finder patterns (all corners except bottom right; overwrites some timing modules)
252 this.drawFinderPattern(3, 3);
253 this.drawFinderPattern(this.size - 4, 3);
254 this.drawFinderPattern(3, this.size - 4);
255
256 // Draw numerous alignment patterns
257 const alignPatPos: Array<int> = this.getAlignmentPatternPositions();
258 const numAlign: int = alignPatPos.length;
259 for (let i = 0; i < numAlign; i++) {
260 for (let j = 0; j < numAlign; j++) {
261 // Don't draw on the three finder corners
262 if (
263 !((i == 0 && j == 0) || (i == 0 && j == numAlign - 1) || (i == numAlign - 1 && j == 0))
264 )
265 this.drawAlignmentPattern(alignPatPos[i], alignPatPos[j]);
266 }
267 }
268
269 // Draw configuration data
270 this.drawFormatBits(0); // Dummy mask value; overwritten later in the constructor
271 this.drawVersion();
272 }
273
274 // Draws two copies of the format bits (with its own error correction code)
275 // based on the given mask and this object's error correction level field.
276 private drawFormatBits(mask: int): void {
277 // Calculate error correction code and pack bits
278 const data: int = (this.errorCorrectionLevel.formatBits << 3) | mask; // errCorrLvl is uint2, mask is uint3
279 let rem: int = data;
280 for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
281 const bits = ((data << 10) | rem) ^ 0x5412; // uint15
282 assert(bits >>> 15 == 0);
283
284 // Draw first copy
285 for (let i = 0; i <= 5; i++) this.setFunctionModule(8, i, getBit(bits, i));
286 this.setFunctionModule(8, 7, getBit(bits, 6));
287 this.setFunctionModule(8, 8, getBit(bits, 7));
288 this.setFunctionModule(7, 8, getBit(bits, 8));
289 for (let i = 9; i < 15; i++) this.setFunctionModule(14 - i, 8, getBit(bits, i));
290
291 // Draw second copy
292 for (let i = 0; i < 8; i++) this.setFunctionModule(this.size - 1 - i, 8, getBit(bits, i));
293 for (let i = 8; i < 15; i++) this.setFunctionModule(8, this.size - 15 + i, getBit(bits, i));
294 this.setFunctionModule(8, this.size - 8, true); // Always dark
295 }
296
297 // Draws two copies of the version bits (with its own error correction code),
298 // based on this object's version field, iff 7 <= version <= 40.
299 private drawVersion(): void {
300 if (this.version < 7) return;
301
302 // Calculate error correction code and pack bits
303 let rem: int = this.version; // version is uint6, in the range [7, 40]
304 for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25);
305 const bits: int = (this.version << 12) | rem; // uint18
306 assert(bits >>> 18 == 0);
307
308 // Draw two copies
309 for (let i = 0; i < 18; i++) {
310 const color: boolean = getBit(bits, i);
311 const a: int = this.size - 11 + (i % 3);
312 const b: int = Math.floor(i / 3);
313 this.setFunctionModule(a, b, color);
314 this.setFunctionModule(b, a, color);
315 }
316 }
317
318 // Draws a 9*9 finder pattern including the border separator,
319 // with the center module at (x, y). Modules can be out of bounds.
320 private drawFinderPattern(x: int, y: int): void {
321 for (let dy = -4; dy <= 4; dy++) {
322 for (let dx = -4; dx <= 4; dx++) {
323 const dist: int = Math.max(Math.abs(dx), Math.abs(dy)); // Chebyshev/infinity norm
324 const xx: int = x + dx;
325 const yy: int = y + dy;
326 if (0 <= xx && xx < this.size && 0 <= yy && yy < this.size)
327 this.setFunctionModule(xx, yy, dist != 2 && dist != 4);
328 }
329 }
330 }
331
332 // Draws a 5*5 alignment pattern, with the center module
333 // at (x, y). All modules must be in bounds.
334 private drawAlignmentPattern(x: int, y: int): void {
335 for (let dy = -2; dy <= 2; dy++) {
336 for (let dx = -2; dx <= 2; dx++)
337 this.setFunctionModule(x + dx, y + dy, Math.max(Math.abs(dx), Math.abs(dy)) != 1);
338 }
339 }
340
341 // Sets the color of a module and marks it as a function module.
342 // Only used by the constructor. Coordinates must be in bounds.
343 private setFunctionModule(x: int, y: int, isDark: boolean): void {
344 this.modules[y][x] = isDark;
345 this.isFunction[y][x] = true;
346 }
347
348 /*-- Private helper methods for constructor: Codewords and masking --*/
349
350 // Returns a new byte string representing the given data with the appropriate error correction
351 // codewords appended to it, based on this object's version and error correction level.
352 private addEccAndInterleave(data: Readonly<Array<byte>>): Array<byte> {
353 const ver: int = this.version;
354 const ecl: QrCode.Ecc = this.errorCorrectionLevel;
355 if (data.length != QrCode.getNumDataCodewords(ver, ecl))
356 throw new RangeError("Invalid argument");
357
358 // Calculate parameter numbers
359 const numBlocks: int = QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver];
360 const blockEccLen: int = QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal][ver];
361 const rawCodewords: int = Math.floor(QrCode.getNumRawDataModules(ver) / 8);
362 const numShortBlocks: int = numBlocks - (rawCodewords % numBlocks);
363 const shortBlockLen: int = Math.floor(rawCodewords / numBlocks);
364
365 // Split data into blocks and append ECC to each block
366 let blocks: Array<Array<byte>> = [];
367 const rsDiv: Array<byte> = QrCode.reedSolomonComputeDivisor(blockEccLen);
368 for (let i = 0, k = 0; i < numBlocks; i++) {
369 let dat: Array<byte> = data.slice(
370 k,
371 k + shortBlockLen - blockEccLen + (i < numShortBlocks ? 0 : 1),
372 );
373 k += dat.length;
374 const ecc: Array<byte> = QrCode.reedSolomonComputeRemainder(dat, rsDiv);
375 if (i < numShortBlocks) dat.push(0);
376 blocks.push(dat.concat(ecc));
377 }
378
379 // Interleave (not concatenate) the bytes from every block into a single sequence
380 let result: Array<byte> = [];
381 for (let i = 0; i < blocks[0].length; i++) {
382 blocks.forEach((block, j) => {
383 // Skip the padding byte in short blocks
384 if (i != shortBlockLen - blockEccLen || j >= numShortBlocks) result.push(block[i]);
385 });
386 }
387 assert(result.length == rawCodewords);
388 return result;
389 }
390
391 // Draws the given sequence of 8-bit codewords (data and error correction) onto the entire
392 // data area of this QR Code. Function modules need to be marked off before this is called.
393 private drawCodewords(data: Readonly<Array<byte>>): void {
394 if (data.length != Math.floor(QrCode.getNumRawDataModules(this.version) / 8))
395 throw new RangeError("Invalid argument");
396 let i: int = 0; // Bit index into the data
397 // Do the funny zigzag scan
398 for (let right = this.size - 1; right >= 1; right -= 2) {
399 // Index of right column in each column pair
400 if (right == 6) right = 5;
401 for (let vert = 0; vert < this.size; vert++) {
402 // Vertical counter
403 for (let j = 0; j < 2; j++) {
404 const x: int = right - j; // Actual x coordinate
405 const upward: boolean = ((right + 1) & 2) == 0;
406 const y: int = upward ? this.size - 1 - vert : vert; // Actual y coordinate
407 if (!this.isFunction[y][x] && i < data.length * 8) {
408 this.modules[y][x] = getBit(data[i >>> 3], 7 - (i & 7));
409 i++;
410 }
411 // If this QR Code has any remainder bits (0 to 7), they were assigned as
412 // 0/false/light by the constructor and are left unchanged by this method
413 }
414 }
415 }
416 assert(i == data.length * 8);
417 }
418
419 // XORs the codeword modules in this QR Code with the given mask pattern.
420 // The function modules must be marked and the codeword bits must be drawn
421 // before masking. Due to the arithmetic of XOR, calling applyMask() with
422 // the same mask value a second time will undo the mask. A final well-formed
423 // QR Code needs exactly one (not zero, two, etc.) mask applied.
424 private applyMask(mask: int): void {
425 if (mask < 0 || mask > 7) throw new RangeError("Mask value out of range");
426 for (let y = 0; y < this.size; y++) {
427 for (let x = 0; x < this.size; x++) {
428 let invert: boolean;
429 switch (mask) {
430 case 0:
431 invert = (x + y) % 2 == 0;
432 break;
433 case 1:
434 invert = y % 2 == 0;
435 break;
436 case 2:
437 invert = x % 3 == 0;
438 break;
439 case 3:
440 invert = (x + y) % 3 == 0;
441 break;
442 case 4:
443 invert = (Math.floor(x / 3) + Math.floor(y / 2)) % 2 == 0;
444 break;
445 case 5:
446 invert = ((x * y) % 2) + ((x * y) % 3) == 0;
447 break;
448 case 6:
449 invert = (((x * y) % 2) + ((x * y) % 3)) % 2 == 0;
450 break;
451 case 7:
452 invert = (((x + y) % 2) + ((x * y) % 3)) % 2 == 0;
453 break;
454 default:
455 throw new Error("Unreachable");
456 }
457 if (!this.isFunction[y][x] && invert) this.modules[y][x] = !this.modules[y][x];
458 }
459 }
460 }
461
462 // Calculates and returns the penalty score based on state of this QR Code's current modules.
463 // This is used by the automatic mask choice algorithm to find the mask pattern that yields the lowest score.
464 private getPenaltyScore(): int {
465 let result: int = 0;
466
467 // Adjacent modules in row having same color, and finder-like patterns
468 for (let y = 0; y < this.size; y++) {
469 let runColor = false;
470 let runX = 0;
471 let runHistory = [0, 0, 0, 0, 0, 0, 0];
472 for (let x = 0; x < this.size; x++) {
473 if (this.modules[y][x] == runColor) {
474 runX++;
475 if (runX == 5) result += QrCode.PENALTY_N1;
476 else if (runX > 5) result++;
477 } else {
478 this.finderPenaltyAddHistory(runX, runHistory);
479 if (!runColor)
480 result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3;
481 runColor = this.modules[y][x];
482 runX = 1;
483 }
484 }
485 result +=
486 this.finderPenaltyTerminateAndCount(runColor, runX, runHistory) * QrCode.PENALTY_N3;
487 }
488 // Adjacent modules in column having same color, and finder-like patterns
489 for (let x = 0; x < this.size; x++) {
490 let runColor = false;
491 let runY = 0;
492 let runHistory = [0, 0, 0, 0, 0, 0, 0];
493 for (let y = 0; y < this.size; y++) {
494 if (this.modules[y][x] == runColor) {
495 runY++;
496 if (runY == 5) result += QrCode.PENALTY_N1;
497 else if (runY > 5) result++;
498 } else {
499 this.finderPenaltyAddHistory(runY, runHistory);
500 if (!runColor)
501 result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3;
502 runColor = this.modules[y][x];
503 runY = 1;
504 }
505 }
506 result +=
507 this.finderPenaltyTerminateAndCount(runColor, runY, runHistory) * QrCode.PENALTY_N3;
508 }
509
510 // 2*2 blocks of modules having same color
511 for (let y = 0; y < this.size - 1; y++) {
512 for (let x = 0; x < this.size - 1; x++) {
513 const color: boolean = this.modules[y][x];
514 if (
515 color == this.modules[y][x + 1] &&
516 color == this.modules[y + 1][x] &&
517 color == this.modules[y + 1][x + 1]
518 )
519 result += QrCode.PENALTY_N2;
520 }
521 }
522
523 // Balance of dark and light modules
524 let dark: int = 0;
525 for (const row of this.modules)
526 dark = row.reduce((sum, color) => sum + (color ? 1 : 0), dark);
527 const total: int = this.size * this.size; // Note that size is odd, so dark/total != 1/2
528 // Compute the smallest integer k >= 0 such that (45-5k)% <= dark/total <= (55+5k)%
529 const k: int = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1;
530 assert(0 <= k && k <= 9);
531 result += k * QrCode.PENALTY_N4;
532 assert(0 <= result && result <= 2568888); // Non-tight upper bound based on default values of PENALTY_N1, ..., N4
533 return result;
534 }
535
536 /*-- Private helper functions --*/
537
538 // Returns an ascending list of positions of alignment patterns for this version number.
539 // Each position is in the range [0,177), and are used on both the x and y axes.
540 // This could be implemented as lookup table of 40 variable-length lists of integers.
541 private getAlignmentPatternPositions(): Array<int> {
542 if (this.version == 1) return [];
543 else {
544 const numAlign: int = Math.floor(this.version / 7) + 2;
545 const step: int =
546 this.version == 32 ? 26 : Math.ceil((this.version * 4 + 4) / (numAlign * 2 - 2)) * 2;
547 let result: Array<int> = [6];
548 for (let pos = this.size - 7; result.length < numAlign; pos -= step)
549 result.splice(1, 0, pos);
550 return result;
551 }
552 }
553
554 // Returns the number of data bits that can be stored in a QR Code of the given version number, after
555 // all function modules are excluded. This includes remainder bits, so it might not be a multiple of 8.
556 // The result is in the range [208, 29648]. This could be implemented as a 40-entry lookup table.
557 private static getNumRawDataModules(ver: int): int {
558 if (ver < QrCode.MIN_VERSION || ver > QrCode.MAX_VERSION)
559 throw new RangeError("Version number out of range");
560 let result: int = (16 * ver + 128) * ver + 64;
561 if (ver >= 2) {
562 const numAlign: int = Math.floor(ver / 7) + 2;
563 result -= (25 * numAlign - 10) * numAlign - 55;
564 if (ver >= 7) result -= 36;
565 }
566 assert(208 <= result && result <= 29648);
567 return result;
568 }
569
570 // Returns the number of 8-bit data (i.e. not error correction) codewords contained in any
571 // QR Code of the given version number and error correction level, with remainder bits discarded.
572 // This stateless pure function could be implemented as a (40*4)-cell lookup table.
573 private static getNumDataCodewords(ver: int, ecl: QrCode.Ecc): int {
574 return (
575 Math.floor(QrCode.getNumRawDataModules(ver) / 8) -
576 QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal][ver] *
577 QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver]
578 );
579 }
580
581 // Returns a Reed-Solomon ECC generator polynomial for the given degree. This could be
582 // implemented as a lookup table over all possible parameter values, instead of as an algorithm.
583 private static reedSolomonComputeDivisor(degree: int): Array<byte> {
584 if (degree < 1 || degree > 255) throw new RangeError("Degree out of range");
585 // Polynomial coefficients are stored from highest to lowest power, excluding the leading term which is always 1.
586 // For example the polynomial x^3 + 255x^2 + 8x + 93 is stored as the uint8 array [255, 8, 93].
587 let result: Array<byte> = [];
588 for (let i = 0; i < degree - 1; i++) result.push(0);
589 result.push(1); // Start off with the monomial x^0
590
591 // Compute the product polynomial (x - r^0) * (x - r^1) * (x - r^2) * ... * (x - r^{degree-1}),
592 // and drop the highest monomial term which is always 1x^degree.
593 // Note that r = 0x02, which is a generator element of this field GF(2^8/0x11D).
594 let root = 1;
595 for (let i = 0; i < degree; i++) {
596 // Multiply the current product by (x - r^i)
597 for (let j = 0; j < result.length; j++) {
598 result[j] = QrCode.reedSolomonMultiply(result[j], root);
599 if (j + 1 < result.length) result[j] ^= result[j + 1];
600 }
601 root = QrCode.reedSolomonMultiply(root, 0x02);
602 }
603 return result;
604 }
605
606 // Returns the Reed-Solomon error correction codeword for the given data and divisor polynomials.
607 private static reedSolomonComputeRemainder(
608 data: Readonly<Array<byte>>,
609 divisor: Readonly<Array<byte>>,
610 ): Array<byte> {
611 let result: Array<byte> = divisor.map((_) => 0);
612 for (const b of data) {
613 // Polynomial division
614 const factor: byte = b ^ (result.shift() as byte);
615 result.push(0);
616 divisor.forEach((coef, i) => (result[i] ^= QrCode.reedSolomonMultiply(coef, factor)));
617 }
618 return result;
619 }
620
621 // Returns the product of the two given field elements modulo GF(2^8/0x11D). The arguments and result
622 // are unsigned 8-bit integers. This could be implemented as a lookup table of 256*256 entries of uint8.
623 private static reedSolomonMultiply(x: byte, y: byte): byte {
624 if (x >>> 8 != 0 || y >>> 8 != 0) throw new RangeError("Byte out of range");
625 // Russian peasant multiplication
626 let z: int = 0;
627 for (let i = 7; i >= 0; i--) {
628 z = (z << 1) ^ ((z >>> 7) * 0x11d);
629 z ^= ((y >>> i) & 1) * x;
630 }
631 assert(z >>> 8 == 0);
632 return z as byte;
633 }
634
635 // Can only be called immediately after a light run is added, and
636 // returns either 0, 1, or 2. A helper function for getPenaltyScore().
637 private finderPenaltyCountPatterns(runHistory: Readonly<Array<int>>): int {
638 const n: int = runHistory[1];
639 assert(n <= this.size * 3);
640 const core: boolean =
641 n > 0 &&
642 runHistory[2] == n &&
643 runHistory[3] == n * 3 &&
644 runHistory[4] == n &&
645 runHistory[5] == n;
646 return (
647 (core && runHistory[0] >= n * 4 && runHistory[6] >= n ? 1 : 0) +
648 (core && runHistory[6] >= n * 4 && runHistory[0] >= n ? 1 : 0)
649 );
650 }
651
652 // Must be called at the end of a line (row or column) of modules. A helper function for getPenaltyScore().
653 private finderPenaltyTerminateAndCount(
654 currentRunColor: boolean,
655 currentRunLength: int,
656 runHistory: Array<int>,
657 ): int {
658 if (currentRunColor) {
659 // Terminate dark run
660 this.finderPenaltyAddHistory(currentRunLength, runHistory);
661 currentRunLength = 0;
662 }
663 currentRunLength += this.size; // Add light border to final run
664 this.finderPenaltyAddHistory(currentRunLength, runHistory);
665 return this.finderPenaltyCountPatterns(runHistory);
666 }
667
668 // Pushes the given value to the front and drops the last value. A helper function for getPenaltyScore().
669 private finderPenaltyAddHistory(currentRunLength: int, runHistory: Array<int>): void {
670 if (runHistory[0] == 0) currentRunLength += this.size; // Add light border to initial run
671 runHistory.pop();
672 runHistory.unshift(currentRunLength);
673 }
674
675 /*-- Constants and tables --*/
676
677 // The minimum version number supported in the QR Code Model 2 standard.
678 public static readonly MIN_VERSION: int = 1;
679 // The maximum version number supported in the QR Code Model 2 standard.
680 public static readonly MAX_VERSION: int = 40;
681
682 // For use in getPenaltyScore(), when evaluating which mask is best.
683 private static readonly PENALTY_N1: int = 3;
684 private static readonly PENALTY_N2: int = 3;
685 private static readonly PENALTY_N3: int = 40;
686 private static readonly PENALTY_N4: int = 10;
687
688 private static readonly ECC_CODEWORDS_PER_BLOCK: Array<Array<int>> = [
689 // Version: (note that index 0 is for padding, and is set to an illegal value)
690 //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
691 [
692 -1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28,
693 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30,
694 ], // Low
695 [
696 -1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28,
697 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28,
698 ], // Medium
699 [
700 -1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30,
701 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30,
702 ], // Quartile
703 [
704 -1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24,
705 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30,
706 ], // High
707 ];
708
709 private static readonly NUM_ERROR_CORRECTION_BLOCKS: Array<Array<int>> = [
710 // Version: (note that index 0 is for padding, and is set to an illegal value)
711 //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
712 [
713 -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,
714 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25,
715 ], // Low
716 [
717 -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,
718 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49,
719 ], // Medium
720 [
721 -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,
722 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68,
723 ], // Quartile
724 [
725 -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,
726 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81,
727 ], // High
728 ];
729 }
730
731 // Appends the given number of low-order bits of the given value
732 // to the given buffer. Requires 0 <= len <= 31 and 0 <= val < 2^len.
733 function appendBits(val: int, len: int, bb: Array<bit>): void {
734 if (len < 0 || len > 31 || val >>> len != 0) throw new RangeError("Value out of range");
735 for (
736 let i = len - 1;
737 i >= 0;
738 i-- // Append bit by bit
739 )
740 bb.push((val >>> i) & 1);
741 }
742
743 // Returns true iff the i'th bit of x is set to 1.
744 function getBit(x: int, i: int): boolean {
745 return ((x >>> i) & 1) != 0;
746 }
747
748 // Throws an exception if the given condition is false.
749 function assert(cond: boolean): void {
750 if (!cond) throw new Error("Assertion error");
751 }
752
753 /*---- Data segment class ----*/
754
755 /*
756 * A segment of character/binary/control data in a QR Code symbol.
757 * Instances of this class are immutable.
758 * The mid-level way to create a segment is to take the payload data
759 * and call a static factory function such as QrSegment.makeNumeric().
760 * The low-level way to create a segment is to custom-make the bit buffer
761 * and call the QrSegment() constructor with appropriate values.
762 * This segment class imposes no length restrictions, but QR Codes have restrictions.
763 * Even in the most favorable conditions, a QR Code can only hold 7089 characters of data.
764 * Any segment longer than this is meaningless for the purpose of generating QR Codes.
765 */
766 export class QrSegment {
767 /*-- Static factory functions (mid level) --*/
768
769 // Returns a segment representing the given binary data encoded in
770 // byte mode. All input byte arrays are acceptable. Any text string
771 // can be converted to UTF-8 bytes and encoded as a byte mode segment.
772 public static makeBytes(data: Readonly<Array<byte>>): QrSegment {
773 let bb: Array<bit> = [];
774 for (const b of data) appendBits(b, 8, bb);
775 return new QrSegment(QrSegment.Mode.BYTE, data.length, bb);
776 }
777
778 // Returns a segment representing the given string of decimal digits encoded in numeric mode.
779 public static makeNumeric(digits: string): QrSegment {
780 if (!QrSegment.isNumeric(digits))
781 throw new RangeError("String contains non-numeric characters");
782 let bb: Array<bit> = [];
783 for (let i = 0; i < digits.length; ) {
784 // Consume up to 3 digits per iteration
785 const n: int = Math.min(digits.length - i, 3);
786 appendBits(parseInt(digits.substring(i, i + n), 10), n * 3 + 1, bb);
787 i += n;
788 }
789 return new QrSegment(QrSegment.Mode.NUMERIC, digits.length, bb);
790 }
791
792 // Returns a segment representing the given text string encoded in alphanumeric mode.
793 // The characters allowed are: 0 to 9, A to Z (uppercase only), space,
794 // dollar, percent, asterisk, plus, hyphen, period, slash, colon.
795 public static makeAlphanumeric(text: string): QrSegment {
796 if (!QrSegment.isAlphanumeric(text))
797 throw new RangeError("String contains unencodable characters in alphanumeric mode");
798 let bb: Array<bit> = [];
799 let i: int;
800 for (i = 0; i + 2 <= text.length; i += 2) {
801 // Process groups of 2
802 let temp: int = QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)) * 45;
803 temp += QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i + 1));
804 appendBits(temp, 11, bb);
805 }
806 if (i < text.length)
807 // 1 character remaining
808 appendBits(QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)), 6, bb);
809 return new QrSegment(QrSegment.Mode.ALPHANUMERIC, text.length, bb);
810 }
811
812 // Returns a new mutable list of zero or more segments to represent the given Unicode text string.
813 // The result may use various segment modes and switch modes to optimize the length of the bit stream.
814 public static makeSegments(text: string): Array<QrSegment> {
815 // Select the most efficient segment encoding automatically
816 if (text == "") return [];
817 else if (QrSegment.isNumeric(text)) return [QrSegment.makeNumeric(text)];
818 else if (QrSegment.isAlphanumeric(text)) return [QrSegment.makeAlphanumeric(text)];
819 else return [QrSegment.makeBytes(QrSegment.toUtf8ByteArray(text))];
820 }
821
822 // Returns a segment representing an Extended Channel Interpretation
823 // (ECI) designator with the given assignment value.
824 public static makeEci(assignVal: int): QrSegment {
825 let bb: Array<bit> = [];
826 if (assignVal < 0) throw new RangeError("ECI assignment value out of range");
827 else if (assignVal < 1 << 7) appendBits(assignVal, 8, bb);
828 else if (assignVal < 1 << 14) {
829 appendBits(0b10, 2, bb);
830 appendBits(assignVal, 14, bb);
831 } else if (assignVal < 1000000) {
832 appendBits(0b110, 3, bb);
833 appendBits(assignVal, 21, bb);
834 } else throw new RangeError("ECI assignment value out of range");
835 return new QrSegment(QrSegment.Mode.ECI, 0, bb);
836 }
837
838 // Tests whether the given string can be encoded as a segment in numeric mode.
839 // A string is encodable iff each character is in the range 0 to 9.
840 public static isNumeric(text: string): boolean {
841 return QrSegment.NUMERIC_REGEX.test(text);
842 }
843
844 // Tests whether the given string can be encoded as a segment in alphanumeric mode.
845 // A string is encodable iff each character is in the following set: 0 to 9, A to Z
846 // (uppercase only), space, dollar, percent, asterisk, plus, hyphen, period, slash, colon.
847 public static isAlphanumeric(text: string): boolean {
848 return QrSegment.ALPHANUMERIC_REGEX.test(text);
849 }
850
851 /*-- Constructor (low level) and fields --*/
852
853 // Creates a new QR Code segment with the given attributes and data.
854 // The character count (numChars) must agree with the mode and the bit buffer length,
855 // but the constraint isn't checked. The given bit buffer is cloned and stored.
856 public constructor(
857 // The mode indicator of this segment.
858 public readonly mode: QrSegment.Mode,
859
860 // The length of this segment's unencoded data. Measured in characters for
861 // numeric/alphanumeric/kanji mode, bytes for byte mode, and 0 for ECI mode.
862 // Always zero or positive. Not the same as the data's bit length.
863 public readonly numChars: int,
864
865 // The data bits of this segment. Accessed through getData().
866 private readonly bitData: Array<bit>,
867 ) {
868 if (numChars < 0) throw new RangeError("Invalid argument");
869 this.bitData = bitData.slice(); // Make defensive copy
870 }
871
872 /*-- Methods --*/
873
874 // Returns a new copy of the data bits of this segment.
875 public getData(): Array<bit> {
876 return this.bitData.slice(); // Make defensive copy
877 }
878
879 // (Package-private) Calculates and returns the number of bits needed to encode the given segments at
880 // the given version. The result is infinity if a segment has too many characters to fit its length field.
881 public static getTotalBits(segs: Readonly<Array<QrSegment>>, version: int): number {
882 let result: number = 0;
883 for (const seg of segs) {
884 const ccbits: int = seg.mode.numCharCountBits(version);
885 if (seg.numChars >= 1 << ccbits) return Infinity; // The segment's length doesn't fit the field's bit width
886 result += 4 + ccbits + seg.bitData.length;
887 }
888 return result;
889 }
890
891 // Returns a new array of bytes representing the given string encoded in UTF-8.
892 private static toUtf8ByteArray(str: string): Array<byte> {
893 str = encodeURI(str);
894 let result: Array<byte> = [];
895 for (let i = 0; i < str.length; i++) {
896 if (str.charAt(i) != "%") result.push(str.charCodeAt(i));
897 else {
898 result.push(parseInt(str.substring(i + 1, i + 3), 16));
899 i += 2;
900 }
901 }
902 return result;
903 }
904
905 /*-- Constants --*/
906
907 // Describes precisely all strings that are encodable in numeric mode.
908 private static readonly NUMERIC_REGEX: RegExp = /^[0-9]*$/;
909
910 // Describes precisely all strings that are encodable in alphanumeric mode.
911 private static readonly ALPHANUMERIC_REGEX: RegExp = /^[A-Z0-9 $%*+.\/:-]*$/;
912
913 // The set of all legal characters in alphanumeric mode,
914 // where each character value maps to the index in the string.
915 private static readonly ALPHANUMERIC_CHARSET: string =
916 "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:";
917 }
918}
919
920/*---- Public helper enumeration ----*/
921
922namespace qrcodegen.QrCode {
923 type int = number;
924
925 /*
926 * The error correction level in a QR Code symbol. Immutable.
927 */
928 export class Ecc {
929 /*-- Constants --*/
930
931 public static readonly LOW = new Ecc(0, 1); // The QR Code can tolerate about 7% erroneous codewords
932 public static readonly MEDIUM = new Ecc(1, 0); // The QR Code can tolerate about 15% erroneous codewords
933 public static readonly QUARTILE = new Ecc(2, 3); // The QR Code can tolerate about 25% erroneous codewords
934 public static readonly HIGH = new Ecc(3, 2); // The QR Code can tolerate about 30% erroneous codewords
935
936 /*-- Constructor and fields --*/
937
938 private constructor(
939 // In the range 0 to 3 (unsigned 2-bit integer).
940 public readonly ordinal: int,
941 // (Package-private) In the range 0 to 3 (unsigned 2-bit integer).
942 public readonly formatBits: int,
943 ) {}
944 }
945}
946
947/*---- Public helper enumeration ----*/
948
949namespace qrcodegen.QrSegment {
950 type int = number;
951
952 /*
953 * Describes how a segment's data bits are interpreted. Immutable.
954 */
955 export class Mode {
956 /*-- Constants --*/
957
958 public static readonly NUMERIC = new Mode(0x1, [10, 12, 14]);
959 public static readonly ALPHANUMERIC = new Mode(0x2, [9, 11, 13]);
960 public static readonly BYTE = new Mode(0x4, [8, 16, 16]);
961 public static readonly KANJI = new Mode(0x8, [8, 10, 12]);
962 public static readonly ECI = new Mode(0x7, [0, 0, 0]);
963
964 /*-- Constructor and fields --*/
965
966 private constructor(
967 // The mode indicator bits, which is a uint4 value (range 0 to 15).
968 public readonly modeBits: int,
969 // Number of character count bits for three different version ranges.
970 private readonly numBitsCharCount: [int, int, int],
971 ) {}
972
973 /*-- Method --*/
974
975 // (Package-private) Returns the bit width of the character count field for a segment in
976 // this mode in a QR Code at the given version number. The result is in the range [0, 16].
977 public numCharCountBits(ver: int): int {
978 return this.numBitsCharCount[Math.floor((ver + 7) / 17)];
979 }
980 }
981}
982
983export const QrCode = qrcodegen.QrCode;
984export const QrSegment = qrcodegen.QrSegment;