mirror of
https://github.com/Qortal/chrome-extension.git
synced 2025-02-11 17:55:49 +00:00
authentication in the beginning
This commit is contained in:
parent
4b920dfead
commit
891d7a3529
6
package-lock.json
generated
6
package-lock.json
generated
@ -17,6 +17,7 @@
|
||||
"asmcrypto.js": "2.3.2",
|
||||
"bcryptjs": "2.4.3",
|
||||
"buffer": "6.0.3",
|
||||
"file-saver": "^2.0.5",
|
||||
"jssha": "3.3.1",
|
||||
"react": "^18.2.0",
|
||||
"react-copy-to-clipboard": "^5.1.0",
|
||||
@ -3439,6 +3440,11 @@
|
||||
"node": "^10.12.0 || >=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/file-saver": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
|
||||
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA=="
|
||||
},
|
||||
"node_modules/file-selector": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.6.0.tgz",
|
||||
|
@ -21,6 +21,7 @@
|
||||
"asmcrypto.js": "2.3.2",
|
||||
"bcryptjs": "2.4.3",
|
||||
"buffer": "6.0.3",
|
||||
"file-saver": "^2.0.5",
|
||||
"jssha": "3.3.1",
|
||||
"react": "^18.2.0",
|
||||
"react-copy-to-clipboard": "^5.1.0",
|
||||
|
173
src/App.tsx
173
src/App.tsx
@ -53,7 +53,9 @@ type extStates =
|
||||
| "download-wallet"
|
||||
| "create-wallet"
|
||||
| "transfer-success-regular"
|
||||
| "transfer-success-request";
|
||||
| "transfer-success-request"
|
||||
| "wallet-dropped"
|
||||
;
|
||||
|
||||
function App() {
|
||||
const [extState, setExtstate] = useState<extStates>("not-authenticated");
|
||||
@ -73,6 +75,8 @@ function App() {
|
||||
const [walletToBeDownloaded, setWalletToBeDownloaded] = useState<any>(null);
|
||||
const [walletToBeDownloadedPassword, setWalletToBeDownloadedPassword] =
|
||||
useState<string>("");
|
||||
const [authenticatePassword, setAuthenticatePassword] =
|
||||
useState<string>("");
|
||||
const [sendqortState, setSendqortState] = useState<any>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false)
|
||||
const [
|
||||
@ -81,7 +85,8 @@ function App() {
|
||||
] = useState<string>("");
|
||||
const [walletToBeDownloadedError, setWalletToBeDownloadedError] =
|
||||
useState<string>("");
|
||||
|
||||
const [walletToBeDecryptedError, setWalletToBeDecryptedError] =
|
||||
useState<string>("");
|
||||
const holdRefExtState = useRef<extStates>("not-authenticated")
|
||||
useEffect(()=> {
|
||||
if(extState){
|
||||
@ -135,10 +140,10 @@ function App() {
|
||||
for (const field of requiredFields) {
|
||||
if (!(field in pf)) throw new Error(field + " not found in JSON");
|
||||
}
|
||||
// setBackupjson(pf)
|
||||
storeWalletInfo(pf);
|
||||
// storeWalletInfo(pf);
|
||||
setRawWallet(pf);
|
||||
setExtstate("authenticated");
|
||||
// setExtstate("authenticated");
|
||||
setExtstate("wallet-dropped");
|
||||
setdecryptedWallet(null);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
@ -296,10 +301,10 @@ function App() {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!paymentPassword) {
|
||||
setSendPaymentError("Please enter your wallet password");
|
||||
return;
|
||||
}
|
||||
// if (!paymentPassword) {
|
||||
// setSendPaymentError("Please enter your wallet password");
|
||||
// return;
|
||||
// }
|
||||
setIsLoading(true)
|
||||
chrome.runtime.sendMessage(
|
||||
{
|
||||
@ -351,7 +356,6 @@ function App() {
|
||||
// rawWalletRef.current = rawWallet
|
||||
// }, [rawWallet])
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
@ -447,18 +451,35 @@ function App() {
|
||||
crypto.kdfThreads,
|
||||
() => {}
|
||||
);
|
||||
setRawWallet(wallet);
|
||||
storeWalletInfo(wallet);
|
||||
setWalletToBeDownloaded({
|
||||
wallet,
|
||||
qortAddress: wallet.address0,
|
||||
chrome.runtime.sendMessage({ action: "decryptWallet", payload: {
|
||||
password: walletToBeDownloadedPassword,
|
||||
wallet
|
||||
} }, (response) => {
|
||||
if (response && !response?.error) {
|
||||
setRawWallet(wallet);
|
||||
setWalletToBeDownloaded({
|
||||
wallet,
|
||||
qortAddress: wallet.address0,
|
||||
});
|
||||
chrome.runtime.sendMessage({ action: "userInfo" }, (response2) => {
|
||||
setIsLoading(false)
|
||||
if (response2 && !response2.error) {
|
||||
setUserInfo(response);
|
||||
}
|
||||
});
|
||||
getBalanceFunc();
|
||||
} else if(response?.error){
|
||||
setIsLoading(false)
|
||||
setWalletToBeDecryptedError(response.error)
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
} catch (error: any) {
|
||||
setWalletToBeDownloadedError(error?.message);
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const logoutFunc = () => {
|
||||
@ -505,6 +526,40 @@ function App() {
|
||||
setSendqortState(null);
|
||||
};
|
||||
|
||||
const authenticateWallet = async()=> {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setWalletToBeDecryptedError('')
|
||||
await new Promise<void>((res)=> {
|
||||
setTimeout(()=> {
|
||||
res()
|
||||
}, 250)
|
||||
})
|
||||
chrome.runtime.sendMessage({ action: "decryptWallet", payload: {
|
||||
password: authenticatePassword,
|
||||
wallet: rawWallet
|
||||
} }, (response) => {
|
||||
if (response && !response?.error) {
|
||||
setAuthenticatePassword("");
|
||||
setExtstate("authenticated");
|
||||
setWalletToBeDecryptedError('')
|
||||
chrome.runtime.sendMessage({ action: "userInfo" }, (response) => {
|
||||
setIsLoading(false)
|
||||
if (response && !response.error) {
|
||||
setUserInfo(response);
|
||||
}
|
||||
});
|
||||
getBalanceFunc();
|
||||
} else if(response?.error){
|
||||
setIsLoading(false)
|
||||
setWalletToBeDecryptedError(response.error)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
setWalletToBeDecryptedError('Unable to authenticate. Wrong password')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
{extState === "not-authenticated" && (
|
||||
@ -802,7 +857,7 @@ function App() {
|
||||
>
|
||||
{sendqortState?.amount} QORT
|
||||
</TextP>
|
||||
<Spacer height="29px" />
|
||||
{/* <Spacer height="29px" />
|
||||
|
||||
<CustomLabel htmlFor="standard-adornment-password">
|
||||
Confirm Wallet Password
|
||||
@ -812,7 +867,7 @@ function App() {
|
||||
id="standard-adornment-password"
|
||||
value={paymentPassword}
|
||||
onChange={(e) => setPaymentPassword(e.target.value)}
|
||||
/>
|
||||
/> */}
|
||||
<Spacer height="29px" />
|
||||
<Box
|
||||
sx={{
|
||||
@ -945,6 +1000,80 @@ function App() {
|
||||
</CustomButton>
|
||||
</>
|
||||
)}
|
||||
{rawWallet && extState === 'wallet-dropped' && (
|
||||
<>
|
||||
<Spacer height="22px" />
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
justifyContent: "flex-start",
|
||||
paddingLeft: "22px",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={()=> {
|
||||
setRawWallet(null);
|
||||
setExtstate("not-authenticated");
|
||||
}}
|
||||
src={Return}
|
||||
/>
|
||||
</Box>
|
||||
<Spacer height="10px" />
|
||||
<div className="image-container" style={{
|
||||
width: '136px',
|
||||
height: '154px'
|
||||
}}>
|
||||
<img src={Logo1} className="base-image" />
|
||||
<img src={Logo1Dark} className="hover-image" />
|
||||
</div>
|
||||
<Spacer height="35px" />
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
}}
|
||||
>
|
||||
<TextP
|
||||
sx={{
|
||||
textAlign: "start",
|
||||
lineHeight: "24px",
|
||||
fontSize: "20px",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Authenticate
|
||||
</TextP>
|
||||
</Box>
|
||||
<Spacer height="35px" />
|
||||
|
||||
<>
|
||||
<CustomLabel htmlFor="standard-adornment-password">
|
||||
Wallet Password
|
||||
</CustomLabel>
|
||||
<Spacer height="5px" />
|
||||
<PasswordField
|
||||
id="standard-adornment-password"
|
||||
value={authenticatePassword}
|
||||
onChange={(e) =>
|
||||
setAuthenticatePassword(e.target.value)
|
||||
}
|
||||
/>
|
||||
<Spacer height="20px" />
|
||||
<CustomButton onClick={authenticateWallet}>
|
||||
Authenticate
|
||||
</CustomButton>
|
||||
<Typography color="error">
|
||||
{walletToBeDecryptedError}
|
||||
</Typography>
|
||||
</>
|
||||
</>
|
||||
)}
|
||||
{extState === "download-wallet" && (
|
||||
<>
|
||||
<Spacer height="22px" />
|
||||
@ -1010,7 +1139,7 @@ function App() {
|
||||
<CustomButton onClick={confirmPasswordToDownload}>
|
||||
Confirm password
|
||||
</CustomButton>
|
||||
<Typography color="errror">
|
||||
<Typography color="error">
|
||||
{walletToBeDownloadedError}
|
||||
</Typography>
|
||||
</>
|
||||
@ -1095,7 +1224,7 @@ function App() {
|
||||
<CustomButton onClick={createAccountFunc}>
|
||||
Create Account
|
||||
</CustomButton>
|
||||
<Typography color="errror">
|
||||
<Typography color="error">
|
||||
{walletToBeDownloadedError}
|
||||
</Typography>
|
||||
</>
|
||||
|
@ -80,6 +80,15 @@ async function getAddressInfo(address) {
|
||||
return data;
|
||||
}
|
||||
|
||||
async function getKeyPair() {
|
||||
const res = await chrome.storage.local.get(["keyPair"]);
|
||||
if (res?.keyPair) {
|
||||
return res.keyPair;
|
||||
} else {
|
||||
throw new Error("Wallet not authenticated");
|
||||
}
|
||||
}
|
||||
|
||||
async function getSaveWallet() {
|
||||
const res = await chrome.storage.local.get(["walletInfo"]);
|
||||
if (res?.walletInfo) {
|
||||
@ -222,15 +231,68 @@ async function getNameOrAddress(receiver) {
|
||||
throw new Error(error?.message || "cannot validate address or name");
|
||||
}
|
||||
}
|
||||
async function sendCoin({ password, amount, receiver }) {
|
||||
|
||||
async function decryptWallet({password, wallet, walletVersion}) {
|
||||
try {
|
||||
const response = await decryptStoredWallet(password, wallet);
|
||||
const wallet2 = new PhraseWallet(response, walletVersion);
|
||||
const keyPair = wallet2._addresses[0].keyPair;
|
||||
const toSave = {
|
||||
privateKey: Base58.encode(keyPair.privateKey),
|
||||
publicKey: Base58.encode(keyPair.publicKey)
|
||||
}
|
||||
const dataString = JSON.stringify(toSave)
|
||||
await new Promise((resolve, reject) => {
|
||||
chrome.storage.local.set({ keyPair: dataString }, () => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
} else {
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
chrome.storage.local.set({ walletInfo: wallet }, () => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
} else {
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log({error})
|
||||
throw new Error(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendCoin({ password, amount, receiver }, skipConfirmPassword) {
|
||||
try {
|
||||
const confirmReceiver = await getNameOrAddress(receiver);
|
||||
if (confirmReceiver.error)
|
||||
throw new Error("Invalid receiver address or name");
|
||||
const wallet = await getSaveWallet();
|
||||
const response = await decryptStoredWallet(password, wallet);
|
||||
let keyPair = ''
|
||||
if(skipConfirmPassword){
|
||||
const resKeyPair = await getKeyPair()
|
||||
const parsedData = JSON.parse(resKeyPair)
|
||||
const uint8PrivateKey = Base58.decode(parsedData.privateKey);
|
||||
const uint8PublicKey = Base58.decode(parsedData.publicKey);
|
||||
keyPair = {
|
||||
privateKey: uint8PrivateKey,
|
||||
publicKey: uint8PublicKey
|
||||
};
|
||||
} else {
|
||||
const response = await decryptStoredWallet(password, wallet);
|
||||
const wallet2 = new PhraseWallet(response, walletVersion);
|
||||
|
||||
keyPair = wallet2._addresses[0].keyPair
|
||||
}
|
||||
|
||||
|
||||
const lastRef = await getLastRef();
|
||||
const fee = await sendQortFee();
|
||||
const validApi = await findUsableApi();
|
||||
@ -240,7 +302,7 @@ async function sendCoin({ password, amount, receiver }) {
|
||||
lastRef,
|
||||
amount,
|
||||
fee,
|
||||
wallet2._addresses[0].keyPair,
|
||||
keyPair,
|
||||
validApi
|
||||
);
|
||||
return { res, validApi };
|
||||
@ -263,13 +325,10 @@ function fetchMessages(apiCall) {
|
||||
|
||||
try {
|
||||
const response = await fetch(apiCall);
|
||||
console.log({response})
|
||||
const data = await response.json();
|
||||
console.log({data})
|
||||
if (data && data.length > 0) {
|
||||
resolve(data[0]); // Resolve the promise when data is found
|
||||
} else {
|
||||
console.log("No items found, retrying in", retryDelay / 1000, "seconds...");
|
||||
setTimeout(attemptFetch, retryDelay);
|
||||
retryDelay = Math.min(retryDelay * 2, 360000); // Ensure delay does not exceed 6 minutes
|
||||
}
|
||||
@ -299,15 +358,19 @@ async function listenForChatMessage({ nodeBaseUrl, senderAddress, senderPublicKe
|
||||
const after = timestamp - 5000
|
||||
const apiCall = `${validApi}/chat/messages?involving=${senderAddress}&involving=${address}&reverse=true&limit=1&before=${before}&after=${after}`;
|
||||
const encodedMessageObj = await fetchMessages(apiCall)
|
||||
console.log({encodedMessageObj})
|
||||
const response = await decryptStoredWallet('1234567890', wallet);
|
||||
console.log({response})
|
||||
const wallet2 = new PhraseWallet(response, walletVersion);
|
||||
console.log({wallet2})
|
||||
const decodedMessage = decryptChatMessage(encodedMessageObj.data, wallet2._addresses[0].keyPair.privateKey, senderPublicKey, encodedMessageObj.reference)
|
||||
console.log({decodedMessage})
|
||||
|
||||
const resKeyPair = await getKeyPair()
|
||||
const parsedData = JSON.parse(resKeyPair)
|
||||
const uint8PrivateKey = Base58.decode(parsedData.privateKey);
|
||||
const uint8PublicKey = Base58.decode(parsedData.publicKey);
|
||||
const keyPair = {
|
||||
privateKey: uint8PrivateKey,
|
||||
publicKey: uint8PublicKey
|
||||
};
|
||||
const decodedMessage = decryptChatMessage(encodedMessageObj.data, keyPair.privateKey, senderPublicKey, encodedMessageObj.reference)
|
||||
return { secretCode: decodedMessage };
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
throw new Error(error.message);
|
||||
}
|
||||
}
|
||||
@ -329,15 +392,21 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||
});
|
||||
break;
|
||||
case "getWalletInfo":
|
||||
chrome.storage.local.get(["walletInfo"], (result) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
sendResponse({ error: chrome.runtime.lastError.message });
|
||||
} else if (result.walletInfo) {
|
||||
sendResponse({ walletInfo: result.walletInfo });
|
||||
} else {
|
||||
sendResponse({ error: "No wallet info found" });
|
||||
}
|
||||
});
|
||||
|
||||
getKeyPair().then(()=> {
|
||||
chrome.storage.local.get(["walletInfo"], (result) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
sendResponse({ error: chrome.runtime.lastError.message });
|
||||
} else if (result.walletInfo) {
|
||||
sendResponse({ walletInfo: result.walletInfo });
|
||||
} else {
|
||||
sendResponse({ error: "No wallet info found" });
|
||||
}
|
||||
});
|
||||
}).catch((error)=> {
|
||||
sendResponse({ error: error.message });
|
||||
})
|
||||
|
||||
break;
|
||||
case "validApi":
|
||||
findUsableApi()
|
||||
@ -366,6 +435,22 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||
console.error(error.message);
|
||||
});
|
||||
break;
|
||||
case "decryptWallet": {
|
||||
const { password, wallet } = request.payload;
|
||||
|
||||
decryptWallet({
|
||||
password, wallet, walletVersion
|
||||
})
|
||||
.then((hasDecrypted) => {
|
||||
sendResponse(hasDecrypted);
|
||||
})
|
||||
.catch((error) => {
|
||||
sendResponse({ error: error?.message });
|
||||
console.error(error.message);
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
case "balance":
|
||||
getBalanceInfo()
|
||||
.then((balance) => {
|
||||
@ -392,7 +477,7 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||
|
||||
case "oauth": {
|
||||
const { nodeBaseUrl, senderAddress, senderPublicKey, timestamp } = request.payload;
|
||||
console.log('sup', nodeBaseUrl, senderAddress, senderPublicKey, timestamp)
|
||||
|
||||
listenForChatMessage({ nodeBaseUrl, senderAddress, senderPublicKey, timestamp })
|
||||
.then(({ secretCode }) => {
|
||||
sendResponse(secretCode);
|
||||
@ -699,7 +784,7 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||
sendResponse(false);
|
||||
return;
|
||||
}
|
||||
sendCoin({ password, amount, receiver })
|
||||
sendCoin({ password, amount, receiver }, true)
|
||||
.then((res) => {
|
||||
sendResponse(true);
|
||||
// Use the sendResponse callback to respond to the original message
|
||||
@ -721,7 +806,7 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||
break;
|
||||
case "logout":
|
||||
{
|
||||
chrome.storage.local.remove("walletInfo", () => {
|
||||
chrome.storage.local.remove(["keyPair", "walletInfo"], () => {
|
||||
if (chrome.runtime.lastError) {
|
||||
// Handle error
|
||||
console.error(chrome.runtime.lastError.message);
|
||||
|
@ -4,6 +4,7 @@ import { crypto, walletVersion } from '../../constants/decryptWallet';
|
||||
import { doInitWorkers, kdf } from '../../deps/kdf';
|
||||
import PhraseWallet from './phrase-wallet';
|
||||
import * as WORDLISTS from './wordlists';
|
||||
import { saveAs } from 'file-saver';
|
||||
|
||||
export function generateRandomSentence(template = 'adverb verb noun adjective noun adverb verb noun adjective noun adjective verbed adjective noun', maxWordLength = 0, capitalize = true) {
|
||||
const partsOfSpeechMap = {
|
||||
@ -83,63 +84,19 @@ export const createAccount = async()=> {
|
||||
|
||||
}
|
||||
|
||||
export const saveFileToDisk= async(data, qortAddress) => {
|
||||
export const saveFileToDisk = async (data, qortAddress) => {
|
||||
try {
|
||||
const dataString = JSON.stringify(data)
|
||||
const blob = new Blob([dataString], { type: 'text/plain;charset=utf-8' })
|
||||
const fileName = "qortal_backup_" + qortAddress + ".json"
|
||||
// Feature detection. The API needs to be supported
|
||||
// and the app not run in an iframe.
|
||||
const supportsFileSystemAccess =
|
||||
'showSaveFilePicker' in window &&
|
||||
(() => {
|
||||
try {
|
||||
return window.self === window.top
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})()
|
||||
// If the File System Access API is supported...
|
||||
if (supportsFileSystemAccess) {
|
||||
try {
|
||||
// Show the file save dialog.
|
||||
const fileHandle = await window.showSaveFilePicker({
|
||||
suggestedName: fileName,
|
||||
types: [{
|
||||
description: "File",
|
||||
}]
|
||||
})
|
||||
// Write the blob to the file.
|
||||
const writable = await fileHandle.createWritable()
|
||||
await writable.write(blob)
|
||||
await writable.close()
|
||||
console.log("FILE SAVED")
|
||||
return
|
||||
} catch (err) {
|
||||
// Fail silently if the user has simply canceled the dialog.
|
||||
if (err.name === 'AbortError') {
|
||||
console.error(err.name, err.message)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback if the File System Access API is not supported...
|
||||
// Create the blob URL.
|
||||
const blobURL = URL.createObjectURL(blob)
|
||||
// Create the `<a download>` element and append it invisibly.
|
||||
const a = document.createElement('a')
|
||||
a.href = blobURL
|
||||
a.download = fileName
|
||||
a.style.display = 'none'
|
||||
document.body.append(a)
|
||||
// Programmatically click the element.
|
||||
a.click()
|
||||
// Revoke the blob URL and remove the element.
|
||||
setTimeout(() => {
|
||||
URL.revokeObjectURL(blobURL);
|
||||
a.remove();
|
||||
}, 1000);
|
||||
const dataString = JSON.stringify(data);
|
||||
const blob = new Blob([dataString], { type: 'application/json' });
|
||||
const fileName = "qortal_backup_" + qortAddress + ".json";
|
||||
|
||||
saveAs(blob, fileName);
|
||||
} catch (error) {
|
||||
console.log({error})
|
||||
console.log({ error });
|
||||
if (error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
// This fallback will only be executed if the `showSaveFilePicker` method fails.
|
||||
FileSaver.saveAs(blob, fileName); // Ensure FileSaver is properly imported or available in your environment.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user