Compare commits

9 Commits

Author SHA1 Message Date
joel.poyet 558802b552 Remove the simulator reset again
It was added on the theory that the runner's reused simulators were leaking state
between runs. That theory was wrong: erasing both devices changed nothing — the
same seven suites failed identically before and after.

The leftover files LoggingTests trips on are its own, from a previous attempt of
the same test within one run (the suite runs with -test-iterations 4), so nothing
crosses runs for an erase to clean. Keeping a step that mutates shared state on the
release machine for no measured benefit is worse than not having it.
2026-07-16 09:44:43 +02:00
joel.poyet 7a37282d0b Erase the test simulators before running the tests
The unit tests assume a clean simulator, which costs nothing on a hosted VM
because it hands out a fresh device every run. This runner reuses the same two
devices, so app containers, caches and logs survive between runs and six suites
fail on the previous run's leftovers rather than on anything in the code.
LoggingTests is the clearest: it expects no log files and finds nine, in the same
device container each time.

Only the two devices the tooling pins are erased, resolved by UDID. A blanket
erase would take out simulator state belonging to other work on a machine the
release pipeline shares.
2026-07-16 09:25:01 +02:00
joel.poyet 728ace6f81 Make CI work on this internal fork
Every check was red on every pull request. Upstream declares permissions: {} in its
workflows, which costs nothing on a public repo and is fatal here: the token is
limited to Metadata:read, so actions/checkout cannot read an internal repo and dies
with a misleading "Repository not found". Repo settings cannot fix it — an explicit
permissions block always overrides the default.

Each job now gets the minimum it needs. Danger also reads the pull request through
the issues API, so it needs issues:read alongside pull-requests:write, and zizmor's
SARIF upload needs actions:read on a private repo.

The unit tests move to the org's Mac Studio: GitHub-hosted macos-26 could not finish
inside the hour and bills at 10x. Preview tests are skipped there, using the flag
upstream already uses for its own enterprise tests — they render a snapshot per
preview and never once finished on either runner. The trade is that preview snapshot
regressions are no longer caught in CI.

Workflows that only make sense upstream are gated off rather than deleted, so they
keep rebasing cleanly. The enterprise tests need Element's bot token and their
private submodule. Three nightlies fail at checkout every weeknight — this repo is a
detached copy rather than a network fork, so GitHub never disabled their schedules —
and one of them posts to Element's Codecov on every run.
2026-07-16 08:08:41 +02:00
joel.poyet e4d4541c75 Sync the plan's skill block with the corrected guard wording 2026-07-15 15:08:42 +02:00
joel.poyet 0588cdc542 Correct when the version guard fires relative to xcodes
Task 6 Step 3 asserted the guard must fail before xcodes runs. It cannot:
fastlane's before_all always executes before the lane body, and the plan puts
xcodes in before_all and the guard in the lane. The acceptance criterion was
unmeetable on any machine.

The guard still does what it exists for — aborting before the ~30 minutes of
signing — so the wording was wrong, not the pipeline. Also record that the step
needs the xcodes CLI, since without it the lane dies earlier with an error that
reads like a config problem.
2026-07-15 15:08:12 +02:00
joel.poyet 68ef01cc87 Write the release skill out in full in the plan
Task 11 described the skill's sections rather than showing them, the one place
the plan told an implementer what to write instead of what to type. Replace it
with the complete SKILL.md.

Writing it surfaced a broken convention worth not copying: the legacy skill
documents healthchat-v1.5.0-rc1 style tag suffixes, but the workflow derives the
marketing version by stripping only the healthchat-v prefix, so the suffix reaches
CFBundleShortVersionString, which Apple caps at three integers. Tags are numeric
only here, and test builds go through workflow_dispatch with the marker in the
release notes.
2026-07-15 11:52:43 +02:00
joel.poyet a7f8f667ff Implementation plan for HealthChat App Store release
Task-by-task plan for the three PRs in the release design: the classic migration
fix, the fastlane pipeline, and the releasing-healthchat skill.

The migration fix gets a real test cycle — an existing upstream test,
classicAppAccountRequiresBackup, encodes the behaviour we're changing, so it is
rewritten first and drives the view model change.
2026-07-15 11:09:09 +02:00
joel.poyet 16c6727d29 Design for HealthChat App Store release capability
Spec for bringing App Store release capability to element-x-ios, targeting the
existing HealthChat record that the legacy element-ios fork ships to today.

Both apps share bundle id ch.healthchat.element and team 6G37K72W82, so element-x
arrives as an in-place update rather than a new listing. That drives the design:
the identity already aligns and 26.07.3 clears the legacy's 1.4.1, but the repo
has no release machinery at all, and upstream's Xcode Cloud path is unavailable
because it needs the private Enterprise submodule.

Records three parts: repointing CLASSIC_APP_* at HealthChat's own identifiers so
existing users keep their sessions and encrypted history, a fork-owned fastlane
pipeline ported from the legacy fork, and a releasing-healthchat skill.

Revised after an adversarial review of every factual claim; 26 issues found, none
rejected. Most consequential: the migration fix is not monotonic, because making
loadAccounts() succeed exposes a backup-instructions sheet whose only button opens
element://open — a scheme this app does not register and whose legacy owner has
been replaced in place.
2026-07-15 09:53:18 +02:00
joel.poyet 01dc703b48 Rebrand as HealthChat and pin the account provider to healthchat.ch
Applies the HealthChat product identity through the fork seams upstream already
provides, so no upstream logic files are touched and merges stay clean.

- Variants/HealthChat/healthchat.yml holds the identity scalars (display name,
  bundle id ch.healthchat.element, app group, development team) and is included
  permanently from project.yml. APP_NAME stays ElementX so the module name and
  @testable import ElementX keep working.
- ElementX/Sources/AppHooks/HealthChat/ registers an AppSettings hook pinning
  accountProviders to healthchat.ch with other providers disabled, push through
  sygnal.healthchat.ch and a custom-scheme OIDC redirect; plus a Compound hook
  recolouring the accent token family to the HealthChat blue.
- Entitlements are edited directly in ElementX/SupportingFiles/target.yml rather
  than in the variant, because XcodeGen concatenates arrays on include-merge and
  a variant can override scalars but cannot remove array entries. Drops the
  Element Classic app group and keychain group, and narrows associated domains
  to app.healthchat.ch, keeping matrix.to for permalinks.
- Info.plist CFBundleName now follows APP_DISPLAY_NAME — it, not
  CFBundleDisplayName, is what the web authentication sign-in alert shows.
- App logo and app icon assets swapped in place; the onboarding welcome title
  moves to a new screen_onboarding_welcome_title_ios untranslated string.
2026-07-15 08:46:31 +02:00
29 changed files with 2090 additions and 51 deletions
@@ -11,6 +11,11 @@ permissions: {}
jobs:
tests:
name: Tests
# HealthChat: skip in this fork. It runs nightly Mon-Fri and fails at checkout, because
# permissions: {} leaves a metadata-only token that cannot read an INTERNAL repo. Gated rather
# than deleted so the file keeps rebasing cleanly. Run locally with
# `swift run tools ci accessibility-tests`.
if: github.repository == 'element-hq/element-x-ios'
runs-on: macos-26
timeout-minutes: 150
+5
View File
@@ -20,6 +20,11 @@ permissions: {}
jobs:
tests:
# HealthChat: skip in this fork. It only triggers on changes under compound-ios/, which this fork
# has never edited, so it would only ever fire on an upstream sync and go red at checkout —
# permissions: {} leaves a metadata-only token that cannot read an INTERNAL repo. Gated rather
# than deleted so the file keeps rebasing cleanly.
if: github.repository == 'element-hq/element-x-ios'
runs-on: macos-26
timeout-minutes: 15
+5
View File
@@ -12,6 +12,11 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read # HealthChat: this fork is INTERNAL, so a metadata-only token 404s on checkout. Upstream is public, hence permissions: {} there.
issues: read # HealthChat: Danger reads the PR via the issues API (a PR is an issue there); pull-requests alone 403s on GET /issues/{n}.
pull-requests: write # HealthChat: Danger posts its report comment and inline SwiftLint review comments (Dangerfile.swift:5 sets inline: true) with GITHUB_TOKEN.
concurrency:
# Only allow a single run of this workflow on each branch, automatically cancelling older runs.
group: ${{ format('danger-ci-{0}', github.ref) }}
+7
View File
@@ -11,6 +11,13 @@ permissions: {}
jobs:
integration_tests:
name: Integration Tests
# HealthChat: skip in this fork. It runs nightly Mon-Fri and fails at checkout, and it is the one
# job that still calls out to Element's Codecov — its upload guard is true on schedule events, so
# it POSTs slug/branch/SHA every night with continue-on-error hiding the failure. Fixing it would
# need a real healthchat.ch test account and three new secrets, and would still not work:
# HealthChatAppSettingsHook sets allowOtherAccountProviders: false, hiding the change-server UI
# these tests drive. Gated rather than deleted so the file keeps rebasing cleanly.
if: github.repository == 'element-hq/element-x-ios'
runs-on: macos-26
timeout-minutes: 45
+6
View File
@@ -15,6 +15,12 @@ permissions: {}
jobs:
tests:
name: Tests
# HealthChat: skip in this fork. It runs nightly Mon-Fri and fails at checkout, because
# permissions: {} leaves a metadata-only token that cannot read an INTERNAL repo. Fixing the
# permission would not help: the rebrand changed the start screen without re-recording any
# UITests snapshot, so it would fail on content instead. Gated rather than deleted so the file
# keeps rebasing cleanly. Run locally with `swift run tools ci ui-tests`.
if: github.repository == 'element-hq/element-x-ios'
runs-on: macos-26
timeout-minutes: 90
strategy:
+8 -1
View File
@@ -17,7 +17,14 @@ jobs:
timeout-minutes: 45
# Skip in forks
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
# HealthChat: this job can never pass in this fork — it needs
# secrets.ELEMENT_BOT_TOKEN (Element's bot, absent here) and recursively
# checks out the private element-hq/element-ios-enterprise submodule, which
# Novaloop cannot obtain. The guard below only asks whether the PR comes
# FROM a fork, not whether this repo IS one, so it does not skip us. Gate on
# the upstream repo as post-release.yml does. Gated rather than deleted so
# the file keeps rebasing cleanly against upstream.
if: ${{ github.repository == 'element-hq/element-x-ios' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
concurrency:
# Only allow a single run of this workflow on each branch, automatically cancelling older runs.
+61 -8
View File
@@ -13,24 +13,71 @@ permissions: {}
jobs:
tests:
name: Tests
runs-on: macos-26
# HealthChat: GitHub-hosted macos-26 could not finish this job inside
# timeout-minutes: 60 (two attempts: 51m42s, then killed at 60m44s), and macOS
# minutes bill at 10x. novaloop-mac-studio-001 is the org's Mac Studio, already
# used by the release pipeline; its labels are [self-hosted, macOS, ARM64].
runs-on: [self-hosted, macOS]
# Upstream's 60 is kept: with previews skipped (see Run tests) the build plus unit tests measured
# ~16 min here. A run approaching this budget is a signal, not normal — the earlier 60m timeouts
# were preview tests, not the build.
timeout-minutes: 60
permissions:
contents: read # HealthChat: this fork is INTERNAL, so a metadata-only token 404s on checkout (and on git lfs pull). Upstream is public, hence permissions: {} there.
concurrency:
# Only allow a single run of this workflow on each branch, automatically cancelling older runs.
group: ${{ format('unit-tests-{0}', github.ref) }}
cancel-in-progress: true
steps:
# HealthChat: upstream passes persist-credentials: false here, which the
# composite forwards to actions/checkout — stripping the auth header before
# its separate "Git LFS Pull" step runs. Upstream can afford that because
# its repo is public and git lfs pull works anonymously; this fork is
# INTERNAL, so the LFS batch endpoint would 404. Keep the composite's own
# default (true) so the pull is authenticated with GITHUB_TOKEN.
- uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
with:
persist-credentials: false
- name: Setup environment
run: source ci_scripts/ci_common.sh && setup_github_actions_environment
# HealthChat: verify only — never provision. Upstream's
# setup_github_actions_environment cannot run on this runner: it calls
# `sudo xcode-select -s /Applications/Xcode_26.5.0.app`, but the runner has no
# passwordless sudo and no such path (only /Applications/Xcode.app exists), and
# it calls `brew update && brew install ...` — novaloop-mac-studio-001 is the
# shared release machine, so a per-run `brew update` could upgrade xcodegen
# underneath the release pipeline. These tools are installed once by hand; CI
# only asserts they are present and fails loudly, naming the fix, if not.
- name: Verify environment
run: |
set -eu
missing=""
formulae=""
for tool in xcodegen swiftlint swiftformat xcbeautify xcresultparser pkl git-lfs; do
if ! command -v "$tool" >/dev/null 2>&1; then
missing="$missing $tool"
case "$tool" in
# Not in homebrew-core; only this tap ships it.
xcresultparser) formulae="$formulae a7ex/homebrew-formulae/xcresultparser" ;;
*) formulae="$formulae $tool" ;;
esac
fi
done
if [ -n "$missing" ]; then
echo "::error::Missing on self-hosted runner:${missing}. Install by hand on novaloop-mac-studio-001, then re-run: brew install${formulae}"
exit 1
fi
# The Xcode version is no longer pinned anywhere, so this is the only record
# of which Xcode built a given run.
xcodebuild -version
- name: Run tests
run: swift run -q tools ci unit-tests
# HealthChat: previews are skipped. PreviewTests renders a snapshot for every preview in the
# app and was the entire reason this job could not fit in an hour — it never once finished,
# on either runner. Unit tests alone take ~16 min here. Upstream skips them the same way in
# unit-tests-enterprise.yml. Cost: preview snapshot regressions are not caught in CI, so
# re-record and check them locally when changing UI.
run: swift run -q tools ci unit-tests --skip-previews
- name: Archive artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -44,7 +91,11 @@ jobs:
- name: Upload coverage to Codecov
# Skip if not successful and in forks
if: ${{ success() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
# HealthChat: the existing guard only asks whether the PR comes FROM a
# fork, not whether this repo IS one, so it is true here and the step
# runs without a CODECOV_TOKEN — which fails the job via
# fail_ci_if_error. Gate on the upstream repo as post-release.yml does.
if: ${{ success() && github.repository == 'element-hq/element-x-ios' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
report_type: coverage
@@ -56,7 +107,9 @@ jobs:
- name: Upload test results to Codecov
# Skip if cancelled and in forks
if: ${{ !cancelled() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
# HealthChat: gated on the upstream repo for the same reason as the
# coverage step above — no CODECOV_TOKEN exists in this fork.
if: ${{ !cancelled() && github.repository == 'element-hq/element-x-ios' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
continue-on-error: true
with:
+2
View File
@@ -14,6 +14,8 @@ jobs:
runs-on: ubuntu-latest
permissions:
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
contents: read # HealthChat: this fork is INTERNAL, so a metadata-only token 404s on checkout. Upstream is public, hence permissions: {} there.
actions: read # HealthChat: upload-sarif additionally needs this on private/internal repos; public upstream does not.
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+41 -17
View File
@@ -615,6 +615,7 @@
659E5B766F76FDEC1BF393A4 /* RoomDetailsEditScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E413F4CBD7BF0588F394A9DD /* RoomDetailsEditScreenViewModel.swift */; };
661EF50C1F7D4B0BC8A7AAE3 /* EmoteRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44ABA63DBE7F76C58260B43B /* EmoteRoomTimelineView.swift */; };
663E198678778F7426A9B27D /* Collection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9FAFE1C2149E6AC8156ED2B /* Collection.swift */; };
665314B4CB31904F107D2D3B /* HealthChatAppSettingsHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1ECC44D40D05D3A6F0DE0F7 /* HealthChatAppSettingsHook.swift */; };
6681D6D3ADF69EBD2625F29A /* KnockedRoomProxyMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E8F4D7D61B80EBD5CB92F8A /* KnockedRoomProxyMock.swift */; };
66832DE7B5C2E861045265DC /* RoomSelectionScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D879DC5515B1D42577F96C94 /* RoomSelectionScreen.swift */; };
66C48B5B35C861C2089DBB12 /* ToolbarButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510DA63AB273A68D659CEC95 /* ToolbarButton.swift */; };
@@ -669,6 +670,7 @@
6CBC544A7CC4DDDBDED4D0A6 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 4B1F71AC585827E6C416C15A /* AppIcon.icon */; };
6CD61FAF03E8986523C2ABB8 /* StartChatScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3005886F00029F058DB62BE /* StartChatScreenCoordinator.swift */; };
6DC8E43BA04AC2AC4EB2EB97 /* AnalyticsPromptScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18486B87745B1811E7FBD3D2 /* AnalyticsPromptScreenModels.swift */; };
6DDA932A28F531791BDBFB08 /* HealthChatBrandColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A60C2712F5607406E8B1C80 /* HealthChatBrandColors.swift */; };
6E03A710799E6C65C0AB36BC /* TargetConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5D829FD8958376614504B18 /* TargetConfiguration.swift */; };
6E0C68918BEC5D27A4830BA6 /* PlaybackSpeedButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDBB8E5436E1BE0D6814BB7D /* PlaybackSpeedButton.swift */; };
6E391F7F628D984AF44385D9 /* RoomAttachmentPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 128501375217576AF0FE3E92 /* RoomAttachmentPicker.swift */; };
@@ -933,6 +935,7 @@
95690DDD9D547D3D842ACBE3 /* AnalyticsSettingsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD371B60E07A5324B9507EF /* AnalyticsSettingsScreenCoordinator.swift */; };
9586E90A447C4896C0CA3A8E /* TimelineItemReplyDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE89A8BD65CCE3FCC925CA14 /* TimelineItemReplyDetails.swift */; };
95B690EA99A3385D4B8A593D /* NotificationToneManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B94A6A57FE7E71F7605070AA /* NotificationToneManager.swift */; };
95C338B0F2101D8A8CA16A98 /* HealthChatCompoundHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = E09E2B1AB937F2D01815495F /* HealthChatCompoundHook.swift */; };
95E7B236F7116CACE05A6BC9 /* BlockedUsersScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A16D0F226B1819D017531647 /* BlockedUsersScreenCoordinator.swift */; };
9603EEF6DE980BB1D15D4707 /* UIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05A3E8741D199CD1A37F4CBF /* UIView.swift */; };
962A4F8AD6312804E2C6BB6E /* PhotoLibraryPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = A232D9156D225BD9FD1D0C43 /* PhotoLibraryPicker.swift */; };
@@ -1404,6 +1407,7 @@
E794AB6ABE1FF5AF0573FEA1 /* BlurHashEncode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9332DFE9642F0A46ECA0497B /* BlurHashEncode.swift */; };
E79D79CDAFE8BEBCC3AECA54 /* AppLockScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08283301736A6FE9D558B2CB /* AppLockScreenViewModelProtocol.swift */; };
E7DB506E77CED28D6FE6203D /* ManageAuthorizedSpacesScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD8E7C716B8DC2F71E9AC439 /* ManageAuthorizedSpacesScreenModels.swift */; };
E7F27870397639C204402434 /* HealthChatAppHooks.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0B439012B010EC4C3DD24D0 /* HealthChatAppHooks.swift */; };
E81E8B7A8ADDB2A912A609F6 /* CallIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C2DB32B72EA4772E2C9E3B3 /* CallIntent.swift */; };
E82E13CC3EB923CCB8F8273C /* TimelineProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9E543072DE58E751F028998 /* TimelineProxy.swift */; };
E84ADFE9696936C18C2424B5 /* SecureBackupScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84A00BB9CD12CF6AC98D5485 /* SecureBackupScreen.swift */; };
@@ -2341,6 +2345,7 @@
796CBD0C56FA0D3AEDAB255B /* SessionVerificationScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationScreenCoordinator.swift; sourceTree = "<group>"; };
79A106D76741914F82664959 /* StateStoreViewModelV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateStoreViewModelV2.swift; sourceTree = "<group>"; };
7A03E073077D92AA19C43DCF /* IdentityConfirmationScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityConfirmationScreenCoordinator.swift; sourceTree = "<group>"; };
7A60C2712F5607406E8B1C80 /* HealthChatBrandColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatBrandColors.swift; sourceTree = "<group>"; };
7A6D867A7FBB70C6EFDBCBC5 /* AccessibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityTests.swift; sourceTree = "<group>"; };
7AAD8C633AA57948B34EDCF7 /* RoomChangeRolesScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomChangeRolesScreenViewModelProtocol.swift; sourceTree = "<group>"; };
7AB74D407F0E413E7593B369 /* SpaceAddRoomsScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceAddRoomsScreenViewModelTests.swift; sourceTree = "<group>"; };
@@ -2938,6 +2943,8 @@
DF3D25B3EDB283B5807EADCF /* ReadMarkerRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadMarkerRoomTimelineItem.swift; sourceTree = "<group>"; };
DFFB0E7C6D8E190AFA0176DC /* uz */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = uz; path = uz.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
E06AAD6D9D3F5833E7A5A2F9 /* RoomListFilterModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomListFilterModels.swift; sourceTree = "<group>"; };
E09E2B1AB937F2D01815495F /* HealthChatCompoundHook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatCompoundHook.swift; sourceTree = "<group>"; };
E0B439012B010EC4C3DD24D0 /* HealthChatAppHooks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatAppHooks.swift; sourceTree = "<group>"; };
E0FCA0957FAA0E15A9F5579D /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Untranslated.stringsdict; sourceTree = "<group>"; };
E0FF9CB3EFA753277291F609 /* EncryptionResetScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionResetScreenCoordinator.swift; sourceTree = "<group>"; };
E10DA51DBC8C7E1460DBCCBD /* UserProfileListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileListRow.swift; sourceTree = "<group>"; };
@@ -2946,6 +2953,7 @@
E1714DD78BFE27ADD40A6F89 /* LinkNewDeviceScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkNewDeviceScreenViewModelTests.swift; sourceTree = "<group>"; };
E1A5FEF17ED7E6176D922D4F /* RoomDetailsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsScreen.swift; sourceTree = "<group>"; };
E1E0B4A34E69BD2132BEC521 /* MessageText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageText.swift; sourceTree = "<group>"; };
E1ECC44D40D05D3A6F0DE0F7 /* HealthChatAppSettingsHook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatAppSettingsHook.swift; sourceTree = "<group>"; };
E1ED17433ADC77287F8904F9 /* CallNotificationRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallNotificationRoomTimelineItem.swift; sourceTree = "<group>"; };
E20403084A320D588ACED200 /* ReportRoomScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportRoomScreenViewModelProtocol.swift; sourceTree = "<group>"; };
E2520C4F33AA0C061D209C28 /* RoomMembersListScreenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMembersListScreenTests.swift; sourceTree = "<group>"; };
@@ -3588,6 +3596,7 @@
isa = PBXGroup;
children = (
B6E4AB573FAEBB7B853DD04C /* AppHooks.swift */,
801BA878EE879E01E82F3A5F /* HealthChat */,
2ED530A4DD98266D31E5D8E1 /* Hooks */,
);
path = AppHooks;
@@ -5366,6 +5375,17 @@
path = View;
sourceTree = "<group>";
};
801BA878EE879E01E82F3A5F /* HealthChat */ = {
isa = PBXGroup;
children = (
E0B439012B010EC4C3DD24D0 /* HealthChatAppHooks.swift */,
E1ECC44D40D05D3A6F0DE0F7 /* HealthChatAppSettingsHook.swift */,
7A60C2712F5607406E8B1C80 /* HealthChatBrandColors.swift */,
E09E2B1AB937F2D01815495F /* HealthChatCompoundHook.swift */,
);
path = HealthChat;
sourceTree = "<group>";
};
8039515BAA53B7C3275AC64A /* Client */ = {
isa = PBXGroup;
children = (
@@ -7480,34 +7500,34 @@
ORGANIZATIONNAME = Element;
TargetAttributes = {
0E28CD62691FDBC63147D5E3 = {
DevelopmentTeam = 7J4U792NQT;
DevelopmentTeam = 6G37K72W82;
TestTargetID = C0FAEB81CFD9776CD78CE489;
};
19F0C845D67E9BEA4BE7133E = {
DevelopmentTeam = "$(DEVELOPMENT_TEAM)";
};
32C23C8D224D46EFE62AFAD0 = {
DevelopmentTeam = 7J4U792NQT;
DevelopmentTeam = 6G37K72W82;
};
676A87694F0F247B2AF1A142 = {
DevelopmentTeam = 7J4U792NQT;
DevelopmentTeam = 6G37K72W82;
};
7A17BE29BAC81ADBAC6349D9 = {
DevelopmentTeam = 7J4U792NQT;
DevelopmentTeam = 6G37K72W82;
};
C0C687DE1D270F9895FEE186 = {
DevelopmentTeam = 7J4U792NQT;
DevelopmentTeam = 6G37K72W82;
TestTargetID = C0FAEB81CFD9776CD78CE489;
};
C0FAEB81CFD9776CD78CE489 = {
DevelopmentTeam = "$(DEVELOPMENT_TEAM)";
};
D3DB351B7FBE0F49649171FC = {
DevelopmentTeam = 7J4U792NQT;
DevelopmentTeam = 6G37K72W82;
TestTargetID = C0FAEB81CFD9776CD78CE489;
};
F8E276FD6DC43EADB85241BC = {
DevelopmentTeam = 7J4U792NQT;
DevelopmentTeam = 6G37K72W82;
};
FEB53A5BC378C913769656D8 = {
DevelopmentTeam = "$(DEVELOPMENT_TEAM)";
@@ -8510,6 +8530,10 @@
4295E5F850897710A51AE114 /* GeoURI.swift in Sources */,
F3C9CAD26FD4D7D6EBACF501 /* HTMLFixtures.swift in Sources */,
E8C4D9F93F0DCED211D5F187 /* HTMLParserStyle.swift in Sources */,
E7F27870397639C204402434 /* HealthChatAppHooks.swift in Sources */,
665314B4CB31904F107D2D3B /* HealthChatAppSettingsHook.swift in Sources */,
6DDA932A28F531791BDBFB08 /* HealthChatBrandColors.swift in Sources */,
95C338B0F2101D8A8CA16A98 /* HealthChatCompoundHook.swift in Sources */,
0C1E537A49ABB386F7554D4A /* HighlightedTimelineItemModifier.swift in Sources */,
964B9D2EC38C488C360CE0C9 /* HomeScreen.swift in Sources */,
62C5876C4254C58C2086F0DE /* HomeScreenContent.swift in Sources */,
@@ -9833,11 +9857,11 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
APP_DISPLAY_NAME = "Element X";
APP_GROUP_IDENTIFIER = group.io.element;
APP_DISPLAY_NAME = HealthChat;
APP_GROUP_IDENTIFIER = group.ch.healthchat;
APP_NAME = ElementX;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = "colors/accent-color";
BASE_BUNDLE_IDENTIFIER = io.element.elementx;
BASE_BUNDLE_IDENTIFIER = ch.healthchat.element;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
@@ -9875,7 +9899,7 @@
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 7J4U792NQT;
DEVELOPMENT_TEAM = 6G37K72W82;
ENABLE_BITCODE = NO;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
@@ -9894,7 +9918,7 @@
MARKETING_VERSION = 26.07.3;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCTION_APP_NAME = Element;
PRODUCTION_APP_NAME = HealthChat;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SUPPORTS_MACCATALYST = NO;
@@ -9908,11 +9932,11 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
APP_DISPLAY_NAME = "Element X";
APP_GROUP_IDENTIFIER = group.io.element;
APP_DISPLAY_NAME = HealthChat;
APP_GROUP_IDENTIFIER = group.ch.healthchat;
APP_NAME = ElementX;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = "colors/accent-color";
BASE_BUNDLE_IDENTIFIER = io.element.elementx;
BASE_BUNDLE_IDENTIFIER = ch.healthchat.element;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
@@ -9950,7 +9974,7 @@
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 7J4U792NQT;
DEVELOPMENT_TEAM = 6G37K72W82;
ENABLE_BITCODE = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
@@ -9976,7 +10000,7 @@
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
PRODUCTION_APP_NAME = Element;
PRODUCTION_APP_NAME = HealthChat;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SUPPORTS_MACCATALYST = NO;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

After

Width:  |  Height:  |  Size: 265 KiB

@@ -1,15 +1,23 @@
{
"images" : [
{
"filename" : "app-logo.pdf",
"idiom" : "universal"
"filename" : "app-logo.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "app-logo@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "app-logo@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

@@ -1,4 +1,5 @@
"screen_home_tab_search" = "Search";
"screen_onboarding_welcome_title_ios" = "Welcome to HealthChat";
"screen_search_empty_state_message" = "Search for chats and messages";
"screen_search_empty_state_title" = "Start searching...";
"screen_search_no_results_message" = "There are no results for “%1$@.” Try a new search term.";
@@ -0,0 +1,21 @@
//
// Copyright 2026 Novaloop AG
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
/// Registers the HealthChat hooks.
///
/// `AppHooks` is a concrete type and `AppCoordinator` calls `setUp()` on it directly, so this method
/// wins over the empty `AppHooksProtocol` default via static dispatch — mirroring how Element's own
/// Enterprise fork provides its `setUp()`. This file is compiled only into the main app; the NSE and
/// Share Extension keep the empty default (they never drive authentication).
extension AppHooks {
func setUp() {
#if IS_MAIN_APP
registerAppSettingsHook(HealthChatAppSettingsHook())
registerCompoundHook(HealthChatCompoundHook())
#endif
}
}
@@ -0,0 +1,40 @@
//
// Copyright 2026 Novaloop AG
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Foundation
/// Pins HealthChat's server, OIDC and push configuration by overriding `AppSettings` at startup.
///
/// - `accountProviders` / `allowOtherAccountProviders` lock every user to `healthchat.ch` (the server name;
/// `.well-known` delegates to `matrix.healthchat.ch`) and hide the change-server / create-account UI.
/// - `oAuthRedirectURL` uses the app's own URL scheme so the OIDC/MAS callback needs no associated domain.
/// - Legacy `element.io` help/encryption/copyright URLs are passed through unchanged for now.
struct HealthChatAppSettingsHook: AppSettingsHookProtocol {
func configure(_ appSettings: AppSettings) -> AppSettings {
appSettings.override(accountProviders: ["healthchat.ch"],
allowOtherAccountProviders: false,
hideBrandChrome: false,
pushGatewayBaseURL: "https://sygnal.healthchat.ch",
oAuthRedirectURL: "ch.healthchat.element:/oauth/callback",
websiteURL: "https://healthchat.ch",
logoURL: "https://app.healthchat.ch/themes/element/img/logos/element-logo.png",
copyrightURL: appSettings.copyrightURL,
acceptableUseURL: "https://www.healthchat.ch/datenschutz",
privacyURL: "https://www.healthchat.ch/datenschutz",
encryptionURL: appSettings.encryptionURL,
deviceVerificationURL: appSettings.deviceVerificationURL,
chatBackupDetailsURL: appSettings.chatBackupDetailsURL,
identityPinningViolationDetailsURL: appSettings.identityPinningViolationDetailsURL,
historySharingDetailsURL: appSettings.historySharingDetailsURL,
elementWebHosts: ["app.healthchat.ch"],
accountProvisioningHost: appSettings.accountProvisioningHost,
bugReportApplicationID: appSettings.bugReportApplicationID,
analyticsTermsURL: appSettings.analyticsTermsURL,
mapTilerConfiguration: AppSettings.bundledMapTilerConfiguration)
return appSettings
}
}
@@ -0,0 +1,33 @@
//
// Copyright 2026 Novaloop AG
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import UIKit
/// HealthChat brand palette, ported from the legacy Element iOS client.
/// These light/dark values are applied to Compound's accent token family by `HealthChatCompoundHook`.
///
/// **Note:** `nonisolated` is required. This target defaults to `MainActor` isolation, but UIKit resolves
/// the dynamic colour providers below on SwiftUI's render thread, which traps on an isolated closure.
nonisolated enum HealthChatBrandColors {
/// Primary brand blue — light `#1F4D85`, dark `#376A91`.
static let brand = dynamic(light: hex(0x1F4D85), dark: hex(0x376A91))
/// Pressed state — a slightly darker brand blue.
static let brandPressed = dynamic(light: hex(0x17385F), dark: hex(0x2A5171))
/// Subtle accent background / tint — light `#EBF0F7`, dark `#1A2D47`.
static let tint = dynamic(light: hex(0xEBF0F7), dark: hex(0x1A2D47))
private static func dynamic(light: UIColor, dark: UIColor) -> UIColor {
UIColor { $0.userInterfaceStyle == .dark ? dark : light }
}
private static func hex(_ value: Int, alpha: CGFloat = 1) -> UIColor {
UIColor(red: CGFloat((value >> 16) & 0xFF) / 255,
green: CGFloat((value >> 8) & 0xFF) / 255,
blue: CGFloat(value & 0xFF) / 255,
alpha: alpha)
}
}
@@ -0,0 +1,84 @@
//
// Copyright 2026 Novaloop AG
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Compound
import SwiftUI
import UIKit
/// Recolours Compound's accent token family to the HealthChat brand.
/// Applied once at startup from `AppCoordinator` via `AppHooks.compoundHook`. Because the override
/// runs at app startup (not in previews), preview snapshots keep the default Compound colours.
struct HealthChatCompoundHook: CompoundHookProtocol {
func override(colors: CompoundColors, uiColors: CompoundUIColors) {
let brand = HealthChatBrandColors.brand
let tint = HealthChatBrandColors.tint
// Solid accent → brand blue (primary + send buttons, links, accent icons, badges, borders).
// NB: bgAccentRest is a SOLID fill (behind a white icon on the send button), not a subtle tint.
let solidColorTokens: [KeyPath<CompoundColorTokens, Color>] = [
\.bgActionPrimaryRest,
\.bgAccentRest,
\.iconAccentPrimary,
\.iconAccentTertiary,
\.textActionPrimary,
\.textActionAccent,
\.textLinkExternal,
\.borderInteractivePrimary,
\.textBadgeAccent
]
let solidUIColorTokens: [KeyPath<CompoundUIColorTokens, UIColor>] = [
\.bgActionPrimaryRest,
\.bgAccentRest,
\.iconAccentPrimary,
\.iconAccentTertiary,
\.textActionPrimary,
\.textActionAccent,
\.textLinkExternal,
\.borderInteractivePrimary,
\.textBadgeAccent
]
for keyPath in solidColorTokens {
colors.override(keyPath, with: Color(brand))
}
for keyPath in solidUIColorTokens {
uiColors.override(keyPath, with: brand)
}
// Primary button pressed state. The disabled state stays Compound's neutral grey.
colors.override(\.bgActionPrimaryPressed, with: Color(HealthChatBrandColors.brandPressed))
uiColors.override(\.bgActionPrimaryPressed, with: HealthChatBrandColors.brandPressed)
// Gradient "super"/action buttons → brand blue (SwiftUI-only concept).
let gradientTokens: [KeyPath<CompoundColorTokens, Color>] = [
\.gradientActionStop1,
\.gradientActionStop2,
\.gradientActionStop3,
\.gradientActionStop4
]
for keyPath in gradientTokens {
colors.override(keyPath, with: Color(brand))
}
// Subtle accent backgrounds (paired with accent-coloured foregrounds) → brand tint.
let tintColorTokens: [KeyPath<CompoundColorTokens, Color>] = [
\.bgAccentSelected,
\.bgBadgeAccent,
\.borderAccentSubtle
]
let tintUIColorTokens: [KeyPath<CompoundUIColorTokens, UIColor>] = [
\.bgAccentSelected,
\.bgBadgeAccent,
\.borderAccentSubtle
]
for keyPath in tintColorTokens {
colors.override(keyPath, with: Color(tint))
}
for keyPath in tintUIColorTokens {
uiColors.override(keyPath, with: tint)
}
}
}
@@ -12,6 +12,8 @@ import Foundation
internal nonisolated enum UntranslatedL10n {
/// Search
internal static var screenHomeTabSearch: String { return UntranslatedL10n.tr("Untranslated", "screen_home_tab_search") }
/// Welcome to HealthChat
internal static var screenOnboardingWelcomeTitleIos: String { return UntranslatedL10n.tr("Untranslated", "screen_onboarding_welcome_title_ios") }
/// Search for chats and messages
internal static var screenSearchEmptyStateMessage: String { return UntranslatedL10n.tr("Untranslated", "screen_search_empty_state_message") }
/// Start searching...
@@ -76,7 +76,7 @@ struct AuthenticationStartScreen: View {
if !context.viewState.hideBrandChrome {
VStack(spacing: 8) {
Text(L10n.screenOnboardingWelcomeTitle)
Text(UntranslatedL10n.screenOnboardingWelcomeTitleIos)
.font(.compound.headingLGBold)
.foregroundColor(.compound.textPrimary)
.multilineTextAlignment(.center)
@@ -6,13 +6,9 @@
<string>development</string>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:element.io</string>
<string>applinks:app.element.io</string>
<string>applinks:staging.element.io</string>
<string>applinks:develop.element.io</string>
<string>applinks:mobile.element.io</string>
<string>applinks:app.healthchat.ch</string>
<string>applinks:matrix.to</string>
<string>webcredentials:*.element.io</string>
<string>webcredentials:*.healthchat.ch</string>
</array>
<key>com.apple.developer.usernotifications.communication</key>
<true/>
@@ -21,14 +17,12 @@
<key>com.apple.security.application-groups</key>
<array>
<string>$(APP_GROUP_IDENTIFIER)</string>
<string>$(CLASSIC_APP_GROUP_IDENTIFIER)</string>
</array>
<key>com.apple.security.network.client</key>
<true/>
<key>keychain-access-groups</key>
<array>
<string>$(KEYCHAIN_ACCESS_GROUP_IDENTIFIER)</string>
<string>$(CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER)</string>
</array>
</dict>
</plist>
+1 -1
View File
@@ -30,7 +30,7 @@
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<string>$(APP_DISPLAY_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
+8 -9
View File
@@ -48,6 +48,9 @@ targets:
UILaunchScreen:
UIColorName: colors/background-color
CFBundleDisplayName: $(APP_DISPLAY_NAME)
# HealthChat: without this CFBundleName defaults to $(PRODUCT_NAME) (ElementX, kept for the module
# name), which is what the web authentication session's sign-in alert shows — not CFBundleDisplayName.
CFBundleName: $(APP_DISPLAY_NAME)
CFBundleShortVersionString: $(MARKETING_VERSION)
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
CFBundleURLTypes: [
@@ -115,23 +118,19 @@ targets:
properties:
aps-environment: development
com.apple.developer.associated-domains:
- applinks:element.io
- applinks:app.element.io
- applinks:staging.element.io
- applinks:develop.element.io
- applinks:mobile.element.io
# HealthChat: app.healthchat.ch universal links + webcredentials (OAuth/autofill). matrix.to kept for permalinks.
- applinks:app.healthchat.ch
- applinks:matrix.to
# - applinks:localhost?mode=developer # developer mode only works with https (but self-signed is fine).
- webcredentials:*.element.io
- webcredentials:*.healthchat.ch
com.apple.developer.usernotifications.communication: true
com.apple.security.app-sandbox: true
com.apple.security.application-groups:
# HealthChat: dropped $(CLASSIC_APP_GROUP_IDENTIFIER) (group.im.vector) — owned by Element's team, not provisionable here.
- $(APP_GROUP_IDENTIFIER)
- $(CLASSIC_APP_GROUP_IDENTIFIER)
com.apple.security.network.client: true
keychain-access-groups:
# HealthChat: dropped $(CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER) (im.vector.app.*) — not provisionable here.
- $(KEYCHAIN_ACCESS_GROUP_IDENTIFIER)
- $(CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER)
settings:
base:
+13
View File
@@ -0,0 +1,13 @@
# HealthChat product identity.
# Included permanently from project.yml (this fork always builds as HealthChat).
# Only build-setting scalars belong here — XcodeGen concatenates arrays on include-merge,
# so entitlement arrays (associated-domains, app-groups, keychain-groups) are edited directly
# in ElementX/SupportingFiles/target.yml instead.
# NB: APP_NAME (internal PRODUCT_NAME / module name) is intentionally left as ElementX so that
# `@testable import ElementX` and $(PRODUCT_MODULE_NAME) keep working.
settings:
APP_DISPLAY_NAME: HealthChat
PRODUCTION_APP_NAME: HealthChat
APP_GROUP_IDENTIFIER: group.ch.healthchat
BASE_BUNDLE_IDENTIFIER: ch.healthchat.element
DEVELOPMENT_TEAM: 6G37K72W82
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,445 @@
# HealthChat (element-x-ios) — App Store release capability
**Date:** 2026-07-15
**Branch:** `release/healthchat`
**Status:** Design approved; revised after adversarial review (26 issues, 0 rejected)
## Context
`Novaloop-AG/element-x-ios` is the HealthChat rebrand of Element X. The rebrand landed in `01dc703b4`
(branding, fixed homeserver, variant seam). What it cannot do is ship: the repo has **no release machinery
of any kind** — no `fastlane/`, no `Gemfile`, and none of its 17 GitHub workflows build or upload anything.
The legacy fork `Novaloop-AG/element-ios` ships HealthChat to the App Store today via a proven pipeline
(last green run 2026-04-15, version 1.4.1). This design brings equivalent capability to element-x-ios and
captures the process in a skill.
Both apps use **the same bundle id** (`ch.healthchat.element`) and **the same Apple team** (`6G37K72W82`).
element-x therefore reaches the App Store as an **in-place update** of the existing HealthChat record. That
single fact drives most of this design — including the fact that the two apps can never coexist on a
device, which upstream's migration UX assumes they can.
### Why upstream's own release path is unavailable
element-hq ships element-x via Xcode Cloud. `ci_scripts/ci_post_clone.sh` branches on `$CI_WORKFLOW` and
calls `swift run pipeline update-foss-secrets` (`Tools/Sources/Commands/CI/ConfigureProduction.swift:8`).
`pipeline` lives only in the private `Enterprise` submodule (`.gitmodules` → `element-hq/element-ios-enterprise`;
`Enterprise/` is empty here and Novaloop cannot obtain it). Separately `ci_scripts/ci_post_xcodebuild.sh:13`
unconditionally uploads dSYMs to Element's Sentry org. Xcode Cloud is a dead end for this fork.
## Decisions
| # | Decision | Rationale |
|---|---|---|
| D1 | **In-place update** of the existing App Store record | Identity already aligns. `26.07.3` already clears the legacy's `1.4.1`, so no version bump is needed. |
| D2 | **Preserve** legacy sessions + E2EE keys | The migration code already ships; the fix is 3 scalars + 1 entitlement, all HealthChat's own provisionable ids. |
| D3 | **Port the legacy fastlane** (slim, purpose-built) | Xcode Cloud is blocked. The legacy pipeline is proven and reuses the identical team, cert and ASC key. |
| D4 | Migration fix ships **first, as its own PR** | It corrects a defect in `01dc703b4`. **Note: it is _not_ monotonic — see D9.** |
| D5 | Skill lives at `.agents/skills/releasing-healthchat/SKILL.md` | Reachable via the existing `.claude/skills -> ../.agents/skills` symlink. A new subdirectory cannot conflict on rebase. |
| D6 | Reuse the legacy's `HEALTHCHAT_*` secrets | Same team, cert and ASC key. **Reject fastlane match** — the legacy deliberately has none. |
| D7 | Keep the `healthchat-v*` tag namespace | Upstream owns `release/<version>` (`ReleaseToGithub.swift:83-85`) and `nightly/*`. |
| D8 | **Create** `HEALTHCHAT_CHANGES.md` as the release-notes source | element-x's `CHANGES.md` is owned by upstream's `release-to-github`. The file does **not exist** in this repo — it must be created, not carried over. |
| D9 | Fix the `.requiresBackup` dead-end with a **small upstream edit** | Accepts the first-ever edit to an upstream logic file. Required for D4 to be safe — see Part 1. |
---
## Part 1 — Classic migration fix (PR1, `pr-bugfix`)
### The defect
`01dc703b4` dropped `$(CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER)` from the entitlements with the
rationale "not provisionable here". That was correct *about Element's identifiers* — but the fix is to
**repoint them at HealthChat's own**, not to remove them.
Left as-is, every existing user who updates is silently logged out and loses local encrypted history
unless they had key backup. `loadAccounts()` throws `invalidAppGroupIdentifier`, caught and logged only —
`AuthenticationService.swift:59-63`: *"No need to alert the user of the failure, just log it."*
### Why the fix is small
The plumbing already ships. `ElementX/SupportingFiles/target.yml:78-81` maps the values into `Info.plist`;
`InfoPlistReader.swift:28-31,129-143` reads them. `ClassicAppManager` reads exactly `accountIv`,
`accountAesKey`, `cryptoSDKStoreKey` (`ClassicAppManager.swift:31-35,61-68`) — and the legacy
`EncryptionKeyManager` writes exactly those three names, to service
`ch.healthchat.element.encryption-manager-service`, in access group
`$(AppIdentifierPrefix)ch.healthchat.element.keychain.shared`
(`element-ios/Riot/Managers/EncryptionKeyManager/EncryptionKeyManager.swift:17,20-25,27`).
Only the scalars are wrong: `app.yml:11-13` points them at Element's `group.im.vector` / `im.vector.app.*`,
and `healthchat.yml` does not override them.
**Verified mechanics** (these were the design's load-bearing assumptions):
- **XcodeGen scalar precedence.** `healthchat.yml` is included after `app.yml` (`project.yml:71` vs `:61`)
and wins — proven by the already-generated pbxproj, where `APP_GROUP_IDENTIFIER = group.ch.healthchat`
and `BASE_BUNDLE_IDENTIFIER = ch.healthchat.element` beat app.yml's Element values.
- **No trailing-dot bug.** `$(AppIdentifierPrefix)` carries its own trailing dot, so the legacy resolves to
`6G37K72W82.ch.healthchat.element.keychain.shared`. `$(DEVELOPMENT_TEAM).ch.healthchat.element.keychain.shared`
is byte-equivalent and matches upstream's own convention (`app.yml:13`).
### The change
In `Variants/HealthChat/healthchat.yml`:
```yaml
CLASSIC_APP_GROUP_IDENTIFIER: group.ch.healthchat
CLASSIC_APP_KEYCHAIN_SERVICE_IDENTIFIER: ch.healthchat.element.encryption-manager-service
CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER: "$(DEVELOPMENT_TEAM).ch.healthchat.element.keychain.shared"
```
In `ElementX/SupportingFiles/target.yml`, restore the classic keychain group to `keychain-access-groups`
and correct the misleading comment at `:132`:
```yaml
keychain-access-groups:
- $(KEYCHAIN_ACCESS_GROUP_IDENTIFIER)
# HealthChat: the legacy app's keychain group — ClassicAppManager reads the account + crypto store
# keys from here to migrate existing users. Same team prefix, so it is provisionable.
- $(CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER)
```
**Do not** re-add `$(CLASSIC_APP_GROUP_IDENTIFIER)` to `com.apple.security.application-groups`: it resolves
to `group.ch.healthchat`, already the value of `$(APP_GROUP_IDENTIFIER)`, so it would duplicate the entry.
Correct the comment at `:128` to say this rather than implying the group was dropped as un-provisionable.
`CLASSIC_APP_DEEP_LINK_URL` needs no override — legacy HealthChat registers the `element` scheme
(`element-ios/Config/AppIdentifiers.xcconfig:22`) — but see the `.requiresBackup` fix below, which makes it
moot.
**Finally: run `xcodegen` and commit both `ElementX.xcodeproj/project.pbxproj` and
`ElementX/SupportingFiles/ElementX.entitlements`.** Both are tracked and not ignored. Without this the
checked-in entitlements still lack the classic group and anyone building the branch gets the un-fixed,
silently-failing behaviour.
### The `.requiresBackup` fix (D9) — why Part 1 is not monotonic
`availableSecrets` maps `.withoutBackup` / `.unusedBackup` → `.requiresBackup`
(`ClassicAppManager.swift:82-85`). `login(classicAppAccount:)` routes that to
`state.bindings.showClassicAppBackupInstructions`, whose sheet's only button fires `.openClassicApp` →
`appMediator.open(element://open)` (`AuthenticationStartScreenViewModel.swift:106`).
**That URL resolves to nothing.** element-x registers only `$(BASE_BUNDLE_IDENTIFIER)` and `matrix`
(`ElementX/SupportingFiles/target.yml:60-63`) — no `element` scheme — and the legacy app that *did*
register it has been replaced in place by D1's same-bundle-id update. Upstream's UX assumes Element Classic
(`im.vector.app`) and Element X (`io.element.elementx`) coexist as separate apps. HealthChat cannot.
So making `loadAccounts()` succeed switches on a UI branch that is worse than the status quo for
`.requiresBackup` users: today they see a working login screen; after the fix they see a modal urging them
to open an app that no longer exists. The sheet is dismissible (`@Environment(\.dismiss)`) and `otherOptions`
(`AuthenticationClassicAppAccountView.swift:106`) is an escape hatch, so it is not a hard lockout — but it
is a knowingly broken call-to-action.
**Fix:** in `AuthenticationStartScreenViewModel.login(classicAppAccount:)`, treat `.requiresBackup` the same
way `.unavailable` is already treated — skip the sheet and fall through to
`configureAccountProvider(account.serverName, loginHint: "mxid:\(account.userID)", fallbackHomeserverURL:)`.
Those users sign into their own account and re-verify the device, losing only history that by definition has
no backup.
This is the **first edit to an upstream logic file** in this fork, ending the rebrand's "no upstream logic
edits" property. It is ~3 lines in a method upstream rarely touches. Record it in the skill's divergence
section so a future rebase does not silently revert it.
### Precondition for D2 (verified, but confirm on device)
`AuthenticationStartScreenViewModel:46` gates the whole classic flow on
`appSettings.accountProviders.contains(serverName)`, where `serverName` is derived from the **MXID domain**
(`ClassicAppAccountManager.swift:107-111`), not the homeserver URL. HealthChat pins
`accountProviders: ["healthchat.ch"]`.
`curl https://healthchat.ch/.well-known/matrix/server` → `{"m.server": "matrix.healthchat.ch:8448"}`. This is
*server-name delegation*: the server is **named** `healthchat.ch` and merely reachable at
`matrix.healthchat.ch:8448`. Legacy MXIDs are therefore `@user:healthchat.ch`, the gate passes, and D2 holds.
Confirm against a real legacy MXID during device testing.
---
## Part 2 — Release pipeline (PR2, `pr-build`)
### Design note: this fork owns `fastlane/` outright
Upstream element-x-ios has **no `fastlane/` directory**. The legacy skill devotes a whole section to rebase
divergence in `fastlane/.env.default` and `fastlane/Fastfile` precisely because those are upstream-owned in
element-ios. Here that class of conflict does not exist — which is why this is a slim purpose-built Fastfile
(~120 lines), not a copy of Vector's ~600-line multi-variant one.
**Dropped:** cocoapods (element-x is SPM), diawi, sentry (HealthChat has no Sentry), and the `adhoc` /
`alpha` / `setup_app_variant` lanes. Siri-intents and broadcast-upload have no counterpart target here.
### Files
| File | Purpose |
|---|---|
| `fastlane/Fastfile` | `before_all` (Xcode pin) + public `release_testflight` + private helpers |
| `fastlane/Appfile` | `apple_id ENV["APPLE_ID"]`, `team_id ENV["TEAM_ID"]` |
| `fastlane/.env.default` | Project/target/bundle-id/profile configuration (full key list below) |
| `fastlane/Pluginfile` | **`fastlane-plugin-xcodegen` only** |
| `Gemfile` + `Gemfile.lock` | `fastlane` + plugins. No cocoapods, no slather. **Commit the lock** — the workflow's gem cache keys on `hashFiles('**/Gemfile.lock')`; without it the key is constant and fastlane silently re-resolves between two runs of the same tag. |
| `Brewfile` | `xcodesorg/made/xcodes`. **Not** `xcodegen` — see runner notes. |
| `HEALTHCHAT_CHANGES.md` | **New file** (D8). Seed with a `## Changes in 26.07.3 (<date>)` section matching the awk pattern. |
| `.gitignore` | Add `out/` and **`provisioning_profiles/`** (signing material — security-relevant). `build` (`:35`) and `fastlane/report.xml` (`:28`) are already covered upstream. |
**Drop `fastlane-plugin-versioning`.** The legacy carries it but never uses it — its `update_marketing_version`
is raw `File.read`/`gsub!`, and no `increment_version_number` / `get_version_number` / `update_info_plist`
call exists anywhere. Copying it into a file we call "slim and purpose-built" would propagate dead weight.
### `.env.default`
```
PROJECT_PATH=ElementX.xcodeproj
SCHEME=ElementX
MAIN_TARGET=ElementX
NSE_TARGET=NSE
SHARE_EXTENSION_TARGET=ShareExtension
MAIN_BUNDLE_ID=ch.healthchat.element
NSE_BUNDLE_ID=ch.healthchat.element.nse
SHARE_EXTENSION_BUNDLE_ID=ch.healthchat.element.shareextension
DERIVED_DATA_PATH=build
IPA_NAME=ElementX
ARCHIVE_PATH=out/ElementX.xcarchive
BUILD_OUTPUT_DIRECTORY=out
BUILD_LOG_DIRECTORY=build/Logs/Gym/
PROVISIONING_PROFILES_PATH=./provisioning_profiles/
MAIN_PROVISIONING_PROFILE_FILENAME=main.mobileprovision
NSE_PROVISIONING_PROFILE_FILENAME=nse.mobileprovision
SHARE_EXTENSION_PROVISIONING_PROFILE_FILENAME=share_extension.mobileprovision
APPSTORE_CODESIGNING_IDENTITY="Apple Distribution: E-HealthSolutions GmbH (6G37K72W82)"
APPSTORE_SIGNING_CERTIFICATE="Apple Distribution: E-HealthSolutions GmbH (6G37K72W82)"
APPSTORE_MAIN_PROVISIONING_PROFILE_SPECIFIER="HealthChat App Store"
APPSTORE_NSE_PROVISIONING_PROFILE_SPECIFIER="HealthChat NSE: App Store"
APPSTORE_SHARE_EXTENSION_PROVISIONING_PROFILE_SPECIFIER="HealthChat Share Extension: App Store"
TEAM_ID=6G37K72W82
# APPLE_ID is a machine-level shell variable on the runner, not a repo secret.
```
`APPSTORE_CODESIGNING_IDENTITY` and `APPSTORE_SIGNING_CERTIFICATE` are **distinct keys** in the legacy — both
are needed. The `out/ElementX.ipa` artifact path is only true because `BUILD_OUTPUT_DIRECTORY`/`IPA_NAME` are
set here.
Verified: bundle ids from `NSE/SupportingFiles/target.yml:75` and `ShareExtension/SupportingFiles/target.yml:77`;
schemes/targets from `ElementX/SupportingFiles/target.yml:3-4,41`, `NSE/SupportingFiles/target.yml:3-4,30`,
`ShareExtension/SupportingFiles/target.yml:3-4,30`.
### `release_testflight` lane
Mirrors `element-ios/fastlane/Fastfile:199-224`: accept inputs via options (local) or env vars (CI, avoiding
shell-escaping of multi-line notes), then:
1. `app_store_connect_api_key(...)` from `APPSTORECONNECT_KEY_{ID,ISSUER_ID,CONTENT}` when `is_ci`.
2. `generate_build_number` → `YYYYMMDDHHMMSS` (ported from `Fastfile:562`). Monotonic; makes retries free.
3. `xcodegen(spec: "project.yml")`.
4. `download_provisioning_profiles` → three profiles (main, NSE, ShareExtension).
5. `disable_automatic_code_signing` + `update_project_provisioning` per target.
6. `build_app`, `export_method: "app-store"`, `CODE_SIGN_STYLE=Manual`.
7. `upload_to_testflight(skip_submission: true, distribute_external: false, changelog: release_notes)`.
**Version injection.** element-x holds `MARKETING_VERSION: 26.07.3` / `CURRENT_PROJECT_VERSION: 1` in
`project.yml:56-57`, consumed by XcodeGen at *generate* time (unlike the legacy's `AppVersion.xcconfig`, read
at *build* time). Pass both via `xcargs` to `build_app` instead of rewriting `project.yml`. Verified sound:
NSE (`:51-52,76-77`) and ShareExtension (`:46-47,78-79`) both resolve their version keys from
`$(MARKETING_VERSION)`/`$(CURRENT_PROJECT_VERSION)` at build time, and xcodebuild command-line settings
override project *and* target level uniformly — so all three targets stay version-consistent. This matters:
App Store rejects an upload whose extension version disagrees with the app's.
This spares `project.yml`, but **it does not mean "no tracked file is mutated"** — `ElementX.xcodeproj/project.pbxproj`
is tracked and steps 3 and 5 rewrite it (xcodegen regenerates; `disable_automatic_code_signing` /
`update_project_provisioning` write Manual signing and profile UUIDs). The pbxproj footgun **moved, it did not
vanish**. The skill must say: never commit the post-lane tree; `git checkout -- ElementX.xcodeproj`.
### GitHub workflow
`.github/workflows/release-healthchat.yml` — near-copy of the legacy's, which is already well-built (pinned
action SHAs, `contents: read`, concurrency group, 90-minute timeout, awk notes extraction, artifact upload).
Changes from the legacy:
- **`submodules: false`** — critical. element-x-ios's only submodule is the **private**
`element-hq/element-ios-enterprise` over SSH; the legacy's `submodules: 'true'` would fail checkout on step
one of every run.
- Drop the CocoaPods cache and `rm -rf Pods/MatrixSDK`.
- Drop `MX_GIT_BRANCH` (legacy `:41`) — no `mx_git_branch` helper exists in the slim Fastfile.
- Re-evaluate `fetch-depth: 0` (legacy `:48`); keep only if something needs history.
- Keep the explicit `brew bundle` step (legacy `:115`).
- `bundle exec fastlane release_testflight`; artifacts `out/ElementX.ipa`, `out/ElementX.app.dSYM.zip`.
- Harden the notes step with `[[ -f HEALTHCHAT_CHANGES.md ]]`. The legacy's "warning fallback" only fires on
an *empty match*, not an absent file — under `set -euo pipefail` a missing file aborts the job.
**Do not carry `HEALTHCHAT_KEYCHAIN_PASSWORD` forward as mandatory.** The legacy skill lists it among six
required secrets and the legacy workflow references it, but it exists at neither repo nor org level — and the
pipeline runs green regardless. `apple-actions/import-codesign-certs` tolerates its absence. **Five secrets is
the real requirement.**
### Runner
`novaloop-mac-studio-001` — org-level, online, labels `[self-hosted, macOS, ARM64]`; already satisfies
`runs-on: [self-hosted, macOS]`. Confirm the org runner group's visibility includes element-x-ios (two
`Default` groups exist, one `all`, one `selected`).
**Xcode pin: use `"26.4"`.** The runner has it and the legacy pipeline proves it works.
`ci_scripts/ci_common.sh:34` (`sudo xcode-select -s /Applications/Xcode_26.5.0.app`) is an **Xcode Cloud image
path, not an `xcodes` version identifier**, and is not evidence the runner has 26.5. If element-x turns out to
need 26.5, install it on the runner manually **first** — otherwise `xcodes` attempts a multi-GB authenticated
download needing `XCODES_USERNAME`/`XCODES_PASSWORD`, which none of the five secrets supply, and hangs in
`before_all`.
Also required on the runner: `xcodegen` on PATH (installed manually once, deliberately **not** in `Brewfile`
so `brew bundle` cannot churn it), Homebrew, Ruby + bundler, and `APPLE_ID` exported as a machine-level shell
variable (the `Appfile` reads it; it is not repo-carried).
### Secrets to copy
element-x-ios has **zero** repository secrets. Copy these five from `Novaloop-AG/element-ios`:
`HEALTHCHAT_ASC_KEY_ID`, `HEALTHCHAT_ASC_KEY_ISSUER_ID`, `HEALTHCHAT_ASC_KEY_CONTENT`,
`HEALTHCHAT_CERTIFICATES_P12`, `HEALTHCHAT_CERTIFICATES_P12_PASSWORD`.
Requires the `snowping` GitHub account — `Novaloop-AG/element-x-ios` is INTERNAL and invisible to
`joel-poyet_GALGH`.
---
## Part 3 — Release skill (PR3, `pr-doc`)
`.agents/skills/releasing-healthchat/SKILL.md`, reachable as a project skill through the existing
`.claude/skills -> ../.agents/skills` symlink.
Mirror the legacy skill's shape — it is a good model: two-field frontmatter (`name`, `description`), terse
imperative prose, checklists and tables, **rationale inline on every prohibition**. Sections: preflight,
cutting a release, `workflow_dispatch` retry path, tag format, release notes, post-release checks, required
secrets, runner requirements, certificate renewal, TestFlight → App Store promotion, rollback, divergence.
Corrected for this repo:
- **Five** required secrets, not six.
- Version source is `project.yml:56`, not `Config/AppVersion.xcconfig`.
- Three profiles (main, NSE, **ShareExtension**), not main/NSE/broadcast.
- **Never commit the post-lane tree** — `git checkout -- ElementX.xcodeproj` after a local run.
- **Test versions must be numeric-only** (see Verification) — this trap costs 30 minutes per hit.
- The divergence section shrinks: `fastlane/` and the release workflow are fork-only here, so the legacy's
`--ours` guidance for `.env.default`/`Fastfile` does not apply. What *does* need calling out:
`automatic-calendar-version.yml` and `post-release.yml` are gated on
`github.repository == 'element-hq/element-x-ios'` and silently no-op in the fork, so rebases drag
`project.yml`'s version along — **the tag is the source of truth**.
- **New section — the migration contract** (no legacy equivalent): `CLASSIC_APP_*` in `healthchat.yml` is
load-bearing for existing users and fails *silently*; it must never be "cleaned up" to upstream's values.
The `.requiresBackup` edit in `AuthenticationStartScreenViewModel` is a deliberate upstream divergence — a
rebase that reverts it reintroduces a broken CTA. Both apps share the `group.ch.healthchat` UserDefaults
suite (`AppSettings.swift:56`), so unqualified `@UserPreference` property names land in a suite the legacy
also writes to — future key collisions are cheap to introduce.
Add a 2-line delegation in `AGENTS.md` pointing at the skill rather than duplicating the runbook — the legacy
does exactly this (`element-ios/CLAUDE.md:288-290`). element-x's `AGENTS.md` has no release section today, and
per its own "keep current" rule this is a new fact about the repo, so it belongs there, in caveman voice.
---
## Landing plan
`Dangerfile.swift:50-51` **fails** the PR unless it carries exactly one `pr-` label. (The empty-body rule
`:24` and the >1000-additions rule `:18-19` are `warn`s, not fails.)
| PR | Contents | Label |
|---|---|---|
| PR1 | Part 1 — `CLASSIC_APP_*`, entitlement, `.requiresBackup` edit, regenerated pbxproj | `pr-bugfix` |
| PR2 | Part 2 — fastlane, workflow, Gemfile(.lock), Brewfile, .gitignore, HEALTHCHAT_CHANGES.md | `pr-build` |
| PR3 | Part 3 — skill + AGENTS.md delegation | `pr-doc` |
AGENTS.md's 500-addition guidance argues for keeping these separate rather than combining PR2 and PR3.
---
## Apple Developer portal prerequisites
Manual, one-time, **blocking** — no signed archive is possible until all three are done.
1. **Create App ID `ch.healthchat.element.shareextension`** with App Groups + Keychain Sharing, then the
`HealthChat Share Extension: App Store` profile. Genuinely new: the legacy provisioned
`ch.healthchat.element.shareExtension` — **camelCase, a different string** — and disabled the target anyway
(`element-ios/fastlane/.env.default`: `SHARE_EXTENSION_TARGET=`). element-x embeds ShareExtension
unconditionally (`ElementX/SupportingFiles/target.yml:227-228`).
2. **Add `com.apple.developer.usernotifications.communication`** to `ch.healthchat.element`; regenerate
`HealthChat App Store`. New vs. legacy (absent from `Riot/SupportingFiles/Riot.entitlements`).
3. **Add `com.apple.developer.usernotifications.filtering`** to `ch.healthchat.element.nse`; regenerate
`HealthChat NSE: App Store`. New vs. legacy (`RiotNSE.entitlements` has only app-groups + keychain).
The `HealthChat Broadcast Upload Extension: App Store` profile becomes orphaned — leave it; the legacy
pipeline still uses it.
Certificate: **not due.** `HEALTHCHAT_CERTIFICATES_P12` was updated 2026-04-07 and the workflow ran green
2026-04-15. Apple Distribution certs last one year → renewal due around **April 2027**.
---
## Verification
### Part 1 — device-only
**Step 0 (or the test silently false-negatives).** `AuthenticationService.swift:49-52` short-circuits
`loadAccounts()` entirely when `appSettings.hasSignedInBefore` — which lives in the `group.ch.healthchat`
UserDefaults suite (`AppSettings.swift:56`), survives app deletion via the shared container, and is never
cleared. **Any dev device that has previously run a HealthChat element-x build will show a plain login screen
while the fix works perfectly.** Delete the app before step 1. A `"skipping loadAccounts"` log line means the
run is **void, not failed**.
1. Install the *shipping* legacy HealthChat on a physical device. Sign in. Send an encrypted message so the
crypto store is populated.
2. Build element-x with the fix and install **over it** — same bundle id → in-place update. **Do not delete
the app first**; that destroys the keychain and container and invalidates the test.
3. Expected: the app finds the account and migrates without a re-login.
4. Confirm the legacy MXID is `@user:healthchat.ch` (the D2 precondition).
5. Exercise **both** secrets paths if possible: `.complete` (migrates) and `.requiresBackup` (must fall
through to sign-in, never the backup sheet).
6. Watch the log for `ClassicAppManagerError` — the failure is silent in the UI.
7. **Send a push notification to the migrated device.** Part 2 adds two brand-new notification entitlements
and a first-ever NSE profile; nothing else exercises delivery, and upload proves signing, not push.
### Part 2 — pipeline
Use `workflow_dispatch` with a throwaway version, as the legacy skill prescribes for cert renewal:
1. Run "Release HealthChat" manually with version **`26.7.99`** and a release-notes override.
**The version must be numeric-only.** It lands verbatim in `CFBundleShortVersionString` (`Info.plist:36-37`)
and Apple requires ≤3 period-separated integers. A marker like `26.07.3-pipeline-test` passes the
workflow's own regex (`:59`) but earns an **ITMS rejection at the final step, after ~30 minutes of
signing** — indistinguishable from a real signing failure. Put markers in the tag or release notes only.
2. Green run → build appears in App Store Connect → TestFlight within ~10 minutes.
3. Discard the build.
Do **not** verify by submitting to the App Store. TestFlight acceptance proves signing, entitlements and
upload; review proves nothing extra about the pipeline.
`swiftformat --lint .` and SwiftLint run on build and are currently clean.
---
## Risks & open questions
| # | Risk | Status |
|---|---|---|
| **O1** | **Was the legacy build on the Rust crypto store?** `cryptoSDKStoreKey` is generated unconditionally (`EncryptionKeyManager.swift:42`), but whether the store is Rust-backed decides if `SecretsBundleWithUserId.fromDatabase` can read it. If it was still Olm, D2 collapses to "forced re-login" and needs product sign-off. **Settle on a real device before the first submission.** |
| **O2** | **iOS floor rises 15.1 → 18.5** (`element-ios/Config/Project.xcconfig:29` vs `project.yml:14-15`). Every user below 18.5 silently stays on 1.4.1 forever — the App Store will not offer the update. Not fixable (upstream's floor); check the install base before shipping. |
| **O3** | **Pusher overwrite.** element-x's `pusherAppID` and device pushkey are identical to the legacy's, so first launch overwrites the user's sygnal pusher in place and the format changes to `.eventIdOnly`. Benign under D1 — but a rollback to 1.4.1 loses push. |
| **O4** | **Runner group visibility** — confirm `novaloop-mac-studio-001` is reachable from element-x-ios. |
| **O5** | `Secrets/Secrets.swift` holds placeholders; `Secrets/Secrets.pkl:12-17` wants MAPLIBRE_API_KEY among others. Maps degrade without it. Not a release blocker — track separately. |
| **O6** | Cosmetic Element strings ship in a HealthChat binary, e.g. `BGTaskSchedulerPermittedIdentifiers: io.element.elementx.background.refresh` (`ElementX/SupportingFiles/target.yml:102-104`). Working; cosmetic only. |
**Closed:** the app-group collision question — container paths are disjoint, and the shared
`group.ch.healthchat` UserDefaults suite has a verified-empty key intersection. Captured as a standing hazard
in the Part 3 migration contract rather than an open risk.
### Corrected prior belief
An earlier session recorded that a legacy app-lock PIN could lock the new build out via a shared keychain
group. **Refuted.** The two differ in *group, service and account* — `…keychain.shared` vs `…element`;
`…pin-service` vs `…keychain.sessions`; `pin` vs `appLockPINCode`
(`element-ios/Riot/Modules/SetPinCode/PinCodePreferences.swift:20-24,37-38` vs
`ElementX/.../KeychainController.swift:21-23,34-37`). The groups were never shared. The conclusion
("harmless") was right; the mechanism was wrong — and the wrong mechanism is what motivated dropping the
classic keychain group in `01dc703b4`.
## Out of scope
App Store submission automation (needs `fastlane/metadata/` + HealthChat screenshots, neither exists), German
localisation, the `to.healthchat.ch` links service, blue outgoing bubbles.
+1 -1
View File
@@ -68,7 +68,7 @@ include:
- path: UITests/SupportingFiles/target.yml
- path: IntegrationTests/SupportingFiles/target.yml
- path: AccessibilityTests/SupportingFiles/target.yml
# - path: MyAppVariant/override.yml
- path: Variants/HealthChat/healthchat.yml
packages:
# Element/Matrix dependencies