mirror of
https://github.com/Matir/skel.git
synced 2026-05-25 13:19:07 -07:00
Handle /etc/profile overriding PATH
This commit is contained in:
546
bin/macos/chromebundles.py
Executable file
546
bin/macos/chromebundles.py
Executable 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()
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user