web_lib

Common web application libraries
git clone https://radroots.dev/git/web_lib.git
Log | Files | Refs | LICENSE

commit 878eff9856c31a382e8b39681ea2320f9f17f50e
parent cf4732f1d168705f9a50b44fa03fbdc23f6fb8e3
Author: triesap <triesap@radroots.dev>
Date:   Tue, 18 Nov 2025 00:12:13 +0000

client: add browser clients for datastore, filesystem, http, geolocation, notifications, keystore and sql with encrypted indexeddb, removing tauri adapters and unifying the module build

Diffstat:
Mclient/.gitignore | 58++++++++++++++++++++++++++++++++--------------------------
Dclient/README.md | 1-
Dclient/index.ts | 1-
Mclient/package.json | 41++++++++++++++++++++++++-----------------
Dclient/src/database/tauri/lib.ts | 595-------------------------------------------------------------------------------
Dclient/src/database/tauri/types.ts | 45---------------------------------------------
Aclient/src/datastore/index.ts | 3+++
Dclient/src/datastore/tauri.ts | 78------------------------------------------------------------------------------
Aclient/src/datastore/types.ts | 35+++++++++++++++++++++++++++++++++++
Aclient/src/datastore/web.ts | 193+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aclient/src/fs/index.ts | 3+++
Dclient/src/fs/tauri.ts | 24------------------------
Mclient/src/fs/types.ts | 29+++++++++++++++--------------
Aclient/src/fs/web.ts | 38++++++++++++++++++++++++++++++++++++++
Aclient/src/geolocation/index.ts | 3+++
Dclient/src/geolocation/tauri.ts | 52----------------------------------------------------
Aclient/src/geolocation/types.ts | 12++++++++++++
Aclient/src/geolocation/web.ts | 22++++++++++++++++++++++
Dclient/src/gui/tauri.ts | 64----------------------------------------------------------------
Dclient/src/haptics/tauri.ts | 33---------------------------------
Dclient/src/haptics/types.ts | 12------------
Aclient/src/http/index.ts | 3+++
Dclient/src/http/tauri.ts | 69---------------------------------------------------------------------
Mclient/src/http/types.ts | 9++++-----
Aclient/src/http/web.ts | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mclient/src/index.ts | 26++++++++------------------
Dclient/src/keys/tauri.ts | 79-------------------------------------------------------------------------------
Dclient/src/keys/types.ts | 26--------------------------
Aclient/src/keystore/aes-gcm-cipher.ts | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aclient/src/keystore/index.ts | 4++++
Aclient/src/keystore/types.ts | 12++++++++++++
Aclient/src/keystore/web.ts | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dclient/src/lib.ts | 3---
Aclient/src/notifications/index.ts | 3+++
Aclient/src/notifications/types.ts | 39+++++++++++++++++++++++++++++++++++++++
Aclient/src/notifications/web.ts | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aclient/src/radroots/index.ts | 3+++
Dclient/src/radroots/tauri.ts | 105-------------------------------------------------------------------------------
Mclient/src/radroots/types.ts | 13+++++++------
Aclient/src/radroots/web.ts | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aclient/src/sql/index.ts | 3+++
Aclient/src/sql/types.ts | 31+++++++++++++++++++++++++++++++
Aclient/src/sql/web.ts | 134+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dclient/src/types.ts | 4----
Dclient/src/util.ts | 14--------------
Aclient/src/utils/idb.ts | 5+++++
Aclient/tsconfig.cjs.json | 16++++++++++++++++
Aclient/tsconfig.esm.json | 14++++++++++++++
Mclient/tsconfig.json | 25++-----------------------
49 files changed, 1084 insertions(+), 1314 deletions(-)

diff --git a/client/.gitignore b/client/.gitignore @@ -1,36 +1,42 @@ -# dependencies node_modules -.pnp -.pnp.js +dist +.turbo -# testing -coverage +# Logs +logs/ +*.log -# svelte -.svelte-kit +# Env +.env +.env.* +!.env.example +!.env.test -# misc +# OS .DS_Store +Thumbs.db + +# Secrets *.pem +*.crt +*.key -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* +# Testing +test*.json -# local env files -.env.local -.env.development.local -.env.test.local -.env.production.local +# Editors +.vscode/ +.idea/ +*.iml -# turbo -.turbo - -.tmp* -.vscode +# Notes notes*.txt notes*.md -justfile -dist -git-diff.txt -\ No newline at end of file +notes*.json +tree*.txt +diff*.txt +prompt*.txt + +# Dev +.local* +justfile +\ No newline at end of file diff --git a/client/README.md b/client/README.md @@ -1 +0,0 @@ -# client diff --git a/client/index.ts b/client/index.ts @@ -1 +0,0 @@ -export * from "./src"; diff --git a/client/package.json b/client/package.json @@ -1,28 +1,35 @@ { "name": "@radroots/client", - "version": "0.0.0", + "version": "0.0.1", "private": true, "license": "GPLv3", "type": "module", - "module": "index.ts", + "main": "./dist/cjs/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/types/index.d.ts", + "exports": { + ".": { + "types": "./dist/types/index.d.ts", + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.js" + } + }, "scripts": { - "build": "tsc", - "dev": "tsc -w" + "build:esm": "tsc -p tsconfig.esm.json", + "build:cjs": "tsc -p tsconfig.cjs.json", + "build": "npm run clean && npm run build:esm && npm run build:cjs", + "prebuild": "npm run clean", + "clean": "rimraf dist", + "dev": "npm run watch", + "watch": "tsc -w" }, "dependencies": { - "@radroots/models": "workspace:*", - "@radroots/nostr-util": "workspace:*", - "@radroots/util": "workspace:*", - "@tauri-apps/api": "^2.5.0", - "@tauri-apps/plugin-dialog": "workspace:*", - "@tauri-apps/plugin-fs": "workspace:*", - "@tauri-apps/plugin-geolocation": "workspace:*", - "@tauri-apps/plugin-haptics": "workspace:*", - "@tauri-apps/plugin-http": "workspace:*", - "@tauri-apps/plugin-log": "workspace:*", - "@tauri-apps/plugin-notification": "workspace:*", - "@tauri-apps/plugin-os": "workspace:*", - "@tauri-apps/plugin-store": "workspace:*" + "@radroots/models": "*", + "@radroots/utils": "*", + "@radroots/utils-nostr": "*", + "idb": "^8.0.3", + "idb-keyval": "^6.2.1", + "sql.js": "^1.13.0" }, "devDependencies": { "@types/debug": "^4.1.12", diff --git a/client/src/database/tauri/lib.ts b/client/src/database/tauri/lib.ts @@ -1,594 +0,0 @@ - -import { farm_parse, farm_parse_list, farm_parse_select_query, farm_parse_select_query_list, farm_parse_update_query, farm_validate_form_fields, farm_validate_update_form_fields, location_gcs_parse, location_gcs_parse_list, location_gcs_parse_select_query, location_gcs_parse_select_query_list, location_gcs_parse_update_query, location_gcs_validate_form_fields, location_gcs_validate_update_form_fields, log_error_parse, log_error_parse_list, log_error_parse_select_query, log_error_parse_select_query_list, log_error_parse_update_query, log_error_validate_form_fields, log_error_validate_update_form_fields, media_image_parse, media_image_parse_list, media_image_parse_select_query, media_image_parse_select_query_list, media_image_parse_update_query, media_image_validate_form_fields, media_image_validate_update_form_fields, nostr_profile_parse, nostr_profile_parse_list, nostr_profile_parse_select_query, nostr_profile_parse_select_query_list, nostr_profile_parse_update_query, nostr_profile_validate_form_fields, nostr_profile_validate_update_form_fields, nostr_relay_parse, nostr_relay_parse_list, nostr_relay_parse_select_query, nostr_relay_parse_select_query_list, nostr_relay_parse_update_query, nostr_relay_validate_form_fields, nostr_relay_validate_update_form_fields, trade_product_parse, trade_product_parse_list, trade_product_parse_select_query, trade_product_parse_select_query_list, trade_product_parse_update_query, trade_product_validate_form_fields, trade_product_validate_update_form_fields, type IFarmCreate, type IFarmCreateHandler, type IFarmCreateResolve, type IFarmDelete, type IFarmDeleteHandler, type IFarmDeleteResolve, type IFarmLocationRelation, type IFarmLocationResolve, type IFarmRead, type IFarmReadHandler, type IFarmReadList, type IFarmReadListHandler, type IFarmReadListResolve, type IFarmReadResolve, type IFarmUpdate, type IFarmUpdateHandler, type IFarmUpdateResolve, type ILocationGcsCreate, type ILocationGcsCreateHandler, type ILocationGcsCreateResolve, type ILocationGcsDelete, type ILocationGcsDeleteHandler, type ILocationGcsDeleteResolve, type ILocationGcsRead, type ILocationGcsReadHandler, type ILocationGcsReadList, type ILocationGcsReadListHandler, type ILocationGcsReadListResolve, type ILocationGcsReadResolve, type ILocationGcsUpdate, type ILocationGcsUpdateHandler, type ILocationGcsUpdateResolve, type ILogErrorCreate, type ILogErrorCreateHandler, type ILogErrorCreateResolve, type ILogErrorDelete, type ILogErrorDeleteHandler, type ILogErrorDeleteResolve, type ILogErrorRead, type ILogErrorReadHandler, type ILogErrorReadList, type ILogErrorReadListHandler, type ILogErrorReadListResolve, type ILogErrorReadResolve, type ILogErrorUpdate, type ILogErrorUpdateHandler, type ILogErrorUpdateResolve, type IMediaImageCreate, type IMediaImageCreateHandler, type IMediaImageCreateResolve, type IMediaImageDelete, type IMediaImageDeleteHandler, type IMediaImageDeleteResolve, type IMediaImageRead, type IMediaImageReadHandler, type IMediaImageReadList, type IMediaImageReadListHandler, type IMediaImageReadListResolve, type IMediaImageReadResolve, type IMediaImageUpdate, type IMediaImageUpdateHandler, type IMediaImageUpdateResolve, type INostrProfileCreate, type INostrProfileCreateHandler, type INostrProfileCreateResolve, type INostrProfileDelete, type INostrProfileDeleteHandler, type INostrProfileDeleteResolve, type INostrProfileRead, type INostrProfileReadHandler, type INostrProfileReadList, type INostrProfileReadListHandler, type INostrProfileReadListResolve, type INostrProfileReadResolve, type INostrProfileRelayRelation, type INostrProfileRelayResolve, type INostrProfileUpdate, type INostrProfileUpdateHandler, type INostrProfileUpdateResolve, type INostrRelayCreate, type INostrRelayCreateHandler, type INostrRelayCreateResolve, type INostrRelayDelete, type INostrRelayDeleteHandler, type INostrRelayDeleteResolve, type INostrRelayRead, type INostrRelayReadHandler, type INostrRelayReadList, type INostrRelayReadListHandler, type INostrRelayReadListResolve, type INostrRelayReadResolve, type INostrRelayUpdate, type INostrRelayUpdateHandler, type INostrRelayUpdateResolve, type ITradeProductCreate, type ITradeProductCreateHandler, type ITradeProductCreateResolve, type ITradeProductDelete, type ITradeProductDeleteHandler, type ITradeProductDeleteResolve, type ITradeProductLocationRelation, type ITradeProductLocationResolve, type ITradeProductMediaRelation, type ITradeProductMediaResolve, type ITradeProductRead, type ITradeProductReadHandler, type ITradeProductReadList, type ITradeProductReadListHandler, type ITradeProductReadListResolve, type ITradeProductReadResolve, type ITradeProductUpdate, type ITradeProductUpdateHandler, type ITradeProductUpdateResolve } from "@radroots/models"; -import { err_msg, is_err_response, is_pass_response, is_result_response, is_results_response, type ErrorMessage } from "@radroots/util"; -import { invoke } from "@tauri-apps/api/core"; -import type { IClientTauriDatabase, IClientTauriDatabaseMessage } from "./types"; - -export class TauriClientDatabase implements IClientTauriDatabase { - private append_logs(scope: string, opts: any, error: any): IClientTauriDatabaseMessage { - console.log('[radroots] append_logs (scope, opts, error)'); - console.log(scope, opts, error); - const error_msg = String(error); - return `append_logs::${error_msg}`; - } - - private handle_errors(scope: string, opts: any, e: any): ErrorMessage<IClientTauriDatabaseMessage> { - const error = this.append_logs(scope, opts, e); - if (error.includes("UNIQUE constraint failed: location_gcs.geohash")) return err_msg("*-location-gcs-geohash-unique"); - else if (error.includes("UNIQUE constraint failed: nostr_relay.url")) return err_msg("*-nostr-relay-url-unique"); - return err_msg(error); - } - - public reset = async (): Promise<{ pass: true } | ErrorMessage<IClientTauriDatabaseMessage>> => { - try { - const response = await invoke<any>("model_tables_reset"); - return { pass: true }; - } catch (e) { - return this.handle_errors("reset", undefined, e); - }; - } - - public async location_gcs_create(opts: ILocationGcsCreate): Promise<ILocationGcsCreateResolve<IClientTauriDatabaseMessage>> { - try { - const args = location_gcs_validate_form_fields(opts); - if (Array.isArray(args)) return { err_s: args }; - const response = await invoke<any>("model_location_gcs_create", { args } satisfies ILocationGcsCreateHandler); - if (is_err_response(response)) return err_msg(response.err); - else if (is_result_response(response)) return { id: response.result }; - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_location_gcs_create", opts, e); - }; - } - - public async location_gcs_read(opts: ILocationGcsRead): Promise<ILocationGcsReadResolve<IClientTauriDatabaseMessage>> { - try { - const args = location_gcs_parse_select_query(opts); - const response = await invoke<any>("model_location_gcs_read", { args } satisfies ILocationGcsReadHandler); - if (is_err_response(response)) return err_msg(response.err); - else if (is_results_response(response)) { - const result = location_gcs_parse(response.results[0]); - if (result) return { result }; - } - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_location_gcs_read", opts, e); - }; - } - - public async location_gcs_read_list(opts?: ILocationGcsReadList): Promise<ILocationGcsReadListResolve<IClientTauriDatabaseMessage>> { - try { - const args = location_gcs_parse_select_query_list(opts); - const response = await invoke<any>("model_location_gcs_read_list", { args } satisfies ILocationGcsReadListHandler); - if (is_err_response(response)) return err_msg(response.err); - else if (is_results_response(response)) { - const results = location_gcs_parse_list(response.results); - if (results.length) return { results }; - } - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_location_gcs_read_list", opts, e); - }; - } - - public async location_gcs_update(opts: ILocationGcsUpdate): Promise<ILocationGcsUpdateResolve<IClientTauriDatabaseMessage>> { - try { - const fields = location_gcs_validate_update_form_fields(opts.fields); - if (Array.isArray(fields)) return { err_s: fields }; - const args = location_gcs_parse_update_query(opts.filter, fields); - const response = await invoke<any>("model_location_gcs_update", { args } satisfies ILocationGcsUpdateHandler); - if (is_err_response(response)) return err_msg(response.err); - else if (is_pass_response(response)) return { pass: true }; - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_location_gcs_update", opts, e); - }; - } - - public async location_gcs_delete(args: ILocationGcsDelete): Promise<ILocationGcsDeleteResolve<IClientTauriDatabaseMessage>> { - try { - const response = await invoke<any>("model_location_gcs_delete", { args } satisfies ILocationGcsDeleteHandler); - if (is_err_response(response)) return err_msg(response.err); - else if (is_pass_response(response)) return { pass: true }; - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_location_gcs_delete", args, e); - }; - } - - public async trade_product_create(opts: ITradeProductCreate): Promise<ITradeProductCreateResolve<IClientTauriDatabaseMessage>> { - try { - const args = trade_product_validate_form_fields(opts); - if (Array.isArray(args)) return { err_s: args }; - const response = await invoke<any>("model_trade_product_create", { args } satisfies ITradeProductCreateHandler); - if (is_err_response(response)) return err_msg(response.err); - else if (is_result_response(response)) return { id: response.result }; - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_trade_product_create", opts, e); - }; - } - - public async trade_product_read(opts: ITradeProductRead): Promise<ITradeProductReadResolve<IClientTauriDatabaseMessage>> { - try { - const args = trade_product_parse_select_query(opts); - const response = await invoke<any>("model_trade_product_read", { args } satisfies ITradeProductReadHandler); - if (is_err_response(response)) return err_msg(response.err); - else if (is_results_response(response)) { - const result = trade_product_parse(response.results[0]); - if (result) return { result }; - } - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_trade_product_read", opts, e); - }; - } - - public async trade_product_read_list(opts?: ITradeProductReadList): Promise<ITradeProductReadListResolve<IClientTauriDatabaseMessage>> { - try { - const args = trade_product_parse_select_query_list(opts); - const response = await invoke<any>("model_trade_product_read_list", { args } satisfies ITradeProductReadListHandler); - if (is_err_response(response)) return err_msg(response.err); - else if (is_results_response(response)) { - const results = trade_product_parse_list(response.results); - if (results.length) return { results }; - } - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_trade_product_read_list", opts, e); - }; - } - - public async trade_product_update(opts: ITradeProductUpdate): Promise<ITradeProductUpdateResolve<IClientTauriDatabaseMessage>> { - try { - const fields = trade_product_validate_update_form_fields(opts.fields); - if (Array.isArray(fields)) return { err_s: fields }; - const args = trade_product_parse_update_query(opts.filter, fields); - const response = await invoke<any>("model_trade_product_update", { args } satisfies ITradeProductUpdateHandler); - if (is_err_response(response)) return err_msg(response.err); - else if (is_pass_response(response)) return { pass: true }; - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_trade_product_update", opts, e); - }; - } - - public async trade_product_delete(args: ITradeProductDelete): Promise<ITradeProductDeleteResolve<IClientTauriDatabaseMessage>> { - try { - const response = await invoke<any>("model_trade_product_delete", { args } satisfies ITradeProductDeleteHandler); - if (is_err_response(response)) return err_msg(response.err); - else if (is_pass_response(response)) return { pass: true }; - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_trade_product_delete", args, e); - }; - } - - public async nostr_profile_create(opts: INostrProfileCreate): Promise<INostrProfileCreateResolve<IClientTauriDatabaseMessage>> { - try { - const args = nostr_profile_validate_form_fields(opts); - if (Array.isArray(args)) return { err_s: args }; - const response = await invoke<any>("model_nostr_profile_create", { args } satisfies INostrProfileCreateHandler); - if (is_err_response(response)) return err_msg(response.err); - else if (is_result_response(response)) return { id: response.result }; - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_nostr_profile_create", opts, e); - }; - } - - public async nostr_profile_read(opts: INostrProfileRead): Promise<INostrProfileReadResolve<IClientTauriDatabaseMessage>> { - try { - const args = nostr_profile_parse_select_query(opts); - const response = await invoke<any>("model_nostr_profile_read", { args } satisfies INostrProfileReadHandler); - if (is_err_response(response)) return err_msg(response.err); - else if (is_results_response(response)) { - const result = nostr_profile_parse(response.results[0]); - if (result) return { result }; - } - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_nostr_profile_read", opts, e); - }; - } - - public async nostr_profile_read_list(opts?: INostrProfileReadList): Promise<INostrProfileReadListResolve<IClientTauriDatabaseMessage>> { - try { - const args = nostr_profile_parse_select_query_list(opts); - const response = await invoke<any>("model_nostr_profile_read_list", { args } satisfies INostrProfileReadListHandler); - if (is_err_response(response)) return err_msg(response.err); - else if (is_results_response(response)) { - const results = nostr_profile_parse_list(response.results); - if (results.length) return { results }; - } - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_nostr_profile_read_list", opts, e); - }; - } - - public async nostr_profile_update(opts: INostrProfileUpdate): Promise<INostrProfileUpdateResolve<IClientTauriDatabaseMessage>> { - try { - const fields = nostr_profile_validate_update_form_fields(opts.fields); - if (Array.isArray(fields)) return { err_s: fields }; - const args = nostr_profile_parse_update_query(opts.filter, fields); - const response = await invoke<any>("model_nostr_profile_update", { args } satisfies INostrProfileUpdateHandler); - if (is_err_response(response)) return err_msg(response.err); - else if (is_pass_response(response)) return { pass: true }; - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_nostr_profile_update", opts, e); - }; - } - - public async nostr_profile_delete(args: INostrProfileDelete): Promise<INostrProfileDeleteResolve<IClientTauriDatabaseMessage>> { - try { - const response = await invoke<any>("model_nostr_profile_delete", { args } satisfies INostrProfileDeleteHandler); - if (is_err_response(response)) return err_msg(response.err); - else if (is_pass_response(response)) return { pass: true }; - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_nostr_profile_delete", args, e); - }; - } - - public async nostr_relay_create(opts: INostrRelayCreate): Promise<INostrRelayCreateResolve<IClientTauriDatabaseMessage>> { - try { - const args = nostr_relay_validate_form_fields(opts); - if (Array.isArray(args)) return { err_s: args }; - const response = await invoke<any>("model_nostr_relay_create", { args } satisfies INostrRelayCreateHandler); - if (is_err_response(response)) return err_msg(response.err); - else if (is_result_response(response)) return { id: response.result }; - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_nostr_relay_create", opts, e); - }; - } - - public async nostr_relay_read(opts: INostrRelayRead): Promise<INostrRelayReadResolve<IClientTauriDatabaseMessage>> { - try { - const args = nostr_relay_parse_select_query(opts); - const response = await invoke<any>("model_nostr_relay_read", { args } satisfies INostrRelayReadHandler); - if (is_err_response(response)) return err_msg(response.err); - else if (is_results_response(response)) { - const result = nostr_relay_parse(response.results[0]); - if (result) return { result }; - } - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_nostr_relay_read", opts, e); - }; - } - - public async nostr_relay_read_list(opts?: INostrRelayReadList): Promise<INostrRelayReadListResolve<IClientTauriDatabaseMessage>> { - try { - const args = nostr_relay_parse_select_query_list(opts); - const response = await invoke<any>("model_nostr_relay_read_list", { args } satisfies INostrRelayReadListHandler); - if (is_err_response(response)) return err_msg(response.err); - else if (is_results_response(response)) { - const results = nostr_relay_parse_list(response.results); - if (results.length) return { results }; - } - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_nostr_relay_read_list", opts, e); - }; - } - - public async nostr_relay_update(opts: INostrRelayUpdate): Promise<INostrRelayUpdateResolve<IClientTauriDatabaseMessage>> { - try { - const fields = nostr_relay_validate_update_form_fields(opts.fields); - if (Array.isArray(fields)) return { err_s: fields }; - const args = nostr_relay_parse_update_query(opts.filter, fields); - const response = await invoke<any>("model_nostr_relay_update", { args } satisfies INostrRelayUpdateHandler); - if (is_err_response(response)) return err_msg(response.err); - else if (is_pass_response(response)) return { pass: true }; - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_nostr_relay_update", opts, e); - }; - } - - public async nostr_relay_delete(args: INostrRelayDelete): Promise<INostrRelayDeleteResolve<IClientTauriDatabaseMessage>> { - try { - const response = await invoke<any>("model_nostr_relay_delete", { args } satisfies INostrRelayDeleteHandler); - if (is_err_response(response)) return err_msg(response.err); - else if (is_pass_response(response)) return { pass: true }; - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_nostr_relay_delete", args, e); - }; - } - - public async media_image_create(opts: IMediaImageCreate): Promise<IMediaImageCreateResolve<IClientTauriDatabaseMessage>> { - try { - const args = media_image_validate_form_fields(opts); - if (Array.isArray(args)) return { err_s: args }; - const response = await invoke<any>("model_media_image_create", { args } satisfies IMediaImageCreateHandler); - if (is_err_response(response)) return err_msg(response.err); - else if (is_result_response(response)) return { id: response.result }; - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_media_image_create", opts, e); - }; - } - - public async media_image_read(opts: IMediaImageRead): Promise<IMediaImageReadResolve<IClientTauriDatabaseMessage>> { - try { - const args = media_image_parse_select_query(opts); - const response = await invoke<any>("model_media_image_read", { args } satisfies IMediaImageReadHandler); - if (is_err_response(response)) return err_msg(response.err); - else if (is_results_response(response)) { - const result = media_image_parse(response.results[0]); - if (result) return { result }; - } - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_media_image_read", opts, e); - }; - } - - public async media_image_read_list(opts?: IMediaImageReadList): Promise<IMediaImageReadListResolve<IClientTauriDatabaseMessage>> { - try { - const args = media_image_parse_select_query_list(opts); - const response = await invoke<any>("model_media_image_read_list", { args } satisfies IMediaImageReadListHandler); - if (is_err_response(response)) return err_msg(response.err); - else if (is_results_response(response)) { - const results = media_image_parse_list(response.results); - if (results.length) return { results }; - } - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_media_image_read_list", opts, e); - }; - } - - public async media_image_update(opts: IMediaImageUpdate): Promise<IMediaImageUpdateResolve<IClientTauriDatabaseMessage>> { - try { - const fields = media_image_validate_update_form_fields(opts.fields); - if (Array.isArray(fields)) return { err_s: fields }; - const args = media_image_parse_update_query(opts.filter, fields); - const response = await invoke<any>("model_media_image_update", { args } satisfies IMediaImageUpdateHandler); - if (is_err_response(response)) return err_msg(response.err); - else if (is_pass_response(response)) return { pass: true }; - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_media_image_update", opts, e); - }; - } - - public async media_image_delete(args: IMediaImageDelete): Promise<IMediaImageDeleteResolve<IClientTauriDatabaseMessage>> { - try { - const response = await invoke<any>("model_media_image_delete", { args } satisfies IMediaImageDeleteHandler); - if (is_err_response(response)) return err_msg(response.err); - else if (is_pass_response(response)) return { pass: true }; - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_media_image_delete", args, e); - }; - } - - public async log_error_create(opts: ILogErrorCreate): Promise<ILogErrorCreateResolve<IClientTauriDatabaseMessage>> { - try { - const args = log_error_validate_form_fields(opts); - if (Array.isArray(args)) return { err_s: args }; - const response = await invoke<any>("model_log_error_create", { args } satisfies ILogErrorCreateHandler); - if (is_err_response(response)) return err_msg(response.err); - else if (is_result_response(response)) return { id: response.result }; - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_log_error_create", opts, e); - }; - } - - public async log_error_read(opts: ILogErrorRead): Promise<ILogErrorReadResolve<IClientTauriDatabaseMessage>> { - try { - const args = log_error_parse_select_query(opts); - const response = await invoke<any>("model_log_error_read", { args } satisfies ILogErrorReadHandler); - if (is_err_response(response)) return err_msg(response.err); - else if (is_results_response(response)) { - const result = log_error_parse(response.results[0]); - if (result) return { result }; - } - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_log_error_read", opts, e); - }; - } - - public async log_error_read_list(opts?: ILogErrorReadList): Promise<ILogErrorReadListResolve<IClientTauriDatabaseMessage>> { - try { - const args = log_error_parse_select_query_list(opts); - const response = await invoke<any>("model_log_error_read_list", { args } satisfies ILogErrorReadListHandler); - if (is_err_response(response)) return err_msg(response.err); - else if (is_results_response(response)) { - const results = log_error_parse_list(response.results); - if (results.length) return { results }; - } - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_log_error_read_list", opts, e); - }; - } - - public async log_error_update(opts: ILogErrorUpdate): Promise<ILogErrorUpdateResolve<IClientTauriDatabaseMessage>> { - try { - const fields = log_error_validate_update_form_fields(opts.fields); - if (Array.isArray(fields)) return { err_s: fields }; - const args = log_error_parse_update_query(opts.filter, fields); - const response = await invoke<any>("model_log_error_update", { args } satisfies ILogErrorUpdateHandler); - if (is_err_response(response)) return err_msg(response.err); - else if (is_pass_response(response)) return { pass: true }; - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_log_error_update", opts, e); - }; - } - - public async log_error_delete(args: ILogErrorDelete): Promise<ILogErrorDeleteResolve<IClientTauriDatabaseMessage>> { - try { - const response = await invoke<any>("model_log_error_delete", { args } satisfies ILogErrorDeleteHandler); - if (is_err_response(response)) return err_msg(response.err); - else if (is_pass_response(response)) return { pass: true }; - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_log_error_delete", args, e); - }; - } - - public async farm_create(opts: IFarmCreate): Promise<IFarmCreateResolve<IClientTauriDatabaseMessage>> { - try { - const args = farm_validate_form_fields(opts); - if (Array.isArray(args)) return { err_s: args }; - const response = await invoke<any>("model_farm_create", { args } satisfies IFarmCreateHandler); - if (is_err_response(response)) return err_msg(response.err); - else if (is_result_response(response)) return { id: response.result }; - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_farm_create", opts, e); - }; - } - - public async farm_read(opts: IFarmRead): Promise<IFarmReadResolve<IClientTauriDatabaseMessage>> { - try { - const args = farm_parse_select_query(opts); - const response = await invoke<any>("model_farm_read", { args } satisfies IFarmReadHandler); - if (is_err_response(response)) return err_msg(response.err); - else if (is_results_response(response)) { - const result = farm_parse(response.results[0]); - if (result) return { result }; - } - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_farm_read", opts, e); - }; - } - - public async farm_read_list(opts?: IFarmReadList): Promise<IFarmReadListResolve<IClientTauriDatabaseMessage>> { - try { - const args = farm_parse_select_query_list(opts); - const response = await invoke<any>("model_farm_read_list", { args } satisfies IFarmReadListHandler); - if (is_err_response(response)) return err_msg(response.err); - else if (is_results_response(response)) { - const results = farm_parse_list(response.results); - if (results.length) return { results }; - } - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_farm_read_list", opts, e); - }; - } - - public async farm_update(opts: IFarmUpdate): Promise<IFarmUpdateResolve<IClientTauriDatabaseMessage>> { - try { - const fields = farm_validate_update_form_fields(opts.fields); - if (Array.isArray(fields)) return { err_s: fields }; - const args = farm_parse_update_query(opts.filter, fields); - const response = await invoke<any>("model_farm_update", { args } satisfies IFarmUpdateHandler); - if (is_err_response(response)) return err_msg(response.err); - else if (is_pass_response(response)) return { pass: true }; - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_farm_update", opts, e); - }; - } - - public async farm_delete(args: IFarmDelete): Promise<IFarmDeleteResolve<IClientTauriDatabaseMessage>> { - try { - const response = await invoke<any>("model_farm_delete", { args } satisfies IFarmDeleteHandler); - if (is_err_response(response)) return err_msg(response.err); - else if (is_pass_response(response)) return { pass: true }; - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_farm_delete", args, e); - }; - } - - public async nostr_profile_relay_set(args: INostrProfileRelayRelation): Promise<INostrProfileRelayResolve<IClientTauriDatabaseMessage>> { - try { - const response = await invoke<any>("model_nostr_profile_relay_set", { args }); - if (response?.pass === true) return { pass: true }; - else if (typeof response === "string") return err_msg(response); - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_nostr_profile_relay_set", args, e); - }; - } - - public async nostr_profile_relay_unset(args: INostrProfileRelayRelation): Promise<INostrProfileRelayResolve<IClientTauriDatabaseMessage>> { - try { - const response = await invoke<any>("model_nostr_profile_relay_unset", { args }); - if (response?.pass === true) return { pass: true }; - else if (typeof response === "string") return err_msg(response); - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_nostr_profile_relay_unset", args, e); - }; - } - - public async farm_location_set(args: IFarmLocationRelation): Promise<IFarmLocationResolve<IClientTauriDatabaseMessage>> { - try { - const response = await invoke<any>("model_farm_location_set", { args }); - if (response?.pass === true) return { pass: true }; - else if (typeof response === "string") return err_msg(response); - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_farm_location_set", args, e); - }; - } - - public async farm_location_unset(args: IFarmLocationRelation): Promise<IFarmLocationResolve<IClientTauriDatabaseMessage>> { - try { - const response = await invoke<any>("model_farm_location_unset", { args }); - if (response?.pass === true) return { pass: true }; - else if (typeof response === "string") return err_msg(response); - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_farm_location_unset", args, e); - }; - } - - public async trade_product_location_set(args: ITradeProductLocationRelation): Promise<ITradeProductLocationResolve<IClientTauriDatabaseMessage>> { - try { - const response = await invoke<any>("model_trade_product_location_set", { args }); - if (response?.pass === true) return { pass: true }; - else if (typeof response === "string") return err_msg(response); - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_trade_product_location_set", args, e); - }; - } - - public async trade_product_location_unset(args: ITradeProductLocationRelation): Promise<ITradeProductLocationResolve<IClientTauriDatabaseMessage>> { - try { - const response = await invoke<any>("model_trade_product_location_unset", { args }); - if (response?.pass === true) return { pass: true }; - else if (typeof response === "string") return err_msg(response); - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_trade_product_location_unset", args, e); - }; - } - - public async trade_product_media_set(args: ITradeProductMediaRelation): Promise<ITradeProductMediaResolve<IClientTauriDatabaseMessage>> { - try { - const response = await invoke<any>("model_trade_product_media_set", { args }); - if (response?.pass === true) return { pass: true }; - else if (typeof response === "string") return err_msg(response); - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_trade_product_media_set", args, e); - }; - } - - public async trade_product_media_unset(args: ITradeProductMediaRelation): Promise<ITradeProductMediaResolve<IClientTauriDatabaseMessage>> { - try { - const response = await invoke<any>("model_trade_product_media_unset", { args }); - if (response?.pass === true) return { pass: true }; - else if (typeof response === "string") return err_msg(response); - return err_msg("*-result"); - } catch (e) { - return this.handle_errors("model_trade_product_media_unset", args, e); - }; - } -} -\ No newline at end of file diff --git a/client/src/database/tauri/types.ts b/client/src/database/tauri/types.ts @@ -1,44 +0,0 @@ -import { type IFarmCreate, type IFarmCreateResolve, type IFarmDelete, type IFarmDeleteResolve, type IFarmRead, type IFarmReadList, type IFarmReadListResolve, type IFarmReadResolve, type IFarmUpdate, type IFarmUpdateResolve, type ILocationGcsCreate, type ILocationGcsCreateResolve, type ILocationGcsDelete, type ILocationGcsDeleteResolve, type ILocationGcsRead, type ILocationGcsReadList, type ILocationGcsReadListResolve, type ILocationGcsReadResolve, type ILocationGcsUpdate, type ILocationGcsUpdateResolve, type ILogErrorCreate, type ILogErrorCreateResolve, type ILogErrorDelete, type ILogErrorDeleteResolve, type ILogErrorRead, type ILogErrorReadList, type ILogErrorReadListResolve, type ILogErrorReadResolve, type ILogErrorUpdate, type ILogErrorUpdateResolve, type IMediaImageCreate, type IMediaImageCreateResolve, type IMediaImageDelete, type IMediaImageDeleteResolve, type IMediaImageRead, type IMediaImageReadList, type IMediaImageReadListResolve, type IMediaImageReadResolve, type IMediaImageUpdate, type IMediaImageUpdateResolve, type INostrProfileCreate, type INostrProfileCreateResolve, type INostrProfileDelete, type INostrProfileDeleteResolve, type INostrProfileRead, type INostrProfileReadList, type INostrProfileReadListResolve, type INostrProfileReadResolve, type INostrProfileUpdate, type INostrProfileUpdateResolve, type INostrRelayCreate, type INostrRelayCreateResolve, type INostrRelayDelete, type INostrRelayDeleteResolve, type INostrRelayRead, type INostrRelayReadList, type INostrRelayReadListResolve, type INostrRelayReadResolve, type INostrRelayUpdate, type INostrRelayUpdateResolve, type ITradeProductCreate, type ITradeProductCreateResolve, type ITradeProductDelete, type ITradeProductDeleteResolve, type ITradeProductRead, type ITradeProductReadList, type ITradeProductReadListResolve, type ITradeProductReadResolve, type ITradeProductUpdate, type ITradeProductUpdateResolve } from "@radroots/models"; - -export type IClientTauriDatabaseMessage = - | string - | "*-fields" - | "*-result"; - -export type IClientTauriDatabase = { - location_gcs_create(opts: ILocationGcsCreate): Promise<ILocationGcsCreateResolve<IClientTauriDatabaseMessage>>; - location_gcs_read(opts: ILocationGcsRead): Promise<ILocationGcsReadResolve<IClientTauriDatabaseMessage>>; - location_gcs_read_list(opts: ILocationGcsReadList): Promise<ILocationGcsReadListResolve<IClientTauriDatabaseMessage>>; - location_gcs_delete(opts: ILocationGcsDelete): Promise<ILocationGcsDeleteResolve<IClientTauriDatabaseMessage>>; - location_gcs_update(opts: ILocationGcsUpdate): Promise<ILocationGcsUpdateResolve<IClientTauriDatabaseMessage>>; - trade_product_create(opts: ITradeProductCreate): Promise<ITradeProductCreateResolve<IClientTauriDatabaseMessage>>; - trade_product_read(opts: ITradeProductRead): Promise<ITradeProductReadResolve<IClientTauriDatabaseMessage>>; - trade_product_read_list(opts: ITradeProductReadList): Promise<ITradeProductReadListResolve<IClientTauriDatabaseMessage>>; - trade_product_delete(opts: ITradeProductDelete): Promise<ITradeProductDeleteResolve<IClientTauriDatabaseMessage>>; - trade_product_update(opts: ITradeProductUpdate): Promise<ITradeProductUpdateResolve<IClientTauriDatabaseMessage>>; - nostr_profile_create(opts: INostrProfileCreate): Promise<INostrProfileCreateResolve<IClientTauriDatabaseMessage>>; - nostr_profile_read(opts: INostrProfileRead): Promise<INostrProfileReadResolve<IClientTauriDatabaseMessage>>; - nostr_profile_read_list(opts: INostrProfileReadList): Promise<INostrProfileReadListResolve<IClientTauriDatabaseMessage>>; - nostr_profile_delete(opts: INostrProfileDelete): Promise<INostrProfileDeleteResolve<IClientTauriDatabaseMessage>>; - nostr_profile_update(opts: INostrProfileUpdate): Promise<INostrProfileUpdateResolve<IClientTauriDatabaseMessage>>; - nostr_relay_create(opts: INostrRelayCreate): Promise<INostrRelayCreateResolve<IClientTauriDatabaseMessage>>; - nostr_relay_read(opts: INostrRelayRead): Promise<INostrRelayReadResolve<IClientTauriDatabaseMessage>>; - nostr_relay_read_list(opts: INostrRelayReadList): Promise<INostrRelayReadListResolve<IClientTauriDatabaseMessage>>; - nostr_relay_delete(opts: INostrRelayDelete): Promise<INostrRelayDeleteResolve<IClientTauriDatabaseMessage>>; - nostr_relay_update(opts: INostrRelayUpdate): Promise<INostrRelayUpdateResolve<IClientTauriDatabaseMessage>>; - media_image_create(opts: IMediaImageCreate): Promise<IMediaImageCreateResolve<IClientTauriDatabaseMessage>>; - media_image_read(opts: IMediaImageRead): Promise<IMediaImageReadResolve<IClientTauriDatabaseMessage>>; - media_image_read_list(opts: IMediaImageReadList): Promise<IMediaImageReadListResolve<IClientTauriDatabaseMessage>>; - media_image_delete(opts: IMediaImageDelete): Promise<IMediaImageDeleteResolve<IClientTauriDatabaseMessage>>; - media_image_update(opts: IMediaImageUpdate): Promise<IMediaImageUpdateResolve<IClientTauriDatabaseMessage>>; - log_error_create(opts: ILogErrorCreate): Promise<ILogErrorCreateResolve<IClientTauriDatabaseMessage>>; - log_error_read(opts: ILogErrorRead): Promise<ILogErrorReadResolve<IClientTauriDatabaseMessage>>; - log_error_read_list(opts: ILogErrorReadList): Promise<ILogErrorReadListResolve<IClientTauriDatabaseMessage>>; - log_error_delete(opts: ILogErrorDelete): Promise<ILogErrorDeleteResolve<IClientTauriDatabaseMessage>>; - log_error_update(opts: ILogErrorUpdate): Promise<ILogErrorUpdateResolve<IClientTauriDatabaseMessage>>; - farm_create(opts: IFarmCreate): Promise<IFarmCreateResolve<IClientTauriDatabaseMessage>>; - farm_read(opts: IFarmRead): Promise<IFarmReadResolve<IClientTauriDatabaseMessage>>; - farm_read_list(opts: IFarmReadList): Promise<IFarmReadListResolve<IClientTauriDatabaseMessage>>; - farm_delete(opts: IFarmDelete): Promise<IFarmDeleteResolve<IClientTauriDatabaseMessage>>; - farm_update(opts: IFarmUpdate): Promise<IFarmUpdateResolve<IClientTauriDatabaseMessage>>; -}; -\ No newline at end of file diff --git a/client/src/datastore/index.ts b/client/src/datastore/index.ts @@ -0,0 +1,3 @@ +export * from "./types.js"; +export * from "./web.js"; + diff --git a/client/src/datastore/tauri.ts b/client/src/datastore/tauri.ts @@ -1,78 +0,0 @@ -import { err_msg, type IClientDatastore, type IClientDatastoreEntriesResolve, type IClientDatastoreGetPResolve, type IClientDatastoreGetResolve, type IClientDatastoreKeysResolve, type IClientDatastoreRemoveResolve, type IClientDatastoreSetPResolve, type IClientDatastoreSetResolve } from '@radroots/util'; -import { load, Store } from '@tauri-apps/plugin-store'; - -const ds_map_param = { - radroots_profile: (public_key: string) => `radroots:profile:${public_key}` -} as const; - -const ds_map = { - init_nostr: `init:nostr:publickey`, - key_nostr: `nostr:publickey`, - role: `config:role`, - is_setup: `config:is_setup`, - eula: `eula:date` -} as const; - -export class TauriClientDatastore implements IClientDatastore< - typeof ds_map, - typeof ds_map_param -> { - private _store: Store | null = null; - private _store_path: string; - - constructor(store_path: string = 'radroots_datastore.json') { - this._store_path = store_path; - } - - public async init(): Promise<void> { - this._store = await load(this._store_path); - } - - public async set(key: keyof typeof ds_map, value: string): Promise<IClientDatastoreSetResolve> { - if (!this._store) return err_msg(`*-store`); - await this._store.set(ds_map[key], { value }); - await this._store.save(); - return { pass: true }; - } - - public async get(key: keyof typeof ds_map): Promise<IClientDatastoreGetResolve> { - if (!this._store) return err_msg(`*-store`); - const result = await this._store.get<{ value: any }>(ds_map[key]); - if (result && typeof result.value === `string`) return { result: result.value }; - return err_msg(`*-result`); - } - - public async setp(key: keyof typeof ds_map_param, key_param: string, value: string): Promise<IClientDatastoreSetPResolve> { - if (!this._store) return err_msg(`*-store`); - await this._store.set(ds_map_param[key](key_param), { value }); - await this._store.save(); - return { pass: true }; - } - - public async getp(key: keyof typeof ds_map_param, key_param: string): Promise<IClientDatastoreGetPResolve> { - if (!this._store) return err_msg(`*-store`); - const result = await this._store.get<{ value: any }>(ds_map_param[key](key_param)); - if (result && typeof result.value === `string`) return { result: result.value }; - return err_msg(`*-result`); - } - - public async remove(key: keyof typeof ds_map): Promise<IClientDatastoreRemoveResolve> { - if (!this._store) return err_msg(`*-store`); - const res = await this._store.delete(ds_map[key]); - if (!res) return err_msg(`*-pass`); - await this._store.save(); - return { pass: true }; - } - - public async keys(): Promise<IClientDatastoreKeysResolve> { - if (!this._store) return err_msg(`*-store`); - const results = await this._store.keys(); - return { results }; - } - - public async entries(): Promise<IClientDatastoreEntriesResolve> { - if (!this._store) return err_msg(`*-store`); - const results = await this._store.entries(); - return { results }; - } -} diff --git a/client/src/datastore/types.ts b/client/src/datastore/types.ts @@ -0,0 +1,35 @@ +import type { ResolveError, ResultObj, ResultPass, ResultsList } from "@radroots/utils"; + +export type IClientDatastoreValue = string | null; + +export type IClientDatastoreSetResolve = ResolveError<ResultPass>; +export type IClientDatastoreGetResolve = ResolveError<ResultObj<string>>; +export type IClientDatastoreSetPResolve = ResolveError<ResultPass>; +export type IClientDatastoreGetPResolve = ResolveError<ResultObj<string>>; +export type IClientDatastoreKeysResolve = ResolveError<ResultsList<string>>; +export type IClientDatastoreDelResolve = ResolveError<ResultObj<string>>; +export type IClientDatastoreDelPrefResolve = ResolveError<ResultsList<string>>; +export type IClientDatastoreEntriesResolve = ResolveError<ResultsList<[string, IClientDatastoreValue]>>; + +export type IClientDatastoreKeyMap = Record<string, string>; +export type IClientDatastoreKeyParamMap = Record<string, (...args: string[]) => string>; + +export type IClientDatastore< + TKeyMap extends IClientDatastoreKeyMap, + TKeyParamMap extends IClientDatastoreKeyParamMap, + TKeyObjMap extends IClientDatastoreKeyMap, +> = { + init(): Promise<ResolveError<void>>; + set(key: keyof TKeyMap, value: string): Promise<ResolveError<ResultPass>>; + get(key: keyof TKeyMap): Promise<ResolveError<ResultObj<string>>>; + set_obj(key: keyof TKeyObjMap, value: TKeyObjMap): Promise<ResolveError<ResultPass>>; + update_obj(key: keyof TKeyObjMap, value: Partial<TKeyObjMap>): Promise<ResolveError<ResultPass>>; + get_obj<T>(key: keyof TKeyObjMap): Promise<ResolveError<ResultObj<T>>>; + del_obj(key: keyof TKeyObjMap): Promise<ResolveError<ResultObj<string>>>; + del(key: keyof TKeyMap): Promise<IClientDatastoreDelResolve>; + del_pref(key_prefix: string): Promise<IClientDatastoreDelPrefResolve>; + setp<K extends keyof TKeyParamMap>(key: K, key_param: Parameters<TKeyParamMap[K]>[0], value: string): Promise<ResolveError<ResultPass>>; + getp<K extends keyof TKeyParamMap>(key: K, key_param: Parameters<TKeyParamMap[K]>[0]): Promise<ResolveError<ResultObj<string>>>; + keys(): Promise<ResolveError<ResultsList<string>>>; + reset(): Promise<ResolveError<ResultPass>>; +}; diff --git a/client/src/datastore/web.ts b/client/src/datastore/web.ts @@ -0,0 +1,192 @@ +import { err_msg, handle_err, ResolveError, ResultObj } from "@radroots/utils"; +import { + createStore, + clear as idb_clear, + del as idb_del, + get as idb_get, + keys as idb_keys, + set as idb_set, + type UseStore +} from "idb-keyval"; +import { type IClientIdbConfig } from "../utils/idb.js"; +import type { + IClientDatastore, + IClientDatastoreDelPrefResolve, + IClientDatastoreDelResolve, + IClientDatastoreGetPResolve, + IClientDatastoreGetResolve, + IClientDatastoreKeyMap, + IClientDatastoreKeyParamMap, + IClientDatastoreKeysResolve, + IClientDatastoreSetPResolve, + IClientDatastoreSetResolve +} from "./types.js"; + +export class WebDatastore< + Tk extends IClientDatastoreKeyMap, + Tp extends IClientDatastoreKeyParamMap, + TkO extends IClientDatastoreKeyMap, +> implements IClientDatastore<Tk, Tp, TkO> { + private db_name: string; + private store_name: string; + private store: UseStore | null = null; + private _key_map: Tk; + private _key_param_map: Tp; + private _key_obj_map: TkO; + + constructor(key_map: Tk, key_param_map: Tp, key_obj_map: TkO, config?: IClientIdbConfig) { + this.db_name = config?.database || "radroots-web-datastore"; + this.store_name = config?.store || "default"; + this.store = null; + this._key_map = key_map; + this._key_param_map = key_param_map; + this._key_obj_map = key_obj_map; + } + + private get_store(): UseStore { + if (!this.store) { + if (typeof indexedDB === "undefined") throw new Error("error.client.keystore.idb_undefined"); + this.store = createStore(this.db_name, this.store_name); + } + return this.store; + } + + public async init() { + try { + this.get_store(); + } catch (e) { + return handle_err(e); + } + } + + public async set(key: keyof Tk, value: string): Promise<IClientDatastoreSetResolve> { + try { + await idb_set(this._key_map[key], value, this.get_store()); + return { pass: true }; + } catch (e) { + return handle_err(e); + } + } + + public async get(key: keyof Tk): Promise<IClientDatastoreGetResolve> { + try { + const value = await idb_get(this._key_map[key], this.get_store()); + if (!value) return err_msg("error.client.datastore.no_result") + return { result: value }; + } catch (e) { + return handle_err(e); + } + } + + public async del(key: keyof Tk): Promise<IClientDatastoreDelResolve> { + try { + await idb_del(this._key_map[key]); + return { result: key.toString() }; + } catch (e) { + return handle_err(e); + } + } + + public async set_obj<T>(key: keyof TkO, value: T): Promise<IClientDatastoreSetResolve> { + try { + await idb_set(this._key_obj_map[key], JSON.stringify(value), this.get_store()); + return { pass: true }; + } catch (e) { + return handle_err(e); + } + } + + public async update_obj<T>(key: keyof TkO, value: Partial<T>): Promise<IClientDatastoreSetResolve> { + try { + const k = this._key_obj_map[key]; + const curr = await idb_get(k, this.get_store()); + const obj_u: any = {} + if (curr) for (const [curr_key, curr_val] of Object.entries(JSON.parse(curr))) if (curr_val) obj_u[curr_key] = curr_val; + await idb_set(k, JSON.stringify({ + ...obj_u, + ...value + }), this.get_store()); + return { pass: true }; + } catch (e) { + return handle_err(e); + } + } + + public async get_obj<T>(key: keyof TkO): Promise<ResolveError<ResultObj<T>>> { + try { + const value = await idb_get(this._key_obj_map[key], this.get_store()); + if (!value) return err_msg("error.client.datastore.no_result") + return { result: JSON.parse(value) }; + } catch (e) { + return handle_err(e); + } + } + + public async del_obj(key: keyof TkO): Promise<IClientDatastoreDelResolve> { + try { + await idb_del(this._key_obj_map[key]); + return { result: key.toString() }; + } catch (e) { + return handle_err(e); + } + } + + public async setp<K extends keyof Tp>( + key: K, + key_param: Parameters<Tp[K]>[0], + value: string + ): Promise<IClientDatastoreSetPResolve> { + try { + await idb_set(this._key_param_map[key](key_param), value, this.get_store()); + return { pass: true }; + } catch (e) { + return handle_err(e); + } + } + + public async getp<K extends keyof Tp>( + key: K, + key_param: Parameters<Tp[K]>[0] + ): Promise<IClientDatastoreGetPResolve> { + try { + const value = await idb_get(this._key_param_map[key](key_param)); + if (!value) return err_msg("error.client.datastore.no_result") + return { result: value }; + } catch (e) { + return handle_err(e); + } + } + + public async del_pref(key_prefix: string): Promise<IClientDatastoreDelPrefResolve> { + try { + const all_keys = await idb_keys(this.get_store()); + console.log(JSON.stringify(all_keys, null, 4), `all_keys`) + const filtered_keys = all_keys.filter((k): k is string => (typeof k === "string" && k.startsWith(key_prefix))); + console.log(JSON.stringify(filtered_keys, null, 4), `filtered_keys`) + for (const key of filtered_keys) { + await idb_del(key); + } + return { results: filtered_keys }; + } catch (e) { + return handle_err(e); + } + } + + public async keys(): Promise<IClientDatastoreKeysResolve> { + try { + const all_keys = await idb_keys(this.get_store()); + return { results: all_keys.filter((k): k is string => typeof k === "string") }; + } catch (e) { + return handle_err(e); + } + } + + public async reset() { + try { + await idb_clear(this.get_store()); + return { pass: true } as const; + } catch (e) { + return handle_err(e); + } + } +} +\ No newline at end of file diff --git a/client/src/fs/index.ts b/client/src/fs/index.ts @@ -0,0 +1,3 @@ +export * from "./types.js"; +export * from "./web.js"; + diff --git a/client/src/fs/tauri.ts b/client/src/fs/tauri.ts @@ -1,24 +0,0 @@ -import { BaseDirectory, exists, open, readFile, stat } from '@tauri-apps/plugin-fs'; -import type { IClientFs, IClientFsExistsResolve, IClientFsInfoResolve, IClientFsOpenResolve, IClientFsReadBinResolve } from "./types"; - -export class TauriClientFs implements IClientFs { - public async exists(path: string): Promise<IClientFsExistsResolve> { - const res = await exists(path, { baseDir: BaseDirectory.AppData }); - return res; - } - - public async open(path: string): Promise<IClientFsOpenResolve> { - const res = await open(path, { read: true, baseDir: BaseDirectory.AppData }); - return res; - } - - public async read_bin(path: string): Promise<IClientFsReadBinResolve> { - const res = await readFile(path, { baseDir: BaseDirectory.AppData }); - return res; - } - - public async info(path: string): Promise<IClientFsInfoResolve> { - const res = await stat(path, { baseDir: BaseDirectory.AppData }); - return res; - } -} diff --git a/client/src/fs/types.ts b/client/src/fs/types.ts @@ -1,17 +1,19 @@ -import type { FileHandle, FileInfo } from "@tauri-apps/plugin-fs"; +import { type ResolveError } from "@radroots/utils"; -export type IClientFsOpenResult = FileHandle; +export type IClientFsOpenResult = { path: string } -export type IClientFsFileInfo = FileInfo; - -export type IClientFsExistsResolve = boolean; -export type IClientFsOpenResolve = IClientFsOpenResult; -export type IClientFsReadBinResolve = Uint8Array; -export type IClientFsInfoResolve = IClientFsFileInfo; +export type IClientFsFileInfo = { + size: number + isFile: boolean + isDirectory: boolean + accessedAt?: number + modifiedAt?: number + createdAt?: number +}; export type IClientFs = { - exists(path: string): Promise<IClientFsExistsResolve>; - open(path: string): Promise<IClientFsOpenResolve>; - read_bin(path: string): Promise<IClientFsReadBinResolve>; - info(path: string): Promise<IClientFsInfoResolve>; -}; -\ No newline at end of file + exists(path: string): Promise<ResolveError<boolean>>; + open(path: string): Promise<ResolveError<IClientFsOpenResult>>; + info(path: string): Promise<ResolveError<IClientFsFileInfo>>; + read_bin(path: string): Promise<ResolveError<Uint8Array>>; +}; diff --git a/client/src/fs/web.ts b/client/src/fs/web.ts @@ -0,0 +1,38 @@ +import { handle_err } from "@radroots/utils"; +import type { IClientFs } from "./types.js"; + +export class WebFs implements IClientFs { + public async exists(path: string) { + try { + const res = await fetch(path, { method: 'HEAD' }); + return res.ok; + } catch (e) { + return handle_err(e); + } + } + + public async open(path: string) { + return { path }; + } + + public async info(path: string) { + try { + const res = await fetch(path, { method: 'HEAD' }); + const sizeHeader = res.headers.get('Content-Length'); + const size = sizeHeader ? Number(sizeHeader) : 0; + return { size, isFile: true, isDirectory: false }; + } catch (e) { + return handle_err(e); + } + } + + public async read_bin(path: string) { + try { + const res = await fetch(path); + const buf = await res.arrayBuffer(); + return new Uint8Array(buf); + } catch (e) { + return handle_err(e); + } + } +} diff --git a/client/src/geolocation/index.ts b/client/src/geolocation/index.ts @@ -0,0 +1,3 @@ +export * from "./types.js"; +export * from "./web.js"; + diff --git a/client/src/geolocation/tauri.ts b/client/src/geolocation/tauri.ts @@ -1,52 +0,0 @@ -import { err_msg, handle_error, parse_geol_coords, type ErrorMessage, type IClientGeolocation, type IClientGeolocationPosition, type IGeolocationErrorMessage } from '@radroots/util'; -import { - checkPermissions, - getCurrentPosition, - requestPermissions, - type PermissionStatus, - type Position -} from '@tauri-apps/plugin-geolocation'; - -export class TauriClientGeolocation implements IClientGeolocation { - private parse_geolocation_position({ coords: geol_p }: Position): IClientGeolocationPosition { - const position: IClientGeolocationPosition = { - lat: parse_geol_coords(geol_p.latitude), - lng: parse_geol_coords(geol_p.longitude), - accuracy: geol_p.accuracy ?? undefined, - altitude: geol_p.altitude ?? undefined, - altitude_accuracy: geol_p.altitudeAccuracy ?? undefined - }; - return position; - } - - private async has_permissions(): Promise<boolean> { - try { - const permissions = await checkPermissions(); - if (permissions.location !== `granted`) { - const permission = await this.request_permissions(); - if (permission.location !== `granted`) return false - }; - return true; - } catch (e) { - console.log(`e has_permissions`, e); - return false; - } - } - - public async request_permissions(): Promise<PermissionStatus> { - return await requestPermissions(['location']); - } - - public async current(): Promise<IClientGeolocationPosition | ErrorMessage<IGeolocationErrorMessage>> { - try { - if (!(await this.has_permissions())) return err_msg(`error.client.geolocation.permission_denied`); - const position = await getCurrentPosition() - return this.parse_geolocation_position(position); - } catch (e) { - console.log(`e current`, e) - const { err } = handle_error(e); - if (err.includes(`The operation couldn’t be completed`)) return err_msg(`error.client.geolocation.location_unavailable`); - return err_msg(`*`); - }; - } -} diff --git a/client/src/geolocation/types.ts b/client/src/geolocation/types.ts @@ -0,0 +1,11 @@ +import type { IClientGeolocationPosition, ResolveErrorMsg } from "@radroots/utils"; + +export type IGeolocationIError = + | `error.client.geolocation.permission_denied` + | `error.client.geolocation.location_unavailable` + | `error.client.geolocation.timeout` + | `*`; + +export type IClientGeolocation = { + current(): Promise<ResolveErrorMsg<IClientGeolocationPosition, IGeolocationIError>>; +}; +\ No newline at end of file diff --git a/client/src/geolocation/web.ts b/client/src/geolocation/web.ts @@ -0,0 +1,22 @@ +import { err_msg } from "@radroots/utils"; +import type { IClientGeolocation } from "./types.js"; + +export class WebGeolocation implements IClientGeolocation { + public async current() { + try { + if (!navigator.geolocation) return err_msg("error.client.geolocation.location_unavailable"); + + const position = await new Promise<GeolocationPosition>((resolve, reject) => + navigator.geolocation.getCurrentPosition(resolve, reject) + ); + + return { + lat: position.coords.latitude, + lng: position.coords.longitude, + accuracy: position.coords.accuracy + }; + } catch (e) { + return err_msg("*"); + } + }; +} diff --git a/client/src/gui/tauri.ts b/client/src/gui/tauri.ts @@ -1,64 +0,0 @@ - -import { type IClientGui, type IClientGuiDialogConfirmOpts, type IClientGuiDialogKind, type IClientGuiDialogResolve, type IClientGuiNotifyPermission, type IClientGuiNotifySendOptions } from '@radroots/util'; -import { confirm, type ConfirmDialogOptions, message, open, type OpenDialogOptions } from '@tauri-apps/plugin-dialog'; -import { - isPermissionGranted as isPermissionGrantedNotification, - type Options as NotificationsOptions, - requestPermission as requestPermissionNotification, - sendNotification -} from '@tauri-apps/plugin-notification'; - -export class TauriClientGui implements IClientGui { - public async alert(opts: string, title?: string, kind?: IClientGuiDialogKind): Promise<boolean> { - await message(opts, { title: title || ``, kind: kind || `info` }); - return true; - } - - public async confirm(opts: IClientGuiDialogConfirmOpts): Promise<boolean> { - const msg = typeof opts === `string` ? opts : opts.message; - const options: ConfirmDialogOptions = { title: `` }; - if (typeof opts !== `string`) { - options.title = opts.title || ``; - options.kind = opts.kind || `info`; - if (opts.cancel) options.cancelLabel = opts.cancel; - if (opts.ok) options.okLabel = opts.ok; - } - return await confirm(msg, options); - } - - public notify_init = async (): Promise<IClientGuiNotifyPermission | undefined> => { - return await requestPermissionNotification(); - } - - public notify_send = async (opts: string | IClientGuiNotifySendOptions): Promise<void> => { - if (!(await isPermissionGrantedNotification())) { - const permission = await this.notify_init(); - if (permission !== 'granted') return; - }; - const options: NotificationsOptions = typeof opts === `string` ? { - title: `Radroots`, - body: opts - } : { - id: opts.id, - channelId: opts.channel_id, - title: opts.title, - body: opts.body - }; - sendNotification(options); - } - - public async open_photos(): Promise<IClientGuiDialogResolve | undefined> { - const options: OpenDialogOptions = { - multiple: true, - directory: false, - filters: [{ - name: `Image`, - extensions: ['png'] - }] - }; - const res = await open(options) as any; - if (Array.isArray(res)) return { results: res.map(i => String(i)) }; - else if (typeof res === `string`) return { results: [res] }; - return undefined; - } -} diff --git a/client/src/haptics/tauri.ts b/client/src/haptics/tauri.ts @@ -1,33 +0,0 @@ -import { - impactFeedback, - notificationFeedback, - selectionFeedback, - vibrate, -} from '@tauri-apps/plugin-haptics'; -import type { IClientHaptics, IClientHapticsFeedback, IClientHapticsImpact } from './types'; - -export class TauriClientHaptics implements IClientHaptics { - public impact = async (opts: IClientHapticsImpact = `medium`): Promise<void> => { - try { - await impactFeedback(opts); - } catch (e) { }; - }; - - public vibrate = async (duration: number = 10): Promise<void> => { - try { - await vibrate(duration); - } catch (e) { }; - }; - - public feedback = async (opts: IClientHapticsFeedback): Promise<void> => { - try { - await notificationFeedback(opts); - } catch (e) { }; - }; - - public selection = async (): Promise<void> => { - try { - await selectionFeedback(); - } catch (e) { }; - }; -} diff --git a/client/src/haptics/types.ts b/client/src/haptics/types.ts @@ -1,11 +0,0 @@ -import type { ImpactFeedbackStyle, NotificationFeedbackType } from "@tauri-apps/plugin-haptics"; - -export type IClientHapticsImpact = ImpactFeedbackStyle; -export type IClientHapticsFeedback = NotificationFeedbackType; - -export type IClientHaptics = { - impact: (opts: IClientHapticsImpact) => Promise<void>; - vibrate: (duration?: number) => Promise<void>; - feedback: (opts: IClientHapticsFeedback) => Promise<void>; - selection: () => Promise<void>; -}; -\ No newline at end of file diff --git a/client/src/http/index.ts b/client/src/http/index.ts @@ -0,0 +1,3 @@ +export * from "./types.js"; +export * from "./web.js"; + diff --git a/client/src/http/tauri.ts b/client/src/http/tauri.ts @@ -1,68 +0,0 @@ -import { err_msg, http_fetch_opts, lib_http_parse_headers, lib_http_parse_response, type ErrorMessage, type FieldRecord, type IHttpImageResponse, type IHttpOpts, type IHttpResponse } from '@radroots/util'; -import { fetch, type ClientOptions } from '@tauri-apps/plugin-http'; -import type { IClientHttp } from './types'; - -export class TauriClientHttp implements IClientHttp { - private _headers: FieldRecord; - - constructor() { - this._headers = { - "Content-Type": 'application/json', - "User-Agent": `radroots/1.0.0`, - "X-Radroots-Version": `radroots/*`, - }; - } - - public async init(opts?: { - app_version?: string; - app_hash?: string; - }): Promise<void> { - if (opts?.app_version) this._headers["User-Agent"] = `radroots/${opts.app_version}`; - if (opts?.app_hash) this._headers["X-Radroots-Version"] = `radroots/${opts.app_hash}`; - } - - public async fetch(opts: IHttpOpts): Promise<IHttpResponse | ErrorMessage<string>> { - try { - const { url, options } = http_fetch_opts(opts); - const response = await fetch(url, options); - return lib_http_parse_response(response); - } catch (e) { - console.log(`e fetch`, e) - return err_msg(String(e)); - }; - } - - public async fetch_image(url: string): Promise<IHttpImageResponse | ErrorMessage<string>> { - try { - const headers: FieldRecord = { - ...this._headers, - }; - const options: RequestInit & ClientOptions = { - method: `GET`, - headers, - } - const response = await fetch(url, options); - switch (response.ok) { - case true: { - const blob = await response.blob(); - return { - status: response.status, - url: response.url, - blob, - headers: lib_http_parse_headers(response.headers) - }; - } - case false: { - return { - status: response.status, - url: response.url, - headers: lib_http_parse_headers(response.headers) - }; - } - } - } catch (e) { - console.log(`e fetch_image`, e) - return err_msg(String(e)); - }; - } -} -\ No newline at end of file diff --git a/client/src/http/types.ts b/client/src/http/types.ts @@ -1,6 +1,6 @@ -import type { ErrorMessage, IHttpImageResponse, IHttpOpts, IHttpResponse } from "@radroots/util"; +import type { IHttpImageResponse, IHttpOpts, IHttpResponse, ResolveError } from "@radroots/utils"; export type IClientHttp = { - fetch(opts: IHttpOpts): Promise<IHttpResponse | ErrorMessage<string>>; - fetch_image(url: string): Promise<IHttpImageResponse | ErrorMessage<string>>; -}; -\ No newline at end of file + fetch(opts: IHttpOpts): Promise<ResolveError<IHttpResponse>>; + fetch_image(url: string): Promise<ResolveError<IHttpImageResponse>>; +}; diff --git a/client/src/http/web.ts b/client/src/http/web.ts @@ -0,0 +1,65 @@ +import { handle_err, http_fetch_opts, lib_http_parse_headers, lib_http_parse_response, type FieldRecord, type IHttpOpts } from '@radroots/utils'; +import type { IClientHttp } from "./types.js"; + +export class WebHttp implements IClientHttp { + private _headers: FieldRecord; + + constructor() { + this._headers = { + "Content-Type": 'application/json', + "User-Agent": `radroots/1.0.0`, + "X-Radroots-Version": `radroots/*`, + }; + } + + public async init(opts?: { + app_version?: string; + app_hash?: string; + }) { + if (opts?.app_version) this._headers["User-Agent"] = `radroots/${opts.app_version}`; + if (opts?.app_hash) this._headers["X-Radroots-Version"] = `radroots/${opts.app_hash}`; + } + + public async fetch(opts: IHttpOpts) { + try { + const { url, options } = http_fetch_opts(opts); + const response = await fetch(url, options); + return lib_http_parse_response(response); + } catch (e) { + return handle_err(e); + }; + } + + public async fetch_image(url: string) { + try { + const headers: FieldRecord = { + ...this._headers, + }; + const options: RequestInit = { + method: `GET`, + headers, + } + const response = await fetch(url, options); + switch (response.ok) { + case true: { + const blob = await response.blob(); + return { + status: response.status, + url: response.url, + blob, + headers: lib_http_parse_headers(response.headers) + }; + } + case false: { + return { + status: response.status, + url: response.url, + headers: lib_http_parse_headers(response.headers) + }; + } + } + } catch (e) { + return handle_err(e); + }; + } +} +\ No newline at end of file diff --git a/client/src/index.ts b/client/src/index.ts @@ -1,18 +1,8 @@ -export * from "./database/tauri/lib" -export * from "./database/tauri/types" -export * from "./datastore/tauri" -export * from "./fs/tauri" -export * from "./fs/types" -export * from "./geolocation/tauri" -export * from "./gui/tauri" -export * from "./haptics/tauri" -export * from "./haptics/types" -export * from "./http/tauri" -export * from "./http/types" -export * from "./keys/tauri" -export * from "./keys/types" -export * from "./lib" -export * from "./radroots/tauri" -export * from "./radroots/types" -export * from "./types" -export * from "./util" +export * as datastore from "./datastore/index.js" +export * as fs from "./fs/index.js" +export * as geolocation from "./geolocation/index.js" +export * as http from "./http/index.js" +export * as keystore from "./keystore/index.js" +export * as notifications from "./notifications/index.js" +export * as radroots from "./radroots/index.js" +export * as sql from "./sql/index.js" diff --git a/client/src/keys/tauri.ts b/client/src/keys/tauri.ts @@ -1,79 +0,0 @@ - -import { lib_nostr_secret_key_validate } from '@radroots/nostr-util'; -import { err_msg, type ErrorMessage, is_pass_response, is_result_response, is_results_response } from '@radroots/util'; -import { invoke } from '@tauri-apps/api/core'; -import type { IClientKeys, IClientKeysNostrAddResolve, IClientKeysNostrCreateResolve, IClientKeysNostrDeleteResolve, IClientKeysNostrKeystoreResetResolve, IClientKeysNostrReadAllResolve, IClientKeysNostrReadResolve } from './types'; - -export class TauriClientKeys implements IClientKeys { - private async command(cmd: string, opts?: any): Promise<any> { - return await invoke<any>(cmd, opts ? opts : undefined); - }; - - private handle_error = (e: any): ErrorMessage<string> => { - const err = String(e); - if (err) return err_msg(err); - return err_msg(`*`); - } - - public nostr_gen = async (): Promise<IClientKeysNostrCreateResolve> => { - try { - const response = await this.command("keys_nostr_gen"); - if (is_result_response(response)) return { public_key: response.result }; - return err_msg(`*-result`); - } catch (e) { - return this.handle_error(e); - } - } - - public nostr_add = async (nsec_or_hex: string): Promise<IClientKeysNostrAddResolve> => { - try { - const secret_key = lib_nostr_secret_key_validate(nsec_or_hex); - if (!secret_key) return err_msg(`*-key`); - const response = await this.command("keys_nostr_add", { secret_key }); - if (is_result_response(response)) return { public_key: response.result }; - return err_msg(`*-result`); - } catch (e) { - return this.handle_error(e); - } - } - - public nostr_read = async (public_key: string): Promise<IClientKeysNostrReadResolve> => { - try { - const response = await this.command("keys_nostr_read", { public_key }); - if (is_result_response(response)) return { secret_key: response.result }; - return err_msg(`*-result`); - } catch (e) { - return this.handle_error(e); - } - } - - public nostr_read_all = async (): Promise<IClientKeysNostrReadAllResolve> => { - try { - const response = await this.command("keys_nostr_read_all"); - if (is_results_response(response)) return { results: response.results }; - return err_msg(`*-result`); - } catch (e) { - return this.handle_error(e); - } - } - - public nostr_delete = async (public_key: string): Promise<IClientKeysNostrDeleteResolve> => { - try { - const response = await this.command("keys_nostr_delete", { public_key }); - if (is_pass_response(response)) return { pass: true }; - return err_msg(`*-result`); - } catch (e) { - return this.handle_error(e); - } - } - - public nostr_keystore_reset = async (): Promise<IClientKeysNostrKeystoreResetResolve> => { - try { - const response = await this.command("keys_nostr_keystore_reset"); - if (is_pass_response(response)) return { pass: true }; - return err_msg(`*-result`); - } catch (e) { - return this.handle_error(e); - } - } -} diff --git a/client/src/keys/types.ts b/client/src/keys/types.ts @@ -1,25 +0,0 @@ -import type { ErrorMessage, ResultPass, ResultPublicKey, ResultSecretKey, ResultsList } from "@radroots/util"; - -export type ICoreDeviceMetadata = { - os_arch: string; - os_platform: string; - os_version: string; - app_name: string; - app_version: string; -}; - -export type IClientKeysNostrCreateResolve = ResultPublicKey | ErrorMessage<string>; -export type IClientKeysNostrAddResolve = ResultPublicKey | ErrorMessage<string>; -export type IClientKeysNostrReadResolve = ResultSecretKey | ErrorMessage<string>; -export type IClientKeysNostrReadAllResolve = ResultsList<string> | ErrorMessage<string>; -export type IClientKeysNostrDeleteResolve = ResultPass | ErrorMessage<string>; -export type IClientKeysNostrKeystoreResetResolve = ResultPass | ErrorMessage<string>; - -export type IClientKeys = { - nostr_gen(): Promise<IClientKeysNostrCreateResolve>; - nostr_add(secret_key: string): Promise<IClientKeysNostrAddResolve>; - nostr_read(public_key: string): Promise<IClientKeysNostrReadResolve>; - nostr_read_all(): Promise<IClientKeysNostrReadAllResolve>; - nostr_delete(public_key: string): Promise<IClientKeysNostrDeleteResolve>; - nostr_keystore_reset(): Promise<IClientKeysNostrKeystoreResetResolve>; -}; -\ No newline at end of file diff --git a/client/src/keystore/aes-gcm-cipher.ts b/client/src/keystore/aes-gcm-cipher.ts @@ -0,0 +1,98 @@ +import { createStore, del as idb_del, get as idb_get, set as idb_set } from "idb-keyval"; + +function asArrayBuffer(u8: Uint8Array): ArrayBuffer { + if (u8.byteOffset === 0 && u8.buffer instanceof ArrayBuffer && u8.byteLength === u8.buffer.byteLength) { + return u8.buffer; + } + return u8.slice().buffer; +} + +export class AesGcmKeystoreCipher { + private static readonly dbName = "radroots-aes-gcm-keystore"; + private static readonly storeName = "default"; + private static readonly keystoreKey = "radroots.aes-gcm.key"; + private static readonly algorithmName = "AES-GCM"; + private static readonly keyUsages: KeyUsage[] = ["encrypt", "decrypt"]; + private static readonly ivLength = 12; + private static readonly store = createStore(AesGcmKeystoreCipher.dbName, AesGcmKeystoreCipher.storeName); + private static cachedKey: CryptoKey | null = null; + + private static async importKey(rawKey: Uint8Array): Promise<CryptoKey> { + return crypto.subtle.importKey( + "raw", + asArrayBuffer(rawKey), + AesGcmKeystoreCipher.algorithmName, + false, + AesGcmKeystoreCipher.keyUsages + ); + } + + private static async generateAndPersistKey(): Promise<CryptoKey> { + const key = await crypto.subtle.generateKey( + { name: AesGcmKeystoreCipher.algorithmName, length: 256 }, + true, + AesGcmKeystoreCipher.keyUsages + ); + const raw = new Uint8Array(await crypto.subtle.exportKey("raw", key)); + try { + await idb_set(AesGcmKeystoreCipher.keystoreKey, raw, AesGcmKeystoreCipher.store); + const importedKey = await AesGcmKeystoreCipher.importKey(raw); + AesGcmKeystoreCipher.cachedKey = importedKey; + return importedKey; + } finally { + raw.fill(0); + } + } + + static async load_key(): Promise<CryptoKey> { + if (AesGcmKeystoreCipher.cachedKey) { + return AesGcmKeystoreCipher.cachedKey; + } + const existing = await idb_get(AesGcmKeystoreCipher.keystoreKey, AesGcmKeystoreCipher.store); + if (existing instanceof Uint8Array) { + const key = await AesGcmKeystoreCipher.importKey(existing); + AesGcmKeystoreCipher.cachedKey = key; + return key; + } + return AesGcmKeystoreCipher.generateAndPersistKey(); + } + + static async reset(): Promise<void> { + AesGcmKeystoreCipher.cachedKey = null; + await idb_del(AesGcmKeystoreCipher.keystoreKey, AesGcmKeystoreCipher.store); + } + + static async encrypt(data: Uint8Array): Promise<Uint8Array> { + const key = await AesGcmKeystoreCipher.load_key(); + const iv = crypto.getRandomValues(new Uint8Array(AesGcmKeystoreCipher.ivLength)); + const ciphertextBuffer = await crypto.subtle.encrypt( + { name: AesGcmKeystoreCipher.algorithmName, iv: asArrayBuffer(iv) }, + key, + asArrayBuffer(data) + ); + const ciphertext = new Uint8Array(ciphertextBuffer); + const out = new Uint8Array(AesGcmKeystoreCipher.ivLength + ciphertext.byteLength); + out.set(iv, 0); + out.set(ciphertext, AesGcmKeystoreCipher.ivLength); + return out; + } + + static async decrypt(blob: Uint8Array): Promise<Uint8Array> { + if (blob.byteLength < AesGcmKeystoreCipher.ivLength + 1) { + return blob; + } + const key = await AesGcmKeystoreCipher.load_key(); + const iv = blob.slice(0, AesGcmKeystoreCipher.ivLength); + const ciphertext = blob.slice(AesGcmKeystoreCipher.ivLength); + try { + const plaintext = await crypto.subtle.decrypt( + { name: AesGcmKeystoreCipher.algorithmName, iv: asArrayBuffer(iv) }, + key, + asArrayBuffer(ciphertext) + ); + return new Uint8Array(plaintext); + } catch { + return blob; + } + } +} diff --git a/client/src/keystore/index.ts b/client/src/keystore/index.ts @@ -0,0 +1,4 @@ +export * from "./aes-gcm-cipher.js"; +export * from "./types.js"; +export * from "./web.js"; + diff --git a/client/src/keystore/types.ts b/client/src/keystore/types.ts @@ -0,0 +1,11 @@ +import type { ResolveError, ResultObj, ResultPass, ResultsList } from "@radroots/utils"; + +export type IClientKeystoreValue = string | null; + +export type IClientKeystore = { + add(key: string, value: string): Promise<ResolveError<ResultObj<string>>>; + remove(key: string): Promise<ResolveError<ResultObj<string>>>; + read(key?: string | null): Promise<ResolveError<ResultObj<IClientKeystoreValue>>>; + keys(key: string): Promise<ResolveError<ResultsList<string>>>; + reset(): Promise<ResolveError<ResultPass>>; +}; +\ No newline at end of file diff --git a/client/src/keystore/web.ts b/client/src/keystore/web.ts @@ -0,0 +1,78 @@ +import { err_msg, handle_err, text_dec, text_enc } from "@radroots/utils"; +import { createStore, clear as idb_clear, del as idb_del, get as idb_get, keys as idb_keys, set as idb_set, type UseStore } from "idb-keyval"; +import { type IClientIdbConfig } from "../utils/idb.js"; +import { AesGcmKeystoreCipher } from "./aes-gcm-cipher.js"; +import type { IClientKeystore } from "./types.js"; + + +export class WebKeystore implements IClientKeystore { + private db_name: string; + private store_name: string; + private store: UseStore | null = null; + + constructor(config?: IClientIdbConfig) { + this.db_name = config?.database || "radroots-web-keystore"; + this.store_name = config?.store || "default"; + this.store = null; + AesGcmKeystoreCipher.load_key(); + } + + private get_store(): UseStore { + if (!this.store) { + if (typeof indexedDB === "undefined") throw new Error("error.client.keystore.idb_undefined"); + this.store = createStore(this.db_name, this.store_name); + } + return this.store; + } + + public async add(key: string, value: string) { + try { + const bytes = text_enc(value); + const cipher = await AesGcmKeystoreCipher.encrypt(bytes); + await idb_set(key, cipher, this.get_store()); + return { result: key }; + } catch (e) { + return handle_err(e); + } + } + + public async remove(key: string) { + try { + await idb_del(key, this.get_store()); + return { result: key }; + } catch (e) { + return handle_err(e); + } + } + + public async read(key?: string | null) { + try { + if (!key) return err_msg("error.client.keystore.missing_key"); + const cipher = await idb_get<Uint8Array | null>(key, this.get_store()); + if (!(cipher instanceof Uint8Array)) return err_msg("error.client.keystore.corrupt_data"); + const bytes = await AesGcmKeystoreCipher.decrypt(cipher); + const plain = text_dec(bytes); + return { result: plain }; + } catch (e) { + return handle_err(e); + } + } + + public async keys() { + try { + const all_keys = await idb_keys(this.get_store()); + return { results: all_keys.filter((k): k is string => typeof k === "string") }; + } catch (e) { + return handle_err(e); + } + } + + public async reset() { + try { + await idb_clear(this.get_store()); + return { pass: true } as const; + } catch (e) { + return handle_err(e); + } + } +} diff --git a/client/src/lib.ts b/client/src/lib.ts @@ -1,3 +0,0 @@ -import { TauriClientHttp } from "."; - -export const lib_http = new TauriClientHttp(); diff --git a/client/src/notifications/index.ts b/client/src/notifications/index.ts @@ -0,0 +1,3 @@ +export * from "./types.js"; +export * from "./web.js"; + diff --git a/client/src/notifications/types.ts b/client/src/notifications/types.ts @@ -0,0 +1,39 @@ +import { type IResultList } from "@radroots/types-bindings"; +import { type ResolveError, type ResolveStatus } from "@radroots/utils"; + + +export type IClientNotificationsNotifyPermission = "granted" | "denied" | "default" | "unavailable"; + +export type IClientNotificationsDialogConfirmOpts = + | string + | { + message: string; + title?: string; + status?: ResolveStatus; + cancel?: string; + ok?: string; + } + +export type IClientNotificationsNotifySendOptions = { + id?: string; + channel_id?: string; + title?: string; + body?: string; +} + +export type IClientNotificationsConfig = { + app_name: string; +}; + +export type IClientNotificationsAlertResolve = boolean; +export type IClientNotificationsConfirmResolve = boolean; +export type IClientNotificationsNotifyInitResolve = ResolveError<IClientNotificationsNotifyPermission>; +export type IClientNotificationsNotifySendResolve = ResolveError<Notification> + +export type IClientNotifications = { + alert(opts: string, title?: string, status?: ResolveStatus): Promise<IClientNotificationsAlertResolve>; + confirm(opts: IClientNotificationsDialogConfirmOpts): Promise<IClientNotificationsConfirmResolve>; + notify_init(): Promise<IClientNotificationsNotifyInitResolve>; + notify_send(opts: string | IClientNotificationsNotifySendOptions): Promise<IClientNotificationsNotifySendResolve>; + open_photos(): Promise<ResolveError<IResultList<string> | undefined>> +} diff --git a/client/src/notifications/web.ts b/client/src/notifications/web.ts @@ -0,0 +1,75 @@ +import { IResultList } from "@radroots/types-bindings"; +import { err_msg, handle_err, type ResolveStatus } from "@radroots/utils"; +import type { IClientNotifications, IClientNotificationsConfig, IClientNotificationsDialogConfirmOpts, IClientNotificationsNotifySendOptions } from "./types.js"; + +export class WebNotifications implements IClientNotifications { + private _config: IClientNotificationsConfig; + + constructor(config: IClientNotificationsConfig = { app_name: "Radroots" }) { + this._config = config; + } + public async alert(opts: string, title?: string, kind?: ResolveStatus) { + try { + const msg = title ? `${title}\n\n${opts}` : opts; + window.alert(msg); + return true; + } catch (e) { + handle_err(e); + return false; + } + } + + public async confirm(opts: IClientNotificationsDialogConfirmOpts) { + try { + const msg = typeof opts === 'string' ? opts : opts.message + return window.confirm(msg); + } catch (e) { + handle_err(e); + return false; + } + } + + public async notify_init() { + try { + if (!("Notification" in window)) return "unavailable"; + if (Notification.permission === 'granted') return "granted"; + return await Notification.requestPermission(); + } catch (e) { + return handle_err(e); + } + } + + public async notify_send(opts: string | IClientNotificationsNotifySendOptions) { + try { + if (!("Notification" in window)) return err_msg("unavailable"); + if (Notification.permission !== "granted") { + const permission = await this.notify_init(); + if (permission !== "granted") return err_msg("unavailable"); + } + if (typeof opts === "string") return new Notification(this._config.app_name, { body: opts }); + else return new Notification(opts.title || this._config.app_name, { body: opts.body }); + } catch (e) { + return handle_err(e); + } + } + + public async open_photos() { + return await new Promise<IResultList<string> | undefined>((resolve) => { + const input = document.createElement('input'); + input.type = 'file'; + input.multiple = true; + input.accept = 'image/png'; + input.onchange = () => { + const files = input.files; + if (!files) return resolve(undefined); + const results: string[] = []; + for (let i = 0; i < files.length; i++) { + const url = URL.createObjectURL(files[i]!); + results.push(url); + } + resolve({ results }); + } + input.click(); + }) + } +} diff --git a/client/src/radroots/index.ts b/client/src/radroots/index.ts @@ -0,0 +1,3 @@ +export * from "./types.js"; +export * from "./web.js"; + diff --git a/client/src/radroots/tauri.ts b/client/src/radroots/tauri.ts @@ -1,105 +0,0 @@ - -import { lib_nostr_event_sign_attest } from '@radroots/nostr-util'; -import { err_msg, type IHttpResponse, is_err_response, is_error_response } from '@radroots/util'; -import { lib_http } from '../lib'; -import type { IClientRadroots, IClientRadrootsFetchMediaImageUpload, IClientRadrootsFetchMediaImageUploadResolve, IClientRadrootsFetchProfileActivate, IClientRadrootsFetchProfileActivateResolve, IClientRadrootsFetchProfileCreate, IClientRadrootsFetchProfileCreateResolve, IClientRadrootsFetchProfileRequest, IClientRadrootsFetchProfileRequestResolve } from './types'; - -export class TauriClientRadroots implements IClientRadroots { - private _base_url: string; - - constructor(base_url: string) { - this._base_url = base_url.replaceAll(`/`, ``); - } - - private is_res_pass(res: IHttpResponse): boolean { - return res.data && res.data.pass === true; - } - - private parse_res_field(field: any): string | undefined { - if (typeof field === `string` && field) return field; - } - - public fetch_profile_request = async (opts: IClientRadrootsFetchProfileRequest): Promise<IClientRadrootsFetchProfileRequestResolve> => { - const { profile_name, secret_key } = opts; - const res = await lib_http.fetch({ - url: `${this._base_url}/public/profile/request`, - method: `post`, - headers: { - "X-Nostr-Event": JSON.stringify(lib_nostr_event_sign_attest(secret_key)), - }, - data: { - profile_name - } - }); - if (is_err_response(res)) return res; - if (is_error_response(res)) return err_msg(res.error); - else if (this.is_res_pass(res)) { - const tok = this.parse_res_field(res.data.tok); - if (tok) return { result: res.data.tok }; - } - return err_msg(`error.radroots.profile_registered`); - } - - public fetch_profile_create = async (opts: IClientRadrootsFetchProfileCreate): Promise<IClientRadrootsFetchProfileCreateResolve> => { - const { tok, secret_key } = opts; - const res = await lib_http.fetch({ - url: `${this._base_url}/public/profile/create`, - method: `post`, - headers: { - "X-Nostr-Event": JSON.stringify(lib_nostr_event_sign_attest(secret_key)), - }, - authorization: tok, - }); - if (is_err_response(res)) return res; - if (is_error_response(res)) return err_msg(res.error); - else if (this.is_res_pass(res)) { - const id = this.parse_res_field(res.data.id); - if (id) return { result: id }; - } - return err_msg(`error.client.request_failure`); - } - - public fetch_profile_activate = async (opts: IClientRadrootsFetchProfileActivate): Promise<IClientRadrootsFetchProfileActivateResolve> => { - const { id, secret_key } = opts; - const res = await lib_http.fetch({ - url: `${this._base_url}/public/profile/activate`, - method: `post`, - headers: { - "X-Nostr-Event": JSON.stringify(lib_nostr_event_sign_attest(secret_key)), - }, - data: { - id - } - }); - if (is_err_response(res)) return res; - if (is_error_response(res)) return err_msg(res.error); - else if (this.is_res_pass(res)) return { pass: true }; - return err_msg(`error.client.request_failure`); - } - - public fetch_media_image_upload = async (opts: IClientRadrootsFetchMediaImageUpload): Promise<IClientRadrootsFetchMediaImageUploadResolve> => { - const { file_path, file_data, secret_key } = opts; - const res = await lib_http.fetch({ - url: `${this._base_url}/public/media/image/upload`, - method: `put`, - headers: { - "Content-Type": file_path.mime_type, - "X-Nostr-Event": JSON.stringify(lib_nostr_event_sign_attest(secret_key)), - }, - data_bin: file_data, - }); - if (is_err_response(res)) return res; - if (is_error_response(res)) return err_msg(res.error); - else if ( - this.is_res_pass(res) && - `res_base` in res.data && - typeof res.data.res_base === `string` && - `res_path` in res.data && - typeof res.data.res_path === `string`) return { - res_base: res.data.res_base, - res_path: res.data.res_path, - }; - return err_msg(`error.client.request_failure`); - } -} - diff --git a/client/src/radroots/types.ts b/client/src/radroots/types.ts @@ -1,4 +1,5 @@ -import { type ErrorMessage, type FilePath, type ResultObj, type ResultPass } from '@radroots/util'; +import type { IError } from "@radroots/types-bindings"; +import { type FilePath, type ResultObj, type ResultPass } from '@radroots/utils'; export type IClientRadrootsFetchProfileRequestMessage = | string @@ -6,16 +7,17 @@ export type IClientRadrootsFetchProfileRequestMessage = | `*-registered`; export type IClientRadrootsFetchProfileRequest = { profile_name: string; secret_key: string; }; -export type IClientRadrootsFetchProfileRequestResolve = ResultObj<string> | ErrorMessage<IClientRadrootsFetchProfileRequestMessage>; +export type IClientRadrootsFetchProfileRequestResolve = ResultObj<string> | IError<IClientRadrootsFetchProfileRequestMessage>; export type IClientRadrootsFetchProfileCreate = { tok: string; secret_key: string; }; -export type IClientRadrootsFetchProfileCreateResolve = ResultObj<string> | ErrorMessage<IClientRadrootsFetchProfileRequestMessage>; +export type IClientRadrootsFetchProfileCreateResolve = ResultObj<string> | IError<IClientRadrootsFetchProfileRequestMessage>; export type IClientRadrootsFetchProfileActivate = { id: string; secret_key: string; }; -export type IClientRadrootsFetchProfileActivateResolve = ResultPass | ErrorMessage<IClientRadrootsFetchProfileRequestMessage>; +export type IClientRadrootsFetchProfileActivateResolve = ResultPass | IError<IClientRadrootsFetchProfileRequestMessage>; export type IClientRadrootsFetchMediaImageUpload = { file_path: FilePath; file_data: Uint8Array; secret_key: string; }; export type IClientRadrootsFetchMediaImageUploadResolve = any; + export type IClientRadroots = { fetch_profile_request: (opts: IClientRadrootsFetchProfileRequest) => Promise<IClientRadrootsFetchProfileRequestResolve>; fetch_profile_create: (opts: IClientRadrootsFetchProfileCreate) => Promise<IClientRadrootsFetchProfileCreateResolve>; fetch_profile_activate: (opts: IClientRadrootsFetchProfileActivate) => Promise<IClientRadrootsFetchProfileActivateResolve>; fetch_media_image_upload: (opts: IClientRadrootsFetchMediaImageUpload) => Promise<IClientRadrootsFetchMediaImageUploadResolve>; -}; -\ No newline at end of file +}; diff --git a/client/src/radroots/web.ts b/client/src/radroots/web.ts @@ -0,0 +1,99 @@ +import { err_msg, type IHttpResponse, is_err_response, is_error_response } from '@radroots/utils'; +import { lib_nostr_event_sign_attest } from '@radroots/utils-nostr'; +import { WebHttp } from '../http/web.js'; +import type { IClientRadroots, IClientRadrootsFetchMediaImageUpload, IClientRadrootsFetchMediaImageUploadResolve, IClientRadrootsFetchProfileActivate, IClientRadrootsFetchProfileActivateResolve, IClientRadrootsFetchProfileCreate, IClientRadrootsFetchProfileCreateResolve, IClientRadrootsFetchProfileRequest, IClientRadrootsFetchProfileRequestResolve } from "./types.js"; + +export class WebClientRadroots implements IClientRadroots { + private _base_url: string + private _http_client: WebHttp + + constructor(base_url: string) { + this._base_url = base_url.replaceAll(`/`, ``); + this._http_client = new WebHttp(); + } + + private is_res_pass(res: IHttpResponse): boolean { + return res.data && res.data.pass === true + } + + private parse_res_field(field: unknown): string | undefined { + if (typeof field === `string` && field) return field + } + + public fetch_profile_request = async (opts: IClientRadrootsFetchProfileRequest): Promise<IClientRadrootsFetchProfileRequestResolve> => { + const { profile_name, secret_key } = opts + const res = await this._http_client.fetch({ + url: `${this._base_url}/public/profile/request`, + method: `post`, + headers: { + "X-Nostr-Event": JSON.stringify(lib_nostr_event_sign_attest(secret_key)) + }, + data: { profile_name } + }) + if (is_err_response(res)) return res + if (is_error_response(res)) return err_msg(res.error) + else if (this.is_res_pass(res)) { + const tok = this.parse_res_field(res.data.tok) + if (tok) return { result: tok } + } + return err_msg(`error.radroots.profile_registered`) + } + + public fetch_profile_create = async (opts: IClientRadrootsFetchProfileCreate): Promise<IClientRadrootsFetchProfileCreateResolve> => { + const { tok, secret_key } = opts + const res = await this._http_client.fetch({ + url: `${this._base_url}/public/profile/create`, + method: `post`, + headers: { + "X-Nostr-Event": JSON.stringify(lib_nostr_event_sign_attest(secret_key)) + }, + authorization: tok + }) + if (is_err_response(res)) return res + if (is_error_response(res)) return err_msg(res.error) + else if (this.is_res_pass(res)) { + const id = this.parse_res_field(res.data.id) + if (id) return { result: id } + } + return err_msg(`error.client.request_failure`) + } + + public fetch_profile_activate = async (opts: IClientRadrootsFetchProfileActivate): Promise<IClientRadrootsFetchProfileActivateResolve> => { + const { id, secret_key } = opts + const res = await this._http_client.fetch({ + url: `${this._base_url}/public/profile/activate`, + method: `post`, + headers: { + "X-Nostr-Event": JSON.stringify(lib_nostr_event_sign_attest(secret_key)) + }, + data: { id } + }) + if (is_err_response(res)) return res + if (is_error_response(res)) return err_msg(res.error) + else if (this.is_res_pass(res)) return { pass: true } + return err_msg(`error.client.request_failure`) + } + + public fetch_media_image_upload = async (opts: IClientRadrootsFetchMediaImageUpload): Promise<IClientRadrootsFetchMediaImageUploadResolve> => { + const { file_path, file_data, secret_key } = opts + const res = await this._http_client.fetch({ + url: `${this._base_url}/public/media/image/upload`, + method: `put`, + headers: { + "Content-Type": file_path.mime_type, + "X-Nostr-Event": JSON.stringify(lib_nostr_event_sign_attest(secret_key)) + }, + data_bin: file_data + }) + if (is_err_response(res)) return res + if (is_error_response(res)) return err_msg(res.error) + else if ( + this.is_res_pass(res) && + typeof res.data.res_base === 'string' && + typeof res.data.res_path === 'string' + ) { + return { res_base: res.data.res_base, res_path: res.data.res_path } + } + return err_msg(`error.client.request_failure`) + } +} diff --git a/client/src/sql/index.ts b/client/src/sql/index.ts @@ -0,0 +1,3 @@ +export * from "./types.js"; +export * from "./web.js"; + diff --git a/client/src/sql/types.ts b/client/src/sql/types.ts @@ -0,0 +1,30 @@ +import type { SqlValue } from "sql.js"; + +export type SqlJsExecOutcome = { + changes: number; + last_insert_id: number; +}; + +export type SqlJsResultRow = Record<string, unknown>; + +export type SqlJsMigrationRow = { + id: number; + name: string; + applied_at: string; +}; + +export type SqlJsMigrationState = { + applied_names: string[]; + applied_count: number; +}; + +export type SqlJsValue = SqlValue; + +export type SqlJsParams = Readonly<Record<string, SqlJsValue>> | ReadonlyArray<SqlJsValue>; + + +export type IClientSqlEncryptedStore = { + load(): Promise<Uint8Array | null>; + save(bytes: Uint8Array): Promise<void>; + remove(): Promise<void>; +} +\ No newline at end of file diff --git a/client/src/sql/web.ts b/client/src/sql/web.ts @@ -0,0 +1,134 @@ +import { del as idb_del } from "idb-keyval"; +import type { BindParams, Database, SqlJsStatic, SqlValue, Statement } from "sql.js"; +import init_sql_js from "sql.js/dist/sql-wasm.js"; +import { AesGcmKeystoreCipher } from "../keystore/aes-gcm-cipher.js"; +import type { IClientSqlEncryptedStore, SqlJsExecOutcome, SqlJsParams, SqlJsResultRow } from "./types.js"; + +class WebSqlEngineEncryptedStore implements IClientSqlEncryptedStore { + constructor(private readonly key: string) { } + + async load() { + const get = (globalThis as any).indexedDB ? (await import("idb-keyval")).get : null; + if (!get) return null; + const data = await get(this.key); + if (data instanceof Uint8Array) return AesGcmKeystoreCipher.decrypt(data); + return null; + } + + async save(bytes: Uint8Array) { + const enc = await AesGcmKeystoreCipher.encrypt(bytes); + const set = (globalThis as any).indexedDB ? (await import("idb-keyval")).set : null; + if (set) await set(this.key, enc); + } + + async remove() { + await idb_del(this.key); + } +} + +export class WebSqlEngine { + private save_timer: number | undefined; + + private constructor( + private readonly sqljs: SqlJsStatic, + private readonly db: Database, + private readonly store: WebSqlEngineEncryptedStore + ) { } + + static async create(store_key: string): Promise<WebSqlEngine> { + const sql = await init_sql_js({ locateFile: f => `/assets/${f}` }); + const kv = new WebSqlEngineEncryptedStore(store_key); + const existing = await kv.load(); + const db = existing ? new sql.Database(existing) : new sql.Database(); + return new WebSqlEngine(sql, db, kv); + } + + async close(): Promise<void> { + this.db.close(); + } + + async purge_storage(): Promise<void> { + await this.store.remove(); + } + + private schedule_persist(): void { + if (this.save_timer) return; + this.save_timer = self.setTimeout(async () => { + const bytes = this.db.export(); + await this.store.save(bytes); + this.save_timer = undefined; + }, 200); + } + + public exec(sql: string, params: SqlJsParams): SqlJsExecOutcome { + const st = this.prepare(sql); + this.bind(st, params); + const result = this.consume_exec(st); + st.free(); + this.schedule_persist(); + return result; + } + + public query(sql: string, params: SqlJsParams): SqlJsResultRow[] { + const st = this.prepare(sql); + this.bind(st, params); + const rows = this.collect_rows(st); + st.free(); + return rows; + } + + private prepare(sql: string): Statement { + return this.db.prepare(sql); + } + + private bind(st: Statement, params: SqlJsParams): void { + let bindParams: BindParams; + if (Array.isArray(params)) { + bindParams = [...params]; + } else { + bindParams = { ...(params as Readonly<Record<string, SqlValue>>) }; + } + st.bind(bindParams); + } + + private consume_exec(st: Statement): SqlJsExecOutcome { + const changes_before = this.db.getRowsModified(); + let last_id = 0; + while (st.step()) { + const colNames = st.getColumnNames(); + const idx = colNames.indexOf("last_insert_rowid()"); + if (idx >= 0) { + const v = st.get()[idx]; + if (typeof v === "number") { + last_id = v; + } + } + } + const changes = this.db.getRowsModified() - changes_before; + if (!last_id) { + const res = this.db.exec("select last_insert_rowid() as id"); + if (res[0]?.values?.[0]?.[0]) { + const v = res[0].values[0][0]; + if (typeof v === "number") { + last_id = v; + } + } + } + return { changes, last_insert_id: last_id }; + } + + private collect_rows(st: Statement): SqlJsResultRow[] { + const out: SqlJsResultRow[] = []; + const names = st.getColumnNames(); + while (st.step()) { + const row = st.get(); + const obj: SqlJsResultRow = {}; + for (let i = 0; i < names.length; i++) { + obj[names[i]] = row[i]; + } + out.push(obj); + } + return out; + } +} + diff --git a/client/src/types.ts b/client/src/types.ts @@ -1,4 +0,0 @@ -import type { UnlistenFn } from "@tauri-apps/api/event"; - -export type IClientPlatform = `androiď` | `ios` | `web`; -export type IClientUnlisten = UnlistenFn; diff --git a/client/src/util.ts b/client/src/util.ts @@ -1,14 +0,0 @@ -import type { IClientPlatform } from "./types"; - -export function parse_platform(str: string): IClientPlatform { - switch (str) { - case `ios`: - case `androiď`: - case `web`: - return str; - default: - return `web`; - }; -}; - - diff --git a/client/src/utils/idb.ts b/client/src/utils/idb.ts @@ -0,0 +1,4 @@ +export type IClientIdbConfig = { + database?: string; + store?: string; +}; +\ No newline at end of file diff --git a/client/tsconfig.cjs.json b/client/tsconfig.cjs.json @@ -0,0 +1,16 @@ +{ + "extends": "@radroots/tsconfig/tsconfig.esm.json", + "compilerOptions": { + "module": "CommonJS", + "moduleResolution": "Node", + "lib": ["dom", "ES2022"], + "rootDir": "./src", + "outDir": "dist/cjs", + "declaration": false, + "declarationMap": false, + "emitDeclarationOnly": false, + "tsBuildInfoFile": "node_modules/.cache/tsc.utils-nostr.cjs.tsbuildinfo" + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/client/tsconfig.esm.json b/client/tsconfig.esm.json @@ -0,0 +1,14 @@ +{ + "extends": "@radroots/tsconfig/tsconfig.esm.json", + "compilerOptions": { + "moduleResolution": "nodenext", + "lib": ["dom", "ES2022"], + "rootDir": "./src", + "outDir": "dist/esm", + "declaration": true, + "declarationMap": true, + "declarationDir": "dist/types" + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/client/tsconfig.json b/client/tsconfig.json @@ -1,23 +1,3 @@ { - "compilerOptions": { - "strict": true, - "target": "es2021", - "lib": [ - "es2021", - "dom" - ], - "module": "CommonJS", - "outDir": "./dist", - "rootDir": "./src", - "declaration": true, - "emitDeclarationOnly": true, - "esModuleInterop": true, - "skipLibCheck": true - }, - "include": [ - "src" - ], - "exclude": [ - "node_modules" - ], -} -\ No newline at end of file + "extends": "./tsconfig.esm.json" +}