camera.ts 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  1. /**
  2. * How a camera stream opens: a separate browser window, or a floating overlay
  3. * on the page you are already on.
  4. *
  5. * This used to be one global switch in Settings > General > Camera. It is now
  6. * chosen per click from the camera button's own menu, and the stored setting
  7. * survives only as the default a browser starts from.
  8. */
  9. export type CameraViewMode = 'window' | 'embedded';
  10. /** The mode the last camera button click chose, in this browser. */
  11. const CAMERA_VIEW_MODE_KEY = 'cameraViewMode';
  12. /** Geometry the camera popup was last left at, written by CameraPage. */
  13. const CAMERA_WINDOW_STATE_KEY = 'cameraWindowState';
  14. export function isCameraViewMode(value: unknown): value is CameraViewMode {
  15. return value === 'window' || value === 'embedded';
  16. }
  17. /**
  18. * The local choice, or null if this browser has never made one.
  19. *
  20. * Null rather than a default so the caller can fall back to the server-side
  21. * setting: that is what makes a fresh browser open the same way the last one
  22. * did, without the local choice ever being overwritten by it.
  23. */
  24. export function readStoredCameraViewMode(): CameraViewMode | null {
  25. try {
  26. const saved = localStorage.getItem(CAMERA_VIEW_MODE_KEY);
  27. return isCameraViewMode(saved) ? saved : null;
  28. } catch {
  29. // Private-mode Safari and friends. A camera that opens the default way is
  30. // a better outcome than a page that throws on render.
  31. return null;
  32. }
  33. }
  34. export function storeCameraViewMode(mode: CameraViewMode): void {
  35. try {
  36. localStorage.setItem(CAMERA_VIEW_MODE_KEY, mode);
  37. } catch {
  38. // Choice applies to this session regardless; only persistence is lost.
  39. }
  40. }
  41. /**
  42. * Open a printer's camera in its own browser window, reusing whatever size and
  43. * position the user last left one at.
  44. *
  45. * Deliberately not `noopener`: the popup is same-origin and needs `opener` set
  46. * so the browser copies sessionStorage -- which is where the auth token lives
  47. * -- into the new window. Without it the camera page loads unauthenticated.
  48. */
  49. export function openCameraWindow(printerId: number): boolean {
  50. let state: { width?: number; height?: number; left?: number; top?: number } = {
  51. width: 640,
  52. height: 400,
  53. };
  54. try {
  55. const saved = localStorage.getItem(CAMERA_WINDOW_STATE_KEY);
  56. if (saved) {
  57. const parsed = JSON.parse(saved) as typeof state;
  58. if (parsed && typeof parsed === 'object') state = parsed;
  59. }
  60. } catch {
  61. // Corrupt or unreadable geometry falls back to the defaults above rather
  62. // than leaving the camera unopenable.
  63. }
  64. const features = [
  65. `width=${state.width ?? 640}`,
  66. `height=${state.height ?? 400}`,
  67. state.left !== undefined ? `left=${state.left}` : '',
  68. state.top !== undefined ? `top=${state.top}` : '',
  69. 'menubar=no,toolbar=no,location=no,status=no',
  70. ]
  71. .filter(Boolean)
  72. .join(',');
  73. // False when a popup blocker stopped it, so a caller closing the overlay it
  74. // came from can leave the camera where it was.
  75. return window.open(`/camera/${printerId}`, `camera-${printerId}`, features) !== null;
  76. }
  77. /**
  78. * Moving a camera window back into a Bambuddy tab as an overlay (#2737).
  79. *
  80. * The camera window asks over a BroadcastChannel, every tab showing the app
  81. * answers, and exactly one of them is told to open the overlay: a window can
  82. * be open beside several tabs, and the camera should not appear in each.
  83. * The tab the window was opened from is preferred. Its id is in
  84. * sessionStorage, which the browser copies into the popup when it opens.
  85. */
  86. const CAMERA_CHANNEL = 'bambuddy-camera';
  87. const TAB_ID_KEY = 'bambuddyTabId';
  88. type PopInMessage =
  89. | { type: 'pop-in-offer'; requestId: string; printerId: number; printerName: string }
  90. | { type: 'pop-in-candidate'; requestId: string; tabId: string; instanceId: string }
  91. | { type: 'pop-in-claim'; requestId: string; instanceId: string; deadline: number }
  92. | { type: 'pop-in-done'; requestId: string }
  93. | { type: 'pop-in-refused'; requestId: string; instanceId: string };
  94. function randomId(prefix: string): string {
  95. // Math.random: crypto.randomUUID needs a secure context (see useCameraViewerId).
  96. return `${prefix}${Math.random().toString(36).slice(2, 12)}`;
  97. }
  98. /**
  99. * This tab's id, kept in sessionStorage so a popup it opens inherits it. It
  100. * survives a reload or a sign-in again, so a camera window opened before
  101. * still finds the tab it came from.
  102. */
  103. export function registerCameraTab(): string {
  104. try {
  105. const existing = sessionStorage.getItem(TAB_ID_KEY);
  106. if (existing) return existing;
  107. const tabId = randomId('t');
  108. sessionStorage.setItem(TAB_ID_KEY, tabId);
  109. return tabId;
  110. } catch {
  111. // Without it the window still pops in, just not necessarily to this tab.
  112. return randomId('t');
  113. }
  114. }
  115. /** The tab this camera window was opened from, if it was. */
  116. export function readOpenerTabId(): string | null {
  117. try {
  118. return sessionStorage.getItem(TAB_ID_KEY);
  119. } catch {
  120. return null;
  121. }
  122. }
  123. /**
  124. * Answer camera windows asking to move into a tab; `open` shows the overlay.
  125. * Returns the cleanup.
  126. *
  127. * A duplicated tab starts with a copy of sessionStorage and so the same tab
  128. * id; the claim names one listener (`instanceId`), so only one of them opens.
  129. * `canShow` is asked before saying done: each tab has its own sign-in, and a
  130. * tab whose user cannot see the printer would drop the overlay right after
  131. * the window had closed. It refuses instead, and the window asks the next.
  132. */
  133. export function listenForCameraPopIn(
  134. tabId: string,
  135. open: (printerId: number, printerName: string) => void,
  136. canShow: (printerId: number) => Promise<boolean> = async () => true,
  137. ): () => void {
  138. if (typeof BroadcastChannel === 'undefined') return () => {};
  139. const channel = new BroadcastChannel(CAMERA_CHANNEL);
  140. let closed = false;
  141. const instanceId = randomId('i');
  142. const offers = new Map<string, { printerId: number; printerName: string }>();
  143. channel.onmessage = async (event: MessageEvent<PopInMessage>) => {
  144. const msg = event.data;
  145. if (msg?.type === 'pop-in-offer' && Number.isInteger(msg.printerId) && msg.printerId > 0) {
  146. offers.set(msg.requestId, { printerId: msg.printerId, printerName: String(msg.printerName ?? '') });
  147. channel.postMessage({ type: 'pop-in-candidate', requestId: msg.requestId, tabId, instanceId } satisfies PopInMessage);
  148. } else if (msg?.type === 'pop-in-claim') {
  149. if (msg.instanceId !== instanceId) return;
  150. const offer = offers.get(msg.requestId);
  151. offers.delete(msg.requestId);
  152. if (!offer) return;
  153. let allowed = false;
  154. try {
  155. allowed = await canShow(offer.printerId);
  156. } catch {
  157. allowed = false;
  158. }
  159. if (closed) return;
  160. if (!allowed) {
  161. channel.postMessage({ type: 'pop-in-refused', requestId: msg.requestId, instanceId } satisfies PopInMessage);
  162. return;
  163. }
  164. // A claim that reaches a throttled background tab after the window
  165. // gave up would show the camera twice: the window kept its own.
  166. if (!(Date.now() <= msg.deadline)) return;
  167. open(offer.printerId, offer.printerName);
  168. channel.postMessage({ type: 'pop-in-done', requestId: msg.requestId } satisfies PopInMessage);
  169. } else if (msg?.type === 'pop-in-done') {
  170. // Another tab took it; an offer this one was not claimed for is over.
  171. offers.delete(msg.requestId);
  172. }
  173. };
  174. return () => {
  175. closed = true;
  176. channel.close();
  177. };
  178. }
  179. /**
  180. * Ask a Bambuddy tab to take this camera as an overlay. Resolves true once one
  181. * has opened it, false when no tab answered (none open, or all signed out),
  182. * none could show the printer, or the claimed one did not open it in time.
  183. */
  184. export function requestCameraPopIn(
  185. printerId: number,
  186. printerName: string,
  187. preferredTabId: string | null,
  188. { answerMs = 400, doneMs = 3000 }: { answerMs?: number; doneMs?: number } = {},
  189. ): Promise<boolean> {
  190. if (typeof BroadcastChannel === 'undefined') return Promise.resolve(false);
  191. const channel = new BroadcastChannel(CAMERA_CHANNEL);
  192. const requestId = randomId('r');
  193. return new Promise<boolean>((resolve) => {
  194. const candidates: Array<{ tabId: string; instanceId: string }> = [];
  195. let claimedId: string | null = null;
  196. let timer: ReturnType<typeof setTimeout>;
  197. const finish = (ok: boolean) => {
  198. clearTimeout(timer);
  199. channel.close();
  200. resolve(ok);
  201. };
  202. const claimNext = () => {
  203. clearTimeout(timer);
  204. const target = candidates.find(c => c.tabId === preferredTabId) ?? candidates[0];
  205. if (!target) {
  206. finish(false);
  207. return;
  208. }
  209. candidates.splice(candidates.indexOf(target), 1);
  210. claimedId = target.instanceId;
  211. // The tab must open it before this window stops waiting; a margin
  212. // covers the done message's own trip back.
  213. const deadline = Date.now() + doneMs / 2;
  214. channel.postMessage({ type: 'pop-in-claim', requestId, instanceId: target.instanceId, deadline } satisfies PopInMessage);
  215. timer = setTimeout(() => finish(false), doneMs);
  216. };
  217. channel.onmessage = (event: MessageEvent<PopInMessage>) => {
  218. const msg = event.data;
  219. if (msg?.requestId !== requestId) return;
  220. if (msg.type === 'pop-in-candidate') {
  221. // Kept after a claim too: a tab that refuses hands on to the others.
  222. candidates.push({ tabId: msg.tabId, instanceId: msg.instanceId });
  223. // The tab it came from needs no waiting for the others.
  224. if (claimedId === null && preferredTabId && msg.tabId === preferredTabId) claimNext();
  225. } else if (msg.type === 'pop-in-refused' && msg.instanceId === claimedId) {
  226. claimNext();
  227. } else if (msg.type === 'pop-in-done') {
  228. finish(true);
  229. }
  230. };
  231. timer = setTimeout(claimNext, answerMs);
  232. channel.postMessage({ type: 'pop-in-offer', requestId, printerId, printerName } satisfies PopInMessage);
  233. });
  234. }