Author: Derrick

  • Fun with Telex

    Fun with Telex

    Automattic recently released a really cool tool called Telex that is an AI assisted block builder. I’ve been playing around with it, and it’s been really cool!

    I decided to use it to build a “TeaHouse Hero” block. What is that you didn’t ask? Well, some of us really old folks might remember a Google product called “iGoogle” (🙄) that was a customized start page for your browser. Yeah, it was a different time, we were young and didn’t really know what we were doing.

    One thing you could do is have different themes. Obviously I chose the “Tea House” theme. It looked something like this:

    I recently learned that someone has saved all of the images and put them on GitHub 11 years ago.

    As an homage to my little fox friend, I wanted to add them to my blog, which gives us the new header for my homepage:

    🦊🍵🏠

    This block will maintain the proper aspect ratio for the images, and it will change, just like the iGoogle page, according to the visitor’s time.

    After I built it with Telex, I brought it to Claude to clean up, and convert its build process to the standard wp-scripts block building process.

    So here, if you’d like it, take it. IDK what else to do with it.

    I don’t own the artwork, and I’m not sure if it’s Meomi or Google that own the rights at this point. So like me, use at your own risk.

  • *click* *click* ENHANCE *click* *click* ENHANCE *click *click*

    *click* *click* ENHANCE *click* *click* ENHANCE *click *click*

    I recently learned that there’s a built-in “lightbox” function for the block editor, where you can click on images to expand them. Go ahead, try it on the picture below:

    Isn’t that wild?! Who knew! This should be the default:

    Through the magic of the Internet, now it can be!

    I took the lazy way and vibe coded this with Claude Code, so now all images on my blarg (and yours too if you’d like!) will expand by default.

    This plugin comes with no warranty. I will not be able to help you with it. Use it at your own expense. If it deletes your site, code, house, whatever, that’s on you. Don’t download random things from the Internet and run them. Lesson learned. I am not held liable.

    Go ahead, take this plugin, and do evil terrible things with it! Crush the skulls of your enemies!

  • Extract Transcript from Quill Meetings Files

    Extract Transcript from Quill Meetings Files

    I use Quill Meetings for local on-device transcriptions of calls. It’s pretty great!

    The app definitely has some quirks and is missing some features that I’d prefer, like the ability just export a text file of a call transcript. Sure, I can “copy” it and paste it into a file, but it’s missing things like timestamps:

    So I built a quick script to extract transcripts from .qm files for me. .qm files are basically just JSON files:

    #!/opt/homebrew/bin/php
    <?php
    declare(strict_types=1);
    
    error_reporting( E_ALL );
    ini_set( 'display_errors', '1' );
    
    // Quill export dir is first argument, or current directory if not provided.
    $export_dir = isset( $argv[1] ) ? rtrim( $argv[1], '/' ) : getcwd();
    
    // Find every file that ends in .qm in the export directory.
    $files = glob( $export_dir . '/*.qm' );
    if ( ! $files ) {
    	echo "No .qm files found in the directory: $export_dir\n";
    	exit( 1 );
    }
    
    /**
     * Each QM file is just a JSON file with a .qm extension and the first line being "QMv2"
     * We need to read each file, remove the first line, and decode the JSON.
     */
    foreach( $files as $file ) {
    	if ( ! is_readable( $file ) ) {
    		echo "Cannot read file: $file\n";
    		continue;
    	}
    
    	// Read the file and remove the first line.
    	$content = file_get_contents( $file );
    	if ( false === $content ) {
    		echo "Failed to read file: $file\n";
    		continue;
    	}
    
    	// Remove the first line (QMv2).
    	$lines = explode( "\n", $content );
    	array_shift( $lines ); // Remove the first line.
    	$json_content = implode( "\n", $lines );
    
    	// Decode the JSON content.
    	$data = json_decode( $json_content, true );
    	if ( null === $data && json_last_error() !== JSON_ERROR_NONE ) {
    		echo "Invalid JSON in file: $file\n";
    		continue;
    	}
    
    	// Pretty print the JSON data.
    	$pretty_json = json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES );
    	if ( false === $pretty_json ) {
    		echo "Failed to encode JSON for file: $file\n";
    		continue;
    	}
    
    	$speakers = array();
    	$transcript = array();
    	$output_string = '';
    	$output_file = '';
    	foreach ( $data as $quill_objects => $quill_object ) {
    	    // Each Quill object is an array. We want to check if it has a 'type' of 'Meeting'.
    		if ( isset( $quill_object['type'] ) && $quill_object['type'] === 'Meeting' ) {
    			$output_file = $quill_object['data']['start'] . '-' . $quill_object['data']['end'] . ': ' . $quill_object['data']['title'] . '.txt';
    			// The "audio_transcript" is just a JSON string that we need to decode.
    			$audio_transcript = json_decode( $quill_object['data']['audio_transcript'], true );
    			$encoded_speakers = $quill_object['data']['speakers'] ?? [];
    			foreach( $encoded_speakers as $encoded_speaker ) {
    				$speakers[ $encoded_speaker['id'] ] = $encoded_speaker['name'] ?? 'Unknown Speaker ' . $encoded_speaker['id'];
    			}
    			if ( ! isset ( $audio_transcript['startTime'] ) ) {
    				echo "Invalid start time in audio transcript for file: $file\n";
    				continue;
    			}
    			$start_time = $audio_transcript['startTime'];
    			$end_time   = $audio_transcript['endTime'];
    			foreach( $audio_transcript['blocks'] as $block ) {
    				$time_block = ms_to_readable( $block['from'] - $start_time );
    				if ( isset( $block['speaker_id' ] ) ) {
    					$speaker_block = $speakers[ $block['speaker_id'] ];
    				} else {
    					echo 'Unkown Speaker found. Please manually mark all speakers in Quill before exporting.' . PHP_EOL;
    					die( 1 );
    				}
    				$output_string .= sprintf( "%s %s: %s\n", $time_block, $speaker_block, $block['text'] );
    			}
    		}
    	}
    
    	if ( ! empty( $output_string ) && ! empty( $output_file ) ) {
    		// Sanitize the filename.
    		$output_file = sanitize_filename( $output_file );
    		// Write the output string to the file.
    		if ( file_put_contents( $output_file, $output_string ) === false ) {
    			echo "Failed to write to file: $output_file\n";
    		} else {
    			echo "Exported to: $output_file\n";
    		}
    	} else {
    		echo "No valid Meeting data found in file: $file\n";
    	}
    }
    
    function ms_to_readable(int $ms): string {
    	// round to nearest second
    	$secs = (int) round($ms / 1000);
    	// gmdate formats seconds since 0 into H:i:s — we just need i:s
    	return '[' . gmdate('i:s', $secs) . ']';
    }
    
    function sanitize_filename(string $filename): string {
    	// strip any path information
    	$fname = basename($filename);
    	// replace any character that is NOT a-z, 0-9, dot, hyphen or underscore with an underscore
    	$clean = preg_replace('/[^\w\.-]+/', '_', $fname);
    	// collapse multiple underscores
    	return preg_replace('/_+/', '_', $clean);
    }Code language: PHP (php)

    and when I say “I” wrote it, it was probably half AI 🙃

    This gives me a nice text file with timestamps:

    So, yeah. Whatever.

  • A Trio of Pibooth Plugins

    A Trio of Pibooth Plugins

    This past weekend I did a thing:

    One of the things that I had done, as previously blargged about here, is having a Pibooth Photo Booth.

    I ended up writing three custom plugins that I’m going to share here. I had plenty of help from ChatGPT because I hate Python and it’s the worst language invented and it should be destroyed.

    I also ended up hacking a bit of Pibooth core because its hook/plugin system was a bit lacking for my needs, but that was only around moving around some of the touchscreen UI items for a better user experience. But enough about that, here’s my three plugins:

    Fullscreen Toggle

    One of the things I wanted to be able to easily do, running a keyboard/mouseless touchscreen Raspbery Pi was to be able to jump in and out of Fullscreen mode to do things like debug printers, change Wifi, etc. So I pulled this plugin out of the ether. It allows you to touch the top right corner (or click, if that’s your thing) to toggle between fullscreen and windowed mode.

    import pygame
    from pibooth import hookimpl
    from pibooth.utils import LOGGER
    
    __version__ = "0.0.1"
    
    """
    Plugin to toggle fullscreen/windowed mode by tapping top right corner in take-a-picture screen.
    """
    
    # Fractional region size
    REGION_WIDTH_FRAC = 0.10  # 10% width
    REGION_HEIGHT_FRAC = 0.10  # 10% height
    
    @hookimpl
    def state_wait_do(cfg, app, win, events):
        """
        In the wait state (take-a-picture), detect a tap/click in the top-right corner
        and toggle fullscreen mode.
        """
        # Only on the initial "take a picture" screen (wait state)
        # events: list of pygame events
        for ev in events:
            if ev.type in (pygame.MOUSEBUTTONUP, pygame.FINGERUP):
                # Determine click position
                if hasattr(ev, 'pos'):
                    x, y = ev.pos
                else:
                    sw, sh = win.surface.get_size()
                    x, y = ev.x * sw, ev.y * sh
                sw, sh = win.surface.get_size()
                # Define top-right region
                region_x0 = sw * (1 - REGION_WIDTH_FRAC)
                region_y1 = sh * REGION_HEIGHT_FRAC
                if x >= region_x0 and y <= region_y1:
                    # Toggle fullscreen
                    try:
                        win.toggle_fullscreen()
                        LOGGER.info("[fullscreen_toggle] Toggled fullscreen mode")
                    except Exception as e:
                        LOGGER.error(f"[fullscreen_toggle] Failed to toggle fullscreen: {e}")
                    # Consume event
                    return ev
        # Return None to fall back to default behaviorCode language: Python (python)

    Upload to WordPress

    Well, yeah. Obvi! This plugin uploads the photos to your WordPress Media library, with an optional tag and optional post object to attach them to. It will also update the URL so that it works with the “QR Code” plugin (not mine, from Pihole or something, IDK). You just have to add some of the options to your Pihole config file, like the hostname, username, application password, etc.

    import os
    import http.client
    import base64
    import json
    import pibooth
    from pibooth.utils import LOGGER
    
    __version__ = "0.0.4"
    
    SECTION = 'WORDPRESS'
    PICTURE_SECTION = 'PICTURE'
    
    @pibooth.hookimpl
    def pibooth_configure(cfg):
        """Declare the new configuration options"""
        cfg.add_option(SECTION, 'wordpress_host', '',
            "WordPress site host (e.g., 'yourwordpresssite.com')")
        cfg.add_option(SECTION, 'wordpress_username', '',
          "WordPress username")
        cfg.add_option(SECTION, 'wordpress_password', '',
            "WordPress application password")
        cfg.add_option(SECTION, 'wordpress_parent_id', '',
            "(Optional) WordPress post or page ID to attach the uploads",
            "Post or Page ID (optional)", '')
        cfg.add_option(SECTION, 'image_title', '',
            "(Optional) Title for the uploaded image",
            "Image title (optional)", '')
        cfg.add_option(SECTION, 'image_caption', '',
            "(Optional) Caption for the uploaded image",
            "Image caption (optional)", '')
        cfg.add_option(SECTION, 'wordpress_post_tag', '',
            "(Optional) Post tag taxonomy term slug to assign to the uploaded media",
            "Post tag (optional)", '')
    
    @pibooth.hookimpl
    def pibooth_startup(app, cfg):
        """Initialize the WordPress uploader plugin"""
        app.previous_picture_url = None
        wordpress_host = cfg.get(SECTION, 'wordpress_host')
        wordpress_username = cfg.get(SECTION, 'wordpress_username')
        wordpress_password = cfg.get(SECTION, 'wordpress_password')
        wordpress_parent_id = cfg.get(SECTION, 'wordpress_parent_id')
        wordpress_post_tag = cfg.get(SECTION, 'wordpress_post_tag')
        
        image_title = ''
        image_caption = ''
    
        if cfg.has_option(SECTION, 'image_title') and cfg.get(SECTION, 'image_title'):
            image_title = cfg.get(SECTION, 'image_title')
            LOGGER.info(f"Using image title from WORDPRESS config: {image_title}")
        elif cfg.has_option(PICTURE_SECTION, 'footer_text1') and cfg.get(PICTURE_SECTION, 'footer_text1'):
            image_title = cfg.get(PICTURE_SECTION, 'footer_text1')
            LOGGER.info(f"Using image title from GENERAL config: {image_title}")
        else:
            LOGGER.info("Image title missing!")
        
        if cfg.has_option(SECTION, 'image_caption') and cfg.get(SECTION, 'image_caption'):
            image_caption = cfg.get(SECTION, 'image_caption')
            LOGGER.info(f"Using image caption from WORDPRESS config: {image_caption}")
        elif cfg.has_option(PICTURE_SECTION, 'footer_text2') and cfg.get(PICTURE_SECTION, 'footer_text2'):
            image_caption = cfg.get(PICTURE_SECTION, 'footer_text2')
            LOGGER.info(f"Using image caption from GENERAL config: {image_caption}")
        else:
            LOGGER.info("Image caption missing!")
    
        if not wordpress_host:
            LOGGER.error(f"WordPress host not defined in [{SECTION}][wordpress_host], uploading deactivated")
        elif not wordpress_username:
            LOGGER.error(f"WordPress username not defined in [{SECTION}][wordpress_username], uploading deactivated")
        elif not wordpress_password:
            LOGGER.error(f"WordPress password not defined in [{SECTION}][wordpress_password], uploading deactivated")
        else:
            LOGGER.info("WordPress uploader plugin installed")
            app.wordpress_host = wordpress_host
            app.auth_token = base64.b64encode(f"{wordpress_username}:{wordpress_password}".encode()).decode()
            app.wordpress_parent_id = wordpress_parent_id if wordpress_parent_id else None
            app.wordpress_post_tag = wordpress_post_tag if wordpress_post_tag else None
            LOGGER.info(f"Post tag set to: {app.wordpress_post_tag}")
            app.image_title = image_title
            app.image_caption = image_caption
    
            LOGGER.info(f"Image title set to: {app.image_title}")
            LOGGER.info(f"Image caption set to: {app.image_caption}")
    
    @pibooth.hookimpl
    def state_processing_exit(app, cfg):
        """Upload picture to WordPress media library"""
        # Skip plugin if disabled in config
        # if not (cfg.has_section('CUSTOM_PROCESSING') and cfg.getboolean('CUSTOM_PROCESSING', 'enabled')):
        #    return
            
        if hasattr(app, 'wordpress_host') and hasattr(app, 'auth_token'):
            name = os.path.basename(app.previous_picture_file)
    
            with open(app.previous_picture_file, 'rb') as fp:
                image_data = fp.read()
    
            headers = {
                'Content-Disposition': f'attachment; filename={name}',
                'Content-Type': 'image/jpeg',
                'Authorization': f'Basic {app.auth_token}'
            }
    
            # Prepare the request path with an optional parent ID
            path = "/wp-json/wp/v2/media"
            if app.wordpress_parent_id:
                path += f"?parent={app.wordpress_parent_id}"
    
            conn = http.client.HTTPSConnection(app.wordpress_host)
            conn.request("POST", path, body=image_data, headers=headers)
            response = conn.getresponse()
            
            if response.status == 201:
                response_data = json.loads(response.read().decode())
                app.previous_picture_url = response_data['source_url']
                
                LOGGER.info(f"Uploaded image URL: {app.previous_picture_url}")
    
                # Set metadata if provided
                metadata = {}
                if app.image_title:
                    metadata['title'] = app.image_title
                if app.image_caption:
                    metadata['caption'] = app.image_caption
                if app.wordpress_post_tag:
                    term_slug = app.wordpress_post_tag
                    tag_headers = {
                        'Content-Type': 'application/json',
                        'Authorization': f'Basic {app.auth_token}'
                    }
                    # Try to fetch existing tag
                    conn.request("GET", f"/wp-json/wp/v2/tags?slug={term_slug}", headers=tag_headers)
                    tag_resp = conn.getresponse()
                    if tag_resp.status == 200:
                        tags_list = json.loads(tag_resp.read().decode())
                        if tags_list:
                            tag_id = tags_list[0]['id']
                        else:
                            # Create the tag if not found
                            payload = json.dumps({'name': term_slug, 'slug': term_slug})
                            conn.request("POST", "/wp-json/wp/v2/tags", body=payload, headers=tag_headers)
                            create_resp = conn.getresponse()
                            if create_resp.status == 201:
                                tag_id = json.loads(create_resp.read().decode())['id']
                            else:
                                LOGGER.error(f"Failed to create tag {term_slug}. Status code: {create_resp.status}")
                                tag_id = None
                    else:
                        LOGGER.error(f"Failed to fetch tag {term_slug}. Status code: {tag_resp.status}")
                        tag_id = None
                    if tag_id:
                        metadata['tags'] = [tag_id]
                        LOGGER.info(f"Assigning tag ID {tag_id} to attachment")
    
                LOGGER.info(f"Metadata to be set: {metadata}")
    
                if metadata:
                    meta_headers = {
                        'Content-Type': 'application/json',
                        'Authorization': f'Basic {app.auth_token}'
                    }
                    conn.request("POST", f"/wp-json/wp/v2/media/{response_data['id']}", body=json.dumps(metadata), headers=meta_headers)
                    meta_response = conn.getresponse()
                    if meta_response.status != 200:
                        LOGGER.error(f"Failed to set metadata. Status code: {meta_response.status}, Response: {meta_response.read().decode()}")
    
                LOGGER.info(f"Successfully uploaded {name} to WordPress. URL: {app.previous_picture_url}")
            else:
                LOGGER.error(f"Failed to upload {name}. Status code: {response.status}, Response: {response.read().decode()}")
    
            conn.close()
    
    @pibooth.hookimpl
    def pibooth_cleanup(app):
        """Cleanup actions if necessary"""
        passCode language: Python (python)

    Custom Processing (More Fun!)

    Finally, I didn’t like the generic “PROCESSING” screen that happened. I wanted it to be more personal, so I rebuilt it so that it shows the last three images taken, along with a custom waiting message taken from an array:

    #!/usr/bin/env python3
    """
    custom_processing.py
    
    A PiBooth plugin that, during the 'processing' state, grabs the last
    three saved pictures from your output directory and shows them as thumbnails.
    """
    import os
    import glob
    import pygame
    import pibooth
    from pibooth.utils import LOGGER
    from pibooth.view import background
    from pibooth import pictures
    import random
    from pibooth import fonts
    
    __version__ = "0.0.1"
    
    # A list of fun processing messages
    PROCESSING_SAYINGS = [
        "Processing…",
        "Shaking it like a Polaroid picture",
        "Now developing…",
        "Cooking your memories",
        "Almost ready!",
        "Frame by frame…",
        "Developing brilliance…",
        "Hang tight, magic in progress…",
        "Give us a sec, art is brewing…",
        "Hold on, pixels aligning…",
        "Magic happening behind the scenes…",
        "One moment please…",
        "Crafting your memories…",
        "Almost there, thanks for your patience…",
        "Don’t blink, or you might miss it…",
        "Your masterpiece is on its way…",
    ]
    
    @pibooth.hookimpl(trylast=True)
    def state_processing_enter(cfg, app, win):
        """
        When entering the 'processing' screen, list the output folder,
        pick the 3 newest image files, and render them as thumbnails.
        """
    
        # 1) Determine your output directory (default: ~/Pictures/pibooth)
        outdir = os.path.expanduser(cfg.get('PATHS', 'output_dir') if
                                    cfg.has_option('PATHS', 'output_dir') else
                                    '~/Pictures/pibooth')
        LOGGER.info(f"[custom_processing] Scanning directory: {outdir}")
    
        # 2) Glob all image files (you can tweak extensions as needed)
        exts = ('*.jpg', '*.jpeg', '*.png', '*.avif')
        files = []
        for ext in exts:
            files.extend(glob.glob(os.path.join(outdir, ext)))
        if not files:
            LOGGER.info("[custom_processing] No files found in output dir")
            return
    
        # 3) Sort by modification time and pick the last three
        files.sort(key=lambda f: os.path.getmtime(f))
        thumbs = files[-3:]
        LOGGER.info(f"[custom_processing] Last 3 files: {thumbs}")
    
        # 4) Load, scale and display as before
        surface = win.surface
        # Redraw PiBooth’s default processing background
        win._update_background(background.Background(''))
    
        sw, sh = surface.get_size()
        thumb_h = int(sh * 0.3)
        scaled = []
        for path in thumbs:
            try:
                # Load with alpha so we can mask out black
                img = pygame.image.load(path).convert_alpha()
                ow, oh = img.get_size()
                tw = int(ow * thumb_h / oh)
                img = pygame.transform.smoothscale(img, (tw, thumb_h))
    
                # Make pure black transparent (so border and photo remain)
                img.set_colorkey((0, 0, 0))
    
                scaled.append(img)
                LOGGER.info(f"[custom_processing] Loaded & scaled {os.path.basename(path)} → {(tw, thumb_h)}")
            except Exception as e:
                LOGGER.error(f"[custom_processing] Failed to load {path}: {e}")
    
        if not scaled:
            LOGGER.info("[custom_processing] No thumbnails rendered")
            return
    
        # 5) Blit and rotate thumbnails into the hanging frames with random tilt
        # Percent‐based anchor points so they adjust on resize:
        frame_positions = [
            (int(sw * 0.20), int(sh * 0.34)),  # left (moved up to 40% height)
            (int(sw * 0.50), int(sh * 0.36)),  # center (35% height)
            (int(sw * 0.80), int(sh * 0.34)),  # right (40% height)
        ]
        for img, (cx, cy) in zip(scaled, frame_positions):
            # Random rotation between -10° and +10°
            angle = random.uniform(-10, 10)
            rot_img = pygame.transform.rotozoom(img, angle, 1.0)
            rw, rh = rot_img.get_size()
            # Center the rotated thumbnail on its anchor point
            surface.blit(rot_img, (cx - rw // 2, cy - rh // 2))
    
        # Draw the transparent custom processing template over the thumbnails
        custom_bg = os.path.expanduser("~/.config/pibooth/assets/processing_custom.png")
        try:
            # Load with alpha to preserve transparency
            overlay_img = pygame.image.load(custom_bg).convert_alpha()
            # Scale to cover the full window
            overlay_img = pygame.transform.smoothscale(overlay_img, (sw, sh))
            # Blit on top of thumbnails
            surface.blit(overlay_img, (0, 0))
        except Exception as e:
            LOGGER.error(f"[custom_processing] Failed to load processing_custom overlay '{custom_bg}': {e}")
    
        # Pick and uppercase a random processing message
        say = random.choice(PROCESSING_SAYINGS).upper()
        # Define the bottom third with 5% side padding
        bottom_rect = pygame.Rect(
            int(sw * 0.05), 
            int(sh * 2/3), 
            int(sw * 0.9), 
            int(sh / 3)
        )
        # Choose a font that fits in that rectangle
        font = fonts.get_pygame_font(
            say,
            fonts.CURRENT,
            bottom_rect.width,
            bottom_rect.height
        )
        # Render the text in the window's text color
        text_color = cfg.gettuple('WINDOW', 'text_color', int)
        text_surf = font.render(say, True, text_color)
        # Center the message within the bottom third
        text_rect = text_surf.get_rect(center=bottom_rect.center)
        surface.blit(text_surf, text_rect)
    
        pygame.display.update()
        LOGGER.info("[custom_processing] Thumbnails hung with rotation")Code language: PHP (php)

    This is what it ends up looking like, anonymized:

    So yeah, use at your own risk. I don’t offer any support. Assume it will break things and burn down your house. I am not a professional. Don’t trust Python.

    Farewell!

  • Rescuing Tampermonkey Scripts from a LevelDB Backup

    Rescuing Tampermonkey Scripts from a LevelDB Backup

    I have a confession to make. I didn’t back up my Tampermonkey scripts. I recently switched to a new computer, and thought I had copied all of my important information over from the old one.

    It turns out, I did not. Luckily I did have a full disk backup that I thought I could just pull the .user.js files off of. OH BOY WAS I WRONG.

    The Oops: No Script Backup

    I’d cobbled together a dozen userscripts or more over years, but never bothered to add them to a backup routine. When I opened Tampermonkey on the new machine, it greeted me with the emptiest dashboard imaginable.

    Digging Into Chrome’s LevelDB

    It turns out Chrome buries extension data in your profile directory. The Tampermonkey store lives in a LevelDB folder named after its extension ID:

    /Volumes/OldHDD/derrick/Library/Application Support/Google/Chrome/Profile 1/IndexedDB/\
    chrome-extension_dhdgffkkebhmkfjojejmpbldmpobfkfo_0.indexeddb.leveldb

    Dumping with leveldbutil

    I used leveldbutil, a C++ command-line tool for dumping LevelDB databases. I had to clone the repo and compile it from source before it would run on macOS.

    ./leveldbutil dump "/Volumes/OldHDD/derrick/Library/Application Support/Google/Chrome/Profile 1/IndexedDB/chrome-extension_dhdgffkkebhmkfjojejmpbldmpobfkfo_0.indexeddb.leveldb" > tampermonkey-dump.txtCode language: Bash (bash)

    This produced a massive text dump (tampermonkey-dump.txt) of JSON blobs—metadata, source code, state… you name it.

    Parsing the Dump: My One-Off Script

    Rather than manually eyeballing hundreds of lines, I whipped up a throwaway PHP script, let’s call it ScriptSalvager, to automate recovery. It:

    1. Loads the dump file.
    2. Scans for script entries via regex.
    3. json_decode()s each blob.
    4. Writes out individual script files.
    <?php
    /**
     * Exports Tampermonkey userscripts from a raw dump to per-script directories.
     *
     * This script reads the Tampermonkey IndexedDB dump, splits it into
     * individual userscript segments, parses each piece (meta, source,
     * rules, state, etc.), and writes each to its own file.
     */
    
    // Path to the raw Tampermonkey dump.
    $file = __DIR__ . '/tampermonkey-dump.txt';
    
    if ( ! is_readable( $file ) ) {
    	fwrite( STDERR, 'Cannot read file: ' . $file . "\n" );
    	exit( 1 );
    }
    
    // Load the file.
    $contents = file_get_contents( $file );
    
    // Split on lines like: "--- offset 123456; sequence 789".
    $segments = preg_split( '/^--- offset \d+; sequence \d+/m', $contents );
    
    // Remove any leading empty segment
    if ( isset( $segments[0] ) && trim( $segments[0] ) === '' ) {
    	array_shift( $segments );
    }
    
    // Collect only the userscript segments (those with both @meta and @source).
    $userscripts = [];
    foreach ( $segments as $segment ) {
    	if ( str_contains( $segment, "put '@meta#" ) && str_contains( $segment, "put '@source#" ) ) {
    		  // Normalize line endings and split into lines for readability.
    		  $normalized = str_replace( [ "\r\n", "\r" ], "\n", $segment );
    		  $lines = explode( "\n", $normalized );
    
    		  // Trim each line, and remove empty or external-resource lines.
    		  $filtered = [];
    		  foreach ( $lines as $line ) {
    			  $line = trim( $line );
    			  // Skip blank lines.
    			  if ( $line === '' ) {
    				  continue;
    			  }
    			  // Skip external-resource entries.
    			  if ( str_starts_with( $line, "put '@ext#" ) ) {
    				  continue;
    			  }
    			  $filtered[] = $line;
    		  }
    
    		  $userscripts[] = $filtered;
    	}
    }
    
    // Free memory we no longer need.
    unset( $contents, $segments );
    
    // Build an associative array of scripts by their UID values.
    $scripts = [];
    foreach ( $userscripts as $lines ) {
    	$items = [];
    	$script_uuid = null;
    	foreach ( $lines as $line ) {
    		  $trimmed = trim( $line );
    		  if ( ! str_starts_with( $trimmed, "put '" ) ) {
    			  echo "Error: Expected line to start with 'put '\n";
    			  die();
    		  }
    		  // Strip off leading "put '".
    		  $rest = substr( $trimmed, strlen( "put '" ) );
    		  // Split into type and remainder.
    		  [ $type, $after_hash ] = explode( '#', $rest, 2 );
    		  // Split remainder into UUID+closing-quote and JSON blob.
    		  [ $uid_quoted, $json_with_quote ] = explode( "' ", $after_hash, 2 );
    		  // Extract the UUID (no quotes).
    		  $uuid = trim( $uid_quoted, "'" );
    		  if ( $script_uuid === null ) {
    			  $script_uuid = $uuid;
    		  }
    
    		  // Trim single-quotes from JSON.
    		  $json_blob = trim( $json_with_quote, "'" );
    		  $value = $json_blob;
    		  $value = json_decode( $json_blob, true );
    		  if ( '@source' === $type ) {
    			  // Decode the source JSON.
    			  $value = json_decode( $json_blob, true );
    			  $value = $value['value'];
    		  } else {
    			$value = json_encode( $value['value'] ) ?? $json_blob;
    		}
    
    		  // Store, allowing duplicates to become arrays.
    		  if ( isset( $items[$type] ) ) {
    			  if ( ! is_array( $items[$type] ) ) {
    				  $items[$type] = [ $items[$type] ];
    			  }
    			  $items[$type][] = $value;
    		  } else {
    			  $items[$type] = $value;
    		  }
    	}
    	// Use the extracted UUID as the script key.
    	if ( $script_uuid === null ) {
    		echo "Error: Could not extract script UUID\n";
    		die();
    	}
    
    	if ( ! isset( $scripts[$script_uuid] ) ) {
    		$scripts[$script_uuid] = [];
    	}
    
    	$scripts[$script_uuid][] = $items;
    }
    
    // Add script names to the array.
    foreach ( $scripts as $uuid => $script ) {
    	foreach ( $script as $index => $version ) {
    		$name = $uuid;
    		// Check if the script has a name.
    		if ( isset( $version['@uid'] ) ) {
    			$uid = $version['@uid'];
    			$name = $uid;
    		} elseif ( isset( $version['@source'] ) ) {
    			$source = $version['@source'];
    			$normalized_source = str_replace( [ "\r\n", "\r" ], "\n", $source ?? '' );
    			$lines_source = explode( "\n", $normalized_source );
    			foreach( $lines_source as $line_source ) {
    				$line_source = trim( $line_source );
    				// Skip blank lines.
    				if ( $line_source === '' ) {
    					continue;
    				}
    
    				if ( str_contains( $line_source, "@name" ) ) {
    					$name_lines = explode( "@name", $line_source );
    					$name = trim( $name_lines[1] );
    					break;
    				}
    			}
    		}
    		if ( $name === $uuid ) {
    			echo "Error: Could not extract script name\n";
    			die();
    		}
    
    		// Remove leading and trailing quotes.
    		$name = trim( $name, '"' );
    
    		// Store the name in the script array.
    		$scripts[$uuid][$index]['@name'] = $name;
    	}
    }
    
    // If a script has multiple meta entries, split them into separate scripts.
    foreach ( $scripts as $uuid => $script ) {
    	foreach ( $script as $index => $version ) {
    		// Check if the script has multiple meta entries.
    		if ( isset( $version['@meta'] ) && is_array( $version['@meta'] ) ) {
    			foreach ( $version['@meta'] as $meta_index => $meta_value ) {
    				// Copy the script data to a new entry.
    				$new_script = $scripts[$uuid][$index];
    				// Remove the meta entry from the original script.
    				unset( $new_script['@meta'] );
    				// Copy the single meta entry to the new script.
    				$new_script['@meta'] = $meta_value;
    				// Unset the meta entry from the original script.
    				unset( $scripts[$uuid][$index]['@meta'] );
    				// Add the new script to the array.
    				$scripts[$uuid][] = $new_script;
    			}
    			// After splitting, remove the original script entry.
    			unset( $scripts[$uuid][$index] );
    		}
    	}
    }
    
    // Add lastModified to the array.
    foreach ( $scripts as $uuid => $script ) {
    	foreach ( $script as $index => $version ) {
    		// Check if the script has a lastModified.
    		if ( isset( $version['@meta'] ) ) {
    			$meta = $version['@meta'];
    			$meta = json_decode( $meta, true );
    			$meta = $meta['value'] ?? $meta;
    		   
    			if ( isset( $meta['lastModified'] ) ) {
    				$last_modified = $meta['lastModified'];
    				$scripts[$uuid][$index]['@lastModified'] = $last_modified;
    				$scripts[$uuid][$index]['@lastModifiedHuman'] = date( 'Y-m-d H:i:s', intval($last_modified/1000 ));
    			}
    
    			if ( isset( $meta['version'] ) ) {
    				$version_number = $meta['version'];
    				$scripts[$uuid][$index]['@version'] = $version_number;
    			}
    
    		}
    	}
    }
    
    // Export each script’s data into its own directory, with a unique name, timestamp, and version.
    foreach ( $scripts as $uuid => $script ) {
    	$latest_version = 0;
    	foreach ( $script as $index => $version ) {
    		// Determine directory name based on script name (fallback to UUID).
    		$name = $version['@name'];
    
    		$directory = $name . '/' . $version['@lastModifiedHuman'] . ' - v' . $version['@version'];
    
    		// Create or clear directory.
    		if ( ! is_dir( $directory ) ) {
    			mkdir( $directory, 0777, true );
    		}
    
    		// Write each part to its own JSON file.
    		foreach ( $version as $type => $value ) {
    			$base = trim( $type, '@' );
    
    			if ( 'source' === $base) {
    				// Export the userscript source unescaped so code remains intact.
    				$file_name = "{$directory}/{$base}.user.js";
    				file_put_contents( $file_name, $value );
    			} else {
    				// Single value, write normally.
    				if ( ! in_array( $base, [ 'name', 'lastModified', 'lastModifiedHuman', 'version' ] ) ) {
    					$value = json_decode( $value, true );
    				}
    				$file_name = "{$directory}/{$base}.json";
    				file_put_contents( $file_name, json_encode( $value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) );
    			}
    		}
    
    		// Find the most recent lastModified date and set it as the "latest" version.
    		if ( isset( $version['@lastModified'] ) && $version['@lastModified'] > $latest_version ) {
    			$latest_version = $version['@lastModified'];
    
    			// Are we at the end of the array?
    			if ( $index === count( $script ) - 1 ) {
    				// Create the latest version directory.
    				$latest_directory = $name . '/latest';
    				if ( ! is_dir( $latest_directory ) ) {
    					mkdir( $latest_directory, 0777, true );
    				}
    				// Write each part to its own JSON file.
    				foreach ( $version as $type => $value ) {
    					if ( $latest_version !== $version['@lastModified'] ) {
    						continue;
    					}
    					$base = trim( $type, '@' );
    
    					if ( 'source' === $base) {
    						// Export the userscript source unescaped so code remains intact.
    						$file_name = "{$latest_directory}/{$base}.user.js";
    						file_put_contents( $file_name, $value );
    					} else {
    						// Single value, write normally.
    						if ( ! in_array( $base, [ 'name', 'lastModified', 'lastModifiedHuman', 'version' ] ) ) {
    							$value = json_decode( $value, true );
    						}
    						$file_name = "{$latest_directory}/{$base}.json";
    						file_put_contents( $file_name, json_encode( $value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) );
    					}
    				}
    			}
    		}
    	}
    }Code language: PHP (php)

    Note: This was a one-and-done hack, don’t judge my variable names.

    The Recovery Steps

    1. Mount or locate your backup folder.
    2. Dump the LevelDB files: ./leveldbutil dump "/path/to/<profile>.leveldb" > tampermonkey-dump.txt
    3. Run ScriptSalvager: php scriptsalvager.php
    4. Cross your fingers and hope it dumps out all of your scripts.

    Lessons (That I Probably Won’t Learn)

    • Always export your userscripts from Tampermonkey–seriously.
    • Add them to your actual backup routine.
    • Or just never switch laptops again. 😬

    Happy salvaging!

  • Stop WordPress From Sending Emails (And Log Them Too!)

    Stop WordPress From Sending Emails (And Log Them Too!)

    While doing some development work recently, I wanted to make sure that I disabled all email sending in my test site so that no users imported would get any weird emails.

    To do this I had ChatGPT whip me up a quick plugin, and after cleaning it up here it is to share with the world:

    <?php
    /**
     * Plugin Name: Restrict and Log Emails
     * Description: Blocks emails for users and logs all email attempts.
     * Version: 1.3
     * Author: Emrikol
     */
    
    if ( ! defined( 'ABSPATH' ) ) {
    	exit; // Prevent direct access.
    }
    define( 'REL_EMAIL_LOG_DB_VERSION', '1.2' );
    
    /**
     * Database installation and upgrade function.
     * Creates or updates the email_log table if needed based on a stored version option.
     *
     * @return void
     */
    function rel_install() {
    	global $wpdb;
    	$table_name      = $wpdb->prefix . 'email_log';
    	$charset_collate = $wpdb->get_charset_collate();
    
    	$sql = "CREATE TABLE $table_name (
    	   id mediumint(9) NOT NULL AUTO_INCREMENT,
    	   recipient_email varchar(100) NOT NULL,
    	   subject text NOT NULL,
    	   message text NOT NULL,
    	   status varchar(20) NOT NULL,
    	   sent_at datetime DEFAULT '0000-00-00 00:00:00' NOT NULL,
    	   PRIMARY KEY (id)
    	) $charset_collate;";
    
    	require_once ABSPATH . 'wp-admin/includes/upgrade.php';
    	dbDelta( $sql );
    
    	// Update the database version option.
    	update_option( 'rel_email_log_db_version', REL_EMAIL_LOG_DB_VERSION, false );
    }
    register_activation_hook( __FILE__, 'rel_install' );
    
    /**
     * Adds the Email Log submenu to Tools in the WordPress admin area.
     *
     * @return void
     */
    function rel_add_admin_menu(): void {
    	if ( function_exists( 'add_submenu_page' ) ) {
    		add_submenu_page(
    			'tools.php',
    			'Email Log',
    			'Email Log',
    			'manage_options',
    			'email-log',
    			'rel_email_log_page'
    		);
    	}
    }
    add_action( 'admin_menu', 'rel_add_admin_menu' );
    
    /**
     * Displays the Email Log page in the WordPress admin area.
     *
     * @return void
     */
    function rel_email_log_page(): void {
    	global $wpdb;
    	$table_name = $wpdb->prefix . 'email_log';
    	$logs       = $wpdb->get_results( "SELECT * FROM $table_name ORDER BY sent_at DESC LIMIT 50" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
    
    	echo '<div class="wrap">';
    	echo '<h1>Email Log</h1>';
    
    	if ( current_user_can( 'manage_options' ) ) {
    		// Process toggle form submission.
    		if ( isset( $_POST['rel_toggle_blocking'] ) && check_admin_referer( 'rel_toggle_blocking_action', 'rel_toggle_blocking_nonce' ) ) {
    			// Sanitize and update the blocking option.
    			if ( isset( $_POST['rel_email_blocking_enabled'] ) && in_array( $_POST['rel_email_blocking_enabled'], array( 'enabled', 'disabled' ), true ) ) {
    				$blocking = $_POST['rel_email_blocking_enabled']; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
    			} else {
    				$blocking = 'enabled';
    			}
    
    			update_option( 'rel_email_blocking_enabled', $blocking, false );
    			echo '<div class="updated notice"><p>Email blocking has been ' . ( 'enabled' === $blocking ? 'enabled' : 'disabled' ) . '.</p></div>';
    		}
    		$current_blocking = get_option( 'rel_email_blocking_enabled', true );
    
    		echo '<form method="post" style="margin-bottom:20px;">';
    		wp_nonce_field( 'rel_toggle_blocking_action', 'rel_toggle_blocking_nonce' );
    		echo '<p>Email blocking is currently <strong>' . ( 'enabled' === $current_blocking ? 'enabled' : 'disabled' ) . '</strong>.';
    		echo '<br/><br/><label for="rel_email_blocking_enabled">Toggle: </label>';
    		echo '<select id="rel_email_blocking_enabled" name="rel_email_blocking_enabled">';
    		echo '<option value="enabled"' . selected( $current_blocking, 'enabled', false ) . '>Enabled</option>';
    		echo '<option value="disabled"' . selected( $current_blocking, 'disabled', false ) . '>Disabled</option>';
    		echo '</select> ';
    		echo '<input type="submit" name="rel_toggle_blocking" value="Update" class="button-primary" />';
    		echo '</p>';
    		echo '</form>';
    	}
    
    	echo '<table class="widefat fixed" cellspacing="0">';
    	echo '<thead><tr><th>ID</th><th>Email</th><th>Subject</th><th>Message</th><th>Status</th><th>Sent At</th></tr></thead>';
    	echo '<tbody>';
    
    	if ( $logs ) {
    		foreach ( $logs as $log ) {
    			$truncated_message = ( strlen( $log->message ) > 30 ) ? substr( $log->message, 0, 30 ) . '…' : $log->message;
    			echo wp_kses_post(
    				'<tr>
    					<td>' . $log->id . '</td>
    					<td>' . $log->recipient_email . '</td>
    					<td>' . $log->subject . '</td>
    					<td>' . $truncated_message . '</td>
    					<td>' . $log->status . '</td>
    					<td>' . $log->sent_at . '</td>
    				  </tr>'
    			);
    		}
    	} else {
    		echo '<tr><td colspan="6">No emails logged yet.</td></tr>';
    	}
    
    	echo '</tbody></table>';
    	echo '</div>';
    }
    
    /**
     * Intercepts email sending attempts, restricts emails for users without the 'manage_options' capability, and logs the attempt.
     *
     * @param null|bool $short_circuit Default value if email is allowed.
     * @param array     $atts An array of email attributes (to, subject, etc.).
     * @return bool|null Returns a non-null value to short-circuit email sending if restricted.
     */
    function rel_prevent_email( $short_circuit, $atts ) {
    	global $wpdb;
    	$table_name = $wpdb->prefix . 'email_log';
    
    	$recipient_email = isset( $atts['to'] ) ? $atts['to'] : '';
    	$subject         = isset( $atts['subject'] ) ? $atts['subject'] : '';
    	$message         = isset( $atts['message'] ) ? $atts['message'] : '';
    	$sent_at         = current_time( 'mysql' );
    
    	// Check if email blocking is enabled (default true).
    	$blocking = get_option( 'rel_email_blocking_enabled', 'enabled' );
    	switch ( $blocking ) {
    		case 'enabled':
    			$blocking_enabled = true;
    			break;
    		case 'disabled':
    			$blocking_enabled = false;
    			break;
    		default:
    			$blocking_enabled = true;
    			break;
    	}
    
    	// Determine status based on blocking setting.
    	if ( true === $blocking_enabled ) {
    		$status = 'Blocked';
    	} else {
    		$status = 'Sent';
    	}
    
    	// Log the email attempt.
    	$wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
    		$table_name,
    		array(
    			'recipient_email' => $recipient_email,
    			'subject'         => $subject,
    			'message'         => $message,
    			'status'          => $status,
    			'sent_at'         => $sent_at,
    		)
    	);
    
    	// If blocking is enabled and the current user cannot manage options, block the email, otherwise allow sending.
    	if ( $blocking_enabled ) {
    		return true;
    	}
    	return null;
    }
    add_filter( 'pre_wp_mail', 'rel_prevent_email', 10, 2 );
    
    /**
     * Checks the current database version and updates the email_log table if necessary.
     *
     * @return void
     */
    function rel_check_db_version() {
    	if ( get_option( 'rel_email_log_db_version' ) !== REL_EMAIL_LOG_DB_VERSION ) {
    		rel_install();
    	}
    }
    add_action( 'init', 'rel_check_db_version' );
    
    Code language: PHP (php)

    As usual, don’t use this slop, nor anything you read here because this is my blarg and I can’t be trusted to do anything correct.

  • How I Saved My MacBook Pro From Bad RAM

    How I Saved My MacBook Pro From Bad RAM

    Oh boy, how did we end up here?

    When the first macOS Sequoia public beta came out, I went ahead and installed it on my 2019 Intel MacBook Pro. I’ve never had a problem with public betas, and usually try them out.

    Well, this time something went wrong. I don’t remember the details, but the install crashed, froze, or borked somehow near the end. I restarted and it seemed to finish.

    … but we wouldn’t be here if that were the end of the story…

    Something felt off, and whenever I rebooted my MacBook Pro, anything I did reverted itself to back to the end of the public beta install. I was stuck in some sort of disk snapshot mode. I tried repairing the disk, I tried rebooting into single user mode and fixing things, all sorts of stuff I tried to think of, stuff I googled, and stuff I dangerously got from ChatGPT. Nothing would fix it to where I could permanently modify my drive anymore.

    So I did what anyone would do–I backed up my important files and completely reformatted the drive and did a fresh install once the final version of Sequoia came out.

    And that worked, and things were fine. Except for some random crashes every now and then. Sometimes a Firefox tab would crash. Sometimes the machine would not come out of sleep. Sometimes Messages would not sync from iCloud. Sometimes entire programs would crash.

    I had just assumed it was the perils of the new macOS on older hardware, and I kept going forward… 15.1 came out, and problems persisted… 15.2…. 15.3… well, by now any bugs should be fixed! Something is wrong. I of course understand that these random crashes feel a lot like bad memory, but I was in denial. The MacBook had soldered RAM and there was nothing I could 😭

    I first went with Memtester as a quick test, just to confirm that my RAM is fine:

    $ sudo memtester 16G 2
    Password:
    memtester version 4.7.0 (64-bit)
    Copyright (C) 2001-2024 Charles Cazabon.
    Licensed under the GNU General Public License version 2 (only).
    
    pagesize is 4096
    pagesizemask is 0xfffffffffffff000
    want 16384MB (17179869184 bytes)
    got  16384MB (17179869184 bytes), trying mlock ...locked.
    Loop 1/2:
      Stuck Address       : testing   1FAILURE: possible bad address line at offset 0x0000000254c27038.
    Skipping to next test...
      Random Value        : FAILURE: 0x06bf7b9befebc9e5 != 0x0ebf7b9befebc9e5 at offset 0x0000000054c27840.
    FAILURE: 0x2106487df0007127 != 0x2906487df0007127 at offset 0x0000000054c27030.
    FAILURE: 0x2106487df0007127 != 0x2906487df0007127 at offset 0x0000000054c27038.
    FAILURE: 0x2106487df0007127 != 0x2906487df0007127 at offset 0x0000000054c28238.
      Compare XOR         :   Compare SUB         : ok
    FAILURE: 0x0400ed73c8282b4d != 0x0c00ed73c8282b4d at offset 0x0000000054c27038.
    FAILURE: 0x0400ed73c8282b4d != 0x0c00ed73c8282b4d at offset 0x0000000054c28238.
      Compare MUL         : FAILURE: 0x0000000000000002 != 0x0800000000000002 at offset 0x0000000054c27030.
    FAILURE: 0x0000000000000002 != 0x0800000000000002 at offset 0x0000000054c27038.
    FAILURE: 0x0000000000000002 != 0x0800000000000002 at offset 0x0000000054c28238.
      Compare DIV         :   Compare OR          : ok
      Compare AND         : ok
      Sequential Increment: ok
      Solid Bits          : testing   0FAILURE: 0x0000000000000000 != 0x0800000000000000 at offset 0x0000000054c27038.
    FAILURE: 0x0000000000000000 != 0x0800000000000000 at offset 0x0000000054c28238.
      Block Sequential    : testing   0FAILURE: 0x0000000000000000 != 0x0800000000000000 at offset 0x0000000054c27030.
    FAILURE: 0x0000000000000000 != 0x0800000000000000 at offset 0x0000000054c27038.
    FAILURE: 0x0000000000000000 != 0x0800000000000000 at offset 0x0000000054c28238.
      Checkerboard        : testing   1FAILURE: 0x5555555555555555 != 0x5d55555555555555 at offset 0x0000000054c27038.
    FAILURE: 0x5555555555555555 != 0x5d55555555555555 at offset 0x0000000054c28238.
      Bit Spread          : testing  57FAILURE: 0xf5ffffffffffffff != 0xfdffffffffffffff at offset 0x0000000054c27038.
      Bit Flip            : testing   0FAILURE: 0x0000000000000001 != 0x0800000000000001 at offset 0x0000000054c27038.
    FAILURE: 0x0000000000000001 != 0x0800000000000001 at offset 0x0000000054c28238.
      Walking Ones        : setting   4Code language: PHP (php)

    I was nervous! There were failures! But I also knew that testing live in the OS isn’t perfect, so maybe there was a chance! I whipped up a flash drive with Memtest86 to prove that my memory was still good. Phew.

    Yeah… about that…

    I had a serious problem! This machine is still perfectly fine. I had to make a choice:

    1. I could try to repair it myself. I’ve never done any BGA work myself, but I’ve seen plenty of it done and honestly how hard could it be? *nervous chuckle*
    2. I could pay to get this fixed. That’s the safest solution.
    3. I could install Linux and use badram to just ignore the problem. The problem with this is that I’d lose all of the MacOS magic with the ecosystem.

    Alright, so let’s go with the safest option first. I fired off a quote request to Rossman Repair Group, who seem to be the best at this stuff.

    *sad trombone noises*

    Alright, so the choices are clear. I can either start hacking away at my hardware or try some software based solutions. I knew that Linux had a nice badram option with Grub, so I started searching around to see if there was any way at all to get that working for macOS.

    I searched and continued to come up with nothing. There was no way that I could find to get badram to work with macOS. Bummer.

    …there’s a glimmer of hope though…

    In my many searches, I ran across this project:

    Could this be what I need? Basically a badram type functionality for macOS? Sweet! Alright, let’s try this out.

    So I can’t just magically install this software. It’s part of the EFI boot loader, so we need to install it there.

    BUT WAIT THERE’S MORE. From what I was able to tell, I needed an EFI shell because macOS doesn’t have a built-in way to blacklist bad RAM addresses at boot like Linux’s badram does. So the plan was simple:

    1. Install an EFI shell that would let me run the RAM-disabling utility before macOS boots.
    2. Set up rEFInd as a boot manager to automate the process.
    3. Write an EFI shell script to block the bad RAM every time the system starts.


    First, I grabbed the TianoCore UEFI Shell (aka Shell_Full.efi) from the official TianoCore repository. I copied it onto my EFI partition, so it could be accessed at boot. It went a little something like this:

    sudo mkdir -p /Volumes/ESP
    sudo mount -t msdos /dev/disk0s1 /Volumes/ESP
    sudo cp ~/Downloads/Shell_Full.efi /Volumes/ESP/EFI/tools/ShellX64.efiCode language: JavaScript (javascript)

    This meant that at boot, I’d have an EFI shell available to execute commands manually.

    Rather than having to manually select the EFI shell every time, I installed rEFInd to make life easier. This boot manager would allow me to configure an automatic script to disable the bad RAM before macOS even loads.


    After installing rEFInd and mounting the EFI partition again, I placed the RAM-disabling utility (disable-ram-area.efi) into the correct folder by running sudo cp disable-ram-area.efi /Volumes/ESP/EFI/refind/


    Now came the fun part—writing a script (startup.nsh) that would automatically execute disable-ram-area.efi with the right parameters every time the system booted.

    My bad RAM addresses (from MemTest86) were 0x1608F80780x1628FFCB4 (~32MB). To ensure alignment, I slightly expanded the range to 0x1608F70000x162A00000. This ensures that the entire faulty region is covered and avoids instability caused by page misalignment.

    My final startup.nsh script looked like this:

    echo Running disable-ram-area.efi
    
    # Locate the EFI partition (fs0:)
    set DISK fs0
    
    # Check if disable-ram-area.efi exists
    if not exist %DISK%\EFI\refind\disable-ram-area.efi then
        echo ERROR: Could not find disable-ram-area.efi in /EFI/refind/. Exiting...
        exit
    endif
    
    echo Found disable-ram-area.efi on %DISK%
    stall 1000000
    
    # Run the memory fix
    %DISK%\EFI\refind\disable-ram-area.efi 0x1608F7000 0x162A00000
    
    echo Starting macOS
    stall 1000000
    
    # Locate macOS bootloader (fs3:)
    set MACOS fs3
    
    # Check if boot.efi exists
    if not exist %MACOS%\System\Library\CoreServices\boot.efi then
        echo ERROR: Could not find boot.efi in fs3:. Exiting...
        exit
    endif
    
    echo Found boot.efi on %MACOS%
    stall 1000000
    
    # Boot macOS
    %MACOS%\System\Library\CoreServices\boot.efiCode language: PHP (php)

    This script does the following:

    1. Finds the EFI partition (fs0:) where disable-ram-area.efi is stored.
    2. Runs disable-ram-area.efi with my calculated bad RAM range.
    3. Finds the correct fsX: partition that contains boot.efi (macOS bootloader).
    4. Boots macOS, now with the bad RAM disabled.

    After saving startup.nsh to /Volumes/ESP/EFI/refind/, I rebooted into rEFInd, selected “EFI Shell”, and ran:

    fs0:
    cd EFI/refind
    startup.nsh

    It worked! The script executed, disabled my bad RAM, and booted into macOS.

    To make sure this runs automatically every time I turn on my Mac, I edited refind.conf and added:

    menuentry "Boot macOS with Defective RAM Disabled" {
        loader /EFI/tools/ShellX64.efi
        options "fs0:\EFI\refind\startup.nsh"
    }Code language: JavaScript (javascript)


    And to set it as the default boot option, I updated:

    default_selection "Boot macOS with Defective RAM Disabled"Code language: JavaScript (javascript)


    Now, every time I boot up my Mac, it automatically disables the bad RAM before macOS loads! Huge win!

    Now, this is just my story, please don’t take this as a tutorial. I rebuilt the entire process I have done through some scribbled notes, open tabs, and browser histories. I may have missed things or gotten them wrong and I’m too lazy to remount my EFI partition to verify that the scripts I shared here are the final ones I actually used.

    But if you find yourself in a similar position, hopefully this helps you with your own process of trying to save your MacBook.

  • Get OctoPrint Status via Bash

    Get OctoPrint Status via Bash

    I’m working on a module to add OctoPrint status to my zsh prompt, which I’ll probably write about in the future as a bigger post about my prompt customizations.

    To start with that though, I need to play around with accessing the API via curl.

    So here’s my super alpha version that will request the current status via HTTP and output it:

    #!/bin/bash
    
    OCTOPRINT_API="hunter2"
    BASE_URL="http://octoprint.example.com"
    JOB_ENDPOINT="${BASE_URL}/api/job"
    CURL_TIMEOUT="0.5"
    
    # Fetch the JSON from OctoPrint
    response="$(curl --max-time ${CURL_TIMEOUT} --silent --fail \
      --header "X-Api-Key: ${OCTOPRINT_API}" \
      "${JOB_ENDPOINT}")"
    
    # Extract fields with jq
    file_name=$(jq -r '.job.file.display' <<< "$response")
    completion=$(jq -r '.progress.completion' <<< "$response")
    state=$(jq -r '.state' <<< "$response")
    time_elapsed=$(jq -r '.progress.printTime' <<< "$response")
    time_left=$(jq -r '.progress.printTimeLeft' <<< "$response")
    
    # Round the completion percentage to two decimals
    completion_str=$(printf "%.2f" "$completion")
    
    # Convert seconds to H:MM:SS
    function fmt_time() {
      local total_seconds="$1"
      local hours=$((total_seconds / 3600))
      local minutes=$(((total_seconds % 3600) / 60))
      local seconds=$((total_seconds % 60))
      printf "%02d:%02d:%02d" "$hours" "$minutes" "$seconds"
    }
    
    # Convert the times
    time_elapsed_str=$(fmt_time "$time_elapsed")
    time_left_str=$(fmt_time "$time_left")
    
    # Print a readable summary
    echo "File: ${file_name}"
    echo "State: ${state}"
    echo "Completion: ${completion_str}%"
    echo "Time Elapsed: ${time_elapsed_str}"
    echo "Time Left: ${time_left_str}"
    Code language: Bash (bash)

    Do with this what you will.