From fcc132e72f47578c7ea8eba6195ebd9c7682bf36 Mon Sep 17 00:00:00 2001 From: Ian Fijolek Date: Thu, 26 Nov 2020 08:03:23 -0800 Subject: [PATCH] Working Python and Go versions --- .gitignore | 3 + Makefile | 25 ++++++ README.md | 16 ++++ browserRuler.desktop | 9 ++ browser_ruler.py | 201 +++++++++++++++++++++++++++++++++++++++++++ config.go | 114 ++++++++++++++++++++++++ main.go | 182 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 550 insertions(+) create mode 100644 Makefile create mode 100644 browserRuler.desktop create mode 100755 browser_ruler.py create mode 100644 config.go create mode 100644 main.go diff --git a/.gitignore b/.gitignore index f4d432a..466db45 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ # Dependency directories (remove the comment below to include it) # vendor/ +# Application specific +nohup.out +browser-ruler diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e947e97 --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +# BIN = browser_ruler.py +BIN = browser-ruler +SCRIPT_INSTALL_PATH ?= $(HOME)/.local/bin +DESKTOP_INSTALL_PATH ?= $(HOME)/.local/share/applications + +.PHONY: default all clean +default: test + +.PHONY: test +test: $(BIN) + ./$(BIN) https://duck.com/ + +$(BIN): *.go + go build . + +.PHONY: install +install: $(BIN) + mkdir -p "$(SCRIPT_INSTALL_PATH)" + mkdir -p "$(DESKTOP_INSTALL_PATH)" + cp $(BIN) "$(SCRIPT_INSTALL_PATH)/$(BIN)" + sed "s|{SCRIPT}|$(SCRIPT_INSTALL_PATH)/$(BIN)|" ./browserRuler.desktop > "$(DESKTOP_INSTALL_PATH)/browserRuler.desktop" + +.PHONY: set-default +set-default: + xdg-settings set default-web-browser browserRuler.desktop diff --git a/README.md b/README.md index a70bef3..6b098f7 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,18 @@ # browser-ruler +A small program that allows writing rules to determine which browser to lauch based on the URL + +This is tested on an Ubuntu system but should work on anything that supports xdg + +## Configuration + +There is no configuration, persay. Intead, the code must be updated. + +Edit `main.go` and edit the list of `BrowserRules` in a similar fashion as the example and then run `make install` again. + +There is also a Python version as `browser_ruler.py`. To use that instead, you can edit that and then update the Makefile to make the `BIN` variable `BIN = browser_ruler.py`. + +## Installation + + make install set-default + diff --git a/browserRuler.desktop b/browserRuler.desktop new file mode 100644 index 0000000..7e0e6fa --- /dev/null +++ b/browserRuler.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Name=Browser Ruler +Comment=Automatically opens urls in a particular browser +Exec={SCRIPT} %U +Terminal=false +Type=Application +MimeType=text/html;text/xml;application/xhtml+xml;x-scheme-handler/http;x-scheme-handler/https; +Categories=Network;WebBrowser; +StartupNotify=false diff --git a/browser_ruler.py b/browser_ruler.py new file mode 100755 index 0000000..2aad935 --- /dev/null +++ b/browser_ruler.py @@ -0,0 +1,201 @@ +#! /usr/bin/env python3 +import sys +from re import match +from subprocess import Popen +from typing import Any +from typing import Callable +from typing import List +from typing import Iterable +from typing import NamedTuple +from urllib.parse import urlparse + + +class Intent(NamedTuple): + url: str + referrer: str + + +class BrowserRule(NamedTuple): + match_func: Callable[[Intent], bool] + command: str + args: List[str] = [] + + def matched(self, intent: Intent) -> bool: + """Checks if the intent matches the rule using match_func""" + return self.match_func(intent) + + def launch(self, intent: Intent) -> None: + """Launch the command""" + Popen(["nohup", self.command, *self.args, intent.url], shell=False) + + def maybe_launch(self, intent: Intent) -> bool: + if self.matched(intent): + self.launch(intent) + return True + + return False + + +# Matching functions and factories +MatchFunc = Callable[[Intent], bool] + + +def match_regex(rules: List[str]) -> MatchFunc: + """Returns a function to handle urls and match them using regex""" + def match_func(intent: Intent) -> bool: + for rule in rules: + if match(rule, intent.url): + return True + return False + + return match_func + + +def match_host_regex(rules: List[str]) -> MatchFunc: + """Returns a function to handle urls and match them using regex""" + def match_func(intent: Intent) -> bool: + url = urlparse(intent.url) + for rule in rules: + if match(rule, url.hostname): + return True + return False + + return match_func + + +def match_hostname(hostnames: List[str]) -> MatchFunc: + """Returns a matching function that parses URLs and checks their hosts""" + def match_func(intent: Intent) -> bool: + url = urlparse(intent.url) + for hostname in hostnames: + if url.hostname == hostname: + return True + return False + + return match_func + + +def any_match(rules: Iterable[MatchFunc]) -> MatchFunc: + def match_func(intent: Intent) -> bool: + return any(r(intent) for r in rules) + + return match_func + + +def default(x: Any) -> bool: + """Always returns True""" + return True + + +def noop(x: Any) -> bool: + """Always returns False""" + return False + + +browser_rules = [ + # Self-hosted sites + BrowserRule( + match_host_regex([ + r".*\.iamthefij\.com$", + r".*\.thefij\.rocks$", + r".*\.thefij$", + ]), + "firefox", + ), + # Work domains + BrowserRule( + any_match(( + match_hostname([ + "app.signalfx.com", + "lever.co", + "work.grubhub.com", + "y", + "yelp.rimeto.io", + "yelp.slack.com", + "yelplove.appspot.com", + ]), + match_host_regex([ + r".*\.lifesize\.com$", + r".*\.myworkday\.com$", + r".*\.salesforce\.com$", + r".*\.yelpcorp\.com$", + ]), + )), + "chromium-browser" + ), + # Other, generally Googly things + BrowserRule( + any_match(( + match_hostname([ + "google.com", + "youtube.com", + ]), + match_host_regex([ + r".*\.google\.com$", + r".*\.youtube\.com$", + ]), + )), + "chromium-browser", + ), + # Fall back to firefox as the default + BrowserRule( + default, + "firefox", + ), + + # Sample browsers + + # Firefox new tab + BrowserRule( + noop, + "firefox", + ), + # Firefox new window + BrowserRule( + noop, + "firefox", + ["--new-window"], + ), + # Firefox private window + BrowserRule( + noop, + "firefox", + ["--private-window"], + ), + # Chromium new tab + BrowserRule( + noop, + "chromium-browser", + ), + # Chromium new window + BrowserRule( + noop, + "chromium-browser", + ["--new-window"], + ), + # Chromium incongnito + BrowserRule( + noop, + "chromium-browser", + ["--incognito"], + ), +] + + +def open_url(url: str, referrer=None) -> bool: + intent = Intent(url, referrer) + return any(rule.maybe_launch(intent) for rule in browser_rules) + + +def main(urls: List[str], referrer=None): + failed_urls: List[str] = [] + for url in urls: + if not open_url(url): + failed_urls.append(url) + + if failed_urls: + raise ValueError(f"No rules present for {failed_urls}. Add a default rule") + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/config.go b/config.go new file mode 100644 index 0000000..e97183c --- /dev/null +++ b/config.go @@ -0,0 +1,114 @@ +package main + +import ( + "encoding/json" + "fmt" +) + +// TODO: This is a total work in progress. It generally works execept it's ugly and regex strings aren't parsed correctly + +// Config is the full set of configuration required +type Config struct { + DefaultBrowserCommand []string + Rules []BrowserRuleConfig +} + +// BrowserRuleConfig represents a BrowserRule +type BrowserRuleConfig struct { + BrowserCommand []string + Matchers []MatcherConfig +} + +// MatcherConfig represents a single MatchFunc +type MatcherConfig struct { + Kind string + Targets []string +} + +func parseConfig(jsonBytes []byte) (Config, error) { + var c Config + err := json.Unmarshal(jsonBytes, &c) + return c, err +} + +// GetRules returns all rules parsed from a JSON string +func GetRules(jsonBytes []byte) (rules []BrowserRule, err error) { + c, err := parseConfig(jsonBytes) + if err != nil { + return + } + + for _, rule := range c.Rules { + if len(rule.BrowserCommand) < 1 { + err = fmt.Errorf("Must provide a browser command") + return + } + browserCommand := rule.BrowserCommand[0] + args := rule.BrowserCommand[1:] + + matchers := []MatchFunc{} + for _, matcher := range rule.Matchers { + switch matcher.Kind { + case "matchHostname": + fmt.Println("Match hostname for ", matcher.Targets) + matchers = append(matchers, matchHostname(matcher.Targets...)) + case "matchHostRegexp": + matchers = append(matchers, matchHostRegexp(matcher.Targets...)) + case "matchRegexp": + matchers = append(matchers, matchRegexp(matcher.Targets...)) + default: + err = fmt.Errorf("Unknown matcher kind %s", matcher.Kind) + return + } + } + var matcher MatchFunc + if len(matchers) == 1 { + matcher = matchers[0] + } else { + matcher = matchAny(matchers...) + } + + rules = append( + rules, + BrowserRule{ + matcher: matcher, + command: browserCommand, + args: args, + }, + BrowserRule{ + matcher: defaultBrowser, + command: c.DefaultBrowserCommand[0], + args: c.DefaultBrowserCommand[1:], + }, + ) + + } + + return +} + +func GetTestRules() ([]BrowserRule, error) { + jsonBytes := []byte(`{ + "DefaultBrowserCommand": ["chromium-browser"], + "Rules": [ + { + "BrowserCommand": ["firefox"], + "Matchers": [ + { + "Kind": "matchHostname", + "Targets": [ + "iamthefij.com" + ] + }, + { + "Kind": "matchHostRegexp", + "Targets": [ + ".*\.iamthefij\.com$", + ] + } + ] + } + ] + }`) + return GetRules(jsonBytes) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..111f77a --- /dev/null +++ b/main.go @@ -0,0 +1,182 @@ +package main + +import ( + "flag" + "net/url" + "os/exec" + "regexp" +) + +// MatchFunc is a signature for a function to match a URL +type MatchFunc = func(url.URL) bool + +// A BrowserRule is a rule that will launch a browser, if matched +type BrowserRule struct { + matcher MatchFunc + command string + args []string +} + +// IsMatched will check to see the rule matches the provied URL +func (r BrowserRule) IsMatched(dest url.URL) bool { + return r.matcher(dest) +} + +// Launch will launch the browser with the provided URL +func (r BrowserRule) Launch(dest url.URL) error { + args := append(r.args, "--", dest.String()) + cmd := exec.Command(r.command, args...) + return cmd.Start() +} + +// MaybeLaunch will lauch the browser with the provided URL if it matches the rule +func (r BrowserRule) MaybeLaunch(dest url.URL) (bool, error) { + if r.IsMatched(dest) { + return true, r.Launch(dest) + } + return false, nil +} + +func matchHostname(hostnames ...string) MatchFunc { + return func(dest url.URL) bool { + for _, host := range hostnames { + if host == dest.Hostname() { + return true + } + } + return false + } +} + +func matchHostRegexp(hostRegexp ...string) MatchFunc { + matchers := []*regexp.Regexp{} + for _, s := range hostRegexp { + matchers = append(matchers, regexp.MustCompile(s)) + } + return func(dest url.URL) bool { + for _, matcher := range matchers { + if matcher.MatchString(dest.Hostname()) { + return true + } + } + return false + } +} + +func matchRegexp(urlRegexp ...string) MatchFunc { + matchers := []*regexp.Regexp{} + for _, s := range urlRegexp { + matchers = append(matchers, regexp.MustCompile(s)) + } + return func(dest url.URL) bool { + for _, matcher := range matchers { + if matcher.MatchString(dest.String()) { + return true + } + } + return false + } +} + +func matchAny(matchFuncs ...MatchFunc) MatchFunc { + return func(dest url.URL) bool { + for _, f := range matchFuncs { + if f(dest) { + return true + } + } + return false + } +} + +// Always returns true +func defaultBrowser(dest url.URL) bool { + return true +} + +// Never returns true +func noop(dest url.URL) bool { + return false +} + +// Rules to evaluate +var rules = []BrowserRule{ + // Personal domains + BrowserRule{ + matcher: matchHostRegexp( + `.*\.iamthefij\.com$`, + `.*\.thefij\.rocks$`, + `.*\.thefij$`, + ), + command: "firefox", + }, + // Work domains + BrowserRule{ + matcher: matchAny( + matchHostname( + "app.signalfx.com", + "lever.co", + "work.grubhub.com", + "y", + "yelp.rimeto.io", + "yelp.slack.com", + "yelplove.appspot.com", + ), + matchHostRegexp( + `.*\.lifesize\.com$`, + `.*\.myworkday\.com$`, + `.*\.salesforce\.com$`, + `.*\.yelpcorp\.com$`, + ), + ), + command: "chromium-browser", + }, + // Googly domains + BrowserRule{ + matcher: matchAny( + matchHostname( + "google.com", + "youtube.com", + ), + matchHostRegexp( + `.*\.google\.com$`, + `.*\.youtube\.com$`, + ), + ), + command: "chromium-browser", + }, + // Default fallback browser + BrowserRule{ + matcher: defaultBrowser, + command: "firefox", + }, +} + +func handleUrl(urlString string) error { + dest, err := url.Parse(urlString) + if err != nil { + return err + } + + var matched bool + for _, rule := range rules { + matched, err = rule.MaybeLaunch(*dest) + if matched { + break + } + } + + return nil +} + +func main() { + flag.Parse() + urls := flag.Args() + + for _, urlString := range urls { + err := handleUrl(urlString) + if err != nil { + panic(err) + } + } +}