Initial scaffold for kestrove-desktop
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
name: ci
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix: { os: [ubuntu-22.04, windows-latest] }
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with: { path: kestrove-desktop }
|
||||
- uses: actions/checkout@v4
|
||||
with: { repository: ${{ github.repository_owner }}/kestrove-core, path: kestrove-core, token: ${{ secrets.CORE_READ_TOKEN }} }
|
||||
- if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libxdo-dev
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
- run: npm ci && npm run build
|
||||
working-directory: kestrove-desktop
|
||||
- run: cargo check --all-targets
|
||||
working-directory: kestrove-desktop/src-tauri
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
@@ -0,0 +1,23 @@
|
||||
# kestrove-desktop
|
||||
|
||||
**Windows and Linux** app built on Tauri 2. The Rust side links `kestrove-core`'s `kv-ffi`
|
||||
directly. The web UI only renders and never touches keys. macOS uses the native `kestrove-apple` app.
|
||||
|
||||
Also ships `kestrove-nmh`, the **native-messaging host** that bridges `kestrove-browser` to the app.
|
||||
|
||||
```bash
|
||||
# prerequisites: rustup, Node 20+, and Tauri's OS deps (https://tauri.app/start/prerequisites/)
|
||||
npm install
|
||||
npm run tauri icon path/to/logo.png # generates src-tauri/icons (required before building)
|
||||
npm run tauri dev
|
||||
npm run tauri build # MSI/NSIS on Windows, AppImage/deb on Linux
|
||||
```
|
||||
|
||||
## Roadmap (M3, kestrove-spec/docs/07)
|
||||
- [ ] Persistent vault + sync (kv-store / kv-sync)
|
||||
- [ ] Unlock with Windows Hello (KeyCredentialManager, TPM) and libsecret on Linux
|
||||
- [ ] Auto-lock on idle, screen lock and suspend
|
||||
- [ ] Local IPC between `kestrove-nmh` and the app, plus the pairing AEAD channel
|
||||
- [ ] **Quick Access** global hotkey (Ctrl+Shift+Space): search, then copy or auto-type (Windows `SendInput`, X11 XTest; Wayland gets copy only)
|
||||
- [ ] Installer registers native-messaging manifests (`native-messaging/`)
|
||||
- [ ] Windows 11 passkey plugin provider
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Kestrove</title>
|
||||
<link rel="stylesheet" href="/src/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main id="app"></main>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
# Native messaging manifests
|
||||
|
||||
The installer fills in the placeholders and registers the manifests:
|
||||
|
||||
| Browser | Linux | Windows |
|
||||
|---|---|---|
|
||||
| Chrome | `~/.config/google-chrome/NativeMessagingHosts/app.kestrove.nmh.json` | `HKCU\Software\Google\Chrome\NativeMessagingHosts\app.kestrove.nmh` → path to json |
|
||||
| Edge | `~/.config/microsoft-edge/NativeMessagingHosts/` | `HKCU\Software\Microsoft\Edge\NativeMessagingHosts\app.kestrove.nmh` |
|
||||
| Firefox | `~/.mozilla/native-messaging-hosts/app.kestrove.nmh.json` | `HKCU\Software\Mozilla\NativeMessagingHosts\app.kestrove.nmh` |
|
||||
| Brave | `~/.config/BraveSoftware/Brave-Browser/NativeMessagingHosts/` | same key as Chrome |
|
||||
|
||||
`allowed_origins` / `allowed_extensions` pin the host to **your** extension id.
|
||||
That is the first line of defense against other extensions (docs/04 A8).
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "app.kestrove.nmh",
|
||||
"description": "Kestrove native messaging host",
|
||||
"path": "__INSTALL_DIR__/kestrove-nmh",
|
||||
"type": "stdio",
|
||||
"allowed_origins": ["chrome-extension://__CHROME_EXTENSION_ID__/"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "app.kestrove.nmh",
|
||||
"description": "Kestrove native messaging host",
|
||||
"path": "__INSTALL_DIR__/kestrove-nmh",
|
||||
"type": "stdio",
|
||||
"allowed_extensions": ["__FIREFOX_EXTENSION_ID__"]
|
||||
}
|
||||
Generated
+1282
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "kestrove-desktop",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "2.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "2.1.0",
|
||||
"typescript": "5.7.2",
|
||||
"vite": "6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
[package]
|
||||
name = "kestrove-desktop"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "Apache-2.0"
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "kestrove-desktop"
|
||||
path = "src/main.rs"
|
||||
|
||||
# Native messaging host: started by the browser, relays to the running app (docs/05).
|
||||
[[bin]]
|
||||
name = "kestrove-nmh"
|
||||
path = "src/bin/kestrove-nmh.rs"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
# Repos are checked out side by side (see ../../README.md).
|
||||
kv-ffi = { path = "../../kestrove-core/crates/kv-ffi" }
|
||||
tauri = { version = "2", features = [] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
arboard = "3"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
strip = true
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Main window: core IPC only. No fs, shell or http plugins.",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default"]
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//! Native messaging host for Chrome/Edge/Firefox (kestrove-spec/docs/05).
|
||||
//!
|
||||
//! The browser starts this process and talks over stdio: each message is a u32 length
|
||||
//! (native byte order) followed by UTF-8 JSON, at most 1 MiB to the browser.
|
||||
//! This process holds **no** secrets. It relays each request to the running desktop app over
|
||||
//! a local IPC channel (Windows named pipe `\\.\pipe\kestrove-nmh-<user>`, Unix socket
|
||||
//! `$XDG_RUNTIME_DIR/kestrove/nmh.sock` with 0600 perms). The app re-checks origins and asks for
|
||||
//! approval or unlock as needed.
|
||||
|
||||
use std::io::{self, Read, Write};
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
const MAX_IN: usize = 4 * 1024 * 1024;
|
||||
|
||||
fn read_message(stdin: &mut impl Read) -> io::Result<Option<Value>> {
|
||||
let mut len = [0u8; 4];
|
||||
match stdin.read_exact(&mut len) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None), // browser closed the port
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
let len = u32::from_ne_bytes(len) as usize;
|
||||
if len > MAX_IN {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "message too large"));
|
||||
}
|
||||
let mut buf = vec![0u8; len];
|
||||
stdin.read_exact(&mut buf)?;
|
||||
serde_json::from_slice(&buf).map(Some).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
|
||||
}
|
||||
|
||||
fn write_message(stdout: &mut impl Write, v: &Value) -> io::Result<()> {
|
||||
let bytes = serde_json::to_vec(v)?;
|
||||
stdout.write_all(&(bytes.len() as u32).to_ne_bytes())?;
|
||||
stdout.write_all(&bytes)?;
|
||||
stdout.flush()
|
||||
}
|
||||
|
||||
/// TODO(M3): forward to the desktop app over local IPC, inside the paired AEAD channel.
|
||||
fn relay(req: &Value) -> Value {
|
||||
match req.get("type").and_then(Value::as_str) {
|
||||
Some("hello") => json!({ "type": "hello", "version": env!("CARGO_PKG_VERSION"), "paired": false }),
|
||||
Some(_) => json!({ "type": "error", "code": "unavailable" }),
|
||||
None => json!({ "type": "error", "code": "bad_request" }),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> io::Result<()> {
|
||||
let (mut stdin, mut stdout) = (io::stdin().lock(), io::stdout().lock());
|
||||
while let Some(req) = read_message(&mut stdin)? {
|
||||
write_message(&mut stdout, &relay(&req))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// Kestrove desktop (Windows/Linux; macOS uses kestrove-apple). Thin Tauri shell over kv-ffi.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
use std::{sync::Arc, thread, time::Duration};
|
||||
|
||||
use kv_ffi::{generate_password as core_generate_password, CoreError, ItemDto, ItemKindDto, VaultCore};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
struct App {
|
||||
// TODO(M3): persistent store + sync (kv-store/kv-sync) instead of in-memory.
|
||||
core: Arc<VaultCore>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AppState {
|
||||
has_account: bool,
|
||||
locked: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Summary {
|
||||
id: String,
|
||||
title: String,
|
||||
username: Option<String>,
|
||||
urls: Vec<String>,
|
||||
favorite: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Detail {
|
||||
id: String,
|
||||
title: String,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
urls: Vec<String>,
|
||||
totp: Option<String>,
|
||||
notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct NewLogin {
|
||||
title: String,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
urls: Vec<String>,
|
||||
}
|
||||
|
||||
type CmdResult<T> = Result<T, String>;
|
||||
|
||||
fn err(e: CoreError) -> String {
|
||||
e.to_string()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn state(app: State<'_, App>) -> AppState {
|
||||
AppState { has_account: app.core.has_account(), locked: app.core.is_locked() }
|
||||
}
|
||||
|
||||
// Argon2id takes ~0.5 s, so these run as async commands off the main thread.
|
||||
#[tauri::command]
|
||||
async fn create_account(app: State<'_, App>, master_password: String) -> CmdResult<String> {
|
||||
app.core.create_account(master_password).map_err(err)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn unlock(app: State<'_, App>, master_password: String, secret_key: String) -> CmdResult<()> {
|
||||
app.core.unlock(master_password, secret_key).map_err(err)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn lock(app: State<'_, App>) {
|
||||
app.core.lock();
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn list_items(app: State<'_, App>) -> CmdResult<Vec<Summary>> {
|
||||
Ok(app
|
||||
.core
|
||||
.list_items()
|
||||
.map_err(err)?
|
||||
.into_iter()
|
||||
.map(|s| Summary { id: s.id, title: s.title, username: s.username, urls: s.urls, favorite: s.favorite })
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_item(app: State<'_, App>, id: String) -> CmdResult<Detail> {
|
||||
let d = app.core.get_item(id).map_err(err)?;
|
||||
Ok(Detail { id: d.id, title: d.title, username: d.username, password: d.password, urls: d.urls, totp: d.totp, notes: d.notes })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_item(app: State<'_, App>, item: NewLogin) -> CmdResult<String> {
|
||||
app.core
|
||||
.save_item(ItemDto {
|
||||
id: String::new(),
|
||||
kind: ItemKindDto::Login,
|
||||
title: item.title,
|
||||
username: item.username,
|
||||
password: item.password,
|
||||
urls: item.urls,
|
||||
totp: None,
|
||||
notes: None,
|
||||
favorite: false,
|
||||
tags: vec![],
|
||||
modified_at: 0,
|
||||
})
|
||||
.map_err(err)
|
||||
}
|
||||
|
||||
/// Copies the password from Rust so it never needs to round-trip through JS, then clears it after 90 s.
|
||||
/// TODO(M3): on Windows also set ExcludeClipboardContentFromMonitorProcessing (docs/04).
|
||||
#[tauri::command]
|
||||
fn copy_secret(app: State<'_, App>, id: String) -> CmdResult<()> {
|
||||
let secret = app.core.get_item(id).map_err(err)?.password.unwrap_or_default();
|
||||
let mut cb = arboard::Clipboard::new().map_err(|e| e.to_string())?;
|
||||
cb.set_text(secret.clone()).map_err(|e| e.to_string())?;
|
||||
thread::spawn(move || {
|
||||
thread::sleep(Duration::from_secs(90));
|
||||
if let Ok(mut cb) = arboard::Clipboard::new() {
|
||||
if cb.get_text().ok().as_deref() == Some(secret.as_str()) {
|
||||
let _ = cb.clear();
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn generate_password(length: u32) -> String {
|
||||
core_generate_password(length, true)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
.manage(App { core: VaultCore::new_in_memory() })
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
state,
|
||||
create_account,
|
||||
unlock,
|
||||
lock,
|
||||
list_items,
|
||||
get_item,
|
||||
save_item,
|
||||
copy_secret,
|
||||
generate_password
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Kestrove");
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Kestrove",
|
||||
"version": "0.1.0",
|
||||
"identifier": "app.kestrove.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [{ "title": "Kestrove", "width": 960, "height": 640, "minWidth": 720, "minHeight": 480 }],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src ipc: http://ipc.localhost"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["msi", "nsis", "appimage", "deb"],
|
||||
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/icon.ico", "icons/icon.png"]
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
// Minimal desktop UI. All vault logic runs in Rust (src-tauri → kv-ffi); this layer only renders.
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
interface ItemSummary { id: string; title: string; username: string | null; urls: string[]; favorite: boolean }
|
||||
interface ItemDto { id: string; title: string; username: string | null; password: string | null; urls: string[]; totp: string | null; notes: string | null }
|
||||
|
||||
const app = document.getElementById("app")!;
|
||||
|
||||
function el<K extends keyof HTMLElementTagNameMap>(tag: K, props: Record<string, unknown> = {}, ...children: (Node | string)[]): HTMLElementTagNameMap[K] {
|
||||
const e: HTMLElementTagNameMap[K] = Object.assign(document.createElement(tag), props);
|
||||
e.append(...children);
|
||||
return e;
|
||||
}
|
||||
|
||||
async function render() {
|
||||
const state = await invoke<{ has_account: boolean; locked: boolean }>("state");
|
||||
app.replaceChildren();
|
||||
if (!state.has_account) return renderCreate();
|
||||
if (state.locked) return renderUnlock();
|
||||
return renderVault();
|
||||
}
|
||||
|
||||
function renderCreate() {
|
||||
const pw = el("input", { type: "password", placeholder: "Master password (10+ chars)" });
|
||||
const err = el("div", { className: "err" });
|
||||
const btn = el("button", { textContent: "Create vault" });
|
||||
const card = el("div", { className: "card" }, el("h2", { textContent: "Create your Kestrove vault" }), pw, btn, err);
|
||||
btn.onclick = async () => {
|
||||
try {
|
||||
const secretKey = await invoke<string>("create_account", { masterPassword: pw.value });
|
||||
card.replaceChildren(
|
||||
el("h2", { textContent: "Your Secret Key" }),
|
||||
el("p", { className: "mono", textContent: secretKey }),
|
||||
el("p", { className: "muted", textContent: "Write this down in your Emergency Kit. You need it to add devices." }),
|
||||
el("button", { textContent: "Continue", onclick: render }),
|
||||
);
|
||||
} catch (e) {
|
||||
err.textContent = String(e);
|
||||
}
|
||||
};
|
||||
app.append(el("div", { className: "center" }, card));
|
||||
}
|
||||
|
||||
function renderUnlock() {
|
||||
const pw = el("input", { type: "password", placeholder: "Master password" });
|
||||
const sk = el("input", { placeholder: "Secret Key (K1-…)", className: "mono" });
|
||||
const err = el("div", { className: "err" });
|
||||
const btn = el("button", { textContent: "Unlock" });
|
||||
btn.onclick = async () => {
|
||||
try {
|
||||
await invoke("unlock", { masterPassword: pw.value, secretKey: sk.value });
|
||||
pw.value = "";
|
||||
await render();
|
||||
} catch (e) {
|
||||
err.textContent = String(e);
|
||||
}
|
||||
};
|
||||
app.append(el("div", { className: "center" }, el("div", { className: "card" }, el("h2", { textContent: "Kestrove is locked" }), pw, sk, btn, err)));
|
||||
}
|
||||
|
||||
async function renderVault() {
|
||||
const items = await invoke<ItemSummary[]>("list_items");
|
||||
const detail = el("section");
|
||||
const list = el("ul");
|
||||
for (const it of items) {
|
||||
const li = el("li", {}, el("div", { textContent: it.title }), el("div", { className: "muted", textContent: it.username ?? "" }));
|
||||
li.onclick = async () => {
|
||||
list.querySelectorAll("li").forEach((n) => n.classList.remove("sel"));
|
||||
li.classList.add("sel");
|
||||
const item = await invoke<ItemDto>("get_item", { id: it.id });
|
||||
const pwSpan = el("span", { className: "mono", textContent: "••••••••" });
|
||||
detail.replaceChildren(
|
||||
el("h2", { textContent: item.title }),
|
||||
el("p", {}, "Username: ", el("span", { className: "mono", textContent: item.username ?? "" })),
|
||||
el("p", {}, "Password: ", pwSpan, " ",
|
||||
el("button", { textContent: "Reveal", onclick: () => (pwSpan.textContent = item.password ?? "") }),
|
||||
" ",
|
||||
el("button", { textContent: "Copy", onclick: () => invoke("copy_secret", { id: item.id }) })),
|
||||
el("p", { className: "muted", textContent: item.urls.join(", ") }),
|
||||
);
|
||||
};
|
||||
list.append(li);
|
||||
}
|
||||
const add = el("button", { textContent: "+ New login" });
|
||||
add.onclick = () => renderEditor(detail);
|
||||
const lock = el("button", { textContent: "Lock", onclick: async () => { await invoke("lock"); await render(); } });
|
||||
app.append(el("aside", {}, el("div", { className: "toolbar" }, add, lock), list), detail);
|
||||
}
|
||||
|
||||
function renderEditor(target: HTMLElement) {
|
||||
const title = el("input", { placeholder: "Title" });
|
||||
const user = el("input", { placeholder: "Username" });
|
||||
const pass = el("input", { placeholder: "Password", className: "mono" });
|
||||
const url = el("input", { placeholder: "https://example.com" });
|
||||
const gen = el("button", { textContent: "Generate", onclick: async () => (pass.value = await invoke<string>("generate_password", { length: 24 })) });
|
||||
const save = el("button", { textContent: "Save" });
|
||||
save.onclick = async () => {
|
||||
await invoke("save_item", {
|
||||
item: { title: title.value, username: user.value || null, password: pass.value || null, urls: url.value ? [url.value] : [] },
|
||||
});
|
||||
await render();
|
||||
};
|
||||
target.replaceChildren(el("div", { className: "card" }, el("h2", { textContent: "New login" }), title, user, pass, gen, url, save));
|
||||
}
|
||||
|
||||
void render();
|
||||
@@ -0,0 +1,16 @@
|
||||
:root { color-scheme: light dark; font: 14px system-ui, sans-serif; }
|
||||
body { margin: 0; }
|
||||
main { display: grid; grid-template-columns: 300px 1fr; height: 100vh; }
|
||||
.center { display: grid; place-items: center; height: 100vh; grid-column: 1 / -1; }
|
||||
.card { display: grid; gap: 8px; width: 340px; }
|
||||
input, button { font: inherit; padding: 8px; border-radius: 6px; border: 1px solid #8886; }
|
||||
button { cursor: pointer; }
|
||||
aside { border-right: 1px solid #8884; overflow: auto; }
|
||||
aside li { list-style: none; padding: 8px 12px; cursor: pointer; }
|
||||
aside li:hover, aside li.sel { background: #8882; }
|
||||
aside ul { margin: 0; padding: 0; }
|
||||
section { padding: 16px; }
|
||||
.muted { opacity: .7; }
|
||||
.err { color: #d33; }
|
||||
.mono { font-family: ui-monospace, monospace; }
|
||||
.toolbar { display: flex; gap: 8px; padding: 8px; }
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
clearScreen: false,
|
||||
server: { port: 1420, strictPort: true },
|
||||
build: { target: "es2022", outDir: "dist" },
|
||||
});
|
||||
Reference in New Issue
Block a user