diff --git a/appleApp/FTW-Info.plist b/appleApp/FTW-Info.plist index 03bab63..1bb587f 100644 --- a/appleApp/FTW-Info.plist +++ b/appleApp/FTW-Info.plist @@ -9,5 +9,12 @@ en sv + ITSAppUsesNonExemptEncryption + + UILaunchScreen + + UIColorName + LaunchBackground + diff --git a/appleApp/FTW.xcconfig b/appleApp/FTW.xcconfig index 8a90e57..e4a5a20 100644 --- a/appleApp/FTW.xcconfig +++ b/appleApp/FTW.xcconfig @@ -22,3 +22,9 @@ CODE_SIGN_STYLE = Automatic // anyone without one. The entitlements, and with them the App Group, still // travel with the app. CODE_SIGN_IDENTITY[sdk=iphonesimulator*] = - + +// The commit Source & licenses links to, which the license asks the app to +// offer. CI passes the full commit (xcodebuild … FTW_SOURCE_REVISION=$GITHUB_SHA). +// Left empty, the "Record the source revision" phase asks git, and a tree +// with changes is a local build that links to the repository instead. +FTW_SOURCE_REVISION = diff --git a/appleApp/FTW.xcodeproj/project.pbxproj b/appleApp/FTW.xcodeproj/project.pbxproj index 9a2ab24..f3b1b06 100644 --- a/appleApp/FTW.xcodeproj/project.pbxproj +++ b/appleApp/FTW.xcodeproj/project.pbxproj @@ -10,6 +10,9 @@ F7A000000000000000000014 /* FTWKit in Frameworks */ = {isa = PBXBuildFile; productRef = F7A000000000000000000016 /* FTWKit */; }; F7A000000000000000000030 /* FTWDemo in Frameworks */ = {isa = PBXBuildFile; productRef = F7A000000000000000000031 /* FTWDemo */; }; F7A000000000000000000032 /* FTWShared in Frameworks */ = {isa = PBXBuildFile; productRef = F7A000000000000000000033 /* FTWShared */; }; + F7A000000000000000000064 /* LICENSE in Copy the license texts */ = {isa = PBXBuildFile; fileRef = F7A000000000000000000060 /* LICENSE */; }; + F7A000000000000000000065 /* NOTICE in Copy the license texts */ = {isa = PBXBuildFile; fileRef = F7A000000000000000000061 /* NOTICE */; }; + F7A000000000000000000066 /* LICENSING.md in Copy the license texts */ = {isa = PBXBuildFile; fileRef = F7A000000000000000000062 /* LICENSING.md */; }; F7A000000000000000000034 /* FTWWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = F7A000000000000000000040 /* FTWWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ @@ -35,6 +38,19 @@ name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; }; + F7A000000000000000000067 /* Copy the license texts */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = Licenses; + dstSubfolderSpec = 7; + files = ( + F7A000000000000000000064 /* LICENSE in Copy the license texts */, + F7A000000000000000000065 /* NOTICE in Copy the license texts */, + F7A000000000000000000066 /* LICENSING.md in Copy the license texts */, + ); + name = "Copy the license texts"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -44,6 +60,9 @@ F7A000000000000000000008 /* FTW.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = FTW.xcconfig; sourceTree = ""; }; F7A000000000000000000009 /* FTW-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "FTW-Info.plist"; sourceTree = ""; }; F7A00000000000000000000A /* FTWWidgets-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "FTWWidgets-Info.plist"; sourceTree = ""; }; + F7A000000000000000000060 /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; name = LICENSE; path = ../LICENSE; sourceTree = SOURCE_ROOT; }; + F7A000000000000000000061 /* NOTICE */ = {isa = PBXFileReference; lastKnownFileType = text; name = NOTICE; path = ../NOTICE; sourceTree = SOURCE_ROOT; }; + F7A000000000000000000062 /* LICENSING.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; name = LICENSING.md; path = ../LICENSING.md; sourceTree = SOURCE_ROOT; }; F7A000000000000000000040 /* FTWWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FTWWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -91,10 +110,21 @@ F7A000000000000000000009 /* FTW-Info.plist */, F7A000000000000000000007 /* FTWWidgets.entitlements */, F7A00000000000000000000A /* FTWWidgets-Info.plist */, + F7A000000000000000000063 /* License texts */, F7A000000000000000000004 /* Products */, ); sourceTree = ""; }; + F7A000000000000000000063 /* License texts */ = { + isa = PBXGroup; + children = ( + F7A000000000000000000060 /* LICENSE */, + F7A000000000000000000061 /* NOTICE */, + F7A000000000000000000062 /* LICENSING.md */, + ); + name = "License texts"; + sourceTree = ""; + }; F7A000000000000000000004 /* Products */ = { isa = PBXGroup; children = ( @@ -114,6 +144,8 @@ F7A000000000000000000011 /* Sources */, F7A000000000000000000012 /* Frameworks */, F7A000000000000000000013 /* Resources */, + F7A000000000000000000067 /* Copy the license texts */, + F7A000000000000000000068 /* Record the source revision */, F7A000000000000000000036 /* Embed Foundation Extensions */, ); buildRules = ( @@ -215,6 +247,30 @@ }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + F7A000000000000000000068 /* Record the source revision */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Record the source revision"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/Licenses/REVISION", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# The commit Source & licenses links to. CI passes FTW_SOURCE_REVISION\n# (GITHUB_SHA). Otherwise git says. A tree with changes, or no git at all,\n# is a local build, and the screen says so.\nset -eu\nout=\"$SCRIPT_OUTPUT_FILE_0\"\nmkdir -p \"$(dirname \"$out\")\"\nrev=\"${FTW_SOURCE_REVISION:-}\"\nif [ -n \"$rev\" ]; then\n if [ ${#rev} -ne 40 ] || [ -n \"$(printf '%s' \"$rev\" | tr -d '0-9a-f')\" ]; then\n echo \"error: FTW_SOURCE_REVISION must be a full 40-character commit, not '$rev'\"\n exit 1\n fi\nelif head=$(git -C \"$SRCROOT\" rev-parse --verify HEAD 2>/dev/null) &&\n changes=$(git -C \"$SRCROOT\" --no-optional-locks status --porcelain 2>/dev/null); then\n rev=\"$head\"\n if [ -n \"$changes\" ]; then rev=\"$head-dirty\"; fi\nfi\nprintf '%s' \"$rev\" > \"$out\"\necho \"Source revision: ${rev:-unknown}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ F7A000000000000000000011 /* Sources */ = { isa = PBXSourcesBuildPhase; diff --git a/appleApp/FTW/About/AboutLink.swift b/appleApp/FTW/About/AboutLink.swift new file mode 100644 index 0000000..65d2de1 --- /dev/null +++ b/appleApp/FTW/About/AboutLink.swift @@ -0,0 +1,60 @@ +import FTWKit +import SwiftUI + +/// The way to Source & licenses, the privacy policy and removing this phone, +/// at the foot of Now and of pairing. The license asks that its text and the +/// source offer be reachable from inside the app; App Review asks the same +/// of the privacy policy (5.1.1(i)) and of removing what the app set up. +struct AboutLink: View { + let app: AppModel + @State private var open: AboutView.Start? + + var body: some View { + VStack(spacing: 6) { + // One line while it fits, one link to a line when it does not. + ViewThatFits(in: .horizontal) { + HStack(spacing: 20) { links } + VStack(spacing: 8) { links } + } + .font(.footnote.weight(.medium)) + .foregroundStyle(Theme.accent) + .buttonStyle(.plain) + Text(verbatim: "FTW \(BuildSource.bundled.versionText)") + .font(.caption) + .foregroundStyle(Theme.fgMuted) + } + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.top, 28) + .padding(.bottom, 8) + .sheet(item: $open) { start in + AboutView(app: app, start: start) + } + #if DEBUG + // For screenshots: `-FTWAbout about`, `remove` or a file name such + // as `LICENSE` opens Source & licenses there. + .task { + if let name = UserDefaults.standard.string(forKey: "FTWAbout") { open = AboutView.Start(name) } + } + #endif + } + + @ViewBuilder private var links: some View { + Button(AboutWords.text("Source & licenses")) { open = .about } + Link(AboutWords.text("Privacy"), destination: Origin.privacyURL()) + if AboutView.canRemove(app) { + Button(AboutWords.text("Remove this phone")) { open = .remove } + } + } +} + +extension BuildSource { + /// This build, as its bundle records it. The "Record the source revision" + /// phase writes the commit; CI sets FTW_SOURCE_REVISION to GITHUB_SHA. + static let bundled = BuildSource( + version: AppInfo.version, + build: AppInfo.buildNumber, + recorded: Bundle.main.url(forResource: "REVISION", withExtension: nil, subdirectory: "Licenses") + .flatMap { try? String(contentsOf: $0, encoding: .utf8) } + ) +} diff --git a/appleApp/FTW/About/AboutView.swift b/appleApp/FTW/About/AboutView.swift new file mode 100644 index 0000000..78906e1 --- /dev/null +++ b/appleApp/FTW/About/AboutView.swift @@ -0,0 +1,167 @@ +import FTWKit +import SwiftUI + +/// Source & licenses: what this build is, where its exact source is, and +/// the license texts the build copied in from the repository root. +struct AboutView: View { + enum Start: Hashable, Identifiable { + case about, remove + case document(LicenseText.Document) + + var id: Self { self } + + init?(_ name: String) { + switch name { + case "about": self = .about + case "remove": self = .remove + default: + guard let document = LicenseText.Document(rawValue: name) else { return nil } + self = .document(document) + } + } + } + + enum Route: Hashable { + case document(LicenseText.Document) + case remove + } + + let app: AppModel + @State private var path: [Route] + @Environment(\.dismiss) private var dismiss + @Environment(\.dynamicTypeSize) private var typeSize + @ScaledMetric(relativeTo: .title2) private var markSize: CGFloat = 52 + private let source = BuildSource.bundled + + init(app: AppModel, start: Start = .about) { + self.app = app + switch start { + case .about: _path = State(initialValue: []) + case .remove: _path = State(initialValue: [.remove]) + case .document(let document): _path = State(initialValue: [.document(document)]) + } + } + + /// A paired phone has something to remove. The demo has not, and its + /// way out is Exit demo. + static func canRemove(_ app: AppModel) -> Bool { + !app.isDemo && (app.vault.isEnrolled || app.sites.current() != nil) + } + + var body: some View { + NavigationStack(path: $path) { + List { + Section { header } + + Section { + LabeledContent(AboutWords.text("Version"), value: source.version) + LabeledContent(AboutWords.text("Build"), value: source.build) + LabeledContent(AboutWords.text("Commit")) { + Text(source.revisionText) + .font(source.isLocal ? .body : .body.monospaced()) + .textSelection(.enabled) + } + } header: { + Text(AboutWords.text("This version")) + } footer: { + if let note = source.localNote { Text(note) } + } + + Section { + Link(destination: source.sourceURL) { + Label(source.isLocal ? AboutWords.text("Source code on GitHub") : AboutWords.text("Source code of this version"), systemImage: "chevron.left.forwardslash.chevron.right") + } + Link(destination: source.licenseURL) { + Label(source.isLocal ? AboutWords.text("License on GitHub") : AboutWords.text("License at this commit"), systemImage: "doc.text") + } + } header: { + Text(AboutWords.text("Source code")) + } footer: { + Text(AboutWords.text("FTW is free software. You may run, study, change and share it under the GNU AGPL v3 with the two additional permissions in LICENSE. The link gives the complete source of this exact version, at no charge.")) + } + + Section { + Link(destination: Origin.privacyURL()) { + Label(AboutWords.text("Privacy policy"), systemImage: "hand.raised") + } + Link(destination: Origin.supportURL()) { + Label(AboutWords.text("Support"), systemImage: "questionmark.circle") + } + } header: { + Text(AboutWords.text("Privacy and support")) + } footer: { + Text(AboutWords.text("FTW collects nothing about you. Readings and commands travel encrypted between this phone and your box, and Sourceful cannot read them.")) + } + + Section { + ForEach(LicenseText.Document.allCases) { document in + NavigationLink(value: Route.document(document)) { + VStack(alignment: .leading, spacing: 2) { + Text(document.title) + Text(document.rawValue) + .font(.caption.monospaced()) + .foregroundStyle(Theme.fgMuted) + } + } + } + } header: { + Text(AboutWords.text("Licenses")) + } footer: { + Text(verbatim: "\(AboutWords.text("Third-party code")): \(AboutWords.text("None. FTW uses only Apple's own frameworks."))") + } + + if Self.canRemove(app) { + Section(AboutWords.text("This phone")) { + NavigationLink(value: Route.remove) { + Text(AboutWords.text("Remove this phone")) + .foregroundStyle(Theme.importing) + } + } + } + } + .scrollContentBackground(.hidden) + .background(Theme.surface) + .navigationTitle(AboutWords.text("Source & licenses")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(role: .close) { dismiss() } + .tint(nil) + } + } + .navigationDestination(for: Route.self) { route in + switch route { + case .document(let document): + LicenseView(document: document, source: source) + case .remove: + RemovePhoneView(app: app) { dismiss() } + } + } + } + .tint(Theme.accent) + } + + /// The notices the AGPL asks an interactive program to show: who holds + /// the copyright, that there is no warranty, and where the license is. + private var header: some View { + let stack = typeSize.isAccessibilitySize ? AnyLayout(VStackLayout(alignment: .leading, spacing: 12)) : AnyLayout(HStackLayout(alignment: .top, spacing: 14)) + return stack { + Image(systemName: "bolt.fill") + .font(.title2) + .foregroundStyle(Theme.accent) + .frame(width: markSize, height: markSize) + .background(Color(white: 0.05), in: RoundedRectangle(cornerRadius: markSize * 0.23, style: .continuous)) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 4) { + Text("FTW").font(.title2.weight(.bold)) + Text(AboutWords.text("© 2026 Sourceful Labs AB and contributors.")) + .font(.footnote) + Text(AboutWords.text("There is no warranty, to the extent the law allows.")) + .font(.footnote) + .foregroundStyle(Theme.fgDim) + } + } + .padding(.vertical, 4) + .accessibilityElement(children: .combine) + } +} diff --git a/appleApp/FTW/About/LicenseView.swift b/appleApp/FTW/About/LicenseView.swift new file mode 100644 index 0000000..716eb53 --- /dev/null +++ b/appleApp/FTW/About/LicenseView.swift @@ -0,0 +1,65 @@ +import FTWKit +import SwiftUI + +/// One license file, as the build copied it into the app from the +/// repository root, with its lines joined for a phone's width. +struct LicenseView: View { + let document: LicenseText.Document + let source: BuildSource + private let raw: String? + private let blocks: [LicenseText.Block] + + init(document: LicenseText.Document, source: BuildSource) { + self.document = document + self.source = source + raw = Bundle.main.url(forResource: document.rawValue, withExtension: nil, subdirectory: "Licenses") + .flatMap { try? String(contentsOf: $0, encoding: .utf8) } + blocks = raw.map { LicenseText.blocks($0, markdown: document.isMarkdown) } ?? [] + } + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 12) { + if raw == nil { + Text(AboutWords.text("This text is missing from this build.")) + .foregroundStyle(Theme.importing) + } + ForEach(blocks) { block in + row(block) + } + } + .textSelection(.enabled) + .padding(16) + .frame(maxWidth: 640, alignment: .leading) + .frame(maxWidth: .infinity) + } + .background(Theme.surface) + .navigationTitle(document.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if let raw { + ToolbarItem(placement: .primaryAction) { + ShareLink(item: raw, preview: SharePreview(document.rawValue)) + } + } + } + } + + @ViewBuilder private func row(_ block: LicenseText.Block) -> some View { + switch block.kind { + case .heading(let level): + Text(block.text) + .font(level == 1 ? .title3.weight(.semibold) : .headline) + .foregroundStyle(Theme.fg) + .padding(.top, 8) + .accessibilityAddTraits(.isHeader) + case .rule: + Divider().overlay(Theme.line) + case .paragraph: + Text(document.isMarkdown ? LicenseText.inline(block.text, base: source.documentBaseURL) : AttributedString(block.text)) + .font(.callout) + .foregroundStyle(Theme.fg) + .fixedSize(horizontal: false, vertical: true) + } + } +} diff --git a/appleApp/FTW/About/RemovePhoneView.swift b/appleApp/FTW/About/RemovePhoneView.swift new file mode 100644 index 0000000..2b95d95 --- /dev/null +++ b/appleApp/FTW/About/RemovePhoneView.swift @@ -0,0 +1,67 @@ +import FTWKit +import SwiftUI + +/// Removing this phone: what the app deletes here, and how the owner takes +/// the phone off the box, since no op lets a phone do that itself. +struct RemovePhoneView: View { + let app: AppModel + /// Closes Source & licenses, which has nothing left to show after. + let close: () -> Void + @State private var confirming = false + + var body: some View { + List { + Section { + Text(AboutWords.text("This deletes what FTW keeps on this phone: the key to your home, the pairing, the saved readings and what the widgets show. Your box and everything on it stay as they are.")) + Text(AboutWords.text("FTW has no account with Sourceful. Your box keeps a row for this phone until you remove it there.")) + .foregroundStyle(Theme.fgDim) + } + .font(.callout) + + if let id = app.vault.deviceIDOnBox { + Section { + LabeledContent(AboutWords.text("Your box calls this phone")) { + Text(PhoneRemoval.nameOnBox(id)) + .font(.body.monospaced()) + .foregroundStyle(Theme.fg) + .textSelection(.enabled) + } + ForEach(Array(PhoneRemoval.boxSteps(id).enumerated()), id: \.offset) { index, step in + Label { + Text(step) + } icon: { + Image(systemName: "\(index + 1).circle") + .foregroundStyle(Theme.accent) + } + } + } header: { + Text(AboutWords.text("On your box")) + } footer: { + Text(AboutWords.text("Once it is gone there, the box no longer lets this phone in. Do it before or after the step below.")) + } + } + + Section { + Button(AboutWords.text("Remove from this phone"), role: .destructive) { confirming = true } + // On the button, so the question points at what asked it. + .confirmationDialog(AboutWords.text("Remove this phone?"), isPresented: $confirming, titleVisibility: .visible) { + Button(AboutWords.text("Remove"), role: .destructive) { + close() + app.leave() + } + Button(AboutWords.text("Cancel"), role: .cancel) {} + } message: { + Text(AboutWords.text("FTW forgets your home on this phone. To come back, scan a new pairing QR from your box.")) + } + } header: { + Text(AboutWords.text("On this phone")) + } footer: { + Text(AboutWords.text("If you made a passkey for FTW, it stays in the Passwords app, where you can delete it. Keep it if another device of yours still uses FTW.")) + } + } + .scrollContentBackground(.hidden) + .background(Theme.surface) + .navigationTitle(AboutWords.text("Remove this phone")) + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/appleApp/FTW/AppIcon.icon/Assets/bolt.svg b/appleApp/FTW/AppIcon.icon/Assets/bolt.svg new file mode 100644 index 0000000..54e730e --- /dev/null +++ b/appleApp/FTW/AppIcon.icon/Assets/bolt.svg @@ -0,0 +1,4 @@ + + + + diff --git a/appleApp/FTW/AppIcon.icon/icon.json b/appleApp/FTW/AppIcon.icon/icon.json new file mode 100644 index 0000000..d018b86 --- /dev/null +++ b/appleApp/FTW/AppIcon.icon/icon.json @@ -0,0 +1,45 @@ +{ + "fill-specializations" : [ + { + "value" : { + "solid" : "srgb:0.05098,0.05098,0.05098,1.00000" + } + }, + { + "appearance" : "dark", + "value" : { + "solid" : "srgb:0.00000,0.00000,0.00000,1.00000" + } + } + ], + "groups" : [ + { + "layers" : [ + { + "glass" : true, + "image-name" : "bolt.svg", + "name" : "bolt", + "position" : { + "scale" : 1.1, + "translation-in-points" : [ + 0, + 0 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "specular" : true, + "translucency" : { + "enabled" : true, + "value" : 0.2 + } + } + ], + "supported-platforms" : { + "squares" : "shared" + } +} diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/Contents.json b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 592f5b9..0000000 --- a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "images": [ - { - "filename": "icon-ios-1024.png", - "idiom": "universal", - "platform": "ios", - "size": "1024x1024" - }, - { - "filename": "icon-mac-16@1x.png", - "idiom": "mac", - "scale": "1x", - "size": "16x16" - }, - { - "filename": "icon-mac-16@2x.png", - "idiom": "mac", - "scale": "2x", - "size": "16x16" - }, - { - "filename": "icon-mac-32@1x.png", - "idiom": "mac", - "scale": "1x", - "size": "32x32" - }, - { - "filename": "icon-mac-32@2x.png", - "idiom": "mac", - "scale": "2x", - "size": "32x32" - }, - { - "filename": "icon-mac-128@1x.png", - "idiom": "mac", - "scale": "1x", - "size": "128x128" - }, - { - "filename": "icon-mac-128@2x.png", - "idiom": "mac", - "scale": "2x", - "size": "128x128" - }, - { - "filename": "icon-mac-256@1x.png", - "idiom": "mac", - "scale": "1x", - "size": "256x256" - }, - { - "filename": "icon-mac-256@2x.png", - "idiom": "mac", - "scale": "2x", - "size": "256x256" - }, - { - "filename": "icon-mac-512@1x.png", - "idiom": "mac", - "scale": "1x", - "size": "512x512" - }, - { - "filename": "icon-mac-512@2x.png", - "idiom": "mac", - "scale": "2x", - "size": "512x512" - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} \ No newline at end of file diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-ios-1024.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-ios-1024.png deleted file mode 100644 index 91a152a..0000000 Binary files a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-ios-1024.png and /dev/null differ diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-128@1x.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-128@1x.png deleted file mode 100644 index e548c7e..0000000 Binary files a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-128@1x.png and /dev/null differ diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-128@2x.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-128@2x.png deleted file mode 100644 index 5bd332f..0000000 Binary files a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-128@2x.png and /dev/null differ diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-16@1x.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-16@1x.png deleted file mode 100644 index dd8790b..0000000 Binary files a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-16@1x.png and /dev/null differ diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-16@2x.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-16@2x.png deleted file mode 100644 index e8a13a2..0000000 Binary files a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-16@2x.png and /dev/null differ diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-256@1x.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-256@1x.png deleted file mode 100644 index 5bd332f..0000000 Binary files a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-256@1x.png and /dev/null differ diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-256@2x.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-256@2x.png deleted file mode 100644 index 42decea..0000000 Binary files a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-256@2x.png and /dev/null differ diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-32@1x.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-32@1x.png deleted file mode 100644 index e8a13a2..0000000 Binary files a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-32@1x.png and /dev/null differ diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-32@2x.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-32@2x.png deleted file mode 100644 index 652376e..0000000 Binary files a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-32@2x.png and /dev/null differ diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-512@1x.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-512@1x.png deleted file mode 100644 index 42decea..0000000 Binary files a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-512@1x.png and /dev/null differ diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-512@2x.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-512@2x.png deleted file mode 100644 index 0b759c2..0000000 Binary files a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-512@2x.png and /dev/null differ diff --git a/appleApp/FTW/Assets.xcassets/LaunchBackground.colorset/Contents.json b/appleApp/FTW/Assets.xcassets/LaunchBackground.colorset/Contents.json new file mode 100644 index 0000000..4cd9661 --- /dev/null +++ b/appleApp/FTW/Assets.xcassets/LaunchBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF2", + "green" : "0xF4", + "red" : "0xF4" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x0D", + "green" : "0x0D", + "red" : "0x0D" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/appleApp/FTW/PrivacyInfo.xcprivacy b/appleApp/FTW/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..dba570d --- /dev/null +++ b/appleApp/FTW/PrivacyInfo.xcprivacy @@ -0,0 +1,31 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + + diff --git a/appleApp/FTW/UI/NowView.swift b/appleApp/FTW/UI/NowView.swift index 78c2797..30148b7 100644 --- a/appleApp/FTW/UI/NowView.swift +++ b/appleApp/FTW/UI/NowView.swift @@ -42,6 +42,7 @@ struct NowView: View { home.prices.deactivate() home.savings.deactivate() } + AboutLink(app: app) } /// Paired, and not one reading has ever arrived. A hollow house would diff --git a/appleApp/FTW/UI/PairView.swift b/appleApp/FTW/UI/PairView.swift index 8942377..b1e1c67 100644 --- a/appleApp/FTW/UI/PairView.swift +++ b/appleApp/FTW/UI/PairView.swift @@ -22,6 +22,7 @@ struct PairView: View { ScrollView { VStack(alignment: .leading, spacing: 16) { content + AboutLink(app: app) } .padding(20) .frame(maxWidth: 520, alignment: .leading) diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/AboutWords.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/AboutWords.swift new file mode 100644 index 0000000..d369896 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/AboutWords.swift @@ -0,0 +1,93 @@ +import Foundation + +/// The words of Source & licenses and of removing this phone, in English or +/// Swedish as the phone prefers. Keyed by the English, so a missing entry +/// shows English rather than nothing; a test reads the views and fails on +/// any sentence without a Swedish entry. +public enum AboutWords { + public enum Language: String, Sendable { + case en, sv + + /// The phone's choice among the two the app speaks. + public static var preferred: Language { + Bundle.preferredLocalizations(from: ["en", "sv"]).first == "sv" ? .sv : .en + } + } + + @TaskLocal public static var language: Language = .preferred + + public static func text(_ en: String) -> String { + language == .sv ? (sv[en] ?? en) : en + } + + /// `%@` in order, replaced with ready text. + public static func format(_ en: String, _ args: String...) -> String { + var out = text(en) + for arg in args { + guard let r = out.range(of: "%@") else { break } + out.replaceSubrange(r, with: arg) + } + return out + } + + static let sv: [String: String] = [ + // Source & licenses + "Source & licenses": "Källkod och licenser", + "Done": "Klar", + "This version": "Den här versionen", + "Version": "Version", + "Build": "Bygge", + "Commit": "Commit", + "Local build": "Lokalt bygge", + "Source code": "Källkod", + "Source code of this version": "Källkoden till den här versionen", + "Source code on GitHub": "Källkoden på GitHub", + "License at this commit": "Licensen i den här commiten", + "License on GitHub": "Licensen på GitHub", + "FTW is free software. You may run, study, change and share it under the GNU AGPL v3 with the two additional permissions in LICENSE. The link gives the complete source of this exact version, at no charge.": + "FTW är fri programvara. Du får köra, studera, ändra och dela den enligt GNU AGPL v3 med de två extra tillstånden i LICENSE. Länken ger hela källkoden till exakt den här versionen, utan kostnad.", + "Built from uncommitted changes on top of %@. The links go to the repository, which does not hold these changes.": + "Byggd från ändringar som inte är incheckade, ovanpå %@. Länkarna går till repot, som inte har de här ändringarna.", + "Built without a known commit. The links go to the repository, which may not hold this exact code.": + "Byggd utan känd commit. Länkarna går till repot, som kanske inte har exakt den här koden.", + "Licenses": "Licenser", + "License": "Licens", + "Notices": "Notiser", + "Licensing in plain words": "Licensen i klartext", + "Third-party code": "Tredjepartskod", + "None. FTW uses only Apple's own frameworks.": "Ingen. FTW använder bara Apples egna ramverk.", + "© 2026 Sourceful Labs AB and contributors.": "© 2026 Sourceful Labs AB och bidragsgivare.", + "There is no warranty, to the extent the law allows.": "Det finns ingen garanti, så långt lagen tillåter.", + "This text is missing from this build.": "Texten saknas i det här bygget.", + "Privacy": "Integritet", + "Privacy policy": "Integritetspolicy", + "Support": "Support", + "Privacy and support": "Integritet och support", + "FTW collects nothing about you. Readings and commands travel encrypted between this phone and your box, and Sourceful cannot read them.": + "FTW samlar inte in något om dig. Mätvärden och kommandon går krypterade mellan telefonen och boxen, och Sourceful kan inte läsa dem.", + + // Removing this phone + "This phone": "Den här telefonen", + "Remove this phone": "Ta bort den här telefonen", + "This deletes what FTW keeps on this phone: the key to your home, the pairing, the saved readings and what the widgets show. Your box and everything on it stay as they are.": + "Det här raderar det FTW sparar på telefonen: nyckeln till ditt hem, parkopplingen, sparade mätvärden och det widgetarna visar. Boxen och allt på den blir kvar som det är.", + "FTW has no account with Sourceful. Your box keeps a row for this phone until you remove it there.": + "FTW har inget konto hos Sourceful. Boxen har kvar en rad för telefonen tills du tar bort den där.", + "On your box": "På boxen", + "Your box calls this phone": "Boxen kallar telefonen", + "Open your box's local dashboard while on your home network.": "Öppna boxens lokala sida medan du är på hemnätverket.", + "Go to Settings → FTW app.": "Gå till Settings → FTW app.", + "Choose Remove next to %@.": "Välj Remove bredvid %@.", + "Once it is gone there, the box no longer lets this phone in. Do it before or after the step below.": + "När den är borta där släpper boxen inte längre in telefonen. Gör det före eller efter steget nedan.", + "On this phone": "På telefonen", + "Remove from this phone": "Ta bort från telefonen", + "If you made a passkey for FTW, it stays in the Passwords app, where you can delete it. Keep it if another device of yours still uses FTW.": + "Om du skapade en lösennyckel för FTW finns den kvar i appen Lösenord, där du kan radera den. Behåll den om en annan av dina enheter fortfarande använder FTW.", + "Remove this phone?": "Ta bort den här telefonen?", + "FTW forgets your home on this phone. To come back, scan a new pairing QR from your box.": + "FTW glömmer ditt hem på telefonen. För att komma tillbaka skannar du en ny QR-kod för parkoppling från boxen.", + "Remove": "Ta bort", + "Cancel": "Avbryt", + ] +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/BuildSource.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/BuildSource.swift new file mode 100644 index 0000000..d50adcf --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/BuildSource.swift @@ -0,0 +1,93 @@ +import Foundation + +/// What this build was made from, and where its source is. +/// +/// The AGPL and the app store permission in LICENSE ask that the license and +/// an offer of the exact version's source be reachable in the app. The build +/// writes its commit into the bundle (the "Record the source revision" phase +/// in the Xcode project), and this turns that record into the links and the +/// words Source & licenses shows. +public struct BuildSource: Equatable, Sendable { + public enum Revision: Equatable, Sendable { + /// A clean tree at a known commit. The links pin it. + case commit(String) + /// Uncommitted changes on top of `base`, or no commit known at all. + /// The links go to the repository, which may not hold this code. + case local(base: String?) + } + + public let version: String + public let build: String + public let revision: Revision + + /// What the build phase wrote: 40 hex digits for a clean tree, the same + /// with `-dirty` for uncommitted changes, and nothing when no commit was + /// known. Anything else is a local build too. + public init(version: String, build: String, recorded: String?) { + self.version = version + self.build = build + let text = (recorded ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if Self.isCommit(text) { + revision = .commit(text) + } else if text.hasSuffix(Self.dirtySuffix), Self.isCommit(String(text.dropLast(Self.dirtySuffix.count))) { + revision = .local(base: String(text.dropLast(Self.dirtySuffix.count))) + } else { + revision = .local(base: nil) + } + } + + static let dirtySuffix = "-dirty" + + static func isCommit(_ text: String) -> Bool { + text.count == 40 && text.allSatisfy { $0.isHexDigit && !$0.isUppercase } + } + + public var commit: String? { + if case .commit(let sha) = revision { return sha } + return nil + } + + public var isLocal: Bool { commit == nil } + + /// Seven characters, as GitHub shows a commit. + public static func short(_ sha: String) -> String { String(sha.prefix(7)) } + + /// The tree at this commit, whose Code menu offers the archive. A local + /// build gets the repository. + public var sourceURL: URL { + guard let commit else { return Origin.sourceURL } + return Origin.sourceURL.appending(path: "tree/\(commit)") + } + + /// The license as it stood at this commit, not on the moving branch. + public var licenseURL: URL { + Origin.sourceURL.appending(path: "blob/\(commit ?? "main")/LICENSE") + } + + /// Where a relative link in LICENSING.md points, at the same revision. + public var documentBaseURL: URL { + Origin.sourceURL.appending(path: "blob/\(commit ?? "main")/") + } + + /// "0.1.0 (1)", as Settings shows an app's version. + public var versionText: String { "\(version) (\(build))" } + + /// The commit as the screen names it. + public var revisionText: String { + switch revision { + case .commit(let sha): return Self.short(sha) + case .local: return AboutWords.text("Local build") + } + } + + /// Why the links do not pin this build, or nil when they do. + public var localNote: String? { + switch revision { + case .commit: return nil + case .local(let base?): + return AboutWords.format("Built from uncommitted changes on top of %@. The links go to the repository, which does not hold these changes.", Self.short(base)) + case .local(nil): + return AboutWords.text("Built without a known commit. The links go to the repository, which may not hold this exact code.") + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/LicenseText.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/LicenseText.swift new file mode 100644 index 0000000..39fda9a --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/LicenseText.swift @@ -0,0 +1,155 @@ +import Foundation + +/// The license files as a phone reads them. +/// +/// The files are wrapped at about 72 columns for a terminal. Shown as they +/// are, every line breaks twice on a phone: once where the file breaks it and +/// once where the screen does. This joins the lines of a paragraph and keeps +/// every word in order; it changes line breaks, never text. +public enum LicenseText { + /// The three files the build copies into the app from the repository + /// root. Nothing here is a copy that could drift from the original. + public enum Document: String, CaseIterable, Sendable, Identifiable { + case license = "LICENSE" + case notice = "NOTICE" + case licensing = "LICENSING.md" + + public var id: String { rawValue } + + public var isMarkdown: Bool { self == .licensing } + + public var title: String { + switch self { + case .license: return AboutWords.text("License") + case .notice: return AboutWords.text("Notices") + case .licensing: return AboutWords.text("Licensing in plain words") + } + } + } + + public struct Block: Equatable, Sendable, Identifiable { + public enum Kind: Equatable, Sendable { + /// 1 is a document's title, 2 a section. + case heading(Int) + case paragraph + /// A line of dashes or equals signs between parts. + case rule + } + + public let id: Int + public let kind: Kind + /// Lines that start a list item or end short in the file keep their + /// break as "\n". Every other break is a space. + public let text: String + } + + public static func blocks(_ text: String, markdown: Bool) -> [Block] { + let lines = text.replacingOccurrences(of: "\r\n", with: "\n").components(separatedBy: "\n") + // The column the file wraps at, so a line that ends well short of it + // is known to end there on purpose. + let width = min(80, lines.map(\.count).max() ?? 0) + var out: [Block] = [] + var paragraph: [String] = [] + + func add(_ kind: Block.Kind, _ text: String) { + out.append(Block(id: out.count, kind: kind, text: text)) + } + + func flush() { + guard !paragraph.isEmpty else { return } + defer { paragraph = [] } + if !markdown, paragraph.count == 1, let level = plainHeading(paragraph[0]) { + add(.heading(level), paragraph[0].trimmingCharacters(in: .whitespaces)) + return + } + // A title block: lines set in the middle of the page, or a + // name in capitals over a short line, such as the license's + // name over its version. + let titled = paragraph.count == 2 && plainHeading(paragraph[0]) == 1 && paragraph[1].trimmingCharacters(in: .whitespaces).count <= 40 + if !markdown, paragraph.count > 1, titled || paragraph.allSatisfy(isCentred) { + add(.heading(1), paragraph.map { $0.trimmingCharacters(in: .whitespaces) }.joined(separator: "\n")) + return + } + add(.paragraph, join(paragraph, width: width)) + } + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { + flush() + } else if isRule(trimmed) { + flush() + add(.rule, "") + } else if markdown, let (level, title) = markdownHeading(trimmed) { + flush() + add(.heading(level), title) + } else { + paragraph.append(line) + } + } + flush() + return out + } + + /// A Markdown paragraph with its links made absolute against `base`, so + /// LICENSING.md's `[LICENSE](LICENSE)` opens the file at the build's + /// commit rather than nowhere. Plain text when it does not parse. + public static func inline(_ text: String, base: URL) -> AttributedString { + guard var out = try? AttributedString(markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace), baseURL: base) else { + return AttributedString(text) + } + for run in out.runs { + if let link = run.link { + out[run.range].link = link.absoluteURL + } + } + return out + } + + /// Joins a paragraph's lines. A line keeps its break when the next one + /// starts a list item, or when it ends well short of the wrap column, + /// as an address or a title over a line does. + static func join(_ lines: [String], width: Int) -> String { + var out = "" + for (i, raw) in lines.enumerated() { + let line = raw.trimmingCharacters(in: .whitespaces) + if i > 0 { + let short = lines[i - 1].count < width - 20 + out += startsItem(line) || short ? "\n" : " " + } + out += line + } + return out + } + + static func startsItem(_ line: String) -> Bool { + line.hasPrefix("- ") || line.hasPrefix("* ") || line.hasPrefix("• ") + || line.range(of: #"^(\(?[a-z0-9]{1,3}\)|[0-9]{1,2}\.) "#, options: .regularExpression) != nil + } + + static func isRule(_ line: String) -> Bool { + line.count >= 3 && Set(line).count == 1 && (line.first == "-" || line.first == "=" || line.first == "_") + } + + static func markdownHeading(_ line: String) -> (Int, String)? { + let hashes = line.prefix(while: { $0 == "#" }).count + guard (1...6).contains(hashes), line.dropFirst(hashes).first == " " else { return nil } + return (min(hashes, 2), String(line.dropFirst(hashes + 1))) + } + + static func isCentred(_ line: String) -> Bool { + line.prefix(while: { $0 == " " }).count >= 8 && line.trimmingCharacters(in: .whitespaces).count <= 60 + } + + /// In plain text a title is a line on its own: set in capitals, centred + /// with spaces, or a numbered section such as "0. Definitions." + static func plainHeading(_ line: String) -> Int? { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.count <= 60 else { return nil } + let letters = trimmed.filter(\.isLetter) + if !letters.isEmpty, letters == letters.uppercased(), letters.count >= 4 { return 1 } + if isCentred(line) { return 1 } + if trimmed.range(of: #"^[0-9]{1,2}\. [A-Z][^.]*\.$"#, options: .regularExpression) != nil { return 2 } + return nil + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/Origin.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/Origin.swift index 8afd8bc..937eec1 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Identity/Origin.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/Origin.swift @@ -15,7 +15,22 @@ public enum Origin { /// The relay. One value, no setting. public static let relayURL = URL(string: "wss://relay.ftw.energy")! - /// Source and licence, as the AGPL asks a network client to offer them. + /// The repository. A fork changes this line, as LICENSING.md asks: the + /// links in Source & licenses must lead to the fork's own source. + /// `BuildSource` pins them to the build's commit. public static let sourceURL = URL(string: "https://github.com/srcfl/ftw-app")! - public static let licenseURL = URL(string: "https://github.com/srcfl/ftw-app/blob/main/LICENSE")! + + /// The privacy policy, which App Review asks to be easy to find in the + /// app, and the support page beside it. Swedish where the app speaks it. + public static func privacyURL(_ language: AboutWords.Language = AboutWords.language) -> URL { + site(language, "privacy") + } + + public static func supportURL(_ language: AboutWords.Language = AboutWords.language) -> URL { + site(language, "support") + } + + private static func site(_ language: AboutWords.Language, _ page: String) -> URL { + URL(string: "https://ftw.energy/\(language == .sv ? "sv/" : "")\(page)")! + } } diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/PhoneRemoval.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/PhoneRemoval.swift new file mode 100644 index 0000000..de7b472 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/PhoneRemoval.swift @@ -0,0 +1,27 @@ +import Foundation + +/// Removing this phone, as far as the phone can reach. +/// +/// The registry has no op that lets a phone take itself off its box, so the +/// app does what is in its own hands, `AppModel.leave`: the key, the pairing, +/// the sealed readings and the widgets' records go. It then says how the +/// owner removes the phone on the box. That is also the one way that always +/// works: over a session the box refuses to remove its last owner, and on +/// its own page it allows it. +/// +/// FTW creates no account. A pairing is a key on this phone and a row on the +/// household's own box, and this is where both are dealt with. +public enum PhoneRemoval { + /// The row on the box's Settings → FTW app page, which names each phone + /// by the first eight characters of its key (`Vault.deviceIDOnBox`). + /// The box's page is in English, so the name is too. + public static func nameOnBox(_ deviceID: String) -> String { "Phone \(deviceID)" } + + public static func boxSteps(_ deviceID: String) -> [String] { + [ + AboutWords.text("Open your box's local dashboard while on your home network."), + AboutWords.text("Go to Settings → FTW app."), + AboutWords.format("Choose Remove next to %@.", nameOnBox(deviceID)), + ] + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/AboutTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/AboutTests.swift new file mode 100644 index 0000000..a51a89c --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/AboutTests.swift @@ -0,0 +1,202 @@ +import Foundation +import Testing +@testable import FTWKit + +/// Source & licenses and removing this phone: what the license asks the app +/// to offer, and the words it uses to offer it. +@Suite struct AboutTests { + static let sha = "5a14ff0960d99fa0ca982c82fb2b7c1d5e213d25" + + // MARK: The build's source + + @Test func aCleanCommitPinsBothLinks() { + let source = BuildSource(version: "0.1.0", build: "7", recorded: Self.sha + "\n") + #expect(source.revision == .commit(Self.sha)) + #expect(source.sourceURL.absoluteString == "https://github.com/srcfl/ftw-app/tree/\(Self.sha)") + #expect(source.licenseURL.absoluteString == "https://github.com/srcfl/ftw-app/blob/\(Self.sha)/LICENSE") + #expect(source.documentBaseURL.absoluteString == "https://github.com/srcfl/ftw-app/blob/\(Self.sha)/") + #expect(source.versionText == "0.1.0 (7)") + #expect(source.revisionText == "5a14ff0") + #expect(source.localNote == nil) + } + + @Test func uncommittedChangesAreALocalBuildOnTheirBase() { + let source = BuildSource(version: "0.1.0", build: "1", recorded: Self.sha + "-dirty") + #expect(source.revision == .local(base: Self.sha)) + #expect(source.isLocal) + #expect(source.sourceURL == Origin.sourceURL) + #expect(source.licenseURL.absoluteString == "https://github.com/srcfl/ftw-app/blob/main/LICENSE") + AboutWords.$language.withValue(.en) { + #expect(source.revisionText == "Local build") + #expect(source.localNote?.contains("5a14ff0") == true) + } + AboutWords.$language.withValue(.sv) { + #expect(source.revisionText == "Lokalt bygge") + } + } + + /// Only 40 lower-case hex digits name a commit. Anything else, including + /// nothing at all, links to the repository and says so. + @Test(arguments: [nil, "", "dev", "5a14ff0", Self.sha.uppercased(), Self.sha + "0", "g" + Self.sha.dropFirst(), "-dirty"]) + func anythingElseIsALocalBuild(_ recorded: String?) { + let source = BuildSource(version: "0.1.0", build: "1", recorded: recorded) + #expect(source.revision == .local(base: nil)) + #expect(source.sourceURL == Origin.sourceURL) + #expect(source.localNote != nil) + } + + /// App Review asks for the privacy policy inside the app. The pages are + /// srcfl/ftw-web's, in English and Swedish. + @Test func privacyAndSupportFollowTheLanguage() { + #expect(Origin.privacyURL(.en).absoluteString == "https://ftw.energy/privacy") + #expect(Origin.privacyURL(.sv).absoluteString == "https://ftw.energy/sv/privacy") + #expect(Origin.supportURL(.en).absoluteString == "https://ftw.energy/support") + #expect(Origin.supportURL(.sv).absoluteString == "https://ftw.energy/sv/support") + AboutWords.$language.withValue(.sv) { + #expect(Origin.privacyURL() == Origin.privacyURL(.sv)) + } + } + + // MARK: The license files + + /// Joining lines must never change the text: every word of every file, + /// in order, and nothing else. + @Test(arguments: LicenseText.Document.allCases) + func reflowKeepsEveryWord(_ document: LicenseText.Document) throws { + let text = try RepoFiles.read(document.rawValue) + let blocks = LicenseText.blocks(text, markdown: document.isMarkdown) + let original = text.split(whereSeparator: \.isNewline) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !LicenseText.isRule($0) } + .map { document.isMarkdown ? $0.replacingOccurrences(of: #"^#{1,6} "#, with: "", options: .regularExpression) : $0 } + .flatMap { $0.split(whereSeparator: \.isWhitespace) } + let shown = blocks.flatMap { $0.text.split(whereSeparator: \.isWhitespace) } + #expect(shown == original) + #expect(!blocks.isEmpty) + } + + @Test func theLicenseReadsAsParagraphsAndSections() throws { + let blocks = LicenseText.blocks(try RepoFiles.read("LICENSE"), markdown: false) + // The heading over the file stays two lines. + #expect(blocks.first?.text == "FTW native app\nCopyright 2026 Sourceful Labs AB and contributors.") + // A wrapped paragraph becomes one line the screen wraps itself. + let opening = try #require(blocks.first { $0.text.hasPrefix("This version of the project, as a whole") }) + #expect(!opening.text.contains("\n")) + #expect(blocks.contains { $0.kind == .heading(2) && $0.text == "0. Definitions." }) + #expect(blocks.contains { $0.kind == .heading(1) && $0.text == "TERMS AND CONDITIONS" }) + #expect(blocks.contains { $0.kind == .heading(1) && $0.text == "GNU AFFERO GENERAL PUBLIC LICENSE\nVersion 3, 19 November 2007" }) + #expect(blocks.contains { $0.kind == .rule }) + // Both of the license's own permissions are there. + #expect(blocks.contains { $0.text.contains("FTW app store distribution permission") } || blocks.contains { $0.text.contains("Energyplan") }) + // A list keeps one item to a line. + let items = blocks.filter { $0.text.contains("\n- ") } + for block in items { + for line in block.text.split(separator: "\n").dropFirst() { + #expect(!line.hasPrefix(" ")) + } + } + } + + @Test func licensingMarkdownHasItsHeadings() throws { + let blocks = LicenseText.blocks(try RepoFiles.read("LICENSING.md"), markdown: true) + #expect(blocks.first == LicenseText.Block(id: 0, kind: .heading(1), text: "FTW native app licensing")) + #expect(blocks.contains { $0.kind == .heading(2) && $0.text == "Source for a distributed version" }) + #expect(!blocks.contains { $0.text.hasPrefix("#") }) + } + + @Test func relativeLinksOpenTheFileAtTheBuildsCommit() throws { + let source = BuildSource(version: "0.1.0", build: "1", recorded: Self.sha) + let text = LicenseText.inline("See [LICENSE](LICENSE) and [the vision](https://github.com/srcfl/ftw/blob/master/VISION.md).", base: source.documentBaseURL) + let links = text.runs.compactMap(\.link).map(\.absoluteString) + #expect(links == ["https://github.com/srcfl/ftw-app/blob/\(Self.sha)/LICENSE", "https://github.com/srcfl/ftw/blob/master/VISION.md"]) + #expect(String(text.characters) == "See LICENSE and the vision.") + } + + @Test func listsAndShortLinesKeepTheirBreaks() { + let text = """ + All other conditions of the GNU AGPL remain in force. In particular: + + - Offer the Corresponding Source of the exact version you convey, under the + GNU AGPL and at no charge, outside the service and free of its terms. + - Make the license text and that source offer reachable from within the + program. + """ + let blocks = LicenseText.blocks(text, markdown: false) + #expect(blocks.count == 2) + #expect(blocks[1].text == "- Offer the Corresponding Source of the exact version you convey, under the GNU AGPL and at no charge, outside the service and free of its terms.\n- Make the license text and that source offer reachable from within the program.") + } + + // MARK: Third-party code + + /// Source & licenses says FTW carries no third-party code. That stays + /// true only while neither the package nor the project pulls any in; add + /// one and this fails until the screen lists it with its license. + @Test func thereIsNoThirdPartyCode() throws { + let package = try RepoFiles.read("appleApp/FTWKit/Package.swift") + #expect(!package.contains(".package(")) + let project = try RepoFiles.read("appleApp/FTW.xcodeproj/project.pbxproj") + #expect(!project.contains("XCRemoteSwiftPackageReference")) + } + + // MARK: Removing this phone + + @Test func theBoxNamesThePhoneByItsKey() throws { + let vectors = try IdentityVectors.load() + let name = PhoneRemoval.nameOnBox(vectors.enrollment.deviceIdOnBox) + #expect(name == "Phone \(vectors.enrollment.deviceIdOnBox)") + AboutWords.$language.withValue(.en) { + #expect(PhoneRemoval.boxSteps("abcd1234").last == "Choose Remove next to Phone abcd1234.") + } + AboutWords.$language.withValue(.sv) { + #expect(PhoneRemoval.boxSteps("abcd1234").last == "Välj Remove bredvid Phone abcd1234.") + } + } + + @MainActor + @Test func theNameOnTheBoxIsTheVaultsOwn() async throws { + let store = MemoryStore() + let vault = Vault(store: store) + #expect(vault.deviceIDOnBox == nil) + _ = try vault.deviceKey(vault.localWrappingKey()) + let id = try #require(vault.deviceIDOnBox) + #expect(id.count == Vault.boxDeviceIDChars) + #expect(PhoneRemoval.nameOnBox(id).hasSuffix(id)) + } + + // MARK: Words + + /// Every sentence the About screens hand to `AboutWords` has a Swedish + /// entry. Read from the source, so a new sentence cannot slip through. + @Test func everySentenceHasSwedish() throws { + let files = [ + "appleApp/FTWKit/Sources/FTWKit/Identity/BuildSource.swift", + "appleApp/FTWKit/Sources/FTWKit/Identity/LicenseText.swift", + "appleApp/FTWKit/Sources/FTWKit/Identity/PhoneRemoval.swift", + ] + (try RepoFiles.list("appleApp/FTW/About")) + let pattern = #/AboutWords\.(?:text|format)\("((?:[^"\\]|\\.)*)"/# + var seen = 0 + for file in files { + let source = try RepoFiles.read(file) + for match in source.matches(of: pattern) { + let english = String(match.1).replacingOccurrences(of: "\\\"", with: "\"") + seen += 1 + #expect(AboutWords.sv[english] != nil, "no Swedish for \"\(english)\" in \(file)") + } + } + #expect(seen > 20) + for (english, swedish) in AboutWords.sv { + #expect(!swedish.isEmpty) + #expect(english.components(separatedBy: "%@").count == swedish.components(separatedBy: "%@").count, "\(english)") + } + } +} + +extension RepoFiles { + /// The Swift files in a folder of the repository, as paths from its root. + static func list(_ folder: String) throws -> [String] { + try FileManager.default.contentsOfDirectory(atPath: root.appendingPathComponent(folder).path) + .filter { $0.hasSuffix(".swift") } + .sorted() + .map { "\(folder)/\($0)" } + } +} diff --git a/appleApp/FTWWidgets/PrivacyInfo.xcprivacy b/appleApp/FTWWidgets/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..64e785c --- /dev/null +++ b/appleApp/FTWWidgets/PrivacyInfo.xcprivacy @@ -0,0 +1,23 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + +