Просмотр исходного кода

fix(slicer): offer the desktop handoff only for formats the target slicer accepts (issue #3029)

The Slice action and Open in Slicer offered .stl, .step and .stp
alongside .3mf, on the assumption that a slicer which opens an STL from
its own File menu will open one from a link. Bambu Studio does not.
Every bambustudio:// and bambustudioopen:// URL reaches one import path
that refuses any filename which is not .3mf, and refuses it before
fetching anything: "Download failed, unknown file format." The message
names the format, so the failure reads as a broken model rather than an
unsupported handoff.

OrcaSlicer has no such limit. Only MakerWorld links take its equivalent
path; a link to the user's own Bambuddy goes to its general downloader,
which does not inspect the extension.

So the format list becomes per slicer. isSliceableFilename and
isSliceableFileType take the target as a required argument -- a default
would quietly reintroduce the handoff that cannot work -- and an
unrecognised value falls back to the Bambu Studio list, which is where
openInSlicer sends anything that is not exactly 'orcaslicer'.

The File Manager offers Slice only when the configured desktop slicer
will take the file. The 3D preview reaches both slicers from one split
button, so instead of hiding it promotes whichever can take the file to
the primary action, naming that slicer when it is not the configured
one; the split collapses to a plain button when no alternative is left.

The sidecar path is untouched: with Use Slicer API on, Slice still takes
STL and 3MF whatever the desktop target is.
maziggy 11 часов назад
Родитель
Сommit
e2493132bf

+ 1 - 0
CHANGELOG.md

@@ -29,6 +29,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Every FTP session Bambuddy opens now records how it closed (#3009, reported by @grengojbo)** — the report traced a print completion that opened two FTP connections to the printer, deleted one file and then, as far as the log showed, did nothing else until the printer was powered off 21 minutes later, and concluded the connections were being left open. They were not: the post-print SD-card cleanup opens one connection per candidate filename and closes each in a `finally`, which a run against a real FTPS server confirms at the server end for both the delete and the 550 not-here case. The trouble is that nothing in the log could have said so. Neither the clean close nor the hard socket drop logged anything at any level, so a session closed properly and a socket genuinely abandoned produced the same output — none — and the only way to tell them apart was to read the source. Both now log one DEBUG line naming the printer, whether QUIT was acknowledged or the socket had to be dropped without it, why, and how long the session was held. Every connect in a debug log is now paired with a close, so the next person suspecting a leaked FTP connection can settle it from a support bundle rather than by inference. Nothing about the connection handling itself changed, and at default log level nothing new is printed. This does not explain the SD-card read/write error in that report or in #645; it only removes one theory from the list by making it checkable.
 - **Every FTP session Bambuddy opens now records how it closed (#3009, reported by @grengojbo)** — the report traced a print completion that opened two FTP connections to the printer, deleted one file and then, as far as the log showed, did nothing else until the printer was powered off 21 minutes later, and concluded the connections were being left open. They were not: the post-print SD-card cleanup opens one connection per candidate filename and closes each in a `finally`, which a run against a real FTPS server confirms at the server end for both the delete and the 550 not-here case. The trouble is that nothing in the log could have said so. Neither the clean close nor the hard socket drop logged anything at any level, so a session closed properly and a socket genuinely abandoned produced the same output — none — and the only way to tell them apart was to read the source. Both now log one DEBUG line naming the printer, whether QUIT was acknowledged or the socket had to be dropped without it, why, and how long the session was held. Every connect in a debug log is now paired with a close, so the next person suspecting a leaked FTP connection can settle it from a support bundle rather than by inference. Nothing about the connection handling itself changed, and at default log level nothing new is printed. This does not explain the SD-card read/write error in that report or in #645; it only removes one theory from the list by making it checkable.
 
 
 ### Fixed
 ### Fixed
+- **The Slice action offered Bambu Studio files its URI handler cannot load (#3029)** — "Open in Slicer" was offered for `.stl`, `.step` and `.stp` as well as `.3mf`, on the reasonable assumption that a slicer which opens an STL from its own File menu will open one from a link. Bambu Studio does not. Every `bambustudio://` and `bambustudioopen://` URL goes through one import path that refuses any filename which is not `.3mf`, and it refuses it *before* fetching anything — the user gets "Download failed, unknown file format", which names the format and so reads as a broken model rather than an unsupported handoff. OrcaSlicer has no such limit: only MakerWorld links go down its equivalent path, and a link to your own Bambuddy goes to its general downloader, which does not look at the extension at all. So the action is now offered per slicer. With **Open in Slicer** set to Bambu Studio the File Manager offers Slice on 3MF only; set it to OrcaSlicer and STL, STEP and STP come back. In the 3D preview, where both slicers are reachable from one split button, a file the configured slicer cannot take is offered to the other one instead, with the button naming it rather than quietly sending the file somewhere the setting did not ask for. None of this touches the in-app slicer: with **Use Slicer API** on, Slice goes to the sidecar and still takes STL and 3MF whichever desktop slicer is configured.
 - **"Slice" handed the slicer a download link that only worked once (#3029)** — the Slice action in the File Manager, and Open in Slicer on the Archives page, mint a short-lived token and put it in the URL, because a protocol handler cannot carry an `Authorization` header. That token was spent by the first request to reach the endpoint, which made the handoff depend on the slicer fetching the URL exactly once — and nothing guarantees that. Bambu Studio's own downloader retries three times after a failed attempt, transfers get resumed, on-access scanners fetch. Whichever request arrived first won and every later one got a 403, so the slicer opened on an error about the file instead of on the file, and the message it showed described the 403 body it had been handed rather than anything wrong with the model — which made this very hard to read from the user's side. The three protocol-handler downloads — a library file, an archive's sliced 3MF, an archive's source 3MF — now accept their token for the rest of its five-minute life. Nothing else about it changed: it is still bound to the single file it was minted for, still expires in five minutes, and is still refused everywhere else. The two downloads that are not handoffs stay single-use, because what they hand over is itself consumed — a prepared printer-file bundle is deleted the moment it has been streamed. Fixed on the way through: **Open source 3MF in slicer never worked at all with authentication enabled**. The gateway that lets these token-in-URL downloads past the login check matches the path fragment `/dl/`, and the source 3MF's route says `source-dl`, which does not contain it — so the slicer's header-less request was turned away as unauthenticated before the route's own token check ever ran. It failed on every install with auth on, and only there.
 - **"Slice" handed the slicer a download link that only worked once (#3029)** — the Slice action in the File Manager, and Open in Slicer on the Archives page, mint a short-lived token and put it in the URL, because a protocol handler cannot carry an `Authorization` header. That token was spent by the first request to reach the endpoint, which made the handoff depend on the slicer fetching the URL exactly once — and nothing guarantees that. Bambu Studio's own downloader retries three times after a failed attempt, transfers get resumed, on-access scanners fetch. Whichever request arrived first won and every later one got a 403, so the slicer opened on an error about the file instead of on the file, and the message it showed described the 403 body it had been handed rather than anything wrong with the model — which made this very hard to read from the user's side. The three protocol-handler downloads — a library file, an archive's sliced 3MF, an archive's source 3MF — now accept their token for the rest of its five-minute life. Nothing else about it changed: it is still bound to the single file it was minted for, still expires in five minutes, and is still refused everywhere else. The two downloads that are not handoffs stay single-use, because what they hand over is itself consumed — a prepared printer-file bundle is deleted the moment it has been streamed. Fixed on the way through: **Open source 3MF in slicer never worked at all with authentication enabled**. The gateway that lets these token-in-URL downloads past the login check matches the path fragment `/dl/`, and the source 3MF's route says `source-dl`, which does not contain it — so the slicer's header-less request was turned away as unauthenticated before the route's own token check ever ran. It failed on every install with auth on, and only there.
 - **`camera:view` was required to see any image in the app (#3025, reported by @lonix)** — thirteen routes that have nothing to do with a camera took the camera stream token as their credential: library and archive thumbnails, plate previews and plate thumbnails, timelapses, print photos, archive QR codes, project cover images, print-log thumbnails, printer cover images and external-link icons. A browser cannot put an `Authorization` header on an `<img src>`, so those routes need a credential that fits in the URL, and the camera token was the only one there was. But the only way to mint one is `camera:view`, so a user granted library access to their own files got a grid of broken images until they were also granted the live camera — which on a home install points at the room the printer is in, and is not something you hand to a commission client so their own thumbnails will load. Those routes now take a new media token, minted by `POST /auth/media-token` behind plain authentication and carrying the identity of whoever asked, so each of them applies the permission its own resource is governed by: `library:read_own` for a library thumbnail, `projects:read` for a project cover, `external_links:read` for a sidebar icon. The camera stream token keeps the three routes it was named for. The frontend fetches a media token for every signed-in user and asks for a camera token only when the user can actually mint one, which also stops the 403 that used to fire on every page load for everyone else. Fixed on the way through: the printer file-browser's plate thumbnails, which had no query-token support at all and so 401'd in the file manager whenever auth was enabled; and the project cover image in the edit dialog, whose cache-busting `?v=` was appended after the token and corrupted it.
 - **`camera:view` was required to see any image in the app (#3025, reported by @lonix)** — thirteen routes that have nothing to do with a camera took the camera stream token as their credential: library and archive thumbnails, plate previews and plate thumbnails, timelapses, print photos, archive QR codes, project cover images, print-log thumbnails, printer cover images and external-link icons. A browser cannot put an `Authorization` header on an `<img src>`, so those routes need a credential that fits in the URL, and the camera token was the only one there was. But the only way to mint one is `camera:view`, so a user granted library access to their own files got a grid of broken images until they were also granted the live camera — which on a home install points at the room the printer is in, and is not something you hand to a commission client so their own thumbnails will load. Those routes now take a new media token, minted by `POST /auth/media-token` behind plain authentication and carrying the identity of whoever asked, so each of them applies the permission its own resource is governed by: `library:read_own` for a library thumbnail, `projects:read` for a project cover, `external_links:read` for a sidebar icon. The camera stream token keeps the three routes it was named for. The frontend fetches a media token for every signed-in user and asks for a camera token only when the user can actually mint one, which also stops the 403 that used to fire on every page load for everyone else. Fixed on the way through: the printer file-browser's plate thumbnails, which had no query-token support at all and so 401'd in the file manager whenever auth was enabled; and the project cover image in the edit dialog, whose cache-busting `?v=` was appended after the token and corrupted it.
 
 

+ 37 - 7
frontend/src/__tests__/components/ModelViewerModal.test.tsx

@@ -668,7 +668,11 @@ describe('ModelViewerModal', () => {
       expect(screen.queryByRole('button', { name: 'More slicer options' })).not.toBeInTheDocument();
       expect(screen.queryByRole('button', { name: 'More slicer options' })).not.toBeInTheDocument();
     });
     });
 
 
-    it('offers the desktop handoff for an STL library file', async () => {
+    it('offers the desktop handoff for an STL library file, naming the slicer', async () => {
+      // Default settings mean Bambu Studio is preferred, and its protocol
+      // handler takes 3MF only (#3029) -- so OrcaSlicer becomes the primary
+      // action. It is named rather than hidden behind a generic "Open in
+      // Slicer", because the file is not going where the setting says.
       render(
       render(
         <ModelViewerModal
         <ModelViewerModal
           libraryFileId={1}
           libraryFileId={1}
@@ -678,17 +682,41 @@ describe('ModelViewerModal', () => {
         />
         />
       );
       );
 
 
-      await waitFor(() => {
-        expect(screen.getByRole('button', { name: 'Open in Slicer' })).toBeEnabled();
-      });
+      const button = await screen.findByRole('button', { name: 'Open in OrcaSlicer' });
+      expect(button).toBeEnabled();
 
 
-      fireEvent.click(screen.getByRole('button', { name: 'More slicer options' }));
+      // OrcaSlicer is the only slicer that can take it, so there is no
+      // alternative to offer and the split collapses to a plain button.
+      expect(screen.queryByRole('button', { name: 'More slicer options' })).not.toBeInTheDocument();
 
 
+      fireEvent.click(button);
       await waitFor(() => {
       await waitFor(() => {
-        expect(screen.getByText('Open in OrcaSlicer')).toBeInTheDocument();
+        expect(openInSlicer).toHaveBeenCalledWith(expect.any(String), 'orcaslicer');
       });
       });
     });
     });
 
 
+    it('does not offer Bambu Studio a file its handler will refuse', async () => {
+      server.use(
+        http.get('/api/v1/settings/', () => {
+          return HttpResponse.json({ preferred_slicer: 'orcaslicer' });
+        })
+      );
+
+      render(
+        <ModelViewerModal
+          libraryFileId={1}
+          title="Model.stl"
+          fileType="stl"
+          onClose={mockOnClose}
+        />
+      );
+
+      // OrcaSlicer is preferred and takes an STL, so it is the plain primary.
+      await screen.findByRole('button', { name: 'Open in Slicer' });
+      expect(screen.queryByRole('button', { name: 'More slicer options' })).not.toBeInTheDocument();
+      expect(screen.queryByText('Open in Bambu Studio')).not.toBeInTheDocument();
+    });
+
     it('offers the split Slice button for an STL when the slicer API is enabled', async () => {
     it('offers the split Slice button for an STL when the slicer API is enabled', async () => {
       server.use(
       server.use(
         http.get('/api/v1/settings/', () => {
         http.get('/api/v1/settings/', () => {
@@ -713,9 +741,11 @@ describe('ModelViewerModal', () => {
       fireEvent.click(screen.getByRole('button', { name: 'More slicer options' }));
       fireEvent.click(screen.getByRole('button', { name: 'More slicer options' }));
 
 
       await waitFor(() => {
       await waitFor(() => {
-        expect(screen.getByText('Open in Bambu Studio')).toBeInTheDocument();
         expect(screen.getByText('Open in OrcaSlicer')).toBeInTheDocument();
         expect(screen.getByText('Open in OrcaSlicer')).toBeInTheDocument();
       });
       });
+      // Bambu Studio is absent: the sidecar can slice an STL, but Bambu
+      // Studio's URL handler cannot load one (#3029).
+      expect(screen.queryByText('Open in Bambu Studio')).not.toBeInTheDocument();
     });
     });
   });
   });
 });
 });

+ 67 - 2
frontend/src/__tests__/pages/FileManagerPage.test.tsx

@@ -1178,6 +1178,13 @@ describe('FileManagerPage', () => {
       vi.mocked(openInSlicer).mockClear();
       vi.mocked(openInSlicer).mockClear();
       server.use(
       server.use(
         http.post('/api/v1/library/files/:id/slicer-token', () => HttpResponse.json({ token: 'test-token' })),
         http.post('/api/v1/library/files/:id/slicer-token', () => HttpResponse.json({ token: 'test-token' })),
+        // The only sliceable fixture is an STL, and since #3029 the desktop
+        // handoff is only offered to a slicer whose protocol handler will
+        // actually load one -- Bambu Studio's takes 3MF only. These tests are
+        // about the handoff mechanics and the permission gate, not about which
+        // slicer, so they run against OrcaSlicer. The Bambu Studio side is
+        // covered by its own tests below.
+        http.get('/api/v1/settings/', () => HttpResponse.json({ preferred_slicer: 'orcaslicer' })),
       );
       );
     });
     });
 
 
@@ -1209,7 +1216,7 @@ describe('FileManagerPage', () => {
       await waitFor(() => {
       await waitFor(() => {
         expect(openInSlicer).toHaveBeenCalledWith(
         expect(openInSlicer).toHaveBeenCalledWith(
           expect.stringContaining('/library/files/2/dl/test-token/'),
           expect.stringContaining('/library/files/2/dl/test-token/'),
-          'bambu_studio',
+          'orcaslicer',
         );
         );
       });
       });
     });
     });
@@ -1255,6 +1262,64 @@ describe('FileManagerPage', () => {
       expect(card.className).not.toContain('overflow-hidden');
       expect(card.className).not.toContain('overflow-hidden');
     });
     });
 
 
+    // #3029: Bambu Studio's protocol handler refuses anything that is not a
+    // 3MF before it even fetches the URL -- "Download failed, unknown file
+    // format." Offering the handoff anyway put an action on an STL card that
+    // could only fail, with an error that blamed the file.
+    it('hides the desktop handoff for an STL when the target is Bambu Studio', async () => {
+      server.use(
+        http.get('/api/v1/settings/', () => HttpResponse.json({ preferred_slicer: 'bambu_studio' })),
+      );
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
+
+      const card = await openMenu(user, 'bracket.stl');
+      expect(within(card).queryByText('Slice')).not.toBeInTheDocument();
+    });
+
+    it('honours the open_in_slicer override over the preferred slicer', async () => {
+      // The desktop target is its own setting (#1329), so it -- not
+      // preferred_slicer, which drives the sidecar -- decides whether an STL
+      // can be handed over at all.
+      server.use(
+        http.get('/api/v1/settings/', () =>
+          HttpResponse.json({ preferred_slicer: 'bambu_studio', open_in_slicer: 'orcaslicer' }),
+        ),
+      );
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
+
+      const card = await openMenu(user, 'bracket.stl');
+      await user.click(within(card).getByText('Slice'));
+
+      await waitFor(() => {
+        expect(openInSlicer).toHaveBeenCalledWith(expect.any(String), 'orcaslicer');
+      });
+    });
+
+    it('still offers an STL to the in-app slicer with Bambu Studio as the desktop target', async () => {
+      // The restriction is on the URL handoff, not on the file: the sidecar
+      // slices an STL regardless of which desktop slicer is configured.
+      server.use(
+        http.get('/api/v1/settings/', () =>
+          HttpResponse.json({ use_slicer_api: true, preferred_slicer: 'bambu_studio' }),
+        ),
+      );
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
+
+      const card = await openMenu(user, 'bracket.stl');
+      await user.click(within(card).getByText('Slice'));
+
+      expect(await screen.findByTestId('slice-modal')).toBeInTheDocument();
+    });
+
     it('hides the slice item for already-sliced files', async () => {
     it('hides the slice item for already-sliced files', async () => {
       const user = userEvent.setup();
       const user = userEvent.setup();
       render(<FileManagerPage />);
       render(<FileManagerPage />);
@@ -1384,7 +1449,7 @@ describe('FileManagerPage', () => {
       await waitFor(() => {
       await waitFor(() => {
         expect(openInSlicer).toHaveBeenCalledWith(
         expect(openInSlicer).toHaveBeenCalledWith(
           expect.stringContaining('/library/files/2/dl/test-token/'),
           expect.stringContaining('/library/files/2/dl/test-token/'),
-          'bambu_studio',
+          'orcaslicer',
         );
         );
       });
       });
     });
     });

+ 56 - 4
frontend/src/__tests__/utils/slicerStepGating.test.ts

@@ -4,6 +4,7 @@ import {
   isApiSliceableFilename,
   isApiSliceableFilename,
   isSliceableFileType,
   isSliceableFileType,
   isSliceableFilename,
   isSliceableFilename,
+  type SlicerType,
 } from '../../utils/slicer';
 } from '../../utils/slicer';
 
 
 /**
 /**
@@ -17,10 +18,14 @@ import {
  *
  *
  * One predicate used to serve both, which is why a STEP got a Slice button
  * One predicate used to serve both, which is why a STEP got a Slice button
  * that could only ever fail, several seconds and one upload later.
  * that could only ever fail, several seconds and one upload later.
+ *
+ * The desktop half is asserted against OrcaSlicer since #3029: Bambu Studio's
+ * protocol handler takes 3MF only, so the handoff is per-slicer now. The split
+ * this file is about is unchanged -- see the second describe for the new axis.
  */
  */
 describe('STEP is offered to the desktop slicer but not the sidecar', () => {
 describe('STEP is offered to the desktop slicer but not the sidecar', () => {
   it.each(['part.step', 'part.stp', 'PART.STEP'])('%s is a desktop handoff', (name) => {
   it.each(['part.step', 'part.stp', 'PART.STEP'])('%s is a desktop handoff', (name) => {
-    expect(isSliceableFilename(name)).toBe(true);
+    expect(isSliceableFilename(name, 'orcaslicer')).toBe(true);
   });
   });
 
 
   it.each(['part.step', 'part.stp', 'PART.STEP'])('%s is not sidecar-sliceable', (name) => {
   it.each(['part.step', 'part.stp', 'PART.STEP'])('%s is not sidecar-sliceable', (name) => {
@@ -28,17 +33,18 @@ describe('STEP is offered to the desktop slicer but not the sidecar', () => {
   });
   });
 
 
   it.each(['cube.stl', 'project.3mf'])('%s stays sliceable both ways', (name) => {
   it.each(['cube.stl', 'project.3mf'])('%s stays sliceable both ways', (name) => {
-    expect(isSliceableFilename(name)).toBe(true);
+    expect(isSliceableFilename(name, 'orcaslicer')).toBe(true);
     expect(isApiSliceableFilename(name)).toBe(true);
     expect(isApiSliceableFilename(name)).toBe(true);
   });
   });
 
 
   it.each(['out.gcode', 'out.gcode.3mf'])('%s is slicer output, not input', (name) => {
   it.each(['out.gcode', 'out.gcode.3mf'])('%s is slicer output, not input', (name) => {
-    expect(isSliceableFilename(name)).toBe(false);
+    expect(isSliceableFilename(name, 'orcaslicer')).toBe(false);
+    expect(isSliceableFilename(name, 'bambu_studio')).toBe(false);
     expect(isApiSliceableFilename(name)).toBe(false);
     expect(isApiSliceableFilename(name)).toBe(false);
   });
   });
 
 
   it('applies the same split to stored file types', () => {
   it('applies the same split to stored file types', () => {
-    expect(isSliceableFileType('step')).toBe(true);
+    expect(isSliceableFileType('step', 'orcaslicer')).toBe(true);
     expect(isApiSliceableFileType('step')).toBe(false);
     expect(isApiSliceableFileType('step')).toBe(false);
     expect(isApiSliceableFileType('stl')).toBe(true);
     expect(isApiSliceableFileType('stl')).toBe(true);
     expect(isApiSliceableFileType('3mf')).toBe(true);
     expect(isApiSliceableFileType('3mf')).toBe(true);
@@ -51,3 +57,49 @@ describe('STEP is offered to the desktop slicer but not the sidecar', () => {
     expect(isApiSliceableFileType('')).toBe(false);
     expect(isApiSliceableFileType('')).toBe(false);
   });
   });
 });
 });
+
+/**
+ * Which slicer the URL is handed to changes the answer (#3029).
+ *
+ * Bambu Studio funnels every protocol URL into ``Plater::import_model_id``,
+ * which refuses a filename that is not .3mf before it even makes the request:
+ * "Download failed, unknown file format." So a File Manager that offered an
+ * STL handoff to Bambu Studio was offering an action that could only fail, and
+ * the error blamed the file.
+ *
+ * OrcaSlicer only sends MakerWorld links down that path; a link to our own host
+ * goes to its generic downloader, which has no extension check.
+ */
+describe('the desktop handoff is per-slicer', () => {
+  it.each(['cube.stl', 'part.step', 'part.stp'])('%s goes to OrcaSlicer but not Bambu Studio', (name) => {
+    expect(isSliceableFilename(name, 'orcaslicer')).toBe(true);
+    expect(isSliceableFilename(name, 'bambu_studio')).toBe(false);
+  });
+
+  it('a 3MF goes to either', () => {
+    expect(isSliceableFilename('project.3mf', 'orcaslicer')).toBe(true);
+    expect(isSliceableFilename('project.3mf', 'bambu_studio')).toBe(true);
+  });
+
+  it('applies the same split to stored file types', () => {
+    expect(isSliceableFileType('stl', 'orcaslicer')).toBe(true);
+    expect(isSliceableFileType('stl', 'bambu_studio')).toBe(false);
+    expect(isSliceableFileType('3mf', 'bambu_studio')).toBe(true);
+    expect(isSliceableFileType('gcode.3mf', 'orcaslicer')).toBe(false);
+  });
+
+  it('treats a missing type as not sliceable for either', () => {
+    expect(isSliceableFileType(undefined, 'orcaslicer')).toBe(false);
+    expect(isSliceableFileType(null, 'bambu_studio')).toBe(false);
+    expect(isSliceableFileType('', 'orcaslicer')).toBe(false);
+  });
+
+  it('falls back to the Bambu Studio list for an unrecognised slicer', () => {
+    // `settings.open_in_slicer` is not validated on the way in, and
+    // `openInSlicer` sends anything that is not exactly 'orcaslicer' to Bambu
+    // Studio. The gate has to agree with where the URL actually goes.
+    const bogus = 'prusa' as unknown as SlicerType;
+    expect(isSliceableFilename('cube.stl', bogus)).toBe(false);
+    expect(isSliceableFilename('project.3mf', bogus)).toBe(true);
+  });
+});

+ 41 - 9
frontend/src/components/ModelViewerModal.tsx

@@ -44,6 +44,11 @@ interface SlicerSplitButtonProps {
 // opens a dropdown with the other slicer options. Outside click or Escape
 // opens a dropdown with the other slicer options. Outside click or Escape
 // (non-propagating) closes the dropdown. The split only renders when the
 // (non-propagating) closes the dropdown. The split only renders when the
 // action is already possible, so there is no disabled state to express.
 // action is already possible, so there is no disabled state to express.
+//
+// With nothing to put in the dropdown it collapses to a plain button rather
+// than offering a chevron onto an empty menu. That is reachable since #3029:
+// an STL has exactly one slicer that will take it, so there is no alternative
+// to offer once that one is the primary action.
 function SlicerSplitButton({ icon, label, dropdownLabel, onPrimary, items }: SlicerSplitButtonProps) {
 function SlicerSplitButton({ icon, label, dropdownLabel, onPrimary, items }: SlicerSplitButtonProps) {
   const [open, setOpen] = useState(false);
   const [open, setOpen] = useState(false);
   const containerRef = useRef<HTMLDivElement>(null);
   const containerRef = useRef<HTMLDivElement>(null);
@@ -69,6 +74,15 @@ function SlicerSplitButton({ icon, label, dropdownLabel, onPrimary, items }: Sli
     };
     };
   }, [open]);
   }, [open]);
 
 
+  if (items.length === 0) {
+    return (
+      <Button variant="secondary" size="sm" onClick={onPrimary}>
+        {icon}
+        {label}
+      </Button>
+    );
+  }
+
   return (
   return (
     <div className="relative inline-flex" ref={containerRef}>
     <div className="relative inline-flex" ref={containerRef}>
       <div className="flex relative z-50">
       <div className="flex relative z-50">
@@ -367,12 +381,20 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
     };
     };
   }, [isDraggingDivider, dividerHeight, minPlateHeight, minViewerPx, minViewerRatio]);
   }, [isDraggingDivider, dividerHeight, minPlateHeight, minViewerPx, minViewerRatio]);
 
 
-  // Which file types can be handed to a desktop slicer via the URL protocol
-  // handler. Shares its list with `isSliceableFilename()`, which the File
+  // Which desktop slicers this file can actually be handed to, configured one
+  // first. Shares its lists with `isSliceableFilename()`, which the File
   // Manager's card menu and list row use, so a file's "Slice" action and its
   // Manager's card menu and list row use, so a file's "Slice" action and its
   // 3D-preview slicer button can no longer disagree about the same file.
   // 3D-preview slicer button can no longer disagree about the same file.
-  const slicerReadyType = isSliceableFileType(fileType);
-  const canOpenInSlicer = isLibrary ? slicerReadyType : true;
+  //
+  // Per-slicer rather than one list, because Bambu Studio's protocol handler
+  // takes 3MF only while OrcaSlicer's takes STL and STEP too (#3029). Archive
+  // previews always hand over a 3MF, which both accept.
+  const desktopSlicerOrder: SlicerType[] =
+    preferredSlicer === 'orcaslicer' ? ['orcaslicer', 'bambu_studio'] : ['bambu_studio', 'orcaslicer'];
+  const usableSlicers = isLibrary
+    ? desktopSlicerOrder.filter((slicer) => isSliceableFileType(fileType, slicer))
+    : desktopSlicerOrder;
+  const canOpenInSlicer = usableSlicers.length > 0;
   // The sidecar's list is narrower: its CLI cannot load STEP even though the
   // The sidecar's list is narrower: its CLI cannot load STEP even though the
   // desktop GUI opens one fine, so in-app slicing is gated separately.
   // desktop GUI opens one fine, so in-app slicing is gated separately.
   const apiSlicerReadyType = isApiSliceableFileType(fileType);
   const apiSlicerReadyType = isApiSliceableFileType(fileType);
@@ -415,9 +437,10 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
     }
     }
   };
   };
 
 
-  const slicerDropdownTypes: SlicerType[] = useBambuddySlicer
-    ? ['bambu_studio', 'orcaslicer']
-    : [preferredSlicer === 'orcaslicer' ? 'bambu_studio' : 'orcaslicer'];
+  // With the sidecar as the primary action every usable slicer is an
+  // alternative; without it the first one is the primary, so the dropdown holds
+  // the rest.
+  const slicerDropdownTypes: SlicerType[] = useBambuddySlicer ? usableSlicers : usableSlicers.slice(1);
   const slicerName = (slicer: SlicerType) =>
   const slicerName = (slicer: SlicerType) =>
     slicer === 'orcaslicer' ? t('settings.slicerOrcaSlicer') : t('settings.slicerBambuStudio');
     slicer === 'orcaslicer' ? t('settings.slicerOrcaSlicer') : t('settings.slicerBambuStudio');
   const slicerDropdownItems = slicerDropdownTypes.map((slicer) => ({
   const slicerDropdownItems = slicerDropdownTypes.map((slicer) => ({
@@ -459,9 +482,18 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
             ) : canOpenInSlicer ? (
             ) : canOpenInSlicer ? (
               <SlicerSplitButton
               <SlicerSplitButton
                 icon={<ExternalLink className="w-4 h-4" />}
                 icon={<ExternalLink className="w-4 h-4" />}
-                label={t('modelViewer.openInSlicer')}
+                // Name the slicer when it is not the configured one. That happens
+                // when the configured slicer cannot take this format — an STL with
+                // Bambu Studio selected — and silently handing the file to the
+                // other one without saying so would be worse than the failure it
+                // replaces.
+                label={
+                  usableSlicers[0] === preferredSlicer
+                    ? t('modelViewer.openInSlicer')
+                    : t('modelViewer.openInSlicerWith', { slicer: slicerName(usableSlicers[0]) })
+                }
                 dropdownLabel={t('modelViewer.moreSlicerOptions')}
                 dropdownLabel={t('modelViewer.moreSlicerOptions')}
-                onPrimary={() => handleOpenInSlicer(preferredSlicer)}
+                onPrimary={() => handleOpenInSlicer(usableSlicers[0])}
                 items={slicerDropdownItems}
                 items={slicerDropdownItems}
               />
               />
             ) : (
             ) : (

+ 11 - 6
frontend/src/pages/FileManagerPage.tsx

@@ -755,6 +755,10 @@ interface FileCardProps {
   onOpenInSlicer?: (file: LibraryFileListItem) => void;
   onOpenInSlicer?: (file: LibraryFileListItem) => void;
   onRunPipeline?: (file: LibraryFileListItem) => void;
   onRunPipeline?: (file: LibraryFileListItem) => void;
   useSlicerApi?: boolean;
   useSlicerApi?: boolean;
+  // Which slicer the desktop handoff targets. Decides whether an STL or STEP
+  // gets a Slice action at all: Bambu Studio's protocol handler takes 3MF only
+  // (#3029), so offering one there would only ever fail.
+  desktopSlicer: SlicerType;
   canSlice?: boolean;
   canSlice?: boolean;
   onPreview3d?: (file: LibraryFileListItem) => void;
   onPreview3d?: (file: LibraryFileListItem) => void;
   onRename?: (file: LibraryFileListItem) => void;
   onRename?: (file: LibraryFileListItem) => void;
@@ -768,7 +772,7 @@ interface FileCardProps {
   t: TFunction;
   t: TFunction;
 }
 }
 
 
-function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, onSlice, onOpenInSlicer, onRunPipeline, useSlicerApi, canSlice, onPreview3d, onRename, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, showModified, t }: FileCardProps) {
+function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, onSlice, onOpenInSlicer, onRunPipeline, useSlicerApi, desktopSlicer, canSlice, onPreview3d, onRename, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, showModified, t }: FileCardProps) {
   // Viewport coordinates rather than a flag, because the menu is rendered by
   // Viewport coordinates rather than a flag, because the menu is rendered by
   // `ContextMenu` at `position: fixed` and anchored to the button (#2846). The
   // `ContextMenu` at `position: fixed` and anchored to the button (#2846). The
   // card it belongs to is only ~270px tall for a bare STL, which is shorter
   // card it belongs to is only ~270px tall for a bare STL, which is shorter
@@ -792,7 +796,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
       title: !hasPermission('queue:create') ? t('fileManager.noPermissionAddToQueue') : undefined,
       title: !hasPermission('queue:create') ? t('fileManager.noPermissionAddToQueue') : undefined,
     });
     });
   }
   }
-  if (isSliceableLibraryFile(file, !!useSlicerApi) && (useSlicerApi ? onSlice : onOpenInSlicer)) {
+  if (isSliceableLibraryFile(file, !!useSlicerApi, desktopSlicer) && (useSlicerApi ? onSlice : onOpenInSlicer)) {
     menuItems.push({
     menuItems.push({
       label: t('slice.action'),
       label: t('slice.action'),
       icon: useSlicerApi ? <Cog className="w-4 h-4" /> : <ExternalLink className="w-4 h-4" />,
       icon: useSlicerApi ? <Cog className="w-4 h-4" /> : <ExternalLink className="w-4 h-4" />,
@@ -801,7 +805,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
       title: !canSlice ? (useSlicerApi ? t('fileManager.noPermissionSlice') : t('fileManager.noPermissionDownload')) : undefined,
       title: !canSlice ? (useSlicerApi ? t('fileManager.noPermissionSlice') : t('fileManager.noPermissionDownload')) : undefined,
     });
     });
   }
   }
-  if (onRunPipeline && useSlicerApi && isSliceableLibraryFile(file, true)) {
+  if (onRunPipeline && useSlicerApi && isSliceableLibraryFile(file, true, desktopSlicer)) {
     menuItems.push({
     menuItems.push({
       label: t('library.runWithPipeline.actionLabel'),
       label: t('library.runWithPipeline.actionLabel'),
       icon: <Play className="w-4 h-4" />,
       icon: <Play className="w-4 h-4" />,
@@ -2428,6 +2432,7 @@ export function FileManagerPage() {
                     onPrint={setPrintFile}
                     onPrint={setPrintFile}
                     onSlice={setSliceFile}
                     onSlice={setSliceFile}
                     onOpenInSlicer={handleOpenInSlicer}
                     onOpenInSlicer={handleOpenInSlicer}
+                    desktopSlicer={preferredSlicer}
                     onRunPipeline={setRunPipelineFile}
                     onRunPipeline={setRunPipelineFile}
                     useSlicerApi={settings?.use_slicer_api ?? false}
                     useSlicerApi={settings?.use_slicer_api ?? false}
                     canSlice={canSlice()}
                     canSlice={canSlice()}
@@ -2606,7 +2611,7 @@ export function FileManagerPage() {
                           </button>
                           </button>
                         </>
                         </>
                       )}
                       )}
-                      {isSliceableLibraryFile(file, !!settings?.use_slicer_api) && (
+                      {isSliceableLibraryFile(file, !!settings?.use_slicer_api, preferredSlicer) && (
                         <button
                         <button
                           onClick={() => {
                           onClick={() => {
                             if (!canSlice()) return;
                             if (!canSlice()) return;
@@ -2623,7 +2628,7 @@ export function FileManagerPage() {
                           {settings?.use_slicer_api ? <Cog className="w-4 h-4" /> : <ExternalLink className="w-4 h-4" />}
                           {settings?.use_slicer_api ? <Cog className="w-4 h-4" /> : <ExternalLink className="w-4 h-4" />}
                         </button>
                         </button>
                       )}
                       )}
-                      {(settings?.use_slicer_api ?? false) && isSliceableLibraryFile(file, true) && (
+                      {(settings?.use_slicer_api ?? false) && isSliceableLibraryFile(file, true, preferredSlicer) && (
                         <button
                         <button
                           onClick={() => hasPermission('pipelines:run') && setRunPipelineFile(file)}
                           onClick={() => hasPermission('pipelines:run') && setRunPipelineFile(file)}
                           className={`p-1.5 rounded transition-colors ${
                           className={`p-1.5 rounded transition-colors ${
@@ -2869,7 +2874,7 @@ export function FileManagerPage() {
           onSliceWithBambuddy={
           onSliceWithBambuddy={
             // Only offer in-app slicing on files the SliceModal can actually
             // Only offer in-app slicing on files the SliceModal can actually
             // handle (matches the file-row Cog visibility check at :2127).
             // handle (matches the file-row Cog visibility check at :2127).
-            isSliceableLibraryFile(viewerFile, true) && hasPermission('library:upload')
+            isSliceableLibraryFile(viewerFile, true, preferredSlicer) && hasPermission('library:upload')
               ? () => {
               ? () => {
                   const f = viewerFile;
                   const f = viewerFile;
                   setViewerFile(null);
                   setViewerFile(null);

+ 10 - 2
frontend/src/utils/libraryFiles.ts

@@ -1,4 +1,4 @@
-import { isApiSliceableFilename, isSliceableFilename } from './slicer';
+import { isApiSliceableFilename, isSliceableFilename, type SlicerType } from './slicer';
 
 
 /**
 /**
  * Is a library file sliced — does it carry printer-executable G-code?
  * Is a library file sliced — does it carry printer-executable G-code?
@@ -34,11 +34,19 @@ export function isSlicedLibraryFile(file: {
  * as name-bound as the Print gate it sits beside (#2993), so once a sliced
  * as name-bound as the Print gate it sits beside (#2993), so once a sliced
  * `Foo.3mf` correctly gained a Print button it would have been offered a
  * `Foo.3mf` correctly gained a Print button it would have been offered a
  * Slice one as well: re-slicing its own G-code.
  * Slice one as well: re-slicing its own G-code.
+ *
+ * `desktopSlicer` is required even when `useSlicerApi` is true, where it is not
+ * consulted: the desktop answer depends on which slicer the URL is handed to
+ * (#3029), and a caller that cannot name one has not yet decided what its
+ * button will do.
  */
  */
 export function isSliceableLibraryFile(
 export function isSliceableLibraryFile(
   file: { filename: string; file_type?: string | null },
   file: { filename: string; file_type?: string | null },
   useSlicerApi: boolean,
   useSlicerApi: boolean,
+  desktopSlicer: SlicerType,
 ): boolean {
 ): boolean {
   if (isSlicedLibraryFile(file)) return false;
   if (isSlicedLibraryFile(file)) return false;
-  return useSlicerApi ? isApiSliceableFilename(file.filename) : isSliceableFilename(file.filename);
+  return useSlicerApi
+    ? isApiSliceableFilename(file.filename)
+    : isSliceableFilename(file.filename, desktopSlicer);
 }
 }

+ 51 - 14
frontend/src/utils/slicer.ts

@@ -42,17 +42,49 @@ export function resolveDesktopSlicer(
 }
 }
 
 
 /**
 /**
- * File types a slicer can be handed — both by the desktop URI handler and by
- * the in-app sidecar. Source geometry only: a sliced file is an output, and
- * neither slicer has anything to do with one.
+ * What each desktop slicer's protocol handler will actually load.
  *
  *
- * Lives here rather than beside either caller because both the File Manager
- * (which has a filename) and the 3D preview (which has a `LibraryFile.file_type`)
+ * These are not the formats the two applications can open — both take an STL
+ * from File > Import perfectly well. They are what survives the *URL handoff*,
+ * which is a narrower thing, and the two slicers disagree about it (#3029).
+ *
+ * Bambu Studio routes every `bambustudio://open?file=` and `bambustudioopen://`
+ * URL into `Plater::import_model_id`, which refuses any filename that is not
+ * `.3mf` before it makes the HTTP request at all — "Download failed, unknown
+ * file format." Handing it an STL could only ever fail, and the message names
+ * the format rather than the handoff, so the failure reads as a broken file.
+ *
+ * OrcaSlicer sends only MakerWorld links and `bambustudioopen://` down that
+ * same 3MF-only path (`Downloader::start_download`). Everything else — which is
+ * what we emit for it, `orcaslicer://open?file=` against our own host — goes to
+ * its generic downloader, which has no extension check at all. So STL and STEP
+ * work there.
+ *
+ * Source geometry only either way: a sliced file is an output, and neither
+ * slicer has anything to do with one.
+ *
+ * This lives here rather than beside any caller because the File Manager (which
+ * has a filename) and the 3D preview (which has a `LibraryFile.file_type`)
  * decide the same thing about the same file. They used to hold separate lists,
  * decide the same thing about the same file. They used to hold separate lists,
  * and the two disagreed — a card menu offered a desktop handoff for an STL
  * and the two disagreed — a card menu offered a desktop handoff for an STL
  * whose own 3D preview showed "Open in Slicer" greyed out.
  * whose own 3D preview showed "Open in Slicer" greyed out.
  */
  */
-export const SLICEABLE_FILE_TYPES = ['3mf', 'stl', 'step', 'stp'] as const;
+export const DESKTOP_SLICEABLE_FILE_TYPES: Record<SlicerType, readonly string[]> = {
+  bambu_studio: ['3mf'],
+  orcaslicer: ['3mf', 'stl', 'step', 'stp'],
+};
+
+/**
+ * The formats `slicer` accepts over the handoff.
+ *
+ * Unknown values fall back to Bambu Studio's list rather than throwing, because
+ * that is what the handoff itself does: `openInSlicer` treats anything that is
+ * not exactly `orcaslicer` as Bambu Studio. `settings.open_in_slicer` comes off
+ * the API unvalidated, so the two have to agree on that.
+ */
+function desktopFormats(slicer: SlicerType): readonly string[] {
+  return DESKTOP_SLICEABLE_FILE_TYPES[slicer] ?? DESKTOP_SLICEABLE_FILE_TYPES.bambu_studio;
+}
 
 
 /**
 /**
  * The subset the *sidecar* can slice.
  * The subset the *sidecar* can slice.
@@ -66,35 +98,40 @@ export const SLICEABLE_FILE_TYPES = ['3mf', 'stl', 'step', 'stp'] as const;
 export const API_SLICEABLE_FILE_TYPES = ['3mf', 'stl'] as const;
 export const API_SLICEABLE_FILE_TYPES = ['3mf', 'stl'] as const;
 
 
 /**
 /**
- * Does a `LibraryFile.file_type` name a sliceable source file?
+ * Can `slicer` be handed this `LibraryFile.file_type` over the protocol handler?
  *
  *
  * The backend stores compound extensions whole — a sliced 3MF classifies as
  * The backend stores compound extensions whole — a sliced 3MF classifies as
  * `gcode.3mf`, not `3mf` (`classify_file_type` in `api/routes/library.py`) — so
  * `gcode.3mf`, not `3mf` (`classify_file_type` in `api/routes/library.py`) — so
  * membership alone is enough to exclude sliced output here.
  * membership alone is enough to exclude sliced output here.
+ *
+ * The slicer is a required argument on purpose: the answer genuinely differs
+ * between the two, and a default would quietly reintroduce the STL handoff that
+ * Bambu Studio cannot honour.
  */
  */
-export function isSliceableFileType(fileType?: string | null): boolean {
+export function isSliceableFileType(fileType: string | null | undefined, slicer: SlicerType): boolean {
   const normalized = (fileType || '').toLowerCase();
   const normalized = (fileType || '').toLowerCase();
-  return (SLICEABLE_FILE_TYPES as readonly string[]).includes(normalized);
+  return desktopFormats(slicer).includes(normalized);
 }
 }
 
 
 /**
 /**
- * Does a filename name a sliceable source file?
+ * Can `slicer` be handed this filename over the protocol handler?
  *
  *
  * Checked against the name rather than a stored type, so the compound
  * Checked against the name rather than a stored type, so the compound
  * extensions have to be ruled out explicitly: `.gcode.3mf` ends with `.3mf`.
  * extensions have to be ruled out explicitly: `.gcode.3mf` ends with `.3mf`.
  */
  */
-export function isSliceableFilename(filename: string): boolean {
+export function isSliceableFilename(filename: string, slicer: SlicerType): boolean {
   const lower = filename.toLowerCase();
   const lower = filename.toLowerCase();
   if (lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf')) return false;
   if (lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf')) return false;
-  return SLICEABLE_FILE_TYPES.some((ext) => lower.endsWith(`.${ext}`));
+  return desktopFormats(slicer).some((ext) => lower.endsWith(`.${ext}`));
 }
 }
 
 
 /**
 /**
  * Does a filename name something the slicer *sidecar* can slice?
  * Does a filename name something the slicer *sidecar* can slice?
  *
  *
- * Narrower than `isSliceableFilename` by exactly STEP — see
+ * Narrower than OrcaSlicer's handoff list by exactly STEP — see
  * `API_SLICEABLE_FILE_TYPES`. Use this wherever the action posts to
  * `API_SLICEABLE_FILE_TYPES`. Use this wherever the action posts to
- * `/library/files/{id}/slice`; use the wider one for the desktop handoff.
+ * `/library/files/{id}/slice`; use `isSliceableFilename` for the desktop
+ * handoff, which needs to know which slicer it is handing to.
  */
  */
 export function isApiSliceableFilename(filename: string): boolean {
 export function isApiSliceableFilename(filename: string): boolean {
   const lower = filename.toLowerCase();
   const lower = filename.toLowerCase();

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-DlHDB85v.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BQ97FBEj.js"></script>
+    <script type="module" crossorigin src="/assets/index-DlHDB85v.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-ChscM3lF.css">
     <link rel="stylesheet" crossorigin href="/assets/index-ChscM3lF.css">
   </head>
   </head>
   <body>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов