Skip to content

Latest commit

 

History

48 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

chromeos-boot

Two unrelated scripts that share a name because they share a theme: getting a Linux box to behave like a Chromebook.

  • stage — bring a fresh ChromeOS Linux (Crostini) container up from nothing, when the things you want to install live in a private Cloud Storage bucket.
  • flex — turn a stock Debian desktop into a ChromeOS Flex lookalike: shelf, web apps, keybindings, wallpaper. No bucket, no gcloud, nothing private — this one is standalone.

stage

That is a chicken-and-egg problem: reading the bucket needs gcloud, and a bare container has no gcloud. This script is the one piece that has to be fetchable without credentials, so it lives here instead of in the bucket.

Use

bash <(curl -sSL https://raw.githubusercontent.com/xbill9/chromeos-boot/main/stage)

No git required — curl is enough, so there is nothing to apt-get install first.

Process substitution rather than a pipe is deliberate. curl ... | bash hands the script to bash on stdin, which is the same stdin gcloud auth login needs to read your answers from; the login then fails or silently eats the rest of the script. bash <(curl ...) passes it as a file descriptor instead and leaves stdin attached to your terminal.

If your shell has no process substitution, download and run in two steps:

curl -sSL https://raw.githubusercontent.com/xbill9/chromeos-boot/main/stage -o /tmp/stage
bash /tmp/stage

Full sequence

stage leaves you with a container that can read the bucket. Four commands take it the rest of the way:

bash <(curl -sSL https://raw.githubusercontent.com/xbill9/chromeos-boot/main/stage)
exec bash -l
bootstrap
bootstrap code
  • stage — gcloud, login, then ~/bin and the dotfiles out of the bucket, and finally the phone tools.
  • exec bash -l — load-bearing, and easy to skip. bootstrap is a shell function defined in the .bashrc that stage has just fetched, so it does not exist until a new login shell reads it.
  • bootstrap — apt packages, node, python, rust, go, docker, aws and the agent CLIs. Every stage is idempotent, so re-running is how you repair one that failed; bootstrap <stage> runs a single one and bootstrap -l lists them. The python stage compiles CPython and is slow.
  • bootstrap code — clones the repos. Kept out of the default set because it takes a while.

Two things then need the steps above to have finished:

  • Log out and back in. The docker stage adds you to the docker group, which a session that is already running will not pick up.
  • Re-run nnn. A couple of the scripts in ~/bin are symlinks into a cloned repo rather than copies from the bucket, so they cannot be linked until bootstrap code has cloned it. nnn warns and skips them until then.

What it does

  1. Installs the Google Cloud CLI from the tarball into $HOMEno sudo, no apt, no keyring setup.
  2. Logs you in, opening a browser tab. Crostini hands the URL to the ChromeOS browser you are already signed into.
  3. Copies nnn out of the bucket into ~/bin and runs it, which fetches everything else.
  4. Replaces the tarball with the apt-managed google-cloud-cli in /usr/bin, then deletes ~/google-cloud-sdk.
  5. Installs the phone tools — adb, fastboot and the libimobiledevice utilities — and puts you in the plugdev group.
  6. Installs the doc tools — pandoc, python3-pil, fonts-liberation, fonts-dejavu-core and git — plus zip.

It is idempotent: existing gcloud and an active login are detected and skipped, so re-run it to repair a half-finished container.

Why gcloud is installed twice

The tarball is the only kind of gcloud a bare container can install: no sudo, no keyring, no apt repo to add — and adding one needs gnupg, which may not be there yet either. It exists to read the bucket once, and is deliberately never added to PATH.

It is not the copy you want to keep. Step 4 adds Google's apt repo and installs google-cloud-cli into /usr/bin, which is what everything downstream expects and what gets updated along with the rest of the machine, and then removes ~/google-cloud-sdk. This used to be left to the bootstrap function in the fetched .bashrc; stage now does it, so bootstrap's gcloud stage finds the CLI already in place and does nothing.

Step 4 is non-fatal. Without sudo, or with apt unreachable, it warns, keeps the tarball and leaves the job to bootstrap gcloud.

Credentials live in ~/.config/gcloud, a separate directory shared by both copies, so you log in exactly once and removing the tarball does not log you out.

Phone tools

Step 5 installs adb and fastboot for Android, and usbmuxd, libimobiledevice-utils and ifuse for iPhones, plus usbutils for lsusb. All of it from Debian rather than from Google's or libimobiledevice's own builds: the distro packages lag a release or two but they are the ones apt keeps current, and both protocols tolerate an older client talking to a newer phone.

android-sdk-platform-tools-common is in the list for its udev rules alone. Without them adb devices reports no permissions for everything it sees. The rules hand each device to uaccess and to the plugdev group; uaccess does nothing in Crostini, which has no logind seat, so plugdev is what actually grants access — which is why the step adds you to it. Like the docker group, that needs a log out and back in.

Like step 4, this one is non-fatal: without sudo, or with apt unreachable, it warns and carries on, and re-running stage installs the tools later.

Then there is the part no script can do. A phone plugged into the Chromebook belongs to ChromeOS, not to the container, until you share it across from ChromeOS Settings, under the Linux development environment, Manage USB devices — and it has to be re-shared after every unplug. Once it is shared, adb devices should list an Android handset and ideviceinfo should answer for an iPhone; lsusb is the thing to check when neither does.

Doc tools

Step 6 installs pandoc, python3-pil, fonts-liberation, fonts-dejavu-core and git, which are what the publishing kit shells out to: pandoc renders article markdown to HTML, Pillow draws the cover image and rasterises every table, the cover and table generators hardcode paths into the Liberation faces, and diagrams are drawn in DejaVu Sans Mono — the only face with full box-drawing coverage. DejaVu is usually on the machine already, but only as one branch of fontconfig-config's dependency, so it is named explicitly. git is there because the kit's checks are git checks: a cover that is not committed is a broken image on dev.to, so check-article, preflight and publish-devto all fail without it. zip is the exception: nothing in the kit needs it, and it is there only so an archive can be packed without a trip back to apt.

Debian's pandoc rather than the upstream release or the pypandoc-binary wheel. The wheel works, but it installs a second pandoc under ~/.local that drifts from apt on its own schedule, and two pandocs on one machine is how you get output that changes without an edit.

python3-pil rather than pip install Pillow for a related reason: the kit is run with whatever python3 is on PATH, which in a fresh container is /usr/bin/python3. A --user wheel installed against a pyenv interpreter is invisible to it, and the failure is an import error at the point of use rather than at install time.

Like steps 4 and 5, this is non-fatal — no sudo or no network warns and moves on, and re-running stage installs them later.

Bucket

Defaults to the stage bucket; pass another as the first argument, or set BUCKET. The name is not sensitive - the bucket is private and IAM gates every object in it, so knowing the name gets you nothing without an authorised account. Step 2 is what establishes that.

flex

Turn a stock Debian 13 (trixie) / GNOME 48 desktop into a ChromeOS Flex lookalike: a bottom shelf, the Google web apps as first-class icons, ChromeOS keybindings, a pruned app grid, and a matching pair of wallpapers. Written from — and matches — the customisation actually run on this Yoga Pro 9 16IRP8 install.

Unlike stage, this one is self-contained: no bucket, no gcloud, nothing private. Everything it touches lives under $HOME except the repos and pkgs stages (apt, and non-fatal without sudo) and boot-splash.sh, a script it installs but never runs, since that one edits GRUB.

Display manager

Install GNOME with gdm3 — the Debian installer's default desktop task gives you that; take it and change nothing. flex never touches the display manager, so this is not about anything it configures, it is about the session it lands you in, and the script only works in one: it dies without gsettings, warns unless XDG_CURRENT_DESKTOP is GNOME, and installs a gnome-shell extension for the shelf, reading gnome-shell --version to pick the build.

lightdm is the wrong choice here. Debian's greeter only lists /usr/share/xsessions, so GNOME starts on Xorg rather than the Wayland session everything below was measured on, and it doesn't take the handoff from Plymouth cleanly, which is most of the point of boot-splash.sh. If a machine somehow ends up with both, sudo dpkg-reconfigure gdm3 chooses.

Use

bash flex              # everything, dark mode
bash flex --light      # everything, light mode
bash flex -l           # list the stages
bash flex shelf webapps   # just those two
bash flex revert       # undo it

Every stage is idempotent, so re-running is how you repair one that failed, and naming a stage is how you repair it without redoing the rest. icons (a ~60MB icon theme) and wallpaper (~9s per render) are the slow ones.

Wayland cannot restart gnome-shell in place, so the shelf, the GTK theme and the app grid only take effect after a full log out and back in; the wallpaper and the keybindings are live immediately.

Stages

  • repos — turns on the contrib and non-free apt components, which is what puts the Microsoft fonts, unrar and the proprietary drivers within apt's reach. It adds a separate file, /etc/apt/sources.list.d/chromeos-flex-nonfree.sources, naming the Debian archives the machine already uses with only the extra components, rather than rewriting the distro's own sources — which are deb822 on a fresh trixie and one-line on one upgraded from bookworm, and awkward to undo either way. The archives are read back from apt-get indextargets and filtered on Origin: Debian, so third-party repos such as Chrome's and Docker's are left alone. non-free-firmware is not touched; the installer has enabled it since Debian 12. This is the one stage bash flex revert needs sudo for.

  • pkgs — Roboto, the croscore and Noto font sets, gnome-tweaks, unzip (the shelf's extension download is a zip), zip, gh, pandoc, python3-pil, fonts-liberation, fonts-dejavu-core and git, and Chrome if no Chromium-family browser is already installed. Noto CJK is 91MB and left out; FONTS_CJK=1 adds it. The pandoc and Pillow pair are the publishing kit's dependencies, listed here for the same reason stage lists them: the kit runs under the system python3, where a --user wheel is not visible. The two fonts are named because the kit loads them by hardcoded path, and git because its checks shell out to it.

  • theme — adw-gtk3, light and dark, from the upstream release tarball (trixie has no package for it), so GTK3 apps match the libadwaita GTK4 ones. Pinned to v5.7, upstream's GNOME 48 / libadwaita 1.7 release — which is what trixie ships. Do not raise the pin to keep up: from v6.4 the GTK4 half needs GTK 4.20 for a CSS @media query, trixie has GTK 4.18, and the mismatch is silent — the stage still reports success while every @media block is discarded at parse time. The stage records the installed version in ~/.local/share/themes/.adw-gtk3-version and re-extracts when the pin moves, so correcting a bad pin is a re-run rather than a manual rm -rf.

  • icons — Papirus and Papirus-Dark, user-level.

  • shelf — installs dash-to-panel in place of the packaged dash-to-dock: only dash-to-panel merges the taskbar and system tray into one bar, which is what the ChromeOS shelf is. Bottom, 36px, 75% opacity, Google-Blue running-app dots, Alt+1-9 launches the nth pinned app. App icons are centred on the monitor with the launcher hard left and the clock and tray hard right, as on ChromeOS. The per-monitor layout is resolved through Mutter's DisplayConfig at run time rather than a hardcoded panel ID, so it isn't tied to one laptop's monitor — but a monitor this stage has never seen falls back to dash-to-panel's own left-stacked default, so plug the second screen in first and re-run bash flex shelf.

  • webapps — the eleven Google apps (Gemini, Gmail, Chat, Calendar, Drive, Docs, Sheets, Keep, Photos, Maps, YouTube) as windowless launchers with their own shelf icons, icons fetched from gstatic with a favicon-service fallback. All are pinned to the shelf except Keep.

    Two forms. If the site has been installed as a PWA the launcher uses --app-id=<id>, which is what ChromeOS itself does: the window carries the manifest's identity and Chrome names it crx_<id>, a name that survives Google reorganising the site's URLs. Otherwise it falls back to --app=<url>, a plain app-shortcut window with no manifest and no scope. The stage detects installed apps by looking for the launcher Chrome generates for them, so the upgrade path is: install from Chrome's ⋮ menu → Cast, Save and ShareInstall page as app, then re-run bash flex webapps. Where both exist, Chrome's own launcher is hidden with NoDisplay=true and stripped of its StartupWMClass so it neither doubles up in the app grid nor competes for the window; flex revert restores both lines.

    --class does nothing under Wayland. Chrome names an --app= window after its URL — chrome-<host>__<path>-Default, every character outside [A-Za-z0-9.-] replaced by an underscore — and ignores the flag entirely. A StartupWMClass that doesn't match means GNOME never sees the app as running, so every click on the shelf icon opens another window, which is exactly what the hand-written --class=chromeos-<id> launchers used to do. The rule in wmclass_for_url() was measured rather than guessed, and reproduces all ten ids exactly:

    WAYLAND_DEBUG=1 google-chrome-stable --user-data-dir=$(mktemp -d) \
        --app=https://mail.google.com/mail/u/0/ 2>&1 | grep set_app_id
    

    That is the only way to read the id on this box: xprop cannot see Wayland windows, org.gnome.Shell.Eval is off without unsafe-mode, and org.gnome.Shell.Introspect.GetWindows returns AccessDenied to callers that are not allowlisted.

  • appgrid — hides the apps ChromeOS doesn't have (LibreOffice, xterm, Disk Utility, and 30-odd others) by shadowing each system .desktop with a copy carrying NoDisplay=true — a copy, not a stub, so MIME associations and "Open with" still work. Nothing is uninstalled. GNOME Text Editor is renamed to "Text".

  • fonts — what Chrome puts on a page, as opposed to look, which sets the desktop's own font. Chrome asks fontconfig what sans-serif, serif and monospace mean; stock Debian answers DejaVu for all three, where ChromeOS answers Arimo, Tinos and Cousine. monospace is the one that visibly breaks — DejaVu Sans Mono is much wider and heavier than Cousine, so code blocks, diffs and devtools all read wrong. The stage writes ~/.config/fontconfig/conf.d/50-chromeos-flex.conf pointing the generics at the ChromeOS families, and pins grayscale antialiasing with slight hinting. system-ui needs no rule: Chrome takes that from the GTK font, so look already makes it Roboto. Set FONT_SANS=Roboto to have plain sans-serif match the shelf rather than match ChromeOS — it is narrower than Arimo and will reflow some pages. Chrome reads fontconfig once at startup, so this one needs a Chrome restart, not a reload.

  • look — Roboto as the UI/document/titlebar font, text scaled to 1.2×, blue accent, no hot corners, time-only clock, one workspace, shelf favourites. The text scale is what makes Chrome readable: Chrome turns GNOME's text-scaling-factor into Xft.dpi and uses it as its device scale factor, so it is the only setting that enlarges the browser UI — tabs, omnibox, menus — rather than just page text. Set TEXT_SCALE=1.0 to leave the size alone, or higher on a denser panel.

  • keys — Caps Lock becomes Super (the ChromeOS Launcher key); Alt for window minimise/maximise/tile; Alt+Tab cycles windows (not app groups, which move to Super+Tab); Super+[ / Super+] switch workspaces; Ctrl+F5 overview; Ctrl+Shift+F5 screenshot; Ctrl+Shift+Q sign out; Ctrl+Alt+T terminal.

  • helpers — installs set-mode.sh, gen_wallpaper.py and boot-splash.sh under ~/.local/share/chromeos-flex/.

  • claude — Claude Code's own preferences, so a rebuilt machine opens on a prompt rather than on the onboarding wizard and a fortnight of first-run notices: theme following --light/--dark, auto permission mode, spinner tips and the feedback survey off, and the one-shot dialogs (onboarding, Claude in Chrome, release notes) already answered. The two config files are merged, key by key, never copied: ~/.claude/settings.json is preferences, but ~/.claude.json is machine state and also holds the OAuth account, the machine id and the path of every project ever opened. ~/.claude/.credentials.json is not touched at all — the new box does one claude login. The packaged global ~/.claude/CLAUDE.md is installed only when there isn't one there already. Claude Code rewrites ~/.claude.json wholesale when it exits, so the stage says so if a session is running: run it again once that session has quit.

  • wallpaper — renders both wallpaper variants (pure-Python PNG encoder, no Pillow needed) and applies the requested mode.

After it runs

~/.local/share/chromeos-flex/set-mode.sh light   # flip the whole look
~/.local/share/chromeos-flex/set-mode.sh         # report the current mode

Two things it can't do for you, both a couple of clicks in Settings: mounting Drive in the Files app (Settings → Online Accounts → Google), and Chrome reopening its tabs on launch (Chrome Settings → On startup). The latter has a managed-policy route, deliberately not taken here — it stamps "Managed by your organization" onto the Chrome menu permanently in exchange for skipping one five-second click.

boot-splash.sh, also under ~/.local/share/chromeos-flex/, is installed but never run automatically: it edits /etc/default/grub to drop the GRUB menu and enable the Plymouth splash that ships already installed. Needs root; read it before running it.

bash flex revert resets every setting this script touched back to the GNOME defaults (not to whatever they were before — this doesn't snapshot prior values) and removes the launchers, icons, wallpapers and helper scripts it created, plus the contrib/non-free sources file — the one part that asks for a password. Custom keyboard shortcuts are the exception to the reset: only the Ctrl+Alt+T entry keys added is taken out, matched by name and command, so shortcuts you added yourself keep working. Claude Code is the same shape: the keys claude wrote come out of both files by name and nothing else in them is disturbed, and ~/.claude/CLAUDE.md goes only if it is still byte-for-byte the packaged copy. adw-gtk3, Papirus, dash-to-panel, Chrome's apt repo and any apt packages are left in place, since removing them isn't really an "undo" either. Anything already installed from contrib or non-free stays installed, and stops getting updates, so uninstall it first if you care.

About

Bootstrap a fresh ChromeOS Linux (Crostini) container from a private GCS bucket - no sudo, no apt, no git required

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages