Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Changelog

All notable changes to this project are documented here.

## v0.1.2 - 2026-05-20

### Added

- Add optional 3MF post-processing script checks. By default, the bridge notifies the user, writes the script text to the log, and then continues opening the file.
- Add `post_process_action` config support with `warn`, `block`, and `ignore` modes.

### Fixed

- Ignore empty `bambustudioopen` URI launches without showing an error or running the download flow.
- Improve diagnostics for malformed slicer links by logging the original input URI when URL extraction fails.

## v0.1.1 - 2026-04-28

### Added

- Add an automatic Windows installer that sets up the bridge and registers URI handlers.
- Add `python -m slicer_uri_bridge` support as a fallback way to run the CLI when the script entry point is not on `PATH`.

## v0.1.0 - 2026-04-28

### Initial Release

- Add the first Slicer URI Bridge CLI for routing slicer links to Bambu Studio.
- Support Bambu Studio, PrusaSlicer, OrcaSlicer, Cura, and Creality Print style URI links.
- Add safe model download validation before opening files in Bambu Studio.
- Add interactive URI handler registration and status management.
- Add an automatic macOS installer.
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](https://opensource.org/licenses/MIT)
[![Platform: Windows | macOS | Linux](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)]()

[Installation](#installation) · [Security Model](#security-model) · [Changelog](CHANGELOG.md)

Slicer URI Bridge helps open 3D model links from websites in Bambu Studio, including sites that do not provide a native Bambu Studio button or where that integration is not available.

https://github.com/user-attachments/assets/32b1fd48-4498-42de-81d6-629b452712b9
Expand Down Expand Up @@ -135,9 +137,12 @@ The bridge validates downloads before opening them:
* redirect targets are revalidated
* downloaded files must use an allowed model extension
* empty files and obvious executable formats are refused
* 3MF files are checked for embedded post-processing scripts ([scripts that can run after slicing](https://manual.slic3r.org/advanced/post-processing))

By default, downloads are accepted from any host. To restrict downloads to specific hosts, set `allow_any_original_host = false` in the config and use the `allowed_hosts` list (the default config includes CDNs for Printables, Thingiverse, and Creality).

All available options are described in the bundled [`default_config.toml`](src/slicer_uri_bridge/resources/default_config.toml) template and copied into the generated `config.toml` file.

## Troubleshooting

The bridge writes log files next to the config file. To find their location:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "slicer-uri-bridge"
version = "0.1.1"
version = "0.1.2"
description = "Register slicer URI handlers and bridge slicer links to Bambu Studio."
readme = "README.md"
requires-python = ">=3.11"
Expand Down
2 changes: 0 additions & 2 deletions src/slicer_uri_bridge/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
"""Slicer URI Bridge package."""

from __future__ import annotations

__version__ = "0.1.1"
16 changes: 14 additions & 2 deletions src/slicer_uri_bridge/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@
import subprocess
import sys
import tomllib
from importlib.metadata import PackageNotFoundError, version as package_version
from pathlib import Path

from . import __version__
from .config import config_matches_default, init_user_config, user_config_path
from .manager import main as manager_main


PACKAGE_NAME = "slicer-uri-bridge"
YES_VALUES = {"y", "yes"}
NO_VALUES = {"n", "no"}
IS_WINDOWS = sys.platform == "win32"
Expand All @@ -27,12 +28,23 @@ def eprint(message: str) -> None:
print(message, file=sys.stderr)


def metadata_version() -> str:
try:
return package_version(PACKAGE_NAME)
except PackageNotFoundError:
return "unknown"


def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="slicer-uri-bridge",
description="Register slicer URI handlers and bridge slicer links to Bambu Studio.",
)
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
parser.add_argument(
"--version",
action="version",
version=f"%(prog)s {metadata_version()}",
)

subparsers = parser.add_subparsers(dest="command", required=True)

Expand Down
102 changes: 98 additions & 4 deletions src/slicer_uri_bridge/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import argparse
import ipaddress
import json
import logging
import posixpath
import re
Expand All @@ -17,6 +18,7 @@
import urllib.parse
import urllib.request
import urllib.response
import zipfile
from pathlib import Path, PurePosixPath, PureWindowsPath
from urllib.parse import urlsplit

Expand All @@ -34,6 +36,9 @@
BUFFER_SIZE = 81920
REDIRECT_CODES = {301, 302, 303, 307, 308}
EXECUTABLE_MODE_BITS = stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
PROJECT_SETTINGS_PATH = "Metadata/project_settings.config"
POST_PROCESS_ACTION_DEFAULT = "warn"
POST_PROCESS_ACTIONS = {"ignore", "warn", "block"}


class BridgeError(RuntimeError):
Expand Down Expand Up @@ -80,7 +85,18 @@ def is_host(value: str) -> bool:
return bool(p.hostname) and not any((p.scheme, p.path, p.query, p.fragment, p.username, p.password))
except ValueError:
return False



def normalize_post_process_action(value: object) -> str:
if isinstance(value, str):
action = value.strip().lower()
if action in POST_PROCESS_ACTIONS:
return action

logger.warning("Invalid security.post_process_action; using warn")
return POST_PROCESS_ACTION_DEFAULT


def load_config() -> dict:
if not CONFIG_FILE.is_file():
raise BridgeError(missing_config_message(CONFIG_FILE))
Expand All @@ -106,6 +122,8 @@ def load_config() -> dict:
logger.warning("Invalid security.allow_any_original_host; using false")
security["allow_any_original_host"] = False

security["post_process_action"] = normalize_post_process_action(security.get("post_process_action"))

allowed_hosts = security.get("allowed_hosts", [])
valid_hosts = []
if isinstance(allowed_hosts, list):
Expand Down Expand Up @@ -224,6 +242,15 @@ def extract_download(protocol_uri: str, allowed_extensions: set[str]) -> tuple[s
return strip_trailing_model_slash(download_url, allowed_extensions), suggested_name


def is_empty_bambustudioopen_uri(protocol_uri: str) -> bool:
parsed = urllib.parse.urlsplit(protocol_uri)
if parsed.scheme.lower() != "bambustudioopen":
return False

payload = protocol_uri.split(":", 1)[1].lstrip("/")
return not urllib.parse.unquote(payload).strip()


def filename_from_url(url: str) -> str | None:
parsed = urllib.parse.urlsplit(url)
query_name = urllib.parse.parse_qs(parsed.query).get("name", [""])[0].strip()
Expand Down Expand Up @@ -504,6 +531,55 @@ def validate_downloaded_file(path: Path) -> None:
raise BridgeError("Downloaded file is a Mach-O executable, refusing to open it.")


def scan_3mf_post_process(path: Path) -> list[str] | None:
if path.suffix.lower() != ".3mf":
return None

try:
with zipfile.ZipFile(path) as archive:
settings = json.loads(archive.read(PROJECT_SETTINGS_PATH).decode("utf-8-sig"))
if isinstance(raw := settings.get("post_process"), list):
return raw or None
except Exception as exc:
logger.warning("Could not inspect 3MF project settings in %s: %s", path, exc)

return None


def post_process_message(path: Path, commands: list[str]) -> str:
if len(commands) == 1:
post_process = commands[0]
else:
post_process = "\n\n".join(
f"[{index}]\n{command}" for index, command in enumerate(commands, start=1)
)

return (
"Downloaded 3MF file contains a post-processing script.\n\n"
f"File: {path}\n\n"
"post_process:\n"
f"{post_process}"
)


def check_3mf_post_process(path: Path, action: str) -> None:
if action == "ignore":
return

commands = scan_3mf_post_process(path)
# No real commands if every value becomes empty after trimming whitespace.
if commands is None or not any(command.strip() for command in commands):
return

message = post_process_message(path, commands)
logger.warning("%s", message)

if action == "block":
raise BridgeError(message)

show_warning(message)


def has_executable_bits(mode: int) -> bool:
if IS_WINDOWS:
return False
Expand Down Expand Up @@ -592,23 +668,32 @@ def launch_bambu(command: list[str], model_path: Path) -> None:
raise BridgeError(f"Failed to start Bambu Studio: {exc}") from exc


def show_error(message: str) -> None:
def show_message(message: str, kind: str) -> None:
print(message, file=sys.stderr)
try:
import tkinter
from tkinter import messagebox

root = tkinter.Tk()
root.withdraw()
messagebox.showerror("Bambu Studio URI Bridge", message)
getattr(messagebox, kind)("Slicer URI Bridge", message)
root.destroy()
except Exception:
pass


def show_error(message: str) -> None:
show_message(message, "showerror")


def show_warning(message: str) -> None:
show_message(message, "showwarning")


def main(argv: list[str] | None = None) -> int:
setup_logging()
args = parse_args(sys.argv[1:] if argv is None else argv)
uri: str | None = None
local_path: Path | None = None
download_folder: Path | None = None

Expand All @@ -620,7 +705,15 @@ def main(argv: list[str] | None = None) -> int:
download_folder = download_folder_from_config(config)

uri = resolve_protocol_uri(args)
download_url, suggested_name = extract_download(uri, allowed_extensions)
if is_empty_bambustudioopen_uri(uri):
logger.info("Ignoring empty bambustudioopen URI: %r", uri)
return 0

try:
download_url, suggested_name = extract_download(uri, allowed_extensions)
except BridgeError:
logger.error("Input URI: %r", uri)
raise
logger.info(f"Resolved input URI with download URL: {download_url}")
allowed_hosts, allow_any_original_host = load_allowed_hosts(config)
validate_remote_url(
Expand All @@ -643,6 +736,7 @@ def main(argv: list[str] | None = None) -> int:
allow_plain_http=allow_plain_http,
)
validate_downloaded_file(local_path)
check_3mf_post_process(local_path, security["post_process_action"])

launch_bambu(command, local_path)

Expand Down
3 changes: 3 additions & 0 deletions src/slicer_uri_bridge/resources/default_config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
allow_plain_http = false
allow_any_original_host = true
allowed_extensions = [".3mf", ".stl", ".step", ".stp", ".obj"]
# 3MF files can contain Bambu/Orca post-processing commands.
# Values: "ignore", "warn" (show a warning and open), or "block" (refuse to open).
post_process_action = "warn"
# allowed_hosts is only used when allow_any_original_host = false
allowed_hosts = [
"files.printables.com",
Expand Down
24 changes: 24 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,30 @@ def isatty(self) -> bool:
return True


class CliVersionTests(unittest.TestCase):
def test_version_action_uses_fallback_when_package_metadata_is_absent(self) -> None:
with (
patch("slicer_uri_bridge.cli.package_version", side_effect=cli.PackageNotFoundError),
patch("sys.stdout", new_callable=StringIO) as stdout,
):
parser = cli.build_parser()
with self.assertRaises(SystemExit) as exit_context:
parser.parse_args(["--version"])

self.assertEqual(exit_context.exception.code, 0)
self.assertIn("slicer-uri-bridge unknown", stdout.getvalue())

def test_subcommands_do_not_require_package_metadata(self) -> None:
with (
patch("slicer_uri_bridge.cli.package_version", side_effect=cli.PackageNotFoundError),
patch("slicer_uri_bridge.cli.user_config_path", return_value=Path("config.toml")),
patch("sys.stdout", new_callable=StringIO) as stdout,
):
self.assertEqual(cli.main(["config-path"]), 0)

self.assertIn("config.toml", stdout.getvalue())


class InteractiveOnboardingTests(unittest.TestCase):
def test_package_can_run_as_python_module(self) -> None:
completed = subprocess.run(
Expand Down
Loading
Loading