web.ts (12243B)
1 import { err_msg, type ResolveErrorMsg } from "@radroots/utils"; 2 import { NFC_ERROR, type NfcErrorMessage } from "./error.js"; 3 import { nfc_message_to_web, nfc_read_payload_from_event } from "./records.js"; 4 import type { 5 INfc, 6 INfcScanSession, 7 NdefReader, 8 NdefWriteOptions, 9 NfcAvailability, 10 NfcPermissionState, 11 NfcRecordInput, 12 NfcReadPayload, 13 NfcReadHandler, 14 NfcErrorHandler, 15 NfcScanOptions, 16 NfcWriteInput, 17 NfcWriteOptions 18 } from "./types.js"; 19 20 type NfcStopReason = "user" | "abort" | "timeout" | "error"; 21 22 type NfcPermissionName = PermissionName | "nfc"; 23 24 type NfcPermissionDescriptor = { 25 name: NfcPermissionName; 26 }; 27 28 interface PermissionsNfc { 29 query(permissionDesc: NfcPermissionDescriptor): Promise<PermissionStatus>; 30 } 31 32 interface NavigatorWithPermissions extends Navigator { 33 permissions: PermissionsNfc; 34 } 35 36 const has_permissions_api = (nav: Navigator): nav is NavigatorWithPermissions => "permissions" in nav; 37 38 const read_permission_state = async (nav: Navigator): Promise<NfcPermissionState> => { 39 if (!has_permissions_api(nav)) return "unknown"; 40 try { 41 const status = await nav.permissions.query({ name: "nfc" }); 42 return status.state; 43 } catch { 44 return "unknown"; 45 } 46 }; 47 48 const read_availability = (): NfcAvailability => { 49 const window_available = typeof window !== "undefined"; 50 const secure_context = window_available && window.isSecureContext === true; 51 const reader_available = window_available && typeof window.NDEFReader !== "undefined"; 52 return { 53 supported: window_available && secure_context && reader_available, 54 secure_context, 55 window_available, 56 reader_available 57 }; 58 }; 59 60 const create_reader = (): ResolveErrorMsg<NdefReader, NfcErrorMessage> => { 61 const availability = read_availability(); 62 if (!availability.window_available) return err_msg(NFC_ERROR.window_undefined); 63 if (!availability.secure_context) return err_msg(NFC_ERROR.secure_context_required); 64 if (!availability.reader_available) return err_msg(NFC_ERROR.unsupported); 65 const Reader = window.NDEFReader; 66 if (!Reader) return err_msg(NFC_ERROR.unsupported); 67 return new Reader(); 68 }; 69 70 const map_nfc_error = (err: unknown, fallback: NfcErrorMessage): NfcErrorMessage => { 71 if (typeof DOMException !== "undefined" && err instanceof DOMException) { 72 if (err.name === "AbortError") return NFC_ERROR.abort; 73 if (err.name === "NotAllowedError") return NFC_ERROR.permission_denied; 74 if (err.name === "NotSupportedError") return NFC_ERROR.unsupported; 75 if (err.name === "SecurityError") return NFC_ERROR.secure_context_required; 76 if (err.name === "NotReadableError") return NFC_ERROR.read_failed; 77 } 78 return fallback; 79 }; 80 81 const link_abort_signal = (controller: AbortController, signal?: AbortSignal): void => { 82 if (!signal) return; 83 if (signal.aborted) { 84 controller.abort(); 85 return; 86 } 87 signal.addEventListener("abort", () => controller.abort(), { once: true }); 88 }; 89 90 const create_timeout = (controller: AbortController, timeout_ms?: number): { is_timeout: () => boolean; clear: () => void } => { 91 let fired = false; 92 let timeout_id: ReturnType<typeof setTimeout> | undefined; 93 if (timeout_ms && timeout_ms > 0) { 94 timeout_id = setTimeout(() => { 95 fired = true; 96 controller.abort(); 97 }, timeout_ms); 98 } 99 const clear = (): void => { 100 if (timeout_id) clearTimeout(timeout_id); 101 timeout_id = undefined; 102 }; 103 const is_timeout = (): boolean => fired; 104 return { is_timeout, clear }; 105 }; 106 107 const build_write_options = (controller: AbortController, opts?: NfcWriteOptions): NdefWriteOptions => { 108 return { 109 signal: controller.signal, 110 overwrite: opts?.overwrite 111 }; 112 }; 113 114 const is_message_input = (value: NfcWriteInput): value is { records: NfcRecordInput[] } => { 115 if (typeof value !== "object" || value === null) return false; 116 if (!("records" in value)) return false; 117 return Array.isArray(value.records); 118 }; 119 120 const resolve_message = (value: NfcWriteInput): ResolveErrorMsg<string | ReturnType<typeof nfc_message_to_web>, NfcErrorMessage> => { 121 if (typeof value === "string") return value; 122 if (Array.isArray(value)) { 123 if (!value.length) return err_msg(NFC_ERROR.invalid_message); 124 return nfc_message_to_web({ records: value }); 125 } 126 if (is_message_input(value)) { 127 if (!value.records.length) return err_msg(NFC_ERROR.invalid_message); 128 return nfc_message_to_web(value); 129 } 130 if (typeof value === "object" && value !== null && "records" in value) return err_msg(NFC_ERROR.invalid_message); 131 return nfc_message_to_web({ records: [value] }); 132 }; 133 134 const is_error = <T>(value: ResolveErrorMsg<T, NfcErrorMessage>): value is { err: NfcErrorMessage } => { 135 return typeof value === "object" && value !== null && "err" in value; 136 }; 137 138 class NfcScanSession implements INfcScanSession { 139 private active = true; 140 private started = false; 141 private stop_reason: NfcStopReason | null = null; 142 private on_read?: NfcReadHandler; 143 private on_error?: NfcErrorHandler; 144 private timeout_id?: ReturnType<typeof setTimeout>; 145 146 constructor( 147 private readonly reader: NdefReader, 148 private readonly controller: AbortController, 149 opts: { 150 on_read?: NfcReadHandler; 151 on_error?: NfcErrorHandler; 152 timeout_ms?: number; 153 on_stop: () => void; 154 } 155 ) { 156 this.on_read = opts.on_read; 157 this.on_error = opts.on_error; 158 if (opts.timeout_ms && opts.timeout_ms > 0) { 159 this.timeout_id = setTimeout(() => this.stop_with_reason("timeout"), opts.timeout_ms); 160 } 161 this.controller.signal.addEventListener("abort", () => { 162 if (this.stop_reason) return; 163 this.stop_with_reason("abort"); 164 }, { once: true }); 165 this.on_stop = opts.on_stop; 166 } 167 168 private on_stop: () => void; 169 170 public get_active(): boolean { 171 return this.active; 172 } 173 174 public get_signal(): AbortSignal { 175 return this.controller.signal; 176 } 177 178 public set_on_read(handler?: NfcReadHandler): void { 179 this.on_read = handler; 180 } 181 182 public set_on_error(handler?: NfcErrorHandler): void { 183 this.on_error = handler; 184 } 185 186 public async start(): Promise<ResolveErrorMsg<void, NfcErrorMessage>> { 187 if (this.started) return err_msg(NFC_ERROR.scan_failed); 188 this.started = true; 189 try { 190 this.reader.onreading = event => { 191 if (!this.active) return; 192 const payload = nfc_read_payload_from_event(event); 193 if (this.on_read) this.on_read(payload); 194 }; 195 this.reader.onreadingerror = () => { 196 if (!this.active) return; 197 if (this.on_error) this.on_error(NFC_ERROR.read_failed); 198 }; 199 await this.reader.scan({ signal: this.controller.signal }); 200 return; 201 } catch (e) { 202 const mapped = map_nfc_error(e, NFC_ERROR.scan_failed); 203 this.stop_with_reason("error", mapped); 204 return err_msg(mapped); 205 } 206 } 207 208 public async stop(): Promise<void> { 209 this.stop_with_reason("user"); 210 } 211 212 private stop_with_reason(reason: NfcStopReason, error?: NfcErrorMessage): void { 213 if (!this.active) return; 214 this.active = false; 215 this.stop_reason = reason; 216 if (this.timeout_id) { 217 clearTimeout(this.timeout_id); 218 this.timeout_id = undefined; 219 } 220 this.reader.onreading = null; 221 this.reader.onreadingerror = null; 222 if (!this.controller.signal.aborted) this.controller.abort(); 223 this.on_stop(); 224 if (reason === "abort" && this.on_error) this.on_error(NFC_ERROR.abort); 225 if (reason === "timeout" && this.on_error) this.on_error(NFC_ERROR.timeout); 226 if (reason === "error" && error && this.on_error) this.on_error(error); 227 } 228 } 229 230 export interface IWebNfc extends INfc {} 231 232 export class WebNfc implements IWebNfc { 233 private scan_session: NfcScanSession | null = null; 234 235 public availability(): NfcAvailability { 236 return read_availability(); 237 } 238 239 public async permission_state(): Promise<NfcPermissionState> { 240 if (typeof navigator === "undefined") return "unknown"; 241 return read_permission_state(navigator); 242 } 243 244 public async scan_start(opts?: NfcScanOptions): Promise<ResolveErrorMsg<INfcScanSession, NfcErrorMessage>> { 245 if (this.scan_session && this.scan_session.get_active()) return err_msg(NFC_ERROR.scan_in_progress); 246 const reader = create_reader(); 247 if (is_error(reader)) return reader; 248 249 const controller = new AbortController(); 250 link_abort_signal(controller, opts?.signal); 251 if (controller.signal.aborted) return err_msg(NFC_ERROR.abort); 252 253 const session = new NfcScanSession(reader, controller, { 254 on_read: opts?.on_read, 255 on_error: opts?.on_error, 256 timeout_ms: opts?.timeout_ms, 257 on_stop: () => { 258 if (this.scan_session === session) this.scan_session = null; 259 } 260 }); 261 262 const started = await session.start(); 263 if (is_error(started)) return started; 264 this.scan_session = session; 265 return session; 266 } 267 268 public async scan_once(opts?: NfcScanOptions): Promise<ResolveErrorMsg<NfcReadPayload, NfcErrorMessage>> { 269 const session = await this.scan_start({ 270 signal: opts?.signal, 271 timeout_ms: opts?.timeout_ms 272 }); 273 if (is_error(session)) return session; 274 275 return await new Promise<ResolveErrorMsg<NfcReadPayload, NfcErrorMessage>>(resolve => { 276 session.set_on_read(payload => { 277 session.stop(); 278 if (opts?.on_read) opts.on_read(payload); 279 resolve(payload); 280 }); 281 session.set_on_error(error => { 282 session.stop(); 283 if (opts?.on_error) opts.on_error(error); 284 resolve(err_msg(error)); 285 }); 286 }); 287 } 288 289 public async write(message: NfcWriteInput, opts?: NfcWriteOptions): Promise<ResolveErrorMsg<void, NfcErrorMessage>> { 290 const reader = create_reader(); 291 if (is_error(reader)) return reader; 292 293 const resolved = resolve_message(message); 294 if (is_error(resolved)) return resolved; 295 296 const controller = new AbortController(); 297 link_abort_signal(controller, opts?.signal); 298 299 if (controller.signal.aborted) { 300 return err_msg(NFC_ERROR.abort); 301 } 302 303 const timeout = create_timeout(controller, opts?.timeout_ms); 304 305 try { 306 await reader.write(resolved, build_write_options(controller, opts)); 307 timeout.clear(); 308 return; 309 } catch (e) { 310 if (timeout.is_timeout()) { 311 timeout.clear(); 312 return err_msg(NFC_ERROR.timeout); 313 } 314 timeout.clear(); 315 return err_msg(map_nfc_error(e, NFC_ERROR.write_failed)); 316 } 317 } 318 319 public async make_read_only(opts?: NfcWriteOptions): Promise<ResolveErrorMsg<void, NfcErrorMessage>> { 320 const reader = create_reader(); 321 if (is_error(reader)) return reader; 322 if (!reader.makeReadOnly) return err_msg(NFC_ERROR.unsupported); 323 324 const controller = new AbortController(); 325 link_abort_signal(controller, opts?.signal); 326 327 if (controller.signal.aborted) { 328 return err_msg(NFC_ERROR.abort); 329 } 330 331 const timeout = create_timeout(controller, opts?.timeout_ms); 332 333 try { 334 await reader.makeReadOnly(build_write_options(controller, opts)); 335 timeout.clear(); 336 return; 337 } catch (e) { 338 if (timeout.is_timeout()) { 339 timeout.clear(); 340 return err_msg(NFC_ERROR.timeout); 341 } 342 timeout.clear(); 343 return err_msg(map_nfc_error(e, NFC_ERROR.make_read_only_failed)); 344 } 345 } 346 }