Kaynağa Gözat

fix(slicer): token-based auth for Open in Slicer downloads (#433)

Slicer protocol handlers (bambustudio://, orcaslicer://) launch the
slicer app which fetches files via HTTP but cannot send auth headers.
The global auth middleware returned 401, causing "importing failed."

Add short-lived, single-use download tokens: the frontend fetches a
token via authenticated POST, then builds a /dl/{token}/{filename} URL
the slicer can access without auth headers. Tokens are validated
server-side (5-min expiry, single-use). Applies to archive files,
source 3MFs, and library files.

Also fix platform-specific URL formats to match slicer source code:
- macOS: bambustudioopen:// with encodeURIComponent
- Windows/Linux: bambustudio://open?file= (was wrongly using macOS scheme on Linux)
- OrcaSlicer: orcaslicer://open?file=
maziggy 6 ay önce
ebeveyn
işleme
a903816b8a

+ 1 - 0
CHANGELOG.md

@@ -20,6 +20,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Usage Tracking Wrong Spool on Dual-Nozzle / Multi-AMS Printers** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — On H2C, H2D Pro, and other dual-nozzle printers with multiple AMS units, the usage tracker attributed filament consumption to the wrong spools. The MQTT `mapping` field — a per-print array that maps slicer filament slots to physical AMS trays — was preserved in state but never parsed or used. The tracker fell back to `slot_id - 1` as the global tray ID, which is incorrect when AMS hardware IDs differ from sequential indices (e.g., AMS-HT units with ID 128). Now decodes the MQTT mapping field from its snow encoding (`ams_hw_id * 256 + local_slot`) into bambuddy global tray IDs and uses it as a universal mapping source — working for all printer models and all print sources (slicer, queue, reprint) without relying on `tray_now` disambiguation.
 - **npm audit: suppress moderate ajv ReDoS finding** — Added `audit-level=high` to `frontend/.npmrc` so `npm audit` exits cleanly. The ajv@6 ReDoS (GHSA-2g4f-4pwh-qvx6) is a transitive dependency of eslint@9 with no patched v6 release; ajv@8 override breaks eslint. The vulnerability requires crafted `$data` schema input — not an attack vector in a linting config.
 - **Spool Form Allows Empty Brand & Subtype** ([#417](https://github.com/maziggy/bambuddy/issues/417)) — The spool add/edit modal did not require Brand or Subtype fields, allowing spools to be saved without them. When such a spool was assigned to an AMS slot, the `tray_sub_brands` sent to the printer was incomplete (e.g., just "PETG" instead of "PETG Basic"), causing BambuStudio to not recognize the filament profile. Brand and Subtype are now mandatory fields with validation errors shown on submit.
+- **Open in Slicer Fails When Authentication Enabled** ([#433](https://github.com/maziggy/bambuddy/issues/433)) — The "Open in Slicer" buttons for BambuStudio and OrcaSlicer failed with "importing failed" when authentication was enabled. Slicer protocol handlers (`bambustudio://`, `orcaslicer://`) launch the slicer app which fetches the file via HTTP — but cannot send authentication headers, so the global auth middleware returned 401. Additionally, the URL format was wrong on Linux (used the macOS-only `bambustudioopen://` scheme instead of `bambustudio://open?file=`). Fixed with short-lived, single-use download tokens: the frontend fetches a token via an authenticated POST endpoint, then builds a `/dl/{token}/{filename}` URL that the slicer can access without auth headers. The token is validated server-side (5-minute expiry, single-use). Platform-specific URL formats now match the actual slicer source code: macOS uses `bambustudioopen://` with URL encoding, Windows/Linux use `bambustudio://open?file=`, and OrcaSlicer uses `orcaslicer://open?file=`.
 
 ### Changed
 - **Filament Catalog API Renamed** ([#427](https://github.com/maziggy/bambuddy/issues/427)) — Renamed `/api/v1/filaments/` to `/api/v1/filament-catalog/` to avoid confusion with the inventory spools page (labeled "Filament" in the UI). The old endpoint managed material type definitions (cost, temperature, density), not physical spools — the shared name caused users to expect the API to return their spool inventory.

+ 114 - 2
backend/app/api/routes/archives.py

@@ -1074,7 +1074,63 @@ async def download_archive_with_filename(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
-    """Download the 3MF file with filename in URL (for Bambu Studio protocol)."""
+    """Download the 3MF file with filename in URL."""
+    service = ArchiveService(db)
+    archive = await service.get_archive(archive_id)
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    file_path = settings.base_dir / archive.file_path
+    if not file_path.exists():
+        raise HTTPException(404, "File not found")
+
+    return FileResponse(
+        path=file_path,
+        filename=archive.filename,
+        media_type="application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
+    )
+
+
+@router.post("/{archive_id}/slicer-token")
+async def create_archive_slicer_token(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+):
+    """Create a short-lived download token for opening files in slicer applications.
+
+    Slicer protocol handlers (bambustudioopen://, orcaslicer://) cannot send
+    auth headers, so they use this token in the URL path instead.
+    """
+    from backend.app.core.auth import create_slicer_download_token
+
+    service = ArchiveService(db)
+    archive = await service.get_archive(archive_id)
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    token = create_slicer_download_token("archive", archive_id)
+    return {"token": token}
+
+
+@router.get("/{archive_id}/dl/{token}/{filename}")
+async def download_archive_for_slicer(
+    archive_id: int,
+    token: str,
+    filename: str,
+    db: AsyncSession = Depends(get_db),
+):
+    """Download 3MF file using a slicer download token.
+
+    Token-authenticated (no auth headers needed). The token is short-lived
+    and single-use, created by POST /{archive_id}/slicer-token.
+    Filename is at the end of the URL so slicers can detect the file format.
+    """
+    from backend.app.core.auth import verify_slicer_download_token
+
+    if not verify_slicer_download_token(token, "archive", archive_id):
+        raise HTTPException(403, "Invalid or expired download token")
+
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
     if not archive:
@@ -3093,7 +3149,63 @@ async def download_source_3mf_for_slicer(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
-    """Download source 3MF with filename in URL (for Bambu Studio compatibility)."""
+    """Download source 3MF with filename in URL."""
+    result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
+    archive = result.scalar_one_or_none()
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    if not archive.source_3mf_path:
+        raise HTTPException(404, "No source 3MF attached to this archive")
+
+    source_path = settings.base_dir / archive.source_3mf_path
+    if not source_path.exists():
+        raise HTTPException(404, "Source 3MF file not found on disk")
+
+    return FileResponse(
+        path=source_path,
+        filename=filename if filename.endswith(".3mf") else f"{filename}.3mf",
+        media_type="application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
+    )
+
+
+@router.post("/{archive_id}/source-slicer-token")
+async def create_source_slicer_token(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+):
+    """Create a short-lived download token for opening source 3MF in slicer."""
+    from backend.app.core.auth import create_slicer_download_token
+
+    result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
+    archive = result.scalar_one_or_none()
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+    if not archive.source_3mf_path:
+        raise HTTPException(404, "No source 3MF attached to this archive")
+
+    token = create_slicer_download_token("source", archive_id)
+    return {"token": token}
+
+
+@router.get("/{archive_id}/source-dl/{token}/{filename}")
+async def download_source_3mf_for_slicer_with_token(
+    archive_id: int,
+    token: str,
+    filename: str,
+    db: AsyncSession = Depends(get_db),
+):
+    """Download source 3MF using a slicer download token.
+
+    Token-authenticated (no auth headers needed). The token is short-lived
+    and single-use, created by POST /{archive_id}/source-slicer-token.
+    """
+    from backend.app.core.auth import verify_slicer_download_token
+
+    if not verify_slicer_download_token(token, "source", archive_id):
+        raise HTTPException(403, "Invalid or expired download token")
+
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     archive = result.scalar_one_or_none()
     if not archive:

+ 56 - 0
backend/app/api/routes/library.py

@@ -2138,6 +2138,62 @@ async def download_file(
     )
 
 
+@router.post("/files/{file_id}/slicer-token")
+async def create_library_slicer_token(
+    file_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
+):
+    """Create a short-lived download token for opening files in slicer applications.
+
+    Slicer protocol handlers (bambustudioopen://, orcaslicer://) cannot send
+    auth headers, so they use this token in the URL path instead.
+    """
+    from backend.app.core.auth import create_slicer_download_token
+
+    result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
+    file = result.scalar_one_or_none()
+    if not file:
+        raise HTTPException(status_code=404, detail="File not found")
+
+    token = create_slicer_download_token("library", file_id)
+    return {"token": token}
+
+
+@router.get("/files/{file_id}/dl/{token}/{filename}")
+async def download_library_file_for_slicer(
+    file_id: int,
+    token: str,
+    filename: str,
+    db: AsyncSession = Depends(get_db),
+):
+    """Download a library file using a slicer download token.
+
+    Token-authenticated (no auth headers needed). The token is short-lived
+    and single-use, created by POST /files/{file_id}/slicer-token.
+    Filename is at the end of the URL so slicers can detect the file format.
+    """
+    from backend.app.core.auth import verify_slicer_download_token
+
+    if not verify_slicer_download_token(token, "library", file_id):
+        raise HTTPException(status_code=403, detail="Invalid or expired download token")
+
+    result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
+    file = result.scalar_one_or_none()
+    if not file:
+        raise HTTPException(status_code=404, detail="File not found")
+
+    abs_path = to_absolute_path(file.file_path)
+    if not abs_path or not abs_path.exists():
+        raise HTTPException(status_code=404, detail="File not found on disk")
+
+    return FastAPIFileResponse(
+        str(abs_path),
+        filename=file.filename,
+        media_type="application/octet-stream",
+    )
+
+
 @router.get("/files/{file_id}/thumbnail")
 async def get_thumbnail(file_id: int, db: AsyncSession = Depends(get_db)):
     """Get a file's thumbnail."""

+ 37 - 0
backend/app/core/auth.py

@@ -98,6 +98,43 @@ ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7  # 7 days
 # HTTP Bearer token
 security = HTTPBearer(auto_error=False)
 
+# --- Slicer download tokens ---
+# Short-lived tokens for slicer protocol handlers that can't send auth headers.
+# Maps token → (resource_key, expiry). resource_key = "archive:{id}" or "library:{id}".
+_slicer_tokens: dict[str, tuple[str, datetime]] = {}
+SLICER_TOKEN_EXPIRE_MINUTES = 5
+
+
+def create_slicer_download_token(resource_type: str, resource_id: int) -> str:
+    """Create a short-lived download token for slicer protocol handlers."""
+    # Cleanup expired tokens
+    now = datetime.utcnow()
+    expired = [k for k, (_, exp) in _slicer_tokens.items() if exp < now]
+    for k in expired:
+        del _slicer_tokens[k]
+
+    token = secrets.token_urlsafe(24)
+    resource_key = f"{resource_type}:{resource_id}"
+    _slicer_tokens[token] = (resource_key, now + timedelta(minutes=SLICER_TOKEN_EXPIRE_MINUTES))
+    return token
+
+
+def verify_slicer_download_token(token: str, resource_type: str, resource_id: int) -> bool:
+    """Verify a slicer download token is valid for the given resource."""
+    entry = _slicer_tokens.get(token)
+    if not entry:
+        return False
+    resource_key, expiry = entry
+    if datetime.utcnow() > expiry:
+        del _slicer_tokens[token]
+        return False
+    expected_key = f"{resource_type}:{resource_id}"
+    if resource_key != expected_key:
+        return False
+    # Token is single-use
+    del _slicer_tokens[token]
+    return True
+
 
 def verify_password(plain_password: str, hashed_password: str) -> bool:
     """Verify a password against a hash.

+ 4 - 0
backend/app/main.py

@@ -3356,6 +3356,10 @@ PUBLIC_API_PATTERNS = [
     # Camera (streams loaded via <img> tag)
     "/camera/stream",  # /printers/{id}/camera/stream
     "/camera/snapshot",  # /printers/{id}/camera/snapshot
+    # Slicer token-authenticated downloads — protocol handlers (bambustudioopen://,
+    # orcaslicer://) cannot send auth headers. These endpoints validate a short-lived
+    # download token in the URL path instead.
+    "/dl/",  # /archives/{id}/dl/{token}/{filename}, /library/files/{id}/dl/{token}/{filename}
 ]
 
 

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

@@ -2786,6 +2786,10 @@ export const api = {
   },
   getSource3mfForSlicer: (archiveId: number, filename: string) =>
     `${API_BASE}/archives/${archiveId}/source/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
+  createSourceSlicerToken: (archiveId: number) =>
+    request<{ token: string }>(`/archives/${archiveId}/source-slicer-token`, { method: 'POST' }),
+  getSourceSlicerDownloadUrl: (archiveId: number, token: string, filename: string) =>
+    `${API_BASE}/archives/${archiveId}/source-dl/${token}/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
   uploadSource3mf: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => {
     const formData = new FormData();
     formData.append('file', file);
@@ -2908,6 +2912,10 @@ export const api = {
     `${API_BASE}/archives/${archiveId}/project-image/${encodeURIComponent(imagePath)}`,
   getArchiveForSlicer: (id: number, filename: string) =>
     `${API_BASE}/archives/${id}/file/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
+  createArchiveSlicerToken: (archiveId: number) =>
+    request<{ token: string }>(`/archives/${archiveId}/slicer-token`, { method: 'POST' }),
+  getArchiveSlicerDownloadUrl: (archiveId: number, token: string, filename: string) =>
+    `${API_BASE}/archives/${archiveId}/dl/${token}/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
   getArchivePlates: (archiveId: number) =>
     request<ArchivePlatesResponse>(`/archives/${archiveId}/plates`),
   getArchiveFilamentRequirements: (archiveId: number, plateId?: number) =>
@@ -3938,6 +3946,10 @@ export const api = {
   deleteLibraryFile: (id: number) =>
     request<{ status: string; message: string }>(`/library/files/${id}`, { method: 'DELETE' }),
   getLibraryFileDownloadUrl: (id: number) => `${API_BASE}/library/files/${id}/download`,
+  createLibrarySlicerToken: (fileId: number) =>
+    request<{ token: string }>(`/library/files/${fileId}/slicer-token`, { method: 'POST' }),
+  getLibrarySlicerDownloadUrl: (fileId: number, token: string, filename: string) =>
+    `${API_BASE}/library/files/${fileId}/dl/${token}/${encodeURIComponent(filename)}`,
   downloadLibraryFile: async (id: number, filename?: string): Promise<void> => {
     const headers: Record<string, string> = {};
     if (authToken) {

+ 20 - 8
frontend/src/components/ModelViewerModal.tsx

@@ -263,17 +263,29 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
 
   const canOpenInSlicer = isLibrary ? (fileType || '').toLowerCase() === '3mf' : true;
 
-  const handleOpenInSlicer = () => {
+  const handleOpenInSlicer = async () => {
     if (!canOpenInSlicer) return;
-    // URL must include .3mf filename for Bambu Studio to recognize the format
     const filename = title || 'model';
-    if (isLibrary) {
-      const downloadUrl = `${window.location.origin}${api.getLibraryFileDownloadUrl(libraryFileId!)}`;
-      openInSlicer(downloadUrl, preferredSlicer);
-      return;
+    try {
+      if (isLibrary) {
+        const { token } = await api.createLibrarySlicerToken(libraryFileId!);
+        const path = api.getLibrarySlicerDownloadUrl(libraryFileId!, token, filename);
+        openInSlicer(`${window.location.origin}${path}`, preferredSlicer);
+      } else {
+        const { token } = await api.createArchiveSlicerToken(archiveId!);
+        const path = api.getArchiveSlicerDownloadUrl(archiveId!, token, filename);
+        openInSlicer(`${window.location.origin}${path}`, preferredSlicer);
+      }
+    } catch {
+      // Fallback to direct URL (works when auth is disabled)
+      if (isLibrary) {
+        const downloadUrl = `${window.location.origin}${api.getLibraryFileDownloadUrl(libraryFileId!)}`;
+        openInSlicer(downloadUrl, preferredSlicer);
+      } else {
+        const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId!, filename)}`;
+        openInSlicer(downloadUrl, preferredSlicer);
+      }
     }
-    const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId!, filename)}`;
-    openInSlicer(downloadUrl, preferredSlicer);
   };
 
   return (

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

@@ -105,6 +105,36 @@ function getArchiveFileType(filename: string | null | undefined): string | undef
 
 // formatDate imported from '../utils/date' - handles UTC conversion
 
+/**
+ * Open an archive file in the slicer.
+ * Fetches a short-lived download token, then builds a token-authenticated URL
+ * that bypasses auth middleware (slicer protocol handlers can't send auth headers).
+ */
+async function openInSlicerWithToken(
+  archiveId: number,
+  filename: string,
+  resourceType: 'file' | 'source',
+  slicer: SlicerType,
+): Promise<void> {
+  try {
+    if (resourceType === 'source') {
+      const { token } = await api.createSourceSlicerToken(archiveId);
+      const path = api.getSourceSlicerDownloadUrl(archiveId, token, filename);
+      openInSlicer(`${window.location.origin}${path}`, slicer);
+    } else {
+      const { token } = await api.createArchiveSlicerToken(archiveId);
+      const path = api.getArchiveSlicerDownloadUrl(archiveId, token, filename);
+      openInSlicer(`${window.location.origin}${path}`, slicer);
+    }
+  } catch {
+    // Fallback to direct URL (works when auth is disabled)
+    const path = resourceType === 'source'
+      ? api.getSource3mfForSlicer(archiveId, filename)
+      : api.getArchiveForSlicer(archiveId, filename);
+    openInSlicer(`${window.location.origin}${path}`, slicer);
+  }
+}
+
 function ArchiveCard({
   archive,
   printerName,
@@ -337,8 +367,7 @@ function ArchiveCard({
         icon: <ExternalLink className="w-4 h-4" />,
         onClick: () => {
           const filename = archive.print_name || archive.filename || 'model';
-          const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
-          openInSlicer(downloadUrl, preferredSlicer);
+          openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
         },
         disabled: !archive.file_path,
         title: !archive.file_path ? t('archives.card.noFileForReprint') : undefined,
@@ -349,8 +378,7 @@ function ArchiveCard({
         icon: <ExternalLink className="w-4 h-4" />,
         onClick: () => {
           const filename = archive.print_name || archive.filename || 'model';
-          const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
-          openInSlicer(downloadUrl, preferredSlicer);
+          openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
         },
       },
     ]),
@@ -738,8 +766,7 @@ function ArchiveCard({
               e.stopPropagation();
               // 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, preferredSlicer);
+              openInSlicerWithToken(archive.id, sourceName, 'source', preferredSlicer);
             }}
             title={t('archives.card.openSource3mf')}
           >
@@ -1008,8 +1035,7 @@ function ArchiveCard({
                 className="min-w-0 p-1 sm:p-1.5"
                 onClick={() => {
                   const filename = archive.print_name || archive.filename || 'model';
-                  const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
-                  openInSlicer(downloadUrl, preferredSlicer);
+                  openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
                 }}
                 title={t('archives.card.openInBambuStudio')}
               >
@@ -1024,8 +1050,7 @@ function ArchiveCard({
               className="flex-1 min-w-0"
               onClick={() => {
                 const filename = archive.print_name || archive.filename || 'model';
-                const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
-                openInSlicer(downloadUrl, preferredSlicer);
+                openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
               }}
               title={t('archives.card.openInBambuStudioToSlice')}
             >
@@ -1546,8 +1571,7 @@ function ArchiveListRow({
         icon: <ExternalLink className="w-4 h-4" />,
         onClick: () => {
           const filename = archive.print_name || archive.filename || 'model';
-          const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
-          openInSlicer(downloadUrl, preferredSlicer);
+          openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
         },
         disabled: !archive.file_path,
         title: !archive.file_path ? t('archives.card.noFileForReprint') : undefined,
@@ -1558,8 +1582,7 @@ function ArchiveListRow({
         icon: <ExternalLink className="w-4 h-4" />,
         onClick: () => {
           const filename = archive.print_name || archive.filename || 'model';
-          const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
-          openInSlicer(downloadUrl, preferredSlicer);
+          openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
         },
       },
     ]),
@@ -1900,8 +1923,7 @@ function ArchiveListRow({
             size="sm"
             onClick={() => {
               const filename = archive.print_name || archive.filename || 'model';
-              const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
-              openInSlicer(downloadUrl, preferredSlicer);
+              openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
             }}
             title={t('archives.card.openInBambuStudio')}
           >

+ 30 - 10
frontend/src/utils/slicer.ts

@@ -1,12 +1,23 @@
 /**
  * Utility for opening files in slicer applications
  *
- * Bambu Studio URL protocol is OS-specific:
- * - Windows: bambustudio://<encoded-URL>
- * - macOS/Linux: bambustudioopen://<encoded-URL>
+ * Protocol handler URL formats (from BambuStudio/OrcaSlicer source code):
  *
- * OrcaSlicer uses the same protocol on all platforms:
- * - orcaslicer://open?file=<URL>
+ * Bambu Studio has TWO separate URL handlers:
+ *   1. post_init() [Windows/Linux CLI args]: bambustudio://open?file=<URL>
+ *      - Checks: starts_with("bambustudio://open")
+ *      - Calls url_decode(), then split_str(url, "file=")
+ *   2. MacOpenURL() [macOS Apple Events]: bambustudioopen://<encoded-URL>
+ *      - Checks: starts_with("bambustudioopen://")
+ *      - Strips prefix, then url_decode()
+ *
+ * OrcaSlicer Downloader accepts both formats via regex:
+ *   - (orcaslicer|bambustudio|...)://open?file=<URL>
+ *   - bambustudioopen://<URL>
+ *
+ * Key insight: Using ?file= query format, the browser's URL parser preserves
+ * http:// in the query string without any encoding. Only the macOS-specific
+ * bambustudioopen:// format needs encodeURIComponent (BS calls url_decode).
  */
 
 export type SlicerType = 'bambu_studio' | 'orcaslicer';
@@ -34,8 +45,6 @@ export function detectPlatform(): Platform {
 
 /**
  * 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)
  */
@@ -43,15 +52,26 @@ export function openInSlicer(downloadUrl: string, slicer: SlicerType = 'bambu_st
   let url: string;
 
   if (slicer === 'orcaslicer') {
+    // OrcaSlicer: ?file= query format — http:// preserved in query string
     url = `orcaslicer://open?file=${downloadUrl}`;
   } else {
     const platform = detectPlatform();
-    const protocol = platform === 'windows' ? 'bambustudio' : 'bambustudioopen';
-    url = `${protocol}://${encodeURIComponent(downloadUrl)}`;
+    if (platform === 'macos') {
+      // macOS only: bambustudioopen scheme via MacOpenURL() callback.
+      // Must encode because bare http:// in authority gets mangled by browser.
+      // BS calls url_decode() after stripping "bambustudioopen://" prefix.
+      url = `bambustudioopen://${encodeURIComponent(downloadUrl)}`;
+    } else {
+      // Windows/Linux: bambustudio://open?file= via post_init() CLI args.
+      // The ?file= query format preserves http:// without encoding.
+      // IMPORTANT: On Linux, BS only handles "bambustudio://open" prefix —
+      // it does NOT process "bambustudioopen://" (that's macOS-only).
+      url = `bambustudio://open?file=${downloadUrl}`;
+    }
   }
 
   // Use a temporary <a> element to trigger the protocol handler.
-  // This works more reliably than window.location.href for custom protocols.
+  // This avoids navigating away from the page (unlike window.location.href).
   const link = document.createElement('a');
   link.href = url;
   link.style.display = 'none';

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/index-BPE9r2Ne.js


+ 1 - 1
static/index.html

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

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor