- Shell 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
A full recreate now runs from scratch to a provisioned VM on the M2 against Fedora 44 - derived image, disk build, first boot, sudo bootstrap, hostshare automount, and the composefs root grow. The limitations section said the provisioning path had only been checked by inspection, which is no longer true. Replaces that with what was actually exercised and on what, and flags FEDORA_RELEASE as the knob most likely to invalidate it: the per-transport sshd layout and the composefs root are both release-specific. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
| .gitignore | ||
| CLAUDE.md | ||
| README.md | ||
| swaybox.sh | ||
swayonmac
Run Fedora Sway Atomic as a desktop VM on an Apple Silicon Mac, under QEMU.
swaybox.sh is the whole project: one Bash script that builds the disk image,
boots the VM in a window, and manages its lifecycle. There is nothing to
install, compile, or configure beyond the script itself.
This exists because you want the Sway UI specifically — a tiling Wayland compositor, running as a real desktop in a window on macOS — not because a VM is a convenient way to get a Linux shell. The intended workload inside the guest is a terminal plus Chromium.
Requirements
- macOS on Apple Silicon (M1/M2/…). x86_64 Macs are handled but incidental.
- Homebrew packages:
QEMU is needed for everything. Podman is needed only forbrew install qemu podman socatcreate, which builds the disk image.socatis used as a fallback path when shutting down. - An SSH public key at
~/.ssh/id_ed25519.pub, or setSSH_KEYto point at one. Generate one withssh-keygen -t ed25519if you don't have it. - Free disk space. The disk image is thin-provisioned and grows as you use it, but its ceiling defaults to 64 GB.
About memory
The reference setup is an M2 with 8 GB of unified memory, and that is the binding constraint on this whole project — not CPU, not disk. macOS needs about 3 GB, QEMU adds roughly 0.4 GB of overhead on top of whatever the guest gets, and the rest is yours.
The default RAM_MB=3584 (3.5 GB) is chosen to leave the Mac usable while
still fitting a browser inside the guest. If you have 16 GB or more, raising it
is the single most effective change you can make.
Quick start
git clone <this repo> && cd swayonmac
./swaybox.sh create # build the disk image — several minutes, several GB
./swaybox.sh run # opens a window; Sway is running inside it
create also boots the VM once, headless, to finish provisioning, then shuts it
down. When it's finished, run is what you use day to day.
run detaches: the VM window opens and your prompt returns immediately. That
also means Ctrl-C in the terminal no longer stops the VM — shut it down from
inside Sway, or with ./swaybox.sh stop. Closing the terminal leaves the VM
running.
To get a shell without the graphical window:
./swaybox.sh run-bg # boot headless
./swaybox.sh ssh # ssh in
./swaybox.sh ssh 'uptime' # or run a single command
Commands
| Command | What it does |
|---|---|
create |
Build disk.qcow2 with bootc-image-builder, then provision it |
setup |
Re-run guest provisioning on a VM that already exists and is running |
run |
Boot with a Cocoa window — Sway as your live desktop. Detaches, so your terminal comes straight back |
run-bg |
Boot headless with VNC on 127.0.0.1:5900 |
stop |
Graceful shutdown (SSH → QMP → kill -9) |
restart |
stop, then run |
destroy |
Delete the disk image (keeps the pulled container image cache) |
recreate |
destroy + create |
ssh [cmd] |
SSH into the running VM, optionally running one command |
status |
Whether the VM is running, and how to connect |
password |
Print the console login password |
create is the expensive one: it pulls multi-GB container images, switches your
Podman machine to rootful mode (stopping it first if it's running), and takes
many minutes. setup exists so you can apply provisioning changes to an
existing VM without paying that cost again.
Configuration
Everything is set by environment variable — you should not need to edit the script. Set them per-invocation, or export them in your shell profile.
RAM_MB=6144 ./swaybox.sh run
| Variable | Default | Notes |
|---|---|---|
VM_DIR |
~/vms/fedora-sway |
Where the disk and runtime state live |
RAM_MB |
3584 |
Guest RAM. See the memory note above |
CPUS |
4 |
vCPUs. These cost no host RAM |
DISK_SIZE |
64G |
Ceiling, not an allocation. Applied at create only |
VM_USER |
user |
Account created inside the guest |
VM_HOSTNAME |
swaybox |
Guest hostname |
SSH_PORT |
2222 |
Host port forwarded to the guest's port 22 |
VNC_DISPLAY |
:0 |
VNC display for run-bg (:0 = port 5900) |
SHARE_DIR |
~/vm-share |
Host directory shared into the guest |
SSH_KEY |
~/.ssh/id_ed25519.pub |
Public key injected at build time |
BOOTC_IMAGE |
quay.io/fedora-ostree-desktops/sway-atomic:44 |
Source image |
FEDORA_RELEASE |
44 |
Used to build the default BOOTC_IMAGE |
BALLOON |
1 |
virtio-balloon, so the host can reclaim idle guest RAM |
VM_PASSWORD |
(generated) | Set the console password at create time — see Logging in |
GL |
0 |
Set to 1 for GPU acceleration — see Graphics below |
⚠️ Paths must not contain spaces. QEMU arguments are assembled as strings
and eval'd, so a SHARE_DIR or VM_DIR containing a space will break the
command line.
Logging in
You get two credentials, and you need both:
- Your SSH key gets you in over the network (
./swaybox.sh ssh). - A console password is what you type at the graphical Sway login screen, because a login screen cannot authenticate an SSH key.
By default the password is generated at first create, stored in
$VM_DIR/.password with mode 0600, and printed by:
./swaybox.sh password
Choosing the password yourself
You do not have to accept a generated one. create picks a password in this
order, first match wins:
VM_PASSWORDin the environment — set it for thecreateinvocation:VM_PASSWORD='correct horse battery staple' ./swaybox.sh create- An existing
$VM_DIR/.passwordfile — write it yourself before the firstcreateand that is the password you get:
The file should hold the password on a single line and nothing else.mkdir -p ~/vms/fedora-sway printf 'my-chosen-password\n' > ~/vms/fedora-sway/.password chmod 600 ~/vms/fedora-sway/.password ./swaybox.sh create - Generated — a 20-character random string, only when neither of the above is present.
Whichever path is taken, the result is written back to $VM_DIR/.password, so
./swaybox.sh password always prints the real one.
⚠️ The password is baked into the disk image at create time. Editing
.password afterwards changes the file, not the guest — you'd be left with a
file that no longer matches the login. To change the password on a VM that
already exists, change it inside the guest with passwd, and update
.password yourself to match if you want the two to agree.
destroy deliberately leaves .password alone, and create reuses an existing
one, so recreate won't invalidate a password you've written down.
Sharing files with the Mac
Everything in ~/vm-share on the host (or whatever SHARE_DIR points at)
appears inside the guest at:
/var/mnt/hostshare
It is mounted over virtio-9p by a pair of systemd units, and it's an
automount: nothing is mounted until something first touches the path, so a
boot where the share isn't available costs you one missing directory rather than
a hung startup. A /mnt/hostshare symlink is created too, where the system
allows it.
The mountpoint is under
/varrather than/mntbecause Fedora Atomic's root filesystem is read-only —/mntcannot be given new subdirectories.
Check it from the host:
./swaybox.sh ssh 'findmnt -t 9p /var/mnt/hostshare'
Setting up your actual workload
The script provisions the user, SSH, the share, and the disk. Installing what you actually work in is manual and one-time.
Fedora Sway Atomic is an image-based (bootc/rpm-ostree) system, so dnf install
on the host system is not how software gets installed. Instead:
Put everything in one toolbox
toolbox create
toolbox enter
# inside the toolbox, dnf works normally:
sudo dnf install nodejs chromium
Install both your terminal tooling and Chromium inside the same toolbox
(Node is there for Node-based CLI tools; swap in whatever you actually use).
This is deliberate: a toolbox shares the host network namespace and your real
$HOME, so your dev server, your editor, and your browser all agree about what
localhost means and where files are. The Flatpak Chromium has its own
filesystem view and reintroduces exactly that friction.
Chromium on Sway
Launch it with the Wayland backend so it renders natively instead of through XWayland:
chromium --ozone-platform=wayland
Recent versions may detect this on their own.
If Chromium fails to start with an error about being unable to move to a new
namespace, its sandbox is colliding with the container. The usual workaround is
--no-sandbox. That is an acceptable trade for a browser you use to debug
localhost, but it is a genuine reduction in browser security — don't use that
profile for general web browsing.
Installing outside a container
rpm-ostree install <pkg> layers a package onto the base image and requires a
reboot. Reserve it for things that genuinely must exist outside a container,
like drivers or a VPN client — every layered package slows down future image
updates.
A note on swap
Fedora enables zram (compressed swap in RAM) by default, and in a 3.5 GB guest that is doing a lot of work — it's a large part of why Chromium plus a toolbox fits at all. Leave it on, and don't replace it with a disk swapfile.
Graphics
By default the guest uses a software-rendered virtio GPU. It works out of the box, but compositing and scrolling will feel slower than a native app.
Homebrew's QEMU ships without OpenGL support, so hardware acceleration needs a patched build:
brew tap knazarov/qemu-virgl
brew install knazarov/qemu-virgl/qemu-virgl
GL=1 ./swaybox.sh run
GL=1 against stock Homebrew QEMU will not work.
Networking
The VM uses QEMU's usermode (SLIRP) networking: outbound connections and DNS
work normally, including through a VPN, but the guest is not directly reachable
from your LAN. The one forwarded port is SSH, on localhost:2222.
This is not an oversight — the faster vmnet backends require the
com.apple.vm.networking entitlement, and Homebrew's QEMU binary isn't signed
with it. To expose another port, add a hostfwd rule in net_args().
Disk space
DISK_SIZE is a ceiling, not an allocation: the qcow2 file grows as the guest
writes, so a 64 GB setting doesn't cost 64 GB up front. It does eventually,
though, if you fill it.
Budget generously. The deployed system image is around 10 GB, bootc keeps the previous deployment after an update (so plan for two), and your toolbox — Node, Chromium, container images — lands on the same filesystem.
DISK_SIZE only applies when the disk is built. To grow a disk that already
exists:
./swaybox.sh stop
qemu-img resize ~/vms/fedora-sway/disk.qcow2 96G # can only grow, never shrink
./swaybox.sh run-bg
./swaybox.sh setup # grows the filesystem to match
Check the result with ./swaybox.sh ssh 'df -h /'.
How create works, and why it's unusual
Sway Atomic ships no Anaconda installer ISO for Apple Silicon — only a bootc container image. So there is no installer to boot.
Instead, create runs bootc-image-builder under Podman to convert the
container image directly into a bootable qcow2, driven by a generated config
that injects your user, SSH key, console password, and wheel membership. It
then resizes the image, boots it once headlessly, and provisions the guest over
SSH: passwordless sudo, the host share automount, and growing the root
filesystem into the resized disk.
Before that, create builds a small derived image — one layer on top of the
base that runs systemctl enable sshd.socket. The base image ships sshd split
into per-transport units with the TCP one disabled, so without this nothing
listens on port 22 and the port forward has nothing to reach. Customising the
image as an image is the bootc-native way to do this; the equivalent
blueprint-level settings do not work here. The derived image is built locally as
localhost/swaybox-base:<release> and create verifies the unit really is
enabled in it before spending time on the disk build.
This is why create needs Podman and switches it to rootful mode, and why it
takes a while.
Troubleshooting
Provisioning is written to fail loudly. If a step fails you should get a
named reason rather than a VM that looks fine and isn't. Re-run ./swaybox.sh setup after fixing something — it's safe to run repeatedly.
create finished but nothing is provisioned / SSH never came up.
Almost always this means the guest has no TCP SSH listener. Fedora splits
sshd into per-transport units and ships the TCP one disabled, so the image comes
up with only sshd-unix-local.socket (AF_UNIX) and sshd-vsock.socket
(AF_VSOCK) active — neither accepts TCP, so port 22 stays closed and the port
forward has nothing to reach. create now enables sshd.socket when it builds
the image; a VM built before that fix needs it turned on by hand.
Get a console. The simplest route is a native window rather than VNC, which avoids macOS Screen Sharing's password prompt entirely (QEMU's VNC server here has no password set, so that prompt cannot be satisfied):
./swaybox.sh stop && ./swaybox.sh run
Log in as user with ./swaybox.sh password, then:
systemctl list-unit-files | grep -i ssh # what exists, and its enabled state
sudo systemctl enable --now sshd.socket # the TCP listener
ss -ltnp | grep :22 # confirm something is listening
Then run ./swaybox.sh setup from the Mac.
The failure path in create also prints the tail of the guest serial console
($VM_DIR/console.log) and QEMU's own log ($VM_DIR/qemu.log), both captured
on every boot, so a guest that never finishes booting isn't a black box.
sudo asks for a password over SSH.
An SSH session has no terminal, so sudo can't prompt, and unattended setup
fails at its first line. setup bootstraps passwordless sudo using the saved
console password. Verify with:
./swaybox.sh ssh 'sudo -n true; echo exit=$?'
"Host key verification failed" / "REMOTE HOST IDENTIFICATION HAS CHANGED".
The guest generates a new host key every time you recreate. The script's own
SSH calls discard host keys for this reason. If you're connecting by hand, drop
the stale entry:
ssh-keygen -R '[localhost]:2222'
The disk is smaller inside the guest than DISK_SIZE.
Growing the qcow2 file doesn't grow the filesystem in it. Run ./swaybox.sh setup, then check df -h /.
The share isn't there.
It's an automount, so it won't appear until something accesses the path. Try
ls /var/mnt/hostshare first, then systemctl status var-mnt-hostshare.mount
and journalctl -u var-mnt-hostshare.mount.
The VM won't shut down.
stop escalates automatically: SSH systemctl poweroff, then a QMP powerdown
over the monitor socket, then kill -9.
Where state lives
Nothing is stored in this repo. Everything lives in $VM_DIR
(~/vms/fedora-sway by default):
| File | Purpose |
|---|---|
disk.qcow2 |
The VM's disk |
.password |
Console password, mode 0600 |
qemu.pid |
PID file, used to tell whether the VM is running |
qmp.sock |
QEMU monitor socket, used by stop |
console.log |
Guest serial console from the current boot |
qemu.log |
QEMU's own log from the current boot |
qemu-stderr.log |
QEMU's startup output — where launch failures land |
build/ |
bootc-image-builder config and output |
destroy removes only the disk. It keeps the pulled container images and your
password, so recreate is much faster than the first create.
Status and limitations
- macOS only. The script can't run on Linux — it assumes
hvfacceleration, the Cocoa display backend, and Homebrew's firmware paths. - Apple Silicon is the primary target; x86_64 is handled but far less exercised.
- Paths containing spaces will break QEMU argument assembly.
- Exercised end to end on an M2 against Fedora 44 Sway Atomic:
createfrom scratch through to a provisioned, running VM. Other hosts and releases are untried, andFEDORA_RELEASEin particular is the knob most likely to move under you — the sshd unit layout and the composefs root are both things this release does one specific way. - Provisioning is written to fail loudly. If
createorsetupmisbehaves, the error names the failing step, andcreateadditionally prints the guest serial console and QEMU's log — that output is the thing to report.