Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ jobs:
CODE_SIGNING_ALLOWED=NO
fi

- name: Test FrameGenKit and the command-line tool
shell: bash
run: swift test --package-path Packages/FrameGenKit

- name: Upload test results
if: failure()
uses: actions/upload-artifact@v4
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,6 @@ xcuserdata/
# Build products
build/
DerivedData/
Packages/*/.build/
*.profraw
*.xcarchive
240 changes: 192 additions & 48 deletions FrameGen.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion FrameGen.xcodeproj/xcshareddata/xcschemes/FrameGen.xcscheme
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
shouldAutocreateTestPlan = "YES"
language = "en"
region = "US">
<Testables>
<TestableReference
skipped = "NO"
Expand Down
2 changes: 1 addition & 1 deletion FrameGen/Application/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}

func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
mainWindowController.confirmStoppingBuild(action: "Quit") ? .terminateNow : .terminateCancel
mainWindowController.confirmStoppingBuild(action: String(localized: "Stop and Quit")) ? .terminateNow : .terminateCancel
}

func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {
Expand Down
1 change: 1 addition & 0 deletions FrameGen/Application/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
<array>
<string>com.apple.xcode.project</string>
<string>public.folder</string>
<string>com.apple.dt.document.workspace</string>
</array>
</dict>
</array>
Expand Down
66 changes: 35 additions & 31 deletions FrameGen/Application/Menus/MainMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
//

import AppKit
import FrameGenKit

/// Builds the standard Mac menu bar plus the Product menu. Items target the responder chain,
/// so the main window controller handles and validates the project commands.
Expand All @@ -16,7 +17,7 @@ final class MainMenu: NSObject {

let menu = NSMenu(title: "Main Menu")
private let applicationName = ProcessInfo.processInfo.processName
private let recentProjectsMenu = NSMenu(title: "Open Recent")
private let recentProjectsMenu = NSMenu(title: String(localized: "Open Recent"))

// MARK: - Initial methods

Expand All @@ -25,73 +26,76 @@ final class MainMenu: NSObject {

recentProjectsMenu.delegate = self
menu.items = [submenu(applicationName, items: applicationItems()),
submenu("File", items: fileItems()),
submenu("Edit", items: editItems()),
submenu("Product", items: productItems()),
submenu(String(localized: "File"), items: fileItems()),
submenu(String(localized: "Edit"), items: editItems()),
submenu(String(localized: "Product"), items: productItems()),
windowMenu(),
helpMenu()]
}

// MARK: - Private methods

private func applicationItems() -> [NSMenuItem] {
let services = NSMenuItem(title: "Services", action: nil, keyEquivalent: "")
services.submenu = NSMenu(title: "Services")
let services = NSMenuItem(title: String(localized: "Services"), action: nil, keyEquivalent: "")
services.submenu = NSMenu(title: String(localized: "Services"))
NSApp.servicesMenu = services.submenu

return [item("About \(applicationName)", #selector(NSApplication.orderFrontStandardAboutPanel(_:))),
return [item(String(localized: "About \(applicationName)"), #selector(NSApplication.orderFrontStandardAboutPanel(_:))),
.separator(),
item("Settings…", #selector(AppDelegate.showSettings(_:)), key: ","),
item(String(localized: "Settings…"), #selector(AppDelegate.showSettings(_:)), key: ","),
.separator(),
services,
.separator(),
item("Hide \(applicationName)", #selector(NSApplication.hide(_:)), key: "h"),
item("Hide Others", #selector(NSApplication.hideOtherApplications(_:)), key: "h", modifiers: [.command, .option]),
item("Show All", #selector(NSApplication.unhideAllApplications(_:))),
item(String(localized: "Hide \(applicationName)"), #selector(NSApplication.hide(_:)), key: "h"),
item(String(localized: "Hide Others"), #selector(NSApplication.hideOtherApplications(_:)), key: "h", modifiers: [.command, .option]),
item(String(localized: "Show All"), #selector(NSApplication.unhideAllApplications(_:))),
.separator(),
item("Quit \(applicationName)", #selector(NSApplication.terminate(_:)), key: "q")]
item(String(localized: "Quit \(applicationName)"), #selector(NSApplication.terminate(_:)), key: "q")]
}

private func fileItems() -> [NSMenuItem] {
let openRecent = NSMenuItem(title: "Open Recent", action: nil, keyEquivalent: "")
let openRecent = NSMenuItem(title: String(localized: "Open Recent"), action: nil, keyEquivalent: "")
openRecent.submenu = recentProjectsMenu

return [item("Open Project…", #selector(MainWindowController.openDocument(_:)), key: "o"),
return [item(String(localized: "Open Project…"), #selector(MainWindowController.openDocument(_:)), key: "o"),
openRecent,
.separator(),
item("Close Project", #selector(MainWindowController.closeProject(_:)), key: "w", modifiers: [.command, .shift]),
item("Close Window", #selector(NSWindow.performClose(_:)), key: "w")]
item(String(localized: "Close Project"), #selector(MainWindowController.closeProject(_:)), key: "w", modifiers: [.command, .shift]),
item(String(localized: "Close Window"), #selector(NSWindow.performClose(_:)), key: "w")]
}

private func editItems() -> [NSMenuItem] {
[item("Undo", Selector(("undo:")), key: "z"),
item("Redo", Selector(("redo:")), key: "z", modifiers: [.command, .shift]),
[item(String(localized: "Undo"), Selector(("undo:")), key: "z"),
item(String(localized: "Redo"), Selector(("redo:")), key: "z", modifiers: [.command, .shift]),
.separator(),
item("Cut", #selector(NSText.cut(_:)), key: "x"),
item("Copy", #selector(NSText.copy(_:)), key: "c"),
item("Paste", #selector(NSText.paste(_:)), key: "v"),
item("Select All", #selector(NSText.selectAll(_:)), key: "a")]
item(String(localized: "Cut"), #selector(NSText.cut(_:)), key: "x"),
item(String(localized: "Copy"), #selector(NSText.copy(_:)), key: "c"),
item(String(localized: "Paste"), #selector(NSText.paste(_:)), key: "v"),
item(String(localized: "Select All"), #selector(NSText.selectAll(_:)), key: "a")]
}

private func productItems() -> [NSMenuItem] {
[item("Build XCFramework", #selector(MainWindowController.buildXCFramework(_:)), key: "b"),
item("Stop", #selector(MainWindowController.stopBuild(_:)), key: "."),
[item(String(localized: "Build XCFramework"), #selector(MainWindowController.buildXCFramework(_:)), key: "b"),
item(String(localized: "Stop"), #selector(MainWindowController.stopBuild(_:)), key: "."),
.separator(),
item("Show Output in Finder", #selector(MainWindowController.showOutputInFinder(_:)), key: "r", modifiers: [.command, .shift])]
item(String(localized: "Publish"), #selector(MainWindowController.publishXCFramework(_:)), key: "u"),
item(String(localized: "Copy Build Command"), #selector(MainWindowController.copyBuildCommand(_:)), key: "c", modifiers: [.command, .option]),
.separator(),
item(String(localized: "Show Output in Finder"), #selector(MainWindowController.showOutputInFinder(_:)), key: "r", modifiers: [.command, .shift])]
}

private func windowMenu() -> NSMenuItem {
let menuItem = submenu("Window", items: [item("Minimize", #selector(NSWindow.performMiniaturize(_:)), key: "m"),
item("Zoom", #selector(NSWindow.performZoom(_:))),
let menuItem = submenu(String(localized: "Window"), items: [item(String(localized: "Minimize"), #selector(NSWindow.performMiniaturize(_:)), key: "m"),
item(String(localized: "Zoom"), #selector(NSWindow.performZoom(_:))),
.separator(),
item("Bring All to Front", #selector(NSApplication.arrangeInFront(_:)))])
item(String(localized: "Bring All to Front"), #selector(NSApplication.arrangeInFront(_:)))])
NSApp.windowsMenu = menuItem.submenu

return menuItem
}

private func helpMenu() -> NSMenuItem {
let menuItem = submenu("Help", items: [])
let menuItem = submenu(String(localized: "Help"), items: [])
NSApp.helpMenu = menuItem.submenu

return menuItem
Expand Down Expand Up @@ -126,7 +130,7 @@ extension MainMenu: NSMenuDelegate {
guard menu === recentProjectsMenu else { return }

let recentProjects = Preferences.recentProjects
menu.items = recentProjects.map { url in
menu.items = recentProjects.map(\.url).map { url in
let menuItem = NSMenuItem(title: url.deletingPathExtension().lastPathComponent,
action: #selector(MainWindowController.openRecentProject(_:)),
keyEquivalent: "")
Expand All @@ -140,7 +144,7 @@ extension MainMenu: NSMenuDelegate {
if !recentProjects.isEmpty {
menu.addItem(.separator())
}
menu.addItem(item("Clear Menu", #selector(MainWindowController.clearRecentProjects(_:))))
menu.addItem(item(String(localized: "Clear Menu"), #selector(MainWindowController.clearRecentProjects(_:))))
}

}
158 changes: 69 additions & 89 deletions FrameGen/Controllers/BuildController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,132 +7,112 @@
//

import AppKit
import FrameGenKit

/// Runs the build pipeline for a project: archive every platform, create the XCFramework,
/// zip it and compute the SwiftPM checksum. Progress is published through `Project.build`.
/// Runs `XCFrameworkPipeline` for a project and mirrors its events into `Project.build`,
/// then publishes the result on request. The same pipeline powers the `framegen` tool.
final class BuildController {

// MARK: - Properties

private var task: Task<Void, Never>?
private var publishTask: Task<Void, Never>?
private let notifier = BuildNotifier()

// MARK: - Public methods

func start(_ project: Project) {
guard project.canBuild, let scheme = project.scheme, let configuration = project.configuration else { return }
guard project.canBuild, let request = project.makeBuildRequest() else { return }

let platforms = project.selectedPlatforms
let state = project.build
state.start(steps: platforms.map { BuildStep(kind: .archive($0)) }
+ [BuildStep(kind: .createXCFramework), BuildStep(kind: .package)])
state.start(steps: [])
notifier.requestAuthorizationIfNeeded()
task = Task {
await run(project: project.fileURL,
scheme: scheme,
configuration: configuration,
platforms: platforms,
outputDirectory: project.outputDirectory,
state: state)
await run(request, projectName: project.name, state: state)
task = nil
}
}

func cancel() {
task?.cancel()
publishTask?.cancel()
}

// MARK: - Private methods

private func run(project: URL,
scheme: String,
configuration: String,
platforms: [Platform],
outputDirectory: URL,
state: BuildState) async {
let archivesDirectory = outputDirectory.appendingPathComponent("Archives", isDirectory: true)
var stepIndex = 0
do {
var archiveURLs: [URL] = []
for platform in platforms {
let archiveURL = archivesDirectory.appendingPathComponent("\(scheme)-\(platform.archiveSuffix).xcarchive")
try await perform(step: stepIndex, in: state) {
try await self.stream(XcodeCommand.archive(project: project,
scheme: scheme,
configuration: configuration,
platform: platform,
archiveURL: archiveURL),
into: state)
}
archiveURLs.append(archiveURL)
stepIndex += 1
}

let plan = try await perform(step: stepIndex, in: state) {
let plan = try await XCFrameworkBuilder.makePlan(archiveURLs: archiveURLs, outputDirectoryURL: outputDirectory)
try await self.stream(XcodeCommand.createXCFramework(slices: plan.slices, output: plan.xcframeworkURL), into: state)
func publish(_ project: Project) {
guard
case .succeeded(let summary) = project.build.phase,
let destination = project.publishDestination,
project.build.publishState != .publishing
else {
return
}

return plan
let state = project.build
let version = project.version
state.publishState = .publishing
publishTask = Task {
do {
let url = try await Publisher.publish(summary, version: version, to: destination) { state.appendLog($0) }
let updated = try summary.withDownloadURL(url)
state.finish(.succeeded(updated))
state.publishState = .published(url)
} catch is CancellationError {
state.publishState = .idle
} catch ProcessError.canceled {
state.publishState = .idle
} catch let ProcessError.failed(_, output) {
state.publishState = .failed(LogAnalyzer.highlights(in: output.components(separatedBy: .newlines), limit: 5)
.joined(separator: "\n"))
} catch {
state.publishState = .failed(error.localizedDescription)
}
stepIndex += 1

let summary = try await perform(step: stepIndex, in: state) {
try await self.stream(XcodeCommand.zip(source: plan.xcframeworkURL, destination: plan.zipURL), into: state)
publishTask = nil
}
}

return try await XCFrameworkBuilder.finalize(plan)
}
// MARK: - Private methods

if !Preferences.keepArchives {
try? FileManager.default.removeItem(at: archivesDirectory)
private func run(_ request: BuildRequest, projectName: String, state: BuildState) async {
do {
let summary = try await XCFrameworkPipeline.run(request) { event in
switch event {
case .planned(let steps):
state.steps = steps.map { BuildStep(kind: $0) }
case .stepStarted(let index):
state.steps[index].status = .running(since: .now)
case let .stepFinished(index, duration):
state.steps[index].status = .succeeded(duration: duration)
case .stepFailed(let index):
state.steps[index].status = .failed
case .log(let line):
state.appendLog(line)
}
}
state.finish(.succeeded(summary))
notifier.notify(.succeeded(projectName: projectName, frameworkName: summary.plan.frameworkName))
if Preferences.revealInFinder {
NSWorkspace.shared.activateFileViewerSelecting([summary.plan.xcframeworkURL])
}
} catch is CancellationError {
state.reset()
} catch ProcessError.canceled {
state.reset()
} catch let error as PipelineError {
let step = BuildStep(kind: error.step)
let reason: String
if case let ProcessError.failed(status, _) = error.underlying {
reason = String(localized: "The command exited with code \(status). See the highlighted lines below or the full log.")
} else {
reason = error.underlying.localizedDescription
}
state.finish(.failed(BuildFailure(title: step.failureTitle, reason: reason, highlights: error.highlights)))
notifier.notify(.failed(projectName: projectName, title: step.failureTitle))
} catch {
state.finish(.failed(Self.failure(for: error, step: state.steps[stepIndex], log: state.log)))
state.finish(.failed(BuildFailure(title: String(localized: "The build couldn’t start"),
reason: error.localizedDescription,
highlights: [])))
}
NSApp.requestUserAttention(.informationalRequest)
}

private func perform<T>(step index: Int, in state: BuildState, _ body: () async throws -> T) async throws -> T {
let start = Date.now
state.steps[index].status = .running(since: start)
do {
let result = try await body()
state.steps[index].status = .succeeded(duration: Date.now.timeIntervalSince(start))

return result
} catch {
state.steps[index].status = Task.isCancelled ? .pending : .failed
throw error
}
}

private func stream(_ runner: ProcessRunner, into state: BuildState) async throws {
for try await line in runner.lines() {
state.appendLog(line)
}
try Task.checkCancellation()
}

private static func failure(for error: Error, step: BuildStep, log: [String]) -> BuildFailure {
let reason: String
if case let ProcessError.failed(status, _) = error {
reason = "The command exited with code \(status). See the highlighted lines below or the full log."
} else {
reason = error.localizedDescription
}

var seen = Set<String>()
let errors = log
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { $0.contains("error:") && seen.insert($0).inserted }
let highlights = errors.isEmpty ? log.suffix(20).filter { !$0.isEmpty } : Array(errors.prefix(20))

return BuildFailure(title: step.failureTitle, reason: reason, highlights: highlights)
}

}
Loading