Browse Source

Add configurable slicer preference — Bambu Studio or OrcaSlicer (#313)

Users can now choose their preferred slicer (Bambu Studio or OrcaSlicer)
in Settings → General. All "Open in Slicer" buttons across Archives,
3D Preview, and context menus use the correct protocol for the selected
slicer. OrcaSlicer uses orcaslicer://open?file=<URL> on all platforms;
Bambu Studio uses bambustudio:// (Windows) or bambustudioopen:// (macOS/
Linux). Default remains Bambu Studio for backward compatibility.
maziggy 3 months ago
parent
commit
8d42b05f62

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.1.9b] - Not released
 
 ### New Features
+- **Configurable Slicer Preference** ([#313](https://github.com/maziggy/bambuddy/issues/313)) — New "Preferred Slicer" setting in General settings to choose between Bambu Studio and OrcaSlicer. Controls the protocol used by all "Open in Slicer" buttons across Archives, 3D Preview, and context menus. OrcaSlicer uses the `orcaslicer://open?file=` protocol. Default remains Bambu Studio for backward compatibility.
 - **Local Profiles — OrcaSlicer Import** ([#310](https://github.com/maziggy/bambuddy/issues/310)) — Import slicer presets from OrcaSlicer without Bambu Cloud. Supports `.orca_filament`, `.bbscfg`, `.bbsflmt`, `.zip`, and `.json` exports. Resolves OrcaSlicer inheritance chains by fetching base Bambu profiles from GitHub (cached locally with 7-day TTL). Stores presets in the database with extracted core fields (material type, vendor, nozzle temps, pressure advance, compatible printers). New "Local Profiles" tab on the Profiles page with drag-and-drop import, 3-column layout (Filament/Process/Printer), search, and expandable preset details. Local filament presets appear in AMS slot configuration alongside cloud presets. Includes smart profile type detection (explicit type field, ZIP path hints, settings ID keys, content heuristics, and name-based patterns) and material/vendor extraction from preset names as fallback.
 - **Hostname Support for Printers** ([#290](https://github.com/maziggy/bambuddy/issues/290)) — Printers can now be added using hostnames (e.g., `printer.local`, `my-printer.home.lan`) in addition to IPv4 addresses. Updated backend validation, frontend forms, and all locale labels.
 - **Camera View Controls** ([#291](https://github.com/maziggy/bambuddy/issues/291)) — Added chamber light toggle and skip objects buttons to both embedded camera viewer and standalone camera page. Extracted skip objects modal into a reusable `SkipObjectsModal` component shared across PrintersPage and both camera views.

+ 1 - 1
README.md

@@ -191,7 +191,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 </tr>
 </table>
 
-**Plus:** Customizable themes (style, background, accent) • Mobile responsive • Keyboard shortcuts • Multi-language (EN/DE) • Auto updates • Database backup/restore • System info dashboard
+**Plus:** Configurable slicer (Bambu Studio / OrcaSlicer) • Customizable themes (style, background, accent) • Mobile responsive • Keyboard shortcuts • Multi-language (EN/DE) • Auto updates • Database backup/restore • System info dashboard
 
 ---
 

+ 7 - 0
backend/app/schemas/settings.py

@@ -130,6 +130,12 @@ class AppSettings(BaseModel):
         description="Camera view mode: 'window' opens in new browser window, 'embedded' shows overlay on main screen",
     )
 
+    # Preferred slicer application
+    preferred_slicer: str = Field(
+        default="bambu_studio",
+        description="Preferred slicer: 'bambu_studio' or 'orcaslicer'",
+    )
+
     # Prometheus metrics endpoint
     prometheus_enabled: bool = Field(default=False, description="Enable Prometheus metrics endpoint at /metrics")
     prometheus_token: str = Field(
@@ -191,5 +197,6 @@ class AppSettingsUpdate(BaseModel):
     library_archive_mode: str | None = None
     library_disk_warning_gb: float | None = None
     camera_view_mode: str | None = None
+    preferred_slicer: str | None = None
     prometheus_enabled: bool | None = None
     prometheus_token: str | None = None

+ 48 - 0
backend/tests/unit/test_slicer_settings.py

@@ -0,0 +1,48 @@
+"""Unit tests for preferred_slicer setting in AppSettings schema."""
+
+import pytest
+
+from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
+
+
+@pytest.mark.unit
+class TestPreferredSlicerSchema:
+    """Tests for the preferred_slicer field in settings schemas."""
+
+    def test_default_value_is_bambu_studio(self):
+        """Default preferred_slicer should be bambu_studio."""
+        settings = AppSettings()
+        assert settings.preferred_slicer == "bambu_studio"
+
+    def test_set_to_orcaslicer(self):
+        """Should accept orcaslicer as a valid value."""
+        settings = AppSettings(preferred_slicer="orcaslicer")
+        assert settings.preferred_slicer == "orcaslicer"
+
+    def test_set_to_bambu_studio_explicit(self):
+        """Should accept bambu_studio as an explicit value."""
+        settings = AppSettings(preferred_slicer="bambu_studio")
+        assert settings.preferred_slicer == "bambu_studio"
+
+    def test_update_schema_default_is_none(self):
+        """AppSettingsUpdate preferred_slicer should default to None."""
+        update = AppSettingsUpdate()
+        assert update.preferred_slicer is None
+
+    def test_update_schema_accepts_value(self):
+        """AppSettingsUpdate should accept a preferred_slicer value."""
+        update = AppSettingsUpdate(preferred_slicer="orcaslicer")
+        assert update.preferred_slicer == "orcaslicer"
+
+    def test_serialization_roundtrip(self):
+        """Settings should survive serialization roundtrip."""
+        settings = AppSettings(preferred_slicer="orcaslicer")
+        data = settings.model_dump()
+        restored = AppSettings(**data)
+        assert restored.preferred_slicer == "orcaslicer"
+
+    def test_partial_update_preserves_other_fields(self):
+        """Updating preferred_slicer should not affect other fields."""
+        update = AppSettingsUpdate(preferred_slicer="orcaslicer")
+        data = update.model_dump(exclude_none=True)
+        assert data == {"preferred_slicer": "orcaslicer"}

+ 17 - 0
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -118,6 +118,23 @@ describe('SettingsPage', () => {
       });
     });
 
+    it('shows preferred slicer setting', async () => {
+      render(<SettingsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Preferred Slicer')).toBeInTheDocument();
+      });
+    });
+
+    it('shows slicer dropdown with both options', async () => {
+      render(<SettingsPage />);
+
+      await waitFor(() => {
+        const slicerSelect = screen.getAllByDisplayValue('Bambu Studio');
+        expect(slicerSelect.length).toBeGreaterThan(0);
+      });
+    });
+
     it('shows appearance section', async () => {
       render(<SettingsPage />);
 

+ 115 - 0
frontend/src/__tests__/utils/slicer.test.ts

@@ -0,0 +1,115 @@
+/**
+ * Tests for the slicer utility functions.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { openInSlicer, detectPlatform, buildDownloadUrl } from '../../utils/slicer';
+
+describe('slicer utility', () => {
+  let clickSpy: ReturnType<typeof vi.fn>;
+  let appendSpy: ReturnType<typeof vi.fn>;
+  let removeSpy: ReturnType<typeof vi.fn>;
+  let createdLink: HTMLAnchorElement;
+
+  beforeEach(() => {
+    clickSpy = vi.fn();
+    appendSpy = vi.spyOn(document.body, 'appendChild').mockImplementation((node) => {
+      createdLink = node as HTMLAnchorElement;
+      return node;
+    });
+    removeSpy = vi.spyOn(document.body, 'removeChild').mockImplementation((node) => node);
+
+    // Mock click on created elements
+    vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(clickSpy);
+  });
+
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  describe('detectPlatform', () => {
+    it('detects Windows', () => {
+      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Windows NT 10.0; Win64; x64)');
+      expect(detectPlatform()).toBe('windows');
+    });
+
+    it('detects macOS', () => {
+      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)');
+      expect(detectPlatform()).toBe('macos');
+    });
+
+    it('detects Linux', () => {
+      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (X11; Linux x86_64)');
+      expect(detectPlatform()).toBe('linux');
+    });
+  });
+
+  describe('openInSlicer', () => {
+    it('uses bambustudio:// protocol on Windows for bambu_studio', () => {
+      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Windows NT 10.0)');
+      openInSlicer('http://localhost:8000/file.3mf', 'bambu_studio');
+
+      expect(appendSpy).toHaveBeenCalled();
+      expect(createdLink.href).toContain('bambustudio://');
+      expect(createdLink.href).toContain(encodeURIComponent('http://localhost:8000/file.3mf'));
+      expect(clickSpy).toHaveBeenCalled();
+      expect(removeSpy).toHaveBeenCalled();
+    });
+
+    it('uses bambustudioopen:// protocol on macOS for bambu_studio', () => {
+      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Macintosh; Intel Mac OS X)');
+      openInSlicer('http://localhost:8000/file.3mf', 'bambu_studio');
+
+      expect(createdLink.href).toContain('bambustudioopen://');
+    });
+
+    it('uses bambustudioopen:// protocol on Linux for bambu_studio', () => {
+      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (X11; Linux x86_64)');
+      openInSlicer('http://localhost:8000/file.3mf', 'bambu_studio');
+
+      expect(createdLink.href).toContain('bambustudioopen://');
+    });
+
+    it('uses orcaslicer:// protocol for orcaslicer on all platforms', () => {
+      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Macintosh; Intel Mac OS X)');
+      openInSlicer('http://localhost:8000/file.3mf', 'orcaslicer');
+
+      expect(createdLink.href).toContain('orcaslicer://');
+      expect(createdLink.href).toContain('open?file=');
+    });
+
+    it('does not encode the file URL for orcaslicer', () => {
+      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Windows NT 10.0)');
+      const url = 'http://localhost:8000/api/v1/archives/1/file/My Model.3mf';
+      openInSlicer(url, 'orcaslicer');
+
+      // The href should contain the raw URL (browser may normalize it but it should not be double-encoded)
+      expect(createdLink.href).toContain('orcaslicer://open?file=');
+      // Should NOT contain %253A (double-encoded colon)
+      expect(createdLink.href).not.toContain('%253A');
+    });
+
+    it('defaults to bambu_studio when no slicer specified', () => {
+      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Windows NT 10.0)');
+      openInSlicer('http://localhost:8000/file.3mf');
+
+      expect(createdLink.href).toContain('bambustudio://');
+    });
+
+    it('creates and removes a temporary link element', () => {
+      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Windows NT 10.0)');
+      openInSlicer('http://localhost:8000/file.3mf', 'bambu_studio');
+
+      expect(appendSpy).toHaveBeenCalledOnce();
+      expect(clickSpy).toHaveBeenCalledOnce();
+      expect(removeSpy).toHaveBeenCalledOnce();
+    });
+  });
+
+  describe('buildDownloadUrl', () => {
+    it('prepends window.location.origin', () => {
+      const result = buildDownloadUrl('/api/v1/archives/1/file/test.3mf');
+      expect(result).toBe(`${window.location.origin}/api/v1/archives/1/file/test.3mf`);
+    });
+  });
+});

+ 2 - 0
frontend/src/api/client.ts

@@ -772,6 +772,8 @@ export interface AppSettings {
   library_disk_warning_gb: number;
   // Camera view settings
   camera_view_mode: 'window' | 'embedded';
+  // Preferred slicer
+  preferred_slicer: 'bambu_studio' | 'orcaslicer';
   // Prometheus metrics
   prometheus_enabled: boolean;
   prometheus_token: string;

+ 6 - 3
frontend/src/components/ModelViewerModal.tsx

@@ -1,11 +1,12 @@
 import { useState, useEffect, useRef, useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
+import { useQuery } from '@tanstack/react-query';
 import { X, ExternalLink, Box, Code2, Loader2, Layers, Check, Maximize2, Minimize2 } from 'lucide-react';
 import { ModelViewer } from './ModelViewer';
 import { GcodeViewer } from './GcodeViewer';
 import { Button } from './Button';
 import { api } from '../api/client';
-import { openInSlicer } from '../utils/slicer';
+import { openInSlicer, type SlicerType } from '../utils/slicer';
 import type { ArchivePlatesResponse, LibraryFilePlatesResponse, PlateMetadata } from '../types/plates';
 
 type ViewTab = '3d' | 'gcode';
@@ -28,6 +29,8 @@ interface Capabilities {
 
 export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, onClose }: ModelViewerModalProps) {
   const { t } = useTranslation();
+  const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings });
+  const preferredSlicer: SlicerType = settings?.preferred_slicer || 'bambu_studio';
   const isLibrary = libraryFileId != null;
   const [activeTab, setActiveTab] = useState<ViewTab | null>(null);
   const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
@@ -266,11 +269,11 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
     const filename = title || 'model';
     if (isLibrary) {
       const downloadUrl = `${window.location.origin}${api.getLibraryFileDownloadUrl(libraryFileId!)}`;
-      openInSlicer(downloadUrl);
+      openInSlicer(downloadUrl, preferredSlicer);
       return;
     }
     const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId!, filename)}`;
-    openInSlicer(downloadUrl);
+    openInSlicer(downloadUrl, preferredSlicer);
   };
 
   return (

+ 5 - 3
frontend/src/i18n/locales/de.ts

@@ -512,7 +512,7 @@ export default {
     menu: {
       print: 'Drucken',
       schedule: 'Planen',
-      openInBambuStudio: 'In Bambu Studio öffnen',
+      openInBambuStudio: 'Im Slicer öffnen',
       slice: 'Slicen',
       externalLink: 'Externer Link',
       viewOnMakerWorld: 'Auf MakerWorld ansehen',
@@ -593,8 +593,8 @@ export default {
       reprint: 'Drucken',
       schedulePrint: 'Druck planen',
       schedule: 'Planen',
-      openInBambuStudio: 'In Bambu Studio öffnen',
-      openInBambuStudioToSlice: 'In Bambu Studio öffnen zum Slicen',
+      openInBambuStudio: 'Im Slicer öffnen',
+      openInBambuStudioToSlice: 'Im Slicer öffnen zum Slicen',
       slice: 'Slicen',
       externalLink: 'Externer Link',
       makerWorld: 'MakerWorld: {{designer}}',
@@ -1254,6 +1254,8 @@ export default {
     archiveSettings: 'Archiv-Einstellungen',
     newWindow: 'Neues Fenster',
     embeddedOverlay: 'Eingebettetes Overlay',
+    preferredSlicer: 'Bevorzugter Slicer',
+    preferredSlicerDescription: 'Wähle die Slicer-Anwendung zum Öffnen von Dateien',
     externalCameras: 'Externe Kameras',
     costTracking: 'Kostenverfolgung',
     printsOnly: 'Nur Drucke',

+ 5 - 3
frontend/src/i18n/locales/en.ts

@@ -512,7 +512,7 @@ export default {
     menu: {
       print: 'Print',
       schedule: 'Schedule',
-      openInBambuStudio: 'Open in Bambu Studio',
+      openInBambuStudio: 'Open in Slicer',
       slice: 'Slice',
       externalLink: 'External Link',
       viewOnMakerWorld: 'View on MakerWorld',
@@ -593,8 +593,8 @@ export default {
       reprint: 'Reprint',
       schedulePrint: 'Schedule Print',
       schedule: 'Schedule',
-      openInBambuStudio: 'Open in Bambu Studio',
-      openInBambuStudioToSlice: 'Open in Bambu Studio to slice',
+      openInBambuStudio: 'Open in Slicer',
+      openInBambuStudioToSlice: 'Open in Slicer to slice',
       slice: 'Slice',
       externalLink: 'External Link',
       makerWorld: 'MakerWorld: {{designer}}',
@@ -1254,6 +1254,8 @@ export default {
     archiveSettings: 'Archive Settings',
     newWindow: 'New Window',
     embeddedOverlay: 'Embedded Overlay',
+    preferredSlicer: 'Preferred Slicer',
+    preferredSlicerDescription: 'Choose which slicer application to open files with',
     externalCameras: 'External Cameras',
     costTracking: 'Cost Tracking',
     printsOnly: 'Prints Only',

+ 5 - 3
frontend/src/i18n/locales/it.ts

@@ -512,7 +512,7 @@ export default {
     menu: {
       print: 'Stampa',
       schedule: 'Programma',
-      openInBambuStudio: 'Apri in Bambu Studio',
+      openInBambuStudio: 'Apri nello slicer',
       slice: 'Slice',
       externalLink: 'Link esterno',
       viewOnMakerWorld: 'Vedi su MakerWorld',
@@ -593,8 +593,8 @@ export default {
       reprint: 'Ristampa',
       schedulePrint: 'Programma Stampa',
       schedule: 'Programma',
-      openInBambuStudio: 'Apri in Bambu Studio',
-      openInBambuStudioToSlice: 'Apri in Bambu Studio per slicing',
+      openInBambuStudio: 'Apri nello slicer',
+      openInBambuStudioToSlice: 'Apri nello slicer per slicing',
       slice: 'Slice',
       externalLink: 'Link esterno',
       makerWorld: 'MakerWorld: {{designer}}',
@@ -1254,6 +1254,8 @@ export default {
     archiveSettings: 'Impostazioni archivio',
     newWindow: 'Nuova finestra',
     embeddedOverlay: 'Overlay incorporato',
+    preferredSlicer: 'Slicer preferito',
+    preferredSlicerDescription: 'Scegli quale applicazione slicer usare per aprire i file',
     externalCameras: 'Camere esterne',
     costTracking: 'Tracciamento costi',
     printsOnly: 'Solo stampe',

+ 6 - 4
frontend/src/i18n/locales/ja.ts

@@ -551,7 +551,7 @@ export default {
       select: '選択',
       deselect: '選択解除',
       print: '印刷',
-      openInBambuStudio: 'Bambu Studioで開く',
+      openInBambuStudio: 'スライサーで開く',
       scanForTimelapse: 'タイムラプスをスキャン',
       copyDownloadLink: 'ダウンロードリンクをコピー',
       viewPhotosCount: '写真を表示 ({{count}})',
@@ -604,8 +604,8 @@ export default {
       uploadedBy: 'アップロード者',
       noPermissionReprint: '再印刷する権限がありません',
       noPermissionDelete: 'アーカイブを削除する権限がありません',
-      openInBambuStudio: 'Bambu Studioで開く',
-      openInBambuStudioToSlice: 'Bambu Studioでスライス',
+      openInBambuStudio: 'スライサーで開く',
+      openInBambuStudioToSlice: 'スライサーでスライス',
       makerWorld: 'MakerWorld: {{designer}}',
       viewProject: 'プロジェクトを表示',
       noExternalLink: '外部リンクなし',
@@ -673,7 +673,7 @@ export default {
       compare: '比較',
       viewTimelapse: 'タイムラプスを表示',
       downloadTimelapse: 'タイムラプスをダウンロード',
-      openInBambuStudio: 'Bambu Studioで開く',
+      openInBambuStudio: 'スライサーで開く',
       openInOrcaSlicer: 'OrcaSlicerで開く',
       addToProject: 'プロジェクトに追加',
       removeFromProject: 'プロジェクトから削除',
@@ -1309,6 +1309,8 @@ export default {
     configureBambuddy: 'Bambuddyを設定',
     newWindow: '新しいウィンドウ',
     embeddedOverlay: '埋め込みオーバーレイ',
+    preferredSlicer: '優先スライサー',
+    preferredSlicerDescription: 'ファイルを開くスライサーアプリケーションを選択',
     externalCameras: '外部カメラ',
     printsOnly: '印刷のみ',
     totalConsumption: '総消費量',

+ 16 - 9
frontend/src/pages/ArchivesPage.tsx

@@ -48,7 +48,7 @@ import {
   User,
 } from 'lucide-react';
 import { api } from '../api/client';
-import { openInSlicer } from '../utils/slicer';
+import { openInSlicer, type SlicerType } from '../utils/slicer';
 import { formatDateTime, formatDateOnly, parseUTCDate, type TimeFormat } from '../utils/date';
 import { useIsMobile } from '../hooks/useIsMobile';
 import type { Archive, ProjectListItem } from '../api/client';
@@ -119,6 +119,7 @@ function ArchiveCard({
   projects,
   isHighlighted,
   timeFormat = 'system',
+  preferredSlicer = 'bambu_studio',
   t,
 }: {
   archive: Archive;
@@ -129,6 +130,7 @@ function ArchiveCard({
   projects: ProjectListItem[] | undefined;
   isHighlighted?: boolean;
   timeFormat?: TimeFormat;
+  preferredSlicer?: SlicerType;
   t: TFunction;
 }) {
   // Debug: log when card is highlighted
@@ -317,7 +319,7 @@ function ArchiveCard({
         onClick: () => {
           const filename = archive.print_name || archive.filename || 'model';
           const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
-          openInSlicer(downloadUrl);
+          openInSlicer(downloadUrl, preferredSlicer);
         },
       },
     ] : [
@@ -327,7 +329,7 @@ function ArchiveCard({
         onClick: () => {
           const filename = archive.print_name || archive.filename || 'model';
           const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
-          openInSlicer(downloadUrl);
+          openInSlicer(downloadUrl, preferredSlicer);
         },
       },
     ]),
@@ -701,7 +703,7 @@ function ArchiveCard({
               // Open source 3MF in Bambu Studio - use filename in URL for slicer compatibility
               const sourceName = (archive.print_name || archive.filename || 'source').replace(/\.gcode\.3mf$/i, '') + '_source';
               const downloadUrl = `${window.location.origin}${api.getSource3mfForSlicer(archive.id, sourceName)}`;
-              openInSlicer(downloadUrl);
+              openInSlicer(downloadUrl, preferredSlicer);
             }}
             title={t('archives.card.openSource3mf')}
           >
@@ -948,7 +950,7 @@ function ArchiveCard({
                 onClick={() => {
                   const filename = archive.print_name || archive.filename || 'model';
                   const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
-                  openInSlicer(downloadUrl);
+                  openInSlicer(downloadUrl, preferredSlicer);
                 }}
                 title={t('archives.card.openInBambuStudio')}
               >
@@ -964,7 +966,7 @@ function ArchiveCard({
               onClick={() => {
                 const filename = archive.print_name || archive.filename || 'model';
                 const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
-                openInSlicer(downloadUrl);
+                openInSlicer(downloadUrl, preferredSlicer);
               }}
               title={t('archives.card.openInBambuStudioToSlice')}
             >
@@ -1276,6 +1278,7 @@ function ArchiveListRow({
   selectionMode,
   projects,
   isHighlighted,
+  preferredSlicer = 'bambu_studio',
   t,
 }: {
   archive: Archive;
@@ -1285,6 +1288,7 @@ function ArchiveListRow({
   selectionMode: boolean;
   projects: ProjectListItem[] | undefined;
   isHighlighted?: boolean;
+  preferredSlicer?: SlicerType;
   t: TFunction;
 }) {
   const queryClient = useQueryClient();
@@ -1450,7 +1454,7 @@ function ArchiveListRow({
         onClick: () => {
           const filename = archive.print_name || archive.filename || 'model';
           const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
-          openInSlicer(downloadUrl);
+          openInSlicer(downloadUrl, preferredSlicer);
         },
       },
     ] : [
@@ -1460,7 +1464,7 @@ function ArchiveListRow({
         onClick: () => {
           const filename = archive.print_name || archive.filename || 'model';
           const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
-          openInSlicer(downloadUrl);
+          openInSlicer(downloadUrl, preferredSlicer);
         },
       },
     ]),
@@ -1770,7 +1774,7 @@ function ArchiveListRow({
             onClick={() => {
               const filename = archive.print_name || archive.filename || 'model';
               const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
-              openInSlicer(downloadUrl);
+              openInSlicer(downloadUrl, preferredSlicer);
             }}
             title={t('archives.card.openInBambuStudio')}
           >
@@ -2161,6 +2165,7 @@ export function ArchivesPage() {
   });
 
   const timeFormat: TimeFormat = settings?.time_format || 'system';
+  const preferredSlicer: SlicerType = settings?.preferred_slicer || 'bambu_studio';
 
   const bulkDeleteMutation = useMutation({
     mutationFn: async (ids: number[]) => {
@@ -2910,6 +2915,7 @@ export function ArchivesPage() {
               projects={projects}
               isHighlighted={archive.id === highlightedArchiveId}
               timeFormat={timeFormat}
+              preferredSlicer={preferredSlicer}
               t={t}
             />
           ))}
@@ -2937,6 +2943,7 @@ export function ArchivesPage() {
                 selectionMode={selectionMode}
                 projects={projects}
                 isHighlighted={archive.id === highlightedArchiveId}
+                preferredSlicer={preferredSlicer}
                 t={t}
               />
             ))}

+ 22 - 0
frontend/src/pages/SettingsPage.tsx

@@ -750,6 +750,7 @@ export function SettingsPage() {
       (settings.library_archive_mode ?? 'ask') !== (localSettings.library_archive_mode ?? 'ask') ||
       Number(settings.library_disk_warning_gb ?? 5) !== Number(localSettings.library_disk_warning_gb ?? 5) ||
       (settings.camera_view_mode ?? 'window') !== (localSettings.camera_view_mode ?? 'window') ||
+      (settings.preferred_slicer ?? 'bambu_studio') !== (localSettings.preferred_slicer ?? 'bambu_studio') ||
       settings.prometheus_enabled !== localSettings.prometheus_enabled ||
       settings.prometheus_token !== localSettings.prometheus_token;
 
@@ -813,6 +814,7 @@ export function SettingsPage() {
         library_archive_mode: localSettings.library_archive_mode,
         library_disk_warning_gb: localSettings.library_disk_warning_gb,
         camera_view_mode: localSettings.camera_view_mode,
+        preferred_slicer: localSettings.preferred_slicer,
         prometheus_enabled: localSettings.prometheus_enabled,
         prometheus_token: localSettings.prometheus_token,
       };
@@ -1154,6 +1156,25 @@ export function SettingsPage() {
                   Pre-select this printer for uploads, reprints, and other operations.
                 </p>
               </div>
+              <div>
+                <label className="block text-sm text-bambu-gray mb-1">
+                  {t('settings.preferredSlicer')}
+                </label>
+                <div className="relative">
+                  <select
+                    value={localSettings.preferred_slicer ?? 'bambu_studio'}
+                    onChange={(e) => updateSetting('preferred_slicer', e.target.value as 'bambu_studio' | 'orcaslicer')}
+                    className="w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
+                  >
+                    <option value="bambu_studio">Bambu Studio</option>
+                    <option value="orcaslicer">OrcaSlicer</option>
+                  </select>
+                  <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
+                </div>
+                <p className="text-xs text-bambu-gray mt-1">
+                  {t('settings.preferredSlicerDescription')}
+                </p>
+              </div>
               <div className="flex items-center justify-between">
                 <div>
                   <p className="text-white">{t('settings.sidebarOrder')}</p>
@@ -1476,6 +1497,7 @@ export function SettingsPage() {
             </CardContent>
           </Card>
 
+
           <Card>
             <CardHeader>
               <h2 className="text-lg font-semibold text-white">{t('settings.costTracking')}</h2>

+ 33 - 24
frontend/src/utils/slicer.ts

@@ -1,11 +1,16 @@
 /**
- * Utility for opening files in Bambu Studio slicer
+ * Utility for opening files in slicer applications
  *
- * The URL protocol handler is OS-specific:
- * - Windows: bambustudio://
- * - macOS/Linux: bambustudioopen://
+ * Bambu Studio URL protocol is OS-specific:
+ * - Windows: bambustudio://<encoded-URL>
+ * - macOS/Linux: bambustudioopen://<encoded-URL>
+ *
+ * OrcaSlicer uses the same protocol on all platforms:
+ * - orcaslicer://open?file=<URL>
  */
 
+export type SlicerType = 'bambu_studio' | 'orcaslicer';
+
 type Platform = 'windows' | 'macos' | 'linux' | 'unknown';
 
 /**
@@ -28,28 +33,31 @@ export function detectPlatform(): Platform {
 }
 
 /**
- * Get the appropriate slicer protocol for the current OS
+ * Open a URL in the specified slicer application.
+ * Uses a temporary link element to trigger the protocol handler,
+ * which avoids browser "unknown protocol" blocks on window.location.href.
+ * @param downloadUrl - The URL to the file to open
+ * @param slicer - Which slicer to use (defaults to bambu_studio)
  */
-export function getSlicerProtocol(): string {
-  const platform = detectPlatform();
+export function openInSlicer(downloadUrl: string, slicer: SlicerType = 'bambu_studio'): void {
+  let url: string;
 
-  switch (platform) {
-    case 'windows':
-      return 'bambustudio://';
-    case 'macos':
-    case 'linux':
-    default:
-      return 'bambustudioopen://';
+  if (slicer === 'orcaslicer') {
+    url = `orcaslicer://open?file=${downloadUrl}`;
+  } else {
+    const platform = detectPlatform();
+    const protocol = platform === 'windows' ? 'bambustudio' : 'bambustudioopen';
+    url = `${protocol}://${encodeURIComponent(downloadUrl)}`;
   }
-}
 
-/**
- * Open a URL in Bambu Studio slicer
- * @param downloadUrl - The URL to the file to open (will be encoded)
- */
-export function openInSlicer(downloadUrl: string): void {
-  const protocol = getSlicerProtocol();
-  window.location.href = `${protocol}${encodeURIComponent(downloadUrl)}`;
+  // Use a temporary <a> element to trigger the protocol handler.
+  // This works more reliably than window.location.href for custom protocols.
+  const link = document.createElement('a');
+  link.href = url;
+  link.style.display = 'none';
+  document.body.appendChild(link);
+  link.click();
+  document.body.removeChild(link);
 }
 
 /**
@@ -63,8 +71,9 @@ export function buildDownloadUrl(path: string): string {
 /**
  * Convenience function to open an archive in the slicer
  * @param path - The API path to the archive
+ * @param slicer - Which slicer to use (defaults to bambu_studio)
  */
-export function openArchiveInSlicer(path: string): void {
+export function openArchiveInSlicer(path: string, slicer: SlicerType = 'bambu_studio'): void {
   const downloadUrl = buildDownloadUrl(path);
-  openInSlicer(downloadUrl);
+  openInSlicer(downloadUrl, slicer);
 }

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-47EQ7Zpi.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CbUXXMeN.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-Csb7GscS.css


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-C468hjMw.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-47EQ7Zpi.css">
+    <script type="module" crossorigin src="/assets/index-CbUXXMeN.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-Csb7GscS.css">
   </head>
   <body>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff