slicer.ts 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. /**
  2. * Utility for opening files in slicer applications
  3. *
  4. * Protocol handler URL formats (from BambuStudio/OrcaSlicer source code):
  5. *
  6. * Bambu Studio has TWO separate URL handlers:
  7. * 1. post_init() [Windows/Linux CLI args]: bambustudio://open?file=<URL>
  8. * - Checks: starts_with("bambustudio://open")
  9. * - Calls url_decode(), then split_str(url, "file=")
  10. * 2. MacOpenURL() [macOS Apple Events]: bambustudioopen://<encoded-URL>
  11. * - Checks: starts_with("bambustudioopen://")
  12. * - Strips prefix, then url_decode()
  13. *
  14. * OrcaSlicer Downloader accepts both formats via regex:
  15. * - (orcaslicer|bambustudio|...)://open?file=<URL>
  16. * - bambustudioopen://<URL>
  17. *
  18. * Key insight: every form needs encodeURIComponent on the file URL, because
  19. * the slicer calls url_decode() on the received query (post_init calls
  20. * url_decode then split_str; MacOpenURL strips the prefix then url_decode;
  21. * OrcaSlicer's Downloader regex-extracts then url_decode). Without encoding,
  22. * any already-percent-encoded character in the download URL (most commonly
  23. * %20 in filenames with spaces) decodes to a literal space and the slicer's
  24. * subsequent HTTP fetch fails with a 0-byte body or 404. See issue #1059.
  25. */
  26. export type SlicerType = 'bambu_studio' | 'orcaslicer';
  27. type Platform = 'windows' | 'macos' | 'linux' | 'unknown';
  28. /**
  29. * Resolve the desktop "Open in Slicer" target. Prefers an explicit
  30. * `open_in_slicer` override (#1329), then falls back to the API slicer's
  31. * `preferred_slicer`, then Bambu Studio. This is ONLY the URI-handoff target;
  32. * the in-app SliceModal keeps using `preferred_slicer` for the sidecar.
  33. */
  34. export function resolveDesktopSlicer(
  35. openInSlicer?: SlicerType | null,
  36. preferredSlicer?: SlicerType,
  37. ): SlicerType {
  38. return openInSlicer ?? preferredSlicer ?? 'bambu_studio';
  39. }
  40. /**
  41. * File types a slicer can be handed — both by the desktop URI handler and by
  42. * the in-app sidecar. Source geometry only: a sliced file is an output, and
  43. * neither slicer has anything to do with one.
  44. *
  45. * Lives here rather than beside either caller because both the File Manager
  46. * (which has a filename) and the 3D preview (which has a `LibraryFile.file_type`)
  47. * decide the same thing about the same file. They used to hold separate lists,
  48. * and the two disagreed — a card menu offered a desktop handoff for an STL
  49. * whose own 3D preview showed "Open in Slicer" greyed out.
  50. */
  51. export const SLICEABLE_FILE_TYPES = ['3mf', 'stl', 'step', 'stp'] as const;
  52. /**
  53. * Does a `LibraryFile.file_type` name a sliceable source file?
  54. *
  55. * The backend stores compound extensions whole — a sliced 3MF classifies as
  56. * `gcode.3mf`, not `3mf` (`classify_file_type` in `api/routes/library.py`) — so
  57. * membership alone is enough to exclude sliced output here.
  58. */
  59. export function isSliceableFileType(fileType?: string | null): boolean {
  60. const normalized = (fileType || '').toLowerCase();
  61. return (SLICEABLE_FILE_TYPES as readonly string[]).includes(normalized);
  62. }
  63. /**
  64. * Does a filename name a sliceable source file?
  65. *
  66. * Checked against the name rather than a stored type, so the compound
  67. * extensions have to be ruled out explicitly: `.gcode.3mf` ends with `.3mf`.
  68. */
  69. export function isSliceableFilename(filename: string): boolean {
  70. const lower = filename.toLowerCase();
  71. if (lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf')) return false;
  72. return SLICEABLE_FILE_TYPES.some((ext) => lower.endsWith(`.${ext}`));
  73. }
  74. /**
  75. * Detect the user's operating system
  76. */
  77. export function detectPlatform(): Platform {
  78. const userAgent = navigator.userAgent.toLowerCase();
  79. const platform = navigator.platform?.toLowerCase() || '';
  80. if (userAgent.includes('win') || platform.includes('win')) {
  81. return 'windows';
  82. }
  83. if (userAgent.includes('mac') || platform.includes('mac')) {
  84. return 'macos';
  85. }
  86. if (userAgent.includes('linux') || platform.includes('linux')) {
  87. return 'linux';
  88. }
  89. return 'unknown';
  90. }
  91. /**
  92. * Open a URL in the specified slicer application.
  93. * @param downloadUrl - The URL to the file to open
  94. * @param slicer - Which slicer to use (defaults to bambu_studio)
  95. */
  96. export function openInSlicer(downloadUrl: string, slicer: SlicerType = 'bambu_studio'): void {
  97. let url: string;
  98. const encoded = encodeURIComponent(downloadUrl);
  99. if (slicer === 'orcaslicer') {
  100. url = `orcaslicer://open?file=${encoded}`;
  101. } else {
  102. const platform = detectPlatform();
  103. if (platform === 'macos') {
  104. // macOS only: bambustudioopen scheme via MacOpenURL() callback.
  105. url = `bambustudioopen://${encoded}`;
  106. } else {
  107. // Windows/Linux: bambustudio://open?file= via post_init() CLI args.
  108. // IMPORTANT: On Linux, BS only handles "bambustudio://open" prefix —
  109. // it does NOT process "bambustudioopen://" (that's macOS-only).
  110. url = `bambustudio://open?file=${encoded}`;
  111. }
  112. }
  113. // Use a temporary <a> element to trigger the protocol handler.
  114. // This avoids navigating away from the page (unlike window.location.href).
  115. const link = document.createElement('a');
  116. link.href = url;
  117. link.style.display = 'none';
  118. document.body.appendChild(link);
  119. link.click();
  120. document.body.removeChild(link);
  121. }
  122. /**
  123. * Build a full download URL for a file
  124. * @param path - The API path (e.g., from api.getArchiveForSlicer())
  125. */
  126. export function buildDownloadUrl(path: string): string {
  127. return `${window.location.origin}${path}`;
  128. }
  129. /**
  130. * Convenience function to open an archive in the slicer
  131. * @param path - The API path to the archive
  132. * @param slicer - Which slicer to use (defaults to bambu_studio)
  133. */
  134. export function openArchiveInSlicer(path: string, slicer: SlicerType = 'bambu_studio'): void {
  135. const downloadUrl = buildDownloadUrl(path);
  136. openInSlicer(downloadUrl, slicer);
  137. }