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
21 changes: 2 additions & 19 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,7 @@
A SwiftUI visionOS app that serves as a portal for museum visitors to interact and immerse themselves in 3D assets and environments.

## Project Structure

```
Museum/
├── Museum/ # App source code
│ ├── MuseumApp.swift # @main entry point (WindowGroup scene)
│ ├── ContentView.swift # Main UI view
│ ├── Info.plist # Supports multiple scenes
│ └── Assets.xcassets/ # visionOS solid image stack icon
├── MuseumTests/ # Swift Testing framework tests
│ └── MuseumTests.swift
├── Packages/
│ └── RealityKitContent/ # Local SPM package for 3D assets
│ ├── Package.swift # Swift 6.2, visionOS 26+
│ └── Sources/RealityKitContent/
│ ├── RealityKitContent.swift # Bundle.module export
│ └── RealityKitContent.rkassets/ # USDA scenes & materials
└── Museum.xcodeproj/
```
Refer to the README.md file for project structure.

## Build & Run

Expand All @@ -36,7 +19,7 @@ The app uses a light MVVM architecture with clearly defined layers (top to botto

### 1. Views
- SwiftUI views with business logic extracted into view models
- Never cascade multiple `.sheet`, `.alert` or similar modifiers — use a single declaration controlled by a Controller
- Never cascade multiple `.sheet` or `.alert` modifiers — use a single declaration controlled by a Controller
- Prefer alternatives to event subscribing modifiers (`.onAppear`, `.task`, `.onChange`) — only use when strictly necessary
- Should always declare the view models as `@ObservedObject` and leave the lifecycle control to the scope of the Factory registration, such as `.singleton`, `.shared`, `.unique`

Expand Down
17 changes: 17 additions & 0 deletions Museum.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
18234CCB2F4645DB00C0A12D /* FactoryKit in Frameworks */ = {isa = PBXBuildFile; productRef = 18234CCA2F4645DB00C0A12D /* FactoryKit */; };
18234CCD2F4645DB00C0A12D /* FactoryTesting in Frameworks */ = {isa = PBXBuildFile; productRef = 18234CCC2F4645DB00C0A12D /* FactoryTesting */; };
189E39012F463C380013FCC3 /* RealityKitContent in Frameworks */ = {isa = PBXBuildFile; productRef = 189E39002F463C380013FCC3 /* RealityKitContent */; };
18F081EE2F48B494007F0DB8 /* Equatable in Frameworks */ = {isa = PBXBuildFile; productRef = 18F081ED2F48B494007F0DB8 /* Equatable */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand Down Expand Up @@ -60,6 +61,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
18F081EE2F48B494007F0DB8 /* Equatable in Frameworks */,
18234CCB2F4645DB00C0A12D /* FactoryKit in Frameworks */,
189E39012F463C380013FCC3 /* RealityKitContent in Frameworks */,
);
Expand Down Expand Up @@ -126,6 +128,7 @@
packageProductDependencies = (
189E39002F463C380013FCC3 /* RealityKitContent */,
18234CCA2F4645DB00C0A12D /* FactoryKit */,
18F081ED2F48B494007F0DB8 /* Equatable */,
);
productName = Museum;
productReference = 189E38FB2F463C380013FCC3 /* Museum.app */;
Expand Down Expand Up @@ -185,6 +188,7 @@
minimizedProjectReferenceProxies = 1;
packageReferences = (
18234CC92F4645DB00C0A12D /* XCRemoteSwiftPackageReference "Factory" */,
18F081EC2F48B494007F0DB8 /* XCRemoteSwiftPackageReference "equatable" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 189E38FC2F463C380013FCC3 /* Products */;
Expand Down Expand Up @@ -506,6 +510,14 @@
minimumVersion = 2.5.3;
};
};
18F081EC2F48B494007F0DB8 /* XCRemoteSwiftPackageReference "equatable" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/ordo-one/equatable";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.2.0;
};
};
/* End XCRemoteSwiftPackageReference section */

/* Begin XCSwiftPackageProductDependency section */
Expand All @@ -523,6 +535,11 @@
isa = XCSwiftPackageProductDependency;
productName = RealityKitContent;
};
18F081ED2F48B494007F0DB8 /* Equatable */ = {
isa = XCSwiftPackageProductDependency;
package = 18F081EC2F48B494007F0DB8 /* XCRemoteSwiftPackageReference "equatable" */;
productName = Equatable;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 189E38F32F463C380013FCC3 /* Project object */;
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 14 additions & 5 deletions Museum/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,23 @@ import SwiftUI
import FactoryKit

struct ContentView: View {
@ObservedObject private var viewModel = Container.shared.assetDisplayViewModel()
let asset: Asset

@ObservedObject private var viewModel: AssetDisplayViewModel

init(asset: Asset) {
self.asset = asset
self.viewModel = Container.shared.assetDisplayViewModel(asset)
}

var body: some View {
Group {
switch viewModel.state {
case .idle:
ProgressView()
.onAppear {
viewModel.startLoading()
}
case .loading(let progress):
AssetLoadingView(progress: progress)
case .loaded(let url):
Expand All @@ -22,9 +34,6 @@ struct ContentView: View {
failureContent
}
}
.onAppear {
viewModel.startLoading()
}
}
}

Expand All @@ -46,5 +55,5 @@ private extension ContentView {
}

#Preview(windowStyle: .automatic) {
ContentView()
ContentView(asset: .warship)
}
4 changes: 4 additions & 0 deletions Museum/Managers/AssetProviderManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,12 @@ nonisolated final class AssetProviderManager: AssetProviding, @unchecked Sendabl
self.diskCache = diskCache
self.downloader = downloader
self.logger = logger

logger.debug("init")
}

deinit { logger.debug("deinit") }

func provide(
_ asset: Asset,
strategy: RetryStrategy,
Expand Down
2 changes: 1 addition & 1 deletion Museum/Models/Asset.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import simd

nonisolated enum Asset: String, CaseIterable, Sendable, CacheKeyProtocol {
nonisolated enum Asset: String, CaseIterable, Sendable, Equatable, CacheKeyProtocol {
case warship = "KM1PUvbAai5kXm8"

var value: String { rawValue }
Expand Down
1 change: 1 addition & 0 deletions Museum/Models/AssetDisplayState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import Foundation

nonisolated enum AssetDisplayState: Sendable {
case idle
case loading(progress: Float)
case loaded(url: URL)
case failed(Error)
Expand Down
11 changes: 7 additions & 4 deletions Museum/MuseumApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ struct MuseumApp: App {
@ObservedObject private var viewModel = Container.shared.museumAppViewModel()
@ObservedObject private var immersiveSpaceController = Container.shared.immersiveSpaceController()
private let isRunningTests: Bool
// On a real app the user would first select which asset to be
// interacted with
private let currentAsset = Asset.warship

init() {
isRunningTests = NSClassFromString("XCTestProbe") != nil
Expand All @@ -23,21 +26,21 @@ struct MuseumApp: App {
}

var body: some Scene {
WindowGroup {
WindowGroup(id: Constants.volumetricSpaceId) {
switch viewModel.loadingState {
case .loading:
loadingContent
case .loaded:
ContentView()
ContentView(asset: currentAsset)
case .failed:
failureContent
}
}
.windowStyle(.volumetric)
.defaultSize(width: 1, height: 1, depth: 0.5, in: .meters)
.defaultSize(width: 1, height: 1, depth: 5, in: .meters)

ImmersiveSpace(id: Constants.immersiveSpaceId) {
ImmersiveAssetView()
ImmersiveAssetView(asset: currentAsset)
}
.immersionStyle(selection: .constant(.full), in: .full)
}
Expand Down
28 changes: 24 additions & 4 deletions Museum/Operators/AssetDownloadOperator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,23 +56,41 @@ nonisolated protocol AssetDownloading: Sendable {
nonisolated final class AssetDownloadOperator: AssetDownloading, @unchecked Sendable {

private let session: any URLSessionDownloading
private let logger: any Logging
private let fileManager: FileManager

init(session: any URLSessionDownloading = URLSessionDownloader(), fileManager: FileManager = .default) {
init(
session: any URLSessionDownloading = URLSessionDownloader(),
logger: any Logging = Container.shared.logOperator("AssetDownloadOperator"),
fileManager: FileManager = .default
) {
self.session = session
self.logger = logger
self.fileManager = fileManager
}

func download(from url: URL) -> AsyncThrowingStream<AssetDownloadEvent, Error> {
AsyncThrowingStream { continuation in
let task = Task {
logger.debug("Downloading asset from \(url)")
return AsyncThrowingStream { continuation in
let task = Task { [weak self] in
guard let self else {
return continuation.finish(throwing: CancellationError())
}
do {
let fileURL = try await performDownload(from: url, continuation: continuation)
let fileURL = try await self.performDownload(
from: url,
continuation: continuation
)
logger.debug("Asset downloaded to \(fileURL)")
continuation.yield(.completed(fileURL))
continuation.finish()
} catch is CancellationError {
logger.debug("User cancelled asset download")
continuation.finish(throwing: CancellationError())
} catch {
logger.error(
"Failed downloading asset: \(error.localizedDescription)"
)
continuation.finish(throwing: error)
}
}
Expand Down Expand Up @@ -124,6 +142,8 @@ private extension AssetDownloadOperator {
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension(originalURL.pathExtension)

logger.debug("Moving downloaded file from \(tempURL) to \(stableURL)")

do {
try fileManager.moveItem(at: tempURL, to: stableURL)
} catch {
Expand Down
32 changes: 20 additions & 12 deletions Museum/Operators/Cache/DiskCacheOperator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,22 +55,33 @@ nonisolated final class DiskCacheOperator: CacheOperatorProtocol, @unchecked Sen
try fileManager.removeItem(at: destination)
}
try fileManager.moveItem(at: sourceURL, to: destination)
logger.debug("Saved cache \(key.value) at \(destination)")
} catch {
assertionFailure("\(error)")
logger.error("Failed to save cache on disk: \(error), key: \(key.value)")
logger.error("Failed to save cache \(key.value) on disk: \(error)")
}
}

func retrieve(at key: any CacheKeyProtocol) async -> URL? {
let url = fileURL(for: key)

guard fileManager.fileExists(atPath: url.path) else { return nil }
guard fileManager.fileExists(atPath: url.path) else {
logger.debug(
"Failed to retrieve cache \(key.value) as it's missing"
)
return nil
}

guard !isExpired(fileURL: url) else {
try? fileManager.removeItem(at: url)
logger.debug(
"Failed to retrieve cache \(key.value) as it's already expired"
)
return nil
}

logger.debug("Retrieved cache \(key.value) from \(url)")

return url
}
}
Expand All @@ -88,30 +99,27 @@ private extension DiskCacheOperator {
func setup() {
guard !fileManager.fileExists(atPath: cacheDirectory.path) else { return }

logger.debug("Creating cache directory at \(cacheDirectory)")
do {
try fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
} catch {
assertionFailure("\(error)")
logger.error("Failed to setup disk for caching: \(error)")
logger.error("Failed to create cache directory: \(error)")
}
}

func clearExpired() {
do {
for fileName in try fileManager.contentsOfDirectory(atPath: cacheDirectory.path) {
let filePath = cacheDirectory.appendingPathComponent(fileName).path
let attributes = try fileManager.attributesOfItem(atPath: filePath)

guard
let creationDate = attributes[.creationDate] as? Date,
Date().timeIntervalSince(creationDate) >= timeToLive
else { continue }
let fileURL = cacheDirectory.appendingPathComponent(fileName)
guard isExpired(fileURL: fileURL) else { continue }

try fileManager.removeItem(atPath: filePath)
logger.debug("Clearing expired cache at \(fileURL)")
try fileManager.removeItem(atPath: fileURL.path)
}
} catch {
assertionFailure("\(error)")
logger.error("Failed to clear expired disk cache: \(error)")
logger.error("Failed to clear expired cache: \(error)")
}
}

Expand Down
1 change: 1 addition & 0 deletions Museum/Other/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,6 @@ nonisolated enum Constants {
in: .userDomainMask
).first!
static let assetBaseURL = URL(string: "https://drive.trifork.com/index.php/s/")!
static let volumetricSpaceId = "VolumetricSpace"
static let immersiveSpaceId = "ImmersiveAssetSpace"
}
Loading