Updated Wrapper component, added queue for all ttp requests, updated some desktop app configuration,

This commit is contained in:
Alois 2026-04-06 01:41:51 +02:00
commit 0903f96ba3
10 changed files with 207 additions and 106 deletions

View file

@ -6,9 +6,11 @@ export default {
app: { app: {
name: "tensamin", name: "tensamin",
identifier: "client.tensamin.net", identifier: "client.tensamin.net",
version: version, version,
urlSchemes: ["tensamin"],
}, },
build: { build: {
useAsar: true,
views: { views: {
mainview: { mainview: {
entrypoint: "src/mainview/index.ts", entrypoint: "src/mainview/index.ts",
@ -16,12 +18,17 @@ export default {
}, },
copy: { copy: {
"../web/dist/": "views/mainview/", "../web/dist/": "views/mainview/",
"../mobile/src-tauri/icons": "views/mainview/icons/",
}, },
mac: { mac: {
icons: "",
bundleWGPU: false,
defaultRenderer: "cef", defaultRenderer: "cef",
bundleCEF: true, bundleCEF: true,
}, },
linux: { linux: {
icon: "../mobile/src-tauri/icons/icon.png",
bundleWGPU: false,
defaultRenderer: "cef", defaultRenderer: "cef",
bundleCEF: true, bundleCEF: true,
chromiumFlags: { chromiumFlags: {
@ -30,6 +37,8 @@ export default {
}, },
}, },
win: { win: {
icon: "../mobile/src-tauri/icons/icon.ico",
bundleWGPU: false,
defaultRenderer: "cef", defaultRenderer: "cef",
bundleCEF: true, bundleCEF: true,
}, },

View file

@ -5,7 +5,7 @@
"scripts": { "scripts": {
"format": "bunx prettier --write .", "format": "bunx prettier --write .",
"lint": "echo desktop lint skipped", "lint": "echo desktop lint skipped",
"build": "echo desktop build skipped", "build": "electrobun build",
"start": "electrobun dev", "start": "electrobun dev",
"dev": "electrobun dev --watch" "dev": "electrobun dev --watch"
}, },

View file

@ -1,8 +1,14 @@
import { BrowserWindow } from "electrobun/bun"; import { BrowserWindow, Tray } from "electrobun/bun";
const tray = new Tray({
title: "Tensamin",
image: "views://mainview/icons/64x64.png",
height: 64,
width: 64,
});
// Create the main application window
const mainWindow = new BrowserWindow({ const mainWindow = new BrowserWindow({
title: "Hello Electrobun!", title: "Tensamin",
url: "views://mainview/index.html", url: "views://mainview/index.html",
renderer: "cef", renderer: "cef",
frame: { frame: {
@ -15,4 +21,8 @@ const mainWindow = new BrowserWindow({
mainWindow.url = "views://mainview/index.html"; mainWindow.url = "views://mainview/index.html";
console.log("Hello Electrobun app started!"); tray.on("tray-clicked", () => {
console.log("Tray clicked");
});
console.log("Started desktop app");

View file

@ -1,6 +1,4 @@
import { useStorage } from "@tensamin/storage/context";
import Wrapper from "@tensamin/user/wrapper"; import Wrapper from "@tensamin/user/wrapper";
import * as React from "react";
import { Basic, Loading } from "./modals/basic"; import { Basic, Loading } from "./modals/basic";
import List from "@/features/conversation/list/body"; import List from "@/features/conversation/list/body";
@ -19,14 +17,7 @@ import { useLocation } from "@tanstack/react-router";
* @returns Sidebar JSX. * @returns Sidebar JSX.
*/ */
export default function Sidebar() { export default function Sidebar() {
const [userId, setUserId] = React.useState<undefined | number>(undefined);
const { load } = useStorage();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
React.useEffect(() => {
load("user_id").then(setUserId);
}, [load]);
const location = useLocation(); const location = useLocation();
return ( return (
@ -38,7 +29,7 @@ export default function Sidebar() {
<div> <div>
<Wrapper <Wrapper
loading={<Loading />} loading={<Loading />}
userId={userId} userId={"own"}
component={(user) => <Basic user={user} />} component={(user) => <Basic user={user} />}
/> />
</div> </div>

View file

@ -31,7 +31,7 @@
}, },
"apps/mobile": { "apps/mobile": {
"name": "@tensamin/mobile", "name": "@tensamin/mobile",
"version": "0.1.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
@ -220,6 +220,7 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@tensamin/shared": "workspace:*", "@tensamin/shared": "workspace:*",
"@tensamin/storage": "workspace:*",
"@tensamin/ttp": "workspace:*", "@tensamin/ttp": "workspace:*",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",

View file

@ -189,14 +189,14 @@ export default function Provider(props: {
* Sends typed protocol messages through the active transport client. * Sends typed protocol messages through the active transport client.
* @param type Protocol message type. * @param type Protocol message type.
* @param data Optional request payload. * @param data Optional request payload.
* @param options Optional request id and response mode. * @param options Optional request id.
* @returns A promise for either void (no response) or typed message payload. * @returns A promise for the typed message payload.
*/ */
const send = useCallback<BoundSendFn<Schemas>>( const send = useCallback<BoundSendFn<Schemas>>(
(( ((
type: string, type: string,
data?: Record<string, unknown>, data?: Record<string, unknown>,
options?: { id?: number; noResponse?: boolean }, options?: { id?: number },
) => { ) => {
const client = clientRef.current; const client = clientRef.current;
@ -204,16 +204,8 @@ export default function Provider(props: {
return Promise.reject(new Error("ttp is not connected")); return Promise.reject(new Error("ttp is not connected"));
} }
if (options?.noResponse) {
return client.send(type as keyof Schemas & string, data as never, {
...options,
noResponse: true,
});
}
return client.send(type as keyof Schemas & string, data as never, { return client.send(type as keyof Schemas & string, data as never, {
...options, ...options,
noResponse: false,
}); });
}) as BoundSendFn<Schemas>, }) as BoundSendFn<Schemas>,
[], [],

View file

@ -27,9 +27,7 @@ type MockTransportInstance = {
ready: Promise<void>; ready: Promise<void>;
closed: Promise<void>; closed: Promise<void>;
createUnidirectionalStream: () => Promise<MockStream>; createUnidirectionalStream: () => Promise<MockStream>;
incomingUnidirectionalStreams: { incomingUnidirectionalStreams: ReadableStream<ReadableStream<Uint8Array>>;
getReader: () => MockReader;
};
close: () => void; close: () => void;
}; };
@ -69,6 +67,10 @@ function createMockWebTransport() {
let readyResolve!: () => void; let readyResolve!: () => void;
let closedResolve!: () => void; let closedResolve!: () => void;
let incomingController:
| ReadableStreamDefaultController<ReadableStream<Uint8Array>>
| null = null;
const outgoingMessages: TypedMessage[] = [];
const ready = new Promise<void>((resolve) => { const ready = new Promise<void>((resolve) => {
readyResolve = resolve; readyResolve = resolve;
@ -87,17 +89,19 @@ function createMockWebTransport() {
getWriter: () => writer, getWriter: () => writer,
}; };
const incomingUnidirectionalStreams = new ReadableStream<
ReadableStream<Uint8Array>
>({
start(controller) {
incomingController = controller;
},
});
const transport: MockTransportInstance = { const transport: MockTransportInstance = {
ready, ready,
closed, closed,
createUnidirectionalStream: async () => stream, createUnidirectionalStream: async () => stream,
incomingUnidirectionalStreams: { incomingUnidirectionalStreams,
getReader: () => ({
read: async () => ({ done: true }),
releaseLock: () => {},
cancel: async () => {},
}),
},
close: () => {}, close: () => {},
}; };
@ -109,10 +113,32 @@ function createMockWebTransport() {
close = transport.close; close = transport.close;
} }
const pushIncomingMessage = (message: TypedMessage) => {
if (!incomingController) {
throw new Error("Incoming stream controller is not ready");
}
const frame = wrapForDecode(encodeCommunicationMessage(message));
const incomingStream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(frame);
controller.close();
},
});
incomingController.enqueue(incomingStream);
};
writer.write = async (chunk: Uint8Array) => {
outgoingMessages.push(decodeCommunicationMessage(chunk));
};
return { return {
readyResolve, readyResolve,
closedResolve, closedResolve,
MockWebTransport, MockWebTransport,
outgoingMessages,
pushIncomingMessage,
}; };
} }
@ -378,6 +404,61 @@ describe("Core Protocol", () => {
await expectRejection(sendPromise); await expectRejection(sendPromise);
}); });
it("queues sends until the active request receives a response", async () => {
const {
readyResolve,
pushIncomingMessage,
outgoingMessages,
MockWebTransport,
} = createMockWebTransport();
const globalWithWebTransport = globalThis as unknown as {
WebTransport?: new (url: string) => MockTransportInstance;
};
globalWithWebTransport.WebTransport = MockWebTransport;
const schemas: SchemaMap = {
ping: {
request: z.object({}),
response: z.object({}),
},
};
const client = createTransportClient(schemas);
const connectPromise = client.connect("http://localhost:8000");
readyResolve();
await connectPromise;
const firstSend = client.send("ping", {});
const secondSend = client.send("ping", {});
for (let attempt = 0; attempt < 10 && outgoingMessages.length === 0; attempt += 1) {
await Promise.resolve();
}
expect(outgoingMessages.length).toBe(1);
pushIncomingMessage({
id: outgoingMessages[0].id,
type: "ping",
data: {},
});
await firstSend;
for (let attempt = 0; attempt < 10 && outgoingMessages.length === 1; attempt += 1) {
await Promise.resolve();
}
expect(outgoingMessages.length).toBe(2);
pushIncomingMessage({
id: outgoingMessages[1].id,
type: "ping",
data: {},
});
await secondSend;
});
}); });
describe("Push subscriptions", () => { describe("Push subscriptions", () => {

View file

@ -69,20 +69,13 @@ export type SchemaMap = Record<
type SendOptions = { type SendOptions = {
id?: number; id?: number;
noResponse?: boolean;
}; };
export type BoundSendFn<T extends SchemaMap> = { export type BoundSendFn<T extends SchemaMap> = {
<K extends keyof T & string>( <K extends keyof T & string>(
type: K, type: K,
data: z.input<T[K]["request"]>, data: z.input<T[K]["request"]>,
options: { id?: number; noResponse: true }, options?: { id?: number },
): Promise<void>;
<K extends keyof T & string>(
type: K,
data: z.input<T[K]["request"]>,
options?: { id?: number; noResponse?: false },
): Promise<TypedMessage<z.output<T[K]["response"]>>>; ): Promise<TypedMessage<z.output<T[K]["response"]>>>;
}; };
@ -125,6 +118,7 @@ export function createTransportClient<T extends SchemaMap>(
let currentConnection: ActiveConnection | null = null; let currentConnection: ActiveConnection | null = null;
let currentReadyState: number = READY_STATE.CLOSED; let currentReadyState: number = READY_STATE.CLOSED;
let nextRequestId = 1; let nextRequestId = 1;
let sendQueueTail: Promise<void> = Promise.resolve();
let configuredUrl = options.url; let configuredUrl = options.url;
/** /**
@ -192,6 +186,20 @@ export function createTransportClient<T extends SchemaMap>(
notifyClosed(connection, error); notifyClosed(connection, error);
}; };
/**
* Serializes outbound send work so only one request is active at a time.
* @param task Request task to run in queue order.
* @returns Promise for the task result.
*/
const enqueueSend = <T>(task: () => Promise<T>) => {
const queuedTask = sendQueueTail.then(task, task);
sendQueueTail = queuedTask.then(
() => undefined,
() => undefined,
);
return queuedTask;
};
/** /**
* Handles decoded incoming messages and resolves request promises or push listeners. * Handles decoded incoming messages and resolves request promises or push listeners.
* @param message Decoded incoming message. * @param message Decoded incoming message.
@ -515,8 +523,8 @@ export function createTransportClient<T extends SchemaMap>(
* Sends a typed protocol request over the current connection. * Sends a typed protocol request over the current connection.
* @param type Protocol message type. * @param type Protocol message type.
* @param input Optional request payload. * @param input Optional request payload.
* @param options Optional id and response behavior. * @param options Optional request id.
* @returns Promise for response message or void when no response is expected. * @returns Promise for the typed response message.
*/ */
const send: BoundSendFn<T> = (( const send: BoundSendFn<T> = ((
type: string, type: string,
@ -527,8 +535,6 @@ export function createTransportClient<T extends SchemaMap>(
return Promise.reject(new Error("Transport is not connected")); return Promise.reject(new Error("Transport is not connected"));
} }
const connection = currentConnection;
try { try {
const schema = schemas[type]; const schema = schemas[type];
let payload: Record<string, unknown>; let payload: Record<string, unknown>;
@ -555,69 +561,66 @@ export function createTransportClient<T extends SchemaMap>(
payload = coercePayload(input ?? {}); payload = coercePayload(input ?? {});
} }
if (!options?.id && !options?.noResponse) { const requestOptions = options ? { ...options } : {};
const array = new Uint32Array(1); const enqueuedConnection = currentConnection;
crypto.getRandomValues(array);
options = options ?? {};
options.id = array[0];
}
if (type !== "ping") { return enqueueSend(() => {
log(2, "ttp", "gray", "Sent:", type, payload, { id: options.id }); if (
} !enqueuedConnection ||
currentConnection !== enqueuedConnection ||
currentReadyState !== READY_STATE.OPEN ||
enqueuedConnection.closeNotified
) {
return Promise.reject(new Error("Transport is not connected"));
}
const expectsResponse = !options?.noResponse; const requestId = resolveRequestId(
const requestId = resolveRequestId( requestOptions.id,
options.id, true,
expectsResponse, pending,
pending, () => {
() => { const current = nextRequestId;
const current = nextRequestId; nextRequestId = current >= MAX_REQUEST_ID ? 1 : current + 1;
nextRequestId = current >= MAX_REQUEST_ID ? 1 : current + 1; return current;
return current;
},
);
const messageBytes = encodeCommunicationMessage({
id: requestId,
type,
data: payload,
});
if (!expectsResponse) {
return writeMessageOnPersistentStream(connection, messageBytes).catch(
(error) => {
handleConnectionFailure(connection, error);
throw error;
}, },
); );
}
return new Promise<TypedMessage>((resolve, reject) => { if (type !== "ping") {
const timeoutId = setTimeout(() => { log(2, "ttp", "gray", "Sent:", type, payload, { id: requestId });
pending.delete(requestId); }
reject(
new Error(
`Request "${type}" timed out after ${RESPONSE_TIMEOUT}ms`,
),
);
}, RESPONSE_TIMEOUT);
pending.set(requestId, { const messageBytes = encodeCommunicationMessage({
requestType: type, id: requestId,
resolve, type,
reject, data: payload,
timeoutId,
}); });
void writeMessageOnPersistentStream(connection, messageBytes).catch( return new Promise<TypedMessage>((resolve, reject) => {
(error) => { const timeoutId = setTimeout(() => {
handleConnectionFailure(connection, error);
clearTimeout(timeoutId);
pending.delete(requestId); pending.delete(requestId);
reject(error); reject(
}, new Error(
); `Request "${type}" timed out after ${RESPONSE_TIMEOUT}ms`,
),
);
}, RESPONSE_TIMEOUT);
pending.set(requestId, {
requestType: type,
resolve,
reject,
timeoutId,
});
void writeMessageOnPersistentStream(enqueuedConnection, messageBytes).catch(
(error) => {
handleConnectionFailure(enqueuedConnection, error);
clearTimeout(timeoutId);
pending.delete(requestId);
reject(error);
},
);
});
}); });
} catch (error) { } catch (error) {
return Promise.reject(error); return Promise.reject(error);

View file

@ -16,6 +16,7 @@
"dependencies": { "dependencies": {
"@tensamin/ttp": "workspace:*", "@tensamin/ttp": "workspace:*",
"@tensamin/shared": "workspace:*", "@tensamin/shared": "workspace:*",
"@tensamin/storage": "workspace:*",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"zod": "^4.3.6" "zod": "^4.3.6"

View file

@ -2,14 +2,16 @@ import { useEffect, useState } from "react";
import { useUser, type User } from "./context"; import { useUser, type User } from "./context";
import { failedUser } from "@tensamin/shared/data"; import { failedUser } from "@tensamin/shared/data";
import { useStorage } from "@tensamin/storage/context";
// Wrapper function to pass user data to some component // Wrapper function to pass user data to some component
export default function Wrapper(props: { export default function Wrapper(props: {
userId?: number; userId?: number | "own";
loading: React.ReactNode; loading: React.ReactNode;
component: (user: User) => React.ReactNode; component: (user: User) => React.ReactNode;
}) { }) {
const { get } = useUser(); const { get } = useUser();
const { load } = useStorage();
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
useEffect(() => { useEffect(() => {
@ -20,6 +22,17 @@ export default function Wrapper(props: {
let active = true; let active = true;
if (props.userId === "own") {
load("user_id")
.then((id) => {
get(id)
.then((user) => (active ? setUser(user) : null))
.catch(() => setUser(failedUser));
})
.catch(() => setUser(failedUser));
return;
}
get(props.userId) get(props.userId)
.then((value) => { .then((value) => {
if (active) { if (active) {
@ -35,7 +48,7 @@ export default function Wrapper(props: {
return () => { return () => {
active = false; active = false;
}; };
}, [get, props.userId]); }, [load, get, props.userId]);
return <>{user ? props.component(user) : props.loading}</>; return <>{user ? props.component(user) : props.loading}</>;
} }