Skip to content

fix(ios): pull-to-sync shows "Syncing…" and stays up for the real backfill - #590

Merged
ryanbr merged 1 commit into
ryanbr:mainfrom
tigercraft4:fix/pull-to-sync-syncing-label
Jul 19, 2026
Merged

ryanbr merged 1 commit into
ryanbr:mainfrom
tigercraft4:fix/pull-to-sync-syncing-label

Conversation

@tigercraft4

Copy link
Copy Markdown

Fixes #587.

Problem

LiquidTodayView's pull-to-refresh vessel (#334/#426/#502) is silent — no text — and its visibility is tied only to a local refreshing flag:

ble.syncNow()
await repo.refresh()
await load()
try? await Task.sleep(nanoseconds: 350_000_000)   // let the fill read as "done"
withAnimation(.easeOut(duration: 0.25)) { refreshing = false }

refreshing flips false ~350ms after release. But syncNow() starts a real BLE history offload that can run far longer than that. Releasing the pull makes the vessel disappear while the strap is still mid-sync, with no feedback beyond the header's SyncStatusChip (#258) — easy to miss and not visually connected to the gesture the user just performed.

Fix

  • Extracted liquidRefreshIndicator into its own LiquidRefreshIndicator leaf, following the file's existing LiveState-isolation convention (mirrors LiquidLiveHR) — it owns @EnvironmentObject var live: LiveState itself so a ~1 Hz live-HR notify still doesn't re-render the rest of Today.
  • Added a "Syncing…" label next to the vessel.
  • Visibility (syncing) is now refreshing || live.backfilling instead of just refreshing — the indicator (vessel + label) stays up until the actual strap backfill finishes, not just the local UI reload.

Scope

iOS only (Strand/Liquid/LiquidTodayView.swift), one file. No BLE/protocol/schema change — reads the existing LiveState.backfilling flag the header chip already uses. Android isn't affected (different pull-to-sync implementation, PullToRefreshContainer in TodayScreen.kt, already fixed for its own visibility bug in #583).

Testing

macOS Strand scheme builds clean (0 errors) — xcodebuild -scheme Strand -destination 'platform=macOS'. Full NOOPiOS scheme not run locally (missing watchOS SDK in my environment, same constraint as prior PRs); this file is shared between iOS and macOS Today (liquidTodayEnabled gate), so the macOS build exercises the same code path.

No behavior change for the non-syncing states (pull-in-progress, idle) — only the syncing branch's visibility condition and content changed.

…kfill

The liquid pull-to-refresh vessel was silent and its visibility was tied only
to a local `refreshing` flag that flips false ~350ms after release (once the
local repo reload finishes) — but `ble.syncNow()` kicks off a real BLE history
offload that can run far longer than that. Releasing the pull made the vessel
vanish while the strap was still mid-sync, with no feedback beyond the
easy-to-miss header SyncStatusChip.

Extract the indicator into its own LiveState-isolated leaf (same pattern as
LiquidLiveHR) and add a "Syncing…" label. Visibility (`syncing`) is now
`refreshing || live.backfilling`, so the indicator stays up until the actual
strap sync finishes, not just the local UI reload — and it stays a leaf so a
live-HR notify still doesn't re-render the rest of Today.

Fixes ryanbr#587.
@ryanbr
ryanbr merged commit e3b013f into ryanbr:main Jul 19, 2026
1 check passed
ryanbr added a commit that referenced this pull request Jul 19, 2026
…licker it (#587 follow-up) (#608)

Follow-up to #590. That change tied the pull-to-sync vessel + "Syncing…" label to
`live.backfilling`, but that flag toggles false→true between EVERY offload chunk
(exitBackfilling at each HISTORY_END → auto-continue re-kick → beginBackfill), with
a real BLE gap in between. A deep backlog is now up to ~24 chunks in one connection
(#594 raised the auto-continue cap 6→24), so the vessel strobed in/out on every
chunk boundary — the exact per-chunk toggle the MenuBar header already pins a
constant height against.

Debounce the visibility: a new @State `syncing` goes true instantly on syncingRaw,
but goes false only after riding out a 3s quiet window with no new chunk (cancelled
the moment the next chunk arrives). So the vessel stays steady through a multi-chunk
drain and only drops when the sync is genuinely done.

iOS/macOS shared (LiquidTodayView), one file. swiftc -parse clean; i18n audit
exit 0 (reuses the existing "Syncing…" key). App-target compile to be confirmed by
app-build.
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…kfill (ryanbr#590)

The liquid pull-to-refresh vessel was silent and its visibility was tied only
to a local `refreshing` flag that flips false ~350ms after release (once the
local repo reload finishes) — but `ble.syncNow()` kicks off a real BLE history
offload that can run far longer than that. Releasing the pull made the vessel
vanish while the strap was still mid-sync, with no feedback beyond the
easy-to-miss header SyncStatusChip.

Extract the indicator into its own LiveState-isolated leaf (same pattern as
LiquidLiveHR) and add a "Syncing…" label. Visibility (`syncing`) is now
`refreshing || live.backfilling`, so the indicator stays up until the actual
strap sync finishes, not just the local UI reload — and it stays a leaf so a
live-HR notify still doesn't re-render the rest of Today.

Fixes ryanbr#587.

Co-authored-by: tigercraft4 <>
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…licker it (ryanbr#587 follow-up) (ryanbr#608)

Follow-up to ryanbr#590. That change tied the pull-to-sync vessel + "Syncing…" label to
`live.backfilling`, but that flag toggles false→true between EVERY offload chunk
(exitBackfilling at each HISTORY_END → auto-continue re-kick → beginBackfill), with
a real BLE gap in between. A deep backlog is now up to ~24 chunks in one connection
(ryanbr#594 raised the auto-continue cap 6→24), so the vessel strobed in/out on every
chunk boundary — the exact per-chunk toggle the MenuBar header already pins a
constant height against.

Debounce the visibility: a new @State `syncing` goes true instantly on syncingRaw,
but goes false only after riding out a 3s quiet window with no new chunk (cancelled
the moment the next chunk arrives). So the vessel stays steady through a multi-chunk
drain and only drops when the sync is genuinely done.

iOS/macOS shared (LiquidTodayView), one file. swiftc -parse clean; i18n audit
exit 0 (reuses the existing "Syncing…" key). App-target compile to be confirmed by
app-build.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…kfill (ryanbr#590)

The liquid pull-to-refresh vessel was silent and its visibility was tied only
to a local `refreshing` flag that flips false ~350ms after release (once the
local repo reload finishes) — but `ble.syncNow()` kicks off a real BLE history
offload that can run far longer than that. Releasing the pull made the vessel
vanish while the strap was still mid-sync, with no feedback beyond the
easy-to-miss header SyncStatusChip.

Extract the indicator into its own LiveState-isolated leaf (same pattern as
LiquidLiveHR) and add a "Syncing…" label. Visibility (`syncing`) is now
`refreshing || live.backfilling`, so the indicator stays up until the actual
strap sync finishes, not just the local UI reload — and it stays a leaf so a
live-HR notify still doesn't re-render the rest of Today.

Fixes ryanbr#587.

Co-authored-by: tigercraft4 <>
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…licker it (ryanbr#587 follow-up) (ryanbr#608)

Follow-up to ryanbr#590. That change tied the pull-to-sync vessel + "Syncing…" label to
`live.backfilling`, but that flag toggles false→true between EVERY offload chunk
(exitBackfilling at each HISTORY_END → auto-continue re-kick → beginBackfill), with
a real BLE gap in between. A deep backlog is now up to ~24 chunks in one connection
(ryanbr#594 raised the auto-continue cap 6→24), so the vessel strobed in/out on every
chunk boundary — the exact per-chunk toggle the MenuBar header already pins a
constant height against.

Debounce the visibility: a new @State `syncing` goes true instantly on syncingRaw,
but goes false only after riding out a 3s quiet window with no new chunk (cancelled
the moment the next chunk arrives). So the vessel stays steady through a multi-chunk
drain and only drops when the sync is genuinely done.

iOS/macOS shared (LiquidTodayView), one file. swiftc -parse clean; i18n audit
exit 0 (reuses the existing "Syncing…" key). App-target compile to be confirmed by
app-build.
ryanbr added a commit that referenced this pull request Sep 25, 2026
…y that

Reported in #2446 by @UtkuDenizAltiok, with the cause traced and a canary to reproduce it.

At launch, and on the Storage screen's Clean up, the app removed every item in the temporary
directory whose name began `noop-` and was more than 60 seconds old. The comment beside it said the
temp dir is NOOP's private sandbox.

That is true on iOS and false on the Mac build we ship. `Strand.entitlements` does declare
`com.apple.security.app-sandbox`, but entitlements apply only to a signed build, and fork-release
builds with `CODE_SIGNING_ALLOWED=NO` and then ad-hoc re-signs with no entitlements at all; project.yml
says as much three lines from the entitlement. So the distributed Mac app is unsandboxed, its
temporary directory is the shared per-user one, and the sweep deleted other processes' files. The
report lost a running `xcodebuild`'s derived data to it, from `$TMPDIR/noop-verify` and
`noop-measure`.

NOOP now stages its scratch in a `NOOP` folder inside the temporary directory and sweeps only what is
in there. Ownership becomes a location instead of a guess about a name, so nothing outside the folder
is a candidate whatever it is called.

Every writer moved: the backup's settings/manifest/extract staging, the import copy, the Rhythm CSV,
the export stagers, the Apple Health decompression, the Xiaomi extraction and the raw-sensor CSV.
Internal scratch drops its now-redundant `noop-` prefix. The export stagers keep their filenames,
because that name is what the user receives from the share sheet.

Two of those live in packages the parity ledger inventories, so they spell the folder name inline
rather than gaining a declaration; the ledger is unchanged, counts identical.

Legacy flat scratch is still reclaimed, by EXACT prefixes this app wrote rather than by the open
`noop-`. Without that, the multi-GB `noop-health-*` extraction an interrupted import strands (#590)
would become permanent the moment the sweep narrowed, which is the trap in the smaller of the two
fixes the report offered. The list covers only builds that predate the owned folder and can go when
they are out of the field.

`noop-last-crash.txt` and `noop-raw-capture-*` are deliberately NOT in that list: they are written to
the caches and documents directories and were never swept. Matching a name is only safe for a
directory this app stages into.

The sweep now takes the directory to work in, which is also why it finally has tests: it read
`FileManager.default.temporaryDirectory` directly, so there was no way to exercise it without
deleting the tester's own files. The reported canaries are pinned by name.

`AppModel.isNoopTempScratch` goes with it. Both its callers now ask `NoopScratch` directly, so the
wrapper was left referenced by nothing; its doc had also been split across the rewiring and its
first line had drifted onto the sizing function below.

One thing fixed on the way past: the CSV export staged its zip under a bare UUID, which matched no
`noop-` prefix, so an interrupted export leaked it. Inside the owned folder it is swept like the rest.

Parity ledger unchanged, counts identical. Doc-comment lint clean.

Refs #590.
ryanbr added a commit that referenced this pull request Sep 25, 2026
…y that

Reported in #2446 by @UtkuDenizAltiok, with the cause traced and a canary to reproduce it.

At launch, and on the Storage screen's Clean up, the app removed every item in the temporary
directory whose name began `noop-` and was more than 60 seconds old. The comment beside it said the
temp dir is NOOP's private sandbox.

That is true on iOS and false on the Mac build we ship. `Strand.entitlements` does declare
`com.apple.security.app-sandbox`, but entitlements apply only to a signed build, and fork-release
builds with `CODE_SIGNING_ALLOWED=NO` and then ad-hoc re-signs with no entitlements at all; project.yml
says as much three lines from the entitlement. So the distributed Mac app is unsandboxed, its
temporary directory is the shared per-user one, and the sweep deleted other processes' files. The
report lost a running `xcodebuild`'s derived data to it, from `$TMPDIR/noop-verify` and
`noop-measure`.

NOOP now stages its scratch in a folder it owns inside the temporary directory and sweeps only what
is in there. Ownership becomes a location instead of a guess about a name, so nothing outside the
folder is a candidate whatever it is called.

The folder is named for the running bundle, not "NOOP". Everything inside it is removed without being
matched by name, on the grounds that NOOP put it there, so the name has to be one nothing else will
have taken, and a short one is not: macOS filesystems are case-insensitive by default, so `NOOP` and
`noop` are the same directory, and the report behind this change had a process writing `noop-verify`
and `noop-measure` into that same shared folder. Adopting a stranger's directory and then emptying it
is this bug one level down. A bundle identifier is per-install and fork-specific.

Every writer moved: the backup's settings/manifest/extract staging, the import copy, the Rhythm CSV,
the export stagers, the Apple Health decompression, the Xiaomi extraction and the raw-sensor CSV.
Internal scratch drops its now-redundant `noop-` prefix. The export stagers keep their filenames,
because that name is what the user receives from the share sheet.

Three of those live in packages the parity ledger inventories, so they spell the folder name inline
rather than gaining a declaration; the ledger is unchanged, counts identical. A test pins the three
spellings to the app's own, because three independent spellings of one name drift, and a drift here
is not cosmetic: a writer staging outside the swept folder leaks forever, and one staging into a
folder it does not own hands its files to somebody else's sweep.

Legacy flat scratch is still reclaimed, by EXACT prefixes this app wrote rather than by the open
`noop-`. Without that, the multi-GB `noop-health-*` extraction an interrupted import strands (#590)
would become permanent the moment the sweep narrowed, which is the trap in the smaller of the two
fixes the report offered. The list covers only builds that predate the owned folder and can go when
they are out of the field.

`noop-last-crash.txt` and `noop-raw-capture-*` are deliberately NOT in that list: they are written to
the caches and documents directories and were never swept. Matching a name is only safe for a
directory this app stages into.

The sweep now takes the directory to work in, which is also why it finally has tests: it read
`FileManager.default.temporaryDirectory` directly, so there was no way to exercise it without
deleting the tester's own files. The reported canaries are pinned by name.

`AppModel.isNoopTempScratch` goes with it. Both its callers now ask `NoopScratch` directly, so the
wrapper was left referenced by nothing; its doc had also been split across the rewiring and its
first line had drifted onto the sizing function below.

One thing fixed on the way past: the CSV export staged its zip under a bare UUID, which matched no
`noop-` prefix, so an interrupted export leaked it. Inside the owned folder it is swept like the rest.

Parity ledger unchanged, counts identical. Doc-comment lint clean.

Refs #590.
ryanbr added a commit that referenced this pull request Sep 25, 2026
…y that (#2453)

Reported in #2446 by @UtkuDenizAltiok, with the cause traced and a canary to reproduce it.

At launch, and on the Storage screen's Clean up, the app removed every item in the temporary
directory whose name began `noop-` and was more than 60 seconds old. The comment beside it said the
temp dir is NOOP's private sandbox.

That is true on iOS and false on the Mac build we ship. `Strand.entitlements` does declare
`com.apple.security.app-sandbox`, but entitlements apply only to a signed build, and fork-release
builds with `CODE_SIGNING_ALLOWED=NO` and then ad-hoc re-signs with no entitlements at all; project.yml
says as much three lines from the entitlement. So the distributed Mac app is unsandboxed, its
temporary directory is the shared per-user one, and the sweep deleted other processes' files. The
report lost a running `xcodebuild`'s derived data to it, from `$TMPDIR/noop-verify` and
`noop-measure`.

NOOP now stages its scratch in a folder it owns inside the temporary directory and sweeps only what
is in there. Ownership becomes a location instead of a guess about a name, so nothing outside the
folder is a candidate whatever it is called.

The folder is named for the running bundle, not "NOOP". Everything inside it is removed without being
matched by name, on the grounds that NOOP put it there, so the name has to be one nothing else will
have taken, and a short one is not: macOS filesystems are case-insensitive by default, so `NOOP` and
`noop` are the same directory, and the report behind this change had a process writing `noop-verify`
and `noop-measure` into that same shared folder. Adopting a stranger's directory and then emptying it
is this bug one level down. A bundle identifier is per-install and fork-specific.

Every writer moved: the backup's settings/manifest/extract staging, the import copy, the Rhythm CSV,
the export stagers, the Apple Health decompression, the Xiaomi extraction and the raw-sensor CSV.
Internal scratch drops its now-redundant `noop-` prefix. The export stagers keep their filenames,
because that name is what the user receives from the share sheet.

Three of those live in packages the parity ledger inventories, so they spell the folder name inline
rather than gaining a declaration; the ledger is unchanged, counts identical. A test pins the three
spellings to the app's own, because three independent spellings of one name drift, and a drift here
is not cosmetic: a writer staging outside the swept folder leaks forever, and one staging into a
folder it does not own hands its files to somebody else's sweep.

Legacy flat scratch is still reclaimed, by EXACT prefixes this app wrote rather than by the open
`noop-`. Without that, the multi-GB `noop-health-*` extraction an interrupted import strands (#590)
would become permanent the moment the sweep narrowed, which is the trap in the smaller of the two
fixes the report offered. The list covers only builds that predate the owned folder and can go when
they are out of the field.

`noop-last-crash.txt` and `noop-raw-capture-*` are deliberately NOT in that list: they are written to
the caches and documents directories and were never swept. Matching a name is only safe for a
directory this app stages into.

The sweep now takes the directory to work in, which is also why it finally has tests: it read
`FileManager.default.temporaryDirectory` directly, so there was no way to exercise it without
deleting the tester's own files. The reported canaries are pinned by name.

`AppModel.isNoopTempScratch` goes with it. Both its callers now ask `NoopScratch` directly, so the
wrapper was left referenced by nothing; its doc had also been split across the rewiring and its
first line had drifted onto the sizing function below.

One thing fixed on the way past: the CSV export staged its zip under a bare UUID, which matched no
`noop-` prefix, so an interrupted export leaked it. Inside the owned folder it is swept like the rest.

Parity ledger unchanged, counts identical. Doc-comment lint clean.

Refs #590.
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.

Today pull-to-sync: release shows only a silent liquid animation, no "Syncing…" text, and it ends before the real sync does

2 participants