From 5b1bb1c233747b1e2b710fe523dd58a27f975c14 Mon Sep 17 00:00:00 2001 From: David Tomaschik Date: Mon, 16 Mar 2026 13:55:14 -0400 Subject: [PATCH] Handle /etc/profile overriding PATH --- bin/macos/chromebundles.py | 546 +++++++++++++++++++++++++++++++++++++ dotfiles/zshrc | 4 + 2 files changed, 550 insertions(+) create mode 100755 bin/macos/chromebundles.py diff --git a/bin/macos/chromebundles.py b/bin/macos/chromebundles.py new file mode 100755 index 0000000..b1ee6aa --- /dev/null +++ b/bin/macos/chromebundles.py @@ -0,0 +1,546 @@ +#!/usr/bin/env python3 + +import argparse +import plistlib +import shutil +import stat +import subprocess +import sys +import tempfile +from pathlib import Path + +from PIL import Image, ImageEnhance + + +DEFAULT_CHROME_APP = Path("/Applications/Google Chrome.app") +DEFAULT_APPS_DIR = Path.home() / "Applications" / "Chrome Containers" + +# Edit this list for your containers. +CONTAINERS = [ +# { +# "name": "Chrome Work", +# "bundle_id": "com.example.chrome.work", +# "mode": "persistent", +# "profile_dir": str(Path.home() / ".chrome-work"), +# "badge_path": str(Path.home() / ".chrome-container-badges" / "briefcase.svg"), +# }, + { + "name": "Chrome Family", + "bundle_id": "com.example.chrome.family", + "mode": "persistent", + "profile_dir": str(Path.home() / ".chrome-family"), + "badge_path": str(Path.home() / ".chrome-container-badges" / "family.svg"), + }, + { + "name": "Chrome Research", + "bundle_id": "com.example.chrome.research", + "mode": "persistent", + "profile_dir": str(Path.home() / ".chrome-research"), + "badge_path": str(Path.home() / ".chrome-container-badges" / "research.svg"), + }, + { + "name": "Chrome Ephemeral", + "bundle_id": "com.example.chrome.ephemeral", + "mode": "ephemeral", + "profile_dir": None, + "badge_path": str(Path.home() / ".chrome-container-badges" / "fire.svg"), + }, +] + +DEFAULT_COLOR_FACTOR = 0.55 +DEFAULT_BRIGHTNESS_FACTOR = 0.94 +DEFAULT_CONTRAST_FACTOR = 0.97 +DEFAULT_BADGE_FRACTION = 0.50 +DEFAULT_PADDING_FRACTION = 0.03 +DEFAULT_BADGE_OPACITY = 0.96 + + +def run(cmd, check=True, capture_output=False, text=True): + return subprocess.run(cmd, check=check, capture_output=capture_output, text=text) + + +def require_tool(name: str): + if shutil.which(name) is None: + print(f"Missing required tool: {name}", file=sys.stderr) + sys.exit(1) + + +def parse_icon_size(path: Path): + name = path.name + if not name.endswith(".png") or not name.startswith("icon_"): + return (0, 0) + + stem = name[:-4] + rest = stem[len("icon_"):] + scale = 1 + if rest.endswith("@2x"): + rest = rest[:-3] + scale = 2 + + try: + left, right = rest.split("x", 1) + return (int(left) * scale, int(right) * scale) + except Exception: + return (0, 0) + + +def find_source_icns(app_path: Path) -> Path: + info_plist = app_path / "Contents" / "Info.plist" + resources_dir = app_path / "Contents" / "Resources" + + if not info_plist.exists(): + raise FileNotFoundError(f"Missing Info.plist: {info_plist}") + if not resources_dir.exists(): + raise FileNotFoundError(f"Missing Resources directory: {resources_dir}") + + with info_plist.open("rb") as f: + plist = plistlib.load(f) + + icon_name = plist.get("CFBundleIconFile") + if icon_name: + if not icon_name.endswith(".icns"): + icon_name += ".icns" + candidate = resources_dir / icon_name + if candidate.exists(): + return candidate + + chrome_named = sorted(resources_dir.glob("*[Cc]hrome*.icns")) + if chrome_named: + return chrome_named[0] + + any_icns = sorted(resources_dir.glob("*.icns")) + if any_icns: + return any_icns[0] + + raise FileNotFoundError(f"No .icns file found in {resources_dir}") + + +def extract_iconset(icns_path: Path, out_iconset: Path): + run(["iconutil", "-c", "iconset", str(icns_path), "-o", str(out_iconset)]) + + +def largest_png(iconset_dir: Path) -> Path: + pngs = list(iconset_dir.glob("*.png")) + if not pngs: + raise FileNotFoundError(f"No PNGs found in {iconset_dir}") + pngs.sort(key=lambda p: parse_icon_size(p)[0] * parse_icon_size(p)[1], reverse=True) + return pngs[0] + + +def rasterize_svg(svg_path: Path, out_png: Path, size: int = 1024): + # Prefer librsvg if installed. + if shutil.which("rsvg-convert"): + run([ + "rsvg-convert", + "-w", str(size), + "-h", str(size), + "-o", str(out_png), + str(svg_path), + ]) + return + + # Fallback to Inkscape CLI if available. + if shutil.which("inkscape"): + run([ + "inkscape", + str(svg_path), + "--export-type=png", + f"--export-filename={out_png}", + "-w", str(size), + "-h", str(size), + ]) + return + + raise RuntimeError( + f"SVG badge provided but no SVG rasterizer found for {svg_path}. " + "Install librsvg (rsvg-convert) or Inkscape." + ) + + +def load_badge_image(badge_path: Path, temp_dir: Path) -> Image.Image | None: + if not badge_path.exists(): + print(f"Warning: badge file not found, skipping overlay: {badge_path}") + return None + + suffix = badge_path.suffix.lower() + + if suffix == ".png": + return Image.open(badge_path).convert("RGBA") + + if suffix == ".svg": + rasterized = temp_dir / f"{badge_path.stem}.png" + rasterize_svg(badge_path, rasterized, size=1024) + return Image.open(rasterized).convert("RGBA") + + raise RuntimeError( + f"Unsupported badge format for {badge_path}. " + "Supported formats: .png, .svg" + ) + + +def compose_icon( + base_png: Path, + badge_path: str | None, + out_master: Path, + color_factor: float, + brightness_factor: float, + contrast_factor: float, + badge_fraction: float, + padding_fraction: float, + badge_opacity: float, + temp_dir: Path, +): + base = Image.open(base_png).convert("RGBA") + + muted = ImageEnhance.Color(base).enhance(color_factor) + muted = ImageEnhance.Brightness(muted).enhance(brightness_factor) + muted = ImageEnhance.Contrast(muted).enhance(contrast_factor) + + result = muted.copy() + + if badge_path: + badge = load_badge_image(Path(badge_path).expanduser(), temp_dir) + if badge is not None: + w, h = result.size + + max_badge_w = int(w * badge_fraction) + max_badge_h = int(h * badge_fraction) + pad = max(4, int(w * padding_fraction)) + + bw, bh = badge.size + scale = min(max_badge_w / bw, max_badge_h / bh) + new_size = (max(1, int(bw * scale)), max(1, int(bh * scale))) + badge = badge.resize(new_size, Image.LANCZOS) + + if badge_opacity < 1.0: + alpha = badge.getchannel("A") + alpha = ImageEnhance.Brightness(alpha).enhance(badge_opacity) + badge.putalpha(alpha) + + x = w - badge.width - pad + y = h - badge.height - pad + result.alpha_composite(badge, (x, y)) + + result.save(out_master) + + +def build_iconset_from_master(master_png: Path, out_iconset: Path): + out_iconset.mkdir(parents=True, exist_ok=True) + img = Image.open(master_png).convert("RGBA") + + sizes = [ + ("icon_16x16.png", 16), + ("icon_16x16@2x.png", 32), + ("icon_32x32.png", 32), + ("icon_32x32@2x.png", 64), + ("icon_128x128.png", 128), + ("icon_128x128@2x.png", 256), + ("icon_256x256.png", 256), + ("icon_256x256@2x.png", 512), + ("icon_512x512.png", 512), + ("icon_512x512@2x.png", 1024), + ] + + for filename, size in sizes: + resized = img.resize((size, size), Image.LANCZOS) + resized.save(out_iconset / filename) + + +def iconset_to_icns(iconset_dir: Path, out_icns: Path): + run(["iconutil", "-c", "icns", str(iconset_dir), "-o", str(out_icns)]) + + +def make_launch_script(chrome_bin: Path, mode: str, profile_dir: str | None) -> str: + chrome_bin_escaped = str(chrome_bin).replace("\\", "\\\\").replace('"', '\\"') + + if mode == "persistent": + if not profile_dir: + raise ValueError("Persistent container requires profile_dir") + profile_dir_escaped = profile_dir.replace("\\", "\\\\").replace('"', '\\"') + return f"""#!/bin/bash +set -euo pipefail + +CHROME_BIN="{chrome_bin_escaped}" +PROFILE_DIR="{profile_dir_escaped}" + +mkdir -p "$PROFILE_DIR" + +exec "$CHROME_BIN" \\ + --user-data-dir="$PROFILE_DIR" \\ + --no-first-run \\ + --no-default-browser-check \\ + --new-window +""" + elif mode == "ephemeral": + return f"""#!/bin/bash +set -euo pipefail + +CHROME_BIN="{chrome_bin_escaped}" +PROFILE_DIR="$(mktemp -d /tmp/chrome-ephemeral-XXXXXX)" + +cleanup() {{ + rm -rf "$PROFILE_DIR" +}} +trap cleanup EXIT INT TERM + +exec "$CHROME_BIN" \\ + --user-data-dir="$PROFILE_DIR" \\ + --no-first-run \\ + --no-default-browser-check \\ + --new-window +""" + else: + raise ValueError(f"Unknown mode: {mode}") + + +def write_plist(app_name: str, bundle_id: str, plist_path: Path): + plist = { + "CFBundleDisplayName": app_name, + "CFBundleExecutable": "launch", + "CFBundleIdentifier": bundle_id, + "CFBundleName": app_name, + "CFBundlePackageType": "APPL", + "CFBundleShortVersionString": "1.0", + "CFBundleVersion": "1", + "LSMinimumSystemVersion": "12.0", + "NSHighResolutionCapable": True, + "CFBundleIconFile": "applet", + } + with plist_path.open("wb") as f: + plistlib.dump(plist, f) + + +def codesign_app(app_dir: Path): + try: + run(["/usr/bin/codesign", "--force", "--deep", "--sign", "-", str(app_dir)]) + except subprocess.CalledProcessError as e: + print(f"Warning: codesign failed for {app_dir}: {e}") + + +def sanitize_container(container: dict) -> dict: + required = ["name", "bundle_id", "mode"] + for key in required: + if key not in container or not container[key]: + raise ValueError(f"Container missing required key: {key}") + + mode = container["mode"] + if mode not in {"persistent", "ephemeral"}: + raise ValueError(f"Invalid mode for {container['name']}: {mode}") + + if mode == "persistent" and not container.get("profile_dir"): + raise ValueError(f"Persistent container missing profile_dir: {container['name']}") + + return container + + +def container_matches_filter(name: str, only_names: set[str]) -> bool: + if not only_names: + return True + return name in only_names + + +def build_icon_for_app( + source_icns: Path, + badge_path: str | None, + out_icns: Path, + color_factor: float, + brightness_factor: float, + contrast_factor: float, + badge_fraction: float, + padding_fraction: float, + badge_opacity: float, +): + with tempfile.TemporaryDirectory() as tmp: + tmpdir = Path(tmp) + base_iconset = tmpdir / "base.iconset" + new_iconset = tmpdir / "new.iconset" + master_png = tmpdir / "master.png" + + extract_iconset(source_icns, base_iconset) + base_png = largest_png(base_iconset) + size = parse_icon_size(base_png) + print(f" Base icon source: {base_png.name} ({size[0]}x{size[1]})") + + compose_icon( + base_png=base_png, + badge_path=badge_path, + out_master=master_png, + color_factor=color_factor, + brightness_factor=brightness_factor, + contrast_factor=contrast_factor, + badge_fraction=badge_fraction, + padding_fraction=padding_fraction, + badge_opacity=badge_opacity, + temp_dir=tmpdir, + ) + build_iconset_from_master(master_png, new_iconset) + iconset_to_icns(new_iconset, out_icns) + + +def create_or_update_container( + container: dict, + apps_dir: Path, + chrome_bin: Path, + source_icns: Path, + force: bool, + update_icons_only: bool, + codesign: bool, + color_factor: float, + brightness_factor: float, + contrast_factor: float, + badge_fraction: float, + padding_fraction: float, + badge_opacity: float, +): + app_name = container["name"] + bundle_id = container["bundle_id"] + mode = container["mode"] + profile_dir = container.get("profile_dir") + badge_path = container.get("badge_path") + + app_dir = apps_dir / f"{app_name}.app" + contents_dir = app_dir / "Contents" + macos_dir = contents_dir / "MacOS" + resources_dir = contents_dir / "Resources" + out_icns = resources_dir / "applet.icns" + + exists = app_dir.exists() + + if update_icons_only: + if not exists: + print(f"Skipping missing app for icon update: {app_dir}") + return + print(f"Updating icon only for {app_name}...") + resources_dir.mkdir(parents=True, exist_ok=True) + build_icon_for_app( + source_icns, + badge_path, + out_icns, + color_factor, + brightness_factor, + contrast_factor, + badge_fraction, + padding_fraction, + badge_opacity, + ) + if codesign: + codesign_app(app_dir) + print(f" Updated icon: {out_icns}") + return + + if exists and not force: + print(f"Skipping existing app: {app_dir}") + return + + if exists and force: + print(f"Recreating existing app: {app_dir}") + shutil.rmtree(app_dir) + else: + print(f"Creating {app_name}...") + + macos_dir.mkdir(parents=True, exist_ok=True) + resources_dir.mkdir(parents=True, exist_ok=True) + + write_plist(app_name, bundle_id, contents_dir / "Info.plist") + + launch_script = make_launch_script(chrome_bin, mode, profile_dir) + launch_path = macos_dir / "launch" + launch_path.write_text(launch_script, encoding="utf-8") + launch_path.chmod(launch_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + if mode == "persistent": + Path(profile_dir).expanduser().mkdir(parents=True, exist_ok=True) + + build_icon_for_app( + source_icns, + badge_path, + out_icns, + color_factor, + brightness_factor, + contrast_factor, + badge_fraction, + padding_fraction, + badge_opacity, + ) + + if codesign: + codesign_app(app_dir) + + print(f" Created: {app_dir}") + + +def main(): + parser = argparse.ArgumentParser(description="Create and manage Chrome container wrapper apps on macOS.") + parser.add_argument("--chrome-app", default=str(DEFAULT_CHROME_APP), help="Path to Chrome app bundle") + parser.add_argument("--apps-dir", default=str(DEFAULT_APPS_DIR), help="Directory for generated wrapper apps") + parser.add_argument("--force", action="store_true", help="Recreate containers even if they already exist") + parser.add_argument("--update-icons-only", action="store_true", help="Only rebuild icons for existing containers") + parser.add_argument("--no-codesign", action="store_true", help="Skip ad-hoc codesigning") + parser.add_argument( + "--only", + action="append", + default=[], + help="Limit to specific container name; can be passed multiple times", + ) + parser.add_argument("--color-factor", type=float, default=DEFAULT_COLOR_FACTOR) + parser.add_argument("--brightness-factor", type=float, default=DEFAULT_BRIGHTNESS_FACTOR) + parser.add_argument("--contrast-factor", type=float, default=DEFAULT_CONTRAST_FACTOR) + parser.add_argument("--badge-fraction", type=float, default=DEFAULT_BADGE_FRACTION) + parser.add_argument("--padding-fraction", type=float, default=DEFAULT_PADDING_FRACTION) + parser.add_argument("--badge-opacity", type=float, default=DEFAULT_BADGE_OPACITY) + args = parser.parse_args() + + require_tool("iconutil") + + chrome_app = Path(args.chrome_app).expanduser().resolve() + apps_dir = Path(args.apps_dir).expanduser().resolve() + chrome_bin = chrome_app / "Contents" / "MacOS" / "Google Chrome" + + if not chrome_bin.exists(): + print(f"Chrome binary not found: {chrome_bin}", file=sys.stderr) + sys.exit(1) + + try: + import PIL # noqa: F401 + except ImportError: + print("Pillow is required. Install it with:", file=sys.stderr) + print(" python3 -m pip install --user pillow", file=sys.stderr) + sys.exit(1) + + source_icns = find_source_icns(chrome_app) + apps_dir.mkdir(parents=True, exist_ok=True) + only_names = set(args.only) + + print(f"Using Python: {sys.executable}") + print(f"Using Chrome app: {chrome_app}") + print(f"Using source icon: {source_icns}") + print(f"Apps directory: {apps_dir}") + print() + + for raw_container in CONTAINERS: + container = sanitize_container(raw_container) + if not container_matches_filter(container["name"], only_names): + continue + + create_or_update_container( + container=container, + apps_dir=apps_dir, + chrome_bin=chrome_bin, + source_icns=source_icns, + force=args.force, + update_icons_only=args.update_icons_only, + codesign=not args.no_codesign, + color_factor=args.color_factor, + brightness_factor=args.brightness_factor, + contrast_factor=args.contrast_factor, + badge_fraction=args.badge_fraction, + padding_fraction=args.padding_fraction, + badge_opacity=args.badge_opacity, + ) + print() + + print("Done.") + + +if __name__ == "__main__": + main() diff --git a/dotfiles/zshrc b/dotfiles/zshrc index 53599e0..555e2b3 100755 --- a/dotfiles/zshrc +++ b/dotfiles/zshrc @@ -181,6 +181,10 @@ have_command() { command -v "${1}" &>/dev/null } +if test -d ${HOME}/.local/bin ; then + export PATH="${HOME}/.local/bin:${PATH}" +fi + # Source extras and aliases if interactive if [[ $- == *i* ]] ; then source_if_existing $HOME/.aliases