Commit dac304f8 authored by Kourser's avatar Kourser
Browse files

feat: write to the developer, and an invitation to say so

Two ways to reach the developer from inside the app: a composer in
Réglages (and in the Aide menu on macOS) for a problem, a suggestion or a
question, and an invitation that shows up once someone has actually used
the app for a while.

The composer builds a mailto: the app transmits nothing itself, and the
diagnostic block travels visible and editable inside the message the user
sends from their own account. Its body is written in English on purpose:
the app ships in 24 languages but the reports land in one inbox. When no
mail client answers the URL, the address is offered for copying instead
of leaving a button that does nothing.

The invitation offers both channels side by side rather than asking
whether the user is happy and routing them accordingly — that filtering
is the review gating Apple disallows — and its App Store button is a
plain ?action=write-review link. It is a sheet rather than an alert
because UIAlertController cannot host the "ne plus me le demander"
checkbox. It waits for five finished episodes, three days since install,
and a hundred and twenty days between two showings, and fires when an
episode ends rather than at launch. On a Developer ID Mac there is
nothing to rate, so the rating button is dropped there.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 404f9df7
Loading
Loading
Loading
Loading
+12 −0
Original line number Diff line number Diff line
@@ -7,6 +7,18 @@ adopte le [versionnage sémantique](https://semver.org/lang/fr/).

## [Non publié]

### Ajouté
- **Nous écrire** : depuis les Réglages (et le menu Aide sur macOS), un message
  au développeur — problème, suggestion ou question. Le message s'ouvre
  prérempli dans votre application Mail, avec un bloc de diagnostic (version,
  système, appareil, langue) visible et modifiable avant l'envoi. L'application
  ne transmet rien elle-même.
- **Invite de notation** : après quelques épisodes terminés, une invite propose
  d'écrire au développeur ou de laisser un avis sur l'App Store — les deux au
  même niveau, sans question de satisfaction préalable. Une case « Ne plus me le
  demander » la désactive définitivement. Hors App Store (macOS en Developer
  ID), seul le canal e-mail est proposé.

### Corrigé
- **Notification de mise à jour** : le numéro de version publié est désormais
  lu par plateforme. iOS et macOS ayant des trains de versions distincts sur
+6 −0
Original line number Diff line number Diff line
@@ -119,9 +119,15 @@ final class AppModel {

    private func handlePlaybackFinished() async {
        await markCurrentEpisodePlayed()
        finishedEpisodeTicks += 1
        await advanceQueue()
    }

    /// Bumped whenever an episode plays through to the end. The review prompt
    /// hangs off it: finishing an episode is the natural pause, as opposed to
    /// launch or mid-playback.
    private(set) var finishedEpisodeTicks = 0

    func start() async {
        await load()
        #if DEBUG
+3 −0
Original line number Diff line number Diff line
@@ -9,6 +9,9 @@ final class CommandCenter {
    var showAddPodcast = false
    var showAddRadio = false
    var showOPMLImporter = false
    /// Set by the Aide menu and the Réglages rows; RootView presents the
    /// composer for this kind, then clears it.
    var feedbackKind: Feedback.Kind?
    /// Podcast picked outside the view hierarchy (Touch Bar scrubber):
    /// RootView opens it (or plays it, for a radio) then clears the value.
    var openPodcastID: UUID?
+163 −0
Original line number Diff line number Diff line
import Foundation
#if os(iOS)
import UIKit
#endif

/// Everything the "nous écrire" and "noter l'app" paths need: the address, the
/// diagnostic footer, and the URLs that open Mail and the App Store.
///
/// The mail body is written in English on purpose. The app ships in 24
/// languages but the reports land in one inbox, so the prose the user types is
/// theirs while the scaffolding stays readable by the maintainer.
enum Feedback {
    static let contactAddress = "contact@kourser.bzh"
    static let appStoreID = "6782177493"

    enum Kind: String, CaseIterable, Identifiable {
        case bug, suggestion, question

        var id: String { rawValue }

        /// Shown in the picker, so localized.
        var label: String {
            switch self {
            case .bug: String(localized: "Problème")
            case .suggestion: String(localized: "Suggestion")
            case .question: String(localized: "Question")
            }
        }

        /// Goes in the mail subject, so deliberately not localized.
        var subjectTag: String {
            switch self {
            case .bug: "Bug"
            case .suggestion: "Suggestion"
            case .question: "Question"
            }
        }

        /// Prompt above the text field.
        var placeholder: String {
            switch self {
            case .bug:
                String(localized: "Ce que vous faisiez, ce qui s'est passé, ce que vous attendiez.")
            case .suggestion:
                String(localized: "Ce qui vous manque, et ce que ça vous permettrait de faire.")
            case .question:
                String(localized: "Votre question.")
            }
        }
    }

    // MARK: - Diagnostics

    private static var appVersion: String {
        let info = Bundle.main.infoDictionary
        let short = info?["CFBundleShortVersionString"] as? String ?? "?"
        let build = info?["CFBundleVersion"] as? String ?? "?"
        return "\(short) (\(build))"
    }

    private static var gitCommit: String {
        Bundle.main.object(forInfoDictionaryKey: "GitCommit") as? String ?? "—"
    }

    private static var systemName: String {
        #if os(macOS)
        "macOS"
        #else
        UIDevice.current.systemName
        #endif
    }

    private static var systemVersion: String {
        let v = ProcessInfo.processInfo.operatingSystemVersion
        return "\(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
    }

    /// The marketing-ish identifier a bug report needs: "iPhone16,2",
    /// "MacBookPro18,3". `utsname` reports the CPU on macOS and the model on
    /// iOS, so each platform reads the one that actually names the machine.
    private static var hardwareModel: String {
        #if os(macOS)
        var size = 0
        guard sysctlbyname("hw.model", nil, &size, nil, 0) == 0, size > 0 else { return "Mac" }
        var bytes = [CChar](repeating: 0, count: size)
        guard sysctlbyname("hw.model", &bytes, &size, nil, 0) == 0 else { return "Mac" }
        return String(cString: bytes)
        #else
        var info = utsname()
        uname(&info)
        let machine = withUnsafeBytes(of: &info.machine) { raw in
            raw.prefix { $0 != 0 }.map { CChar(bitPattern: $0) }
        }
        return String(cString: machine + [0])
        #endif
    }

    /// The block appended to every message. Shown in the composer before it is
    /// sent, and editable there — nothing leaves the device on its own.
    static var diagnostics: String {
        """
        App: Skingomz \(appVersion) — \(gitCommit)
        System: \(systemName) \(systemVersion)
        Device: \(hardwareModel)
        Language: \(Locale.current.identifier)
        """
    }

    // MARK: - URLs

    static func subject(for kind: Kind) -> String {
        "[Skingomz] \(kind.subjectTag) — \(appVersion)"
    }

    static func body(message: String, diagnostics: String) -> String {
        """
        \(message)

        —
        \(diagnostics)
        """
    }

    /// `URLQueryItem` leaves "+" unescaped and most mail clients then read it
    /// as a space, so it is re-escaped by hand after the fact.
    static func mailURL(kind: Kind, message: String, diagnostics: String) -> URL? {
        var components = URLComponents()
        components.scheme = "mailto"
        components.path = contactAddress
        components.queryItems = [
            URLQueryItem(name: "subject", value: subject(for: kind)),
            URLQueryItem(name: "body", value: body(message: message, diagnostics: diagnostics)),
        ]
        components.percentEncodedQuery = components.percentEncodedQuery?
            .replacingOccurrences(of: "+", with: "%2B")
        return components.url
    }

    static var writeReviewURL: URL {
        #if os(macOS)
        URL(string: "macappstore://apps.apple.com/app/id\(appStoreID)?action=write-review")!
        #else
        URL(string: "itms-apps://apps.apple.com/app/id\(appStoreID)?action=write-review")!
        #endif
    }

    /// macOS ships through two channels: the Mac App Store and Developer ID
    /// (direct download from GitLab). Someone who took the direct download has
    /// nothing to rate, and `requestReview` would do nothing for them — an App
    /// Store receipt is what tells the two apart at runtime.
    static var canRateOnAppStore: Bool {
        #if os(macOS)
        #if DEBUG
        return true // so the prompt stays reachable when running from Xcode
        #else
        guard let receipt = Bundle.main.appStoreReceiptURL else { return false }
        return FileManager.default.fileExists(atPath: receipt.path)
        #endif
        #else
        return true
        #endif
    }
}
+114 −0
Original line number Diff line number Diff line
import SwiftUI

/// Composes a message to the maintainer and hands it to the user's mail client.
/// Nothing is sent by the app: the diagnostics are shown here, editable, and
/// travel only inside the message the user sends themselves.
struct FeedbackView: View {
    let initialKind: Feedback.Kind

    @Environment(\.dismiss) private var dismiss
    @Environment(\.openURL) private var openURL
    @State private var kind: Feedback.Kind
    @State private var message = ""
    @State private var diagnostics = Feedback.diagnostics
    @State private var showingFallback = false

    init(kind: Feedback.Kind = .bug) {
        self.initialKind = kind
        _kind = State(initialValue: kind)
    }

    var body: some View {
        NavigationStack {
            Form {
                Section {
                    Picker("Type", selection: $kind) {
                        ForEach(Feedback.Kind.allCases) { kind in
                            Text(kind.label).tag(kind)
                        }
                    }
                    .pickerStyle(.segmented)
                    .labelsHidden()
                }

                Section {
                    TextEditor(text: $message)
                        .frame(minHeight: 160)
                        .font(.body)
                } footer: {
                    Text(kind.placeholder)
                }

                Section {
                    TextEditor(text: $diagnostics)
                        .frame(minHeight: 90)
                        .font(.system(.caption, design: .monospaced))
                } header: {
                    Text("Informations jointes")
                } footer: {
                    Text("Ces lignes partent dans le corps du message. Vous pouvez les modifier ou les effacer avant l'envoi.")
                }

                Section {
                    Label(
                        "Aucune donnée n'est transmise par l'application. Le message part depuis votre compte, avec ce que vous y laissez.",
                        systemImage: "lock.shield"
                    )
                    .font(.footnote)
                    .foregroundStyle(.secondary)
                    .labelStyle(.titleAndIcon)
                }
            }
            .formStyle(.grouped)
            .navigationTitle("Nous écrire")
            .inlineNavigationTitle()
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Annuler") { dismiss() }
                }
                ToolbarItem(placement: .confirmationAction) {
                    Button("Envoyer") { send() }
                        .disabled(message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
                }
            }
            .alert("Aucune application Mail", isPresented: $showingFallback) {
                Button("Copier l'adresse") {
                    copyAddress()
                    dismiss()
                }
                Button("Annuler", role: .cancel) {}
            } message: {
                Text("Écrivez-nous à \(Feedback.contactAddress).")
            }
        }
        #if os(macOS)
        .frame(width: 520, height: 620)
        #endif
    }

    /// `openURL` reports whether anything actually handled the mailto — without
    /// a configured mail account nothing does, and the user would otherwise be
    /// left tapping a button that does nothing.
    private func send() {
        guard let url = Feedback.mailURL(kind: kind, message: message, diagnostics: diagnostics) else {
            showingFallback = true
            return
        }
        openURL(url) { accepted in
            if accepted {
                dismiss()
            } else {
                showingFallback = true
            }
        }
    }

    private func copyAddress() {
        #if os(macOS)
        NSPasteboard.general.clearContents()
        NSPasteboard.general.setString(Feedback.contactAddress, forType: .string)
        #else
        UIPasteboard.general.string = Feedback.contactAddress
        #endif
    }
}
Loading