【Win / Mac】アプリパスからアプリアイコンを保存する

# 目標

```python
save_app_icon("アプリファイル(.exeまたは.app)へのパス", "保存先パス(.png)")
```

# 環境構築

```sh:Windows
pip install pillow
```

```sh:Mac
pip install pyobjc
```

まとめてインストールするにはrequirements.txtを作成します。

```sh:requirements.txt
pyobjc; sys_platform == 'darwin'
pillow; sys_platform == 'win32'
```

```sh
pip install -r requirements.txt
```

# コード

```python
import sys

if sys.platform == "darwin":
    from AppKit import NSWorkspace, NSBitmapImageRep, NSBitmapImageFileTypePNG

    def save_app_icon(app_path: str, file_path: str) -> None:
        icon = NSWorkspace.sharedWorkspace().iconForFile_(app_path)
        bitmap = NSBitmapImageRep(data=icon.TIFFRepresentation())
        bitmap.representationUsingType_properties_(NSBitmapImageFileTypePNG, None).writeToFile_atomically_(file_path, False)

elif sys.platform == "win32":
    import ctypes
    from enum import Enum
    from math import sqrt
    from decimal import Decimal
    from PIL import Image
    from ctypes.wintypes import BOOL, BYTE, DWORD, HBITMAP, HDC, HICON, HGDIOBJ, LONG, LPCWSTR, LPVOID, UINT, WORD

    BI_RGB = 0
    DIB_RGB_COLORS = 0

    class ICONINFO(ctypes.Structure):
        _fields_ = [
            ("fIcon", BOOL),
            ("xHotspot", DWORD),
            ("yHotspot", DWORD),
            ("hbmMask", HBITMAP),
            ("hbmColor", HBITMAP)
        ]

    class RGBQUAD(ctypes.Structure):
        _fields_ = [
            ("rgbBlue", BYTE),
            ("rgbGreen", BYTE),
            ("rgbRed", BYTE),
            ("rgbReserved", BYTE),
        ]

    class BITMAPINFOHEADER(ctypes.Structure):
        _fields_ = [
            ("biSize", DWORD),
            ("biWidth", LONG),
            ("biHeight", LONG),
            ("biPlanes", WORD),
            ("biBitCount", WORD),
            ("biCompression", DWORD),
            ("biSizeImage", DWORD),
            ("biXPelsPerMeter", LONG),
            ("biYPelsPerMeter", LONG),
            ("biClrUsed", DWORD),
            ("biClrImportant", DWORD)
        ]

    class BITMAPINFO(ctypes.Structure):
        _fields_ = [
            ("bmiHeader", BITMAPINFOHEADER),
            ("bmiColors", RGBQUAD * 1),
        ]


    shell32 = ctypes.WinDLL("shell32", use_last_error=True)
    user32 = ctypes.WinDLL("user32", use_last_error=True)
    gdi32 = ctypes.WinDLL("gdi32", use_last_error=True)

    gdi32.CreateCompatibleDC.argtypes = [HDC]
    gdi32.CreateCompatibleDC.restype = HDC
    gdi32.GetDIBits.argtypes = [
        HDC, HBITMAP, UINT, UINT, LPVOID, ctypes.c_void_p, UINT
    ]
    gdi32.GetDIBits.restype = ctypes.c_int
    gdi32.DeleteObject.argtypes = [HGDIOBJ]
    gdi32.DeleteObject.restype = BOOL
    shell32.ExtractIconExW.argtypes = [
        LPCWSTR, ctypes.c_int, ctypes.POINTER(HICON), ctypes.POINTER(HICON), UINT
    ]
    shell32.ExtractIconExW.restype = UINT
    user32.GetIconInfo.argtypes = [HICON, ctypes.POINTER(ICONINFO)]
    user32.GetIconInfo.restype = BOOL
    user32.DestroyIcon.argtypes = [HICON]
    user32.DestroyIcon.restype = BOOL


    class IconSize(Enum):
        SMALL = 1
        LARGE = 2

        @staticmethod
        def to_wh(size: "IconSize") -> tuple[int, int]:
            size_table = {
                IconSize.SMALL: (16, 16),
                IconSize.LARGE: (32, 32)
            }
            return size_table[size]


    def extract_icon(filename: str, size: IconSize=IconSize.LARGE) -> ctypes.Array[ctypes.c_char]:
        dc: HDC = gdi32.CreateCompatibleDC(0)
        if dc == 0:
            raise ctypes.WinError()

        hicon: HICON = HICON()
        extracted_icons: UINT = shell32.ExtractIconExW(
            filename,
            0,
            ctypes.byref(hicon) if size == IconSize.LARGE else None,
            ctypes.byref(hicon) if size == IconSize.SMALL else None,
            1
        )
        if extracted_icons != 1:
            raise ctypes.WinError()

        def cleanup() -> None:
            if icon_info.hbmColor != 0:
                gdi32.DeleteObject(icon_info.hbmColor)
            if icon_info.hbmMask != 0:
                gdi32.DeleteObject(icon_info.hbmMask)
            user32.DestroyIcon(hicon)

        icon_info: ICONINFO = ICONINFO(0, 0, 0, 0, 0)
        if not user32.GetIconInfo(hicon, ctypes.byref(icon_info)):
            cleanup()
            raise ctypes.WinError()

        w, h = icon_info.xHotspot * 2, icon_info.yHotspot * 2
        bmi: BITMAPINFO = BITMAPINFO()
        ctypes.memset(ctypes.byref(bmi), 0, ctypes.sizeof(bmi))
        bmi.bmiHeader.biSize = ctypes.sizeof(BITMAPINFOHEADER)
        bmi.bmiHeader.biWidth = w
        bmi.bmiHeader.biHeight = -h
        bmi.bmiHeader.biPlanes = 1
        bmi.bmiHeader.biBitCount = 32
        bmi.bmiHeader.biCompression = BI_RGB
        bmi.bmiHeader.biSizeImage = w * h * 4
        bits = ctypes.create_string_buffer(bmi.bmiHeader.biSizeImage)
        copied_lines = gdi32.GetDIBits(
            dc, icon_info.hbmColor, 0, h, bits, ctypes.byref(bmi), DIB_RGB_COLORS
        )
        if copied_lines == 0:
            cleanup()
            raise ctypes.WinError()

        cleanup()
        return bits
    def win32_icon_to_image(icon_bits: ctypes.Array[ctypes.c_char], size: IconSize=IconSize.LARGE) -> Image:
        try:
            root = sqrt(len(icon_bits))
            if not Decimal(str(root)).as_tuple().exponent == -1:
                w, h = IconSize.to_wh(size)
            else:
                w = h = int(root) // 2
            img = Image.frombytes("RGBA", (w, h), icon_bits, "raw", "BGRA")
        except ValueError:
            w, h = IconSize.to_wh(size)
            img = Image.frombytes("RGBA", (w, h), icon_bits, "raw", "BGRA")
        return img
    def save_app_icon(app_path: str, file_path: str) -> None:
        icon_bits = extract_icon(app_path)
        img = win32_icon_to_image(icon_bits)
        img.save(file_path)
```

# 解説
割愛（リクエストがあれば書きます）

# 謝辞
上記のコードの中には私が書いたものだけではなく、ネットを彷徨って見つけたコードのコピペも紛れています。それらのページを見つけることができなかったので出典がありません。原作者の方ごめんなさい。
もしコピペ元だと思われるページを見つけた方は教えていただけるとありがたいです。