Initial commit
This commit is contained in:
commit
74c912dda7
59
main.go
Normal file
59
main.go
Normal 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
85
pkg/app.go
Normal 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
35
pkg/browsers.go
Normal 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
138
pkg/image.go
Normal 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
219
pkg/net.go
Normal 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
101
pkg/templates.go
Normal 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
|
||||
}
|
Loading…
Reference in New Issue
Block a user