返回 CodeWhale
CodewhalePetApp.swift
根目录 / pet / ios / CodewhalePetApp.swift
1 import SwiftUI
2 import AVFoundation
3 import UniformTypeIdentifiers
4
5 @main struct CodewhalePetMobileApp: App {
6 @StateObject private var host = PetHost(points: WHALE_POINTS)
7 @Environment(\.scenePhase) private var phase
8 @State private var pendingSource: PetSource?
9 @State private var confirmLeave = false
10 @State private var exporting = false
11 @State private var joining = false
12 @State private var recording: PetRecordingDocument?
13 @State private var notice = ""
14 var body: some Scene {
15 WindowGroup {
16 VStack(alignment: .leading, spacing: 22) {
17 Text("CODEWHALE / WHALESONG").font(.caption.monospaced()).foregroundStyle(.secondary)
18 Text("A living field.").font(.largeTitle.weight(.medium))
19 Text("A whale at rest. Knots, strands and branching paths as it works. The dots make the activity visible.").font(.subheadline).foregroundStyle(.secondary)
20 PetHabitatView(host: host).frame(minHeight: 300, maxHeight: .infinity)
21 Picker("World", selection: Binding(get: { host.source }, set: { next in
22 if !host.selectSource(next) { pendingSource = next; confirmLeave = true }
23 })) { ForEach(PetSource.allCases) { Text($0.label).tag($0) } }.pickerStyle(.segmented)
24 Toggle("Still", isOn: $host.still)
25 Menu("Earlier recordings") {
26 ForEach(host.archives, id: \.self) { name in
27 Button(petArchiveLabel(name)) {
28 do { recording = PetRecordingDocument(data: try host.archivedRecording(name)); exporting = true }
29 catch { notice = error.localizedDescription }
30 }
31 }
32 }.disabled(host.archives.isEmpty)
33 Button("Join shared pet…") { joining = true }
34 Button("Save recording…") {
35 if host.source == .live { Task { do { recording = PetRecordingDocument(data: try await host.shared.export()); exporting = true } catch { notice = error.localizedDescription } }; return }
36 do { recording = PetRecordingDocument(data: try host.recordingForExport()); exporting = true }
37 catch { notice = error.localizedDescription }
38 }.disabled(host.core == nil && host.shared.frame == nil)
39 if !notice.isEmpty { Text(notice).font(.caption).foregroundStyle(.secondary) }
40 Text(host.source == .file ? "File study: pet-state in this app’s Documents folder." : "Hollow dots mean missing telemetry. Sleep is a separate dimmer.")
41 .font(.caption).foregroundStyle(.secondary)
42 }.padding(24).preferredColorScheme(.dark)
43 .onAppear {
44 host.setLiveFile(FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("pet-state"))
45 do { try AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default); try AVAudioSession.sharedInstance().setActive(true) }
46 catch { /* Visual world remains available; audio output reports start errors. */ }
47 }
48 .onChange(of: phase) { _, value in host.suspend(value != .active) }
49 .fileImporter(isPresented: $joining, allowedContentTypes: [.json]) { result in
50 do {
51 let url = try result.get(); let access = url.startAccessingSecurityScopedResource(); defer { if access {url.stopAccessingSecurityScopedResource()} }
52 let file = try FileHandle(forReadingFrom:url); defer {try? file.close()}
53 let data = try file.read(upToCount:4097) ?? Data(); guard data.count <= 4096 else {throw PetCoreError.invalid("Invalid connection file.")}
54 let connection = try JSONDecoder().decode(PetConnection.self,from:data);try connection.validate()
55 guard host.save() else {throw PetCoreError.invalid("Export the current world before leaving it.")}
56 let target = FileManager.default.urls(for:.documentDirectory,in:.userDomainMask)[0].appendingPathComponent("pet-connection.json")
57 try data.write(to:target,options:.atomic);host.selectSource(.live);host.shared.stop();host.shared.start()
58 } catch {notice = error.localizedDescription}
59 }
60 .fileExporter(isPresented: $exporting, document: recording, contentType: .json, defaultFilename: "codewhale-pet.json") { result in
61 switch result {
62 case .success: notice = "Recording saved. Files over 8 MiB open in the browser."
63 case .failure(let error): notice = error.localizedDescription
64 }
65 recording = nil
66 }
67 .alert("This world has not been saved", isPresented: $confirmLeave) {
68 Button("Keep this world", role: .cancel) { pendingSource = nil }
69 Button("Leave without saving", role: .destructive) {
70 if let next = pendingSource { host.selectSource(next, discardingUnsaved: true) }
71 pendingSource = nil
72 }
73 } message: { Text("Keep this world and use Save recording to retain the current visit. Leaving opens the other world and discards this visit’s unsaved progress.") }
74 }
75 }
76 }
77
78 private struct PetRecordingDocument: FileDocument {
79 static var readableContentTypes: [UTType] { [.json] }
80 let data: Data
81 init(data: Data) { self.data = data }
82 init(configuration: ReadConfiguration) throws {
83 guard let data = configuration.file.regularFileContents, data.count <= 64 * 1024 * 1024 else { throw PetCoreError.invalid("Invalid pet recording file.") }
84 self.data = data
85 }
86 func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { FileWrapper(regularFileWithContents: data) }
87 }
88
88 lines Plain Text