diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index a60ec99..dfc69db 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -39,6 +39,11 @@ jobs: distribution: temurin java-version: "25" + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Install native build dependencies shell: bash run: | @@ -47,6 +52,7 @@ jobs: apt-get update apt-get install -y --no-install-recommends \ zsh \ + python3 \ pkg-config \ libx11-dev \ libx11-xcb-dev \ @@ -58,6 +64,12 @@ jobs: libxcursor-dev \ libxfixes-dev + - name: Install web-ext + shell: bash + run: | + set -euo pipefail + npm install -g web-ext + - name: Lint shell: bash run: | @@ -74,6 +86,12 @@ jobs: trap 'rm -rf -- "$state_dir"' EXIT KEEPASSGO_STATE_DIR="$state_dir" go test -tags nox11,nowayland,novulkan ./... + - name: Firefox extension lint + shell: bash + run: | + set -euo pipefail + make browser-extension-firefox-lint + build: needs: lint-test runs-on: keepassgo-android @@ -92,6 +110,11 @@ jobs: distribution: temurin java-version: "25" + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Install native build dependencies shell: bash run: | @@ -100,6 +123,7 @@ jobs: apt-get update apt-get install -y --no-install-recommends \ zsh \ + python3 \ pkg-config \ libx11-dev \ libx11-xcb-dev \ @@ -111,6 +135,12 @@ jobs: libxcursor-dev \ libxfixes-dev + - name: Install web-ext + shell: bash + run: | + set -euo pipefail + npm install -g web-ext + - name: Prepare dist directory shell: bash run: | @@ -159,6 +189,13 @@ jobs: make apk-release RELEASE_SIGNKEY="$signkey_path" RELEASE_SIGNPASS_FILE="$signpass_path" cp build/keepassgo.apk "${DIST_DIR}/keepassgo.apk" + - name: Build Firefox extension + shell: bash + run: | + set -euo pipefail + make browser-extension-firefox-build + cp build/browser-extension/*.zip "${DIST_DIR}/" + - name: Upload CI artifacts uses: christopherhx/gitea-upload-artifact@v4 env: @@ -171,6 +208,7 @@ jobs: dist/keepassgo-windows-amd64.exe dist/keepassgo-windows-arm64.exe dist/keepassgo.apk + dist/*.zip retention-days: 30 - name: Publish release artifacts @@ -193,4 +231,5 @@ jobs: "${DIST_DIR}/keepassgo-linux-amd64" \ "${DIST_DIR}/keepassgo-windows-amd64.exe" \ "${DIST_DIR}/keepassgo-windows-arm64.exe" \ - "${DIST_DIR}/keepassgo.apk" + "${DIST_DIR}/keepassgo.apk" \ + "${DIST_DIR}"/*.zip diff --git a/AGENTS.md b/AGENTS.md index 17557af..b064fbf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -136,6 +136,10 @@ These features are product requirements, not “nice to have” ideas. ## Delivery Discipline - Treat bug fixes as the highest-priority items in `TODO.md`. +- Do not start a new feature while unrelated tracked or untracked local changes + remain in the repo. +- If previous work leaves unrelated uncommitted changes behind, stop and ask + the user before continuing with the next feature. - Do not treat this product as complete until the stated requirements in this file are actually satisfied. - Do not stop at a “good checkpoint” or “meaningful tranche” when required product capabilities are still missing. - Continue iterating in test-first slices: diff --git a/Makefile b/Makefile index 2c29c83..3bfe151 100644 --- a/Makefile +++ b/Makefile @@ -20,6 +20,9 @@ ARCH_PKG_TMPL ?= $(ARCH_PKG_DIR)/PKGBUILD.tmpl ARCH_PKGBUILD ?= $(ARCH_PKG_DIR)/PKGBUILD ARCH_PKGVER ?= $(shell printf 'r%s.%s' "$$(git rev-list --count HEAD 2>/dev/null || echo 0)" "$$(git rev-parse --short HEAD 2>/dev/null || echo dev)") ARCH_REPO_DIR ?= $(CURDIR) +WEB_EXT ?= web-ext +FIREFOX_EXTENSION_DIR ?= build/firefox-extension +FIREFOX_EXTENSION_ARTIFACT_DIR ?= build/browser-extension GOGIO_SIGN_FLAGS := ifneq ($(strip $(SIGNKEY)),) @@ -44,7 +47,7 @@ CONTAINER_SIGNPASSFILE_MOUNT += -v "$(dir $(abspath $(SIGNPASS_FILE))):$(dir $(a CONTAINER_SIGN_ARGS += SIGNPASS_FILE="$(abspath $(SIGNPASS_FILE))" endif -.PHONY: apk apk-local apk-release apk-container apk-container-image archlinux-pkgbuild browser-bridge browser-extension-validate +.PHONY: apk apk-local apk-release apk-container apk-container-image archlinux-pkgbuild browser-bridge browser-extension-validate browser-extension-firefox-dir browser-extension-firefox-lint browser-extension-firefox-build browser-extension-firefox-run browser-extension-firefox-sign apk: @if [ -x "$(JAVA_HOME)/bin/java" ] && "$(JAVA_HOME)/bin/java" -version 2>&1 | grep -q 'version "25'; then \ $(MAKE) apk-local JAVA_HOME="$(JAVA_HOME)"; \ @@ -132,6 +135,23 @@ archlinux-pkgbuild: $(ARCH_PKG_TMPL) Makefile browser-bridge: go build ./cmd/keepassgo-browser-bridge +browser-extension-firefox-dir: + @mkdir -p "$(dir $(FIREFOX_EXTENSION_DIR))" + @python3 scripts/prepare_firefox_extension.py "$(FIREFOX_EXTENSION_DIR)" + +browser-extension-firefox-lint: browser-extension-firefox-dir + $(WEB_EXT) lint --source-dir "$(FIREFOX_EXTENSION_DIR)" + +browser-extension-firefox-build: browser-extension-firefox-dir + @mkdir -p "$(FIREFOX_EXTENSION_ARTIFACT_DIR)" + $(WEB_EXT) build --source-dir "$(FIREFOX_EXTENSION_DIR)" --artifacts-dir "$(FIREFOX_EXTENSION_ARTIFACT_DIR)" + +browser-extension-firefox-run: browser-extension-firefox-dir + $(WEB_EXT) run --source-dir "$(FIREFOX_EXTENSION_DIR)" + +browser-extension-firefox-sign: browser-extension-firefox-dir + $(WEB_EXT) sign --source-dir "$(FIREFOX_EXTENSION_DIR)" + browser-extension-validate: @command -v xvfb-run >/dev/null 2>&1 || { echo "xvfb-run is required"; exit 1; } @command -v firefox >/dev/null 2>&1 || { echo "firefox is required"; exit 1; } diff --git a/TODO.md b/TODO.md index bd73621..feef56d 100644 --- a/TODO.md +++ b/TODO.md @@ -130,6 +130,98 @@ These are important, but they should likely move behind a dedicated settings gea - Accessibility preferences: future display-density, contrast, reduced-motion, or keyboard-focus tuning should live under settings. +## Upstream Gap Review + +This section tracks explicit feature gaps against the source-level behavior of: + +- KeePass 2.57.1 +- KeePassHttp +- Keepass2Android + +These are not speculative enhancements. They are parity gaps relative to the +stated product requirement to cover the practical feature surface of those +upstream tools where it fits KeePassGO's security model. + +### Stage 1 + +- Android autofill parity/completeness: + close the remaining gaps in Android autofill behavior, including broader + page/app detection coverage, stronger approval and visibility UX, and more + reliable fill behavior across real-world apps and browsers. +- Android fallback fill workflows: + provide a non-autofill fallback comparable in usefulness to KP2A's keyboard + and share-driven workflows for apps and browsers that do not cooperate with + platform autofill. +- Browser extension save/update: + add the browser-side save/update-credential workflow after successful form + submission, not only lookup and fill. +- Search and matching controls: + browser/API result behavior does not yet expose KeePassHttp-style controls + such as best-match-only, scheme matching, and sort preferences as a finished + product surface. +- Unlock-request workflow: + KeePassHttp has an explicit locked-database browser flow; KeePassGO still + needs a polished browser-visible locked/unlock request experience. +- Android share/intents: + browser/app share-driven lookup and open flows comparable to KP2A are not + implemented as a full user workflow. + +### Stage 2 + +- OTP/TOTP: + implement real OTP/TOTP support, including storage conventions compatible + with common KeePass ecosystems and usable display/copy/fill workflows. +- TOTP product surface: + KP2A exposes TOTP directly in entry and list UX; KeePassGO does not. +- Browser-returned field breadth: + KeePassHttp can return string fields for browser consumers; KeePassGO does + not yet have a finished policy and browser UX for rich field return. +- Placeholder and field-reference parity: + KeePass-style placeholder expansion, field references, and related command + and URL override behavior are not implemented as a product surface. +- Offline/work-offline flow: + KP2A has explicit offline/cache-oriented remote-file workflows that are more + mature than KeePassGO's current user-facing remote behavior. + +### Stage 3 + +- Desktop automation: + implement desktop login automation comparable in practical capability to + KeePass auto-type, or replace it with a demonstrably superior workflow that + covers global invocation, selected-entry invocation, window targeting, and + field sequencing. +- Trigger system: + KeePass-style event/condition/action triggers are not implemented. +- Import/export breadth: + KDBX load/save exists, but KeePass-style breadth for CSV/XML/HTML and other + exchange formats is still missing. +- Multi-database lookup: + KeePassHttp can search across all opened databases when configured; KeePassGO + does not yet have an equivalent multi-vault lookup model. +- Remote backend breadth: + KP2A supports far more remote/file backends than KeePassGO currently does; + KeePassGO is still effectively WebDAV-first. +- Plugin/extensibility model: + KeePassGO has integrations, but not a first-class plugin model comparable to + KeePass. +- Plugin ecosystem replacements: + QR transfer, keyboard transport, and related mobile integration equivalents + do not exist in KeePassGO. +- Emergency and recovery utilities: + emergency-sheet/key-file-backup style flows are not implemented. + +### Immediate Product Questions + +- Desktop automation: + decide whether KeePassGO will implement true auto-type, or a different + security model that still satisfies the practical workflows KeePass users + expect. +- OTP model: + decide the canonical KeePassGO representation for TOTP/HOTP so desktop, + Android, gRPC, and browser workflows can all target the same semantics. +- Remote breadth: + decide which non-WebDAV backends are in product scope after WebDAV. + ### Exit Criteria - The main workflow screens prioritize opening, browsing, copying, editing, and synchronizing credentials. @@ -269,7 +361,7 @@ Exit criteria: - Tests cover clear/reset transitions. - `go test ./...` passes. -### Segment 10: Template CRUD UI +### Segment 10 (stage 3): Template CRUD UI Scope: - Create template. diff --git a/android/application_snippets.xml b/android/application_snippets.xml index d2f6caf..5e1a60d 100644 --- a/android/application_snippets.xml +++ b/android/application_snippets.xml @@ -35,6 +35,11 @@ android:name="org.julianfamily.keepassgo.SharedVaultImportActivity" android:exported="true" android:theme="@android:style/Theme.Translucent.NoTitleBar"> + + + + + @@ -42,6 +47,13 @@ + + + + + + + diff --git a/androidsrc/org/julianfamily/keepassgo/AutofillCacheStore.java b/androidsrc/org/julianfamily/keepassgo/AutofillCacheStore.java index 5686193..187dc49 100644 --- a/androidsrc/org/julianfamily/keepassgo/AutofillCacheStore.java +++ b/androidsrc/org/julianfamily/keepassgo/AutofillCacheStore.java @@ -25,26 +25,7 @@ final class AutofillCacheStore { if (entries.isEmpty()) { return null; } - NormalizedTarget target = normalizeURL(webDomain); - if (target.host.isEmpty()) { - return null; - } - List exactHost = new ArrayList<>(); - List parentHost = new ArrayList<>(); - for (Entry entry : entries) { - if (entryMatchesHost(entry, target.host)) { - exactHost.add(entry); - continue; - } - if (entryMatchesParentHost(entry, target.host)) { - parentHost.add(entry); - } - } - Entry matched = chooseEntry(target, exactHost); - if (matched != null) { - return matched; - } - return chooseEntry(target, parentHost); + return fromMatcherEntry(AutofillTargetMatcher.findBestMatch(toMatcherEntries(entries), webDomain)); } static Entry findByID(Context context, String entryID) { @@ -64,7 +45,7 @@ final class AutofillCacheStore { if (entries.isEmpty()) { return entries; } - Entry direct = findBestMatch(context, rawTarget); + Entry direct = fromMatcherEntry(AutofillTargetMatcher.findBestMatch(toMatcherEntries(entries), rawTarget)); if (direct != null) { List resolved = new ArrayList<>(); resolved.add(direct); @@ -77,6 +58,30 @@ final class AutofillCacheStore { return entries; } + private static List toMatcherEntries(List entries) { + List converted = new ArrayList<>(entries.size()); + for (Entry entry : entries) { + converted.add(new AutofillTargetMatcher.Entry( + entry.id, + entry.title, + entry.username, + entry.password, + entry.host, + entry.url, + entry.targets, + entry.path + )); + } + return converted; + } + + private static Entry fromMatcherEntry(AutofillTargetMatcher.Entry entry) { + if (entry == null) { + return null; + } + return new Entry(entry.id, entry.title, entry.username, entry.password, entry.host, entry.url, entry.targets, entry.path); + } + private static List readEntries(Context context) { File cacheFile = findCacheFile(context); if (cacheFile == null) { @@ -199,143 +204,7 @@ final class AutofillCacheStore { } private static String normalizeHost(String raw) { - return normalizeURL(raw).host; - } - - private static NormalizedTarget normalizeURL(String raw) { - if (raw == null) { - return new NormalizedTarget("", "", ""); - } - String value = raw.trim().toLowerCase(Locale.US); - if (value.startsWith("http://")) { - value = value.substring("http://".length()); - } else if (value.startsWith("https://")) { - value = value.substring("https://".length()); - } - int slash = value.indexOf('/'); - if (slash >= 0) { - value = value.substring(0, slash); - } - int colon = value.indexOf(':'); - if (colon >= 0) { - value = value.substring(0, colon); - } - String host = value; - String path = "/"; - int schemeSep = raw.indexOf("://"); - String original = raw.trim(); - if (schemeSep < 0) { - original = "https://" + original; - } - try { - java.net.URI uri = java.net.URI.create(original); - if (uri.getHost() != null) { - host = uri.getHost().toLowerCase(Locale.US); - } - path = cleanPath(uri.getPath()); - } catch (IllegalArgumentException ignored) { - path = "/"; - } - return new NormalizedTarget(host, path, host + path); - } - - private static String cleanPath(String raw) { - if (raw == null || raw.trim().isEmpty() || "/".equals(raw.trim())) { - return "/"; - } - String value = raw.trim(); - while (value.endsWith("/") && value.length() > 1) { - value = value.substring(0, value.length() - 1); - } - if (!value.startsWith("/")) { - value = "/" + value; - } - return value; - } - - private static Entry chooseEntry(NormalizedTarget target, List entries) { - if (entries.isEmpty()) { - return null; - } - if (entries.size() == 1) { - return entries.get(0); - } - - List exact = new ArrayList<>(); - List prefix = new ArrayList<>(); - int bestPrefixLen = -1; - for (Entry entry : entries) { - MatchQuality quality = bestTargetMatch(entry, target); - if (quality.exact) { - exact.add(entry); - continue; - } - if (quality.prefixLength <= 0) { - continue; - } - if (quality.prefixLength > bestPrefixLen) { - prefix.clear(); - prefix.add(entry); - bestPrefixLen = quality.prefixLength; - } else if (quality.prefixLength == bestPrefixLen) { - prefix.add(entry); - } - } - if (exact.size() == 1) { - return exact.get(0); - } - if (exact.size() > 1 || prefix.isEmpty()) { - return null; - } - - return prefix.size() == 1 ? prefix.get(0) : null; - } - - private static boolean entryMatchesHost(Entry entry, String host) { - for (NormalizedTarget target : entryTargets(entry)) { - if (target.host.equals(host)) { - return true; - } - } - return false; - } - - private static boolean entryMatchesParentHost(Entry entry, String host) { - for (NormalizedTarget target : entryTargets(entry)) { - if (!target.host.isEmpty() && host.endsWith("." + target.host)) { - return true; - } - } - return false; - } - - private static List entryTargets(Entry entry) { - List rawTargets = entry.targets; - if (rawTargets.isEmpty()) { - rawTargets = new ArrayList<>(); - rawTargets.add(entry.url); - } - List targets = new ArrayList<>(); - for (String rawTarget : rawTargets) { - NormalizedTarget target = normalizeURL(rawTarget); - if (!target.host.isEmpty()) { - targets.add(target); - } - } - return targets; - } - - private static MatchQuality bestTargetMatch(Entry entry, NormalizedTarget target) { - int bestPrefixLen = -1; - for (NormalizedTarget entryTarget : entryTargets(entry)) { - if (entryTarget.url.equals(target.url)) { - return new MatchQuality(true, 0); - } - if (!"/".equals(entryTarget.path) && target.path.startsWith(entryTarget.path)) { - bestPrefixLen = Math.max(bestPrefixLen, entryTarget.path.length()); - } - } - return new MatchQuality(false, bestPrefixLen); + return AutofillTargetMatcher.normalize(raw).host; } static final class Entry { @@ -359,26 +228,4 @@ final class AutofillCacheStore { this.path = new ArrayList<>(path); } } - - private static final class MatchQuality { - final boolean exact; - final int prefixLength; - - MatchQuality(boolean exact, int prefixLength) { - this.exact = exact; - this.prefixLength = prefixLength; - } - } - - private static final class NormalizedTarget { - final String host; - final String path; - final String url; - - NormalizedTarget(String host, String path, String url) { - this.host = host; - this.path = path; - this.url = url; - } - } } diff --git a/androidsrc/org/julianfamily/keepassgo/AutofillFallbackTarget.java b/androidsrc/org/julianfamily/keepassgo/AutofillFallbackTarget.java new file mode 100644 index 0000000..821e175 --- /dev/null +++ b/androidsrc/org/julianfamily/keepassgo/AutofillFallbackTarget.java @@ -0,0 +1,24 @@ +package org.julianfamily.keepassgo; + +final class AutofillFallbackTarget { + private static final String APP_SCHEME = "androidapp://"; + + private AutofillFallbackTarget() { + } + + static String resolve(String packageName, String webDomain) { + String domain = trim(webDomain); + if (!domain.isEmpty()) { + return domain; + } + String pkg = trim(packageName); + if (pkg.isEmpty()) { + return ""; + } + return APP_SCHEME + pkg; + } + + private static String trim(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/androidsrc/org/julianfamily/keepassgo/AutofillTargetMatcher.java b/androidsrc/org/julianfamily/keepassgo/AutofillTargetMatcher.java new file mode 100644 index 0000000..706da68 --- /dev/null +++ b/androidsrc/org/julianfamily/keepassgo/AutofillTargetMatcher.java @@ -0,0 +1,260 @@ +package org.julianfamily.keepassgo; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; + +final class AutofillTargetMatcher { + private AutofillTargetMatcher() { + } + + static Entry findBestMatch(List entries, String rawTarget) { + if (entries == null || entries.isEmpty()) { + return null; + } + NormalizedTarget target = normalize(rawTarget); + if (target.host.isEmpty()) { + return null; + } + List exactHost = new ArrayList<>(); + List parentHost = new ArrayList<>(); + for (Entry entry : entries) { + if (entryMatchesHost(entry, target.host)) { + exactHost.add(entry); + continue; + } + if (entryMatchesParentHost(entry, target.host)) { + parentHost.add(entry); + } + } + Entry matched = chooseEntry(target, exactHost); + if (matched != null) { + return matched; + } + return chooseEntry(target, parentHost); + } + + static List chooserCandidates(List entries, String rawTarget) { + if (entries == null || entries.isEmpty()) { + return new ArrayList<>(); + } + Entry direct = findBestMatch(entries, rawTarget); + if (direct != null) { + List resolved = new ArrayList<>(); + resolved.add(direct); + return resolved; + } + NormalizedTarget target = normalize(rawTarget); + List exactHost = new ArrayList<>(); + List parentHost = new ArrayList<>(); + for (Entry entry : entries) { + if (entryMatchesHost(entry, target.host)) { + exactHost.add(entry); + continue; + } + if (entryMatchesParentHost(entry, target.host)) { + parentHost.add(entry); + } + } + if (!exactHost.isEmpty()) { + return sortEntries(exactHost); + } + if (!parentHost.isEmpty()) { + return sortEntries(parentHost); + } + return sortEntries(entries); + } + + static NormalizedTarget normalize(String raw) { + if (raw == null) { + return new NormalizedTarget("", "", ""); + } + String trimmed = raw.trim(); + if (trimmed.isEmpty()) { + return new NormalizedTarget("", "", ""); + } + String original = trimmed; + String value = trimmed.toLowerCase(Locale.US); + if (value.startsWith("http://")) { + value = value.substring("http://".length()); + } else if (value.startsWith("https://")) { + value = value.substring("https://".length()); + } + int slash = value.indexOf('/'); + if (slash >= 0) { + value = value.substring(0, slash); + } + int colon = value.indexOf(':'); + if (colon >= 0) { + value = value.substring(0, colon); + } + String host = value; + String path = "/"; + int schemeSep = original.indexOf("://"); + if (schemeSep < 0) { + original = "https://" + original; + } + try { + URI uri = URI.create(original); + if (uri.getHost() != null) { + host = uri.getHost().toLowerCase(Locale.US); + } + path = cleanPath(uri.getPath()); + } catch (IllegalArgumentException ignored) { + path = "/"; + } + return new NormalizedTarget(host, path, host + path); + } + + private static String cleanPath(String raw) { + if (raw == null || raw.trim().isEmpty() || "/".equals(raw.trim())) { + return "/"; + } + String value = raw.trim(); + while (value.endsWith("/") && value.length() > 1) { + value = value.substring(0, value.length() - 1); + } + if (!value.startsWith("/")) { + value = "/" + value; + } + return value; + } + + private static Entry chooseEntry(NormalizedTarget target, List entries) { + if (entries.isEmpty()) { + return null; + } + if (entries.size() == 1) { + return entries.get(0); + } + List exact = new ArrayList<>(); + List prefix = new ArrayList<>(); + int bestPrefixLen = -1; + for (Entry entry : entries) { + MatchQuality quality = bestTargetMatch(entry, target); + if (quality.exact) { + exact.add(entry); + continue; + } + if (quality.prefixLength <= 0) { + continue; + } + if (quality.prefixLength > bestPrefixLen) { + prefix.clear(); + prefix.add(entry); + bestPrefixLen = quality.prefixLength; + } else if (quality.prefixLength == bestPrefixLen) { + prefix.add(entry); + } + } + if (exact.size() == 1) { + return exact.get(0); + } + if (exact.size() > 1 || prefix.isEmpty()) { + return null; + } + return prefix.size() == 1 ? prefix.get(0) : null; + } + + private static boolean entryMatchesHost(Entry entry, String host) { + for (NormalizedTarget target : entryTargets(entry)) { + if (target.host.equals(host)) { + return true; + } + } + return false; + } + + private static boolean entryMatchesParentHost(Entry entry, String host) { + for (NormalizedTarget target : entryTargets(entry)) { + if (!target.host.isEmpty() && host.endsWith("." + target.host)) { + return true; + } + } + return false; + } + + private static List entryTargets(Entry entry) { + List rawTargets = entry.targets; + if (rawTargets.isEmpty()) { + rawTargets = new ArrayList<>(); + rawTargets.add(entry.url); + } + List targets = new ArrayList<>(); + for (String rawTarget : rawTargets) { + NormalizedTarget target = normalize(rawTarget); + if (!target.host.isEmpty()) { + targets.add(target); + } + } + return targets; + } + + private static MatchQuality bestTargetMatch(Entry entry, NormalizedTarget target) { + int bestPrefixLen = -1; + for (NormalizedTarget entryTarget : entryTargets(entry)) { + if (entryTarget.url.equals(target.url)) { + return new MatchQuality(true, 0); + } + if (!"/".equals(entryTarget.path) && target.path.startsWith(entryTarget.path)) { + bestPrefixLen = Math.max(bestPrefixLen, entryTarget.path.length()); + } + } + return new MatchQuality(false, bestPrefixLen); + } + + private static List sortEntries(List entries) { + List sorted = new ArrayList<>(entries); + sorted.sort(Comparator + .comparing((Entry entry) -> entry.title.toLowerCase(Locale.US)) + .thenComparing(entry -> String.join("/", entry.path).toLowerCase(Locale.US)) + .thenComparing(entry -> entry.id)); + return sorted; + } + + static final class NormalizedTarget { + final String host; + final String path; + final String url; + + NormalizedTarget(String host, String path, String url) { + this.host = host; + this.path = path; + this.url = url; + } + } + + static final class Entry { + final String id; + final String title; + final String username; + final String password; + final String host; + final String url; + final List targets; + final List path; + + Entry(String id, String title, String username, String password, String host, String url, List targets, List path) { + this.id = id; + this.title = title; + this.username = username; + this.password = password; + this.host = host; + this.url = url; + this.targets = new ArrayList<>(targets); + this.path = new ArrayList<>(path); + } + } + + private static final class MatchQuality { + final boolean exact; + final int prefixLength; + + MatchQuality(boolean exact, int prefixLength) { + this.exact = exact; + this.prefixLength = prefixLength; + } + } +} diff --git a/androidsrc/org/julianfamily/keepassgo/KeePassGOAccessibilityService.java b/androidsrc/org/julianfamily/keepassgo/KeePassGOAccessibilityService.java index b347812..40d5f67 100644 --- a/androidsrc/org/julianfamily/keepassgo/KeePassGOAccessibilityService.java +++ b/androidsrc/org/julianfamily/keepassgo/KeePassGOAccessibilityService.java @@ -12,7 +12,6 @@ import java.util.List; public final class KeePassGOAccessibilityService extends AccessibilityService { private static final String TAG = "KeePassGOA11y"; - private String lastFilledSignature = ""; @Override @@ -22,22 +21,17 @@ public final class KeePassGOAccessibilityService extends AccessibilityService { if (root == null) { return; } - CharSequence packageName = root.getPackageName(); - if (packageName == null || !"com.android.chrome".contentEquals(packageName)) { - return; - } - - ChromeForm form = inspectChrome(root); + ChromeForm form = inspectWindow(root); if (form == null || form.passwordField == null) { return; } - AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, form.url); + AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, form.matchTarget); if (entry == null) { - Log.i(TAG, "no accessibility-fill match for " + form.url); + Log.i(TAG, "no accessibility-fill match for " + form.matchTarget); return; } - String signature = form.url + "|" + entry.username + "|" + nodeKey(form.passwordField); + String signature = form.matchTarget + "|" + entry.username + "|" + nodeKey(form.passwordField); if (signature.equals(lastFilledSignature)) { return; } @@ -49,7 +43,7 @@ public final class KeePassGOAccessibilityService extends AccessibilityService { filled |= setNodeText(form.passwordField, entry.password); if (filled) { lastFilledSignature = signature; - Log.i(TAG, "filled login form for " + form.url + " with " + entry.username); + Log.i(TAG, "filled login form for " + form.matchTarget + " with " + entry.username); } } catch (Exception err) { Log.e(TAG, "accessibility fill failed", err); @@ -61,11 +55,11 @@ public final class KeePassGOAccessibilityService extends AccessibilityService { Log.i(TAG, "accessibility service interrupted"); } - private static ChromeForm inspectChrome(AccessibilityNodeInfo root) { + private static ChromeForm inspectWindow(AccessibilityNodeInfo root) { List editables = new ArrayList<>(); ChromeForm form = new ChromeForm(); walk(root, editables, form); - if (form.url.isEmpty()) { + if (form.matchTarget.isEmpty()) { return null; } if (form.passwordField == null) { @@ -111,7 +105,13 @@ public final class KeePassGOAccessibilityService extends AccessibilityService { if (viewID != null && viewID.toString().endsWith(":id/url_bar")) { CharSequence text = node.getText(); if (text != null) { - form.url = text.toString().trim(); + form.webDomain = text.toString().trim(); + } + } + if (form.packageName.isEmpty()) { + CharSequence packageName = node.getPackageName(); + if (packageName != null) { + form.packageName = packageName.toString().trim(); } } if (node.isEditable()) { @@ -128,6 +128,7 @@ public final class KeePassGOAccessibilityService extends AccessibilityService { walk(child, editables, form); } } + form.matchTarget = AutofillFallbackTarget.resolve(form.packageName, form.webDomain); } private static boolean isPasswordNode(AccessibilityNodeInfo node) { @@ -188,7 +189,9 @@ public final class KeePassGOAccessibilityService extends AccessibilityService { } private static final class ChromeForm { - String url = ""; + String packageName = ""; + String webDomain = ""; + String matchTarget = ""; AccessibilityNodeInfo usernameField; AccessibilityNodeInfo passwordField; } diff --git a/androidsrc/org/julianfamily/keepassgo/SharedVaultImportActivity.java b/androidsrc/org/julianfamily/keepassgo/SharedVaultImportActivity.java index 778b50c..c4eb884 100644 --- a/androidsrc/org/julianfamily/keepassgo/SharedVaultImportActivity.java +++ b/androidsrc/org/julianfamily/keepassgo/SharedVaultImportActivity.java @@ -20,6 +20,9 @@ import java.util.ArrayList; public final class SharedVaultImportActivity extends Activity { private static final String TAG = "KeePassGOImport"; private static final String DEFAULT_NAME = "shared-vault.kdbx"; + private static final String PENDING_SHARED_VAULT = "pending-shared-vault.kdbx"; + private static final String PENDING_SHARED_VAULT_NAME = "pending-shared-vault-name.txt"; + private static final String PENDING_SHARED_LOOKUP = "pending-shared-lookup.txt"; @Override protected void onCreate(Bundle state) { @@ -40,6 +43,16 @@ public final class SharedVaultImportActivity extends Activity { private void handleIntent(Intent intent) { logIntent(intent); + String sharedLookup = resolveSharedLookup(intent); + if (!sharedLookup.isEmpty()) { + try { + persistPendingLookup(sharedLookup); + Log.i(TAG, "queued shared lookup target"); + } catch (IOException | RuntimeException err) { + Log.e(TAG, "failed to queue shared lookup target", err); + } + return; + } Uri uri = resolveSharedUri(intent); if (uri == null) { Log.i(TAG, "no shared vault URI on intent"); @@ -86,12 +99,35 @@ public final class SharedVaultImportActivity extends Activity { return null; } + private String resolveSharedLookup(Intent intent) { + if (intent == null) { + return ""; + } + String action = intent.getAction(); + if (Intent.ACTION_SEND.equals(action) && "text/plain".equalsIgnoreCase(intent.getType())) { + CharSequence extraText = intent.getCharSequenceExtra(Intent.EXTRA_TEXT); + if (extraText != null) { + return extraText.toString().trim(); + } + } + if (Intent.ACTION_VIEW.equals(action)) { + Uri data = intent.getData(); + if (data != null) { + String scheme = data.getScheme(); + if ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme)) { + return data.toString(); + } + } + } + return ""; + } + private void persistPendingImport(Uri uri) throws IOException { File dir = new File(getFilesDir(), "keepassgo"); if (!dir.exists() && !dir.mkdirs()) { throw new IOException("failed to create " + dir.getAbsolutePath()); } - File pendingFile = new File(dir, "pending-shared-vault.kdbx"); + File pendingFile = new File(dir, PENDING_SHARED_VAULT); try (InputStream in = openSharedInputStream(uri)) { if (in == null) { throw new IOException("failed to open shared vault stream"); @@ -105,12 +141,23 @@ public final class SharedVaultImportActivity extends Activity { } } - File nameFile = new File(dir, "pending-shared-vault-name.txt"); + File nameFile = new File(dir, PENDING_SHARED_VAULT_NAME); try (FileOutputStream out = new FileOutputStream(nameFile, false)) { out.write(resolveDisplayName(uri).getBytes(StandardCharsets.UTF_8)); } } + private void persistPendingLookup(String lookup) throws IOException { + File dir = new File(getFilesDir(), "keepassgo"); + if (!dir.exists() && !dir.mkdirs()) { + throw new IOException("failed to create " + dir.getAbsolutePath()); + } + File pendingFile = new File(dir, PENDING_SHARED_LOOKUP); + try (FileOutputStream out = new FileOutputStream(pendingFile, false)) { + out.write(lookup.getBytes(StandardCharsets.UTF_8)); + } + } + private InputStream openSharedInputStream(Uri uri) throws IOException { if ("file".equalsIgnoreCase(uri.getScheme())) { String path = uri.getPath(); diff --git a/androidtestsrc/org/julianfamily/keepassgo/AutofillCacheStoreBehaviorTest.java b/androidtestsrc/org/julianfamily/keepassgo/AutofillCacheStoreBehaviorTest.java new file mode 100644 index 0000000..6c29946 --- /dev/null +++ b/androidtestsrc/org/julianfamily/keepassgo/AutofillCacheStoreBehaviorTest.java @@ -0,0 +1,153 @@ +package org.julianfamily.keepassgo; + +import java.util.Arrays; +import java.util.ArrayList; +import java.util.List; + +public final class AutofillCacheStoreBehaviorTest { + public static void main(String[] args) { + testFindBestMatchUsesAndroidAppTargets(); + testChooserCandidatesCollapseToExactAndroidAppMatch(); + testChooserCandidatesStayScopedToExactHostMatches(); + testChooserCandidatesStayScopedToParentHostMatches(); + } + + private static void testFindBestMatchUsesAndroidAppTargets() { + List entries = new ArrayList<>(); + entries.add(new AutofillTargetMatcher.Entry( + "blink-entry", + "Blink", + "linuscaldwell", + "bellagio-stack", + "account.blinknetwork.com", + "https://account.blinknetwork.com", + Arrays.asList("https://account.blinknetwork.com", "androidapp://com.blinknetwork.mobile2"), + Arrays.asList("Crew", "Apps") + )); + + AutofillTargetMatcher.Entry got = AutofillTargetMatcher.findBestMatch(entries, "androidapp://com.blinknetwork.mobile2"); + if (got == null || !"blink-entry".equals(got.id)) { + throw new AssertionError("findBestMatch(entries, androidapp target) = " + describe(got) + ", want blink-entry"); + } + } + + private static void testChooserCandidatesCollapseToExactAndroidAppMatch() { + List entries = new ArrayList<>(); + entries.add(new AutofillTargetMatcher.Entry( + "blink-entry", + "Blink", + "linuscaldwell", + "bellagio-stack", + "account.blinknetwork.com", + "https://account.blinknetwork.com", + Arrays.asList("https://account.blinknetwork.com", "androidapp://com.blinknetwork.mobile2"), + Arrays.asList("Crew", "Apps") + )); + entries.add(new AutofillTargetMatcher.Entry( + "night-fox-entry", + "Night Fox", + "nightfox", + "vault-code", + "gitlab.com", + "https://gitlab.com", + Arrays.asList("https://gitlab.com"), + Arrays.asList("Crew", "Internet") + )); + + List got = AutofillTargetMatcher.chooserCandidates(entries, "androidapp://com.blinknetwork.mobile2"); + if (got.size() != 1 || !"blink-entry".equals(got.get(0).id)) { + throw new AssertionError("chooserCandidates(entries, androidapp target) = " + describe(got) + ", want [blink-entry]"); + } + } + + private static void testChooserCandidatesStayScopedToExactHostMatches() { + List entries = new ArrayList<>(); + entries.add(new AutofillTargetMatcher.Entry( + "bellagio-primary", + "Bellagio Primary", + "dannyocean", + "vault-code", + "bellagio.example.invalid", + "https://bellagio.example.invalid/login", + Arrays.asList("https://bellagio.example.invalid/login"), + Arrays.asList("Crew", "Internet") + )); + entries.add(new AutofillTargetMatcher.Entry( + "bellagio-backup", + "Bellagio Backup", + "rustyryan", + "backup-code", + "bellagio.example.invalid", + "https://bellagio.example.invalid/admin", + Arrays.asList("https://bellagio.example.invalid/admin"), + Arrays.asList("Crew", "Internet") + )); + entries.add(new AutofillTargetMatcher.Entry( + "night-fox-entry", + "Night Fox", + "nightfox", + "vault-code", + "gitlab.com", + "https://gitlab.com", + Arrays.asList("https://gitlab.com"), + Arrays.asList("Crew", "Internet") + )); + + List got = AutofillTargetMatcher.chooserCandidates(entries, "https://bellagio.example.invalid/security"); + if (got.size() != 2 || !containsIDs(got, "bellagio-primary", "bellagio-backup")) { + throw new AssertionError("chooserCandidates(entries, exact host) = " + describe(got) + ", want only Bellagio entries"); + } + } + + private static void testChooserCandidatesStayScopedToParentHostMatches() { + List entries = new ArrayList<>(); + entries.add(new AutofillTargetMatcher.Entry( + "bellagio-parent", + "Bellagio Parent", + "linuscaldwell", + "chip-stack", + "example.invalid", + "https://example.invalid/login", + Arrays.asList("https://example.invalid/login"), + Arrays.asList("Crew", "Internet") + )); + entries.add(new AutofillTargetMatcher.Entry( + "night-fox-entry", + "Night Fox", + "nightfox", + "vault-code", + "gitlab.com", + "https://gitlab.com", + Arrays.asList("https://gitlab.com"), + Arrays.asList("Crew", "Internet") + )); + + List got = AutofillTargetMatcher.chooserCandidates(entries, "https://bellagio.example.invalid/security"); + if (got.size() != 1 || !"bellagio-parent".equals(got.get(0).id)) { + throw new AssertionError("chooserCandidates(entries, parent host) = " + describe(got) + ", want [bellagio-parent]"); + } + } + + private static String describe(AutofillTargetMatcher.Entry entry) { + if (entry == null) { + return "null"; + } + return entry.id; + } + + private static String describe(List entries) { + List ids = new ArrayList<>(); + for (AutofillTargetMatcher.Entry entry : entries) { + ids.add(entry.id); + } + return ids.toString(); + } + + private static boolean containsIDs(List entries, String... wantIDs) { + List ids = new ArrayList<>(); + for (AutofillTargetMatcher.Entry entry : entries) { + ids.add(entry.id); + } + return ids.containsAll(Arrays.asList(wantIDs)) && ids.size() == wantIDs.length; + } +} diff --git a/androidtestsrc/org/julianfamily/keepassgo/AutofillFallbackTargetBehaviorTest.java b/androidtestsrc/org/julianfamily/keepassgo/AutofillFallbackTargetBehaviorTest.java new file mode 100644 index 0000000..1cdc4e2 --- /dev/null +++ b/androidtestsrc/org/julianfamily/keepassgo/AutofillFallbackTargetBehaviorTest.java @@ -0,0 +1,30 @@ +package org.julianfamily.keepassgo; + +public final class AutofillFallbackTargetBehaviorTest { + public static void main(String[] args) { + testPrefersWebDomainWhenPresent(); + testFallsBackToAndroidAppPackage(); + testEmptyWhenNeitherSignalExists(); + } + + private static void testPrefersWebDomainWhenPresent() { + String got = AutofillFallbackTarget.resolve("com.android.chrome", "gitlab.com"); + if (!"gitlab.com".equals(got)) { + throw new AssertionError("resolve(chrome, gitlab.com) = " + got + ", want gitlab.com"); + } + } + + private static void testFallsBackToAndroidAppPackage() { + String got = AutofillFallbackTarget.resolve("com.blinknetwork.mobile2", ""); + if (!"androidapp://com.blinknetwork.mobile2".equals(got)) { + throw new AssertionError("resolve(package-only) = " + got + ", want androidapp://com.blinknetwork.mobile2"); + } + } + + private static void testEmptyWhenNeitherSignalExists() { + String got = AutofillFallbackTarget.resolve("", ""); + if (!"".equals(got)) { + throw new AssertionError("resolve(empty) = " + got + ", want empty"); + } + } +} diff --git a/browser/extension/background.js b/browser/extension/background.js index 3cfb627..5ccb0f7 100644 --- a/browser/extension/background.js +++ b/browser/extension/background.js @@ -3,7 +3,10 @@ const nativeHost = "com.keepassgo.browser"; const isNodeTestEnv = typeof module !== "undefined" && module.exports; const usePromiseAPI = typeof globalThis.browser !== "undefined"; const defaultSettings = { - bearerToken: "" + bearerToken: "", + bestMatchOnly: false, + requireSchemeMatch: false, + sortResults: "quality" }; const pageStatePrefix = "keepassgo-page-state:"; const matchCacheTTL = 30 * 1000; @@ -173,12 +176,20 @@ function connectNative(message) { } async function loadSettings() { - const stored = await storageGet(["bearerToken"]); + const stored = await storageGet(["bearerToken", "bestMatchOnly", "requireSchemeMatch", "sortResults"]); + const sortResults = normalizeSortResults(stored.sortResults); return { - bearerToken: (stored.bearerToken || defaultSettings.bearerToken).trim() + bearerToken: (stored.bearerToken || defaultSettings.bearerToken).trim(), + bestMatchOnly: Boolean(stored.bestMatchOnly ?? defaultSettings.bestMatchOnly), + requireSchemeMatch: Boolean(stored.requireSchemeMatch ?? defaultSettings.requireSchemeMatch), + sortResults }; } +function normalizeSortResults(value) { + return ["quality", "title", "path"].includes(value) ? value : defaultSettings.sortResults; +} + function supportsPageStateURL(rawURL) { return typeof rawURL === "string" && /^https?:\/\//i.test(rawURL); } @@ -191,6 +202,185 @@ function cloneTarget(target) { return target && typeof target === "object" ? { ...target } : null; } +function cloneSavePlan(plan) { + if (!plan || typeof plan !== "object") { + return null; + } + return { + mode: plan.mode === "update" ? "update" : "save", + entryId: typeof plan.entryId === "string" ? plan.entryId : "", + title: typeof plan.title === "string" ? plan.title : "", + path: Array.isArray(plan.path) ? [...plan.path] : [], + username: typeof plan.username === "string" ? plan.username : "", + password: typeof plan.password === "string" ? plan.password : "", + url: typeof plan.url === "string" ? plan.url : "" + }; +} + +function normalizeObservedCredential(observed) { + if (!observed || typeof observed !== "object") { + return null; + } + const password = typeof observed.password === "string" ? observed.password.trim() : ""; + const url = typeof observed.url === "string" ? observed.url.trim() : ""; + if (!password || !url) { + return null; + } + return { + title: typeof observed.title === "string" ? observed.title.trim() : "", + username: typeof observed.username === "string" ? observed.username.trim() : "", + password, + url + }; +} + +function matchHost(rawURL) { + if (typeof rawURL !== "string") { + return ""; + } + const trimmed = rawURL.trim(); + if (!trimmed) { + return ""; + } + try { + return new URL(trimmed).hostname.toLowerCase(); + } catch (_error) { + return trimmed.replace(/^https?:\/\//i, "").replace(/\/.*$/, "").toLowerCase(); + } +} + +function matchScheme(rawURL) { + if (typeof rawURL !== "string") { + return ""; + } + const trimmed = rawURL.trim(); + if (!trimmed) { + return ""; + } + try { + return new URL(trimmed).protocol.toLowerCase(); + } catch (_error) { + return ""; + } +} + +function qualityRank(quality) { + switch (String(quality || "").trim().toLowerCase()) { + case "exact": + return 0; + case "scheme": + return 1; + case "host": + return 2; + default: + return 3; + } +} + +function compareMatchText(left, right) { + return String(left || "").localeCompare(String(right || ""), undefined, { sensitivity: "base" }); +} + +function sortMatches(matches, sortResults) { + const sorted = [...matches]; + switch (normalizeSortResults(sortResults)) { + case "title": + sorted.sort((left, right) => + compareMatchText(left?.title, right?.title) || + compareMatchText(left?.username, right?.username) || + compareMatchText(left?.path?.join("/"), right?.path?.join("/")) + ); + return sorted; + case "path": + sorted.sort((left, right) => + compareMatchText(left?.path?.join("/"), right?.path?.join("/")) || + compareMatchText(left?.title, right?.title) || + compareMatchText(left?.username, right?.username) + ); + return sorted; + default: + sorted.sort((left, right) => + qualityRank(left?.quality) - qualityRank(right?.quality) || + compareMatchText(left?.title, right?.title) || + compareMatchText(left?.username, right?.username) + ); + return sorted; + } +} + +function filterSchemeMatches(matches, pageURL) { + const pageScheme = matchScheme(pageURL); + if (!pageScheme) { + return [...matches]; + } + return matches.filter((match) => { + const entryScheme = matchScheme(match?.url); + return !entryScheme || entryScheme === pageScheme; + }); +} + +function filterBestQuality(matches) { + if (!Array.isArray(matches) || matches.length === 0) { + return []; + } + const bestRank = Math.min(...matches.map((match) => qualityRank(match?.quality))); + return matches.filter((match) => qualityRank(match?.quality) === bestRank); +} + +function applyMatchControls(matches, settings, pageURL) { + let filtered = Array.isArray(matches) ? [...matches] : []; + if (settings?.requireSchemeMatch) { + filtered = filterSchemeMatches(filtered, pageURL); + } + if (settings?.bestMatchOnly) { + filtered = filterBestQuality(filtered); + } + return sortMatches(filtered, settings?.sortResults); +} + +function defaultObservedTitle(observed) { + if (observed?.title) { + return observed.title; + } + return matchHost(observed?.url) || "Browser Login"; +} + +function savePlanForObservedLogin(observed, matches) { + const normalized = normalizeObservedCredential(observed); + if (!normalized) { + return null; + } + const targetHost = matchHost(normalized.url); + const exact = Array.isArray(matches) ? matches.find((match) => + typeof match?.id === "string" && + String(match?.username || "").trim().toLowerCase() === normalized.username.toLowerCase() && + matchHost(match?.url || "") === targetHost + ) : null; + if (exact) { + return { + mode: "update", + entryId: exact.id, + title: exact.title || defaultObservedTitle(normalized), + path: Array.isArray(exact.path) ? [...exact.path] : [], + username: normalized.username, + password: normalized.password, + url: normalized.url + }; + } + const fallbackPath = Array.isArray(matches) && matches.length > 0 && Array.isArray(matches[0]?.path) + ? [...matches[0].path] + : []; + return { + mode: "save", + entryId: "", + title: defaultObservedTitle(normalized), + path: fallbackPath, + username: normalized.username, + password: normalized.password, + url: normalized.url + }; +} + function normalizePageState(state) { return { tabId: Number.isInteger(state?.tabId) ? state.tabId : null, @@ -207,6 +397,7 @@ function normalizePageState(state) { pendingEntryId: typeof state?.pendingEntryId === "string" ? state.pendingEntryId : "", pendingTarget: cloneTarget(state?.pendingTarget), pendingMessage: typeof state?.pendingMessage === "string" ? state.pendingMessage : "", + pendingSave: cloneSavePlan(state?.pendingSave), lastFilledEntryId: typeof state?.lastFilledEntryId === "string" ? state.lastFilledEntryId : "", updatedAt: Number.isFinite(state?.updatedAt) ? state.updatedAt : 0 }; @@ -228,6 +419,7 @@ function defaultPageState(tabId, pageUrl) { pendingEntryId: "", pendingTarget: null, pendingMessage: "", + pendingSave: null, lastFilledEntryId: "", updatedAt: 0 }); @@ -292,6 +484,16 @@ function approvalHintForState(state) { return state.pendingMessage || "Approve or deny the fill request in KeePassGO."; } +function shouldContinueWatchingState(state) { + if (!state?.pageHasLoginForm) { + return false; + } + if (state?.pendingFill) { + return true; + } + return Boolean(state?.status?.locked); +} + function schedulePendingPoll(tabId, pageUrl) { if (!Number.isInteger(tabId)) { return; @@ -337,6 +539,12 @@ function actionPresentationForState(state) { badgeText = "!"; color = "#9f5f0e"; title = approvalHintForState(state) || "KeePassGO approval needed for this page"; + } else if (state.pendingSave) { + badgeText = "S"; + color = "#255f4a"; + title = state.pendingSave.mode === "update" + ? `KeePassGO can update ${state.pendingSave.title || "this login"}` + : "KeePassGO can save the submitted login"; } else if (!state.configured) { title = "Configure KeePassGO Browser in extension settings"; } else if (!state.success) { @@ -492,7 +700,7 @@ async function refreshPageState(tabId, pageUrl, options = {}) { state.matches = []; state.updatedAt = Date.now(); const saved = await setPageState(tabId, state); - if (saved.pendingFill) { + if (shouldContinueWatchingState(saved)) { schedulePendingPoll(tabId, resolvedURL); } else { clearPendingPoll(tabId); @@ -502,7 +710,7 @@ async function refreshPageState(tabId, pageUrl, options = {}) { if (shouldReuseMatches(state, force)) { const saved = await setPageState(tabId, state); - if (saved.pendingFill) { + if (shouldContinueWatchingState(saved)) { schedulePendingPoll(tabId, resolvedURL); } else { clearPendingPoll(tabId); @@ -515,6 +723,11 @@ async function refreshPageState(tabId, pageUrl, options = {}) { bearerToken: settings.bearerToken, url: resolvedURL }); + const filteredMatches = applyMatchControls( + Array.isArray(matches?.matches) ? matches.matches : [], + settings, + resolvedURL + ); state = { ...state, @@ -524,12 +737,12 @@ async function refreshPageState(tabId, pageUrl, options = {}) { pendingMessage: tokenPendingApprovalCount(matches?.status ?? state.status) > 0 ? approvalHintForState(state) || "Approve or deny the browser fill request in KeePassGO." : "", - matches: Array.isArray(matches?.matches) ? matches.matches : [], + matches: filteredMatches, error: matches?.error ?? "", updatedAt: Date.now() }; const saved = await setPageState(tabId, state); - if (saved.pendingFill) { + if (shouldContinueWatchingState(saved)) { schedulePendingPoll(tabId, resolvedURL); } else { clearPendingPoll(tabId); @@ -653,11 +866,65 @@ async function refreshActivePage(options = {}) { return refreshPageState(page.tabId, page.url, options); } +async function saveObservedLogin(tabId, selectedMatch = null) { + if (!Number.isInteger(tabId)) { + throw new Error("No active tab is available."); + } + const tab = await tabsGet(tabId); + const pageUrl = typeof tab?.url === "string" ? tab.url : ""; + let state = await getPageState(tabId, pageUrl); + const pendingSave = cloneSavePlan(state.pendingSave); + if (!pendingSave) { + throw new Error("There is no pending login to save."); + } + const request = { + action: "save-login", + title: pendingSave.title, + username: pendingSave.username, + password: pendingSave.password, + url: pendingSave.url + }; + if (selectedMatch && typeof selectedMatch === "object") { + if (pendingSave.mode === "update" && typeof selectedMatch.id === "string" && selectedMatch.id.trim()) { + request.entryId = selectedMatch.id.trim(); + request.title = String(selectedMatch.title || pendingSave.title || "").trim(); + } else if (Array.isArray(selectedMatch.path) && selectedMatch.path.length > 0) { + request.path = [...selectedMatch.path]; + } + } else if (pendingSave.mode === "update" && pendingSave.entryId) { + request.entryId = pendingSave.entryId; + } else if (pendingSave.path.length > 0) { + request.path = [...pendingSave.path]; + } + const settings = await loadSettings(); + if (!settings.bearerToken) { + throw new Error("API token is not configured."); + } + const response = await connectNative({ + ...request, + bearerToken: settings.bearerToken + }); + if (!response?.success) { + throw new Error(response?.error || "KeePassGO did not save the submitted login."); + } + state = await setPageState(tabId, { + ...state, + pendingSave: null, + error: "", + updatedAt: Date.now() + }); + await refreshPageState(tabId, pageUrl, { force: true }); + return { state }; +} + const backgroundTestExports = { + applyMatchControls, normalizePageState, actionPresentationForState, shouldReuseMatches, + shouldContinueWatchingState, tokenPendingApprovalCount, + savePlanForObservedLogin, defaultSettings }; @@ -692,11 +959,62 @@ if (isNodeTestEnv) { return; case "keepassgo-save-settings": await storageSet({ - bearerToken: String(message.settings?.bearerToken || "").trim() + bearerToken: String(message.settings?.bearerToken || "").trim(), + bestMatchOnly: Boolean(message.settings?.bestMatchOnly), + requireSchemeMatch: Boolean(message.settings?.requireSchemeMatch), + sortResults: normalizeSortResults(message.settings?.sortResults) }); await refreshActivePage({ force: true }).catch(() => null); sendResponse({ success: true }); return; + case "keepassgo-search-logins": { + const settings = await loadSettings(); + const page = await activePageContext(); + const response = await connectNative({ + action: "search-logins", + bearerToken: settings.bearerToken, + query: String(message?.query || "").trim() + }); + const searchResults = applyMatchControls( + Array.isArray(response?.searchResults) ? response.searchResults : [], + settings, + page.url + ); + sendResponse({ + success: Boolean(response?.success), + error: response?.error || "", + results: searchResults, + status: response?.status ?? null + }); + return; + } + case "keepassgo-observed-login": + if (Number.isInteger(sender?.tab?.id)) { + const targetState = await getPageState(sender.tab.id, sender.tab.url || ""); + const nextSave = savePlanForObservedLogin(message.observed, targetState.matches); + sendResponse(await setPageState(sender.tab.id, { + ...targetState, + pendingSave: nextSave, + updatedAt: Date.now() + })); + return; + } + sendResponse({ success: false, error: "No active tab is available." }); + return; + case "keepassgo-save-login": { + const targetTabID = Number.isInteger(message?.tabId) + ? message.tabId + : (Number.isInteger(sender?.tab?.id) ? sender.tab.id : (await activePageContext()).tabId); + const selectedMatch = message?.selectedMatch && typeof message.selectedMatch === "object" + ? { + id: String(message.selectedMatch.id || "").trim(), + title: String(message.selectedMatch.title || "").trim(), + path: Array.isArray(message.selectedMatch.path) ? message.selectedMatch.path : [] + } + : null; + sendResponse({ success: true, ...(await saveObservedLogin(targetTabID, selectedMatch)) }); + return; + } case "keepassgo-page-ready": if (Number.isInteger(sender?.tab?.id)) { sendResponse(await refreshPageState(sender.tab.id, sender.tab.url, { diff --git a/browser/extension/background.test.cjs b/browser/extension/background.test.cjs index c6d7e4e..ee48b2b 100644 --- a/browser/extension/background.test.cjs +++ b/browser/extension/background.test.cjs @@ -49,6 +49,128 @@ test("tokenPendingApprovalCount reads token-scoped approval state", () => { assert.equal(background.tokenPendingApprovalCount({}), 0); }); +test("shouldContinueWatchingState keeps polling locked login pages", () => { + assert.equal(background.shouldContinueWatchingState({ + pageHasLoginForm: true, + pendingFill: false, + status: { locked: true } + }), true); + assert.equal(background.shouldContinueWatchingState({ + pageHasLoginForm: true, + pendingFill: true, + status: { locked: false } + }), true); + assert.equal(background.shouldContinueWatchingState({ + pageHasLoginForm: true, + pendingFill: false, + status: { locked: false } + }), false); +}); + test("default settings include a blank bearer token that can be overridden by harness patching", () => { assert.equal(background.defaultSettings.bearerToken, ""); + assert.equal(background.defaultSettings.bestMatchOnly, false); + assert.equal(background.defaultSettings.requireSchemeMatch, false); + assert.equal(background.defaultSettings.sortResults, "quality"); +}); + +test("savePlanForObservedLogin prefers updating an exact username match", () => { + const plan = background.savePlanForObservedLogin({ + username: "dannyocean", + password: "bellagio-safe", + url: "https://vault.example.invalid/login" + }, [ + { + id: "vault-console", + title: "Vault Console", + username: "dannyocean", + url: "vault.example.invalid", + path: ["Crew", "Internet"] + }, + { + id: "bellagio-backup", + title: "Bellagio Backup", + username: "rustyryan", + url: "vault.example.invalid", + path: ["Crew", "Internet"] + } + ]); + + assert.deepEqual(plan, { + mode: "update", + entryId: "vault-console", + title: "Vault Console", + path: ["Crew", "Internet"], + username: "dannyocean", + password: "bellagio-safe", + url: "https://vault.example.invalid/login" + }); +}); + +test("savePlanForObservedLogin falls back to saving into the current page group", () => { + const plan = background.savePlanForObservedLogin({ + username: "linuscaldwell", + password: "yellow-chip", + url: "https://vault.example.invalid/login" + }, [ + { + id: "vault-console", + title: "Vault Console", + username: "dannyocean", + url: "vault.example.invalid", + path: ["Crew", "Internet"] + } + ]); + + assert.deepEqual(plan, { + mode: "save", + entryId: "", + title: "vault.example.invalid", + path: ["Crew", "Internet"], + username: "linuscaldwell", + password: "yellow-chip", + url: "https://vault.example.invalid/login" + }); +}); + +test("applyMatchControls keeps only the strongest quality band when best-match-only is enabled", () => { + const filtered = background.applyMatchControls([ + { id: "livingston", title: "Livingston Dell", quality: "exact" }, + { id: "rusty", title: "Rusty Ryan", quality: "host" }, + { id: "linus", title: "Linus Caldwell", quality: "scheme" } + ], { + bestMatchOnly: true, + requireSchemeMatch: false, + sortResults: "quality" + }, "https://vault.example.invalid/login"); + + assert.deepEqual(filtered.map((match) => match.id), ["livingston"]); +}); + +test("applyMatchControls removes explicit scheme mismatches but keeps scheme-less matches", () => { + const filtered = background.applyMatchControls([ + { id: "yen", title: "The Amazing Yen", url: "https://vault.example.invalid/login", quality: "exact" }, + { id: "saul", title: "Saul Bloom", url: "http://vault.example.invalid/login", quality: "exact" }, + { id: "basher", title: "Basher Tarr", url: "vault.example.invalid", quality: "host" } + ], { + bestMatchOnly: false, + requireSchemeMatch: true, + sortResults: "quality" + }, "https://vault.example.invalid/login"); + + assert.deepEqual(filtered.map((match) => match.id), ["yen", "basher"]); +}); + +test("applyMatchControls sorts by path when requested", () => { + const filtered = background.applyMatchControls([ + { id: "linus", title: "Linus Caldwell", path: ["Crew", "Inside"] }, + { id: "rusty", title: "Rusty Ryan", path: ["Crew", "Casino"] }, + { id: "danny", title: "Danny Ocean", path: ["Crew"] } + ], { + bestMatchOnly: false, + requireSchemeMatch: false, + sortResults: "path" + }, "https://vault.example.invalid/login"); + + assert.deepEqual(filtered.map((match) => match.id), ["danny", "rusty", "linus"]); }); diff --git a/browser/extension/content.js b/browser/extension/content.js index cc09521..cf3b936 100644 --- a/browser/extension/content.js +++ b/browser/extension/content.js @@ -396,6 +396,22 @@ function fillCredential(credential, targetDescriptor) { return { ok: true }; } +function submittedCredential(candidate, rawURL) { + if (!candidate?.passwordInput) { + return null; + } + const password = String(candidate.passwordInput.value || "").trim(); + if (!password) { + return null; + } + return { + title: domainLabel(rawURL), + username: String(candidate.usernameInput?.value || "").trim(), + password, + url: String(rawURL || "").trim() + }; +} + function domainLabel(rawURL) { try { return new URL(rawURL).host || ""; @@ -429,6 +445,7 @@ function shouldShowInlineOverlay(state, hasTarget, suppressed, idleHidden) { state?.pageHasLoginForm && ( state?.pendingFill || + (state?.configured && state?.success && state?.status?.locked) || (state?.configured && state?.success && !state?.status?.locked && Array.isArray(state?.matches) && state.matches.length > 0) ) ); @@ -446,7 +463,8 @@ const contentTestExports = { fieldHintText, scopeHintText, hasAuthFlowSignals, - authFlowCandidate + authFlowCandidate, + submittedCredential }; if (isNodeTestEnv) { @@ -727,10 +745,13 @@ if (isNodeTestEnv) { ensureRootMounted(); dock.style.display = "block"; - trigger.dataset.tone = pageState.pendingFill ? "warning" : (pageState.error ? "error" : "ready"); + trigger.dataset.tone = pageState.pendingFill || pageState.status?.locked ? "warning" : (pageState.error ? "error" : "ready"); if (pageState.pendingFill) { meta.textContent = "Approval needed in KeePassGO"; panelCopy.textContent = pageState.pendingMessage || "Approve or deny the fill request in KeePassGO."; + } else if (pageState.status?.locked) { + meta.textContent = "Unlock KeePassGO"; + panelCopy.textContent = "Unlock KeePassGO to turn this field back into live login suggestions."; } else { const count = Array.isArray(pageState.matches) ? pageState.matches.length : 0; meta.textContent = count === 1 ? "1 login ready" : `${count} logins ready`; @@ -801,6 +822,23 @@ if (isNodeTestEnv) { scheduleRefresh(false); }, true); + document.addEventListener("submit", (event) => { + const form = event.target instanceof HTMLFormElement ? event.target : null; + if (!form) { + return; + } + const passwordInput = visibleInputs(form).find(isPasswordCandidate) || null; + const candidate = passwordInput ? authFlowCandidate(passwordInput) : null; + const observed = submittedCredential(candidate, window.location.href); + if (!observed) { + return; + } + void runtimeSend({ + type: "keepassgo-observed-login", + observed + }).catch(() => null); + }, true); + document.addEventListener("click", (event) => { if (!root.contains(event.target)) { chooserOpen = false; diff --git a/browser/extension/content.test.cjs b/browser/extension/content.test.cjs index ecee94d..1e0166a 100644 --- a/browser/extension/content.test.cjs +++ b/browser/extension/content.test.cjs @@ -94,6 +94,19 @@ test("shouldShowInlineOverlay hides the page overlay after it is suppressed", () assert.equal(content.shouldShowInlineOverlay(state, true, true, false), false); }); +test("shouldShowInlineOverlay stays visible for locked login pages", () => { + const state = { + pageHasLoginForm: true, + configured: true, + success: true, + status: { locked: true }, + matches: [], + pendingFill: false + }; + + assert.equal(content.shouldShowInlineOverlay(state, true, false, false), true); +}); + test("shouldShowInlineOverlay hides the page overlay after idle expiry", () => { const state = { pageHasLoginForm: true, @@ -107,3 +120,17 @@ test("shouldShowInlineOverlay hides the page overlay after idle expiry", () => { assert.equal(content.shouldShowInlineOverlay(state, true, false, false), true); assert.equal(content.shouldShowInlineOverlay(state, true, false, true), false); }); + +test("submittedCredential captures the pending browser save payload from a login candidate", () => { + const candidate = { + usernameInput: { value: "linuscaldwell" }, + passwordInput: { value: "yellow-chip" } + }; + + assert.deepEqual(content.submittedCredential(candidate, "https://bellagio.example.invalid/login"), { + title: "bellagio.example.invalid", + username: "linuscaldwell", + password: "yellow-chip", + url: "https://bellagio.example.invalid/login" + }); +}); diff --git a/browser/extension/icons/icon-128.png b/browser/extension/icons/icon-128.png new file mode 100644 index 0000000..8602f77 Binary files /dev/null and b/browser/extension/icons/icon-128.png differ diff --git a/browser/extension/icons/icon-16.png b/browser/extension/icons/icon-16.png new file mode 100644 index 0000000..e976c5e Binary files /dev/null and b/browser/extension/icons/icon-16.png differ diff --git a/browser/extension/icons/icon-32.png b/browser/extension/icons/icon-32.png new file mode 100644 index 0000000..c1faec1 Binary files /dev/null and b/browser/extension/icons/icon-32.png differ diff --git a/browser/extension/icons/icon-48.png b/browser/extension/icons/icon-48.png new file mode 100644 index 0000000..2144944 Binary files /dev/null and b/browser/extension/icons/icon-48.png differ diff --git a/browser/extension/icons/icon-96.png b/browser/extension/icons/icon-96.png new file mode 100644 index 0000000..00c1a42 Binary files /dev/null and b/browser/extension/icons/icon-96.png differ diff --git a/browser/extension/manifest.firefox.json b/browser/extension/manifest.firefox.json index e652fad..1c15b6e 100644 --- a/browser/extension/manifest.firefox.json +++ b/browser/extension/manifest.firefox.json @@ -3,6 +3,13 @@ "name": "KeePassGO Browser", "version": "0.1.0", "description": "Fill credentials from KeePassGO on sign-in pages.", + "icons": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png", + "48": "icons/icon-48.png", + "96": "icons/icon-96.png", + "128": "icons/icon-128.png" + }, "permissions": [ "activeTab", "nativeMessaging", @@ -16,6 +23,10 @@ }, "browser_action": { "default_title": "KeePassGO Browser", + "default_icon": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png" + }, "default_popup": "popup.html" }, "options_ui": { @@ -31,7 +42,14 @@ ], "browser_specific_settings": { "gecko": { - "id": "browser@keepassgo.com" + "id": "browser@keepassgo.com", + "data_collection_permissions": { + "required": ["authenticationInfo", "websiteActivity"] + }, + "strict_min_version": "140.0" + }, + "gecko_android": { + "strict_min_version": "142.0" } } } diff --git a/browser/extension/options.html b/browser/extension/options.html index 1f9a6b1..ce5631c 100644 --- a/browser/extension/options.html +++ b/browser/extension/options.html @@ -19,6 +19,25 @@ API token +
+ Browser Matching + + + +
diff --git a/browser/extension/options.js b/browser/extension/options.js index 2cdd4ed..faee9ec 100644 --- a/browser/extension/options.js +++ b/browser/extension/options.js @@ -23,6 +23,9 @@ async function loadSettings() { throw new Error(response?.error || "Could not load settings."); } document.getElementById("bearer-token").value = response.settings.bearerToken || ""; + document.getElementById("best-match-only").checked = Boolean(response.settings.bestMatchOnly); + document.getElementById("require-scheme-match").checked = Boolean(response.settings.requireSchemeMatch); + document.getElementById("sort-results").value = response.settings.sortResults || "quality"; } async function saveSettings(event) { @@ -33,7 +36,10 @@ async function saveSettings(event) { const response = await runtimeSend({ type: "keepassgo-save-settings", settings: { - bearerToken: document.getElementById("bearer-token").value + bearerToken: document.getElementById("bearer-token").value, + bestMatchOnly: document.getElementById("best-match-only").checked, + requireSchemeMatch: document.getElementById("require-scheme-match").checked, + sortResults: document.getElementById("sort-results").value } }); if (!response?.success) { diff --git a/browser/extension/popup.html b/browser/extension/popup.html index 8df2a02..c8401f8 100644 --- a/browser/extension/popup.html +++ b/browser/extension/popup.html @@ -20,10 +20,25 @@

Checking KeePassGO.

Loading page state.

+

Matches

+
+

Search Vault

+
+ + +
+
+
diff --git a/browser/extension/popup.js b/browser/extension/popup.js index 71c01b8..de456e3 100644 --- a/browser/extension/popup.js +++ b/browser/extension/popup.js @@ -43,21 +43,25 @@ function matchSubtitle(match) { return parts.join(" · ") || "No username"; } -function renderMatches(state) { - const root = document.getElementById("matches"); +function saveCardLabel(pendingSave) { + return pendingSave?.mode === "update" + ? `Update ${pendingSave.title || "Login"}` + : "Save Login"; +} + +function renderMatchList(root, matches, options = {}) { const targetTabID = popupTabID(); + const emptyMessage = options.emptyMessage || "No matching entries."; root.textContent = ""; - if (!Array.isArray(state.matches) || state.matches.length === 0) { + if (!Array.isArray(matches) || matches.length === 0) { const empty = document.createElement("p"); empty.className = "subtle"; - empty.textContent = state.pageHasLoginForm - ? "No matching entries for this page." - : "No login fields detected on this page."; + empty.textContent = emptyMessage; root.appendChild(empty); return; } - for (const match of state.matches) { + for (const match of matches) { const row = document.createElement("button"); row.type = "button"; row.className = "match-row"; @@ -77,19 +81,23 @@ function renderMatches(state) { row.appendChild(quality); row.addEventListener("click", async () => { row.disabled = true; - setStatus("Approval may be required", "KeePassGO will prompt if this token needs approval before fill.", "warning"); try { - const result = await runtimeSend({ - type: "keepassgo-fill-entry", - entryId: match.id, - tabId: targetTabID - }); - if (!result?.success) { - throw new Error(result?.error || "Fill failed."); + if (typeof options.onSelect === "function") { + await options.onSelect(match, targetTabID); + } else { + setStatus("Approval may be required", "KeePassGO will prompt if this token needs approval before fill.", "warning"); + const result = await runtimeSend({ + type: "keepassgo-fill-entry", + entryId: match.id, + tabId: targetTabID + }); + if (!result?.success) { + throw new Error(result?.error || "Fill failed."); + } + setStatus("Filled", `${match.title} was sent to the current page.`, "ready"); } - setStatus("Filled", `${match.title} was sent to the current page.`, "ready"); } catch (error) { - setStatus("Fill failed", error instanceof Error ? error.message : String(error), "error"); + setStatus(options.onSelect ? "Save failed" : "Fill failed", error instanceof Error ? error.message : String(error), "error"); } finally { row.disabled = false; } @@ -98,6 +106,51 @@ function renderMatches(state) { } } +function renderMatches(state) { + const emptyMessage = state.pageHasLoginForm + ? "No matching entries for this page." + : "No login fields detected on this page."; + const root = document.getElementById("matches"); + if (state.pendingSave) { + renderMatchList(root, state.matches, { + emptyMessage, + onSelect: async (match, targetTabID) => { + const result = await runtimeSend({ + type: "keepassgo-save-login", + tabId: targetTabID, + selectedMatch: { + id: match.id, + title: match.title, + path: Array.isArray(match.path) ? match.path : [] + } + }); + if (!result?.success) { + throw new Error(result?.error || "Save failed."); + } + setStatus("Saved", `${state.pendingSave.title || "Login"} is now in KeePassGO.`, "ready"); + document.getElementById("save-card").hidden = true; + } + }); + return; + } + renderMatchList(root, state.matches, { emptyMessage }); +} + +function renderSearchResults(results, query) { + const root = document.getElementById("search-results"); + if (!query) { + root.textContent = ""; + const hint = document.createElement("p"); + hint.className = "subtle"; + hint.textContent = "Search all entries you can access with this token."; + root.appendChild(hint); + return; + } + renderMatchList(root, results, { + emptyMessage: `No entries matched "${query}".` + }); +} + function renderPageHint(state) { const hint = document.getElementById("page-hint"); if (state.pendingFill) { @@ -115,6 +168,46 @@ function renderPageHint(state) { hint.textContent = "Open a sign-in page to see KeePassGO suggestions here."; } +function renderPendingSave(state) { + const card = document.getElementById("save-card"); + const message = document.getElementById("save-message"); + const action = document.getElementById("save-action"); + const pendingSave = state.pendingSave; + if (!pendingSave) { + card.hidden = true; + action.onclick = null; + return; + } + card.hidden = false; + action.textContent = saveCardLabel(pendingSave); + if (pendingSave.mode === "update") { + message.textContent = `KeePassGO can update ${pendingSave.title || "this login"} with the submitted password.`; + } else if (Array.isArray(pendingSave.path) && pendingSave.path.length > 0) { + message.textContent = `KeePassGO can save this login in ${pendingSave.path.join(" / ")}. Search the vault to choose a different group if needed.`; + } else { + message.textContent = "Search the vault below to choose a group for this submitted login."; + } + action.disabled = pendingSave.mode !== "update" && (!Array.isArray(pendingSave.path) || pendingSave.path.length === 0); + action.onclick = async () => { + action.disabled = true; + try { + const result = await runtimeSend({ + type: "keepassgo-save-login", + tabId: popupTabID() + }); + if (!result?.success) { + throw new Error(result?.error || "Save failed."); + } + setStatus("Saved", `${pendingSave.title || "Login"} is now in KeePassGO.`, "ready"); + card.hidden = true; + } catch (error) { + setStatus("Save failed", error instanceof Error ? error.message : String(error), "error"); + } finally { + action.disabled = false; + } + }; +} + function popupTabID() { const rawValue = new URLSearchParams(window.location.search).get("tabId"); if (rawValue === null) { @@ -124,8 +217,38 @@ function popupTabID() { return Number.isInteger(parsed) ? parsed : null; } +async function searchVault(event) { + event.preventDefault(); + const query = document.getElementById("search-query").value.trim(); + const resultsRoot = document.getElementById("search-results"); + if (!query) { + renderSearchResults([], ""); + return; + } + resultsRoot.textContent = ""; + const loading = document.createElement("p"); + loading.className = "subtle"; + loading.textContent = "Searching KeePassGO…"; + resultsRoot.appendChild(loading); + try { + const response = await runtimeSend({ + type: "keepassgo-search-logins", + query + }); + if (!response?.success) { + throw new Error(response?.error || "Search failed."); + } + renderSearchResults(Array.isArray(response.results) ? response.results : [], query); + } catch (error) { + renderSearchResults([], query); + setStatus("Search failed", error instanceof Error ? error.message : String(error), "error"); + } +} + async function main() { try { + document.getElementById("search-form").addEventListener("submit", searchVault); + renderSearchResults([], ""); const state = await runtimeSend({ type: "keepassgo-popup-state", force: true, @@ -133,6 +256,7 @@ async function main() { }); document.getElementById("page-host").textContent = hostFromURL(state.pageUrl || ""); renderPageHint(state); + renderPendingSave(state); if (!state.configured) { setStatus("Configure access", state.error || "Set the API token in extension settings.", "warning"); @@ -158,6 +282,8 @@ async function main() { const count = Array.isArray(state.matches) ? state.matches.length : 0; if (!state.pageHasLoginForm) { setStatus("Ready", "KeePassGO is connected. Open a login form to check for matches.", "ready"); + } else if (state.pendingSave) { + setStatus("Save submitted login", state.pendingSave.mode === "update" ? `Update ${state.pendingSave.title || "this login"} or pick a different target below.` : "Save this submitted login or search below to choose a target entry.", "ready"); } else if (count === 0) { setStatus("Checked this page", "KeePassGO did not find a matching login for this form.", "ready"); } else { diff --git a/browser/extension/style.css b/browser/extension/style.css index 63228d5..9d4ed52 100644 --- a/browser/extension/style.css +++ b/browser/extension/style.css @@ -96,6 +96,29 @@ h2 { gap: 8px; } +.search-section { + margin-top: 16px; +} + +.save-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 14px; + margin: 0 0 16px; + border: 1px solid #c5dccf; + border-radius: 12px; + background: var(--accent-soft); +} + +.search-form { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + margin-bottom: 10px; +} + .match-row, button, .link-button { @@ -154,7 +177,8 @@ label { } input, -textarea { +textarea, +select { width: 100%; padding: 10px 12px; border: 1px solid var(--line); @@ -164,6 +188,33 @@ textarea { font: inherit; } +fieldset { + margin: 0; + padding: 12px; + border: 1px solid var(--line); + border-radius: 12px; + display: grid; + gap: 12px; +} + +legend { + padding: 0 6px; + color: var(--ink-soft); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.checkbox-row { + display: flex; + align-items: center; + gap: 10px; +} + +.checkbox-row input { + width: auto; +} + button, .link-button { padding: 10px 14px; diff --git a/docs/android-autofill.md b/docs/android-autofill.md new file mode 100644 index 0000000..32ef869 --- /dev/null +++ b/docs/android-autofill.md @@ -0,0 +1,78 @@ +# Android Autofill + +## App Target Matching + +User story: + +- When an entry carries an Android-specific target such as + `androidapp://com.blinknetwork.mobile2`, KeePassGO should treat that as a + first-class autofill target on Android. +- If an exact app target exists, Android autofill should resolve that entry + directly instead of falling back to a generic chooser for the whole cache. + +Expected behavior: + +- `AndroidApp*` custom fields exported into the autofill cache must match the + Android package target used by the autofill and accessibility services. +- The Android-side matcher must normalize `androidapp://...` targets the same + way the Go cache builder does. +- The chooser path should still collapse to a single direct result when there + is one exact app-target match. + +## Accessibility Fallback + +User story: + +- When Android accessibility fallback is needed, KeePassGO should not be + limited to Chrome-only URL bar parsing. +- Apps with stable package identities should still be fillable when an entry + carries a matching `AndroidApp*` target. + +Expected behavior: + +- Accessibility fallback derives its match target from the web domain when one + is available. +- If no web domain is available, accessibility fallback uses the active app + package as `androidapp://`. +- The fallback path can therefore fill supported apps that never expose a + browser-style URL bar. + +## Share-Driven Lookup + +User story: + +- When Android shares a login URL or a text snippet containing a login URL into + KeePassGO, the app should open into a credential lookup flow instead of only + supporting shared `.kdbx` imports. +- If the vault is already open, the shared target should immediately narrow the + entries view. +- If the vault is not open yet, the shared target should survive startup and + apply as soon as the vault is unlocked. + +Expected behavior: + +- Android share intents can queue a pending lookup target in addition to shared + vault file imports. +- KeePassGO normalizes the shared value into a search query that users can + immediately act on. +- The pending lookup is consumed once and does not keep reappearing on later + launches. + +## Chooser Relevance + +User story: + +- When Android autofill cannot resolve a single direct match, KeePassGO should + still keep the chooser focused on entries that are relevant to the current + site or app. +- The picker should not fall back to an alphabetized dump of unrelated vault + entries when KeePassGO already knows the current host or package target. + +Expected behavior: + +- If multiple entries exactly match the current web host or Android app target, + the chooser shows only those relevant entries. +- If there are no exact matches but there are parent-host matches, the chooser + shows only those related entries. +- KeePassGO falls back to the full chooser list only when it has no related + host or app-target candidates at all. diff --git a/docs/browser-extension.md b/docs/browser-extension.md index 17abca2..5ced05b 100644 --- a/docs/browser-extension.md +++ b/docs/browser-extension.md @@ -93,6 +93,89 @@ Chromium / Chrome: - Username and password fields get an inline KeePassGO affordance that opens a candidate chooser anchored to the focused field and keeps fills scoped to that field's form when possible. - If a fill request needs user approval, the extension keeps the pending state visible in both the page affordance and the popup until KeePassGO resolves it, using the token-scoped pending-approval count from the local gRPC API. +## Search And Matching + +User story: + +- When a page has no obvious match, the popup still lets the user search the + vault without leaving the browser. +- Search results must stay scoped to what the current API token can actually + access. +- Browser matching must treat common KeePass data conventions as real browser + targets, not just the primary `URL` field. +- Users need explicit control over how aggressive browser matching should be so + they can prefer narrow page suggestions or broader candidate lists without + reconfiguring KeePassGO itself. + +Expected behavior: + +- The popup exposes a `Search Vault` field that queries KeePassGO directly. +- Search results use the same fill path as page matches. +- Search never leaks entries outside the token's authorized group scope. +- A browser match can come from: + - the primary `URL` field + - scheme-less host values such as `gitlab.com` + - custom URL fields such as `URL1`, `URL2`, and similar KeePass-style URL + slots +- The extension settings page exposes browser match controls for: + - `Best match only` + - `Require current scheme` + - `Sort results` +- `Best match only` limits page suggestions to the strongest quality band + returned by KeePassGO instead of showing every candidate for the host. +- `Require current scheme` hides `http` credentials on `https` pages and + vice versa when an entry stores an explicit scheme, while still allowing + scheme-less host entries to match either page. +- `Sort results` affects both page suggestions and popup search results so the + user can prefer: + - KeePassGO match quality first + - title order + - path order + +## Locked Vault Workflow + +User story: + +- When the current page has a login form but KeePassGO is locked, the browser + must still make that state visible on the page and in the popup. +- Unlocking KeePassGO should not require the user to reopen the popup multiple + times or reload the page before the extension becomes usable again. + +Expected behavior: + +- The popup shows a locked-state message instead of silently falling back to + "no matches." +- The inline page affordance stays visible on login forms while KeePassGO is + locked and tells the user to unlock the vault. +- After the vault is unlocked, the extension rechecks the page automatically + and turns the locked affordance back into live matches without requiring a + page reload. + +## Save And Update Workflow + +User story: + +- After the user submits a login form, the browser extension should help store + that credential instead of forcing the user back into KeePassGO manually. +- If KeePassGO already has a matching entry for that site and username, the + popup should offer an update. +- If the user is creating a new login, the popup should let the user save it + into a relevant vault group without leaving the browser. + +Expected behavior: + +- Submitted login forms queue a pending browser save/update state for the + active tab. +- The popup shows that pending save/update state prominently instead of hiding + it behind page matches alone. +- When KeePassGO finds an exact browser match for the submitted username and + site, the popup offers an `Update` action for that entry. +- When there is no exact entry match, the popup offers a `Save` action using a + relevant group path from the current page matches or a user-selected search + result. +- The browser save/update action writes through KeePassGO's existing secure + gRPC mutation API and stays scoped to the browser token's allowed groups. + For extension-side regression checks, run: ```bash diff --git a/internal/api/server.go b/internal/api/server.go index 469ee4b..9001593 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -275,7 +275,7 @@ func (s *Server) FindBrowserLogins(ctx context.Context, req *keepassgov1.FindBro var matches []rankedBrowserMatch for _, entry := range displayModel.Entries { - quality, score := classifyBrowserEntryMatch(pageHost, entry.URL) + quality, score := classifyBrowserEntry(pageHost, entry) if score == 0 { continue } @@ -390,7 +390,7 @@ func (s *Server) GetBrowserCredential(ctx context.Context, req *keepassgov1.GetB if err != nil { return nil, status.Error(codes.InvalidArgument, err.Error()) } - if _, score := classifyBrowserEntryMatch(pageHost, entry.URL); score == 0 { + if _, score := classifyBrowserEntry(pageHost, entry); score == 0 { return nil, status.Error(codes.InvalidArgument, "entry url does not match requested page") } } @@ -446,19 +446,22 @@ func (s *Server) ListEntries(ctx context.Context, req *keepassgov1.ListEntriesRe } displayModel := visibleModel(model) internalPath := expandClientPath(displayModel, req.GetPath()) - if _, err := s.authorizePathRequest(ctx, apitokens.OperationListEntries, internalPath); err != nil { - return nil, err - } - model = displayModel var entries []vault.Entry if strings.TrimSpace(req.GetQuery()) != "" { + token, err := s.authenticateRequest(ctx) + if err != nil { + return nil, err + } results := model.Search(req.GetQuery()) - entries = make([]vault.Entry, 0, len(results)) - for _, result := range results { - entries = append(entries, result.Entry) + entries, err = s.authorizedSearchEntries(ctx, model, token, internalPath, results) + if err != nil { + return nil, err } } else { + if _, err := s.authorizePathRequest(ctx, apitokens.OperationListEntries, internalPath); err != nil { + return nil, err + } entries = model.EntriesInPath(internalPath) } @@ -472,6 +475,49 @@ func (s *Server) ListEntries(ctx context.Context, req *keepassgov1.ListEntriesRe return resp, nil } +func (s *Server) authorizedSearchEntries(ctx context.Context, model vault.Model, token apitokens.Token, path []string, results []vault.SearchResult) ([]vault.Entry, error) { + entries := make([]vault.Entry, 0, len(results)) + var promptResource *apitokens.Resource + for _, result := range results { + entry := result.Entry + if !hasPathPrefix(path, entry.Path) { + continue + } + resource := apitokens.Resource{Kind: apitokens.ResourceGroup, Path: entry.Path} + switch evaluateAuthorization(model, token, apitokens.OperationListEntries, resource) { + case apitokens.DecisionAllow: + entries = append(entries, entry) + case apitokens.DecisionPrompt: + if promptResource == nil { + candidate := resource + promptResource = &candidate + } + } + } + if len(entries) != 0 || promptResource == nil { + return entries, nil + } + if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationListEntries, *promptResource); err != nil { + return nil, err + } + return authorizedSearchEntriesWithinPath(path, promptResource.Path, results), nil +} + +func authorizedSearchEntriesWithinPath(requestPath, approvedPath []string, results []vault.SearchResult) []vault.Entry { + entries := make([]vault.Entry, 0, len(results)) + for _, result := range results { + entry := result.Entry + if !hasPathPrefix(requestPath, entry.Path) { + continue + } + if !hasPathPrefix(approvedPath, entry.Path) { + continue + } + entries = append(entries, entry) + } + return entries +} + func (s *Server) ListGroups(ctx context.Context, req *keepassgov1.ListGroupsRequest) (*keepassgov1.ListGroupsResponse, error) { model, locked := s.snapshotModel() if locked { @@ -1063,6 +1109,52 @@ func normalizedBrowserEntryHost(raw string) string { return "" } +func browserURLFieldKey(key string) bool { + if len(key) <= len("URL") || !strings.EqualFold(key[:len("URL")], "URL") { + return false + } + for _, r := range key[len("URL"):] { + if r < '0' || r > '9' { + return false + } + } + return true +} + +func browserEntryURLs(entry vault.Entry) []string { + urls := make([]string, 0, 1+len(entry.Fields)) + if raw := strings.TrimSpace(entry.URL); raw != "" { + urls = append(urls, raw) + } + if len(entry.Fields) == 0 { + return urls + } + keys := slices.Collect(maps.Keys(entry.Fields)) + slices.Sort(keys) + for _, key := range keys { + if !browserURLFieldKey(key) { + continue + } + if raw := strings.TrimSpace(entry.Fields[key]); raw != "" { + urls = append(urls, raw) + } + } + return urls +} + +func classifyBrowserEntry(pageHost string, entry vault.Entry) (string, int) { + bestQuality := "" + bestScore := 0 + for _, rawURL := range browserEntryURLs(entry) { + quality, score := classifyBrowserEntryMatch(pageHost, rawURL) + if score > bestScore { + bestQuality = quality + bestScore = score + } + } + return bestQuality, bestScore +} + func classifyBrowserEntryMatch(pageHost, rawEntryURL string) (string, int) { entryHost := normalizedBrowserEntryHost(rawEntryURL) if entryHost == "" { @@ -1078,6 +1170,13 @@ func classifyBrowserEntryMatch(pageHost, rawEntryURL string) (string, int) { } } +func hasPathPrefix(prefix, path []string) bool { + if len(prefix) > len(path) { + return false + } + return slices.Equal(prefix, path[:len(prefix)]) +} + func visibleModel(model vault.Model) vault.Model { out := model out.Entries = nil diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 85fb51b..3668952 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -294,6 +294,55 @@ func TestVaultServiceFindsBrowserLoginsForSchemeLessEntryURLs(t *testing.T) { } } +func TestVaultServiceFindsBrowserLoginsForCustomURLFields(t *testing.T) { + t.Parallel() + + client, _, cleanup := newTestClientForModel(t, vault.Model{ + Entries: []vault.Entry{ + { + ID: "night-fox-gitlab", + Title: "Night Fox GitLab", + Username: "nightfox", + Password: "vault-code", + Path: []string{"Root", "Internet"}, + Fields: map[string]string{ + "URL1": "gitlab.com", + }, + }, + testAPITokenEntry(t, + apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, + apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, + apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, + ), + }, + }) + defer cleanup() + + resp, err := client.FindBrowserLogins(tokenContext(defaultTestTokenSecret), &keepassgov1.FindBrowserLoginsRequest{ + PageUrl: "https://gitlab.com/users/sign_in", + }) + if err != nil { + t.Fatalf("FindBrowserLogins() error = %v", err) + } + if len(resp.Matches) != 1 { + t.Fatalf("len(FindBrowserLogins().Matches) = %d, want 1", len(resp.Matches)) + } + if resp.Matches[0].Id != "night-fox-gitlab" { + t.Fatalf("FindBrowserLogins().Matches[0].Id = %q, want night-fox-gitlab", resp.Matches[0].Id) + } + + credential, err := client.GetBrowserCredential(tokenContext(defaultTestTokenSecret), &keepassgov1.GetBrowserCredentialRequest{ + Id: "night-fox-gitlab", + PageUrl: "https://gitlab.com/users/sign_in", + }) + if err != nil { + t.Fatalf("GetBrowserCredential() error = %v", err) + } + if credential.GetId() != "night-fox-gitlab" { + t.Fatalf("GetBrowserCredential().Id = %q, want night-fox-gitlab", credential.GetId()) + } +} + func TestVaultServiceFindsBrowserLoginsWithinAuthorizedGroupScope(t *testing.T) { t.Parallel() @@ -1203,6 +1252,51 @@ func TestVaultServiceListsEntriesForAuthorizedClients(t *testing.T) { } } +func TestVaultServiceSearchesEntriesWithinAuthorizedScope(t *testing.T) { + t.Parallel() + + client, _, cleanup := newTestClientForModel(t, vault.Model{ + Entries: []vault.Entry{ + { + ID: "turk-codex", + Title: "Turk Codex GitLab", + Username: "basher", + Password: "chip-stack", + URL: "https://gitlab.com", + Path: []string{"keepass", "Joe", "codex"}, + }, + { + ID: "rusty-internet", + Title: "Rusty Internet GitLab", + Username: "rusty", + Password: "bellagio-stack", + URL: "https://gitlab.com", + Path: []string{"keepass", "Joe", "Internet"}, + }, + testAPITokenEntry(t, + apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Joe", "codex"}}}, + ), + }, + }) + defer cleanup() + + resp, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{ + Query: "GitLab", + }) + if err != nil { + t.Fatalf("ListEntries() error = %v", err) + } + if len(resp.Entries) != 1 { + t.Fatalf("len(ListEntries().Entries) = %d, want 1", len(resp.Entries)) + } + if got := resp.Entries[0].Id; got != "turk-codex" { + t.Fatalf("ListEntries().Entries[0].Id = %q, want turk-codex", got) + } + if got := resp.Entries[0].Path; !slices.Equal(got, []string{"Joe", "codex"}) { + t.Fatalf("ListEntries().Entries[0].Path = %v, want [Joe codex]", got) + } +} + func TestVaultServiceListsCreatesAndRenamesGroupsForAuthorizedClients(t *testing.T) { t.Parallel() diff --git a/internal/appui/app.go b/internal/appui/app.go index 170ace8..eb67464 100644 --- a/internal/appui/app.go +++ b/internal/appui/app.go @@ -140,6 +140,7 @@ type statePaths struct { AutofillCachePath string PendingSharedVaultPath string PendingSharedVaultNamePath string + PendingSharedLookupPath string } type recentVaultRecord struct { @@ -474,6 +475,8 @@ type ui struct { autofillCachePath string pendingSharedVaultPath string pendingSharedVaultNamePath string + pendingSharedLookupPath string + pendingSharedLookupQuery string editingEntry bool syncDefaultSourceMode syncSourceMode syncDefaultDirection syncDirection @@ -656,6 +659,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) autofillCachePath: paths.AutofillCachePath, pendingSharedVaultPath: paths.PendingSharedVaultPath, pendingSharedVaultNamePath: paths.PendingSharedVaultNamePath, + pendingSharedLookupPath: paths.PendingSharedLookupPath, recentVaultGroups: map[string][]string{}, recentVaultUsedAt: map[string]time.Time{}, lifecycleAdvancedHidden: true, @@ -704,6 +708,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) u.showStatusMessage("Some saved remote sign-ins came from an older KeePassGO build. Reopen those remotes and save them in the vault to migrate them.") } u.consumePendingSharedVaultImport() + u.consumePendingSharedLookup() u.restoreStartupLifecycleTarget() u.requestMasterPassFocus = u.hasSelectedLifecycleTarget() u.loadUIPreferences() @@ -785,6 +790,7 @@ func defaultStatePaths(stateDir string) statePaths { AutofillCachePath: filepath.Join(baseDir, "autofill-cache.json"), PendingSharedVaultPath: filepath.Join(baseDir, "pending-shared-vault.kdbx"), PendingSharedVaultNamePath: filepath.Join(baseDir, "pending-shared-vault-name.txt"), + PendingSharedLookupPath: filepath.Join(baseDir, "pending-shared-lookup.txt"), } } diff --git a/internal/appui/lifecycle_actions.go b/internal/appui/lifecycle_actions.go index 9fc4617..d065275 100644 --- a/internal/appui/lifecycle_actions.go +++ b/internal/appui/lifecycle_actions.go @@ -4,8 +4,10 @@ import ( "errors" "fmt" "io" + "net/url" "os" "path/filepath" + "regexp" "runtime" "strings" @@ -17,6 +19,8 @@ import ( "git.julianfamily.org/keepassgo/internal/webdav" ) +var pendingSharedLookupURLPattern = regexp.MustCompile(`https?://[^\s<>"']+`) + func (u *ui) createVaultAction() error { key, err := u.currentMasterKey() defer u.clearMasterPassword() @@ -78,6 +82,7 @@ func (u *ui) openVaultAction() error { u.loadSecuritySettingsFromSession() u.editingEntry = false u.filter() + u.applyPendingSharedLookup() u.applyPendingLifecycleOpenIntent() return nil } @@ -120,6 +125,7 @@ func (u *ui) startOpenVaultAction() { u.loadSecuritySettingsFromSession() u.editingEntry = false u.filter() + u.applyPendingSharedLookup() u.applyPendingLifecycleOpenIntent() return nil }, nil @@ -741,6 +747,49 @@ func (u *ui) consumePendingSharedVaultImport() { } } +func normalizePendingSharedLookupQuery(raw string) string { + value := strings.TrimSpace(raw) + if value == "" { + return "" + } + if match := pendingSharedLookupURLPattern.FindString(value); match != "" { + value = match + } + if parsed, err := url.Parse(value); err == nil && strings.TrimSpace(parsed.Hostname()) != "" { + return strings.ToLower(strings.TrimSpace(parsed.Hostname())) + } + return value +} + +func (u *ui) consumePendingSharedLookup() { + path := strings.TrimSpace(u.pendingSharedLookupPath) + if path == "" { + return + } + data, err := os.ReadFile(path) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + u.state.ErrorMessage = fmt.Sprintf("shared lookup: %v", err) + } + return + } + _ = os.Remove(path) + u.pendingSharedLookupQuery = normalizePendingSharedLookupQuery(string(data)) + u.applyPendingSharedLookup() +} + +func (u *ui) applyPendingSharedLookup() { + query := strings.TrimSpace(u.pendingSharedLookupQuery) + status, ok := u.state.Session.(sessionStatus) + if query == "" || !ok || !status.HasVault() || status.IsLocked() { + return + } + u.pendingSharedLookupQuery = "" + u.state.Section = appstate.SectionEntries + u.search.SetText(query) + u.filter() +} + func (u *ui) importSharedVaultBytesAction(name string, content []byte) error { target := u.importedVaultDestination(name) if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil { diff --git a/internal/appui/main_test.go b/internal/appui/main_test.go index 66ce854..9082337 100644 --- a/internal/appui/main_test.go +++ b/internal/appui/main_test.go @@ -8390,6 +8390,9 @@ func TestDefaultStatePathsUsesProvidedStateDir(t *testing.T) { if got := paths.PendingSharedVaultNamePath; got != filepath.Join(base, "pending-shared-vault-name.txt") { t.Fatalf("PendingSharedVaultNamePath = %q, want %q", got, filepath.Join(base, "pending-shared-vault-name.txt")) } + if got := paths.PendingSharedLookupPath; got != filepath.Join(base, "pending-shared-lookup.txt") { + t.Fatalf("PendingSharedLookupPath = %q, want %q", got, filepath.Join(base, "pending-shared-lookup.txt")) + } } func TestImportedVaultDestinationUsesIncomingFilenameInsideDefaultDirectory(t *testing.T) { @@ -8520,6 +8523,95 @@ func TestUIConsumesPendingSharedVaultImportOnStartup(t *testing.T) { } } +func TestUIConsumesPendingSharedLookupOnStartupWhenVaultIsAlreadyOpen(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + paths := statePaths{ + DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"), + RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), + RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"), + UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), + PendingSharedLookupPath: filepath.Join(dir, "pending-shared-lookup.txt"), + } + if err := os.WriteFile(paths.PendingSharedLookupPath, []byte("https://bellagio.example.invalid/login\n"), 0o600); err != nil { + t.Fatalf("WriteFile(PendingSharedLookupPath) error = %v", err) + } + + u := newUIWithSession("phone", &uiSession{model: vault.Model{ + Entries: []vault.Entry{ + {ID: "bellagio-login", Title: "Bellagio", URL: "https://bellagio.example.invalid/login", Path: []string{"Crew", "Internet"}}, + {ID: "vault-console", Title: "Vault Console", URL: "https://vault.example.invalid", Path: []string{"Crew", "Internet"}}, + }, + }}, paths) + + if got := u.search.Text(); got != "bellagio.example.invalid" { + t.Fatalf("search after pending shared lookup = %q, want %q", got, "bellagio.example.invalid") + } + if got := u.filteredTitles(); !slices.Equal(got, []string{"Bellagio"}) { + t.Fatalf("filteredTitles() after pending shared lookup = %v, want [Bellagio]", got) + } + if _, err := os.Stat(paths.PendingSharedLookupPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("Stat(PendingSharedLookupPath) error = %v, want not exist", err) + } +} + +func TestNormalizePendingSharedLookupQueryExtractsURLFromTextSnippet(t *testing.T) { + t.Parallel() + + raw := "Meet the crew at https://bellagio.example.invalid/login before the vault opens." + if got := normalizePendingSharedLookupQuery(raw); got != "bellagio.example.invalid" { + t.Fatalf("normalizePendingSharedLookupQuery() = %q, want %q", got, "bellagio.example.invalid") + } +} + +func TestUIAppliesPendingSharedLookupAfterOpeningVault(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + paths := statePaths{ + DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"), + RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), + RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"), + UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), + PendingSharedLookupPath: filepath.Join(dir, "pending-shared-lookup.txt"), + } + if err := os.WriteFile(paths.PendingSharedLookupPath, []byte("https://bellagio.example.invalid/login\n"), 0o600); err != nil { + t.Fatalf("WriteFile(PendingSharedLookupPath) error = %v", err) + } + + key := vault.MasterKey{Password: "correct horse battery staple"} + vaultPath := filepath.Join(dir, "bellagio.kdbx") + var encoded bytes.Buffer + if err := vault.SaveKDBXWithKey(&encoded, vault.Model{ + Entries: []vault.Entry{ + {ID: "bellagio-login", Title: "Bellagio", URL: "https://bellagio.example.invalid/login", Path: []string{"Crew", "Internet"}}, + {ID: "vault-console", Title: "Vault Console", URL: "https://vault.example.invalid", Path: []string{"Crew", "Internet"}}, + }, + }, key); err != nil { + t.Fatalf("SaveKDBXWithKey() error = %v", err) + } + if err := os.WriteFile(vaultPath, encoded.Bytes(), 0o600); err != nil { + t.Fatalf("WriteFile(vaultPath) error = %v", err) + } + + u := newUIWithState("phone", &session.Manager{}, paths) + if got := u.search.Text(); got != "" { + t.Fatalf("search before open with pending shared lookup = %q, want empty", got) + } + u.vaultPath.SetText(vaultPath) + u.masterPassword.SetText(key.Password) + if err := u.openVaultAction(); err != nil { + t.Fatalf("openVaultAction() with pending shared lookup error = %v", err) + } + if got := u.search.Text(); got != "bellagio.example.invalid" { + t.Fatalf("search after open with pending shared lookup = %q, want %q", got, "bellagio.example.invalid") + } + if got := u.filteredTitles(); !slices.Equal(got, []string{"Bellagio"}) { + t.Fatalf("filteredTitles() after open with pending shared lookup = %v, want [Bellagio]", got) + } +} + func TestUICurrentShareableVaultPathUsesSelectedVaultPath(t *testing.T) { t.Parallel() diff --git a/internal/browserbridge/bridge.go b/internal/browserbridge/bridge.go index 987134c..a1a5798 100644 --- a/internal/browserbridge/bridge.go +++ b/internal/browserbridge/bridge.go @@ -2,12 +2,15 @@ package browserbridge import ( "context" + "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/binary" + "encoding/hex" "encoding/json" "fmt" "io" + "net/url" "os" "path/filepath" "runtime" @@ -28,19 +31,25 @@ const ( ) type Request struct { - Action string `json:"action"` - BearerToken string `json:"bearerToken,omitempty"` - URL string `json:"url,omitempty"` - EntryID string `json:"entryId,omitempty"` + Action string `json:"action"` + BearerToken string `json:"bearerToken,omitempty"` + URL string `json:"url,omitempty"` + EntryID string `json:"entryId,omitempty"` + Query string `json:"query,omitempty"` + Title string `json:"title,omitempty"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + Path []string `json:"path,omitempty"` } type Response struct { - Success bool `json:"success"` - Error string `json:"error,omitempty"` - Status *Status `json:"status,omitempty"` - Matches []Match `json:"matches,omitempty"` - Credential *Credential `json:"credential,omitempty"` - Version string `json:"version,omitempty"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` + Status *Status `json:"status,omitempty"` + Matches []Match `json:"matches,omitempty"` + SearchResults []Match `json:"searchResults,omitempty"` + Credential *Credential `json:"credential,omitempty"` + Version string `json:"version,omitempty"` } type Status struct { @@ -77,11 +86,15 @@ type Connection struct { type Client interface { Status(context.Context) (*keepassgov1.GetSessionStatusResponse, error) FindBrowserLogins(context.Context, string) ([]*keepassgov1.BrowserLoginMatch, error) + ListEntries(context.Context, []string, string) ([]*keepassgov1.Entry, error) GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error) + UpsertEntry(context.Context, *keepassgov1.Entry) (*keepassgov1.Entry, error) } type Browser string +type actionHandler func(context.Context, Client, Request, string) Response + const ( BrowserFirefox Browser = "firefox" BrowserChrome Browser = "chrome" @@ -163,34 +176,70 @@ func HandleRequest(ctx context.Context, req Request, grpcAddr string, client Cli return Response{Success: false, Error: err.Error()} } action := strings.TrimSpace(req.Action) - switch action { - case "status": - status, err := statusResponse(ctx, client, conn.GRPCAddress) - if err != nil { - return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)} - } - return Response{Success: true, Status: status, Version: responseVersion} - case "find-logins": - matches, err := findMatches(ctx, client, req.URL) - if err != nil { - if status := inferredActionStatus(conn.GRPCAddress, err); status != nil { - return Response{Success: true, Status: status, Matches: nil, Version: responseVersion} - } - return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)} - } - return Response{Success: true, Status: availableStatus(conn.GRPCAddress), Matches: matches, Version: responseVersion} - case "get-login": - credential, err := loadCredential(ctx, client, req.EntryID, req.URL) - if err != nil { - if status := inferredActionStatus(conn.GRPCAddress, err); status != nil { - return Response{Success: false, Error: err.Error(), Status: status} - } - return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)} - } - return Response{Success: true, Status: availableStatus(conn.GRPCAddress), Credential: credential, Version: responseVersion} - default: + handler, ok := actionHandlers[action] + if !ok { return Response{Success: false, Error: fmt.Sprintf("unsupported action %q", action)} } + return handler(ctx, client, req, conn.GRPCAddress) +} + +var actionHandlers = map[string]actionHandler{ + "status": handleStatusAction, + "find-logins": handleFindLoginsAction, + "search-logins": handleSearchLoginsAction, + "get-login": handleGetLoginAction, + "save-login": handleSaveLoginAction, +} + +func handleStatusAction(ctx context.Context, client Client, _ Request, grpcAddress string) Response { + status, err := statusResponse(ctx, client, grpcAddress) + if err != nil { + return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(grpcAddress)} + } + return Response{Success: true, Status: status, Version: responseVersion} +} + +func handleFindLoginsAction(ctx context.Context, client Client, req Request, grpcAddress string) Response { + matches, err := findMatches(ctx, client, req.URL) + if err != nil { + if status := inferredActionStatus(grpcAddress, err); status != nil { + return Response{Success: true, Status: status, Matches: nil, Version: responseVersion} + } + return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(grpcAddress)} + } + return Response{Success: true, Status: availableStatus(grpcAddress), Matches: matches, Version: responseVersion} +} + +func handleSearchLoginsAction(ctx context.Context, client Client, req Request, grpcAddress string) Response { + results, err := searchEntries(ctx, client, req.Query) + if err != nil { + if status := inferredActionStatus(grpcAddress, err); status != nil { + return Response{Success: true, Status: status, SearchResults: nil, Version: responseVersion} + } + return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(grpcAddress)} + } + return Response{Success: true, Status: availableStatus(grpcAddress), SearchResults: results, Version: responseVersion} +} + +func handleGetLoginAction(ctx context.Context, client Client, req Request, grpcAddress string) Response { + credential, err := loadCredential(ctx, client, req.EntryID, req.URL) + if err != nil { + if status := inferredActionStatus(grpcAddress, err); status != nil { + return Response{Success: false, Error: err.Error(), Status: status} + } + return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(grpcAddress)} + } + return Response{Success: true, Status: availableStatus(grpcAddress), Credential: credential, Version: responseVersion} +} + +func handleSaveLoginAction(ctx context.Context, client Client, req Request, grpcAddress string) Response { + if err := saveLogin(ctx, client, req); err != nil { + if status := inferredActionStatus(grpcAddress, err); status != nil { + return Response{Success: false, Error: err.Error(), Status: status} + } + return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(grpcAddress)} + } + return Response{Success: true, Status: availableStatus(grpcAddress), Version: responseVersion} } func disconnectedStatus(addr string) *Status { @@ -264,6 +313,113 @@ func loadCredential(ctx context.Context, client Client, entryID, rawURL string) }, nil } +func saveLogin(ctx context.Context, client Client, req Request) error { + if strings.TrimSpace(req.Password) == "" { + return fmt.Errorf("browser save requires a password") + } + if strings.TrimSpace(req.EntryID) != "" { + entries, err := client.ListEntries(ctx, nil, "") + if err != nil { + return err + } + existing := findEntry(entries, req.EntryID) + if existing == nil { + return fmt.Errorf("entry %q was not found", strings.TrimSpace(req.EntryID)) + } + entry := cloneEntry(existing) + entry.Title = coalesceTitle(req.Title, existing.Title, req.URL) + entry.Username = strings.TrimSpace(req.Username) + entry.Password = strings.TrimSpace(req.Password) + entry.Url = strings.TrimSpace(req.URL) + _, err = client.UpsertEntry(ctx, entry) + return err + } + path := append([]string(nil), req.Path...) + if len(path) == 0 { + return fmt.Errorf("browser save requires a target group path") + } + entry := &keepassgov1.Entry{ + Id: newBrowserEntryID(), + Title: coalesceTitle(req.Title, "", req.URL), + Username: strings.TrimSpace(req.Username), + Password: strings.TrimSpace(req.Password), + Url: strings.TrimSpace(req.URL), + Path: path, + Fields: map[string]string{}, + } + _, err := client.UpsertEntry(ctx, entry) + return err +} + +func findEntry(entries []*keepassgov1.Entry, id string) *keepassgov1.Entry { + for _, entry := range entries { + if entry.GetId() == strings.TrimSpace(id) { + return entry + } + } + return nil +} + +func cloneEntry(entry *keepassgov1.Entry) *keepassgov1.Entry { + if entry == nil { + return &keepassgov1.Entry{Fields: map[string]string{}} + } + fields := make(map[string]string, len(entry.GetFields())) + for key, value := range entry.GetFields() { + fields[key] = value + } + return &keepassgov1.Entry{ + Id: entry.GetId(), + Title: entry.GetTitle(), + Username: entry.GetUsername(), + Password: entry.GetPassword(), + Url: entry.GetUrl(), + Notes: entry.GetNotes(), + Tags: append([]string(nil), entry.GetTags()...), + Path: append([]string(nil), entry.GetPath()...), + Fields: fields, + } +} + +func coalesceTitle(title, fallback, rawURL string) string { + if trimmed := strings.TrimSpace(title); trimmed != "" { + return trimmed + } + if trimmed := strings.TrimSpace(fallback); trimmed != "" { + return trimmed + } + if parsed, err := url.Parse(strings.TrimSpace(rawURL)); err == nil && strings.TrimSpace(parsed.Hostname()) != "" { + return strings.ToLower(strings.TrimSpace(parsed.Hostname())) + } + return "Browser Login" +} + +func newBrowserEntryID() string { + var buf [16]byte + if _, err := rand.Read(buf[:]); err != nil { + return fmt.Sprintf("browser-%d", os.Getpid()) + } + return hex.EncodeToString(buf[:]) +} + +func searchEntries(ctx context.Context, client Client, query string) ([]Match, error) { + resp, err := client.ListEntries(ctx, nil, strings.TrimSpace(query)) + if err != nil { + return nil, err + } + out := make([]Match, 0, len(resp)) + for _, entry := range resp { + out = append(out, Match{ + ID: entry.GetId(), + Title: entry.GetTitle(), + Username: entry.GetUsername(), + URL: entry.GetUrl(), + Path: append([]string(nil), entry.GetPath()...), + }) + } + return out, nil +} + func Manifest(browser Browser, binaryPath, extensionID string) (NativeHostManifest, error) { path := strings.TrimSpace(binaryPath) if path == "" { diff --git a/internal/browserbridge/bridge_test.go b/internal/browserbridge/bridge_test.go index 59fd806..f57e4f2 100644 --- a/internal/browserbridge/bridge_test.go +++ b/internal/browserbridge/bridge_test.go @@ -149,6 +149,110 @@ func TestHandleRequestGetLogin(t *testing.T) { } } +func TestHandleRequestSearchLogins(t *testing.T) { + t.Parallel() + + client := &fakeClient{ + entries: []*keepassgov1.Entry{ + {Id: "rusty-gitlab", Title: "Rusty GitLab", Username: "rustyryan", Url: "gitlab.com", Path: []string{"Joe", "Internet"}}, + }, + } + resp := HandleRequest(context.Background(), Request{ + Action: "search-logins", + BearerToken: "secret", + Query: "GitLab", + }, "", client) + if !resp.Success { + t.Fatalf("HandleRequest(search-logins) success = false, error = %q", resp.Error) + } + if len(resp.SearchResults) != 1 || resp.SearchResults[0].ID != "rusty-gitlab" { + t.Fatalf("HandleRequest(search-logins).SearchResults = %#v, want rusty-gitlab", resp.SearchResults) + } +} + +func TestHandleRequestSaveLoginUpdatesExistingEntry(t *testing.T) { + t.Parallel() + + client := &fakeClient{ + entries: []*keepassgov1.Entry{ + { + Id: "vault-console", + Title: "Vault Console", + Username: "dannyocean", + Password: "old-password", + Url: "https://vault.example.invalid/login", + Path: []string{"Crew", "Internet"}, + Fields: map[string]string{ + "URL1": "vault.example.invalid", + "X-Role": "inside-man", + }, + Tags: []string{"vault"}, + Notes: "Original notes stay intact.", + }, + }, + } + + resp := HandleRequest(context.Background(), Request{ + Action: "save-login", + BearerToken: "secret", + EntryID: "vault-console", + Username: "dannyocean", + Password: "new-password", + URL: "https://vault.example.invalid/login", + }, "", client) + if !resp.Success { + t.Fatalf("HandleRequest(save-login update) success = false, error = %q", resp.Error) + } + if client.upserted == nil { + t.Fatal("HandleRequest(save-login update) did not upsert an entry") + } + if got := client.upserted.Id; got != "vault-console" { + t.Fatalf("upserted.Id = %q, want vault-console", got) + } + if got := client.upserted.Password; got != "new-password" { + t.Fatalf("upserted.Password = %q, want new-password", got) + } + if got := client.upserted.Fields["X-Role"]; got != "inside-man" { + t.Fatalf("upserted.Fields[X-Role] = %q, want inside-man", got) + } + if got := client.upserted.Notes; got != "Original notes stay intact." { + t.Fatalf("upserted.Notes = %q, want original notes", got) + } +} + +func TestHandleRequestSaveLoginCreatesNewEntryInChosenPath(t *testing.T) { + t.Parallel() + + client := &fakeClient{} + resp := HandleRequest(context.Background(), Request{ + Action: "save-login", + BearerToken: "secret", + Title: "Bellagio Login", + Username: "linuscaldwell", + Password: "yellow-chip", + URL: "https://bellagio.example.invalid/login", + Path: []string{"Crew", "Internet"}, + }, "", client) + if !resp.Success { + t.Fatalf("HandleRequest(save-login create) success = false, error = %q", resp.Error) + } + if client.upserted == nil { + t.Fatal("HandleRequest(save-login create) did not upsert an entry") + } + if got := client.upserted.Title; got != "Bellagio Login" { + t.Fatalf("upserted.Title = %q, want Bellagio Login", got) + } + if got := client.upserted.Username; got != "linuscaldwell" { + t.Fatalf("upserted.Username = %q, want linuscaldwell", got) + } + if got := client.upserted.Path; !slices.Equal(got, []string{"Crew", "Internet"}) { + t.Fatalf("upserted.Path = %v, want [Crew Internet]", got) + } + if got := client.upserted.Id; got == "" { + t.Fatal("upserted.Id = empty, want generated id") + } +} + func TestHandleRequestFindLoginsInfersLockedStatusFromRPC(t *testing.T) { t.Parallel() @@ -309,10 +413,14 @@ func TestEnsureNativeHostManifestsInstallsFirefoxAndDiscoveredChromium(t *testin type fakeClient struct { status *keepassgov1.GetSessionStatusResponse matches []*keepassgov1.BrowserLoginMatch + entries []*keepassgov1.Entry credential *keepassgov1.GetBrowserCredentialResponse + upserted *keepassgov1.Entry err error matchesErr error + entriesErr error credentialErr error + upsertErr error statusCalls int } @@ -382,6 +490,16 @@ func (f *fakeClient) FindBrowserLogins(context.Context, string) ([]*keepassgov1. return f.matches, nil } +func (f *fakeClient) ListEntries(context.Context, []string, string) ([]*keepassgov1.Entry, error) { + if f.entriesErr != nil { + return nil, f.entriesErr + } + if f.err != nil { + return nil, f.err + } + return f.entries, nil +} + func (f *fakeClient) GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error) { if f.credentialErr != nil { return nil, f.credentialErr @@ -394,3 +512,11 @@ func (f *fakeClient) GetBrowserCredential(context.Context, string, string) (*kee } return f.credential, nil } + +func (f *fakeClient) UpsertEntry(_ context.Context, entry *keepassgov1.Entry) (*keepassgov1.Entry, error) { + if f.upsertErr != nil { + return nil, f.upsertErr + } + f.upserted = entry + return entry, nil +} diff --git a/internal/browserbridge/client.go b/internal/browserbridge/client.go index 8d0f01f..048129b 100644 --- a/internal/browserbridge/client.go +++ b/internal/browserbridge/client.go @@ -65,9 +65,28 @@ func (c *GRPCClient) FindBrowserLogins(ctx context.Context, pageURL string) ([]* return resp.GetMatches(), nil } +func (c *GRPCClient) ListEntries(ctx context.Context, path []string, query string) ([]*keepassgov1.Entry, error) { + resp, err := c.client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{ + Path: append([]string(nil), path...), + Query: strings.TrimSpace(query), + }) + if err != nil { + return nil, err + } + return resp.GetEntries(), nil +} + func (c *GRPCClient) GetBrowserCredential(ctx context.Context, entryID, pageURL string) (*keepassgov1.GetBrowserCredentialResponse, error) { return c.client.GetBrowserCredential(ctx, &keepassgov1.GetBrowserCredentialRequest{ Id: strings.TrimSpace(entryID), PageUrl: strings.TrimSpace(pageURL), }) } + +func (c *GRPCClient) UpsertEntry(ctx context.Context, entry *keepassgov1.Entry) (*keepassgov1.Entry, error) { + resp, err := c.client.UpsertEntry(ctx, &keepassgov1.UpsertEntryRequest{Entry: entry}) + if err != nil { + return nil, err + } + return resp.GetEntry(), nil +} diff --git a/internal/vaultview/root.go b/internal/vaultview/root.go index bd5a09b..8631b39 100644 --- a/internal/vaultview/root.go +++ b/internal/vaultview/root.go @@ -5,10 +5,13 @@ import "git.julianfamily.org/keepassgo/internal/vault" // HiddenRoot returns the single synthetic top-level vault group that should be // treated as an internal storage root rather than as a user-visible group. func HiddenRoot(model vault.Model) string { - if !hasGroup(model.Groups, []string{KeepassRoot}) { - return "" + if hasGroup(model.Groups, []string{KeepassRoot}) { + return KeepassRoot } - return KeepassRoot + if usesTopLevelRoot(model, KeepassRoot) { + return KeepassRoot + } + return "" } func hasGroup(groups [][]string, path []string) bool { diff --git a/internal/vaultview/root_test.go b/internal/vaultview/root_test.go index fee444e..926dc3f 100644 --- a/internal/vaultview/root_test.go +++ b/internal/vaultview/root_test.go @@ -24,3 +24,20 @@ func TestHiddenRootIgnoresRecycleBin(t *testing.T) { t.Fatalf("HiddenRoot() = %q, want %q", got, "keepass") } } + +func TestHiddenRootFallsBackToEntryPathsWhenGroupsAreSparse(t *testing.T) { + t.Parallel() + + model := vault.Model{ + Entries: []vault.Entry{ + {ID: "rusty", Title: "Rusty GitLab", Path: []string{"keepass", "Joe", "Internet"}}, + }, + Groups: [][]string{ + {"Recycle Bin"}, + }, + } + + if got := HiddenRoot(model); got != "keepass" { + t.Fatalf("HiddenRoot() = %q, want %q", got, "keepass") + } +} diff --git a/packaging/archlinux/keepassgo-git/PKGBUILD.tmpl b/packaging/archlinux/keepassgo-git/PKGBUILD.tmpl index ed9e2e2..8301fd9 100644 --- a/packaging/archlinux/keepassgo-git/PKGBUILD.tmpl +++ b/packaging/archlinux/keepassgo-git/PKGBUILD.tmpl @@ -56,6 +56,16 @@ package() { "${pkgdir}/usr/share/keepassgo/browser-extension/background.js" install -Dm644 browser/extension/content.js \ "${pkgdir}/usr/share/keepassgo/browser-extension/content.js" + install -Dm644 browser/extension/icons/icon-16.png \ + "${pkgdir}/usr/share/keepassgo/browser-extension/icons/icon-16.png" + install -Dm644 browser/extension/icons/icon-32.png \ + "${pkgdir}/usr/share/keepassgo/browser-extension/icons/icon-32.png" + install -Dm644 browser/extension/icons/icon-48.png \ + "${pkgdir}/usr/share/keepassgo/browser-extension/icons/icon-48.png" + install -Dm644 browser/extension/icons/icon-96.png \ + "${pkgdir}/usr/share/keepassgo/browser-extension/icons/icon-96.png" + install -Dm644 browser/extension/icons/icon-128.png \ + "${pkgdir}/usr/share/keepassgo/browser-extension/icons/icon-128.png" install -Dm644 browser/extension/manifest.chromium.json \ "${pkgdir}/usr/share/keepassgo/browser-extension/manifest.chromium.json" install -Dm644 browser/extension/manifest.firefox.json \ diff --git a/scripts/prepare_firefox_extension.py b/scripts/prepare_firefox_extension.py new file mode 100644 index 0000000..a01c0af --- /dev/null +++ b/scripts/prepare_firefox_extension.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 + +import argparse +import json +import shutil +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +SOURCE_DIR = REPO_ROOT / "browser" / "extension" + + +def main() -> int: + parser = argparse.ArgumentParser(description="Prepare a Firefox extension directory for web-ext.") + parser.add_argument("output_dir", help="directory to write the prepared extension into") + args = parser.parse_args() + + output_dir = Path(args.output_dir).resolve() + if output_dir.exists(): + shutil.rmtree(output_dir) + shutil.copytree(SOURCE_DIR, output_dir) + + manifest = json.loads((output_dir / "manifest.firefox.json").read_text(encoding="utf-8")) + (output_dir / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())