Skip to content

Commit cba5dd3

Browse files
authored
Merge pull request #456 from LNPANDY/v0lumehi/build-log-store
Move the build log into a BuildLog store (#387)
2 parents 552d75c + 5add68a commit cba5dd3

7 files changed

Lines changed: 203 additions & 35 deletions

File tree

‎frontend/ARCHITECTURE.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@ The plan and the steps are in [LNPANDY/NX2Apple#387](https://github.com/LNPANDY/
1313

1414
## Where it stands
1515

16-
Step 1 of #387 made the targets and moved the files that already fit. Step 2 moved the language out of `BuildModel` into the `Localizer` store. The rest still lives in the app target and moves with the later steps.
16+
Step 1 of #387 made the targets and moved the files that already fit. Step 2 moved the language out of `BuildModel` into the `Localizer` store. Step 3 moved the build log into the `BuildLog` store. The rest still lives in the app target and moves with the later steps.
1717

1818
- `BuildModel` is the model of the whole launcher and reaches into every layer, so the files that use it cannot leave the app target yet. It no longer owns the language: it holds the `Localizer` it was given and forwards `lang` and `t()` to it, for the text it writes itself (alerts, status, failures).
1919
- `NXCore` holds the value types and static functions that need only Foundation: app and engine update parsing, `Localization.swift`, the release notes, the device and signing tools, the cloud folder and saves migration, the bundled tools, input staging, the build target and the pipe reader.
20-
- `NXModel` holds `Localizer`: `lang` and `t()`. The app creates one with each `BuildModel`, and `ContentView` hands it to every view as an environment object. A view that shows text declares `@EnvironmentObject var loc: Localizer` and calls `loc.t(...)`. A language change redraws these views and nothing else. No other store exists yet.
20+
- `NXModel` holds `Localizer`: `lang` and `t()`. The app creates one with each `BuildModel`, and `ContentView` hands it to every view as an environment object. A view that shows text declares `@EnvironmentObject var loc: Localizer` and calls `loc.t(...)`. A language change redraws these views and nothing else.
21+
- `NXModel` also holds `BuildLog`: the log text, its tail trimming and the line buffer. `BuildModel` holds it in a `let` and does not publish it, and only `BuildLogView` (in `NXLauncher`) observes it. A log line redraws the log and no game card. `LauncherPerformanceTests` writes 1000 lines during a build and allows 0 cards. `BuildModel` is not `@MainActor` yet, so it reaches the log through `BuildLog.onMain`.
2122
- `NXLauncher` holds the views and styles that do not need `BuildModel`: `LauncherPalette`, `Typography`, `LauncherStyles`, `LauncherDropdown`, `PadFocus` and `EmbeddedFonts`.
2223
- `NX2Apple` holds everything else, including `BuildModel`.
2324

‎frontend/Sources/NX2Apple/ContentView.swift‎

Lines changed: 13 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,12 @@ final class BuildModel: ObservableObject {
123123
@Published var staleFrameworks: [StaleFrameworks.Entry] = [] // the prompt of StaleFrameworksPrompt.swift (#449)
124124
// The shell folder (shellCachePath, shellRoot): ShellRoot.swift.
125125
@Published var stopping = false
126-
@Published var logTail = ""
126+
/// The build log. Not published: only `BuildLogView` observes it (#387), so a log line redraws the log and nothing else.
127+
let log = BuildLog()
128+
var logTail: String {
129+
get { BuildLog.onMain { log.text } }
130+
set { BuildLog.onMain { log.set(newValue) } }
131+
}
127132
@Published var done = false
128133
@Published var doneNote = ""
129134
/// What the NSP script stopped on, when it is one of the known early stops (BuildFailure.swift). nil for a build that
@@ -223,7 +228,6 @@ final class BuildModel: ObservableObject {
223228
init(localizer: Localizer = Localizer()) { self.localizer = localizer }
224229

225230
private var proc: Process?
226-
private var lineBuf = ""
227231
private var staging: String?
228232

229233
// ---- Process groups: Stop and quitting end the whole build ----
@@ -1244,7 +1248,7 @@ final class BuildModel: ObservableObject {
12441248
shellDeviceChecked = false
12451249
if target != BuildTarget.mac, askAboutStaleFrameworks() { return } // StaleFrameworksPrompt.swift (#449)
12461250
stopping = false
1247-
running = true; done = false; doneNote = ""; stages = []; logTail = ""; lineBuf = ""; chainNoteShown = false
1251+
running = true; done = false; doneNote = ""; stages = []; BuildLog.onMain { log.clear() }; chainNoteShown = false
12481252
failure = nil; confirmingOverride = nil
12491253
let override = takeOverrideFlag() // for this build only: the next one asks again
12501254
installedOn = ""; installedAppPath = ""
@@ -1677,7 +1681,7 @@ final class BuildModel: ObservableObject {
16771681
}
16781682

16791683
/// A stopped build leaves no half-finished stages behind: the progress starts empty again.
1680-
private func resetProgress() { stages = []; logTail = ""; lineBuf = "" }
1684+
private func resetProgress() { stages = []; BuildLog.onMain { log.clear() } }
16811685

16821686
private func markAllDone() { for i in stages.indices where stages[i].state != .failed { stages[i].state = .done } }
16831687

@@ -1695,29 +1699,20 @@ final class BuildModel: ObservableObject {
16951699
}
16961700

16971701
private func say(_ s: String) { append(s + "\n") }
1698-
private func append(_ s: String) {
1699-
logTail += s
1700-
if logTail.count > 12000 { logTail = String(logTail.suffix(9000)) }
1701-
}
1702+
private func append(_ s: String) { BuildLog.onMain { log.append(s) } }
17021703

17031704
/// Split into lines (the pipe delivers arbitrary chunks) and turn the script's progress into the stage list.
17041705
/// `--progress json` writes one event per line (stage, step, progress with done / total / frac / eta, stage_end,
17051706
/// say), a count change at most every half second; anything else on the pipe (a tool's traceback) is log text.
17061707
/// The older `--progress lines` form is still understood:
17071708
/// [12:00:01] [2/7] extract v720896 …
17081709
/// [12:00:20] ✓ [2/7] extract v720896 (0:19): 30,000 files, 3.4 GB
1709-
private func ingest(_ chunk: String) {
1710-
lineBuf += chunk
1711-
while let i = lineBuf.firstIndex(of: "\n") {
1712-
let line = String(lineBuf[..<i]).replacingOccurrences(of: "\r", with: "")
1713-
lineBuf = String(lineBuf[lineBuf.index(after: i)...])
1714-
handle(line)
1715-
}
1710+
func ingest(_ chunk: String) {
1711+
for line in BuildLog.onMain({ log.feed(chunk) }) { handle(line) }
17161712
}
17171713

17181714
private func flushLines() {
1719-
guard !lineBuf.isEmpty else { return }
1720-
handle(lineBuf); lineBuf = ""
1715+
if let rest = BuildLog.onMain({ log.flush() }) { handle(rest) }
17211716
}
17221717

17231718
private func handle(_ line: String) {
@@ -3394,21 +3389,7 @@ struct BuilderView: View {
33943389
}
33953390

33963391
private var logView: some View {
3397-
ScrollViewReader { sp in
3398-
ScrollView {
3399-
Text(m.logTail.isEmpty ? loc.t("log.placeholder") : m.logTail)
3400-
.font(.mono(11))
3401-
.textSelection(.enabled)
3402-
.frame(maxWidth: .infinity, alignment: .leading)
3403-
.foregroundStyle(.secondary)
3404-
.padding(12)
3405-
.id("end")
3406-
}
3407-
.frame(height: 220)
3408-
.background(Color.black.opacity(0.35), in: RoundedRectangle(cornerRadius: 10))
3409-
.onChange(of: m.logTail) { _ in sp.scrollTo("end", anchor: .bottom) }
3410-
.onAppear { sp.scrollTo("end", anchor: .bottom) }
3411-
}
3392+
BuildLogView(log: m.log,placeholder: loc.t("log.placeholder"))
34123393
}
34133394

34143395
/// The page's conclusion, at the end of its column: what is still missing, and the button.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import SwiftUI
2+
import NXModel
3+
4+
/// The build log as a scrolling text. It is the only view that observes `BuildLog`, so a log line redraws this and not the
5+
/// launcher around it (LNPANDY/NX2Apple#387).
6+
package struct BuildLogView: View {
7+
@ObservedObject var log: BuildLog
8+
let placeholder: String
9+
10+
package init(log: BuildLog, placeholder: String) {
11+
self.log = log
12+
self.placeholder = placeholder
13+
}
14+
15+
package var body: some View {
16+
ScrollViewReader { sp in
17+
ScrollView {
18+
Text(log.text.isEmpty ? placeholder : log.text)
19+
.font(.mono(11))
20+
.textSelection(.enabled)
21+
.frame(maxWidth: .infinity, alignment: .leading)
22+
.foregroundStyle(.secondary)
23+
.padding(12)
24+
.id("end")
25+
}
26+
.frame(height: 220)
27+
.background(Color.black.opacity(0.35), in: RoundedRectangle(cornerRadius: 10))
28+
.onChange(of: log.text) { _ in sp.scrollTo("end", anchor: .bottom) }
29+
.onAppear { sp.scrollTo("end", anchor: .bottom) }
30+
}
31+
}
32+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import Foundation
2+
import Combine
3+
4+
/// The text of the build log (LNPANDY/NX2Apple#387). It is a store of its own, so a line from the build script invalidates
5+
/// the views that show the log and nothing else: `BuildModel` holds it in a `let` and does not publish it, and no view above
6+
/// the log observes it. The failure classifier reads `text` once, when a build stops.
7+
///
8+
/// `text` keeps the tail: past `limit` characters it shrinks to the last `keep`. The pipe delivers arbitrary chunks, so
9+
/// `feed` also splits them into lines and holds back a partial last line until its newline arrives.
10+
@MainActor package final class BuildLog: ObservableObject {
11+
package static let limit = 12000
12+
package static let keep = 9000
13+
14+
@Published package private(set) var text = ""
15+
private var partial = ""
16+
17+
nonisolated package init() {}
18+
19+
/// Adds text to the log. It is not split into lines: the caller ends a line itself.
20+
package func append(_ s: String) {
21+
text += s
22+
if text.count > Self.limit { text = String(text.suffix(Self.keep)) }
23+
}
24+
25+
/// Takes a chunk of the script's output and returns the lines it completes, without their line ends. The rest waits
26+
/// for the next chunk. It does not add to `text`: the caller decides what of each line is log text.
27+
package func feed(_ chunk: String) -> [String] {
28+
partial += chunk
29+
var lines: [String] = []
30+
while let i = partial.firstIndex(of: "\n") {
31+
lines.append(String(partial[..<i]).replacingOccurrences(of: "\r", with: ""))
32+
partial = String(partial[partial.index(after: i)...])
33+
}
34+
return lines
35+
}
36+
37+
/// The partial last line, if any, when the output ends without a newline.
38+
package func flush() -> String? {
39+
guard !partial.isEmpty else { return nil }
40+
defer { partial = "" }
41+
return partial
42+
}
43+
44+
/// Empties the log and drops a partial line: a new build or a stopped one starts clean.
45+
package func clear() {
46+
text = ""
47+
partial = ""
48+
}
49+
50+
/// Replaces the text (tests set a log to classify).
51+
package func set(_ s: String) { text = s }
52+
53+
/// Runs `body` as the main actor's. `BuildModel` is not `@MainActor` yet and every call that reaches the log runs on the
54+
/// main queue already (pipe output is hopped over, the rest is UI actions). This is `MainActor.assumeIsolated` for
55+
/// macOS 13. Drop it when `BuildModel` is `@MainActor`.
56+
nonisolated package static func onMain<T>(_ body: @MainActor () -> T) -> T {
57+
dispatchPrecondition(condition: .onQueue(.main))
58+
typealias Plain = () -> T
59+
return withoutActuallyEscaping(body) { unsafeBitCast($0, to: Plain.self)() }
60+
}
61+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import Combine
2+
import XCTest
3+
@testable import NXModel
4+
5+
/// LNPANDY/NX2Apple#387, step 3: the build log is a store of its own and needs no SwiftUI.
6+
@MainActor final class BuildLogTests: XCTestCase {
7+
func testAppendAddsText() {
8+
let log = BuildLog()
9+
log.append("one\n")
10+
log.append("two\n")
11+
XCTAssertEqual(log.text, "one\ntwo\n")
12+
}
13+
14+
func testTheTextKeepsItsTail() {
15+
let log = BuildLog()
16+
let line = String(repeating: "x", count: 99) + "\n" // 100 characters
17+
for n in 0..<150 { log.append(n == 149 ? "last\n" : line) }
18+
XCTAssertLessThanOrEqual(log.text.count, BuildLog.limit)
19+
XCTAssertGreaterThanOrEqual(log.text.count, BuildLog.keep)
20+
XCTAssertTrue(log.text.hasSuffix("last\n"), "the newest text stays")
21+
}
22+
23+
func testTextUnderTheLimitIsKept() {
24+
let log = BuildLog()
25+
log.append(String(repeating: "y", count: BuildLog.limit))
26+
XCTAssertEqual(log.text.count, BuildLog.limit)
27+
}
28+
29+
func testFeedReturnsCompleteLines() {
30+
let log = BuildLog()
31+
XCTAssertEqual(log.feed("a\nb\n"), ["a", "b"])
32+
XCTAssertNil(log.flush())
33+
}
34+
35+
func testALineAcrossChunkBoundariesIsJoined() {
36+
let log = BuildLog()
37+
XCTAssertEqual(log.feed("hel"), [])
38+
XCTAssertEqual(log.feed("lo wor"), [])
39+
XCTAssertEqual(log.feed("ld\nnext"), ["hello world"])
40+
XCTAssertEqual(log.feed("\n"), ["next"])
41+
}
42+
43+
func testCarriageReturnsAreDropped() {
44+
let log = BuildLog()
45+
// A "\r" inside a line goes, as before. ("\r\n" is one Character in Swift, so it never matches "\n": the old code
46+
// had the same gap, and the script's output has plain "\n".)
47+
XCTAssertEqual(log.feed("a\rb\nc"), ["ab"])
48+
XCTAssertEqual(log.flush(), "c")
49+
}
50+
51+
func testFlushReturnsThePartialLineOnce() {
52+
let log = BuildLog()
53+
_ = log.feed("done\nno newline")
54+
XCTAssertEqual(log.flush(), "no newline")
55+
XCTAssertNil(log.flush())
56+
}
57+
58+
func testFeedDoesNotTouchTheText() {
59+
let log = BuildLog()
60+
_ = log.feed("a\nb\n")
61+
XCTAssertEqual(log.text, "")
62+
}
63+
64+
func testClearEmptiesTheTextAndThePartialLine() {
65+
let log = BuildLog()
66+
log.append("old\n")
67+
_ = log.feed("half")
68+
log.clear()
69+
XCTAssertEqual(log.text, "")
70+
XCTAssertNil(log.flush())
71+
XCTAssertEqual(log.feed("new\n"), ["new"])
72+
}
73+
74+
func testOnlyTheTextPublishes() {
75+
let log = BuildLog()
76+
var changes = 0
77+
let sink = log.objectWillChange.sink { changes += 1 }
78+
_ = log.feed("a\nb")
79+
XCTAssertEqual(changes, 0, "splitting lines is not a change of the text")
80+
log.append("a\n")
81+
XCTAssertEqual(changes, 1)
82+
sink.cancel()
83+
}
84+
}

‎frontend/Tests/NX2AppleTests/LauncherPerformanceTests.swift‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,14 @@ final class LauncherPerformanceTests: XCTestCase {
159159
cards: 120, fonts: 20, plistReads: 110, busyMs: 800),
160160
Interaction(name: "open Manage", action: { pad.perform(.y) }, undo: { pad.perform(.b) },
161161
cards: 180, fonts: 20, plistReads: 180, busyMs: 400),
162+
// A build writes its log while the player looks at the library. The log is its own store (#387), so no game card
163+
// is built for it. Each line is its own turn of the run loop, as the pipe delivers them.
164+
Interaction(name: "1000 log lines in a build", action: {
165+
for n in 0..<1000 {
166+
model.ingest("[12:00:\(String(format: "%02d", n % 60))] line \(n) of the build script's output\n")
167+
RunLoop.main.run(until: Date().addingTimeInterval(0.0005))
168+
}
169+
}, cards: 0, fonts: 20, plistReads: 10, busyMs: 1500),
162170
]
163171
let meter = MainThreadMeter()
164172
defer { meter.stop() }
@@ -202,6 +210,7 @@ final class LauncherPerformanceTests: XCTestCase {
202210
+ "then profile the release build with the Time Profiler.", step.name, busy, step.busyMs))
203211
}
204212
}
213+
XCTAssertTrue(model.log.text.contains("line 999 of the build"), "the lines reached the log")
205214
print(report.joined(separator: "\n"))
206215
for failure in failures { XCTFail(failure) }
207216
#endif

‎frontend/Tests/NX2AppleTests/SourceSizeTests.swift‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ final class SourceSizeTests: XCTestCase {
77

88
/// Path under frontend/Sources → its line count when the limit came in. Lower the number when the file shrinks.
99
private static let aboveTheLimit: [String: Int] = [
10-
"NX2Apple/ContentView.swift": 3749,
10+
"NX2Apple/ContentView.swift": 3730,
1111
"NX2Apple/LauncherView.swift": 973,
1212
"NXCore/Localization.swift": 4237,
1313
]

0 commit comments

Comments
 (0)