Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: improved autolock to only keep wallet unlocked while user is actively interacting with it #1699

Merged
merged 4 commits into from
Nov 18, 2024
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
2 changes: 1 addition & 1 deletion apps/extension/src/index.onboarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ import "@common/i18nConfig"
import { renderTalisman } from "@ui"
import Onboarding from "@ui/apps/onboard"

renderTalisman(<Onboarding />)
renderTalisman(<Onboarding />, { keepWalletUnlockedMode: "always" })
7 changes: 6 additions & 1 deletion apps/extension/src/index.popup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,5 +59,10 @@ const adjustPopupSize = async () => {
}
}

renderTalisman(<Popup />)
// Always keep wallet unlocked when embedded popup is open,
// listen for user interaction when standalone popup window is open.
const keepWalletUnlockedMode =
window.location.search === "?embedded" ? "always" : "user-interaction"

renderTalisman(<Popup />, { keepWalletUnlockedMode })
adjustPopupSize()
3 changes: 2 additions & 1 deletion apps/extension/src/ui/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import MessageTypes from "./types"
const messageService = new PortMessageService()

export const api: MessageTypes = {
ping: () => messageService.sendMessage("pri(ping)"),
keepalive: () => messageService.sendMessage("pri(keepalive)"),
keepunlocked: () => messageService.sendMessage("pri(keepunlocked)"),
unsubscribe: (id) => messageService.sendMessage("pri(unsubscribe)", { id }),
// UNSORTED
onboardCreatePassword: (pass, passConfirm) =>
Expand Down
3 changes: 2 additions & 1 deletion apps/extension/src/ui/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ import {
} from "@extension/core/domains/accounts/helpers.catalog"

export default interface MessageTypes {
ping: () => Promise<boolean>
keepalive: () => Promise<boolean>
keepunlocked: () => Promise<boolean>
unsubscribe: (id: string) => Promise<null>
// UNSORTED
onboardCreatePassword: (pass: string, passConfirm: string) => Promise<boolean>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const AutoLockEditor = () => {
const options = useMemo(
() => [
{ value: 0, label: t("Disabled") },
{ value: 1, label: t("{{count}} minute", { count: 1 }) },
{ value: 5, label: t("{{count}} minutes", { count: 5 }) },
{ value: 15, label: t("{{count}} minutes", { count: 15 }) },
{ value: 30, label: t("{{count}} minutes", { count: 30 }) },
Expand Down
6 changes: 3 additions & 3 deletions apps/extension/src/ui/hooks/useKeepBackgroundOpen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@ import { api } from "@ui/api"

/**
* Used to keep the background page open on Firefox
* @returns void
**/
export const useKeepBackgroundOpen = () => {
useEffect(() => {
const interval = setInterval(() => {
// making any runtime call keeps the background page open
// and resets the autolock timer
api.ping()
}, 10000)
api.keepalive()
}, 10_000)

return () => clearInterval(interval)
}, [])
}
57 changes: 57 additions & 0 deletions apps/extension/src/ui/hooks/useKeepWalletUnlocked.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import throttle from "lodash/throttle"
import { useEffect } from "react"

import { api } from "@ui/api"

/** Sets whether the wallet autolock timer should be restarted on a user-interaction, or on a 10s interval. */
export type KeepWalletUnlockedMode = "user-interaction" | "always"

/**
* Used to reset the wallet autolock timer whenever the user interacts with the UI.
**/
export const useKeepWalletUnlocked = ({
mode = "user-interaction",
}: {
/**
* When `user-interaction`, the wallet will remain unlocked as long as the user interacts with the UI.
* When `always`, the wallet will remain unlocked as long as the hook is mounted.
*/
mode?: KeepWalletUnlockedMode
} = {}) => {
useEffect(() => {
// throttle this call so we only call it a maximum of once per 10 seconds
const keepunlocked = throttle(() => api.keepunlocked(), 10_000, {
leading: true,
trailing: true,
})

if (mode === "always") {
const interval = setInterval(keepunlocked, 10_000)
return () => clearInterval(interval)
}

// attach event listeners to keep the wallet unlocked
window.addEventListener("mousedown", keepunlocked)
window.addEventListener("mouseup", keepunlocked)
window.addEventListener("mousemove", keepunlocked)
window.addEventListener("keydown", keepunlocked)
window.addEventListener("keyup", keepunlocked)
window.addEventListener("keypress", keepunlocked)
window.addEventListener("touchstart", keepunlocked)
window.addEventListener("touchend", keepunlocked)
window.addEventListener("touchmove", keepunlocked)

return () => {
// remove event listeners
window.removeEventListener("mousedown", keepunlocked)
window.removeEventListener("mouseup", keepunlocked)
window.removeEventListener("mousemove", keepunlocked)
window.removeEventListener("keydown", keepunlocked)
window.removeEventListener("keyup", keepunlocked)
window.removeEventListener("keypress", keepunlocked)
window.removeEventListener("touchstart", keepunlocked)
window.removeEventListener("touchend", keepunlocked)
window.removeEventListener("touchmove", keepunlocked)
}
}, [mode])
}
16 changes: 15 additions & 1 deletion apps/extension/src/ui/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { ErrorBoundaryDatabaseMigration } from "@talisman/components/ErrorBounda
import { NotificationsContainer } from "@talisman/components/Notifications/NotificationsContainer"
import { SuspenseTracker } from "@talisman/components/SuspenseTracker"
import { useKeepBackgroundOpen } from "@ui/hooks/useKeepBackgroundOpen"
import { KeepWalletUnlockedMode, useKeepWalletUnlocked } from "@ui/hooks/useKeepWalletUnlocked"

import { initSentryFrontend } from "../sentry"

Expand All @@ -25,15 +26,27 @@ const KeepBackgroundOpen = () => {
useKeepBackgroundOpen()
return null
}
const KeepWalletUnlocked = ({ mode }: { mode?: KeepWalletUnlockedMode }) => {
useKeepWalletUnlocked({ mode })
return null
}

const queryClient = new QueryClient()

initSentryFrontend()
const container = document.getElementById("root")

export type RenderTalismanOptions = {
/** Sets whether the wallet autolock timer should be restarted on a user-interaction, or on a 10s interval. */
keepWalletUnlockedMode?: KeepWalletUnlockedMode
}

// render a context dependent app with all providers
// could possibly re-org this slightly better
export const renderTalisman = (app: ReactNode) => {
export const renderTalisman = (
app: ReactNode,
{ keepWalletUnlockedMode }: RenderTalismanOptions = {},
) => {
if (!container) throw new Error("#root element not found.")
const root = createRoot(container)
root.render(
Expand All @@ -42,6 +55,7 @@ export const renderTalisman = (app: ReactNode) => {
<ErrorBoundaryDatabaseMigration>
<Suspense fallback={<SuspenseTracker name="Root" />}>
<KeepBackgroundOpen />
<KeepWalletUnlocked mode={keepWalletUnlockedMode} />
<Subscribe>
<QueryClientProvider client={queryClient}>
<HashRouter>{app}</HashRouter>
Expand Down
4 changes: 4 additions & 0 deletions packages/extension-core/src/domains/app/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ export default class AppHandler extends ExtensionHandler {
await this.stores.password.authenticate(pass)
talismanAnalytics.capture("authenticate", { method: "new" })
}
// start the autolock timer
this.stores.settings
.get()
.then(({ autoLockMinutes }) => this.stores.password.resetAutolockTimer(autoLockMinutes))

return true
} catch (e) {
Expand Down
10 changes: 9 additions & 1 deletion packages/extension-core/src/domains/app/store.password.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { BehaviorSubject } from "rxjs"
import { Err, Ok, Result } from "ts-results"

import { StorageProvider } from "../../libs/Store"
import { createNotification } from "../../notifications"
import { sessionStorage } from "../../util/sessionStorageCompat"

/* ----------------------------------------------------------------
Expand Down Expand Up @@ -48,11 +49,14 @@ export class PasswordStore extends StorageProvider<PasswordStoreData> {

chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name !== ALARM_NAME) return
if (this.isLoggedIn.value !== TRUE) return

this.clearPassword()
createNotification("autolocked", "", "autolocked")
})
}

public async resetAutoLockTimer(minutes: number) {
public async resetAutolockTimer(minutes?: number) {
const alarm = await chrome.alarms.get(ALARM_NAME)
if (alarm) await chrome.alarms.clear(ALARM_NAME)

Expand Down Expand Up @@ -143,7 +147,11 @@ export class PasswordStore extends StorageProvider<PasswordStoreData> {
}

public clearPassword() {
// clear password
this.setPassword(undefined)

// clear autolock timer
this.resetAutolockTimer()
}

async transformPassword(password: string) {
Expand Down
13 changes: 9 additions & 4 deletions packages/extension-core/src/handlers/Extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export default class Extension extends ExtensionHandler {
// connect auto lock timeout setting to the password store
this.stores.settings.observable.subscribe(({ autoLockMinutes }) => {
this.#autoLockMinutes = autoLockMinutes
stores.password.resetAutoLockTimer(autoLockMinutes)
stores.password.resetAutolockTimer(autoLockMinutes)
})

// reset the databaseUnavailable and databaseQuotaExceeded flags on start-up
Expand Down Expand Up @@ -219,9 +219,14 @@ export default class Extension extends ExtensionHandler {
// Then try remaining which are present in this class
// --------------------------------------------------------------------
switch (type) {
case "pri(ping)":
// Reset the auto lock timer on ping, the extension UI is open
this.stores.password.resetAutoLockTimer(this.#autoLockMinutes)
// Ensures that the background script remains open when the UI is also open (especially on firefox)
case "pri(keepalive)":
return true

// Keeps the wallet unlocked for N (user-definable) minutes after the last user interaction
case "pri(keepunlocked)":
// Restart the autolock timer when the user interacts with the wallet UI
this.stores.password.resetAutolockTimer(this.#autoLockMinutes)
return true

default:
Expand Down
14 changes: 12 additions & 2 deletions packages/extension-core/src/notifications/createNotification.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { sentry } from "../config/sentry"
import { ensureNotificationClickHandler } from "./ensureNotificationClickHandler"

export type NotificationType = "submitted" | "success" | "error" | "not_found"
export type NotificationType = "submitted" | "success" | "error" | "not_found" | "autolocked"

const getNotificationOptions = (
type: NotificationType,
Expand All @@ -16,13 +16,15 @@ const getNotificationOptions = (
message: `Waiting on transaction confirmation on ${networkName}.`,
iconUrl: "/images/tx-ok.png",
}

case "success":
return {
type: "basic",
title: "Transaction successful",
message: `Your transaction on ${networkName} has been confirmed.`,
iconUrl: "/images/tx-ok.png",
}

case "error":
return {
type: "basic",
Expand All @@ -34,14 +36,22 @@ const getNotificationOptions = (
`Failed transaction on ${networkName}.`,
iconUrl: "/images/tx-nok.png",
}

case "not_found":
return {
type: "basic",
title: "Transaction not found",
message: `We aren't able to determine the status of this transaction.`,

iconUrl: "/images/tx-nok.png",
}

case "autolocked":
return {
type: "basic",
title: "Talisman locked",
message: "Your wallet has been locked due to inactivity.",
iconUrl: "/images/tx-ok.png",
}
}
}

Expand Down
11 changes: 10 additions & 1 deletion packages/extension-core/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export declare type RequestIdOnlyMessageTypes = IdOnlyValues<{
}>

type RemovedMessages =
| "pri(ping)"
| "pri(signing.approve.password)"
| "pri(signing.approve.signature)"
| "pri(authorize.list)"
Expand Down Expand Up @@ -85,7 +86,15 @@ type RequestSignaturesBase = Omit<PolkadotRequestSignatures, RemovedMessages> &
TokenRatesMessages &
SubstrateMessages &
AssetDiscoveryMessages &
NftsMessages
NftsMessages &
PingMessages

interface PingMessages {
// keeps the background script alive while the UI is open
"pri(keepalive)": [null, boolean]
// keeps the wallet unlocked while the user is actively interacting with it (clicks/keypresses)
"pri(keepunlocked)": [null, boolean]
}

export interface RequestSignatures extends RequestSignaturesBase {
// Values for RequestSignatures are arrays where the items are [RequestType, ResponseType, SubscriptionMesssageType?]
Expand Down
Loading