Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ All of this is kept in your browser's extension storage. CardHop never sends it

- **Your settings:** which banks you chose, the names you gave their buttons, whether to skip the popup, where card pages open, and the checkout prompt options. Saved with your browser's extension settings, which your browser may sync to your other devices if you use browser sync.
- **Saved card pages:** the links to card pages you chose to save, and the names you gave them. Kept only on this device, never synced. Delete them one at a time in Settings, or all at once with **Forget my cards**.
- **Where each bank's button lands:** the card page a bank opens when you use its main CardHop button (Capital One adds your default card to the address), so CardHop doesn't offer to save a page that button already opens. Kept only on this device, never synced, replaced each time you use the button, and removed by **Forget my cards**.
- **Sites where you turned off the checkout prompt:** kept only on this device, never synced.
- **The tab CardHop opened:** while you sign in to Capital One, CardHop remembers which tab it opened for up to 10 minutes, so it can send that tab back to your virtual cards. This is cleared when the browser closes.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ Citi doesn't have this setting yet. To add it for a bank, give that bank a `sign

Capital One's virtual cards page opens on your first card after you sign in. To jump straight to another card, save its page:

- Open that card's virtual cards page, then open CardHop and click **Save**. The CardHop icon shows a **+** on card pages you haven't saved. (Both need returning after sign-in turned on, so CardHop can see the page address.) Or
- Open that card's virtual cards page, then open CardHop and click **Save**. The CardHop icon shows a **+** on card pages you haven't saved, except the page the bank's own button opens. (Both need returning after sign-in turned on, so CardHop can see the page address.) Or
- Paste the page's link under **Settings → Card buttons**.

Until you save one, the popup shows a short tip explaining this; **Got it** hides it.
Expand Down
11 changes: 9 additions & 2 deletions src/background.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { refreshSaveBadges, updateSaveBadge } from './badge.ts';
import { forgetBankLanding, handleBankLandingUpdate } from './bank-landing.ts';
import { syncCheckoutScript } from './checkout-access.ts';
import { buildCheckoutButtons, isCheckoutButtonsRequest, isCheckoutFrameMessage, isOpenOptionsRequest } from './checkout-messages.ts';
import { ext } from './ext.ts';
import { isOpenBankMessage, openSavedCard, openVirtualCards } from './open.ts';
import { getSavedCards, SAVED_CARDS_KEY } from './saved-cards.ts';
import { BANK_LANDINGS_KEY, getSavedCards, SAVED_CARDS_KEY } from './saved-cards.ts';
import { applyActionMode, BANK_IDS, bankLabel, canOpenDirectly, cardMenuId, getSettings, menuId } from './shared.ts';
import { forgetTab, handleTabUpdated } from './sign-in-return.ts';

Expand Down Expand Up @@ -96,6 +97,8 @@ ext.storage.onChanged.addListener((changes, area) => {
if (banksChanged || cardsChanged) {
void syncMenus();
void refreshSaveBadges();
} else if (area === 'local' && changes[BANK_LANDINGS_KEY] !== undefined) {
void refreshSaveBadges();
}

if (banksChanged || cardsChanged || (area === 'sync' && changes.openDirectly !== undefined)) {
Expand Down Expand Up @@ -129,10 +132,14 @@ ext.contextMenus.onClicked.addListener(async (info, tab) => {
ext.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
void handleTabUpdated(tabId, changeInfo, tab);
if (changeInfo.url !== undefined || changeInfo.status === 'complete') {
void updateSaveBadge(tabId, tab.url);
// Record a bank button's landing page first, so the badge already knows to skip it.
void handleBankLandingUpdate(tabId, tab.url)
.catch(() => undefined)
.then(() => updateSaveBadge(tabId, tab.url));
}
});

ext.tabs.onRemoved.addListener((tabId) => {
void forgetTab(tabId);
void forgetBankLanding(tabId);
});
29 changes: 20 additions & 9 deletions src/badge.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,38 @@
import { ext } from './ext.ts';
import { bankForCardUrl, getSavedCards, isSaved } from './saved-cards.ts';
import { bankForCardUrl, getBankLandings, getSavedCards, isBankPage, isSaved } from './saved-cards.ts';
import { getSettings } from './shared.ts';

export const SAVE_BADGE_TEXT = '+';
// The full-width plus is drawn in the middle of its box; a plain "+" sits off-centre in the badge.
export const SAVE_BADGE_TEXT = '\uFF0B';
export const SAVE_BADGE_TITLE = 'CardHop: save this card page';
// The brand blue.
const BADGE_COLOR = '#0b3b60';
const BADGE_TEXT_COLOR = '#ffffff';

// Shows a "+" on the toolbar icon while a tab is on a card page CardHop could save. The browser only
// reveals addresses on sites CardHop has permission for, so other tabs report no address and get no badge.
// The pages a bank's own button opens are skipped: that button already gets there.
export async function updateSaveBadge(tabId: number, url: string | undefined): Promise<void> {
const bankId = url === undefined ? undefined : bankForCardUrl(url);
let offer = false;
if (bankId !== undefined && url !== undefined) {
const [{ banks }, cards] = await Promise.all([getSettings(), getSavedCards()]);
offer = banks.includes(bankId) && !isSaved(url, cards);
const [{ banks }, cards, landings] = await Promise.all([getSettings(), getSavedCards(), getBankLandings()]);
offer = banks.includes(bankId) && !isSaved(url, cards) && !isBankPage(url, landings);
}

await ext.action.setBadgeText({ tabId, text: offer ? SAVE_BADGE_TEXT : '' });
if (offer) {
await ext.action.setBadgeBackgroundColor({ tabId, color: BADGE_COLOR });
// The tab can close while settings load, and the browser then rejects with "No tab with id".
// Nothing is left to badge, so the error is dropped.
try {
await ext.action.setBadgeText({ tabId, text: offer ? SAVE_BADGE_TEXT : '' });
if (offer) {
await ext.action.setBadgeBackgroundColor({ tabId, color: BADGE_COLOR });
await ext.action.setBadgeTextColor({ tabId, color: BADGE_TEXT_COLOR });
}
// An empty tab title falls back to the extension's default title.
await ext.action.setTitle({ tabId, title: offer ? SAVE_BADGE_TITLE : '' });
} catch {
// The tab is gone.
}
// An empty tab title falls back to the extension's default title.
await ext.action.setTitle({ tabId, title: offer ? SAVE_BADGE_TITLE : '' });
}

// After saving or deleting a card, or changing banks, recheck every tab CardHop can see.
Expand Down
70 changes: 70 additions & 0 deletions src/bank-landing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { ext } from './ext.ts';
import { bankForCardUrl, setBankLanding } from './saved-cards.ts';
import { BANKS, isBankId } from './shared.ts';
import type { BankId } from './shared.ts';
import { TRACK_FOR_MS } from './sign-in-return.ts';

// Long enough to sign in first, including two-step verification.
export const WATCH_FOR_MS = TRACK_FOR_MS;

const KEY_PREFIX = 'bankLanding:';

interface WatchedTab {
bankId: BankId;
expiresAt: number;
}

function keyFor(tabId: number): string {
return `${KEY_PREFIX}${tabId}`;
}

function isWatchedTab(value: unknown): value is WatchedTab {
if (typeof value !== 'object' || value === null) {
return false;
}
const watched = value as Record<string, unknown>;
return isBankId(watched.bankId) && typeof watched.expiresAt === 'number';
}

async function getWatched(tabId: number): Promise<WatchedTab | undefined> {
const key = keyFor(tabId);
const stored = await ext.storage.session.get(key);
const watched = stored[key];
return isWatchedTab(watched) ? watched : undefined;
}

// Session storage keeps this across background restarts while the user signs in.
export async function watchBankLanding(tabId: number, bankId: BankId, now = Date.now()): Promise<void> {
const watched: WatchedTab = { bankId, expiresAt: now + WATCH_FOR_MS };
await ext.storage.session.set({ [keyFor(tabId)]: watched });
}

export async function forgetBankLanding(tabId: number): Promise<void> {
await ext.storage.session.remove(keyFor(tabId));
}

// Records the card page a tab opened by a bank's button lands on, after any sign-in.
export async function handleBankLandingUpdate(tabId: number, url: string | undefined, now = Date.now()): Promise<void> {
const watched = await getWatched(tabId);
if (!watched) {
return;
}

if (now > watched.expiresAt) {
await forgetBankLanding(tabId);
return;
}

// Sign-in pages are hidden from CardHop or aren't card pages: keep watching.
if (url === undefined || bankForCardUrl(url) !== watched.bankId) {
return;
}
// The bank hasn't added the card to the address yet.
if (new URL(url).href === new URL(BANKS[watched.bankId].url).href) {
return;
}

// Only the first card page counts: anything after it is the user switching cards.
await forgetBankLanding(tabId);
await setBankLanding(watched.bankId, url);
}
3 changes: 3 additions & 0 deletions src/open.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { forgetBankLanding, watchBankLanding } from './bank-landing.ts';
import { ext } from './ext.ts';
import { findSavedCard } from './saved-cards.ts';
import { BANKS, getSettings, isBankId, OPEN_IN } from './shared.ts';
Expand Down Expand Up @@ -54,6 +55,8 @@ async function openPage(bankId: BankId, url: string, tabId?: number, options: Op

if (tab?.id !== undefined) {
await trackTab(tab.id, bankId, { url });
// Only the bank's own button lands on a page CardHop shouldn't offer to save.
await (url === BANKS[bankId].url ? watchBankLanding(tab.id, bankId) : forgetBankLanding(tab.id));
}
}

Expand Down
10 changes: 8 additions & 2 deletions src/popup/popup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ import {
CardPageError,
defaultLabel,
forgetAllCards,
getBankLandings,
getSavedCards,
isBankPage,
isSaved,
removeSavedCard,
renameSavedCard,
} from '../saved-cards.ts';
import type { SavedCard } from '../saved-cards.ts';
import type { BankLandings, SavedCard } from '../saved-cards.ts';
import {
BANK_IDS,
bankLabel,
Expand Down Expand Up @@ -78,6 +80,7 @@ const orList = new Intl.ListFormat('en', { type: 'disjunction' });

let settings: Settings = DEFAULT_SETTINGS;
let savedCards: SavedCard[] = [];
let bankLandings: BankLandings = {};
// The page in the active tab, when it is a card page CardHop could offer to save.
let currentCardUrl: string | undefined;

Expand Down Expand Up @@ -168,7 +171,8 @@ function renderSignInReturnNotices(banks: BankId[]): void {
// sites CardHop has permission for, so this appears once returning after sign-in is turned on.
function renderSaveCurrent(banks: BankId[]): void {
const bankId = currentCardUrl === undefined ? undefined : bankForCardUrl(currentCardUrl);
const show = bankId !== undefined && banks.includes(bankId) && currentCardUrl !== undefined && !isSaved(currentCardUrl, savedCards);
const show = bankId !== undefined && banks.includes(bankId) && currentCardUrl !== undefined
&& !isSaved(currentCardUrl, savedCards) && !isBankPage(currentCardUrl, bankLandings);
saveCurrent.hidden = !show;
if (show) {
saveCurrentLabel.placeholder = defaultLabel(bankId, savedCards);
Expand Down Expand Up @@ -330,6 +334,7 @@ function renderBankState(banks: BankId[]): void {
async function reload(): Promise<void> {
settings = await getSettings();
savedCards = await getSavedCards();
bankLandings = await getBankLandings();
renderBankState(selectedBanks());
}

Expand Down Expand Up @@ -488,6 +493,7 @@ async function init(): Promise<void> {

settings = await getSettings();
savedCards = await getSavedCards();
bankLandings = await getBankLandings();

openDirectlyInput.checked = settings.openDirectly;
openInSelect.value = settings.openIn;
Expand Down
38 changes: 37 additions & 1 deletion src/saved-cards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,5 +116,41 @@ export async function removeSavedCard(id: string): Promise<void> {
}

export async function forgetAllCards(): Promise<void> {
await ext.storage.local.remove(SAVED_CARDS_KEY);
await ext.storage.local.remove([SAVED_CARDS_KEY, BANK_LANDINGS_KEY]);
}

// Where each bank's button last landed. Capital One adds the default card to its general page's address,
// which then looks like any card page, but the bank button already opens it, so CardHop doesn't offer to
// save it. Kept on this device only, like saved card pages.
export const BANK_LANDINGS_KEY = 'bankLandings';

export type BankLandings = Partial<Record<BankId, string>>;

export async function getBankLandings(): Promise<BankLandings> {
const stored = await ext.storage.local.get(BANK_LANDINGS_KEY);
const landings: unknown = stored[BANK_LANDINGS_KEY];
if (typeof landings !== 'object' || landings === null) {
return {};
}
return Object.fromEntries(Object.entries(landings).filter(([bankId, url]) => (
isBankId(bankId) && typeof url === 'string' && bankForCardUrl(url) === bankId
)));
}

export async function setBankLanding(bankId: BankId, url: string): Promise<void> {
const href = new URL(url.trim()).href;
const landings = await getBankLandings();
if (landings[bankId] !== href) {
await ext.storage.local.set({ [BANK_LANDINGS_KEY]: { ...landings, [bankId]: href } });
}
}

// True for the pages a bank's own button opens: its general virtual cards page, or where that last landed.
export function isBankPage(url: string, landings: BankLandings): boolean {
const bankId = bankForCardUrl(url);
if (bankId === undefined) {
return false;
}
const href = new URL(url.trim()).href;
return href === new URL(BANKS[bankId].url).href || href === landings[bankId];
}
35 changes: 32 additions & 3 deletions tests/background.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ const { api } = chromeMock;
// Importing registers the background script's listeners on the mock.
await import('../src/background.ts');
const { BANKS } = await import('../src/shared.ts');
const { addSavedCard, SAVED_CARDS_KEY } = await import('../src/saved-cards.ts');
const { SAVE_BADGE_TEXT } = await import('../src/badge.ts');
const { addSavedCard, BANK_LANDINGS_KEY, getBankLandings, SAVED_CARDS_KEY } = await import('../src/saved-cards.ts');

const SECOND_CARD = 'https://myaccounts.capitalone.com/vc/virtual-cards/home?arid=abc';

Expand Down Expand Up @@ -301,7 +302,7 @@ describe('open requests from the popup', () => {
it('starts watching the new tab for a sign-in detour', async () => {
chromeMock.grant('https://myaccounts.capitalone.com/*');
await fire(api.runtime.onMessage, { type: 'openBank', bankId: 'capitalOne' });
assert.deepEqual(Object.keys(chromeMock.getSession()), ['signInReturn:101']);
assert.deepEqual(Object.keys(chromeMock.getSession()), ['signInReturn:101', 'bankLanding:101']);
});

it('ignores other messages', async () => {
Expand Down Expand Up @@ -393,7 +394,35 @@ describe('custom names and the save badge', () => {
it('updates the badge when a tab finishes loading', async () => {
chromeMock.setStored({ banks: ['capitalOne'] });
await fire(api.tabs.onUpdated, 4, { status: 'complete' }, { url: SECOND_CARD });
assert.deepEqual(callArgs(api.action.setBadgeText), [[{ tabId: 4, text: '+' }]]);
assert.deepEqual(callArgs(api.action.setBadgeText), [[{ tabId: 4, text: SAVE_BADGE_TEXT }]]);
});

it('records where the bank button lands and shows no badge there', async () => {
const landing = `${BANKS.capitalOne.url}?arid=default`;
chromeMock.setStored({ banks: ['capitalOne'] });
await fire(api.contextMenus.onClicked, { menuItemId: 'open-capitalOne' });
const tabId = 101;

await fire(api.tabs.onUpdated, tabId, { url: landing }, { url: landing });

assert.deepEqual(await getBankLandings(), { capitalOne: landing });
assert.deepEqual(callArgs(api.action.setBadgeText), [[{ tabId, text: '' }]]);
});

it('rechecks the badges when the bank button lands somewhere new', async () => {
await fire(api.storage.onChanged, { [BANK_LANDINGS_KEY]: { newValue: {} } }, 'local');
assert.equal(api.tabs.query.mock.callCount(), 1);
assert.equal(api.contextMenus.removeAll.mock.callCount(), 0, 'menus are unaffected');
});

it('stops watching for the landing page when a saved card replaces the tab', async () => {
chromeMock.setStored({ banks: ['capitalOne'], openIn: 'currentTab' });
await fire(api.contextMenus.onClicked, { menuItemId: 'open-capitalOne' }, { id: 5 });
const card = await addSavedCard(SECOND_CARD, 'Second');
await fire(api.contextMenus.onClicked, { menuItemId: `open-card-${card.id}` }, { id: 5 });

await fire(api.tabs.onUpdated, 5, { url: SECOND_CARD }, { url: SECOND_CARD });
assert.deepEqual(await getBankLandings(), {});
});
});

Loading
Loading