feat: main & brewing video tool, catalog APIs, sheet/recipe updates

- Add /tools/video-mainpage page (main + brewing-page advertisement videos,
  date-gated, per-country, push to machine over ADB) + api/video-mainpage
  create/list/update proxies; sidebar entry "Main & Brewing Video"
- Add catalog API proxies (catalog-create, catalog-list, catalog-banner,
  catalog-banner-image)
- Sheet: overview/edit/add/priceslot/price updates, stores & services
- Misc: adb, websocket/message handlers, crypto, recipe & brew tweaks

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
thanawat saiyota 2026-06-30 17:38:59 +07:00
parent 6011b92b7b
commit 47ee23777d
26 changed files with 5582 additions and 539 deletions

View file

@ -137,7 +137,7 @@ async function connectWithRetry<T>(
export async function connnectViaWebUSB(connectAndroidServer = true) {
const device = await AdbDaemonWebUsbDeviceManager.BROWSER?.requestDevice();
console.log('usb ok', globalThis.navigator.usb);
console.log('usb ok', (globalThis.navigator as Navigator & { usb?: unknown }).usb);
if (device) {
console.log('connect ', device.name);
@ -362,7 +362,6 @@ export async function executeCmd(command: string) {
}
}
export async function goToMachineHome() {
if (!getAdbInstance()) return;
try {

View file

@ -27,7 +27,8 @@ import {
sheetCatalogsLoading,
handleRawStreamHeader,
handleRawStreamChunk,
handleRawStreamEnd
handleRawStreamEnd,
handleSheetPriceResponse
} from '../stores/sheetStore';
import {
handleGenLayoutBatchStart,
@ -293,13 +294,14 @@ const handlers: Record<string, (payload: any) => void> = {
if (from === 'sheet-service' && level === 'content') {
const currentUid = auth.currentUser?.uid;
const content = p.content ?? p.value ?? p.payload;
const ref = p.ref ?? '';
console.log('[Sheet] Notify content received:', {
msg,
target,
currentUid,
contentKeys: content && typeof content === 'object' ? Object.keys(content) : [],
content
contentItems: Array.isArray(content) ? content.length : undefined
});
if (!target || (currentUid && target === currentUid)) {
@ -324,6 +326,12 @@ const handlers: Record<string, (payload: any) => void> = {
return;
}
if (!msg && ref === 'price') {
handleSheetPriceResponse(p.country ?? p.payload?.country ?? '', content);
addNotification('INFO:Loaded sheet price data');
return;
}
// Handle streaming messages (with msg field)
switch (msg) {
case 'priceslot':
@ -332,19 +340,29 @@ const handlers: Record<string, (payload: any) => void> = {
addNotification('INFO:Loaded PriceSlot data');
break;
case 'start':
handleSheetStreamStart(p);
addNotification('INFO:Sheet data streaming started');
if (ref === 'price') {
addNotification('INFO:Sheet price streaming started');
} else {
handleSheetStreamStart(p);
addNotification('INFO:Sheet data streaming started');
}
break;
case 'chunk':
if (isPriceSlotsPayload(content)) {
handlePriceSlotsResponse(content);
} else if (ref === 'price') {
handleSheetPriceResponse(p.country ?? p.payload?.country ?? '', content);
} else {
handleSheetStreamChunk(p);
}
break;
case 'end':
handleSheetStreamEnd(p);
addNotification('INFO:Sheet data streaming complete');
if (ref === 'price') {
addNotification('INFO:Sheet price streaming complete');
} else {
handleSheetStreamEnd(p);
addNotification('INFO:Sheet data streaming complete');
}
break;
case 'error':
handleSheetStreamError(p);
@ -352,14 +370,16 @@ const handlers: Record<string, (payload: any) => void> = {
break;
default:
// Handle other content notifications from sheet-service
console.log('[Sheet] Received content:', content);
console.log('[Sheet] Received content:', {
contentItems: Array.isArray(content) ? content.length : undefined
});
}
} else {
console.warn('[Sheet] Ignored content because target does not match current user:', {
target,
currentUid,
msg,
content
contentItems: Array.isArray(content) ? content.length : undefined
});
}
return;
@ -438,6 +458,7 @@ const handlers: Record<string, (payload: any) => void> = {
country: current_meta?.country ?? '',
content: saved_product_code_to_get_from_sheet,
param: 'price',
option: 'price',
stream: true,
request_id
});
@ -564,7 +585,7 @@ export async function handleIncomingMessages(raw: string, clientPrivateKey?: Cry
);
let actual_message: WSMessage = JSON.parse(decrypted_string);
if (actual_message.type !== 'heartbeat') {
console.log(`[WS MSG] type=${actual_message.type}`, actual_message.payload);
// console.log(`[WS MSG] type=${actual_message.type}`, actual_message.payload);
}
handlers[actual_message.type]?.(actual_message.payload);
@ -572,7 +593,7 @@ export async function handleIncomingMessages(raw: string, clientPrivateKey?: Cry
} else {
const msg: WSMessage = parsedMessage;
if (msg.type !== 'heartbeat') {
console.log(`[WS MSG] type=${msg.type}`, msg.payload);
// console.log(`[WS MSG] type=${msg.type}`, msg.payload);
}
if (msg == null) {
// error response

View file

@ -1,14 +1,17 @@
import { get, writable } from 'svelte/store';
import type { OutMessage } from '../types/outMessage';
import { sharedKey, socketStore } from '../stores/websocketStore';
import { sharedKey, socketStore, wsAuthReady } from '../stores/websocketStore';
import { addNotification } from '../stores/noti';
import { auth } from '../stores/auth';
import { WebCryptoHelper } from '../utils/crypto';
import * as semver from 'semver';
import { env } from '$env/dynamic/public';
export const queue = writable<string[]>([]);
function isSecuredAppVersion(version: string | undefined) {
return version?.startsWith('0.0.2') ?? false;
}
type CommandRequest = 'sheet' | 'command';
function getServiceName(cmdReq: CommandRequest) {
@ -20,8 +23,40 @@ function getServiceName(cmdReq: CommandRequest) {
}
}
function waitForWsAuthReady(timeoutMs = 10000): Promise<boolean> {
if (get(wsAuthReady)) return Promise.resolve(true);
return new Promise((resolve) => {
let settled = false;
let unsubscribe = () => {};
const timeout = setTimeout(() => {
if (settled) return;
settled = true;
unsubscribe();
resolve(false);
}, timeoutMs);
unsubscribe = wsAuthReady.subscribe((ready) => {
if (!ready || settled) return;
settled = true;
clearTimeout(timeout);
unsubscribe();
resolve(true);
});
});
}
// Websocket message wrapper for commands like `sheet`, `command`
export async function sendCommandRequest(target: CommandRequest, values: any): Promise<boolean> {
const authReady = await waitForWsAuthReady();
if (!authReady) {
console.warn('[WS Send] Skip command request because websocket auth is not ready', {
target,
param: values?.param
});
return false;
}
let srv_name = getServiceName(target);
let curr_user = get(auth);
@ -71,10 +106,10 @@ export async function sendMessage(
return false;
}
// console.log('send v2', APP_VERSION, semver.satisfies(APP_VERSION, '^0.0.2'));
// console.log('send v2', APP_VERSION, isSecuredAppVersion(APP_VERSION));
if (semver.satisfies(APP_VERSION, '^0.0.2')) {
// console.log('sending secured');
if (isSecuredAppVersion(APP_VERSION)) {
console.log('sending secured');
let sharedKeyRes = get(sharedKey);
// do encrypt
@ -82,6 +117,13 @@ export async function sendMessage(
data = JSON.stringify(await WebCryptoHelper.encryptMessage(sharedKeyRes, data));
}
// console.log('[WS Send]', {
// type: logMessage.type,
// service: logMessage.payload?.srv_name,
// param: logMessage.payload?.values?.param,
// bytes: data.length,
// secured: isSecuredAppVersion(APP_VERSION)
// });
socket.send(data);
return true;
}

View file

@ -15,6 +15,10 @@ import {
import type { PriceSlot } from '../stores/sheetStore';
import { setGenLayoutGenerating } from '../stores/genLayoutStore';
type SheetCellUpdate = { value: string; coord: { row: number; col: number } };
type SheetRowUpdate = { row_index: number; cells: SheetCellUpdate[] };
type SheetRowCreate = { header?: string[]; cells: string[] };
export async function requestCatalogs(country: string): Promise<boolean> {
return await sendCommandRequest('sheet', {
country: country,
@ -22,9 +26,36 @@ export async function requestCatalogs(country: string): Promise<boolean> {
});
}
/**
* Register a newly created catalog as a Grist table so it shows in the overview
* and menus can be added to it. `catalog` is the .skt filename produced by
* /api/catalog-create (e.g. "page_catalog_group_pro_summer_splash.skt").
*/
export async function addCatalog(
country: string,
catalogName: string,
catalog: string
): Promise<boolean> {
return await sendCommandRequest('sheet', {
country: country,
catalog: catalog,
catalog_name: catalogName,
param: 'add/catalog'
});
}
export async function requestPriceSlots(country: string): Promise<boolean> {
setPendingPriceSlotsCountry(country);
resetPriceSlotsCountry(country);
return requestPriceSlotOption(country, 'PriceSlot');
}
export async function requestPriceSlot(country: string, slotNumber: number): Promise<boolean> {
setPendingPriceSlotsCountry(country);
return requestPriceSlotOption(country, `PriceSlot${slotNumber}`);
}
async function requestPriceSlotOption(country: string, option: string): Promise<boolean> {
const request_id = crypto.randomUUID();
streamingRawData.update((data) => ({
@ -41,7 +72,7 @@ export async function requestPriceSlots(country: string): Promise<boolean> {
const values = {
country: country,
param: 'price',
option: 'PriceSlot',
option,
stream: true,
request_id
};
@ -54,12 +85,120 @@ export async function requestPriceSlots(country: string): Promise<boolean> {
return sent;
}
export async function updatePriceSlot(country: string, content: PriceSlot): Promise<boolean> {
return await sendCommandRequest('sheet', {
export async function refreshPriceSlotList(country: string): Promise<boolean> {
return requestPriceSlotOption(country, 'PriceSlot');
}
export async function updatePriceSlot(
country: string,
slot: PriceSlot,
content: SheetRowUpdate[]
): Promise<boolean> {
// console.log('[sheetService] Sending PriceSlot update:', {
// country,
// slot: slot.slot,
// name: slot.name,
// description: slot.description,
// kind: slot.kind,
// rows: content.length,
// param: 'update/price',
// option: `PriceSlot${slot.slot}`
// });
const sent = await sendCommandRequest('sheet', {
country: country,
content: content,
param: 'update/priceslot'
param: 'update/price',
option: `PriceSlot${slot.slot}`
});
console.log('[sheetService] PriceSlot update sent:', {
country,
slot: slot.slot,
sent
});
return sent;
}
export async function addPriceSlot(
country: string,
slot: PriceSlot,
content: SheetRowCreate[]
): Promise<boolean> {
console.log('[sheetService] Sending PriceSlot create:', {
country,
slot: slot.slot,
name: slot.name,
description: slot.description,
kind: slot.kind,
rows: content.length,
param: 'add/price',
option: `PriceSlot${slot.slot}`
});
const sent = await sendCommandRequest('sheet', {
country: country,
content: content,
param: 'add/price',
option: `PriceSlot${slot.slot}`
});
console.log('[sheetService] PriceSlot create sent:', {
country,
slot: slot.slot,
sent
});
return sent;
}
export async function addPriceSlotRows(
country: string,
slot: PriceSlot,
content: SheetRowCreate[]
): Promise<boolean> {
if (!content || content.length === 0) return true;
const sent = await sendCommandRequest('sheet', {
country: country,
content: content,
param: 'add/price',
option: `PriceSlot${slot.slot}`
});
console.log('[sheetService] PriceSlot rows add sent:', {
country,
slot: slot.slot,
rows: content.length,
sent
});
return sent;
}
export async function deletePriceSlotRows(
country: string,
slot: PriceSlot,
rowIds: number[]
): Promise<boolean> {
if (!rowIds || rowIds.length === 0) return true;
const sent = await sendCommandRequest('sheet', {
country: country,
content: rowIds.map((target_id) => ({ target_id })),
param: 'delete/price',
option: `PriceSlot${slot.slot}`
});
console.log('[sheetService] PriceSlot rows delete sent:', {
country,
slot: slot.slot,
rows: rowIds.length,
sent
});
return sent;
}
export async function enterRoom(country: string, catalog: string): Promise<boolean> {
@ -208,9 +347,13 @@ export async function requestGenLayout(country: string): Promise<boolean> {
* Request price data from sheet for specific product codes
* NOTE: Can only send once per type (price). Use hasSheetPriceBeenSent to check.
*/
export async function requestSheetPrice(country: string, productCodes: string[]): Promise<boolean> {
export async function requestSheetPrice(
country: string,
productCodes: string[],
force = false
): Promise<boolean> {
// Check if already sent
if (hasSheetPriceBeenSent('price')) {
if (!force && hasSheetPriceBeenSent('price')) {
console.warn('[sheetService] Price request already sent, skipping');
return false;
}
@ -252,6 +395,48 @@ export async function requestSheetPrice(country: string, productCodes: string[])
country: country,
content: content,
param: 'price',
option: 'price',
stream: true,
request_id
});
console.log('[sheetService] Sheet price request sent:', { country, request_id, sent });
if (sent) {
markSheetPriceAsSent('price');
} else {
sheetPriceLoading.set(false);
}
return sent;
}
export async function requestAllSheetPrice(country: string, force = false): Promise<boolean> {
if (!force && hasSheetPriceBeenSent('price')) {
console.warn('[sheetService] Price request already sent, skipping');
return false;
}
const request_id = crypto.randomUUID();
streamingRawData.update((data) => ({
...data,
price: {
request_id,
country,
chunks: [],
rawParts: []
}
}));
sheetPriceLoading.set(true);
console.log('[sheetService] Sending all sheet price request:', { country, request_id });
const sent = await sendCommandRequest('sheet', {
country,
content: [],
param: 'price',
option: 'price',
stream: true,
request_id
});

View file

@ -40,6 +40,7 @@ export interface PriceSlot {
}
export const priceSlots = writable<Record<string, PriceSlot[]>>({});
export const priceSlotNamespaces = writable<Record<string, PriceSlot[]>>({});
export const priceSlotsLoading = writable<boolean>(false);
export const priceSlotsError = writable<string | null>(null);
let pendingPriceSlotsCountry = '';
@ -54,6 +55,10 @@ export function resetPriceSlotsCountry(country: string) {
...data,
[key]: []
}));
priceSlotNamespaces.update((data) => ({
...data,
[key]: []
}));
priceSlotsError.set(null);
}
@ -150,12 +155,22 @@ function normalizePriceSlot(slot: any, index: number): PriceSlot {
};
}
export function handlePriceSlotsResponse(content: any) {
console.log('[PriceSlot] Raw backend response:', content);
const country = String(
content?.country ?? content?.Country ?? pendingPriceSlotsCountry
).toLowerCase();
const source =
function normalizePriceSlotNamespace(sheetName: string, index: number): PriceSlot {
const slotNumber = Number(sheetName.match(/\d+/)?.[0] ?? index + 1);
const slot = Number.isNaN(slotNumber) ? index + 1 : slotNumber;
return {
slot,
name: sheetName || `PriceSlot${slot}`,
description: '',
kind: 'price',
header: [],
products: []
};
}
function getPriceSlotSource(content: any) {
return (
content?.priceSlots ??
content?.priceslots ??
content?.price_slots ??
@ -163,31 +178,70 @@ export function handlePriceSlotsResponse(content: any) {
content?.data ??
content?.value ??
content?.content ??
content;
const slotList = Array.isArray(source)
? source
: typeof source === 'object' && source
? Object.entries(source).map(([key, value]) => ({
...(typeof value === 'object' && value ? value : {}),
name: (value as any)?.name ?? key
}))
: [];
content
);
}
function getPriceSlotItems(content: any): any[] {
const source = getPriceSlotSource(content);
if (Array.isArray(source)) {
return source.flatMap((item) => {
if (Array.isArray(item?.sheet)) {
return item.sheet.map((sheetName: any, index: number) =>
normalizePriceSlotNamespace(String(sheetName ?? ''), index)
);
}
return [item];
});
}
if (Array.isArray(source?.sheet)) {
return source.sheet.map((sheetName: any, index: number) =>
normalizePriceSlotNamespace(String(sheetName ?? ''), index)
);
}
if (typeof source?.sheet === 'string' && source.sheet.startsWith('PriceSlot')) return [source];
if (typeof source === 'object' && source) {
return Object.entries(source).map(([key, value]) => ({
...(typeof value === 'object' && value ? value : {}),
name: (value as any)?.name ?? key
}));
}
return [];
}
export function handlePriceSlotsResponse(content: any) {
console.log('[PriceSlot] Raw backend response:', {
items: Array.isArray(content) ? content.length : undefined,
keys:
content && typeof content === 'object' && !Array.isArray(content) ? Object.keys(content) : []
});
const country = String(
content?.country ?? content?.Country ?? pendingPriceSlotsCountry
).toLowerCase();
const source = getPriceSlotSource(content);
const slotList = getPriceSlotItems(content);
if (!country || slotList.length === 0) {
console.warn('[PriceSlot] No slot list found:', { country, source, content });
console.warn('[PriceSlot] No slot list found:', {
country,
sourceItems: Array.isArray(source) ? source.length : undefined
});
priceSlotsError.set('No PriceSlot data found in backend response');
priceSlotsLoading.set(false);
return;
}
const normalizedSlots = slotList
.map(normalizePriceSlot)
.filter((slot) =>
slot.kind === 'service' ? (slot.serviceRows?.length ?? 0) > 0 : slot.products.length > 0
);
const normalizedSlots = slotList.map((slot, index) =>
isPriceSlotNamespace(slot) ? slot : normalizePriceSlot(slot, index)
);
if (normalizedSlots.length === 0) {
console.warn('[PriceSlot] Response did not include usable rows:', { country, slotList });
console.warn('[PriceSlot] Response did not include usable rows:', {
country,
slotListItems: slotList.length
});
return;
}
@ -195,15 +249,41 @@ export function handlePriceSlotsResponse(content: any) {
country,
slots: normalizedSlots.length,
firstSlot: normalizedSlots[0]
? {
slot: normalizedSlots[0].slot,
name: normalizedSlots[0].name,
kind: normalizedSlots[0].kind,
products: normalizedSlots[0].products.length,
serviceRows: normalizedSlots[0].serviceRows?.length ?? 0
}
: undefined
});
priceSlots.update((data) => {
const merged = new Map<string, PriceSlot>();
const loadedSlots = normalizedSlots.filter((slot) => !isPriceSlotNamespace(slot as any));
if (loadedSlots.length > 0) {
priceSlots.update((data) => {
const merged = new Map<number, PriceSlot>();
for (const slot of data[country] ?? []) {
merged.set(slot.slot, slot);
}
for (const slot of loadedSlots) {
merged.set(slot.slot, slot);
}
return {
...data,
[country]: Array.from(merged.values()).sort((a, b) => a.slot - b.slot)
};
});
}
priceSlotNamespaces.update((data) => {
const merged = new Map<number, PriceSlot>();
for (const slot of data[country] ?? []) {
merged.set(`${slot.slot}:${slot.name}`, slot);
merged.set(slot.slot, slot);
}
for (const slot of normalizedSlots) {
merged.set(`${slot.slot}:${slot.name}`, slot);
merged.set(slot.slot, slot);
}
return {
@ -216,19 +296,31 @@ export function handlePriceSlotsResponse(content: any) {
}
export function isPriceSlotsPayload(content: any): boolean {
const source =
content?.priceSlots ??
content?.priceslots ??
content?.price_slots ??
content?.slots ??
content?.data ??
content?.value ??
content?.content ??
content;
const source = getPriceSlotSource(content);
if (content?.param === 'priceslot' || content?.option === 'PriceSlot') return true;
if (Array.isArray(source?.sheet)) {
return source.sheet.some((sheetName: any) => String(sheetName ?? '').startsWith('PriceSlot'));
}
if (typeof source?.sheet === 'string') return source.sheet.startsWith('PriceSlot');
if (!Array.isArray(source)) return false;
return source.some((item) => String(item?.sheet ?? item?.Sheet ?? '').startsWith('PriceSlot'));
return source.some(
(item) =>
String(item?.sheet ?? item?.Sheet ?? '').startsWith('PriceSlot') ||
(Array.isArray(item?.sheet) &&
item.sheet.some((sheetName: any) => String(sheetName ?? '').startsWith('PriceSlot')))
);
}
function isPriceSlotNamespace(slot: any): slot is PriceSlot {
return (
typeof slot?.slot === 'number' &&
Array.isArray(slot?.products) &&
slot.products.length === 0 &&
Array.isArray(slot?.header) &&
slot.header.length === 0 &&
slot.name?.startsWith?.('PriceSlot')
);
}
export const countryPrimaryLanguageMap: Record<string, string> = {
@ -277,7 +369,12 @@ export function getCountryPrimaryLanguage(countryCode: string): string {
export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record<
string,
{
// Column→language map for the new-layout-v2 sheet (menu name/desc rows).
language: Record<string, number>;
// Column→language map for the name-desc-v2 sheet (Translations). Different
// namespace/sheet so the columns can differ from new-layout-v2; falls back
// to `language` when not set (countries where the two are identical).
nameDescLanguage?: Record<string, number>;
productCode: { hot: number; cold: number; blend: number };
primaryLanguage: string;
}
@ -289,6 +386,7 @@ export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record<
},
aus: {
language: { en: 3, th: 4 },
nameDescLanguage: { en: 3, th: 4, ms: 7 },
productCode: { hot: 9, cold: 10, blend: 11 },
primaryLanguage: 'en'
},
@ -299,11 +397,13 @@ export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record<
},
hkg: {
language: { en: 3, zh_hans: 4, zh_hant: 5, th: 6 },
nameDescLanguage: { en: 3, zh_hans: 4, zh_hant: 5 },
productCode: { hot: 9, cold: 10, blend: 11 },
primaryLanguage: 'zh_hant'
},
ltu: {
language: { en: 3, th: 4, lt: 5, ro: 6 },
nameDescLanguage: { en: 3, lt: 5, ro: 6 },
productCode: { hot: 9, cold: 10, blend: 11 },
primaryLanguage: 'lt'
},
@ -329,6 +429,7 @@ export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record<
},
sgp: {
language: { en: 3, th: 4 },
nameDescLanguage: { en: 3 },
productCode: { hot: 9, cold: 10, blend: 11 },
primaryLanguage: 'en'
},
@ -596,10 +697,22 @@ export function getPriceFromCells(
cells: GristCell[],
priceType: 'cash_price' | 'non_cash_price' = 'cash_price'
): string | null {
const colIdx = getPriceColumnIndex(country, priceType);
if (colIdx < 0) return null;
// Find the cell with matching column index
const priceCell = cells.find((c) => c.coord?.col === colIdx);
return priceCell?.value ?? null;
}
export function getPriceColumnIndex(
country: string,
priceType: 'cash_price' | 'non_cash_price' = 'cash_price'
): number {
const headers = get(sheetPriceHeader)[country];
if (!headers || headers.length === 0) {
console.warn(`[getPriceFromCells] No header found for country: ${country}`);
return null;
return -1;
}
// Get possible header names for this country
@ -617,13 +730,10 @@ export function getPriceFromCells(
`[getPriceFromCells] No ${priceType} column found for ${country}, tried:`,
possibleNames
);
return null;
return -1;
}
// Find the cell with matching column index
const priceCell = cells.find((c) => c.coord?.col === colIdx);
//console.log(`[getPriceFromCells] Found cell for col ${colIdx}:`, priceCell);
return priceCell?.value ?? null;
return colIdx;
}
// Store for tracking streaming state
@ -790,11 +900,16 @@ export function handleRawStreamEnd(subtype: string, payload: any) {
if (targetSubtype === 'priceslot' && isPriceSlotsPayload({ slots: chunks })) {
handlePriceSlotsResponse({ country, slots: chunks });
}
if (targetSubtype === 'priceslot') {
priceSlotsLoading.set(false);
}
if (targetSubtype === 'price') {
const looksLikePriceSlot = chunks.some((item) => {
return (
String(item?.sheet ?? item?.Sheet ?? '').startsWith('PriceSlot') ||
(Array.isArray(item?.sheet) &&
item.sheet.some((sheetName: any) => String(sheetName ?? '').startsWith('PriceSlot'))) ||
item?.option === 'PriceSlot' ||
item?.param === 'priceslot'
);
@ -970,6 +1085,13 @@ function processSheetPriceData(country: string, header: string[], chunks: any[])
}
}
export function handleSheetPriceResponse(country: string, content: any) {
const resolvedCountry = country || get(streamingRawData).price?.country || '';
const chunks = Array.isArray(content) ? content : [content];
processSheetPriceData(resolvedCountry.toLowerCase(), [], chunks);
sheetPriceLoading.set(false);
}
// Reset sheet price stores
export function resetSheetPriceStore() {
sheetPriceStreamMeta.set(null);

View file

@ -18,6 +18,7 @@ const ENABLE_WS_DEBUG: boolean = false;
export const socketConnectionOfflineCount = writable<number>(0);
export const socketAlreadySendHeartbeat = writable<number>(0);
export const socketStore = writable<WebSocket | null>(null);
export const wsAuthReady = writable<boolean>(false);
export const sharedKey = writable<CryptoKey | null>(null);
@ -53,6 +54,31 @@ export function waitForOpenSocket(timeoutMs = 8000): Promise<WebSocket | null> {
});
}
export async function waitForAuthenticatedSocket(timeoutMs = 10000): Promise<WebSocket | null> {
const openSocket = await waitForOpenSocket(timeoutMs);
if (!openSocket) return null;
if (get(wsAuthReady)) return openSocket;
return new Promise((resolve) => {
let settled = false;
let unsubscribe = () => {};
const timeout = setTimeout(() => {
if (settled) return;
settled = true;
unsubscribe();
resolve(null);
}, timeoutMs);
unsubscribe = wsAuthReady.subscribe((ready) => {
if (!ready || settled) return;
settled = true;
clearTimeout(timeout);
unsubscribe();
resolve(openSocket);
});
});
}
export async function connectToWebsocket(id_token?: string) {
if (browser) {
// console.log('connecting to ', env.PUBLIC_WSS);
@ -63,6 +89,7 @@ export async function connectToWebsocket(id_token?: string) {
let ws_url = env.PUBLIC_WSS;
socket = new WebSocket(ws_url);
wsAuthReady.set(false);
sharedKey.set(null);
const { privateKey, publicKeyBase64 } = await WebCryptoHelper.generateKeyPair();
@ -87,13 +114,16 @@ export async function connectToWebsocket(id_token?: string) {
sendAuthInfoInterval = setInterval(async () => {
if (get(sharedKey)) {
auth_data = get(authStore);
perms = get(permission);
// Debug: check if auth_data has uid
console.log('[WS Auth] Sending auth info with:', {
uid: auth_data?.uid,
name: auth_data?.displayName,
email: auth_data?.email
email: auth_data?.email,
date: new Date()
});
await sendMessage({
const sent = await sendMessage({
type: 'auth',
payload: {
user: {
@ -104,9 +134,10 @@ export async function connectToWebsocket(id_token?: string) {
}
}
});
wsAuthReady.set(sent);
clearInterval(sendAuthInfoInterval);
}
}, 3000);
}, 2000);
}
console.log(socket);
@ -159,10 +190,12 @@ export async function connectToWebsocket(id_token?: string) {
socket.addEventListener('close', () => {
socketStore.set(null);
wsAuthReady.set(false);
sharedKey.set(null);
socket = null;
clearInterval(socketCheck);
clearInterval(sendAuthInfoInterval);
if (auth.currentUser && !socket) {
console.log('try reconnect websocket ...');
@ -177,6 +210,7 @@ export async function connectToWebsocket(id_token?: string) {
socket.addEventListener('error', (e) => {
// console.log('WebSocket error: ', e);
socketStore.set(null);
wsAuthReady.set(false);
sharedKey.set(null);
});
} catch (socket_error: any) {

View file

@ -1,4 +1,14 @@
export class WebCryptoHelper {
private static bytesToBase64(bytes: Uint8Array) {
const chunkSize = 0x8000;
let binary = '';
for (let i = 0; i < bytes.length; i += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
}
return btoa(binary);
}
static async generateKeyPair() {
const keyPair = await window.crypto.subtle.generateKey(
{
@ -10,7 +20,7 @@ export class WebCryptoHelper {
);
const exportedPublic = await window.crypto.subtle.exportKey('raw', keyPair.publicKey);
const publicKeyBase64 = btoa(String.fromCharCode(...new Uint8Array(exportedPublic)));
const publicKeyBase64 = WebCryptoHelper.bytesToBase64(new Uint8Array(exportedPublic));
return { privateKey: keyPair.privateKey, publicKeyBase64 };
}
@ -60,8 +70,8 @@ export class WebCryptoHelper {
encodedText
);
const ciphertextBase64 = btoa(String.fromCharCode(...new Uint8Array(ciphertextBuffer)));
const ivBase64 = btoa(String.fromCharCode(...iv));
const ciphertextBase64 = WebCryptoHelper.bytesToBase64(new Uint8Array(ciphertextBuffer));
const ivBase64 = WebCryptoHelper.bytesToBase64(iv);
return { ciphertext: ciphertextBase64, iv: ivBase64 };
}