Viewing is open to everyone. Downloading and starring need a GitHub sign-in. Sign in with GitHub

payload.mjs

v0.1.0-build.85 Latest Commit ede38f5

Size
4.1 KB
Lines
117
Language
javascript
SHA-256
4b2bfa49d8fb…
View on GitHub

Sign in with GitHub to download. Each download is recorded with your GitHub username.

1// Payload interpretation per spec/CKey-Payload-Conventions.md. Pure
2// JavaScript. These helpers only describe a proposed action; they never open,
3// dial, navigate or otherwise act. The host shows the payload and the action
4// and lets the user decide (safety rule 1).
5
6const ALLOWED_CONTROL = new Set([0x09, 0x0a, 0x0d]); // tab, LF, CR (vCards use CR LF)
7
8/**
9 * Text if the bytes are valid UTF-8 with no control characters other than
10 * tab, LF and CR; otherwise null (show the payload as binary, e.g. hex).
11 */
12export function readableText(bytes) {
13 if (!(bytes instanceof Uint8Array) || bytes.length === 0) return null;
14 let text;
15 try {
16 text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
17 } catch {
18 return null;
19 }
20 for (const ch of text) {
21 const c = ch.codePointAt(0);
22 if ((c < 0x20 || (c >= 0x7f && c <= 0x9f)) && !ALLOWED_CONTROL.has(c)) return null;
23 }
24 return text;
25}
26
27/** Lowercase hexadecimal of bytes. */
28export function toHex(bytes) {
29 let s = '';
30 for (const b of bytes) s += b.toString(16).padStart(2, '0');
31 return s;
32}
33
34function decodeSafe(s) {
35 try {
36 return decodeURIComponent(s);
37 } catch {
38 return s;
39 }
40}
41
42const TEXT = (label = 'Plain text') => ({ kind: 'text', target: null, label });
43
44/**
45 * Classifies a text payload into the action a conforming reader may OFFER.
46 * kind: 'https' | 'http' | 'mailto' | 'tel' | 'sms' | 'geo' | 'address' | 'vcard' | 'text'
47 * target: what the host would hand to the platform if the user accepts:
48 * the trimmed URI for https/http/mailto/tel/sms/geo, the joined
49 * address for address:, the vCard text for vcard; null for text.
50 * label: the action stating its target, e.g. "Open openckey.org",
51 * "Call +15551234567", "Show 1 Infinite Loop, Cupertino on a map".
52 * Anything not on the allowlist, or failing validation (rule 4), is 'text'
53 * with no action (javascript:, data:, file:, intent:, custom schemes ...).
54 */
55export function classifyPayload(input) {
56 if (input === null || input === undefined) return TEXT();
57 if (input instanceof Uint8Array) {
58 const t = readableText(input);
59 if (t === null) return { kind: 'text', target: null, label: 'Binary data' };
60 input = t;
61 }
62 const t = String(input).trim();
63 if (t === '') return TEXT();
64
65 if (/^BEGIN:VCARD\r?\n/i.test(t)) {
66 if (!/\r?\nEND:VCARD$/i.test(t)) return TEXT();
67 const fn = /(?:^|\r?\n)FN(?:;[^:\r\n]*)?:([^\r\n]*)/i.exec(t);
68 const name = fn ? fn[1].trim() : '';
69 return { kind: 'vcard', target: t, label: name ? `Add contact ${name}` : 'Add contact' };
70 }
71
72 const m = /^([A-Za-z][A-Za-z0-9+.-]*):/.exec(t);
73 if (!m) return TEXT();
74 const scheme = m[1].toLowerCase();
75 const rest = t.slice(m[0].length);
76
77 switch (scheme) {
78 case 'https':
79 case 'http': {
80 let url;
81 try {
82 url = new URL(t);
83 } catch {
84 return TEXT();
85 }
86 if (url.protocol !== `${scheme}:` || !url.host) return TEXT();
87 return { kind: scheme, target: t, label: `Open ${url.host}` };
88 }
89 case 'mailto': {
90 if (!rest.includes('@')) return TEXT();
91 const addr = decodeSafe(rest.split('?')[0]);
92 return { kind: 'mailto', target: t, label: addr ? `Email ${addr}` : 'Compose an email' };
93 }
94 case 'tel':
95 case 'sms': {
96 const number = rest.split(/[?;]/)[0].replace(/[^+0-9]/g, '');
97 if (number.replace(/\+/g, '').length < 3) return TEXT();
98 return { kind: scheme, target: t, label: `${scheme === 'tel' ? 'Call' : 'Message'} ${number}` };
99 }
100 case 'geo': {
101 const g = /^(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)/.exec(rest);
102 if (!g || Math.abs(Number(g[1])) > 90 || Math.abs(Number(g[2])) > 180) return TEXT();
103 return { kind: 'geo', target: t, label: `Show ${g[1]}, ${g[2]} on a map` };
104 }
105 case 'address': {
106 const joined = rest
107 .split(/\r?\n/)
108 .map((s) => s.trim())
109 .filter(Boolean)
110 .join(', ');
111 if (joined === '') return TEXT();
112 return { kind: 'address', target: joined, label: `Show ${joined} on a map` };
113 }
114 default:
115 return TEXT();
116 }
117}