initial commit
8
.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
dist
|
||||||
|
**/node_modules
|
||||||
|
src-tauri/target/
|
||||||
|
|
||||||
|
# just in case
|
||||||
|
credentials*
|
||||||
|
|
||||||
|
|
25
index.html
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Vite + Svelte</title>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
align-items: center;
|
||||||
|
justify-items: center;
|
||||||
|
min-width: 100vw;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
</head>
|
||||||
|
<body class="bg-zinc-800">
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
2660
package-lock.json
generated
Normal file
22
package.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "creddy",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"tauri": "tauri"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^1.0.1",
|
||||||
|
"@tauri-apps/cli": "^1.0.5",
|
||||||
|
"autoprefixer": "^10.4.8",
|
||||||
|
"postcss": "^8.4.16",
|
||||||
|
"svelte": "^3.49.0",
|
||||||
|
"tailwindcss": "^3.1.8",
|
||||||
|
"vite": "^3.0.7"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^1.0.2"
|
||||||
|
}
|
||||||
|
}
|
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
3805
src-tauri/Cargo.lock
generated
Normal file
31
src-tauri/Cargo.toml
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
[package]
|
||||||
|
name = "app"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "A Tauri App"
|
||||||
|
authors = ["you"]
|
||||||
|
license = ""
|
||||||
|
repository = ""
|
||||||
|
default-run = "app"
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.57"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "1.0.4", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde_json = "1.0"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
tauri = { version = "1.0.5", features = ["api-all"] }
|
||||||
|
sodiumoxide = "0.2.7"
|
||||||
|
tokio = { version = ">=1.19", features = ["full"] }
|
||||||
|
# futures = ">=0.3.21"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
# by default Tauri runs in production mode
|
||||||
|
# when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL
|
||||||
|
default = [ "custom-protocol" ]
|
||||||
|
# this feature is used used for production builds where `devPath` points to the filesystem
|
||||||
|
# DO NOT remove this
|
||||||
|
custom-protocol = [ "tauri/custom-protocol" ]
|
3
src-tauri/build.rs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
BIN
src-tauri/icons/128x128.png
Normal file
After Width: | Height: | Size: 3.4 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
After Width: | Height: | Size: 6.8 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
After Width: | Height: | Size: 974 B |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
After Width: | Height: | Size: 3.8 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
After Width: | Height: | Size: 7.6 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
After Width: | Height: | Size: 903 B |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
After Width: | Height: | Size: 8.4 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
After Width: | Height: | Size: 85 KiB |
BIN
src-tauri/icons/icon.png
Normal file
After Width: | Height: | Size: 14 KiB |
42
src-tauri/src/http/errors.rs
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
use std::fmt::{Display, Formatter};
|
||||||
|
use std::convert::From;
|
||||||
|
use std::str::Utf8Error;
|
||||||
|
|
||||||
|
// use tokio::sync::oneshot::error::RecvError;
|
||||||
|
|
||||||
|
|
||||||
|
// Represents errors encountered while handling an HTTP request
|
||||||
|
pub enum RequestError {
|
||||||
|
StreamIOError(std::io::Error),
|
||||||
|
InvalidUtf8,
|
||||||
|
MalformedHttpRequest,
|
||||||
|
RequestTooLarge,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<tokio::io::Error> for RequestError {
|
||||||
|
fn from(e: std::io::Error) -> RequestError {
|
||||||
|
RequestError::StreamIOError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl From<Utf8Error> for RequestError {
|
||||||
|
fn from(_e: Utf8Error) -> RequestError {
|
||||||
|
RequestError::InvalidUtf8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// impl From<RecvError> for RequestError {
|
||||||
|
// fn from (_e: RecvError) -> RequestError {
|
||||||
|
// RequestError::
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
impl Display for RequestError {
|
||||||
|
fn fmt(&self, f: &mut Formatter) -> Result<(), std::fmt::Error> {
|
||||||
|
use RequestError::*;
|
||||||
|
match self {
|
||||||
|
StreamIOError(e) => write!(f, "Stream IO error: {e}"),
|
||||||
|
InvalidUtf8 => write!(f, "Could not decode UTF-8 from bytestream"),
|
||||||
|
MalformedHttpRequest => write!(f, "Maformed HTTP request"),
|
||||||
|
RequestTooLarge => write!(f, "HTTP request too large"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
99
src-tauri/src/http/mod.rs
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
use std::io;
|
||||||
|
use std::net::SocketAddrV4;
|
||||||
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
|
mod errors;
|
||||||
|
use errors::RequestError;
|
||||||
|
|
||||||
|
|
||||||
|
pub async fn serve(addr: SocketAddrV4, app_handle: AppHandle) -> io::Result<()> {
|
||||||
|
let listener = TcpListener::bind(&addr).await?;
|
||||||
|
println!("Listening on {addr}");
|
||||||
|
loop {
|
||||||
|
let new_handle = app_handle.app_handle();
|
||||||
|
match listener.accept().await {
|
||||||
|
Ok((stream, _)) => {
|
||||||
|
tokio::spawn(async {
|
||||||
|
if let Err(e) = handle(stream, new_handle).await {
|
||||||
|
eprintln!("{e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
println!("Error accepting connection: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// it doesn't really return a String, we just need to placate the compiler
|
||||||
|
async fn stall(stream: &mut TcpStream) -> Result<String, tokio::io::Error> {
|
||||||
|
let delay = std::time::Duration::from_secs(1);
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(delay).await;
|
||||||
|
stream.write(b"x").await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async fn handle(mut stream: TcpStream, app_handle: AppHandle) -> Result<(), RequestError> {
|
||||||
|
let mut buf = [0; 8192]; // it's what tokio's BufReader uses
|
||||||
|
let mut n = 0;
|
||||||
|
loop {
|
||||||
|
n += stream.read(&mut buf[n..]).await?;
|
||||||
|
if &buf[(n - 4)..n] == b"\r\n\r\n" {break;}
|
||||||
|
if n == buf.len() {return Err(RequestError::RequestTooLarge);}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("{}", std::str::from_utf8(&buf).unwrap());
|
||||||
|
|
||||||
|
stream.write(b"HTTP/1.0 200 OK\r\n").await?;
|
||||||
|
stream.write(b"Content-Type: application/json\r\n").await?;
|
||||||
|
stream.write(b"X-Creddy-delaying-tactic: ").await?;
|
||||||
|
|
||||||
|
let creds = tokio::select!{
|
||||||
|
r = stall(&mut stream) => r?, // this will never return Ok, just Err if it can't write to the stream
|
||||||
|
c = get_creds(&app_handle) => c?,
|
||||||
|
};
|
||||||
|
|
||||||
|
stream.write(b"\r\nContent-Length: ").await?;
|
||||||
|
stream.write(creds.as_bytes().len().to_string().as_bytes()).await?;
|
||||||
|
stream.write(b"\r\n\r\n").await?;
|
||||||
|
stream.write(creds.as_bytes()).await?;
|
||||||
|
stream.write(b"\r\n\r\n").await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
use tokio::io::{stdin, stdout, BufReader, AsyncBufReadExt};
|
||||||
|
use crate::storage;
|
||||||
|
|
||||||
|
use tokio::sync::oneshot;
|
||||||
|
|
||||||
|
async fn get_creds(app_handle: &AppHandle) -> io::Result<String> {
|
||||||
|
app_handle.emit_all("credentials-request", ()).unwrap();
|
||||||
|
|
||||||
|
// let mut out = stdout();
|
||||||
|
// out.write_all(b"Enter passphrase: ").await?;
|
||||||
|
// out.flush().await?;
|
||||||
|
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
app_handle.once_global("passphrase-entered", |event| {
|
||||||
|
match event.payload() {
|
||||||
|
Some(p) => {tx.send(p.to_string());}
|
||||||
|
None => {tx.send("".to_string());} // will fail decryption, we just need to unblock the outer function
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Error is only returned if the rx is closed/dropped before receiving, which should never happen
|
||||||
|
let passphrase = rx.await.unwrap();
|
||||||
|
|
||||||
|
// let mut passphrase = String::new();
|
||||||
|
// let mut reader = BufReader::new(stdin());
|
||||||
|
// reader.read_line(&mut passphrase).await?;
|
||||||
|
|
||||||
|
Ok(storage::load(&passphrase.trim()))
|
||||||
|
}
|
30
src-tauri/src/main.rs
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
#![cfg_attr(
|
||||||
|
all(not(debug_assertions), target_os = "windows"),
|
||||||
|
windows_subsystem = "windows"
|
||||||
|
)]
|
||||||
|
|
||||||
|
use std::str::FromStr;
|
||||||
|
// use tokio::runtime::Runtime;
|
||||||
|
|
||||||
|
mod storage;
|
||||||
|
mod http;
|
||||||
|
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.setup(|app| {
|
||||||
|
let addr = std::net::SocketAddrV4::from_str("127.0.0.1:12345").unwrap();
|
||||||
|
tauri::async_runtime::spawn(http::serve(addr, app.handle()));
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running tauri application");
|
||||||
|
|
||||||
|
// let addr = std::net::SocketAddrV4::from_str("127.0.0.1:12345").unwrap();
|
||||||
|
// let rt = Runtime::new().unwrap();
|
||||||
|
|
||||||
|
// rt.block_on(http::serve(addr)).unwrap();
|
||||||
|
|
||||||
|
// let creds = std::fs::read_to_string("credentials.json").unwrap();
|
||||||
|
// storage::save(&creds, "correct horse battery staple");
|
||||||
|
}
|
31
src-tauri/src/storage.rs
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
use sodiumoxide::crypto::{pwhash, secretbox};
|
||||||
|
|
||||||
|
|
||||||
|
pub fn save(data: &str, passphrase: &str) {
|
||||||
|
let salt = pwhash::Salt([0; 32]); // yes yes, just for now
|
||||||
|
let mut kbuf = [0; secretbox::KEYBYTES];
|
||||||
|
pwhash::derive_key_interactive(&mut kbuf, passphrase.as_bytes(), &salt)
|
||||||
|
.expect("Couldn't compute password hash. Are you out of memory?");
|
||||||
|
let key = secretbox::Key(kbuf);
|
||||||
|
let nonce = secretbox::Nonce([0; 24]); // we don't care about e.g. replay attacks so this might be safe?
|
||||||
|
let encrypted = secretbox::seal(data.as_bytes(), &nonce, &key);
|
||||||
|
|
||||||
|
//todo: store in a database, along with salt, nonce, and hash parameters
|
||||||
|
std::fs::write("credentials.enc", &encrypted).expect("Failed to write file.");
|
||||||
|
|
||||||
|
//todo: key is automatically zeroed, but we should use 'zeroize' or something to zero out passphrase and data
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub fn load(passphrase: &str) -> String {
|
||||||
|
let salt = pwhash::Salt([0; 32]);
|
||||||
|
let mut kbuf = [0; secretbox::KEYBYTES];
|
||||||
|
pwhash::derive_key_interactive(&mut kbuf, passphrase.as_bytes(), &salt)
|
||||||
|
.expect("Couldn't compute password hash. Are you out of memory?");
|
||||||
|
let key = secretbox::Key(kbuf);
|
||||||
|
let nonce = secretbox::Nonce([0; 24]);
|
||||||
|
|
||||||
|
let encrypted = std::fs::read("credentials.enc").expect("Failed to read file.");
|
||||||
|
let decrypted = secretbox::open(&encrypted, &nonce, &key).expect("Failed to decrypt.");
|
||||||
|
String::from_utf8(decrypted).expect("Invalid utf-8")
|
||||||
|
}
|
66
src-tauri/tauri.conf.json
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
|
||||||
|
"build": {
|
||||||
|
"beforeBuildCommand": "npm run build",
|
||||||
|
"beforeDevCommand": "npm run dev",
|
||||||
|
"devPath": "http://localhost:5173",
|
||||||
|
"distDir": "../dist"
|
||||||
|
},
|
||||||
|
"package": {
|
||||||
|
"productName": "creddy",
|
||||||
|
"version": "0.1.0"
|
||||||
|
},
|
||||||
|
"tauri": {
|
||||||
|
"allowlist": {
|
||||||
|
"all": true
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"category": "DeveloperTool",
|
||||||
|
"copyright": "",
|
||||||
|
"deb": {
|
||||||
|
"depends": []
|
||||||
|
},
|
||||||
|
"externalBin": [],
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
],
|
||||||
|
"identifier": "com.tauri.dev",
|
||||||
|
"longDescription": "",
|
||||||
|
"macOS": {
|
||||||
|
"entitlements": null,
|
||||||
|
"exceptionDomain": "",
|
||||||
|
"frameworks": [],
|
||||||
|
"providerShortName": null,
|
||||||
|
"signingIdentity": null
|
||||||
|
},
|
||||||
|
"resources": [],
|
||||||
|
"shortDescription": "",
|
||||||
|
"targets": "all",
|
||||||
|
"windows": {
|
||||||
|
"certificateThumbprint": null,
|
||||||
|
"digestAlgorithm": "sha256",
|
||||||
|
"timestampUrl": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
},
|
||||||
|
"updater": {
|
||||||
|
"active": false
|
||||||
|
},
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"fullscreen": false,
|
||||||
|
"height": 600,
|
||||||
|
"resizable": true,
|
||||||
|
"title": "Creddy",
|
||||||
|
"width": 800
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
14
src/App.svelte
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<script>
|
||||||
|
import { emit, listen } from '@tauri-apps/api/event';
|
||||||
|
import Home from './views/Home.svelte';
|
||||||
|
import Approve from './views/Approve.svelte';
|
||||||
|
|
||||||
|
// listen('credentials-request', (event) => {
|
||||||
|
// const passphrase = prompt('Please enter your passphrase:');
|
||||||
|
// emit('passphrase-entered', passphrase);
|
||||||
|
// });
|
||||||
|
|
||||||
|
let activeComponent = Approve;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:component this={activeComponent} />
|
24
src/lib/queue.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
export default function() {
|
||||||
|
return {
|
||||||
|
items: [],
|
||||||
|
|
||||||
|
resolvers: []
|
||||||
|
|
||||||
|
put(item) {
|
||||||
|
this.items.push(item);
|
||||||
|
if (resolvers.length > 0) {
|
||||||
|
resolvers.shift().resolve();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async get() {
|
||||||
|
if (this.items.length === 0) {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
this.resolvers.push(resolve);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.items.shift();
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
8
src/main.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import './style.css';
|
||||||
|
import App from './App.svelte';
|
||||||
|
|
||||||
|
const app = new App({
|
||||||
|
target: document.getElementById('app')
|
||||||
|
})
|
||||||
|
|
||||||
|
export default app;
|
3
src/style.css
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
22
src/views/Approve.svelte
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<script>
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
import check_circle from '../assets/check-circle.svg?raw';
|
||||||
|
import x_circle from '../assets/x-circle.svg?raw';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h2 class="text-3xl text-gray-200">An application would like to access your AWS credentials.</h2>
|
||||||
|
|
||||||
|
<button on:click={() => dispatch('response', 'approved')}>
|
||||||
|
<svg class="w-32 stroke-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button on:click={() => dispatch('response', 'denied')}>
|
||||||
|
<svg class="w-32 stroke-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
1
src/views/Home.svelte
Normal file
@ -0,0 +1 @@
|
|||||||
|
<h1 class="text-4xl text-gray-300">Creddy</h1>
|
11
tailwind.config.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{svelte,html}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
7
vite.config.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [svelte()]
|
||||||
|
})
|