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
6 changes: 6 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
build:
swift build

# Build, wrap the result in a development app bundle and start it: what the Apple Vision Pro
# preview needs (see scripts/dev-app.sh). CONFIG=release for the release build.
CONFIG ?= debug
run-app:
scripts/dev-app.sh --run $(CONFIG)

# Clean build artifact

clean:
Expand Down
15 changes: 15 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ let package = Package(
.product(name: "UntoldComponentKit", package: "UntoldEngine"),
],
path: "Sources/UntoldEditor",
exclude: [
"Info.plist",
],
resources: [
.process("Resources/Thumbnails"),
],
Expand All @@ -34,6 +37,18 @@ let package = Package(
.linkedFramework("Metal"),
.linkedFramework("MetalKit"),
.linkedFramework("QuartzCore"),
// The identity of the executable run from the build folder (`swift run`, Xcode),
// which has no app bundle around it: its bundle identifier and name, read from
// the Info.plist embedded in the binary. The system's device picker of the Apple
// Vision Pro preview traps without one, and the settings live under it, shared
// with the packaged app (create_app_bundle.sh writes the bundle's own Info.plist).
// An incremental build does not notice a change to the file: clean, or touch a source.
.unsafeFlags([
"-Xlinker", "-sectcreate",
"-Xlinker", "__TEXT",
"-Xlinker", "__info_plist",
"-Xlinker", Context.packageDirectory + "/Sources/UntoldEditor/Info.plist",
]),
]
),

Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,16 @@ swift build
swift run UntoldEditor
```

### Run as an app, for the Apple Vision Pro preview

`swift run` and Xcode start the editor as a bare executable. View ▸ Preview on Apple Vision Pro (macOS 26, a Vision Pro on visionOS 26 nearby, both on the same Apple Account) needs the editor to run as an app that LaunchServices knows, because the system's device picker asks LaunchServices for the app behind the request. This makes a development app bundle around the build in seconds and starts it:

```bash
scripts/dev-app.sh --run # the debug build; add `release` for the release build
```

It builds, wraps the result in `Untold Engine Studio.app` inside the build folder (the executable copied, the resources and the build products linked), registers the bundle, and starts it with its output in the terminal; `make run-app` does the same. Run it again after a build. The packaged app from `create_app_bundle.sh` works as well.

### Open in Xcode (recommended)

1. Open Xcode → File ▸ Open → select the Package.swift in this repo
Expand Down
15 changes: 15 additions & 0 deletions Sources/UntoldEditor/Components/ComponentSDK.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ struct ComponentSDK: Equatable {

static let bundleDirectoryName = "ComponentSDK"
static let manifestFileName = "sdk.json"
/// A development app bundle (`scripts/dev-app.sh`) links the build products it wraps here,
/// beside its resources: its executable is a copy that sits apart from them.
static let buildProductsLinkName = "BuildProducts"
/// The engine's own C module: every SDK has it.
static let cShaderTypesModule = "CShaderTypes"

Expand Down Expand Up @@ -73,12 +76,24 @@ struct ComponentSDK: Equatable {
{
return bundled
}
if let products = developmentBuildProducts(resourceURL: resourceURL, fileManager: fileManager) {
return resolveFromBuildProducts(productsDirectory: products, fileManager: fileManager)
}
guard let executableURL else { return nil }
// `.build/debug` is a symlink; the module maps are found relative to the real directory.
let products = executableURL.resolvingSymlinksInPath().deletingLastPathComponent()
return resolveFromBuildProducts(productsDirectory: products, fileManager: fileManager)
}

/// The build products a development app bundle wraps, from the link beside its resources;
/// nil for a packaged app and for an executable run from the build folder.
static func developmentBuildProducts(resourceURL: URL?, fileManager: FileManager = .default) -> URL? {
guard let link = resourceURL?.appendingPathComponent(buildProductsLinkName, isDirectory: true) else { return nil }
var isDirectory: ObjCBool = false
guard fileManager.fileExists(atPath: link.path, isDirectory: &isDirectory), isDirectory.boolValue else { return nil }
return link.resolvingSymlinksInPath()
}

static func resolveBundled(at sdkRoot: URL, fileManager: FileManager = .default) -> ComponentSDK? {
let modules = sdkRoot.appendingPathComponent("Modules", isDirectory: true)
let manifestURL = sdkRoot.appendingPathComponent(manifestFileName)
Expand Down
10 changes: 7 additions & 3 deletions Sources/UntoldEditor/Components/EditorEnginePackage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,20 @@ import UntoldEngine
/// engine is what makes "it compiles in Xcode" and "it compiles in the editor" mean the same.
enum EditorEnginePackage {
/// A packaged editor reads it from its Component SDK; an editor run from source finds the
/// `Package.resolved` of its own checkout above the executable. `nil` when neither exists,
/// in which case new projects keep the engine's default reference and get no plugins folder.
/// `Package.resolved` of its own checkout above the executable, or above the build products
/// a development app bundle links. `nil` when none exists, in which case new projects keep
/// the engine's default reference and get no plugins folder.
static func resolve(
sdk: ComponentSDK? = ComponentSDK.resolve(),
resourceURL: URL? = Bundle.main.resourceURL,
executableURL: URL? = Bundle.main.executableURL
) -> EnginePackageReference? {
if let sdk, let url = sdk.engineURL, let revision = sdk.engineRevision {
return EnginePackageReference(url: url, requirement: .revision(revision))
}
guard var directory = executableURL?.resolvingSymlinksInPath().deletingLastPathComponent() else { return nil }
guard var directory = ComponentSDK.developmentBuildProducts(resourceURL: resourceURL)
?? executableURL?.resolvingSymlinksInPath().deletingLastPathComponent()
else { return nil }
for _ in 0 ..< 8 {
let candidate = directory.appendingPathComponent("Package.resolved")
if let reference = reference(fromResolvedFile: candidate) {
Expand Down
50 changes: 50 additions & 0 deletions Sources/UntoldEditor/Config/EditorSettingsDomain.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
//
// EditorSettingsDomain.swift
// UntoldEditor
//
// Copyright (C) Untold Engine Studios
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
//

import Foundation

/// Where the editor's settings live. An executable run from the build folder
/// (`swift run`, Xcode) had no bundle, so `UserDefaults` kept them under its
/// name, `UntoldEditor`. With the identity the executable carries now they
/// live under the app's identifier, as the packaged app's do: the first launch
/// with the identifier takes over what the old domain holds.
enum EditorSettingsDomain {
/// The domain an executable without an identity gets: its name.
static let legacyName = "UntoldEditor"

/// Set in the current domain once the old one has been looked at.
static let adoptedKey = "EditorSettingsDomain.adoptedLegacySettings"

/// Copies every setting of the old domain that the current one lacks, once;
/// a setting the current domain already has stays. Returns the keys copied.
@discardableResult
static func adoptLegacySettings(
defaults: UserDefaults = .standard,
current: String? = Bundle.main.bundleIdentifier,
legacy: String = legacyName
) -> [String] {
guard let current, current != legacy else {
return []
}
var settings = defaults.persistentDomain(forName: current) ?? [:]
guard settings[adoptedKey] == nil else {
return []
}
settings[adoptedKey] = true
var copied: [String] = []
for (key, value) in defaults.persistentDomain(forName: legacy) ?? [:] where settings[key] == nil {
settings[key] = value
copied.append(key)
}
defaults.setPersistentDomain(settings, forName: current)
return copied.sorted()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
//
// EditorPillTextButton.swift
// UntoldEditor
//
// Copyright (C) Untold Engine Studios
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
//

import SwiftUI

/// A text button inside an `EditorPillGroup` that does something at once: an
/// optional glyph and the title, 26 pt, lit in the accent colour while what it
/// started goes on, with no fill of its own so the group's pill is the surface.
/// The preview control uses it.
struct EditorPillTextButton: View {
let title: String
var systemImage: String?
var isActive = false
var isEnabled = true
var help = ""
let action: () -> Void

var body: some View {
Button(action: action) {
HStack(spacing: 6) {
if let systemImage {
Image(systemName: systemImage)
.font(.system(size: 12, weight: .semibold))
}
Text(title)
.font(EditorType.body)
.lineLimit(1)
.fixedSize()
}
.foregroundColor(color)
.padding(.horizontal, 10)
.frame(height: 26)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.focusable(false)
.disabled(isEnabled == false)
.help(help)
}

private var color: Color {
if isEnabled == false {
return Color.editorTextDisabled
}
return isActive ? Color.editorAccent : Color.editorTextPrimary
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import SwiftUI

/// The toolbar row that shares the window's title bar: the traffic lights on
/// the left (drawn by macOS), the project chip, Undo / Redo / History, the play
/// controls in the middle, and the build target on the right. The search field
/// controls in the middle, and the preview and the build target on the right. The search field
/// of the mockup joins it with the command palette (stage 1.9). Global chrome:
/// it shows in every experience mode. Dragging its empty space moves the
/// window, as the title bar it replaces did.
Expand Down Expand Up @@ -44,8 +44,11 @@ struct EditorToolbarView: View {
)
UndoRedoControls()
Spacer(minLength: 0)
PreviewControl()
BuildTargetMenu(target: $buildTarget)
.padding(.leading, 12)
}
.background(VisionProPreviewBridge())
.padding(.trailing, 12)
.frame(maxWidth: .infinity)
.frame(height: Self.height)
Expand Down
80 changes: 80 additions & 0 deletions Sources/UntoldEditor/Editor/Toolbar/PreviewControl.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
//
// PreviewControl.swift
// UntoldEditor
//
// Copyright (C) Untold Engine Studios
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
//

import SwiftUI

/// The preview pill at the right of the toolbar, beside the build target: one
/// click shows the scene on the chosen destination, the next ends it. Wide,
/// so that it can be hit with a headset on; lit while the preview goes on.
/// The destination is picked from a chevron once there is more than one to
/// pick from. Hidden where this Mac has no destination, as the View menu's
/// item is.
struct PreviewControl: View {
@ObservedObject private var session = VisionProPreviewSession.shared
@ObservedObject private var playback = EditorPlaybackSettings.shared
@State private var destination: PreviewDestination = .visionPro
@State private var showsDestinations = false

var body: some View {
let destinations = PreviewDestination.available(visionPro: session.state)
if destinations.isEmpty == false {
let item = VisionProPreviewSession.menuItem(for: session.state, isPlaying: ViewportCameras.isPlaying)
EditorPillGroup {
EditorPillTextButton(
title: Self.title(for: session.state),
systemImage: destination.systemImage,
isActive: session.state == .connecting || session.state == .previewing,
isEnabled: item.isEnabled,
help: item.title,
action: session.toggle
)
if destinations.count > 1 {
EditorPillMenuButton(
title: destination.title,
isEnabled: session.state == .idle,
help: "Where the preview shows the scene"
) {
showsDestinations.toggle()
}
.popover(isPresented: $showsDestinations, arrowEdge: .bottom) {
VStack(alignment: .leading, spacing: 2) {
ForEach(destinations) { choice in
Button {
destination = choice
showsDestinations = false
} label: {
Label(choice.title, systemImage: choice.systemImage)
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
}
}
.padding(8)
}
}
}
}
}

/// What the button reads in each state of the preview.
static func title(for state: VisionProPreviewSession.State) -> String {
switch state {
case .unavailable, .idle:
return "Preview"
case .connecting:
return "Connecting…"
case .previewing:
return "Stop Preview"
case .ending:
return "Ending…"
}
}
}
42 changes: 42 additions & 0 deletions Sources/UntoldEditor/Editor/Toolbar/PreviewDestination.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
//
// PreviewDestination.swift
// UntoldEditor
//
// Copyright (C) Untold Engine Studios
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
//

import Foundation

/// Where the toolbar's preview shows the scene, besides the viewport: an
/// Apple Vision Pro nearby. A window of its own and the full screen are the
/// destinations to come; the control offers the choice once there is one.
enum PreviewDestination: String, CaseIterable, Identifiable {
case visionPro

var id: String {
rawValue
}

var title: String {
switch self {
case .visionPro:
return "Apple Vision Pro"
}
}

var systemImage: String {
switch self {
case .visionPro:
return "visionpro"
}
}

/// The destinations this Mac can preview on now.
static func available(visionPro: VisionProPreviewSession.State) -> [PreviewDestination] {
visionPro == .unavailable ? [] : [.visionPro]
}
}
Loading
Loading