From c6d5eb333466acaa217ac335a5141613fc050c79 Mon Sep 17 00:00:00 2001 From: Lorenzo Blasa Date: Tue, 16 May 2023 04:32:47 -0700 Subject: [PATCH] Flipper as PWA Summary: ^ Reference: https://docs.google.com/document/d/1flQJUzTe4AuQz3QCpvbloQycenHsu7ZxbKScov7K7ao Reviewed By: passy Differential Revision: D45693382 fbshipit-source-id: 5a2e6c213a7e7e2cf9cd5f3033cff3e5291a2a92 --- .../src/FlipperServerClient.tsx | 4 +- .../src/utils/certificateUtils.tsx | 52 +++++-- .../flipper-server/src/findInstallation.tsx | 32 +++++ desktop/flipper-server/src/index.tsx | 24 +++- desktop/flipper-ui-browser/src/index.tsx | 21 ++- .../src/chrome/PWAppInstallationWizard.tsx | 122 ++++++++++++++++ .../src/sandy-chrome/SandyApp.tsx | 8 ++ .../scripts/build-flipper-server-release.tsx | 3 + desktop/static/index.web.dev.html | 90 +++++++----- desktop/static/index.web.html | 49 +++++-- desktop/static/manifest.json | 18 +++ desktop/static/offline.html | 133 ++++++++++++++++++ desktop/static/service-worker.js | 70 +++++++++ 13 files changed, 556 insertions(+), 70 deletions(-) create mode 100644 desktop/flipper-server/src/findInstallation.tsx create mode 100644 desktop/flipper-ui-core/src/chrome/PWAppInstallationWizard.tsx create mode 100644 desktop/static/manifest.json create mode 100644 desktop/static/offline.html create mode 100644 desktop/static/service-worker.js diff --git a/desktop/flipper-server-client/src/FlipperServerClient.tsx b/desktop/flipper-server-client/src/FlipperServerClient.tsx index 56e29a0ca..9d94a5213 100644 --- a/desktop/flipper-server-client/src/FlipperServerClient.tsx +++ b/desktop/flipper-server-client/src/FlipperServerClient.tsx @@ -30,10 +30,10 @@ export type {FlipperServer, FlipperServerCommands, FlipperServerExecOptions}; export function createFlipperServer( host: string, port: number, - args: string, + args: URLSearchParams, onStateChange: (state: FlipperServerState) => void, ): Promise { - const socket = new ReconnectingWebSocket(`ws://${host}:${port}${args}`); + const socket = new ReconnectingWebSocket(`ws://${host}:${port}?${args}`); return createFlipperServerWithSocket(socket as WebSocket, onStateChange); } diff --git a/desktop/flipper-server-core/src/utils/certificateUtils.tsx b/desktop/flipper-server-core/src/utils/certificateUtils.tsx index c665e25d3..cf4b70b1d 100644 --- a/desktop/flipper-server-core/src/utils/certificateUtils.tsx +++ b/desktop/flipper-server-core/src/utils/certificateUtils.tsx @@ -16,7 +16,7 @@ import { } from './openssl-wrapper-with-promises'; import path from 'path'; import tmp, {FileOptions} from 'tmp'; -import {reportPlatformFailures} from 'flipper-common'; +import {FlipperServerConfig, reportPlatformFailures} from 'flipper-common'; import {isTest} from 'flipper-common'; import {flipperDataFolder} from './paths'; import * as jwt from 'jsonwebtoken'; @@ -265,38 +265,68 @@ const writeToTempFile = async (content: string): Promise => { return path; }; -const getStaticFilePath = (filename: string): string => { - return path.resolve(getFlipperServerConfig().paths.staticPath, filename); -}; - const tokenFilename = 'auth.token'; -const getTokenPath = (): string => { - const config = getFlipperServerConfig(); +const getTokenPath = (config: FlipperServerConfig): string => { if (config.environmentInfo.isHeadlessBuild) { - return getStaticFilePath(tokenFilename); + return path.resolve(config.paths.staticPath, tokenFilename); } return getFilePath(tokenFilename); }; +const manifestFilename = 'manifest.json'; +const getManifestPath = (config: FlipperServerConfig): string => { + return path.resolve(config.paths.staticPath, manifestFilename); +}; + +const exportTokenToManifest = async ( + config: FlipperServerConfig, + token: string, +) => { + const manifestPath = getManifestPath(config); + try { + const manifestData = await fs.readFile(manifestPath, { + encoding: 'utf-8', + }); + const manifest = JSON.parse(manifestData); + manifest.token = token; + + const newManifestData = JSON.stringify(manifest, null, 4); + + await fs.writeFile(manifestPath, newManifestData); + } catch (e) { + console.error( + 'Unable to export authentication token to manifest, may be non existent.', + ); + } +}; export const generateAuthToken = async () => { + const config = getFlipperServerConfig(); + const privateKey = await fs.readFile(serverKey); const token = jwt.sign({unixname: os.userInfo().username}, privateKey, { algorithm: 'RS256', expiresIn: '21 days', }); - await fs.writeFile(getTokenPath(), token); + await fs.writeFile(getTokenPath(config), token); + + if (config.environmentInfo.isHeadlessBuild) { + await exportTokenToManifest(config, token); + } return token; }; export const getAuthToken = async () => { - if (!(await fs.pathExists(getTokenPath()))) { + const config = getFlipperServerConfig(); + const tokenPath = getTokenPath(config); + + if (!(await fs.pathExists(tokenPath))) { return generateAuthToken(); } - const token = await fs.readFile(getTokenPath()); + const token = await fs.readFile(tokenPath); return token.toString(); }; diff --git a/desktop/flipper-server/src/findInstallation.tsx b/desktop/flipper-server/src/findInstallation.tsx new file mode 100644 index 000000000..a06152b40 --- /dev/null +++ b/desktop/flipper-server/src/findInstallation.tsx @@ -0,0 +1,32 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +import {FlipperServerImpl} from 'flipper-server-core'; +import path from 'path'; +import fs from 'fs-extra'; +import os from 'os'; + +export async function findInstallation( + server: FlipperServerImpl, +): Promise { + if (server.config.environmentInfo.os.platform !== 'darwin') { + return; + } + + const appPath = path.join( + os.homedir(), + 'Applications', + 'Chrome Apps.localized', + 'Flipper.app', + ); + const appPlistPath = path.join(appPath, 'Contents', 'Info.plist'); + if (await fs.pathExists(appPlistPath)) { + return appPath; + } +} diff --git a/desktop/flipper-server/src/index.tsx b/desktop/flipper-server/src/index.tsx index 5cdc13081..012e97baf 100644 --- a/desktop/flipper-server/src/index.tsx +++ b/desktop/flipper-server/src/index.tsx @@ -26,6 +26,7 @@ import { import {isTest} from 'flipper-common'; import exitHook from 'exit-hook'; import {getAuthToken} from 'flipper-server-core'; +import {findInstallation} from './findInstallation'; const argv = yargs .usage('yarn flipper-server [args]') @@ -158,6 +159,8 @@ async function start() { await attachDevServer(app, server, socket, rootPath); } await readyForIncomingConnections(flipperServer, companionEnv); + + return flipperServer; } process.on('uncaughtException', (error) => { @@ -178,19 +181,28 @@ process.on('unhandledRejection', (reason, promise) => { }); start() - .then(async () => { + .then(async (flipperServer) => { if (!argv.tcp) { - console.log('Flipper server started and listening'); + console.log('Flipper server started'); return; } console.log( - 'Flipper server started and listening at port ' + chalk.green(argv.port), + 'Flipper server started and is listening at port ' + + chalk.green(argv.port), ); const token = await getAuthToken(); - const url = `http://localhost:${argv.port}?token=${token}`; + const searchParams = new URLSearchParams({token: token ?? ''}); + const url = new URL(`http://localhost:${argv.port}?${searchParams}`); console.log('Go to: ' + chalk.green(chalk.bold(url))); - if (argv.open) { - open(url); + if (!argv.open) { + return; + } + + if (argv.bundler) { + open(url.toString()); + } else { + const path = await findInstallation(flipperServer); + open(path ?? url.toString()); } }) .catch((e) => { diff --git a/desktop/flipper-ui-browser/src/index.tsx b/desktop/flipper-ui-browser/src/index.tsx index cd5456030..06b79d61b 100644 --- a/desktop/flipper-ui-browser/src/index.tsx +++ b/desktop/flipper-ui-browser/src/index.tsx @@ -11,7 +11,10 @@ import {getLogger, Logger, setLoggerInstance} from 'flipper-common'; import {initializeRenderHost} from './initializeRenderHost'; import {createFlipperServer, FlipperServerState} from 'flipper-server-client'; -document.getElementById('root')!.innerText = 'flipper-ui-browser started'; +const loadingContainer = document.getElementById('loading'); +if (loadingContainer) { + loadingContainer.innerText = 'Loading...'; +} async function start() { // @ts-ignore @@ -27,20 +30,30 @@ async function start() { const logger = createDelegatedLogger(); setLoggerInstance(logger); + const params = new URL(location.href).searchParams; + let token = params.get('token'); + if (!token) { + const manifestResponse = await fetch('manifest.json'); + const manifest = await manifestResponse.json(); + token = manifest.token; + } + + const searchParams = new URLSearchParams({token: token ?? ''}); + const flipperServer = await createFlipperServer( location.hostname, parseInt(location.port, 10), - location.search, + searchParams, (state: FlipperServerState) => { switch (state) { case FlipperServerState.CONNECTING: - window.flipperShowError?.('Connecting to flipper-server...'); + window.flipperShowError?.('Connecting to server...'); break; case FlipperServerState.CONNECTED: window?.flipperHideError?.(); break; case FlipperServerState.DISCONNECTED: - window?.flipperShowError?.('Lost connection to flipper-server'); + window?.flipperShowError?.('Lost connection to server'); break; } }, diff --git a/desktop/flipper-ui-core/src/chrome/PWAppInstallationWizard.tsx b/desktop/flipper-ui-core/src/chrome/PWAppInstallationWizard.tsx new file mode 100644 index 000000000..3642f8b54 --- /dev/null +++ b/desktop/flipper-ui-core/src/chrome/PWAppInstallationWizard.tsx @@ -0,0 +1,122 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +import React from 'react'; +import {Modal, Button} from 'antd'; +import {Layout, _NuxManagerContext} from 'flipper-plugin'; +type Props = { + onHide: () => void; +}; + +const lastShownTimestampKey = 'flipper-pwa-wizard-last-shown-timestamp'; +export function shouldShowPWAInstallationWizard(): boolean { + if (window.matchMedia('(display-mode: standalone)').matches) { + return false; + } + + let lastShownTimestampFromStorage = undefined; + try { + lastShownTimestampFromStorage = window.localStorage.getItem( + lastShownTimestampKey, + ); + } catch (e) {} + + if (lastShownTimestampFromStorage) { + const withinOneDay = (timestamp: number) => { + const Day = 1 * 24 * 60 * 60 * 1000; + const DayAgo = Date.now() - Day; + + return timestamp > DayAgo; + }; + const lastShownTimestamp = Number(lastShownTimestampFromStorage); + + return !withinOneDay(lastShownTimestamp); + } + + const lastShownTimestamp = Date.now(); + try { + window.localStorage.setItem( + lastShownTimestampKey, + String(lastShownTimestamp), + ); + } catch (e) {} + + return true; +} + +async function install(event: any) { + event.prompt(); + + (event.userChoice as any) + .then((choiceResult: any) => { + if (choiceResult.outcome === 'accepted') { + console.log('PWA installation, user accepted the prompt.'); + } else { + console.log('PWA installation, user dismissed the prompt.'); + } + (globalThis as any).PWAppInstallationEvent = null; + }) + .catch((e: Error) => { + console.error('PWA failed to install with error', e); + }); +} + +export default function PWAInstallationWizard(props: Props) { + const contents = ( + + + <> + Please install Flipper as a PWA. Installed Progressive Web Apps run in + a standalone window instead of a browser tab. They're launchable from + your home screen, dock, taskbar, or shelf. It's possible to search for + and jump between them with the app switcher, making them feel like + part of the device they're installed on. New capabilities open up + after a web app is installed. Keyboard shortcuts, usually reserved + when running in the browser, become available too. + + + + ); + + const footer = ( + <> + + + + ); + + return ( + { + props.onHide(); + }} + width={570} + title="Install Flipper to Desktop" + footer={footer}> + {contents} + + ); +} diff --git a/desktop/flipper-ui-core/src/sandy-chrome/SandyApp.tsx b/desktop/flipper-ui-core/src/sandy-chrome/SandyApp.tsx index d18ba1faf..9758af51c 100644 --- a/desktop/flipper-ui-core/src/sandy-chrome/SandyApp.tsx +++ b/desktop/flipper-ui-core/src/sandy-chrome/SandyApp.tsx @@ -33,6 +33,9 @@ import {showChangelog} from '../chrome/ChangelogSheet'; import PlatformSelectWizard, { hasPlatformWizardBeenDone, } from '../chrome/PlatformSelectWizard'; +import PWAInstallationWizard, { + shouldShowPWAInstallationWizard, +} from '../chrome/PWAppInstallationWizard'; import {getVersionString} from '../utils/versionString'; import config from '../fb-stubs/config'; import {WelcomeScreenStaticView} from './WelcomeScreen'; @@ -110,6 +113,11 @@ export function SandyApp() { )); } + if (shouldShowPWAInstallationWizard()) { + console.info('Attempt to install PWA, launch installation wizard.'); + Dialog.showModal((onHide) => ); + } + showChangelog(true); // don't warn about logger, even with a new logger we don't want to re-register diff --git a/desktop/scripts/build-flipper-server-release.tsx b/desktop/scripts/build-flipper-server-release.tsx index 7f19db3c2..a2b99c464 100644 --- a/desktop/scripts/build-flipper-server-release.tsx +++ b/desktop/scripts/build-flipper-server-release.tsx @@ -224,6 +224,9 @@ async function copyStaticResources(outDir: string, versionNumber: string) { 'icons.json', 'index.web.dev.html', 'index.web.html', + 'manifest.json', + 'offline.html', + 'service-worker.js', 'style.css', ]; if (isFB) { diff --git a/desktop/static/index.web.dev.html b/desktop/static/index.web.dev.html index 2e0152b35..77af1f863 100644 --- a/desktop/static/index.web.dev.html +++ b/desktop/static/index.web.dev.html @@ -4,9 +4,14 @@ - + + + + + + Flipper diff --git a/desktop/static/index.web.html b/desktop/static/index.web.html index c17c715a9..9095cb6d1 100644 --- a/desktop/static/index.web.html +++ b/desktop/static/index.web.html @@ -4,9 +4,14 @@ - + + + + + + Flipper - \ No newline at end of file + diff --git a/desktop/static/manifest.json b/desktop/static/manifest.json new file mode 100644 index 000000000..5884ae8f9 --- /dev/null +++ b/desktop/static/manifest.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json.schemastore.org/web-manifest-combined.json", + "name": "Flipper", + "description": "Flipper is a platform for debugging iOS, Android and React Native apps", + "short_name": "Flipper", + "start_url": "index.web.html", + "display": "standalone", + "background_color": "#fff", + "theme_color": "#4267B2", + "orientation": "portrait-primary", + "icons": [ + { + "src": "/icon.png", + "type": "image/png", + "sizes": "256x256" + } + ] + } diff --git a/desktop/static/offline.html b/desktop/static/offline.html new file mode 100644 index 000000000..da049a13e --- /dev/null +++ b/desktop/static/offline.html @@ -0,0 +1,133 @@ + + + + + + + + + You are offline + + + + + + +
+
+ Oops! It seems Flipper is not running in the background + +

+ Flipper will automatically reload once the connection is re-established. + Click the button below to try reloading manually. +

+ + + +

Also, you can manually start Flipper. + From the terminal, please run:

+ +
+
+

shell

+
+
+

> open -a 'Flipper' --args '--server'

+
+
+
+ +
+ + + + + diff --git a/desktop/static/service-worker.js b/desktop/static/service-worker.js new file mode 100644 index 000000000..c633275c3 --- /dev/null +++ b/desktop/static/service-worker.js @@ -0,0 +1,70 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +// OFFLINE_VERSION is used as an update marker so that on subsequent installations +// the newer version of the file gets updated. +// eslint-disable-next-line header/header, no-unused-vars +const OFFLINE_VERSION = 1; +const CACHE_NAME = 'offline'; +const OFFLINE_URL = 'offline.html'; + +self.addEventListener('install', (event) => { + console.log('Flipper service worker installed'); + + event.waitUntil((async () => { + const cache = await caches.open(CACHE_NAME); + // Setting {cache: 'reload'} in the new request will ensure that the response + // isn't fulfilled from the HTTP cache; i.e., it will be from the network. + await cache.add(new Request(OFFLINE_URL, {cache: 'reload'})); + })()); + // Force the waiting service worker to become the active service worker. + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + console.log('Flipper service worker activate'); + event.waitUntil((async () => { + // Enable navigation preload if it's supported. + // See https://developers.google.com/web/updates/2017/02/navigation-preload + if ('navigationPreload' in self.registration) { + await self.registration.navigationPreload.enable(); + } + })()); + + // Tell the active service worker to take control of the page immediately. + self.clients.claim(); +}); + +self.addEventListener('fetch', (event) => { + // We only want to call event.respondWith() if this is a navigation request + // for an HTML page. + if (event.request.mode === 'navigate') { + event.respondWith((async () => { + try { + // First, try to use the navigation preload response if it's supported. + const preloadResponse = await event.preloadResponse; + if (preloadResponse) { + return preloadResponse; + } + + // Always try the network first (try flipper server) + const networkResponse = await fetch(event.request); + return networkResponse; + } catch (error) { + // Catch is only triggered if an exception is thrown, which is likely + // due to a network error. + // If fetch() returns a valid HTTP response with a response code in + // the 4xx or 5xx range, the catch() will NOT be called. + console.log('Fetch failed; returning offline page instead.', error); + + const cache = await caches.open(CACHE_NAME); + const cachedResponse = await cache.match(OFFLINE_URL); + return cachedResponse; + } + })()); + } +});