feat(pwa): add base
All checks were successful
/ build-web (push) Successful in 5m35s
/ build-desktop (linux) (push) Successful in 9m41s
/ build-mobile (push) Successful in 20m12s
/ release (push) Successful in 1m51s
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
All checks were successful
/ build-web (push) Successful in 5m35s
/ build-desktop (linux) (push) Successful in 9m41s
/ build-mobile (push) Successful in 20m12s
/ release (push) Successful in 1m51s
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
This commit is contained in:
parent
6e977bca7e
commit
7b36218ffa
40 changed files with 4014 additions and 922 deletions
3
packages/cache/package.json
vendored
3
packages/cache/package.json
vendored
|
|
@ -12,8 +12,7 @@
|
|||
"scripts": {
|
||||
"format": "pnpm exec prettier --write .",
|
||||
"lint": "eslint src",
|
||||
"test": "vitest run",
|
||||
"build": "pnpm run test && tsc -p tsconfig.json --noEmit"
|
||||
"build": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tensamin/mtp": "workspace:*",
|
||||
|
|
|
|||
50
packages/cache/src/helpers.test.ts
vendored
50
packages/cache/src/helpers.test.ts
vendored
|
|
@ -1,50 +0,0 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
replaceConversation,
|
||||
selectConversationWindows,
|
||||
trimMessages,
|
||||
} from "./helpers";
|
||||
import type { CachedMessage, ConversationWindow } from "./schemas";
|
||||
|
||||
const message = (SendTime: number): CachedMessage => ({
|
||||
SenderId: 1,
|
||||
SendTime,
|
||||
Content: "Y2lwaGVydGV4dA==",
|
||||
MessageState: "received",
|
||||
});
|
||||
const window = (UserId: number, LastMessageAt: number): ConversationWindow => ({
|
||||
UserId,
|
||||
LastMessageAt,
|
||||
Messages: [],
|
||||
});
|
||||
|
||||
describe("conversation cache helpers", () => {
|
||||
it("selects the five most recent windows", () => {
|
||||
const selected = selectConversationWindows(
|
||||
[
|
||||
window(1, 1),
|
||||
window(2, 6),
|
||||
window(3, 3),
|
||||
window(4, 4),
|
||||
window(5, 5),
|
||||
window(6, 2),
|
||||
],
|
||||
5,
|
||||
);
|
||||
expect(selected.map(({ UserId }) => UserId)).toEqual([2, 5, 4, 3, 6]);
|
||||
});
|
||||
|
||||
it("replaces only the matching conversation", () => {
|
||||
expect(
|
||||
replaceConversation([window(1, 1), window(2, 2)], window(1, 9)),
|
||||
).toEqual([window(1, 9), window(2, 2)]);
|
||||
});
|
||||
|
||||
it("retains the newest messages in chronological order", () => {
|
||||
expect(
|
||||
trimMessages([message(2), message(3), message(1)], 2).map(
|
||||
(item) => item.SendTime,
|
||||
),
|
||||
).toEqual([2, 3]);
|
||||
});
|
||||
});
|
||||
|
|
@ -4,8 +4,6 @@ import {
|
|||
CardHeader,
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
"format": "pnpm exec prettier --write .",
|
||||
"lint": "eslint src",
|
||||
"test": "vitest run",
|
||||
"build": "pnpm run test && tsc -p tsconfig.json --noEmit"
|
||||
"build": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.8",
|
||||
|
|
|
|||
|
|
@ -1,65 +0,0 @@
|
|||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("mtp", () => ({
|
||||
crypto: {},
|
||||
}));
|
||||
|
||||
import { createCryptoActions } from "./context";
|
||||
|
||||
/**
|
||||
* Creates a rejected API getter used to verify initialization guards.
|
||||
* @returns Null API reference.
|
||||
*/
|
||||
function getUninitializedApi(): null {
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("createCryptoActions", () => {
|
||||
const textEncoder = new TextEncoder();
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
test("throws when API is not initialized", async () => {
|
||||
const actions = createCryptoActions(getUninitializedApi);
|
||||
|
||||
let failed = false;
|
||||
try {
|
||||
await actions.encrypt("ab", new TextEncoder().encode("plain"));
|
||||
} catch (error) {
|
||||
failed = (error as Error).message.includes("API not initialized");
|
||||
}
|
||||
|
||||
expect(failed).toBe(true);
|
||||
});
|
||||
|
||||
test("delegates encrypt/decrypt/getSharedSecret to API reference", async () => {
|
||||
const api = {
|
||||
encrypt: async (
|
||||
secret: string,
|
||||
input: Uint8Array<ArrayBuffer>,
|
||||
): Promise<Uint8Array<ArrayBuffer>> =>
|
||||
textEncoder.encode(`${secret}:${textDecoder.decode(input)}`),
|
||||
decrypt: async (
|
||||
secret: string,
|
||||
input: Uint8Array<ArrayBuffer>,
|
||||
): Promise<Uint8Array<ArrayBuffer>> =>
|
||||
textEncoder.encode(`${secret}|${textDecoder.decode(input)}`),
|
||||
encryptText: async (secret: string, plaintext: string): Promise<string> =>
|
||||
`${secret}:${plaintext}`,
|
||||
decryptText: async (
|
||||
secret: string,
|
||||
ciphertext: string,
|
||||
): Promise<string> => `${secret}|${ciphertext}`,
|
||||
};
|
||||
|
||||
const actions = createCryptoActions(() => api);
|
||||
|
||||
expect(
|
||||
textDecoder.decode(await actions.encrypt("s", textEncoder.encode("p"))),
|
||||
).toBe("s:p");
|
||||
expect(
|
||||
textDecoder.decode(await actions.decrypt("s", textEncoder.encode("c"))),
|
||||
).toBe("s|c");
|
||||
expect(await actions.encryptText("s", "p")).toBe("s:p");
|
||||
expect(await actions.decryptText("s", "c")).toBe("s|c");
|
||||
});
|
||||
});
|
||||
|
|
@ -141,20 +141,30 @@ export default function Provider(props: { children: React.ReactNode }) {
|
|||
const hasPermissions = await requestNotificationPermission();
|
||||
|
||||
if (hasPermissions) {
|
||||
const notification = new Notification(user.Display, {
|
||||
const options: NotificationOptions = {
|
||||
body: content,
|
||||
icon: user.Avatar || user.Display.slice(0, 2).toUpperCase(),
|
||||
badge: user.Avatar || user.Display.slice(0, 2).toUpperCase(),
|
||||
icon: user.Avatar || "/icons/icon-192.png",
|
||||
badge: "/icons/notification-badge.png",
|
||||
tag: `message-${user.UserId}`,
|
||||
silent: true,
|
||||
});
|
||||
|
||||
};
|
||||
if ("serviceWorker" in navigator) {
|
||||
const registration =
|
||||
await navigator.serviceWorker.getRegistration();
|
||||
if (registration) {
|
||||
await registration.showNotification(user.Display, {
|
||||
...options,
|
||||
data: { url: `/chat?id=${user.UserId}` },
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
const notification = new Notification(user.Display, options);
|
||||
notification.onclick = () => {
|
||||
window.focus();
|
||||
navigate({
|
||||
to: `/chat?id=${user.UserId}`,
|
||||
});
|
||||
|
||||
notification.close();
|
||||
};
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -21,17 +21,37 @@ export {
|
|||
type OnboardingStepControls,
|
||||
} from "@methanium/ui";
|
||||
|
||||
|
||||
|
||||
interface GateState {
|
||||
docs: z.infer<typeof legalDocsSchema>;
|
||||
acceptedPP: boolean;
|
||||
acceptedTOS: boolean;
|
||||
changedPP: boolean;
|
||||
changedTOS: boolean;
|
||||
includeLegal: boolean;
|
||||
includeOnboarding: boolean;
|
||||
includeTauriPermissions: boolean;
|
||||
}
|
||||
|
||||
async function fetchLegalDocumentHash(document: string) {
|
||||
const response = await fetch(
|
||||
`https://legal.methanium.net/tensamin/${document}/raw`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Legal document request failed: ${response.status}`);
|
||||
}
|
||||
if (!response.headers.get("content-type")?.startsWith("text/plain")) {
|
||||
throw new Error("Legal document request returned an invalid content type");
|
||||
}
|
||||
|
||||
const hash = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
await response.arrayBuffer(),
|
||||
);
|
||||
return Array.from(new Uint8Array(hash), (byte) =>
|
||||
byte.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
}
|
||||
|
||||
export default function OnboardingGate({ children }: { children: ReactNode }) {
|
||||
const { load, save } = useStorage();
|
||||
const [state, setState] = useState<GateState>();
|
||||
|
|
@ -51,25 +71,9 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
|
|||
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch("https://legal.tensamin.net/api/current");
|
||||
if (!response.ok) {
|
||||
throw new Error(`Legal documents request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const current: unknown = await response.json();
|
||||
if (!active) return;
|
||||
|
||||
const parsed = legalDocsSchema.safeParse(current);
|
||||
if (!parsed.success) {
|
||||
setError("Failed to load legal documents");
|
||||
setErrorDescription(
|
||||
"The legal documents data received from the server is invalid. Please try again later.",
|
||||
);
|
||||
log(0, "Legal", "red", "Invalid legal documents data", parsed.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const [
|
||||
ppHash,
|
||||
tosHash,
|
||||
localDocs,
|
||||
acceptedPP,
|
||||
acceptedTOS,
|
||||
|
|
@ -77,6 +81,8 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
|
|||
onboardingStarted,
|
||||
tauriPermissionsDone,
|
||||
] = await Promise.all([
|
||||
fetchLegalDocumentHash("privacy-policy"),
|
||||
fetchLegalDocumentHash("terms-of-service"),
|
||||
load("legal_docs"),
|
||||
load("accepted_privacy_policy"),
|
||||
load("accepted_terms_of_service"),
|
||||
|
|
@ -87,10 +93,16 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
|
|||
|
||||
if (!active) return;
|
||||
|
||||
const docs = legalDocsSchema.parse({
|
||||
pp: { hash: ppHash },
|
||||
tos: { hash: tosHash },
|
||||
});
|
||||
const changedPP = acceptedPP && localDocs.pp.hash !== docs.pp.hash;
|
||||
const changedTOS = acceptedTOS && localDocs.tos.hash !== docs.tos.hash;
|
||||
const currentAcceptedPP =
|
||||
acceptedPP && localDocs.pp.hash === parsed.data.pp.hash;
|
||||
acceptedPP && localDocs.pp.hash === docs.pp.hash;
|
||||
const currentAcceptedTOS =
|
||||
acceptedTOS && localDocs.tos.hash === parsed.data.tos.hash;
|
||||
acceptedTOS && localDocs.tos.hash === docs.tos.hash;
|
||||
const existingUser = acceptedPP && acceptedTOS;
|
||||
const includeOnboarding =
|
||||
!onboardingDone && (!existingUser || onboardingStarted);
|
||||
|
|
@ -104,9 +116,11 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
|
|||
}
|
||||
|
||||
setState({
|
||||
docs: parsed.data,
|
||||
docs,
|
||||
acceptedPP: currentAcceptedPP,
|
||||
acceptedTOS: currentAcceptedTOS,
|
||||
changedPP,
|
||||
changedTOS,
|
||||
includeLegal: !currentAcceptedPP || !currentAcceptedTOS,
|
||||
includeOnboarding,
|
||||
includeTauriPermissions:
|
||||
|
|
@ -168,16 +182,28 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
|
|||
|
||||
const steps: OnboardingStep[] = [];
|
||||
if (state.includeLegal) {
|
||||
const changedDocuments = [
|
||||
state.changedPP && "Privacy Policy",
|
||||
state.changedTOS && "Terms of Service",
|
||||
].filter(Boolean);
|
||||
|
||||
steps.push({
|
||||
id: "legal",
|
||||
title: "Privacy Policy & ToS",
|
||||
description: `${state.docs.pp.version} / ${state.docs.tos.version}`,
|
||||
title:
|
||||
changedDocuments.length > 0
|
||||
? "Legal documents changed"
|
||||
: "Privacy Policy & ToS",
|
||||
description:
|
||||
changedDocuments.length > 0
|
||||
? changedDocuments.join(" & ")
|
||||
: "Review and accept our legal documents",
|
||||
defaultCanContinue: false,
|
||||
content: (
|
||||
<LegalPage
|
||||
docs={state.docs}
|
||||
initiallyAcceptedPP={state.acceptedPP}
|
||||
initiallyAcceptedTOS={state.acceptedTOS}
|
||||
changedPP={state.changedPP}
|
||||
changedTOS={state.changedTOS}
|
||||
onAccept={acceptLegal}
|
||||
/>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,21 +1,17 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import { Checkbox, Label, Link } from "@methanium/ui";
|
||||
import { legalDocsSchema } from "@tensamin/shared/features/legal/schema";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { useOnboardingStep } from "@methanium/ui";
|
||||
|
||||
|
||||
import { Checkbox, Label, Link, useOnboardingStep } from "@methanium/ui";
|
||||
|
||||
export default function LegalPage({
|
||||
docs,
|
||||
initiallyAcceptedPP,
|
||||
initiallyAcceptedTOS,
|
||||
changedPP,
|
||||
changedTOS,
|
||||
onAccept,
|
||||
}: {
|
||||
docs: z.infer<typeof legalDocsSchema>;
|
||||
initiallyAcceptedPP: boolean;
|
||||
initiallyAcceptedTOS: boolean;
|
||||
changedPP: boolean;
|
||||
changedTOS: boolean;
|
||||
onAccept: () => Promise<void>;
|
||||
}) {
|
||||
const [acceptedPP, setAcceptedPP] = useState(initiallyAcceptedPP);
|
||||
|
|
@ -34,34 +30,61 @@ export default function LegalPage({
|
|||
return (
|
||||
<div className="mx-auto flex min-h-[calc(100dvh-17rem)] w-full max-w-5xl flex-col gap-10 p-2 py-20 md:min-h-[calc(100dvh-20.5rem)] md:p-24">
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<BigCheckbox
|
||||
id="acceptPP"
|
||||
checked={acceptedPP}
|
||||
onChange={setAcceptedPP}
|
||||
label="I agree to the Privacy Policy"
|
||||
/>
|
||||
<BigCheckbox
|
||||
id="acceptTOS"
|
||||
checked={acceptedTOS}
|
||||
onChange={setAcceptedTOS}
|
||||
label="I agree to the Terms of Service"
|
||||
/>
|
||||
<div className="w-full border-t-2" />
|
||||
<Link
|
||||
label="Privacy Policy"
|
||||
link={`https://legal.tensamin.net/pp/${docs.pp.version}`}
|
||||
/>
|
||||
<Link
|
||||
label="Terms of Service"
|
||||
link={`https://legal.tensamin.net/tos/${docs.tos.version}`}
|
||||
/>
|
||||
<div className="flex w-full max-w-xl flex-col items-start gap-8">
|
||||
{!initiallyAcceptedPP && (
|
||||
<LegalDocumentAcceptance
|
||||
id="acceptPP"
|
||||
name="Privacy Policy"
|
||||
link="https://legal.methanium.net/tensamin/privacy-policy/"
|
||||
changed={changedPP}
|
||||
checked={acceptedPP}
|
||||
onChange={setAcceptedPP}
|
||||
/>
|
||||
)}
|
||||
{!initiallyAcceptedTOS && (
|
||||
<LegalDocumentAcceptance
|
||||
id="acceptTOS"
|
||||
name="Terms of Service"
|
||||
link="https://legal.methanium.net/tensamin/terms-of-service/"
|
||||
changed={changedTOS}
|
||||
checked={acceptedTOS}
|
||||
onChange={setAcceptedTOS}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LegalDocumentAcceptance({
|
||||
id,
|
||||
name,
|
||||
link,
|
||||
changed,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
name: string;
|
||||
link: string;
|
||||
changed: boolean;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex w-full flex-col items-start gap-3">
|
||||
<Link label={`Read the ${name}`} link={link} />
|
||||
<BigCheckbox
|
||||
id={id}
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
label={`I agree to the ${changed ? `updated ${name}` : name}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BigCheckbox({
|
||||
id,
|
||||
label,
|
||||
|
|
|
|||
|
|
@ -580,20 +580,11 @@ export const storageDefaults: Storage = {
|
|||
analytics_done: false,
|
||||
...settingsStorageDefaults,
|
||||
legal_docs: {
|
||||
eula: {
|
||||
version: "0.0",
|
||||
hash: "000000000000",
|
||||
unix: 0,
|
||||
},
|
||||
tos: {
|
||||
version: "0.0",
|
||||
hash: "000000000000",
|
||||
unix: 0,
|
||||
hash: "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
},
|
||||
pp: {
|
||||
version: "0.0",
|
||||
hash: "000000000000",
|
||||
unix: 0,
|
||||
hash: "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
},
|
||||
},
|
||||
cached_contacts: [],
|
||||
|
|
|
|||
|
|
@ -1,13 +1,10 @@
|
|||
import { z } from "zod";
|
||||
|
||||
const legalDocSchema = z.object({
|
||||
version: z.string().regex(/^\d+\.\d+$/),
|
||||
hash: z.string().regex(/^[a-f0-9]{12}$/),
|
||||
unix: z.number().int().positive(),
|
||||
hash: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
});
|
||||
|
||||
export const legalDocsSchema = z.object({
|
||||
eula: legalDocSchema,
|
||||
tos: legalDocSchema,
|
||||
pp: legalDocSchema,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@
|
|||
"exports": {
|
||||
"./session": "./src/session.tsx",
|
||||
"./context": "./src/context.tsx",
|
||||
"./secure": "./src/secure.ts"
|
||||
"./secure": "./src/secure.ts",
|
||||
"./browserSecure": "./src/browserSecure.ts",
|
||||
"./credentials": "./src/credentials.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"format": "pnpm exec prettier --write .",
|
||||
|
|
|
|||
40
packages/storage/src/browserSecure.ts
Normal file
40
packages/storage/src/browserSecure.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { getDatabaseEntry } from "@tensamin/shared/indexedDb";
|
||||
|
||||
type SecureEnvelope = {
|
||||
__tensaminSecure: 1;
|
||||
version: 1;
|
||||
iv: string;
|
||||
data: string;
|
||||
};
|
||||
|
||||
function base64ToBytes(value: string) {
|
||||
const binary = atob(value);
|
||||
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
||||
}
|
||||
|
||||
function isSecureEnvelope(value: unknown): value is SecureEnvelope {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const envelope = value as Partial<SecureEnvelope>;
|
||||
return (
|
||||
envelope.__tensaminSecure === 1 &&
|
||||
envelope.version === 1 &&
|
||||
typeof envelope.iv === "string" &&
|
||||
typeof envelope.data === "string"
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadSecureBrowserValue<T>(key: string) {
|
||||
const stored = await getDatabaseEntry<unknown>("storage", key);
|
||||
if (stored === undefined || !isSecureEnvelope(stored)) {
|
||||
return stored as T | undefined;
|
||||
}
|
||||
|
||||
const masterKey = await getDatabaseEntry<CryptoKey>("keys", "master-v1");
|
||||
if (!masterKey) throw new Error("Secure storage key is unavailable.");
|
||||
const plaintext = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: base64ToBytes(stored.iv) },
|
||||
masterKey,
|
||||
base64ToBytes(stored.data),
|
||||
);
|
||||
return JSON.parse(new TextDecoder().decode(plaintext)) as T;
|
||||
}
|
||||
|
|
@ -30,7 +30,7 @@ import {
|
|||
|
||||
export type SaveOptions = { secure?: boolean };
|
||||
|
||||
interface StorageContextValue {
|
||||
export interface StorageContextValue {
|
||||
load<K extends keyof StorageSchema>(key: K): Promise<StorageSchema[K]>;
|
||||
save<K extends keyof StorageSchema>(
|
||||
key: K,
|
||||
|
|
|
|||
68
packages/storage/src/credentials.ts
Normal file
68
packages/storage/src/credentials.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||
|
||||
import type { StorageContextValue } from "./context";
|
||||
|
||||
export function parseTuFileContent(rawFileContent: string): {
|
||||
userId: number;
|
||||
privateKey: string;
|
||||
domain: string | null;
|
||||
} {
|
||||
const content = rawFileContent.trim();
|
||||
const separator = content.indexOf("::");
|
||||
if (separator <= 0 || separator !== content.lastIndexOf("::")) {
|
||||
throw new Error("Invalid file");
|
||||
}
|
||||
|
||||
const identity = content.slice(0, separator);
|
||||
const privateKey = content.slice(separator + 2).trim();
|
||||
const [userIdValue, domain, ...extraDomainParts] = identity.split("@");
|
||||
const userId = Number(userIdValue);
|
||||
if (
|
||||
!Number.isSafeInteger(userId) ||
|
||||
userId <= 0 ||
|
||||
!privateKey ||
|
||||
extraDomainParts.length > 0 ||
|
||||
(identity.includes("@") && !domain)
|
||||
) {
|
||||
throw new Error("Invalid file");
|
||||
}
|
||||
|
||||
return { userId, privateKey, domain: domain ?? null };
|
||||
}
|
||||
|
||||
export async function persistMtpCredentials({
|
||||
storage,
|
||||
userId,
|
||||
keyring,
|
||||
domain,
|
||||
}: {
|
||||
storage: Pick<StorageContextValue, "load" | "save">;
|
||||
userId: number;
|
||||
keyring: string;
|
||||
domain?: string | null;
|
||||
}) {
|
||||
const omegaUrl = domain
|
||||
? `https://${domain}/`
|
||||
: await storage.load("omega_url");
|
||||
if (domain) await storage.save("omega_url", omegaUrl);
|
||||
|
||||
if (isTauri()) {
|
||||
const [forcedOmikronUrl, forcedOmikronPublicKey] = await Promise.all([
|
||||
storage.load("forced_omikron_url"),
|
||||
storage.load("forced_omikron_public_key"),
|
||||
]);
|
||||
await invoke("mtp_store_credentials", {
|
||||
config: {
|
||||
userId,
|
||||
keyring,
|
||||
omegaUrl,
|
||||
forcedOmikronUrl,
|
||||
forcedOmikronPublicKey,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await storage.save("mtp_keyring", keyring, { secure: true });
|
||||
await storage.save("session_id", Date.now());
|
||||
await storage.save("user_id", userId);
|
||||
}
|
||||
|
|
@ -4,10 +4,7 @@ import { getDatabaseEntry, setDatabaseEntry } from "@tensamin/shared/indexedDb";
|
|||
|
||||
export type SecureStorageStatus = {
|
||||
backend:
|
||||
| "electron-keyring"
|
||||
| "application-storage"
|
||||
| "webcrypto"
|
||||
| "indexeddb";
|
||||
"electron-keyring" | "application-storage" | "webcrypto" | "indexeddb";
|
||||
secure: boolean;
|
||||
reason?: string;
|
||||
};
|
||||
|
|
@ -71,7 +68,12 @@ async function getKey() {
|
|||
if (!globalThis.crypto?.subtle || typeof indexedDB === "undefined") {
|
||||
return null;
|
||||
}
|
||||
if (window.tensaminDesktop?.secureStorage) return loadElectronKey();
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
window.tensaminDesktop?.secureStorage
|
||||
) {
|
||||
return loadElectronKey();
|
||||
}
|
||||
return loadBrowserKey();
|
||||
})().catch(() => null);
|
||||
return keyPromise;
|
||||
|
|
@ -120,7 +122,10 @@ export async function decodeSecureValue(value: unknown): Promise<unknown> {
|
|||
}
|
||||
|
||||
export async function getSecureStorageStatus(): Promise<SecureStorageStatus> {
|
||||
const desktop = window.tensaminDesktop?.secureStorage;
|
||||
const desktop =
|
||||
typeof window === "undefined"
|
||||
? undefined
|
||||
: window.tensaminDesktop?.secureStorage;
|
||||
if (desktop?.getStatus) {
|
||||
const status = await desktop.getStatus();
|
||||
if (status.available) return { backend: "electron-keyring", secure: true };
|
||||
|
|
|
|||
Loading…
Reference in a new issue