@gryt/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,201 @@
1
+ /**
2
+ * The shape `Gryt-chat/reports` takes, and how an app fills it in.
3
+ *
4
+ * `POST /v1/reports`. Only `type` and `message` are required — everything else
5
+ * is diagnostics, and the service is explicit that a field an app gets wrong is
6
+ * truncated or dropped rather than a reason to reject: "a report lost to a
7
+ * validation error is a bug nobody hears about."
8
+ *
9
+ * That cuts both ways, and it is why nothing here throws and nothing here is
10
+ * required to succeed. A diagnostic an app cannot work out is left off rather
11
+ * than sent as a guess, because a wrong Electron version in a bug report is
12
+ * worse than no Electron version.
13
+ *
14
+ * ## Why this is one file and not two
15
+ *
16
+ * The desktop and the phone had a copy each, with the same seven exports and
17
+ * different insides. Most of the difference was real: a renderer knows its
18
+ * Chrome build, a phone knows whether it is an emulator, and neither should
19
+ * send the other's fields empty. Some of it was not. `MESSAGE_MAX` was 8000 on
20
+ * the desktop and 4000 on the phone, and nothing recorded a reason — the two
21
+ * files even carried the same sentence about not cutting people off at a tweet.
22
+ *
23
+ * So `Diagnostics` below is the union of both, every field optional, and each
24
+ * app fills what it can actually find out. A field neither app fills is dead
25
+ * and can be seen to be dead, which is the other half of the point.
26
+ */
27
+ /**
28
+ * The service truncates, but an app should not send a novel either.
29
+ *
30
+ * Generous rather than tight: somebody describing a bug properly is the good
31
+ * case, and cutting them off at a tweet is how you get "it broke" instead. The
32
+ * desktop's 8000 wins over the phone's 4000 on exactly that argument, which
33
+ * both files made and only one of them followed.
34
+ */
35
+ export const MESSAGE_MAX = 8000;
36
+ export const TITLE_MAX = 120;
37
+ /** Trimmed, capped, and undefined rather than empty. */
38
+ function text(value, max) {
39
+ const trimmed = String(value ?? "").trim();
40
+ if (!trimmed)
41
+ return undefined;
42
+ return trimmed.length > max ? trimmed.slice(0, max) : trimmed;
43
+ }
44
+ /** Only the keys that have a value, or undefined if none of them do. */
45
+ function some(entries) {
46
+ const kept = Object.entries(entries).filter(([, v]) => v !== undefined);
47
+ return kept.length ? Object.fromEntries(kept) : undefined;
48
+ }
49
+ function str(value) {
50
+ if (value === null || value === undefined)
51
+ return undefined;
52
+ const s = String(value).trim();
53
+ return s ? s : undefined;
54
+ }
55
+ function bool(value) {
56
+ return typeof value === "boolean" ? value : undefined;
57
+ }
58
+ function count(value) {
59
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
60
+ }
61
+ /**
62
+ * Assemble what gets sent.
63
+ *
64
+ * `type` and `message` always; every diagnostic only if it is actually known.
65
+ * An empty `device` object says "this app does not collect device
66
+ * information", which is a different and wronger claim than leaving it off.
67
+ */
68
+ export function buildReport(type, input, diagnostics = {}) {
69
+ /* The embedded server is two fields on the wire rather than one, because
70
+ * "running its own server" and "which version that server is" answer
71
+ * different questions and the second is often the one that matters. Neither
72
+ * has a column on the service, so they go in `extra`, which is what it is
73
+ * for. Only the desktop can run one, and the phone simply never sets them. */
74
+ const extra = some({
75
+ embeddedServer: bool(diagnostics.embeddedServer),
76
+ embeddedServerVersion: str(diagnostics.embeddedServerVersion),
77
+ });
78
+ return {
79
+ type,
80
+ // Capped rather than validated. The service rejects only an empty message,
81
+ // and the form is what stops it being empty.
82
+ message: text(input.message, MESSAGE_MAX) ?? "",
83
+ title: text(input.title, TITLE_MAX),
84
+ app: some({
85
+ version: str(diagnostics.version),
86
+ build: str(diagnostics.build),
87
+ channel: str(diagnostics.channel),
88
+ installId: str(diagnostics.installId),
89
+ locale: str(diagnostics.locale),
90
+ }),
91
+ device: some({
92
+ platform: str(diagnostics.platform),
93
+ osVersion: str(diagnostics.osVersion),
94
+ isEmulator: bool(diagnostics.isEmulator),
95
+ screen: diagnostics.screen ?? undefined,
96
+ timezone: str(diagnostics.timezone),
97
+ }),
98
+ runtime: some({
99
+ engine: str(diagnostics.engine),
100
+ chromeVersion: str(diagnostics.chromeVersion),
101
+ electronVersion: str(diagnostics.electronVersion),
102
+ reactNativeVersion: str(diagnostics.reactNativeVersion),
103
+ expoVersion: str(diagnostics.expoVersion),
104
+ userAgent: str(diagnostics.userAgent),
105
+ }),
106
+ context: some({
107
+ route: str(diagnostics.route),
108
+ serverVersion: str(diagnostics.serverVersion),
109
+ connected: bool(diagnostics.connected),
110
+ voiceActive: bool(diagnostics.voiceActive),
111
+ online: bool(diagnostics.online),
112
+ networkType: str(diagnostics.networkType),
113
+ sessionUptimeSec: count(diagnostics.sessionUptimeSec),
114
+ }),
115
+ logs: diagnostics.logs?.length ? diagnostics.logs : undefined,
116
+ extra,
117
+ };
118
+ }
119
+ /** "2 min", not "127 s". A duration somebody reads, not a field. */
120
+ function uptime(seconds) {
121
+ if (seconds === undefined)
122
+ return undefined;
123
+ if (seconds < 90)
124
+ return `${seconds} sec`;
125
+ const minutes = Math.round(seconds / 60);
126
+ if (minutes < 90)
127
+ return `${minutes} min`;
128
+ return `${Math.round(minutes / 60)} h`;
129
+ }
130
+ /**
131
+ * `win32` on the wire, "Windows" on the screen.
132
+ *
133
+ * The service's enum is lowercase and the report keeps it that way. This is
134
+ * only for the list somebody reads before sending, where a bare `darwin` or
135
+ * "ios" next to a version number reads as a typo, and "Ios" would be worse
136
+ * than either.
137
+ *
138
+ * Both apps had one of these knowing only its own platforms. One function
139
+ * knowing all of them is the same size and stops a macOS build of the desktop
140
+ * from printing "Macos".
141
+ */
142
+ function platformLabel(platform) {
143
+ if (platform === "darwin" || platform === "macos")
144
+ return "macOS";
145
+ if (platform === "win32" || platform === "windows")
146
+ return "Windows";
147
+ if (platform === "linux")
148
+ return "Linux";
149
+ if (platform === "ios")
150
+ return "iOS";
151
+ if (platform === "android")
152
+ return "Android";
153
+ return platform.charAt(0).toUpperCase() + platform.slice(1);
154
+ }
155
+ /**
156
+ * The same diagnostics, as lines to show somebody before they send.
157
+ *
158
+ * A form that quietly ships a route, a server version and a log tail is worse
159
+ * than one that says so — and this is the list that makes "what is attached"
160
+ * answerable without reading the source. It is built from the report rather
161
+ * than from the inputs, so it cannot drift from what actually goes.
162
+ *
163
+ * Every row is conditional, so an app that fills half the fields shows half the
164
+ * rows rather than a column of blanks.
165
+ */
166
+ export function describeAttached(report) {
167
+ const lines = [];
168
+ const add = (label, value) => {
169
+ if (value)
170
+ lines.push({ label, value });
171
+ };
172
+ const device = report.device;
173
+ add("Gryt", report.app?.version
174
+ ? `v${report.app.version}${report.app.build ? ` (${report.app.build})` : ""}`
175
+ : undefined);
176
+ add("Channel", report.app?.channel);
177
+ add("System", device?.platform && device?.osVersion
178
+ ? `${platformLabel(device.platform)} ${device.osVersion}`
179
+ : device?.platform
180
+ ? platformLabel(device.platform)
181
+ : undefined);
182
+ add("Simulator", device?.isEmulator ? "yes" : undefined);
183
+ add("Electron", report.runtime?.electronVersion);
184
+ add("Chrome", report.runtime?.chromeVersion);
185
+ add("React Native", report.runtime?.reactNativeVersion);
186
+ add("Expo", report.runtime?.expoVersion);
187
+ add("Window", device?.screen
188
+ ? `${device.screen.width}×${device.screen.height} @${device.screen.scale}x`
189
+ : undefined);
190
+ add("Timezone", device?.timezone);
191
+ add("Where you were", report.context?.route);
192
+ add("Running for", uptime(report.context?.sessionUptimeSec));
193
+ add("Server", report.context?.serverVersion);
194
+ add("In voice", report.context?.voiceActive ? "yes" : undefined);
195
+ add("Own server", report.extra?.embeddedServer
196
+ ? String(report.extra.embeddedServerVersion ?? "yes")
197
+ : undefined);
198
+ add("Log", report.logs?.length ? `last ${report.logs.length} lines` : undefined);
199
+ add("Install", report.app?.installId);
200
+ return lines;
201
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@gryt/core",
3
+ "version": "0.1.0",
4
+ "description": "Gryt's shared application logic: one implementation of the things the desktop and mobile apps both do.",
5
+ "license": "AGPL-3.0-only",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/Gryt-chat/core.git"
9
+ },
10
+ "type": "module",
11
+ "main": "./dist/index.js",
12
+ "types": "./dist/index.d.ts",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "default": "./dist/index.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "scripts": {
23
+ "build": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json && node scripts/add-esm-extensions.mjs",
24
+ "typecheck": "tsc -p tsconfig.json --noEmit",
25
+ "test": "node --test \"src/**/*.test.ts\"",
26
+ "check-surface": "node scripts/check-public-surface.mjs",
27
+ "prepublishOnly": "npm run build && npm run test && npm run check-surface"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^20.19.33",
31
+ "typescript": "^5.9.3"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ }
36
+ }