diff --git a/README.md b/README.md index 2846a1d..c5ecca0 100644 --- a/README.md +++ b/README.md @@ -87,16 +87,29 @@ Selections are saved to `$XDG_CACHE_HOME/bw-menu/history.json` (defaults to `~/. Config file location: `$XDG_CONFIG_HOME/bw-menu/config.yaml` (or `config.yml`), defaults to `~/.config/bw-menu/config.yaml`. ```yaml +rbw_path: '~/.cargo/bin/rbw' + keybindings: - password: Return - username: Control+Return - totp: Shift+Return + password: 'Return' + username: 'Control+Return' + totp: 'Shift+Return' ``` +| Key | Description | Default | +|----------------|----------------------------------------------------|---------| +| `rbw_path` | Path to the `rbw` binary (`~` is expanded) | `rbw` | +| `keybindings` | Map of vault fields to rofi key names (see below) | see above | + +### `keybindings` + Each entry maps a vault field to a [rofi key name](https://davatorium.github.io/rofi/current/rofi-keys.5/). Available fields: `password`, `username`, `totp`, `name`. The **first** entry is bound to rofi's accept key (`kb-accept-entry`). Additional entries become custom keybindings (`kb-custom-1`, `kb-custom-2`, …). +### `rbw_path` + +By default, `bw-menu` calls `rbw` from `$PATH`. If `rbw` is not on your PATH (e.g. when launching via `swaymsg exec`), set `rbw_path` to the full path of the binary. + ## Inspired by diff --git a/src/bw_menu/__main__.py b/src/bw_menu/__main__.py index 3ae64f7..64dbbb0 100644 --- a/src/bw_menu/__main__.py +++ b/src/bw_menu/__main__.py @@ -1,26 +1,27 @@ import sys -from bw_menu import rbw, history, clipboard +from bw_menu import rbw, history, clipboard, config from bw_menu.cli import parse_args from bw_menu.selector import format_entry, create_selector def main(): args = parse_args() + cfg = config.load() if args.command == "select": - _handle_select(args) + _handle_select(args, cfg.rbw_path) elif args.command == "history": - _handle_history(args) + _handle_history(args, cfg.rbw_path) -def _handle_select(args): +def _handle_select(args, rbw_path: str): selector = create_selector(args.selector) - selection = selector.run(args) + selection = selector.run(args, rbw_path) if selection is None: return - value = rbw.get_field(selection.entry, selection.field) + value = rbw.get_field(selection.entry, selection.field, rbw_path) history.add(selection.entry) if args.print_result: @@ -29,7 +30,7 @@ def _handle_select(args): clipboard.copy(value) -def _handle_history(args): +def _handle_history(args, rbw_path: str): if args.history_command == "list": for i, entry in enumerate(history.load()): print(f"{i}: {format_entry(entry)}") @@ -40,7 +41,7 @@ def _handle_history(args): print(f"No history entry at index {args.index}", file=sys.stderr) sys.exit(1) - value = rbw.get_field(entry, args.field) + value = rbw.get_field(entry, args.field, rbw_path) if args.print_result: print(value) else: diff --git a/src/bw_menu/config.py b/src/bw_menu/config.py index d85cfe3..20436ad 100644 --- a/src/bw_menu/config.py +++ b/src/bw_menu/config.py @@ -19,6 +19,7 @@ class Config: keybindings: dict[str, str] = field( default_factory=lambda: dict(DEFAULT_KEYBINDINGS) ) + rbw_path: str = "rbw" def __config_path() -> Path | None: @@ -38,32 +39,49 @@ def load() -> Config: try: data = yaml.safe_load(path.read_text()) - if not isinstance(data, dict) or "keybindings" not in data: - print( - f"Warning: config file {path} has invalid structure, using defaults", - file=sys.stderr, - ) - return Config() - kb = data["keybindings"] - if not isinstance(kb, dict) or not kb: - print( - f"Warning: config file {path} has invalid keybindings, using defaults", - file=sys.stderr, - ) - return Config() - keybindings = {str(k): str(v) for k, v in kb.items()} - invalid = {k for k in keybindings if k not in VALID_FIELDS} - if invalid: - print( - f"Warning: invalid keybinding field(s): {', '.join(sorted(invalid))}. " - f"Valid fields: {', '.join(sorted(VALID_FIELDS))}. Using defaults", - file=sys.stderr, - ) - return Config() - return Config(keybindings=keybindings) except yaml.YAMLError: print( f"Warning: config file {path} could not be parsed, using defaults", file=sys.stderr, ) return Config() + + if not isinstance(data, dict): + print( + f"Warning: config file {path} has invalid structure, using defaults", + file=sys.stderr, + ) + return Config() + + rbw_path = "rbw" + if "rbw_path" in data: + raw = data["rbw_path"] + if isinstance(raw, str) and raw: + rbw_path = os.path.expanduser(raw) + else: + print( + f"Warning: invalid rbw_path in {path}, using default", + file=sys.stderr, + ) + + keybindings = dict(DEFAULT_KEYBINDINGS) + if "keybindings" in data: + kb = data["keybindings"] + if not isinstance(kb, dict) or not kb: + print( + f"Warning: config file {path} has invalid keybindings, using defaults", + file=sys.stderr, + ) + else: + parsed = {str(k): str(v) for k, v in kb.items()} + invalid = {k for k in parsed if k not in VALID_FIELDS} + if invalid: + print( + f"Warning: invalid keybinding field(s): {', '.join(sorted(invalid))}. " + f"Valid fields: {', '.join(sorted(VALID_FIELDS))}. Using defaults", + file=sys.stderr, + ) + else: + keybindings = parsed + + return Config(keybindings=keybindings, rbw_path=rbw_path) diff --git a/src/bw_menu/rbw.py b/src/bw_menu/rbw.py index 310c5d4..bd0471c 100644 --- a/src/bw_menu/rbw.py +++ b/src/bw_menu/rbw.py @@ -12,8 +12,8 @@ class Entry: type: str = field(default="Login", compare=False) -def list_entries() -> list[Entry]: - result = run(["rbw", "list", "--raw"], capture_output=True, encoding="utf-8") +def list_entries(rbw_path: str = "rbw") -> list[Entry]: + result = run([rbw_path, "list", "--raw"], capture_output=True, encoding="utf-8") if result.returncode != 0: print("Error calling rbw. Is it correctly configured?", file=sys.stderr) @@ -32,22 +32,22 @@ def list_entries() -> list[Entry]: ] -def get_field(entry: Entry, field: str) -> str: +def get_field(entry: Entry, field: str, rbw_path: str = "rbw") -> str: if field == "name": return entry.name if field == "totp": - return __get_totp(entry) + return __get_totp(entry, rbw_path) if field == "username": return entry.username - data = __load_raw(entry) + data = __load_raw(entry, rbw_path) if field == "password": return data.get("data", {}).get("password") or "" raise ValueError(f"Unknown field: {field}") -def __get_totp(entry: Entry) -> str: - cmd = ["rbw", "code", entry.name] +def __get_totp(entry: Entry, rbw_path: str) -> str: + cmd = [rbw_path, "code", entry.name] if entry.username: cmd.append(entry.username) if entry.folder: @@ -60,8 +60,8 @@ def __get_totp(entry: Entry) -> str: return result.stdout.strip() -def __load_raw(entry: Entry) -> dict: - cmd = ["rbw", "get", "--raw", entry.name] +def __load_raw(entry: Entry, rbw_path: str) -> dict: + cmd = [rbw_path, "get", "--raw", entry.name] if entry.username: cmd.append(entry.username) if entry.folder: diff --git a/src/bw_menu/selector/__init__.py b/src/bw_menu/selector/__init__.py index f756cb4..2752ee4 100644 --- a/src/bw_menu/selector/__init__.py +++ b/src/bw_menu/selector/__init__.py @@ -32,8 +32,8 @@ def decode_info(info: str) -> Entry: return Entry(parts[0], parts[1], parts[2]) -def prepare_entries() -> tuple[list[Entry], list[Entry]]: - all_entries = rbw.list_entries() +def prepare_entries(rbw_path: str = "rbw") -> tuple[list[Entry], list[Entry]]: + all_entries = rbw.list_entries(rbw_path) history_entries = history.load() all_dict = {e: e for e in all_entries} diff --git a/src/bw_menu/selector/fzf.py b/src/bw_menu/selector/fzf.py index ddec76c..03f0279 100644 --- a/src/bw_menu/selector/fzf.py +++ b/src/bw_menu/selector/fzf.py @@ -10,8 +10,8 @@ from bw_menu.selector import ( class FzfSelector: - def run(self, args) -> Selection | None: - history_in_list, remaining = prepare_entries() + def run(self, args, rbw_path: str = "rbw") -> Selection | None: + history_in_list, remaining = prepare_entries(rbw_path) lines = [] for entry in history_in_list: diff --git a/src/bw_menu/selector/rofi.py b/src/bw_menu/selector/rofi.py index 9d94e77..8d8b125 100644 --- a/src/bw_menu/selector/rofi.py +++ b/src/bw_menu/selector/rofi.py @@ -28,7 +28,7 @@ _TYPE_ICONS: dict[str, str] = { class RofiSelector: - def run(self, args) -> Selection | None: + def run(self, args, rbw_path: str = "rbw") -> Selection | None: cfg = config.load() keybindings = cfg.keybindings rofi_retv = os.environ.get("ROFI_RETV") @@ -40,7 +40,7 @@ class RofiSelector: rofi_retv = int(rofi_retv) if rofi_retv == 0: - self.__print_entries(keybindings) + self.__print_entries(keybindings, rbw_path) return None fields = list(keybindings.keys()) @@ -118,8 +118,8 @@ class RofiSelector: subprocess.run(cmd) - def __print_entries(self, keybindings: dict[str, str]): - history_in_list, remaining = prepare_entries() + def __print_entries(self, keybindings: dict[str, str], rbw_path: str): + history_in_list, remaining = prepare_entries(rbw_path) print("\0prompt\x1fSelect item") print("\0no-custom\x1ftrue")