-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathinstall.py
More file actions
2607 lines (2320 loc) · 109 KB
/
Copy pathinstall.py
File metadata and controls
2607 lines (2320 loc) · 109 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""Filanex filament profile installer for Bambu Studio.
Run from the unzipped bundle directory (alongside additions.json and
BBL/filament/).
Strictly additive design with first-class upgrade and uninstall support.
A tracking file at `system/.polymaker-install.json` records exactly which
files and BBL.json entries this installer owns, along with SHA-256 hashes
of each installed file. That ownership record is what makes upgrade
(replace ours, leave the rest) and uninstall (remove ours, leave the
rest) possible without ever touching anything that wasn't ours to touch.
Subcommands:
install Fresh install if not previously installed; upgrade if a
tracking file is present. Default if no subcommand given.
upgrade Same as install but errors if no previous install detected.
uninstall Remove every file and BBL.json entry recorded as ours.
Files modified since install (hash mismatch) are kept by
default; pass --force to delete anyway.
status Print current install state and exit. Read-only.
What every run does, regardless of subcommand:
1. Locates your Bambu Studio user-data system/ folder (per OS).
2. Detects whether Bambu Studio is running; asks you to close it.
3. Sanity-checks the target looks like a real Bambu Studio install.
4. Backs up the current BBL.json + BBL/filament/ to a timestamped
folder under system/ (skip with --no-backup).
Requires Python 3.9+. No external packages.
"""
from __future__ import annotations
import argparse
import datetime as dt
import hashlib
import json
import os
import platform
import shutil
import subprocess
import sys
import time
from pathlib import Path
# When run as a PyInstaller .exe, __file__ points at PyInstaller's
# temporary extraction dir (sys._MEIPASS), not where the user put the
# .exe. We need the latter -- the bundle's additions.json + BBL/filament/
# live next to the .exe, not next to the bootloader's unpack dir.
if getattr(sys, "frozen", False):
HERE = Path(sys.executable).resolve().parent
else:
HERE = Path(__file__).resolve().parent
TRACKING_FILENAME = ".polymaker-install.json"
TRACKING_TOOL = "polymaker-installer/1"
# Installer-binary version. Bumped when the installer .exe itself
# changes (new wizard pages, new install logic, bug fixes). Independent
# of the database version (which lives in VERSION at the repo root and
# bumps when chemistry changes). The wizard fetches additions.json from
# DISTRIBUTION_BASE_URL on startup and compares its `installer_version`
# field to this constant; if remote is newer, it offers a self-update.
# bundle_bbl_inject.py parses this constant out of install.py and stamps
# it into the additions.json it ships, so they always match per release.
INSTALLER_VERSION = "1.2.4"
# Stable URL for the `update` subcommand. Points at the BBL-injection
# bundle on the project's default branch via GitHub raw. Override with
# the POLYMAKER_DISTRIBUTION_URL env var (useful for local testing or
# a different hosting plan -- see question 4 in QUESTIONS_FOR_MIKE.md).
DISTRIBUTION_BASE_URL = os.environ.get(
"POLYMAKER_DISTRIBUTION_URL",
"https://raw.githubusercontent.com/Cstm3DBldr/Filanex"
"/main/install",
)
SYSTEM_DIR_DEFAULTS: dict[str, Path | None] = {
"Windows": (Path(os.environ["APPDATA"]) / "BambuStudio" / "system")
if os.environ.get("APPDATA") else None,
"Darwin": Path.home() / "Library" / "Application Support" / "BambuStudio" / "system",
"Linux": Path.home() / ".config" / "BambuStudio" / "system",
}
# Where the installer remembers the user's last picker selection so a
# repeat run starts pre-checked the same way (instead of forcing the
# user to uncheck the same lines every single time). Lives OUTSIDE
# Bambu Studio's system/ folder on purpose: uninstall + reinstall
# should preserve the user's last picks.
PREFS_DIR_DEFAULTS: dict[str, Path | None] = {
"Windows": (Path(os.environ["APPDATA"]) / "PolymakerInstaller")
if os.environ.get("APPDATA") else None,
"Darwin": Path.home() / "Library" / "Application Support" / "PolymakerInstaller",
"Linux": Path.home() / ".config" / "polymaker-installer",
}
PREFS_FILENAME = "picker-prefs.json"
PROCESS_NAMES = {
"Windows": ["bambu-studio.exe", "BambuStudio.exe", "Bambu Studio.exe"],
"Darwin": ["BambuStudio", "Bambu Studio"],
"Linux": ["bambu-studio", "BambuStudio"],
}
# ---------------------------------------------------------------------------
# Output helpers
# ---------------------------------------------------------------------------
def banner(msg: str) -> None:
line = "=" * max(len(msg), 60)
print(f"\n{line}\n{msg}\n{line}\n")
def section(msg: str) -> None:
print(f"\n--- {msg} ---")
# Throttle so a 16k-file install doesn't generate 16k stdout lines.
# Tighter than 50 -- want visible bar motion even when files are tiny
# JSONs that copy in <1ms each. 16k / 25 = 640 markers across an
# install, ~2 per Tk paint frame at 100ms polls. Visible motion.
_PROGRESS_EVERY_N = 25
def _emit_progress(verb: str, current: int, total: int) -> None:
"""Emit a structured progress marker. The wizard parses these out
of stdout to drive a determinate progress bar; CLI users just see
them in the log. Throttled to roughly every _PROGRESS_EVERY_N
items + always on the final tick so the bar always reaches 100%.
"""
if total <= 0:
return
if current == total or current % _PROGRESS_EVERY_N == 0 or current == 1:
print(f"[PROGRESS {verb}] {current}/{total}", flush=True)
def _emit_phase(label: str) -> None:
"""Emit a phase-change marker. Non-counted phases (loading the
bundle, filtering selection, writing BBL.json, updating the
slicer's enable list) still take perceptible time but have no
natural per-item progress. This marker lets the wizard at least
update the label so the user sees motion through each step.
Encoded as a degenerate [PROGRESS] marker with 0/0 so the GUI
parser recognizes it via the same regex but treats total=0 as
"label-only update, don't touch the bar percentage".
"""
print(f"[PROGRESS {label}] 0/0", flush=True)
# ---------------------------------------------------------------------------
# Hashing + JSON IO
# ---------------------------------------------------------------------------
def file_sha256(p: Path) -> str:
h = hashlib.sha256()
with open(p, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
h.update(chunk)
return h.hexdigest()
def atomic_write_json(path: Path, data: object) -> None:
"""Write JSON via temp file + os.replace so a crash mid-write can't
leave the target half-written."""
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(data, indent=4) + "\n", encoding="utf-8")
os.replace(tmp, path)
# ---------------------------------------------------------------------------
# Environment detection
# ---------------------------------------------------------------------------
def detect_system_dir(override: Path | None) -> Path:
if override is not None:
return override.expanduser().resolve()
osname = platform.system()
p = SYSTEM_DIR_DEFAULTS.get(osname)
if p is None:
sys.exit(f"Unsupported OS: {osname}. Pass --system-dir to override.")
return p
def detect_user_printers(system_dir: Path) -> set[str]:
"""Detect which Bambu printers the user has configured / actively
uses in Bambu Studio. Returns a set of canonical printer names like
{"H2C", "X1 Carbon", "A1 mini"} -- empty set if detection fails or
the user has no clear printer preference.
Three signals, in order of authority:
1. BambuStudio.conf field `user_last_selected_machine` -- the
printer the user most recently picked in the slicer UI.
2. BambuStudio.conf field `machine` -- the printer in the active
project.
3. Existing filament profiles in <system_dir>/BBL/filament/ that
reference specific printers in their compatible_printers list.
The detection feeds the install pipeline's printer filter -- if the
user has only an H2C, we won't install filament leaves whose
compatible_printers is exclusively for other printers (eliminating
the "Unsupported" flood in the slicer's filament dropdown).
Returns an empty set if we can't make a confident determination;
the caller should treat that as "install for all printers" (current
behavior pre-filter).
"""
found: set[str] = set()
# system_dir is typically <appdata>/BambuStudio/system; the conf
# lives one level up.
conf_path = system_dir.parent / "BambuStudio.conf"
if conf_path.exists():
try:
text = conf_path.read_text(encoding="utf-8", errors="ignore")
except Exception:
text = ""
# Parse simple JSON-style "key": "value" pairs we care about.
# The conf is a JSON object but we only need a couple of fields,
# so regex-grep is more resilient to malformed input than full
# json.loads.
import re as _re
for key in ("user_last_selected_machine", "machine"):
m = _re.search(rf'"{key}"\s*:\s*"([^"]+)"', text)
if m:
# Value might be a machine GUID (printer-side internal
# ID) or a human name like "Bambu Lab H2C 0.4 nozzle".
# We only care about the human names.
val = m.group(1).strip()
if val.startswith("Bambu Lab "):
# Strip "Bambu Lab " prefix and " <nozzle> nozzle" suffix
# to get the canonical printer name (e.g. "H2C", "X1 Carbon").
body = val[len("Bambu Lab "):].strip()
body = _re.sub(r"\s+\d\.\d nozzle$", "", body).strip()
if body:
found.add(body)
# Also harvest any printer names referenced in filament-list-additions
# tracking file (printers the user has installed Filanex profiles for).
# This handles the multi-printer case where the user added profiles
# for several printers across past install runs.
tracking = system_dir / TRACKING_FILENAME
if tracking.exists():
try:
t = json.loads(tracking.read_text(encoding="utf-8"))
for entry in t.get("entries", []):
name = entry.get("name", "")
# Names look like "<line> @Bambu Lab <printer> <nozzle> nozzle"
# or "<line> @BBL <short> <nozzle> nozzle".
import re as _re
m = _re.search(r"@Bambu Lab (.+?) \d\.\d nozzle$", name)
if m:
found.add(m.group(1).strip())
else:
m = _re.search(r"@BBL ([A-Za-z0-9]+) \d\.\d nozzle$", name)
if m:
# Short name -> canonical (e.g. X1C -> "X1 Carbon",
# H2DP -> "H2D Pro"). Hardcoded a few common
# abbreviations; unknowns get passed through.
short = m.group(1)
canonical = {
"X1C": "X1 Carbon",
"H2DP": "H2D Pro",
"A1M": "A1 mini",
}.get(short, short)
found.add(canonical)
except Exception:
pass
return found
def printer_matches_entry(entry_name: str, allowed_printers: set[str]) -> bool:
"""Return True if entry_name's printer (parsed from "@Bambu Lab X"
or "@BBL X" suffix) is in allowed_printers, OR the entry is a @base
(no printer-specific binding) OR allowed_printers is empty (filter
disabled).
Handles both naming conventions:
"Polymaker PA12-CF @BBL H2C 0.4 nozzle" -> printer="H2C"
"Fiberon PA12-CF10 @Bambu Lab H2C 0.4 nozzle" -> printer="H2C"
"""
if not allowed_printers:
return True # filter disabled
if " @base" in entry_name:
return True # @base files always kept; leaves anchor them
import re as _re
# "@Bambu Lab <printer> <nozzle> nozzle"
m = _re.search(r"@Bambu Lab (.+?) \d\.\d nozzle$", entry_name)
if m:
return m.group(1).strip() in allowed_printers
# "@BBL <short> <nozzle> nozzle"
m = _re.search(r"@BBL ([A-Za-z0-9]+) \d\.\d nozzle$", entry_name)
if m:
short = m.group(1)
canonical = {
"X1C": "X1 Carbon",
"H2DP": "H2D Pro",
"A1M": "A1 mini",
}.get(short, short)
return canonical in allowed_printers or short in allowed_printers
return True # unparseable -- keep to be safe
def find_bambu_process() -> tuple[bool, Path | None]:
osname = platform.system()
names = PROCESS_NAMES.get(osname, [])
# On Windows, every subprocess.run from a --windowed (no-console)
# PyInstaller .exe pops a brief cmd window. With this code being
# called repeatedly from the wizard's preflight step (3+ subprocesses
# per check, often called twice in a row), the screen flashes hard
# enough to be a real photosensitive-epilepsy hazard. CREATE_NO_WINDOW
# (0x08000000) suppresses the console window for the spawned
# subprocess.
nw_flags = 0x08000000 if osname == "Windows" else 0
if osname == "Windows":
for name in names:
base = name.removesuffix(".exe")
ps = (
f"(Get-Process -Name '{base}' -ErrorAction SilentlyContinue | "
f"Select-Object -First 1).Path"
)
r = subprocess.run(
["powershell", "-NoProfile", "-Command", ps],
capture_output=True, text=True,
creationflags=nw_flags,
)
path = r.stdout.strip()
if path:
return True, Path(path)
r = subprocess.run(
["tasklist", "/FI", f"IMAGENAME eq {name}", "/NH"],
capture_output=True, text=True,
creationflags=nw_flags,
)
if name.lower() in r.stdout.lower():
return True, None
return False, None
if osname == "Darwin":
for name in names:
r = subprocess.run(
["pgrep", "-x", name], capture_output=True, text=True,
)
if r.returncode == 0:
return True, None
return False, None
if osname == "Linux":
for name in names:
r = subprocess.run(
["pgrep", "-x", name], capture_output=True, text=True,
)
if r.returncode == 0:
pid = r.stdout.strip().splitlines()[0]
exe = Path(f"/proc/{pid}/exe")
if exe.exists():
try:
return True, exe.resolve()
except OSError:
return True, None
return True, None
return False, None
return False, None
def find_bambu_install_path() -> Path | None:
"""Locate Bambu Studio's executable WITHOUT requiring it to be
running. Used so the wizard's Re-launch Bambu Studio button works
even when Bambu wasn't already open at install time (the normal
flow: user closes Bambu, runs installer, expects to launch it
after).
Returns None if no install can be located -- caller should hide
the button or message the user to launch Bambu manually.
"""
osname = platform.system()
if osname == "Windows":
candidates: list[Path] = []
# Standard Program Files installs
for pf in (
os.environ.get("ProgramFiles"),
os.environ.get("ProgramFiles(x86)"),
os.environ.get("LOCALAPPDATA"),
):
if not pf:
continue
base = Path(pf)
for subdir in (
base / "Bambu Studio" / "bambu-studio.exe",
base / "BambuStudio" / "bambu-studio.exe",
base / "Programs" / "Bambu Studio" / "bambu-studio.exe",
base / "Programs" / "BambuStudio" / "bambu-studio.exe",
):
candidates.append(subdir)
# Windows registry: HKLM Uninstall key for any "Bambu Studio"
# entry's InstallLocation. PowerShell because the stdlib's
# winreg requires extra plumbing we don't need here.
try:
ps = (
"Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*', "
"'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*' "
"-ErrorAction SilentlyContinue | "
"Where-Object { $_.DisplayName -like '*Bambu Studio*' } | "
"Select-Object -ExpandProperty InstallLocation -First 1"
)
r = subprocess.run(
["powershell", "-NoProfile", "-Command", ps],
capture_output=True, text=True, timeout=5,
creationflags=0x08000000,
)
loc = r.stdout.strip()
if loc:
candidates.append(Path(loc) / "bambu-studio.exe")
except Exception:
pass
for c in candidates:
try:
if c.is_file():
return c.resolve()
except OSError:
continue
return None
if osname == "Darwin":
for p in (
Path("/Applications/BambuStudio.app/Contents/MacOS/BambuStudio"),
Path("/Applications/Bambu Studio.app/Contents/MacOS/Bambu Studio"),
):
if p.is_file():
return p
return None
# Linux: best-effort, common install locations + which()
if osname == "Linux":
for name in ("bambu-studio", "BambuStudio"):
r = subprocess.run(["which", name], capture_output=True, text=True)
if r.returncode == 0 and r.stdout.strip():
return Path(r.stdout.strip())
for p in (
Path("/usr/bin/bambu-studio"),
Path("/usr/local/bin/bambu-studio"),
Path("/opt/BambuStudio/bambu-studio"),
):
if p.is_file():
return p
return None
def wait_for_close() -> None:
while True:
input("Press Enter once Bambu Studio is fully closed... ")
time.sleep(1)
running, _ = find_bambu_process()
if not running:
print("Confirmed closed.")
return
print("Bambu Studio still appears to be running. Close it and try again.")
# ---------------------------------------------------------------------------
# Sanity checks
# ---------------------------------------------------------------------------
def sanity_check_target(system_dir: Path) -> None:
if not system_dir.exists():
sys.exit(
f"\nERROR: System folder doesn't exist:\n {system_dir}\n"
f"\nHas Bambu Studio been launched at least once on this account?"
)
if not system_dir.is_dir():
sys.exit(f"\nERROR: Not a directory: {system_dir}")
bbl_json = system_dir / "BBL.json"
bbl_dir = system_dir / "BBL"
if not bbl_json.exists() or not bbl_dir.is_dir():
sys.exit(
f"\nERROR: {system_dir} doesn't look like a Bambu Studio system\n"
f"folder. Expected to find BBL.json and a BBL/ folder inside."
)
def sanity_check_source(need_bundle: bool) -> tuple[Path | None, Path | None]:
"""Returns (additions_path, filament_dir). Both None if uninstall mode
and bundle isn't needed."""
if not need_bundle:
return None, None
src_additions = HERE / "additions.json"
src_filament_dir = HERE / "BBL" / "filament"
if not src_additions.exists():
sys.exit(
f"ERROR: Bundle is missing additions.json. Expected at:\n"
f" {src_additions}\n"
f"Run this from inside the unzipped bundle directory."
)
if not src_filament_dir.is_dir():
sys.exit(
f"ERROR: Bundle is missing BBL/filament/. Expected at:\n"
f" {src_filament_dir}"
)
return src_additions, src_filament_dir
# ---------------------------------------------------------------------------
# Backup
# ---------------------------------------------------------------------------
# Number of timestamped _backup-* folders kept in system/. Older ones
# get pruned automatically after each new backup so they don't pile up.
BACKUP_RETENTION = 5
def back_up(system_dir: Path) -> Path:
ts = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
backup_dir = system_dir / f"_backup-{ts}"
backup_dir.mkdir()
bbl_json = system_dir / "BBL.json"
if bbl_json.exists():
shutil.copy2(bbl_json, backup_dir / "BBL.json")
filament_dir = system_dir / "BBL" / "filament"
if filament_dir.exists():
# Instrumented per-file copy instead of shutil.copytree so we
# can emit progress markers. With 16k existing profile JSONs
# this loop is the BIGGEST silent freeze in an install --
# without progress here the bar appears stuck before the
# main file-write loop even starts.
dst_filament = backup_dir / "BBL" / "filament"
dst_filament.mkdir(parents=True)
all_files = [p for p in filament_dir.rglob("*") if p.is_file()]
total = len(all_files)
for i, src in enumerate(all_files, 1):
rel = src.relative_to(filament_dir)
dst = dst_filament / rel
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
_emit_progress("Backing up existing", i, total)
tracking = system_dir / TRACKING_FILENAME
if tracking.exists():
shutil.copy2(tracking, backup_dir / TRACKING_FILENAME)
_prune_old_backups(system_dir)
return backup_dir
def _prune_old_backups(system_dir: Path, keep: int = BACKUP_RETENTION) -> None:
"""Keep the newest `keep` _backup-* folders; delete older ones.
The folder name's YYYYMMDD-HHMMSS timestamp sorts lexicographically
the same as chronologically, so a string sort is enough."""
backups = sorted(
(p for p in system_dir.iterdir()
if p.is_dir() and p.name.startswith("_backup-")),
key=lambda p: p.name,
reverse=True,
)
pruned = 0
for old in backups[keep:]:
try:
shutil.rmtree(old)
pruned += 1
except OSError as e:
print(f" WARN: couldn't remove old backup {old.name}: {e}")
if pruned:
print(f" Pruned {pruned} old backup(s); keeping the newest "
f"{min(len(backups), keep)}.")
# Vendors whose Bambu-Studio-shipped legacy subfolder files we sweep
# during install. Bambu Studio's filament folder contains both:
# - Top-level files: <line> @Bambu Lab <printer> <nozzle> nozzle.json
# (new naming, what we overlay)
# - Vendor subfolders: BBL/filament/<vendor>/<line> @BBL <short>.json
# (Bambu's older naming, predates the @Bambu Lab convention)
#
# The subfolder legacy files persist in user installs and pollute the
# slicer's dropdown with "Unsupported" entries on printers they
# weren't tested for (typically X1C and H2D only -- so on H2C they all
# appear unsupported). We disable them by renaming .json -> .filanex-
# disabled (rather than deleting outright) so the user can restore
# manually if Bambu's profile-sync re-adds them or if needed for
# debugging.
LEGACY_SUBFOLDER_VENDORS = {"Polymaker"}
def disable_bambu_legacy_subfolders(system_dir: Path) -> int:
"""Walk BBL/filament/<vendor>/ for vendors in LEGACY_SUBFOLDER_VENDORS
and rename every *.json -> *.filanex-disabled. Bambu Studio's
profile loader only loads .json files so the renamed ones become
invisible. Returns the count of files disabled."""
filament_dir = system_dir / "BBL" / "filament"
if not filament_dir.exists():
return 0
disabled = 0
for vendor in LEGACY_SUBFOLDER_VENDORS:
vdir = filament_dir / vendor
if not vdir.is_dir():
continue
for f in vdir.iterdir():
if not f.is_file() or f.suffix != ".json":
continue
new_path = f.with_suffix(".filanex-disabled")
try:
f.rename(new_path)
disabled += 1
except OSError as e:
print(f" WARN: couldn't disable legacy {f.name}: {e}")
return disabled
# Top-level Bambu stock Polymaker files we DON'T overlay (different
# printer/nozzle combos or SKUs we don't have) get renamed to
# .filanex-bambu-backup at install time. This eliminates all setting_id
# collision risk in Bambu Studio's GFSL-prefix namespace: with all
# Bambu stock Polymaker files disabled, the only profiles in that
# namespace are ours, so the slicer can't accidentally hide our leaves
# via dedupe-by-setting_id.
#
# Restoration: rename .filanex-bambu-backup -> .json (manual or on
# uninstall).
BACKUP_EXT = ".filanex-bambu-backup"
# Polymaker brand prefixes used to detect Bambu's stock Polymaker files
# by filename (Bambu's stock LEAVES often have filament_vendor=None
# because it's set only at @base level; matching the @base/leaf chain
# is fragile, so we match by filename brand prefix instead, which
# Bambu has been consistent about).
POLYMAKER_BRAND_PREFIXES = (
"PolyLite ", "PolyTerra ", "Polymaker ", "Panchroma ", "Fiberon ",
"PolyFlex ", "PolyMax ", "PolyMide ", "PolySmooth", "PolySupport",
"PolyCast", "PolyDissolve", "PolySonic ", "PolyWood",
)
# Vendor -> brand prefixes for the full Bambu-stock wipe. When the
# user ticks a vendor in the picker, every Bambu stock file matching
# one of that vendor's brand prefixes (root + subfolder, @base + leaf)
# gets renamed to .json.filanex-bambu-backup. Bambu's stock disappears
# from the picker; only our overlay remains. Uninstall reverses every
# backup.
#
# Vendor key here MUST match the vendor field in our bundle entries
# (the same field the picker uses to track selection). Brand prefixes
# match how Bambu names their files -- different sub-brands like
# PolyLite and PolyTerra both live under the "Polymaker" picker vendor.
VENDOR_TO_BAMBU_BRAND_PREFIXES: dict[str, tuple[str, ...]] = {
"Polymaker": POLYMAKER_BRAND_PREFIXES,
"Overture": ("Overture ",),
"SUNLU": ("SUNLU ",),
"eSUN": ("eSUN ",),
}
def restore_disabled_bambu_legacy_subfolders(system_dir: Path) -> int:
"""Reverse disable_bambu_legacy_subfolders by renaming
.filanex-disabled files back to .json in BBL/filament/<vendor>/
subfolders. Used on uninstall to leave Bambu's stock state intact.
Returns count restored."""
filament_dir = system_dir / "BBL" / "filament"
if not filament_dir.exists():
return 0
restored = 0
for vendor in LEGACY_SUBFOLDER_VENDORS:
vdir = filament_dir / vendor
if not vdir.is_dir():
continue
for f in vdir.iterdir():
if not f.is_file() or f.suffix != ".filanex-disabled":
continue
target = f.with_suffix(".json")
if target.exists():
# Original already exists (e.g. Bambu's profile-sync
# re-downloaded); delete the disabled copy to avoid
# duplicate confusion.
try:
f.unlink()
except OSError:
pass
continue
try:
f.rename(target)
restored += 1
except OSError as e:
print(f" WARN: couldn't restore legacy {f.name}: {e}")
return restored
def backup_bambu_stock_for_picked_vendors(
filament_dir: Path,
our_filenames: set[str],
picked_vendors: set[str],
) -> tuple[int, int]:
"""Wholesale Bambu-stock removal for every vendor the user ticked
in the picker. Scans BBL/filament/ (root AND every subfolder) for
.json files matching one of the brand prefixes mapped to the
picked vendors, renaming each to .json.filanex-bambu-backup.
Why: Bambu's stock files for vendors we overlay (Polymaker,
Overture, SUNLU, eSUN) ship aimed at specific printers (mostly
H2D / X1C / X1E / H2D Pro / H2S). They show up as "Unsupported"
entries in the picker for any printer the file's
compatible_printers list doesn't include. Our overlay covers
every printer comprehensively, so leaving Bambu's stock in place
just creates Unsupported noise. User explicitly asked: "the
installer should look for the stock installed files remove them
and install our own."
Granularity: VENDOR-level. Tick Polymaker -> back up every
PolyLite / PolyTerra / Fiberon / Panchroma / Polymaker* /
PolyMax / PolyMide / etc. file. Don't tick Polymaker -> Bambu's
stock stays in place untouched (you keep their broken-on-H2C
files, you fix it yourself via Bambu Studio).
Skip rules:
- Files OUR overlay writes at the same filename get overwritten
during the file-write step -- no backup needed.
- Files in folders starting with "_" (system reserved).
- Subfolders are walked too, since Bambu ships some legacy
Polymaker files under BBL/filament/Polymaker/.
Restoration: restore_all_bambu_polymaker_backups (walks the
same dirs looking for .filanex-bambu-backup) renames them all
back to .json on uninstall.
Returns (backed_up_count, vendors_skipped_no_pick)."""
if not filament_dir.exists():
return (0, 0)
# Build the set of brand prefixes we should target this run
# based on which vendors the user picked. If the user picked
# nothing relevant (e.g. only Anycubic, which Bambu doesn't
# ship), the prefix set is empty and we do nothing.
target_prefixes: list[str] = []
vendors_in_play = 0
for vendor, prefixes in VENDOR_TO_BAMBU_BRAND_PREFIXES.items():
if vendor in picked_vendors:
vendors_in_play += 1
target_prefixes.extend(prefixes)
if not target_prefixes:
return (0, len(VENDOR_TO_BAMBU_BRAND_PREFIXES) - vendors_in_play)
def _walk_target_files():
# Root .json files
for f in filament_dir.iterdir():
if f.is_file() and f.suffix == ".json":
yield f
# Subfolder .json files (Bambu's Polymaker/ legacy folder, etc.)
for sub in filament_dir.iterdir():
if not sub.is_dir():
continue
if sub.name.startswith("_"):
continue
for f in sub.iterdir():
if f.is_file() and f.suffix == ".json":
yield f
# Pass 1: classify every .json. A file is a REMOVAL CANDIDATE if it
# matches a picked-vendor prefix AND is Bambu's own (from != "User").
# Everything else is a KEEPER: our previously-written profiles
# (from=="User", refreshed by the write step -> idempotent re-runs),
# and any non-picked-vendor Bambu file. We also record the `inherits`
# targets keepers still depend on, to guard against orphaning them.
candidates: list[tuple[Path, str]] = [] # (path, profile "name")
kept_inherits: set[str] = set()
for f in _walk_target_files():
name = f.name
try:
d = json.loads(f.read_text(encoding="utf-8"))
except Exception:
d = {}
is_target = any(name.startswith(p) for p in target_prefixes)
if is_target and d.get("from") != "User":
candidates.append((f, d.get("name") or name[:-5]))
else:
inh = d.get("inherits")
if inh:
kept_inherits.add(inh)
# Pass 2: back up + remove the whole Bambu chain (@base, mid-level
# parents, and leaves) for picked vendors. Renaming to
# .filanex-bambu-backup both removes it from Bambu's active set AND
# preserves the exact bytes for Uninstall to restore. Our flat leaves
# (inherits=None) don't depend on the @base, so removing the whole
# chain leaves nothing to orphan -- unlike the old leaf-only sweep
# that preserved @base and left the "both intact" clutter behind.
backed_up = 0
for f, prof_name in candidates:
# Inheritance guard (belt-and-suspenders vs the #22/#23 breakage):
# never remove a file another RETAINED profile still inherits from.
# Only trips on rare cross-brand inheritance; a fully-replaced line
# has no external inheritors once its leaves are gone.
if prof_name in kept_inherits:
print(f" KEEP (still inherited by a retained profile): {f.name}")
continue
new_path = f.parent / (f.name + BACKUP_EXT)
if new_path.exists():
# Prior-run backup already preserves the original; this
# current .json is a fresh re-download from Bambu's CDN.
# Delete the redundant copy.
try:
f.unlink()
backed_up += 1
except OSError as e:
print(f" WARN: couldn't remove duplicate Bambu stock {f.name}: {e}")
continue
try:
f.rename(new_path)
backed_up += 1
except OSError as e:
print(f" WARN: couldn't back up Bambu stock {f.name}: {e}")
return (backed_up, len(VENDOR_TO_BAMBU_BRAND_PREFIXES) - vendors_in_play)
def cleanup_user_folder_polymaker_copies(system_dir: Path) -> int:
"""Scan user/<id>/filament/ for Bambu Studio auto-copy files
(* - Copy.json / * - Copy.info) whose base name matches one of our
Polymaker brand prefixes. Move them to a timestamped backup folder
under user/<id>/_filanex-userfile-backup-<timestamp>/.
Why: Bambu Studio creates "<name> - Copy" auto-saves when the user
clicks Edit/Save-As on a stock profile. These accumulate over
install/uninstall cycles and show up as Unsupported entries
(compatible_printers is often empty on auto-copies). The user has
no use for these; they were created accidentally during prior
install testing.
Backup-then-move is reversible: the timestamped folder lets the
user restore manually if they discover a copy they actually wanted.
Returns count moved."""
# system_dir is .../BambuStudio/system; user folder is sibling
user_root = system_dir.parent / "user"
if not user_root.exists():
return 0
moved = 0
timestamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
for user_id_dir in user_root.iterdir():
if not user_id_dir.is_dir():
continue
filament_dir = user_id_dir / "filament"
if not filament_dir.is_dir():
continue
backup_dir = None # lazy create per user-id
for f in filament_dir.iterdir():
if not f.is_file():
continue
if f.suffix not in (".json", ".info"):
continue
name = f.name
# Bambu Studio's auto-copy filename pattern
if " - Copy" not in name:
continue
stem = f.stem
if not any(stem.startswith(p) for p in POLYMAKER_BRAND_PREFIXES):
continue
if backup_dir is None:
backup_dir = (
user_id_dir / f"_filanex-userfile-backup-{timestamp}"
)
try:
backup_dir.mkdir(exist_ok=True)
except OSError as e:
print(f" WARN: couldn't create backup dir {backup_dir}: {e}")
backup_dir = None
continue
try:
shutil.move(str(f), str(backup_dir / name))
moved += 1
except OSError as e:
print(f" WARN: couldn't move user copy {f.name}: {e}")
return moved
def cleanup_user_base_synced_files(
system_dir: Path, picked_vendors: set[str],
) -> int:
"""Back up cloud-synced Bambu user-base filament files in
user/<id>/filament/base/ that match picked vendor brand prefixes.
Why this exists (the long version):
Bambu Studio syncs the user's Bambu Lab cloud filament library
down into user/<id>/filament/base/. The contents are per-printer
Bambu stock leaves (Fiberon ASA-CF08 @Bambu Lab H2D 0.4 nozzle
etc.) with single-printer compatible_printers. When the user's
current printer isn't covered by ANY of these (e.g. H2C user
with cloud entries only for H2D/H2S/X1/X2D), the picker shows
the lines under Unsupported.
This was the actual root cause of the "5 persistent Fiberon
entries in Unsupported" issue Mike chased for an hour -- the
system/, Program Files/, and even our overlay were all clean,
but this third location (which I'd never inspected) held the
stock leaves causing the Unsupported display.
sync_user_preset = false in Preferences should prevent the
cloud pull, but past sync state still lingers in the folder.
Returns count moved (counts .json + .info pairs separately).
"""
user_root = system_dir.parent / "user"
if not user_root.exists():
return 0
# Compute target prefixes from picked vendors
target_prefixes: list[str] = []
for vendor, prefixes in VENDOR_TO_BAMBU_BRAND_PREFIXES.items():
if vendor in picked_vendors:
target_prefixes.extend(prefixes)
if not target_prefixes:
return 0
moved = 0
timestamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
for user_id_dir in user_root.iterdir():
if not user_id_dir.is_dir():
continue
base_dir = user_id_dir / "filament" / "base"
if not base_dir.is_dir():
continue
backup_dir = None
for f in base_dir.iterdir():
if not f.is_file():
continue
if f.suffix not in (".json", ".info"):
continue
name = f.name
if not any(name.startswith(p) for p in target_prefixes):
continue
if backup_dir is None:
backup_dir = (
user_id_dir / f"_filanex-userbase-backup-{timestamp}"
)
try:
backup_dir.mkdir(exist_ok=True)
except OSError as e:
print(f" WARN: couldn't create backup dir {backup_dir}: {e}")
backup_dir = None
continue
try:
shutil.move(str(f), str(backup_dir / name))
moved += 1
except OSError as e:
print(f" WARN: couldn't move user base file {f.name}: {e}")
return moved
def deep_wipe_bambu_install_resources(picked_vendors: set[str]) -> tuple[int, bool]:
"""Back up files in Bambu Studio's bundled resources folder
(<Program Files>/Bambu Studio/resources/profiles/BBL/filament/)
that match picked vendor brand prefixes.
Why: Bambu Studio's install_bundles_rsrc() hardcodes
"always update configs from resource to vendor for BBL", meaning
every startup it copies bundled resources into system/. Wiping
only system/ means Bambu re-installs the stock files on next
launch. Wiping the SOURCE (Program Files) stops the re-extraction.
Requirements:
- Admin privileges (Program Files is protected). If we can't
write, log a warning and skip -- not a fatal error.
- Bambu Studio not running (file locks otherwise).
Survives until Bambu Studio updates (a Bambu update overwrites
Program Files). User must re-run install.exe as admin after each
Bambu Studio update.
Returns (count_backed_up, ran_with_admin).
"""
bambu_resource = Path(
r"C:\Program Files\Bambu Studio\resources\profiles\BBL\filament"
)
if not bambu_resource.exists():
return (0, False)
# Probe write access -- the cleanest test for admin
probe = bambu_resource / ".filanex_write_probe"
try:
probe.touch()
probe.unlink()
except (PermissionError, OSError):
return (0, False)
# Compute target prefixes from picked vendors
target_prefixes: list[str] = []
for vendor, prefixes in VENDOR_TO_BAMBU_BRAND_PREFIXES.items():
if vendor in picked_vendors:
target_prefixes.extend(prefixes)
if not target_prefixes:
return (0, True)
backed_up = 0
for f in bambu_resource.iterdir():
if not f.is_file() or f.suffix != ".json":
continue
name = f.name
if not any(name.startswith(p) for p in target_prefixes):
continue
new_path = f.parent / (f.name + BACKUP_EXT)
if new_path.exists():
# Prior backup exists; current .json is from a Bambu update.
try:
f.unlink()
backed_up += 1
except OSError as e:
print(f" WARN: couldn't remove duplicate {name}: {e}")