From 8a5b210a1d2f6badaecba36b3b26ab261ed0d627 Mon Sep 17 00:00:00 2001
From: shuaiplus <2327005759@qq.com>
Date: Tue, 7 Jul 2026 01:27:55 +0800
Subject: [PATCH] Improve Bitwarden-compatible TOTP handling
---
webapp/src/components/TotpCodesPage.tsx | 44 ++--
webapp/src/components/VaultPage.tsx | 4 +-
.../src/components/vault/VaultDetailView.tsx | 12 +-
webapp/src/components/vault/VaultEditor.tsx | 7 +-
.../components/vault/vault-page-helpers.tsx | 3 +-
webapp/src/lib/crypto.ts | 246 ++++++++++++++++--
6 files changed, 252 insertions(+), 64 deletions(-)
diff --git a/webapp/src/components/TotpCodesPage.tsx b/webapp/src/components/TotpCodesPage.tsx
index 1bffc9b..83d1ee8 100644
--- a/webapp/src/components/TotpCodesPage.tsx
+++ b/webapp/src/components/TotpCodesPage.tsx
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { Clipboard, Globe } from 'lucide-preact';
import { copyTextToClipboard as copyTextWithFeedback } from '@/lib/clipboard';
-import { calcTotpNow } from '@/lib/crypto';
+import { calcTotpNow, type TotpCodeResult } from '@/lib/crypto';
import { t } from '@/lib/i18n';
import type { Cipher } from '@/lib/types';
import LoadingState from '@/components/LoadingState';
@@ -14,17 +14,9 @@ interface TotpCodesPageProps {
onNotify: (type: 'success' | 'error', text: string) => void;
}
-const TOTP_PERIOD_SECONDS = 30;
const TOTP_RING_RADIUS = 14;
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
const TOTP_REFRESH_BATCH_SIZE = 16;
-function getTotpTimeState(): { windowId: number; remain: number } {
- const epoch = Math.floor(Date.now() / 1000);
- return {
- windowId: Math.floor(epoch / TOTP_PERIOD_SECONDS),
- remain: TOTP_PERIOD_SECONDS - (epoch % TOTP_PERIOD_SECONDS),
- };
-}
function TotpListIcon({ cipher }: { cipher: Cipher }) {
return } />;
@@ -32,13 +24,15 @@ function TotpListIcon({ cipher }: { cipher: Cipher }) {
interface TotpRowProps {
cipher: Cipher;
- live: { code: string; remain: number } | null;
+ live: TotpCodeResult | null;
onCopy: (value: string) => void;
}
function TotpRow(props: TotpRowProps) {
const name = props.cipher.decName || props.cipher.name || t('txt_no_name');
const username = props.cipher.login?.decUsername || '';
+ const period = Math.max(1, props.live?.period || 30);
+ const progress = props.live ? Math.max(0, Math.min(period, props.live.remain)) / period : 0;
return (
@@ -69,8 +63,7 @@ function TotpRow(props: TotpRowProps) {
strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`,
strokeDashoffset: String(
TOTP_RING_CIRCUMFERENCE -
- TOTP_RING_CIRCUMFERENCE *
- (Math.max(0, Math.min(TOTP_PERIOD_SECONDS, props.live?.remain ?? 0)) / TOTP_PERIOD_SECONDS)
+ TOTP_RING_CIRCUMFERENCE * progress
),
}}
/>
@@ -86,8 +79,7 @@ function TotpRow(props: TotpRowProps) {
}
export default function TotpCodesPage(props: TotpCodesPageProps) {
- const [totpCodes, setTotpCodes] = useState>({});
- const [remainingSeconds, setRemainingSeconds] = useState(() => getTotpTimeState().remain);
+ const [totpCodes, setTotpCodes] = useState>({});
const [columnCount, setColumnCount] = useState(1);
const listRef = useRef(null);
@@ -120,11 +112,10 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
let stopped = false;
let activeRun = 0;
let timer = 0;
- let currentWindowId = -1;
const refreshCodes = async () => {
const runId = ++activeRun;
- const nextCodes: Record = {};
+ const nextCodes: Record = {};
for (let start = 0; start < totpItems.length; start += TOTP_REFRESH_BATCH_SIZE) {
if (stopped || runId !== activeRun) return;
const batch = totpItems.slice(start, start + TOTP_REFRESH_BATCH_SIZE);
@@ -132,7 +123,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
batch.map(async (cipher) => {
try {
const next = await calcTotpNow(cipher.login?.decTotp || '');
- return [cipher.id, next?.code || null] as const;
+ return [cipher.id, next] as const;
} catch {
return [cipher.id, null] as const;
}
@@ -146,15 +137,20 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
if (stopped || runId !== activeRun) return;
setTotpCodes((prev) => {
let changed = false;
- const next: Record = { ...prev };
+ const next: Record = { ...prev };
for (const id of Object.keys(next)) {
if (id in nextCodes) continue;
delete next[id];
changed = true;
}
- for (const [id, code] of Object.entries(nextCodes)) {
- if (next[id] === code) continue;
- next[id] = code;
+ for (const [id, live] of Object.entries(nextCodes)) {
+ const prevLive = next[id];
+ if (
+ prevLive?.code === live?.code &&
+ prevLive?.remain === live?.remain &&
+ prevLive?.period === live?.period
+ ) continue;
+ next[id] = live;
changed = true;
}
return changed ? next : prev;
@@ -162,10 +158,6 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
};
const tick = () => {
- const next = getTotpTimeState();
- setRemainingSeconds((prev) => (prev === next.remain ? prev : next.remain));
- if (next.windowId === currentWindowId) return;
- currentWindowId = next.windowId;
void refreshCodes();
};
@@ -215,7 +207,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
void copyToClipboard(value)}
/>
))}
diff --git a/webapp/src/components/VaultPage.tsx b/webapp/src/components/VaultPage.tsx
index a00d07b..cba0ce3 100644
--- a/webapp/src/components/VaultPage.tsx
+++ b/webapp/src/components/VaultPage.tsx
@@ -31,7 +31,7 @@ import {
type SidebarFilter,
type VaultSortMode,
} from '@/components/vault/vault-page-helpers';
-import { calcTotpNow } from '@/lib/crypto';
+import { calcTotpNow, type TotpCodeResult } from '@/lib/crypto';
import { computeSshFingerprint, generateDefaultSshKeyMaterial } from '@/lib/ssh';
import { ChevronLeft } from 'lucide-preact';
import type { Cipher, CustomFieldType, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
@@ -109,7 +109,7 @@ export default function VaultPage(props: VaultPageProps) {
const [renameFolderName, setRenameFolderName] = useState('');
const [pendingDeleteFolder, setPendingDeleteFolder] = useState(null);
const [deleteAllFoldersOpen, setDeleteAllFoldersOpen] = useState(false);
- const [totpLive, setTotpLive] = useState<{ code: string; remain: number } | null>(null);
+ const [totpLive, setTotpLive] = useState(null);
const [hiddenFieldVisibleMap, setHiddenFieldVisibleMap] = useState>({});
const [attachmentQueue, setAttachmentQueue] = useState([]);
const [removedAttachmentIds, setRemovedAttachmentIds] = useState>({});
diff --git a/webapp/src/components/vault/VaultDetailView.tsx b/webapp/src/components/vault/VaultDetailView.tsx
index c138f17..3415191 100644
--- a/webapp/src/components/vault/VaultDetailView.tsx
+++ b/webapp/src/components/vault/VaultDetailView.tsx
@@ -2,11 +2,11 @@ import { createPortal } from 'preact/compat';
import { useEffect, useMemo, useState } from 'preact/hooks';
import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Folder, Paperclip, Pencil, RotateCcw, Trash2, X } from 'lucide-preact';
import { useDialogLifecycle } from '@/components/ConfirmDialog';
+import type { TotpCodeResult } from '@/lib/crypto';
import type { Cipher } from '@/lib/types';
import { t } from '@/lib/i18n';
import {
CardBrandIcon,
- TOTP_PERIOD_SECONDS,
TOTP_RING_CIRCUMFERENCE,
VaultListIcon,
copyToClipboard,
@@ -25,7 +25,7 @@ interface VaultDetailViewProps {
selectedCipher: Cipher;
repromptApprovedCipherId: string | null;
showPassword: boolean;
- totpLive: { code: string; remain: number } | null;
+ totpLive: TotpCodeResult | null;
passkeyCreatedAt: string | null;
hiddenFieldVisibleMap: Record;
folderName: (id: string | null | undefined) => string;
@@ -42,6 +42,11 @@ interface VaultDetailViewProps {
onUnarchive: (cipher: Cipher) => void | Promise;
}
+function totpProgress(live: TotpCodeResult | null): number {
+ const period = Math.max(1, live?.period || 30);
+ return live ? Math.max(0, Math.min(period, live.remain)) / period : 0;
+}
+
function PasswordHistoryDialog(props: {
open: boolean;
entries: Array<{ password: string; lastUsedDate: string | null }>;
@@ -191,8 +196,7 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`,
strokeDashoffset: String(
TOTP_RING_CIRCUMFERENCE -
- TOTP_RING_CIRCUMFERENCE *
- (Math.max(0, Math.min(TOTP_PERIOD_SECONDS, props.totpLive?.remain ?? 0)) / TOTP_PERIOD_SECONDS)
+ TOTP_RING_CIRCUMFERENCE * totpProgress(props.totpLive)
),
}}
/>
diff --git a/webapp/src/components/vault/VaultEditor.tsx b/webapp/src/components/vault/VaultEditor.tsx
index 7bc6d1f..ef0bb95 100644
--- a/webapp/src/components/vault/VaultEditor.tsx
+++ b/webapp/src/components/vault/VaultEditor.tsx
@@ -4,6 +4,7 @@ import { ArrowDown, ArrowUp, CheckCheck, Download, Paperclip, Plus, QrCode, Refr
import jsQR from 'jsqr';
import { useEffect, useRef, useState } from 'preact/hooks';
import { useDialogLifecycle } from '@/components/ConfirmDialog';
+import { normalizeTotpInput } from '@/lib/crypto';
import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
import { t } from '@/lib/i18n';
import { cardBrand } from '@/lib/import-format-shared';
@@ -161,9 +162,9 @@ export default function VaultEditor(props: VaultEditorProps) {
};
const applyTotpQrValue = (value: string) => {
- const trimmed = value.trim();
- if (!trimmed) return false;
- props.onUpdateDraft({ loginTotp: trimmed });
+ const normalized = normalizeTotpInput(value);
+ if (!normalized) return false;
+ props.onUpdateDraft({ loginTotp: normalized });
setTotpQrStatus(t('txt_totp_qr_scanned'));
setTotpQrOpen(false);
return true;
diff --git a/webapp/src/components/vault/vault-page-helpers.tsx b/webapp/src/components/vault/vault-page-helpers.tsx
index 79bab46..8288394 100644
--- a/webapp/src/components/vault/vault-page-helpers.tsx
+++ b/webapp/src/components/vault/vault-page-helpers.tsx
@@ -207,8 +207,7 @@ export function getWebsiteMatchOptions(): Array<{ value: number | null; label: s
];
}
-export const TOTP_PERIOD_SECONDS = 30;
-export const TOTP_RING_RADIUS = 14;
+const TOTP_RING_RADIUS = 14;
export const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
export function CreateTypeIcon({ type }: { type: number }) {
diff --git a/webapp/src/lib/crypto.ts b/webapp/src/lib/crypto.ts
index af59509..0e8ffa3 100644
--- a/webapp/src/lib/crypto.ts
+++ b/webapp/src/lib/crypto.ts
@@ -259,17 +259,33 @@ interface TotpConfig {
period: number;
}
+interface GoogleAuthenticatorMigrationTotp {
+ secret: string;
+ name: string;
+ issuer: string;
+ algorithm: TotpHashAlgorithm;
+ digits: number;
+ period: number;
+}
+
const DEFAULT_TOTP_CONFIG: Omit = {
algorithm: 'SHA-1',
digits: 6,
period: 30,
};
-function parseTotpPositiveInt(value: string | null, fallback: number, min: number, max: number): number {
- if (!value) return fallback;
+function parseTotpDigits(value: string | null): number {
+ if (!value) return DEFAULT_TOTP_CONFIG.digits;
const parsed = Number(value);
- if (!Number.isInteger(parsed) || parsed < min || parsed > max) return fallback;
- return parsed;
+ if (!Number.isInteger(parsed)) return DEFAULT_TOTP_CONFIG.digits;
+ return Math.max(0, Math.min(10, parsed));
+}
+
+function parseTotpPeriod(value: string | null): number {
+ if (!value) return DEFAULT_TOTP_CONFIG.period;
+ const parsed = Number(value);
+ if (!Number.isSafeInteger(parsed)) return DEFAULT_TOTP_CONFIG.period;
+ return Math.max(1, parsed);
}
function parseTotpHashAlgorithm(value: string | null): TotpHashAlgorithm {
@@ -279,9 +295,190 @@ function parseTotpHashAlgorithm(value: string | null): TotpHashAlgorithm {
return 'SHA-1';
}
-function parseTotpConfig(raw: string): TotpConfig {
- if (!raw) return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG };
+function base64ToBytesLoose(value: string): Uint8Array {
+ const normalized = value.trim().replace(/\s/g, '+').replace(/-/g, '+').replace(/_/g, '/');
+ if (!normalized) return new Uint8Array();
+ const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
+ try {
+ const binary = atob(padded);
+ return Uint8Array.from(binary, (char) => char.charCodeAt(0));
+ } catch {
+ return new Uint8Array();
+ }
+}
+
+function bytesToBase32(bytes: Uint8Array): string {
+ const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
+ let bits = 0;
+ let value = 0;
+ let out = '';
+ for (const byte of bytes) {
+ value = (value << 8) | byte;
+ bits += 8;
+ while (bits >= 5) {
+ out += alphabet[(value >>> (bits - 5)) & 31];
+ bits -= 5;
+ }
+ }
+ if (bits > 0) {
+ out += alphabet[(value << (5 - bits)) & 31];
+ }
+ return out;
+}
+
+function readProtoVarint(bytes: Uint8Array, state: { offset: number }): number | null {
+ let result = 0;
+ let factor = 1;
+ for (let i = 0; i < 10 && state.offset < bytes.length; i += 1) {
+ const byte = bytes[state.offset++];
+ result += (byte & 0x7f) * factor;
+ if ((byte & 0x80) === 0) return Number.isSafeInteger(result) ? result : null;
+ factor *= 128;
+ }
+ return null;
+}
+
+function readProtoBytes(bytes: Uint8Array, state: { offset: number }): Uint8Array | null {
+ const length = readProtoVarint(bytes, state);
+ if (length == null || length < 0 || state.offset + length > bytes.length) return null;
+ const out = bytes.slice(state.offset, state.offset + length);
+ state.offset += length;
+ return out;
+}
+
+function skipProtoField(bytes: Uint8Array, state: { offset: number }, wireType: number): boolean {
+ if (wireType === 0) return readProtoVarint(bytes, state) != null;
+ if (wireType === 1 && state.offset + 8 <= bytes.length) {
+ state.offset += 8;
+ return true;
+ }
+ if (wireType === 2) return readProtoBytes(bytes, state) != null;
+ if (wireType === 5 && state.offset + 4 <= bytes.length) {
+ state.offset += 4;
+ return true;
+ }
+ return false;
+}
+
+function googleMigrationAlgorithm(value: number): TotpHashAlgorithm | null {
+ if (value === 0 || value === 1) return 'SHA-1';
+ if (value === 2) return 'SHA-256';
+ if (value === 3) return 'SHA-512';
+ return null;
+}
+
+function googleMigrationDigits(value: number): number {
+ if (value === 2) return 8;
+ return 6;
+}
+
+function parseGoogleMigrationOtpParameter(bytes: Uint8Array): GoogleAuthenticatorMigrationTotp | null {
+ const state = { offset: 0 };
+ let secretBytes: Uint8Array | null = null;
+ let name = '';
+ let issuer = '';
+ let algorithm: TotpHashAlgorithm | null = 'SHA-1';
+ let digits = 6;
+ let otpType = 0;
+ const decoder = new TextDecoder();
+
+ while (state.offset < bytes.length) {
+ const key = readProtoVarint(bytes, state);
+ if (key == null) return null;
+ const fieldNumber = Math.floor(key / 8);
+ const wireType = key % 8;
+
+ if (fieldNumber === 1 && wireType === 2) {
+ secretBytes = readProtoBytes(bytes, state);
+ } else if (fieldNumber === 2 && wireType === 2) {
+ const value = readProtoBytes(bytes, state);
+ name = value ? decoder.decode(value) : '';
+ } else if (fieldNumber === 3 && wireType === 2) {
+ const value = readProtoBytes(bytes, state);
+ issuer = value ? decoder.decode(value) : '';
+ } else if (fieldNumber === 4 && wireType === 0) {
+ const value = readProtoVarint(bytes, state);
+ algorithm = value == null ? null : googleMigrationAlgorithm(value);
+ } else if (fieldNumber === 5 && wireType === 0) {
+ const value = readProtoVarint(bytes, state);
+ digits = googleMigrationDigits(value ?? 0);
+ } else if (fieldNumber === 6 && wireType === 0) {
+ otpType = readProtoVarint(bytes, state) ?? 0;
+ } else if (!skipProtoField(bytes, state, wireType)) {
+ return null;
+ }
+ }
+
+ if (!secretBytes?.length || !algorithm || otpType === 1) return null;
+ return {
+ secret: bytesToBase32(secretBytes),
+ name,
+ issuer,
+ algorithm,
+ digits,
+ period: DEFAULT_TOTP_CONFIG.period,
+ };
+}
+
+function parseGoogleAuthenticatorMigration(raw: string): GoogleAuthenticatorMigrationTotp[] {
+ let data = '';
+ try {
+ data = new URL(raw).searchParams.get('data') || '';
+ } catch {
+ data = readOtpAuthParam(raw, 'data');
+ }
+ const bytes = base64ToBytesLoose(data);
+ if (!bytes.length) return [];
+
+ const state = { offset: 0 };
+ const out: GoogleAuthenticatorMigrationTotp[] = [];
+ while (state.offset < bytes.length) {
+ const key = readProtoVarint(bytes, state);
+ if (key == null) return [];
+ const fieldNumber = Math.floor(key / 8);
+ const wireType = key % 8;
+ if (fieldNumber === 1 && wireType === 2) {
+ const parameterBytes = readProtoBytes(bytes, state);
+ const parameter = parameterBytes ? parseGoogleMigrationOtpParameter(parameterBytes) : null;
+ if (parameter) out.push(parameter);
+ } else if (!skipProtoField(bytes, state, wireType)) {
+ return [];
+ }
+ }
+ return out;
+}
+
+function buildOtpAuthUri(account: GoogleAuthenticatorMigrationTotp): string {
+ const issuer = account.issuer.trim();
+ const name = account.name.trim();
+ const label = issuer && name && !name.toLowerCase().startsWith(`${issuer.toLowerCase()}:`)
+ ? `${issuer}:${name}`
+ : name || issuer || 'TOTP';
+ const params = new URLSearchParams({
+ secret: account.secret,
+ algorithm: account.algorithm.replace('-', ''),
+ digits: String(account.digits),
+ period: String(account.period),
+ });
+ if (issuer) params.set('issuer', issuer);
+ return `otpauth://totp/${encodeURIComponent(label)}?${params.toString()}`;
+}
+
+export function normalizeTotpInput(raw: string): string {
const s = raw.trim();
+ if (!s) return '';
+ if (/^otpauth-migration:\/\//i.test(s)) {
+ const accounts = parseGoogleAuthenticatorMigration(s);
+ return accounts.length === 1 ? buildOtpAuthUri(accounts[0]) : '';
+ }
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(s) && !/^otpauth:\/\//i.test(s) && !/^steam:\/\//i.test(s)) {
+ return '';
+ }
+ return s;
+}
+
+function parseTotpConfig(raw: string): TotpConfig {
+ const s = normalizeTotpInput(raw);
if (!s) return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG };
if (/^steam:\/\//i.test(s)) {
return {
@@ -295,31 +492,20 @@ function parseTotpConfig(raw: string): TotpConfig {
if (/^otpauth:\/\//i.test(s)) {
try {
const u = new URL(s);
- const otpType = u.hostname.toLowerCase();
- if (otpType !== 'totp') {
- return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG };
- }
- const label = decodeURIComponent((u.pathname || '').replace(/^\/+/, '')).toLowerCase();
- const issuer = (u.searchParams.get('issuer') || '').trim().toLowerCase();
- const algorithm = (u.searchParams.get('algorithm') || '').trim().toLowerCase();
- const steam = issuer === 'steam' || label.startsWith('steam:') || algorithm === 'steam';
return {
secret: normalizeTotpSecret(u.searchParams.get('secret') || ''),
- steam,
- algorithm: steam ? 'SHA-1' : parseTotpHashAlgorithm(u.searchParams.get('algorithm')),
- digits: steam ? 5 : parseTotpPositiveInt(u.searchParams.get('digits'), DEFAULT_TOTP_CONFIG.digits, 1, 10),
- period: parseTotpPositiveInt(u.searchParams.get('period'), DEFAULT_TOTP_CONFIG.period, 1, 3600),
+ steam: false,
+ algorithm: parseTotpHashAlgorithm(u.searchParams.get('algorithm')),
+ digits: parseTotpDigits(u.searchParams.get('digits')),
+ period: parseTotpPeriod(u.searchParams.get('period')),
};
} catch {
- const issuer = readOtpAuthParam(s, 'issuer').trim().toLowerCase();
- const algorithm = readOtpAuthParam(s, 'algorithm').trim().toLowerCase();
- const steam = issuer === 'steam' || algorithm === 'steam';
return {
secret: normalizeTotpSecret(readOtpAuthParam(s, 'secret')),
- steam,
- algorithm: steam ? 'SHA-1' : parseTotpHashAlgorithm(algorithm),
- digits: steam ? 5 : parseTotpPositiveInt(readOtpAuthParam(s, 'digits'), DEFAULT_TOTP_CONFIG.digits, 1, 10),
- period: parseTotpPositiveInt(readOtpAuthParam(s, 'period'), DEFAULT_TOTP_CONFIG.period, 1, 3600),
+ steam: false,
+ algorithm: parseTotpHashAlgorithm(readOtpAuthParam(s, 'algorithm')),
+ digits: parseTotpDigits(readOtpAuthParam(s, 'digits')),
+ period: parseTotpPeriod(readOtpAuthParam(s, 'period')),
};
}
}
@@ -349,7 +535,13 @@ function base32ToBytes(input: string): Uint8Array {
return new Uint8Array(out);
}
-export async function calcTotpNow(rawSecret: string, nowMs: number = Date.now()): Promise<{ code: string; remain: number } | null> {
+export interface TotpCodeResult {
+ code: string;
+ remain: number;
+ period: number;
+}
+
+export async function calcTotpNow(rawSecret: string, nowMs: number = Date.now()): Promise {
const { secret, steam, algorithm, digits, period } = parseTotpConfig(rawSecret);
if (!secret) return null;
const keyBytes = base32ToBytes(secret);
@@ -378,5 +570,5 @@ export async function calcTotpNow(rawSecret: string, nowMs: number = Date.now())
value = Math.floor(value / chars.length);
}
}
- return { code, remain };
+ return { code, remain, period };
}