diff --git a/README.md b/README.md index bf6fb76..a67c8e0 100644 --- a/README.md +++ b/README.md @@ -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). @@ -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 diff --git a/slicebug/cli/bootstrap.py b/slicebug/cli/bootstrap.py index 8dd75e0..ae495a7 100644 --- a/slicebug/cli/bootstrap.py +++ b/slicebug/cli/bootstrap.py @@ -3,6 +3,7 @@ import io import json import os.path +import platform import re import shutil import urllib.request @@ -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): @@ -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", @@ -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 = ( @@ -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"]: @@ -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.") diff --git a/slicebug/cli/cut.py b/slicebug/cli/cut.py index 8d53d04..4177f2e 100644 --- a/slicebug/cli/cut.py +++ b/slicebug/cli/cut.py @@ -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}" @@ -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( @@ -297,6 +312,8 @@ def cut_inner(config, dev, plan): | PBInteractionStatus.riDetectingTool | PBInteractionStatus.riMATCUTSetProgress | PBInteractionStatus.riWaitForEndMoveProgress + | PBInteractionStatus.riGoPressed + | PBInteractionStatus.riWaitClear ): pass case _: diff --git a/slicebug/config/config.py b/slicebug/config/config.py index 41ad0ee..884ac76 100644 --- a/slicebug/config/config.py +++ b/slicebug/config/config.py @@ -1,4 +1,5 @@ import os.path +import platform import shutil from dataclasses import dataclass @@ -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 @@ -62,9 +68,8 @@ 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 @@ -72,7 +77,8 @@ def device_plugin_path(self): 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") diff --git a/slicebug/cricut/material_settings.py b/slicebug/cricut/material_settings.py index bf9f03d..b6abb80 100644 --- a/slicebug/cricut/material_settings.py +++ b/slicebug/cricut/material_settings.py @@ -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)