Handle /etc/profile overriding PATH

This commit is contained in:
David Tomaschik
2026-03-16 13:55:14 -04:00
parent 2e0ebb4d6f
commit 5b1bb1c233
2 changed files with 550 additions and 0 deletions

546
bin/macos/chromebundles.py Executable file
View File

@@ -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()