183 lines
6.0 KiB
Python
183 lines
6.0 KiB
Python
#!/usr/bin/env python3
|
|
"""Replace the icon inside an already-built .exe, without rebuilding it.
|
|
|
|
Useful when a binary you want to keep byte-for-byte was built with a bad icon.
|
|
Only the RT_ICON / RT_GROUP_ICON resources are rewritten; no code is touched.
|
|
|
|
python tools/patch_exe_icon.py "C:/path/to/App.exe"
|
|
python tools/patch_exe_icon.py "C:/path/to/App.exe" --icon other.ico --no-backup
|
|
|
|
Sizes up to 64px are embedded as 32-bit BGRA DIBs (what Windows shells have
|
|
always understood); 128 and 256 go in PNG-compressed, as Vista+ expects.
|
|
|
|
Windows only. Requires: pillow.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import ctypes
|
|
import shutil
|
|
import struct
|
|
import sys
|
|
from ctypes import wintypes
|
|
from io import BytesIO
|
|
from pathlib import Path
|
|
|
|
from PIL import Image
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
DEFAULT_ICO = ROOT / "src-tauri" / "icons" / "icon.ico"
|
|
|
|
RT_ICON = 3
|
|
RT_GROUP_ICON = 14
|
|
LANG_EN_US = 1033
|
|
PNG_FROM = 128 # sizes >= this are stored as PNG, smaller ones as DIB
|
|
|
|
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
kernel32.BeginUpdateResourceW.argtypes = [wintypes.LPCWSTR, wintypes.BOOL]
|
|
kernel32.BeginUpdateResourceW.restype = wintypes.HANDLE
|
|
kernel32.UpdateResourceW.argtypes = [
|
|
wintypes.HANDLE, wintypes.LPCWSTR, wintypes.LPCWSTR,
|
|
wintypes.WORD, wintypes.LPVOID, wintypes.DWORD,
|
|
]
|
|
kernel32.UpdateResourceW.restype = wintypes.BOOL
|
|
kernel32.EndUpdateResourceW.argtypes = [wintypes.HANDLE, wintypes.BOOL]
|
|
kernel32.EndUpdateResourceW.restype = wintypes.BOOL
|
|
|
|
|
|
def _int_resource(value):
|
|
"""MAKEINTRESOURCE: an integer id is passed where a string pointer is expected."""
|
|
return ctypes.cast(ctypes.c_void_p(value), wintypes.LPCWSTR)
|
|
|
|
|
|
def _check(ok, what):
|
|
if not ok:
|
|
err = ctypes.get_last_error()
|
|
raise OSError(err, f"{what} failed: {ctypes.FormatError(err)}")
|
|
|
|
|
|
def to_dib(img: Image.Image) -> bytes:
|
|
"""32bpp bottom-up BGRA DIB plus the 1bpp AND mask, as RT_ICON expects."""
|
|
w, h = img.size
|
|
px = img.convert("RGBA").load()
|
|
|
|
xor = bytearray()
|
|
for y in range(h - 1, -1, -1):
|
|
for x in range(w):
|
|
r, g, b, a = px[x, y]
|
|
xor += bytes((b, g, r, a))
|
|
|
|
stride = ((w + 31) // 32) * 4
|
|
mask = bytearray()
|
|
for y in range(h - 1, -1, -1):
|
|
row = bytearray(stride)
|
|
for x in range(w):
|
|
if px[x, y][3] == 0: # 1 = transparent
|
|
row[x // 8] |= 0x80 >> (x % 8)
|
|
mask += row
|
|
|
|
header = struct.pack(
|
|
"<IiiHHIIiiII", 40, w, h * 2, 1, 32, 0, len(xor) + len(mask), 0, 0, 0, 0
|
|
)
|
|
return bytes(header + xor + mask)
|
|
|
|
|
|
def to_png(img: Image.Image) -> bytes:
|
|
buf = BytesIO()
|
|
img.save(buf, "PNG", optimize=True)
|
|
return buf.getvalue()
|
|
|
|
|
|
def load_sizes(ico_path: Path):
|
|
"""Read every image out of a .ico, smallest first."""
|
|
sizes = sorted(Image.open(ico_path).info["sizes"])
|
|
out = []
|
|
for size in sizes:
|
|
im = Image.open(ico_path)
|
|
im.size = size
|
|
out.append(im.convert("RGBA"))
|
|
return out
|
|
|
|
|
|
def build_group(images, first_id: int, blobs) -> bytes:
|
|
"""GRPICONDIR + one GRPICONDIRENTRY per image."""
|
|
data = struct.pack("<HHH", 0, 1, len(images))
|
|
for i, (im, blob) in enumerate(zip(images, blobs)):
|
|
w = 0 if im.width >= 256 else im.width
|
|
h = 0 if im.height >= 256 else im.height
|
|
data += struct.pack("<BBBBHHIH", w, h, 0, 0, 1, 32, len(blob), first_id + i)
|
|
return data
|
|
|
|
|
|
def patch(exe: Path, ico: Path, group_id: int, backup: bool) -> None:
|
|
images = load_sizes(ico)
|
|
blobs = [to_png(im) if im.width >= PNG_FROM else to_dib(im) for im in images]
|
|
|
|
if backup:
|
|
bak = exe.with_suffix(exe.suffix + ".bak")
|
|
if bak.exists():
|
|
print(f" backup already exists, leaving it alone: {bak.name}")
|
|
else:
|
|
shutil.copy2(exe, bak)
|
|
print(f" backup -> {bak.name}")
|
|
|
|
handle = kernel32.BeginUpdateResourceW(str(exe), False)
|
|
if not handle:
|
|
_check(False, "BeginUpdateResource")
|
|
|
|
try:
|
|
for i, (im, blob) in enumerate(zip(images, blobs)):
|
|
buf = ctypes.create_string_buffer(blob, len(blob))
|
|
_check(
|
|
kernel32.UpdateResourceW(
|
|
handle, _int_resource(RT_ICON), _int_resource(i + 1),
|
|
LANG_EN_US, ctypes.cast(buf, wintypes.LPVOID), len(blob),
|
|
),
|
|
f"UpdateResource RT_ICON #{i + 1}",
|
|
)
|
|
kind = "png" if im.width >= PNG_FROM else "dib"
|
|
print(f" RT_ICON #{i + 1:<2} {im.width:>3}x{im.height:<3} {kind} {len(blob):>7,}b")
|
|
|
|
group = build_group(images, 1, blobs)
|
|
buf = ctypes.create_string_buffer(group, len(group))
|
|
_check(
|
|
kernel32.UpdateResourceW(
|
|
handle, _int_resource(RT_GROUP_ICON), _int_resource(group_id),
|
|
LANG_EN_US, ctypes.cast(buf, wintypes.LPVOID), len(group),
|
|
),
|
|
"UpdateResource RT_GROUP_ICON",
|
|
)
|
|
print(f" RT_GROUP_ICON #{group_id} {len(images)} entries")
|
|
except Exception:
|
|
kernel32.EndUpdateResourceW(handle, True) # discard
|
|
raise
|
|
|
|
_check(kernel32.EndUpdateResourceW(handle, False), "EndUpdateResource")
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__)
|
|
ap.add_argument("exe", type=Path)
|
|
ap.add_argument("--icon", type=Path, default=DEFAULT_ICO)
|
|
ap.add_argument("--group-id", type=int, default=32512,
|
|
help="RT_GROUP_ICON id the shell shows (default: 32512)")
|
|
ap.add_argument("--no-backup", action="store_true")
|
|
args = ap.parse_args()
|
|
|
|
if sys.platform != "win32":
|
|
print("This tool uses the Windows resource API.", file=sys.stderr)
|
|
return 1
|
|
for p in (args.exe, args.icon):
|
|
if not p.exists():
|
|
print(f"not found: {p}", file=sys.stderr)
|
|
return 1
|
|
|
|
print(f"{args.exe}")
|
|
patch(args.exe, args.icon, args.group_id, not args.no_backup)
|
|
print("done - Explorer caches icons, so run `ie4uinit.exe -show` if the old one lingers")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|