From 74c912dda7452e072aa471a14e7acbe2aa84ec30 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Tue, 23 Jun 2020 17:23:36 -0700 Subject: [PATCH] Initial commit --- main.go | 59 +++++++++++++ pkg/app.go | 85 ++++++++++++++++++ pkg/browsers.go | 35 ++++++++ pkg/image.go | 138 +++++++++++++++++++++++++++++ pkg/net.go | 219 +++++++++++++++++++++++++++++++++++++++++++++++ pkg/templates.go | 101 ++++++++++++++++++++++ 6 files changed, 637 insertions(+) create mode 100644 main.go create mode 100644 pkg/app.go create mode 100644 pkg/browsers.go create mode 100644 pkg/image.go create mode 100644 pkg/net.go create mode 100644 pkg/templates.go diff --git a/main.go b/main.go new file mode 100644 index 0000000..deb6b3a --- /dev/null +++ b/main.go @@ -0,0 +1,59 @@ +package main + +import ( + "flag" + "log" + + app "./pkg" +) + +// CLI flags +var ( + title string + icon string + browser string + browserExec string + url string +) + +func init() { + flag.StringVar(&title, "title", "", "App launcher title, default uses the web page title") + flag.StringVar(&icon, "icon", "", "App icon image (filesystem or URL), default uses the site favicon") + flag.StringVar(&browser, "browser", "", "Browser executable, full path or command, default firefox or chromium") + flag.StringVar(&browserExec, "exec", "", "Manually provide the browser exec string, like `firefox --ssb %s` or `chromium --app=%s`, "+ + "default is auto-detected based on Firefox and Chrome/ium.") +} + +func main() { + flag.Parse() + + // Detect defaults. + if browser == "" { + detected, err := app.DetectBrowser() + if err != nil { + panic("Failed to detect your web browser. Provide the -browser or -exec options to set one manually") + } + browser = detected + log.Printf("Detected browser: %s", browser) + } + if browserExec == "" { + detected, err := app.DetectExec(browser) + if err != nil { + panic("Failed to detect the browser exec line. Provide the -exec option to set one manually") + } + browserExec = detected + log.Printf("Browser exec line for PWA: '%s'", browserExec) + } + + url = flag.Arg(0) + if url == "" { + panic("Usage: pwa-launcher [options] ") + } + + app.Run(app.Parameters{ + Title: title, + URL: url, + Exec: browserExec, + Icon: icon, + }) +} diff --git a/pkg/app.go b/pkg/app.go new file mode 100644 index 0000000..60ee822 --- /dev/null +++ b/pkg/app.go @@ -0,0 +1,85 @@ +package app + +import ( + "image" + "log" + "os" + "strings" +) + +// Parameters to run the pwa-launcher app. +type Parameters struct { + Title string + Icon string + iconData image.Image + URL string + Exec string +} + +// Run the main app logic. +func Run(p Parameters) error { + // If no title or icon given, parse the site. + if p.Title == "" || p.Icon == "" { + insights, err := Parse(p.URL) + if err != nil { + log.Printf("Insights error: %s", err) + } else { + log.Printf("insights: %+v", insights) + if p.Title == "" && insights.Title != "" { + p.Title = insights.Title + } + } + + if icons, err := DetectIcons(p.URL); err == nil { + log.Printf("detected icons: %+v", icons) + insights.Icons = append(insights.Icons, icons...) + } else { + log.Printf("DetectIcons error: %s", err) + } + + log.Printf("Final insights: %+v", insights) + + // Select the best icon. + if p.Icon == "" { + icon, err := BestIcon(insights) + if err != nil { + panic("No suitable app icon found, provide one manually with -icon") + } + p.iconData = icon + } + } + + // Do we need to get or download an icon? + if p.iconData == nil && p.Icon != "" { + if strings.HasPrefix(p.Icon, "http:") || strings.HasPrefix(p.Icon, "https:") { + // Download an icon from the web. + png, err := ParseWebPNG(p.Icon) + if err != nil { + log.Fatalf("Couldn't download -icon from %s: %s", p.Icon, err) + } + p.iconData = png + } else { + fh, err := os.Open(p.Icon) + if err != nil { + panic(err) + } + png, err := ParsePNG(fh) + if err != nil { + panic(err) + } + p.iconData = png + fh.Close() + } + } + + // Missing a title? + if p.Title == "" { + panic("Couldn't detect a page title, provide one with the -title option") + } + + // Install the icon and launcher. + Install(p) + log.Printf("Launcher installed successfully.") + + return nil +} diff --git a/pkg/browsers.go b/pkg/browsers.go new file mode 100644 index 0000000..0005cbf --- /dev/null +++ b/pkg/browsers.go @@ -0,0 +1,35 @@ +package app + +import ( + "errors" + "fmt" + "os" + "strings" +) + +// Preferred browsers list. +var browsers = []string{ + "/usr/bin/firefox", + "/usr/bin/chromium", + "/usr/bin/google-chrome", +} + +// DetectBrowser auto-detects a preferred web browser and returns its path. +func DetectBrowser() (string, error) { + for _, path := range browsers { + if _, err := os.Stat(path); err == nil { + return path, nil + } + } + return "", errors.New("failed to auto-detect a web browser") +} + +// DetectExec auto-detects the exec line syntax for the selected browser. +func DetectExec(browser string) (string, error) { + if strings.Contains(browser, "firefox") { + return fmt.Sprintf("%s --ssb %%s", browser), nil + } else if strings.Contains(browser, "chromium") || strings.Contains(browser, "chrome") { + return fmt.Sprintf("%s --app=%%s", browser), nil + } + return "", errors.New("failed to auto-detect the browser exec line") +} diff --git a/pkg/image.go b/pkg/image.go new file mode 100644 index 0000000..0452809 --- /dev/null +++ b/pkg/image.go @@ -0,0 +1,138 @@ +package app + +import ( + "bytes" + "errors" + "fmt" + "image" + "image/png" + "io" + "io/ioutil" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" +) + +// BestIcon selects the best PNG icon image. +func BestIcon(i Insights) (image.Image, error) { + // Ensure all icons are loaded. + for _, icon := range i.Icons { + if icon.Data == nil { + img, err := ParseWebPNG(icon.URL) + if err == nil { + icon.Data = img + } + } + } + + // Get the biggest icon. + var biggest image.Image + var maxSize int + for _, icon := range i.Icons { + w := icon.Width() + if w > maxSize && icon.Data != nil { + biggest = icon.Data + } + } + + if biggest == nil { + return nil, errors.New("no suitable icon available") + } + return biggest, nil +} + +// ParsePNG parses a PNG image. +func ParsePNG(r io.Reader) (image.Image, error) { + img, err := png.Decode(r) + return img, err +} + +// ParseWebPNG parses a PNG image by HTTP URL. +func ParseWebPNG(url string) (image.Image, error) { + resp, err := http.Get(AddScheme(url)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + return ParsePNG(resp.Body) +} + +// IcoToPNG converts a .ico file into PNG images. +func IcoToPNG(r io.Reader) ([]image.Image, error) { + var result = []image.Image{} + bin, _ := ioutil.ReadAll(r) + + // Check if the image is already a PNG format. + if string(bin[1:4]) == "PNG" { + fmt.Printf("IcoToPNG: ico is already a png format!") + buf := bytes.NewBuffer(bin) + img, err := png.Decode(buf) + if err != nil { + return result, err + } + return []image.Image{img}, nil + } + + // Ensure we have icotool available. + if !HasICOTool() { + return result, errors.New("icotool not available") + } + + // Create a temp directory to extract this icon. + dir, err := ioutil.TempDir("", "pwa-launcher") + if err != nil { + return result, fmt.Errorf("TempDir: %s", err) + } + log.Printf("Temp dir to extract .ico file: %s", dir) + // defer os.RemoveAll(dir) + + // Write the .ico binary to the source file. + icoFile := filepath.Join(dir, "favicon.ico") + fh, err := os.Create(icoFile) + if err != nil { + return result, fmt.Errorf("Write %s: %s", icoFile, err) + } + fh.Write(bin) + fh.Close() + + // Run the commands. + cmd := exec.Command( + "icotool", "-x", "-o", dir, icoFile, + ) + stdout, err := cmd.CombinedOutput() + fmt.Println(stdout) + + // Glom the PNG images. + files, _ := filepath.Glob(filepath.Join(dir, "*.png")) + for _, file := range files { + fh, err := os.Open(file) + if err != nil { + continue + } + + if img, err := ParsePNG(fh); err == nil { + result = append(result, img) + } + + fh.Close() + } + + return result, nil +} + +// HasICOTool checks if the icotool binary is available. +func HasICOTool() bool { + _, err := exec.LookPath("icotool") + if err != nil { + fmt.Printf( + "********\n" + + "WARNING: command `icotool` was not found on your system.\n" + + "Extracting PNG images from .ico files will not be supported.\n" + + "To remedy this, install icoutils, e.g. `sudo apt install icoutils`\n" + + "********\n", + ) + } + return true +} diff --git a/pkg/net.go b/pkg/net.go new file mode 100644 index 0000000..564ddf5 --- /dev/null +++ b/pkg/net.go @@ -0,0 +1,219 @@ +package app + +import ( + "fmt" + "image" + "io" + "log" + "net/http" + "net/url" + "path/filepath" + "strconv" + "strings" + + "golang.org/x/net/html" +) + +// Insights parsed from a web URL. +type Insights struct { + Title string + Icons []Icon +} + +// Icon found for the web URL. +type Icon struct { + Size string + URL string + Data image.Image // raw image data, if available +} + +// Width returns the icon's width. +func (i Icon) Width() int { + parts := strings.Split(i.Size, "x") + if len(parts) > 1 { + w, _ := strconv.Atoi(parts[0]) + return w + } + return 0 +} + +// Parse a web page and return page insights. +func Parse(url string) (Insights, error) { + log.Printf("### Parse HTML on %s for Title and Icon URLs", url) + var ( + result = Insights{ + Icons: []Icon{}, + } + inTag = "" + ) + + resp, err := http.Get(url) + if err != nil { + return result, fmt.Errorf("HTTP error: %s", err) + } + defer resp.Body.Close() + + z := html.NewTokenizer(resp.Body) + parsing := true + for parsing { + tt := z.Next() + token := z.Token() + switch tt { + case html.ErrorToken: + fmt.Printf("error: %s\n", z.Err()) + if z.Err() == io.EOF { + // successful error condition + parsing = false + break + } + return result, fmt.Errorf("HTML parsing error: %s", z.Err()) + + case html.TextToken: + if inTag == "title" && result.Title == "" { + result.Title += token.Data + } + + case html.StartTagToken: + inTag = token.Data + + // Looking for + if token.Data == "link" { + attr := AttrDict(token) + rel, _ := attr["rel"] + sizes, _ := attr["sizes"] + href, _ := attr["href"] + + // Ensure "//" URIs start with "https://" + href = AddScheme(href) + + if rel == "shortcut icon" { + log.Printf(`Found URL: %s`, href) + if sizes == "" { + sizes = "16x16" + } + + // If an ico file, extract the PNGs. + if filepath.Ext(href) == ".ico" { + log.Printf("The favicon is a .ico file, extracting PNGs") + if resp, err := http.Get(href); err == nil { + if pngs, err := IcoToPNG(resp.Body); err == nil { + for _, png := range pngs { + size := png.Bounds().Size() + result.Icons = append(result.Icons, Icon{ + Size: fmt.Sprintf("%dx%d", size.X, size.Y), + URL: href, + Data: png, + }) + } + resp.Body.Close() + continue + } else { + log.Printf("Error extracting PNG from %s: %s", href, err) + } + resp.Body.Close() + } else { + log.Printf("HTTP error downloading %s: %s", href, err) + } + } + + result.Icons = append(result.Icons, Icon{ + Size: sizes, + URL: href, + }) + } + } + + case html.EndTagToken, html.SelfClosingTagToken: + inTag = "" + } + + } + + return result, nil +} + +// DetectIcons checks well-known icon URLs on a base domain. +func DetectIcons(weburl string) ([]Icon, error) { + var result = []Icon{} + + uri, err := url.Parse(weburl) + if err != nil { + return result, err + } + + baseURL := uri.Scheme + "://" + uri.Host + log.Printf("### Auto-detecting Icon URLs from site %s", baseURL) + + tryURI := []string{ + "/apple-touch-icon.png", + "/apple-touch-icon-180x180.png", + "/apple-touch-icon-152x152.png", + "/apple-touch-icon-144x144.png", + "/apple-touch-icon-120x120.png", + "/apple-touch-icon-114x114.png", + "/apple-touch-icon-76x76.png", + "/apple-touch-icon-72x72.png", + "/apple-touch-icon-57x57.png", + "/apple-touch-icon-60x60.png", + "/favicon.ico", + } + for _, uri := range tryURI { + resp, err := http.Get(baseURL + uri) + if err != nil { + continue + } + defer resp.Body.Close() + + // PNG images? + if filepath.Ext(uri) == ".png" { + png, err := ParsePNG(resp.Body) + if err != nil { + continue + } + + size := png.Bounds().Size() + result = append(result, Icon{ + Size: fmt.Sprintf("%dx%d", size.X, size.Y), + URL: baseURL + uri, + }) + } else if filepath.Ext(uri) == ".ico" { + // Extract the PNG images from the icon. + pngs, err := IcoToPNG(resp.Body) + if err != nil { + continue + } + + for _, png := range pngs { + size := png.Bounds().Size() + result = append(result, Icon{ + Size: fmt.Sprintf("%dx%d", size.X, size.Y), + URL: baseURL + uri, + Data: png, + }) + } + } + + log.Printf("Found icon: %s", uri) + _ = resp + } + + return result, nil +} + +// AttrDict converts an HTML Token attributes list into a hash map. +func AttrDict(token html.Token) map[string]string { + var result = map[string]string{} + for _, attr := range token.Attr { + result[attr.Key] = attr.Val + } + return result +} + +// AddScheme ensures an HTTP URL has a valid scheme, converting "//" into +// "https://" +func AddScheme(uri string) string { + if strings.HasPrefix(uri, "//") { + return "https:" + uri + } + return uri +} diff --git a/pkg/templates.go b/pkg/templates.go new file mode 100644 index 0000000..314a23a --- /dev/null +++ b/pkg/templates.go @@ -0,0 +1,101 @@ +package app + +import ( + "fmt" + "image/png" + "log" + "net/url" + "os" + "path/filepath" + "strings" + "text/template" + "time" +) + +// Launcher is the source code template for a desktop launcher. +const Launcher = `[Desktop Entry] +Version=1.0 +Name={{ .Name }} +GenericName={{ .Name }} +Comment=Progressive Web App +Exec={{ .Exec }} +Icon={{ .Icon }} +Terminal=false +Type=Application +StartupNotify=true +Categories=Network; +` + +// Vars for the launcher template. +type Vars struct { + Name string + Exec string + Icon string +} + +// Install the launcher and icon. +func Install(p Parameters) { + var ( + HOME = os.Getenv("HOME") + basename = UID(p) + ) + + if HOME == "" { + panic("No $HOME variable found for current user!") + } + + // Ensure directories exist. + for _, path := range []string{ + filepath.Join(HOME, ".local", "share", "applications"), + filepath.Join(HOME, ".config", "pwa-launcher"), + } { + err := os.MkdirAll(path, 0755) + if err != nil { + log.Fatalf("Failed to mkdir %s: %s", path, err) + } + } + + // Write the PNG image. + var iconPath = filepath.Join(HOME, ".config", "pwa-launcher", basename+".png") + log.Printf("Write icon to: %s", iconPath) + icoFH, err := os.Create(iconPath) + if err != nil { + panic(err) + } + defer icoFH.Close() + if err := png.Encode(icoFH, p.iconData); err != nil { + panic(err) + } + + // Write the desktop launcher. + var launcherPath = filepath.Join(HOME, ".local", "share", "applications", basename+".desktop") + log.Printf("Write launcher to: %s", launcherPath) + + fh, err := os.Create(launcherPath) + if err != nil { + panic(err) + } + defer fh.Close() + t := template.Must(template.New("launcher").Parse(Launcher)) + err = t.Execute(fh, Vars{ + Name: p.Title, + Exec: fmt.Sprintf(p.Exec, p.URL), + Icon: iconPath, + }) + if err != nil { + panic(err) + } +} + +// UID generates a unique name for the launcher and icon files. +func UID(p Parameters) string { + var name = "pwa-" + + uri, err := url.Parse(p.URL) + if err == nil { + name += strings.Replace(uri.Host, ":", "_", -1) + "-" + } + + name += fmt.Sprintf("%d", time.Now().Unix()) + return name +}