icns

Easily create .icns files (Mac Icons) with this Go library or the included CLI.
Log | Files | Refs | LICENSE

dll_test.go (13698B)


      1 //go:build windows
      2 
      3 package main
      4 
      5 import (
      6 	"bytes"
      7 	"fmt"
      8 	"image"
      9 	"image/color"
     10 	"os"
     11 	"os/exec"
     12 	"path/filepath"
     13 	"syscall"
     14 	"testing"
     15 	"unsafe"
     16 
     17 	"github.com/jackmordaunt/icns/cmd/shell-extension/internal/com"
     18 	"github.com/jackmordaunt/icns/cmd/shell-extension/internal/provider"
     19 	"github.com/jackmordaunt/icns/v4"
     20 	"golang.org/x/sys/windows"
     21 )
     22 
     23 // The test image has three regions: the top-left quarter is opaque red, the
     24 // top-right quarter is half-transparent blue, and the bottom half is opaque
     25 // green. Region interiors survive the icns encoder's resampling unchanged, so
     26 // we can assert exact BGRA values that prove the channel order, that alpha is
     27 // premultiplied, and (via the asymmetry) that the bitmap is top-down.
     28 var (
     29 	topLeftColor  = color.NRGBA{R: 255, G: 0, B: 0, A: 255}
     30 	topRightColor = color.NRGBA{R: 0, G: 0, B: 255, A: 128}
     31 	bottomColor   = color.NRGBA{R: 0, G: 255, B: 0, A: 255}
     32 
     33 	topLeftBGRA  = [4]byte{0, 0, 255, 255}
     34 	topRightBGRA = [4]byte{128, 0, 0, 128} // Premultiplied: 255 * 128/255 = 128.
     35 	bottomBGRA   = [4]byte{0, 255, 0, 255}
     36 )
     37 
     38 // checkPixels asserts the three regions of the test image at the expected
     39 // positions in a top-down bitmap of the given side length.
     40 func checkPixels(t *testing.T, ds dibSection, prefix string) {
     41 	t.Helper()
     42 	side := int(ds.Width)
     43 	for _, tc := range []struct {
     44 		name string
     45 		x, y int
     46 		want [4]byte
     47 	}{
     48 		{"top-left", side / 4, side / 4, topLeftBGRA},
     49 		{"top-right", side * 3 / 4, side / 4, topRightBGRA},
     50 		{"bottom", side / 2, side * 3 / 4, bottomBGRA},
     51 	} {
     52 		if got := pixel(ds, tc.x, tc.y); got != tc.want {
     53 			t.Errorf("%s%s pixel BGRA = %v, want %v", prefix, tc.name, got, tc.want)
     54 		}
     55 	}
     56 }
     57 
     58 // testICNS encodes a 128px icon set (128, 64, 32 and 16 px variants).
     59 func testICNS(t *testing.T) []byte {
     60 	t.Helper()
     61 	img := image.NewNRGBA(image.Rect(0, 0, 128, 128))
     62 	for y := 0; y < 128; y++ {
     63 		for x := 0; x < 128; x++ {
     64 			switch {
     65 			case y >= 64:
     66 				img.SetNRGBA(x, y, bottomColor)
     67 			case x < 64:
     68 				img.SetNRGBA(x, y, topLeftColor)
     69 			default:
     70 				img.SetNRGBA(x, y, topRightColor)
     71 			}
     72 		}
     73 	}
     74 	var buf bytes.Buffer
     75 	if err := icns.Encode(&buf, img); err != nil {
     76 		t.Fatalf("encoding test icns: %v", err)
     77 	}
     78 	return buf.Bytes()
     79 }
     80 
     81 // buildDLL compiles this package as a shared library. The DLL is built outside
     82 // t.TempDir because a loaded Go DLL cannot be unloaded, and Windows refuses to
     83 // delete a mapped image.
     84 func buildDLL(t *testing.T) string {
     85 	t.Helper()
     86 	dir, err := os.MkdirTemp("", "icns-shellext-*")
     87 	if err != nil {
     88 		t.Fatal(err)
     89 	}
     90 	t.Cleanup(func() { _ = os.RemoveAll(dir) }) // Best effort; see above.
     91 	out := filepath.Join(dir, "icns-shellext.dll")
     92 	cmd := exec.Command("go", "build", "-buildmode=c-shared", "-o", out, ".")
     93 	cmd.Stdout, cmd.Stderr = os.Stderr, os.Stderr
     94 	if err := cmd.Run(); err != nil {
     95 		t.Fatalf("building dll: %v", err)
     96 	}
     97 	return out
     98 }
     99 
    100 // call invokes the method at index of a COM object's vtable.
    101 func call(obj unsafe.Pointer, index int, args ...uintptr) com.HRESULT {
    102 	vtbl := *(**[32]uintptr)(obj)
    103 	r, _, _ := syscall.SyscallN(vtbl[index], append([]uintptr{uintptr(obj)}, args...)...)
    104 	return com.HRESULT(uint32(r))
    105 }
    106 
    107 const (
    108 	methodQueryInterface = iota
    109 	methodAddRef
    110 	methodRelease
    111 	methodFirst // First method of the derived interface.
    112 )
    113 
    114 func queryInterface(t *testing.T, obj unsafe.Pointer, iid *com.GUID) (unsafe.Pointer, com.HRESULT) {
    115 	t.Helper()
    116 	var out unsafe.Pointer
    117 	hr := call(obj, methodQueryInterface, uintptr(unsafe.Pointer(iid)), uintptr(unsafe.Pointer(&out)))
    118 	return out, hr
    119 }
    120 
    121 func release(obj unsafe.Pointer) uint32 {
    122 	return uint32(call(obj, methodRelease))
    123 }
    124 
    125 var (
    126 	shlwapi                = windows.NewLazySystemDLL("shlwapi.dll")
    127 	procSHCreateMemStream  = shlwapi.NewProc("SHCreateMemStream")
    128 	testGdi32              = windows.NewLazySystemDLL("gdi32.dll")
    129 	procGetObject          = testGdi32.NewProc("GetObjectW")
    130 	iidIShellItemImageFact = com.MustGUID("{BCC18B79-BA16-442F-80C4-8A59C30C463B}")
    131 )
    132 
    133 func memStream(t *testing.T, data []byte) unsafe.Pointer {
    134 	t.Helper()
    135 	r, _, _ := procSHCreateMemStream.Call(uintptr(unsafe.Pointer(unsafe.SliceData(data))), uintptr(len(data)))
    136 	if r == 0 {
    137 		t.Fatal("SHCreateMemStream returned nil")
    138 	}
    139 	// The stream lives in COM's heap, not Go's; smuggle the address past vet's
    140 	// uintptr-to-pointer check without a conversion it would flag.
    141 	var stream unsafe.Pointer
    142 	*(*uintptr)(unsafe.Pointer(&stream)) = r
    143 	return stream
    144 }
    145 
    146 // dibSection mirrors DIBSECTION from wingdi.h.
    147 type dibSection struct {
    148 	Type       int32
    149 	Width      int32
    150 	Height     int32
    151 	WidthBytes int32
    152 	Planes     uint16
    153 	BitsPixel  uint16
    154 	Bits       unsafe.Pointer
    155 	Header     struct {
    156 		Size          uint32
    157 		Width         int32
    158 		Height        int32
    159 		Planes        uint16
    160 		BitCount      uint16
    161 		Compression   uint32
    162 		SizeImage     uint32
    163 		XPelsPerMeter int32
    164 		YPelsPerMeter int32
    165 		ClrUsed       uint32
    166 		ClrImportant  uint32
    167 	}
    168 	Bitfields [3]uint32
    169 	Section   windows.Handle
    170 	Offset    uint32
    171 }
    172 
    173 func inspect(t *testing.T, hbmp windows.Handle) dibSection {
    174 	t.Helper()
    175 	var ds dibSection
    176 	r, _, err := procGetObject.Call(uintptr(hbmp), unsafe.Sizeof(ds), uintptr(unsafe.Pointer(&ds)))
    177 	if r == 0 {
    178 		t.Fatalf("GetObject: %v", err)
    179 	}
    180 	return ds
    181 }
    182 
    183 func pixel(ds dibSection, x, y int) [4]byte {
    184 	stride := int(ds.WidthBytes)
    185 	bits := unsafe.Slice((*byte)(ds.Bits), stride*int(ds.Height))
    186 	off := y*stride + x*4
    187 	return [4]byte(bits[off : off+4])
    188 }
    189 
    190 func TestDLL(t *testing.T) {
    191 	dll, err := windows.LoadDLL(buildDLL(t))
    192 	if err != nil {
    193 		t.Fatal(err)
    194 	}
    195 	getClassObject := dll.MustFindProc("DllGetClassObject")
    196 	canUnloadNow := dll.MustFindProc("DllCanUnloadNow")
    197 
    198 	getFactory := func(clsid, iid *com.GUID) (unsafe.Pointer, com.HRESULT) {
    199 		var out unsafe.Pointer
    200 		r, _, _ := getClassObject.Call(uintptr(unsafe.Pointer(clsid)), uintptr(unsafe.Pointer(iid)), uintptr(unsafe.Pointer(&out)))
    201 		return out, com.HRESULT(uint32(r))
    202 	}
    203 
    204 	if _, hr := getFactory(com.IID_IStream, com.IID_IClassFactory); hr != com.CLASS_E_CLASSNOTAVAILABLE {
    205 		t.Fatalf("DllGetClassObject(unknown CLSID) = %#x, want CLASS_E_CLASSNOTAVAILABLE", hr)
    206 	}
    207 	if _, hr := getFactory(provider.CLSID, com.IID_IStream); hr != com.E_NOINTERFACE {
    208 		t.Fatalf("DllGetClassObject(CLSID, IStream) = %#x, want E_NOINTERFACE", hr)
    209 	}
    210 	factory, hr := getFactory(provider.CLSID, com.IID_IClassFactory)
    211 	if hr != com.S_OK || factory == nil {
    212 		t.Fatalf("DllGetClassObject(CLSID, IClassFactory) = %#x, %p", hr, factory)
    213 	}
    214 	if unk, hr := queryInterface(t, factory, com.IID_IUnknown); hr != com.S_OK || unk != factory {
    215 		t.Fatalf("factory QueryInterface(IUnknown) = %#x, %p; want S_OK, %p", hr, unk, factory)
    216 	}
    217 
    218 	createInstance := func(outer unsafe.Pointer, iid *com.GUID) (unsafe.Pointer, com.HRESULT) {
    219 		var out unsafe.Pointer
    220 		hr := call(factory, methodFirst, uintptr(outer), uintptr(unsafe.Pointer(iid)), uintptr(unsafe.Pointer(&out)))
    221 		return out, hr
    222 	}
    223 	if _, hr := createInstance(factory, provider.IID_IInitializeWithStream); hr != com.CLASS_E_NOAGGREGATION {
    224 		t.Fatalf("CreateInstance(aggregated) = %#x, want CLASS_E_NOAGGREGATION", hr)
    225 	}
    226 	if _, hr := createInstance(nil, com.IID_IStream); hr != com.E_NOINTERFACE {
    227 		t.Fatalf("CreateInstance(IStream) = %#x, want E_NOINTERFACE", hr)
    228 	}
    229 
    230 	data := testICNS(t)
    231 
    232 	newProvider := func(t *testing.T) (init, thumb unsafe.Pointer) {
    233 		t.Helper()
    234 		init, hr := createInstance(nil, provider.IID_IInitializeWithStream)
    235 		if hr != com.S_OK || init == nil {
    236 			t.Fatalf("CreateInstance(IInitializeWithStream) = %#x, %p", hr, init)
    237 		}
    238 		stream := memStream(t, data)
    239 		defer release(stream)
    240 		if hr := call(init, methodFirst, uintptr(stream), 0); hr != com.S_OK {
    241 			t.Fatalf("Initialize = %#x", hr)
    242 		}
    243 		if hr := call(init, methodFirst, uintptr(stream), 0); hr != com.HRESULT_ALREADY_INITIALIZED {
    244 			t.Fatalf("second Initialize = %#x, want HRESULT_ALREADY_INITIALIZED", hr)
    245 		}
    246 		thumb, hr = queryInterface(t, init, provider.IID_IThumbnailProvider)
    247 		if hr != com.S_OK || thumb == nil {
    248 			t.Fatalf("QueryInterface(IThumbnailProvider) = %#x, %p", hr, thumb)
    249 		}
    250 		if thumb == init {
    251 			t.Fatal("IThumbnailProvider and IInitializeWithStream share a vtable pointer")
    252 		}
    253 		// COM identity: IUnknown must be the same pointer from every interface.
    254 		unk, hr := queryInterface(t, thumb, com.IID_IUnknown)
    255 		if hr != com.S_OK || unk != init {
    256 			t.Fatalf("QueryInterface(IUnknown) via IThumbnailProvider = %#x, %p; want %p", hr, unk, init)
    257 		}
    258 		release(unk)
    259 		return init, thumb
    260 	}
    261 
    262 	getThumbnail := func(t *testing.T, thumb unsafe.Pointer, cx uint32) (windows.Handle, uint32) {
    263 		t.Helper()
    264 		var (
    265 			hbmp  windows.Handle
    266 			alpha uint32
    267 		)
    268 		hr := call(thumb, methodFirst, uintptr(cx), uintptr(unsafe.Pointer(&hbmp)), uintptr(unsafe.Pointer(&alpha)))
    269 		if hr != com.S_OK {
    270 			t.Fatalf("GetThumbnail(%d) = %#x", cx, hr)
    271 		}
    272 		if hbmp == 0 {
    273 			t.Fatalf("GetThumbnail(%d) returned a null bitmap", cx)
    274 		}
    275 		return hbmp, alpha
    276 	}
    277 
    278 	for _, tc := range []struct {
    279 		cx   uint32
    280 		want int32 // Expected bitmap side.
    281 	}{
    282 		{cx: 64, want: 64},    // Exact icon size available.
    283 		{cx: 100, want: 100},  // 128px icon downscaled to fit.
    284 		{cx: 1000, want: 128}, // Nothing large enough: largest icon, unscaled.
    285 	} {
    286 		init, thumb := newProvider(t)
    287 		hbmp, alpha := getThumbnail(t, thumb, tc.cx)
    288 		if alpha != provider.WTSAT_ARGB {
    289 			t.Errorf("cx=%d: alpha = %d, want WTSAT_ARGB", tc.cx, alpha)
    290 		}
    291 		ds := inspect(t, hbmp)
    292 		if ds.Width != tc.want || ds.Height != tc.want {
    293 			t.Errorf("cx=%d: bitmap is %dx%d, want %dx%d", tc.cx, ds.Width, ds.Height, tc.want, tc.want)
    294 		}
    295 		if ds.BitsPixel != 32 || ds.Bits == nil {
    296 			t.Errorf("cx=%d: bitmap is %dbpp with bits %p, want a 32bpp DIB section", tc.cx, ds.BitsPixel, ds.Bits)
    297 		}
    298 		checkPixels(t, ds, fmt.Sprintf("cx=%d: ", tc.cx))
    299 		if err := provider.DeleteObject(hbmp); err != nil {
    300 			t.Error(err)
    301 		}
    302 		if n := release(thumb); n != 1 {
    303 			t.Errorf("cx=%d: Release(thumb) = %d, want 1", tc.cx, n)
    304 		}
    305 		if n := release(init); n != 0 {
    306 			t.Errorf("cx=%d: Release(init) = %d, want 0", tc.cx, n)
    307 		}
    308 	}
    309 
    310 	// A provider that was never initialised must fail cleanly.
    311 	init, hr := createInstance(nil, provider.IID_IThumbnailProvider)
    312 	if hr != com.S_OK {
    313 		t.Fatalf("CreateInstance(IThumbnailProvider) = %#x", hr)
    314 	}
    315 	var (
    316 		hbmp  windows.Handle
    317 		alpha uint32
    318 	)
    319 	if hr := call(init, methodFirst, 64, uintptr(unsafe.Pointer(&hbmp)), uintptr(unsafe.Pointer(&alpha))); hr != com.E_UNEXPECTED {
    320 		t.Errorf("GetThumbnail before Initialize = %#x, want E_UNEXPECTED", hr)
    321 	}
    322 	release(init)
    323 
    324 	// Garbage in must not crash the surrogate process.
    325 	init, _ = createInstance(nil, provider.IID_IInitializeWithStream)
    326 	garbage := memStream(t, []byte("not an icns file, definitely not"))
    327 	if hr := call(init, methodFirst, uintptr(garbage), 0); hr != com.S_OK {
    328 		t.Fatalf("Initialize(garbage) = %#x, want S_OK (validation is deferred)", hr)
    329 	}
    330 	release(garbage)
    331 	thumb, _ := queryInterface(t, init, provider.IID_IThumbnailProvider)
    332 	if hr := call(thumb, methodFirst, 64, uintptr(unsafe.Pointer(&hbmp)), uintptr(unsafe.Pointer(&alpha))); hr != com.WTS_E_FAILEDEXTRACTION {
    333 		t.Errorf("GetThumbnail(garbage) = %#x, want WTS_E_FAILEDEXTRACTION", hr)
    334 	}
    335 	release(thumb)
    336 	release(init)
    337 
    338 	if r, _, _ := canUnloadNow.Call(); com.HRESULT(uint32(r)) != com.S_FALSE {
    339 		t.Errorf("DllCanUnloadNow = %#x, want S_FALSE", r)
    340 	}
    341 }
    342 
    343 // TestExplorer asks the shell itself for a thumbnail, which exercises the
    344 // registry entries and the out-of-process surrogate. It needs the DLL to be
    345 // registered with regsvr32 first, so it only runs when ICNS_SHELLEXT_E2E is set.
    346 func TestExplorer(t *testing.T) {
    347 	if os.Getenv("ICNS_SHELLEXT_E2E") == "" {
    348 		t.Skip("set ICNS_SHELLEXT_E2E=1 after registering the DLL to run this test")
    349 	}
    350 	// A fresh filename per run sidesteps the shell's thumbnail cache.
    351 	path := filepath.Join(t.TempDir(), "sample.icns")
    352 	if err := os.WriteFile(path, testICNS(t), 0o644); err != nil {
    353 		t.Fatal(err)
    354 	}
    355 	if err := windows.CoInitializeEx(0, windows.COINIT_APARTMENTTHREADED); err != nil {
    356 		t.Fatal(err)
    357 	}
    358 	defer windows.CoUninitialize()
    359 
    360 	var (
    361 		shell32                         = windows.NewLazySystemDLL("shell32.dll")
    362 		procSHCreateItemFromParsingName = shell32.NewProc("SHCreateItemFromParsingName")
    363 	)
    364 	var factory unsafe.Pointer
    365 	r, _, _ := procSHCreateItemFromParsingName.Call(
    366 		uintptr(unsafe.Pointer(windows.StringToUTF16Ptr(path))),
    367 		0,
    368 		uintptr(unsafe.Pointer(iidIShellItemImageFact)),
    369 		uintptr(unsafe.Pointer(&factory)),
    370 	)
    371 	if hr := com.HRESULT(uint32(r)); hr != com.S_OK {
    372 		t.Fatalf("SHCreateItemFromParsingName = %#x", hr)
    373 	}
    374 	defer release(factory)
    375 
    376 	const (
    377 		siigbfBiggerSizeOK  = 0x01
    378 		siigbfThumbnailOnly = 0x08
    379 		size                = 128
    380 	)
    381 	// SIZE is passed by value; on x64 an 8-byte struct travels in one register.
    382 	packedSize := uintptr(size) | uintptr(size)<<32
    383 	var hbmp windows.Handle
    384 	hr := call(factory, methodFirst, packedSize, siigbfThumbnailOnly|siigbfBiggerSizeOK, uintptr(unsafe.Pointer(&hbmp)))
    385 	if hr != com.S_OK || hbmp == 0 {
    386 		t.Fatalf("IShellItemImageFactory::GetImage = %#x, hbmp %#x (is the DLL registered?)", hr, hbmp)
    387 	}
    388 	defer provider.DeleteObject(hbmp)
    389 
    390 	ds := inspect(t, hbmp)
    391 	if ds.Width != size || ds.Height != size {
    392 		t.Fatalf("shell thumbnail is %dx%d, want %dx%d", ds.Width, ds.Height, size, size)
    393 	}
    394 	if ds.Bits == nil {
    395 		t.Fatal("shell returned a device-dependent bitmap; expected a DIB section")
    396 	}
    397 	// The shell may hand back a bottom-up DIB; GetObject reports a positive
    398 	// header height either way, so probe the top half only.
    399 	if got := pixel(ds, size/4, size/2); got != topLeftBGRA && got != bottomBGRA {
    400 		t.Errorf("left pixel BGRA = %v, want %v or %v", got, topLeftBGRA, bottomBGRA)
    401 	}
    402 }