Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 46 additions & 3 deletions Strand/Data/BackupSync.swift
Original file line number Diff line number Diff line change
Expand Up @@ -202,11 +202,53 @@ enum FolderBackup {
return dir
}

/// Whether a folder path sits in iCloud. The SINGLE definition of that rule — `restoreListHealth`
/// reports the same signal to diagnostics, and a screen that disagreed with the diagnostics dump
/// about where the backups are would make #278-style triage worse, not better.
///
/// Ubiquity containers live under `Mobile Documents`, which covers both iCloud Drive proper
/// (`com~apple~CloudDocs`) and per-app containers. Pure and path-only, so it is testable without a
/// picker, a bookmark or a device.
static func isICloudPath(_ path: String) -> Bool { path.contains("Mobile Documents") }

/// The tail of a folder path, for display: `"NOOP › 2026"`.
///
/// The bare last component is not enough to identify a folder — a "Backups" folder in iCloud Drive
/// and the #52 internal fallback's `Documents/Backups` render identically, which is exactly the
/// confusion this line is meant to settle. The FULL path is not shown either: on iOS it is a long
/// sandbox UUID and on macOS it carries the user's home directory, neither of which helps.
///
/// For an iCloud path everything up to and including the sync root (`com~apple~CloudDocs`, or a
/// per-app `iCloud~…` container) is dropped, since [folderLabel] already names iCloud Drive and the
/// raw tilde form means nothing to a reader.
static func folderTrail(path: String, maxComponents: Int = 2) -> String {
var parts = path.split(separator: "/").map(String.init).filter { !$0.isEmpty }
// Slice AFTER the sync root rather than filtering it out: on macOS an iCloud path is
// `~/Library/Mobile Documents/com~apple~CloudDocs/NOOP`, so merely dropping the tilde component
// leaves "Mobile Documents › NOOP" — plumbing the reader should never see.
if let i = parts.lastIndex(where: {
$0 == "com~apple~CloudDocs" || $0.hasPrefix("iCloud~") || $0 == "Mobile Documents"
}) { parts = Array(parts[(i + 1)...]) }
return parts.suffix(max(1, maxComponents)).joined(separator: " › ")
}

/// A short, human label for the chosen folder, or nil if none chosen. The internal fallback gets a
/// friendly name instead of the raw "Backups" path component.
///
/// Names iCloud explicitly (#52): the picker can already point anywhere, iCloud Drive included, so
/// the gap was never the choosing — it was that the result was unidentifiable afterwards. A user on
/// the internal fallback saw a bare "Backups" and could not tell their backups were on-device only.
static func folderLabel() -> String? {
if useInternalFolder { return String(localized: "NOOP (in Files)") }
return resolveFolder()?.lastPathComponent
guard let path = resolveFolder()?.path else { return nil }
let trail = folderTrail(path: path)
if isICloudPath(path) {
// Picking the iCloud Drive root itself leaves no trail; naming the place alone beats an
// empty separator dangling off the end.
return trail.isEmpty ? String(localized: "iCloud Drive")
: String(localized: "iCloud Drive › \(trail)")
}
return trail.isEmpty ? (path.split(separator: "/").last.map(String.init) ?? path) : trail
}

// MARK: - Security-scoped bookmark
Expand Down Expand Up @@ -310,8 +352,9 @@ enum FolderBackup {
let raw = (try? FileManager.default.contentsOfDirectory(atPath: folder.path))?.count ?? 0
// Report iCloud-vs-local (the actual #278 triage signal — undownloaded placeholders live in the
// iCloud container) WITHOUT the folder's user-chosen name, which redactPii can't scrub and the
// iOS twin never exposes. The iCloud ubiquity container path contains "Mobile Documents".
let isICloud = folder.path.contains("Mobile Documents")
// iOS twin never exposes. Shares `isICloudPath` with the label the user sees, so the screen and
// this dump can never disagree about where the backups are.
let isICloud = isICloudPath(folder.path)
return (isICloud, raw, listSnapshots().count)
}

Expand Down
5 changes: 4 additions & 1 deletion Strand/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -209686,7 +209686,10 @@
"Intermediate type": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Mischtyp"}},"es":{"stringUnit":{"state":"translated","value":"Tipo intermedio"}},"fr":{"stringUnit":{"state":"translated","value":"Type intermédiaire"}},"it":{"stringUnit":{"state":"translated","value":"Tipo intermedio"}},"pl":{"stringUnit":{"state":"translated","value":"Typ pośredni"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Tipo intermédio"}},"ru":{"stringUnit":{"state":"translated","value":"Промежуточный тип"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"中间型"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"中間型"}}}},
"Evening type": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Abendtyp"}},"es":{"stringUnit":{"state":"translated","value":"Tipo vespertino"}},"fr":{"stringUnit":{"state":"translated","value":"Type vespéral"}},"it":{"stringUnit":{"state":"translated","value":"Tipo serale"}},"pl":{"stringUnit":{"state":"translated","value":"Typ wieczorny"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Tipo vespertino"}},"ru":{"stringUnit":{"state":"translated","value":"Вечерний тип"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"夜型人"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"夜型人"}}}},
"Body clock dial": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Zifferblatt der inneren Uhr"}},"es":{"stringUnit":{"state":"translated","value":"Esfera del reloj interno"}},"fr":{"stringUnit":{"state":"translated","value":"Cadran de l'horloge biologique"}},"it":{"stringUnit":{"state":"translated","value":"Quadrante dell'orologio biologico"}},"pl":{"stringUnit":{"state":"translated","value":"Tarcza zegara biologicznego"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Mostrador do relógio biológico"}},"ru":{"stringUnit":{"state":"translated","value":"Циферблат биологических часов"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"生物钟表盘"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"生理時鐘錶盤"}}}},
"Your clock": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Deine innere Uhr"}},"es":{"stringUnit":{"state":"translated","value":"Tu reloj interno"}},"fr":{"stringUnit":{"state":"translated","value":"Votre horloge"}},"it":{"stringUnit":{"state":"translated","value":"Il tuo orologio"}},"pl":{"stringUnit":{"state":"translated","value":"Twój zegar"}},"pt-PT":{"stringUnit":{"state":"translated","value":"O seu relógio"}},"ru":{"stringUnit":{"state":"translated","value":"Ваши часы"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"你的生物钟"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"你的生理時鐘"}}}}
"Your clock": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Deine innere Uhr"}},"es":{"stringUnit":{"state":"translated","value":"Tu reloj interno"}},"fr":{"stringUnit":{"state":"translated","value":"Votre horloge"}},"it":{"stringUnit":{"state":"translated","value":"Il tuo orologio"}},"pl":{"stringUnit":{"state":"translated","value":"Twój zegar"}},"pt-PT":{"stringUnit":{"state":"translated","value":"O seu relógio"}},"ru":{"stringUnit":{"state":"translated","value":"Ваши часы"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"你的生物钟"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"你的生理時鐘"}}}},
"iCloud Drive": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"iCloud Drive"}},"es":{"stringUnit":{"state":"translated","value":"iCloud Drive"}},"fr":{"stringUnit":{"state":"translated","value":"iCloud Drive"}},"it":{"stringUnit":{"state":"translated","value":"iCloud Drive"}},"pl":{"stringUnit":{"state":"translated","value":"iCloud Drive"}},"pt-PT":{"stringUnit":{"state":"translated","value":"iCloud Drive"}},"ru":{"stringUnit":{"state":"translated","value":"iCloud Drive"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"iCloud 云盘"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"iCloud 雲碟"}}}},
"iCloud Drive › %@": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"iCloud Drive › %@"}},"es":{"stringUnit":{"state":"translated","value":"iCloud Drive › %@"}},"fr":{"stringUnit":{"state":"translated","value":"iCloud Drive › %@"}},"it":{"stringUnit":{"state":"translated","value":"iCloud Drive › %@"}},"pl":{"stringUnit":{"state":"translated","value":"iCloud Drive › %@"}},"pt-PT":{"stringUnit":{"state":"translated","value":"iCloud Drive › %@"}},"ru":{"stringUnit":{"state":"translated","value":"iCloud Drive › %@"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"iCloud 云盘 › %@"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"iCloud 雲碟 › %@"}}}},
"NOOP (in Files)": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"NOOP (in Dateien)"}},"es":{"stringUnit":{"state":"translated","value":"NOOP (en Archivos)"}},"fr":{"stringUnit":{"state":"translated","value":"NOOP (dans Fichiers)"}},"it":{"stringUnit":{"state":"translated","value":"NOOP (in File)"}},"pl":{"stringUnit":{"state":"translated","value":"NOOP (w Plikach)"}},"pt-PT":{"stringUnit":{"state":"translated","value":"NOOP (em Ficheiros)"}},"ru":{"stringUnit":{"state":"translated","value":"NOOP (в Файлах)"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"NOOP(在“文件”中)"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"NOOP(在「檔案」中)"}}}}
},
"version": "1.0"
}
41 changes: 41 additions & 0 deletions StrandTests/BackupSyncTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -130,4 +130,45 @@ final class BackupSyncTests: XCTestCase {
let out = BackupSync.restorablesNewestFirst([z, a]) { _ in sameMs }
XCTAssertEqual(out.map(\.name), [a, z])
}

// MARK: - Folder identity (#52): "Saving to:" has to say WHICH folder

func testTrailSlicesAfterTheICloudSyncRoot() {
// macOS shape: merely filtering out the tilde component would leave "Mobile Documents › NOOP".
XCTAssertEqual(
FolderBackup.folderTrail(path: "/Users/x/Library/Mobile Documents/com~apple~CloudDocs/NOOP"),
"NOOP")
// iOS shape, two components deep.
XCTAssertEqual(
FolderBackup.folderTrail(
path: "/private/var/mobile/Library/Mobile Documents/com~apple~CloudDocs/Backups/NOOP"),
"Backups › NOOP")
// A per-app ubiquity container is a sync root too.
XCTAssertEqual(
FolderBackup.folderTrail(path: "/var/mobile/Library/Mobile Documents/iCloud~com~foo~bar/Documents/B"),
"Documents › B")
}

func testTrailIsEmptyAtTheSyncRootItself() {
// Nothing below the root: `folderLabel` must fall back to naming the place, not emit a dangling "›".
XCTAssertEqual(
FolderBackup.folderTrail(path: "/private/var/mobile/Library/Mobile Documents/com~apple~CloudDocs"),
"")
}

func testTrailKeepsTwoComponentsOutsideICloud() {
// The #52 internal fallback and an external cloud folder both stay identifiable.
XCTAssertEqual(
FolderBackup.folderTrail(path: "/var/mobile/Containers/Data/Application/9F3A-UUID/Documents/Backups"),
"Documents › Backups")
XCTAssertEqual(
FolderBackup.folderTrail(path: "/private/var/mobile/Containers/Shared/AppGroup/U/Dropbox/NoopBackups"),
"Dropbox › NoopBackups")
}

func testICloudPathRuleMatchesTheDiagnosticsSignal() {
// One definition: the screen and `restoreListHealth`'s dump must never disagree.
XCTAssertTrue(FolderBackup.isICloudPath("/Users/x/Library/Mobile Documents/com~apple~CloudDocs/NOOP"))
XCTAssertFalse(FolderBackup.isICloudPath("/var/mobile/Containers/Data/Application/U/Documents/Backups"))
}
}
20 changes: 20 additions & 0 deletions Tools/i18n_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -926,13 +926,33 @@ def extra_locale_allowance() -> dict[str, int]:
FORMAT_SPECIFIER_PATTERN = re.compile(r"%(?:\d+\$)?[@#0\-+ ]*[\d.]*(?:ll|l|h)?[@dfsu]|%%")


# Multi-word product names that travel verbatim into Latin-script locales. The two-word floor below
# already lets a ONE-word brand through ("HRV", "Strava"); it cannot see a two-word one, so "iCloud
# Drive" repeated verbatim in German reads as an untranslated echo when it is the correct rendering.
# Only strings that are ENTIRELY brand are exempted (see `_is_pure_brand_phrase`), so "Apple Health
# sync" stays gated on its translatable word. CJK locales that DO translate these are unaffected —
# they differ from the source, so they were never counted as echoes in the first place.
BRAND_PHRASES = ("iCloud Drive",)


def _is_pure_brand_phrase(text: str) -> bool:
"""Whether a string is nothing but brand names, placeholders and punctuation."""
stripped = FORMAT_SPECIFIER_PATTERN.sub(" ", text)
for brand in BRAND_PHRASES:
stripped = stripped.replace(brand, " ")
return not re.search(r"[^\W\d_]{2,}", stripped, flags=re.UNICODE)


def _has_translatable_words(text: str) -> bool:
"""Whether a string carries enough real words that an identical translation is suspicious.

Strips format specifiers first: "%@ · n = %lld" / "%1$s: %2$s" are placeholders and punctuation
with nothing to translate, so a locale repeating them verbatim is CORRECT, not a gap. Two words is
the floor — one word is very often a term that legitimately travels ("HRV", "Yoga", a brand name).
A string that is entirely a multi-word brand is the same case one size up (see [BRAND_PHRASES]).
"""
if _is_pure_brand_phrase(text):
return False
stripped = FORMAT_SPECIFIER_PATTERN.sub(" ", text)
return len(re.findall(r"[^\W\d_]{2,}", stripped, flags=re.UNICODE)) >= 2

Expand Down
Loading