Skip to content

Commit fc04450

Browse files
jigar-fatavism
andauthored
macOS split tunneling support iOS/ iPad apps (#9093)
* Add support to show iPad and iPhone apps in split-tunnling on macOS. * Split-tunnling UI changes * code review updates * FIx ios binding issue * code review updates (#9098) * code review updates --------- Co-authored-by: atavism <paul@getlantern.org> Co-authored-by: atavism <atavism@users.noreply.github.com>
1 parent e805a18 commit fc04450

18 files changed

Lines changed: 853 additions & 103 deletions

‎lantern-core/apps/apps.go‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,12 @@ func scanAppDirs(appDirs []string, seen map[string]bool, excludeDirs []string, c
158158
keyName := normalizeKey(rawName)
159159

160160
if seen[keyID] || seen[keyPath] || (runtime.GOOS != "windows" && seen[keyName]) {
161+
// Don't descend into a bundle we already know about, or its
162+
// embedded helper apps (Xcode's agents, inspectors, ...) get
163+
// listed as if they were installed apps.
164+
if appIsDir {
165+
return filepath.SkipDir
166+
}
161167
return nil
162168
}
163169

@@ -176,6 +182,8 @@ func scanAppDirs(appDirs []string, seen map[string]bool, excludeDirs []string, c
176182
AppPath: path,
177183
IconPath: iconPath,
178184
IconBytes: iconBytes,
185+
186+
WrappedBundle: wrappedBundleName(path),
179187
}
180188

181189
if cb != nil {

‎lantern-core/apps/apps_darwin.go‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"os"
88
"path/filepath"
9+
"strings"
910

1011
"howett.net/plist"
1112
)
@@ -36,8 +37,34 @@ func loadInstalledAppsPlatform(appDirs []string, seen map[string]bool, excludeDi
3637
return scanAppDirs(appDirs, seen, excludeDirs, cb)
3738
}
3839

40+
// bundleInfoPlist returns the bundle's Info.plist path and, for iPhone/iPad
41+
// apps (<App>.app/Wrapper/<Inner>.app, no Contents/), the inner bundle name.
42+
func bundleInfoPlist(appPath string) (plistPath, wrappedBundle string) {
43+
native := filepath.Join(appPath, "Contents", "Info.plist")
44+
if _, err := os.Stat(native); err == nil {
45+
return native, ""
46+
}
47+
// Not Glob: appPath may contain pattern characters such as "[".
48+
entries, _ := os.ReadDir(filepath.Join(appPath, "Wrapper"))
49+
for _, e := range entries {
50+
if !e.IsDir() || !strings.HasSuffix(e.Name(), ".app") {
51+
continue
52+
}
53+
plist := filepath.Join(appPath, "Wrapper", e.Name(), "Info.plist")
54+
if _, err := os.Stat(plist); err == nil {
55+
return plist, e.Name()
56+
}
57+
}
58+
return native, ""
59+
}
60+
61+
func wrappedBundleName(appPath string) string {
62+
_, wrapped := bundleInfoPlist(appPath)
63+
return wrapped
64+
}
65+
3966
func getAppID(appPath string) (string, error) {
40-
plistPath := filepath.Join(appPath, "Contents", "Info.plist")
67+
plistPath, _ := bundleInfoPlist(appPath)
4168
file, err := os.Open(plistPath)
4269
if err != nil {
4370
return "", fmt.Errorf("unable to open plist: %w", err)

‎lantern-core/apps/apps_data.go‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,8 @@ type AppData struct {
66
AppPath string `json:"appPath"`
77
IconPath string `json:"iconPath"`
88
IsBrowser bool `json:"isBrowser"`
9-
IconBytes []byte `json:"iconBytes,omitempty"`
9+
// WrappedBundle is the inner bundle name (<App>.app/Wrapper/<this>) for
10+
// iPhone and iPad apps on macOS; empty for native bundles.
11+
WrappedBundle string `json:"wrappedBundle,omitempty"`
12+
IconBytes []byte `json:"iconBytes,omitempty"`
1013
}

‎lantern-core/apps/apps_exclude_darwin.go‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ package apps
44

55
import (
66
"os"
7-
"path/filepath"
87
"strings"
98

109
"howett.net/plist"
@@ -24,7 +23,7 @@ func shouldExcludeAppBundle(appPath, rawName, bundleID string) bool {
2423
return true
2524
}
2625

27-
plistPath := filepath.Join(appPath, "Contents", "Info.plist")
26+
plistPath, _ := bundleInfoPlist(appPath)
2827
f, err := os.Open(plistPath)
2928
if err != nil {
3029
return false

‎lantern-core/apps/apps_other.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,6 @@ func getAppID(appPath string) (string, error) { return appPath, nil }
1919
func loadInstalledAppsPlatform(appDirs []string, seen map[string]bool, excludeDirs []string, cb Callback) []*AppData {
2020
return nil
2121
}
22+
23+
// Only macOS has iPhone/iPad wrapper bundles.
24+
func wrappedBundleName(string) string { return "" }

‎lantern-core/apps/apps_test.go‎

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
package apps
44

55
import (
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+
1539
func 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+
}

‎lantern-core/apps/apps_windows.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1913,3 +1913,6 @@ func dirExists(p string) bool {
19131913
func getAppID(appPath string) (string, error) {
19141914
return appPath, nil
19151915
}
1916+
1917+
// Only macOS has iPhone/iPad wrapper bundles.
1918+
func wrappedBundleName(string) string { return "" }

‎lantern-core/apps/icons_darwin.go‎

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,74 @@
1-
//go:build darwin
1+
//go:build darwin && !ios
22

33
package apps
44

55
import (
66
"bytes"
77
"fmt"
8-
"log/slog"
8+
"image"
9+
_ "image/png"
910
"os"
1011
"os/exec"
1112
"path/filepath"
1213
"strconv"
14+
"strings"
1315
)
1416

1517
// getIconPath finds the .icns file inside the app bundle
1618
func getIconPath(appPath string) (string, error) {
1719
resourcesPath := filepath.Join(appPath, "Contents", "Resources")
18-
matches, err := filepath.Glob(filepath.Join(resourcesPath, "*.icns"))
20+
entries, err := os.ReadDir(resourcesPath)
21+
if err != nil && !os.IsNotExist(err) {
22+
return "", fmt.Errorf("read icon directory: %w", err)
23+
}
24+
for _, entry := range entries {
25+
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".icns") {
26+
return filepath.Join(resourcesPath, entry.Name()), nil
27+
}
28+
}
29+
return wrappedIconPath(appPath), nil
30+
}
31+
32+
// wrappedIconPath picks the AppIcon*.png with the most pixels from an
33+
// iPhone/iPad bundle's inner .app. Those bundles carry no .icns; sips resizes
34+
// PNG just as well. Ranking is by raster size, not byte size: a detailed
35+
// small icon can encode to more bytes than a flat large one.
36+
func wrappedIconPath(appPath string) string {
37+
plistPath, wrapped := bundleInfoPlist(appPath)
38+
if wrapped == "" {
39+
return ""
40+
}
41+
iconDir := filepath.Dir(plistPath)
42+
entries, err := os.ReadDir(iconDir)
1943
if err != nil {
20-
wrapped := fmt.Errorf("error globbing icons for %s: %w", appPath, err)
21-
slog.Error("glob error:", "error", wrapped)
22-
return "", wrapped
44+
return ""
45+
}
46+
best, bestPixels := "", -1
47+
for _, entry := range entries {
48+
if entry.IsDir() || !strings.HasPrefix(entry.Name(), "AppIcon") || !strings.HasSuffix(entry.Name(), ".png") {
49+
continue
50+
}
51+
iconPath := filepath.Join(iconDir, entry.Name())
52+
if pixels := pngPixels(iconPath); pixels > bestPixels {
53+
best, bestPixels = iconPath, pixels
54+
}
2355
}
24-
if len(matches) == 0 {
25-
return "", nil
56+
return best
57+
}
58+
59+
// pngPixels returns width*height from the PNG header, or -1 if the file is
60+
// not a decodable PNG. Only the header is read.
61+
func pngPixels(path string) int {
62+
f, err := os.Open(path)
63+
if err != nil {
64+
return -1
65+
}
66+
defer f.Close()
67+
cfg, _, err := image.DecodeConfig(f)
68+
if err != nil {
69+
return -1
2670
}
27-
return matches[0], nil
71+
return cfg.Width * cfg.Height
2872
}
2973

3074
func getIconBytes(appPath string) ([]byte, error) {
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
//go:build darwin && !ios
2+
3+
package apps
4+
5+
import (
6+
"os"
7+
"path/filepath"
8+
"testing"
9+
)
10+
11+
func TestGetIconPath_LiteralBundlePaths(t *testing.T) {
12+
for _, name := range []string{"App [Beta]", "App ["} {
13+
t.Run(name, func(t *testing.T) {
14+
root := t.TempDir()
15+
native := makeAppBundle(t, root, name, "test.native", true)
16+
wrapped := makeWrappedAppBundle(t, root, name+" Wrapped", "Inner [Beta]", "test.wrapped")
17+
for app, want := range map[string]string{
18+
native: filepath.Join(native, "Contents", "Resources", "AppIcon.icns"),
19+
wrapped: filepath.Join(wrapped, "Wrapper", "Inner [Beta].app", "AppIcon60x60@3x.png"),
20+
} {
21+
got, err := getIconPath(app)
22+
if err != nil {
23+
t.Fatal(err)
24+
}
25+
if got != want {
26+
t.Errorf("getIconPath(%q) = %q, want %q", app, got, want)
27+
}
28+
}
29+
})
30+
}
31+
}
32+
33+
func TestWrappedIconPath_RanksByPixelsNotBytes(t *testing.T) {
34+
root := t.TempDir()
35+
app := makeWrappedAppBundle(t, root, "Ranked", "Inner", "test.ranked")
36+
inner := filepath.Join(app, "Wrapper", "Inner.app")
37+
for _, e := range []struct{ name, data string }{
38+
{"AppIcon40x40.png", pngBytes(t, 40, true)}, // few pixels, many bytes
39+
{"AppIcon1024.png", pngBytes(t, 1024, false)}, // many pixels, few bytes
40+
{"AppIcon-bad.png", "not a png"},
41+
} {
42+
writeFile(t, filepath.Join(inner, e.name), e.data, 0o644)
43+
}
44+
if err := os.Remove(filepath.Join(inner, "AppIcon60x60@3x.png")); err != nil {
45+
t.Fatal(err)
46+
}
47+
small, _ := os.Stat(filepath.Join(inner, "AppIcon40x40.png"))
48+
large, _ := os.Stat(filepath.Join(inner, "AppIcon1024.png"))
49+
if small.Size() <= large.Size() {
50+
t.Fatalf("fixture invalid: noisy 40px icon (%d B) should exceed flat 1024px icon (%d B)", small.Size(), large.Size())
51+
}
52+
want := filepath.Join(inner, "AppIcon1024.png")
53+
if got := wrappedIconPath(app); got != want {
54+
t.Errorf("wrappedIconPath = %q, want %q", got, want)
55+
}
56+
}

0 commit comments

Comments
 (0)