pwaInstall.svelte.js 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. /** @typedef {Event & { prompt: () => Promise<void>, userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }> }} BeforeInstallPromptEvent */
  2. const PROMPT_KEY = '__recipesPwaDeferredPrompt';
  3. const LISTENER_KEY = '__recipesPwaInstallListening';
  4. let ready = $state(false);
  5. let installed = $state(false);
  6. let alreadyOnDevice = $state(false);
  7. function getDeferredPrompt() {
  8. if (typeof window === 'undefined') return null;
  9. return /** @type {BeforeInstallPromptEvent | null} */ (window[PROMPT_KEY] ?? null);
  10. }
  11. function setDeferredPrompt(/** @type {BeforeInstallPromptEvent | null} */ event) {
  12. if (typeof window === 'undefined') return;
  13. window[PROMPT_KEY] = event;
  14. ready = event !== null;
  15. }
  16. function isStandaloneDisplay() {
  17. if (typeof window === 'undefined') return false;
  18. return (
  19. window.matchMedia('(display-mode: standalone)').matches ||
  20. window.matchMedia('(display-mode: window-controls-overlay)').matches ||
  21. /** @type {Navigator & { standalone?: boolean }} */ (window.navigator).standalone === true
  22. );
  23. }
  24. function isIosDevice() {
  25. if (typeof navigator === 'undefined') return false;
  26. const ua = navigator.userAgent;
  27. return /iPad|iPhone|iPod/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
  28. }
  29. async function isBrave() {
  30. if (typeof navigator === 'undefined') return false;
  31. const brave = /** @type {Navigator & { brave?: { isBrave?: () => Promise<boolean> } }} */ (navigator).brave;
  32. if (brave?.isBrave) {
  33. try {
  34. return await brave.isBrave();
  35. } catch {
  36. /* fall through */
  37. }
  38. }
  39. return /Brave/i.test(navigator.userAgent);
  40. }
  41. /**
  42. * Detect an existing install of this origin's PWA (desktop/Android Chromium).
  43. */
  44. async function detectRelatedInstall() {
  45. if (typeof navigator === 'undefined' || !('getInstalledRelatedApps' in navigator)) {
  46. return false;
  47. }
  48. try {
  49. const apps = await /** @type {{ getInstalledRelatedApps: () => Promise<unknown[]> }} */ (
  50. navigator
  51. ).getInstalledRelatedApps();
  52. return Array.isArray(apps) && apps.length > 0;
  53. } catch {
  54. return false;
  55. }
  56. }
  57. async function refreshInstallFlags() {
  58. installed = isStandaloneDisplay();
  59. alreadyOnDevice = installed || (await detectRelatedInstall());
  60. if (alreadyOnDevice) {
  61. setDeferredPrompt(null);
  62. } else if (getDeferredPrompt()) {
  63. ready = true;
  64. }
  65. }
  66. /**
  67. * Start listening for install eligibility. Safe to call multiple times.
  68. * Prompt event is stored on `window` so Vite HMR does not drop it.
  69. */
  70. export function initPwaInstall() {
  71. if (typeof window === 'undefined') return;
  72. // Rehydrate after HMR / remount.
  73. if (getDeferredPrompt()) ready = true;
  74. void refreshInstallFlags();
  75. if (window[LISTENER_KEY]) return;
  76. window[LISTENER_KEY] = true;
  77. window.addEventListener('beforeinstallprompt', (event) => {
  78. event.preventDefault();
  79. setDeferredPrompt(/** @type {BeforeInstallPromptEvent} */ (event));
  80. installed = false;
  81. alreadyOnDevice = false;
  82. });
  83. window.addEventListener('appinstalled', () => {
  84. setDeferredPrompt(null);
  85. installed = true;
  86. alreadyOnDevice = true;
  87. });
  88. const media = window.matchMedia('(display-mode: standalone)');
  89. media.addEventListener?.('change', () => {
  90. void refreshInstallFlags();
  91. });
  92. }
  93. export function isInstallVisible() {
  94. // Hide only while running inside the installed app window.
  95. return !installed;
  96. }
  97. export function isInstallReady() {
  98. return getDeferredPrompt() !== null && !installed;
  99. }
  100. export function isAlreadyInstalledOnDevice() {
  101. return alreadyOnDevice && !installed;
  102. }
  103. /**
  104. * Triggers the native browser install prompt. Must be called from a user gesture.
  105. */
  106. export async function promptInstall() {
  107. await refreshInstallFlags();
  108. if (alreadyOnDevice && !getDeferredPrompt()) {
  109. return { ok: false, reason: /** @type {const} */ ('already-installed') };
  110. }
  111. const event = getDeferredPrompt();
  112. if (!event) {
  113. const brave = await isBrave();
  114. const ios = isIosDevice();
  115. return {
  116. ok: false,
  117. reason: /** @type {const} */ ('unavailable'),
  118. brave,
  119. ios
  120. };
  121. }
  122. try {
  123. await event.prompt();
  124. const choice = await event.userChoice;
  125. setDeferredPrompt(null);
  126. if (choice.outcome === 'accepted') {
  127. installed = true;
  128. alreadyOnDevice = true;
  129. return { ok: true, reason: /** @type {const} */ ('accepted') };
  130. }
  131. return { ok: false, reason: /** @type {const} */ ('dismissed') };
  132. } catch {
  133. const brave = await isBrave();
  134. const ios = isIosDevice();
  135. return {
  136. ok: false,
  137. reason: /** @type {const} */ ('unavailable'),
  138. brave,
  139. ios
  140. };
  141. }
  142. }
  143. if (typeof window !== 'undefined') {
  144. initPwaInstall();
  145. }