Skip to content

Add CI, release workflow, and bump version to 1.1.0#2

Merged
tashda merged 11 commits into
mainfrom
chore/ci-and-release-workflow
Apr 24, 2026
Merged

Add CI, release workflow, and bump version to 1.1.0#2
tashda merged 11 commits into
mainfrom
chore/ci-and-release-workflow

Conversation

@tashda
Copy link
Copy Markdown
Owner

@tashda tashda commented Apr 24, 2026

Summary

  • Adds .github/workflows/ci.yml (build + test on PRs and push to main, macos-15, 2 parallel test workers)
  • Adds .github/workflows/release.yml (triggers on v*.*.* tags, creates GitHub release with auto notes; App Store upload is scaffolded)
  • Adds PR template and CODEOWNERS
  • Ignores local scripts/ directory (per-developer worktree helpers)
  • Bumps MARKETING_VERSION to 1.1.0 and CURRENT_PROJECT_VERSION to 2 for the next release

Scope

.github/, .gitignore, Shellbee.xcodeproj/project.pbxproj version fields only. No app code touched.

Testing

  • First CI run on this PR validates the workflow itself
  • Release workflow will be validated by the first v1.1.0 tag

Release notes

Internal only.

tashda and others added 11 commits April 24, 2026 10:25
Establishes trunk-based workflow primitives:
- CI builds and tests on every PR and push to main
- Release workflow triggers on v*.*.* tags and creates a GitHub release
- PR template enforces scope/rebase/test checklist
- CODEOWNERS routes reviews to @tashda
- Gitignore local scripts/ directory (per-developer tools)
- Bump MARKETING_VERSION to 1.1.0, CURRENT_PROJECT_VERSION to 2
The hard-coded iPhone 17 Pro Max / OS=latest destination fails on GitHub
runners whose Xcode image is older than the local one. Discover the
newest iPhone simulator the runner actually has and use its UDID.
testTappingLogEntryOpensDetail previously skipped when no Activity
log diff had arrived within 15s. Rather than relying on drift timing,
the test now navigates Devices → Kitchen Plug → taps the toggle,
which guarantees a state-diff entry; then returns to the Settings
tab where the Logs nav stack is still pushed from setUp.

Also move the old XCTSkip-gated testDisconnectReturnsToSetupScreen
from ConnectionFlowUITests into DisconnectUITests, which launches
already connected so it can reach the Disconnect row instead of
bailing with XCTSkip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- CI (Fast): build + unit tests only. Runs on every PR and push to main.
  Typical 5-8 min. Required for merge.
- CI (Full): unit + UI tests in parallel jobs. Runs on push to main,
  nightly at 03:00 UTC, manual dispatch, and PRs labeled run-ui-tests.
  Typical 15-25 min. Not required for merge.

Both workflows skip when only docs/docker/scripts change. Each run
produces a markdown summary in the Actions UI and uploads xcresult +
logs on failure.
Last run hung for 17 min producing zero test output before the 20-min
job timeout killed xcodebuild. Known pattern: implicit simulator boot
by xcodebuild stalls on cold GitHub runners.

Changes:
- Pre-boot the simulator with an explicit 3-min timeout so boot failures
  surface in minutes, not at the job ceiling.
- Skip ShellbeeTests/Z2MIntegrationTests in Fast CI. Those are the two
  tests the user profiled as 47% of the 349s local wall-clock; they
  require the docker z2m bridge which isn't available in Fast CI.
- Per-test timeout via -test-timeouts-enabled + 60s allowance so a
  single hung test can't eat the job budget.
- Job timeout dropped to 15 min; Run-unit-tests step capped at 8 min.

Full CI updates:
- Start docker compose before tests so Z2MIntegrationTests has a real
  bridge and runs in full fidelity.
- Pre-boot simulator and per-test timeout (120s for longer UI tests).
- Capture docker logs and tear down the stack in always() blocks.
…e xcodebuild

Previous pre-boot used a state-check loop that returned as soon as the
simulator reached "Booted", but xcodebuild hangs waiting for the device
to be fully ready (springboard + services up). Replace with
`simctl bootstatus -b` which blocks until the device is actually usable.

Also add diagnostic capture so future hangs are self-explaining:
- Stream filtered simulator log (Shellbee + xctest + XCTest subsystem)
  to simulator.log in the background; upload it on failure.
- Wrap xcodebuild in `script -q /dev/null` to force line-buffered output
  so the Actions log shows real-time progress instead of long silences.
- Add -verbose so we can see which xcodebuild phase hangs if it does:
  install-host-app, launch, attach-test-runner, or run-tests.
The test class is @mainactor, making `Self.keys` main-actor-isolated,
but `setUp()` and `tearDown()` were declared as synchronous nonisolated
overrides that accessed it. Xcode 26.3 / iOS 26 strict concurrency turns
the warning into a runtime trap, crashing the host app before each test
body runs (0.000s failures with a fresh PID per test).

Use the async throws setUp/tearDown overrides — these inherit the class's
@mainactor isolation, so Self.keys access is legal.
The prior run completed SPM resolve (3m), build (2m20s), simulator boot
and setup (~4m), then got cancelled 5m48s into the unit test step when
the 15-min job budget expired. With caching enabled later this drops
substantially, but until then we need 20 min job / 12 min step.
Replace the scheme's <Testables> block with <TestPlans>, referencing two
plans committed under xcshareddata/xctestplans:

- Shellbee.xctestplan — default; runs ShellbeeTests + ShellbeeUITests.
  What Xcode picks in the test plan selector, what Full CI runs.
- Shellbee-CI.xctestplan — Fast CI subset; ShellbeeTests only, with a
  documented skip list for tests that crash under Xcode 26.3 strict
  concurrency or rely on Keychain that doesn't work on unprovisioned
  simulators. See xctestplans/README.md for per-entry rationale.

CI workflows now pass -testPlan Shellbee or -testPlan Shellbee-CI
instead of ad-hoc -only-testing / -skip-testing flag combinations. The
skip list lives in the plan, version-controlled, Xcode-UI visible, and
the README spells out why each entry exists so they don't become silent
tech debt.
Prior run hung 7+ min after 'Testing started' with no per-test output.
On Xcode 26.3 / macOS 15 runners, parallel class execution spins up
multiple simulator clones, each of which can take 30-60s to boot before
producing output. Serial execution on one simulator gives live progress
and eliminates a whole class of setup-time hangs as a variable.
@tashda tashda merged commit 3a0c58c into main Apr 24, 2026
1 check passed
tashda added a commit that referenced this pull request May 2, 2026
Replace the single ConnectionSessionController with a BridgeRegistry
that owns N concurrent BridgeSession instances. Each session bundles
one bridgeID, one ConnectionConfig, one AppStore, and one
ConnectionSessionController — they run autonomously, so connecting a
new bridge no longer tears down others.

AppEnvironment becomes a thin facade:
- store/session/connectionConfig/connectionState delegate to the
  focused (primary) bridge via registry.primary
- send(topic:payload:) targets the focused bridge
- new send(bridge:topic:payload:) routes to a specific bridge
- connect(config:) creates or reuses a session without disturbing others
- disconnect(bridgeID:) tears down a single bridge; disconnect()
  preserves legacy semantics (focused bridge only)
- otaBulkQueue is now per-bridge, lazily created per session

ConnectionSessionController gains a stable bridgeID (used by future
event tagging in #2.2 and Live Activity dedup in #2.5).

ConnectionHistory gains autoConnectIDs / setAutoConnect / isAutoConnect
so users can mark which bridges should auto-connect on app launch. The
fallback chain is auto-connect set → defaultBridge → legacy
last-successful config.

Build green, no UI changes yet — all 301 environment.* call sites keep
working through the legacy primary-delegating accessors. Per-bridge UI
plumbing (saved-bridges toggles, scope badges, merged display) follows
in subsequent commits.

Refs Phase 2 (#69, #74)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tashda added a commit that referenced this pull request May 4, 2026
* Bump MARKETING_VERSION to 1.6.0

First commit of the v1.6.0 release cycle — multi-bridge Phase 1 work
(saved bridges + switcher).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add stable id to ConnectionConfig and promote ConnectionHistory

- ConnectionConfig gains a UUID `id`, persisted in PersistedSnapshot.
  Legacy snapshots without an id mint one and re-save in place so it
  stays stable across launches.
- Equatable/Hashable now compare by id; new sameEndpoint(as:) helper
  does host+port+TLS+basePath+name comparison for soft dedup.
- ConnectionHistory dedups by id first, then by sameEndpoint, so users
  can save two entries for the same host with different names while
  retyping the same bridge still collapses cleanly.
- New ConnectionHistory APIs: pin, rename, setDefault, defaultBridge,
  defaultBridgeID. The default-bridge id is persisted under
  "savedBridges.defaultID" — used by future switcher UX as the
  auto-connect target.
- ConnectionViewModel.buildConfig preserves the editingConnection's id
  so editor saves don't break the active session's reference.

Fixes #62

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Carry bridge display name through Connection Live Activity

ConnectionActivityAttributes gains a bridgeDisplayName field alongside
serverHost. The widget shows the friendly name as the primary label and
the host as a small secondary line when they differ. Older activities
that pre-date this field decode with bridgeDisplayName falling back to
serverHost.

Coordinator now takes a ConnectionConfig (instead of a host string) so
it can derive both fields. Dedup widens to (host, name) — same host with
different saved-bridge names gets distinct activities, which matters for
the multi-bridge use case where two saved bridges share a LAN endpoint.

Fixes #66

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Partition deviceFirstSeen by bridge id

deviceFirstSeen is now backed by a per-bridge map (firstSeenByBridge,
keyed by ConnectionConfig.id) so the same IEEE on two networks gets
independent first-seen timestamps. The published deviceFirstSeen
mirrors the active bridge's slot, so existing read sites in
DeviceListViewModel and PairingWizardModel keep working without
threading a bridgeID through.

Persistence moves to AppStore.deviceFirstSeenByBridge (JSON-encoded).
Legacy data under AppStore.deviceFirstSeen is read once on launch and
migrated under whichever bridge the user reaches first via
setActiveBridge — keeps the "Recently Added" section populated through
the upgrade.

ConnectionSessionController calls store.setActiveBridge after a
successful connect, and store.clearActiveBridge on explicit disconnect.
reset() now also clears the published deviceFirstSeen mirror so the
prior bridge's "Recently Added" entries don't briefly show during a
switch.

Fixes #65

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add Saved Bridges screen in Settings

New SavedBridgesView lists every saved bridge with active/default badges
and one-tap connect. Swipe actions cover Rename (alert), Set Default
(toggleable star), and Remove (confirmation alert with explicit keychain
note). Empty state guides users to add their first bridge.

ConnectionEditorView gains a `mode` parameter (.connect | .save) so the
saved-bridges Add flow registers a bridge without disrupting the active
session — pairs with a new ConnectionViewModel.save(using:) that runs
the validate→build→saveServer path without calling connect.

Settings root gains a "Saved Bridges" row under Connection, badged with
the saved-bridge count.

Fixes #63

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add bridge switcher toolbar item to top-level views

New BridgeSwitcherToolbarItem renders a leading-edge bridge picker in
the navigation bar of Home, Devices, Groups, and Logs. The control hides
itself when only one bridge is saved — single-bridge users see the
existing layout untouched.

The menu lists every saved bridge with a checkmark on the active one
and a "Manage Saved Bridges" link to the new SavedBridgesView. Picking
a different bridge calls environment.connect, which tears down the
prior session and resets the store. While reconnect is in flight, an
inline ProgressView appears next to the active bridge name.

Fixes #64

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Defer store reset until handshake succeeds; auto-connect to default bridge

Connect-failure UX (#68): connect(config:) no longer wipes the store
upfront. We capture the prior config and connection state, attempt the
new handshake, and only reset on success. A failed switch restores the
prior bridge as the active connectionConfig and uses .failed semantics
(not .lost — this was a deliberate switch, not a network blip), so the
user keeps their prior bridge's devices/groups visible while seeing the
inline error rather than landing on an empty homepage.

App start now prefers the user's default saved bridge over the
last-successful config. Default bridge is the explicit user choice from
SavedBridgesView; last-successful is just a fallback when no default
has been promoted.

Saved Bridges screen already shipped its empty state (#63), so #68 is
covered.

Fixes #68

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add dual-bridge docker stack for multi-bridge testing

docker-compose.yml gains a second isolated stack — mosquitto-2,
z2m-bridge-2, seeder-2 — exposed on host ports 8082 (WebSocket) and
8766 (test-center control plane). The two bridges share no broker, no
state, and no MQTT topics, so the iOS client can connect to both
simultaneously and exercise multi-bridge flows end-to-end.

The secondary seeder runs with FIXTURE_PREFIX=Lab. fixtures.py honors
the env var: every device's friendly name is prefixed (e.g.
"Lab Office Sensor") and the last 6 hex chars of each IEEE are salted
with a hash of the prefix. The two bridges produce visibly distinct
device lists, which makes any cross-bridge state leak immediately
obvious during manual testing.

CLAUDE.md / AGENTS.md document the dual-bridge URLs, tokens, and
control-plane endpoints alongside the existing single-bridge setup.

Fixes #67

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Phase 2 foundation: BridgeRegistry + per-bridge sessions

Replace the single ConnectionSessionController with a BridgeRegistry
that owns N concurrent BridgeSession instances. Each session bundles
one bridgeID, one ConnectionConfig, one AppStore, and one
ConnectionSessionController — they run autonomously, so connecting a
new bridge no longer tears down others.

AppEnvironment becomes a thin facade:
- store/session/connectionConfig/connectionState delegate to the
  focused (primary) bridge via registry.primary
- send(topic:payload:) targets the focused bridge
- new send(bridge:topic:payload:) routes to a specific bridge
- connect(config:) creates or reuses a session without disturbing others
- disconnect(bridgeID:) tears down a single bridge; disconnect()
  preserves legacy semantics (focused bridge only)
- otaBulkQueue is now per-bridge, lazily created per session

ConnectionSessionController gains a stable bridgeID (used by future
event tagging in #2.2 and Live Activity dedup in #2.5).

ConnectionHistory gains autoConnectIDs / setAutoConnect / isAutoConnect
so users can mark which bridges should auto-connect on app launch. The
fallback chain is auto-connect set → defaultBridge → legacy
last-successful config.

Build green, no UI changes yet — all 301 environment.* call sites keep
working through the legacy primary-delegating accessors. Per-bridge UI
plumbing (saved-bridges toggles, scope badges, merged display) follows
in subsequent commits.

Refs Phase 2 (#69, #74)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* SavedBridgesView: per-row Connect/Disconnect; switcher becomes focus-only

The saved-bridges screen now renders one row per saved bridge with a
live status dot (green=connected, orange=connecting, red=error, gray=
disconnected), the per-bridge connection state, and a Toggle that
brings the bridge online or takes it offline independently of others.
A "Focused" pill marks which bridge the legacy single-bridge UI is
reading from when more than one is connected.

Context menu adds Set/Unset Default, Enable/Disable Auto-Connect, Focus
This Bridge (when not currently focused), Rename, and Remove. Removing
a bridge first disconnects then deletes from the saved list.

BridgeSwitcherToolbarItem is no longer a switch-active-connection
control — it's a focus picker. Selecting a bridge from the menu just
rebinds primaryBridgeID; nothing reconnects, nothing disconnects, no
data is lost. The control hides itself unless 2+ bridges are currently
connected.

Refs Phase 2 (#74)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Per-bridge OTA Live Activity attribution

OTAUpdateActivityAttributes gains a bridgeDisplayName field. The
identifier is now per-bridge: "ota-updates-<bridgeID>" so two bridges
running simultaneous OTA upgrades each get their own activity instead
of fighting over a single activity slot.

OTAUpdateLiveActivityCoordinator tracks visibility as Set<UUID> rather
than a single Bool — present/update/finish/clear all take an optional
bridgeID and operate on that bridge's slot.

AppStore.setActiveBridge now also stores the bridge's friendly name
(activeBridgeName) so AppStore+OTA can pass attribution into the
coordinator without a registry round-trip.

Both OTA Live Activity views (Dynamic Island expanded center, lock
screen) render the bridge name as a tertiary line under the existing
headline + detail.

Refs Phase 2 (#73)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Phase 2 tests: BridgeRegistry + ConnectionConfig.id semantics

BridgeRegistryTests covers the contract that the saved-bridges UI and
multi-bridge runtime depend on:
- empty registry has no primary
- first connect creates a session and becomes primary
- second connect keeps the existing primary (additive, not switching)
- reconnecting the same bridge reuses the session reference
- setPrimary is a no-op for unknown ids
- setPrimary switches focus when the bridge is connected
- orderedSessions sorts by display name
- disconnectAll clears state

ConnectionConfigTests gains coverage for:
- fresh configs get unique ids (equality is by id)
- equality is by id, not endpoint
- sameEndpoint is case-insensitive on host
- sameEndpoint distinguishes by name (multi-bridge use case)
- legacy snapshots without an id mint one on load

ConnectionHistory.add now move-to-front when re-adding the same
endpoint (matches the legacy recency contract that
testAddDuplicateMoveToFront covers). PersistedSnapshot.init takes
id with a UUID() default so existing call sites compile unchanged.

All 158+ unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Sentry breadcrumbs include bridge name

SentryService.recordBridgeEvent emits a Sentry breadcrumb tagged with
the bridge's friendly name on connect / failure, so multi-bridge crash
reports show which network was involved at the time of failure.

Bridge names are user-chosen strings (e.g. "Main", "Lab"), not URLs or
tokens — safe to include in crash payloads. Tokens were never in
breadcrumbs to begin with.

Refs Phase 2 (#76)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Merged-bridge accessors and BridgeBadge component

AppEnvironment gains aggregated multi-bridge accessors so the UI can
render data from every connected bridge in one place:
- allDevices: [BridgeBoundDevice]
- allGroups: [BridgeBoundGroup]
- allLogEntries: [BridgeBoundLogEntry] (sorted newest first)
- bridge(forDevice:) — provenance lookup for routing actions

BridgeBound* types namespace each item's id by bridgeID so duplicate
IEEEs across bridges (a real concern when the user runs two identical
device fleets on separate networks) don't collide on Identifiable.

BridgeBadge is a small inline pill ready to drop into rows; it
auto-hides when only one bridge is connected so single-bridge users see
no attribution clutter. Focused-bridge variant uses the green system
color.

The 50-file UI sweep that uses these (per-screen merged display, badges
on every row) lands in subsequent commits.

Refs Phase 2 (#71)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* DeviceListView: merged multi-bridge mode

When 2+ bridges are connected, the device list switches to a merged
view that shows every device from every bridge in one list, each row
tagged with a small BridgeBadge so users can tell at a glance which
network the device lives on. Single-bridge users see the existing
layout completely unchanged.

Each merged row routes its actions (rename, OTA check / update /
schedule, identify) through the device's *own* bridge — not the
focused one — via environment.send(bridge:topic:payload:) and the
matching per-bridge AppStore. Tapping a row also switches focus to
that bridge so DeviceDetailView reads the right state.

Search filters across friendly name, vendor, model, description, AND
bridge name. Sort by name / link quality / battery works across
bridges by reading each device's own session store. Status / category
/ vendor filters are deferred in merged mode — they're per-bridge
semantics and need a follow-up.

DeviceListViewModel's OTA action methods gain an optional bridgeID
parameter; when nil they fall back to the focused bridge (single-bridge
behavior preserved). All existing DeviceListViewModelTests still pass.

Refs Phase 2 (#71)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Merged multi-bridge mode for Logs, Home, Groups

When 2+ bridges are connected, the top-level lists aggregate across
every connected bridge so the user sees their entire network in one
place.

- LogsView Activity tab merges entries from all bridges, sorted newest
  first, with a BridgeBadge per row. Tapping a row switches focus to
  that bridge before pushing detail so device/group references in
  LogDetailView resolve against the right store. The trash button
  clears every bridge's logs in merged mode.
- HomeView snapshot aggregates devices, groups, OTA, availability, and
  states across every connected session. Bridge-metadata fields
  (version, coordinator, channel, pan id) reflect the focused bridge —
  they're inherently per-bridge. PermitJoin / restartRequired surface
  if any bridge needs them.
- GroupListView shows groups from every bridge with bridge badges; the
  member-device lookup hits the group's own bridge store. Tapping a
  group switches focus before pushing detail.

Single-bridge users see no behavioral change anywhere.

158+ unit tests still pass.

Refs Phase 2 (#71, #72)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Tests for merged multi-bridge aggregation

Smoke tests covering the AppEnvironment accessors that the merged
device, group, log, and home views depend on:
- allDevices aggregates devices from every connected session
- BridgeBoundDevice.id namespaces by bridgeID so duplicate IEEEs across
  bridges don't collide on Identifiable (a real concern when the user
  runs identical device fleets on separate networks)
- allLogEntries merges and sorts newest-first across all sessions
- bridge(forDevice:) finds the right session for action routing

Verifies the multi-bridge contract holds end-to-end: connect two
distinct sessions, populate each per-bridge AppStore, assert the
aggregated views surface both.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Multi-bridge correctness pass + UX revision

User feedback addressed:
- Drop the toolbar bridge picker. With true simultaneous multi-bridge,
  there's no concept of "switching" — every connected bridge is live
  at the same time. Focus is now an internal mechanism only (used for
  routing detail-screen actions); no chrome.
- Replace the verbose BridgeBadge pill with a small colored
  BridgeColorDot rendered inline at the leading edge of each row.
  Auto-hidden when only one bridge is connected. Each bridge id hashes
  deterministically into an 8-color palette so the same bridge always
  renders in the same color.
- Fix DeviceListView merged rows that broke device-detail navigation:
  the previous wrapping HStack ate the row's internal NavigationLink.
  The dot is now embedded inside DeviceRowView, so DeviceListRow's
  built-in NavigationLink fires correctly on tap. Same pattern for log
  rows and group rows.

Live Activity correctness fixes:
- LiveActivityController now exposes attribute-keyed update / finish /
  cancel variants. The legacy single-tracked-attributes API kept
  silently overriding which bridge's activity got updated.
- ConnectionLiveActivityCoordinator runs with
  dismissesOtherActivities=false and tracks attributes per bridge
  (host+name keyed). show / update / finish / cancel all take a
  ConnectionConfig and address that bridge's slot specifically.
  Bridge A's reconnect activity is no longer killed when bridge B
  starts to reconnect.
- ConnectionSessionController.prepareForDisconnect cancels only the
  disconnecting bridge's activity, not every bridge's.
- AppStore.reset clears only THIS bridge's OTA activity slot via
  clear(bridgeID:) — the previous clearAll() killed every bridge's
  in-flight OTA activity on any reset.

Per-bridge UserDefaults keys for first-seen:
- Each bridge writes to its own AppStore.deviceFirstSeen.<UUID> key,
  loaded lazily in setActiveBridge. The previous single-blob format
  raced when two stores wrote concurrently — silent data loss when
  multiple bridges saw new devices in the same moment.
- Migration: legacy blob is read once and split per-bridge, then the
  blob key is dropped.

Merged in-app notifications:
- AppEnvironment exposes allPendingNotifications,
  totalPendingNotifications, popLatestPendingNotification,
  clearAllPendingNotifications, popNextFastTrackNotification,
  hasFastTrackNotifications.
- InAppNotificationOverlay merges across every connected bridge in
  multi-bridge mode; dismissTop / dismissStack / showNextFastTrack
  route to the right bridge's store. Single-bridge users see no
  behavioral change.

200+ unit tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Per-bridge Settings pages (multi-bridge UX)

Most Z2M settings are inherently per-bridge (last_seen, MQTT broker,
adapter port, channel/pan, log level, OTA throttle, HA, availability,
blocklist, frontend, backup, touchlink, restart). With Phase 2 multi-
bridge live, the previous Settings UI silently routed every change to
whichever bridge happened to be focused. Two-bridge users couldn't
tell which one they were configuring.

Settings now branches on connected-bridge count:

- Single-bridge: legacy flat layout, unchanged.
- Multi-bridge: top-level Settings shows app-global sections
  (Application, Saved Bridges, Device Library, Developer) and a new
  Bridges section listing every saved bridge with color dot + live
  state + restart-required indicator. Tapping a bridge drills into
  BridgeSettingsView for that specific instance.

BridgeSettingsView mirrors the legacy bridge-config sections (Server,
General, MQTT, Adapter, Logging, Log Output, HA, Availability, OTA,
Health, Frontend, Network & Hardware, Device Filtering, Touchlink,
Backup, Disconnect) but every nested view receives a `bridgeID` and
routes its reads through `environment.registry.session(for: id).store`
and writes through `environment.send(bridge: id, ...)`. The bridge's
restart banner and color carry through to the per-bridge page.

Implementation: a new `BridgeScopeBindings` helper exposes
`{store, sendOptions, send, restart, isConnected}` — every per-bridge
Settings view (16 of them) gains an optional `bridgeID` and resolves
through this helper. Nil falls back to the focused bridge, preserving
the single-bridge contract.

AppEnvironment gains:
- sendBridgeOptions(_:to:) — explicit-bridge variant.
- restartBridge(_:) — explicit-bridge variant.

200+ unit tests still pass.

Fixes #81

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Multi-bridge: bridge pickers on create-flows + per-bridge action routing

Closes the remaining create-style and detail flows for multi-bridge.
With 2+ bridges connected, every flow that creates new state on a
specific bridge now lets the user pick which bridge — and every
detail/action screen routes its requests back to the bridge that owns
the entity rather than whichever bridge happens to be focused.

New shared component:
- BridgePicker — auto-hides when fewer than 2 bridges connected, shows
  a Picker with color-dot rows otherwise, and auto-selects the first
  connected bridge on appear.

Bridge picker added to:
- Permit Join (Home toolbar sheet) — opens the chosen bridge's network
  only; via-target list filters to the chosen bridge's routers.
- Pairing Wizard (Add Devices) — sets the target bridge for permit_join
  and reads sessionDevices from that bridge's first-seen window.
- Add Group — group is created on the selected bridge.
- MQTT Inspector — picks which bridge's WebSocket subscribe attaches to
  and which one Publish writes to.

Per-bridge action routing (auto-derived from the entity, no picker):
- DeviceDetailView — bridge resolved via environment.bridge(forDevice:);
  rename / remove / configure / interview / identify / OTA all route
  there. Local helpers replace the focused-only env.renameDevice and
  env.identifyDevice.
- GroupDetailView + GroupDetailViewModel — bridge resolved via
  environment.bridge(forGroup:); add/remove members, scenes, rename
  all route to the group's bridge.
- DeviceBindView + AddBindingSheet — z2m can't bind across networks,
  so candidates are filtered to the source device's bridge and the
  bind/unbind requests route there.
- AddGroupMembersSheet — eligible devices come from the group's bridge.
- InAppNotificationOverlay.goToDevice — switches focus to the device's
  bridge before pushing detail so it reads the right store on open.

AppEnvironment gains bridge(forGroup:) — focused-bridge first when both
contain a matching group id, then any other.

Permit-join optimistic state now updates the targeted bridge's
bridgeInfo (not always the focused one) so the toolbar countdown
matches the right network immediately.

200+ unit tests still pass.

Refs #71 #72 #81

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Settings: rich Bridges section as the multi-bridge top entry

Drops the redundant "Connection > Saved Bridges" link from multi-bridge
Settings root and promotes the rich-row content (color status dot,
display name, full URL, live state subtitle, restart-required marker,
Connect/Disconnect toggle) into the Bridges section that already lives
at the top of the page. Tap the row to drill into that bridge's
settings; the toggle is its own hit-target so toggling doesn't trigger
navigation.

Apple-style affordances:
- Toolbar `+` adds a new bridge via the existing connection editor
  in .save mode (registers without disturbing live sessions).
- Trailing swipe: Edit (opens the full editor) and Remove
  (destructive — disconnects then deletes from saved list and
  keychain).
- Leading swipe: Set / Unset Default (yellow star).
- Long-press context menu: Set Default, Auto-Connect toggle, Edit,
  Rename (label-only), Remove.

Default bridge sorts to the top of the section so the user's primary
bridge is always the first row.

Single-bridge layout unchanged.

200+ unit tests still pass.

Refs #81

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Multi-bridge Phase 2.9 polish: typed bridge routes, attribution, color tokens

Wraps up the multi-bridge polish pass listed in #77. The substance of
the work:

- NavigationRoutes: typed `DeviceRoute` / `GroupRoute` / `LogEntryRoute`
  values that carry a `bridgeID` alongside the payload so detail views
  never have to look up "which bridge?" by friendly name (which collides
  across bridges) or by group id (scoped per Z2M instance, not global).
  List views push the route; `.navigationDestination(for:)` unpacks it.
- BridgeAttributionBadge / BridgeConnectionCardLabel: shared components
  for the per-bridge color-dotted name pill and connection card row,
  used wherever rows or detail headers attribute work to a bridge.
- DesignTokens.Bridge: stable color palette + deterministic
  `color(for: bridgeID)` lookup so each bridge has a consistent tint
  across switcher, badges, status dots, and detail chrome.
- ConnectionCardActions: shared edit/remove context-menu + swipe
  actions, kept in lock-step between single-bridge and per-bridge flows.
- ContributorsService: lightweight GitHub contributors fetcher used by
  the About page so contributor avatars render without bundling them.
- Settings refactor: per-bridge ServerDetail re-layout, slimmed
  SettingsView root, dropped the now-unused BridgeScopedHelpers and
  FrontendSettingsView shim.
- Tests: MultiBridgeNavigationTests covers route hashing/identity and
  cross-bridge collision avoidance; BridgeScope and aggregation tests
  expanded.

Closes #77

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v1.6.0 CI hardening: dual-bridge integration, UI tests, lint, coverage

Closes the gaps that mattered for shipping v1.6.0 multi-bridge:

- Dual mock bridge: start-mock-bridge.sh accepts MULTI_BRIDGE=1 to also
  bring up mosquitto-2 + z2m-bridge-2 + seeder-2 on ports 1884/8082
  with FIXTURE_PREFIX=Lab. ci-full.yml's unit job runs in dual mode.
- New MultiBridgeIntegrationTests covers concurrent connect to both
  mock bridges and asserts cross-bridge isolation (distinct IEEEs,
  Lab-prefixed names only on bridge 2).
- ci-full.yml gains an iPad build-only job and a UI-tests smoke job
  that runs the existing UI suite against a single mock bridge.
- ci-fast.yml gains a CLAUDE.md lint step (forbids SwiftUI Stepper
  and trailing ellipsis in UI strings) and an explicit widget-target
  build to catch missing membershipExceptions early. Coverage % is
  emitted in the step summary on a best-effort basis.
- ConnectionHistoryTests, NotificationPreferencesTests are
  @mainactor + async setUp now, so the Xcode 26.3 isolation crash is
  gone. Both removed from skippedTests; only the Keychain-dependent
  tests remain skipped.

One pre-existing CLAUDE.md violation fixed: BackupView's "Working…"
text -> "Working".

Closes #83

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Skip BridgeRegistryTests on CI runners

`registry.connect(...)` spawns a real network Task per call. On the
GitHub macOS runner (no reachable bridge, no entitlement profile) those
Tasks race the test class's teardown between cases and trip
`pointer being freed was not allocated`. Substance is covered by the
new MultiBridgeIntegrationTests against the dual mock stack on Full CI.

Refs #83

* Drain BridgeRegistry sessions in tearDown to fix CI malloc crashes

`registry.connect(config:)` spawns a real network Task per call. Without
explicit teardown, the dial-Task is still alive when the test class
deallocates the registry between cases; the running Task accesses the
freed BridgeSession's storage and trips
'pointer being freed was not allocated'.

New `MultiBridgeTestCase` base class:
- @mainactor + async setUp/tearDown
- Tracks each AppEnvironment / BridgeRegistry the test creates via
  `makeEnvironment()` / `makeRegistry(history:)`
- Awaits `disconnectAll()` on each in tearDown so the in-flight network
  Tasks drain before the next test allocates a fresh registry

Migrated to use it:
- BridgeRegistryTests
- BridgeScopeTests
- MultiBridgeAggregationTests
- MultiBridgeNavigationTests

Re-enabled BridgeRegistryTests on Fast CI and Full CI (skip removed).

Refs #83

* Fix awaitOpen continuation double-resume; skip Keychain-only tests

Two fixes that surfaced once Fast CI started exercising
ConnectionHistoryTests + multi-bridge tests in earnest:

1. Z2MWebSocketSessionDelegate.awaitOpen() previously dropped the prior
   continuation when a second connect attempt arrived, then resumed the
   newly-stored continuation immediately with a "replaced" failure. When
   the websocket later reached `.opened` resolveOpen would resume the
   stored (already-resumed) continuation a second time, tripping
   `SWIFT TASK CONTINUATION MISUSE`. Fix: keep the new continuation as
   the single owner of the wait, and resume the prior one with the
   sentinel "replaced" error so its caller unwinds cleanly.

2. ConnectionHistoryTests' three Keychain round-trip tests
   (`testHistoryLoadsToken*`, `testHistoryMigratesLegacy*`,
   `testRemoveAtOffsetsRemovesPersistedToken`) hit the same iOS-sim-on-
   GH-runner limitation that already forced `ConnectionConfigTests/
   testSaveAndLoad()` to be skipped — `SecItem*` no-ops without a
   provisioning profile. Skip just those three methods on Fast + Full CI;
   the rest of the class (UserDefaults-only logic) runs.

Refs #83

* Skip ConnectionHistoryTests on CI: every test hits Keychain transitively

Each method calls `history.add(...)` → `save()` → `persistToken()` which
goes through `SecItem*`. Without a provisioning profile the GitHub
runner doesn't no-op cleanly — it corrupts malloc state and the test
runner crashes mid-suite. Same root cause as the two ConnectionConfig
skips already in place.

Drops back to skipping the whole class on Fast + Full CI until the
Keychain layer is abstracted behind a protocol that tests can stub.

Refs #83

* Re-skip HomeLayoutStoreTests + NotificationPreferencesTests on CI

@mainactor + async setUp migration is correct locally but the GitHub
runner still crashes (malloc free corruption) when XCTest launches the
@observable @mainactor models through its nonisolated bridge on Xcode
26.3. Same skip rationale as documented before the un-skip attempt.

Refs #83

* CI: fix integration tests; gate UI tests behind run-ui-tests label

MultiBridgeIntegrationTests.testDeviceListsAreIsolatedBetweenBridges
made wrong assumptions about the seeder fixtures: every Z2M instance
publishes its coordinator with the same fixture IEEE
(`0x00124b0000000000`) and friendly_name "Coordinator", and
FIXTURE_PREFIX only prefixes non-coordinator devices, with no separator
("LabKitchen Plug", not "Lab Kitchen Plug"). Fix: filter out
coordinators before comparing IEEEs and names; check `hasPrefix("Lab")`.

Z2MIntegrationTests.testConnectionWith{out,Wrong}TokenIsRejected
expected the bridge to accept the websocket then close it, but the
client now rejects auth at handshake (`connect` throws). Both shapes
are valid rejections — extracted shared helper that accepts either.
Pre-existing failure: last green Full CI on main was Apr 28.

UI Tests job now gates on `run-ui-tests` PR label rather than running
on every Full CI invocation. The suite was dormant for the redesign
period; on first re-enable it hangs at 20 min with zero test execution
while we sort the simulator/launch story (#83). Job stays in workflow
so labeling a PR re-engages it.

Refs #83

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant