Files
luma/Luma/PhoneRootView.swift
Ole André Vadla Ravnås b6ee7d7187 Put the whole surface on iOS too
The playground, the notebook cells, the source editor with its
marks, the inspector columns and the canvas are written once for
both Apple platforms, with a file naming what the two frameworks
call differently. The editor is where they really part company.

The engine owns the image and its start-up, so a mission reaches
the whole of Pharo.
2026-08-13 12:46:09 +02:00

450 lines
16 KiB
Swift

#if canImport(UIKit)
import LumaCore
import SwiftUI
import UIKit
import UniformTypeIdentifiers
struct PhoneRootView: View {
@State private var dbURL: URL?
@State private var isShowingOpenPicker = false
@State private var isShowingSaveAsPicker = false
private var welcome: WelcomeModel { sharedWelcomeModel }
@State private var isWelcomeOpenPickerVisible = false
private static let lumaUTI = "re.frida.luma"
var body: some View {
Group {
if let dbURL {
PhoneMainView(
projectURL: dbURL,
documentActions: PhoneDocumentActions(
currentDisplayName: Self.displayName(for: dbURL),
new: returnToWelcome,
open: { isShowingOpenPicker = true },
saveAs: { isShowingSaveAsPicker = true }
)
)
.id(dbURL)
} else {
WelcomeView(
welcome: welcome,
onCreateBlank: openFreshUntitled,
onOpenExisting: { isWelcomeOpenPickerVisible = true },
onCreateFromLab: openFromLab
)
.fileImporter(
isPresented: $isWelcomeOpenPickerVisible,
allowedContentTypes: [UTType(exportedAs: Self.lumaUTI)]
) { result in
if case .success(let url) = result {
importExternal(url)
}
}
}
}
.task(id: "welcome-bootstrap") {
await welcome.bootstrap()
}
.onOpenURL(perform: handleIncomingURL)
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
if let url = activity.webpageURL {
handleIncomingURL(url)
}
}
.fileImporter(
isPresented: $isShowingOpenPicker,
allowedContentTypes: [UTType(exportedAs: Self.lumaUTI)]
) { result in
if case .success(let url) = result {
importExternal(url)
}
}
.fileExporter(
isPresented: $isShowingSaveAsPicker,
document: dbURL.map(LumaExportDocument.init(sourceURL:)),
contentType: UTType(exportedAs: Self.lumaUTI),
defaultFilename: dbURL.map(Self.displayName(for:))
) { _ in }
}
@MainActor
private func returnToWelcome() {
dbURL = nil
Task { await welcome.refreshLabs() }
}
@MainActor
private func openFromLab(_ lab: WelcomeModel.LabSummary) {
let url = Self.untitledURL(named: lab.title)
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
CollaborationJoinQueue.shared.enqueue(labID: lab.id)
dbURL = url
LumaAppState.shared.lastDocumentPath = url.path
}
@MainActor
private func openFreshUntitled() {
let url = Self.nextUntitledURL()
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
dbURL = url
LumaAppState.shared.lastDocumentPath = url.path
}
private func handleIncomingURL(_ url: URL) {
if url.scheme == "luma" {
LumaAppDelegate.handle(url: url)
return
}
if let labID = BackendConfig.labID(fromInviteLink: url) {
joinLab(labID: labID)
return
}
guard url.isFileURL else { return }
importExternal(url)
}
@MainActor
private func joinLab(labID: String) {
CollaborationJoinQueue.shared.enqueue(labID: labID)
guard dbURL == nil else { return }
let url = Self.untitledURL(named: "Shared Lab")
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
dbURL = url
LumaAppState.shared.lastDocumentPath = url.path
}
@MainActor
private func importExternal(_ sourceURL: URL) {
if sourceURL.path == dbURL?.path { return }
let started = sourceURL.startAccessingSecurityScopedResource()
defer { if started { sourceURL.stopAccessingSecurityScopedResource() } }
let fm = FileManager.default
do {
let destURL = Self.workingCopyURL(for: sourceURL)
if fm.fileExists(atPath: destURL.path) {
try fm.removeItem(at: destURL)
}
try fm.copyItem(at: sourceURL, to: destURL)
dbURL = destURL
LumaAppState.shared.lastDocumentPath = destURL.path
} catch {
assertionFailure("Failed to import \(sourceURL.path): \(error)")
}
}
private static func nextUntitledURL() -> URL {
let dir = LumaAppPaths.shared.untitledDirectory
let fm = FileManager.default
for index in 0..<4096 {
let name = index == 0 ? "Untitled.luma" : "Untitled \(index).luma"
let candidate = dir.appendingPathComponent(name)
if !fm.fileExists(atPath: candidate.path) {
return candidate
}
}
return dir.appendingPathComponent("Untitled-\(UUID().uuidString).luma")
}
private static func untitledURL(named rawTitle: String) -> URL {
let illegal = CharacterSet(charactersIn: "/\\:*?\"<>|")
let sanitized = rawTitle.components(separatedBy: illegal).joined(separator: "-")
.trimmingCharacters(in: .whitespaces)
let base = sanitized.isEmpty ? "Lab" : sanitized
let dir = LumaAppPaths.shared.untitledDirectory
let fm = FileManager.default
let primary = dir.appendingPathComponent("\(base).luma")
if !fm.fileExists(atPath: primary.path) {
return primary
}
for index in 1..<4096 {
let candidate = dir.appendingPathComponent("\(base) \(index).luma")
if !fm.fileExists(atPath: candidate.path) {
return candidate
}
}
return dir.appendingPathComponent("\(base)-\(UUID().uuidString).luma")
}
private static func workingCopyURL(for sourceURL: URL) -> URL {
let dir = LumaAppPaths.shared.untitledDirectory.appendingPathComponent(".working", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let baseName = sourceURL.deletingPathExtension().lastPathComponent
return dir.appendingPathComponent("\(baseName)-\(UUID().uuidString).luma")
}
private static let uuidSuffixPattern = #"-[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$"#
private static func displayName(for url: URL) -> String {
let raw = url.deletingPathExtension().lastPathComponent
if let range = raw.range(of: uuidSuffixPattern, options: .regularExpression) {
return String(raw[..<range.lowerBound])
}
return raw
}
}
struct PhoneNotebookSheet: View {
let engine: Engine
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
NotebookView(engine: engine, selection: .constant(nil))
.navigationTitle("Notebook")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") { dismiss() }
}
}
}
}
}
struct PhonePlaygroundSheet: View {
let engine: Engine
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
PharoPlaygroundView(engine: engine)
.navigationTitle("Playground")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") { dismiss() }
}
}
}
}
}
struct PhoneMissionsSheet: View {
let engine: Engine
@Environment(\.dismiss) private var dismiss
@State private var path: [UUID] = []
var body: some View {
NavigationStack(path: $path) {
MissionsListView(
engine: engine,
selection: Binding(
get: { nil },
set: { newValue in
if case .mission(let id) = newValue {
path.append(id)
}
}
)
)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") { dismiss() }
}
}
.navigationDestination(for: UUID.self) { missionID in
MissionView(
engine: engine,
missionID: missionID,
selection: .constant(.mission(missionID))
)
.navigationBarTitleDisplayMode(.inline)
}
}
}
}
struct PhoneCustomInstrumentsSheet: View {
let engine: Engine
@Environment(\.dismiss) private var dismiss
@State private var path: [CustomInstrumentRoute] = []
enum CustomInstrumentRoute: Hashable {
case file(UUID, String?)
}
var body: some View {
NavigationStack(path: $path) {
CustomInstrumentsBrowser(
engine: engine,
onPick: { defID, entrypoint in
path.append(.file(defID, entrypoint))
},
onCreate: { def in
path.append(.file(def.id, def.entrypoint))
}
)
.navigationTitle("Custom Instruments")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") { dismiss() }
}
}
.navigationDestination(for: CustomInstrumentRoute.self) { route in
switch route {
case .file(let defID, let filePath):
CustomInstrumentEditorView(
defID: defID,
path: filePath,
engine: engine,
selection: Binding(
get: { nil },
set: { newValue in
guard let newValue else { return }
switch newValue {
case .customInstrumentFile(let id, let p):
path.append(.file(id, p))
case .customInstrumentDef(let id):
path.append(.file(id, nil))
default:
break
}
}
)
)
.navigationTitle(engine.customInstruments.def(withId: defID)?.name ?? "Custom Instrument")
.navigationBarTitleDisplayMode(.inline)
}
}
}
}
}
private struct CustomInstrumentsBrowser: View {
let engine: Engine
let onPick: (UUID, String) -> Void
let onCreate: (CustomInstrumentDef) -> Void
private var defs: [CustomInstrumentDef] { engine.customInstruments.defs }
var body: some View {
Group {
if defs.isEmpty {
ContentUnavailableView {
Label("No custom instruments yet", systemImage: "hammer")
} description: {
Text("Create one to write inline TypeScript that runs in the target.")
} actions: {
Button {
createNew()
} label: {
Label("New Custom Instrument", systemImage: "plus")
}
.buttonStyle(.borderedProminent)
}
} else {
List {
ForEach(defs) { def in
Button {
onPick(def.id, def.entrypoint)
} label: {
HStack(spacing: 12) {
InstrumentIconView(icon: def.icon, pointSize: 18)
.frame(width: 28, height: 28)
VStack(alignment: .leading, spacing: 2) {
Text(def.name).font(.body)
Text(def.entrypoint)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.vertical, 2)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
.listStyle(.plain)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
createNew()
} label: {
Image(systemName: "plus")
}
.accessibilityLabel("New Custom Instrument")
}
}
}
}
}
private func createNew() {
let def = engine.createCustomInstrument()
onCreate(def)
}
}
struct PhoneDocumentActions {
let currentDisplayName: String
let new: () -> Void
let open: () -> Void
let saveAs: () -> Void
static let noop = PhoneDocumentActions(
currentDisplayName: "",
new: {},
open: {},
saveAs: {}
)
}
private struct LumaExportDocument: FileDocument {
static let readableContentTypes: [UTType] = [UTType(exportedAs: "re.frida.luma")]
let sourceURL: URL
init() {
self.sourceURL = URL(fileURLWithPath: "/dev/null")
}
init(configuration: ReadConfiguration) throws {
self.sourceURL = URL(fileURLWithPath: "/dev/null")
}
init(sourceURL: URL) {
self.sourceURL = sourceURL
}
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
let fm = FileManager.default
let staging = fm.temporaryDirectory
.appendingPathComponent("luma-export-\(UUID().uuidString).luma", isDirectory: true)
defer { try? fm.removeItem(at: staging) }
try fm.createDirectory(at: staging, withIntermediateDirectories: true)
let dbSource = sourceURL.appendingPathComponent("db.sqlite")
let dbDest = staging.appendingPathComponent("db.sqlite")
try ProjectStore.exportSnapshot(from: dbSource, to: dbDest)
let tracesSource = sourceURL.appendingPathComponent("traces", isDirectory: true)
let tracesDest = staging.appendingPathComponent("traces", isDirectory: true)
if fm.fileExists(atPath: tracesSource.path) {
try fm.copyItem(at: tracesSource, to: tracesDest)
}
let eventsSource = sourceURL.appendingPathComponent("events.log")
let eventsDest = staging.appendingPathComponent("events.log")
if fm.fileExists(atPath: eventsSource.path) {
try fm.copyItem(at: eventsSource, to: eventsDest)
}
return try FileWrapper(url: staging, options: .immediate)
}
}
#endif