Commit c70b54ad62
Unsigned
Layout: unified · split
gitbay/ContentView.swift +43 −3
| @@ -4,6 +4,7 @@ struct ContentView: View { | |||
| 4 | 4 | ||
| 5 | @Environment(SessionStore.self) private var session | 5 | @Environment(SessionStore.self) private var session |
| 6 | @Environment(PushRouter.self) private var router | 6 | @Environment(PushRouter.self) private var router |
| 7 | @Environment(\.openURL) private var openURL | ||
| 7 | 8 | ||
| 8 | enum Tabs { case dashboard, feed, repositories, explore, profile } | 9 | enum Tabs { case dashboard, feed, repositories, explore, profile } |
| 9 | 10 | ||
| @@ -64,7 +65,7 @@ struct ContentView: View { | |||
| 64 | } | 65 | } |
| 65 | } | 66 | } |
| 66 | .id(account.id) // a fresh tab bar and stacks per account | 67 | .id(account.id) // a fresh tab bar and stacks per account |
| 67 | .task(id: DrainKey(account: account.id, target: router.pending)) { | 68 | .task(id: DrainKey(account: account.id, target: router.pending, link: router.pendingLink)) { |
| 68 | await routePendingNotification(account) | 69 | await routePendingNotification(account) |
| 69 | } | 70 | } |
| 70 | } else { | 71 | } else { |
| @@ -87,11 +88,13 @@ struct ContentView: View { | |||
| 87 | nonisolated private struct DrainKey: Equatable, Sendable { | 88 | nonisolated private struct DrainKey: Equatable, Sendable { |
| 88 | let account: String | 89 | let account: String |
| 89 | let target: PushTarget? | 90 | let target: PushTarget? |
| 91 | let link: URL? | ||
| 90 | } | 92 | } |
| 91 | 93 | ||
| 92 | /// Resets the navigation state an account switch invalidated, then | 94 | /// Resets the navigation state an account switch invalidated, then |
| 93 | /// routes a tapped notification, switching accounts first when it | 95 | /// routes a tapped notification, switching accounts first when it |
| 94 | /// belongs to another one. | 96 | /// belongs to another one. With no notification pending, a pending |
| 97 | /// universal link is routed instead. | ||
| 95 | /// | 98 | /// |
| 96 | /// A switch changes `account.id`, which rebuilds the tab bar. That | 99 | /// A switch changes `account.id`, which rebuilds the tab bar. That |
| 97 | /// is why the target is left pending across it: this runs again | 100 | /// is why the target is left pending across it: this runs again |
| @@ -106,7 +109,10 @@ struct ContentView: View { | |||
| 106 | selectedTab = .dashboard | 109 | selectedTab = .dashboard |
| 107 | pathAccount = account.id | 110 | pathAccount = account.id |
| 108 | } | 111 | } |
| 109 | guard let target = router.pending else { return } | 112 | guard let target = router.pending else { |
| 113 | routePendingLink(account) | ||
| 114 | return | ||
| 115 | } | ||
| 110 | if account.id != target.accountID { | 116 | if account.id != target.accountID { |
| 111 | guard let other = session.accounts.first(where: { $0.id == target.accountID }) else { | 117 | guard let other = session.accounts.first(where: { $0.id == target.accountID }) else { |
| 112 | router.pending = nil // signed out of it since the tap | 118 | router.pending = nil // signed out of it since the tap |
| @@ -134,6 +140,40 @@ struct ContentView: View { | |||
| 134 | router.pending = nil | 140 | router.pending = nil |
| 135 | } | 141 | } |
| 136 | 142 | ||
| 143 | /// Opens a pending universal link on the Dashboard stack, under the | ||
| 144 | /// current account when it is on the link's instance, otherwise under | ||
| 145 | /// one that is. Switching leaves the link pending for the rebuilt | ||
| 146 | /// tree, as a notification does. With no account there, or a token | ||
| 147 | /// gone, the link goes to Safari. | ||
| 148 | private func routePendingLink(_ account: Account) { | ||
| 149 | guard let url = router.pendingLink else { return } | ||
| 150 | guard let route = WebURLRoute(url: url), | ||
| 151 | let owner = WebURLRoute.account(for: url, current: account, accounts: session.accounts) | ||
| 152 | else { | ||
| 153 | router.pendingLink = nil | ||
| 154 | openURL(url) | ||
| 155 | return | ||
| 156 | } | ||
| 157 | if owner.id != account.id { | ||
| 158 | if !session.activate(owner) { | ||
| 159 | router.pendingLink = nil | ||
| 160 | openURL(url) | ||
| 161 | } | ||
| 162 | return | ||
| 163 | } | ||
| 164 | selectedTab = .dashboard | ||
| 165 | switch route { | ||
| 166 | case .repo(let r): dashboardPath.append(r) | ||
| 167 | case .issue(let r): dashboardPath.append(r) | ||
| 168 | case .mr(let r): dashboardPath.append(r) | ||
| 169 | case .build(let r): dashboardPath.append(r) | ||
| 170 | case .release(let r): dashboardPath.append(r) | ||
| 171 | case .org(let r): dashboardPath.append(r) | ||
| 172 | case .snippet(let r): dashboardPath.append(r) | ||
| 173 | } | ||
| 174 | router.pendingLink = nil | ||
| 175 | } | ||
| 176 | |||
| 137 | } | 177 | } |
| 138 | 178 | ||
| 139 | /// Every value-routed screen, attachable to any stack root. | 179 | /// Every value-routed screen, attachable to any stack root. |
gitbay/Push/PushRouter.swift +4
| @@ -1,3 +1,4 @@ | |||
| 1 | import Foundation | ||
| 1 | import Observation | 2 | import Observation |
| 2 | 3 | ||
| 3 | /// Carries a tapped notification's destination into the view tree. | 4 | /// Carries a tapped notification's destination into the view tree. |
| @@ -16,4 +17,7 @@ import Observation | |||
| 16 | @MainActor | 17 | @MainActor |
| 17 | final class PushRouter { | 18 | final class PushRouter { |
| 18 | var pending: PushTarget? | 19 | var pending: PushTarget? |
| 20 | /// A universal link to route, held for the same reason, and across | ||
| 21 | /// sign-in when it arrives signed out. | ||
| 22 | var pendingLink: URL? | ||
| 19 | } | 23 | } |
gitbay/Views/WebURLRoute.swift added +107
| @@ -0,0 +1,107 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// The screen a web page's URL opens, for links to the instance that | ||
| 4 | /// arrive as universal links. The shapes follow the forge's route table; | ||
| 5 | /// a page the app has no screen for is nil, and goes to Safari. | ||
| 6 | nonisolated enum WebURLRoute: Hashable, Sendable { | ||
| 7 | case repo(RepoRoute) | ||
| 8 | case issue(IssueRoute) | ||
| 9 | case mr(MRRoute) | ||
| 10 | case build(BuildRoute) | ||
| 11 | case release(ReleaseRoute) | ||
| 12 | case org(OrgRoute) | ||
| 13 | case snippet(SnippetRoute) | ||
| 14 | |||
| 15 | init?(url: URL) { | ||
| 16 | guard let scheme = url.scheme?.lowercased(), scheme == "https" || scheme == "http", | ||
| 17 | let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil } | ||
| 18 | // Split before decoding, so an escaped slash stays in its segment. | ||
| 19 | let s = components.percentEncodedPath.split(separator: "/").compactMap { | ||
| 20 | String($0).removingPercentEncoding | ||
| 21 | } | ||
| 22 | guard let owner = s.first else { return nil } | ||
| 23 | if s.count == 1 { | ||
| 24 | switch owner { | ||
| 25 | case "bookmarks": self = .repo(.bookmarks) | ||
| 26 | case "notifications": self = .repo(.notifications) | ||
| 27 | default: self = .repo(.profile(owner)) | ||
| 28 | } | ||
| 29 | return | ||
| 30 | } | ||
| 31 | if s[1] == "-" { | ||
| 32 | guard let route = Self.ownerPage(owner, Array(s.dropFirst(2))) else { return nil } | ||
| 33 | self = route | ||
| 34 | return | ||
| 35 | } | ||
| 36 | let repo = "\(owner)/\(s[1])" | ||
| 37 | guard s.count > 2 else { | ||
| 38 | self = .repo(.repo(repo)) | ||
| 39 | return | ||
| 40 | } | ||
| 41 | guard let route = Self.repoPage(repo, s[2], Array(s.dropFirst(3))) else { return nil } | ||
| 42 | self = route | ||
| 43 | } | ||
| 44 | |||
| 45 | /// `/{owner}/-/...` | ||
| 46 | private static func ownerPage(_ owner: String, _ rest: [String]) -> WebURLRoute? { | ||
| 47 | switch (rest.first, rest.count) { | ||
| 48 | case ("repositories", 1), ("bookmarks", 1), ("people", 1): .repo(.profile(owner)) | ||
| 49 | case ("labels", 1): .org(.labels(owner)) | ||
| 50 | case ("milestones", 1): .org(.milestones(owner)) | ||
| 51 | case ("snippets", 1): .snippet(.list(owner: owner)) | ||
| 52 | case ("snippets", 2) where rest[1] != "new": .snippet(.snippet(id: rest[1])) | ||
| 53 | default: nil | ||
| 54 | } | ||
| 55 | } | ||
| 56 | |||
| 57 | /// `/{owner}/{repo}/{section}/...` | ||
| 58 | private static func repoPage(_ repo: String, _ section: String, _ rest: [String]) -> WebURLRoute? { | ||
| 59 | let number = rest.count == 1 ? Int64(rest[0]).flatMap { $0 > 0 ? $0 : nil } : nil | ||
| 60 | let ref = rest.first | ||
| 61 | let path = rest.dropFirst().joined(separator: "/") | ||
| 62 | switch section { | ||
| 63 | case "issues": | ||
| 64 | if rest.isEmpty { return .issue(.list(repo: repo)) } | ||
| 65 | return number.map { .issue(.issue(repo: repo, number: $0)) } | ||
| 66 | case "mrs": | ||
| 67 | if rest.isEmpty { return .mr(.list(repo: repo)) } | ||
| 68 | return number.map { .mr(.mr(repo: repo, number: $0)) } | ||
| 69 | case "builds": | ||
| 70 | if rest.isEmpty { return .build(.list(repo: repo)) } | ||
| 71 | return number.map { .build(.detail(repo: repo, number: $0)) } | ||
| 72 | case "tree": | ||
| 73 | return ref.map { .repo(.tree(repo: repo, directory: path, ref: $0)) } | ||
| 74 | case "blob" where !path.isEmpty: | ||
| 75 | return .repo(.file(repo: repo, path: path, ref: ref)) | ||
| 76 | case "blame" where !path.isEmpty: | ||
| 77 | return .repo(.blame(repo: repo, path: path, ref: ref)) | ||
| 78 | case "commit" where rest.count == 1: | ||
| 79 | return .repo(.commit(repo: repo, sha: rest[0])) | ||
| 80 | case "log" where rest.count <= 1: | ||
| 81 | return .repo(.log(repo: repo, ref: ref, path: nil)) | ||
| 82 | case "wiki" where ref != "_raw": | ||
| 83 | if rest.isEmpty { return .repo(.wiki(repo: repo)) } | ||
| 84 | return .repo(.wikiPage(repo: repo, page: rest.joined(separator: "/"))) | ||
| 85 | case _ where !rest.isEmpty: | ||
| 86 | return nil | ||
| 87 | case "refs": return .repo(.refs(repo: repo)) | ||
| 88 | case "compare": return .repo(.compare(repo: repo, defaultBranch: nil)) | ||
| 89 | case "releases": return .release(.list(repo: repo)) | ||
| 90 | case "milestones": return .repo(.milestones(repo: repo)) | ||
| 91 | case "labels": return .repo(.labels(repo: repo)) | ||
| 92 | default: return nil | ||
| 93 | } | ||
| 94 | } | ||
| 95 | |||
| 96 | /// The signed-in account a link opens under: the current one when it | ||
| 97 | /// is on the link's instance, otherwise the first that is. Both sides | ||
| 98 | /// go through `GitbayInstance`'s normalisation, as `PushTarget` does. | ||
| 99 | static func account(for url: URL, current: Account?, accounts: [Account]) -> Account? { | ||
| 100 | guard let instance = try? GitbayInstance(url: url.absoluteString) else { return nil } | ||
| 101 | let matches = { (account: Account) in | ||
| 102 | (try? GitbayInstance(url: account.instance.baseURL.absoluteString))?.baseURL == instance.baseURL | ||
| 103 | } | ||
| 104 | if let current, matches(current) { return current } | ||
| 105 | return accounts.first(where: matches) | ||
| 106 | } | ||
| 107 | } | ||
gitbay/gitbay.entitlements +4
| @@ -4,5 +4,9 @@ | |||
| 4 | <dict> | 4 | <dict> |
| 5 | <key>aps-environment</key> | 5 | <key>aps-environment</key> |
| 6 | <string>development</string> | 6 | <string>development</string> |
| 7 | <key>com.apple.developer.associated-domains</key> | ||
| 8 | <array> | ||
| 9 | <string>applinks:gitbay.org</string> | ||
| 10 | </array> | ||
| 7 | </dict> | 11 | </dict> |
| 8 | </plist> | 12 | </plist> |
gitbay/gitbayApp.swift +9
| @@ -30,6 +30,15 @@ struct gitbayApp: App { | |||
| 30 | // The screenshot checkpoint forces appearance per run; | 30 | // The screenshot checkpoint forces appearance per run; |
| 31 | // simulator clones ignore the base device's setting. | 31 | // simulator clones ignore the base device's setting. |
| 32 | .preferredColorScheme(Self.forcedColorScheme) | 32 | .preferredColorScheme(Self.forcedColorScheme) |
| 33 | .onOpenURL { url in | ||
| 34 | // A page the app has no screen for goes back to | ||
| 35 | // Safari; the rest waits for ContentView's drain. | ||
| 36 | if WebURLRoute(url: url) == nil { | ||
| 37 | UIApplication.shared.open(url) | ||
| 38 | } else { | ||
| 39 | router.pendingLink = url | ||
| 40 | } | ||
| 41 | } | ||
| 33 | .task { | 42 | .task { |
| 34 | let registrar = self.registrar | 43 | let registrar = self.registrar |
| 35 | appDelegate.registrar = registrar | 44 | appDelegate.registrar = registrar |
gitbayTests/WebURLRouteTests.swift added +132
| @@ -0,0 +1,132 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import gitbay | ||
| 4 | |||
| 5 | private func route(_ path: String) -> WebURLRoute? { | ||
| 6 | WebURLRoute(url: URL(string: "https://gitbay.org\(path)")!) | ||
| 7 | } | ||
| 8 | |||
| 9 | @Test func webURLOwnerAndRepo() { | ||
| 10 | #expect(route("/krz") == .repo(.profile("krz"))) | ||
| 11 | #expect(route("/krz/") == .repo(.profile("krz"))) | ||
| 12 | #expect(route("/krz/gitbay") == .repo(.repo("krz/gitbay"))) | ||
| 13 | #expect(route("/krz/gitbay/") == .repo(.repo("krz/gitbay"))) | ||
| 14 | } | ||
| 15 | |||
| 16 | @Test func webURLOwnerPages() { | ||
| 17 | #expect(route("/krz/-/repositories") == .repo(.profile("krz"))) | ||
| 18 | #expect(route("/krz/-/bookmarks") == .repo(.profile("krz"))) | ||
| 19 | #expect(route("/krz/-/people") == .repo(.profile("krz"))) | ||
| 20 | #expect(route("/krz/-/labels") == .org(.labels("krz"))) | ||
| 21 | #expect(route("/krz/-/milestones") == .org(.milestones("krz"))) | ||
| 22 | #expect(route("/cmc/-/snippets") == .snippet(.list(owner: "cmc"))) | ||
| 23 | #expect(route("/cmc/-/snippets/a1b2") == .snippet(.snippet(id: "a1b2"))) | ||
| 24 | } | ||
| 25 | |||
| 26 | @Test func webURLTopLevelPages() { | ||
| 27 | #expect(route("/bookmarks") == .repo(.bookmarks)) | ||
| 28 | #expect(route("/notifications") == .repo(.notifications)) | ||
| 29 | } | ||
| 30 | |||
| 31 | @Test func webURLIssuesMRsBuilds() { | ||
| 32 | #expect(route("/krz/gitbay/issues") == .issue(.list(repo: "krz/gitbay"))) | ||
| 33 | #expect(route("/krz/gitbay/issues/12") == .issue(.issue(repo: "krz/gitbay", number: 12))) | ||
| 34 | #expect(route("/krz/gitbay/mrs") == .mr(.list(repo: "krz/gitbay"))) | ||
| 35 | #expect(route("/krz/gitbay/mrs/7") == .mr(.mr(repo: "krz/gitbay", number: 7))) | ||
| 36 | #expect(route("/krz/gitbay/builds") == .build(.list(repo: "krz/gitbay"))) | ||
| 37 | #expect(route("/krz/gitbay/builds/1510") == .build(.detail(repo: "krz/gitbay", number: 1510))) | ||
| 38 | } | ||
| 39 | |||
| 40 | // Query and fragment do not change the screen: a comment anchor or a | ||
| 41 | // list filter still lands on the page it belongs to. | ||
| 42 | @Test func webURLIgnoresQueryAndFragment() { | ||
| 43 | #expect(route("/krz/gitbay/issues/12#comment-3") == .issue(.issue(repo: "krz/gitbay", number: 12))) | ||
| 44 | #expect(route("/krz/gitbay/issues?state=closed") == .issue(.list(repo: "krz/gitbay"))) | ||
| 45 | } | ||
| 46 | |||
| 47 | @Test func webURLFiles() { | ||
| 48 | #expect(route("/krz/gitbay/tree/main") == .repo(.tree(repo: "krz/gitbay", directory: "", ref: "main"))) | ||
| 49 | #expect(route("/krz/gitbay/tree/main/") == .repo(.tree(repo: "krz/gitbay", directory: "", ref: "main"))) | ||
| 50 | #expect(route("/krz/gitbay/tree/main/internal/httpd") | ||
| 51 | == .repo(.tree(repo: "krz/gitbay", directory: "internal/httpd", ref: "main"))) | ||
| 52 | #expect(route("/krz/gitbay/blob/v1.0.0/README.org") | ||
| 53 | == .repo(.file(repo: "krz/gitbay", path: "README.org", ref: "v1.0.0"))) | ||
| 54 | #expect(route("/krz/gitbay/blame/main/cmd/gitbay/main.go") | ||
| 55 | == .repo(.blame(repo: "krz/gitbay", path: "cmd/gitbay/main.go", ref: "main"))) | ||
| 56 | } | ||
| 57 | |||
| 58 | // Each segment is decoded on its own, so an escaped slash stays inside | ||
| 59 | // the ref instead of splitting it. | ||
| 60 | @Test func webURLDecodesSegments() { | ||
| 61 | #expect(route("/krz/gitbay/blob/main/docs/My%20Notes.md") | ||
| 62 | == .repo(.file(repo: "krz/gitbay", path: "docs/My Notes.md", ref: "main"))) | ||
| 63 | #expect(route("/krz/gitbay/tree/feature%2Fx/docs") | ||
| 64 | == .repo(.tree(repo: "krz/gitbay", directory: "docs", ref: "feature/x"))) | ||
| 65 | } | ||
| 66 | |||
| 67 | @Test func webURLHistoryAndRefs() { | ||
| 68 | #expect(route("/krz/gitbay/commit/d0c63c4") == .repo(.commit(repo: "krz/gitbay", sha: "d0c63c4"))) | ||
| 69 | #expect(route("/krz/gitbay/log") == .repo(.log(repo: "krz/gitbay", ref: nil, path: nil))) | ||
| 70 | #expect(route("/krz/gitbay/log/main") == .repo(.log(repo: "krz/gitbay", ref: "main", path: nil))) | ||
| 71 | #expect(route("/krz/gitbay/refs") == .repo(.refs(repo: "krz/gitbay"))) | ||
| 72 | #expect(route("/krz/gitbay/compare") == .repo(.compare(repo: "krz/gitbay", defaultBranch: nil))) | ||
| 73 | } | ||
| 74 | |||
| 75 | @Test func webURLRepoSections() { | ||
| 76 | #expect(route("/krz/gitbay/releases") == .release(.list(repo: "krz/gitbay"))) | ||
| 77 | #expect(route("/krz/gitbay/milestones") == .repo(.milestones(repo: "krz/gitbay"))) | ||
| 78 | #expect(route("/krz/gitbay/labels") == .repo(.labels(repo: "krz/gitbay"))) | ||
| 79 | #expect(route("/krz/gitbay/wiki") == .repo(.wiki(repo: "krz/gitbay"))) | ||
| 80 | #expect(route("/krz/gitbay/wiki/Home") == .repo(.wikiPage(repo: "krz/gitbay", page: "Home"))) | ||
| 81 | #expect(route("/krz/gitbay/wiki/Architecture/00-Overview") | ||
| 82 | == .repo(.wikiPage(repo: "krz/gitbay", page: "Architecture/00-Overview"))) | ||
| 83 | } | ||
| 84 | |||
| 85 | // Pages the app has no screen for, and malformed ids, are nil: the | ||
| 86 | // caller hands those to Safari. | ||
| 87 | @Test func webURLUnroutable() { | ||
| 88 | for path in [ | ||
| 89 | "", "/", | ||
| 90 | "/krz/gitbay/issues/new", "/krz/gitbay/issues/abc", "/krz/gitbay/issues/12/extra", | ||
| 91 | "/krz/gitbay/mrs/new", "/krz/gitbay/mrs/7/range-diff", "/krz/gitbay/builds/0", | ||
| 92 | "/krz/gitbay/blob/main", "/krz/gitbay/tree", "/krz/gitbay/commit", | ||
| 93 | "/krz/gitbay/raw/main/README.org", "/krz/gitbay/archive/main.tar.gz", | ||
| 94 | "/krz/gitbay/releases/download/v1/x.tgz", "/krz/gitbay/settings", | ||
| 95 | "/krz/gitbay/wiki/_raw/a.svg", "/krz/gitbay/compare/main...x", | ||
| 96 | "/krz/gitbay/log/main/extra", "/krz/-", "/krz/-/queries", | ||
| 97 | "/krz/-/snippets/a1b2/raw/x.txt", | ||
| 98 | ] { | ||
| 99 | #expect(route(path) == nil, "\(path)") | ||
| 100 | } | ||
| 101 | #expect(WebURLRoute(url: URL(string: "mailto:cmc@gitbay.org")!) == nil) | ||
| 102 | #expect(WebURLRoute(url: URL(string: "ftp://gitbay.org/krz")!) == nil) | ||
| 103 | } | ||
| 104 | |||
| 105 | private func account(_ host: String, _ user: String) throws -> Account { | ||
| 106 | Account(instance: try GitbayInstance(url: "https://\(host)"), username: user) | ||
| 107 | } | ||
| 108 | |||
| 109 | @Test func webURLAccountPrefersTheCurrentOne() throws { | ||
| 110 | let accounts = [try account("gitbay.org", "cmc"), try account("gitbay.org", "ios-smoke")] | ||
| 111 | let url = URL(string: "https://gitbay.org/krz")! | ||
| 112 | #expect(WebURLRoute.account(for: url, current: accounts[1], accounts: accounts) == accounts[1]) | ||
| 113 | } | ||
| 114 | |||
| 115 | @Test func webURLAccountFallsBackToOneOnTheInstance() throws { | ||
| 116 | let accounts = [try account("dev.local", "cmc"), try account("gitbay.org", "cmc")] | ||
| 117 | let url = URL(string: "https://gitbay.org/krz")! | ||
| 118 | #expect(WebURLRoute.account(for: url, current: accounts[0], accounts: accounts) == accounts[1]) | ||
| 119 | } | ||
| 120 | |||
| 121 | @Test func webURLAccountIsNilWithoutOneOnTheInstance() throws { | ||
| 122 | let accounts = [try account("dev.local", "cmc")] | ||
| 123 | let url = URL(string: "https://gitbay.org/krz")! | ||
| 124 | #expect(WebURLRoute.account(for: url, current: accounts[0], accounts: accounts) == nil) | ||
| 125 | #expect(WebURLRoute.account(for: url, current: nil, accounts: []) == nil) | ||
| 126 | } | ||
| 127 | |||
| 128 | @Test func webURLAccountMatchesAnExplicitDefaultPort() throws { | ||
| 129 | let accounts = [try account("gitbay.org", "cmc")] | ||
| 130 | let url = URL(string: "https://gitbay.org:443/krz")! | ||
| 131 | #expect(WebURLRoute.account(for: url, current: nil, accounts: accounts) == accounts[0]) | ||
| 132 | } | ||