diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1090c2f..91ed621 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: | grep -E "error:|warning: .*\.swift|Test Case .*(passed|failed)|Test .*(passed|failed) after|Executed [0-9]+ tests?|\*\* TEST" # A run that executes no tests also ends in TEST SUCCEEDED. Read the counts from the result bundle. - # 209 = 208 unit tests (the live-server tests count while skipped without TGARCHIVE_DEMO_URL) plus the + # 219 = 218 unit tests (the live-server tests count while skipped without TGARCHIVE_DEMO_URL) plus the # launch UI test; raise it when a stage adds tests. - name: Gate on the test count run: | @@ -59,7 +59,7 @@ jobs: s = json.load(sys.stdin) total, failed = s.get("totalTestCount", 0), s.get("failedTests", 0) print(f"totalTestCount={total} failedTests={failed}") - sys.exit(0 if total >= 209 and failed == 0 else 1) + sys.exit(0 if total >= 219 and failed == 0 else 1) ' - name: Build the Release configuration diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9dc66a8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,170 @@ +name: Release to TestFlight + +# Push a tag vX.Y.Z that matches MARKETING_VERSION in project.yml, on a commit of main whose CI passed. +# Each upload needs a CURRENT_PROJECT_VERSION higher than the last one App Store Connect received. +on: + push: + tags: ["v*"] + +permissions: + contents: read + actions: read + +concurrency: + group: release + cancel-in-progress: false + +jobs: + testflight: + name: Archive and upload + runs-on: macos-latest + timeout-minutes: 60 + env: + TEAM_ID: 624WUVM8B4 + BUNDLE_ID: io.github.geiserx.tgarchive + APP_PROFILE_NAME: TG Archive App Store + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Check the tag is on main and CI passed there + env: + GH_TOKEN: ${{ github.token }} + run: | + git fetch --no-tags origin main + if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then + echo "::error::$GITHUB_REF_NAME points at $GITHUB_SHA, which is not on main" + exit 1 + fi + conclusion=$(gh run list --repo "$GITHUB_REPOSITORY" --workflow ci.yml --branch main \ + --commit "$GITHUB_SHA" --json conclusion --jq '.[0].conclusion // "none"') + if [ "$conclusion" != success ]; then + echo "::error::CI on main for $GITHUB_SHA is '$conclusion', not success" + exit 1 + fi + + - name: Check the tag matches project.yml + run: | + VERSION=$(awk '/^ *MARKETING_VERSION:/ {print $2; exit}' project.yml) + if [ "v$VERSION" != "$GITHUB_REF_NAME" ]; then + echo "::error::Tag $GITHUB_REF_NAME does not match MARKETING_VERSION $VERSION in project.yml" + exit 1 + fi + + - name: Check the signing secrets exist + env: + APPSTORE_ISSUER_ID: ${{ secrets.APPSTORE_ISSUER_ID }} + APPSTORE_KEY_ID: ${{ secrets.APPSTORE_KEY_ID }} + APPSTORE_PRIVATE_KEY: ${{ secrets.APPSTORE_PRIVATE_KEY }} + DIST_CERTIFICATE_P12: ${{ secrets.DIST_CERTIFICATE_P12 }} + DIST_CERTIFICATE_PASSWORD: ${{ secrets.DIST_CERTIFICATE_PASSWORD }} + PROFILE_APP: ${{ secrets.PROFILE_APP }} + run: | + missing=0 + for name in APPSTORE_ISSUER_ID APPSTORE_KEY_ID APPSTORE_PRIVATE_KEY DIST_CERTIFICATE_P12 \ + DIST_CERTIFICATE_PASSWORD PROFILE_APP; do + if [ -z "${!name}" ]; then echo "::error::Secret $name is not set"; missing=1; fi + done + exit $missing + + - name: Install the distribution certificate + env: + DIST_CERTIFICATE_P12: ${{ secrets.DIST_CERTIFICATE_P12 }} + DIST_CERTIFICATE_PASSWORD: ${{ secrets.DIST_CERTIFICATE_PASSWORD }} + run: | + KEYCHAIN="$RUNNER_TEMP/signing.keychain-db" + KEYCHAIN_PASSWORD=$(openssl rand -hex 24) + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + security set-keychain-settings -lut 3600 "$KEYCHAIN" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + printf '%s' "$DIST_CERTIFICATE_P12" | base64 --decode > "$RUNNER_TEMP/dist.p12" + security import "$RUNNER_TEMP/dist.p12" -k "$KEYCHAIN" -P "$DIST_CERTIFICATE_PASSWORD" \ + -T /usr/bin/codesign -T /usr/bin/security + rm -f "$RUNNER_TEMP/dist.p12" + security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" > /dev/null + # Put the new keychain first and keep the existing ones searchable (one path per word). + existing=$(security list-keychains -d user | tr -d '"' | xargs) + # shellcheck disable=SC2086 + security list-keychains -d user -s "$KEYCHAIN" $existing + security find-identity -v -p codesigning "$KEYCHAIN" + + - name: Install the provisioning profile + env: + PROFILE_APP: ${{ secrets.PROFILE_APP }} + run: | + printf '%s' "$PROFILE_APP" | base64 --decode > "$RUNNER_TEMP/profile.mobileprovision" + security cms -D -i "$RUNNER_TEMP/profile.mobileprovision" > "$RUNNER_TEMP/profile.plist" + name=$(plutil -extract Name raw "$RUNNER_TEMP/profile.plist") + if [ "$name" != "$APP_PROFILE_NAME" ]; then + echo "::error::PROFILE_APP is the profile '$name', not '$APP_PROFILE_NAME'" + exit 1 + fi + uuid=$(plutil -extract UUID raw "$RUNNER_TEMP/profile.plist") + for dir in "$HOME/Library/MobileDevice/Provisioning Profiles" \ + "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles"; do + mkdir -p "$dir" + cp "$RUNNER_TEMP/profile.mobileprovision" "$dir/$uuid.mobileprovision" + done + rm -f "$RUNNER_TEMP/profile.mobileprovision" "$RUNNER_TEMP/profile.plist" + + - name: Install XcodeGen and generate the project + run: | + brew install xcodegen + xcodegen generate + + - name: Archive + run: | + set -o pipefail + xcodebuild archive \ + -project TGArchive.xcodeproj -scheme TGArchive -configuration Release \ + -destination "generic/platform=iOS" \ + -archivePath "$RUNNER_TEMP/TGArchive.xcarchive" \ + | grep -E "error:|warning: .*\.swift|\*\* ARCHIVE" + + - name: Export the IPA + run: | + cat > "$RUNNER_TEMP/ExportOptions.plist" < + + + + methodapp-store-connect + destinationexport + teamID${TEAM_ID} + signingStylemanual + signingCertificateApple Distribution + provisioningProfiles + + ${BUNDLE_ID}${APP_PROFILE_NAME} + + uploadSymbols + + + PLIST + xcodebuild -exportArchive \ + -archivePath "$RUNNER_TEMP/TGArchive.xcarchive" \ + -exportOptionsPlist "$RUNNER_TEMP/ExportOptions.plist" \ + -exportPath "$RUNNER_TEMP/export" + ls -la "$RUNNER_TEMP/export" + + - name: Upload to TestFlight + env: + APPSTORE_ISSUER_ID: ${{ secrets.APPSTORE_ISSUER_ID }} + APPSTORE_KEY_ID: ${{ secrets.APPSTORE_KEY_ID }} + APPSTORE_PRIVATE_KEY: ${{ secrets.APPSTORE_PRIVATE_KEY }} + run: | + mkdir -p "$HOME/.appstoreconnect/private_keys" + KEY="$HOME/.appstoreconnect/private_keys/AuthKey_${APPSTORE_KEY_ID}.p8" + printf '%s\n' "$APPSTORE_PRIVATE_KEY" > "$KEY" + chmod 600 "$KEY" + xcrun altool --upload-app -t ios -f "$RUNNER_TEMP/export/TGArchive.ipa" \ + --apiKey "$APPSTORE_KEY_ID" --apiIssuer "$APPSTORE_ISSUER_ID" + + - name: Remove signing material + if: always() + run: | + security delete-keychain "$RUNNER_TEMP/signing.keychain-db" 2>/dev/null || true + rm -rf "$HOME/.appstoreconnect/private_keys" + rm -f "$HOME/Library/MobileDevice/Provisioning Profiles/"*.mobileprovision + rm -f "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles/"*.mobileprovision diff --git a/README.md b/README.md index 452dcdb..23a2132 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,63 @@ TG-Archive-iOS -The read-only iOS client for a self-hosted [Telegram-Archive](https://github.com/GeiserX/Telegram-Archive) backup. Point it at your own viewer, sign in with a viewer account or a share link, and read your archived chats, search them and open their media on the phone. +TG Archive is the iOS client for [Telegram-Archive](https://github.com/GeiserX/Telegram-Archive), the open-source server you run yourself to back up your chats. Point it at your server, sign in with a viewer account or a share link, and read, search and open your archived chats natively on the iPhone. -TG Archive is the iOS app of the Telegram-Archive project and is independent of Telegram. It talks only to the Telegram-Archive server you run, through its documented HTTP API. +TG Archive is part of the open-source Telegram-Archive project and is independent of Telegram; the server you run uses the Telegram API. -Work in progress: nothing is on TestFlight yet. +Status: v0.1.0 is being prepared for TestFlight and the App Store. + +## Features + +- **Your server, nothing else.** The app talks only to the address you enter, through the server's [documented API](https://github.com/GeiserX/Telegram-Archive/blob/main/docs/reference/api.md). No analytics, ads or tracking ([privacy policy](PRIVACY.md)). +- **Three ways in.** A viewer account, a pasted share link, or nothing at all when the server runs in anonymous mode. +- **Chat list.** Previews, folders, archived chats, forum topics, server-side title search, pull to refresh. +- **Every message kind.** Formatted text, replies, forwards, reactions, polls, locations, venues, contacts, stickers, pinned messages, service rows, and marks for messages that were edited or deleted after the backup saved them. +- **Media.** Zoomable photos, videos and round videos through AVKit, voice notes and audio with their transcripts, documents in Quick Look, and the share sheet when the login allows downloads. +- **Search.** Every message the login can see, with a jump to the hit inside its chat. +- **One clean session.** The session cookie lives in the Keychain on this device. The app holds one server session and ends it when you sign out, so it never pushes out a browser session. +- **Native.** SwiftUI, Swift 6, iOS 18, iPhone, English and Spanish, no third-party packages, no web views. + +## Quick start + +1. Run a Telegram-Archive server with its viewer enabled. Its [documentation](https://github.com/GeiserX/Telegram-Archive) covers the install, viewer accounts and share links. A server outside your local network needs https. +2. Install TG Archive (TestFlight and App Store links will be here once it ships), or build it as below. +3. Enter your server's address, or paste a share link, and sign in. + +### Build from source + +You need a Mac with Xcode and [XcodeGen](https://github.com/yonaskolb/XcodeGen). + +```bash +git clone https://github.com/GeiserX/TG-Archive-iOS.git +cd TG-Archive-iOS +brew install xcodegen +xcodegen generate # writes TGArchive.xcodeproj from project.yml +open TGArchive.xcodeproj +``` + +Run the tests on a simulator: + +```bash +xcodebuild test -project TGArchive.xcodeproj -scheme TGArchive \ + -destination "platform=iOS Simulator,id=$(scripts/pick-simulator.sh)" \ + -parallel-testing-enabled NO +``` + +### Try it without your own archive + +`scripts/demo-server.sh start` runs a demo Telegram-Archive viewer on `http://127.0.0.1:8000` with an invented archive, and prints its logins and share link. It needs `git` and `uv` (`brew install uv`). `stop`, `status` and `reset` do what they say. + +## Documentation + +- [Design](docs/DESIGN.md): the screens, the API routes each one uses, the session rules and the App Review plan. +- [Agent and contributor notes](AGENTS.md): how to build, test and land a change. +- [App Store text](docs/app-store/): the store name, subtitle, keywords and description in English and Spanish, and the App Review notes template. +- [Privacy policy](PRIVACY.md) and [security policy](SECURITY.md). +- [Telegram-Archive API reference](https://github.com/GeiserX/Telegram-Archive/blob/main/docs/reference/api.md). + +Releases: merge to `main`, wait for CI on that commit, then push a tag `vX.Y.Z` that matches `MARKETING_VERSION` in `project.yml`. [`release.yml`](.github/workflows/release.yml) archives, signs and uploads the build to TestFlight. ## License -GPL-3.0-or-later with an App Store distribution permission; see [LICENSE](LICENSE) and [NOTICE](NOTICE). +GPL-3.0-or-later with an App Store distribution permission; see [LICENSE](LICENSE) and [NOTICE](NOTICE). Source: https://github.com/GeiserX/TG-Archive-iOS diff --git a/TGArchiveTests/StoreTextTests.swift b/TGArchiveTests/StoreTextTests.swift new file mode 100644 index 0000000..d40b600 --- /dev/null +++ b/TGArchiveTests/StoreTextTests.swift @@ -0,0 +1,189 @@ +import Foundation +import ImageIO +import Testing + +/// The checkout this test file was compiled from. The tests run in a simulator on the Mac that built them, +/// so the repository's docs and workflows are readable from here. +private let repositoryRoot = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + +private func repositoryFile(_ path: String) throws -> String { + try String(contentsOf: repositoryRoot.appending(path: path), encoding: .utf8) +} + +/// The `## Field` sections of a store text file, each trimmed. +private func storeFields(_ language: String) throws -> [String: String] { + let text = try repositoryFile("docs/app-store/description.\(language).md") + var fields: [String: String] = [:] + var current: String? + var lines: [Substring] = [] + func flush() { + if let current { fields[current] = lines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) } + } + for line in text.split(separator: "\n", omittingEmptySubsequences: false) { + if line.hasPrefix("## ") { + flush() + current = String(line.dropFirst(3)) + lines = [] + } else { + lines.append(line) + } + } + flush() + return fields +} + +private func field(_ fields: [String: String], _ name: String) throws -> String { + let value = try #require(fields[name], "no \(name) field") + try #require(!value.isEmpty, "\(name) is empty") + return value +} + +/// "Telegram" as a word of its own, not as part of the project name "Telegram-Archive". +private var bareTelegram: Regex { /(?i)telegram(?!-archive)/ } + +private let repositoryURL = "https://github.com/GeiserX/TG-Archive-iOS" + +/// The App Store texts in `docs/app-store/` fit App Store Connect's limits and keep the naming rules. +@Suite("Store text") +struct StoreTextTests { + static let opening = [ + "en": "TG Archive is the iOS client for the open-source Telegram-Archive server you run yourself.", + "es": "TG Archive es el cliente de iOS para el servidor de código abierto Telegram-Archive que ejecutas tú mismo.", + ] + static let disclosure = [ + "en": "TG Archive is part of the open-source Telegram-Archive project and is independent of Telegram; " + + "the server you run uses the Telegram API.", + "es": "TG Archive forma parte del proyecto de código abierto Telegram-Archive y es independiente de Telegram; " + + "el servidor que ejecutas usa la API de Telegram.", + ] + + @Test("Every field fits App Store Connect's limits", arguments: ["en", "es"]) + func limits(_ language: String) throws { + let fields = try storeFields(language) + #expect(try field(fields, "Name") == "TG Archive") + #expect(try field(fields, "Subtitle").count <= 30) + #expect(try field(fields, "Promotional text").count <= 170) + #expect(try field(fields, "Keywords").utf8.count <= 100) + #expect(try field(fields, "Description").count <= 4000) + } + + @Test("The description opens with the client line and ends with the disclosure", arguments: ["en", "es"]) + func description(_ language: String) throws { + let description = try field(try storeFields(language), "Description") + #expect(description.hasPrefix(try #require(Self.opening[language]))) + #expect(description.hasSuffix(try #require(Self.disclosure[language]))) + #expect(description.contains(repositoryURL), "the GPL needs the source link") + // "Telegram" on its own appears only in the closing disclosure paragraph. + let paragraphs = description.components(separatedBy: "\n\n") + #expect(paragraphs.dropLast().filter { $0.contains(bareTelegram) } == []) + } + + @Test("No text calls the app unofficial, and Telegram stays out of the name, subtitle and keywords", + arguments: ["en", "es"]) + func wording(_ language: String) throws { + let text = try repositoryFile("docs/app-store/description.\(language).md").lowercased() + #expect(!text.contains("unofficial")) + #expect(!text.contains("no oficial")) + let fields = try storeFields(language) + for name in ["Name", "Subtitle", "Keywords"] { + #expect(!(try field(fields, name)).contains(bareTelegram), "\(name) names Telegram") + } + } + + @Test("Keywords are comma-separated without spaces, unique, and repeat no word of the name or subtitle", + arguments: ["en", "es"]) + func keywords(_ language: String) throws { + let fields = try storeFields(language) + let keywords = try field(fields, "Keywords").lowercased() + #expect(!keywords.contains(", ") && !keywords.hasSuffix(",")) + let terms = keywords.split(separator: ",").map(String.init) + #expect(Set(terms).count == terms.count, "a keyword repeats") + let titleWords = Set((try field(fields, "Name") + " " + field(fields, "Subtitle")).lowercased() + .split(whereSeparator: { $0 == " " || $0 == "," }).map(String.init)) + let keywordWords = Set(terms.flatMap { $0.split(separator: " ").map(String.init) }) + #expect(keywordWords.intersection(titleWords).sorted() == []) + } + + @Test("The support and privacy URLs point at this repository, and the privacy policy exists", + arguments: ["en", "es"]) + func urls(_ language: String) throws { + let fields = try storeFields(language) + #expect(try field(fields, "Support URL") == repositoryURL + "/issues") + #expect(try field(fields, "Privacy policy URL") == repositoryURL + "/blob/main/PRIVACY.md") + #expect(FileManager.default.fileExists(atPath: repositoryRoot.appending(path: "PRIVACY.md").path)) + } + + @Test("The review notes template keeps placeholders, fits the Notes field, and names no host") + func reviewNotes() throws { + let template = try repositoryFile("docs/app-store/review-notes-template.md") + for placeholder in ["", "", "", ""] { + #expect(template.contains(placeholder), "missing \(placeholder)") + } + let notes = try #require(template.components(separatedBy: "\n## Notes\n").last) + #expect(notes.trimmingCharacters(in: .whitespacesAndNewlines).count <= 4000) + #expect(notes.contains(try #require(Self.disclosure["en"]))) + #expect(!template.lowercased().contains("unofficial")) + // Where the recording was made is never stated to App Review. + #expect(!template.lowercased().contains("simulator")) + // The only addresses written down are the public project repositories. + let urls = template.matches(of: /https?:\/\/[^\s)`>]+/).map { String($0.output) } + #expect(urls.filter { !$0.hasPrefix("https://github.com/GeiserX/") } == []) + } +} + +/// The release path: the icon the archive ships and the values `release.yml` repeats from `project.yml`. +@Suite("Release path") +struct ReleasePathTests { + /// The first value of `key:` in a YAML file, as `release.yml`'s own awk check reads it. + private func yamlValue(_ key: String, in text: String) throws -> String { + let line = try #require(text.split(separator: "\n").first { + $0.trimmingCharacters(in: .whitespaces).hasPrefix(key + ":") + }, "no \(key) line") + return line.split(separator: ":", maxSplits: 1)[1].trimmingCharacters(in: .whitespaces) + } + + @Test("The app ships the AppIcon set as its primary icon") + func appIconInBundle() throws { + let info = try #require(Bundle.main.infoDictionary) + try #require(Bundle.main.bundleIdentifier == "io.github.geiserx.tgarchive", "tests are not hosted in the app") + let icons = try #require(info["CFBundleIcons"] as? [String: Any], "no CFBundleIcons: the icon did not compile") + let primary = try #require(icons["CFBundlePrimaryIcon"] as? [String: Any]) + #expect(primary["CFBundleIconName"] as? String == "AppIcon") + } + + @Test("The icon set holds one 1024 px universal iOS icon without alpha") + func appIconFile() throws { + let set = repositoryRoot.appending(path: "TGArchive/Resources/Assets.xcassets/AppIcon.appiconset") + let contents = try JSONSerialization.jsonObject(with: Data(contentsOf: set.appending(path: "Contents.json"))) + let images = try #require((contents as? [String: Any])?["images"] as? [[String: Any]]) + #expect(images.count == 1) + let image = try #require(images.first) + #expect(image["idiom"] as? String == "universal") + #expect(image["platform"] as? String == "ios") + #expect(image["size"] as? String == "1024x1024") + let file = try #require(image["filename"] as? String) + let source = try #require(CGImageSourceCreateWithURL(set.appending(path: file) as CFURL, nil)) + let properties = try #require(CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any]) + #expect(properties[kCGImagePropertyPixelWidth] as? Int == 1024) + #expect(properties[kCGImagePropertyPixelHeight] as? Int == 1024) + #expect(properties[kCGImagePropertyHasAlpha] as? Bool != true, "App Store icons must be opaque") + } + + @Test("release.yml signs with the team, bundle id and profile that project.yml declares") + func releaseMirrorsProject() throws { + let project = try repositoryFile("project.yml") + let release = try repositoryFile(".github/workflows/release.yml") + #expect(try yamlValue("TEAM_ID", in: release) == yamlValue("DEVELOPMENT_TEAM", in: project)) + #expect(try yamlValue("BUNDLE_ID", in: release) == yamlValue("PRODUCT_BUNDLE_IDENTIFIER", in: project)) + #expect(try yamlValue("APP_PROFILE_NAME", in: release) + == yamlValue("PROVISIONING_PROFILE_SPECIFIER", in: project)) + #expect(try yamlValue("BUNDLE_ID", in: release) == Bundle.main.bundleIdentifier) + } + + @Test("The version a release tag must match is the one the app reports") + func marketingVersion() throws { + let version = try yamlValue("MARKETING_VERSION", in: try repositoryFile("project.yml")) + #expect(version.wholeMatch(of: /\d+\.\d+\.\d+/) != nil) + #expect(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String == version) + } +} diff --git a/docs/DESIGN.md b/docs/DESIGN.md index b81282f..37348c0 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -18,6 +18,7 @@ Four server facts shape everything below. - The app is called **TG Archive** everywhere: display name, App Store name, About screen. "Telegram" is never part of the app name, subtitle, keywords, bundle id or icon. - The icon is the Telegram-Archive mark, the same paper plane and database stack on the same dark plate as the server project's icon (`docs/images/icon.svg` there), so the app reads as that product's iOS edition. Never Telegram's own logo, and no bubble that copies Telegram's. Message bubbles use our own accent colour and shape. - The word "Telegram" appears in exactly two places: the store description's last paragraph and the About screen, both with this text: "TG Archive is unofficial and not affiliated with Telegram. It reads backups made by Telegram-Archive, an open source server you run yourself, which uses the Telegram API." The server project's name, "Telegram-Archive", is used as the name of that project and nothing else. +- Stage 10 note: the disclosure is now "TG Archive is part of the open-source Telegram-Archive project and is independent of Telegram; the server you run uses the Telegram API.", and nothing calls the app unofficial. The store texts carry it; the About string and its test in `LocalizationTests` still hold the old sentence. - Nothing committed to this public repo names a private server, hostname, person or deployment. Docs say "your server" or "the demo server". Review credentials and the demo URL live only in App Store Connect. ## 2. Screens and the routes each one uses @@ -214,6 +215,8 @@ CI starts the demo on the `macos-latest` runner (`brew install uv`), runs the un **Trademarks (5.2.1, 4.1)**: the rules in section 1. The subtitle is "Read your self-hosted chat backup". Keywords leave out "telegram". +Stage 10 note: the subtitle names the server project instead, "Read your Telegram-Archive" ("Lee tu Telegram-Archive" in Spanish); "Telegram" on its own stays out of the name, subtitle and keywords. + **Screenshots (2.3.3)**: captured by a UI test from the synthetic archive only: chat list, a thread with a photo and a poll, a location card, search results, the media viewer. Never a real archive. **Age rating**: the questionnaire answered as none for every listed content category, no unrestricted web access, no user-to-user communication in the app; the result is 4+. diff --git a/docs/app-store/description.en.md b/docs/app-store/description.en.md new file mode 100644 index 0000000..003f35a --- /dev/null +++ b/docs/app-store/description.en.md @@ -0,0 +1,52 @@ +# App Store text, English (U.S.) + +Each `##` heading is one App Store Connect field; paste the text under it as it stands. `TGArchiveTests/StoreTextTests.swift` checks the length limits and the wording rules. + +## Name + +TG Archive + +## Subtitle + +Read your Telegram-Archive + +## Promotional text + +Your chat backup on your iPhone: browse chats and topics, open photos, videos and voice notes, and search every message, straight from your own server. + +## Keywords + +backup,chat,history,self-hosted,viewer,messages,search,reader,homelab,open source,privacy,GPL + +## Description + +TG Archive is the iOS client for the open-source Telegram-Archive server you run yourself. Enter your server's address, sign in with a viewer account or paste a share link, and read your backed-up chats natively on your iPhone. + +WHAT YOU CAN DO +- Browse your chat list with previews, folders, archived chats and forum topics. +- Read every thread the way the archive kept it: text formatting, replies, forwards, reactions, polls, locations, contacts, stickers, pinned messages, and marks on messages that were edited or deleted after they were saved. +- Open photos full screen with zoom, play videos, round videos, voice notes and audio, and read voice note transcripts. +- Open documents in Quick Look and share files, when your login allows downloads. +- Search every message you can see and jump to the hit inside its chat. + +PRIVATE BY DESIGN +- The app talks only to the server you enter. It sends nothing to the developer or to anyone else. +- No accounts of its own, no ads, no analytics, no tracking. +- Your session is kept in the iOS Keychain on this device. Signing out ends it on your server and clears the cache. +- Read-only: the app never changes your archive. + +WHAT YOU NEED +A Telegram-Archive server you run yourself, reachable over https (or plain http on your local network), with a viewer account or a share link. Setup guide: https://github.com/GeiserX/Telegram-Archive + +OPEN SOURCE +TG Archive is free software under the GPL. The source code is at https://github.com/GeiserX/TG-Archive-iOS + +TG Archive is part of the open-source Telegram-Archive project and is independent of Telegram; the server you run uses the Telegram API. + +## Support URL + +https://github.com/GeiserX/TG-Archive-iOS/issues + +## Privacy policy URL + +https://github.com/GeiserX/TG-Archive-iOS/blob/main/PRIVACY.md diff --git a/docs/app-store/description.es.md b/docs/app-store/description.es.md new file mode 100644 index 0000000..0a77487 --- /dev/null +++ b/docs/app-store/description.es.md @@ -0,0 +1,52 @@ +# Texto de App Store, español (España) + +Cada encabezado `##` es un campo de App Store Connect; pega el texto que hay debajo tal cual. `TGArchiveTests/StoreTextTests.swift` comprueba los límites de longitud y las reglas de redacción. + +## Name + +TG Archive + +## Subtitle + +Lee tu Telegram-Archive + +## Promotional text + +Tu copia de chats en el iPhone: recorre chats y temas, abre fotos, vídeos y notas de voz, y busca en todos los mensajes, directamente desde tu propio servidor. + +## Keywords + +copia,chat,historial,autoalojado,visor,mensajes,buscar,lector,respaldo,código abierto,privacidad + +## Description + +TG Archive es el cliente de iOS para el servidor de código abierto Telegram-Archive que ejecutas tú mismo. Escribe la dirección de tu servidor, inicia sesión con una cuenta de lector o pega un enlace compartido, y lee tus chats guardados de forma nativa en el iPhone. + +QUÉ PUEDES HACER +- Recorrer la lista de chats con vistas previas, carpetas, chats archivados y temas de foros. +- Leer cada conversación tal y como la guardó el archivo: formato de texto, respuestas, reenvíos, reacciones, encuestas, ubicaciones, contactos, stickers, mensajes fijados y las marcas de los mensajes que se editaron o eliminaron después de guardarse. +- Abrir fotos a pantalla completa con zoom, reproducir vídeos, vídeos redondos, notas de voz y audio, y leer las transcripciones de las notas de voz. +- Abrir documentos con Vista Rápida y compartir archivos, cuando tu inicio de sesión permite descargas. +- Buscar en todos los mensajes que puedes ver y saltar al resultado dentro de su chat. + +PRIVADO POR DISEÑO +- La app solo habla con el servidor que escribes. No envía nada al desarrollador ni a nadie más. +- Sin cuentas propias, sin anuncios, sin analíticas, sin seguimiento. +- Tu sesión se guarda en el llavero de iOS de este dispositivo. Al cerrar sesión, termina en tu servidor y se borra la caché. +- Solo lectura: la app nunca modifica tu archivo. + +QUÉ NECESITAS +Un servidor Telegram-Archive que ejecutes tú mismo, accesible por https (o por http en tu red local), con una cuenta de lector o un enlace compartido. Guía de instalación: https://github.com/GeiserX/Telegram-Archive + +CÓDIGO ABIERTO +TG Archive es software libre bajo la GPL. El código fuente está en https://github.com/GeiserX/TG-Archive-iOS + +TG Archive forma parte del proyecto de código abierto Telegram-Archive y es independiente de Telegram; el servidor que ejecutas usa la API de Telegram. + +## Support URL + +https://github.com/GeiserX/TG-Archive-iOS/issues + +## Privacy policy URL + +https://github.com/GeiserX/TG-Archive-iOS/blob/main/PRIVACY.md diff --git a/docs/app-store/review-notes-template.md b/docs/app-store/review-notes-template.md new file mode 100644 index 0000000..c3f0845 --- /dev/null +++ b/docs/app-store/review-notes-template.md @@ -0,0 +1,33 @@ +# App Review notes template + +Fill the placeholders and paste everything under "Notes" into App Review Information > Notes in App Store Connect (4000 characters at most). The placeholders stay placeholders in this file: the demo address, the login and the share link are written only in App Store Connect, never in this repository. + +| Placeholder | What goes there | +|---|---| +| `` | The public demo server's https address. A hostname, never an IP literal: App Review tests from an IPv6-only network. | +| ``, `` | The demo's viewer login, also entered in the "Sign-in required" fields. | +| `` | The demo's share link, `/#token=`. It expires 14 days after the demo is regenerated, so regenerate it before every submission. | + +Before submitting: regenerate the demo archive, check that `/api/health` answers, sign in once with the login and once with the link, tick "Sign-in required", and attach the screen recording under App Review Information > Attachment. + +## Notes + +1. Screen recording. Attached. It starts at app launch and shows: entering the server address, signing in with the viewer account, browsing the chat list, opening a thread, opening a photo, searching and opening a result, opening the share link with downloads off, and signing out. + +2. Purpose and audience. TG Archive is the iOS client for Telegram-Archive, an open-source server that people run themselves to keep a backup of their own chat history. The app lets them read that backup on their iPhone. It is read-only and shows only the archive on the server the user enters, with the access that server grants to the login. + +3. Setup and access. Normally the user installs the server (https://github.com/GeiserX/Telegram-Archive), enters its address in the app, and signs in with a viewer account or pastes a share link. For review, use our demo server, which holds an invented archive: + - Server address: + - Viewer account: username , password + - Share link (downloads are off for this link, so media shows a "Downloads are off for this login" tile): + To try the share link: in Settings tap Sign Out, choose "Share link" on the sign-in screen, and paste the link. On a fresh install, copying the link and tapping Paste under "Have a share link?" on the first screen does the same. + +4. External services. The app talks only to the server the user enters and controls. It never contacts any other service; the server uses the Telegram API to make the backup. No analytics, ads, purchases or tracking. Accounts are created and deleted by the server's owner on the server, so in-app account deletion (5.1.1(v)) does not apply, and there is no third-party social login, so Sign in with Apple (4.8) does not apply. + +5. Regional differences. None. The app works the same in every region. + +6. Regulated or third-party content. Not applicable: the app shows only the user's own private backup from their own server. There is no posting, no feed, no discovery and no contact between app users, so 1.2 does not apply. + +Native functionality (4.2): there is no web view. Chats, threads and search are native SwiftUI lists with Dynamic Type, dark mode and VoiceOver labels; photos open in a zoomable viewer, video and audio play through AVKit, documents open in Quick Look, locations show in MapKit, the session lives in the Keychain, and files go through the share sheet. + +TG Archive is part of the open-source Telegram-Archive project and is independent of Telegram; the server you run uses the Telegram API. Source code: https://github.com/GeiserX/TG-Archive-iOS