Skip to content
Open
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
30 changes: 26 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,34 @@ slicebug is a command-line tool for preparing and executing cutting jobs on Cric
slicebug interacts with cutters by reusing undocumented components of Cricut Design Space. It is not developed or authorized by Cricut. Using slicebug might damage your cutter.

# Requirements
- Windows
- Windows or macOS
- Cricut Design Space installed and used at least once
- On macOS: ensure only one Cricut device is paired (remove any Bluetooth-paired devices from System Settings > Bluetooth if you're connecting via USB)

slicebug is developed in Python 3.10. You don't need Python to run it, just download a compiled version by clicking the "Releases" section on the right.

## Running from source (macOS)

On macOS, you can run slicebug directly from source using Python:

```bash
# Create a virtual environment (using uv or standard venv)
uv venv
source .venv/bin/activate

# Install dependencies
uv pip install cryptography protobuf

# Bootstrap slicebug (copies required data from Cricut Design Space)
python -m slicebug bootstrap

# List available materials
python -m slicebug list-materials

# Execute a cut plan
python -m slicebug cut examples/star.json
```

# Usage example

slicebug is a command-line utility, so you'll need a terminal to use it. On Windows, I recommend [Windows Terminal](https://aka.ms/terminal).
Expand Down Expand Up @@ -82,9 +105,8 @@ Just follow the instructions and your cut should complete!
- Testing/support for anything other than the original Cricut Maker
- Basic cutting will likely work on other machines supported by Cricut Design Space---please try it and report back!
- Features specific to other machines, like Smart Materials, are not supported yet.
- Operating systems other than Windows
- macOS: should be fairly easy, just some hardcoded paths that need tweaking.
- Linux:
- Operating systems other than Windows and macOS
- Linux:
- CricutDevice.exe does not run under Wine, but perhaps it does under one of the forks?
- `slicebug plan` works under Linux already if you copy the bootstrapped files from a Windows machine and manually install usvg.
- Print then Cut
Expand Down
66 changes: 52 additions & 14 deletions slicebug/cli/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import io
import json
import os.path
import platform
import re
import shutil
import urllib.request
Expand All @@ -14,12 +15,30 @@
from slicebug.exceptions import UserError


USVG_DOWNLOAD_URL = (
"https://github.com/RazrFalcon/resvg/releases/download/v0.27.0/usvg-win64.zip"
)
USVG_DOWNLOAD_SHA256 = (
"fc30023106bc846ba43713a620b638a04cae761a9fa899b7bd31f4ef9236b96d"
)
def _get_usvg_download_info():
"""Return (url, sha256, archive_member) for the current platform."""
system = platform.system()
if system == "Darwin":
return (
"https://github.com/linebender/resvg/releases/download/v0.27.0/usvg-macos-x86_64.zip",
"48c0ca0fbe0a7e195c84545a6924a7aec526070a98facc5c54829620d8e49887",
"usvg",
)
else: # Windows and others default to Windows
return (
"https://github.com/linebender/resvg/releases/download/v0.27.0/usvg-win64.zip",
"fc30023106bc846ba43713a620b638a04cae761a9fa899b7bd31f4ef9236b96d",
"usvg.exe",
)


def _get_default_cds_path():
"""Return the default Cricut Design Space installation path for the current platform."""
system = platform.system()
if system == "Darwin":
return "/Applications/Cricut Design Space.app/Contents/Resources"
else: # Windows
return os.path.expanduser("~/AppData/Local/Programs/Cricut Design Space")


def bootstrap_register_args(subparsers):
Expand All @@ -30,7 +49,7 @@ def bootstrap_register_args(subparsers):
parser.add_argument(
"--design-space-path",
help="Path to where Cricut Design Space is installed. Defaults to %(default)s, you likely don't need to change this.",
default=os.path.expanduser("~/AppData/Local/Programs/Cricut Design Space"),
default=_get_default_cds_path(),
)
parser.add_argument(
"--design-space-profile-path",
Expand All @@ -47,7 +66,13 @@ def import_keys(cds_root, cds_profile_root, cds_user, config):
xor = lambda data, key: bytes(v ^ key[i % len(key)] for i, v in enumerate(data))

print("Locating obfuscation key.")
with open(os.path.join(cds_root, "resources", "app.asar"), "rb") as f:
# On macOS, cds_root is already Contents/Resources
# On Windows, app.asar is in cds_root/resources/
if platform.system() == "Darwin":
asar_path = os.path.join(cds_root, "app.asar")
else:
asar_path = os.path.join(cds_root, "resources", "app.asar")
with open(asar_path, "rb") as f:
asar = f.read()
# this matches ([0x01, 0x02, ...]) with exactly 64 elements.
obfuscation_key_pattern = (
Expand Down Expand Up @@ -89,7 +114,12 @@ def import_keys(cds_root, cds_profile_root, cds_user, config):


def import_plugins(cds_root, config):
plugin_dir = os.path.join(cds_root, "resources", "plugins")
# On macOS, cds_root is already Contents/Resources, so plugins is directly inside
# On Windows, it's cds_root/resources/plugins
if platform.system() == "Darwin":
plugin_dir = os.path.join(cds_root, "plugins")
else:
plugin_dir = os.path.join(cds_root, "resources", "plugins")
print(f"Importing plugins from {cds_root}.")

for plugin in ["device-common"]:
Expand Down Expand Up @@ -178,20 +208,28 @@ def import_machine_profiles(cds_profile_root, cds_users, config):


def download_usvg(config):
usvg_url, usvg_sha256, usvg_member = _get_usvg_download_info()

print("Downloading usvg from Github.")
response = urllib.request.urlopen(USVG_DOWNLOAD_URL)
response = urllib.request.urlopen(usvg_url)
zip_bytes = response.read()
zip_sha256 = hashlib.sha256(zip_bytes).hexdigest()
zip_sha256_actual = hashlib.sha256(zip_bytes).hexdigest()

if zip_sha256 != USVG_DOWNLOAD_SHA256:
if zip_sha256_actual != usvg_sha256:
raise UserError(
"Could not download usvg. Expected to see a file with hash {USVG_DOWNLOAD_SHA256}, saw {zip_sha256}.",
f"Could not download usvg. Expected to see a file with hash {usvg_sha256}, saw {zip_sha256_actual}.",
"Check your network connection.",
)

print("Extracting usvg.")
usvg_dir = os.path.join(config.plugin_root(), "usvg")
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zip_struct:
zip_struct.extract("usvg.exe", os.path.join(config.plugin_root(), "usvg"))
zip_struct.extract(usvg_member, usvg_dir)

# Make executable on Unix systems
if platform.system() != "Windows":
usvg_path = os.path.join(usvg_dir, usvg_member)
os.chmod(usvg_path, 0o755)

print("usvg extracted.")

Expand Down
37 changes: 27 additions & 10 deletions slicebug/cli/cut.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,20 +121,32 @@ def cut_inner(config, dev, plan):
dev.recv(PBInteractionStatus.riStartSuccess)

device_connected_resp = dev.recv()

# Handle macOS-specific handshake (status 1215)
# This appears to be a newer protocol acknowledgment step
if device_connected_resp.status == 1215:
dev.send(
PBCommonBridge(
handle=PBInteractionHandle(currentInteraction=999),
status=1215,
)
)
device_connected_resp = dev.recv()

match device_connected_resp.status:
case PBInteractionStatus.riSingleDeviceConnected:
# great, this is what we're looking for
pass
case PBInteractionStatus.riMultipleDevicesConnected:
# On macOS, the plugin often reports multiple devices even with just USB
# connected. We'll proceed and let the serial check catch mismatches.
print("Note: Multiple devices detected, proceeding with default selection.")
pass
case PBInteractionStatus.riNoDeviceConnected:
raise UserError(
"No Cricut devices connected.",
"Connect your cutter to your computer and try again.",
)
case PBInteractionStatus.riMultipleDevicesConnected:
raise UserError(
"Multiple Cricut devices connected.",
"Disconnect all cutters except for the one you are trying to use and try again.",
)
case _:
raise ProtocolError(
f"unexpected status after start success: {device_connected_resp.status}"
Expand Down Expand Up @@ -219,11 +231,14 @@ def cut_inner(config, dev, plan):
dev.recv(PBInteractionStatus.riWaitOnGo)
print("Press the Go button.")

dev.recv(PBInteractionStatus.riGoPressed)
dev.recv(PBInteractionStatus.riGoPressed)
dev.recv(PBInteractionStatus.riWaitClear)

dev.recv(PBInteractionStatus.riSendToolArray)
# Handle Go button press sequence - may vary between platforms
# Keep consuming messages until we get riSendToolArray
while True:
resp = dev.recv()
if resp.status == PBInteractionStatus.riSendToolArray:
break
# On macOS, we may get: riWaitClear, riWaitOnGo, riGoPressed, etc.
# Just keep consuming until we hit riSendToolArray

dev.send(
PBCommonBridge(
Expand Down Expand Up @@ -297,6 +312,8 @@ def cut_inner(config, dev, plan):
| PBInteractionStatus.riDetectingTool
| PBInteractionStatus.riMATCUTSetProgress
| PBInteractionStatus.riWaitForEndMoveProgress
| PBInteractionStatus.riGoPressed
| PBInteractionStatus.riWaitClear
):
pass
case _:
Expand Down
14 changes: 10 additions & 4 deletions slicebug/config/config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os.path
import platform
import shutil

from dataclasses import dataclass
Expand All @@ -9,6 +10,11 @@
from slicebug.exceptions import UserError


def _exe_suffix():
"""Return '.exe' on Windows, '' on other platforms."""
return ".exe" if platform.system() == "Windows" else ""


@dataclass
class Config:
config_root: str
Expand Down Expand Up @@ -62,17 +68,17 @@ def plugin_root(self):
return os.path.join(self.config_root, "plugins")

def device_plugin_path(self):
path = os.path.join(
self.config_root, "plugins", "device-common", "CricutDevice.exe"
)
exe_name = "CricutDevice" + _exe_suffix()
path = os.path.join(self.config_root, "plugins", "device-common", exe_name)

if not os.path.exists(path):
return None

return path

def usvg_path(self):
path = os.path.join(self.config_root, "plugins", "usvg", "usvg.exe")
exe_name = "usvg" + _exe_suffix()
path = os.path.join(self.config_root, "plugins", "usvg", exe_name)

if not os.path.exists(path):
path = shutil.which("usvg")
Expand Down
11 changes: 7 additions & 4 deletions slicebug/cricut/material_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,11 @@ class MaterialSettings:
def load(cls, path):
with open(path, encoding="utf-8") as ms_file:
materials_json = json.load(ms_file)["customMaterials"]["materials"]
materials = {
material.global_id: material
for material in map(Material.from_json, materials_json)
}
materials = {}
for m_json in materials_json:
# Skip materials missing required fields
if "globalId" not in m_json:
continue
material = Material.from_json(m_json)
materials[material.global_id] = material
return cls(materials=materials)