From 9ddbf56d32b524829049269dec430659902bcec1 Mon Sep 17 00:00:00 2001 From: onewhite <1135651301@qq.com> Date: Tue, 15 Sep 2026 14:48:42 +0200 Subject: [PATCH 1/2] fix: correct Chinese/UTF-8 mojibake when importing OTP from QR images Two independent decode bugs both mangled non-ASCII issuer/account names: - migration.ts decoded the Google Authenticator export protobuf's account/issuer bytes with String.fromCharCode (Latin-1), instead of UTF-8. - QrImport.vue/content.ts preferred the qrcode-reader library, which also decodes QR byte-mode data as Latin-1, over jsQR (UTF-8-correct), only falling back to jsQR on outright decode failure. --- src/components/Import/QrImport.vue | 87 +++++++++++------------------- src/content.ts | 58 +++++++++----------- src/models/migration.ts | 4 +- 3 files changed, 60 insertions(+), 89 deletions(-) diff --git a/src/components/Import/QrImport.vue b/src/components/Import/QrImport.vue index 6edd0702d..6a11d11ab 100644 --- a/src/components/Import/QrImport.vue +++ b/src/components/Import/QrImport.vue @@ -92,74 +92,51 @@ export default Vue.extend({ }, }); +function isOtpAuthUrl(text: string): boolean { + return ( + text.indexOf("otpauth://") === 0 || + text.indexOf("otpauth-migration://") === 0 + ); +} + async function getOtpUrlFromQrFile(file: File): Promise { return new Promise((resolve) => { const reader = new FileReader(); reader.onload = () => { const imageUrl = reader.result as string; - const qrReader = new QRCode(); - qrReader.callback = ( - error: string, - text: { - result: string; - points: Array<{ - x: number; - y: number; - count: number; - estimatedModuleSize: number; - }>; - } - ) => { - if (error) { - console.error(error); - const image: HTMLImageElement = document.createElement("img"); - image.onload = () => { - const canvas: HTMLCanvasElement = document.createElement("canvas"); - const ctx: CanvasRenderingContext2D = canvas.getContext( - "2d" - ) as CanvasRenderingContext2D; + const image: HTMLImageElement = document.createElement("img"); + image.onload = () => { + const canvas: HTMLCanvasElement = document.createElement("canvas"); + const ctx: CanvasRenderingContext2D = canvas.getContext( + "2d" + ) as CanvasRenderingContext2D; - canvas.width = image.width; - canvas.height = image.height; - ctx.drawImage(image, 0, 0); + canvas.width = image.width; + canvas.height = image.height; + ctx.drawImage(image, 0, 0); - const qrImageData = ctx.getImageData( - 0, - 0, - canvas.width, - canvas.height - ); - const jsQrCode = jsQR( - qrImageData.data, - canvas.width, - canvas.height - ); + const qrImageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + // jsQR decodes byte-mode QR data as UTF-8, unlike qrcode-reader below + // which decodes it as Latin-1 and garbles non-ASCII text (e.g. Chinese). + const jsQrCode = jsQR(qrImageData.data, canvas.width, canvas.height); - if (jsQrCode && jsQrCode.data) { - if ( - jsQrCode.data.indexOf("otpauth://") !== 0 && - jsQrCode.data.indexOf("otpauth-migration://") !== 0 - ) { - return resolve(null); - } - return resolve(jsQrCode.data); - } else { - return resolve(null); - } - }; - image.src = imageUrl; - } else { - if ( - text.result.indexOf("otpauth://") !== 0 && - text.result.indexOf("otpauth-migration://") !== 0 - ) { + if (jsQrCode && jsQrCode.data && isOtpAuthUrl(jsQrCode.data)) { + return resolve(jsQrCode.data); + } + + // fallback: qrcode-reader can decode some images jsQR fails on, + // but mangles non-ASCII text - only used when jsQR finds nothing. + const qrReader = new QRCode(); + qrReader.callback = (error: string, text: { result: string }) => { + if (error || !isOtpAuthUrl(text.result)) { return resolve(null); } return resolve(text.result); - } + }; + qrReader.decode(imageUrl); }; - qrReader.decode(imageUrl); + image.src = imageUrl; }; reader.readAsDataURL(file); }); diff --git a/src/content.ts b/src/content.ts index 5ac1805cf..3358a4204 100644 --- a/src/content.ts +++ b/src/content.ts @@ -240,43 +240,35 @@ async function qrDecode( canvas.height = imageData.height; canvas.getContext("2d")?.putImageData(imageData, 0, 0); - const qrReader = new QRCode(); - qrReader.callback = ( - error: string, - text: { - result: string; - points: Array<{ - x: number; - y: number; - count: number; - estimatedModuleSize: number; - }>; - } - ) => { - let qrRes = ""; - if (error) { - console.error(error); - const jsQrCode = jsQR( - imageData.data, - imageData.width, - imageData.height - ); - - if (jsQrCode) { - qrRes = jsQrCode.data; - } else { - alert(chrome.i18n.getMessage("errorqr")); - } - } else { - qrRes = text.result; - } + // jsQR decodes byte-mode QR data as UTF-8, unlike qrcode-reader below + // which decodes it as Latin-1 and garbles non-ASCII text (e.g. Chinese). + const jsQrCode = jsQR(imageData.data, imageData.width, imageData.height); + if (jsQrCode) { chrome.runtime.sendMessage({ action: "getTotp", - info: qrRes, + info: jsQrCode.data, }); - }; - qrReader.decode(imageData); + } else { + // fallback: qrcode-reader can decode some images jsQR fails on, + // but mangles non-ASCII text - only used when jsQR finds nothing. + const qrReader = new QRCode(); + qrReader.callback = (error: string, text: { result: string }) => { + let qrRes = ""; + if (error) { + console.error(error); + alert(chrome.i18n.getMessage("errorqr")); + } else { + qrRes = text.result; + } + + chrome.runtime.sendMessage({ + action: "getTotp", + info: qrRes, + }); + }; + qrReader.decode(imageData); + } } }; qr.src = url; diff --git a/src/models/migration.ts b/src/models/migration.ts index 9d00c63e8..8083878a9 100644 --- a/src/models/migration.ts +++ b/src/models/migration.ts @@ -69,7 +69,9 @@ function wordArrayToByteArray(wordArray: CryptoJS.lib.WordArray) { } function byteArray2String(bytes: number[]) { - return String.fromCharCode.apply(null, bytes); + // account/issuer are UTF-8 encoded in the migration protobuf; + // String.fromCharCode treats each byte as Latin-1, garbling non-ASCII text. + return new TextDecoder().decode(new Uint8Array(bytes)); } function subBytesArray(bytes: number[], start: number, length: number) { From 2b388c44fc70566f0db20ef0e49816f925b9abcd Mon Sep 17 00:00:00 2001 From: onewhite <1135651301@qq.com> Date: Tue, 15 Sep 2026 14:48:49 +0200 Subject: [PATCH 2/2] feat: long-press an entry's name to rename it Long-pressing the issuer or account text now enables the existing edit mode and focuses/selects that entry's rename input, instead of requiring the user to open edit mode from the header first. --- src/components/Popup/EntryComponent.vue | 55 ++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/src/components/Popup/EntryComponent.vue b/src/components/Popup/EntryComponent.vue index 92099b5b1..13e21393e 100644 --- a/src/components/Popup/EntryComponent.vue +++ b/src/components/Popup/EntryComponent.vue @@ -38,7 +38,15 @@ > -
+
{{ entry.issuer.split("::")[0] + (theme === "compact" ? ` (${entry.account})` : "") @@ -46,6 +54,7 @@
- +
{ + this.enableRename(field); + }, LONG_PRESS_MS); + }, + cancelLongPress() { + if (this.longPressTimer !== null) { + clearTimeout(this.longPressTimer); + this.longPressTimer = null; + } + }, + enableRename(field: "issuer" | "account") { + if (!this.$store.state.style.style.isEditing) { + this.$store.commit("style/toggleEdit"); + } + this.$nextTick(() => { + const input = (field === "issuer" + ? this.$refs.issuerInput + : this.$refs.accountInput) as HTMLInputElement; + input.focus(); + input.select(); + }); + }, noCopy(code: string) { return ( code === CodeState.Encrypted ||