Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ Inferno guide. For the AVF backend, the owner's `ios-bundle` workflow builds an
app and iOS 16.5 (see [docs/ETHICS.md](docs/ETHICS.md), point 6). This repo then gets the result running on the
phone.

The app doesn't use the network unless you turn **Network** on for an install (off by default; see
[docs/PHONE-QUICKSTART.md](docs/PHONE-QUICKSTART.md#network-qemu)). It declares Android's INTERNET
permission for that setting, and Android grants it at install.

## What runs where

```
Expand Down
9 changes: 7 additions & 2 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,14 @@
(adb shell pm grant <pkg> <permission>); never at runtime. -->
<uses-permission android:name="android.permission.MANAGE_VIRTUAL_MACHINE" />
<uses-permission android:name="android.permission.USE_CUSTOM_VIRTUAL_MACHINE" />
<!-- The AVF "Network" setting (useNetwork: a TAP that virtualizationservice tethers to the
phone's connection). The Terminal app, which uses the same option, holds it too. -->
<!-- The per-install "Network" settings, both off by default: AVF's (useNetwork: a TAP that
virtualizationservice tethers to the phone's connection; the Terminal app, which uses the
same option, holds it too) and QEMU's (slirp, whose sockets are in this app's UID; see
docs/NETWORKING.md). INTERNET is granted at install and can't be revoked, so "off" means
the app opens no network socket, not that it can't. -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- QEMU's Network setting: the default network's DNS servers (LinkProperties) for slirp. -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- The AVF camera (per-install Camera setting, avf/SccamCamera.kt): asked for at run time with
Android's own prompt when the setting is turned on; used only while Scion is on screen. -->
<uses-permission android:name="android.permission.CAMERA" />
Expand Down
18 changes: 15 additions & 3 deletions app/src/main/java/io/github/dylanneve1/iosonpixel/Installs.kt
Original file line number Diff line number Diff line change
Expand Up @@ -225,12 +225,13 @@ object InstallMigration {

/** A fingerprint of the old preferences: settings and location. */
fun seen(settings: VmSettings, vmInternal: Boolean): String =
encode(settings).apply { remove(KEY_AVF_DISPLAY); remove(KEY_AVF_NETWORK); remove(KEY_AVF_CAMERA) }.put("vm_internal", vmInternal).toString()
encodeLegacy(settings).put("vm_internal", vmInternal).toString()

/** Per-install only (not in the old preferences), so it's left out of [seen] and kept by [resync]. */
/** Per-install only (not in the old preferences), so they're left out of [seen] and kept by [resync]. */
private const val KEY_AVF_DISPLAY = "avf_display"
private const val KEY_AVF_NETWORK = "avf_network"
private const val KEY_AVF_CAMERA = "avf_camera"
private const val KEY_QEMU_NETWORK = "qemu_network"

/**
* After a downgrade and re-upgrade: if the old preferences no longer match [seen] (what this
Expand All @@ -245,6 +246,7 @@ object InstallMigration {
if (it.legacy) it.copy(
settings = legacy.settings.copy(
avfDisplay = it.settings.avfDisplay, avfNetwork = it.settings.avfNetwork, avfCamera = it.settings.avfCamera,
qemuNetwork = it.settings.qemuNetwork,
),
internal = legacy.vmInternal,
)
Expand Down Expand Up @@ -285,7 +287,12 @@ object InstallMigration {
}.takeIf { list -> list.map { it.id }.toSet().size == list.size }
}.getOrNull()

private fun encode(s: VmSettings) = JSONObject().apply {
/**
* The settings the single-VM app's preferences also have, in the order [encode] writes them.
* A fresh object rather than [encode] minus the per-install keys: org.json's (JVM) map would
* order the keys differently once it has grown, and the fingerprint must not change.
*/
private fun encodeLegacy(s: VmSettings) = JSONObject().apply {
put("ram_gb", s.ramGb)
put("smp", s.smp)
put("tb_size_mb", s.tbSizeMb)
Expand All @@ -295,9 +302,13 @@ object InstallMigration {
put("in_process", s.inProcess)
put("security", s.security.id)
put("extra_args", s.extraArgs)
}

private fun encode(s: VmSettings) = encodeLegacy(s).apply {
put(KEY_AVF_DISPLAY, s.avfDisplay)
put(KEY_AVF_NETWORK, s.avfNetwork)
put(KEY_AVF_CAMERA, s.avfCamera)
put(KEY_QEMU_NETWORK, s.qemuNetwork)
}

private fun decodeSettings(o: JSONObject): VmSettings {
Expand All @@ -315,6 +326,7 @@ object InstallMigration {
avfDisplay = o.optBoolean(KEY_AVF_DISPLAY, d.avfDisplay),
avfNetwork = o.optBoolean(KEY_AVF_NETWORK, d.avfNetwork),
avfCamera = o.optBoolean(KEY_AVF_CAMERA, d.avfCamera),
qemuNetwork = o.optBoolean(KEY_QEMU_NETWORK, d.qemuNetwork),
)
}
}
32 changes: 24 additions & 8 deletions app/src/main/java/io/github/dylanneve1/iosonpixel/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ class MainActivity : Activity() {
private lateinit var pinning: TextView
private lateinit var splitWx: Switch
private lateinit var inProcess: Switch
private lateinit var qemuNetwork: Switch
private lateinit var avfDisplay: Switch
private lateinit var avfNetwork: Switch
private lateinit var avfCamera: Switch
Expand Down Expand Up @@ -514,6 +515,13 @@ class MainActivity : Activity() {
)
// The checks depend on it, so apply it right away.
inProcess.setOnCheckedChangeListener { _, _ -> if (!filling) { saveSettings(); render() } }
qemuNetwork = switch(
advanced, "Network (QEMU)",
"QEMU only: the guest reaches the internet through the phone's connection (Wi-Fi or mobile data), over " +
"its USB link: iOS shows it as a wired network, not Wi-Fi. Off: QEMU opens no network socket. " +
"Restore boots never use it. Applies from the next start.", s.qemuNetwork,
)
qemuNetwork.setOnCheckedChangeListener { _, _ -> if (!filling) { saveSettings(); render() } }
extraArgs = ui.field(
advanced, "Extra arguments", s.extraArgs,
help = "Appended to the QEMU command line, or to the AVF guest's kernel command line. " +
Expand Down Expand Up @@ -620,6 +628,7 @@ class MainActivity : Activity() {
affinity.setText(s.vcpuAffinity)
splitWx.isChecked = s.splitWx
inProcess.isChecked = s.inProcess
qemuNetwork.isChecked = s.qemuNetwork
avfDisplay.isChecked = s.avfDisplay
avfNetwork.isChecked = s.avfNetwork
avfCamera.isChecked = s.avfCamera
Expand All @@ -633,7 +642,8 @@ class MainActivity : Activity() {
setAdvancedShown(
s.vcpuAffinity != d.vcpuAffinity || s.splitWx != d.splitWx || s.inProcess != d.inProcess ||
s.backend != d.backend || s.security != d.security || s.extraArgs != d.extraArgs ||
s.avfDisplay != d.avfDisplay || s.avfNetwork != d.avfNetwork || s.avfCamera != d.avfCamera || devSession,
s.avfDisplay != d.avfDisplay || s.avfNetwork != d.avfNetwork || s.avfCamera != d.avfCamera ||
s.qemuNetwork != d.qemuNetwork || devSession,
)
}

Expand Down Expand Up @@ -805,13 +815,18 @@ class MainActivity : Activity() {
statusDot.background = ui.pill(dotColor)
val notice = VmState.notice?.takeIf { ranHere }
statusDetail.setTextColor(if (notice != null) Ui.BAD else Ui.MUTED)
statusDetail.text = when {
notice != null -> notice + (lastExit?.let { "\nLast exit: $it" } ?: "")
lastExit != null -> "Last exit: $lastExit"
st == VmState.Status.RUNNING && backend.hasDisplay -> "Open the display to use the iPhone."
st == VmState.Status.RUNNING -> "No display yet: the guest console is in Diagnostics, below."
else -> ""
}
// The QEMU Network setting's state for the running VM ("Network: waiting for guest", ...).
val network = VmState.network?.takeIf { ranHere && st != VmState.Status.STOPPED }
statusDetail.text = listOfNotNull(
when {
notice != null -> notice + (lastExit?.let { "\nLast exit: $it" } ?: "")
lastExit != null -> "Last exit: $lastExit"
st == VmState.Status.RUNNING && backend.hasDisplay -> "Open the display to use the iPhone."
st == VmState.Status.RUNNING -> "No display yet: the guest console is in Diagnostics, below."
else -> null
},
network,
).joinToString("\n")
statusDetail.visibility = if (statusDetail.text.isEmpty()) View.GONE else View.VISIBLE

val list = backend.checks()
Expand Down Expand Up @@ -966,6 +981,7 @@ class MainActivity : Activity() {
avfDisplay = avfDisplay.isChecked,
avfNetwork = avfNetwork.isChecked,
avfCamera = avfCamera.isChecked,
qemuNetwork = qemuNetwork.isChecked,
)
}

Expand Down
17 changes: 10 additions & 7 deletions app/src/main/java/io/github/dylanneve1/iosonpixel/Monitor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,33 +11,36 @@ import java.util.concurrent.Executors
class Monitor(private val paths: AppPaths) {

/** Sends one command synchronously; returns false if QEMU isn't listening. */
fun send(command: String): Boolean = try {
fun send(command: String): Boolean = query(command) != null

/** Sends one command synchronously and returns what QEMU printed (up to the last 4 KiB), or null if it isn't listening. */
fun query(command: String): String? = try {
LocalSocket().use { s ->
s.connect(LocalSocketAddress(paths.monitorSocket.absolutePath, LocalSocketAddress.Namespace.FILESYSTEM))
s.soTimeout = 2000
val input = s.inputStream
readUntilPrompt(input)
s.outputStream.write("$command\n".toByteArray())
s.outputStream.flush()
readUntilPrompt(input)
readUntilPrompt(input).removeSuffix(PROMPT)
}
true
} catch (e: Exception) {
false
null
}

private fun readUntilPrompt(input: java.io.InputStream) {
private fun readUntilPrompt(input: java.io.InputStream): String {
val tail = StringBuilder()
while (true) {
val b = input.read()
if (b < 0) return
if (b < 0) return tail.toString()
tail.append(b.toChar())
if (tail.endsWith("(qemu) ")) return
if (tail.endsWith(PROMPT)) return tail.toString()
if (tail.length > 4096) tail.delete(0, 2048)
}
}

companion object {
private const val PROMPT = "(qemu) "
private val io = Executors.newSingleThreadExecutor()

/** Presses QEMU key [key] for [holdMs] without blocking the caller. */
Expand Down
69 changes: 55 additions & 14 deletions app/src/main/java/io/github/dylanneve1/iosonpixel/QemuBackend.kt
Original file line number Diff line number Diff line change
Expand Up @@ -60,24 +60,40 @@ class QemuBackend(ctx: Context, private val install: Install) : VmBackend {
val extra = QemuCommand.extraArgs(app)
if (extra.isNotEmpty()) log("debug: ${extra.size} extra QEMU args appended from files/qemu-extra-args.txt")
val cmd = QemuCommand.build(paths, vm, settings) + settings.extraArgList + extra
val network = QemuCommand.network(vm, settings)
when {
network -> log("network: on (slirp behind the guest's USB link)")
settings.qemuNetwork -> log("network: restore boot, so the Network setting is not used")
}
log("$ " + cmd.joinToString(" "))
VmState.network(QemuNet.statusText(settings.qemuNetwork, QemuCommand.restoreBoot(vm), connected = false, address = null))

// With the Network setting on, slirp reads the phone's DNS servers from this file (#82).
val resolver = if (network) QemuNet.Resolver(app, paths, log).also { it.start() } else null
val env = QemuCommand.env(paths.tmpDir, settings) +
(if (resolver != null) mapOf(QemuNet.ENV_RESOLV_CONF to paths.resolvConf.absolutePath) else emptyMap())
fun watched(i: VmBackend.Instance): VmBackend.Instance =
if (resolver != null) QemuNet.Watch(i, paths, resolver) else i
fun failed(message: String): VmBackend.Instance? {
log(message)
resolver?.stop()
VmState.network(null)
return null
}

val env = QemuCommand.env(paths.tmpDir, settings)
if (settings.inProcess) {
val vmProcess = QemuInProcess(app, paths, cmd, vm.dir.absolutePath, env)
if (vmProcess.bind()) return vmProcess
log("error: failed to start QEMU: cannot bind the :vm service")
return null
if (vmProcess.bind()) return watched(vmProcess)
return failed("error: failed to start QEMU: cannot bind the :vm service")
}

val pb = ProcessBuilder(cmd).directory(vm.dir).redirectErrorStream(true)
pb.environment().putAll(env)

return try {
Instance(pb.start())
watched(Instance(pb.start()))
} catch (e: Exception) {
log("error: failed to start QEMU: $e")
null
failed("error: failed to start QEMU: $e")
}
}

Expand Down Expand Up @@ -165,6 +181,18 @@ object QemuCommand {
return runCatching { f.readText().split(Regex("\\s+")).filter { it.isNotEmpty() } }.getOrDefault(emptyList())
}

/** The slirp netdev's id; the machine's net uplink names it. */
const val NETDEV_ID = "n0"

/** A restore boot: the VM folder names a restore ramdisk (passed as -initrd). */
fun restoreBoot(vm: VmLayout): Boolean = vm.ramdisk?.isFile == true

/**
* Whether this boot gets the guest network: the Network setting is on and it is a normal boot.
* Restore boots always keep the inferno uplink, which restore tools talk to.
*/
fun network(vm: VmLayout, s: VmSettings): Boolean = s.qemuNetwork && !restoreBoot(vm)

/**
* Environment for QEMU, the same for the child process and the in-process :vm path.
* TMPDIR is where -snapshot puts its overlays (vl.XXXXXX): the app's cache dir, which
Expand All @@ -179,17 +207,27 @@ object QemuCommand {
/** Mirrors the sample command from the Inferno guide, minus host-only display flags. */
fun build(paths: AppPaths, vm: VmLayout, s: VmSettings): List<String> {
fun path(key: String) = vm.file(key)!!.absolutePath
val machine = listOf(
"t8030",
"trustcache=${path("trustcache")}",
"sep-fw=${path("sep_fw")}",
"sep-rom=${path("sep_rom")}",
"kaslr-off=true",
val network = network(vm, s)
val uplink = if (network) listOf(
// The guest's USB NCM link, bridged to the slirp netdev below (Inferno's
// usb-uplink-type=net: QEMU is the USB host and puts the iPhone in mode 3).
"usb-uplink-type=net",
"usb-uplink-addr=$NETDEV_ID",
) else listOf(
// Connect out over a unix socket (retried quietly while nothing
// listens) instead of the default TCP server, which needs INTERNET.
"usb-uplink-type=inferno",
"usb-uplink-addr=unix:${paths.usbUplinkSocket.absolutePath}",
).joinToString(",")
)
val machine = (
listOf(
"t8030",
"trustcache=${path("trustcache")}",
"sep-fw=${path("sep_fw")}",
"sep-rom=${path("sep_rom")}",
"kaslr-off=true",
) + uplink
).joinToString(",")

val accel = buildString {
append("tcg,thread=multi,tb-size=").append(s.tbSizeMb)
Expand Down Expand Up @@ -223,6 +261,9 @@ object QemuCommand {
"-drive", "file=sep_nvram,if=pflash,format=raw,file.locking=off",
"-drive", "file=sep_ssc,if=pflash,format=raw,file.locking=off",
)
// User-mode networking: DHCP, DNS and NAT in QEMU, through ordinary sockets in the app's
// UID. Only with the Network setting on; off, there is no netdev at all.
if (network) args += listOf("-netdev", "user,id=$NETDEV_ID")

fun nvme(id: String, file: String, format: String, device: String, nsid: Int, nstype: Int, extra: String = "") {
args += listOf(
Expand Down
Loading
Loading