Offline Testing PWA

Yesterday we left off with successfully creating a personal web app to the point that the Google Chrome browser on macOS was able to prompt us to install the application. Once installed, it opened up and worked. Even when the Vite dev server wasn’t running, the app still had everything it needed to open.

Operating systems on mobile devices may behave differently. It’s time to see if Android and iOS need any additional changes in order to operate.

Prepare

Before we can test our emulators, we need to ensure that the website is available to external connections. Typing in localhost within an emulator would go to the emulators own web server if it had one running.

By default, Vite will only work with connections to localhost (127.0.0.1). To keep the project generic for anyone regardless of their private networks IP address, we need to configure vite to accept all requests on any interface. We do this by setting the host to 0.0.0.0

export default defineConfig({
  server: {
    host: '0.0.0.0',
    port: 5173,
    https: {
      key,
      cert,
    },
  },
});

Now I need to start up the web server and visit the page through the IP address on my local area network (LAN). On macOS, I can run ipconfig getifaddr en0 to get this address. Mine is 192.168.54.242. Then I use that address in my browser, using the https protocol ( https://192.168.54.242 ). Did it work? Yes. The server now responds. Do I see my page? No. We get an error that the certificate doesn’t match. We need to go back, assign an extra SAN to our OpenSSL certificate configuration and go through the process of creating and trusting the certificate. The self-signed certificate isn’t assigned to the computers IP Address on the network. To work around this issue, I changed the shell script to a JavaScript file that grabs the local IPv4 addresses and host name to create the certificate.

const { exec } = require('child_process');
const os = require('os');
const fs = require('fs');
const path = require('path');

const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'PeriPlux'));
const tempFile = 'openssl.cnf.temp';
const tempPath = path.join(tempDir, tempFile);
  
const dns = [
    'localhost',
    os.hostname()
  ]
  .filter(name => name.trim() !== '');

// Get all IP addresses assigned to the device (ie 127.0.0.1 and 192.168.0.1)
const ipAddresses = Object.values(os.networkInterfaces())
  .flat()
  .filter(({ family }) => family === 'IPv4')
  .map(({ address }) => address);

ipAddresses.push('0.0.0.0');

let config = [
  '[req]',
  'req_extensions = v3_req',
  'distinguished_name = req_distinguished_name',
  '[req_distinguished_name]',
  '[v3_req]',
  // Android Emulator only works with certificates for certificate authority (CA)
  'basicConstraints = CA:TRUE',
  // Mark key usable for HTTPS
  'keyUsage = digitalSignature, keyEncipherment',
  // Allow alternative names other than the Common Name (CN)
  // for IP Addresses and DNS names
  'subjectAltName = @alt_names',
  '[alt_names]'
];

config.push(...dns.map((dns, index) => `DNS.${index+1} = ${dns}`));
config.push(...ipAddresses.map((ip, index) => `IP.${index+1} = ${ip}`));

fs.writeFileSync(tempPath, config.join("\n"), 'utf-8');

const opensslCommand = [
  // create/process request
  'openssl req',
  // Standard X.509 format for certificates
  '-x509',
  // Key pair for RSA 2048 bit encryption
  '-newkey rsa:2048',
  // where private key is saved
  '-keyout localhost.key',
  // where the certificate is saved
  '-out localhost.crt',
  // days until certificate expires
  '-days 365',
  '-nodes',
  // Hashing algorithm SHA-256
  '-sha256',
  // The common name to identify the host/domain
  '-subj "/CN=localhost"',
  // Add extensions to support subject alternative names (for DNS & IP Addresses)
  '-extensions v3_req',
  // Configuration file
  `-config ${tempPath}`,
  // Do not show pluses, dots, stars
  // '-quiet',
].join(" ");

// Execute the OpenSSL command
exec(opensslCommand, (error, stdout, stderr) => {
  if (error) {
    console.error(`Error: ${error.message}`);
  }
  if (stderr) {
    console.error(stderr);
  }
  if(!(error)) {
    // Let end-user know what IP Addresses and DNS were used
    dns.forEach((dns, index) => console.log(`DNS.${index+1} = ${dns}`));
    ipAddresses.forEach((ip, index) => console.log(`IP.${index+1} = ${ip}`));

    console.log('Certificate generated successfully.');
    console.log(stdout);
  }
  fs.unlinkSync(tempPath);
});

Note: In the above code, CA:FALSE was later changed to CA:TRUE to allow Android to install certificates. Android states that it needs a private key to install certificate files, but even with the private key in a .pem file, it still had the same message. Android requires self-signed certificates to be issued to certificate authorities (CA).

Now we are ready! Now I have many options to visit my local development server securely.

iOS Emulation

  • Start Vite
  • Open Xcode (v15.3)
  • Create a new project
  • Choose iOS
  • Any type of project would work. Let’s go for a Safari Extension so that the browser would be open when it starts.
  • Once your project opens up, press the Play button icon above the file list, or go to the Product menu and click Run.
  • On the iPhone emulator
  • Swipe up to minimize the app
  • Click the Safari Browser icon of a compass
  • Go to your computers IP address on the network (Not 127.0.0.1)
  • We now get a familiar site. Although our computer trusts the certificate, the emulator isn’t aware of that trust.
Untrusted certificate
Show Details
view the certificate
More Details
More Details

How do we trust the certificate?

  • Find the localhost.crt in your projects root directory
  • Make a copy of the file and rename it to localhost.crt.txt
  • Open files on the emulator
  • Click View Options and check Show All Extensions.
  • Drag the txt file onto the iPhone emulator.
  • Hold your pointer over the file until you get a menu
  • Click Rename
  • Remove the .txt extension if it appears, or simply click enter
  • Confirm the new file extension to Use ".crt"
  • Double click the file
  • You’ll get a message that a profile was downloaded and to review it in the settings app to install it.
  • Open Settings
  • Open General
  • Open VPN & Device Management
  • A downloaded profile appears “Localhost”
  • Click on localhost
  • You’ll see that localhost was verified as having signed the sertificate
  • Click “Install”
  • Go back to Safari
  • Refresh
  • Done!

Our PWA is running in Safari. We don’t have an install banner. Can we install it?

  • Click the share icon
  • Scroll down to Add to Home Screen
  • Accept the defaults and click Add
  • Go to your home screen
  • See the icon
PeriPlux on home screen

Offline testing

Unlike a physical iOS device, we can’t simply put the emulated iPhone into airplane mode to disconnect from a WiFi network. The emulator is tied closely with the operating system of the host environment. Instead, we need to install additional tools to emulate an offline state for our host environment that will also be applied to the emulated device.

  • Download Additional Tools for XCode
  • Open the file
  • Open the Hardware folder
  • Double Click Network Link Conditioner.prefPane
  • Agree to install
  • Change profile to 100% loss
  • Turn it on – This makes your own computer have problems in addition to the emulator
  • Back in the emulator
    • Try to visit a known/dependable site in Safari https://www.google.com/ – fails
    • Try to visit the local website in Safari – works
    • Try to visit the installed app on the home screen – works
Additional Tools for XCode: Network Link Conditioner
Deactivate Network Link Conditioner

Did it really work, or did the emulator simply talk to the host machine?

Rather than taking the emulator online, we can also take the website itself offline by stopping Vite. This way our computer that is hosting the emulator is not affected by the Network Link Conditioner.

Android Emulation

  • Open Android Studio
  • Create a new project: No Activity
  • Target a phone (ie Pixel 3a API 34)
  • Run
  • Close the current app
  • Open Chrome
  • Go to private network ip/host name (ie https://192.168.54.242:5173/ )
    • Fails – Certificate error
  • Open settings app
  • Scroll down and open Security & privacy
  • Scroll down and open More security & privacy
  • Scroll down to Encryption & credentials
  • Click Install a certificate
  • Choose CA certificate (All options will fail unless your certificate had basicConstraints = CA:TRUE)
    • CA certificate – private key required
    • VPN & app user certificate – you need a private key
    • Wi-Fi certificate – private key required
  • Drag localhost.crt from host environment onto emulator
  • Click hamburger icon / menu and choose Downloads
  • Open localhost.crt
  • Go back to chrome and refresh the page
  • You now see your app without security errors/warnings
  • You now see an installation banner
  • Click the banner and install the app
  • Your home screen now has an icon for your app
  • Open the icon
  • You now see your app
  • Disable the network (WiFi & Cellular)
  • Reload the web page – works
  • Close/Reopen PWA icon – works
  • Go back to chrome, confirm you can’t reach dependable sites like https://google.com – fails
Untrusted Certificate
Settings
Search “certificate”
More security & privacy
Encryption & credentials
Install a certificate
CA certificate
VPN & app user certificate
Open from
CA certificate
VPN & app user certificate
Open from
Downloads
Refresh with Install Banner
Menu: Install app
Install App
Install Dialog
Installed PWA
Internet Connection
Internet Dialog
Disabled Internet
No internet
Offline Page Works
Offline PWA Works

What happened today?

  • The certificate generation script was modified to include the IP and Hostname of the local machine.
  • The certificate allows for the recipient to be a certificate authority (CA)
  • The PWA can be installed on emulators for Android and iPhone
  • The installed PWA can run offline on both Android and iPhone

Discover more from Lewis Moten

Subscribe now to keep reading and get access to the full archive.

Continue reading