diff --git a/go.mod b/go.mod index e1f09c7..bc3f701 100644 --- a/go.mod +++ b/go.mod @@ -2,13 +2,20 @@ module github.com/space-code/linkctl go 1.26.0 -require github.com/spf13/cobra v1.10.2 +require ( + github.com/bitrise-io/go-xcode v1.3.1 + github.com/fatih/color v1.18.0 + github.com/spf13/cobra v1.10.2 +) require ( - github.com/fatih/color v1.18.0 // indirect + github.com/bitrise-io/go-plist v0.0.0-20210301100253-4b1a112ccd10 // indirect + github.com/bitrise-io/go-utils v1.0.9 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/spf13/pflag v1.0.9 // indirect golang.org/x/sys v0.25.0 // indirect + golang.org/x/text v0.12.0 // indirect + howett.net/plist v1.0.0 // indirect ) diff --git a/go.sum b/go.sum index d39b047..b01a6d8 100644 --- a/go.sum +++ b/go.sum @@ -1,21 +1,70 @@ +github.com/bitrise-io/go-plist v0.0.0-20210301100253-4b1a112ccd10 h1:/2OyBFI7GjYKexBPcfTPvKFz8Ks7qYzkkz2SQ8aiJgc= +github.com/bitrise-io/go-plist v0.0.0-20210301100253-4b1a112ccd10/go.mod h1:pARutiL3kEuRLV3JvswidvfCj+9Y3qMZtji2BDqLFsA= +github.com/bitrise-io/go-utils v1.0.9 h1:wy7FewUpseNSTZr41BbGH0csfFqzptFt4zy2pOAEOg0= +github.com/bitrise-io/go-utils v1.0.9/go.mod h1:ZY1DI+fEpZuFpO9szgDeICM4QbqoWVt0RSY3tRI1heY= +github.com/bitrise-io/go-xcode v1.3.1 h1:ioLPHQ+XnSafCpnFJl+d9+qdfIr0Z55yQRlEA66/XxI= +github.com/bitrise-io/go-xcode v1.3.1/go.mod h1:9OwsvrhZ4A2JxHVoEY7CPcABAKA+OE7FQqFfBfvbFuY= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= +github.com/hashicorp/go-retryablehttp v0.7.0/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.3.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20211202192323-5770296d904e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= +howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= diff --git a/internal/appcheck/appcheck.go b/internal/appcheck/appcheck.go new file mode 100644 index 0000000..26cd802 --- /dev/null +++ b/internal/appcheck/appcheck.go @@ -0,0 +1,477 @@ +package appcheck + +import ( + "fmt" + "os" + "strings" + + "github.com/space-code/linkctl/internal/models" + "github.com/space-code/linkctl/internal/pbxproj" +) + +func CheckApp(projectPath string, link *models.DeepLink, targetFilter string, configuration string) (*models.AppCheckReport, error) { + report := &models.AppCheckReport{ + ProjectPath: projectPath, + Link: link, + } + + platform, err := detectPlatform(projectPath) + if err != nil { + return nil, err + } + report.Platform = platform + + switch platform { + case "ios": + proj, err := pbxproj.ParseXcodeProject(projectPath, configuration) + if err != nil { + return nil, err + } + report.XcodeProject = proj + report.Checks = checkIOS(proj, link, targetFilter) + } + + for _, c := range report.Checks { + report.Summary.Total++ + switch c.Status { + case models.StatusPass: + report.Summary.Passed++ + case models.StatusFail: + report.Summary.Failed++ + case models.StatusWarning: + report.Summary.Warnings++ + } + } + report.Summary.OK = report.Summary.Failed == 0 + + return report, nil +} + +// ScanProject returns all deep link patterns registered in the project, +// without validating a specific link. +func ScanProject(projectPath string) (*models.ProjectScan, error) { + platform, err := detectPlatform(projectPath) + if err != nil { + return nil, err + } + + scan := &models.ProjectScan{ + ProjectPath: projectPath, + Platform: platform, + } + + switch platform { + case "ios": + proj, err := pbxproj.ParseXcodeProject(projectPath, "") + if err != nil { + return nil, err + } + for _, t := range proj.Targets { + ts := models.TargetSummary{ + Name: t.Name, + AssociatedDomains: t.AssociatedDomains, + EntitlementsPath: t.EntitlementsPath, + InfoPlistPath: t.InfoPlistPath, + } + for _, us := range t.URLSchemes { + ts.URLSchemes = append(ts.URLSchemes, us.Schemes...) + } + scan.Targets = append(scan.Targets, ts) + + for _, domain := range t.AssociatedDomains { + if strings.HasPrefix(domain, "applinks:") { + host := strings.TrimPrefix(domain, "applinks:") + host = strings.SplitN(host, "?", 2)[0] + scan.RegisteredLinks = append(scan.RegisteredLinks, models.RegisteredLink{ + Pattern: fmt.Sprintf("https://%s/**", host), + Target: t.Name, + LinkType: "Universal Link", + }) + } else if strings.HasPrefix(domain, "https://") || strings.HasPrefix(domain, "http://") { + // Malformed entry — full URL instead of "applinks:host". + // Include it so the user sees it, marked as misconfigured. + scan.RegisteredLinks = append(scan.RegisteredLinks, models.RegisteredLink{ + Pattern: domain, + Target: t.Name, + LinkType: "⚠ Bad Format (missing applinks: prefix)", + }) + } + } + for _, us := range t.URLSchemes { + for _, s := range us.Schemes { + scan.RegisteredLinks = append(scan.RegisteredLinks, models.RegisteredLink{ + Pattern: fmt.Sprintf("%s://**", s), + Target: t.Name, + LinkType: "Custom Scheme", + }) + } + } + } + } + + return scan, nil +} + +// checkIOS is the top-level iOS validator. +// +// When targetFilter is non-empty only the matching target is validated; +// otherwise every native target is validated and a cross-target scheme-conflict +// warning is emitted when applicable. +func checkIOS(proj *models.XcodeProject, link *models.DeepLink, targetFilter string) []models.ValidationResult { + var results []models.ValidationResult + + if len(proj.Targets) == 0 { + return append(results, models.ValidationResult{ + Check: "Targets Found", + Status: models.StatusFail, + Message: "No targets found in Xcode project", + Detail: "Make sure you pointed to a valid .xcodeproj directory", + }) + } + + targets, notFoundErr := selectTargets(proj.Targets, targetFilter) + if notFoundErr != nil { + return append(results, *notFoundErr) + } + + noun := "target" + if len(proj.Targets) != 1 { + noun = "targets" + } + summary := fmt.Sprintf("%d %s found", len(proj.Targets), noun) + if targetFilter != "" { + summary += fmt.Sprintf(" — checking %q", targets[0].Name) + } + results = append(results, models.ValidationResult{ + Check: "Targets Found", + Status: models.StatusPass, + Message: summary, + }) + + isUniversalLink := link.Scheme == "https" || link.Scheme == "http" + + for _, target := range targets { + prefix := fmt.Sprintf("[%s]", target.Name) + results = append(results, checkIOSTarget(target, link, prefix, isUniversalLink)...) + } + + // Cross-target scheme conflict — only meaningful when all targets are checked. + if !isUniversalLink && targetFilter == "" { + if conflicting := targetsWithScheme(proj.Targets, link.Scheme); len(conflicting) > 1 { + results = append(results, models.ValidationResult{ + Check: "Scheme Conflict", + Status: models.StatusWarning, + Message: fmt.Sprintf("Scheme '%s' is registered in multiple targets: %s", link.Scheme, strings.Join(conflicting, ", ")), + Detail: "Multiple targets with the same scheme may cause unexpected routing", + }) + } + } + + return results +} + +// selectTargets returns the targets to validate. +// filter="" → all targets. +// filter set → the single matching target, or a FAIL result listing what's available. +func selectTargets(targets []models.XcodeTarget, filter string) ([]models.XcodeTarget, *models.ValidationResult) { + if filter == "" { + return targets, nil + } + for _, t := range targets { + if strings.EqualFold(t.Name, filter) { + return []models.XcodeTarget{t}, nil + } + } + names := make([]string, len(targets)) + for i, t := range targets { + names[i] = t.Name + } + return nil, &models.ValidationResult{ + Check: "Target Found", + Status: models.StatusFail, + Message: fmt.Sprintf("Target %q not found in project", filter), + Detail: "Available targets: " + strings.Join(names, ", "), + } +} + +// checkIOSTarget validates a single Xcode target against the link. +func checkIOSTarget(target models.XcodeTarget, link *models.DeepLink, prefix string, isUniversalLink bool) []models.ValidationResult { + var results []models.ValidationResult + + if target.EntitlementsPath == "" { + status := models.StatusInfo + msg := "No entitlements file (not required for custom schemes)" + detail := "" + if isUniversalLink { + status = models.StatusFail + msg = "No entitlements file found" + detail = "Universal Links require an entitlements file with associated-domains" + } + results = append(results, models.ValidationResult{ + Check: prefix + " Entitlements", + Status: status, + Message: msg, + Detail: detail, + }) + } else { + results = append(results, models.ValidationResult{ + Check: prefix + " Entitlements", + Status: models.StatusPass, + Message: fmt.Sprintf("Found: %s", target.EntitlementsPath), + }) + } + + if isUniversalLink { + results = append(results, checkAssociatedDomains(target, link, prefix)...) + } else { + results = append(results, checkURLSchemes(target, link, prefix)...) + } + + // Bundle ID — informational only. + if target.BundleID != "" { + results = append(results, models.ValidationResult{ + Check: prefix + " Bundle ID", + Status: models.StatusInfo, + Message: target.BundleID, + }) + } + + return results +} + +// checkAssociatedDomains validates Universal Link domain registration. +func checkAssociatedDomains(target models.XcodeTarget, link *models.DeepLink, prefix string) []models.ValidationResult { + var results []models.ValidationResult + + if len(target.AssociatedDomains) == 0 { + return append(results, models.ValidationResult{ + Check: prefix + " Associated Domains", + Status: models.StatusFail, + Message: "No associated domains configured", + Detail: fmt.Sprintf("Add 'applinks:%s' to your entitlements file", link.Host), + }) + } + + results = append(results, models.ValidationResult{ + Check: prefix + " Associated Domains", + Status: models.StatusPass, + Message: fmt.Sprintf("%d domain(s): %s", len(target.AssociatedDomains), strings.Join(target.AssociatedDomains, ", ")), + }) + + // Does an applinks: entry cover this host? + if matched, matchedDomain := domainMatchesHost(target.AssociatedDomains, link.Host, "applinks"); matched { + results = append(results, models.ValidationResult{ + Check: prefix + " Host Registered", + Status: models.StatusPass, + Message: fmt.Sprintf("'%s' covered by '%s'", link.Host, matchedDomain), + }) + } else { + results = append(results, models.ValidationResult{ + Check: prefix + " Host Registered", + Status: models.StatusFail, + Message: fmt.Sprintf("Host '%s' not found in associated domains", link.Host), + Detail: fmt.Sprintf("Add 'applinks:%s' to %s", link.Host, target.EntitlementsPath), + }) + } + + // Common mistake: bare domain without applinks: prefix. + for _, d := range target.AssociatedDomains { + if d == link.Host || d == "https://"+link.Host { + results = append(results, models.ValidationResult{ + Check: prefix + " Domain Format", + Status: models.StatusFail, + Message: fmt.Sprintf("'%s' is missing the 'applinks:' prefix", d), + Detail: fmt.Sprintf("Change to 'applinks:%s'", link.Host), + }) + } + } + + // Informational: webcredentials also present for this host. + if _, wc := domainMatchesHost(target.AssociatedDomains, link.Host, "webcredentials"); wc != "" { + results = append(results, models.ValidationResult{ + Check: prefix + " Web Credentials", + Status: models.StatusInfo, + Message: "webcredentials also configured (Shared Web Credentials / Passkeys)", + }) + } + + return results +} + +// checkURLSchemes validates custom scheme registration via Info.plist. +func checkURLSchemes(target models.XcodeTarget, link *models.DeepLink, prefix string) []models.ValidationResult { + var results []models.ValidationResult + + if target.InfoPlistPath == "" && !target.GeneratesInfoPlist { + return append(results, models.ValidationResult{ + Check: prefix + " Info.plist", + Status: models.StatusFail, + Message: "Info.plist not found — cannot verify URL scheme registration", + Detail: "Set INFOPLIST_FILE or GENERATE_INFOPLIST_FILE in build settings", + }) + } + + if target.InfoPlistPath != "" { + results = append(results, models.ValidationResult{ + Check: prefix + " Info.plist", + Status: models.StatusPass, + Message: fmt.Sprintf("Found: %s", target.InfoPlistPath), + }) + } else { + results = append(results, models.ValidationResult{ + Check: prefix + " Info.plist", + Status: models.StatusInfo, + Message: "Info.plist generated at build time (GENERATE_INFOPLIST_FILE = YES)", + Detail: "URL schemes must be set via INFOPLIST_KEY_CFBundleURLTypes or a separate Info.plist", + }) + } + + if len(target.URLSchemes) == 0 { + return append(results, models.ValidationResult{ + Check: prefix + " URL Types", + Status: models.StatusFail, + Message: "No CFBundleURLTypes found in Info.plist", + Detail: fmt.Sprintf("Add '%s' to URL Types in the target's Info settings", link.Scheme), + }) + } + + allSchemes := collectAllSchemes(target.URLSchemes) + results = append(results, models.ValidationResult{ + Check: prefix + " URL Types", + Status: models.StatusPass, + Message: fmt.Sprintf("%d scheme(s): %s", len(allSchemes), strings.Join(allSchemes, ", ")), + }) + + found := false + for _, s := range allSchemes { + if strings.EqualFold(s, link.Scheme) { + found = true + break + } + } + if found { + results = append(results, models.ValidationResult{ + Check: prefix + " Scheme Registered", + Status: models.StatusPass, + Message: fmt.Sprintf("Scheme '%s://' is registered", link.Scheme), + }) + } else { + results = append(results, models.ValidationResult{ + Check: prefix + " Scheme Registered", + Status: models.StatusFail, + Message: fmt.Sprintf("Scheme '%s://' is NOT registered", link.Scheme), + Detail: fmt.Sprintf("Add '%s' to CFBundleURLSchemes in Info.plist", link.Scheme), + }) + } + + return results +} + +func detectPlatform(path string) (string, error) { + if strings.HasSuffix(path, ".xcodeproj") { + return "ios", nil + } + + entries, err := readDirNames(path) + if err != nil { + return "", fmt.Errorf("cannot read %q: %w", path, err) + } + + androidFiles := map[string]bool{ + "AndroidManifest.xml": true, + "build.gradle": true, + "build.gradle.kts": true, + "settings.gradle": true, + } + + for _, e := range entries { + if strings.HasSuffix(e, ".xcodeproj") { + return "ios", nil + } + if androidFiles[e] { + return "android", nil + } + } + + // One level deeper. + for _, e := range entries { + sub, err := readDirNames(path + "/" + e) + if err != nil { + continue + } + for _, se := range sub { + if strings.HasSuffix(se, ".xcodeproj") { + return "ios", nil + } + if androidFiles[se] { + return "android", nil + } + } + } + + return "", fmt.Errorf( + "could not detect platform in %q\n"+ + "Point to a directory containing .xcodeproj (iOS) or AndroidManifest.xml / build.gradle (Android)", + path, + ) +} + +func readDirNames(path string) ([]string, error) { + entries, err := os.ReadDir(path) + if err != nil { + return nil, err + } + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.Name()) + } + return names, nil +} + +// domainMatchesHost reports whether host is covered by any entry in domains +// with the given service prefix ("applinks" or "webcredentials"). +// Handles exact match and wildcard (*.) entries. +// Strips ?mode=developer suffixes added by Xcode Managed Capabilities. +func domainMatchesHost(domains []string, host, prefix string) (bool, string) { + for _, d := range domains { + pfx := prefix + ":" + if !strings.HasPrefix(d, pfx) { + continue + } + pattern := strings.TrimPrefix(d, pfx) + pattern = strings.SplitN(pattern, "?", 2)[0] // strip ?mode=developer + + if pattern == host { + return true, d + } + if strings.HasPrefix(pattern, "*.") { + base := strings.TrimPrefix(pattern, "*.") + if strings.HasSuffix(host, "."+base) { + return true, d + } + } + } + return false, "" +} + +func collectAllSchemes(urlSchemes []models.URLScheme) []string { + var all []string + for _, us := range urlSchemes { + all = append(all, us.Schemes...) + } + return all +} + +func targetsWithScheme(targets []models.XcodeTarget, scheme string) []string { + var names []string + for _, t := range targets { + for _, us := range t.URLSchemes { + for _, s := range us.Schemes { + if strings.EqualFold(s, scheme) { + names = append(names, t.Name) + } + } + } + } + return names +} diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000..83550b3 --- /dev/null +++ b/internal/models/models.go @@ -0,0 +1,270 @@ +// Package models contains all domain types shared across packages. +// No logic lives here — only data structures and constants. +package models + +// DeepLink is a parsed representation of a raw URL or custom-scheme URI. +type DeepLink struct { + Raw string `json:"raw"` + Scheme string `json:"scheme"` + Host string `json:"host"` + Path string `json:"path"` + Query map[string]string `json:"query,omitempty"` + Fragment string `json:"fragment,omitempty"` + Type LinkType `json:"type"` +} + +// LinkType classifies the deep link by platform handling mechanism. +type LinkType string + +const ( + // LinkTypeUniversalLink is an https link handled by iOS via AASA. + LinkTypeUniversalLink LinkType = "Universal Link" + // LinkTypeAppLink is an https link handled by Android via assetlinks.json. + LinkTypeAppLink LinkType = "App Link" + // LinkTypeCustomScheme is a non-http URI handled by any app that registers the scheme. + LinkTypeCustomScheme LinkType = "Custom Scheme" +) + +// ValidationResult is a single check outcome produced by the validator or checker. +type ValidationResult struct { + Check string `json:"check"` + Status Status `json:"status"` + Message string `json:"message"` + Detail string `json:"detail,omitempty"` +} + +// Status is the outcome of a single validation check. +type Status string + +const ( + StatusPass Status = "PASS" + StatusFail Status = "FAIL" + StatusWarning Status = "WARN" + StatusInfo Status = "INFO" + StatusSkip Status = "SKIP" +) + +// SimulationResult is the outcome of opening a deep link on a device or emulator. +type SimulationResult struct { + Platform string `json:"platform"` // "iOS" or "Android" + DeviceID string `json:"device_id"` + DeviceName string `json:"device_name,omitempty"` + Success bool `json:"success"` + Output string `json:"output,omitempty"` + Error string `json:"error,omitempty"` +} + +// CacheResetResult is the outcome of a full cache-reset sequence. +// Steps are executed sequentially; the first fatal failure stops the chain. +type CacheResetResult struct { + Platform string `json:"platform"` // "iOS" or "Android" + DeviceID string `json:"device_id"` + Steps []CacheResetStep `json:"steps"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` +} + +// CacheResetStep is the result of one command within a cache-reset sequence. +type CacheResetStep struct { + Name string `json:"name"` + Success bool `json:"success"` + Output string `json:"output,omitempty"` + Error string `json:"error,omitempty"` +} + +// AASAFile is the JSON shape of apple-app-site-association, served at +// https:///.well-known/apple-app-site-association +type AASAFile struct { + AppLinks struct { + // Apps must be an empty array per Apple spec — non-empty breaks Universal Links. + Apps []string `json:"apps"` + Details []AASADetail `json:"details"` + } `json:"applinks"` + WebCredentials *struct { + Apps []string `json:"apps"` + } `json:"webcredentials,omitempty"` +} + +// AASADetail is one entry in the applinks.details array. +type AASADetail struct { + AppID string `json:"appID"` // "." + Paths []string `json:"paths,omitempty"` // legacy format + Components []AASAComponent `json:"components,omitempty"` // modern format (iOS 13+) +} + +// AASAComponent is a modern path-matcher in AASA (iOS 13+). +type AASAComponent struct { + Path string `json:"/,omitempty"` + Query string `json:"?,omitempty"` + Fragment string `json:"#,omitempty"` + Comment string `json:"comment,omitempty"` + Exclude bool `json:"exclude,omitempty"` +} + +// AssetLinksFile is the JSON shape of /.well-known/assetlinks.json. +type AssetLinksFile []AssetLink + +// AssetLink is one entry in assetlinks.json. +type AssetLink struct { + Relation []string `json:"relation"` + Target AssetLinkTarget `json:"target"` +} + +// AssetLinkTarget identifies an Android app in assetlinks.json. +type AssetLinkTarget struct { + Namespace string `json:"namespace"` + PackageName string `json:"package_name"` + SHA256CertFingerprints []string `json:"sha256_cert_fingerprints"` +} + +// XcodeProject holds data extracted from a .xcodeproj bundle. +type XcodeProject struct { + Path string `json:"path"` + Targets []XcodeTarget `json:"targets"` +} + +// XcodeTarget is one PBXNativeTarget within an Xcode project. +type XcodeTarget struct { + Name string `json:"name"` + BundleID string `json:"bundle_id,omitempty"` + + // Paths are project-relative (e.g. "MyApp/MyApp.entitlements"). + EntitlementsPath string `json:"entitlements_path,omitempty"` + InfoPlistPath string `json:"info_plist_path,omitempty"` + + // AssociatedDomains are raw entitlement values: "applinks:example.com", + // "webcredentials:example.com", "applinks:*.example.com?mode=developer", … + AssociatedDomains []string `json:"associated_domains,omitempty"` + URLSchemes []URLScheme `json:"url_schemes,omitempty"` + + // GeneratesInfoPlist is true when GENERATE_INFOPLIST_FILE = YES in the + // pbxproj. Modern Xcode (13+) projects build the Info.plist entirely from + // build settings at compile time — no static Info.plist exists on disk. + GeneratesInfoPlist bool `json:"generates_info_plist,omitempty"` +} + +// URLScheme represents one CFBundleURLTypes entry from Info.plist. +type URLScheme struct { + Name string `json:"name,omitempty"` // CFBundleURLName + Schemes []string `json:"schemes"` // CFBundleURLSchemes +} + +// AndroidManifest holds data extracted from AndroidManifest.xml. +type AndroidManifest struct { + Path string `json:"path"` + PackageName string `json:"package_name"` + Activities []AndroidActivity `json:"activities,omitempty"` +} + +// AndroidActivity represents one element. +type AndroidActivity struct { + Name string `json:"name"` + IntentFilters []IntentFilter `json:"intent_filters,omitempty"` +} + +// IntentFilter represents one element. +type IntentFilter struct { + AutoVerify bool `json:"auto_verify,omitempty"` + Actions []string `json:"actions,omitempty"` + Categories []string `json:"categories,omitempty"` + DataSchemes []string `json:"data_schemes,omitempty"` + DataHosts []string `json:"data_hosts,omitempty"` + DataPaths []string `json:"data_paths,omitempty"` +} + +// AppCheckReport is the output of `deeplink check-app`. +type AppCheckReport struct { + ProjectPath string `json:"project_path"` + Platform string `json:"platform"` // "ios" or "android" + + // One of these is populated depending on platform. + XcodeProject *XcodeProject `json:"xcode_project,omitempty"` + AndroidManifest *AndroidManifest `json:"android_manifest,omitempty"` + + Link *DeepLink `json:"link"` + Checks []ValidationResult `json:"checks"` + Summary AppCheckSummary `json:"summary"` +} + +// AppCheckSummary aggregates check counts from AppCheckReport. +type AppCheckSummary struct { + Total int `json:"total"` + Passed int `json:"passed"` + Failed int `json:"failed"` + Warnings int `json:"warnings"` + OK bool `json:"ok"` // true when Failed == 0 +} + +// ProjectScan is the output of `deeplink scan`. +// Unlike AppCheckReport it is not tied to a specific link. +type ProjectScan struct { + ProjectPath string `json:"project_path"` + Platform string `json:"platform"` // "ios" or "android" + Targets []TargetSummary `json:"targets,omitempty"` + RegisteredLinks []RegisteredLink `json:"registered_links,omitempty"` +} + +// TargetSummary is the per-target view shown in scan output. +type TargetSummary struct { + Name string `json:"name"` + AssociatedDomains []string `json:"associated_domains,omitempty"` + URLSchemes []string `json:"url_schemes,omitempty"` // flat, e.g. ["myapp", "myapp-dev"] + EntitlementsPath string `json:"entitlements_path,omitempty"` + InfoPlistPath string `json:"info_plist_path,omitempty"` +} + +// RegisteredLink is one deep link pattern that an app target can handle. +type RegisteredLink struct { + Pattern string `json:"pattern"` // e.g. "https://example.com/**" or "myapp://**" + Target string `json:"target"` // Xcode target name or Android activity class + LinkType string `json:"link_type"` // "Universal Link", "App Link", "Custom Scheme" +} + +// Used by --json output and the embedded Web UI (/api/debug endpoint). +// Separate from the internal types so the JSON shape can evolve independently. + +// JSONReport is the top-level envelope for --json output. +type JSONReport struct { + Link JSONLink `json:"link"` + Validations []JSONValidation `json:"validations"` + Simulations []JSONSimulation `json:"simulations,omitempty"` + Summary JSONSummary `json:"summary"` + GeneratedAt string `json:"generated_at"` // RFC3339 +} + +// JSONLink is the wire representation of a parsed deep link. +type JSONLink struct { + Raw string `json:"raw"` + Scheme string `json:"scheme"` + Host string `json:"host"` + Path string `json:"path"` + Query map[string]string `json:"query,omitempty"` + Fragment string `json:"fragment,omitempty"` + Type string `json:"type"` // string, not LinkType, for forward compatibility +} + +// JSONValidation is the wire representation of one validation result. +type JSONValidation struct { + Check string `json:"check"` + Status string `json:"status"` // "PASS", "FAIL", "WARN", "INFO", "SKIP" + Message string `json:"message"` + Detail string `json:"detail,omitempty"` +} + +// JSONSimulation is the wire representation of one simulation result. +type JSONSimulation struct { + Platform string `json:"platform"` + DeviceID string `json:"device_id"` + Success bool `json:"success"` + Output string `json:"output,omitempty"` + Error string `json:"error,omitempty"` +} + +// JSONSummary aggregates counts for the JSON report header. +type JSONSummary struct { + Total int `json:"total"` + Passed int `json:"passed"` + Failed int `json:"failed"` + Warnings int `json:"warnings"` + OK bool `json:"ok"` +} diff --git a/internal/parser/parser.go b/internal/parser/parser.go new file mode 100644 index 0000000..905e2ea --- /dev/null +++ b/internal/parser/parser.go @@ -0,0 +1,98 @@ +// Package parser turns a raw string into a typed *models.DeepLink. +// +// Three link categories are handled: +// - Universal Link / App Link — https:// or http:// +// - Custom scheme — anything else (myapp://, fb://, …) +package parser + +import ( + "fmt" + "net/url" + "strings" + + "github.com/space-code/linkctl/internal/models" +) + +// Parse parses rawLink into a *models.DeepLink. +// +// Input rules: +// - Empty / whitespace-only input → error. +// - Input without "://" is treated as a bare host and https:// is prepended, +// so "example.com/path" becomes "https://example.com/path". +// - Scheme is lowercased; host is lowercased. +// - For duplicate query keys only the first value is kept. +// - Raw stores the normalised URL (scheme always present), never the +// original bare input, so callers always have a valid URL string. +func Parse(rawLink string) (*models.DeepLink, error) { + rawLink = strings.TrimSpace(rawLink) + if rawLink == "" { + return nil, fmt.Errorf("deep link cannot be empty") + } + + // Inject https:// when the caller omits the scheme entirely. + normalised := rawLink + if !strings.Contains(rawLink, "://") { + normalised = "https://" + rawLink + } + + u, err := url.Parse(normalised) + if err != nil { + return nil, fmt.Errorf("cannot parse %q: %w", normalised, err) + } + + // url.Parse is extremely permissive — enforce the parts we require. + if u.Scheme == "" { + return nil, fmt.Errorf("no scheme in %q", rawLink) + } + if u.Host == "" { + return nil, fmt.Errorf("no host in %q", rawLink) + } + + // Normalise: scheme and host are always lowercase. + u.Scheme = strings.ToLower(u.Scheme) + u.Host = strings.ToLower(u.Host) + + return &models.DeepLink{ + Raw: u.String(), + Scheme: u.Scheme, + Host: u.Host, + Path: u.Path, + Query: flattenQuery(u.Query()), + Fragment: u.Fragment, + Type: classify(u.Scheme), + }, nil +} + +// classify assigns a LinkType from the scheme alone. +// +// https / http → LinkTypeUniversalLink. +// The distinction between Universal Link (iOS) and App Link (Android) is +// resolved at validation time once the target platform is known; the parser +// has no project context. +// +// Everything else → LinkTypeCustomScheme. +func classify(scheme string) models.LinkType { + switch scheme { + case "https", "http": + return models.LinkTypeUniversalLink + default: + return models.LinkTypeCustomScheme + } +} + +// flattenQuery converts url.Values (map[string][]string) → map[string]string +// by keeping the first value for each key, matching mobile deep link handler +// behaviour which does not expect multi-value params. +// Returns nil when there are no query parameters. +func flattenQuery(q url.Values) map[string]string { + if len(q) == 0 { + return nil + } + out := make(map[string]string, len(q)) + for k, vs := range q { + if len(vs) > 0 { + out[k] = vs[0] + } + } + return out +} diff --git a/internal/pbxproj/pbxproj.go b/internal/pbxproj/pbxproj.go new file mode 100644 index 0000000..a5988fd --- /dev/null +++ b/internal/pbxproj/pbxproj.go @@ -0,0 +1,345 @@ +package pbxproj + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/bitrise-io/go-xcode/plistutil" + "github.com/bitrise-io/go-xcode/xcodeproject/serialized" + "github.com/bitrise-io/go-xcode/xcodeproject/xcodeproj" + "github.com/space-code/linkctl/internal/models" +) + +func ParseXcodeProject(projectPath string, configuration string) (*models.XcodeProject, error) { + xcprojPath, err := resolveXcodeprojDir(projectPath) + if err != nil { + return nil, err + } + projectRoot := filepath.Dir(xcprojPath) + + proj, err := xcodeproj.Open(xcprojPath) + if err != nil { + return nil, fmt.Errorf("cannot open %q: %w", xcprojPath, err) + } + + result := &models.XcodeProject{ + Path: filepath.Join(xcprojPath, "project.pbxproj"), + } + + for _, target := range proj.Proj.Targets { + if target.Type != xcodeproj.NativeTargetType { + continue + } + + if isTestTarget(target.ProductType) { + continue + } + + t := models.XcodeTarget{Name: target.Name} + + applyBuildSettings(&t, proj, target, configuration) + + enrichTarget(&t, projectRoot) + + result.Targets = append(result.Targets, t) + } + + return result, nil +} + +func isTestTarget(productType string) bool { + switch productType { + case "com.apple.product-type.bundle.unit-test", + "com.apple.product-type.bundle.ui-testing": + return true + } + return false +} + +func applyBuildSettings( + t *models.XcodeTarget, + proj xcodeproj.XcodeProj, + target xcodeproj.Target, + configuration string, +) { + // 1. Determine configuration to use + config := configuration + if config == "" { + config = target.BuildConfigurationList.DefaultConfigurationName + } + if config == "" { + config = proj.Proj.BuildConfigurationList.DefaultConfigurationName + } + if config == "" { + if len(target.BuildConfigurationList.BuildConfigurations) > 0 { + config = target.BuildConfigurationList.BuildConfigurations[0].Name + } else { + config = "Release" + } + } + + // 2. Query bundle ID + if bundleID, err := proj.TargetBundleID(target.Name, config); err == nil && bundleID != "" { + t.BundleID = bundleID + } + + // 3. Query entitlements path + if entPath, err := proj.TargetCodeSignEntitlementsPath(target.Name, config); err == nil && entPath != "" { + t.EntitlementsPath = entPath + } + + // 4. Query info plist path + if infoPath, err := proj.TargetInfoplistPath(target.Name, config); err == nil && infoPath != "" { + t.InfoPlistPath = infoPath + } + + // 5. Query generate info plist file setting + if bs, err := proj.TargetBuildSettings(target.Name, config); err == nil { + if raw, err := bs.String("GENERATE_INFOPLIST_FILE"); err == nil { + t.GeneratesInfoPlist = strings.EqualFold(raw, "yes") + } + } + + // Fallback to manual first configuration parsing if any value is still empty + if t.BundleID == "" || t.EntitlementsPath == "" || t.InfoPlistPath == "" { + for _, cfg := range target.BuildConfigurationList.BuildConfigurations { + fillFromBuildSettings(t, cfg.BuildSettings) + break + } + if t.EntitlementsPath == "" { + for _, cfg := range proj.Proj.BuildConfigurationList.BuildConfigurations { + if raw, err := cfg.BuildSettings.String("CODE_SIGN_ENTITLEMENTS"); err == nil && raw != "" { + t.EntitlementsPath = raw + } + } + } + } +} + +func fillFromBuildSettings(t *models.XcodeTarget, bs serialized.Object) { + if t.BundleID == "" { + if raw, err := bs.String("PRODUCT_BUNDLE_IDENTIFIER"); err == nil && raw != "" { + t.BundleID = resolveVar(raw, bs) + } + } + if t.EntitlementsPath == "" { + if raw, err := bs.String("CODE_SIGN_ENTITLEMENTS"); err == nil && raw != "" { + t.EntitlementsPath = raw + } + } + if t.InfoPlistPath == "" { + if raw, err := bs.String("INFOPLIST_FILE"); err == nil && raw != "" { + t.InfoPlistPath = normalizePath(raw, t.Name) + } + } + if !t.GeneratesInfoPlist { + if raw, err := bs.String("GENERATE_INFOPLIST_FILE"); err == nil { + t.GeneratesInfoPlist = strings.EqualFold(raw, "yes") + } + } +} + +func resolveVar(raw string, bs serialized.Object) string { + re := regexp.MustCompile(`[$][{(]([^$:})]+)(?::[^$)}]*)??[)}]`) + seen := map[string]bool{} + cur := raw + for strings.Contains(cur, "$") { + m := re.FindStringSubmatchIndex(cur) + if m == nil { + break + } + varName := cur[m[2]:m[3]] + val, err := bs.String(varName) + if err != nil { + break + } + next := cur[:m[0]] + val + cur[m[1]:] + if seen[next] { + break + } + seen[next] = true + cur = next + } + return cur +} + +func enrichTarget(t *models.XcodeTarget, projectRoot string) { + if t.EntitlementsPath != "" { + var absEnt string + if filepath.IsAbs(t.EntitlementsPath) { + absEnt = t.EntitlementsPath + } else { + raw := strings.ReplaceAll(t.EntitlementsPath, "$(TARGET_NAME)", t.Name) + raw = strings.ReplaceAll(raw, "${TARGET_NAME}", t.Name) + absEnt = entitlementsPath(raw, projectRoot) + } + if domains, err := parseEntitlements(absEnt); err == nil { + t.AssociatedDomains = domains + } + if rel, err := filepath.Rel(projectRoot, absEnt); err == nil { + t.EntitlementsPath = rel + } + } + if t.InfoPlistPath != "" { + var absInfo string + if filepath.IsAbs(t.InfoPlistPath) { + absInfo = t.InfoPlistPath + } else { + absInfo = filepath.Join(projectRoot, t.InfoPlistPath) + } + if schemes, bundleID, err := parseInfoPlist(absInfo); err == nil { + t.URLSchemes = schemes + if t.BundleID == "" && bundleID != "" { + t.BundleID = bundleID + } + } + if rel, err := filepath.Rel(projectRoot, absInfo); err == nil { + t.InfoPlistPath = rel + } + } +} + +func resolveXcodeprojDir(startPath string) (string, error) { + if strings.HasSuffix(startPath, ".xcodeproj") { + if _, err := os.Stat(filepath.Join(startPath, "project.pbxproj")); err == nil { + return startPath, nil + } + } + + entries, err := os.ReadDir(startPath) + if err != nil { + return "", fmt.Errorf("cannot read %q: %w", startPath, err) + } + + // Direct child .xcodeproj. + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".xcodeproj") { + c := filepath.Join(startPath, e.Name()) + if _, err := os.Stat(filepath.Join(c, "project.pbxproj")); err == nil { + return c, nil + } + } + } + + // One level deeper (monorepo layout: repo/ios/MyApp.xcodeproj). + for _, e := range entries { + if !e.IsDir() { + continue + } + sub, _ := os.ReadDir(filepath.Join(startPath, e.Name())) + for _, se := range sub { + if strings.HasSuffix(se.Name(), ".xcodeproj") { + c := filepath.Join(startPath, e.Name(), se.Name()) + if _, err := os.Stat(filepath.Join(c, "project.pbxproj")); err == nil { + return c, nil + } + } + } + } + + return "", fmt.Errorf("no .xcodeproj found in %q", startPath) +} + +// normalizePath strips common build-variable prefixes from a pbxproj path value. +// Used for INFOPLIST_FILE and similar settings that do not need abs resolution. +func normalizePath(p, targetName string) string { + p = strings.ReplaceAll(p, "$(SRCROOT)/", "") + p = strings.ReplaceAll(p, "$(SRCROOT)", "") + p = strings.ReplaceAll(p, "$(TARGET_NAME)", targetName) + return p +} + +// parseEntitlements reads a .entitlements plist and returns the values of +// com.apple.developer.associated-domains. +// +// Returns (nil, nil) when the key is absent — not every target uses Universal +// Links, and absence is normal for custom-scheme-only apps. +func parseEntitlements(path string) ([]string, error) { + data, err := plistutil.NewPlistDataFromFile(path) + if err != nil { + return nil, fmt.Errorf("cannot read entitlements %q: %w", path, err) + } + + domains, ok := data.GetStringArray("com.apple.developer.associated-domains") + if !ok { + return nil, nil // key absent — not an error + } + return domains, nil +} + +// parseInfoPlist reads an Info.plist and returns URL schemes + bundle ID. +// +// bundleID is "" when the plist contains a build-variable placeholder such as +// $(PRODUCT_BUNDLE_IDENTIFIER) — the authoritative value comes from pbxproj +// build settings in that case. +func parseInfoPlist(path string) ([]models.URLScheme, string, error) { + data, err := plistutil.NewPlistDataFromFile(path) + if err != nil { + return nil, "", fmt.Errorf("cannot read Info.plist %q: %w", path, err) + } + + bundleID, _ := data.GetString("CFBundleIdentifier") + if strings.HasPrefix(bundleID, "$(") || strings.HasPrefix(bundleID, "${") { + bundleID = "" + } + + schemes := parseURLTypes(data) + return schemes, bundleID, nil +} + +// parseURLTypes extracts CFBundleURLTypes entries from parsed plist data. +func parseURLTypes(data plistutil.PlistData) []models.URLScheme { + entries, ok := data.GetMapStringInterfaceArray("CFBundleURLTypes") + if !ok { + return nil + } + + var schemes []models.URLScheme + for _, entry := range entries { + name, _ := entry.GetString("CFBundleURLName") + rawSchemes, ok := entry.GetStringArray("CFBundleURLSchemes") + if !ok || len(rawSchemes) == 0 { + continue + } + schemes = append(schemes, models.URLScheme{Name: name, Schemes: rawSchemes}) + } + return schemes +} + +// entitlementsPath resolves a raw CODE_SIGN_ENTITLEMENTS build setting value +// to an absolute path on disk. +// +// projectRoot is the directory that contains the .xcodeproj bundle +// (i.e. filepath.Dir(xcprojPath)), which is identical to Xcode's $(SRCROOT). +// +// Patterns seen in real projects, all handled here: +// +// MyApp/MyApp.entitlements — plain relative path +// $(SRCROOT)/MyApp/MyApp.entitlements — explicit SRCROOT with slash +// $(SRCROOT)MyApp/MyApp.entitlements — explicit SRCROOT without slash +// $(TARGET_NAME)/$(TARGET_NAME).entitlements — target-name variables +// MyApp/$(TARGET_NAME).entitlements — partial variable +// /Users/runner/work/MyApp/MyApp.entitlements — absolute (xcodebuild output) +func entitlementsPath(raw, projectRoot string) string { + // Absolute paths need no further processing (come from xcodebuild layer). + if filepath.IsAbs(raw) { + return raw + } + + // Expand the two variables that appear in this setting. + // $(SRCROOT) == projectRoot; $(TARGET_NAME) is unknowable here without the + // target name, so we strip it and let the caller handle the mismatch via + // the filesystem fallback in enrichTarget. + raw = strings.ReplaceAll(raw, "$(SRCROOT)/", "") + raw = strings.ReplaceAll(raw, "$(SRCROOT)", "") + raw = strings.ReplaceAll(raw, "${SRCROOT}/", "") + raw = strings.ReplaceAll(raw, "${SRCROOT}", "") + + // After stripping SRCROOT the path may start with a slash if the original + // value was "$(SRCROOT)/…" and the variable included the slash in the value. + // filepath.Join handles this correctly. + return filepath.Join(projectRoot, raw) +} diff --git a/internal/pbxproj/pbxproj_test.go b/internal/pbxproj/pbxproj_test.go new file mode 100644 index 0000000..7447f8d --- /dev/null +++ b/internal/pbxproj/pbxproj_test.go @@ -0,0 +1,326 @@ +package pbxproj_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/space-code/linkctl/internal/pbxproj" +) + +const mockPbxproj = ` + + + + archiveVersion + 1 + classes + + objectVersion + 46 + objects + + PROJECT_ID + + isa + PBXProject + buildConfigurationList + PROJECT_CONFIG_LIST + targets + + TARGET_ID + + attributes + + + PROJECT_CONFIG_LIST + + isa + XCConfigurationList + buildConfigurations + + PROJECT_CONFIG_ID_RELEASE + PROJECT_CONFIG_ID_DEBUG + + defaultConfigurationName + Release + + PROJECT_CONFIG_ID_RELEASE + + isa + XCBuildConfiguration + name + Release + buildSettings + + + PROJECT_CONFIG_ID_DEBUG + + isa + XCBuildConfiguration + name + Debug + buildSettings + + + TARGET_ID + + isa + PBXNativeTarget + name + MyApp + productType + com.apple.product-type.application + buildConfigurationList + TARGET_CONFIG_LIST + dependencies + + buildPhases + + + TARGET_CONFIG_LIST + + isa + XCConfigurationList + buildConfigurations + + TARGET_CONFIG_ID_RELEASE + TARGET_CONFIG_ID_DEBUG + + defaultConfigurationName + Release + + TARGET_CONFIG_ID_RELEASE + + isa + XCBuildConfiguration + name + Release + buildSettings + + PRODUCT_BUNDLE_IDENTIFIER + com.example.myapp + CODE_SIGN_ENTITLEMENTS + MyApp.entitlements + INFOPLIST_FILE + Info.plist + + + TARGET_CONFIG_ID_DEBUG + + isa + XCBuildConfiguration + name + Debug + buildSettings + + PRODUCT_BUNDLE_IDENTIFIER + com.example.myapp.debug + CODE_SIGN_ENTITLEMENTS + MyApp.debug.entitlements + INFOPLIST_FILE + Info.debug.plist + + + + rootObject + PROJECT_ID + +` + +const mockEntitlements = ` + + + + com.apple.developer.associated-domains + + applinks:example.com + + +` + +const mockDebugEntitlements = ` + + + + com.apple.developer.associated-domains + + applinks:debug.example.com + + +` + +const mockInfoPlist = ` + + + + CFBundleIdentifier + com.example.myapp + CFBundleURLTypes + + + CFBundleURLName + myapp-scheme + CFBundleURLSchemes + + myapp + + + + +` + +const mockDebugInfoPlist = ` + + + + CFBundleIdentifier + com.example.myapp.debug + CFBundleURLTypes + + + CFBundleURLName + myapp-debug-scheme + CFBundleURLSchemes + + myappdebug + + + + +` + +func createMockProject(t *testing.T) (string, func()) { + tmpDir, err := os.MkdirTemp("", "linkctl-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + + xcodeprojPath := filepath.Join(tmpDir, "Mock.xcodeproj") + if err := os.MkdirAll(xcodeprojPath, 0o755); err != nil { + t.Fatalf("failed to create xcodeproj dir: %v", err) + } + + err = os.WriteFile(filepath.Join(xcodeprojPath, "project.pbxproj"), []byte(mockPbxproj), 0o644) + if err != nil { + t.Fatalf("failed to write project.pbxproj: %v", err) + } + + err = os.WriteFile(filepath.Join(tmpDir, "MyApp.entitlements"), []byte(mockEntitlements), 0o644) + if err != nil { + t.Fatalf("failed to write MyApp.entitlements: %v", err) + } + + err = os.WriteFile(filepath.Join(tmpDir, "MyApp.debug.entitlements"), []byte(mockDebugEntitlements), 0o644) + if err != nil { + t.Fatalf("failed to write MyApp.debug.entitlements: %v", err) + } + + err = os.WriteFile(filepath.Join(tmpDir, "Info.plist"), []byte(mockInfoPlist), 0o644) + if err != nil { + t.Fatalf("failed to write Info.plist: %v", err) + } + + err = os.WriteFile(filepath.Join(tmpDir, "Info.debug.plist"), []byte(mockDebugInfoPlist), 0o644) + if err != nil { + t.Fatalf("failed to write Info.debug.plist: %v", err) + } + + cleanup := func() { + os.RemoveAll(tmpDir) + } + + return tmpDir, cleanup +} + +func TestParseXcodeProject_ReleaseConfiguration(t *testing.T) { + projDir, cleanup := createMockProject(t) + defer cleanup() + + // Parse project with default/Release configuration + result, err := pbxproj.ParseXcodeProject(projDir, "Release") + if err != nil { + t.Fatalf("ParseXcodeProject failed: %v", err) + } + + if len(result.Targets) != 1 { + t.Fatalf("expected 1 target, got %d", len(result.Targets)) + } + + target := result.Targets[0] + if target.Name != "MyApp" { + t.Errorf("expected target name 'MyApp', got '%s'", target.Name) + } + + if target.BundleID != "com.example.myapp" { + t.Errorf("expected bundle ID 'com.example.myapp', got '%s'", target.BundleID) + } + + if target.EntitlementsPath != "MyApp.entitlements" { + t.Errorf("expected entitlements path 'MyApp.entitlements', got '%s'", target.EntitlementsPath) + } + + if target.InfoPlistPath != "Info.plist" { + t.Errorf("expected info plist path 'Info.plist', got '%s'", target.InfoPlistPath) + } + + if len(target.AssociatedDomains) != 1 || target.AssociatedDomains[0] != "applinks:example.com" { + t.Errorf("unexpected associated domains: %v", target.AssociatedDomains) + } + + if len(target.URLSchemes) != 1 || target.URLSchemes[0].Schemes[0] != "myapp" { + t.Errorf("unexpected URL schemes: %v", target.URLSchemes) + } +} + +func TestParseXcodeProject_DebugConfiguration(t *testing.T) { + projDir, cleanup := createMockProject(t) + defer cleanup() + + // Parse project with Debug configuration explicitly + result, err := pbxproj.ParseXcodeProject(projDir, "Debug") + if err != nil { + t.Fatalf("ParseXcodeProject failed: %v", err) + } + + if len(result.Targets) != 1 { + t.Fatalf("expected 1 target, got %d", len(result.Targets)) + } + + target := result.Targets[0] + if target.BundleID != "com.example.myapp.debug" { + t.Errorf("expected bundle ID 'com.example.myapp.debug', got '%s'", target.BundleID) + } + + if target.EntitlementsPath != "MyApp.debug.entitlements" { + t.Errorf("expected entitlements path 'MyApp.debug.entitlements', got '%s'", target.EntitlementsPath) + } + + if target.InfoPlistPath != "Info.debug.plist" { + t.Errorf("expected info plist path 'Info.debug.plist', got '%s'", target.InfoPlistPath) + } + + if len(target.AssociatedDomains) != 1 || target.AssociatedDomains[0] != "applinks:debug.example.com" { + t.Errorf("unexpected associated domains: %v", target.AssociatedDomains) + } + + if len(target.URLSchemes) != 1 || target.URLSchemes[0].Schemes[0] != "myappdebug" { + t.Errorf("unexpected URL schemes: %v", target.URLSchemes) + } +} + +func TestParseXcodeProject_DefaultFallback(t *testing.T) { + projDir, cleanup := createMockProject(t) + defer cleanup() + + // Parse project with empty configuration should default to Release + result, err := pbxproj.ParseXcodeProject(projDir, "") + if err != nil { + t.Fatalf("ParseXcodeProject failed: %v", err) + } + + target := result.Targets[0] + if target.BundleID != "com.example.myapp" { + t.Errorf("expected default config to parse Release (com.example.myapp), got bundle ID '%s'", target.BundleID) + } +} diff --git a/internal/reporter/reporter.go b/internal/reporter/reporter.go index 81760c8..71deab9 100644 --- a/internal/reporter/reporter.go +++ b/internal/reporter/reporter.go @@ -3,7 +3,11 @@ package reporter import ( "fmt" "io" + "sort" + "strings" + "unicode/utf8" + "github.com/space-code/linkctl/internal/models" "github.com/space-code/linkctl/pkg/iostreams" ) @@ -23,3 +27,250 @@ func PrintDeviceList(w io.Writer, cs *iostreams.ColorScheme, platform string, de } fmt.Fprintln(w) } + +func PrintAppCheckReport(w io.Writer, cs *iostreams.ColorScheme, report *models.AppCheckReport) { + // Header + icon := "🍎" + if report.Platform == "android" { + icon = "🤖" + } + fmt.Fprintf(w, " %s APP CONFIG CHECK [%s]\n\n", icon, strings.ToUpper(report.Platform)) + + // Project metadata + if report.XcodeProject != nil { + names := make([]string, 0, len(report.XcodeProject.Targets)) + for _, t := range report.XcodeProject.Targets { + names = append(names, t.Name) + } + printKV(w, cs, [][]string{ + {"Project", report.XcodeProject.Path}, + {"Targets", strings.Join(names, ", ")}, + }) + fmt.Fprintln(w) + } + if report.AndroidManifest != nil { + printKV(w, cs, [][]string{ + {"Manifest", report.AndroidManifest.Path}, + {"Package", report.AndroidManifest.PackageName}, + }) + fmt.Fprintln(w) + } + + // Checks — group by target prefix so multi-target output is scannable. + printChecks(w, cs, report.Checks) + + // Summary line + fmt.Fprintf( + w, "\n Summary: %s %s %s\n\n", + cs.Green(fmt.Sprintf("%d passed", report.Summary.Passed)), + cs.Red(fmt.Sprintf("%d failed", report.Summary.Failed)), + cs.Yellow(fmt.Sprintf("%d warnings", report.Summary.Warnings)), + ) + + if report.Summary.OK { + fmt.Fprintf(w, " %s App is correctly configured!\n\n", cs.Green("✅")) + } else { + fmt.Fprintf(w, " %s Configuration has issues — deep link may not work.\n\n", cs.Red("✗")) + } +} + +func printChecks(w io.Writer, cs *iostreams.ColorScheme, checks []models.ValidationResult) { + if len(checks) == 0 { + return + } + + // Measure longest check name for alignment. + maxWidth := 0 + for _, r := range checks { + if n := utf8.RuneCountInString(r.Check); n > maxWidth { + maxWidth = n + } + } + // Cap at 52 so very long names don't push messages off-screen. + if maxWidth > 52 { + maxWidth = 52 + } + + for _, r := range checks { + name := r.Check + pad := maxWidth - utf8.RuneCountInString(name) + if pad < 0 { + pad = 0 + } + fmt.Fprintf( + w, " %s %s%s %s\n", + StatusIcon(cs, r.Status), + name, + strings.Repeat(" ", pad), + r.Message, + ) + if r.Detail != "" { + fmt.Fprintf(w, " %s %s\n", cs.Muted("↳"), r.Detail) + } + } +} + +func printKV(w io.Writer, cs *iostreams.ColorScheme, rows [][]string) { + maxKey := 0 + for _, row := range rows { + if len(row) >= 1 { + if n := utf8.RuneCountInString(row[0]); n > maxKey { + maxKey = n + } + } + } + for _, row := range rows { + if len(row) < 2 { + continue + } + pad := maxKey - utf8.RuneCountInString(row[0]) + fmt.Fprintf( + w, " %s%s %s\n", + cs.Muted(row[0]+":"), + strings.Repeat(" ", pad), + row[1], + ) + } +} + +func StatusIcon(cs *iostreams.ColorScheme, s models.Status) string { + switch s { + case models.StatusPass: + return cs.Green("✓") + case models.StatusFail: + return cs.Red("✗") + case models.StatusWarning: + return cs.Yellow("⚠") + case models.StatusInfo: + return cs.Cyan("ℹ") + default: + return cs.Muted("–") + } +} + +func PrintLinkInfo(w io.Writer, cs *iostreams.ColorScheme, link *models.DeepLink) { + fmt.Fprintln(w, " 🔍 PARSED LINK") + fmt.Fprintln(w) + + rows := [][]string{ + {"Type", string(link.Type)}, + {"Scheme", link.Scheme}, + {"Host", link.Host}, + } + if link.Path != "" && link.Path != "/" { + rows = append(rows, []string{"Path", link.Path}) + } + + // Query params — sorted for deterministic output. + if len(link.Query) > 0 { + keys := make([]string, 0, len(link.Query)) + for k := range link.Query { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + rows = append(rows, []string{" ?" + k, link.Query[k]}) + } + } + if link.Fragment != "" { + rows = append(rows, []string{"Fragment", link.Fragment}) + } + + printKV(w, cs, rows) + fmt.Fprintln(w) +} + +func PrintProjectScan(w io.Writer, cs *iostreams.ColorScheme, scan *models.ProjectScan) { + icon := "🍎" + if scan.Platform == "android" { + icon = "🤖" + } + fmt.Fprintf(w, " %s PROJECT SCAN [%s]\n\n", icon, strings.ToUpper(scan.Platform)) + printKV(w, cs, [][]string{{"Project", scan.ProjectPath}}) + fmt.Fprintln(w) + + fmt.Fprintln(w, " 🎯 TARGETS") + fmt.Fprintln(w) + for _, t := range scan.Targets { + fmt.Fprintf(w, " %s\n", cs.Bold(t.Name)) + + lastField := lastNonEmpty( + len(t.AssociatedDomains) > 0, + len(t.URLSchemes) > 0, + t.EntitlementsPath != "", + t.InfoPlistPath != "", + ) + fields := 0 + + if len(t.AssociatedDomains) > 0 { + fields++ + branch := treeNode(fields, lastField) + fmt.Fprintf(w, " %s Associated Domains: %s\n", + branch, strings.Join(t.AssociatedDomains, ", ")) + } + if len(t.URLSchemes) > 0 { + fields++ + branch := treeNode(fields, lastField) + fmt.Fprintf(w, " %s URL Schemes: %s\n", + branch, strings.Join(t.URLSchemes, ", ")) + } + if t.EntitlementsPath != "" { + fields++ + branch := treeNode(fields, lastField) + fmt.Fprintf(w, " %s Entitlements: %s\n", + branch, cs.Muted(t.EntitlementsPath)) + } + if t.InfoPlistPath != "" { + fields++ + branch := treeNode(fields, lastField) + fmt.Fprintf(w, " %s Info.plist: %s\n", + branch, cs.Muted(t.InfoPlistPath)) + } + fmt.Fprintln(w) + } + + if len(scan.RegisteredLinks) > 0 { + fmt.Fprintln(w, " 🔗 REGISTERED LINK PATTERNS") + fmt.Fprintln(w) + for _, l := range scan.RegisteredLinks { + var label string + switch l.LinkType { + case "Universal Link", "App Link": + label = cs.Green(padRight(l.LinkType, 16)) + case "Custom Scheme": + label = cs.Yellow(padRight(l.LinkType, 16)) + default: + label = padRight(l.LinkType, 16) + } + fmt.Fprintf(w, " %s %-42s → %s\n", label, l.Pattern, cs.Muted(l.Target)) + } + fmt.Fprintln(w) + } else { + fmt.Fprintf(w, " %s No deep link patterns registered in this project.\n\n", cs.Yellow("⚠")) + } +} + +func padRight(s string, w int) string { + pad := w - utf8.RuneCountInString(s) + if pad <= 0 { + return s + } + return s + strings.Repeat(" ", pad) +} + +func treeNode(n, last int) string { + if n == last { + return "└──" + } + return "├──" +} + +func lastNonEmpty(fields ...bool) int { + count := 0 + for _, f := range fields { + if f { + count++ + } + } + return count +} diff --git a/pkg/cmd/check/check.go b/pkg/cmd/check/check.go new file mode 100644 index 0000000..6cd831a --- /dev/null +++ b/pkg/cmd/check/check.go @@ -0,0 +1,80 @@ +package check + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/space-code/linkctl/internal/appcheck" + "github.com/space-code/linkctl/internal/parser" + "github.com/space-code/linkctl/internal/reporter" + "github.com/space-code/linkctl/pkg/cmdutil" + "github.com/spf13/cobra" +) + +type options struct { + project string + target string + configuration string + asJSON bool +} + +func NewCmdCheck(f *cmdutil.Factory) *cobra.Command { + opts := &options{} + + cmd := &cobra.Command{ + Use: "check-app ", + Short: "Validate a deep link against your app project configuration", + Long: "Parses your app project and checks that it is correctly configured to handle the given deep link — without requiring a running simulator.", + Example: `linkctl check-app "myapp://profile" --project ./MyApp.xcodeproj`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return run(f, args[0], opts) + }, + } + + cmd.Flags().StringVarP(&opts.project, "project", "p", ".", "Path to .xcodeproj or Android project directory") + cmd.Flags().StringVarP(&opts.target, "target", "t", "", "Xcode target name to check (default: all targets)") + cmd.Flags().StringVarP(&opts.configuration, "configuration", "c", "", "Xcode build configuration name (e.g. Debug, Release)") + cmd.Flags().BoolVar(&opts.asJSON, "json", false, "Output results as JSON") + + return cmd +} + +func run(f *cmdutil.Factory, rawLink string, opts *options) error { + link, err := parser.Parse(rawLink) + if err != nil { + return fmt.Errorf("invalid link: %w", err) + } + + report, err := appcheck.CheckApp(opts.project, link, opts.target, opts.configuration) + if err != nil { + return fmt.Errorf("check-app: %w", err) + } + + if opts.asJSON { + enc := json.NewEncoder(f.IOStreams.Out) + enc.SetIndent("", " ") + if err := enc.Encode(report); err != nil { + return err + } + if !report.Summary.OK { + os.Exit(1) + } + return nil + } + + w := f.IOStreams.Out + cs := f.IOStreams.ColorScheme() + + // reporter.PrintBanner(w, cs) + reporter.PrintLinkInfo(w, cs, link) + reporter.PrintAppCheckReport(w, cs, report) + + fmt.Println(report.Checks) + + if !report.Summary.OK { + os.Exit(1) + } + return nil +} diff --git a/pkg/cmd/check/check_test.go b/pkg/cmd/check/check_test.go new file mode 100644 index 0000000..1901bb4 --- /dev/null +++ b/pkg/cmd/check/check_test.go @@ -0,0 +1,275 @@ +package check_test + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/space-code/linkctl/pkg/cmd/check" + "github.com/space-code/linkctl/pkg/cmdutil" + "github.com/space-code/linkctl/pkg/iostreams" +) + +const mockPbxproj = ` + + + + archiveVersion + 1 + classes + + objectVersion + 46 + objects + + PROJECT_ID + + isa + PBXProject + buildConfigurationList + PROJECT_CONFIG_LIST + targets + + TARGET_ID + + attributes + + + PROJECT_CONFIG_LIST + + isa + XCConfigurationList + buildConfigurations + + PROJECT_CONFIG_ID_RELEASE + PROJECT_CONFIG_ID_DEBUG + + defaultConfigurationName + Release + + PROJECT_CONFIG_ID_RELEASE + + isa + XCBuildConfiguration + name + Release + buildSettings + + + PROJECT_CONFIG_ID_DEBUG + + isa + XCBuildConfiguration + name + Debug + buildSettings + + + TARGET_ID + + isa + PBXNativeTarget + name + MyApp + productType + com.apple.product-type.application + buildConfigurationList + TARGET_CONFIG_LIST + dependencies + + buildPhases + + + TARGET_CONFIG_LIST + + isa + XCConfigurationList + buildConfigurations + + TARGET_CONFIG_ID_RELEASE + + defaultConfigurationName + Release + + TARGET_CONFIG_ID_RELEASE + + isa + XCBuildConfiguration + name + Release + buildSettings + + PRODUCT_BUNDLE_IDENTIFIER + com.example.myapp + CODE_SIGN_ENTITLEMENTS + MyApp.entitlements + INFOPLIST_FILE + Info.plist + + + + rootObject + PROJECT_ID + +` + +const mockEntitlements = ` + + + + com.apple.developer.associated-domains + + applinks:example.com + + +` + +const mockInfoPlist = ` + + + + CFBundleIdentifier + com.example.myapp + CFBundleURLTypes + + + CFBundleURLName + myapp-scheme + CFBundleURLSchemes + + myapp + + + + +` + +func newFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer) { + t.Helper() + + ios, _, stdout, _ := iostreams.Test() + + f := &cmdutil.Factory{ + AppVersion: "1.0.0", + ExecutableName: "linkctl", + IOStreams: ios, + } + + return f, stdout +} + +func createMockProject(t *testing.T) (string, func()) { + tmpDir, err := os.MkdirTemp("", "linkctl-cmd-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + + xcodeprojPath := filepath.Join(tmpDir, "Mock.xcodeproj") + if err := os.MkdirAll(xcodeprojPath, 0o755); err != nil { + t.Fatalf("failed to create xcodeproj dir: %v", err) + } + + err = os.WriteFile(filepath.Join(xcodeprojPath, "project.pbxproj"), []byte(mockPbxproj), 0o644) + if err != nil { + t.Fatalf("failed to write project.pbxproj: %v", err) + } + + err = os.WriteFile(filepath.Join(tmpDir, "MyApp.entitlements"), []byte(mockEntitlements), 0o644) + if err != nil { + t.Fatalf("failed to write MyApp.entitlements: %v", err) + } + + err = os.WriteFile(filepath.Join(tmpDir, "Info.plist"), []byte(mockInfoPlist), 0o644) + if err != nil { + t.Fatalf("failed to write Info.plist: %v", err) + } + + cleanup := func() { + os.RemoveAll(tmpDir) + } + + return tmpDir, cleanup +} + +func TestCheckCmd_InvalidLink(t *testing.T) { + f, _ := newFactory(t) + cmd := check.NewCmdCheck(f) + cmd.SetArgs([]string{""}) + if err := cmd.Execute(); err == nil { + t.Fatal("expected error for empty link") + } +} + +func TestCheckCmd_AndroidProject(t *testing.T) { + f, stdout := newFactory(t) + + // Create dummy android project (just Manifest) + tmpDir, err := os.MkdirTemp("", "linkctl-android-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + err = os.WriteFile(filepath.Join(tmpDir, "AndroidManifest.xml"), []byte(""), 0o644) + if err != nil { + t.Fatalf("failed to write manifest: %v", err) + } + + cmd := check.NewCmdCheck(f) + cmd.SetArgs([]string{"https://example.com/profile", "--project", tmpDir, "--json"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var result map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("output is not valid JSON: %v\ngot: %s", err, stdout.String()) + } + + if result["platform"] != "android" { + t.Errorf("expected platform 'android', got '%v'", result["platform"]) + } +} + +func TestCheckCmd_IOSProject_WithConfiguration(t *testing.T) { + f, stdout := newFactory(t) + projDir, cleanup := createMockProject(t) + defer cleanup() + + cmd := check.NewCmdCheck(f) + // Check deep link check-app against iOS project, passing configuration explicitly + cmd.SetArgs([]string{"https://example.com/profile", "--project", projDir, "--configuration", "Release", "--json"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var result map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("output is not valid JSON: %v\ngot: %s", err, stdout.String()) + } + + if result["platform"] != "ios" { + t.Errorf("expected platform 'ios', got '%v'", result["platform"]) + } + + // Verify deep link checks summary + summary, ok := result["summary"].(map[string]interface{}) + if !ok { + t.Fatalf("summary key not found or not a map in JSON output") + } + + if summary["ok"] != true { + t.Errorf("expected summary.ok to be true, got %v. JSON: %s", summary["ok"], stdout.String()) + } +} + +func TestCheckCmd_UnknownFlag(t *testing.T) { + f, _ := newFactory(t) + cmd := check.NewCmdCheck(f) + cmd.SetArgs([]string{"https://example.com", "--unknown"}) + if err := cmd.Execute(); err == nil { + t.Fatal("expected error for unknown flag") + } +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 3ec989b..8e7ea1e 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -1,7 +1,9 @@ package root import ( + checkCmd "github.com/space-code/linkctl/pkg/cmd/check" devicesCmd "github.com/space-code/linkctl/pkg/cmd/devices" + scanCmd "github.com/space-code/linkctl/pkg/cmd/scan" versionCmd "github.com/space-code/linkctl/pkg/cmd/version" "github.com/space-code/linkctl/pkg/cmdutil" "github.com/spf13/cobra" @@ -19,6 +21,8 @@ func NewCmdRoot(f *cmdutil.Factory, appVersion string) (*cobra.Command, error) { cmd.AddCommand(versionCmd.NewCmdVersion(f)) cmd.AddCommand(devicesCmd.NewCmdDevices(f)) + cmd.AddCommand(checkCmd.NewCmdCheck(f)) + cmd.AddCommand(scanCmd.NewCmdScan(f)) return cmd, nil } diff --git a/pkg/cmd/scan/scan.go b/pkg/cmd/scan/scan.go new file mode 100644 index 0000000..d55493c --- /dev/null +++ b/pkg/cmd/scan/scan.go @@ -0,0 +1,79 @@ +package scan + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/space-code/linkctl/internal/appcheck" + "github.com/space-code/linkctl/internal/reporter" + "github.com/space-code/linkctl/pkg/cmdutil" + "github.com/spf13/cobra" +) + +type options struct { + asJSON bool +} + +func NewCmdScan(f *cmdutil.Factory) *cobra.Command { + opts := &options{} + + cmd := &cobra.Command{ + Use: "scan [project-path]", + Short: "List all deep link patterns registered in an app project", + Long: `Scans an Xcode or Android project and prints every deep link +pattern the app can handle — Universal Links, App Links, and custom schemes. + +Platform is detected automatically: + iOS — looks for *.xcodeproj in the given directory + Android — looks for AndroidManifest.xml or build.gradle + +Exits with code 0 when at least one pattern is found, 1 otherwise.`, + Args: cobra.MaximumNArgs(1), + Example: ` deeplink scan + deeplink scan ./MyApp.xcodeproj + deeplink scan ./android/app + deeplink scan --json`, + RunE: func(cmd *cobra.Command, args []string) error { + project := "." + if len(args) > 0 { + project = args[0] + } + return run(f, project, opts) + }, + } + + cmd.Flags().BoolVar(&opts.asJSON, "json", false, "Output results as JSON") + + return cmd +} + +func run(f *cmdutil.Factory, project string, opts *options) error { + result, err := appcheck.ScanProject(project) + if err != nil { + return fmt.Errorf("scan: %w", err) + } + + if opts.asJSON { + enc := json.NewEncoder(f.IOStreams.Out) + enc.SetIndent("", " ") + if err := enc.Encode(result); err != nil { + return fmt.Errorf("encoding JSON: %w", err) + } + if len(result.RegisteredLinks) == 0 { + os.Exit(1) + } + return nil + } + + w := f.IOStreams.Out + cs := f.IOStreams.ColorScheme() + + // reporter.PrintBanner(w, cs) + reporter.PrintProjectScan(w, cs, result) + + if len(result.RegisteredLinks) == 0 { + os.Exit(1) + } + return nil +} diff --git a/pkg/cmd/scan/scan_test.go b/pkg/cmd/scan/scan_test.go new file mode 100644 index 0000000..d8fa2d1 --- /dev/null +++ b/pkg/cmd/scan/scan_test.go @@ -0,0 +1,253 @@ +package scan_test + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/space-code/linkctl/pkg/cmd/scan" + "github.com/space-code/linkctl/pkg/cmdutil" + "github.com/space-code/linkctl/pkg/iostreams" +) + +const mockPbxproj = ` + + + + archiveVersion + 1 + classes + + objectVersion + 46 + objects + + PROJECT_ID + + isa + PBXProject + buildConfigurationList + PROJECT_CONFIG_LIST + targets + + TARGET_ID + + attributes + + + PROJECT_CONFIG_LIST + + isa + XCConfigurationList + buildConfigurations + + PROJECT_CONFIG_ID_RELEASE + + defaultConfigurationName + Release + + PROJECT_CONFIG_ID_RELEASE + + isa + XCBuildConfiguration + name + Release + buildSettings + + + TARGET_ID + + isa + PBXNativeTarget + name + MyApp + productType + com.apple.product-type.application + buildConfigurationList + TARGET_CONFIG_LIST + dependencies + + buildPhases + + + TARGET_CONFIG_LIST + + isa + XCConfigurationList + buildConfigurations + + TARGET_CONFIG_ID_RELEASE + + defaultConfigurationName + Release + + TARGET_CONFIG_ID_RELEASE + + isa + XCBuildConfiguration + name + Release + buildSettings + + PRODUCT_BUNDLE_IDENTIFIER + com.example.myapp + CODE_SIGN_ENTITLEMENTS + MyApp.entitlements + INFOPLIST_FILE + Info.plist + + + + rootObject + PROJECT_ID + +` + +const mockEntitlements = ` + + + + com.apple.developer.associated-domains + + applinks:example.com + + +` + +const mockInfoPlist = ` + + + + CFBundleIdentifier + com.example.myapp + CFBundleURLTypes + + + CFBundleURLName + myapp-scheme + CFBundleURLSchemes + + myapp + + + + +` + +func newFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer) { + t.Helper() + + ios, _, stdout, _ := iostreams.Test() + + f := &cmdutil.Factory{ + AppVersion: "1.0.0", + ExecutableName: "linkctl", + IOStreams: ios, + } + + return f, stdout +} + +func createMockIOSProject(t *testing.T) (string, func()) { + tmpDir, err := os.MkdirTemp("", "linkctl-scan-ios-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + + xcodeprojPath := filepath.Join(tmpDir, "Mock.xcodeproj") + if err := os.MkdirAll(xcodeprojPath, 0o755); err != nil { + t.Fatalf("failed to create xcodeproj dir: %v", err) + } + + err = os.WriteFile(filepath.Join(xcodeprojPath, "project.pbxproj"), []byte(mockPbxproj), 0o644) + if err != nil { + t.Fatalf("failed to write project.pbxproj: %v", err) + } + + err = os.WriteFile(filepath.Join(tmpDir, "MyApp.entitlements"), []byte(mockEntitlements), 0o644) + if err != nil { + t.Fatalf("failed to write MyApp.entitlements: %v", err) + } + + err = os.WriteFile(filepath.Join(tmpDir, "Info.plist"), []byte(mockInfoPlist), 0o644) + if err != nil { + t.Fatalf("failed to write Info.plist: %v", err) + } + + cleanup := func() { + os.RemoveAll(tmpDir) + } + + return tmpDir, cleanup +} + +func TestScanCmd_TooManyArgs(t *testing.T) { + f, _ := newFactory(t) + cmd := scan.NewCmdScan(f) + cmd.SetArgs([]string{"./path1", "./path2"}) + if err := cmd.Execute(); err == nil { + t.Fatal("expected error when passing more than 1 argument") + } +} + +func TestScanCmd_UnknownFlag(t *testing.T) { + f, _ := newFactory(t) + cmd := scan.NewCmdScan(f) + cmd.SetArgs([]string{"--invalid-flag"}) + if err := cmd.Execute(); err == nil { + t.Fatal("expected error for unknown flag") + } +} + +func TestScanCmd_NonExistentDirectory(t *testing.T) { + f, _ := newFactory(t) + cmd := scan.NewCmdScan(f) + cmd.SetArgs([]string{"/non/existent/path/for/linkctl/test"}) + if err := cmd.Execute(); err == nil { + t.Fatal("expected error for non-existent directory scan") + } +} + +func TestScanCmd_IOSProject_JSON(t *testing.T) { + f, stdout := newFactory(t) + projDir, cleanup := createMockIOSProject(t) + defer cleanup() + + cmd := scan.NewCmdScan(f) + cmd.SetArgs([]string{projDir, "--json"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error during scan execution: %v", err) + } + + var result map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("output is not valid JSON: %v\ngot: %s", err, stdout.String()) + } + + links, ok := result["registered_links"].([]interface{}) + if !ok { + t.Fatalf("expected 'registered_links' array in output, got: %v", result) + } + + if len(links) == 0 { + t.Errorf("expected to find registered links in mock iOS project, found 0") + } +} + +func TestScanCmd_IOSProject_TextOutput(t *testing.T) { + f, stdout := newFactory(t) + projDir, cleanup := createMockIOSProject(t) + defer cleanup() + + cmd := scan.NewCmdScan(f) + cmd.SetArgs([]string{projDir}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error during scan execution: %v", err) + } + + if stdout.Len() == 0 { + t.Error("expected text reporter output, got empty stdout") + } +}