diff --git a/.gitattributes b/.gitattributes index 82edc76..6ed67a5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -23,3 +23,5 @@ docs/**/*.mdx linguist-detectable=true linguist-language=MDX .agents/** linguist-detectable=false linguist-generated .claude/** linguist-detectable=false linguist-generated + +*.js diff --git a/apps/native-macos/.swiftformat b/apps/native-macos/.swiftformat index b407da3..6e311fb 100644 --- a/apps/native-macos/.swiftformat +++ b/apps/native-macos/.swiftformat @@ -5,3 +5,4 @@ --importgrouping testable-last --exclude .build --disable swiftTestingTestCaseNames +--disable trailingCommas diff --git a/apps/native-macos/Scripts/entitlements/spike-allow-jit.plist b/apps/native-macos/Scripts/entitlements/spike-allow-jit.plist new file mode 100644 index 0000000..4efe1ce --- /dev/null +++ b/apps/native-macos/Scripts/entitlements/spike-allow-jit.plist @@ -0,0 +1,8 @@ + + + + + com.apple.security.cs.allow-jit + + + diff --git a/apps/native-macos/Scripts/entitlements/spike-allow-unsigned-executable-memory.plist b/apps/native-macos/Scripts/entitlements/spike-allow-unsigned-executable-memory.plist new file mode 100644 index 0000000..46f6756 --- /dev/null +++ b/apps/native-macos/Scripts/entitlements/spike-allow-unsigned-executable-memory.plist @@ -0,0 +1,12 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + + diff --git a/apps/native-macos/Scripts/entitlements/spike-baseline.plist b/apps/native-macos/Scripts/entitlements/spike-baseline.plist new file mode 100644 index 0000000..6631ffa --- /dev/null +++ b/apps/native-macos/Scripts/entitlements/spike-baseline.plist @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/native-macos/Scripts/hardened-runtime-spike.sh b/apps/native-macos/Scripts/hardened-runtime-spike.sh new file mode 100755 index 0000000..a52bbfa --- /dev/null +++ b/apps/native-macos/Scripts/hardened-runtime-spike.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PACKAGE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +BUILD_DIR="$PACKAGE_DIR/.build/release" +APP_BUNDLE="$PACKAGE_DIR/.build/spike/HardenedRuntimeSpike.app" +ENTITLEMENTS_DIR="$SCRIPT_DIR/entitlements" + +echo "==> Building release binary" +swift build -c release --package-path "$PACKAGE_DIR" + +echo "==> Assembling minimal .app bundle" +rm -rf "$APP_BUNDLE" +mkdir -p "$APP_BUNDLE/Contents/MacOS" +cp "$BUILD_DIR/App" "$APP_BUNDLE/Contents/MacOS/App" + +cat > "$APP_BUNDLE/Contents/Info.plist" <<'PLIST' + + + + + CFBundleExecutable + App + CFBundleIdentifier + dev.mc-vector.native.spike.hardened-runtime + CFBundlePackageType + APPL + + +PLIST + +declare -a RESULTS=() + +for plist in "$ENTITLEMENTS_DIR"/spike-*.plist; do + name="$(basename "$plist" .plist)" + echo "==> Signing with entitlements: $name" + codesign --force --options runtime -s - --entitlements "$plist" "$APP_BUNDLE" + + echo "==> Running (MCV_SPIKE=hardened-runtime-java) with $name" + set +e + OUTPUT="$(MCV_SPIKE=hardened-runtime-java "$APP_BUNDLE/Contents/MacOS/App" 2>&1)" + STATUS=$? + set -e + + echo "--- $name (exit=$STATUS) ---" + echo "$OUTPUT" + echo "---------------------------" + RESULTS+=("$name: exit=$STATUS") +done + +echo "" +echo "==> Summary" +for line in "${RESULTS[@]}"; do + echo "$line" +done diff --git a/apps/native-macos/Sources/App/Main.swift b/apps/native-macos/Sources/App/Main.swift index 6f311bf..dabb16f 100644 --- a/apps/native-macos/Sources/App/Main.swift +++ b/apps/native-macos/Sources/App/Main.swift @@ -1,8 +1,49 @@ import Core +import Foundation @main struct Main { - static func main() { - print("MC-Vector Native starting…") + static func main() async { + guard let spike = ProcessInfo.processInfo.environment["MCV_SPIKE"] else { + print("MC-Vector Native starting…") + return + } + + switch spike { + case "hardened-runtime-java": + await self.runHardenedRuntimeJavaSpike() + case "panel-nspanel": + PanelSpikeRunner.runNSPanelBridge() + case "panel-window": + PanelSpikeRunner.runSwiftUIWindowLevel() + case "log-stream": + PanelSpikeRunner.runLogStreamSpike() + default: + print("Unknown MCV_SPIKE value: \(spike)") + } + } + + static func runHardenedRuntimeJavaSpike() async { + let javaCandidates = [ + "/opt/homebrew/opt/openjdk/bin/java", + "/usr/bin/java" + ] + guard let javaPath = javaCandidates.first(where: { FileManager.default.isExecutableFile(atPath: $0) }) else { + print("java executable not found in known locations") + return + } + + let harness = JavaLaunchHarness() + do { + let result = try await harness.launch( + executableURL: URL(fileURLWithPath: javaPath), + arguments: ["-version"], + ) + print("exitCode=\(result.terminationStatus)") + print("stdout=\(result.standardOutput)") + print("stderr=\(result.standardError)") + } catch { + print("launch failed: \(error)") + } } } diff --git a/apps/native-macos/Sources/App/Spikes/PanelSpikeRunner.swift b/apps/native-macos/Sources/App/Spikes/PanelSpikeRunner.swift new file mode 100644 index 0000000..05abae8 --- /dev/null +++ b/apps/native-macos/Sources/App/Spikes/PanelSpikeRunner.swift @@ -0,0 +1,22 @@ +import AppKit +import Core + +@MainActor +enum PanelSpikeRunner { + static func runNSPanelBridge() { + let app = NSApplication.shared + app.setActivationPolicy(.accessory) + let panel = NonactivatingGlassPanel() + panel.center() + panel.makeKeyAndOrderFront(nil) + app.run() + } + + static func runSwiftUIWindowLevel() { + SwiftUIWindowLevelSpike.main() + } + + static func runLogStreamSpike() { + LogStreamSpikeApp.main() + } +} diff --git a/apps/native-macos/Sources/Core/Spikes/JavaLaunchHarness.swift b/apps/native-macos/Sources/Core/Spikes/JavaLaunchHarness.swift new file mode 100644 index 0000000..9abbb9c --- /dev/null +++ b/apps/native-macos/Sources/Core/Spikes/JavaLaunchHarness.swift @@ -0,0 +1,62 @@ +import Foundation + +public struct JavaLaunchResult: Sendable, Equatable { + public let terminationStatus: Int32 + public let standardOutput: String + public let standardError: String + + public init(terminationStatus: Int32, standardOutput: String, standardError: String) { + self.terminationStatus = terminationStatus + self.standardOutput = standardOutput + self.standardError = standardError + } +} + +public enum JavaLaunchError: Error, Sendable { + case executableNotFound(URL) +} + +public actor JavaLaunchHarness { + public init() {} + + public func launch(executableURL: URL, arguments: [String]) async throws -> JavaLaunchResult { + guard FileManager.default.isExecutableFile(atPath: executableURL.path) else { + throw JavaLaunchError.executableNotFound(executableURL) + } + + let process = Process() + process.executableURL = executableURL + process.arguments = arguments + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + try process.run() + + // Read both pipes concurrently: draining stdout fully before starting + // on stderr (or vice versa) deadlocks once a child fills the OS pipe + // buffer on the pipe being read second while the other is still full. + async let stdoutData = stdoutPipe.fileHandleForReading.readToEndCompat() + async let stderrData = stderrPipe.fileHandleForReading.readToEndCompat() + let (stdoutBytes, stderrBytes) = try await (stdoutData, stderrData) + + process.waitUntilExit() + + return JavaLaunchResult( + terminationStatus: process.terminationStatus, + standardOutput: String(bytes: stdoutBytes, encoding: .utf8) ?? "", + standardError: String(bytes: stderrBytes, encoding: .utf8) ?? "", + ) + } +} + +private extension FileHandle { + func readToEndCompat() throws -> Data { + if let data = try self.readToEnd() { + return data + } + return Data() + } +} diff --git a/apps/native-macos/Sources/Core/Spikes/LogSpike/DummyLogGenerator.swift b/apps/native-macos/Sources/Core/Spikes/LogSpike/DummyLogGenerator.swift new file mode 100644 index 0000000..763ea40 --- /dev/null +++ b/apps/native-macos/Sources/Core/Spikes/LogSpike/DummyLogGenerator.swift @@ -0,0 +1,40 @@ +import Foundation + +public struct LogLine: Sendable, Identifiable, Equatable { + public let id: Int + public let timestamp: ContinuousClock.Instant + public let text: String + + public init(id: Int, timestamp: ContinuousClock.Instant, text: String) { + self.id = id + self.timestamp = timestamp + self.text = text + } +} + +public actor DummyLogGenerator { + public init() {} + + public func stream(linesPerSecond: Int) -> AsyncStream { + let interval = Duration.seconds(1) / Double(max(linesPerSecond, 1)) + + return AsyncStream { continuation in + let task = Task { + var index = 0 + let clock = ContinuousClock() + while !Task.isCancelled { + index += 1 + continuation.yield( + LogLine(id: index, timestamp: clock.now, text: "[spike] log line #\(index)"), + ) + try? await Task.sleep(for: interval) + } + continuation.finish() + } + + continuation.onTermination = { _ in + task.cancel() + } + } + } +} diff --git a/apps/native-macos/Sources/Core/Spikes/LogSpike/LogBatcher.swift b/apps/native-macos/Sources/Core/Spikes/LogSpike/LogBatcher.swift new file mode 100644 index 0000000..18a71e0 --- /dev/null +++ b/apps/native-macos/Sources/Core/Spikes/LogSpike/LogBatcher.swift @@ -0,0 +1,35 @@ +import Foundation + +public struct LogBatcher: Sendable { + private let interval: Duration + + public init(interval: Duration) { + self.interval = interval + } + + /// Groups `lines` into batches such that every line whose timestamp falls + /// within the same `interval`-sized window (measured from the first + /// line's timestamp) lands in the same batch, in arrival order. + public func batch(_ lines: [LogLine]) -> [[LogLine]] { + guard let first = lines.first else { return [] } + + var batches: [[LogLine]] = [] + var currentBatch: [LogLine] = [] + var windowStart = first.timestamp + + for line in lines { + if line.timestamp - windowStart >= self.interval, !currentBatch.isEmpty { + batches.append(currentBatch) + currentBatch = [] + windowStart = line.timestamp + } + currentBatch.append(line) + } + + if !currentBatch.isEmpty { + batches.append(currentBatch) + } + + return batches + } +} diff --git a/apps/native-macos/Sources/Core/Spikes/LogSpike/LogLineBuffer.swift b/apps/native-macos/Sources/Core/Spikes/LogSpike/LogLineBuffer.swift new file mode 100644 index 0000000..de3d7d8 --- /dev/null +++ b/apps/native-macos/Sources/Core/Spikes/LogSpike/LogLineBuffer.swift @@ -0,0 +1,22 @@ +/// A fixed-capacity buffer that trims with hysteresis: it only shifts the +/// backing array once the overshoot allowance is exceeded, rather than on +/// every single append. Trimming to exactly `retainedLineCount` on every +/// append would make `Array.removeFirst(_:)` the dominant cost at +/// high ingest rates, masking the rendering cost this spike exists to measure. +public struct LogLineBuffer { + public private(set) var lines: [LogLine] = [] + private let retainedLineCount: Int + private let trimOvershoot: Int + + public init(retainedLineCount: Int, trimOvershoot: Int) { + self.retainedLineCount = retainedLineCount + self.trimOvershoot = trimOvershoot + } + + public mutating func append(_ line: LogLine) { + self.lines.append(line) + if self.lines.count > self.retainedLineCount + self.trimOvershoot { + self.lines.removeFirst(self.lines.count - self.retainedLineCount) + } + } +} diff --git a/apps/native-macos/Sources/Core/Spikes/LogSpike/LogStreamSpikeView.swift b/apps/native-macos/Sources/Core/Spikes/LogSpike/LogStreamSpikeView.swift new file mode 100644 index 0000000..c77e2cb --- /dev/null +++ b/apps/native-macos/Sources/Core/Spikes/LogSpike/LogStreamSpikeView.swift @@ -0,0 +1,81 @@ +import Foundation +import SwiftUI + +public struct LogStreamListView: View { + @State private var buffer: LogLineBuffer + private let generator = DummyLogGenerator() + private let linesPerSecond: Int + + public init(linesPerSecond: Int = 1000, retainedLineCount: Int = 5000, trimOvershoot: Int = 500) { + self.linesPerSecond = linesPerSecond + self._buffer = State( + initialValue: LogLineBuffer(retainedLineCount: retainedLineCount, trimOvershoot: trimOvershoot), + ) + } + + public var body: some View { + List(self.buffer.lines) { line in + Text(line.text) + .font(.system(.caption, design: .monospaced)) + } + .task { + for await line in await self.generator.stream(linesPerSecond: self.linesPerSecond) { + self.buffer.append(line) + } + } + } +} + +public struct LogStreamScrollView: View { + @State private var buffer: LogLineBuffer + private let generator = DummyLogGenerator() + private let linesPerSecond: Int + + public init(linesPerSecond: Int = 1000, retainedLineCount: Int = 5000, trimOvershoot: Int = 500) { + self.linesPerSecond = linesPerSecond + self._buffer = State( + initialValue: LogLineBuffer(retainedLineCount: retainedLineCount, trimOvershoot: trimOvershoot), + ) + } + + public var body: some View { + ScrollView { + LazyVStack(alignment: .leading) { + ForEach(self.buffer.lines) { line in + Text(line.text) + .font(.system(.caption, design: .monospaced)) + } + } + } + .task { + for await line in await self.generator.stream(linesPerSecond: self.linesPerSecond) { + self.buffer.append(line) + } + } + } +} + +public struct LogStreamSpikeApp: App { + public enum Variant: String, Sendable { + case list + case scroll + } + + private let variant: Variant + + public init() { + let raw = ProcessInfo.processInfo.environment["MCV_LOG_SPIKE_VARIANT"] ?? Variant.list.rawValue + self.variant = Variant(rawValue: raw) ?? .list + } + + public var body: some Scene { + WindowGroup("Log Stream Spike") { + switch self.variant { + case .list: + LogStreamListView() + case .scroll: + LogStreamScrollView() + } + } + } +} diff --git a/apps/native-macos/Sources/Core/Spikes/PanelSpike/GlassSpikeContent.swift b/apps/native-macos/Sources/Core/Spikes/PanelSpike/GlassSpikeContent.swift new file mode 100644 index 0000000..742583b --- /dev/null +++ b/apps/native-macos/Sources/Core/Spikes/PanelSpike/GlassSpikeContent.swift @@ -0,0 +1,26 @@ +import SwiftUI + +public struct GlassSpikeContent: View { + private let title: String + + public init(title: String) { + self.title = title + } + + public var body: some View { + VStack(spacing: 12) { + Text(self.title) + .font(.headline) + Text("Move focus to another app and observe this panel.") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(24) + .frame(width: 320, height: 160) + .glassEffect(.regular, in: .rect(cornerRadius: 16)) + } +} + +#Preview { + GlassSpikeContent(title: "NSPanel bridge") +} diff --git a/apps/native-macos/Sources/Core/Spikes/PanelSpike/NonactivatingGlassPanel.swift b/apps/native-macos/Sources/Core/Spikes/PanelSpike/NonactivatingGlassPanel.swift new file mode 100644 index 0000000..a7e9e02 --- /dev/null +++ b/apps/native-macos/Sources/Core/Spikes/PanelSpike/NonactivatingGlassPanel.swift @@ -0,0 +1,21 @@ +import AppKit +import SwiftUI + +@MainActor +public final class NonactivatingGlassPanel: NSPanel { + public init() { + super.init( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 160), + styleMask: [.nonactivatingPanel, .titled, .resizable, .closable], + backing: .buffered, + defer: false, + ) + + self.isFloatingPanel = true + self.level = .floating + self.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + self.titleVisibility = .hidden + self.titlebarAppearsTransparent = true + self.contentView = NSHostingView(rootView: GlassSpikeContent(title: "NSPanel bridge")) + } +} diff --git a/apps/native-macos/Sources/Core/Spikes/PanelSpike/SwiftUIWindowLevelSpike.swift b/apps/native-macos/Sources/Core/Spikes/PanelSpike/SwiftUIWindowLevelSpike.swift new file mode 100644 index 0000000..e3b8d9b --- /dev/null +++ b/apps/native-macos/Sources/Core/Spikes/PanelSpike/SwiftUIWindowLevelSpike.swift @@ -0,0 +1,12 @@ +import SwiftUI + +public struct SwiftUIWindowLevelSpike: App { + public init() {} + + public var body: some Scene { + Window("Glass Spike", id: "glass-spike-window") { + GlassSpikeContent(title: "SwiftUI Window level") + } + .windowLevel(.floating) + } +} diff --git a/apps/native-macos/Tests/CoreTests/JavaLaunchHarnessTests.swift b/apps/native-macos/Tests/CoreTests/JavaLaunchHarnessTests.swift new file mode 100644 index 0000000..f445ae1 --- /dev/null +++ b/apps/native-macos/Tests/CoreTests/JavaLaunchHarnessTests.swift @@ -0,0 +1,37 @@ +import Foundation +import Testing +@testable import Core + +@Test("launch succeeds and captures stdout for a trivial executable") +func javaLaunchHarnessCapturesStdout() async throws { + let harness = JavaLaunchHarness() + let result = try await harness.launch( + executableURL: URL(fileURLWithPath: "/bin/echo"), + arguments: ["hardened-runtime-spike"], + ) + + #expect(result.terminationStatus == 0) + #expect(result.standardOutput.trimmingCharacters(in: .whitespacesAndNewlines) == "hardened-runtime-spike") + #expect(result.standardError.isEmpty) +} + +@Test("launch surfaces non-zero exit codes") +func javaLaunchHarnessSurfacesNonZeroExit() async throws { + let harness = JavaLaunchHarness() + let result = try await harness.launch( + executableURL: URL(fileURLWithPath: "/usr/bin/false"), + arguments: [], + ) + + #expect(result.terminationStatus != 0) +} + +@Test("launch throws for a non-existent executable") +func javaLaunchHarnessThrowsForMissingExecutable() async throws { + let harness = JavaLaunchHarness() + let missingURL = URL(fileURLWithPath: "/nonexistent/path/to/java") + + await #expect(throws: JavaLaunchError.self) { + _ = try await harness.launch(executableURL: missingURL, arguments: []) + } +} diff --git a/apps/native-macos/Tests/CoreTests/LogBatcherTests.swift b/apps/native-macos/Tests/CoreTests/LogBatcherTests.swift new file mode 100644 index 0000000..38fad45 --- /dev/null +++ b/apps/native-macos/Tests/CoreTests/LogBatcherTests.swift @@ -0,0 +1,71 @@ +import Foundation +import Testing +@testable import Core + +@Test("batch groups lines within the same interval window together") +func logBatcherGroupsWithinWindow() { + let clock = ContinuousClock() + let start = clock.now + let lines = [ + LogLine(id: 1, timestamp: start, text: "a"), + LogLine(id: 2, timestamp: start + .milliseconds(10), text: "b"), + LogLine(id: 3, timestamp: start + .milliseconds(20), text: "c") + ] + + let batcher = LogBatcher(interval: .milliseconds(100)) + let batches = batcher.batch(lines) + + #expect(batches.count == 1) + #expect(batches.first?.count == 3) +} + +@Test("batch splits lines that cross the interval boundary") +func logBatcherSplitsAcrossWindows() { + let clock = ContinuousClock() + let start = clock.now + let lines = [ + LogLine(id: 1, timestamp: start, text: "a"), + LogLine(id: 2, timestamp: start + .milliseconds(50), text: "b"), + LogLine(id: 3, timestamp: start + .milliseconds(150), text: "c"), + LogLine(id: 4, timestamp: start + .milliseconds(160), text: "d") + ] + + let batcher = LogBatcher(interval: .milliseconds(100)) + let batches = batcher.batch(lines) + + #expect(batches.count == 2) + #expect(batches[0].map(\.id) == [1, 2]) + #expect(batches[1].map(\.id) == [3, 4]) +} + +@Test("batch returns an empty array for empty input") +func logBatcherHandlesEmptyInput() { + let batcher = LogBatcher(interval: .milliseconds(100)) + #expect(batcher.batch([]).isEmpty) +} + +@Test("batch returns a single batch for a single line") +func logBatcherHandlesSingleLine() { + let line = LogLine(id: 1, timestamp: ContinuousClock().now, text: "a") + let batcher = LogBatcher(interval: .milliseconds(100)) + let batches = batcher.batch([line]) + + #expect(batches.count == 1) + #expect(batches.first?.map(\.id) == [1]) +} + +@Test("batch groups lines sharing an identical timestamp") +func logBatcherGroupsIdenticalTimestamps() { + let timestamp = ContinuousClock().now + let lines = [ + LogLine(id: 1, timestamp: timestamp, text: "a"), + LogLine(id: 2, timestamp: timestamp, text: "b"), + LogLine(id: 3, timestamp: timestamp, text: "c") + ] + + let batcher = LogBatcher(interval: .milliseconds(100)) + let batches = batcher.batch(lines) + + #expect(batches.count == 1) + #expect(batches.first?.map(\.id) == [1, 2, 3]) +} diff --git a/apps/native-macos/Tests/CoreTests/LogLineBufferTests.swift b/apps/native-macos/Tests/CoreTests/LogLineBufferTests.swift new file mode 100644 index 0000000..698fff6 --- /dev/null +++ b/apps/native-macos/Tests/CoreTests/LogLineBufferTests.swift @@ -0,0 +1,28 @@ +import Foundation +import Testing +@testable import Core + +private func makeLine(_ id: Int) -> LogLine { + LogLine(id: id, timestamp: ContinuousClock().now, text: "line #\(id)") +} + +@Test("append retains all lines while under the overshoot threshold") +func logLineBufferRetainsUnderThreshold() { + var buffer = LogLineBuffer(retainedLineCount: 5, trimOvershoot: 2) + for id in 1 ... 7 { + buffer.append(makeLine(id)) + } + + #expect(buffer.lines.count == 7) +} + +@Test("append trims back down to retainedLineCount once overshoot is exceeded") +func logLineBufferTrimsAfterOvershoot() { + var buffer = LogLineBuffer(retainedLineCount: 5, trimOvershoot: 2) + for id in 1 ... 8 { + buffer.append(makeLine(id)) + } + + #expect(buffer.lines.count == 5) + #expect(buffer.lines.map(\.id) == [4, 5, 6, 7, 8]) +} diff --git a/apps/native-macos/Tests/CoreTests/PanelSpikeTests.swift b/apps/native-macos/Tests/CoreTests/PanelSpikeTests.swift new file mode 100644 index 0000000..7349b05 --- /dev/null +++ b/apps/native-macos/Tests/CoreTests/PanelSpikeTests.swift @@ -0,0 +1,12 @@ +import Testing +@testable import Core + +@MainActor +@Test("NonactivatingGlassPanel is configured for floating, non-activating presentation") +func nonactivatingGlassPanelConfiguration() { + let panel = NonactivatingGlassPanel() + + #expect(panel.styleMask.contains(.nonactivatingPanel)) + #expect(panel.isFloatingPanel) + #expect(panel.level == .floating) +} diff --git a/spec/native-macos-requirements.md b/spec/native-macos-requirements.md index 16c1f35..f8c0d65 100644 --- a/spec/native-macos-requirements.md +++ b/spec/native-macos-requirements.md @@ -267,9 +267,10 @@ lint-format-swift: | Swift側とTauri側でロジックがドリフトする | 中 | データ契約(JSONスキーマ)のみ厳密に一致させる運用ルール。機能追加時は両実装のチェックリスト化を検討 | | `security.rs`相当の正しさが重要な処理の移植漏れ | 中 | Rust側の実装とテストケースを忠実にSwift側へ移植する運用ルール(§4.4) | | プラグイン解決ロジックの二重実装コスト | 低(現Spike範囲外) | Native版でプラグイン管理を実装する段階で再評価 | -| glass非アクティブ劣化 | 低 | 実機検証→Materialフォールバック(確定済み) | -| `.xcodeproj`なしによるXcode GUI機能の一部制約(Instruments連携等) | 低 | Xcodeは`Package.swift`を直接開けるため多くの機能は利用可能。制約が顕在化したら`.xcodeproj`併用を再検討 | +| glass非アクティブ劣化 | 低 | 実機検証→Materialフォールバック(確定済み)。Phase 3-Aでスキャフォールド実装済み、実機での目視判定は`spec/phase3a-spike-results.md`参照(検証待ち) | +| `.xcodeproj`なしによるXcode GUI機能の一部制約(Instruments連携等) | 低 | Xcodeは`Package.swift`を直接開けるため多くの機能は利用可能。制約が顕在化したら`.xcodeproj`併用を再検討。Phase 3-Aで`record_trace.py`/`analyze_trace.py`によるCLI経由のトレース取得・解析が可能なことを確認(`spec/phase3a-spike-results.md`) | | macOSランナーCIコスト | 低 | Lintはubuntu-latestに寄せ、ビルドのみmacos-latest | +| Hardened Runtime下でのJavaプロセス起動可否 | 低(検証済み) | Phase 3-Aで検証: 追加entitlementsなしでも`java -version`はexit 0で起動可能。実ワークロードでの再検証は3-7(サーバー起動/停止実装)で実施(`spec/phase3a-spike-results.md`) | ## 7. 主要出典 diff --git a/spec/phase3a-spike-results.md b/spec/phase3a-spike-results.md new file mode 100644 index 0000000..06f63c8 --- /dev/null +++ b/spec/phase3a-spike-results.md @@ -0,0 +1,64 @@ +# Phase 3-A: 実機検証3項目 — 結果記録 + +> 関連: `spec/phase-tasks.md`(Phase 3-Aタスク定義), `spec/native-macos-requirements.md` §5.4/§5.5/§6 + +このセッションはバックグラウンドジョブとして起動されておりWindowServerに接続されていないため(`screencapture`が`could not create image from display`で失敗することを確認済み)、3-1と3-3はスキャフォールド・自動テスト・解析スクリプトまでをこのセッションで用意し、実機でのスクリーンショット撮影/トレース記録をユーザーに依頼する形で進めている。3-2はGUI操作が不要なため、このセッションのみで検証まで完結した。 + +## 3-1: NSPanel×glassEffect 非アクティブ時劣化 + +| 項目 | 内容 | +|---|---| +| 検証日 | 未実施(ユーザー実機待ち) | +| 実施環境 | ユーザー実機(macOS Tahoe 26+) | +| 結果 | — | +| 判定 | 未確定 | +| 証跡パス | `apps/native-macos/spec-assets/3-1/{nspanel,window}-{active,inactive}.png`(未作成) | + +**実装済みスキャフォールド**: `apps/native-macos/Sources/Core/Spikes/PanelSpike/`(`GlassSpikeContent.swift`, `NonactivatingGlassPanel.swift`, `SwiftUIWindowLevelSpike.swift`)、`apps/native-macos/Sources/App/Spikes/PanelSpikeRunner.swift`。 + +**ユーザーへの依頼**: `swift run App` を `MCV_SPIKE=panel-nspanel` と `MCV_SPIKE=panel-window` のそれぞれで起動し、他アプリへフォーカスを移して非アクティブ化した状態を含むスクリーンショットを4枚(`nspanel-active.png` / `nspanel-inactive.png` / `window-active.png` / `window-inactive.png`)撮影の上、`apps/native-macos/spec-assets/3-1/` 配下に保存してほしい。保存後、Claudeが画像を読み込み劣化度合いを比較し、本セクションと `native-macos-requirements.md` §5.4 に確定した実装方式を追記する。 + +## 3-2: Hardened Runtime下のJavaプロセス起動 + +| 項目 | 内容 | +|---|---| +| 検証日 | 2026-07-08 | +| 実施環境 | このセッション(macOS 26 / Xcode 26.5.1 / Swift 6.3、Homebrew OpenJDK 25.0.2) | +| 結果 | baseline(entitlements無し)/ `allow-jit` / `allow-unsigned-executable-memory`+`disable-library-validation` の3パターンいずれも `java -version` がexit code 0で成功。stderrにJVMバージョン情報が正常出力され、`Killed`やcodesign関連エラーは発生しなかった | +| 判定 | Hardened Runtime + ad-hoc署名下でも、追加entitlementsなしでJavaサブプロセスの起動自体は可能。ただし検証は`-version`起動のみであり、JITを本格的に使う実ワークロード(サーバー起動後の稼働)でのentitlements要否は未検証 | +| 証跡パス | `apps/native-macos/Scripts/hardened-runtime-spike.sh` 実行ログ(本ファイル上部に転記) | + +**実装済み**: `apps/native-macos/Sources/Core/Spikes/JavaLaunchHarness.swift`(actor化したProcessラッパー)、`apps/native-macos/Scripts/hardened-runtime-spike.sh`、`apps/native-macos/Scripts/entitlements/spike-{baseline,allow-jit,allow-unsigned-executable-memory}.plist`。 + +**実行結果ログ(要約)**: + +``` +spike-allow-jit: exit=0 +spike-allow-unsigned-executable-memory: exit=0 +spike-baseline: exit=0 +``` + +**今後の課題**: 実際のMinecraftサーバーjarを長時間稼働させた際のJIT挙動・GC・ネイティブライブラリロード(一部modが使用)まで含めた検証は3-Bのサーバー起動/停止実装(3-7)時に改めて行う。 + +**コードレビュー指摘への対応**: 初回実装の`JavaLaunchHarness.launch`はstdout→stderrの順に`readToEnd()`を逐次実行しており、子プロセスが標準出力を閉じないまま標準エラーへ大量出力するとOSパイプバッファが埋まりデッドロックする既知の`Process`/`Pipe`アンチパターンだった(`java -version`はstderrのみの小出力のため顕在化しなかった)。3-7でこのハーネスを実サーバー起動に転用する前提のため、`async let`で両パイプを並行読み取りする実装に修正済み(`JavaLaunchHarness.swift`)。 + +## 3-3: 高頻度ログ描画パフォーマンス + +| 項目 | 内容 | +|---|---| +| 検証日 | 未実施(ユーザー実機待ち) | +| 実施環境 | ユーザー実機(macOS Tahoe 26+、Instruments) | +| 結果 | — | +| 判定 | 未確定 | +| 証跡パス | `.trace`ファイル(未取得、パスは取得後にユーザーから共有) | + +**実装済みスキャフォールド**: `apps/native-macos/Sources/Core/Spikes/LogSpike/`(`DummyLogGenerator.swift`, `LogBatcher.swift`, `LogLineBuffer.swift`, `LogStreamSpikeView.swift`)。`LogBatcher`のユニットテストは`Tests/CoreTests/LogBatcherTests.swift`で完了済み(同一ウィンドウ内のグルーピング、ウィンドウ境界での分割、単一行、同一タイムスタンプ、空入力)。 + +**コードレビュー指摘への対応**: 初回実装は`List`/`ScrollView`両方とも「1行追加するたびに`retainedLineCount`ちょうどまで`removeFirst`する」実装で、1000行/秒の負荷下では毎行O(n)の配列シフトが発生し、Instrumentsトレースが本来測定したい「List vs ScrollViewのレンダリングコスト差」ではなく「配列シフトのコスト」を支配的に示してしまう懸念があった。`LogLineBuffer`(ヒステリシス付きトリム: `retainedLineCount + trimOvershoot`を超えた時だけまとめてトリム)を切り出し両Viewで共有する実装に修正し、`Tests/CoreTests/LogLineBufferTests.swift`でトリム挙動を検証済み。 + +**ユーザーへの依頼**: `swift run App` を `MCV_SPIKE=log-stream`(`MCV_LOG_SPIKE_VARIANT=list` または `scroll`)で起動し、`.claude/skills/swiftui-expert-skill/scripts/record_trace.py --launch --template "SwiftUI" --time-limit 30s` でトレースを取得してほしい。取得済み`.trace`はClaudeが`analyze_trace.py --trace `で解析し、ヒッチ/CPUホットスポットからバッチ化/仮想化戦略を決定する。 + +## まとめ + +- 3-2は検証完了。追加entitlementsなしでもJavaサブプロセス起動自体は妨げられないことが判明し、`native-macos-requirements.md` §5.3の「entitlements要否の洗い出し」という設計課題に対する一次情報が得られた。 +- 3-1・3-3はスキャフォールド・自動テストまで完了し、実機でのみ可能な操作(スクリーンショット撮影・Instrumentsトレース記録)をユーザーに依頼した状態で本タスクを一旦クローズする。成果物が揃い次第、このファイルと`native-macos-requirements.md` §5.4/§6を追加コミットで更新する。