33package apps
44
55import (
6+ "bytes"
7+ "image"
8+ "image/png"
9+ "math/rand"
610 "os"
711 "path/filepath"
812 "runtime"
@@ -12,6 +16,26 @@ import (
1216 "github.com/alecthomas/assert/v2"
1317)
1418
19+ // pngBytes encodes a square PNG of the given side. A noisy image compresses
20+ // poorly, so a small noisy icon can be more bytes than a large flat one.
21+ func pngBytes (t * testing.T , side int , noisy bool ) string {
22+ t .Helper ()
23+ img := image .NewNRGBA (image .Rect (0 , 0 , side , side ))
24+ rng := rand .New (rand .NewSource (int64 (side )))
25+ for i := range img .Pix {
26+ if noisy {
27+ img .Pix [i ] = byte (rng .Intn (256 ))
28+ } else {
29+ img .Pix [i ] = 0xff
30+ }
31+ }
32+ var buf bytes.Buffer
33+ if err := png .Encode (& buf , img ); err != nil {
34+ t .Fatal (err )
35+ }
36+ return buf .String ()
37+ }
38+
1539func writeFile (t * testing.T , path , content string , mode os.FileMode ) {
1640 t .Helper ()
1741 if err := os .MkdirAll (filepath .Dir (path ), 0o755 ); err != nil {
@@ -200,3 +224,100 @@ func TestLoadInstalledAppsWithDirs_EmitsCachedThenNew(t *testing.T) {
200224 t .Fatalf ("callback did not receive both cached and fresh apps; got %v" , seen )
201225 }
202226}
227+
228+ // makeWrappedAppBundle mimics an iPhone/iPad app installed from the Mac App
229+ // Store: no Contents/, the real bundle under Wrapper/ with a different name,
230+ // Info.plist at its root and PNG icons instead of .icns.
231+ func makeWrappedAppBundle (t * testing.T , root , name , inner , bundleID string ) string {
232+ t .Helper ()
233+ app := filepath .Join (root , name + ".app" )
234+ innerApp := filepath .Join (app , "Wrapper" , inner + ".app" )
235+ infoPlist := `<?xml version="1.0" encoding="UTF-8"?>
236+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
237+ <plist version="1.0">
238+ <dict>
239+ <key>CFBundleIdentifier</key>
240+ <string>` + bundleID + `</string>
241+ </dict>
242+ </plist>`
243+ writeFile (t , filepath .Join (innerApp , "Info.plist" ), infoPlist , 0o644 )
244+ writeFile (t , filepath .Join (innerApp , "AppIcon29x29.png" ), pngBytes (t , 29 , false ), 0o644 )
245+ writeFile (t , filepath .Join (innerApp , "AppIcon60x60@3x.png" ), pngBytes (t , 180 , false ), 0o644 )
246+ if err := os .Symlink (filepath .Join ("Wrapper" , inner + ".app" ), filepath .Join (app , "WrappedBundle" )); err != nil {
247+ t .Fatal (err )
248+ }
249+ return app
250+ }
251+
252+ func TestScanAppDirs_FindsWrappedIOSApp (t * testing.T ) {
253+ if runtime .GOOS != "darwin" {
254+ t .Skip ("test requires macOS .app bundle scanning" )
255+ }
256+ tmp := t .TempDir ()
257+ root := filepath .Join (tmp , "Applications" )
258+ if err := os .MkdirAll (root , 0o755 ); err != nil {
259+ t .Fatal (err )
260+ }
261+ appPath := makeWrappedAppBundle (t , root , "Stream" , "NetworkSniffer" , "com.fish.stream" )
262+ nativePath := makeAppBundle (t , root , "Native" , "com.example.native" , false )
263+ // Brackets are glob metacharacters; the bundle must still be found.
264+ makeWrappedAppBundle (t , root , "Stream [Beta]" , "NetworkSnifferBeta" , "com.fish.stream.beta" )
265+
266+ apps := scanAppDirs ([]string {root }, map [string ]bool {}, excludeDirs , nil )
267+ if len (apps ) != 3 {
268+ t .Fatalf ("expected 3 apps (outer wrappers only, inner bundles must not be listed separately), got %d: %+v" , len (apps ), apps )
269+ }
270+ byID := map [string ]* AppData {}
271+ for _ , a := range apps {
272+ byID [a .BundleID ] = a
273+ }
274+
275+ wrapped := byID ["com.fish.stream" ]
276+ if wrapped == nil {
277+ t .Fatalf ("wrapped app missing: %+v" , apps )
278+ }
279+ if wrapped .AppPath != appPath {
280+ t .Errorf ("app path: got %s want %s" , wrapped .AppPath , appPath )
281+ }
282+ if wrapped .Name != "Stream" {
283+ t .Errorf ("name should come from the outer bundle: %s" , wrapped .Name )
284+ }
285+ if wrapped .WrappedBundle != "NetworkSniffer.app" {
286+ t .Errorf ("wrappedBundle: got %q want NetworkSniffer.app" , wrapped .WrappedBundle )
287+ }
288+ if filepath .Base (wrapped .IconPath ) != "AppIcon60x60@3x.png" {
289+ t .Errorf ("icon should be the largest PNG: %s" , wrapped .IconPath )
290+ }
291+
292+ if beta := byID ["com.fish.stream.beta" ]; beta == nil || beta .WrappedBundle != "NetworkSnifferBeta.app" {
293+ t .Errorf ("bracketed wrapped app missing or wrong inner bundle: %+v" , beta )
294+ }
295+
296+ native := byID ["com.example.native" ]
297+ if native == nil || native .AppPath != nativePath {
298+ t .Fatalf ("native app missing or wrong path: %+v" , native )
299+ }
300+ if native .WrappedBundle != "" {
301+ t .Errorf ("native app must not report a wrapped bundle: %q" , native .WrappedBundle )
302+ }
303+ }
304+
305+ func TestScanAppDirs_SeenBundleIsNotDescendedInto (t * testing.T ) {
306+ if runtime .GOOS != "darwin" {
307+ t .Skip ("test requires macOS .app bundle scanning" )
308+ }
309+ root := filepath .Join (t .TempDir (), "Applications" )
310+ if err := os .MkdirAll (root , 0o755 ); err != nil {
311+ t .Fatal (err )
312+ }
313+ outer := makeAppBundle (t , root , "Xcode" , "com.apple.dt.Xcode" , false )
314+ // Helper app embedded in the outer bundle, as Xcode ships.
315+ makeAppBundle (t , filepath .Join (outer , "Contents" , "Applications" ), "Instruments" , "com.apple.dt.Instruments" , false )
316+
317+ // Outer bundle already known from the cache.
318+ seen := map [string ]bool {normalizeKey (outer ): true }
319+ apps := scanAppDirs ([]string {root }, seen , excludeDirs , nil )
320+ if len (apps ) != 0 {
321+ t .Fatalf ("expected no apps (outer already seen, helper must not surface), got %+v" , apps )
322+ }
323+ }
0 commit comments