Compare commits

..

No commits in common. "old-master" and "master" have entirely different histories.

46 changed files with 4809 additions and 11660 deletions

1
.envrc Normal file
View file

@ -0,0 +1 @@
use flake

11
.gitignore vendored
View file

@ -1,6 +1,5 @@
.DS_Store
/node_modules/
/src/node_modules/@sapper/
yarn-error.log
/__sapper__/
/test.db
/target
/node_modules
/dist
/.direnv
/result

View file

@ -1,2 +0,0 @@
/package-lock.json
/rollup.config.js

1
.tokeignore Normal file
View file

@ -0,0 +1 @@
package-lock.json

1808
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

27
Cargo.toml Normal file
View file

@ -0,0 +1,27 @@
[package]
name = "grub"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1.0.72"
automerge = "0.5.1"
axum = { version = "0.6.20", features = ["http2", "macros", "ws"] }
chrono = { version = "0.4.26", features = ["serde"] }
ciborium = "0.2.1"
clap = { version = "4.3.21", features = ["derive"] }
dashmap = "5.5.0"
derivative = "2.2.0"
flume = "0.10.14"
futures = "0.3.28"
mime_guess = "2.0.4"
rust-embed = "6.8.1"
serde = { version = "1.0.183", features = ["derive"] }
serde_bytes = "0.11.12"
serde_json = "1.0.104"
tokio = { version = "1.30.0", features = ["full"] }
uuid = "1.4.1"
[dependencies.mzlib]
git = "https://git.mzhang.io/michael/mzlib"
features = ["axum"]

View file

@ -1,11 +1,15 @@
grub
====
# grub
To build:
```
npm i
npm run dev
npm ci
npm run build
cargo build --release
```
u know the deal
To run:
license: WTFPL
```
grub [--port 6106]
```

21
client/App.tsx Normal file
View file

@ -0,0 +1,21 @@
import "bootstrap/dist/css/bootstrap.min.css";
import "./global.css";
import { v4 } from "uuid";
import Room from "./Room";
import { Container } from "react-bootstrap";
export default function App() {
const x = new URL(window.location.href);
const roomId = x.pathname.replace(/^\//, "");
if (!roomId) {
location.href = `/${v4()}`;
}
return (
<Container>
<Room roomId={roomId} />
</Container>
);
}

70
client/Chat.tsx Normal file
View file

@ -0,0 +1,70 @@
import { useContext, useState } from "react";
import useWebSocket from "react-use-websocket";
import { wsUrl } from "./constants";
import CBOR from "cbor";
import * as uuid from "uuid";
import { RoomContext } from "./lib/roomContext";
export default function Chat() {
const { roomId, clientId } = useContext(RoomContext);
const [chats, setChats] = useState([]);
const [message, setMessage] = useState("");
const {
sendMessage,
lastJsonMessage,
readyState: newReadyState,
} = useWebSocket(wsUrl, {
share: true,
onOpen: ({}) => {
console.log("Shiet, connected.");
},
onMessage: async (event) => {
const data = CBOR.decode(await event.data.arrayBuffer());
console.log("received", data);
if (data.type === "ChatMessage") {
setChats([...chats, data]);
}
},
});
function sendCborMessage(data) {
let cbor = CBOR.encode(data);
sendMessage(cbor);
}
function onSubmit(e) {
e.preventDefault();
sendCborMessage({
type: "ChatMessage",
timestamp: new Date().toISOString(),
message_id: uuid.v4(),
room_id: roomId,
author: clientId,
content: message,
});
setMessage("");
}
return (
<>
Messages:
<ul>
{chats.map((x) => (
<li key={x.message_id}>
[{x.timestamp}] {uuid.stringify(x.author)}: {x.content}
</li>
))}
</ul>
<form onSubmit={onSubmit}>
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Type a message..."
/>
</form>
</>
);
}

86
client/Grub.tsx Normal file
View file

@ -0,0 +1,86 @@
import * as Automerge from "@automerge/automerge";
import { useContext, useState } from "react";
import useWebSocket from "react-use-websocket";
import { wsUrl } from "./constants";
import CBOR from "cbor";
import { RoomContext } from "./lib/roomContext";
export default function Grub() {
const { roomId, clientId } = useContext(RoomContext);
const [doc, setDoc] = useState(Automerge.init());
const [syncState, setSyncState] = useState(Automerge.initSyncState());
const [addItemName, setAddItemName] = useState("");
function updateDoc(newDoc) {
setDoc(newDoc);
}
const { sendMessage } = useWebSocket(wsUrl, {
share: true,
onOpen: ({}) => {
console.log("Shiet, connected.");
},
onMessage: async (event) => {
const data = CBOR.decode(await event.data.arrayBuffer());
if (data.type === "Automerge") {
const [nextDoc, nextSyncState, patch] = Automerge.receiveSyncMessage(
doc,
syncState,
data.message[1]
);
setDoc(nextDoc);
setSyncState(nextSyncState);
console.log("patch", patch);
}
},
});
function sendCborMessage(data) {
let cbor = CBOR.encode(data);
sendMessage(cbor);
}
function addItem(e) {
e.preventDefault();
const newDoc = Automerge.change(doc, (doc) => {
if (!doc.items) doc.items = [];
doc.items.push({
id: uuid.v4(),
content: addItemName,
});
});
updateDoc(newDoc);
const [syncMessage, binary] = Automerge.generateSyncMessage(doc, syncState);
if (syncMessage)
sendCborMessage({
type: "Automerge",
client_id: clientId,
room_id: roomId,
message: binary,
});
setAddItemName("");
}
const items = doc.items || [];
return (
<>
Grubs:
<ul>
{items.map((x) => (
<li key={x.id}>{x.content}</li>
))}
</ul>
<form onSubmit={addItem}>
<input
type="text"
value={addItemName}
onChange={(e) => setAddItemName(e.target.value)}
placeholder="Type a message..."
/>
<button type="submit">add item</button>
</form>
</>
);
}

128
client/Room.tsx Normal file
View file

@ -0,0 +1,128 @@
import CBOR from "cbor";
import { useEffect, useState } from "react";
import useWebSocket, { ReadyState } from "react-use-websocket";
import * as Automerge from "@automerge/automerge";
import * as uuid from "uuid";
import { wsUrl } from "./constants";
import { Tab, Tabs } from "react-bootstrap";
import Upload from "./Upload";
import Chat from "./Chat";
import Grub from "./Grub";
import { RoomContext } from "./lib/roomContext";
import { Wifi } from "react-bootstrap-icons";
const connectionStatusMap = {
[ReadyState.CONNECTING]: "Connecting",
[ReadyState.OPEN]: "Open",
[ReadyState.CLOSING]: "Closing",
[ReadyState.CLOSED]: "Closed",
[ReadyState.UNINSTANTIATED]: "Uninstantiated",
};
export default function Room({ roomId }) {
const [readyState, setReadyState] = useState(ReadyState.CLOSED);
const [connectedClients, setConnectedClients] = useState([]);
const [clientId, setClientId] = useState<string | null>(null);
const {
sendMessage,
lastJsonMessage,
readyState: newReadyState,
} = useWebSocket(wsUrl, {
share: true,
onOpen: ({}) => {
console.log("Shiet, connected.");
},
onMessage: async (event) => {
const data = CBOR.decode(await event.data.arrayBuffer());
console.log("received", data);
if (data.type === "ServerHello") {
setClientId(uuid.stringify(data.client_id));
}
if (data.type === "RoomClientList") {
setConnectedClients(data.clients.map((x) => uuid.stringify(x)));
}
if (data.type === "Automerge") {
const [nextDoc, nextSyncState, patch] = Automerge.receiveSyncMessage(
doc,
syncState,
data.message[1]
);
setDoc(nextDoc);
setSyncState(nextSyncState);
console.log("patch", patch);
}
},
});
function sendWtfMessage(data) {
let cbor = CBOR.encode(data);
console.log(
"cbor-encoded",
[...new Uint8Array(cbor)]
.map((x) => x.toString(16).padStart(2, "0"))
.join(" ")
);
sendMessage(cbor);
}
useEffect(() => {
if (
readyState === ReadyState.CONNECTING &&
newReadyState === ReadyState.OPEN
) {
// On Open
sendWtfMessage({ type: "JoinRoom", room_id: roomId });
console.log("Sent connection message");
}
setReadyState(newReadyState);
}, [newReadyState]);
const connectionStatus = connectionStatusMap[readyState];
if (newReadyState !== ReadyState.OPEN) return <>Connecting...</>;
return (
<RoomContext.Provider value={{ roomId, clientId }}>
<p>Room Id: {roomId}</p>
<p>
Connection status: <ConnectionStatus readyState={readyState} />
</p>
Connected:
<ul>
{connectedClients.map((x) => (
<li key={x}>{x}</li>
))}
</ul>
<Tabs
defaultActiveKey="file-transfer"
id="uncontrolled-tab-example"
className="mb-3"
>
<Tab eventKey="file-transfer" title="File Transfer">
<Upload />
</Tab>
<Tab eventKey="grocery-tracking" title="Grocery Tracking">
<Grub />
</Tab>
<Tab eventKey="chat" title="Chat">
<Chat />
</Tab>
</Tabs>
</RoomContext.Provider>
);
}
function ConnectionStatus({ readyState }) {
switch (readyState) {
case ReadyState.CONNECTING:
return <Wifi color="yellow" />;
case ReadyState.OPEN:
return <Wifi color="green" />;
default:
return null;
}
}

289
client/Upload.tsx Normal file
View file

@ -0,0 +1,289 @@
import { useCallback, useContext, useEffect, useState } from "react";
import * as sodium from "libsodium-wrappers-sumo";
import useWebSocket from "react-use-websocket";
import CBOR from "cbor";
import { wsUrl } from "./constants";
import { RoomContext } from "./lib/roomContext";
import { useDebouncedCallback } from "use-debounce";
import {
Row,
Col,
Button,
Form,
Badge,
OverlayTrigger,
Tooltip,
} from "react-bootstrap";
export default function Upload() {
const { roomId } = useContext(RoomContext);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [encryptionKey, setEncryptionKey] = useState(null);
const [passphrase, setPassphrase] = useState("");
const [uploadProgress, setUploadProgress] = useState(null);
const setHashedPassphrase = useDebouncedCallback(
(passphrase) => {
if (passphrase) {
const salt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES);
console.log("salt", salt);
setEncryptionKey(
sodium.crypto_pwhash(
sodium.crypto_box_SEEDBYTES,
passphrase,
salt,
sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE,
sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE,
sodium.crypto_pwhash_ALG_ARGON2ID13
)
);
}
},
// delay in ms
1000
);
const [decryptionKey, setDecryptionKey] = useState("");
const [selectedSaveFile, setSelectedSaveFile] = useState(null);
const [decryptState, setDecryptState] = useState(null);
const [pendingDecryption, setPendingDecryption] = useState([]);
let decryptionKeyInternal: Uint8Array;
if (decryptionKey) {
decryptionKeyInternal = hex2Bytes(decryptionKey);
}
const [writeStream, setWriteStream] = useState(null);
const readyToDownload =
!!decryptionKeyInternal && !!selectedSaveFile && !!writeStream;
const { sendMessage } = useWebSocket(wsUrl, {
share: true,
onMessage: async (event) => {
const message = CBOR.decode(await event.data.arrayBuffer());
if (message.type === "FileUploadBegin") {
const state = sodium.crypto_secretstream_xchacha20poly1305_init_pull(
message.header,
decryptionKeyInternal
);
setDecryptState(state);
}
if (message.type === "FileUploadChunk") {
if (readyToDownload && decryptState) {
for (const { data } of [...pendingDecryption, message]) {
const { message } =
sodium.crypto_secretstream_xchacha20poly1305_pull(
decryptState,
data
);
const result = await writeStream.write(message);
}
setPendingDecryption([]);
} else {
setPendingDecryption([...pendingDecryption, message]);
}
}
if (message.type === "FileUploadComplete") {
await writeStream.close();
}
},
});
function sendCborMessage(data) {
let cbor = CBOR.encode(data);
sendMessage(cbor);
}
useEffect(() => {
(async () => {
await sodium.ready;
if (passphrase) {
setHashedPassphrase(passphrase);
} else {
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
setEncryptionKey(key);
}
})();
}, [passphrase]);
const uploadFile = useCallback(async () => {
if (!encryptionKey) return;
if (!selectedFile) return;
const { state, header } =
sodium.crypto_secretstream_xchacha20poly1305_init_push(encryptionKey);
sendCborMessage({
type: "FileUploadBegin",
room_id: roomId,
header,
});
const stream = selectedFile.stream();
const reader = stream.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
// Encrypt this chunk
const encryptedValue =
sodium.crypto_secretstream_xchacha20poly1305_push(
state,
value,
null,
0
);
sendCborMessage({
type: "FileUploadChunk",
room_id: roomId,
data: encryptedValue,
});
}
sendCborMessage({
type: "FileUploadComplete",
room_id: roomId,
});
} finally {
reader.releaseLock();
}
}, [encryptionKey, selectedFile]);
async function selectSaveFile() {
const newHandle = await window.showSaveFilePicker();
setSelectedSaveFile(newHandle);
const writableStream = await newHandle.createWritable();
setWriteStream(writableStream);
}
return (
<>
<h3>How does this work?</h3>
<p style={{ color: "darkred" }}>
<b>
RECEIVER MUST BE USING CHROME{" "}
<a
href="https://caniuse.com/mdn-api_window_showsavefilepicker"
target="_blank"
>
[?]
</a>
</b>
</p>
<ol>
<li>
<b>Uploader:</b> Copy the key and send it to whoever you're sending
files to
</li>
<li>
<b>Receiver:</b> Paste the key into the "Paste key here" box
</li>
<li>
<b>Receiver:</b> Press the "select save file" button to choose where
to save the file to
</li>
<li>
<b>Receiver:</b> Tell the uploader to press "upload"
</li>
<li>
<b>Uploader:</b> Press "upload"
</li>
</ol>
<p>Only one person can upload at a time.</p>
<Row>
<Col md={6}>
<h3>Send file</h3>
{/* <p>
Upload passphrase:
<Form.Control
value={passphrase}
onChange={(e) => {
setEncryptionKey(null);
setPassphrase(e.target.value);
}}
/>
</p> */}
<p>
Upload Key:{" "}
<Form.Control
type="text"
disabled
value={encryptionKey ? bytes2Hex(encryptionKey) : "generating..."}
/>
</p>
<input
type="file"
onChange={(e) => setSelectedFile(e.target.files[0])}
/>
<Button onClick={uploadFile} disabled={!encryptionKey}>
upload
</Button>
</Col>
<Col md={6}>
<h3>
Receive file
{readyToDownload ? (
<Badge bg="success">ready</Badge>
) : (
<OverlayTrigger
placement="top"
overlay={
<Tooltip>
you are not ready to download, make sure you enter the key
and select a place to save
</Tooltip>
}
>
<Badge bg="warning">not ready</Badge>
</OverlayTrigger>
)}
</h3>
<ol>
<li>
<Form.Control
type="text"
value={decryptionKey}
onChange={(e) => setDecryptionKey(e.target.value)}
placeholder="Paste key here..."
/>
</li>
<li>
<Button onClick={selectSaveFile} disabled={!!selectedSaveFile}>
{selectedSaveFile
? `Saving to "${selectedSaveFile.name}"`
: "select save file"}
</Button>
</li>
</ol>
</Col>
</Row>
</>
);
}
function bytes2Hex(byteArray: Uint8Array): string {
return Array.prototype.map
.call(byteArray, function (byte: number) {
return ("0" + (byte & 0xff).toString(16)).slice(-2);
})
.join("");
}
function hex2Bytes(hexString: string): Uint8Array {
let result = [];
for (var i = 0; i < hexString.length; i += 2) {
result.push(parseInt(hexString.substr(i, 2), 16));
}
return new Uint8Array(result);
}

1
client/constants.ts Normal file
View file

@ -0,0 +1 @@
export const wsUrl = `ws://${location.host}/ws`;

4
client/global.css Normal file
View file

@ -0,0 +1,4 @@
.container {
max-width: 980px;
margin: auto;
}

6
client/index.ts Normal file
View file

@ -0,0 +1,6 @@
import { createRoot } from "react-dom/client";
import App from "./App";
const el = document.getElementById("app")!;
const root = createRoot(el);
root.render(App());

View file

@ -0,0 +1,8 @@
import { createContext } from "react";
interface RoomContextProps {
roomId: string;
clientId: string;
}
export const RoomContext = createContext<RoomContextProps>(undefined);

162
flake.lock Normal file
View file

@ -0,0 +1,162 @@
{
"nodes": {
"fenix": {
"inputs": {
"nixpkgs": "nixpkgs",
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1691648495,
"narHash": "sha256-JULr+eKL9rjfex17hZYn0K/fBxxfK/FM9TOCcxPQay4=",
"owner": "nix-community",
"repo": "fenix",
"rev": "6c9f0709358f212766cff5ce79f6e8300ec1eb91",
"type": "github"
},
"original": {
"id": "fenix",
"type": "indirect"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1689068808,
"narHash": "sha256-6ixXo3wt24N/melDWjq70UuHQLxGV8jZvooRanIHXw0=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "919d646de7be200f3bf08cb76ae1f09402b6f9b4",
"type": "github"
},
"original": {
"id": "flake-utils",
"type": "indirect"
}
},
"flake-utils_2": {
"locked": {
"lastModified": 1659877975,
"narHash": "sha256-zllb8aq3YO3h8B/U0/J1WBgAL8EX5yWf5pMj3G0NAmc=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "c0e246b9b83f637f4681389ecabcb2681b4f3af0",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"napalm": {
"inputs": {
"flake-utils": "flake-utils_2",
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1672245824,
"narHash": "sha256-i596lbPiA/Rfx3DiJiCluxdgxWY7oGSgYMT7OmM+zik=",
"owner": "nix-community",
"repo": "napalm",
"rev": "7c25a05cef52dc405f4688422ce0046ca94aadcf",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "napalm",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1691472822,
"narHash": "sha256-XVfYZ2oB3lNPVq6sHCY9WkdQ8lHoIDzzbpg8bB6oBxA=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "41c7605718399dcfa53dd7083793b6ae3bc969ff",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1663087123,
"narHash": "sha256-cNIRkF/J4mRxDtNYw+9/fBNq/NOA2nCuPOa3EdIyeDs=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "9608ace7009ce5bc3aeb940095e01553e635cbc7",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_3": {
"locked": {
"lastModified": 1691709280,
"narHash": "sha256-zmfH2OlZEXwv572d0g8f6M5Ac6RiO8TxymOpY3uuqrM=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "cf73a86c35a84de0e2f3ba494327cf6fb51c0dfd",
"type": "github"
},
"original": {
"owner": "nixos",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"fenix": "fenix",
"flake-utils": "flake-utils",
"napalm": "napalm",
"nixpkgs": "nixpkgs_3"
}
},
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1691604464,
"narHash": "sha256-nNc/c9r1O8ajE/LkMhGcvJGlyR6ykenR3aRkEkhutxA=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "05b061205179dab9a5cd94ae66d1c0e9b8febe08",
"type": "github"
},
"original": {
"owner": "rust-lang",
"ref": "nightly",
"repo": "rust-analyzer",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

56
flake.nix Normal file
View file

@ -0,0 +1,56 @@
{
description = "A very basic flake";
inputs.nixpkgs.url = "github:nixos/nixpkgs";
inputs.napalm.url = "github:nix-community/napalm";
outputs = { self, nixpkgs, flake-utils, fenix, napalm }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs {
inherit system;
overlays = [ fenix.overlays.default napalm.overlays.default ];
};
toolchain = pkgs.fenix.stable;
rustPlatform =
pkgs.makeRustPlatform { inherit (toolchain) cargo rustc; };
version = "0.1.0";
in rec {
defaultPackage = packages.grub;
packages = rec {
grub = rustPlatform.buildRustPackage {
name = "grub";
inherit version;
src = pkgs.symlinkJoin {
name = "grub-src";
paths = [
(pkgs.nix-gitignore.gitignoreSource [ ./.gitignore ] ./.)
frontend
];
};
cargoLock = {
lockFile = ./Cargo.lock;
outputHashes = {
"mzlib-0.1.0" =
"sha256-J7dEeCTPIoKmldpAkQadDBXhJP+Zv9hNlUdpMl2L5QM=";
};
};
};
frontend = pkgs.buildNpmPackage {
name = "frontend";
inherit version;
src = pkgs.nix-gitignore.gitignoreSource [ ./.gitignore ] ./.;
npmDepsHash = "sha256-fmx7Ee+Idx91w9VwV/yeHgJ+4OHiVevfFJ46LTtmZl4=";
installPhase = ''
mkdir -p $out
mv dist $out
'';
};
};
devShell = pkgs.mkShell { packages = with toolchain; [ cargo rustc ]; };
});
}

7
index.html Normal file
View file

@ -0,0 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<body>
<div id="app"></div>
<script type="module" src="client/index.ts"></script>
</body>
</html>

12638
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,42 +1,39 @@
{
"name": "grubs",
"description": "grubs",
"version": "0.0.1",
"name": "grub",
"version": "0.1.0",
"scripts": {
"dev": "sapper dev",
"build": "sapper build --legacy",
"export": "sapper export --legacy",
"start": "node __sapper__/build"
},
"dependencies": {
"compression": "^1.7.1",
"express": "^4.17.1",
"knex": "^0.21.21",
"sequelize": "^6.6.5",
"sirv": "^1.0.0",
"socket.io": "^4.1.3",
"sqlite3": "^5.0.2",
"uuid": "^8.3.2"
"dev": "vite",
"build": "vite build"
},
"devDependencies": {
"@babel/core": "^7.0.0",
"@babel/plugin-syntax-dynamic-import": "^7.0.0",
"@babel/plugin-transform-runtime": "^7.0.0",
"@babel/preset-env": "^7.0.0",
"@babel/runtime": "^7.0.0",
"@rollup/plugin-babel": "^5.0.0",
"@rollup/plugin-commonjs": "^20.0.0",
"@rollup/plugin-node-resolve": "^8.0.0",
"@rollup/plugin-replace": "^2.4.0",
"@rollup/plugin-url": "^5.0.0",
"bufferutil": "^4.0.3",
"rollup": "^2.3.4",
"rollup-plugin-svelte": "^7.0.0",
"rollup-plugin-terser": "^7.0.0",
"sapper": "^0.28.0",
"socket.io-client": "^4.1.3",
"svelte": "^3.17.3",
"sveltestrap": "^5.6.0",
"utf-8-validate": "^5.0.5"
"@types/libsodium-wrappers": "^0.7.10",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"@types/uuid": "^9.0.2",
"@vitejs/plugin-react": "^4.0.4",
"sass": "^1.65.1",
"typescript": "^5.1.6",
"vite": "^4.4.9",
"vite-plugin-top-level-await": "^1.3.1",
"vite-plugin-wasm": "^3.2.2"
},
"dependencies": {
"@automerge/automerge": "^2.0.3",
"bootstrap": "^5.3.1",
"cbor": "npm:@jprochazk/cbor@^0.5.0",
"libsodium": "^0.7.11",
"libsodium-wrappers": "^0.7.11",
"libsodium-wrappers-sumo": "^0.7.11",
"localforage": "^1.10.0",
"match-sorter": "^6.3.1",
"react": "^18.2.0",
"react-bootstrap": "^2.8.0",
"react-bootstrap-icons": "^1.10.3",
"react-dom": "^18.2.0",
"react-router-dom": "^6.15.0",
"react-use-websocket": "^4.3.1",
"sort-by": "^1.2.0",
"use-debounce": "^9.0.4",
"uuid": "^9.0.0"
}
}

View file

@ -1,127 +0,0 @@
import path from 'path';
import resolve from '@rollup/plugin-node-resolve';
import replace from '@rollup/plugin-replace';
import commonjs from '@rollup/plugin-commonjs';
import url from '@rollup/plugin-url';
import svelte from 'rollup-plugin-svelte';
import babel from '@rollup/plugin-babel';
import { terser } from 'rollup-plugin-terser';
import config from 'sapper/config/rollup.js';
import pkg from './package.json';
const mode = process.env.NODE_ENV;
const dev = mode === 'development';
const legacy = !!process.env.SAPPER_LEGACY_BUILD;
const onwarn = (warning, onwarn) =>
(warning.code === 'MISSING_EXPORT' && /'preload'/.test(warning.message)) ||
(warning.code === 'CIRCULAR_DEPENDENCY' && /[/\\]@sapper[/\\]/.test(warning.message)) ||
onwarn(warning);
export default {
client: {
input: config.client.input(),
output: config.client.output(),
plugins: [
replace({
preventAssignment: true,
values:{
'process.browser': true,
'process.env.NODE_ENV': JSON.stringify(mode)
},
}),
svelte({
compilerOptions: {
dev,
hydratable: true
}
}),
url({
sourceDir: path.resolve(__dirname, 'src/node_modules/images'),
publicPath: '/client/'
}),
resolve({
browser: true,
dedupe: ['svelte']
}),
commonjs(),
legacy && babel({
extensions: ['.js', '.mjs', '.html', '.svelte'],
babelHelpers: 'runtime',
exclude: ['node_modules/@babel/**'],
presets: [
['@babel/preset-env', {
targets: '> 0.25%, not dead'
}]
],
plugins: [
'@babel/plugin-syntax-dynamic-import',
['@babel/plugin-transform-runtime', {
useESModules: true
}]
]
}),
!dev && terser({
module: true
})
],
preserveEntrySignatures: false,
onwarn,
},
server: {
input: config.server.input(),
output: config.server.output(),
plugins: [
replace({
preventAssignment: true,
values:{
'process.browser': false,
'process.env.NODE_ENV': JSON.stringify(mode)
},
}),
svelte({
compilerOptions: {
dev,
generate: 'ssr',
hydratable: true
},
emitCss: false
}),
url({
sourceDir: path.resolve(__dirname, 'src/node_modules/images'),
publicPath: '/client/',
emitFiles: false // already emitted by client build
}),
resolve({
dedupe: ['svelte']
}),
commonjs()
],
external: Object.keys(pkg.dependencies).concat(require('module').builtinModules),
preserveEntrySignatures: 'strict',
onwarn,
},
serviceworker: {
input: config.serviceworker.input(),
output: config.serviceworker.output(),
plugins: [
resolve(),
replace({
preventAssignment: true,
values:{
'process.browser': true,
'process.env.NODE_ENV': JSON.stringify(mode)
},
}),
commonjs(),
!dev && terser()
],
preserveEntrySignatures: false,
onwarn,
}
};

4
rustfmt.toml Normal file
View file

@ -0,0 +1,4 @@
max_width = 80
tab_spaces = 2
wrap_comments = true
fn_single_line = true

39
src/ambient.d.ts vendored
View file

@ -1,39 +0,0 @@
/**
* These declarations tell TypeScript that we allow import of images, e.g.
* ```
<script lang='ts'>
import successkid from 'images/successkid.jpg';
</script>
<img src="{successkid}">
```
*/
declare module "*.gif" {
const value: string;
export default value;
}
declare module "*.jpg" {
const value: string;
export default value;
}
declare module "*.jpeg" {
const value: string;
export default value;
}
declare module "*.png" {
const value: string;
export default value;
}
declare module "*.svg" {
const value: string;
export default value;
}
declare module "*.webp" {
const value: string;
export default value;
}

View file

@ -1,4 +0,0 @@
import * as sapper from '@sapper/app';
sapper.start({
target: document.querySelector('#sapper')
});

View file

@ -1,67 +0,0 @@
<script>
import { onMount } from "svelte";
import { Form, InputGroup, Input, Button, ListGroup, ListGroupItem } from "sveltestrap";
import { v4 as uuidv4 } from "uuid";
import { socket, clientText, clientShadow } from "../lib/stores";
import { emptyState, diffStates } from "../lib/state";
export let urlPath;
let text, shadow;
let connected = false;
$socket.on("connect", () => {
connected = true;
$socket.emit("fullSyncRequest", urlPath);
});
$socket.on("fullSync", newState => {
console.log("what the hell is this state", newState);
clientShadow.set(newState || emptyState());
});
clientText.subscribe(st => { text = st });
clientShadow.subscribe(st => { shadow = st });
let newGrubValue = "";
function newGrub() {
let s = newGrubValue.trim();
if (s == "") return false;
let key = `grub-${uuidv4()}`;
clientText.update(o => {
o.grubs[key] = s;
return o;
});
newGrubValue = "";
// compute the diff
console.log("text", text, "shadow", shadow);
let diff = diffStates(shadow, text);
console.log("diff", diff)
$socket.emit("diffs", diff);
return false;
}
</script>
<h3>Grubs</h3>
<form on:submit|preventDefault|stopPropagation = {newGrub}>
<InputGroup>
<Input
bind:value = {newGrubValue}
placeholder = "What do you need?" />
<Button>Hellosu</Button>
</InputGroup>
</form>
{connected}
<ListGroup>
{#each Array.from(text.grubs) as [key, grub]}
<ListGroupItem>
{grub}
</ListGroupItem>
{/each}
</ListGroup>

View file

@ -1,28 +0,0 @@
import { Sequelize, DataTypes } from "sequelize";
const sequelize = new Sequelize("sqlite:test.db");
const Grub = sequelize.define("Grubs", {
id: {
type: DataTypes.STRING,
primaryKey: true,
},
tag: {
type: DataTypes.INTEGER,
allowNull: false,
autoIncrement: true,
},
data: {
type: DataTypes.JSON,
allowNull: false,
},
}, {
// Other model options go here
tableName: "grubs",
indexes: [
{ fields: ["tag"] },
{ fields: ["id", "tag"] },
],
});
export { sequelize, Grub };

View file

@ -1,31 +0,0 @@
import { Sequelize, Op } from "sequelize";
import { sequelize, Grub } from "./models";
import { emptyState, patchState } from "./state";
export function socketHandler(socket) {
socket.on("fullSyncRequest", async msg => {
// get the latest message for this id
let result = await Grub.findOne({
where: { id: { [Op.eq]: msg } },
order: [ ["tag", "DESC" ] ],
});
socket.emit("fullSync", result);
});
socket.on("diffs", async msg => {
try {
console.log("patch", msg);
let lastGrub = await Grub.findOne({
where: { id: { [Op.eq]: msg } },
order: [ ["tag", "DESC" ] ],
});
console.log("last", lastGrub);
let newGrub = patchState(lastGrub, msg);
console.log("New", newGrub);
// await newGrub.save();
console.log(result);
} catch(err) {
console.log("holy Fuck error...", err);
}
});
}

View file

@ -1,55 +0,0 @@
function emptyState() {
return { tag: 0, grubs: {}, recipes: {} };
}
function diffMaps(a, b) {
let deleted = Object.assign({}, a);
let updated = {};
for (let [key, value] of Object.entries(b)) {
// delete keys that are still in the second map
if (key in deleted) delete deleted[key];
if (!(key in a)) updated[key] = value;
else if (a[key] != b[key]) updated[key] = value;
}
return { deleted, updated };
}
function emptyDiff(diff) {
return diff.deleted.size == 0 && diff.updated.size == 0;
}
function diffStates(prev, next) {
let shadowTag = prev["tag"];
let tag = next["tag"];
let updated = {};
for (let key in prev) {
if (key === "tag") continue;
let diff = diffMaps(prev[key], next[key]);
if (!emptyDiff(diff)) updated[key] = diff;
}
updated["shadowTag"] = shadowTag;
updated["tag"] = tag;
return updated;
}
function patchMap(state, diff) {
let updated = Object.assign({}, state);
for (let key in diff.deleted) {
delete updated[key];
}
for (let [key, value] of Object.entries(diff.updated)) {
updated[key] = value;
}
return updated;
}
function patchState(state, diff) {
let updated = Object.assign({}, state);
for (let key in state) {
if (key === "tag") updated[key] = diff[key];
else if (key in diff) updated[key] = patchMap(state[key], diff[key]);
}
return updated;
}
export { emptyState, diffMaps, diffStates, emptyDiff, patchMap, patchState };

View file

@ -1,11 +0,0 @@
import io from "socket.io-client";
import { readable, writable } from 'svelte/store';
import { emptyState } from "./state";
export const socket = readable(io(), (set) => {
// do nothing lol
});
export const clientShadow = writable(emptyState());
export const clientText = writable(emptyState());

363
src/main.rs Normal file
View file

@ -0,0 +1,363 @@
pub mod message;
#[macro_use]
extern crate derivative;
use std::{
io::Cursor,
mem,
net::{Ipv4Addr, Ipv6Addr, SocketAddr},
sync::Arc,
};
use automerge::{
sync::{State as SyncState, SyncDoc},
Automerge,
};
use axum::{
extract::{
ws::{Message as WsMessage, WebSocket, WebSocketUpgrade},
State,
},
response::Response,
routing::get,
Router, Server,
};
use chrono::Utc;
use clap::Parser;
use dashmap::{DashMap, DashSet};
use flume::r#async::SendSink;
use frontend::frontend;
use futures::{stream, FutureExt, SinkExt, StreamExt};
use message::Message;
use mzlib::axum_error::Result;
use tokio::join;
use uuid::Uuid;
#[derive(Debug, Parser)]
struct Opt {
#[clap(long, default_value = "6106")]
port: u16,
}
#[derive(Default, Clone)]
struct AppState {
clients: Clients,
rooms: Rooms,
}
#[derive(Default, Clone)]
struct Clients(Arc<DashMap<Uuid, Client>>);
struct Client {
writer: SendSink<'static, Result<axum::extract::ws::Message, axum::Error>>,
rooms: DashSet<Uuid>,
}
#[derive(Default, Clone)]
struct Rooms(Arc<DashMap<Uuid, Room>>);
#[derive(Default)]
struct Room {
document: Automerge,
sync_state: SyncState,
connected_clients: DashSet<Uuid>,
current_uploader: Option<Uuid>,
}
impl Room {
pub fn split_borrow(&mut self) -> (&mut Automerge, &mut SyncState) {
(&mut self.document, &mut self.sync_state)
}
}
#[tokio::main]
async fn main() -> Result<()> {
let opts = Opt::parse();
let state = AppState::default();
let app = Router::new()
.route("/ws", get(handler))
.fallback(frontend)
.with_state(state);
let service = app.into_make_service();
let addr6 = SocketAddr::from((Ipv6Addr::UNSPECIFIED, opts.port));
println!("Listening on {}...", addr6);
let server6 = Server::bind(&addr6).serve(service);
server6.await?;
Ok(())
}
#[allow(non_upper_case_globals)]
mod frontend {
use axum::{
body::{Body, HttpBody},
http::{header::CONTENT_TYPE, HeaderMap, HeaderValue, StatusCode, Uri},
response::IntoResponse,
};
use rust_embed::RustEmbed;
#[derive(RustEmbed)]
#[folder = "dist/"]
struct Frontend;
pub async fn frontend(uri: Uri) -> impl IntoResponse {
let path = uri.path().trim_start_matches("/").to_owned();
match Frontend::get(&path).or_else(|| Frontend::get("index.html")) {
Some(file) => {
let guess = mime_guess::from_path(path);
let mut headers = HeaderMap::default();
if let Some(guess) = guess.first() {
headers.append(
CONTENT_TYPE,
HeaderValue::from_str(&guess.to_string()).unwrap(),
);
}
let data = file.data.as_ref();
let body = Body::from(data.to_owned());
let body = body.boxed();
(headers, body).into_response()
}
None => {
(StatusCode::NOT_FOUND, format!("No route for {}", uri)).into_response()
}
}
}
}
async fn handler(
ws: WebSocketUpgrade,
State(state): State<AppState>,
) -> Response {
ws.on_upgrade(|socket| handle_socket(state, socket).map(|res| res.unwrap()))
}
async fn handle_socket(state: AppState, socket: WebSocket) -> Result<()> {
let (socket_tx, socket_rx) = socket.split();
// Generate an ID for this connection
let client_id = Uuid::new_v4();
println!("Connected client {client_id:?}");
// First, let's create multiplexed versions of these
let mut socket_tx = {
let (tx, rx) = flume::unbounded();
tokio::spawn(rx.into_stream().forward(socket_tx));
tx.into_sink()
};
let mut socket_rx = {
let (tx, rx) = flume::unbounded();
tokio::spawn(
socket_rx
.forward(tx.into_sink().sink_map_err(|err| axum::Error::new(err))),
);
rx.into_stream()
};
// Now register this client into the set of clients
let rooms = DashSet::default();
let client = Client {
writer: socket_tx.clone(),
rooms: rooms.clone(),
};
state.clients.0.insert(client_id, client);
// Send a hello message
socket_tx
.send(Ok(
Message::ServerHello {
timestamp: Utc::now(),
client_id: client_id.clone(),
}
.into_ws_message()?,
))
.await?;
// Wait for messages
while let Some(msg) = socket_rx.next().await {
match msg {
WsMessage::Binary(ref bin) => {
let cursor = Cursor::new(bin);
let message = ciborium::from_reader::<Message, _>(cursor)?;
// println!("received message: {message:?}");
match &message {
Message::Automerge {
client_id,
room_id,
message: inner_message,
} => {
let mut room = state
.rooms
.0
.entry(room_id.clone())
.or_insert_with(|| Room::default());
rooms.insert(room_id.clone());
// Update local state
{
let (document, sync_state) = room.split_borrow();
document
.receive_sync_message(sync_state, inner_message.clone().0)?;
}
// println!("inner doc: {:?}", room.document);
// Remove current client, so send to everyone else
let connected_clients = room.connected_clients.clone();
connected_clients.remove(client_id);
// Send to everyone else
let message = message.clone();
broadcast(&state, connected_clients, message).await;
}
Message::JoinRoom { room_id } => {
let room = state
.rooms
.0
.entry(room_id.clone())
.or_insert_with(|| Room::default());
rooms.insert(room_id.clone());
// Add to the list of connected clients
room.connected_clients.insert(client_id);
let connected_clients = room.connected_clients.clone();
mem::drop(room);
println!("Added to room");
// Tell each clients which clients are connected
broadcast(
&state,
connected_clients.clone(),
Message::RoomClientList {
room_id: room_id.clone(),
clients: connected_clients.into_iter().collect(),
},
)
.await;
}
Message::ChatMessage {
timestamp: _,
message_id: _,
room_id,
author: _,
content: _,
} => {
let room = state
.rooms
.0
.entry(room_id.clone())
.or_insert_with(|| Room::default());
// Add to the list of connected clients
let connected_clients = room.connected_clients.clone();
mem::drop(room);
println!("Added to room");
// Broadcast to the room
broadcast(&state, connected_clients.clone(), message).await;
}
Message::FileUploadBegin { room_id, .. }
| Message::FileUploadComplete { room_id }
| Message::FileUploadChunk { room_id, .. } => {
let mut room = state
.rooms
.0
.entry(room_id.clone())
.or_insert_with(|| Room::default());
let connected_clients = room.connected_clients.clone();
connected_clients.remove(&client_id);
match &message {
Message::FileUploadBegin { .. } => match room.current_uploader {
Some(v) if v == client_id => {}
Some(_v) => continue,
None => room.current_uploader = Some(client_id.clone()),
},
Message::FileUploadComplete { .. } => {
room.current_uploader = None;
}
Message::FileUploadChunk { .. } => {}
_ => {}
};
broadcast(&state, connected_clients, message).await;
}
_ => {}
};
}
_ => {}
}
}
state.clients.0.remove(&client_id);
println!("Disconnecting client {client_id:?}");
// Tell other clients about the disconnection
stream::iter(rooms)
.for_each(|room_id| {
let room = state
.rooms
.0
.entry(room_id.clone())
.or_insert_with(|| Room::default());
room.connected_clients.remove(&client_id);
let connected_clients = room.connected_clients.clone();
mem::drop(room);
let room_id = room_id.clone();
{
let state_ref = &state;
async move {
broadcast(
state_ref,
connected_clients.clone(),
Message::RoomClientList {
room_id: room_id,
clients: connected_clients.into_iter().collect(),
},
)
.await;
}
}
})
.await;
Ok(())
}
async fn broadcast<I>(state: &AppState, client_ids: I, message: Message)
where
I: IntoIterator<Item = Uuid>,
{
let client_ids = client_ids.into_iter().collect::<Vec<_>>();
// println!("Broadcasting message to {} clients:", client_ids.len());
// println!(" - message: {message:?}");
stream::iter(
client_ids
.into_iter()
.filter_map(|client_id| state.clients.0.get(&client_id))
.map(|client| client.writer.clone()),
)
.for_each_concurrent(None, move |mut writer| {
let message = message.clone();
async move {
writer.send(Ok(message.into_ws_message().unwrap())).await;
}
})
.await;
}

104
src/message.rs Normal file
View file

@ -0,0 +1,104 @@
use std::collections::HashSet;
use std::fmt::Formatter;
use std::io::Cursor;
use axum::extract::ws;
use chrono::{DateTime, Utc};
use mzlib::axum_error::Result;
use serde::de::Visitor;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use uuid::Uuid;
#[derive(Derivative, Clone, Serialize, Deserialize)]
#[derivative(Debug)]
#[serde(tag = "type")]
pub enum Message {
ServerHello {
timestamp: DateTime<Utc>,
client_id: Uuid,
},
JoinRoom {
room_id: Uuid,
},
RoomClientList {
room_id: Uuid,
clients: HashSet<Uuid>,
},
ChatMessage {
timestamp: DateTime<Utc>,
message_id: Uuid,
room_id: Uuid,
author: Uuid,
content: String,
},
Automerge {
client_id: Uuid,
room_id: Uuid,
#[derivative(Debug = "ignore")]
message: AutomergeMessage,
},
FileUploadBegin {
room_id: Uuid,
#[serde(with = "serde_bytes")]
header: Vec<u8>,
},
FileUploadChunk {
room_id: Uuid,
#[serde(with = "serde_bytes")]
data: Vec<u8>,
},
FileUploadComplete {
room_id: Uuid,
},
}
impl Message {
pub fn into_ws_message(self) -> Result<ws::Message> {
let vec = Vec::new();
let mut cursor = Cursor::new(vec);
ciborium::into_writer(&self, &mut cursor)?;
let vec = cursor.into_inner();
Ok(ws::Message::Binary(vec))
}
}
#[derive(Clone)]
pub struct AutomergeMessage(pub automerge::sync::Message);
impl Serialize for AutomergeMessage {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let bytes = self.0.clone().encode();
serializer.serialize_bytes(&bytes)
}
}
impl<'de> Deserialize<'de> for AutomergeMessage {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct ThisVisitor;
impl<'v> Visitor<'v> for ThisVisitor {
type Value = AutomergeMessage;
fn expecting(&self, f: &mut Formatter) -> std::fmt::Result {
write!(f, "an automerge sync message")
}
fn visit_bytes<E>(self, v: &[u8]) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
automerge::sync::Message::decode(v)
.map(AutomergeMessage)
.map_err(|_err| E::custom("invalid automerge sync message"))
}
}
let visitor = ThisVisitor;
deserializer.deserialize_bytes(visitor)
}
}

View file

@ -1,14 +0,0 @@
<script context="module">
export async function preload({ params }) {
return { grubs: params.grubs };
}
</script>
<script>
import { onMount } from "svelte";
import { socket } from "../lib/stores";
import GrubView from "../components/GrubView.svelte";
export let grubs;
</script>
<GrubView urlPath = {grubs} />

View file

@ -1,40 +0,0 @@
<script>
export let status;
export let error;
const dev = process.env.NODE_ENV === 'development';
</script>
<style>
h1, p {
margin: 0 auto;
}
h1 {
font-size: 2.8em;
font-weight: 700;
margin: 0 0 0.5em 0;
}
p {
margin: 1em auto;
}
@media (min-width: 480px) {
h1 {
font-size: 4em;
}
}
</style>
<svelte:head>
<title>{status}</title>
</svelte:head>
<h1>{status}</h1>
<p>{error.message}</p>
{#if dev && error.stack}
<pre>{error.stack}</pre>
{/if}

View file

@ -1,17 +0,0 @@
<script>
</script>
<style>
main {
position: relative;
max-width: 56em;
background-color: white;
padding: 2em;
margin: 0 auto;
box-sizing: border-box;
}
</style>
<main>
<slot></slot>
</main>

View file

@ -1,5 +0,0 @@
<script>
import GrubView from "../components/GrubView.svelte";
</script>
TODO: redirect to soemthing

View file

@ -1,31 +0,0 @@
import express from "express";
import { Server } from "socket.io";
import http from "http";
import sirv from "sirv";
import * as sapper from "@sapper/server";
import { socketHandler } from "./lib/server-sync";
import { sequelize } from "./lib/models";
const { PORT, NODE_ENV } = process.env;
const dev = NODE_ENV === "development";
const app = express();
const server = http.createServer(app);
const io = new Server(server);
app.use(sirv("static", { dev }));
app.use(sapper.middleware());
io.on("connection", socketHandler);
async function start() {
await sequelize.sync();
const port = PORT || 3000;
server.listen(port, () => {
console.log("listening on ", port);
});
}
start();

View file

View file

@ -1,36 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<meta name="theme-color" content="#333333">
%sapper.base%
<link rel="stylesheet" href="global.css">
<link rel="manifest" href="manifest.json" crossorigin="use-credentials">
<link rel="icon" type="image/png" href="favicon.png">
<link rel="apple-touch-icon" href="logo-192.png">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/css/bootstrap.min.css" integrity="sha384-KyZXEAg3QhqLMpG8r+8fhAXLRk2vvoC2f3B09zVXn8CA5QIVfZOJ3BCsw2P0p/We" crossorigin="anonymous">
<!-- Sapper creates a <script> tag containing `src/client.js`
and anything else it needs to hydrate the app and
initialise the router -->
%sapper.scripts%
<!-- Sapper generates a <style> tag containing critical CSS
for the current page. CSS for the rest of the app is
lazily loaded when it precaches secondary pages -->
%sapper.styles%
<!-- This contains the contents of the <svelte:head> component, if
the current page has one -->
%sapper.head%
</head>
<body>
<!-- The application will be rendered inside this element,
because `src/client.js` references it -->
<div id="sapper">%sapper.html%</div>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

View file

@ -1,36 +0,0 @@
body {
margin: 0;
font-family: Roboto, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
font-size: 14px;
line-height: 1.5;
color: #333;
}
h1, h2, h3, h4, h5, h6 {
margin: 0 0 0.5em 0;
font-weight: 400;
line-height: 1.2;
}
h1 {
font-size: 2em;
}
a {
color: inherit;
}
code {
font-family: menlo, inconsolata, monospace;
font-size: calc(1em - 2px);
color: #555;
background-color: #f0f0f0;
padding: 0.2em 0.4em;
border-radius: 2px;
}
@media (min-width: 400px) {
body {
font-size: 16px;
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View file

@ -1,22 +0,0 @@
{
"background_color": "#ffffff",
"theme_color": "#333333",
"name": "TODO",
"short_name": "TODO",
"display": "minimal-ui",
"start_url": "/",
"icons": [
{
"src": "logo-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "logo-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}

6
tsconfig.json Normal file
View file

@ -0,0 +1,6 @@
{
"compilerOptions": {
"lib": ["ESNext", "DOM"],
"jsx": "react-jsx"
}
}

18
vite.config.ts Normal file
View file

@ -0,0 +1,18 @@
import { defineConfig } from "vite";
import wasm from "vite-plugin-wasm";
import topLevelAwait from "vite-plugin-top-level-await";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react(), wasm(), topLevelAwait()],
server: {
proxy: {
"/ws": {
target: "ws://localhost:6106/ws",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/ws/, ""),
ws: true,
},
},
},
});