mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
Merge branch '0.2.1b' into refactor-all-pages
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -177,7 +177,7 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
|
||||
<p className="text-xs text-bambu-gray">
|
||||
{spool.color_name || ''}
|
||||
{spool.label_weight ? ` - ${spool.label_weight}g` : ''}
|
||||
{spool.weight_used > 0 ? ` (${Math.round(spool.weight_used)}g used)` : ''}
|
||||
{spool.label_weight ? ` (${Math.max(0, Math.round(spool.label_weight - spool.weight_used))}g ${t('ams.remainingUnit')})` : ''}
|
||||
</p>
|
||||
</div>
|
||||
{selectedSpoolId === spool.id && (
|
||||
|
||||
@@ -24,7 +24,7 @@ interface SpoolmanConfig {
|
||||
interface InventoryConfig {
|
||||
onAssignSpool?: () => void;
|
||||
onUnassignSpool?: () => void;
|
||||
assignedSpool?: { id: number; material: string; brand: string | null; color_name: string | null } | null;
|
||||
assignedSpool?: { id: number; material: string; brand: string | null; color_name: string | null; remainingWeightGrams?: number | null } | null;
|
||||
}
|
||||
|
||||
interface ConfigureSlotConfig {
|
||||
@@ -148,6 +148,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
|
||||
};
|
||||
|
||||
const colorHex = data.colorHex ? `#${data.colorHex.replace('#', '')}` : null;
|
||||
const assignedRemainingWeight = inventory?.assignedSpool?.remainingWeightGrams ?? null;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -238,7 +239,10 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
|
||||
{t('ams.fill')}
|
||||
</span>
|
||||
<span className="text-xs text-white font-semibold flex items-center gap-1">
|
||||
{data.fillLevel !== null ? `${data.fillLevel}%` : '—'}
|
||||
<span>{data.fillLevel !== null ? `${data.fillLevel}%` : '—'}</span>
|
||||
{assignedRemainingWeight !== null && data.fillLevel !== null && (
|
||||
<span className="text-[9px] text-bambu-gray font-normal">• {assignedRemainingWeight}g</span>
|
||||
)}
|
||||
{data.fillSource === 'spoolman' && data.fillLevel !== null && (
|
||||
<span className="text-[9px] text-bambu-gray font-normal">{t('spoolman.fillSourceLabel')}</span>
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -320,7 +320,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
|
||||
if (!validation.isValid) {
|
||||
setErrors(validation.errors);
|
||||
// Switch to filament tab if there are errors there
|
||||
if (validation.errors.slicer_filament || validation.errors.material) {
|
||||
if (validation.errors.slicer_filament || validation.errors.material || validation.errors.brand || validation.errors.subtype) {
|
||||
setActiveTab('filament');
|
||||
}
|
||||
return;
|
||||
@@ -438,6 +438,12 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
|
||||
{errors.material && (
|
||||
<p className="mt-1 text-xs text-red-400">{errors.material}</p>
|
||||
)}
|
||||
{errors.brand && (
|
||||
<p className="mt-1 text-xs text-red-400">{errors.brand}</p>
|
||||
)}
|
||||
{errors.subtype && (
|
||||
<p className="mt-1 text-xs text-red-400">{errors.subtype}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Color Section */}
|
||||
|
||||
@@ -161,7 +161,7 @@ export function FilamentSection({
|
||||
|
||||
{/* Brand (dropdown with search) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.brand')}</label>
|
||||
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.brand')} *</label>
|
||||
<div className="relative" ref={brandRef}>
|
||||
<input
|
||||
type="text"
|
||||
@@ -221,7 +221,7 @@ export function FilamentSection({
|
||||
|
||||
{/* Variant / Subtype */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.subtype')}</label>
|
||||
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.subtype')} *</label>
|
||||
<div className="relative" ref={subtypeRef}>
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@@ -117,6 +117,14 @@ export function validateForm(formData: SpoolFormData): ValidationResult {
|
||||
errors.material = 'Material is required';
|
||||
}
|
||||
|
||||
if (!formData.brand) {
|
||||
errors.brand = 'Brand is required';
|
||||
}
|
||||
|
||||
if (!formData.subtype) {
|
||||
errors.subtype = 'Subtype is required';
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: Object.keys(errors).length === 0,
|
||||
errors,
|
||||
|
||||
@@ -8,6 +8,7 @@ import de from './locales/de';
|
||||
import fr from './locales/fr';
|
||||
import ja from './locales/ja';
|
||||
import it from './locales/it';
|
||||
import ptBR from './locales/pt-BR';
|
||||
|
||||
const resources = {
|
||||
en: { translation: en },
|
||||
@@ -15,6 +16,7 @@ const resources = {
|
||||
fr: { translation: fr },
|
||||
ja: { translation: ja },
|
||||
it: { translation: it },
|
||||
'pt-BR': { translation: ptBR },
|
||||
};
|
||||
|
||||
i18n
|
||||
@@ -23,7 +25,7 @@ i18n
|
||||
.init({
|
||||
resources,
|
||||
fallbackLng: 'en',
|
||||
supportedLngs: ['en', 'de', 'fr', 'ja', 'it'],
|
||||
supportedLngs: ['en', 'de', 'fr', 'ja', 'it', 'pt-BR'],
|
||||
|
||||
detection: {
|
||||
// Order of detection methods
|
||||
@@ -52,4 +54,5 @@ export const availableLanguages = [
|
||||
{ code: 'fr', name: 'French', nativeName: 'Français' },
|
||||
{ code: 'ja', name: 'Japanese', nativeName: '日本語' },
|
||||
{ code: 'it', name: 'Italian', nativeName: 'Italiano' },
|
||||
{ code: 'pt-BR', name: 'Portuguese (Brazil)', nativeName: 'Português (Brasil)' },
|
||||
];
|
||||
|
||||
@@ -2682,6 +2682,8 @@ export default {
|
||||
kFactor: 'K-Faktor',
|
||||
fill: 'Füllstand',
|
||||
configure: 'Konfigurieren',
|
||||
used: 'verwendet',
|
||||
remainingUnit: 'verbleibend',
|
||||
},
|
||||
|
||||
// Print modal
|
||||
|
||||
@@ -2686,6 +2686,8 @@ export default {
|
||||
kFactor: 'K Factor',
|
||||
fill: 'Fill',
|
||||
configure: 'Configure',
|
||||
used: 'used',
|
||||
remainingUnit: 'remaining',
|
||||
},
|
||||
|
||||
// Print modal
|
||||
|
||||
@@ -2674,6 +2674,8 @@ export default {
|
||||
kFactor: 'Facteur K',
|
||||
fill: 'Remplir',
|
||||
configure: 'Configurer',
|
||||
used: 'utilisé',
|
||||
remainingUnit: 'restant',
|
||||
},
|
||||
|
||||
// Print modal
|
||||
|
||||
@@ -2347,7 +2347,7 @@ export default {
|
||||
kProfiles: 'K-Profiles',
|
||||
addKProfile: 'Aggiungi K-Profile',
|
||||
assignSpool: 'Assegna Bobina',
|
||||
unassignSpool: 'Deassegna',
|
||||
unassignSpool: 'Scollega',
|
||||
assignSuccess: 'Bobina assegnata e slot AMS configurato',
|
||||
assignFailed: 'Assegnazione bobina fallita',
|
||||
selectSpool: 'Seleziona una bobina da assegnare a questo slot',
|
||||
@@ -2397,8 +2397,10 @@ export default {
|
||||
externalSpool: 'Bobina esterna',
|
||||
profile: 'Profilo',
|
||||
kFactor: 'K Factor',
|
||||
fill: 'Riempi',
|
||||
fill: 'Livello',
|
||||
configure: 'Configura',
|
||||
used: 'utilizzato',
|
||||
remainingUnit: 'rimanente',
|
||||
},
|
||||
|
||||
// Print modal
|
||||
|
||||
@@ -2604,6 +2604,8 @@ export default {
|
||||
kFactor: 'K値',
|
||||
fill: '充填率',
|
||||
configure: '設定',
|
||||
used: '使用済み',
|
||||
remainingUnit: '残り',
|
||||
},
|
||||
printModal: {
|
||||
flowCalibration: 'フローキャリブレーション',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -100,6 +100,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,
|
||||
@@ -332,8 +362,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,
|
||||
@@ -344,8 +373,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);
|
||||
},
|
||||
},
|
||||
]),
|
||||
@@ -733,8 +761,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')}
|
||||
>
|
||||
@@ -1003,8 +1030,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')}
|
||||
>
|
||||
@@ -1019,8 +1045,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')}
|
||||
>
|
||||
@@ -1541,8 +1566,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,
|
||||
@@ -1553,8 +1577,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);
|
||||
},
|
||||
},
|
||||
]),
|
||||
@@ -1895,8 +1918,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')}
|
||||
>
|
||||
|
||||
@@ -2907,6 +2907,7 @@ function PrinterCard({
|
||||
material: assignment.spool.material,
|
||||
brand: assignment.spool.brand,
|
||||
color_name: assignment.spool.color_name,
|
||||
remainingWeightGrams: Math.max(0, Math.round(assignment.spool.label_weight - assignment.spool.weight_used)),
|
||||
} : null,
|
||||
onAssignSpool: filamentData.vendor !== 'Bambu Lab' ? () => setAssignSpoolModal({
|
||||
printerId: printer.id,
|
||||
@@ -3144,6 +3145,7 @@ function PrinterCard({
|
||||
material: assignment.spool.material,
|
||||
brand: assignment.spool.brand,
|
||||
color_name: assignment.spool.color_name,
|
||||
remainingWeightGrams: Math.max(0, Math.round(assignment.spool.label_weight - assignment.spool.weight_used)),
|
||||
} : null,
|
||||
onAssignSpool: filamentData.vendor !== 'Bambu Lab' ? () => setAssignSpoolModal({
|
||||
printerId: printer.id,
|
||||
@@ -3330,6 +3332,7 @@ function PrinterCard({
|
||||
material: assignment.spool.material,
|
||||
brand: assignment.spool.brand,
|
||||
color_name: assignment.spool.color_name,
|
||||
remainingWeightGrams: Math.max(0, Math.round(assignment.spool.label_weight - assignment.spool.weight_used)),
|
||||
} : null,
|
||||
onAssignSpool: () => setAssignSpoolModal({
|
||||
printerId: printer.id,
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user