@pylonsync/create-pylon 0.3.53 → 0.3.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/bin/create-pylon.js +98 -42
  2. package/package.json +1 -1
  3. package/templates/backend/b2b/apps/api/functions/archiveProject.ts +15 -0
  4. package/templates/backend/b2b/apps/api/functions/createOrg.ts +43 -0
  5. package/templates/backend/b2b/apps/api/functions/createProject.ts +25 -0
  6. package/templates/backend/b2b/apps/api/functions/inviteMember.ts +49 -0
  7. package/templates/backend/b2b/apps/api/functions/myOrgs.ts +37 -0
  8. package/templates/backend/b2b/apps/api/functions/orgMembers.ts +13 -0
  9. package/templates/backend/b2b/apps/api/functions/orgProjects.ts +18 -0
  10. package/templates/backend/b2b/apps/api/functions/removeMember.ts +29 -0
  11. package/templates/backend/b2b/apps/api/functions/setMemberRole.ts +38 -0
  12. package/templates/backend/b2b/apps/api/package.json +20 -0
  13. package/templates/backend/b2b/apps/api/schema.ts +171 -0
  14. package/templates/backend/b2b/apps/api/tsconfig.json +13 -0
  15. package/templates/backend/chat/apps/api/functions/createRoom.ts +32 -0
  16. package/templates/backend/chat/apps/api/functions/listRooms.ts +15 -0
  17. package/templates/backend/chat/apps/api/functions/roomMessages.ts +20 -0
  18. package/templates/backend/chat/apps/api/functions/sendMessage.ts +37 -0
  19. package/templates/backend/chat/apps/api/package.json +20 -0
  20. package/templates/backend/chat/apps/api/schema.ts +93 -0
  21. package/templates/backend/chat/apps/api/tsconfig.json +13 -0
  22. package/templates/backend/consumer/apps/api/functions/createPost.ts +48 -0
  23. package/templates/backend/consumer/apps/api/functions/deletePost.ts +21 -0
  24. package/templates/backend/consumer/apps/api/functions/feed.ts +57 -0
  25. package/templates/backend/consumer/apps/api/functions/myProfile.ts +17 -0
  26. package/templates/backend/consumer/apps/api/functions/profilePosts.ts +17 -0
  27. package/templates/backend/consumer/apps/api/functions/toggleLike.ts +48 -0
  28. package/templates/backend/consumer/apps/api/functions/upsertProfile.ts +70 -0
  29. package/templates/backend/consumer/apps/api/package.json +20 -0
  30. package/templates/backend/consumer/apps/api/schema.ts +130 -0
  31. package/templates/backend/consumer/apps/api/tsconfig.json +13 -0
  32. package/templates/expo/chat/apps/expo/App.tsx +384 -0
  33. package/templates/expo/chat/apps/expo/app.json +25 -0
  34. package/templates/expo/chat/apps/expo/babel.config.js +6 -0
  35. package/templates/expo/chat/apps/expo/package.json +30 -0
  36. package/templates/expo/chat/apps/expo/tsconfig.json +16 -0
  37. package/templates/expo/consumer/apps/expo/App.tsx +392 -0
  38. package/templates/expo/consumer/apps/expo/app.json +25 -0
  39. package/templates/expo/consumer/apps/expo/babel.config.js +6 -0
  40. package/templates/expo/consumer/apps/expo/package.json +30 -0
  41. package/templates/expo/consumer/apps/expo/tsconfig.json +16 -0
  42. package/templates/ios/chat/apps/ios/Package.swift +24 -0
  43. package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/ChatRootView.swift +116 -0
  44. package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/Models.swift +26 -0
  45. package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/RoomView.swift +136 -0
  46. package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +58 -0
  47. package/templates/ios/chat/apps/ios/project.yml +44 -0
  48. package/templates/ios/consumer/apps/ios/Package.swift +24 -0
  49. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/FeedView.swift +199 -0
  50. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/Models.swift +34 -0
  51. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/ProfileSetupView.swift +68 -0
  52. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/RootView.swift +40 -0
  53. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +57 -0
  54. package/templates/ios/consumer/apps/ios/project.yml +44 -0
  55. package/templates/mac/b2b/apps/mac/Package.swift +22 -0
  56. package/templates/mac/b2b/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +15 -0
  57. package/templates/mac/b2b/apps/mac/Sources/__APP_NAME_PASCAL__/OrgPickerView.swift +178 -0
  58. package/templates/mac/b2b/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +30 -0
  59. package/templates/mac/b2b/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
  60. package/templates/mac/b2b/apps/mac/project.yml +34 -0
  61. package/templates/mac/barebones/apps/mac/Package.swift +33 -0
  62. package/templates/mac/barebones/apps/mac/Sources/__APP_NAME_PASCAL__/ContentView.swift +104 -0
  63. package/templates/mac/barebones/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +17 -0
  64. package/templates/mac/barebones/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +30 -0
  65. package/templates/mac/barebones/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
  66. package/templates/mac/barebones/apps/mac/project.yml +34 -0
  67. package/templates/mac/chat/apps/mac/Package.swift +34 -0
  68. package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/ChatRootView.swift +143 -0
  69. package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +26 -0
  70. package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/RoomView.swift +136 -0
  71. package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +58 -0
  72. package/templates/mac/chat/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
  73. package/templates/mac/chat/apps/mac/project.yml +36 -0
  74. package/templates/mac/consumer/apps/mac/Package.swift +34 -0
  75. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/FeedView.swift +199 -0
  76. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +34 -0
  77. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/ProfileSetupView.swift +68 -0
  78. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/RootView.swift +40 -0
  79. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +59 -0
  80. package/templates/mac/consumer/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
  81. package/templates/mac/consumer/apps/mac/project.yml +36 -0
  82. package/templates/mac/todo/apps/mac/Package.swift +33 -0
  83. package/templates/mac/todo/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +19 -0
  84. package/templates/mac/todo/apps/mac/Sources/__APP_NAME_PASCAL__/TodoListView.swift +244 -0
  85. package/templates/mac/todo/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +30 -0
  86. package/templates/mac/todo/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
  87. package/templates/mac/todo/apps/mac/project.yml +34 -0
  88. package/templates/web/b2b/apps/web/next-env.d.ts +2 -0
  89. package/templates/web/b2b/apps/web/next.config.ts +24 -0
  90. package/templates/web/b2b/apps/web/package.json +29 -0
  91. package/templates/web/b2b/apps/web/postcss.config.mjs +3 -0
  92. package/templates/web/b2b/apps/web/src/app/components/OrgPicker.tsx +171 -0
  93. package/templates/web/b2b/apps/web/src/app/globals.css +6 -0
  94. package/templates/web/b2b/apps/web/src/app/layout.tsx +21 -0
  95. package/templates/web/b2b/apps/web/src/app/page.tsx +39 -0
  96. package/templates/web/b2b/apps/web/src/lib/pylon.ts +5 -0
  97. package/templates/web/b2b/apps/web/tsconfig.json +26 -0
  98. package/templates/web/chat/apps/web/next-env.d.ts +2 -0
  99. package/templates/web/chat/apps/web/next.config.ts +24 -0
  100. package/templates/web/chat/apps/web/package.json +29 -0
  101. package/templates/web/chat/apps/web/postcss.config.mjs +3 -0
  102. package/templates/web/chat/apps/web/src/app/components/ChatRoom.tsx +231 -0
  103. package/templates/web/chat/apps/web/src/app/globals.css +6 -0
  104. package/templates/web/chat/apps/web/src/app/layout.tsx +22 -0
  105. package/templates/web/chat/apps/web/src/app/page.tsx +12 -0
  106. package/templates/web/chat/apps/web/src/app/providers.tsx +25 -0
  107. package/templates/web/chat/apps/web/src/lib/pylon.ts +5 -0
  108. package/templates/web/chat/apps/web/tsconfig.json +26 -0
  109. package/templates/web/consumer/apps/web/next-env.d.ts +2 -0
  110. package/templates/web/consumer/apps/web/next.config.ts +24 -0
  111. package/templates/web/consumer/apps/web/package.json +29 -0
  112. package/templates/web/consumer/apps/web/postcss.config.mjs +3 -0
  113. package/templates/web/consumer/apps/web/src/app/components/Feed.tsx +315 -0
  114. package/templates/web/consumer/apps/web/src/app/globals.css +6 -0
  115. package/templates/web/consumer/apps/web/src/app/layout.tsx +22 -0
  116. package/templates/web/consumer/apps/web/src/app/page.tsx +21 -0
  117. package/templates/web/consumer/apps/web/src/app/providers.tsx +25 -0
  118. package/templates/web/consumer/apps/web/src/lib/pylon.ts +5 -0
  119. package/templates/web/consumer/apps/web/tsconfig.json +26 -0
  120. /package/templates/{mobile/barebones/apps/mobile → ios/barebones/apps/ios}/Package.swift +0 -0
  121. /package/templates/{mobile/barebones/apps/mobile → ios/barebones/apps/ios}/Sources/__APP_NAME_PASCAL__/ContentView.swift +0 -0
  122. /package/templates/{mobile/barebones/apps/mobile → ios/barebones/apps/ios}/Sources/__APP_NAME_PASCAL__/Models.swift +0 -0
  123. /package/templates/{mobile/barebones/apps/mobile → ios/barebones/apps/ios}/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +0 -0
  124. /package/templates/{mobile/barebones/apps/mobile → ios/barebones/apps/ios}/project.yml +0 -0
  125. /package/templates/{mobile/todo/apps/mobile → ios/todo/apps/ios}/Package.swift +0 -0
  126. /package/templates/{mobile/todo/apps/mobile → ios/todo/apps/ios}/Sources/__APP_NAME_PASCAL__/Models.swift +0 -0
  127. /package/templates/{mobile/todo/apps/mobile → ios/todo/apps/ios}/Sources/__APP_NAME_PASCAL__/TodoListView.swift +0 -0
  128. /package/templates/{mobile/todo/apps/mobile → ios/todo/apps/ios}/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +0 -0
  129. /package/templates/{mobile/todo/apps/mobile → ios/todo/apps/ios}/project.yml +0 -0
@@ -0,0 +1,143 @@
1
+ import SwiftUI
2
+ import PylonClient
3
+ import PylonSync
4
+ import PylonSwiftUI
5
+
6
+ /// macOS chat: rooms in a sidebar, room view in the detail pane.
7
+ /// PylonQuery<Room> subscribes via the SyncEngine — new rooms from
8
+ /// any client appear without polling. Same story inside RoomView for
9
+ /// messages.
10
+ struct ChatRootView: View {
11
+ @EnvironmentObject var session: AppSession
12
+ let engine: SyncEngine
13
+ @StateObject private var rooms: PylonQuery<Room>
14
+ @State private var selected: Room.ID?
15
+ @State private var showingCreate = false
16
+ @State private var draftSlug = ""
17
+ @State private var draftName = ""
18
+ @State private var errorMessage: String?
19
+
20
+ init(engine: SyncEngine) {
21
+ self.engine = engine
22
+ _rooms = StateObject(
23
+ wrappedValue: PylonQuery<Room>(engine: engine, entity: "Room"),
24
+ )
25
+ }
26
+
27
+ var body: some View {
28
+ NavigationSplitView {
29
+ VStack(spacing: 0) {
30
+ List(selection: $selected) {
31
+ Section("Rooms") {
32
+ ForEach(sortedRooms) { r in
33
+ VStack(alignment: .leading, spacing: 2) {
34
+ Text(r.name)
35
+ Text("#\(r.slug)")
36
+ .font(.system(.caption, design: .monospaced))
37
+ .foregroundStyle(.secondary)
38
+ }
39
+ .tag(r.id)
40
+ }
41
+ }
42
+ }
43
+ Divider()
44
+ HStack(spacing: 8) {
45
+ TextField("display name", text: Binding(
46
+ get: { session.authorName },
47
+ set: { session.setAuthorName($0) },
48
+ ))
49
+ .textFieldStyle(.roundedBorder)
50
+ .font(.caption)
51
+ }
52
+ .padding(8)
53
+ }
54
+ .toolbar {
55
+ ToolbarItem(placement: .primaryAction) {
56
+ Button {
57
+ showingCreate.toggle()
58
+ } label: {
59
+ Image(systemName: "plus")
60
+ }
61
+ }
62
+ }
63
+ .sheet(isPresented: $showingCreate) {
64
+ createSheet
65
+ }
66
+ } detail: {
67
+ if let id = selected, let room = sortedRooms.first(where: { $0.id == id }) {
68
+ RoomView(room: room, engine: engine)
69
+ } else {
70
+ placeholder
71
+ }
72
+ }
73
+ .onAppear {
74
+ if selected == nil { selected = sortedRooms.first?.id }
75
+ }
76
+ .onChange(of: rooms.rows.count) { _, _ in
77
+ if selected == nil { selected = sortedRooms.first?.id }
78
+ }
79
+ }
80
+
81
+ private var sortedRooms: [Room] {
82
+ rooms.rows.sorted { $0.createdAt < $1.createdAt }
83
+ }
84
+
85
+ private var createSheet: some View {
86
+ VStack(alignment: .leading, spacing: 12) {
87
+ Text("Create a room").font(.headline)
88
+ TextField("Name", text: $draftName)
89
+ .textFieldStyle(.roundedBorder)
90
+ TextField("Slug (e.g. general)", text: $draftSlug)
91
+ .textFieldStyle(.roundedBorder)
92
+ .autocorrectionDisabled()
93
+ HStack {
94
+ Spacer()
95
+ Button("Cancel") { showingCreate = false }
96
+ .buttonStyle(.bordered)
97
+ Button("Create") { Task { await create() } }
98
+ .keyboardShortcut(.defaultAction)
99
+ .disabled(draftName.trimmingCharacters(in: .whitespaces).isEmpty
100
+ || draftSlug.trimmingCharacters(in: .whitespaces).isEmpty)
101
+ }
102
+ if let errorMessage {
103
+ Text(errorMessage).foregroundStyle(.red).font(.caption)
104
+ }
105
+ }
106
+ .padding(20)
107
+ .frame(width: 360)
108
+ }
109
+
110
+ private var placeholder: some View {
111
+ VStack(spacing: 8) {
112
+ if rooms.rows.isEmpty {
113
+ Text("No rooms yet.")
114
+ .foregroundStyle(.secondary)
115
+ Button("Create your first room") { showingCreate = true }
116
+ .buttonStyle(.bordered)
117
+ } else {
118
+ Text("Pick a room from the sidebar.")
119
+ .foregroundStyle(.secondary)
120
+ }
121
+ }
122
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
123
+ }
124
+
125
+ private func create() async {
126
+ let name = draftName.trimmingCharacters(in: .whitespaces)
127
+ let slug = draftSlug.trimmingCharacters(in: .whitespaces).lowercased()
128
+ guard !name.isEmpty, !slug.isEmpty else { return }
129
+ do {
130
+ let room: Room = try await session.client.callFn(
131
+ "createRoom",
132
+ args: CreateRoomArgs(slug: slug, name: name),
133
+ )
134
+ selected = room.id
135
+ showingCreate = false
136
+ draftName = ""
137
+ draftSlug = ""
138
+ errorMessage = nil
139
+ } catch {
140
+ errorMessage = "Create failed: \(error.localizedDescription)"
141
+ }
142
+ }
143
+ }
@@ -0,0 +1,26 @@
1
+ import Foundation
2
+
3
+ struct Room: Codable, Identifiable, Hashable {
4
+ let id: String
5
+ let slug: String
6
+ let name: String
7
+ let createdAt: String
8
+ }
9
+
10
+ struct Message: Codable, Identifiable, Hashable {
11
+ let id: String
12
+ let roomId: String
13
+ let authorId: String
14
+ let authorName: String
15
+ let body: String
16
+ let createdAt: String
17
+ }
18
+
19
+ struct CreateRoomArgs: Encodable { let slug: String; let name: String }
20
+ struct RoomMessagesArgs: Encodable { let roomId: String }
21
+ struct SendMessageArgs: Encodable {
22
+ let roomId: String
23
+ let body: String
24
+ let authorName: String
25
+ }
26
+ struct EmptyArgs: Encodable {}
@@ -0,0 +1,136 @@
1
+ import SwiftUI
2
+ import PylonClient
3
+ import PylonSync
4
+ import PylonSwiftUI
5
+
6
+ /// Live room view. PylonQuery<Message> with a `where` predicate that
7
+ /// matches `roomId` to the active room. The sync engine pushes diffs
8
+ /// over WebSocket; new messages from any client land in `messages.rows`
9
+ /// without us polling.
10
+ struct RoomView: View {
11
+ @EnvironmentObject var session: AppSession
12
+ let room: Room
13
+ let engine: SyncEngine
14
+ @StateObject private var messages: PylonQuery<Message>
15
+ @State private var draft = ""
16
+ @State private var sending = false
17
+ @State private var errorMessage: String?
18
+
19
+ init(room: Room, engine: SyncEngine) {
20
+ self.room = room
21
+ self.engine = engine
22
+ let roomId = room.id
23
+ _messages = StateObject(
24
+ wrappedValue: PylonQuery<Message>(
25
+ engine: engine,
26
+ entity: "Message",
27
+ where: { row in
28
+ row["roomId"]?.stringValue == roomId
29
+ },
30
+ ),
31
+ )
32
+ }
33
+
34
+ var body: some View {
35
+ VStack(spacing: 0) {
36
+ ScrollViewReader { proxy in
37
+ ScrollView {
38
+ LazyVStack(alignment: .leading, spacing: 12) {
39
+ ForEach(sortedMessages) { msg in
40
+ messageRow(msg).id(msg.id)
41
+ }
42
+ if messages.rows.isEmpty {
43
+ Text("No messages yet. Say hi.")
44
+ .foregroundStyle(.secondary)
45
+ .padding(.top, 32)
46
+ .frame(maxWidth: .infinity)
47
+ }
48
+ }
49
+ .padding(16)
50
+ }
51
+ .onChange(of: messages.rows.count) { _, _ in
52
+ if let last = sortedMessages.last {
53
+ withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
54
+ }
55
+ }
56
+ }
57
+
58
+ if let errorMessage {
59
+ Text(errorMessage)
60
+ .foregroundStyle(.red)
61
+ .font(.caption)
62
+ .padding(8)
63
+ }
64
+
65
+ HStack {
66
+ TextField("Message #\(room.slug)…", text: $draft, axis: .vertical)
67
+ .textFieldStyle(.roundedBorder)
68
+ .lineLimit(1...4)
69
+ .onSubmit { Task { await send() } }
70
+ Button("Send") { Task { await send() } }
71
+ .keyboardShortcut(.defaultAction)
72
+ .disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty || sending)
73
+ }
74
+ .padding(12)
75
+ .background(.thinMaterial)
76
+ }
77
+ .navigationTitle(room.name)
78
+ #if os(iOS)
79
+ .navigationBarTitleDisplayMode(.inline)
80
+ #endif
81
+ }
82
+
83
+ private var sortedMessages: [Message] {
84
+ messages.rows.sorted { $0.createdAt < $1.createdAt }
85
+ }
86
+
87
+ @ViewBuilder
88
+ private func messageRow(_ msg: Message) -> some View {
89
+ VStack(alignment: .leading, spacing: 2) {
90
+ HStack(alignment: .firstTextBaseline) {
91
+ Text(msg.authorName).font(.subheadline.weight(.medium))
92
+ Text(formatTime(msg.createdAt))
93
+ .font(.caption2)
94
+ .foregroundStyle(.tertiary)
95
+ }
96
+ Text(msg.body)
97
+ .font(.body)
98
+ .fixedSize(horizontal: false, vertical: true)
99
+ }
100
+ }
101
+
102
+ private func formatTime(_ iso: String) -> String {
103
+ let formatter = ISO8601DateFormatter()
104
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
105
+ guard let date = formatter.date(from: iso) ?? ISO8601DateFormatter().date(from: iso) else {
106
+ return ""
107
+ }
108
+ let display = DateFormatter()
109
+ display.timeStyle = .short
110
+ return display.string(from: date)
111
+ }
112
+
113
+ private func send() async {
114
+ let body = draft.trimmingCharacters(in: .whitespaces)
115
+ guard !body.isEmpty else { return }
116
+ sending = true
117
+ defer { sending = false }
118
+ do {
119
+ // Server inserts via ctx.db.insert; the change_event flows
120
+ // back through the SyncEngine and our PylonQuery picks up
121
+ // the new row. We only clear the draft here.
122
+ let _: Message = try await session.client.callFn(
123
+ "sendMessage",
124
+ args: SendMessageArgs(
125
+ roomId: room.id,
126
+ body: body,
127
+ authorName: session.authorName,
128
+ ),
129
+ )
130
+ draft = ""
131
+ errorMessage = nil
132
+ } catch {
133
+ errorMessage = "Send failed: \(error.localizedDescription)"
134
+ }
135
+ }
136
+ }
@@ -0,0 +1,58 @@
1
+ import SwiftUI
2
+ import PylonClient
3
+ import PylonSync
4
+
5
+ @main
6
+ struct __APP_NAME_PASCAL__App: App {
7
+ @StateObject private var session = AppSession()
8
+
9
+ var body: some Scene {
10
+ Window("__APP_NAME__", id: "main") {
11
+ Group {
12
+ if let engine = session.engine {
13
+ ChatRootView(engine: engine)
14
+ .environmentObject(session)
15
+ } else {
16
+ ProgressView()
17
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
18
+ }
19
+ }
20
+ .task { await session.bootIfNeeded() }
21
+ .frame(minWidth: 700, minHeight: 500)
22
+ }
23
+ .windowResizability(.contentMinSize)
24
+ }
25
+ }
26
+
27
+ @MainActor
28
+ final class AppSession: ObservableObject {
29
+ let client: PylonClient
30
+ @Published private(set) var engine: SyncEngine?
31
+ @Published var authorName: String
32
+
33
+ init() {
34
+ let baseURLString = ProcessInfo.processInfo.environment["PYLON_BASE_URL"]
35
+ ?? "http://localhost:4321"
36
+ guard let url = URL(string: baseURLString) else {
37
+ fatalError("Invalid PYLON_BASE_URL: \(baseURLString)")
38
+ }
39
+ self.client = PylonClient(baseURL: url, appName: "__APP_NAME_SNAKE__")
40
+ self.authorName = UserDefaults.standard.string(forKey: "authorName") ?? "anonymous"
41
+ }
42
+
43
+ func bootIfNeeded() async {
44
+ guard engine == nil else { return }
45
+ let baseURLString = ProcessInfo.processInfo.environment["PYLON_BASE_URL"]
46
+ ?? "http://localhost:4321"
47
+ guard let url = URL(string: baseURLString) else { return }
48
+ let config = SyncEngineConfig(baseURL: url, appName: "__APP_NAME_SNAKE__")
49
+ let engine = await SyncEngine(config: config, client: client)
50
+ await engine.start()
51
+ self.engine = engine
52
+ }
53
+
54
+ func setAuthorName(_ name: String) {
55
+ authorName = name
56
+ UserDefaults.standard.set(name, forKey: "authorName")
57
+ }
58
+ }
@@ -0,0 +1,13 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <!-- App Sandbox + outgoing-network client. Without these the
6
+ scaffolded app can't reach localhost:4321 during dev or
7
+ a remote Pylon Cloud URL in production. -->
8
+ <key>com.apple.security.app-sandbox</key>
9
+ <true/>
10
+ <key>com.apple.security.network.client</key>
11
+ <true/>
12
+ </dict>
13
+ </plist>
@@ -0,0 +1,36 @@
1
+ name: __APP_NAME_PASCAL__
2
+ options:
3
+ bundleIdPrefix: com.example
4
+ deploymentTarget:
5
+ macOS: "13.0"
6
+
7
+ packages:
8
+ pylon:
9
+ url: https://github.com/pylonsync/pylon.git
10
+ from: "0.3.0"
11
+
12
+ targets:
13
+ __APP_NAME_PASCAL__:
14
+ type: application
15
+ platform: macOS
16
+ sources:
17
+ - path: Sources/__APP_NAME_PASCAL__
18
+ settings:
19
+ base:
20
+ GENERATE_INFOPLIST_FILE: YES
21
+ INFOPLIST_KEY_CFBundleDisplayName: __APP_NAME__
22
+ INFOPLIST_KEY_LSApplicationCategoryType: "public.app-category.developer-tools"
23
+ INFOPLIST_KEY_NSHumanReadableCopyright: ""
24
+ SWIFT_VERSION: "5.9"
25
+ ENABLE_PREVIEWS: YES
26
+ # The mac app talks to the local Pylon control plane during dev.
27
+ # App Sandbox needs the network client entitlement to reach
28
+ # localhost; we declare it via codesign entitlements at build time.
29
+ CODE_SIGN_ENTITLEMENTS: __APP_NAME_PASCAL__.entitlements
30
+ dependencies:
31
+ - package: pylon
32
+ product: PylonClient
33
+ - package: pylon
34
+ product: PylonSync
35
+ - package: pylon
36
+ product: PylonSwiftUI
@@ -0,0 +1,34 @@
1
+ // swift-tools-version:5.9
2
+ import PackageDescription
3
+
4
+ // SwiftPM package for the __APP_NAME__ macOS app.
5
+ //
6
+ // The executable target runs locally with `swift run`. For a proper
7
+ // signed `.app` bundle:
8
+ //
9
+ // brew install xcodegen
10
+ // xcodegen generate
11
+ // open __APP_NAME_PASCAL__.xcodeproj
12
+ //
13
+ // The Xcode project pulls the same Sources/__APP_NAME_PASCAL__/ tree
14
+ // as `swift build`, so SwiftPM and Xcode share one source set.
15
+ let package = Package(
16
+ name: "__APP_NAME_PASCAL__",
17
+ platforms: [
18
+ .macOS(.v13),
19
+ ],
20
+ dependencies: [
21
+ .package(url: "https://github.com/pylonsync/pylon.git", from: "0.3.0"),
22
+ ],
23
+ targets: [
24
+ .executableTarget(
25
+ name: "__APP_NAME_PASCAL__",
26
+ dependencies: [
27
+ .product(name: "PylonClient", package: "pylon"),
28
+ .product(name: "PylonSync", package: "pylon"),
29
+ .product(name: "PylonSwiftUI", package: "pylon"),
30
+ ],
31
+ path: "Sources/__APP_NAME_PASCAL__"
32
+ ),
33
+ ]
34
+ )
@@ -0,0 +1,199 @@
1
+ import SwiftUI
2
+ import PylonClient
3
+ import PylonSync
4
+ import PylonSwiftUI
5
+
6
+ /// Live feed. Three subscriptions (Post / Like / Profile) joined
7
+ /// in-memory. Every new post + every like from any client renders
8
+ /// here without polling.
9
+ struct FeedView: View {
10
+ @EnvironmentObject var session: AppSession
11
+ let engine: SyncEngine
12
+ let me: Profile
13
+ let profiles: [Profile]
14
+
15
+ @StateObject private var posts: PylonQuery<Post>
16
+ @StateObject private var likes: PylonQuery<Like>
17
+ @State private var draft = ""
18
+ @State private var posting = false
19
+ @State private var errorMessage: String?
20
+
21
+ init(engine: SyncEngine, me: Profile, profiles: [Profile]) {
22
+ self.engine = engine
23
+ self.me = me
24
+ self.profiles = profiles
25
+ _posts = StateObject(
26
+ wrappedValue: PylonQuery<Post>(engine: engine, entity: "Post"),
27
+ )
28
+ _likes = StateObject(
29
+ wrappedValue: PylonQuery<Like>(engine: engine, entity: "Like"),
30
+ )
31
+ }
32
+
33
+ var body: some View {
34
+ NavigationStack {
35
+ List {
36
+ Section("Post") {
37
+ VStack(alignment: .leading, spacing: 8) {
38
+ TextEditor(text: $draft)
39
+ .frame(minHeight: 80)
40
+ .font(.body)
41
+ HStack {
42
+ Text("\(draft.count)/1000")
43
+ .font(.caption)
44
+ .foregroundStyle(.secondary)
45
+ Spacer()
46
+ Button(posting ? "Posting…" : "Post") {
47
+ Task { await post() }
48
+ }
49
+ .disabled(posting || draft.trimmingCharacters(in: .whitespaces).isEmpty)
50
+ }
51
+ }
52
+ }
53
+
54
+ Section("Feed") {
55
+ if items.isEmpty {
56
+ Text("No posts yet.")
57
+ .foregroundStyle(.secondary)
58
+ } else {
59
+ ForEach(items, id: \.post.id) { item in
60
+ row(item)
61
+ }
62
+ }
63
+ }
64
+
65
+ if let errorMessage {
66
+ Section {
67
+ Text(errorMessage)
68
+ .foregroundStyle(.red)
69
+ .font(.caption)
70
+ }
71
+ }
72
+ }
73
+ .navigationTitle("__APP_NAME__")
74
+ }
75
+ }
76
+
77
+ private struct FeedRow: Hashable {
78
+ let post: Post
79
+ let author: Profile?
80
+ let likeCount: Int
81
+ let likedByMe: Bool
82
+ }
83
+
84
+ private var profilesById: [String: Profile] {
85
+ Dictionary(uniqueKeysWithValues: profiles.map { ($0.id, $0) })
86
+ }
87
+
88
+ private var items: [FeedRow] {
89
+ posts.rows
90
+ .sorted { $0.createdAt > $1.createdAt }
91
+ .prefix(100)
92
+ .map { post in
93
+ let postLikes = likes.rows.filter { $0.postId == post.id }
94
+ return FeedRow(
95
+ post: post,
96
+ author: profilesById[post.authorId],
97
+ likeCount: postLikes.count,
98
+ likedByMe: postLikes.contains { $0.profileId == me.id },
99
+ )
100
+ }
101
+ }
102
+
103
+ @ViewBuilder
104
+ private func row(_ item: FeedRow) -> some View {
105
+ VStack(alignment: .leading, spacing: 6) {
106
+ HStack(alignment: .firstTextBaseline) {
107
+ Text(item.author?.displayName ?? "Unknown")
108
+ .font(.subheadline.weight(.medium))
109
+ Text("@\(item.author?.handle ?? "?")")
110
+ .font(.system(.caption, design: .monospaced))
111
+ .foregroundStyle(.secondary)
112
+ Spacer()
113
+ Text(item.post.createdAt)
114
+ .font(.caption2)
115
+ .foregroundStyle(.tertiary)
116
+ }
117
+ Text(item.post.body)
118
+ .font(.body)
119
+ .fixedSize(horizontal: false, vertical: true)
120
+ HStack {
121
+ Button {
122
+ Task { await toggleLike(item.post.id) }
123
+ } label: {
124
+ HStack(spacing: 4) {
125
+ Image(systemName: item.likedByMe ? "heart.fill" : "heart")
126
+ Text("\(item.likeCount)")
127
+ }
128
+ .font(.caption)
129
+ .foregroundStyle(item.likedByMe ? .pink : .secondary)
130
+ }
131
+ .buttonStyle(.plain)
132
+
133
+ if item.author?.id == me.id {
134
+ Spacer()
135
+ Button("Delete", role: .destructive) {
136
+ Task { await delete(item.post.id) }
137
+ }
138
+ .font(.caption)
139
+ }
140
+ }
141
+ }
142
+ .padding(.vertical, 4)
143
+ }
144
+
145
+ // MARK: - Mutations (writes flow through callFn; the engine receives
146
+ // the change_event and updates the local store, which re-renders the
147
+ // PylonQuery rows above).
148
+
149
+ private func post() async {
150
+ let body = draft.trimmingCharacters(in: .whitespaces)
151
+ guard !body.isEmpty else { return }
152
+ posting = true
153
+ defer { posting = false }
154
+ do {
155
+ // `createPost` returns the joined shape (Post + author),
156
+ // but we don't use the return value — the engine will
157
+ // surface the new Post row via the live subscription.
158
+ let _: PostCreatedResponse = try await session.client.callFn(
159
+ "createPost",
160
+ args: CreatePostArgs(body: body),
161
+ )
162
+ draft = ""
163
+ errorMessage = nil
164
+ } catch {
165
+ errorMessage = "Post failed: \(error.localizedDescription)"
166
+ }
167
+ }
168
+
169
+ private func toggleLike(_ postId: String) async {
170
+ do {
171
+ let _: ToggleLikeResponse = try await session.client.callFn(
172
+ "toggleLike",
173
+ args: ToggleLikeArgs(postId: postId),
174
+ )
175
+ } catch {
176
+ errorMessage = "Like failed: \(error.localizedDescription)"
177
+ }
178
+ }
179
+
180
+ private func delete(_ postId: String) async {
181
+ do {
182
+ let _: Post = try await session.client.callFn(
183
+ "deletePost",
184
+ args: DeletePostArgs(id: postId),
185
+ )
186
+ } catch {
187
+ errorMessage = "Delete failed: \(error.localizedDescription)"
188
+ }
189
+ }
190
+ }
191
+
192
+ private struct PostCreatedResponse: Decodable {
193
+ let id: String
194
+ }
195
+
196
+ private struct ToggleLikeResponse: Decodable {
197
+ let liked: Bool
198
+ let likeCount: Int
199
+ }
@@ -0,0 +1,34 @@
1
+ import Foundation
2
+
3
+ struct Profile: Codable, Identifiable, Hashable {
4
+ let id: String
5
+ let userId: String
6
+ let handle: String
7
+ let displayName: String
8
+ let bio: String?
9
+ let createdAt: String
10
+ }
11
+
12
+ struct Post: Codable, Identifiable, Hashable {
13
+ let id: String
14
+ let authorId: String
15
+ let body: String
16
+ let createdAt: String
17
+ }
18
+
19
+ struct Like: Codable, Identifiable, Hashable {
20
+ let id: String
21
+ let postId: String
22
+ let profileId: String
23
+ let createdAt: String
24
+ }
25
+
26
+ struct UpsertProfileArgs: Encodable {
27
+ let handle: String
28
+ let displayName: String
29
+ let bio: String
30
+ }
31
+ struct CreatePostArgs: Encodable { let body: String }
32
+ struct DeletePostArgs: Encodable { let id: String }
33
+ struct ToggleLikeArgs: Encodable { let postId: String }
34
+ struct EmptyArgs: Encodable {}