diff --git a/README.md b/README.md
index 3832e59..255213b 100644
--- a/README.md
+++ b/README.md
@@ -1,20 +1,19 @@
# Ente Auth - Alfred Workflow for Ente Exports
-Easily integrate your **Ente Auth** with Alfred using this simple and powerful workflow to manage your Ente secrets and authentication.
+Easily integrate your **Ente Auth** with Alfred using this simple workflow to query your Ente Auth TOTP accounts.
+
+The workflow uses Ente CLI to export your secrets from Ente Auth and then stashes them securely into the macOS Keychain.
## πΈ Shots
![image1](./metadata/image.png)
## π Setup
-### 1. Install the Workflow
-Download and install the workflow from the latest [releases](https://github.com/chkpwd/alfred-ente-auth/releases) page.
-
-
> [!NOTE]
-> Currently, Homebrew installation is not available for the Ente CLI due to an issue with the formula. When running brew test for the formula, the ente CLI fails with an error. As a result, the formula is not ready for installation via brew yet. Please use the manual installation steps outlined above. https://github.com/ente-io/ente/pull/4028
+> Currently, Homebrew installation is not available for the Ente CLI. A formula will be added pending a new release including a [required fix](https://github.com/ente-io/ente/pull/4028). For the time being, please use the manual installation steps outlined below.
+
+### 1. Download and Install the Ente CLI
-### 2. Download and Install the Ente CLI
To use the **Ente Auth** workflow, you'll need the **Ente CLI**. Follow the steps below to install it:
1. Visit the [Ente CLI releases page](https://github.com/ente-io/ente/releases?q=tag%3Acli-v0).
@@ -32,26 +31,37 @@ Once installed, verify that it's working by running the following command in you
ente version
```
-### 3. Configure Your Database
-To configure the Ente CLI and ensure the workflow has access to your data, you'll need to set the **export path**. This path should be the same one you configured when adding your Ente account.
+### 2. Configure Ente CLI
+
+- Run `ente account add` to authenticate yourself with Ente CLI.
+- You'll first be prompted for the app type. Enter `auth`.
+- Next, you'll be asked for an export directory. You can choose any path you wish, but it must exist before you press return, else Ente CLI will not accept it.
+- Finally, you'll be prompted to provide your Ente login credentials.
+
+### 3. Install the Workflow
+
+Download and open the workflow file from the [latest release](https://github.com/chkpwd/alfred-ente-auth/releases/latest) page.
+
+> [!NOTE]
+> To ensure the workflow can import your accounts from Ente Auth, you'll need to define the "Ente Export Directory" when you add this extension to Alfred.
+> This path should be the same one you configured when adding your Ente account.
+> To show the Ente CLI's configured export path, run `ente account list` and refer to the `ExportDir` value.
---
-## π Instructions
+## π Usage Instructions
1. **Launch Alfred**
- - Open Alfred and navigate to the **Workflows** tab.
-
-2. **Select Ente Auth**
- - Find the "Ente Auth" workflow in your list and click on it.
-3. **Configure Workflow**
- - Hit the **Configure Workflow** button to open the settings.
- - Specify the **export path**βthis should be the same path you configured when adding your Ente account.
- - Configure any additional settings as needed (e.g., API keys, other preferences).
+2. **Import Your Data**
+ - To import your Ente Auth TOTP accounts, simply trigger the workflow by running **`ente import`** in Alfred.
-4. **Import Your Data**
- - To import your Ente secrets, simply trigger the workflow by running the command **Import Ente Secrets** in Alfred. This will import your stored Ente secrets into the workflow.
+3. **Search for an Ente Auth TOTP account**
+ - To list all of your Ente Auth TOTP accounts, run `ente` in Alfred.
+ - To search for a specific account, simply append a search string to the previous command.
+ Example: `ente GitHub`
+ - The search feature also supports loose search queries, matching words in the account name in any order.
+ - For example "Docker Hub" will match with the queries "Docker Hub", "Hub", "Do Hu".
---
diff --git a/build.py b/build.py
index adbe65e..21c35c9 100755
--- a/build.py
+++ b/build.py
@@ -8,28 +8,27 @@
import tomllib
+WORKFLOW_PLIST_PATH = "info.plist"
+
+
+def find_venv_py_version() -> str:
+ venv_python_version = os.listdir(os.path.join(os.getcwd(), ".venv", "lib"))[0]
+ return venv_python_version
+
def parse_info_plist():
- """Parse the info.plist file"""
- with open("info.plist", "rb") as f:
+ """Parse a plist file"""
+ with open(WORKFLOW_PLIST_PATH, "rb") as f:
plist = plistlib.load(f)
return plist
-def get_workflow_name():
+def get_workflow_name(plist):
"""Get the workflow name from parsed plist"""
- plist = parse_info_plist()
name = plist["name"].replace(" ", "_").lower()
return name
-def get_workflow_version():
- """Get the workflow version from parsed plist"""
- plist = parse_info_plist()
- version = plist["version"].replace(" ", "_").lower()
- return version
-
-
def get_pyproject_version():
"""Get the project version from pyproject.toml"""
with open("pyproject.toml", "rb") as f:
@@ -38,11 +37,25 @@ def get_pyproject_version():
return version
-def update_version(version: str, plist_path: str = "info.plist"):
- """Update the version in info.plist"""
- plist = parse_info_plist()
+def get_workflow_version(plist):
+ """Get the workflow version from parsed plist"""
+ version = plist["version"].replace(" ", "_").lower()
+ return version
+
+
+def update_workflow_version(plist, version: str):
+ """Update "version" string in parsed plist"""
plist["version"] = version
- with open(plist_path, "wb") as f:
+ with open(WORKFLOW_PLIST_PATH, "wb") as f:
+ plistlib.dump(plist, f)
+
+
+def update_workflow_pythonpath_var(plist, python_dirname: str):
+ """Update "PYTHONPATH" string variables dict in parsed plist"""
+ plist["variables"]["PYTHONPATH"] = os.path.join(
+ ".venv", "lib", python_dirname, "site-packages"
+ )
+ with open(WORKFLOW_PLIST_PATH, "wb") as f:
plistlib.dump(plist, f)
@@ -86,16 +99,24 @@ def should_include(path):
def main():
- workflow_name = get_workflow_name()
- workflow_version = get_workflow_version()
+ plist = parse_info_plist()
+
+ workflow_name = get_workflow_name(plist)
+ workflow_version = get_workflow_version(plist)
pyproject_version = get_pyproject_version()
init_venv()
+ venv_python_version = find_venv_py_version()
+ update_workflow_pythonpath_var(plist, venv_python_version)
+
if workflow_version != pyproject_version:
- update_version(pyproject_version)
+ update_workflow_version(plist, pyproject_version)
+ workflow_version = pyproject_version
else:
- print("Workflow version matches PyProject version. Should this be updated?")
+ print(
+ "\nWARNING: Workflow version matches PyProject version. Should this be updated?"
+ )
zip_name = f"{workflow_name}-{workflow_version}.alfredworkflow"
zip_workflow(zip_name)
diff --git a/info.plist b/info.plist
index e60546d..9fe8909 100644
--- a/info.plist
+++ b/info.plist
@@ -4,6 +4,8 @@
bundleid
com.chkpwd.ente.auth
+ category
+ Tools
connections
4C82069F-5857-4F72-9807-0A05DACC1F7C
@@ -101,6 +103,19 @@
+ E6427711-FED1-47CC-AA39-D59E50B504C7
+
+
+ destinationuid
+ 3101A957-02C3-4D0C-8B13-1C3721459261
+ modifiers
+ 0
+ modifiersubtext
+
+ vitoclose
+
+
+
ECD812F2-4D1E-41CA-B344-81B25B4A6357
@@ -240,15 +255,15 @@
config
alfredfiltersresults
-
+
alfredfiltersresultsmatchmode
- 0
+ 2
argumenttreatemptyqueryasnil
argumenttrimmode
0
argumenttype
- 0
+ 1
escaping
102
keyword
@@ -262,15 +277,15 @@
queuemode
2
runningsubtext
- Getting code for query: "{query}"...
+ Getting TOTP accounts...
script
- python3 main.py search "{query}"
+ python3 main.py get_accounts
scriptargtype
0
scriptfile
subtext
- What service do you need a code for?
+ Get Ente TOTP Codes
title
{const:alfred_workflow_name}
type
@@ -361,8 +376,6 @@
2
escaping
102
- keyword
- ente import
queuedelaycustom
10
queuedelayimmediatelyinitially
@@ -395,6 +408,27 @@
version
3
+
+ config
+
+ argumenttype
+ 2
+ keyword
+ ente import
+ subtext
+
+ text
+ Import Ente Secrets
+ withspace
+
+
+ type
+ alfred.workflow.input.keyword
+ uid
+ E6427711-FED1-47CC-AA39-D59E50B504C7
+ version
+ 1
+
config
@@ -416,109 +450,132 @@
readme
- # An Alfred Workflow that uses your Ente Exports
+ ## π Usage Instructions
+
+1. **Import Your Data**
+ - To import your Ente Auth TOTP accounts, simply trigger the workflow by running `ente import` in Alfred.
+
+2. **Search for an Ente Auth TOTP account**
+ - To list all of your Ente Auth TOTP accounts, run `ente` in Alfred.
+ - To search for a specific account, simply append a search string to the previous command.
+ Example: `ente GitHub`
+ - The search feature also supports loose search queries, matching words in the account name in any order.
+ - For example "Docker Hub" will match with the queries "Docker Hub", "Hub", "Do Hu".
+
+---
+
+## π Setup
-> [!WARNING]
-> This workflow exports secrets from the Ente Auth CLI. Please exercise caution when using it.
+### 1. Download and Install the Ente CLI
-## Setup
+To use the **Ente Auth** workflow, you'll need the **Ente CLI**. Follow the steps below to install it:
-1. Install workflow from releases
-2. Follow instructions below to create the database
+1. Visit the [Ente CLI releases page](https://github.com/ente-io/ente/releases?q=tag%3Acli-v0).
+2. Download the latest version for **macOS**.
+3. Move the binary to `/usr/local/bin` and make it executable with the following commands:
-## Instructions
+```bash
+sudo mv /path/to/ente /usr/local/bin/ente
+sudo chmod +x /usr/local/bin/ente
+```
-1. Open Alfred
-2. Go to Workflows.
-3. Click the "Ente Auth" workflow and click the Configure Workflow button.
-4. Next, configure the settings (NOTE: the export path is what you configured when adding your ente account).
-5. Finally, run the Alfred command `ente import`.
+Once installed, verify that it's working by running the following command in your terminal:
-## Local Development
+```bash
+ente version
+```
-### Install/Update dependencies
-poetry install --only=main
+### 2. Configure Ente CLI
-### Build alfred workflow file
-python3 build.py
+- Run `ente account add` to authenticate yourself with Ente CLI.
+- You'll first be prompted for the app type. Enter `auth`.
+- Next, you'll be asked for an export directory. You can choose any path you wish, but it must exist before you press return, else Ente CLI will not accept it.
+- Finally, you'll be prompted to provide your Ente login credentials.
uidata
046A5FD1-F7FF-430A-A92C-62D5AC3C5328
xpos
- 965.0
+ 1020.0
ypos
- 445.0
+ 260.0
3101A957-02C3-4D0C-8B13-1C3721459261
xpos
- 280.0
+ 315.0
ypos
- 650.0
+ 450.0
4C82069F-5857-4F72-9807-0A05DACC1F7C
xpos
- 540.0
+ 595.0
ypos
- 505.0
+ 320.0
4D86E603-A8EB-4EE2-BB90-12BECBE1D574
xpos
- 540.0
+ 595.0
ypos
- 380.0
+ 195.0
5B0C442D-7335-4973-A2EE-E58281C60266
xpos
- 965.0
+ 1020.0
ypos
- 710.0
+ 525.0
7F4B5FD3-5072-47C9-8129-E130EF0D2E59
xpos
- 960.0
+ 1015.0
ypos
- 305.0
+ 120.0
7F6F4EBD-9981-4B82-B039-7183EA9EEB15
xpos
- 540.0
+ 595.0
ypos
- 435.0
+ 250.0
A8D008AC-C61A-4037-A423-E33492A4CC62
xpos
- 40.0
+ 95.0
ypos
- 440.0
+ 255.0
C7B89588-34B4-49B4-9848-43975E80F16B
xpos
- 260.0
+ 315.0
+ ypos
+ 265.0
+
+ E6427711-FED1-47CC-AA39-D59E50B504C7
+
+ xpos
+ 95.0
ypos
450.0
ECD812F2-4D1E-41CA-B344-81B25B4A6357
xpos
- 540.0
+ 595.0
ypos
- 565.0
+ 380.0
F80F0C10-7220-4984-8EED-67425751FBC3
xpos
- 960.0
+ 1015.0
ypos
- 585.0
+ 400.0
userconfigurationconfig
@@ -569,7 +626,7 @@ python3 build.py
config
default
- ~/Documents/ente
+
filtermode
1
placeholder
@@ -578,7 +635,7 @@ python3 build.py
description
- Point this to the plain text export file from Ente Auth containing your 2FA data. It can be deleted after initial import.
+ Set this to the export path you configured when you set up Ente CLI. If you can't remember, run `ente account list` and refer to the `ExportDir` value.
label
Ente Export Directory
type
@@ -635,7 +692,7 @@ python3 build.py
description
-
+ Overwrite any existing Ente export files when you you import TOTP accounts into this workflow.
label
Overwrite current export
type
@@ -644,8 +701,11 @@ python3 build.py
OVERWRITE_EXPORT
- variablesdontexport
-
+ variables
+
+ PYTHONPATH
+ .venv/lib/python3.13/site-packages
+
version
2.1.0
webaddress
diff --git a/main.py b/main.py
index fc226e8..3d25ee9 100755
--- a/main.py
+++ b/main.py
@@ -1,45 +1,57 @@
-import json
import logging
import os
import sys
-from glob import glob
-# Add the venv directory to the path
-python_dirs = glob(os.path.join(os.path.dirname(__file__), ".venv/lib/python3.*"))
-if python_dirs:
- sys.path.append(os.path.join(python_dirs[0], "site-packages"))
-else:
- raise FileNotFoundError("Could not find python3.* directory in .venv/lib")
-
-from src.ente_auth import EnteAuth # noqa: E402, I001
-from src.store_keychain import ( # noqa: E402
+from src.constants import (
+ CACHE_ENV_VAR,
+ ICONS_FOLDER,
+)
+from src.ente_auth import EnteAuth
+from src.icon_downloader import download_icon
+from src.models import AlfredOutput
+from src.store_keychain import (
ente_export_to_keychain,
- import_accounts_from_keychain,
+ get_totp_accounts,
)
-from src.totp_accounts_manager import format_totp_result # noqa: E402
-from src.utils import ( # noqa: E402
+from src.totp_accounts_manager import format_totp_result
+from src.utils import (
fuzzy_search_accounts,
- sanitize_service_name,
output_alfred_message,
+ sanitize_service_name,
str_to_bool,
)
-from src.constants import ( # noqa: E402
- CACHE_ENV_VAR,
- ICONS_FOLDER,
-)
-
-from src.icon_downloader import get_icon # noqa: E402
-
-
logger = logging.getLogger(__name__)
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
+def get_accounts(search_string: str | None = None) -> AlfredOutput | None:
+ """
+ Load TOTP accounts from the environment variable or keychain, filtering by `search_string`.
+ Dumps the results to stdout in Alfred JSON format, adding all TotpAccounts for Alfred cache."""
+ try:
+ accounts = get_totp_accounts()
+ logger.info("Loaded TOTP accounts.")
+ except Exception as e:
+ logger.exception(f"Failed to load TOTP accounts: {e}", e)
+ output_alfred_message("Failed to load TOTP accounts", str(e))
+ else:
+ # Store all TOTP accounts for Alfred cache
+ alfred_cache = {CACHE_ENV_VAR: accounts.to_json()}
+
+ if search_string:
+ accounts = fuzzy_search_accounts(search_string, accounts)
+
+ # Format accounts/search results for Alfred, adding all accounts to the 'variables' key for Alfred cache.
+ fmt_result = format_totp_result(accounts)
+ fmt_result.variables = alfred_cache
+ return fmt_result
+
+
if __name__ == "__main__":
if len(sys.argv) < 2:
- raise ValueError("No subcommand found. Use one of: import, search")
+ raise ValueError("No subcommand found. Use one of: import, search, get_accounts")
elif sys.argv[1] == "import":
ente_export_dir = os.getenv("ENTE_EXPORT_DIR")
@@ -65,25 +77,21 @@
service_names_list: list[str] = []
result = ente_export_to_keychain(ente_export_path)
- variables = result.variables
- totp_accounts = json.loads(variables[CACHE_ENV_VAR])
-
- for k, _ in totp_accounts.items():
+ for k, _ in result.accounts.items():
try:
- get_icon(sanitize_service_name(k), ICONS_FOLDER)
+ download_icon(sanitize_service_name(k), ICONS_FOLDER)
except Exception as e:
logger.warning(f"Failed to download icon: {e}")
output_alfred_message(
"Imported TOTP data",
f"Successfully imported {result.count} TOTP accounts and downloaded icons.",
- variables=variables,
)
except Exception as e:
+ output_alfred_message("Failed to import TOTP data", str(e))
logger.exception(
f"Failed to populate TOTP data in keychain from file: {e}", e
)
- output_alfred_message("Failed to import TOTP data", str(e))
ente_auth.delete_ente_export(ente_export_path)
@@ -91,15 +99,15 @@
if len(sys.argv) < 3:
raise ValueError("No search string found")
- try:
- accounts = import_accounts_from_keychain()
- logger.info("Loaded TOTP accounts from keychain.")
- except Exception as e:
- logger.exception(f"Failed to load TOTP accounts from keychain: {e}", e)
- output_alfred_message("Failed to load TOTP accounts", str(e))
+ results = get_accounts(sys.argv[2])
+ if results:
+ results.print_json()
+ else:
+ output_alfred_message("No results found", "Try a different search term.")
+ elif sys.argv[1] == "get_accounts":
+ accounts = get_accounts()
+ if accounts:
+ accounts.print_json()
else:
- search_string = sys.argv[2]
- matched_accounts = fuzzy_search_accounts(search_string, accounts)
- formatted_account_data = format_totp_result(matched_accounts)
- formatted_account_data.print_json()
+ output_alfred_message("No TOTP accounts found", "Try importing some accounts.")
diff --git a/pyproject.toml b/pyproject.toml
index 1ee6893..f17ddef 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,7 +5,7 @@ authors = ["Bryan Jones "]
license = "MIT"
readme = "README.md"
package-mode = false
-version = "2.1.0"
+version = "2.2.0"
[tool.poetry.dependencies]
python = "^3.11"
diff --git a/src/constants.py b/src/constants.py
index 722cb33..3fa6ecd 100644
--- a/src/constants.py
+++ b/src/constants.py
@@ -9,3 +9,6 @@
CACHE_ENV_VAR = "TOTP_CACHE"
ICONS_FOLDER = Path(environ["alfred_workflow_data"]) / "service_icons"
+
+ENTE_ICONS_DATABASE_URL = "https://raw.githubusercontent.com/ente-io/ente/refs/heads/main/auth/assets/custom-icons/_data/custom-icons.json"
+ENTE_CUSTOM_ICONS_URL = "https://raw.githubusercontent.com/ente-io/ente/refs/heads/main/auth/assets/custom-icons/icons/"
diff --git a/src/ente_auth.py b/src/ente_auth.py
index 0f6f836..58f8b34 100644
--- a/src/ente_auth.py
+++ b/src/ente_auth.py
@@ -16,6 +16,7 @@ def __init__(self):
self.ente_auth_binary_path = self._find_ente_path()
def _find_ente_path(self) -> str:
+ """Returns the path to the ente binary if it can be found."""
result = subprocess.run(
["which", "ente"], stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
@@ -24,12 +25,17 @@ def _find_ente_path(self) -> str:
return result.stdout.decode("utf-8").strip()
- def create_ente_path(self, path: str) -> None:
+ def create_ente_export_dir(self, path: str) -> None:
if not os.path.exists(path):
os.makedirs(path)
logger.info(f"Ente folder created at: {path}")
def export_ente_auth_accounts(self, export_path: str, overwrite: bool) -> None:
+ """
+ Execute Ente export command.
+
+ Handles removing an old export file if it exists and overwrite is True, and creating the export directory if it doesn't exist.
+ """
path_exists = os.path.exists(export_path)
if path_exists and overwrite:
@@ -53,7 +59,7 @@ def export_ente_auth_accounts(self, export_path: str, overwrite: bool) -> None:
if "error: path does not exist" in result.stderr.decode("utf-8"):
export_dir = os.path.dirname(export_path)
logger.info(f"Export directory does not exist. Creating: {export_dir}")
- self.create_ente_path(export_dir)
+ self.create_ente_export_dir(export_dir)
logger.info("Retrying export...")
self.export_ente_auth_accounts(export_path, overwrite)
@@ -75,10 +81,11 @@ def delete_ente_export(self, export_path: str) -> None:
raise e
@staticmethod
- def check_ente_binary(path) -> bool:
+ def check_ente_binary(path: str) -> bool:
+ """Check if the ente binary exists and is executable."""
try:
subprocess.run(
- [f"{path}", "version"],
+ [path, "version"],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
diff --git a/src/icon_downloader.py b/src/icon_downloader.py
index 5054c62..dc81c14 100644
--- a/src/icon_downloader.py
+++ b/src/icon_downloader.py
@@ -5,15 +5,17 @@
import requests
from simplepycons import all_icons
-logger = logging.getLogger(__name__)
+from src.constants import ENTE_CUSTOM_ICONS_URL, ENTE_ICONS_DATABASE_URL
+logger = logging.getLogger(__name__)
-def get_ente_custom_icon(name: str):
- icons_database_url = "https://raw.githubusercontent.com/ente-io/ente/refs/heads/main/auth/assets/custom-icons/_data/custom-icons.json"
- ente_custom_icons_db = "https://raw.githubusercontent.com/ente-io/ente/refs/heads/main/auth/assets/custom-icons/icons/"
+def search_ente_custom_icons(name: str) -> str | None:
+ """
+ Searches Ente custom icons on GitHub for the provided name and returns the SVG content if found.
+ """
try:
- response = requests.get(icons_database_url)
+ response = requests.get(ENTE_ICONS_DATABASE_URL)
if response.status_code == 200:
ente_custom_icons = response.json()
@@ -27,42 +29,38 @@ def get_ente_custom_icon(name: str):
*[name.lower() for name in icon.get("altNames", [])],
]
]
- # matching_icon: next(icon["slug"] for icon in iter(ente_custom_icons) if name.lower() in )
if matching_icon:
response = requests.get(
- urljoin(ente_custom_icons_db, f"{matching_icon[0]}.svg")
+ urljoin(ENTE_CUSTOM_ICONS_URL, f"{matching_icon[0]}.svg")
)
response.raise_for_status()
return response.text
+ else:
+ logger.debug(f"Icon for '{name}' not found in Ente custom icons.")
else:
logger.error(f"Failed to fetch custom icons: {response.status_code}")
except requests.RequestException as e:
logger.error(f"Error while fetching custom icons: {e}")
-def get_simplepycons_icon(name: str):
- """
- Gets an icon for a given service name.
-
- Returns:
- str: Path to the icon, or the default icon if retrieving the object fails.
- """
-
+def search_simple_icons(name: str) -> str | None:
+ """Searches Simple Icons for the provided name and returns the SVG content if found."""
try:
icon = all_icons[name] # type: ignore
except KeyError:
- logger.warning(f"Icon for '{name}' not found in Simplepycons.")
+ logger.debug(f"Icon for '{name}' not found in Simple Icons.")
else:
icon = icon.customize_svg_as_str(fill=icon.primary_color)
return str(icon)
-def get_icon(service: str, icons_dir: Path):
+def download_icon(service: str, icons_dir: Path) -> None:
+ """Downloads the icon for the given service and saves it to the provided icons directory if found."""
icons_dir.mkdir(parents=True, exist_ok=True)
icon_path = icons_dir / f"{service}.svg"
- ente_custom_icon_url = get_ente_custom_icon(service)
- simplepycons_icon = get_simplepycons_icon(service)
+ ente_custom_icon_url = search_ente_custom_icons(service)
+ simplepycons_icon = search_simple_icons(service)
icon = (
ente_custom_icon_url
@@ -76,3 +74,5 @@ def get_icon(service: str, icons_dir: Path):
with open(icon_path, mode="w") as icon_file:
icon_file.write(icon)
logger.debug(f"Icon imported successfully for {service} at {icon_path}")
+ else:
+ logger.warning(f"Could not find an icon for {service}")
diff --git a/src/models.py b/src/models.py
index 2517858..fdbbcd7 100644
--- a/src/models.py
+++ b/src/models.py
@@ -1,6 +1,6 @@
import json
import sys
-from dataclasses import asdict, dataclass
+from dataclasses import asdict, dataclass, field
from typing import Any
from src.constants import ICONS_FOLDER
@@ -9,6 +9,11 @@
# https://www.alfredapp.com/help/workflows/inputs/script-filter/json
@dataclass
class AlfredOutputItemIcon:
+ """
+ Class to represent a custom icon for an AlfredOutputItem object.
+
+ See https://www.alfredapp.com/help/workflows/inputs/script-filter/json
+ """
path: str = "icon.png"
type: str | None = None
@@ -34,9 +39,15 @@ def to_dict(self):
@dataclass
class AlfredOutputItem:
+ """
+ Class to represent an item in an AlfredOutput object.
+
+ See https://www.alfredapp.com/help/workflows/inputs/script-filter/json
+ """
title: str
uid: str | None = None
subtitle: str | None = None
+ match: str | None = None
arg: str | list[str] | None = None
icon: AlfredOutputItemIcon | None = None
variables: dict[str, Any] | None = None
@@ -55,10 +66,22 @@ def to_dict(self):
@dataclass
class AlfredOutput:
+ """
+ Class to represent structured output to an Alfred session.
+
+ See https://www.alfredapp.com/help/workflows/inputs/script-filter/json
+ """
items: list[AlfredOutputItem]
+ rerun: float | None = None
+ variables: dict[str, Any] = field(default_factory=dict)
def to_dict(self):
- return {"items": [item.to_dict() for item in self.items]}
+ return {k: v for k, v in {
+ "items": [item.to_dict() for item in self.items],
+ "rerun": self.rerun,
+ "variables": self.variables,
+ }.items() if v}
+
def to_json(self):
return json.dumps(self.to_dict(), separators=(",", ":"))
@@ -67,19 +90,16 @@ def print_json(self):
sys.stdout.write(self.to_json())
-@dataclass
-class ImportResult:
- count: int
- variables: dict[str, str]
-
-
@dataclass
class TotpAccount:
+ """Class to represent a TOTP account imported from Ente or stored locally."""
username: str
secret: str
+ period: int = 30
class TotpAccounts(dict[str, TotpAccount]):
+ """Class to represent a collection of TOTP accounts."""
def to_json(self) -> str:
json_data = {k: asdict(v) for k, v in self.items()}
return json.dumps(json_data, separators=(",", ":"))
@@ -87,3 +107,10 @@ def to_json(self) -> str:
def from_json(self, json_str: str) -> "TotpAccounts":
data = json.loads(json_str)
return TotpAccounts({k: TotpAccount(**v) for k, v in data.items()})
+
+
+@dataclass
+class ImportResult:
+ """Class to represent the result of a parsed Ente export."""
+ count: int
+ accounts: TotpAccounts
diff --git a/src/store_keychain.py b/src/store_keychain.py
index 0b72d85..285855a 100644
--- a/src/store_keychain.py
+++ b/src/store_keychain.py
@@ -1,29 +1,22 @@
-import json
import logging
import os
import keyring
-from src.models import AlfredOutput, AlfredOutputItem, ImportResult, TotpAccounts
+from src.constants import CACHE_ENV_VAR, KEYCHAIN_ACCOUNT, KEYCHAIN_SERVICE
+from src.models import ImportResult, TotpAccounts
from src.totp_accounts_manager import parse_ente_export
+from src.utils import output_alfred_message
logger = logging.getLogger(__name__)
-# Keychain service and account for storing the TOTP accounts
-KEYCHAIN_SERVICE = "ente-totp-alfred-workflow"
-KEYCHAIN_ACCOUNT = "totp_secrets"
-
-# Use an environment variable to cache the JSON data to reduce keychain calls
-CACHE_ENV_VAR = "TOTP_CACHE"
-
-
-def import_accounts_from_keychain() -> TotpAccounts:
- """Load TOTP accounts from the environment variable or keychain."""
+def get_totp_accounts() -> TotpAccounts:
+ """Load TOTP accounts from Alfred session cache or keychain."""
cached_accounts = os.getenv(CACHE_ENV_VAR)
if cached_accounts:
logger.info("Loading TOTP accounts from environment variable cache.")
- return json.loads(cached_accounts)
+ return TotpAccounts().from_json(cached_accounts)
# If not cached, load from the keychain
logger.info("Loading TOTP accounts from keychain.")
@@ -41,50 +34,28 @@ def import_accounts_from_keychain() -> TotpAccounts:
def ente_export_to_keychain(file: str) -> ImportResult:
"""Import TOTP accounts from an Ente export file and store them in the keychain."""
- result = ImportResult(0, {})
-
try:
logger.debug(f"import_file: {file}")
accounts = parse_ente_export(file)
accounts_json = accounts.to_json()
- if accounts:
- keyring.set_password(
- service_name=KEYCHAIN_SERVICE,
- username=KEYCHAIN_ACCOUNT,
- password=accounts_json,
- )
+ keyring.set_password(
+ service_name=KEYCHAIN_SERVICE,
+ username=KEYCHAIN_ACCOUNT,
+ password=accounts_json,
+ )
secrets_imported_count = sum(len(k) for k in accounts.items())
logger.info(f"Keychain database created with {secrets_imported_count} entries.")
- result.count = secrets_imported_count
- result.variables = {CACHE_ENV_VAR: accounts_json}
-
- except FileNotFoundError:
- error_message = f"File not found: {file}"
- logger.error(error_message)
- AlfredOutput(
- [
- AlfredOutputItem(
- title="Import Failed",
- subtitle=f"File not found: {file}",
- )
- ]
- ).print_json()
+ return ImportResult(secrets_imported_count, accounts)
+
+ except FileNotFoundError as e:
+ output_alfred_message("Import Failed", f"File not found: {file}")
+ raise e
except Exception as e:
- error_message = f"An error occurred: {str(e)}"
- logger.exception(error_message, e)
- AlfredOutput(
- [
- AlfredOutputItem(
- title="Import Failed",
- subtitle=error_message,
- )
- ]
- ).print_json()
-
- return result
+ output_alfred_message("Import Failed", f"An error occurred during Ente export: {str(e)}")
+ raise e
diff --git a/src/totp_accounts_manager.py b/src/totp_accounts_manager.py
index 1953e00..75c0adc 100644
--- a/src/totp_accounts_manager.py
+++ b/src/totp_accounts_manager.py
@@ -12,12 +12,13 @@
TotpAccount,
TotpAccounts,
)
-from src.utils import calculate_time_remaining, str_to_bool
+from src.utils import calculate_time_remaining, sanitize_service_name, str_to_bool
logger = logging.getLogger(__name__)
def parse_ente_export(file_path: str) -> TotpAccounts:
+ """Parses an Ente export file of otpauth URIs and returns a TotpAccounts object."""
accounts = TotpAccounts()
with open(file_path, "r") as ente_export_file:
@@ -40,19 +41,30 @@ def parse_ente_export(file_path: str) -> TotpAccounts:
query_params = parse_qs(parsed_uri.query)
secret = query_params.get("secret", [None])[0]
+ period = query_params.get("period", [None])[0]
if not secret:
- raise ValueError(
+ raise KeyError(
f"Unable to parse 'secret' parameter in: {line}"
)
- accounts[service_name] = TotpAccount(username, secret)
+ if not period or period == "null":
+ logger.warning(f"Unable to parse 'period' parameter for '{service_name} - {username}'. Will use default of 30 seconds.")
+ accounts[service_name] = TotpAccount(username, secret)
+ else:
+ try:
+ period = int(period)
+ except ValueError:
+ logger.warning(f"Value of 'period' parameter ('{period}') for '{service_name} - {username}' could not be cast to int. Will use default of 30 seconds.")
+ period = 30
+ accounts[service_name] = TotpAccount(username, secret, period)
+
return accounts
def format_totp_result(accounts: TotpAccounts) -> AlfredOutput:
- """Format TOTP accounts for Alfred."""
- result = AlfredOutput([])
+ """Transforms a TotpAccounts object into an AlfredOutput object."""
+ result = AlfredOutput([], rerun=1)
username_in_title = str_to_bool(os.getenv("USERNAME_IN_TITLE", "False"))
username_in_subtitle = str_to_bool(os.getenv("USERNAME_IN_SUBTITLE", "False"))
@@ -62,13 +74,13 @@ def format_totp_result(accounts: TotpAccounts) -> AlfredOutput:
# Generate TOTP
totp = pyotp.TOTP(service_data.secret)
current_totp = totp.now()
- next_totp = totp.at(datetime.now() + timedelta(seconds=30))
+ next_totp = totp.at(datetime.now() + timedelta(seconds=service_data.period))
# Calculate remaining time using the utility
- time_remaining = calculate_time_remaining()
+ time_remaining = calculate_time_remaining(service_data.period)
# Sanitize service name for display and icons
- sanitized_service_name = service_name.strip()
+ sanitized_service_name = sanitize_service_name(service_name)
# Update title and subtitle
title = (
@@ -90,6 +102,7 @@ def format_totp_result(accounts: TotpAccounts) -> AlfredOutput:
title=title,
subtitle=subtitle,
arg=current_totp,
+ match=service_name,
icon=AlfredOutputItemIcon.from_service(sanitized_service_name),
)
)
diff --git a/src/utils.py b/src/utils.py
index a05674e..398e5e6 100644
--- a/src/utils.py
+++ b/src/utils.py
@@ -3,11 +3,11 @@
from src.models import AlfredOutput, AlfredOutputItem, TotpAccounts
-def sanitize_service_name(service_name):
+def sanitize_service_name(service_name: str) -> str:
return service_name.split("-")[0].strip().replace(" ", "").lower()
-def str_to_bool(val):
+def str_to_bool(val: str | bool) -> bool:
if isinstance(val, str):
val = val.lower()
if val in (True, "true", "1", 1):
@@ -18,14 +18,18 @@ def str_to_bool(val):
raise ValueError(msg)
-def calculate_time_remaining(time_step=30):
- # Calculate the time remaining until the next TOTP period.
-
+def calculate_time_remaining(time_step: int) -> int:
+ """
+ Calculate the seconds remaining until the next multiple of the given time step.
+ """
current_time = datetime.now().timestamp()
return int(time_step - (current_time % time_step))
def fuzzy_search_accounts(search_string: str, values: TotpAccounts) -> TotpAccounts:
+ """
+ Fuzzy search given TOTP accounts, matching on service name and username, and return the matched accounts.
+ """
matches: list[tuple[float, str]] = []
# Split the search_string by spaces for more granular search
@@ -37,7 +41,7 @@ def fuzzy_search_accounts(search_string: str, values: TotpAccounts) -> TotpAccou
username_lower = service_info.username.lower()
# Define match scores for prioritization
- score = 0
+ score: float = 0
if all(part in service_name_lower for part in search_parts):
score += 3 # Full match in service name
if all(part in username_lower for part in search_parts):
@@ -60,7 +64,8 @@ def fuzzy_search_accounts(search_string: str, values: TotpAccounts) -> TotpAccou
return matched_accounts
-def output_alfred_message(title: str, subtitle: str, variables: dict | None = None):
+def output_alfred_message(title: str, subtitle: str, variables: dict | None = None) -> None:
+ """Helper function to print a simple message in Alfred JSON format."""
AlfredOutput(
[AlfredOutputItem(title=title, subtitle=subtitle, variables=variables)]
).print_json()