This commit is contained in:
pakintada@gmail.com 2026-02-17 14:30:02 +07:00
commit 451223816b
338 changed files with 9938 additions and 0 deletions

184
src/lib/core/adb/adb.ts Normal file
View file

@ -0,0 +1,184 @@
import { Adb, AdbDaemonTransport, encodeUtf8 } from '@yume-chan/adb';
import AdbWebCredentialStore from '@yume-chan/adb-credential-web';
import {
AdbDaemonWebUsbDeviceManager,
AdbDaemonWebUsbDeviceObserver,
type AdbDaemonWebUsbDevice
} from '@yume-chan/adb-daemon-webusb';
import { AdbInstance } from '../../../routes/state.svelte';
import { deviceCredentialManager } from './deviceCredManager';
import { Consumable, MaybeConsumable, ReadableStream } from '@yume-chan/stream-extra';
import { AdbScrcpyClient } from '@yume-chan/adb-scrcpy';
export async function connnectViaWebUSB() {
const device = await AdbDaemonWebUsbDeviceManager.BROWSER?.requestDevice();
console.log('usb ok', globalThis.navigator.usb);
if (device) {
console.log('connect ', device.name);
try {
const credentialStore = new AdbWebCredentialStore();
const connection = await device.connect();
const transport = await AdbDaemonTransport.authenticate({
connection: connection,
serial: device.serial,
credentialStore: credentialStore
});
const adb = new Adb(transport);
saveAdbInstance(adb);
// save device info
await deviceCredentialManager.saveDeviceInfo(device);
} catch (e: any) {
console.error('error on connect', e);
throw new Error(e.toString());
}
}
}
export async function connectDeviceByCred(
device: AdbDaemonWebUsbDevice,
credStore: AdbWebCredentialStore
) {
try {
const connection = await device.connect();
const transport = await AdbDaemonTransport.authenticate({
connection: connection,
serial: device.serial,
credentialStore: credStore
});
const adb = new Adb(transport);
saveAdbInstance(adb);
return true;
} catch (error) {
throw error;
}
}
export function saveAdbInstance(adb: Adb | undefined) {
AdbInstance.instance = adb;
}
export function getAdbInstance() {
return AdbInstance.instance;
}
export async function executeCmd(command: string) {
let instance = getAdbInstance();
if (!instance) {
console.error('instance not found');
return {};
}
try {
if (instance?.subprocess.shellProtocol?.isSupported) {
const result = await instance.subprocess.shellProtocol.spawnWaitText(command);
return {
output: result.stdout,
error: result.stderr,
exitCode: result.exitCode
};
} else {
const process = await instance.subprocess.noneProtocol.spawn(command);
const reader = process.output.getReader();
const chunks = [];
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(decoder.decode(value, { stream: true }));
}
return {
output: chunks.join('')
};
}
} catch (e: any) {
console.log(e.message);
//ExactReadable ended
if (e.message.includes('ExactReadable ended')) {
console.error('connection cut off');
return {
error: 'ExactReadableEndedError'
};
}
console.error('error while execute command', e);
return {};
}
}
export async function disconnect() {
let instance = getAdbInstance();
if (instance) {
await instance.close();
console.log('close instance');
saveAdbInstance(undefined);
}
}
export async function pull(filename: string) {
let instance = getAdbInstance();
if (instance) {
let chunkList: Uint8Array<ArrayBufferLike>[] = [];
let sync = await instance.sync();
const content = sync.read(filename);
let result = content.values();
let res;
let result_string = '';
while ((res = await result.next()) != null) {
// console.log(res.value);
if (res.value != undefined) {
result_string += new TextDecoder().decode(res.value);
}
if (res.done) {
break;
}
}
return result_string;
}
}
export async function push(path: string, obj: string) {
let instance = getAdbInstance();
if (instance) {
let sync = await instance.sync();
const encoder = new TextEncoder();
const file: ReadableStream<MaybeConsumable<Uint8Array>> = new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array(encoder.encode(obj)));
controller.close();
}
});
try {
console.log('support push v2', sync.supportsSendReceiveV2);
await sync.write({
filename: path,
file
});
} catch (error) {
console.log('error while trying to write to machine', error);
} finally {
await sync.dispose();
}
}
}
// logcat stream
// TODO: screen mirror
export function getScrcpyBinaryFromSource() {
//https://github.com/Genymobile/scrcpy/releases
}

View file

@ -0,0 +1,106 @@
import AdbWebCredentialStore from '@yume-chan/adb-credential-web';
export class DeviceCredentialManager {
#credentialStore;
constructor() {
this.#credentialStore = new AdbWebCredentialStore();
}
async saveDeviceInfo(device: any) {
try {
const deviceInfo = {
name: device.name,
serial: device.serial,
vendorId: device.vendorId,
productId: device.productId,
lastConnected: new Date().toISOString()
};
const storedDevices = this.getStoredDeviceInfos();
storedDevices[device.serial] = deviceInfo;
localStorage.setItem('adb_device_infos', JSON.stringify(storedDevices));
console.log('save device info', deviceInfo);
} catch (error) {
console.error('save device info error', error);
}
}
getStoredDeviceInfos() {
try {
const stored = localStorage.getItem('adb_device_infos');
return stored ? JSON.parse(stored) : {};
} catch (error) {
console.error('unable to get stored device info', error);
return {};
}
}
async hasStoredKeys() {
try {
for await (const key of this.#credentialStore.iterateKeys()) {
return true;
}
return false;
} catch (error) {
console.error('check stored keys fail', error);
return false;
}
}
async getStoredKeyCount() {
try {
let count = 0;
for await (const key of this.#credentialStore.iterateKeys()) {
count++;
}
return count;
} catch (error) {
console.error('get key stored count error', error);
return 0;
}
}
async clearAllCredentials() {
try {
let clearedCount = 0;
const keys = [];
for await (const key of this.#credentialStore.iterateKeys()) {
keys.push(key);
}
for (const key of keys) {
try {
clearedCount++;
} catch (error) {
console.error('clear error', error);
}
}
localStorage.removeItem('adb_device_infos');
try {
const dbName = 'webadb-credentials';
const request = indexedDB.deleteDatabase(dbName);
await new Promise((resolve, reject) => {
request.onsuccess = () => resolve(null);
request.onerror = () => reject(request.error);
request.onblocked = () => {
console.warn('request delete got blocked');
resolve(null);
};
});
} catch (error) {
console.error(error);
}
return clearedCount;
} catch (error) {
console.error(error);
return 0;
}
}
}
export const deviceCredentialManager = new DeviceCredentialManager();

View file

@ -0,0 +1,17 @@
import { doc, getDoc } from "firebase/firestore";
import { db } from "../client/firebase";
export async function checkAllowAccess(userDomain: string): Promise<boolean> {
const docRef = doc(db, "whitelist", "allowedDomains");
const snapshot = await getDoc(docRef);
if(snapshot.exists()){
let domains = snapshot.data();
// console.log(`domains: ${JSON.stringify(domains)}`);
return domains["account_email"].includes(userDomain);
}
return false;
};

View file

@ -0,0 +1,90 @@
import type { User } from "firebase/auth";
import { addDoc, collection, doc, getDoc, setDoc, updateDoc } from "firebase/firestore";
import { db } from "../client/firebase";
export enum UserPermissions {
NO_PERMISSION,
THAI_PERMISSION = 1 << 0,
MALAY_PERMISSION = 1 << 1,
AUS_PERMISSION = 1 << 2,
ALPHA3_PERMISSION = 1 << 3,
VIEWER = 1 << 4,
EDITOR = 1 << 7,
DUBAI_PERMISSION = 1 << 8,
COUNTER_PERMISSION = 1 << 9,
SINGAPORE_PERMISSION = 1 << 10,
COCKTAIL_PERMISSION = 1 << 11,
// add new permission by shifting after 7. eg. 8,9,...
// also do add at server
SUPER_ADMIN_PERMISSION = THAI_PERMISSION |
MALAY_PERMISSION |
AUS_PERMISSION |
ALPHA3_PERMISSION |
COUNTER_PERMISSION |
SINGAPORE_PERMISSION |
DUBAI_PERMISSION |
COCKTAIL_PERMISSION |
(EDITOR | VIEWER),
}
export function getPermissions(perms: number): UserPermissions[] {
return Object.values(UserPermissions).filter(
(permission) =>
typeof permission === "number" && (perms & permission) !== 0,
) as UserPermissions[];
}
export function getDefaultPermission(): UserPermissions {
return UserPermissions.NO_PERMISSION;
}
export async function getUserPermission(user: User | null): Promise<string[]> {
if(user == null){
return [];
}
let qid = user.uid;
let defaultPerms = ["no_permission"];
// TODO: collect only important fields
const ignoredFields = [
"apiKey",
];
const docRef = doc(db, "users", "data");
const snapshot = await getDoc(docRef);
if(snapshot.exists()){
let user_data = snapshot.data();
if(Object.keys(user_data).includes(qid)){
return user_data[qid]["permissions"];
} else {
let umap: any = user.toJSON();
umap["permissions"] = defaultPerms;
umap["role"] = "guest";
for(let ignoredField of ignoredFields){
umap[ignoredField] = undefined;
}
let cleaned_umap: any = {};
for(let k of Object.keys(umap)){
if(umap[k] != undefined){
cleaned_umap[k] = umap[k];
}
}
let fmap: any = {};
fmap[qid] = cleaned_umap;
await updateDoc(doc(db, "users", "data"), fmap);
return defaultPerms;
}
}
return [];
}

View file

@ -0,0 +1,38 @@
import { get } from 'svelte/store';
import { departmentStore } from '../stores/departments';
import { sendMessage } from '../handlers/ws_messageSender';
import { auth } from '../stores/auth';
import { extractCookieOnNonBrowser } from '$lib/helpers/cookie';
import { browser } from '$app/environment';
export async function getRecipes() {
if (browser && !get(departmentStore)) {
console.log('cannot get dep', get(departmentStore));
return [];
}
let countryTarget = get(departmentStore);
let country = '';
// if (!countryTarget && !browser) {
// countryTarget = extractCookieOnNonBrowser()['department'];
// }
// construct path. fetch (GET) {server}/recipe/{countryTarget}/{version}
let idToken = await get(auth)?.getIdToken();
console.log('country target get recipe', country);
sendMessage({
type: 'recipe',
payload: {
auth: idToken ?? '',
partial: false,
country: countryTarget ?? '',
version: -1,
parameters: ''
}
});
return [];
}

View file

@ -0,0 +1,98 @@
import { get, writable } from 'svelte/store';
import { addNotification, notiStore } from '../stores/noti';
import {
recipeData,
recipeDataError,
recipeLoading,
recipeOverviewData,
recipeStreamMeta
} from '../stores/recipeStore';
export const messages = writable<string[]>([]);
type WSMessage = { type: string; payload: any };
const handlers: Record<string, (payload: any) => void> = {
chat: (p) => messages.update((m) => [...m, p]),
ping: (p) => console.log('ping from server'),
recipeResponse: (p) => {
let recipe_result = p.result;
let recipe_request = p.request;
if (recipe_result) {
addNotification('INFO:Start fetch recipe!');
}
},
stream_data_start: (p) => {
let stream_id = p.stream_id;
let total_size = p.total_size;
let chunk_size = p.chunk_size;
if (stream_id) {
addNotification('INFO:Start streaming data');
recipeLoading.set(true);
recipeStreamMeta.set({
id: stream_id,
total_size: total_size,
chunk_size: chunk_size,
progress: 0
});
recipeData.set([]);
recipeOverviewData.set([]);
}
},
stream_data_error: (p) => {
recipeLoading.set(false);
recipeDataError.set(p);
setTimeout(() => {
addNotification(`ERROR:${p.error}`);
}, 2000);
},
stream_data_chunk: (p) => {
let current_meta = get(recipeStreamMeta);
if (current_meta) {
let stream_id = current_meta.id;
let progress_response_id = p.stream_id;
if (stream_id === progress_response_id) {
let current_response_end = p.start_idx + current_meta.chunk_size;
let percent = (current_response_end / current_meta.total_size) * 100;
if (percent > 100) {
percent = 100;
}
let data = p.data;
let currentData = get(recipeData);
for (let rp of data) {
currentData.push(rp);
}
recipeData.set(currentData);
recipeStreamMeta.set({
...current_meta,
progress: percent
});
// build overview
if (percent == 100) {
addNotification(`INFO:Current progress ${percent}%`);
}
}
}
},
stream_data_end: (p) => {
recipeLoading.set(false);
},
stream_patch_update: (p) => {}
};
export function handleIncomingMessages(raw: string) {
const msg: WSMessage = JSON.parse(raw);
console.log(`${new Date().toLocaleTimeString()}:ws msg`, msg);
if (msg == null) {
// error response
addNotification('ERR:No response from server');
return;
}
handlers[msg.type]?.(msg.payload);
}

View file

@ -0,0 +1,41 @@
import { get } from "svelte/store";
import { permission as currentPermissions } from "$lib/core/stores/permissions";
const splitPermCache = new Map<string, string[]>();
function splitPerm(p: string): string[]{
if(!splitPermCache.has(p)){
splitPermCache.set(p, p.split("."));
}
return splitPermCache.get(p)!;
}
/// Check if current user has exacted permissions
export function requirePermission(...permissions: string[]): boolean {
// let perms = get(currentPermissions);
// let countOk = 0;
// for(let perm of perms){
// if(permissions.includes(perm)){
// countOk += 1;
// }
// }
// return countOk > 0 && countOk == perms.length;
const userPerms = get(currentPermissions);
return permissions.every(req => {
return userPerms.includes(req);
});
}
/// Check permission of user by
export function needPermission(...permissions: string[]): boolean {
const userPerms = get(currentPermissions).map(p => splitPerm(p));
return permissions.every(req => {
const reqParts = splitPerm(req);
return userPerms.some(userParts => {
if(userParts.length !== reqParts.length) return false;
return reqParts.every((part, i) => part === "*" || part === userParts[i]);
});
});
}

View file

@ -0,0 +1,26 @@
import { get, writable } from 'svelte/store';
import type { OutMessage } from '../types/outMessage';
import { socketStore } from '../stores/websocketStore';
import { addNotification } from '../stores/noti';
export const queue = writable<string[]>([]);
export function sendMessage(msg: OutMessage): boolean {
const socket = get(socketStore);
const data = JSON.stringify(msg);
if (!socket || socket.readyState !== WebSocket.OPEN) {
console.warn('WebSocket not connected, put to queue');
let currentQueue = get(queue);
currentQueue.push(data);
queue.set(currentQueue);
addNotification('WARN:Queuing overview view request');
return false;
}
socket.send(data);
return true;
}

View file

@ -0,0 +1,9 @@
import type { User } from "firebase/auth";
import { writable } from "svelte/store";
// type User = {
// uid: string,
// email: string,
// };
export const auth = writable<User | null>(null);

View file

@ -0,0 +1,3 @@
import { writable } from 'svelte/store';
export const departmentStore = writable<string | undefined>();

View file

@ -0,0 +1 @@
/// save files' content

View file

@ -0,0 +1,4 @@
import type { MachineInfo } from '$lib/models/machineInfo.model';
import { writable } from 'svelte/store';
export const machineInfoStore = writable<MachineInfo | undefined>();

View file

@ -0,0 +1,41 @@
import { toast } from 'svelte-sonner';
import { get, writable } from 'svelte/store';
// save notifications to user
export const notiStore = writable<string[]>([]);
export function addNotification(msg: string) {
let current = get(notiStore);
current.push(msg);
notiStore.set(current);
}
export function getNotification() {
let current = get(notiStore);
let first = current.shift();
if (first) {
let msg_p = first.split(':');
let msg_level_type = msg_p[0];
let msg = msg_p[1];
switch (msg_level_type) {
case 'ERR':
toast.error('Error', {
description: msg
});
break;
case 'WARN':
toast.warning('Warning', {
description: msg
});
default:
toast(msg);
}
}
notiStore.set(current);
}
setInterval(() => {
getNotification();
}, 100);

View file

@ -0,0 +1,4 @@
import { writable } from "svelte/store";
// blocking views by permission of user
export const permission = writable<string[]>([]);

View file

@ -0,0 +1,67 @@
import { writable } from 'svelte/store';
import type { RecipeOverview } from '../../../routes/(authed)/recipe/overview/columns';
import type { Material } from '$lib/models/material.model';
export const recipeData = writable<any>(null);
export const recipeLoading = writable(false);
export const recipeDataError = writable<string | null>(null);
export const recipeStreamMeta = writable<{
id: string;
total_size: number;
chunk_size: number;
progress: number;
} | null>(null);
// from server
export const recipeOverviewData = writable<RecipeOverview[] | null>(null);
export const materialData = writable<Material | undefined>();
// machine recipe
export const recipeFromMachine = writable<any>(null);
export const recipeFromMachineLoading = writable(false);
export const recipeFromMachineError = writable<string | null>(null);
// NOTE: must not have any nested structures
// { recipe: {}, materials: {}, toppings: { groups: {}, lists: {} } }
export const recipeFromMachineQuery = writable<any>({});
export const materialFromMachineQuery = writable<any>({});
export const referenceFromPage = writable<string>('');
let worker: Worker | null = null;
let initialized = false;
export function loadRecipe(url: string) {
if (initialized) return;
initialized = true;
recipeLoading.set(true);
worker = new Worker(new URL('../../workers/data.worker.ts', import.meta.url), {
type: 'module'
});
worker.onmessage = (e) => {
const { type, payload } = e.data;
if (type === 'data') {
recipeData.set(payload);
recipeLoading.set(false);
}
if (type === 'error') {
recipeDataError.set(payload);
recipeLoading.set(false);
}
};
worker.postMessage({ url });
}
export function getWorker() {
if (!worker) {
worker = new Worker(new URL('../../workers/data.worker.ts', import.meta.url), {
type: 'module'
});
}
return worker;
}

View file

@ -0,0 +1,3 @@
import { writable } from 'svelte/store';
export const sidebarStore = writable<boolean>(true);

View file

@ -0,0 +1,46 @@
import { browser } from '$app/environment';
import { env } from '$env/dynamic/public';
import { get, writable } from 'svelte/store';
import { handleIncomingMessages } from '../handlers/messageHandler';
import { queue as msgQueue } from '../handlers/ws_messageSender';
export const socketStore = writable<WebSocket | null>(null, (set) => {
if (browser) {
console.log('connecting to ', env.PUBLIC_WSS);
const socket = new WebSocket(`${env.PUBLIC_WSS}`);
socket.addEventListener('open', () => {
set(socket);
// recover messages on connect, flushing
while (get(msgQueue).length) {
let queue = get(msgQueue);
let current = queue.shift();
if (current) {
socket.send(current);
// set next
msgQueue.set(queue);
}
}
});
socket.addEventListener('message', (event) => {
handleIncomingMessages(event.data);
});
socket.addEventListener('close', () => {
set(null);
});
socket.addEventListener('error', (e) => {
console.log('WebSocket error: ', e);
set(null);
});
return () => {
if (socket.readyState === WebSocket.OPEN) {
socket.close();
}
};
}
});

View file

@ -0,0 +1,24 @@
enum MenuStatus {
ready,
obsolete = 2,
pendingOnline = 11,
pendingOffline,
drafted = 99
}
function matchMenuStatus(status: number): MenuStatus {
switch (status) {
case 0:
return MenuStatus.ready;
case 2:
return MenuStatus.obsolete;
case 11:
return MenuStatus.pendingOnline;
case 12:
return MenuStatus.pendingOffline;
default:
return MenuStatus.drafted;
}
}
export { MenuStatus, matchMenuStatus };

View file

@ -0,0 +1,25 @@
export type OutMessage =
| { type: 'chat'; payload: string }
| { type: 'ping' }
| { type: 'lock'; payload: { field: string } }
| { type: 'general'; payload: string }
| {
type: 'recipe';
payload: {
auth: string;
partial: boolean;
country: string;
version: number;
parameters: string;
};
}
| {
type: 'auth';
payload: {
user: {
name: string;
email: string;
permissions: string;
};
};
};