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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ Add to `## [Unreleased]` as you go; rename it when you tag.
`wpasupplicant` (NetworkManager only recommends it), so NetworkManager
couldn't scan or run an access point. It also gains `wireless-regdb`
and MediaTek and Broadcom Wi-Fi firmware.
- After setup gave the box a name, the TV could show Chromium's "The
profile appears to be in use by another Chromium process on another
computer" instead of the room. The rename changed the hostname, and
Chromium wouldn't take over the lock its killed predecessor left
behind. The kiosk now clears that stale lock before every start (the
same after a power cut).

### Changed
- The image is smaller: documentation, man pages and translations are
Expand Down
31 changes: 25 additions & 6 deletions boxd/internal/profile/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
// state survive reboots, power cuts and crashes, and both live here or are
// triggered from boxd's bridge:
//
// - Prep clears Chromium's "exited uncleanly" bookkeeping before every
// start, so a power cut never leaves a restore prompt on the screen.
// - Prep clears Chromium's "exited uncleanly" bookkeeping and its stale
// profile lock before every start, so a power cut or a rename never
// leaves a restore prompt or a "profile in use" error on the screen.
// - Reset wipes the whole profile, so the box shows a fresh pairing code
// (when it moves to another room or server).
//
Expand Down Expand Up @@ -35,21 +36,39 @@ func Path() string {
return config.DefaultProfileDir
}

// Prep clears the unclean-exit state Chromium records in its profile, so a
// power cut never turns into a "restore pages?" prompt on the TV. It edits
// in place, preserving every other key:
// singletonFiles are Chromium's profile lock: SingletonLock is a symlink
// to "<hostname>-<pid>", the others belong to it. Chromium removes them
// on a clean exit; a kill (cage stopping under it, a power cut) leaves
// them behind. Chromium takes over a lock whose pid is dead only when the
// hostname matches, so after a rename (setup's name=, hostnamectl) it
// refuses the profile with "in use by another Chromium process on another
// computer" — on a TV with nobody there to click through it.
var singletonFiles = []string{"SingletonLock", "SingletonSocket", "SingletonCookie"}

// Prep readies the profile for a start. It removes the stale profile lock
// (singletonFiles) — safe because the kiosk wrapper calls Prep just before
// it execs the box's only Chromium. Then it clears the unclean-exit state
// Chromium records, so a power cut never turns into a "restore pages?"
// prompt on the TV. It edits in place, preserving every other key:
//
// - <dir>/Default/Preferences: browser.exit_type = "Normal"
// - <dir>/Local State: browser.exited_cleanly = true
//
// A fresh profile (files missing) is fine and left alone: nothing to clear.
// A file that is not valid JSON is reported as an error and left untouched
// — the caller logs it and starts Chromium anyway, exactly as it would
// have without this call.
// have without this call. The lock goes first, so a bad JSON file never
// keeps it.
func Prep(dir string) error {
if dir == "" {
return errors.New("profile: empty directory")
}
for _, name := range singletonFiles {
// os.Remove takes the symlink itself, dangling or not.
if err := os.Remove(filepath.Join(dir, name)); err != nil && !os.IsNotExist(err) {
return err
}
}
prefs := filepath.Join(dir, "Default", "Preferences")
if _, err := os.Stat(prefs); err == nil {
if err := setJSONKeys(prefs, map[string]any{"exit_type": "Normal"}, "browser"); err != nil {
Expand Down
50 changes: 50 additions & 0 deletions boxd/internal/profile/profile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,56 @@ func TestPrepLeavesInvalidJSONAlone(t *testing.T) {
}
}

// writeStaleLock leaves what a killed Chromium does: SingletonLock is a
// dangling symlink to "<hostname>-<pid>", here a hostname the box no
// longer has (setup renamed it).
func writeStaleLock(t *testing.T, dir string) {
t.Helper()
if err := os.Symlink("syncphony-box-a1b2-1234", filepath.Join(dir, "SingletonLock")); err != nil {
t.Fatal(err)
}
if err := os.Symlink("/tmp/.org.chromium.Chromium.gone/SingletonSocket", filepath.Join(dir, "SingletonSocket")); err != nil {
t.Fatal(err)
}
if err := os.Symlink("1234567890", filepath.Join(dir, "SingletonCookie")); err != nil {
t.Fatal(err)
}
}

func TestPrepRemovesStaleProfileLock(t *testing.T) {
dir := t.TempDir()
writeProfile(t, dir)
writeStaleLock(t, dir)
if err := Prep(dir); err != nil {
t.Fatal(err)
}
for _, name := range singletonFiles {
if _, err := os.Lstat(filepath.Join(dir, name)); !os.IsNotExist(err) {
t.Fatalf("%s survived Prep (err=%v)", name, err)
}
}
if _, err := os.Stat(filepath.Join(dir, "Default", "Preferences")); err != nil {
t.Fatalf("profile lost with the lock: %v", err)
}
}

func TestPrepRemovesLockEvenWithInvalidJSON(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "Default"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "Default", "Preferences"), []byte("not json"), 0o644); err != nil {
t.Fatal(err)
}
writeStaleLock(t, dir)
if err := Prep(dir); err == nil {
t.Fatal("expected an error for invalid JSON")
}
if _, err := os.Lstat(filepath.Join(dir, "SingletonLock")); !os.IsNotExist(err) {
t.Fatalf("invalid JSON kept the lock (err=%v)", err)
}
}

func TestPrepRejectsEmptyDir(t *testing.T) {
if err := Prep(""); err == nil {
t.Fatal("expected an error for an empty dir")
Expand Down
10 changes: 10 additions & 0 deletions docs/adr/0006-pairing-persistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,16 @@ key:
- `Default/Preferences`: `browser.exit_type = "Normal"`
- `Local State`: `browser.exited_cleanly = true`

Before that it removes Chromium's profile lock (`SingletonLock`,
`SingletonSocket`, `SingletonCookie`). A killed Chromium — cage stopped
under it on a kiosk restart, or a power cut — leaves the lock behind, and
`SingletonLock` names the hostname that held it. Chromium only takes over
a dead lock from its own hostname; after the box is renamed (setup's
`name=` runs `hostnamectl` just before restarting the kiosk) it refuses
the profile with "in use by another Chromium process on another
computer". The wrapper runs prep right before it execs the box's only
Chromium, so no live process can hold the lock then.

A missing file (fresh profile) is a no-op; invalid JSON is reported and
left alone, and prep never blocks the start (the wrapper runs it best
effort). Together with the existing `--disable-session-crashed-bubble`
Expand Down
7 changes: 4 additions & 3 deletions packaging/root/usr/lib/syncphony-box/kiosk
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,10 @@ fi

mkdir -p "${DATA_DIR}"

# A power cut leaves Chromium's own "exited uncleanly" bookkeeping behind,
# which can surface as a restore prompt. Clear it before every start —
# best effort: a failure never blocks the kiosk.
# A power cut or a kill leaves Chromium's own "exited uncleanly"
# bookkeeping behind (a restore prompt), and its profile lock (a "profile
# in use on another computer" error once the hostname changes). Clear both
# before every start — best effort: a failure never blocks the kiosk.
"${BOXD_BIN}" profile prep "${DATA_DIR}" 2>/dev/null || true

# GPU flags: the Pi's vc4/v3d GPU is on Chromium's software-rendering
Expand Down
Loading