diff --git a/.DS_Store b/.DS_Store index 2ee5893..91da67d 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index 817bf7b..e83afb6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ out/ -result \ No newline at end of file +*/.DS_Store +result diff --git a/README.md b/README.md index 0060c75..d362399 100644 --- a/README.md +++ b/README.md @@ -1,151 +1,172 @@ -# About +# Autobrowser -Automatically choosing web-browser depends on environment context rules. +Automatically selects web browsers based on context rules. ## Features -- suckless solution with no redundant dependencies -- fast single binary -- simple rule engine with custom interpretable DSL -- cross-platform +- Fast single binary with minimal dependencies +- Simple rule engine +- Cross-platform (Linux, macOS) -# Configuration +## Configuration -## Example +Save as `~/.config/autobrowser/config.toml` or use `-config` flag. -``` -work:=firefox -p job {}:url.regex='.*jira.*' - -work:app.class=Slack # Open all jira links from slack with job firefox profile -work:app.class=org.telegram.desktop # Open all links from the telegram app using Isolated firefox container +### Example -# Default fallback -firefox {}:fallback -``` +```toml +# Default when no rules match +default_command = "personal" -Other examples can be found in `examples` folder +# Define commands +[command.work] +cmd = ["firefox", "-p", "work", "{}"] +query_escape = true -## Configuration syntax +[command.personal] +cmd = "firefox {}" -The application just evaluates configuration rules 1 by 1 and applies url to a first matched command. Syntax can be described as: +# Define rules +[[rules]] +command = "work" +[[rules.matchers]] +type = "app" +class = "Slack" +[[rules.matchers]] +type = "url" +regex = ".*jira.*" +[[rules]] +command = "work" +[[rules.matchers]] +type = "app" +class = "org.telegram.desktop" ``` -:.=[;.=] -``` - -Browser command is a sequence of words, divided by spaces. The first word is an executable name and the others are arguments. `{}` char sequence will be replaced with a clicked URL. - -You can escape spaces or other _non-word characters_ can be escaped by a single-quote string. - -To avoid repeating of same browser command you can user assignment syntax `command_name:=your command {}` for further use. - -## Matchers - -### fallback - -This matcher always succeeds. Use it at the end of a configuration to specify the default browser. - -### app - -Matches by source application. -Currently supported desktop environments: _hyprland_, _gnome_, _sway_, _macos_. +More examples in the `examples` folder. -Hyprland/Sway/Gnome Properties: +### Matchers -- _title_: match by source window title with regex -- _class_: match by window class +#### app -MacOS Properties: +Match by source application. -- _display_name_ - match by app display name (ex: `Slack`) -- _bundle_id_ - match by App Bundle ID (ex: `com.tinyspeck.slackmacgap`) -- _bundle_path_ - match by App Bundle path (ex: `/Applications/Slack.app`) -- _executable_path_ - match by app executable path (ex: `/Applications/Slack.app/Contents/MacOS/Slack`) +Supported environments: _hyprland_, _gnome_, _sway_, _macos_ -### url +```toml +[[rules.matchers]] +type = "app" +class = "Slack" +``` -Match by a clicked URL. +**Linux Properties:** +- `title`: window title (regex) +- `class`: window class -Properties: +**macOS Properties:** +- `display_name`: app name +- `bundle_id`: App Bundle ID +- `bundle_path`: App Bundle path +- `executable_path`: app executable path -- _host_: match URL by host -- _scheme_: match URL by scheme -- _regex_: match full URL by regex +#### url -# Setup +Match by clicked URL. -## Linux +```toml +[[rules.matchers]] +type = "url" +host = "github.com" +``` -### Gnome +**Properties:** +- `host`: match by host +- `scheme`: match by scheme +- `regex`: match full URL by regex -Due to stupidity of Gonme shell interface there is no legal way to recieve focused winow for Gnome with wayland: https://www.reddit.com/r/gnome/comments/pneza1/gdbus_call_for_moving_windows_not_working_in/ +## Setup -To be able to use the `app` matcher, please [install the extenions](https://extensions.gnome.org/extension/5592/focused-window-d-bus/), that exposes currently focused window via dbus interface: https://github.com/flexagoon/focused-window-dbus +### Linux -### Prebuilt packages +#### Gnome -You can find `.rpm`, `.deb`, `.apk` and `.zst` packages on the release page. +Install [focused-window-dbus extension](https://github.com/flexagoon/focused-window-dbus) to expose the focused window. -### Linux manual +#### Installation -Clone the repository and run, you can find a result binary in the `out` directory. +**Prebuilt packages:** +Download `.rpm`, `.deb`, `.apk` or `.zst` from releases. +**Manual build:** ```sh make build-linux ``` -Create config at `~/.config/autobrowser.config`. -Then add the following .desktop file to `~/.local/share/applications/` and set it as the default browser. -Change paths for your setup if needed. +Create this `.desktop` file in `~/.local/share/applications/` and set as default browser: ```ini [Desktop Entry] Categories=Network;WebBrowser -Exec=~/go/bin/autobrowser -config ~/.config/autobrowser.config -url %u +Exec=/path/to/autobrowser -config ~/.config/autobrowser/config.toml -url %u Icon=browser MimeType=x-scheme-handler/http;x-scheme-handler/https -Name=Autobrowser: select browser by contextual rules +Name=Autobrowser Terminal=false Type=Application ``` -## Nix home-manager - -This setup works booth for linux and darwin environments. +### Nix home-manager -Actual flakes provides overlay (`overlays.default`) and module for home-manager (`autobrowser.homeModules.default`). +Works for both Linux and macOS. The flake provides an overlay and a home-manager module. -Example of home-manager module configuration: +Example configuration: ```nix { - inputs, - ... -}: { programs.autobrowser = { - package = inputs.autobrowser.packages.x86_64-linux.default; enable = true; - variables = { - work = "firefox 'ext+container:name=Work&url={}'"; - home = "firefox {}"; - - # Example for darwin (MacOS) configuration - work-darwin = "open -a 'Zen' 'ext+container:name=Work&url={}'"; + defaultCommand = "personal"; + + commands = { + work = { + cmd = ["firefox", "ext+container:name=Work&url={}"]; + queryEscape = true; + }; + personal = { + cmd = "firefox {}"; + }; }; + rules = [ - "work:app.class=Slack" - "work:app.class=org.telegram.desktop;app.title='.*Work related group name.*'" - - # Example for darwin (MacOS) configuration - "work-darwin:app.bundle_id='com.tinyspeck.slackmacgap'" + { + command = "work"; + matchers = [ + { + type = "app"; + class = "Slack"; + } + { + type = "url"; + regex = ".*jira.*"; + } + ]; + } ]; - default = "home"; }; } ``` -# Acknowledgements +## Debugging + +### macOS + +Monitor logs: + +``` +log stream --predicate 'subsystem == "dev.pltanton.autobrowser"' --style compact --level debug +``` + +## Acknowledgements - [b-r-o-w-s-e](https://github.com/BlakeWilliams/b-r-o-w-s-e) project and [related article](https://blakewilliams.me/posts/handling-macos-url-schemes-with-go): great example of handling URLs with Golang on macOS -- [Finicky](https://github.com/johnste/finicky) project: inspiration for Autobrowser, good example of handling more complex URL events +- [Finicky](https://github.com/johnste/finicky) project: inspiration for Autobrowser, good example of handling more complex URL events \ No newline at end of file diff --git a/common/go.mod b/common/go.mod index dbd75fa..7d31adc 100644 --- a/common/go.mod +++ b/common/go.mod @@ -1,3 +1,5 @@ module github.com/pltanton/autobrowser/common go 1.22.1 + +require github.com/BurntSushi/toml v1.5.0 diff --git a/common/go.sum b/common/go.sum index e69de29..ff7fd09 100644 --- a/common/go.sum +++ b/common/go.sum @@ -0,0 +1,2 @@ +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= diff --git a/common/pkg/app/app.go b/common/pkg/app/app.go index baf4e02..871d660 100644 --- a/common/pkg/app/app.go +++ b/common/pkg/app/app.go @@ -1,77 +1,105 @@ package app import ( - "errors" "fmt" - "io" "log/slog" "net/url" "os" "os/exec" "strings" - "github.com/pltanton/autobrowser/common/pkg/config" + "github.com/pltanton/autobrowser/common/pkg/configuration" "github.com/pltanton/autobrowser/common/pkg/matchers" ) -func SetupAndRun(configPath string, urlString string, registry *matchers.MatchersRegistry) { - configFile, err := os.Open(configPath) +func SetupAndRun(configPath string, urlString string, r *matchers.MatchersRegistry) { + c, err := configuration.ParseConfigFile(configPath) if err != nil { - slog.Error("Failed to open cofig file", "err", err) + slog.Error("Failed to parse cofig file", "path", configPath, "err", err) os.Exit(1) } - parser := config.NewParser(configFile) + err = evaluate(c, r, urlString) + if err != nil { + slog.Error("Failed to evaluate", "err", err) + os.Exit(1) + } +} - variables := make(map[string][]string) +func evaluate(c *configuration.Config, r *matchers.MatchersRegistry, urlString string) error { + var command configuration.Command + var matched bool - for instruction, err := parser.ParseInstruction(); !errors.Is(err, io.EOF); instruction, err = parser.ParseInstruction() { - if err != nil { - slog.Error("Failed to parse instruction", "err", err) - os.Exit(1) - } + for ruleN, rule := range c.Rules { + allMatched := true + for matcherN, matcherConfig := range rule.Matchers { + logWithMatcher := slog.With("type", matcherConfig.Type, "rule id", ruleN, "matcher id", matcherN) + logWithMatcher.Debug("Start matching") + + matcher, err := r.GetMatcher(matcherConfig.Type) + if err != nil { + return err + } - if assignment, ok := instruction.Assignment(); ok { - variables[assignment.Key] = assignment.Value - } else if rule, ok := instruction.Rule(); ok { - matches, err := registry.EvalRule(rule) + ok, err := matcher.Match(c.ConfigProvider(matcherConfig)) if err != nil { - slog.Error("Failed to evaluate rule", "err", err) - os.Exit(1) + return err } - if matches { - // Replace all placeholders in command to url - command := rule.Command - // Try find command in variables if it's single word command - if len(command) == 1 { - if newCommand, ok := variables[command[0]]; ok { - command = newCommand - } - } - - urlEscaped := url.QueryEscape(urlString) - - for i := range command { - command[i] = strings.Replace(command[i], "{}", urlString, 1) - command[i] = strings.Replace(command[i], "{escape}", urlEscaped, 1) - } - - slog.Info("Launching CMD", "command", command) - - out, err := exec.Command(command[0], command[1:]...).CombinedOutput() - if err != nil { - slog.Error("Failed to run command", "err", err, "output", string(out)) - os.Exit(1) - } - - slog.Debug("Command executed successfully", "output", string(out)) - return + logWithMatcher.Debug("Matcher match result", "matched", ok) + if !ok { + allMatched = false + break } - } else { - slog.Error(fmt.Sprintf("Unknown instruction type %+v", instruction)) } + + if !allMatched { + continue + } + + matched = true + var ok bool + command, ok = c.Commands[rule.Command] + if !ok { + slog.Debug("Command not declared, using command as is", "command", rule.Command) + command = configuration.NewDefaultCommand(rule.Command) + } + + break + } + + if !matched { + slog.Debug("None of matchers matched, using default command") + var ok bool + command, ok = c.Commands[c.DefaultCommand] + if !ok { + slog.Debug("Default command not declared, using command as is", "command", c.DefaultCommand) + command = configuration.NewDefaultCommand(c.DefaultCommand) + } + } + + return runCommand(command, urlString) +} + +func runCommand(cmdConfig configuration.Command, urlString string) error { + cmd := cmdConfig.CMD[:] + + if cmdConfig.QueryEscape { + urlString = url.QueryEscape(urlString) + } + + for i := range cmd { + cmd[i] = strings.Replace(cmd[i], cmdConfig.Placeholder, urlString, 1) + } + + slog.Debug("Launching CMD", "command", cmd) + + out, err := exec.Command(cmd[0], cmd[1:]...).CombinedOutput() + if err != nil { + slog.Error("Failed to run command", "err", err, "output", string(out)) + return fmt.Errorf("failed to execute command: %w", err) } - slog.Info("Nothing matched, please specify 'fallback' rule to setup default browser!") + slog.Debug("Command executed successfully", "output", string(out)) + return nil } diff --git a/common/pkg/config/lexer.go b/common/pkg/config/lexer.go deleted file mode 100644 index d61bd56..0000000 --- a/common/pkg/config/lexer.go +++ /dev/null @@ -1,208 +0,0 @@ -package config - -import ( - "bufio" - "bytes" - "errors" - "fmt" - "io" - "log/slog" - "unicode" -) - -type CharacterClass func(rune) bool - -type TokenType int - -const ( - ILLEGAL TokenType = iota - EOF - - EQ - ASSIGN - DOT - COMMA - COLON - SEMICOLON - WORD - SPACE - COMMENT - ENDL -) - -func (t TokenType) String() string { - switch t { - case ILLEGAL: - return "ILLEGAL" - case EOF: - return "EOF" - case EQ: - return "EQ" - case ASSIGN: - return "ASSIGN" - case DOT: - return "DOT" - case COMMA: - return "COMMA" - case COLON: - return "COLON" - case SEMICOLON: - return "SEMICOLON" - case WORD: - return "WORD" - case SPACE: - return "SPACE" - case ENDL: - return "ENDL" - case COMMENT: - return "COMMENT" - } - return "UNKNOWN" -} - -type Token struct { - Type TokenType - Value string -} - -func (t Token) String() string { - return fmt.Sprintf("%v:%q", t.Type, t.Value) -} - -type Lexer struct { - input *bufio.Reader -} - -func NewLexer(in io.Reader) *Lexer { - return &Lexer{ - input: bufio.NewReader(in), - } -} - -func (l *Lexer) Next() Token { - r := l.readRune() - - if WhitespaceClass(r) { - l.unreadRune() - return l.scanWhitespaces() - } else if ValueClass(r) { - l.unreadRune() - return l.scanValue() - } - - switch r { - case rune(0): - return Token{EOF, ""} - case '\'': - l.unreadRune() - return l.scanEscapedValue() - case '#': - l.unreadRune() - return l.scanComment() - case ':': - if l.readRune() == '=' { - return Token{ASSIGN, ":="} - } else { - l.unreadRune() - return Token{COLON, string(r)} - } - case '=': - return Token{EQ, string(r)} - case '.': - return Token{DOT, string(r)} - case ';': - return Token{SEMICOLON, string(r)} - case ',': - return Token{COMMA, string(r)} - case '\n': - return Token{ENDL, string(r)} - } - - return Token{ILLEGAL, string(r)} -} - -func (l *Lexer) readRune() rune { - r, _, err := l.input.ReadRune() - if errors.Is(io.EOF, err) { - return rune(0) - } else if err != nil { - slog.Warn("Unexpected error occured while reading configuration", "err", err) - return rune(0) - } - return r -} - -func (l *Lexer) unreadRune() { - l.input.UnreadRune() -} - -func (l *Lexer) scanCharclassSequence(tokenType TokenType, class CharacterClass) Token { - var buf bytes.Buffer - - for { - r := l.readRune() - if isEof(r) { - break - } else if !class(r) { - l.unreadRune() - break - } else { - // We don't check err here, because it is impossible - buf.WriteRune(r) - } - } - - return Token{ - Type: tokenType, - Value: buf.String(), - } -} - -func (l *Lexer) scanComment() Token { - return l.scanCharclassSequence(COMMENT, func(r rune) bool { - return r != '\n' && !isEof(r) - }) -} - -func (l *Lexer) scanWhitespaces() Token { - return l.scanCharclassSequence(SPACE, WhitespaceClass) -} - -func (l *Lexer) scanValue() Token { - return l.scanCharclassSequence(WORD, ValueClass) -} - -func (l *Lexer) scanEscapedValue() Token { - // Skip 1st quuote - l.readRune() - var buf bytes.Buffer - for { - r := l.readRune() - - if isEof(r) { - break - } else if r == '\'' { - break - } else { - // We don't check err here, because it is impossible - buf.WriteRune(r) - } - } - - return Token{ - Type: WORD, - Value: buf.String(), - } -} - -var WhitespaceClass CharacterClass = func(r rune) bool { - return r == ' ' || r == '\t' -} - -var ValueClass CharacterClass = func(r rune) bool { - return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_' || r == '{' || r == '}' -} - -func isEof(r rune) bool { - return r == rune(0) -} diff --git a/common/pkg/config/lexer_test.go b/common/pkg/config/lexer_test.go deleted file mode 100644 index 61c840c..0000000 --- a/common/pkg/config/lexer_test.go +++ /dev/null @@ -1,146 +0,0 @@ -package config - -import ( - "bufio" - "reflect" - "strings" - "testing" -) - -func TestLexer_Next(t *testing.T) { - tests := []struct { - name string - inStr string - want Token - }{ - { - name: "Lex eq", - inStr: "=", - want: Token{EQ, "="}, - }, - { - name: "Lex dot", - inStr: ".", - want: Token{DOT, "."}, - }, - { - name: "Lex word", - inStr: "here_is-va1ue{}", - want: Token{WORD, "here_is-va1ue{}"}, - }, - { - name: "Lex escaped value", - inStr: "'here.is,escaped=value\t*** () ?? {} <> 💀'", - want: Token{WORD, "here.is,escaped=value\t*** () ?? {} <> 💀"}, - }, - { - name: "Lex comma", - inStr: ",", - want: Token{COMMA, ","}, - }, - { - name: "Lex single space", - inStr: " ", - want: Token{SPACE, " "}, - }, - { - name: "Lex subsequent spaces", - inStr: " \t\t ", - want: Token{SPACE, " \t\t "}, - }, - { - name: "Lex colon", - inStr: ":", - want: Token{COLON, ":"}, - }, - { - name: "Lex semicolon", - inStr: ";", - want: Token{SEMICOLON, ";"}, - }, - { - name: "Lex endline", - inStr: "\n", - want: Token{ENDL, "\n"}, - }, - { - name: "Lex comment", - inStr: "# Hello.={} ;:", - want: Token{COMMENT, "# Hello.={} ;:"}, - }, - { - name: "Lex assign", - inStr: ":=", - want: Token{ASSIGN, ":="}, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - l := &Lexer{ - input: bufio.NewReader(strings.NewReader(tt.inStr)), - } - got := l.Next() - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("Lexer.Next() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestLexer_FullSequence(t *testing.T) { - input := ` -foo:=bar biz -firefox:url.regex='.*foo.*';app.class=telegram # Commentary with row description -'firefox -p work':url.host='github.com'` - - expected := []Token{ - {ENDL, "\n"}, - - {WORD, "foo"}, - {ASSIGN, ":="}, - {WORD, "bar"}, - {SPACE, " "}, - {WORD, "biz"}, - {ENDL, "\n"}, - - {WORD, "firefox"}, - {COLON, ":"}, - {WORD, "url"}, - {DOT, "."}, - {WORD, "regex"}, - {EQ, "="}, - {WORD, ".*foo.*"}, - {SEMICOLON, ";"}, - {WORD, "app"}, - {DOT, "."}, - {WORD, "class"}, - {EQ, "="}, - {WORD, "telegram"}, - - {SPACE, " "}, - {COMMENT, "# Commentary with row description"}, - {ENDL, "\n"}, - - {WORD, "firefox -p work"}, - {COLON, ":"}, - {WORD, "url"}, - {DOT, "."}, - {WORD, "host"}, - {EQ, "="}, - {WORD, "github.com"}, - } - - l := &Lexer{ - input: bufio.NewReader(strings.NewReader(input)), - } - - tokens := make([]Token, 0) - - for tok := l.Next(); tok.Type != EOF; tok = l.Next() { - tokens = append(tokens, tok) - } - - if !reflect.DeepEqual(tokens, expected) { - t.Errorf("Tokens differs:\n%v\n%v", tokens, expected) - } -} diff --git a/common/pkg/config/parser.go b/common/pkg/config/parser.go deleted file mode 100644 index af208b1..0000000 --- a/common/pkg/config/parser.go +++ /dev/null @@ -1,237 +0,0 @@ -// Package config contains parser to parse configuration rules according to following formal grammar -// -// RULE -> WORDS COLON MATCHER_PROPERTY EQ -// ASSIGNMENT -> WORD ASSIGN WORDS -// -// WORDS -> WORD [WORD]* -// BROWSER_DEF -> VALUE [VALUE]* -// MATCHER_DEF -> MATCHER_PROPERTY EQ VALUE -// MATCHER_PROPERTY -> VALUE DOT VALUE -// -// Example of single rule: -// -// `firefox:url.regex='.*exapmle\.com.*';app.name=telegram` -package config - -import ( - "fmt" - "io" -) - -// Parses configuration -type Parser struct { - l *Lexer - - // Current window size is naive with 1 element wide, could be refactod to use wider window - buf struct { - token Token - n int - } -} - -func NewParser(in io.Reader) *Parser { - return &Parser{ - l: NewLexer(in), - } -} - -// ParseInstruction parse single configuration instruction -// it might be neither an assignment or a rule -// -// RULE -> WORDS COLON MATCHER_PROPERTY EQ -// -// ASSIGNMENT -> WORD ASSIGN WORDS -// WORDS -> WORD [WORD]* -func (p *Parser) ParseInstruction() (Instruction, error) { - p.skipEndls() - tok := p.scanSkipSpace() - if tok.Type == EOF { - return Instruction{}, fmt.Errorf("EOF token reached: %w", io.EOF) - } - p.unscan() - - lValue, err := p.parseWordSequence() - if err != nil { - return Instruction{}, err - } - - if len(lValue) == 0 { - return Instruction{}, fmt.Errorf("expected lValue, but got empty string") - } - - // It might be an assignment potentially - if len(lValue) == 1 { - tok = p.scanSkipSpace() - if tok.Type == ASSIGN { - assignment, err := p.parseRestOfAssignment(lValue[0]) - if err != nil { - return Instruction{}, err - } - return FromAssignment(assignment), nil - } - p.unscan() - } - - if tok = p.scanSkipSpace(); tok.Type != COLON { - return Instruction{}, fmt.Errorf("expected COLON, but got %v", tok) - } - - rule, err := p.parseRestOfRule(lValue) - if err != nil { - return Instruction{}, err - } - - return FromRule(rule), nil - -} - -func (p *Parser) parseRestOfAssignment(name string) (Assignment, error) { - command, err := p.parseWordSequence() - if err != nil { - return Assignment{}, fmt.Errorf("expected command, but got err: %w", err) - } - - if tok := p.scanSkipSpace(); tok.Type != ENDL && tok.Type != EOF { - return Assignment{}, fmt.Errorf("assignment should end with ENDL or EOF, but got %v", tok) - } - - return Assignment{ - Key: name, - Value: command, - }, nil -} - -func (p *Parser) parseRestOfRule(lValue []string) (Rule, error) { - var tok Token - matchers := make(map[string]MatcherProps) - - for { - matcherName, propName, propValue, err := p.parseMatcherDef() - if err != nil { - return Rule{}, fmt.Errorf("failed to parse matcher definition: %w", err) - } - - matcher, ok := matchers[matcherName] - if !ok { - matcher = make(MatcherProps) - matchers[matcherName] = matcher - } - - matcher[propName] = propValue - - tok = p.scanSkipSpace() - if tok.Type == ENDL || tok.Type == EOF { - break - } else if tok.Type != SEMICOLON { - return Rule{}, fmt.Errorf("failed to parse patchers definitions, expected SEMICOLON or end of rule, but got %v", tok) - } - } - - return Rule{ - Command: lValue, - Matchers: matchers, - }, nil -} - -func (p *Parser) parseWordSequence() ([]string, error) { - result := []string{} - - for tok := p.scanSkipSpace(); tok.Type == WORD; tok = p.scanSkipSpace() { - result = append(result, tok.Value) - } - p.unscan() - - return result, nil -} - -// parseMatcherDef parse matcher -// -// MATCHER_DEF -> MATCHER_PROPERTY EQ VALUE -// MATCHER_PROPERTY -> VALUE DOT VALUE -// -// Returns matcher type, property name, property value -func (p *Parser) parseMatcherDef() (string, string, string, error) { - tok := p.scanSkipSpace() - - if tok.Type != WORD { - return "", "", "", fmt.Errorf("unexpected token for matcher type, expected VALUE, but got: %v", tok) - } - matcherType := tok.Value - - tok = p.scan() - if tok.Type != DOT { - tok = p.scanSkipSpace() - - // If after dot there is end of rule, just return it as is - if tok.Type == ENDL || tok.Type == EOF || tok.Type == SEMICOLON { - return matcherType, "", "", nil - } - - return "", "", "", fmt.Errorf("unexpected token, expected DOT, ENDL, SEMICOLON or EOF, but got: %v", tok) - } - - tok = p.scan() - if tok.Type != WORD { - return "", "", "", fmt.Errorf("unexpected token for matcher property name, expected VALUE, but got: %v", tok) - } - matcherProp := tok.Value - - tok = p.scan() - if tok.Type != EQ { - return "", "", "", fmt.Errorf("unexpected token, expected EQ, but got: %v", tok) - } - - tok = p.scan() - if tok.Type != WORD { - return "", "", "", fmt.Errorf("unexpected token for matcher property value, expected VALUE, but got: %v", tok) - } - matcherPropValue := tok.Value - - return matcherType, matcherProp, matcherPropValue, nil -} - -// scan next token by lexer, using 1 wide window buffer -func (p *Parser) scanRaw() Token { - if p.buf.n != 0 { - p.buf.n-- - return p.buf.token - } - - p.buf.token = p.l.Next() - - return p.buf.token -} - -// scan skans with skip of comment -func (p *Parser) scan() Token { - tok := p.scanRaw() - if tok.Type == COMMENT { - return p.scanRaw() - } - return tok -} - -func (p *Parser) unscan() { - p.buf.n = 1 -} - -func (p *Parser) skipEndls() error { - var tok Token - for tok = p.scan(); tok.Type == SPACE || tok.Type == ENDL; tok = p.scan() { - // Just skip it - } - - // We did at least one scan, so unscan is necessary - p.unscan() - return nil -} - -// scanSkipSpace scans skipping spaces -func (p *Parser) scanSkipSpace() Token { - t := p.scan() - if t.Type == SPACE { - return p.scan() - } - - return t -} diff --git a/common/pkg/config/parser_test.go b/common/pkg/config/parser_test.go deleted file mode 100644 index 4d23356..0000000 --- a/common/pkg/config/parser_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package config - -import ( - "errors" - "io" - "reflect" - "strings" - "testing" -) - -func TestParser_ParseRule(t *testing.T) { - tests := []struct { - name string - inStr string - wantRule Rule - wantAssignment Assignment - wantErr bool - err error - }{ - { - name: "Successfully parse rule", - inStr: "firefox -command {}:url.regex='.*example.*'; app.name=slack;app.foo=bar # this is test rule", - wantRule: Rule{ - Command: []string{"firefox", "-command", "{}"}, - Matchers: map[string]MatcherProps{ - "url": {"regex": ".*example.*"}, - "app": {"name": "slack", "foo": "bar"}, - }, - }, - }, - { - name: "Successfully parse assignment", - inStr: "foo := biz 'buz -bin {}'", - wantAssignment: Assignment{ - Key: "foo", - Value: []string{"biz", "buz -bin {}"}, - }, - }, - { - name: "Bad start of rule", - inStr: ";firefox:url.regex='.*example.*';app.name=slack;app.foo=bar", - wantErr: true, - }, - { - name: "Bad matcherdef", - inStr: "firefox:url.regex=.*example.*;app.name=slack;app.foo=bar", - wantErr: true, - }, - { - name: "Should be over", - inStr: " \n\n\n \n", - wantErr: true, - err: io.EOF, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - p := NewParser(strings.NewReader(tt.inStr)) - got, err := p.ParseInstruction() - if (err != nil) != tt.wantErr { - t.Errorf("Parser.ParseInstruction() error = %v, wantErr %v", err, tt.wantErr) - return - } - if (tt.err != nil) && errors.Is(tt.err, err) { - t.Errorf("Parser.ParseInstruction() error = %v, should be err %v", err, tt.err) - return - } - if !reflect.ValueOf(tt.wantRule).Field(0).IsZero() { - rule, ok := got.Rule() - if !ok { - t.Errorf("Expected to get rule instruction but got %+v", got) - return - } - if !reflect.DeepEqual(rule, tt.wantRule) { - t.Errorf("Parser.ParseInstruction() = %v, want %v", rule, tt.wantRule) - return - } - } - if !reflect.ValueOf(tt.wantAssignment).Field(0).IsZero() { - assignment, ok := got.Assignment() - if !ok { - t.Errorf("Expected to get assignment instruction but got %+v", got) - return - } - if !reflect.DeepEqual(assignment, tt.wantAssignment) { - t.Errorf("Parser.ParseInstruction() = %v, want %v", assignment, tt.wantAssignment) - return - } - } - }) - } -} diff --git a/common/pkg/config/structs.go b/common/pkg/config/structs.go deleted file mode 100644 index cbbccbc..0000000 --- a/common/pkg/config/structs.go +++ /dev/null @@ -1,46 +0,0 @@ -package config - -// RuleMatcher matches url by specific rule -type RuleMatcher interface { - Match(arg string) bool -} - -type MatcherProps map[string]string - -// Rule single parsed row of configured rules -type Rule struct { - // Prepeared rule matcher with parsed argument - Matchers map[string]MatcherProps - - // Command browser command - Command []string -} - -// Assignment is variable definition for config -type Assignment struct { - Key string - Value []string -} - -// Insturtion is isngle line in config -type Instruction struct { - instruction any -} - -func FromRule(r Rule) Instruction { - return Instruction{r} -} - -func FromAssignment(a Assignment) Instruction { - return Instruction{a} -} - -func (i Instruction) Rule() (Rule, bool) { - rule, ok := i.instruction.(Rule) - return rule, ok -} - -func (i Instruction) Assignment() (Assignment, bool) { - assignment, ok := i.instruction.(Assignment) - return assignment, ok -} diff --git a/common/pkg/configuration/config_toml.go b/common/pkg/configuration/config_toml.go new file mode 100644 index 0000000..84a6064 --- /dev/null +++ b/common/pkg/configuration/config_toml.go @@ -0,0 +1,158 @@ +package configuration + +import ( + "fmt" + "strings" + + "github.com/BurntSushi/toml" + "github.com/pltanton/autobrowser/common/pkg/matchers" +) + +type Config struct { + DefaultCommand string `toml:"default_command"` + Commands map[string]Command `toml:"command"` + Rules []Rule `toml:"rules"` + + md toml.MetaData +} + +type Command struct { + CMD []string `toml:"-"` + CMDPrimitive toml.Primitive `toml:"cmd"` + Placeholder string `toml:"placeholder,omitempty"` + QueryEscape bool `toml:"query_escape,omitempty"` +} + +type Rule struct { + Command string `toml:"command"` + MatchersPrimitive []toml.Primitive `toml:"matchers"` + Matchers []TypedMatcher `toml:"-"` +} + +type TypedMatcher struct { + Type string + Primitive toml.Primitive +} + +type matcherType struct { + Type string `toml:"type"` +} + +func ParseConfigFile(path string) (*Config, error) { + var config Config + md, err := toml.DecodeFile(path, &config) + if err != nil { + return nil, err + } + + config.md = md + if err := parseConfig(&config); err != nil { + return nil, err + } + return &config, nil +} + +func ParseConfig(str string) (*Config, error) { + var config Config + md, err := toml.Decode(str, &config) + if err != nil { + return nil, err + } + + config.md = md + if err := parseConfig(&config); err != nil { + return nil, err + } + return &config, nil +} + +func parseConfig(config *Config) error { + for name, command := range config.Commands { + if command.Placeholder == "" { + command.Placeholder = "{}" + } + + var sliceCommand []string + err := config.md.PrimitiveDecode(command.CMDPrimitive, &sliceCommand) + if err == nil { + command.CMD = sliceCommand + config.Commands[name] = command + continue + } + + var stringCommand string + err = config.md.PrimitiveDecode(command.CMDPrimitive, &stringCommand) + if err == nil { + command.CMD = splitQuoted(stringCommand) + config.Commands[name] = command + continue + } + + return fmt.Errorf("Failed to parse command cmd %s, cmd=%v", name, command.CMDPrimitive) + } + + for i, rule := range config.Rules { + config.Rules[i].Matchers = make([]TypedMatcher, len(rule.MatchersPrimitive)) + + for j, matcher := range rule.MatchersPrimitive { + var matcherType matcherType + err := config.md.PrimitiveDecode(matcher, &matcherType) + if err != nil { + return fmt.Errorf("Failed to parse matcher type for rule %d, matcher %d", i, j) + } + + config.Rules[i].Matchers[j].Type = matcherType.Type + config.Rules[i].Matchers[j].Primitive = matcher + } + } + + return nil +} + +func splitQuoted(s string) []string { + var result []string + var buf strings.Builder + var quote rune + + flush := func() { + if buf.Len() > 0 { + result = append(result, buf.String()) + buf.Reset() + } + } + + for _, r := range s { + switch { + case (r == '"' || r == '\''): + if quote == r { + quote = 0 + } else if quote == 0 { + quote = r + } else { + buf.WriteRune(r) + } + case r == ' ' || r == '\t': + if quote != 0 { + buf.WriteRune(r) + } else { + flush() + } + default: + buf.WriteRune(r) + } + } + flush() + return result +} + +func NewDefaultCommand(cmdString string) Command { + return Command{ + CMD: splitQuoted(cmdString), + Placeholder: "{}", + QueryEscape: false, + } +} + +func (c *Config) ConfigProvider(matcher TypedMatcher) matchers.MatcherConfigProvider { + return func(v any) error { return c.md.PrimitiveDecode(matcher.Primitive, v) } +} diff --git a/common/pkg/configuration/config_toml_test.go b/common/pkg/configuration/config_toml_test.go new file mode 100644 index 0000000..436c359 --- /dev/null +++ b/common/pkg/configuration/config_toml_test.go @@ -0,0 +1,104 @@ +// Package configuration provides TOML-based configuration parsing +package configuration + +import ( + "testing" +) + +// TestParseConfig tests the configuration parser with various inputs +func TestParseConfig(t *testing.T) { + // Test basic config with command + t.Run("basic config", func(t *testing.T) { + input := ` +default_command = "open" + +[command.open] +cmd = "firefox {{url}}" +placeholder = "{{url}}" +` + config, err := ParseConfig(input) + if err != nil { + t.Fatalf("ParseConfig() error = %v", err) + } + + // Check default command + if config.DefaultCommand != "open" { + t.Errorf("DefaultCommand = %q, want %q", config.DefaultCommand, "open") + } + + // Check commands + if len(config.Commands) != 1 { + t.Errorf("Commands count = %d, want 1", len(config.Commands)) + return + } + + // Check command exists + if _, exists := config.Commands["open"]; !exists { + t.Errorf("Command 'open' not found in config") + return + } + + // Check placeholder + cmd := config.Commands["open"] + if cmd.Placeholder != "{{url}}" { + t.Errorf("Placeholder = %q, want %q", cmd.Placeholder, "{{url}}") + } + }) + + // Test config with rules and matchers + t.Run("config with rules", func(t *testing.T) { + input := ` +[command.test] +cmd = "echo test" + +[[rules]] +command = "test" +matchers = [ + { type = "url", pattern = "example.com" }, + { type = "title", pattern = "Example" } +] +` + config, err := ParseConfig(input) + if err != nil { + t.Fatalf("ParseConfig() error = %v", err) + } + + // Check rules + if len(config.Rules) != 1 { + t.Errorf("Rules count = %d, want 1", len(config.Rules)) + return + } + + // Check rule properties + rule := config.Rules[0] + if rule.Command != "test" { + t.Errorf("Rule command = %q, want %q", rule.Command, "test") + } + + // Check matchers + if len(rule.Matchers) != 2 { + t.Errorf("Matchers count = %d, want 2", len(rule.Matchers)) + return + } + + // Check matcher types + if rule.Matchers[0].Type != "url" { + t.Errorf("First matcher type = %q, want %q", rule.Matchers[0].Type, "url") + } + if rule.Matchers[1].Type != "title" { + t.Errorf("Second matcher type = %q, want %q", rule.Matchers[1].Type, "title") + } + }) + + // Test invalid configuration + t.Run("invalid config", func(t *testing.T) { + input := ` +[command.invalid] +cmd = 123 # This should cause an error - cmd must be string or array +` + c, err := ParseConfig(input) + if err == nil { + t.Errorf("ParseConfig() did not return error for invalid command: %v", c.Commands) + } + }) +} diff --git a/common/pkg/matchers/fallback/default.go b/common/pkg/matchers/fallback/default.go deleted file mode 100644 index 50773b1..0000000 --- a/common/pkg/matchers/fallback/default.go +++ /dev/null @@ -1,17 +0,0 @@ -package fallback - -import "github.com/pltanton/autobrowser/common/pkg/matchers" - -type fallbackMatcher struct { -} - -// Match implements matchers.Matcher. -func (*fallbackMatcher) Match(args map[string]string) bool { - return true -} - -var _ matchers.Matcher = &fallbackMatcher{} - -func New() matchers.Matcher { - return &fallbackMatcher{} -} diff --git a/common/pkg/matchers/matcher.go b/common/pkg/matchers/matcher.go index 76b0691..aa88ee4 100644 --- a/common/pkg/matchers/matcher.go +++ b/common/pkg/matchers/matcher.go @@ -2,13 +2,12 @@ package matchers import ( "fmt" - "log/slog" - - "github.com/pltanton/autobrowser/common/pkg/config" ) +type MatcherConfigProvider func(v any) error + type Matcher interface { - Match(args map[string]string) bool + Match(configProvider MatcherConfigProvider) (bool, error) } type MatchersRegistry struct { @@ -25,24 +24,7 @@ func (r *MatchersRegistry) RegisterMatcher(name string, matcher Matcher) { r.matchers[name] = matcher } -func (r *MatchersRegistry) EvalRule(rule config.Rule) (bool, error) { - for name, args := range rule.Matchers { - matcher, err := r.getMatcher(name) - if err != nil { - return false, err - } - - if !matcher.Match(args) { - slog.Debug("Matcher not matched", "name", name, "matcher", matcher, "command", rule.Command) - return false, nil - } - } - - slog.Debug("Rule matched", "command", rule) - return true, nil -} - -func (r *MatchersRegistry) getMatcher(name string) (Matcher, error) { +func (r *MatchersRegistry) GetMatcher(name string) (Matcher, error) { matcher, ok := r.matchers[name] if !ok { return nil, fmt.Errorf("unknown matcher %s", name) diff --git a/common/pkg/matchers/urlmatcher/url.go b/common/pkg/matchers/urlmatcher/url.go index ca9096a..171c733 100644 --- a/common/pkg/matchers/urlmatcher/url.go +++ b/common/pkg/matchers/urlmatcher/url.go @@ -14,21 +14,32 @@ type urlMatcher struct { url *neturl.URL } +type urlMatcherConfig struct { + Regex string `toml:"regex,omitempty"` + Host string `toml:"host,omitempty"` + Scheme string `toml:"scheme,omitempty"` +} + // Match implements matchers.Matcher. -func (u *urlMatcher) Match(args map[string]string) bool { - if regex, ok := args["regex"]; ok && !u.matchByRegex(regex) { - return false +func (u *urlMatcher) Match(configProvider matchers.MatcherConfigProvider) (bool, error) { + var c urlMatcherConfig + if err := configProvider(&c); err != nil { + return false, fmt.Errorf("failed to load url matcher config %w", err) + } + + if c.Regex != "" && !u.matchByRegex(c.Regex) { + return false, nil } - if host, ok := args["host"]; ok && !u.matchByHost(host) { - return false + if c.Host != "" && !u.matchByHost(c.Host) { + return false, nil } - if scheme, ok := args["scheme"]; ok && !u.matchByScheme(scheme) { - return false + if c.Scheme != "" && !u.matchByScheme(c.Scheme) { + return false, nil } - return true + return true, nil } func (u *urlMatcher) matchByHost(host string) bool { diff --git a/examples/example-debug.config b/examples/example-debug.config deleted file mode 100644 index f7896c0..0000000 --- a/examples/example-debug.config +++ /dev/null @@ -1,10 +0,0 @@ -fromvar := echo 'fromvar command {}' # Command from browser - -fromvar:url.regex='.*fromvar.com.*' - -# Comment that should be ignored - -echo 'matched by url {}':url.regex='.*url.com.*' -echo 'matched by Alacritty class {}':app.class=Alacritty -echo 'matched by gnome class {}':app.class=gnome-terminal-server -echo 'matched by fallback {}':fallback \ No newline at end of file diff --git a/examples/example-macos.config b/examples/example-macos.config deleted file mode 100644 index 9d630e0..0000000 --- a/examples/example-macos.config +++ /dev/null @@ -1,3 +0,0 @@ -open -a 'Firefox' {}:url.host='example.com' -open -a 'Firefox' {}:app.bundle_id='ru.keepcoder.Telegram' -open -a 'Google Chrome' {}:fallback \ No newline at end of file diff --git a/examples/example-macos.toml b/examples/example-macos.toml new file mode 100644 index 0000000..e67f6eb --- /dev/null +++ b/examples/example-macos.toml @@ -0,0 +1,10 @@ +default_command = "work" + +command.work.cmd = "open -a 'Google Chrome' {}" +command.personal.cmd = "open -a 'Firefox' {}" + +[[rules]] +command = "personal" +[[rules.matchers]] +type = "app" +bundle_id = "ru.keepcoder.Telegram" diff --git a/examples/example.config b/examples/example.config deleted file mode 100644 index 52624f0..0000000 --- a/examples/example.config +++ /dev/null @@ -1,3 +0,0 @@ -firefox 'ext+container:name=Work&url={}':app.class=Slack -firefox 'ext+container:name=Work&url={}':app.class=Alacritty -firefox {}:fallback \ No newline at end of file diff --git a/examples/example.toml b/examples/example.toml new file mode 100644 index 0000000..4cb3c3b --- /dev/null +++ b/examples/example.toml @@ -0,0 +1,24 @@ +# Could be a defined command or plain command with default placeholder +default_command = "personal" + +[command.work] +# Command could be an array of strings +cmd = ["firefox", "ext+container:name=Work&url=%"] +# Default placeholder is {} +placeholder = "%" +# Apply query escape to URL before inserting into command +query_escape = true + +[command.personal] +cmd = "chromium {}" + +# List of rules, that will be applied in order, first matched rule will be used +[[rules]] +command = "work" +# List of matchers works like AND condition +[[rules.matchers]] +type = "app" +class = "Slack" +[[rules.matchers]] +type = "url" +regex = ".*jira.*" diff --git a/flake.lock b/flake.lock index 28adc16..0e41151 100644 --- a/flake.lock +++ b/flake.lock @@ -5,11 +5,11 @@ "nixpkgs": "nixpkgs" }, "locked": { - "lastModified": 1713789128, - "narHash": "sha256-GU+mNIu3raRFGxmFYj3KmltiYJhaLvbnBHkpeORWYMY=", + "lastModified": 1756126213, + "narHash": "sha256-iSmyMn8r5NJfTWEsp6R/5jmMHrVLnQ/icebYfNP5QnQ=", "owner": "nix-community", "repo": "flakelight", - "rev": "796777410b196cc6a9635472a1fe1f71f6672b96", + "rev": "4415f8e61c770441f01a5b2550da5815aeebe144", "type": "github" }, "original": { @@ -20,17 +20,18 @@ }, "nixpkgs": { "locked": { - "lastModified": 1713714899, - "narHash": "sha256-+z/XjO3QJs5rLE5UOf015gdVauVRQd2vZtsFkaXBq2Y=", + "lastModified": 1755615617, + "narHash": "sha256-HMwfAJBdrr8wXAkbGhtcby1zGFvs+StOp19xNsbqdOg=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "6143fc5eeb9c4f00163267708e26191d1e918932", + "rev": "20075955deac2583bb12f07151c2df830ef346b4", "type": "github" }, "original": { - "id": "nixpkgs", + "owner": "NixOS", "ref": "nixos-unstable", - "type": "indirect" + "repo": "nixpkgs", + "type": "github" } }, "root": { diff --git a/linux/cmd/autobrowser/main.go b/linux/cmd/autobrowser/main.go index 5adb77f..0d6520b 100644 --- a/linux/cmd/autobrowser/main.go +++ b/linux/cmd/autobrowser/main.go @@ -3,7 +3,6 @@ package main import ( "github.com/pltanton/autobrowser/common/pkg/app" "github.com/pltanton/autobrowser/common/pkg/matchers" - "github.com/pltanton/autobrowser/common/pkg/matchers/fallback" "github.com/pltanton/autobrowser/common/pkg/matchers/urlmatcher" "github.com/pltanton/autobrowser/common/pkg/utils" "github.com/pltanton/autobrowser/linux/internal/deinfo" @@ -22,7 +21,6 @@ func main() { registry.RegisterMatcher("url", urlmatcher.New(options.Url)) registry.RegisterMatcher("app", appmatcher.New(deInfoProvider)) - registry.RegisterMatcher("fallback", fallback.New()) app.SetupAndRun(options.ConfigPath, options.Url, registry) } diff --git a/linux/go.mod b/linux/go.mod index 5642174..277842a 100644 --- a/linux/go.mod +++ b/linux/go.mod @@ -10,6 +10,7 @@ require ( ) require ( + github.com/BurntSushi/toml v1.5.0 // indirect github.com/joshuarubin/lifecycle v1.0.0 // indirect go.uber.org/atomic v1.3.2 // indirect go.uber.org/multierr v1.1.0 // indirect diff --git a/linux/go.sum b/linux/go.sum index 269c566..23d84b4 100644 --- a/linux/go.sum +++ b/linux/go.sum @@ -1,3 +1,5 @@ +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= diff --git a/linux/internal/envx/envx.go b/linux/internal/envx/envx.go index 3473041..eb6b6f9 100644 --- a/linux/internal/envx/envx.go +++ b/linux/internal/envx/envx.go @@ -31,7 +31,7 @@ func init() { }{} dir, _ := os.UserHomeDir() - flag.StringVar(&flags.ConfigPath, "config", dir+"/.config/autobrowser.config", "configuration file path") + flag.StringVar(&flags.ConfigPath, "config", dir+"/.config/autobrowser/config.toml", "configuration file path") flag.StringVar(&flags.Url, "url", "", "url to open") flag.StringVar(&flags.LogLevel, "log", "INFO", "log level: DEBUG, INFO, WARN, ERROR") diff --git a/linux/internal/matchers/appmatcher/appmatcher.go b/linux/internal/matchers/appmatcher/appmatcher.go index 4902aef..14246d2 100644 --- a/linux/internal/matchers/appmatcher/appmatcher.go +++ b/linux/internal/matchers/appmatcher/appmatcher.go @@ -13,17 +13,27 @@ type appMatcher struct { provider *deinfo.DeInfoProvider } +type appMatcherConfig struct { + Class string `toml:"class,omitempty"` + Title string `toml:"title,omitempty"` +} + // Match implements matchers.Matcher. -func (m *appMatcher) Match(args map[string]string) bool { - if class, ok := args["class"]; ok && !m.matchByClass(class) { - return false +func (m *appMatcher) Match(configProvider matchers.MatcherConfigProvider) (bool, error) { + var c appMatcherConfig + if err := configProvider(&c); err != nil { + return false, fmt.Errorf("failed to load app matcher config: %w", err) + } + + if c.Class != "" && !m.matchByClass(c.Class) { + return false, nil } - if title, ok := args["title"]; ok && !m.matchByTitle(title) { - return false + if c.Title != "" && !m.matchByTitle(c.Title) { + return false, nil } - return true + return true, nil } func (m *appMatcher) matchByTitle(regex string) bool { diff --git a/macos/assets/AppIcon.icns b/macos/assets/AppIcon.icns index da22d1d..2d1057e 100644 Binary files a/macos/assets/AppIcon.icns and b/macos/assets/AppIcon.icns differ diff --git a/macos/cmd/autobrowser/main.go b/macos/cmd/autobrowser/main.go index 28b26bd..697f331 100644 --- a/macos/cmd/autobrowser/main.go +++ b/macos/cmd/autobrowser/main.go @@ -8,7 +8,6 @@ import ( "github.com/pltanton/autobrowser/common/pkg/app" "github.com/pltanton/autobrowser/common/pkg/matchers" - "github.com/pltanton/autobrowser/common/pkg/matchers/fallback" "github.com/pltanton/autobrowser/common/pkg/matchers/urlmatcher" "github.com/pltanton/autobrowser/macos/internal/macevents" "github.com/pltanton/autobrowser/macos/internal/matchers/appmatcher" @@ -19,7 +18,7 @@ func parseConfig() string { var result string dir, _ := os.UserHomeDir() - flag.StringVar(&result, "config", dir+"/.config/autobrowser.config", "configuration file path") + flag.StringVar(&result, "config", dir+"/.config/autobrowser/config.toml", "configuration file path") flag.Parse() @@ -49,7 +48,6 @@ func main() { registry.RegisterMatcher("url", urlmatcher.New(urlEvent.URL)) registry.RegisterMatcher("app", appmatcher.New(urlEvent.PID)) - registry.RegisterMatcher("fallback", fallback.New()) app.SetupAndRun(cfg, urlEvent.URL, registry) } diff --git a/macos/go.mod b/macos/go.mod index e0d75da..d250bc3 100644 --- a/macos/go.mod +++ b/macos/go.mod @@ -6,4 +6,6 @@ toolchain go1.24.4 require github.com/pltanton/autobrowser/common v0.0.0 +require github.com/BurntSushi/toml v1.5.0 // indirect + replace github.com/pltanton/autobrowser/common => ../common diff --git a/macos/go.sum b/macos/go.sum index e69de29..ff7fd09 100644 --- a/macos/go.sum +++ b/macos/go.sum @@ -0,0 +1,2 @@ +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= diff --git a/macos/internal/matchers/appmatcher/mac_opener.go b/macos/internal/matchers/appmatcher/mac_opener.go index 0f0f89c..428970a 100644 --- a/macos/internal/matchers/appmatcher/mac_opener.go +++ b/macos/internal/matchers/appmatcher/mac_opener.go @@ -1,6 +1,8 @@ package appmatcher import ( + "fmt" + "github.com/pltanton/autobrowser/common/pkg/matchers" "github.com/pltanton/autobrowser/macos/internal/macevents" ) @@ -11,22 +13,34 @@ type macAppMatcher struct { var _ matchers.Matcher = &macAppMatcher{} +type macAppMatcherConfig struct { + DisplayName string `toml:"display_name,omitempty"` + BundleID string `toml:"bundle_id,omitempty"` + BundlePath string `toml:"bundle_path,omitempty"` + ExecutablePath string `toml:"executable_path,omitempty"` +} + // Match implements matchers.Matcher. -func (h *macAppMatcher) Match(args map[string]string) bool { - if displayName, ok := args["display_name"]; ok && h.sourceApp.LocalizedName != displayName { - return false +func (h *macAppMatcher) Match(configProvider matchers.MatcherConfigProvider) (bool, error) { + var c macAppMatcherConfig + if err := configProvider(&c); err != nil { + return false, fmt.Errorf("failed to load mac app matcher config: %w", err) + } + + if c.DisplayName != "" && h.sourceApp.LocalizedName != c.DisplayName { + return false, nil } - if bundleId, ok := args["bundle_id"]; ok && h.sourceApp.BundleID != bundleId { - return false + if c.BundleID != "" && h.sourceApp.BundleID != c.BundleID { + return false, nil } - if bundlePath, ok := args["bundle_path"]; ok && h.sourceApp.BundleURL != bundlePath { - return false + if c.BundlePath != "" && h.sourceApp.BundleURL != c.BundlePath { + return false, nil } - if executablePath, ok := args["executable_path"]; ok && h.sourceApp.ExecutableURL != executablePath { - return false + if c.ExecutablePath != "" && h.sourceApp.ExecutableURL != c.ExecutablePath { + return false, nil } - return true + return true, nil } func New(ppid int) matchers.Matcher { diff --git a/macos/internal/oslog/handler.go b/macos/internal/oslog/handler.go index df6cfbb..aeffec4 100644 --- a/macos/internal/oslog/handler.go +++ b/macos/internal/oslog/handler.go @@ -7,10 +7,14 @@ import ( type Handler struct { osLogger *Logger + attrs []slog.Attr } func NewHandler() *Handler { - return &Handler{osLogger: NewLogger("dev.pltanton.autobrowser", "AppLog")} + return &Handler{ + osLogger: NewLogger("dev.pltanton.autobrowser", "AppLog"), + attrs: []slog.Attr{}, + } } func (h *Handler) Enabled(_ context.Context, _ slog.Level) bool { @@ -20,22 +24,36 @@ func (h *Handler) Enabled(_ context.Context, _ slog.Level) bool { func (h *Handler) Handle(_ context.Context, r slog.Record) error { // Format log entry msg := r.Message + + // Add handler's attributes + for _, attr := range h.attrs { + msg += " " + attr.String() + } + + // Add record's attributes if r.NumAttrs() > 0 { r.Attrs(func(a slog.Attr) bool { msg += " " + a.String() return true }) } + h.osLogger.Log(r.Level, msg) return nil } func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler { - // naive: just log attrs inline - return h + newAttrs := make([]slog.Attr, len(h.attrs)+len(attrs)) + copy(newAttrs, h.attrs) + copy(newAttrs[len(h.attrs):], attrs) + + return &Handler{ + osLogger: h.osLogger, + attrs: newAttrs, + } } func (h *Handler) WithGroup(name string) slog.Handler { - // groups not handled in this simple version + // Simple implementation: treat groups as prefixes for attributes return h } diff --git a/nix/homeModule.nix b/nix/homeModule.nix index 4753a78..f15cdd3 100644 --- a/nix/homeModule.nix +++ b/nix/homeModule.nix @@ -6,63 +6,274 @@ }: with lib; let cfg = config.programs.autobrowser; - configText = builtins.concatStringsSep "\n" ( - (lib.mapAttrsToList (k: v: "${k}:=${v}") cfg.variables) - ++ cfg.rules - ++ ["${cfg.default}:fallback"] - ); + configText = '' + default_command = "${cfg.defaultCommand}" + + ${lib.concatStringsSep "\n\n" (lib.mapAttrsToList (name: command: '' + [command.${name}] + cmd = ${builtins.toJSON ( + if builtins.isList command.cmd + then command.cmd + else command.cmd + )} + ${lib.optionalString (command.placeholder != null) "placeholder = ${builtins.toJSON command.placeholder}"} + ${lib.optionalString command.queryEscape "query_escape = true"} + '') + cfg.commands)} + + ${lib.concatStringsSep "\n\n" (map (rule: '' + [[rules]] + command = "${rule.command}" + ${lib.concatStringsSep "\n" (map (matcher: '' + [[rules.matchers]] + type = "${matcher.type}" + ${lib.concatStringsSep "\n" (lib.mapAttrsToList ( + k: v: + if k != "type" && v != null + then "${(lib.replaceStrings ["displayName" "bundleId" "bundlePath" "executablePath"] ["display_name" "bundle_id" "bundle_path" "executable_path"] k)} = ${builtins.toJSON v}" + else "" + ) (removeAttrs (lib.filterAttrs (n: v: v != null) matcher) ["type"]))} + '') + rule.matchers)} + '') + cfg.rules)} + ''; in { options.programs.autobrowser = { enable = lib.mkEnableOption "whenever to enable autobrowser as default browser"; package = mkPackageOption pkgs "autobrowser" {}; - variables = mkOption { - type = with lib.types; attrsOf str; - description = "Attribute set of variables"; + commands = mkOption { + type = with lib.types; + attrsOf (submodule { + options = { + cmd = mkOption { + type = either str (listOf str); + description = "Command to execute (string or list of strings)"; + example = ''["firefox", "--new-tab", "{}"]''; + }; + placeholder = mkOption { + type = nullOr str; + description = "Placeholder to replace with URL (default is {})"; + default = null; + example = "{{url}}"; + }; + queryEscape = mkOption { + type = bool; + description = "Whether to apply URL query escaping"; + default = false; + }; + }; + }); + description = "Commands that can be executed"; default = {}; + example = { + personal = { + cmd = "firefox {}"; + }; + work = { + cmd = ["firefox" "--private-window" "{}"]; + queryEscape = true; + }; + }; }; + rules = mkOption { - type = with lib.types; listOf str; - example = ["firefox {}:app.class=telegram" "firefox -p work {}:url.regex='.*atlassian.org.*'"]; - description = "List of rules"; + type = with lib.types; + listOf (submodule { + options = { + command = mkOption { + type = str; + description = "Command to use when rule matches"; + example = "personal"; + }; + matchers = mkOption { + type = with lib.types; + listOf (submodule { + options = { + type = mkOption { + type = enum ["url" "app"]; + description = "Matcher type"; + example = "url"; + }; + + # URL matcher options + regex = mkOption { + type = nullOr str; + description = "Match URL by regex pattern (for URL matchers)"; + default = null; + example = ".*github\\.com.*"; + }; + host = mkOption { + type = nullOr str; + description = "Match URL by host (for URL matchers)"; + default = null; + example = "github.com"; + }; + scheme = mkOption { + type = nullOr str; + description = "Match URL by scheme (for URL matchers)"; + default = null; + example = "https"; + }; + + # App matcher options - Linux + class = mkOption { + type = nullOr str; + description = "Match by window class (Linux, for app matchers)"; + default = null; + example = "Firefox"; + }; + title = mkOption { + type = nullOr str; + description = "Match window title by regex pattern (Linux, for app matchers)"; + default = null; + example = ".*GitHub.*"; + }; + + # App matcher options - macOS + displayName = mkOption { + type = nullOr str; + description = "Match by app display name (macOS, for app matchers)"; + default = null; + example = "Safari"; + }; + bundleId = mkOption { + type = nullOr str; + description = "Match by App Bundle ID (macOS, for app matchers)"; + default = null; + example = "com.apple.Safari"; + }; + bundlePath = mkOption { + type = nullOr str; + description = "Match by App Bundle path (macOS, for app matchers)"; + default = null; + example = "/Applications/Safari.app"; + }; + executablePath = mkOption { + type = nullOr str; + description = "Match by app executable path (macOS, for app matchers)"; + default = null; + example = "/Applications/Safari.app/Contents/MacOS/Safari"; + }; + }; + }); + description = "List of matchers (all must match for rule to apply)"; + example = [ + { + type = "url"; + regex = ".*github.com.*"; + } + { + type = "app"; + class = "Terminal"; + } + ]; + }; + }; + }); + description = "List of rules to match"; + default = []; + example = [ + { + command = "work"; + matchers = [ + { + type = "app"; + class = "Slack"; + } + { + type = "url"; + regex = ".*jira.*"; + } + ]; + } + ]; }; - default = mkOption { + + defaultCommand = mkOption { type = lib.types.str; - description = "Default browser command"; + description = "Default command to use when no rules match"; default = ""; - example = "firefox {}"; + example = "personal"; }; - desktop = - pkgs.writeTextDir "share/applications/autobrowser.desktop" - (lib.generators.toINI {} { - "Desktop Entry" = { - Type = "Application"; - Exec = "${cfg.package}/bin/autobrowser -url %u"; - Terminal = false; - Name = "Autobrowser: select browser by contextual rules"; - Icon = "browser"; - Categories = "Network;WebBrowser"; - MimeType = "x-scheme-handler/http;x-scheme-handler/https"; - }; - }); + desktop = mkOption { + type = lib.types.package; + description = "Desktop entry for autobrowser"; + default = + pkgs.writeTextDir "share/applications/autobrowser.desktop" + (lib.generators.toINI {} { + "Desktop Entry" = { + Type = "Application"; + Exec = "${cfg.package}/bin/autobrowser -url %u"; + Terminal = false; + Name = "Autobrowser: select browser by contextual rules"; + Icon = "browser"; + Categories = "Network;WebBrowser"; + MimeType = "x-scheme-handler/http;x-scheme-handler/https"; + }; + }); + }; }; - config = mkIf cfg.enable { - xdg.configFile."autobrowser.config".text = configText; - home.packages = - [cfg.package] - ++ ( - if pkgs.stdenv.isLinux - then [desktop] - else [] - ); + config = lib.mkMerge [ + # Type validation through assertions + { + assertions = flatten (map ( + rule: + map (matcher: [ + { + # For URL matchers, only URL fields should be set + assertion = + matcher.type + == "url" + -> ( + matcher.class + == null + && matcher.title == null + && matcher.displayName == null + && matcher.bundleId == null + && matcher.bundlePath == null + && matcher.executablePath == null + ); + message = "URL matcher should only use URL-specific fields (regex, host, scheme)"; + } + { + # For app matchers, only app fields should be set + assertion = + matcher.type + == "app" + -> ( + matcher.regex + == null + && matcher.host == null + && matcher.scheme == null + ); + message = "App matcher should only use app-specific fields"; + } + ]) + rule.matchers + ) (cfg.rules or [])); + } + + (mkIf cfg.enable { + xdg.configFile."autobrowser/config.toml".text = configText; - xdg.mimeApps = mkIf pkgs.stdenv.isLinux { - defaultApplications = { - "x-scheme-handler/http" = "autobrowser.desktop"; - "x-scheme-handler/https" = "autobrowser.desktop"; - "x-scheme-handler/about" = "autobrowser.desktop"; + home.packages = + [cfg.package] + ++ ( + if pkgs.stdenv.isLinux + then [cfg.desktop] + else [] + ); + + xdg.mimeApps = mkIf pkgs.stdenv.isLinux { + defaultApplications = { + "x-scheme-handler/http" = "autobrowser.desktop"; + "x-scheme-handler/https" = "autobrowser.desktop"; + "x-scheme-handler/about" = "autobrowser.desktop"; + }; }; - }; - }; + }) + ]; } diff --git a/nix/packages/_default.nix b/nix/packages/_default.nix index 3e53825..a98e7ef 100644 --- a/nix/packages/_default.nix +++ b/nix/packages/_default.nix @@ -9,8 +9,8 @@ buildGoModule { version = "0"; vendorHash = if stdenv.isDarwin - then "sha256-w/MoA6uOgbQVPFzApJEDLeEFviKbvKjpdaIltyZ3he0=" - else "sha256-dvu80fFm3vIBjhk9k9Z5h9J5qTbvl3Tq1MCMQVJ+ru8="; + then "sha256-9asbxZJxovodDZFUNhlaF/B9fG78nDNcfhcKynFIXg8=" + else "sha256-05D0rsPh/QLCL5i5c/xNTBozdRkPmtRQa5KU/Y0Y4pA="; src = import ../src.nix {inherit lib;}; modRoot = @@ -18,11 +18,6 @@ buildGoModule { then "macos" else "linux"; - buildInputs = lib.optionals stdenv.isDarwin [ - darwin.apple_sdk.frameworks.Cocoa - darwin.apple_sdk.frameworks.Foundation - ]; - postInstall = lib.optionalString stdenv.isDarwin '' # Create macOS app bundle mkdir -p $out/Applications/Autobrowser.app/Contents/{MacOS,Resources} diff --git a/nix/packages/common.nix b/nix/packages/common.nix index e41a5b1..8677ec1 100644 --- a/nix/packages/common.nix +++ b/nix/packages/common.nix @@ -6,7 +6,7 @@ buildGoModule { pname = "autobrowser-common"; version = "0"; - vendorHash = null; + vendorHash = "sha256-CVycV7wxo7nOHm7qjZKfJrIkNcIApUNzN1mSIIwQN0g="; src = import ../src.nix {inherit lib;}; modRoot = "common";