import SwiftUI @main struct KnotlessApp: App { @UIApplicationDelegateAdaptor(PushDelegate.self) private var pushDelegate @Environment(\.scenePhase) private var phase @State private var account = Account() @AppStorage(Appearance.key) private var appearance = Appearance.account @State private var update = AppUpdate() private let accountTheme: AccountTheme init() { // Before anything reads them. DevAccount.freshen() accountTheme = AccountTheme.shared } private var theme: ThemeChoice? { DevAccount.theme ?? appearance.choice(account: accountTheme.choice, themes: accountTheme.themes) } var body: some Scene { // Before anything is drawn, so the first colours are the theme's. let _ = Themes.set(theme) WindowGroup { Group { if update.required { UpdateScreen(reason: update.reason) } else { RootView() } } .tint(.accent) .foregroundStyle(Color.fg) .environment(account) .environment(\.themeGeneration, Themes.generation) .preferredColorScheme(theme?.scheme ?? DevAccount.scheme) // Lists show the theme's page rather than iOS's grey. .scrollContentBackground(theme == nil ? .automatic : .hidden) .task { await account.restore() } .task(id: phase) { guard phase == .active else { return } await update.check() await account.refresh() } .task(id: "\(phase == .active) \(account.person?.did ?? "")") { guard phase == .active else { return } if account.person == nil { if !account.checking { accountTheme.hold(nil) } } else if let did = account.person?.did { await accountTheme.load(as: did) } } } } } enum Route: Hashable { case repo(RepoRef) case items(RepoRef, kind: String) case thread(RepoRef, uri: String) case files(RepoRef, uri: String) case commits(RepoRef, uri: String) case tree(RepoRef, ref: String?, path: String) case file(RepoRef, ref: String?, path: String) case profile(String) case history(RepoRef, ref: String?) case commit(RepoRef, sha: String) case releases(RepoRef) case release(Release) case runs(RepoRef) /// One workflow's runs, or all of them. case workflowRuns(RepoRef, workflow: String?) case run(RepoRef, uri: String) /// A run's step's log, or all of its log past its steps. case step(RepoRef, uri: String, index: Int) case repositories(String) case mine(kind: String) case starred(String) case organizations(String) case followers(String) case following(String) case search case searchCode(String) case searchRepositories(String) case searchPeople(String) case blocks case settings case appearance case notificationSettings } /// Opens a screen in the stack it's asked from, for what can't be a /// `NavigationLink`, as a link in some text. struct OpenRoute { let open: @MainActor (Route) -> Void @MainActor func callAsFunction(_ route: Route) { open(route) } } extension EnvironmentValues { @Entry var openRoute: OpenRoute? } extension View { func routes() -> some View { // The theme's page behind every screen, which iOS's own draws where there's none. background(Color.page).navigationDestination(for: Route.self) { route in Group { switch route { case .repo(let repo): RepoView(repo: repo) case .items(let repo, let kind): ItemsView(repo: repo, kind: kind) case .thread(let repo, let uri): ThreadView(repo: repo, uri: uri) case .files(let repo, let uri): FilesView(repo: repo, uri: uri) case .commits(let repo, let uri): CommitsView(repo: repo, uri: uri) case .tree(let repo, let ref, let path): TreeView(repo: repo, ref: ref, path: path) case .file(let repo, let ref, let path): FileView(repo: repo, ref: ref, path: path) case .profile(let actor): ProfileScreen(actor: actor) case .history(let repo, let ref): HistoryView(repo: repo, ref: ref) case .commit(let repo, let sha): CommitView(repo: repo, sha: sha) case .releases(let repo): ReleasesView(repo: repo) case .release(let release): ReleaseView(release: release) case .runs(let repo): ChecksView(repo: repo) case .workflowRuns(let repo, let workflow): WorkflowRunsView(repo: repo, workflow: workflow) case .run(let repo, let uri): RunView(repo: repo, uri: uri) case .step(let repo, let uri, let index): StepLogView(repo: repo, uri: uri, index: index) case .repositories(let actor): RepositoriesView(actor: actor) case .mine(let kind): MyItemsView(kind: kind) case .starred(let actor): StarredView(actor: actor) case .followers(let actor): FollowsView(actor: actor, followers: true) case .following(let actor): FollowsView(actor: actor, followers: false) case .organizations(let actor): OrganizationsView(actor: actor) case .search: SearchView() case .searchCode(let query): CodeResultsView(query: query) case .searchRepositories(let query): RepositoryResultsView(query: query) case .searchPeople(let query): PeopleResultsView(query: query) case .blocks: BlocksView() case .settings: SettingsView() case .appearance: AppearanceView() case .notificationSettings: NotificationSettingsView() } } .background(Color.page) } } } struct RootView: View { enum Page: String { case home, inbox, explore, profile } @State private var page: Page @State private var homePath: [Route] @State private var inboxPath: [Route] = [] @State private var profilePath: [Route] = [] @State private var explorePath: [Route] = [] @Environment(Account.self) private var account private let pushes = Pushes.shared init() { let start = DevRoute.parse(DevAccount.route) _page = State(initialValue: start.page) _homePath = State(initialValue: start.path) } var body: some View { // Above the tabs rather than a safe area inset, which a TabView's // screens draw under, hiding their toolbars. VStack(spacing: 0) { if account.signedIn, account.reauth { ReauthNote() } tabs } .task(id: account.person?.did) { if account.person != nil { await pushes.start() } } // Signed out from a screen of the account's, as Settings: nothing of it stays open. .onChange(of: account.person?.did) { _, did in guard did == nil else { return } homePath = [] inboxPath = [] explorePath = [] profilePath = [] } .task(id: pushes.opened) { guard let target = pushes.opened else { return } pushes.opened = nil page = .inbox inboxPath = target.route.map { [$0] } ?? [] } } private var tabs: some View { TabView(selection: $page) { Tab("Home", image: "oct-home-fill", value: Page.home) { NavigationStack(path: $homePath) { HomeView().routes() } .environment(\.openRoute, OpenRoute { homePath.append($0) }) } Tab("Inbox", image: "oct-inbox-fill", value: Page.inbox) { NavigationStack(path: $inboxPath) { InboxView().routes() } .environment(\.openRoute, OpenRoute { inboxPath.append($0) }) } Tab("Explore", image: "oct-telescope-fill", value: Page.explore) { NavigationStack(path: $explorePath) { ExploreView().routes() } .environment(\.openRoute, OpenRoute { explorePath.append($0) }) } Tab("Profile", image: "oct-person-fill", value: Page.profile) { NavigationStack(path: $profilePath) { OwnProfileView().routes() } .environment(\.openRoute, OpenRoute { profilePath.append($0) }) } } } } /// Opens a screen at launch, for `ios/run.sh`: `inbox`, or /// `thread||||`, `files|…`, `repo|||`, `tree|…|`. enum DevRoute { static func parse(_ text: String?) -> (page: RootView.Page, path: [Route]) { guard let text else { return (.home, []) } if let page = RootView.Page(rawValue: text) { return (page, []) } let p = text.split(separator: "|").map(String.init) if p == ["search"] { return (.home, [.search]) } if p.count == 2, p[0] == "repositories" { return (.home, [.repositories(p[1])]) } if p.count == 2, p[0] == "mine" { return (.home, [.mine(kind: p[1])]) } if p.count == 2, p[0] == "starred" { return (.home, [.starred(p[1])]) } if p.count == 2, p[0] == "organizations" { return (.home, [.organizations(p[1])]) } if p.count == 2, p[0] == "followers" { return (.home, [.followers(p[1])]) } if p == ["blocks"] { return (.home, [.blocks]) } if p == ["settings"] { return (.home, [.settings]) } if p == ["appearance"] { return (.home, [.settings, .appearance]) } if p == ["notification-settings"] { return (.home, [.settings, .notificationSettings]) } if p.count == 2, p[0] == "following" { return (.home, [.following(p[1])]) } if p.count == 2, p[0] == "search-code" { return (.home, [.searchCode(p[1])]) } if p.count == 2, p[0] == "search-repositories" { return (.home, [.searchRepositories(p[1])]) } if p.count == 2, p[0] == "search-people" { return (.home, [.searchPeople(p[1])]) } if p.count == 2, p[0] == "user" { return (.home, [.profile(p[1])]) } guard p.count >= 4 else { return (.home, []) } let repo = RepoRef(did: p[1], handle: p[2], name: p[3]) switch p[0] { case "repo": return (.home, [.repo(repo)]) case "history": return (.home, [.history(repo, ref: nil)]) case "commit" where p.count == 5: return (.home, [.commit(repo, sha: p[4])]) case "releases": return (.home, [.releases(repo)]) case "runs": return (.home, [.runs(repo)]) case "workflow-runs" where p.count == 5: return (.home, [.runs(repo), .workflowRuns(repo, workflow: p[4])]) case "run" where p.count == 5: return (.home, [.runs(repo), .run(repo, uri: p[4])]) case "step" where p.count == 6: return (.home, [.runs(repo), .run(repo, uri: p[4]), .step(repo, uri: p[4], index: Int(p[5]) ?? 0)]) case "tree" where p.count == 5: return (.home, [.tree(repo, ref: nil, path: p[4])]) case "thread" where p.count == 5: return (.home, [.thread(repo, uri: p[4])]) case "files" where p.count == 5: return (.home, [.files(repo, uri: p[4])]) case "commits" where p.count == 5: return (.home, [.commits(repo, uri: p[4])]) case "items" where p.count == 5: return (.home, [.items(repo, kind: p[4])]) case "file" where p.count == 5: return (.home, [.file(repo, ref: nil, path: p[4])]) default: return (.home, []) } } }