refactor: reorganize deep linking types and interfaces
Changes: - Move deep link types from types/ to interfaces/ - Export baseUrlSchema for external use - Add trailing commas for better git diffs - Fix type inference for deepLinkSchemas - Add deepLinks export to interfaces/index.ts - Remove duplicate SuccessResult interface - Update import paths in services/deepLinks.ts This improves code organization by centralizing interface definitions and fixing type inference issues.
This commit is contained in:
33
README.md
33
README.md
@@ -8,8 +8,6 @@ and expand to crowd-fund with time & money, then record and see the impact of co
|
|||||||
See [project.task.yaml](project.task.yaml) for current priorities.
|
See [project.task.yaml](project.task.yaml) for current priorities.
|
||||||
(Numbers at the beginning of lines are estimated hours. See [taskyaml.org](https://taskyaml.org/) for details.)
|
(Numbers at the beginning of lines are estimated hours. See [taskyaml.org](https://taskyaml.org/) for details.)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Setup & Building
|
## Setup & Building
|
||||||
|
|
||||||
Quick start:
|
Quick start:
|
||||||
@@ -19,15 +17,17 @@ npm install
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
See the test locations for "IMAGE_API_SERVER" or "PARTNER_API_SERVER" below, or use http://localhost:3000 for local endorser.ch
|
See the test locations for "IMAGE_API_SERVER" or "PARTNER_API_SERVER" below, or use <http://localhost:3000> for local endorser.ch
|
||||||
|
|
||||||
### Build the test & production app
|
### Build the test & production app
|
||||||
```
|
|
||||||
|
```bash
|
||||||
npm run serve
|
npm run serve
|
||||||
```
|
```
|
||||||
|
|
||||||
### Lint and fix files
|
### Lint and fix files
|
||||||
```
|
|
||||||
|
```bash
|
||||||
npm run lint
|
npm run lint
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -35,7 +35,6 @@ npm run lint
|
|||||||
|
|
||||||
Look below for the "test-all" instructions.
|
Look below for the "test-all" instructions.
|
||||||
|
|
||||||
|
|
||||||
### Compile and minify for test & production
|
### Compile and minify for test & production
|
||||||
|
|
||||||
* If there are DB changes: before updating the test server, open browser(s) with current version to test DB migrations.
|
* If there are DB changes: before updating the test server, open browser(s) with current version to test DB migrations.
|
||||||
@@ -52,15 +51,19 @@ Look below for the "test-all" instructions.
|
|||||||
|
|
||||||
* For test, build the app (because test server is not yet set up to build):
|
* For test, build the app (because test server is not yet set up to build):
|
||||||
|
|
||||||
```
|
```bash
|
||||||
TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.app VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch VITE_PASSKEYS_ENABLED=true npm run build
|
TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.app VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch VITE_PASSKEYS_ENABLED=true npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
... and transfer to the test server: `rsync -azvu -e "ssh -i ~/.ssh/..." dist ubuntutest@test.timesafari.app:time-safari`
|
... and transfer to the test server:
|
||||||
|
|
||||||
(Let's replace that with a .env.development or .env.staging file.)
|
```bash
|
||||||
|
rsync -azvu -e "ssh -i ~/.ssh/..." dist ubuntutest@test.timesafari.app:time-safari
|
||||||
|
```
|
||||||
|
|
||||||
(Note: The test BVC_MEETUPS_PROJECT_CLAIM_ID does not resolve as a URL because it's only in the test DB and the prod redirect won't redirect there.)
|
(Let's replace that with a .env.development or .env.staging file.)
|
||||||
|
|
||||||
|
(Note: The test BVC_MEETUPS_PROJECT_CLAIM_ID does not resolve as a URL because it's only in the test DB and the prod redirect won't redirect there.)
|
||||||
|
|
||||||
* For prod, get on the server and run the correct build:
|
* For prod, get on the server and run the correct build:
|
||||||
|
|
||||||
@@ -76,23 +79,14 @@ TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.
|
|||||||
|
|
||||||
* Record the new hash in the changelog. Edit package.json to increment version & add "-beta", `npm install`, and commit. Also record what version is on production.
|
* Record the new hash in the changelog. Edit package.json to increment version & add "-beta", `npm install`, and commit. Also record what version is on production.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
See [TESTING.md](test-playwright/TESTING.md) for detailed test instructions.
|
See [TESTING.md](test-playwright/TESTING.md) for detailed test instructions.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Icons
|
## Icons
|
||||||
|
|
||||||
To add an icon, add to main.ts and reference with `fa` element and `icon` attribute with the hyphenated name.
|
To add an icon, add to main.ts and reference with `fa` element and `icon` attribute with the hyphenated name.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Other
|
## Other
|
||||||
|
|
||||||
### Reference Material
|
### Reference Material
|
||||||
@@ -104,7 +98,6 @@ To add an icon, add to main.ts and reference with `fa` element and `icon` attrib
|
|||||||
|
|
||||||
* If you are deploying in a subdirectory, add it to `publicPath` in vue.config.js, eg: `publicPath: "/app/time-tracker/",`
|
* If you are deploying in a subdirectory, add it to `publicPath` in vue.config.js, eg: `publicPath: "/app/time-tracker/",`
|
||||||
|
|
||||||
|
|
||||||
### Kudos
|
### Kudos
|
||||||
|
|
||||||
Gifts make the world go 'round!
|
Gifts make the world go 'round!
|
||||||
|
|||||||
915
package-lock.json
generated
915
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -125,6 +125,8 @@
|
|||||||
"eslint-plugin-prettier": "^5.2.1",
|
"eslint-plugin-prettier": "^5.2.1",
|
||||||
"eslint-plugin-vue": "^9.32.0",
|
"eslint-plugin-vue": "^9.32.0",
|
||||||
"fs-extra": "^11.3.0",
|
"fs-extra": "^11.3.0",
|
||||||
|
"markdownlint": "^0.37.4",
|
||||||
|
"markdownlint-cli": "^0.44.0",
|
||||||
"npm-check-updates": "^17.1.13",
|
"npm-check-updates": "^17.1.13",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
"prettier": "^3.2.5",
|
"prettier": "^3.2.5",
|
||||||
|
|||||||
@@ -19,11 +19,6 @@ export interface ResultWithType {
|
|||||||
type: string;
|
type: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SuccessResult extends ResultWithType {
|
|
||||||
type: "success";
|
|
||||||
response: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ErrorResponse {
|
export interface ErrorResponse {
|
||||||
error?: {
|
error?: {
|
||||||
message?: string;
|
message?: string;
|
||||||
|
|||||||
13
src/interfaces/deepLinks.ts
Normal file
13
src/interfaces/deepLinks.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* @file Deep Link Interface Definitions
|
||||||
|
* @author Matthew Raymer
|
||||||
|
*
|
||||||
|
* Defines the core interfaces for the deep linking system.
|
||||||
|
* These interfaces are used across the deep linking implementation
|
||||||
|
* to ensure type safety and consistent error handling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface DeepLinkError extends Error {
|
||||||
|
code: string;
|
||||||
|
details?: unknown;
|
||||||
|
}
|
||||||
@@ -4,3 +4,4 @@ export * from "./common";
|
|||||||
export * from "./limits";
|
export * from "./limits";
|
||||||
export * from "./records";
|
export * from "./records";
|
||||||
export * from "./user";
|
export * from "./user";
|
||||||
|
export * from "./deepLinks";
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
/**
|
/**
|
||||||
* @file Capacitor Main Entry Point
|
* @file Capacitor Main Entry Point
|
||||||
* @author Matthew Raymer
|
* @author Matthew Raymer
|
||||||
*
|
*
|
||||||
* This file initializes the deep linking system for the TimeSafari app.
|
* This file initializes the deep linking system for the TimeSafari app.
|
||||||
* It sets up the connection between Capacitor's URL handling and our deep link processor.
|
* It sets up the connection between Capacitor's URL handling and our deep link processor.
|
||||||
*
|
*
|
||||||
* Deep Linking Flow:
|
* Deep Linking Flow:
|
||||||
* 1. Capacitor receives URL open event
|
* 1. Capacitor receives URL open event
|
||||||
* 2. Event is passed to DeepLinkHandler
|
* 2. Event is passed to DeepLinkHandler
|
||||||
* 3. URL is validated and processed
|
* 3. URL is validated and processed
|
||||||
* 4. Router navigates to appropriate view
|
* 4. Router navigates to appropriate view
|
||||||
*
|
*
|
||||||
* Integration Points:
|
* Integration Points:
|
||||||
* - Capacitor App plugin for URL handling
|
* - Capacitor App plugin for URL handling
|
||||||
* - Vue Router for navigation
|
* - Vue Router for navigation
|
||||||
* - Error handling system
|
* - Error handling system
|
||||||
* - Logging system
|
* - Logging system
|
||||||
*
|
*
|
||||||
* Type Safety:
|
* Type Safety:
|
||||||
* - Uses DeepLinkHandler for type-safe parameter processing
|
* - Uses DeepLinkHandler for type-safe parameter processing
|
||||||
* - Ensures type safety between Capacitor events and app routing
|
* - Ensures type safety between Capacitor events and app routing
|
||||||
* - Maintains type checking through the entire deep link flow
|
* - Maintains type checking through the entire deep link flow
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* // URL open event from OS
|
* // URL open event from OS
|
||||||
* timesafari://claim/123?view=details
|
* timesafari://claim/123?view=details
|
||||||
@@ -72,9 +72,12 @@ const handleDeepLink = async (data: { url: string }) => {
|
|||||||
await deepLinkHandler.handleDeepLink(data.url);
|
await deepLinkHandler.handleDeepLink(data.url);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logConsoleAndDb("[DeepLink] Error handling deep link: " + error, true);
|
logConsoleAndDb("[DeepLink] Error handling deep link: " + error, true);
|
||||||
handleApiError({
|
handleApiError(
|
||||||
message: error instanceof Error ? error.message : String(error)
|
{
|
||||||
} as AxiosError, "deep-link");
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
} as AxiosError,
|
||||||
|
"deep-link",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,41 +1,37 @@
|
|||||||
/**
|
/**
|
||||||
* @file Deep Link Handler Service
|
* @file Deep Link Handler Service
|
||||||
* @author Matthew Raymer
|
* @author Matthew Raymer
|
||||||
*
|
*
|
||||||
* This service handles the processing and routing of deep links in the TimeSafari app.
|
* This service handles the processing and routing of deep links in the TimeSafari app.
|
||||||
* It provides a type-safe interface between the raw deep links and the application router.
|
* It provides a type-safe interface between the raw deep links and the application router.
|
||||||
*
|
*
|
||||||
* Architecture:
|
* Architecture:
|
||||||
* 1. DeepLinkHandler class encapsulates all deep link processing logic
|
* 1. DeepLinkHandler class encapsulates all deep link processing logic
|
||||||
* 2. Uses Zod schemas from types/deepLinks for parameter validation
|
* 2. Uses Zod schemas from types/deepLinks for parameter validation
|
||||||
* 3. Provides consistent error handling and logging
|
* 3. Provides consistent error handling and logging
|
||||||
* 4. Maps validated parameters to Vue router calls
|
* 4. Maps validated parameters to Vue router calls
|
||||||
*
|
*
|
||||||
* Error Handling Strategy:
|
* Error Handling Strategy:
|
||||||
* - All errors are wrapped in DeepLinkError interface
|
* - All errors are wrapped in DeepLinkError interface
|
||||||
* - Errors include error codes for systematic handling
|
* - Errors include error codes for systematic handling
|
||||||
* - Detailed error information is logged for debugging
|
* - Detailed error information is logged for debugging
|
||||||
* - Errors are propagated to the global error handler
|
* - Errors are propagated to the global error handler
|
||||||
*
|
*
|
||||||
* Validation Strategy:
|
* Validation Strategy:
|
||||||
* - URL structure validation
|
* - URL structure validation
|
||||||
* - Route-specific parameter validation using Zod schemas
|
* - Route-specific parameter validation using Zod schemas
|
||||||
* - Query parameter validation and sanitization
|
* - Query parameter validation and sanitization
|
||||||
* - Type-safe parameter passing to router
|
* - Type-safe parameter passing to router
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* const handler = new DeepLinkHandler(router);
|
* const handler = new DeepLinkHandler(router);
|
||||||
* await handler.handleDeepLink("timesafari://claim/123?view=details");
|
* await handler.handleDeepLink("timesafari://claim/123?view=details");
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Router } from "vue-router";
|
import { Router } from "vue-router";
|
||||||
import { deepLinkSchemas, DeepLinkParams } from "../types/deepLinks";
|
import { deepLinkSchemas, baseUrlSchema } from "../types/deepLinks";
|
||||||
import { logConsoleAndDb } from "../db";
|
import { logConsoleAndDb } from "../db";
|
||||||
|
import type { DeepLinkError } from "../interfaces/deepLinks";
|
||||||
interface DeepLinkError extends Error {
|
|
||||||
code: string;
|
|
||||||
details?: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class DeepLinkHandler {
|
export class DeepLinkHandler {
|
||||||
private router: Router;
|
private router: Router;
|
||||||
@@ -44,6 +40,39 @@ export class DeepLinkHandler {
|
|||||||
this.router = router;
|
this.router = router;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses deep link URL into path, params and query components
|
||||||
|
*/
|
||||||
|
private parseDeepLink(url: string) {
|
||||||
|
const parts = url.split("://");
|
||||||
|
if (parts.length !== 2) {
|
||||||
|
throw { code: "INVALID_URL", message: "Invalid URL format" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate base URL structure
|
||||||
|
baseUrlSchema.parse({
|
||||||
|
scheme: parts[0],
|
||||||
|
path: parts[1],
|
||||||
|
queryParams: {}, // Will be populated below
|
||||||
|
});
|
||||||
|
|
||||||
|
const [path, queryString] = parts[1].split("?");
|
||||||
|
const [routePath, param] = path.split("/");
|
||||||
|
|
||||||
|
const query: Record<string, string> = {};
|
||||||
|
if (queryString) {
|
||||||
|
new URLSearchParams(queryString).forEach((value, key) => {
|
||||||
|
query[key] = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: routePath,
|
||||||
|
params: param ? { id: param } : {},
|
||||||
|
query,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Processes incoming deep links and routes them appropriately
|
* Processes incoming deep links and routes them appropriately
|
||||||
* @param url The deep link URL to process
|
* @param url The deep link URL to process
|
||||||
@@ -51,21 +80,23 @@ export class DeepLinkHandler {
|
|||||||
async handleDeepLink(url: string): Promise<void> {
|
async handleDeepLink(url: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
logConsoleAndDb("[DeepLink] Processing URL: " + url, false);
|
logConsoleAndDb("[DeepLink] Processing URL: " + url, false);
|
||||||
|
|
||||||
const { path, params, query } = this.parseDeepLink(url);
|
const { path, params, query } = this.parseDeepLink(url);
|
||||||
await this.validateAndRoute(path, params, query);
|
// Ensure params is always a Record<string,string> by converting undefined to empty string
|
||||||
|
const sanitizedParams = Object.fromEntries(
|
||||||
|
Object.entries(params).map(([key, value]) => [key, value ?? ""]),
|
||||||
|
);
|
||||||
|
await this.validateAndRoute(path, sanitizedParams, query);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const deepLinkError = error as DeepLinkError;
|
const deepLinkError = error as DeepLinkError;
|
||||||
logConsoleAndDb(
|
logConsoleAndDb(
|
||||||
`[DeepLink] Error (${deepLinkError.code}): ${deepLinkError.message}`,
|
`[DeepLink] Error (${deepLinkError.code}): ${deepLinkError.message}`,
|
||||||
true
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
throw {
|
throw {
|
||||||
code: deepLinkError.code || 'UNKNOWN_ERROR',
|
code: deepLinkError.code || "UNKNOWN_ERROR",
|
||||||
message: deepLinkError.message,
|
message: deepLinkError.message,
|
||||||
details: deepLinkError.details
|
details: deepLinkError.details,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,25 +107,25 @@ export class DeepLinkHandler {
|
|||||||
private async validateAndRoute(
|
private async validateAndRoute(
|
||||||
path: string,
|
path: string,
|
||||||
params: Record<string, string>,
|
params: Record<string, string>,
|
||||||
query: Record<string, string>
|
query: Record<string, string>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const routeMap: Record<string, string> = {
|
const routeMap: Record<string, string> = {
|
||||||
claim: 'claim',
|
claim: "claim",
|
||||||
'claim-cert': 'claim-cert',
|
"claim-cert": "claim-cert",
|
||||||
'claim-add-raw': 'claim-add-raw',
|
"claim-add-raw": "claim-add-raw",
|
||||||
'contact-edit': 'contact-edit',
|
"contact-edit": "contact-edit",
|
||||||
'contact-import': 'contact-import',
|
"contact-import": "contact-import",
|
||||||
project: 'project',
|
project: "project",
|
||||||
'invite-one-accept': 'invite-one-accept',
|
"invite-one-accept": "invite-one-accept",
|
||||||
'offer-details': 'offer-details',
|
"offer-details": "offer-details",
|
||||||
'confirm-gift': 'confirm-gift'
|
"confirm-gift": "confirm-gift",
|
||||||
};
|
};
|
||||||
|
|
||||||
const routeName = routeMap[path];
|
const routeName = routeMap[path];
|
||||||
if (!routeName) {
|
if (!routeName) {
|
||||||
throw {
|
throw {
|
||||||
code: 'INVALID_ROUTE',
|
code: "INVALID_ROUTE",
|
||||||
message: `Unsupported route: ${path}`
|
message: `Unsupported route: ${path}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,13 +133,13 @@ export class DeepLinkHandler {
|
|||||||
const schema = deepLinkSchemas[path as keyof typeof deepLinkSchemas];
|
const schema = deepLinkSchemas[path as keyof typeof deepLinkSchemas];
|
||||||
const validatedParams = await schema.parseAsync({
|
const validatedParams = await schema.parseAsync({
|
||||||
...params,
|
...params,
|
||||||
...query
|
...query,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.router.replace({
|
await this.router.replace({
|
||||||
name: routeName,
|
name: routeName,
|
||||||
params: validatedParams,
|
params: validatedParams,
|
||||||
query
|
query,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
/**
|
/**
|
||||||
* @file Deep Link Type Definitions and Validation Schemas
|
* @file Deep Link Type Definitions and Validation Schemas
|
||||||
* @author Matthew Raymer
|
* @author Matthew Raymer
|
||||||
*
|
*
|
||||||
* This file defines the type system and validation schemas for deep linking in the TimeSafari app.
|
* This file defines the type system and validation schemas for deep linking in the TimeSafari app.
|
||||||
* It uses Zod for runtime validation while providing TypeScript types for compile-time checking.
|
* It uses Zod for runtime validation while providing TypeScript types for compile-time checking.
|
||||||
*
|
*
|
||||||
* Type Strategy:
|
* Type Strategy:
|
||||||
* 1. Define base URL schema to validate the fundamental deep link structure
|
* 1. Define base URL schema to validate the fundamental deep link structure
|
||||||
* 2. Define route-specific parameter schemas with exact validation rules
|
* 2. Define route-specific parameter schemas with exact validation rules
|
||||||
* 3. Generate TypeScript types from Zod schemas for type safety
|
* 3. Generate TypeScript types from Zod schemas for type safety
|
||||||
* 4. Export both schemas and types for use in deep link handling
|
* 4. Export both schemas and types for use in deep link handling
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* - Import schemas for runtime validation in deep link handlers
|
* - Import schemas for runtime validation in deep link handlers
|
||||||
* - Import types for type-safe parameter handling in components
|
* - Import types for type-safe parameter handling in components
|
||||||
* - Use DeepLinkParams type for type-safe access to route parameters
|
* - Use DeepLinkParams type for type-safe access to route parameters
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* // Runtime validation
|
* // Runtime validation
|
||||||
* const params = deepLinkSchemas.claim.parse({ id: "123", view: "details" });
|
* const params = deepLinkSchemas.claim.parse({ id: "123", view: "details" });
|
||||||
*
|
*
|
||||||
* // Type-safe parameter access
|
* // Type-safe parameter access
|
||||||
* function handleClaimParams(params: DeepLinkParams["claim"]) {
|
* function handleClaimParams(params: DeepLinkParams["claim"]) {
|
||||||
* // TypeScript knows params.id exists and params.view is optional
|
* // TypeScript knows params.id exists and params.view is optional
|
||||||
@@ -28,46 +28,46 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
// Base URL validation schema
|
// Base URL validation schema
|
||||||
const baseUrlSchema = z.object({
|
export const baseUrlSchema = z.object({
|
||||||
scheme: z.literal("timesafari"),
|
scheme: z.literal("timesafari"),
|
||||||
path: z.string(),
|
path: z.string(),
|
||||||
queryParams: z.record(z.string()).optional()
|
queryParams: z.record(z.string()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Parameter validation schemas for each route type
|
// Parameter validation schemas for each route type
|
||||||
export const deepLinkSchemas = {
|
export const deepLinkSchemas = {
|
||||||
claim: z.object({
|
claim: z.object({
|
||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
view: z.enum(["details", "certificate", "raw"]).optional()
|
view: z.enum(["details", "certificate", "raw"]).optional(),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
contact: z.object({
|
contact: z.object({
|
||||||
did: z.string().regex(/^did:/),
|
did: z.string().regex(/^did:/),
|
||||||
action: z.enum(["edit", "import"]).optional(),
|
action: z.enum(["edit", "import"]).optional(),
|
||||||
jwt: z.string().optional()
|
jwt: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
project: z.object({
|
project: z.object({
|
||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
view: z.enum(["details", "edit"]).optional()
|
view: z.enum(["details", "edit"]).optional(),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
invite: z.object({
|
invite: z.object({
|
||||||
jwt: z.string().min(1),
|
jwt: z.string().min(1),
|
||||||
type: z.enum(["one", "many"]).optional()
|
type: z.enum(["one", "many"]).optional(),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
gift: z.object({
|
gift: z.object({
|
||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
action: z.enum(["confirm", "details"]).optional()
|
action: z.enum(["confirm", "details"]).optional(),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
offer: z.object({
|
offer: z.object({
|
||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
view: z.enum(["details"]).optional()
|
view: z.enum(["details"]).optional(),
|
||||||
})
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DeepLinkParams = {
|
export type DeepLinkParams = {
|
||||||
[K in keyof typeof deepLinkSchemas]: z.infer<typeof deepLinkSchemas[K]>;
|
[K in keyof typeof deepLinkSchemas]: z.infer<(typeof deepLinkSchemas)[K]>;
|
||||||
};
|
};
|
||||||
|
|||||||
149
web-push.md
149
web-push.md
@@ -37,7 +37,7 @@ possibility of users clicking "don't allow".
|
|||||||
Now, to explain what happens in Typescript, we can activate a browser's
|
Now, to explain what happens in Typescript, we can activate a browser's
|
||||||
permission dialogue in this manner:
|
permission dialogue in this manner:
|
||||||
|
|
||||||
```
|
```typescript
|
||||||
function askPermission(): Promise<NotificationPermission> {
|
function askPermission(): Promise<NotificationPermission> {
|
||||||
return new Promise(function(resolve, reject) {
|
return new Promise(function(resolve, reject) {
|
||||||
const permissionResult = Notification.requestPermission(function(result) {
|
const permissionResult = Notification.requestPermission(function(result) {
|
||||||
@@ -59,9 +59,9 @@ function askPermission(): Promise<NotificationPermission> {
|
|||||||
The Notification.permission property indicates the permission level for the
|
The Notification.permission property indicates the permission level for the
|
||||||
current session and returns one of the following string values:
|
current session and returns one of the following string values:
|
||||||
|
|
||||||
'granted': The user has granted permission for notifications.
|
- 'granted': The user has granted permission for notifications.
|
||||||
'denied': The user has denied permission for notifications.
|
- 'denied': The user has denied permission for notifications.
|
||||||
'default': The user has not made a choice yet.
|
- 'default': The user has not made a choice yet.
|
||||||
|
|
||||||
Once the user has granted permission, the client application registers a service
|
Once the user has granted permission, the client application registers a service
|
||||||
worker using the `ServiceWorkerRegistration` API.
|
worker using the `ServiceWorkerRegistration` API.
|
||||||
@@ -77,7 +77,7 @@ subscriptions may be done.
|
|||||||
|
|
||||||
Let's go through the `register` method first:
|
Let's go through the `register` method first:
|
||||||
|
|
||||||
```
|
```javascript
|
||||||
navigator.serviceWorker.register('sw.js', { scope: '/' })
|
navigator.serviceWorker.register('sw.js', { scope: '/' })
|
||||||
.then(function(registration) {
|
.then(function(registration) {
|
||||||
console.log('Service worker registered successfully:', registration);
|
console.log('Service worker registered successfully:', registration);
|
||||||
@@ -108,7 +108,8 @@ Here's a version which can be used for testing locally. Note there can be
|
|||||||
caching issues in your browser! Incognito is highly recommended.
|
caching issues in your browser! Incognito is highly recommended.
|
||||||
|
|
||||||
sw-dev.ts
|
sw-dev.ts
|
||||||
```
|
|
||||||
|
```typescript
|
||||||
self.addEventListener('push', function(event: PushEvent) {
|
self.addEventListener('push', function(event: PushEvent) {
|
||||||
console.log('Received a push message', event);
|
console.log('Received a push message', event);
|
||||||
|
|
||||||
@@ -127,9 +128,9 @@ self.addEventListener('push', function(event: PushEvent) {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
vue.config.js
|
vue.config.js
|
||||||
```
|
|
||||||
|
```javascript
|
||||||
module.exports = {
|
module.exports = {
|
||||||
pwa: {
|
pwa: {
|
||||||
workboxOptions: {
|
workboxOptions: {
|
||||||
@@ -158,30 +159,30 @@ The VAPID (Voluntary Application Server Identification) key provides more
|
|||||||
security and authenticity for web push notifications in the following ways:
|
security and authenticity for web push notifications in the following ways:
|
||||||
|
|
||||||
Identifying the Application Server:
|
Identifying the Application Server:
|
||||||
|
|
||||||
The VAPID key is used to identify the application server that is sending
|
The VAPID key is used to identify the application server that is sending
|
||||||
the push notifications. This ensures that the push notifications are
|
the push notifications. This ensures that the push notifications are
|
||||||
authentic and not sent by a malicious third party.
|
authentic and not sent by a malicious third party.
|
||||||
|
|
||||||
Encrypting the Messages:
|
Encrypting the Messages:
|
||||||
|
|
||||||
The VAPID key is used to sign the push notifications sent by the
|
The VAPID key is used to sign the push notifications sent by the
|
||||||
application server, ensuring that they are not tampered with during
|
application server, ensuring that they are not tampered with during
|
||||||
transmission. This provides an additional layer of security and
|
transmission. This provides an additional layer of security and
|
||||||
authenticity for the push notifications.
|
authenticity for the push notifications.
|
||||||
|
|
||||||
Adding Contact Information:
|
Adding Contact Information:
|
||||||
|
|
||||||
The VAPID key allows a web application to add contact information to
|
The VAPID key allows a web application to add contact information to
|
||||||
the push messages sent to the browser push service. This enables the
|
the push messages sent to the browser push service. This enables the
|
||||||
push service to contact the application server in case of need or
|
push service to contact the application server in case of need or
|
||||||
provide additional debug information about the push messages.
|
provide additional debug information about the push messages.
|
||||||
|
|
||||||
Improving Delivery Rates:
|
Improving Delivery Rates:
|
||||||
|
|
||||||
Using the VAPID key can help improve the overall performance of web push
|
Using the VAPID key can help improve the overall performance of web push
|
||||||
notifications, specifically improving delivery rates. By streamlining the
|
notifications, specifically improving delivery rates. By streamlining the
|
||||||
delivery process, the chance of delivery errors along the way is lessened.
|
delivery process, the chance of delivery errors along the way is lessened.
|
||||||
|
|
||||||
If the BROWSER accepts and grants permission to subscribe to receiving from the
|
If the BROWSER accepts and grants permission to subscribe to receiving from the
|
||||||
SERVICE Web Push messages, then the BROWSER makes a subscription request to
|
SERVICE Web Push messages, then the BROWSER makes a subscription request to
|
||||||
@@ -189,7 +190,7 @@ PROVIDER which creates and stores a special URL for that BROWSER.
|
|||||||
|
|
||||||
Here's a bit of code describing the above process:
|
Here's a bit of code describing the above process:
|
||||||
|
|
||||||
```
|
```typescript
|
||||||
// b64 is the VAPID
|
// b64 is the VAPID
|
||||||
b64 = 'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U';
|
b64 = 'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U';
|
||||||
const applicationServerKey = urlBase64ToUint8Array(b64);
|
const applicationServerKey = urlBase64ToUint8Array(b64);
|
||||||
@@ -210,38 +211,39 @@ registration.pushManager.subscribe(options)
|
|||||||
In this example, the `applicationServerKey` variable contains the VAPID public
|
In this example, the `applicationServerKey` variable contains the VAPID public
|
||||||
key, which is converted to a `Uint8Array` using a function such as this:
|
key, which is converted to a `Uint8Array` using a function such as this:
|
||||||
|
|
||||||
```
|
```typescript
|
||||||
export function toUint8Array(base64String: string, atobFn: typeof atob): Uint8Array {
|
export function toUint8Array(base64String: string, atobFn: typeof atob): Uint8Array {
|
||||||
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
||||||
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
|
||||||
const rawData = atobFn(base64);
|
const rawData = atobFn(base64);
|
||||||
const outputArray = new Uint8Array(rawData.length);
|
const outputArray = new Uint8Array(rawData.length);
|
||||||
|
|
||||||
for (let i = 0; i < rawData.length; ++i) {
|
for (let i = 0; i < rawData.length; ++i) {
|
||||||
outputArray[i] = rawData.charCodeAt(i);
|
outputArray[i] = rawData.charCodeAt(i);
|
||||||
}
|
}
|
||||||
return outputArray;
|
return outputArray;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The options object is of type `PushSubscriptionOptions`, which includes the
|
The options object is of type `PushSubscriptionOptions`, which includes the
|
||||||
`userVisibleOnly` and `applicationServerKey` (ie VAPID public key) properties.
|
`userVisibleOnly` and `applicationServerKey` (ie VAPID public key) properties.
|
||||||
|
|
||||||
options: An object that contains the options used for creating the
|
options: An object that contains the options used for creating the
|
||||||
subscription. This object itself has the following sub-properties:
|
subscription. This object itself has the following sub-properties:
|
||||||
|
|
||||||
applicationServerKey: A public key your push service uses for application
|
applicationServerKey: A public key your push service uses for application
|
||||||
server identification. This is normally a Uint8Array.
|
server identification. This is normally a Uint8Array.
|
||||||
|
|
||||||
userVisibleOnly: A boolean value indicating that the push messages that
|
userVisibleOnly: A boolean value indicating that the push messages that
|
||||||
are sent should be made visible to the user through a notification.
|
are sent should be made visible to the user through a notification.
|
||||||
This is often set to true.
|
This is often set to true.
|
||||||
|
|
||||||
The subscribe() method returns a `Promise` that resolves to a `PushSubscription`
|
The subscribe() method returns a `Promise` that resolves to a `PushSubscription`
|
||||||
object containing details of the subscription, such as the endpoint URL and the
|
object containing details of the subscription, such as the endpoint URL and the
|
||||||
public key. The returned data would have a form like this:
|
public key. The returned data would have a form like this:
|
||||||
|
|
||||||
|
```json
|
||||||
{
|
{
|
||||||
"endpoint": "https://some.pushservice.com/some/unique/identifier",
|
"endpoint": "https://some.pushservice.com/some/unique/identifier",
|
||||||
"expirationTime": null,
|
"expirationTime": null,
|
||||||
@@ -250,14 +252,15 @@ public key. The returned data would have a form like this:
|
|||||||
"auth": "some_other_base64_encoded_string"
|
"auth": "some_other_base64_encoded_string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
```
|
||||||
|
|
||||||
endpoint: A string representing the endpoint URL for the push service. This
|
endpoint: A string representing the endpoint URL for the push service. This
|
||||||
URL is essentially the push service address to which the push message would
|
URL is essentially the push service address to which the push message would
|
||||||
be sent for this particular subscription.
|
be sent for this particular subscription.
|
||||||
|
|
||||||
expirationTime: A DOMHighResTimeStamp (which is basically a number or null)
|
expirationTime: A DOMHighResTimeStamp (which is basically a number or null)
|
||||||
representing the subscription's expiration time in milliseconds since
|
representing the subscription's expiration time in milliseconds since
|
||||||
01 January, 1970 UTC. This can be null if the subscription never expires.
|
01 January, 1970 UTC. This can be null if the subscription never expires.
|
||||||
|
|
||||||
The BROWSER will, internally, then use that URL to check for incoming messages
|
The BROWSER will, internally, then use that URL to check for incoming messages
|
||||||
by way of the service worker we described earlier. The BROWSER also sends this
|
by way of the service worker we described earlier. The BROWSER also sends this
|
||||||
@@ -273,7 +276,7 @@ via the PROVIDER so that they reach the BROWSER service worker.
|
|||||||
Just to remind us that in our service worker our code for receiving messages
|
Just to remind us that in our service worker our code for receiving messages
|
||||||
will look something like this:
|
will look something like this:
|
||||||
|
|
||||||
```
|
```typescript
|
||||||
self.addEventListener('push', function(event: PushEvent) {
|
self.addEventListener('push', function(event: PushEvent) {
|
||||||
console.log('Received a push message', event);
|
console.log('Received a push message', event);
|
||||||
|
|
||||||
@@ -292,7 +295,6 @@ self.addEventListener('push', function(event: PushEvent) {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
Now to address the issue of receiving notification messages on mobile devices.
|
Now to address the issue of receiving notification messages on mobile devices.
|
||||||
It should be noted that Web Push messages are only received when BROWSER is
|
It should be noted that Web Push messages are only received when BROWSER is
|
||||||
open, except in the cases of Chrome and Firefox mobile BROWSERS. In iOS, the
|
open, except in the cases of Chrome and Firefox mobile BROWSERS. In iOS, the
|
||||||
@@ -300,13 +302,13 @@ mobile application (in our case a PWA) must be added to the Home Screen and
|
|||||||
permissions must be explicitly granted that allow the application to receive
|
permissions must be explicitly granted that allow the application to receive
|
||||||
push notifications. Further, with an iOS device the user must enable wake on
|
push notifications. Further, with an iOS device the user must enable wake on
|
||||||
notification to have their device light-up when it receives a notification
|
notification to have their device light-up when it receives a notification
|
||||||
(https://support.apple.com/enus/HT208081).
|
(<https://support.apple.com/enus/HT208081>).
|
||||||
|
|
||||||
So what about #4? - The INTERMEDIARY. Well, It is possible under very special
|
So what about #4? - The INTERMEDIARY. Well, It is possible under very special
|
||||||
circumstances to create your own Web Push PROVIDER. The only case I've found so
|
circumstances to create your own Web Push PROVIDER. The only case I've found so
|
||||||
far relates to making an Android Custom ROM. (An Android Custom ROM is a
|
far relates to making an Android Custom ROM. (An Android Custom ROM is a
|
||||||
customized version of the Android Operating System.) There are open source
|
customized version of the Android Operating System.) There are open source
|
||||||
IMTERMEDIARY products such as UnifiedPush (https://unifiedpush.org/) which can
|
IMTERMEDIARY products such as UnifiedPush (<https://unifiedpush.org/>) which can
|
||||||
fulfill this role. If you are using iOS you are not permitted to make or use
|
fulfill this role. If you are using iOS you are not permitted to make or use
|
||||||
your own custom Web Push PROVIDER. Apple will never allow anyone to do that.
|
your own custom Web Push PROVIDER. Apple will never allow anyone to do that.
|
||||||
Apple has none of its own.
|
Apple has none of its own.
|
||||||
@@ -315,8 +317,7 @@ It is, however, possible to have a sort of proxy working between your SERVICE
|
|||||||
and FCM (or iOS). Services that mash up various Push notification services (like
|
and FCM (or iOS). Services that mash up various Push notification services (like
|
||||||
OneSignal) can perform in the role of such proxies.
|
OneSignal) can perform in the role of such proxies.
|
||||||
|
|
||||||
#4 -The INTERMEDIARY- doesn't appear to be anything we should be spending our
|
# 4 -The INTERMEDIARY- doesn't appear to be anything we should be spending our time on
|
||||||
time on.
|
|
||||||
|
|
||||||
A BROWSER may also remove a subscription. In order to remove a subscription,
|
A BROWSER may also remove a subscription. In order to remove a subscription,
|
||||||
the registration record must be retrieved from the serviceWorker using
|
the registration record must be retrieved from the serviceWorker using
|
||||||
@@ -326,8 +327,7 @@ subscription object, you may call the `unsubscribe` method. `unsubscribe` is
|
|||||||
asynchronnous and returns a boolean true if it is successful in removing the
|
asynchronnous and returns a boolean true if it is successful in removing the
|
||||||
subscription and false if not.
|
subscription and false if not.
|
||||||
|
|
||||||
|
```typescript
|
||||||
```
|
|
||||||
async function unsubscribeFromPush() {
|
async function unsubscribeFromPush() {
|
||||||
// Check if the browser supports service workers
|
// Check if the browser supports service workers
|
||||||
if ("serviceWorker" in navigator) {
|
if ("serviceWorker" in navigator) {
|
||||||
@@ -362,30 +362,34 @@ unsubscribeFromPush().catch((err) => {
|
|||||||
|
|
||||||
NOTE: We could offer an option within the app to "mute" these notifications. This wouldn't turn off the notifications at the browser level, but you could make it so that your Service Worker doesn't display them even if it receives them.
|
NOTE: We could offer an option within the app to "mute" these notifications. This wouldn't turn off the notifications at the browser level, but you could make it so that your Service Worker doesn't display them even if it receives them.
|
||||||
|
|
||||||
|
|
||||||
# NOTIFICATION DIALOG WORKFLOW
|
# NOTIFICATION DIALOG WORKFLOW
|
||||||
|
|
||||||
## ON APP FIRST-LAUNCH:
|
## ON APP FIRST-LAUNCH
|
||||||
|
|
||||||
The user is periodically presented with the notification permission dialog that asks them if they want to turn on notifications. User is given 3 choices:
|
The user is periodically presented with the notification permission dialog that asks them if they want to turn on notifications. User is given 3 choices:
|
||||||
|
|
||||||
- "Turn on Notifications": triggers the browser's own notification permission prompt.
|
- "Turn on Notifications": triggers the browser's own notification permission prompt.
|
||||||
- "Maybe Later": dismisses the dialog, to reappear at a later instance. (The next time the user launches the app? After X amount of days? A combination of both?)
|
- "Maybe Later": dismisses the dialog, to reappear at a later instance. (The next time the user launches the app? After X amount of days? A combination of both?)
|
||||||
- "Never": dismisses the dialog; app remembers to not automatically present the dialog again.
|
- "Never": dismisses the dialog; app remembers to not automatically present the dialog again.
|
||||||
|
|
||||||
## IF THE USER CHOOSES "NEVER":
|
## IF THE USER CHOOSES "NEVER"
|
||||||
|
|
||||||
The dialog can still be accessed via the Notifications toggle switch in `AccountViewView` (which also tells the user if notifications are turned on or off).
|
The dialog can still be accessed via the Notifications toggle switch in `AccountViewView` (which also tells the user if notifications are turned on or off).
|
||||||
|
|
||||||
## TO TEMPORARILY MUTE NOTIFICATIONS:
|
## TO TEMPORARILY MUTE NOTIFICATIONS
|
||||||
|
|
||||||
While notifications are turned on, the user can tap on the Mute Notifications toggle switch in `AccountViewView` (visible only when notifications are turned on) to trigger the Mute Notifications Dialog. User is given the following choices:
|
While notifications are turned on, the user can tap on the Mute Notifications toggle switch in `AccountViewView` (visible only when notifications are turned on) to trigger the Mute Notifications Dialog. User is given the following choices:
|
||||||
|
|
||||||
- Several "Mute for X Hour/s" buttons to temporarily mute notifications.
|
- Several "Mute for X Hour/s" buttons to temporarily mute notifications.
|
||||||
- "Mute until I turn it back on" button to indefinitely mute notifications.
|
- "Mute until I turn it back on" button to indefinitely mute notifications.
|
||||||
- "Cancel" to make no changes and dismiss the dialog.
|
- "Cancel" to make no changes and dismiss the dialog.
|
||||||
|
|
||||||
## TO UNMUTE NOTIFICATIONS:
|
## TO UNMUTE NOTIFICATIONS
|
||||||
|
|
||||||
Simply tap on the Mute Notifications toggle switch in `AccountViewView` to immediately unmute notifications. No dialog needed.
|
Simply tap on the Mute Notifications toggle switch in `AccountViewView` to immediately unmute notifications. No dialog needed.
|
||||||
|
|
||||||
## TO TURN OFF NOTIFICATIONS:
|
## TO TURN OFF NOTIFICATIONS
|
||||||
|
|
||||||
While notifications are turned on, the user can tap on the App Notifications toggle switch in `AccountViewView` to trigger the Turn Off Notifications Dialog. User is given the following choices:
|
While notifications are turned on, the user can tap on the App Notifications toggle switch in `AccountViewView` to trigger the Turn Off Notifications Dialog. User is given the following choices:
|
||||||
|
|
||||||
- "Turn off Notifications" to fully turn them off (which means the user will need to go through the dialogs agains to turn them back on).
|
- "Turn off Notifications" to fully turn them off (which means the user will need to go through the dialogs agains to turn them back on).
|
||||||
@@ -393,29 +397,28 @@ While notifications are turned on, the user can tap on the App Notifications tog
|
|||||||
|
|
||||||
# NOTIFICATION STATES
|
# NOTIFICATION STATES
|
||||||
|
|
||||||
* Unpermissioned. Push server cannot send notifications to the user because it does not have permission.
|
- Unpermissioned. Push server cannot send notifications to the user because it does not have permission.
|
||||||
This may be the same as when the user gave permission in the past but has since revoked it at the OS or browser
|
This may be the same as when the user gave permission in the past but has since revoked it at the OS or browser
|
||||||
level, outside the app. (User can change to Permissioned when the user gives permission.)
|
level, outside the app. (User can change to Permissioned when the user gives permission.)
|
||||||
* Permissioned. (User can change to Unpermissioned via the OS or browser settings.)
|
- Permissioned. (User can change to Unpermissioned via the OS or browser settings.)
|
||||||
* Active. (User can change to Muted when the user mutes notifications.)
|
- Active. (User can change to Muted when the user mutes notifications.)
|
||||||
* Muted. (User can change to Active when the user toggles it.)
|
- Muted. (User can change to Active when the user toggles it.)
|
||||||
(Turning mute off automatically after some amount of time is not planned in version 1.)
|
(Turning mute off automatically after some amount of time is not planned in version 1.)
|
||||||
|
|
||||||
|
|
||||||
# TROUBLESHOOTING
|
# TROUBLESHOOTING
|
||||||
|
|
||||||
## Desktop
|
## Desktop
|
||||||
|
|
||||||
#### Firefox
|
### Firefox
|
||||||
|
|
||||||
Go to `about:debugging` and click on `Inspect` for the service worker.
|
Go to `about:debugging` and click on `Inspect` for the service worker.
|
||||||
|
|
||||||
#### Chrome
|
### Chrome
|
||||||
|
|
||||||
Go to `chrome://inspect/#service-workers` and click on `Inspect` for the service worker.
|
Go to `chrome://inspect/#service-workers` and click on `Inspect` for the service worker.
|
||||||
|
|
||||||
## Mobile
|
## Mobile
|
||||||
|
|
||||||
#### Android
|
### Android
|
||||||
|
|
||||||
#### iOS
|
### iOS
|
||||||
|
|||||||
Reference in New Issue
Block a user