Initial commit

master
Noah 2020-06-23 17:23:36 -07:00
commit 74c912dda7
6 changed files with 637 additions and 0 deletions

59
main.go Normal file
View File

@ -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] <url>")
}
app.Run(app.Parameters{
Title: title,
URL: url,
Exec: browserExec,
Icon: icon,
})
}

85
pkg/app.go Normal file
View File

@ -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
}

35
pkg/browsers.go Normal file
View File

@ -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")
}

138
pkg/image.go Normal file
View File

@ -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
}

219
pkg/net.go Normal file
View File

@ -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 <link rel="shortcut icon">
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 <link rel="shortcut icon"> 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
}

101
pkg/templates.go Normal file
View File

@ -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
}