Jelajahi Sumber

Camera: the iOS home-screen web app opens cameras as the overlay (issue #3419)

- In a web app started from the iOS home screen, window.open loads the
  camera page into the app itself with no way back; cameras there
  always open as the overlay, with no window mode or pop-out
- A Camera Wall tile opens the camera page in place there
- The camera page has a Back button when no window opened it, and its
  pop-in goes back instead of trying to close itself
- Desktop and Android installed apps keep both modes
maziggy 1 hari lalu
induk
melakukan
99e9849c5d
52 mengubah file dengan 338 tambahan dan 33 penghapusan
  1. 1 0
      CHANGELOG.md
  2. 12 0
      frontend/src/__tests__/components/EmbeddedCameraViewer.test.tsx
  3. 79 0
      frontend/src/__tests__/pages/CamWallPageWebApp.test.tsx
  4. 96 2
      frontend/src/__tests__/pages/CameraPage.test.tsx
  5. 20 0
      frontend/src/__tests__/pages/PrintersPageCameraSplitButton.test.tsx
  6. 30 1
      frontend/src/__tests__/utils/cameraPopIn.test.ts
  7. 15 12
      frontend/src/components/EmbeddedCameraViewer.tsx
  8. 10 2
      frontend/src/pages/CamWallPage.tsx
  9. 37 4
      frontend/src/pages/CameraPage.tsx
  10. 12 4
      frontend/src/pages/PrintersPage.tsx
  11. 19 0
      frontend/src/utils/camera.ts
  12. 0 0
      static/assets/ArchivesPage-D1xiNEhO.js
  13. 0 0
      static/assets/CamWallPage-C_YAUmJY.js
  14. 0 0
      static/assets/CamWallPage-tZmJqBIY.js
  15. 0 0
      static/assets/CameraPage-CFBoDKP_.js
  16. 0 0
      static/assets/CameraPage-zEszBP25.js
  17. 0 0
      static/assets/EmbeddedCameraViewer-1c0A6JuB.js
  18. 0 0
      static/assets/EmbeddedCameraViewer-2ensNM-R.js
  19. 0 0
      static/assets/FileManagerPage-DZrsSvhK.js
  20. 0 0
      static/assets/InventoryPage-Z7p3y0zy.js
  21. 0 0
      static/assets/Layout-oQe20WaI.js
  22. 0 0
      static/assets/ModelSourcesPage-DrnRUPhd.js
  23. 0 0
      static/assets/PhotoGalleryModal-38Peurvv.js
  24. 0 0
      static/assets/PrintersPage-CYw9YzHS.js
  25. 0 0
      static/assets/PrintersPage-O8zbUGgp.js
  26. 0 0
      static/assets/SettingsPage-fjRcUCU8.js
  27. 0 0
      static/assets/SliceModal-_8XHEra7.js
  28. 0 0
      static/assets/SpoolBuddyInventoryPage-MeUrU6t1.js
  29. 1 1
      static/assets/SpoolBuddySettingsPage-CjMhLm-E.js
  30. 0 0
      static/assets/camera-DVxLwUaj.js
  31. 0 0
      static/assets/camera-MsEzBYBi.js
  32. 0 0
      static/assets/de-BllG5ljr.js
  33. 0 0
      static/assets/es-DvBAUOXm.js
  34. 0 0
      static/assets/fr-CVn808aW.js
  35. 0 1
      static/assets/index-BhH7Qp2X.css
  36. 0 0
      static/assets/index-DY0oeI_t.js
  37. 1 0
      static/assets/index-ro0pjBoj.css
  38. 0 0
      static/assets/it-4zkxuXXN.js
  39. 0 0
      static/assets/ja-C-0-DJSC.js
  40. 0 0
      static/assets/ko-CLCSNKjv.js
  41. 0 0
      static/assets/locationSensorDefaults-CH-VGsLI.js
  42. 0 0
      static/assets/nl-BfZ8Icw0.js
  43. 0 1
      static/assets/plateCheck-B34PCnEi.js
  44. 0 0
      static/assets/pt-BR-cMA9aquM.js
  45. 0 0
      static/assets/ru-DdhuspWK.js
  46. 0 0
      static/assets/spoolCost-BTXCcaR1.js
  47. 0 0
      static/assets/sv-BsR1aJRS.js
  48. 0 0
      static/assets/tr-BGIGb3hH.js
  49. 0 0
      static/assets/uk-D-6PwWoX.js
  50. 0 0
      static/assets/zh-CN-ImGiP7bZ.js
  51. 0 0
      static/assets/zh-TW-BJlFQQLK.js
  52. 5 5
      static/index.html

+ 1 - 0
CHANGELOG.md

@@ -205,6 +205,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **A camera opened in the iOS home-screen web app can be left again (#3419)** — In Bambuddy added to the home screen ("Add to Home Screen" in Safari), opening a camera in window mode loaded the camera page into the app itself, full screen, with no way back short of closing the app. A web app has no second window to open, so its cameras now always open as the floating overlay: the camera button offers no window mode, and the overlay has no pop-out button. A Camera Wall tile opens the camera page in place. The camera page has a **Back** button whenever it was not opened as a window (web app, bookmark, link), which returns to the previous Bambuddy page or the start page, and its pop-in does the same there instead of trying to close itself. Bambuddy installed as an app on a desktop or Android is unchanged: a camera window opens normally there. Wiki updated.
 - **The connection diagnostic no longer calls a macvlan container bridge networking (#3417, reported by @ddavidebor)** — A container on a Docker macvlan network has its own address on the LAN, so printer discovery and the Virtual Printer work, yet the diagnostic warned that it was on bridge networking and should be recreated with host networking. The interface is moved into the container from the host, like a bridge network's, and the check could not tell the two apart. It now reads the interface's link type: macvlan, and ipvlan in L2 mode, pass, and the subnet check runs as on host networking. ipvlan in L3 mode gets its own warning: it passes no broadcast or multicast, so the slicer can't find the Virtual Printer and printers can only be found with the subnet scan. Where the link type can't be read, the check now says the network mode could not be determined rather than guessing bridge. The support package reads the network mode the same way. Wiki updated.
 - **Recalculate Costs uses the spools' current prices (#3414, reported by @BurgerKerman)** — Changing a spool's price and pressing **Recalculate Costs** changed nothing ("0 archives were updated"), whether or not the spool had its own price. Each usage record keeps what its grams cost when the print completed, and the recalculation only added those up again, so it read the old price back. The usage records of spools still in the inventory now get the spool's current price first, or the current default filament cost for a spool without one, and the archive cost is rebuilt from them. Each print in the Print Log moves by the change of its own records, or takes the archive's new cost when it is the archive's only priced print, so the statistics, project totals and order sums follow the new price as well. A reprint off a spool whose price did not change keeps its cost. Records of a deleted spool keep what they cost. In Spoolman mode, charges recorded since #3407 are repriced from the spool's current price in Spoolman. If Spoolman can't be reached they keep their price, and the Statistics page says how many. Wiki updated.
 - **Spoolman mode gets the same usage history and stock forecast as built-in inventory (#3407, reported by @ojimpo)** — Bambuddy charges Spoolman for every print but kept no record of it, and Spoolman itself keeps only per-spool totals. So Spoolman mode had no per-print usage at all: the stock forecast could never use its **Trend** rate, and the spool dialog showed no usage history. Each charge is now recorded like a built-in spool's: grams, print, status, and the cost when the spool has a price. So the forecast reaches **Trend** once two days of prints are recorded, and the spool dialog lists its prints. The rows are part of the GitHub backup. The fallback rate used before that was also wrong in both modes. It divided a spool's grams by the days since it was added, so shelf time counted as use. A spool used up inside that span but last used more than 90 days ago dropped out with all its grams while its days stayed in. On the reporter's SKU it showed 9.2 g/d. Each spool's grams are now spread over the time it was actually in use, and what falls inside the last 90 days is divided by those days (12.2 g/d there). The stock alerts use the same rule. Wiki updated.

+ 12 - 0
frontend/src/__tests__/components/EmbeddedCameraViewer.test.tsx

@@ -188,6 +188,18 @@ describe('EmbeddedCameraViewer pop-out (#2737)', () => {
     expect(onClose).toHaveBeenCalledOnce();
   });
 
+  it('offers no window to pop out to in a home-screen web app (#3419)', async () => {
+    Object.defineProperty(navigator, 'standalone', { value: true, configurable: true });
+    try {
+      render(<EmbeddedCameraViewer printerId={7} printerName="P1S" onClose={vi.fn()} />);
+      await settle();
+
+      expect(screen.queryByRole('button', { name: 'Open in its own window' })).toBeNull();
+    } finally {
+      delete (navigator as Navigator & { standalone?: boolean }).standalone;
+    }
+  });
+
   it('keeps the overlay when a popup blocker stopped the window', async () => {
     vi.spyOn(window, 'open').mockReturnValue(null);
     const onClose = vi.fn();

+ 79 - 0
frontend/src/__tests__/pages/CamWallPageWebApp.test.tsx

@@ -0,0 +1,79 @@
+/**
+ * A Camera Wall tile in a home-screen web app (#3419).
+ *
+ * A tile opens the camera with window.open, and iOS loads that URL into the
+ * web app itself, full screen. The camera then opens in place instead, where
+ * its back button returns to the wall.
+ */
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { fireEvent, render as rtlRender, screen } from '@testing-library/react';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { ThemeProvider } from '../../contexts/ThemeContext';
+import { ToastProvider } from '../../contexts/ToastContext';
+
+vi.mock('../../contexts/AuthContext', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('../../contexts/AuthContext')>();
+  return {
+    ...actual,
+    useAuth: () => ({
+      authEnabled: false,
+      loading: false,
+      user: null,
+      authUnconfirmed: false,
+      hasPermission: () => true,
+    }),
+  };
+});
+
+import { CamWallPage } from '../../pages/CamWallPage';
+import { api } from '../../api/client';
+
+function renderWall() {
+  const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
+  return rtlRender(
+    <QueryClientProvider client={queryClient}>
+      <MemoryRouter initialEntries={['/camwall']}>
+        <ThemeProvider>
+          <ToastProvider>
+            <Routes>
+              <Route path="/camwall" element={<CamWallPage />} />
+              <Route path="/camera/:printerId" element={<p>camera page</p>} />
+            </Routes>
+          </ToastProvider>
+        </ThemeProvider>
+      </MemoryRouter>
+    </QueryClientProvider>,
+  );
+}
+
+describe('CamWallPage — tile click', () => {
+  let open: ReturnType<typeof vi.spyOn>;
+
+  beforeEach(() => {
+    vi.spyOn(api, 'getPrinters').mockResolvedValue([{ id: 3, name: 'X1C-Lab' }] as never);
+    vi.spyOn(api, 'getPrinterStatus').mockResolvedValue({ connected: true, state: 'IDLE' } as never);
+    open = vi.spyOn(window, 'open').mockReturnValue(null);
+  });
+
+  afterEach(() => {
+    vi.restoreAllMocks();
+    delete (navigator as Navigator & { standalone?: boolean }).standalone;
+  });
+
+  it('opens the camera in its own window in a browser', async () => {
+    renderWall();
+    fireEvent.click(await screen.findByTitle('X1C-Lab'));
+
+    expect(open).toHaveBeenCalledWith('/camera/3', 'camera-3');
+  });
+
+  it('opens the camera in place in a home-screen web app', async () => {
+    Object.defineProperty(navigator, 'standalone', { value: true, configurable: true });
+    renderWall();
+    fireEvent.click(await screen.findByTitle('X1C-Lab'));
+
+    expect(await screen.findByText('camera page')).toBeInTheDocument();
+    expect(open).not.toHaveBeenCalled();
+  });
+});

+ 96 - 2
frontend/src/__tests__/pages/CameraPage.test.tsx

@@ -33,8 +33,21 @@ const mockPrinter = {
   enabled: true,
 };
 
+/** As a window.open popup: something opened it, so it can close itself. */
+function asPopup() {
+  Object.defineProperty(window, 'opener', { value: {}, configurable: true, writable: true });
+}
+
+/** As a web app started from the iOS home screen. */
+function asWebApp(): () => void {
+  Object.defineProperty(navigator, 'standalone', { value: true, configurable: true });
+  return () => {
+    delete (navigator as Navigator & { standalone?: boolean }).standalone;
+  };
+}
+
 // Custom render for CameraPage which needs specific route params
-function renderCameraPage(printerId: number, search = '', strict = false) {
+function renderCameraPage(printerId: number, search = '', strict = false, entries?: string[]) {
   const queryClient = new QueryClient({
     defaultOptions: {
       queries: { retry: false, gcTime: 0 },
@@ -45,12 +58,14 @@ function renderCameraPage(printerId: number, search = '', strict = false) {
   const tree = (
     <QueryClientProvider client={queryClient}>
       <I18nextProvider i18n={i18n}>
-        <MemoryRouter initialEntries={[`/cameras/${printerId}${search}`]}>
+        <MemoryRouter initialEntries={entries ?? [`/cameras/${printerId}${search}`]}>
           <AuthProvider>
             <ThemeProvider>
               <ToastProvider>
                 <Routes>
                   <Route path="/cameras/:printerId" element={<CameraPage />} />
+                  <Route path="/" element={<p>Bambuddy home</p>} />
+                  <Route path="/printers" element={<p>printers page</p>} />
                 </Routes>
               </ToastProvider>
             </ThemeProvider>
@@ -88,6 +103,7 @@ describe('CameraPage', () => {
 
   afterEach(() => {
     document.title = originalTitle;
+    Object.defineProperty(window, 'opener', { value: null, configurable: true, writable: true });
   });
 
   describe('rendering', () => {
@@ -456,6 +472,7 @@ describe('CameraPage', () => {
     });
 
     it('moves into the open tab as an overlay and closes the window', async () => {
+      asPopup();
       const open = vi.fn();
       const stop = listenForCameraPopIn('t1', open);
       const close = vi.spyOn(window, 'close').mockImplementation(() => {});
@@ -515,6 +532,83 @@ describe('CameraPage', () => {
     });
   });
 
+  describe('a page no window opened (#3419)', () => {
+    afterEach(() => {
+      sessionStorage.clear();
+    });
+
+    it('offers a way back to Bambuddy', async () => {
+      renderCameraPage(1);
+      await screen.findByText('X1 Carbon');
+
+      fireEvent.click(screen.getByRole('button', { name: 'Back' }));
+
+      expect(await screen.findByText('Bambuddy home')).toBeInTheDocument();
+    });
+
+    it('goes back instead of closing itself once the camera moved to a tab', async () => {
+      const open = vi.fn();
+      const stop = listenForCameraPopIn('t1', open);
+      const close = vi.spyOn(window, 'close').mockImplementation(() => {});
+      try {
+        renderCameraPage(1);
+        await screen.findByText('X1 Carbon');
+
+        fireEvent.click(screen.getByRole('button', { name: 'Show as an overlay in Bambuddy' }));
+
+        expect(await screen.findByText('Bambuddy home')).toBeInTheDocument();
+        expect(open).toHaveBeenCalledExactlyOnceWith(1, 'X1 Carbon');
+        expect(close).not.toHaveBeenCalled();
+      } finally {
+        stop();
+      }
+    });
+
+    it('goes back to the Bambuddy page it came from', async () => {
+      renderCameraPage(1, '', false, ['/printers', '/cameras/1']);
+      await screen.findByText('X1 Carbon');
+
+      fireEvent.click(screen.getByRole('button', { name: 'Back' }));
+
+      expect(await screen.findByText('printers page')).toBeInTheDocument();
+    });
+
+    it('a popup whose tab has gone still closes rather than navigating', async () => {
+      window.name = 'camera-1';
+      try {
+        renderCameraPage(1);
+        await screen.findByText('X1 Carbon');
+
+        expect(screen.queryByRole('button', { name: 'Back' })).toBeNull();
+      } finally {
+        window.name = '';
+      }
+    });
+
+    it('a popup has no back button: it closes', async () => {
+      asPopup();
+      renderCameraPage(1);
+      await screen.findByText('X1 Carbon');
+
+      expect(screen.queryByRole('button', { name: 'Back' })).toBeNull();
+    });
+
+    it('in a home-screen web app, has a way back and no tab to pop into', async () => {
+      // iOS may report an opener for the page it loaded into the app itself.
+      asPopup();
+      const restore = asWebApp();
+      try {
+        renderCameraPage(1);
+        await screen.findByText('X1 Carbon');
+
+        expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument();
+        expect(screen.queryByRole('button', { name: 'Show as an overlay in Bambuddy' })).toBeNull();
+      } finally {
+        restore();
+      }
+    });
+  });
+
   describe('invalid printer', () => {
     it('shows invalid printer message for ID 0', async () => {
       renderCameraPage(0);

+ 20 - 0
frontend/src/__tests__/pages/PrintersPageCameraSplitButton.test.tsx

@@ -146,6 +146,26 @@ describe('PrintersPage — camera split button', () => {
     );
   });
 
+  it('opens the overlay and offers no window in a home-screen web app (#3419)', async () => {
+    // iOS loads a window.open URL into the app itself, with no way back.
+    storage.set('cameraViewMode', 'window');
+    Object.defineProperty(navigator, 'standalone', { value: true, configurable: true });
+    try {
+      renderPage('window');
+      await waitFor(() => expect(document.getElementById('printer-card-1')).not.toBeNull());
+      const icon = await screen.findByTitle('Open camera overlay');
+      await waitFor(() => expect(icon).not.toBeDisabled());
+      expect(screen.queryByLabelText('Camera View Mode')).toBeNull();
+
+      fireEvent.click(icon);
+
+      expect(await screen.findByTitle('Refresh stream')).toBeInTheDocument();
+      expect(openSpy).not.toHaveBeenCalled();
+    } finally {
+      delete (navigator as Navigator & { standalone?: boolean }).standalone;
+    }
+  });
+
   it('offers both modes from the caret', async () => {
     renderPage('window');
     await openModeMenu();

+ 30 - 1
frontend/src/__tests__/utils/cameraPopIn.test.ts

@@ -4,7 +4,13 @@
  * is told so it can stay open.
  */
 import { afterEach, describe, expect, it, vi } from 'vitest';
-import { listenForCameraPopIn, readOpenerTabId, registerCameraTab, requestCameraPopIn } from '../../utils/camera';
+import {
+  isStandaloneWebApp,
+  listenForCameraPopIn,
+  readOpenerTabId,
+  registerCameraTab,
+  requestCameraPopIn,
+} from '../../utils/camera';
 
 const cleanups: Array<() => void> = [];
 
@@ -140,3 +146,26 @@ describe('the opener tab id', () => {
     expect(registerCameraTab()).not.toBe(first);
   });
 });
+
+describe('isStandaloneWebApp (#3419)', () => {
+  afterEach(() => {
+    delete (navigator as Navigator & { standalone?: boolean }).standalone;
+    vi.restoreAllMocks();
+  });
+
+  it('is false in a browser tab', () => {
+    expect(isStandaloneWebApp()).toBe(false);
+  });
+
+  it('reads iOS Safari home-screen apps by navigator.standalone', () => {
+    Object.defineProperty(navigator, 'standalone', { value: true, configurable: true });
+    expect(isStandaloneWebApp()).toBe(true);
+  });
+
+  it('leaves an app installed on a desktop or Android alone: window.open works there', () => {
+    vi.spyOn(window, 'matchMedia').mockImplementation(
+      (query: string) => ({ matches: query === '(display-mode: standalone)' }) as MediaQueryList,
+    );
+    expect(isStandaloneWebApp()).toBe(false);
+  });
+});

+ 15 - 12
frontend/src/components/EmbeddedCameraViewer.tsx

@@ -11,7 +11,7 @@ import { CameraDiagnoseModal } from './CameraDiagnoseModal';
 import { useStreamImgRef } from '../hooks/useStreamImgRef';
 import { useCameraViewerId } from '../hooks/useCameraViewerId';
 import { isSideways, useSidewaysBox } from '../hooks/useSidewaysBox';
-import { openCameraWindow } from '../utils/camera';
+import { isStandaloneWebApp, openCameraWindow } from '../utils/camera';
 import { useIsSidebarCompact } from '../hooks/useIsSidebarCompact';
 
 export interface EmbeddedCameraViewerProps {
@@ -658,17 +658,20 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
               <Fullscreen className="w-3.5 h-3.5 text-bambu-gray" />
             )}
           </button>
-          <button
-            onClick={() => {
-              // The overlay stays when a popup blocker stopped the window.
-              if (openCameraWindow(printerId)) onClose();
-            }}
-            className="p-1 hover:bg-bambu-dark-tertiary rounded"
-            title={t('camera.popOut')}
-            aria-label={t('camera.popOut')}
-          >
-            <ExternalLink className="w-3.5 h-3.5 text-bambu-gray" />
-          </button>
+          {/* No window to pop out to in a home-screen web app (#3419). */}
+          {!isStandaloneWebApp() && (
+            <button
+              onClick={() => {
+                // The overlay stays when a popup blocker stopped the window.
+                if (openCameraWindow(printerId)) onClose();
+              }}
+              className="p-1 hover:bg-bambu-dark-tertiary rounded"
+              title={t('camera.popOut')}
+              aria-label={t('camera.popOut')}
+            >
+              <ExternalLink className="w-3.5 h-3.5 text-bambu-gray" />
+            </button>
+          )}
           <button
             onClick={() => setIsMinimized(!isMinimized)}
             className="p-1 hover:bg-bambu-dark-tertiary rounded"

+ 10 - 2
frontend/src/pages/CamWallPage.tsx

@@ -20,7 +20,7 @@
  * mint the WS ticket anyway.
  */
 import { useEffect, useMemo, useState } from 'react';
-import { Navigate, useLocation, useSearchParams } from 'react-router-dom';
+import { Navigate, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
 import { useQuery } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { CameraWall, type CameraWallStatus } from '../components/CameraWall';
@@ -29,6 +29,7 @@ import { type CameraTileStatusMode } from '../components/CameraTile';
 import { api, setStreamToken } from '../api/client';
 import { useAuth } from '../contexts/AuthContext';
 import { AuthRetrying } from '../components/AuthRetrying';
+import { isStandaloneWebApp } from '../utils/camera';
 
 // Kiosk polling cadence. Matches the staleTime the in-page wall runs at, so a
 // tile's chip is never more stale than it would be on the Printers page.
@@ -72,6 +73,7 @@ function tileSizeFrom(params: URLSearchParams): number {
 export function CamWallPage() {
   const { t } = useTranslation();
   const location = useLocation();
+  const navigate = useNavigate();
   const { authEnabled, loading: authLoading, user, authUnconfirmed } = useAuth();
   const [searchParams] = useSearchParams();
 
@@ -189,7 +191,13 @@ export function CamWallPage() {
         tileSize={tileSize}
         statuses={kiosk ? kioskStatuses : undefined}
         showSettings={!kiosk}
-        onTileClick={kiosk ? undefined : (id) => window.open(`/camera/${id}`, `camera-${id}`)}
+        // A home-screen web app has no second window: the camera opens in
+        // place and its back button returns here (#3419).
+        onTileClick={
+          kiosk
+            ? undefined
+            : (id) => (isStandaloneWebApp() ? navigate(`/camera/${id}`) : window.open(`/camera/${id}`, `camera-${id}`))
+        }
         // Writes to the same localStorage keys the Printers page reads, so a
         // change made here follows the user back there. A kiosk wall hides the
         // popover, so these never fire.

+ 37 - 4
frontend/src/pages/CameraPage.tsx

@@ -1,8 +1,8 @@
 import { useState, useEffect, useRef, useCallback } from 'react';
-import { useParams, useSearchParams } from 'react-router-dom';
+import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
-import { RefreshCw, AlertTriangle, Camera, Maximize, Minimize, WifiOff, ZoomIn, ZoomOut, Stethoscope, PictureInPicture2 } from 'lucide-react';
+import { RefreshCw, AlertTriangle, ArrowLeft, Camera, Maximize, Minimize, WifiOff, ZoomIn, ZoomOut, Stethoscope, PictureInPicture2 } from 'lucide-react';
 import { api, getAuthToken, getStreamToken, withStreamToken } from '../api/client';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
@@ -13,7 +13,7 @@ import { CameraDiagnoseModal } from '../components/CameraDiagnoseModal';
 import { useStreamImgRef } from '../hooks/useStreamImgRef';
 import { useCameraViewerId } from '../hooks/useCameraViewerId';
 import { isSideways, useSidewaysBox } from '../hooks/useSidewaysBox';
-import { readOpenerTabId, requestCameraPopIn } from '../utils/camera';
+import { isStandaloneWebApp, readOpenerTabId, requestCameraPopIn } from '../utils/camera';
 
 const MAX_RECONNECT_ATTEMPTS = 5;
 // The server's Retry-After for a snapshot while a live view starts.
@@ -688,6 +688,25 @@ function CameraView({ onPoppedIn }: { onPoppedIn: () => void }) {
   // Read at load: the tab that opened this window is the one its
   // sessionStorage was copied from.
   const [openerTabId] = useState(readOpenerTabId);
+  // A window something opened can close itself. A page reached any other way
+  // -- a home-screen web app, which loads a popup's URL into the app itself
+  // (#3419), a bookmark, a link -- can't, so it gets a way back instead.
+  // A popup is known by its opener, or by the name openCameraWindow gives it
+  // when that tab has gone since. A web app counts as one whatever it reports.
+  const [openedAsWindow] = useState(() => {
+    try {
+      return (!!window.opener || window.name.startsWith('camera-')) && !isStandaloneWebApp();
+    } catch {
+      return false;
+    }
+  });
+  const navigate = useNavigate();
+  const location = useLocation();
+  // Back within Bambuddy only: the tab's history can start on another site.
+  const goBack = () => {
+    if (location.key !== 'default') navigate(-1);
+    else navigate('/', { replace: true });
+  };
   const [popInPending, setPopInPending] = useState(false);
   const popIn = async () => {
     setPopInPending(true);
@@ -698,7 +717,8 @@ function CameraView({ onPoppedIn }: { onPoppedIn: () => void }) {
       return;
     }
     onPoppedIn();
-    window.close();
+    if (openedAsWindow) window.close();
+    else goBack();
   };
 
   if (!id) {
@@ -715,6 +735,16 @@ function CameraView({ onPoppedIn }: { onPoppedIn: () => void }) {
       {/* Header */}
       <div className="flex items-center justify-between px-4 py-2 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary">
         <h1 className="text-sm font-medium text-white flex items-center gap-2">
+          {!openedAsWindow && (
+            <button
+              onClick={goBack}
+              className="p-1 -ml-1 hover:bg-bambu-dark-tertiary rounded"
+              title={t('common.back')}
+              aria-label={t('common.back')}
+            >
+              <ArrowLeft className="w-4 h-4 text-bambu-gray" />
+            </button>
+          )}
           <Camera className="w-4 h-4" />
           {printer?.name || `Printer ${id}`}
         </h1>
@@ -781,6 +811,8 @@ function CameraView({ onPoppedIn }: { onPoppedIn: () => void }) {
           >
             <Stethoscope className="w-4 h-4 text-bambu-gray" />
           </button>
+          {/* No other Bambuddy tab can take it in a home-screen web app. */}
+          {!isStandaloneWebApp() && (
           <button
             onClick={popIn}
             disabled={popInPending}
@@ -790,6 +822,7 @@ function CameraView({ onPoppedIn }: { onPoppedIn: () => void }) {
           >
             <PictureInPicture2 className="w-4 h-4 text-bambu-gray" />
           </button>
+          )}
           <button
             onClick={toggleFullscreen}
             className="p-1.5 hover:bg-bambu-dark-tertiary rounded"

+ 12 - 4
frontend/src/pages/PrintersPage.tsx

@@ -6,6 +6,7 @@ import { formatPrintName } from '../utils/printName';
 import { isBedSlinger } from '../utils/bedSlinger';
 import { computePopoverPosition, type PopoverPosition } from '../utils/popoverPosition';
 import {
+  isStandaloneWebApp,
   openCameraWindow,
   readStoredCameraViewMode,
   storeCameraViewMode,
@@ -3589,6 +3590,7 @@ function PrinterCard({
   // Opening a camera in a given mode, without touching which mode is remembered
   // -- the split button's two halves want the same action but disagree about
   // whether the choice was deliberate enough to keep.
+  const webApp = isStandaloneWebApp();
   const openCameraIn = (mode: CameraViewMode) => {
     if (mode === 'embedded' && onOpenEmbeddedCamera) {
       onOpenEmbeddedCamera(printer.id, printer.name);
@@ -6659,10 +6661,12 @@ function PrinterCard({
                     onClick={() => openCameraIn(cameraViewMode)}
                     disabled={!status?.connected || !hasPermission('camera:view')}
                     title={!hasPermission('camera:view') ? t('printers.permission.noCamera') : (cameraViewMode === 'embedded' ? t('printers.openCameraOverlay') : t('printers.openCameraWindow'))}
-                    className={`${footerIconButtonClass} !rounded-r-none`}
+                    className={webApp ? footerIconButtonClass : `${footerIconButtonClass} !rounded-r-none`}
                   >
                     <Video className="w-[var(--pc-i4,1rem)] h-[var(--pc-i4,1rem)]" />
                   </Button>
+                  {/* A home-screen web app has only the overlay (#3419). */}
+                  {!webApp && (
                   <Button
                     variant="secondary"
                     size="sm"
@@ -6682,8 +6686,9 @@ function PrinterCard({
                   >
                     <ChevronDown className="w-3 h-3" />
                   </Button>
+                  )}
                 </div>
-                {cameraMenuAnchor && (
+                {cameraMenuAnchor && !webApp && (
                   <ContextMenu
                     x={cameraMenuAnchor.x}
                     y={cameraMenuAnchor.y}
@@ -8987,8 +8992,11 @@ export function PrintersPage() {
   // The local choice wins over the stored one: someone without settings:update
   // cannot write their pick back, and a preference that silently reverted on
   // the next render would be worse than none at all.
-  const cameraViewMode: CameraViewMode =
-    chosenCameraViewMode ?? (settings?.camera_view_mode === 'embedded' ? 'embedded' : 'window');
+  // A home-screen web app has no second window to open: it would take over
+  // the app with no way back (#3419), so its cameras always open as overlays.
+  const cameraViewMode: CameraViewMode = isStandaloneWebApp()
+    ? 'embedded'
+    : chosenCameraViewMode ?? (settings?.camera_view_mode === 'embedded' ? 'embedded' : 'window');
 
   const selectCameraViewMode = useCallback((mode: CameraViewMode) => {
     storeCameraViewMode(mode);

+ 19 - 0
frontend/src/utils/camera.ts

@@ -44,6 +44,25 @@ export function storeCameraViewMode(mode: CameraViewMode): void {
   }
 }
 
+/**
+ * True in a web app started from the iOS home screen ("Add to Home Screen").
+ *
+ * There is no second window to open there: iOS loads a `window.open` URL into
+ * the app itself, full screen, and the camera page it showed had no way back
+ * to the rest of Bambuddy (#3419). Cameras open as the overlay instead.
+ *
+ * iOS only, deliberately not `display-mode: standalone`: Bambuddy installed as
+ * an app on a desktop opens a real popup window, and Android a closable
+ * in-app browser, so their window mode works and stays.
+ */
+export function isStandaloneWebApp(): boolean {
+  try {
+    return (navigator as Navigator & { standalone?: boolean }).standalone === true;
+  } catch {
+    return false;
+  }
+}
+
 /**
  * Open a printer's camera in its own browser window, reusing whatever size and
  * position the user last left one at.

File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/ArchivesPage-D1xiNEhO.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/CamWallPage-C_YAUmJY.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/CamWallPage-tZmJqBIY.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/CameraPage-CFBoDKP_.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/CameraPage-zEszBP25.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/EmbeddedCameraViewer-1c0A6JuB.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/EmbeddedCameraViewer-2ensNM-R.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/FileManagerPage-DZrsSvhK.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/InventoryPage-Z7p3y0zy.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/Layout-oQe20WaI.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/ModelSourcesPage-DrnRUPhd.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/PhotoGalleryModal-38Peurvv.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/PrintersPage-CYw9YzHS.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/PrintersPage-O8zbUGgp.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/SettingsPage-fjRcUCU8.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/SliceModal-_8XHEra7.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-MeUrU6t1.js


File diff ditekan karena terlalu besar
+ 1 - 1
static/assets/SpoolBuddySettingsPage-CjMhLm-E.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/camera-DVxLwUaj.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/camera-MsEzBYBi.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/de-BllG5ljr.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/es-DvBAUOXm.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/fr-CVn808aW.js


File diff ditekan karena terlalu besar
+ 0 - 1
static/assets/index-BhH7Qp2X.css


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/index-DY0oeI_t.js


File diff ditekan karena terlalu besar
+ 1 - 0
static/assets/index-ro0pjBoj.css


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/it-4zkxuXXN.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/ja-C-0-DJSC.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/ko-CLCSNKjv.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/locationSensorDefaults-CH-VGsLI.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/nl-BfZ8Icw0.js


File diff ditekan karena terlalu besar
+ 0 - 1
static/assets/plateCheck-B34PCnEi.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/pt-BR-cMA9aquM.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/ru-DdhuspWK.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/spoolCost-BTXCcaR1.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/sv-BsR1aJRS.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/tr-BGIGb3hH.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/uk-D-6PwWoX.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/zh-CN-ImGiP7bZ.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/zh-TW-BJlFQQLK.js


+ 5 - 5
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BDpZEvTD.js"></script>
+    <script type="module" crossorigin src="/assets/index-DY0oeI_t.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-8XqSBxc-.js">
     <link rel="modulepreload" crossorigin href="/assets/react-dom-DqnGEPFR.js">
@@ -35,8 +35,8 @@
     <link rel="modulepreload" crossorigin href="/assets/useQuery-DPwkC1fo.js">
     <link rel="modulepreload" crossorigin href="/assets/info-CiH96FtY.js">
     <link rel="modulepreload" crossorigin href="/assets/AuthContext-CmTN4q4U.js">
-    <link rel="modulepreload" crossorigin href="/assets/camera-DVxLwUaj.js">
-    <link rel="modulepreload" crossorigin href="/assets/plateCheck-Chbvr7T1.js">
+    <link rel="modulepreload" crossorigin href="/assets/camera-MsEzBYBi.js">
+    <link rel="modulepreload" crossorigin href="/assets/plateCheck-B34PCnEi.js">
     <link rel="modulepreload" crossorigin href="/assets/chunk-62JRHF6Z-iS9S9umr.js">
     <link rel="modulepreload" crossorigin href="/assets/useMutation-u7tZvPp7.js">
     <link rel="modulepreload" crossorigin href="/assets/useQueries-S5FLKUGR.js">
@@ -122,8 +122,8 @@
     <link rel="modulepreload" crossorigin href="/assets/useColorCatalogVersion-kwE5kmQs.js">
     <link rel="modulepreload" crossorigin href="/assets/circle-minus-BPSMaNFL.js">
     <link rel="modulepreload" crossorigin href="/assets/ConnectionDiagnostic-CirSjZ36.js">
-    <link rel="modulepreload" crossorigin href="/assets/Layout-DZWcUUyM.js">
-    <link rel="stylesheet" crossorigin href="/assets/index-BhH7Qp2X.css">
+    <link rel="modulepreload" crossorigin href="/assets/Layout-oQe20WaI.js">
+    <link rel="stylesheet" crossorigin href="/assets/index-ro0pjBoj.css">
   </head>
   <body>
     <div id="root"></div>

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini