#!/usr/bin/env python3 """Regenerate crates/webui/src/icons.js — the bundled service-icon pack. The web UI is fully offline, so the icons are fetched here, once, and committed as a generated JavaScript file that the binary embeds. Two sources: * Simple Icons (CC0-1.0) — the bulk of the pack, with brand colours * Font Awesome Free brands (CC BY 4.0) — a handful of brands that Simple Icons no longer carries (Amazon, AWS, Microsoft, LinkedIn, Slack, ...) Brands remain trademarks of their owners; the pack exists so the account list can show a recognisable mark instead of an initial. Run with no arguments: python3 scripts/fetch-icons.py """ import json import re import sys import urllib.request from concurrent.futures import ThreadPoolExecutor from pathlib import Path SI_VERSION = "16.30.0" FA_VERSION = "6.7.2" SI_CDN = f"https://cdn.jsdelivr.net/npm/simple-icons@{SI_VERSION}" FA_CDN = f"https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@{FA_VERSION}/svgs/brands" OUT = Path(__file__).resolve().parent.parent / "crates/webui/src/icons.js" # Simple Icons slugs. Roughly the services people actually keep in an # authenticator, weighted towards the homelab / developer crowd. SIMPLE = """ github google apple facebook meta instagram x discord telegram dropbox box cloudflare digitalocean netlify vercel gitlab bitbucket atlassian jira trello notion evernote zoom twitch reddit pinterest snapchat tiktok steam epicgames playstation ubisoft ea battledotnet riotgames paypal stripe coinbase binance robinhood venmo wise revolut shopify wordpress squarespace wix godaddy namecheap porkbun mailchimp hubspot zendesk okta auth0 1password bitwarden lastpass dashlane keeper nordvpn protonmail proton protonvpn tuta zoho mega backblaze synology unraid plex nextcloud homeassistant tailscale wireguard anthropic claude huggingface docker npm pypi rubygems kubernetes akamai vultr hetzner ovh figma spotify netflix uber airbnb ebay etsy patreon kickstarter substack medium tumblr mastodon bluesky threads whatsapp signal wikipedia ubiquiti nvidia intel amd samsung sony cisco mailgun icloud gmail yubico duolingo coursera udemy ghost hostinger render railway flydotio supabase firebase mongodb redis elastic datadog newrelic sentry grafana pagerduty zapier ifttt airtable asana clickup basecamp linear todoist miro dribbble behance vimeo youtube cashapp americanexpress kucoin trezor opensea humblebundle rockstargames squareenix roblox gogdotcom googledrive googlecloud googleplay intuit quickbooks xero square chase bankofamerica wellsfargo """.split() # Font Awesome brand icons filling Simple Icons' gaps: (fa name, title, colour). FONT_AWESOME = [ ("amazon", "Amazon", "#FF9900"), ("aws", "AWS", "#FF9900"), ("microsoft", "Microsoft", "#737373"), ("linkedin", "LinkedIn", "#0A66C2"), ("slack", "Slack", "#4A154B"), ("xbox", "Xbox", "#107C10"), ("salesforce", "Salesforce", "#00A1E0"), ("yahoo", "Yahoo", "#6001D2"), ] # Extra ways people spell an issuer → slug. Keys are matched after # normalisation (lower-case, non-alphanumerics dropped), so "Battle.net", # "battle net" and "BattleNet" all collapse to "battlenet". ALIASES = { # Google "gmail": "gmail", "googleworkspace": "google", "googleaccount": "google", "gsuite": "google", "googlecloudplatform": "googlecloud", "gcp": "googlecloud", # Microsoft "microsoft365": "microsoft", "office365": "microsoft", "office": "microsoft", "outlook": "microsoft", "outlookcom": "microsoft", "hotmail": "microsoft", "hotmailcom": "microsoft", "live": "microsoft", "livecom": "microsoft", "azure": "microsoft", "microsoftazure": "microsoft", "entra": "microsoft", "onedrive": "microsoft", "xboxlive": "xbox", "microsoftaccount": "microsoft", # Amazon "amazonwebservices": "aws", "amazoncom": "amazon", "amazonaws": "aws", "awsiam": "aws", "amazonprime": "amazon", "primevideo": "amazon", # Apple "appleid": "apple", "icloudcom": "icloud", "mecom": "icloud", # Meta "fb": "facebook", "ig": "instagram", "twitter": "x", "xcom": "x", "xtwitter": "x", "twittercom": "x", "whatsappbusiness": "whatsapp", # Gaming "blizzard": "battledotnet", "battlenet": "battledotnet", "battle": "battledotnet", "epic": "epicgames", "epicgamesstore": "epicgames", "riot": "riotgames", "leagueoflegends": "riotgames", "valorant": "riotgames", "electronicarts": "ea", "eaaccount": "ea", "origin": "ea", "psn": "playstation", "playstationnetwork": "playstation", "sonyplaystation": "playstation", "valve": "steam", "steampowered": "steam", "gog": "gogdotcom", "gogcom": "gogdotcom", "rockstar": "rockstargames", "socialclub": "rockstargames", "humble": "humblebundle", "ubisoftconnect": "ubisoft", "uplay": "ubisoft", # Dev / infra "githubcom": "github", "gitlabcom": "gitlab", "dockerhub": "docker", "npmjs": "npm", "npmjscom": "npm", "pypiorg": "pypi", "rubygemsorg": "rubygems", "k8s": "kubernetes", "do": "digitalocean", "digitalocean": "digitalocean", "flyio": "flydotio", "fly": "flydotio", "ovhcloud": "ovh", "hetznercloud": "hetzner", "hetzneronline": "hetzner", "linode": "akamai", "akamailinode": "akamai", "cloudflareaccess": "cloudflare", "cfaccess": "cloudflare", "huggingfaceco": "huggingface", "supabasecom": "supabase", "mongodbatlas": "mongodb", "atlas": "mongodb", "elasticcloud": "elastic", "elasticsearch": "elastic", "grafanacloud": "grafana", "sentryio": "sentry", "confluence": "atlassian", "atlassianaccount": "atlassian", "jirasoftware": "jira", "bitbucketorg": "bitbucket", "pagerdutycom": "pagerduty", "wpcom": "wordpress", "wordpresscom": "wordpress", "wordpressorg": "wordpress", "namecheapcom": "namecheap", "godaddycom": "godaddy", "porkbuncom": "porkbun", "netlifycom": "netlify", "vercelcom": "vercel", "renderc": "render", "railwayapp": "railway", # Homelab "hass": "homeassistant", "homeassistantio": "homeassistant", "nabucasa": "homeassistant", "unifi": "ubiquiti", "ui": "ubiquiti", "unificonsole": "ubiquiti", "plextv": "plex", "synologyaccount": "synology", "synologydsm": "synology", "dsm": "synology", "nextcloudcom": "nextcloud", "unraidnet": "unraid", "limetech": "unraid", "tailscalecom": "tailscale", # Security "protonme": "proton", "protonmailcom": "protonmail", "protonmail": "protonmail", "protonpass": "proton", "protondrive": "proton", "tutanota": "tuta", "tutanotacom": "tuta", "tutaio": "tuta", "1passwordcom": "1password", "onepassword": "1password", "agilebits": "1password", "bitwardencom": "bitwarden", "vaultwarden": "bitwarden", "lastpasscom": "lastpass", "keepersecurity": "keeper", "nord": "nordvpn", "nordaccount": "nordvpn", "yubikey": "yubico", "oktacom": "okta", "auth0com": "auth0", # Money "coinbasecom": "coinbase", "coinbasepro": "coinbase", "binancecom": "binance", "binanceus": "binance", "paypalcom": "paypal", "stripecom": "stripe", "transferwise": "wise", "wisecom": "wise", "revolutcom": "revolut", "amex": "americanexpress", "americanexpresscom": "americanexpress", "jpmorganchase": "chase", "chasecom": "chase", "bofa": "bankofamerica", "wellsfargocom": "wellsfargo", "cashapp": "cashapp", "squareup": "square", "quickbooksonline": "quickbooks", "turbotax": "intuit", "mint": "intuit", # Comms / social "slackcom": "slack", "discordcom": "discord", "discordapp": "discord", "zoomus": "zoom", "telegramorg": "telegram", "linkedincom": "linkedin", "redditcom": "reddit", "twitchtv": "twitch", "tiktokcom": "tiktok", "snap": "snapchat", "bsky": "bluesky", "blueskysocial": "bluesky", "mastodonsocial": "mastodon", "signalorg": "signal", "wikimedia": "wikipedia", "wikimediafoundation": "wikipedia", # Mail hosts (matched from the label's domain when the issuer is unknown) "yahoocom": "yahoo", "ymail": "yahoo", "ymailcom": "yahoo", "aol": "yahoo", "zohomail": "zoho", "zohocom": "zoho", "megaio": "mega", "meganz": "mega", "backblazecom": "backblaze", "b2": "backblaze", # Misc "dropboxcom": "dropbox", "boxcom": "box", "notionso": "notion", "evernotecom": "evernote", "figmacom": "figma", "spotifycom": "spotify", "netflixcom": "netflix", "ubercom": "uber", "airbnbcom": "airbnb", "ebaycom": "ebay", "etsycom": "etsy", "patreoncom": "patreon", "substackcom": "substack", "mediumcom": "medium", "tumblrcom": "tumblr", "shopifycom": "shopify", "squarespacecom": "squarespace", "wixcom": "wix", "mailchimpcom": "mailchimp", "hubspotcom": "hubspot", "zendeskcom": "zendesk", "duolingocom": "duolingo", "courseraorg": "coursera", "udemycom": "udemy", "ghostorg": "ghost", "hostingercom": "hostinger", "airtablecom": "airtable", "asanacom": "asana", "clickupcom": "clickup", "basecampcom": "basecamp", "linearapp": "linear", "todoistcom": "todoist", "mirocom": "miro", "dribbblecom": "dribbble", "behancenet": "behance", "vimeocom": "vimeo", "youtubecom": "youtube", "yt": "youtube", "openseaio": "opensea", "trezorio": "trezor", "kucoincom": "kucoin", "salesforcecom": "salesforce", "sfdc": "salesforce", "nvidiacom": "nvidia", "geforce": "nvidia", "geforcenow": "nvidia", "samsungaccount": "samsung", "sonyaccount": "sony", "ciscocom": "cisco", "meraki": "cisco", "intelcom": "intel", "amdcom": "amd", "squareenixaccount": "squareenix", "robloxcom": "roblox", "zapiercom": "zapier", "iftttcom": "ifttt", "vultrcom": "vultr", "datadoghq": "datadog", "newreliccom": "newrelic", "clickup": "clickup", "anthropiccom": "anthropic", "claudeai": "claude", } def norm(s: str) -> str: return re.sub(r"[^a-z0-9]+", "", s.lower().replace("&", "and")) def fetch(url: str) -> str: req = urllib.request.Request(url, headers={"User-Agent": "openmfa-fetch-icons/1.0"}) with urllib.request.urlopen(req, timeout=30) as r: return r.read().decode("utf-8") def parse_svg(svg: str): vb = re.search(r'viewBox="([^"]+)"', svg) paths = re.findall(r']*\sd="([^"]+)"', svg) if not vb or not paths: raise ValueError("no viewBox/path") return vb.group(1), paths def main() -> int: index = json.loads(fetch(f"{SI_CDN}/data/simple-icons.json")) by_slug = {i["slug"]: i for i in (index["icons"] if isinstance(index, dict) else index)} missing = [s for s in SIMPLE if s not in by_slug] if missing: print("not in simple-icons:", " ".join(missing), file=sys.stderr) return 1 jobs = [(s, "si") for s in SIMPLE] + [(name, "fa") for name, _, _ in FONT_AWESOME] def load(job): name, src = job url = f"{SI_CDN}/icons/{name}.svg" if src == "si" else f"{FA_CDN}/{name}.svg" return job, parse_svg(fetch(url)) icons = {} aliases = {} with ThreadPoolExecutor(max_workers=12) as pool: for (name, src), (vb, paths) in pool.map(load, jobs): if src == "si": meta = by_slug[name] title, hexcol = meta["title"], "#" + meta["hex"] for aka in (meta.get("aliases") or {}).get("aka", []): aliases.setdefault(norm(aka), name) else: title, hexcol = next((t, c) for n, t, c in FONT_AWESOME if n == name) icons[name] = [title, vb, paths, hexcol] aliases.setdefault(norm(title), name) for k, v in ALIASES.items(): if v not in icons: print(f"alias {k!r} points at unknown icon {v!r}", file=sys.stderr) return 1 aliases[norm(k)] = v # A key that is itself an icon needs no alias entry. aliases = {k: v for k, v in sorted(aliases.items()) if k not in icons and k} body = ( "// GENERATED by scripts/fetch-icons.py — do not edit by hand.\n" f"// Simple Icons {SI_VERSION} (CC0-1.0) and Font Awesome Free {FA_VERSION} brands\n" "// (CC BY 4.0). Brand marks are trademarks of their respective owners.\n" "// Shape: slug -> [title, viewBox, [path d, ...], brand colour].\n" "window.OPENMFA_ICONS = " + json.dumps(dict(sorted(icons.items())), separators=(",", ":"), ensure_ascii=False) + ";\n" "window.OPENMFA_ICON_ALIASES = " + json.dumps(aliases, separators=(",", ":"), ensure_ascii=False) + ";\n" ) OUT.write_text(body) print(f"wrote {OUT} — {len(icons)} icons, {len(aliases)} aliases, {len(body)//1024} KiB") return 0 if __name__ == "__main__": sys.exit(main())