Skip to content

miniapp-cli: getLanIp() picks a Tailscale/WSL address, so the QR is unreachable and the phone reports "dev server offline" #3889

Description

@skthegawd

Summary

getLanIp() returns the first non-internal IPv4 address from os.networkInterfaces(). On a
machine running Tailscale that is the 100.64.0.0/10 CGNAT address; on one running WSL or Docker
Desktop it is often a 172.x virtual adapter. The address goes into the QR code, the phone cannot
reach it, and the phone reports "dev server offline".

That message points the developer at the server, where nothing is wrong, instead of at the address it
was handed. We deleted and reinstalled the miniapp twice before checking what the QR actually
encoded.

Cause

Identical code in two places — src/dev.ts and src/release.ts:

function getLanIp(): string | null {
  const interfaces = os.networkInterfaces()
  for (const name of Object.keys(interfaces)) {
    for (const iface of interfaces[name] ?? []) {
      if (iface.family === 'IPv4' && !iface.internal) {
        return iface.address
      }
    }
  }
  return null
}

Enumeration order is not preference order. On our machine it yields Tailscale first:

PICKED Tailscale 100.96.198.122      <- what the QR encoded
       Wi-Fi     192.168.1.199       <- what the phone could actually reach
       vEthernet (WSL)  172.22.176.1

This matters more in release.ts than in dev.ts, since installing is the entire point of that
command.

Reproduction

  1. On a machine with Tailscale running (or WSL installed), start bunx @mentra/miniapp-cli dev.
  2. Scan the printed QR with a phone on the same Wi-Fi but not on the tailnet.
  3. The miniapp installs but reports "dev server offline". curl from the same LAN to
    http://<wifi-ip>:3000/miniapp.json returns 200, so the server is healthy.

Suggested fix

Rank the candidates rather than trusting order:

const candidates: Array<{address: string; rank: number}> = []
for (const name of Object.keys(interfaces)) {
  for (const iface of interfaces[name] ?? []) {
    if (iface.family !== 'IPv4' || iface.internal) continue
    const address = iface.address
    const isCgnat = /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(address)
    const isVirtual = /vEthernet|WSL|Hyper-V|VirtualBox|VMware|Tailscale|ZeroTier/i.test(name)
    if (isCgnat || isVirtual) candidates.push({address, rank: 3})
    else if (/^192\.168\./.test(address)) candidates.push({address, rank: 0})
    else if (/^10\./.test(address)) candidates.push({address, rank: 1})
    else candidates.push({address, rank: 2})
  }
}
candidates.sort((a, b) => a.rank - b.rank)
return candidates.length > 0 ? candidates[0].address : null

Still returns something when every interface is virtual — a wrong address the developer can see
beats a null that exits the CLI before printing anything.

Two smaller suggestions that would have saved us more time than the fix itself:

  • Print the URL next to the QR. The QR is currently the only place the chosen address appears, so
    a wrong pick is invisible without decoding the image.
  • Consider a --host flag for machines where the heuristic still guesses wrong.

Environment

  • @mentra/miniapp-cli 0.1.0-dev.0
  • Host: Windows 11 with Tailscale and WSL both present
  • Device: Mentra Live, MentraOS app staging.20260808.*

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions