From 7fb4bcbcbccba118c0f4cbb65d5e15b90dc6220f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 13 Oct 2016 10:12:04 +0100 Subject: [PATCH] Added detection of OS version for macOS, iOS, Windows and Android at the moment. Backwards compatible. --- browser.go | 43 ++++++++++++++++++++++++++++++++++--------- lex.go | 15 +++++++++++++++ parse.go | 49 ++++++++++++++++++++++++++++++++++++++++++++++--- parse_test.go | 43 ++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 137 insertions(+), 13 deletions(-) diff --git a/browser.go b/browser.go index a997e60..72d8824 100644 --- a/browser.go +++ b/browser.go @@ -32,6 +32,14 @@ var browsers = map[string]*url.URL{ "WebView": u("http://developer.android.com/guide/webapps/webview.html"), } +const ( + osAndroid = "Android" + osMacOS = "Mac OS X" + osIOS = "iOS" + osLinux = "GNU/Linux" + osWindows = "Windows" +) + func parseBrowser(l *lex) *UserAgent { for _, f := range []parseFn{parseGecko, parseChromeSafari, parseIE1, parseIE2} { if ua := f(newLex(l.s)); ua != nil { @@ -67,7 +75,7 @@ func parseMozillaLike(l *lex, ua *UserAgent) bool { parseUnixLike(l, ua) case l.match("Android"): ua.Security = parseSecurity(l) - ua.OS = "Android" + ua.OS = osAndroid if l.match("; Mobile") { ua.Mobile = true } else if l.match("; Tablet") { @@ -76,16 +84,16 @@ func parseMozillaLike(l *lex, ua *UserAgent) bool { case l.match("Linux; "): ua.Security = parseSecurity(l) if l.match("Android") { - ua.OS = "Android" + ua.OS = osAndroid } else { return false } case l.match("Windows"): ua.Security = parseSecurity(l) - ua.OS = "Windows" + ua.OS = osWindows case l.match("Macintosh"): ua.Security = parseSecurity(l) - ua.OS = "Mac OS X" + ua.OS = osMacOS case l.match("Mobile; "): ua.Security = parseSecurity(l) ua.OS = "Firefox OS" @@ -96,11 +104,11 @@ func parseMozillaLike(l *lex, ua *UserAgent) bool { ua.Tablet = true case l.match("iPad; "): ua.Security = parseSecurity(l) - ua.OS = "iOS" + ua.OS = osIOS ua.Tablet = true case l.match("iPhone; ") || l.match("iPod; ") || l.match("iPod touch; "): ua.Security = parseSecurity(l) - ua.OS = "iOS" + ua.OS = osIOS ua.Mobile = true case l.match("Unknown; "): ua.Security = parseSecurity(l) @@ -109,6 +117,9 @@ func parseMozillaLike(l *lex, ua *UserAgent) bool { return false } + // swallow the error to preserve backwards compatibility + _ = parseOSVersion(l, ua) + if _, ok := l.span(") "); !ok { return false } @@ -120,7 +131,7 @@ func parseMozillaLike(l *lex, ua *UserAgent) bool { func parseUnixLike(l *lex, ua *UserAgent) bool { switch { case l.match("Linux") || l.match("Ubuntu"): - ua.OS = "GNU/Linux" + ua.OS = osLinux case l.match("FreeBSD"): ua.OS = "FreeBSD" case l.match("OpenBSD"): @@ -236,11 +247,18 @@ func parseIE1(l *lex) *UserAgent { return nil } ua.Name = "MSIE" - ua.OS = "Windows" if !parseVersion(l, ua, ";") { return nil } + if !l.match(" Windows NT") { + return nil + } + + ua.OS = osWindows + // swallow the error to preserve backwards compatibility + _ = parseOSVersion(l, ua) + return ua } @@ -252,6 +270,14 @@ func parseIE2(l *lex) *UserAgent { if !l.match("Mozilla") { return nil } + if _, ok := l.span("(Windows NT"); !ok { + return nil + } + ua.OS = osWindows + + // swallow the error to preserve backwards compatibility + _ = parseOSVersion(l, ua) + if _, ok := l.span("Trident/"); !ok { return nil } @@ -259,7 +285,6 @@ func parseIE2(l *lex) *UserAgent { return nil } ua.Name = "MSIE" - ua.OS = "Windows" if !parseVersion(l, ua, ")") { return nil } diff --git a/lex.go b/lex.go index 2c260d0..8a72eef 100644 --- a/lex.go +++ b/lex.go @@ -16,6 +16,7 @@ package useragent import ( + "regexp" "strings" ) @@ -56,3 +57,17 @@ func (l *lex) spanAny(chars string) (string, bool) { l.p += i + len(chars) return s, true } + +// assumes the first group is the bit we want +func (l *lex) spanRegexp(re *regexp.Regexp) (before string, match string, success bool) { + loc := re.FindStringSubmatchIndex(l.s[l.p:]) + if loc == nil { + return "", "", false + } + success = true + start, end := loc[2], loc[3] + before = l.s[l.p : l.p+start] + match = l.s[l.p:][start:end] + l.p += end + return +} diff --git a/parse.go b/parse.go index 9ec4412..5a00342 100644 --- a/parse.go +++ b/parse.go @@ -20,6 +20,7 @@ import ( "fmt" "github.com/blang/semver" "net/url" + "regexp" "strings" ) @@ -107,8 +108,9 @@ type UserAgent struct { // CrOS // etc. // If the os is not known, OS will be `unknown'. - OS string - Security Security + OS string + OSVersion semver.Version + Security Security // URL with more information about the user agent (in most cases it's the home page). // If unknown is nil. URL *url.URL @@ -123,9 +125,10 @@ func (ua *UserAgent) String() string { Name: %v Version: %v OS: %v +OSVersion: %v Security: %v Mobile: %v -Tablet: %v`, ua.Type, ua.Name, ua.Version, ua.OS, ua.Security, ua.Mobile, ua.Tablet) +Tablet: %v`, ua.Type, ua.Name, ua.Version, ua.OS, ua.OSVersion, ua.Security, ua.Mobile, ua.Tablet) } func new() *UserAgent { @@ -201,6 +204,46 @@ func parseVersion(l *lex, ua *UserAgent, sep string) bool { return true } +var appleVersionRegexp = regexp.MustCompile(`(?:[^\)]+?)\b(\d+_\d+(_\d+)?)\b`) +var genericVersionRegexp = regexp.MustCompile(`(?:[^\)]*?) (\d+\.\d+(\.\d+)?)\b`) + +func parseOSVersion(l *lex, ua *UserAgent) bool { + switch ua.OS { + case osMacOS, osIOS: + _, s, ok := l.spanRegexp(appleVersionRegexp) + if !ok { + return true + } + + s = strings.Replace(s, "_", ".", -1) + + v, err := semver.ParseTolerant(s) + if err != nil { + return false + } + + ua.OSVersion = v + return true + + case osAndroid, osWindows: + _, s, ok := l.spanRegexp(genericVersionRegexp) + if !ok { + return true + } + + v, err := semver.ParseTolerant(s) + if err != nil { + return false + } + + ua.OSVersion = v + return true + + default: + return false + } +} + func parseNameVersion(l *lex, ua *UserAgent) bool { var s string var ok bool diff --git a/parse_test.go b/parse_test.go index f7f3e86..bb197c3 100644 --- a/parse_test.go +++ b/parse_test.go @@ -29,6 +29,7 @@ func ExampleParse() { //Name: Firefox //Version: 38.0.0 //OS: GNU/Linux + //OSVersion: 0.0.0 //Security: Unknown security //Mobile: false //Tablet: false @@ -60,6 +61,7 @@ func eqUA(a *UserAgent, b *UserAgent) bool { if a.Type != b.Type || a.OS != b.OS || + !a.OSVersion.EQ(b.OSVersion) || a.Name != b.Name || !a.Version.EQ(b.Version) || a.Security != b.Security || @@ -71,7 +73,7 @@ func eqUA(a *UserAgent, b *UserAgent) bool { } func mustParse(s string) semver.Version { - v, err := semver.Parse(s) + v, err := semver.ParseTolerant(s) if err != nil { panic(`semver: Parse(` + s + `): ` + err.Error()) } @@ -85,6 +87,7 @@ func TestGecko(t *testing.T) { got = Parse(`Mozilla/5.0 (X11; U; Linux i686; rv:38.0) Gecko/20100101 Firefox/38.0`) want.Type = Browser want.OS = "GNU/Linux" + want.OSVersion = semver.Version{} want.Name = "Firefox" want.Version = mustParse("38.0.0") want.Security = SecurityStrong @@ -95,6 +98,7 @@ func TestGecko(t *testing.T) { got = Parse(`Mozilla/5.0 (X11; U; Linux x86_64; sv-SE; rv:1.9.1.16) Gecko/20120714 IceCat/3.5.16 (like Firefox/3.5.16)`) want.Type = Browser want.OS = "GNU/Linux" + want.OSVersion = semver.Version{} want.Name = "IceCat" want.Version = mustParse("3.5.16") want.Security = SecurityStrong @@ -105,6 +109,7 @@ func TestGecko(t *testing.T) { got = Parse(`Mozilla/5.0 (Windows x86; rv:19.0) Gecko/20100101 Firefox/19.0`) want.Type = Browser want.OS = "Windows" + want.OSVersion = semver.Version{} want.Name = "Firefox" want.Version = mustParse("19.0.0") want.Security = SecurityUnknown @@ -115,6 +120,7 @@ func TestGecko(t *testing.T) { got = Parse(`Mozilla/5.0 (Mobile; rv:26.0) Gecko/26.0 Firefox/26.0`) want.Type = Browser want.OS = "Firefox OS" + want.OSVersion = semver.Version{} want.Name = "Firefox" want.Version = mustParse("26.0.0") want.Security = SecurityUnknown @@ -126,6 +132,7 @@ func TestGecko(t *testing.T) { got = Parse(`Mozilla/5.0 (iPod touch; CPU iPhone OS 8_3 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) FxiOS/1.0 Mobile/12F69 Safari/600.1.4`) want.Type = Browser want.OS = "iOS" + want.OSVersion = mustParse("8.3") want.Name = "Firefox" want.Version = mustParse("1.0.0") want.Security = SecurityUnknown @@ -137,6 +144,7 @@ func TestGecko(t *testing.T) { got = Parse(`Mozilla/5.0 (iPhone; CPU iPhone OS 8_3 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) FxiOS/1.0 Mobile/12F69 Safari/600.1.4`) want.Type = Browser want.OS = "iOS" + want.OSVersion = mustParse("8.3") want.Name = "Firefox" want.Version = mustParse("1.0.0") want.Security = SecurityUnknown @@ -149,6 +157,7 @@ func TestGecko(t *testing.T) { got = Parse(`Mozilla/5.0 (iPad; CPU iPhone OS 8_3 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) FxiOS/1.0 Mobile/12F69 Safari/600.1.4`) want.Type = Browser want.OS = "iOS" + want.OSVersion = mustParse("8.3") want.Name = "Firefox" want.Version = mustParse("1.0.0") want.Security = SecurityUnknown @@ -162,6 +171,7 @@ func TestGecko(t *testing.T) { got = Parse(`Mozilla/5.0 (Linux; Android 4.4.3; KFTHWI Build/KTU84M) AppleWebKit/537.36 (KHTML, like Gecko) Silk/44.1.54 like Chrome/44.0.2403.63 Safari/537.36`) want.Type = Browser want.OS = "Android" + want.OSVersion = mustParse("4.4.3") want.Name = "Silk" want.Version = mustParse("44.1.54") want.Security = SecurityUnknown @@ -175,6 +185,7 @@ func TestGecko(t *testing.T) { got = Parse(`Mozilla/5.0 (Linux; U; Android 4.4.3; KFTHWI Build/KTU84M) AppleWebKit/537.36 (KHTML, like Gecko) Silk/44.1.54 like Chrome/44.0.2403.63 Mobile Safari/537.36`) want.Type = Browser want.OS = "Android" + want.OSVersion = mustParse("4.4.3") want.Name = "Silk" want.Version = mustParse("44.1.54") want.Security = SecurityStrong @@ -192,6 +203,7 @@ func TestChrome(t *testing.T) { got = Parse(`Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2227.0 Safari/537.36`) want.Type = Browser want.OS = "GNU/Linux" + want.OSVersion = semver.Version{} want.Name = "Chrome" want.Version = mustParse("41.0.2227") want.Security = SecurityUnknown @@ -202,6 +214,7 @@ func TestChrome(t *testing.T) { got = Parse(`Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36`) want.Type = Browser want.OS = "Windows" + want.OSVersion = mustParse("6.1") want.Name = "Chrome" want.Version = mustParse("41.0.2228") want.Security = SecurityUnknown @@ -212,6 +225,7 @@ func TestChrome(t *testing.T) { got = Parse(`Mozilla/5.0 (Linux; Android 4.0.4; Galaxy Nexus Build/IMM76B) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.133 Mobile Safari/535.19`) want.Type = Browser want.OS = "Android" + want.OSVersion = mustParse("4.0.4") want.Name = "Chrome" want.Version = mustParse("18.0.1025") want.Security = SecurityUnknown @@ -223,6 +237,7 @@ func TestChrome(t *testing.T) { got = Parse(`Mozilla/5.0 (iPhone; U; CPU iPhone OS 5_1_1 like Mac OS X; en) AppleWebKit/534.46.0 (KHTML, like Gecko) CriOS/19.0.1084.60 Mobile/9B206 Safari/7534.48.3`) want.Type = Browser want.OS = "iOS" + want.OSVersion = mustParse("5.1.1") want.Name = "Chrome" want.Version = mustParse("19.0.1084") want.Security = SecurityStrong @@ -234,6 +249,7 @@ func TestChrome(t *testing.T) { got = Parse(`Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2490.76 Safari/537.36`) want.Type = Browser want.OS = "Android" + want.OSVersion = mustParse("6.0") want.Name = "Chrome" want.Version = mustParse("46.0.2490") want.Security = SecurityUnknown @@ -242,6 +258,19 @@ func TestChrome(t *testing.T) { if !eqUA(want, got) { t.Errorf("expected %+v, got %+v\n", want, got) } + + got = Parse(`Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.143 Safari/537.36`) + want.Type = Browser + want.OS = "Mac OS X" + want.OSVersion = mustParse("10.12.0") + want.Name = "Chrome" + want.Version = mustParse("53.0.2785") + want.Security = SecurityUnknown + want.Mobile = false + want.Tablet = false + if !eqUA(want, got) { + t.Errorf("expected %+v, got %+v\n", want, got) + } } // Android's Chromium-based web rendering library @@ -252,6 +281,7 @@ func TestWebView(t *testing.T) { got = Parse(`Mozilla/5.0 (Linux; Android 5.1.1; Nexus 5 Build/LMY48B; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/43.0.2357.65 Mobile Safari/537.36`) want.Type = Library want.OS = "Android" + want.OSVersion = mustParse("5.1.1") want.Name = "WebView" want.Version = mustParse("43.0.2357") want.Security = SecurityUnknown @@ -264,6 +294,7 @@ func TestWebView(t *testing.T) { got = Parse(`Mozilla/5.0 (Linux; Android 5.0.2; SM-T350 Build/LRX22G; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/49.0.2623.105 Safari/537.36`) want.Type = Library want.OS = "Android" + want.OSVersion = mustParse("5.0.2") want.Name = "WebView" want.Version = mustParse("49.0.2623") want.Security = SecurityUnknown @@ -281,6 +312,7 @@ func TestSafari(t *testing.T) { got = Parse(`Mozilla/5.0 (Macintosh; Intel Mac OS X 10_6_8) AppleWebKit/537.13+ (KHTML, like Gecko) Version/5.1.7 Safari/534.57.2`) want.Type = Browser want.OS = "Mac OS X" + want.OSVersion = mustParse("10.6.8") want.Name = "Safari" want.Version = mustParse("5.1.7") want.Security = SecurityUnknown @@ -292,6 +324,7 @@ func TestSafari(t *testing.T) { got = Parse(`Mozilla/5.0 (iPhone; CPU iPhone OS 6_1_4 like Mac OS X) AppleWebKit/536.26 (KHTML, like Gecko) Version/6.0 Mobile/10B350 Safari/8536.25`) want.Type = Browser want.OS = "iOS" + want.OSVersion = mustParse("6.1.4") want.Name = "Safari" want.Version = mustParse("6.0.0") want.Security = SecurityUnknown @@ -303,6 +336,7 @@ func TestSafari(t *testing.T) { got = Parse(`Mozilla/5.0 (iPad; U; CPU OS 3_2 like Mac OS X; en-us) AppleWebKit/531.21.10 (KHTML, like Gecko) Version/4.0.4 Mobile/7B334b Safari/531.21.10`) want.Type = Browser want.OS = "iOS" + want.OSVersion = mustParse("3.2") want.Name = "Safari" want.Version = mustParse("4.0.4") want.Security = SecurityStrong @@ -320,6 +354,7 @@ func TestIE(t *testing.T) { got = Parse(`Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; Trident/6.0)`) want.Type = Browser want.OS = "Windows" + want.OSVersion = mustParse("6.1") want.Name = "MSIE" want.Version = mustParse("10.0.0") want.Security = SecurityUnknown @@ -330,6 +365,7 @@ func TestIE(t *testing.T) { got = Parse(`Mozilla/5.0 (Windows NT 6.3; Trident/7.0; .NET4.0E; .NET4.0C; rv:11.0) like Gecko`) want.Type = Browser want.OS = "Windows" + want.OSVersion = mustParse("6.3") want.Name = "MSIE" want.Version = mustParse("11.0.0") want.Security = SecurityUnknown @@ -345,6 +381,7 @@ func TestGeneric(t *testing.T) { got = Parse(`Dillo/0.8.6-i18n-misc`) want.Type = Browser want.OS = "unknown" + want.OSVersion = semver.Version{} want.Name = "Dillo" want.Version = mustParse("0.8.6-i18n-misc") want.Security = SecurityUnknown @@ -356,6 +393,7 @@ func TestGeneric(t *testing.T) { got = Parse(`Googlebot/2.1 (+http://www.google.com/bot.html)`) want.Type = Crawler want.OS = "unknown" + want.OSVersion = semver.Version{} want.Name = "Googlebot" want.Version = mustParse("2.1.0") want.Security = SecurityUnknown @@ -373,6 +411,7 @@ func TestPhantomJS(t *testing.T) { got = Parse(`Mozilla/5.0 (Macintosh; Intel Mac OS X) AppleWebKit/538.1 (KHTML, like Gecko) PhantomJS/2.0.0 Safari/538.1`) want.Type = Library want.OS = "Mac OS X" + want.OSVersion = semver.Version{} want.Name = "PhantomJS" want.Version = mustParse("2.0.0") want.Security = SecurityUnknown @@ -383,6 +422,7 @@ func TestPhantomJS(t *testing.T) { got = Parse(`Mozilla/5.0 (Macintosh; Intel Mac OS X) AppleWebKit/534.34 (KHTML, like Gecko) PhantomJS/1.9.0 (development) Safari/534.34`) want.Type = Library want.OS = "Mac OS X" + want.OSVersion = semver.Version{} want.Name = "PhantomJS" want.Version = mustParse("1.9.0") want.Security = SecurityUnknown @@ -393,6 +433,7 @@ func TestPhantomJS(t *testing.T) { got = Parse(`Mozilla/5.0 (Unknown; Linux x86_64) AppleWebKit/538.1 (KHTML, like Gecko) PhantomJS/2.1.1 Safari/538.1`) want.Type = Library want.OS = "GNU/Linux" + want.OSVersion = semver.Version{} want.Name = "PhantomJS" want.Version = mustParse("2.1.1") want.Security = SecurityUnknown