diff --git a/apple/aesel/Aesel.xcodeproj/project.pbxproj b/apple/aesel/Aesel.xcodeproj/project.pbxproj index ea838e0b3d..5eba832531 100644 --- a/apple/aesel/Aesel.xcodeproj/project.pbxproj +++ b/apple/aesel/Aesel.xcodeproj/project.pbxproj @@ -11,10 +11,14 @@ 1F53641C03321161A7665F3B /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 505E152C400C5CE69463B39F /* ContentView.swift */; }; 2DB3100BC43BD21115A1BA37 /* AeselApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7CE6904A010A2BEC894D13C /* AeselApp.swift */; }; 3661DCFA19E2594688511C86 /* Session.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13B505C3279E0F8DB5292B86 /* Session.swift */; }; + 44709587196D5718C5B25542 /* COMIC-RELIEF-OFL-1.1.txt in Resources */ = {isa = PBXBuildFile; fileRef = 4A54F121629B5E78C6F46F6A /* COMIC-RELIEF-OFL-1.1.txt */; }; 6E01FC52B2208E175FFC6985 /* SessionHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = B71285DFFDAE5A8B3FB1FBD5 /* SessionHost.swift */; }; 7AE3B1696F71BFDE82E8B72E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 228E709932A29DC953A73B71 /* Assets.xcassets */; }; A2CDE2052DDB891D3A4659D6 /* AeselNotebook.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB9C447ACCA3590492C05AC5 /* AeselNotebook.swift */; }; + AC7B6241A0E7F2791E06F1CA /* ComicRelief-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 99FE2FC14157DB3F88708A6B /* ComicRelief-Bold.ttf */; }; + B7D0EF78D26BBA38AB801393 /* slab-theme.json in Resources */ = {isa = PBXBuildFile; fileRef = AACC6D25723505FB156B46F0 /* slab-theme.json */; }; B8236CE8844912F069905928 /* AeselScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CB1DC30F6BBB5B2167272D2 /* AeselScene.swift */; }; + C27DB5FE92281B28965A6535 /* Braincells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D9AB5CF1D7C27E6930F81B1 /* Braincells.swift */; }; C48716DE150CDF3FE383298E /* Session in Resources */ = {isa = PBXBuildFile; fileRef = 47AE0766266534A8EE665542 /* Session */; }; C5323C1BB9D8D59F14A1FF74 /* AeselModelPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F26176753E43E445E2CF551D /* AeselModelPicker.swift */; }; FBC8984584385405A83CB639 /* AeselHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2D2E4941E446673BAF4F97 /* AeselHomeView.swift */; }; @@ -25,9 +29,13 @@ 22509229E839210D9200B4AA /* Aesel.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Aesel.app; sourceTree = BUILT_PRODUCTS_DIR; }; 228E709932A29DC953A73B71 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 47AE0766266534A8EE665542 /* Session */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Session; path = Resources/Session; sourceTree = SOURCE_ROOT; }; + 4A54F121629B5E78C6F46F6A /* COMIC-RELIEF-OFL-1.1.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "COMIC-RELIEF-OFL-1.1.txt"; sourceTree = ""; }; 505E152C400C5CE69463B39F /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 5D9AB5CF1D7C27E6930F81B1 /* Braincells.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Braincells.swift; sourceTree = ""; }; 72A337044339282DB88573A0 /* AeselPieceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AeselPieceView.swift; sourceTree = ""; }; 7CB1DC30F6BBB5B2167272D2 /* AeselScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AeselScene.swift; sourceTree = ""; }; + 99FE2FC14157DB3F88708A6B /* ComicRelief-Bold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "ComicRelief-Bold.ttf"; sourceTree = ""; }; + AACC6D25723505FB156B46F0 /* slab-theme.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "slab-theme.json"; sourceTree = ""; }; B71285DFFDAE5A8B3FB1FBD5 /* SessionHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionHost.swift; sourceTree = ""; }; B7CE6904A010A2BEC894D13C /* AeselApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AeselApp.swift; sourceTree = ""; }; BB9C447ACCA3590492C05AC5 /* AeselNotebook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AeselNotebook.swift; sourceTree = ""; }; @@ -45,6 +53,7 @@ BB9C447ACCA3590492C05AC5 /* AeselNotebook.swift */, 72A337044339282DB88573A0 /* AeselPieceView.swift */, 7CB1DC30F6BBB5B2167272D2 /* AeselScene.swift */, + 5D9AB5CF1D7C27E6930F81B1 /* Braincells.swift */, 505E152C400C5CE69463B39F /* ContentView.swift */, 13B505C3279E0F8DB5292B86 /* Session.swift */, B71285DFFDAE5A8B3FB1FBD5 /* SessionHost.swift */, @@ -74,6 +83,9 @@ isa = PBXGroup; children = ( 228E709932A29DC953A73B71 /* Assets.xcassets */, + 4A54F121629B5E78C6F46F6A /* COMIC-RELIEF-OFL-1.1.txt */, + 99FE2FC14157DB3F88708A6B /* ComicRelief-Bold.ttf */, + AACC6D25723505FB156B46F0 /* slab-theme.json */, ); path = Resources; sourceTree = ""; @@ -139,7 +151,10 @@ buildActionMask = 2147483647; files = ( 7AE3B1696F71BFDE82E8B72E /* Assets.xcassets in Resources */, + 44709587196D5718C5B25542 /* COMIC-RELIEF-OFL-1.1.txt in Resources */, + AC7B6241A0E7F2791E06F1CA /* ComicRelief-Bold.ttf in Resources */, C48716DE150CDF3FE383298E /* Session in Resources */, + B7D0EF78D26BBA38AB801393 /* slab-theme.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -156,6 +171,7 @@ A2CDE2052DDB891D3A4659D6 /* AeselNotebook.swift in Sources */, 06DAB9B425E19C4E02F25A24 /* AeselPieceView.swift in Sources */, B8236CE8844912F069905928 /* AeselScene.swift in Sources */, + C27DB5FE92281B28965A6535 /* Braincells.swift in Sources */, 1F53641C03321161A7665F3B /* ContentView.swift in Sources */, 3661DCFA19E2594688511C86 /* Session.swift in Sources */, 6E01FC52B2208E175FFC6985 /* SessionHost.swift in Sources */, diff --git a/apple/aesel/Info.plist b/apple/aesel/Info.plist index 5693974238..5973b07869 100644 --- a/apple/aesel/Info.plist +++ b/apple/aesel/Info.plist @@ -18,6 +18,8 @@ 0.7.5 CFBundleVersion 2 + UIAppFonts + ComicRelief-Bold.ttf UILaunchScreen UIColorName diff --git a/apple/aesel/Resources/Assets.xcassets/braincell.imageset/Contents.json b/apple/aesel/Resources/Assets.xcassets/braincell.imageset/Contents.json new file mode 100644 index 0000000000..11401b7556 --- /dev/null +++ b/apple/aesel/Resources/Assets.xcassets/braincell.imageset/Contents.json @@ -0,0 +1,5 @@ +{ + "images" : [ { "filename" : "braincell.svg", "idiom" : "universal" } ], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { "preserves-vector-representation" : true } +} diff --git a/apple/aesel/Resources/Assets.xcassets/braincell.imageset/braincell.svg b/apple/aesel/Resources/Assets.xcassets/braincell.imageset/braincell.svg new file mode 100644 index 0000000000..7d06c76b6f --- /dev/null +++ b/apple/aesel/Resources/Assets.xcassets/braincell.imageset/braincell.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apple/aesel/Resources/COMIC-RELIEF-OFL-1.1.txt b/apple/aesel/Resources/COMIC-RELIEF-OFL-1.1.txt new file mode 100644 index 0000000000..be3ee64f6b --- /dev/null +++ b/apple/aesel/Resources/COMIC-RELIEF-OFL-1.1.txt @@ -0,0 +1,93 @@ +Copyright 2013 The Comic Relief Project Authors (https://github.com/loudifier/Comic-Relief) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apple/aesel/Resources/ComicRelief-Bold.ttf b/apple/aesel/Resources/ComicRelief-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..14f8bf9dc98cc6c572864860c14b817ec4a34aff GIT binary patch literal 94684 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

oY}zu)sw&1(|K@NqDazT~sj0CtN;Atz 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 U=5s)LKzJQ!cQUyDt<0zb0Dti+cmMzZ literal 0 HcmV?d00001 diff --git a/apple/aesel/Resources/slab-theme.json b/apple/aesel/Resources/slab-theme.json new file mode 100644 index 0000000000..91a4782ac8 --- /dev/null +++ b/apple/aesel/Resources/slab-theme.json @@ -0,0 +1 @@ +{"dark":true,"enabled":true,"palettes":{"awaiting":{"background":[64,41,14],"bold":[219,255,232],"cursor":[219,179,54],"foreground":[219,226,179]},"blank":{"background":[0,0,10],"bold":[219,255,255],"cursor":[167,195,232],"foreground":[201,233,255]},"complete":{"background":[66,7,12],"bold":[255,214,206],"cursor":[255,43,39],"foreground":[255,175,167]},"interrupted":{"background":[28,11,70],"bold":[201,210,255],"cursor":[134,86,255],"foreground":[181,179,255]},"rendering":{"background":[30,7,36],"bold":[219,218,255],"cursor":[194,70,188],"foreground":[207,171,250]},"stale":{"background":[57,5,22],"bold":[219,214,254],"cursor":[219,78,99],"foreground":[219,163,196]},"working":{"background":[2,47,24],"bold":[181,255,255],"cursor":[47,241,143],"foreground":[134,249,219]}},"version":1} \ No newline at end of file diff --git a/apple/aesel/Sources/AeselApp.swift b/apple/aesel/Sources/AeselApp.swift index 4b91a80f93..e985142975 100644 --- a/apple/aesel/Sources/AeselApp.swift +++ b/apple/aesel/Sources/AeselApp.swift @@ -38,6 +38,16 @@ struct AeselApp: App { session.purchasedDollars = 1.41 session.append(.you, "Make a little orange circle.") session.append(.ac, "The **orange** circle follows your pointer.\n\nTry a radius of `24` or a blue background.\n\n```js\nconst radius = 24;\nwipe(\"blue\");\n```\n\n[Open Aesthetic Computer](https://aesthetic.computer)") + if ProcessInfo.processInfo.environment["AESEL_PREVIEW_PIECE"] == "1" { + session.previewURL = URL(string: "https://aesthetic.computer/blank") + session.source = "wipe(\"orange\");" + } + if ProcessInfo.processInfo.environment["AESEL_PREVIEW_BUSY"] == "1" { + session.append(.you, "Now make it bounce.") + session.busy = true + session.status = "writing" + session.health = .working + } return } #endif diff --git a/apple/aesel/Sources/AeselHomeView.swift b/apple/aesel/Sources/AeselHomeView.swift index 9a1dc62ba3..ca2d3a8fab 100644 --- a/apple/aesel/Sources/AeselHomeView.swift +++ b/apple/aesel/Sources/AeselHomeView.swift @@ -6,6 +6,7 @@ struct AeselHomeView: View { let host: SessionHost let resume: () -> Void @State private var tab = 0 + @Environment(\.paint) private var paint private let media: [(id: String, name: String)] = [ ("piece", "Piece"), ("picture", "Picture"), ("sound", "Sound"), @@ -16,13 +17,13 @@ struct AeselHomeView: View { VStack(spacing: 0) { HStack { if !session.route.isEmpty { - Button("/resume", action: resume).foregroundStyle(Paint.dim) + Button("/resume", action: resume).foregroundStyle(paint.dim) } Spacer() if session.signedIn { AeselHandle(handle: session.handle, colors: session.handleColors) } else { - Button("/login") { host.signIn() }.foregroundStyle(Paint.dim) + Button("/login") { host.signIn() }.foregroundStyle(paint.dim) } } .font(Paint.font(20)) @@ -48,12 +49,12 @@ struct AeselHomeView: View { Text(medium.name) Spacer(minLength: 0) if medium.id != "piece" { - Text("desktop").font(Paint.font(17)).foregroundStyle(Paint.dim) + Text("desktop").font(Paint.font(17)).foregroundStyle(paint.dim) } } .padding(.horizontal, 12) .frame(height: 48) - .foregroundStyle(medium.id == "piece" ? Paint.ink : Paint.dim) + .foregroundStyle(medium.id == "piece" ? paint.ink : paint.dim) .background(medium.id == "piece" ? Color(rgb: 0xc81e64) : .clear) } .disabled(medium.id != "piece" || session.busy) @@ -64,7 +65,7 @@ struct AeselHomeView: View { ScrollView { LazyVStack(alignment: .leading, spacing: 2) { if session.history.isEmpty { - Text("No saved threads").foregroundStyle(Paint.dim).padding(.vertical, 12) + Text("No saved threads").foregroundStyle(paint.dim).padding(.vertical, 12) } ForEach(session.history) { thread in Button { @@ -74,17 +75,17 @@ struct AeselHomeView: View { VStack(alignment: .leading, spacing: 5) { Text("› " + thread.title) .lineLimit(2) - .foregroundStyle(Paint.ink) + .foregroundStyle(paint.ink) Text(thread.medium + " · " + date(thread.updatedAt)) .font(Paint.font(17)) - .foregroundStyle(Paint.dim) + .foregroundStyle(paint.dim) if !thread.route.isEmpty { - Text(thread.route).font(Paint.font(17)).foregroundStyle(Paint.dim).lineLimit(1) + Text(thread.route).font(Paint.font(17)).foregroundStyle(paint.dim).lineLimit(1) } } .frame(maxWidth: .infinity, alignment: .leading) .padding(12) - .background(thread.id == session.currentSessionID ? Paint.deep.opacity(0.65) : .clear) + .background(thread.id == session.currentSessionID ? paint.deep.opacity(0.65) : .clear) } .disabled(session.busy) } @@ -103,7 +104,7 @@ struct AeselHomeView: View { } .buttonStyle(.plain) - .foregroundStyle(Paint.ink) + .foregroundStyle(paint.ink) .background { AeselCloth().ignoresSafeArea() } } @@ -111,7 +112,7 @@ struct AeselHomeView: View { Button { tab = index } label: { Text(label) .padding(.horizontal, 8).padding(.vertical, 7) - .foregroundStyle(tab == index ? Paint.ink : Paint.dim) + .foregroundStyle(tab == index ? paint.ink : paint.dim) .background(tab == index ? Color(rgb: 0xc81e64) : .clear) } .accessibilityAddTraits(tab == index ? [.isSelected] : []) diff --git a/apple/aesel/Sources/AeselModelPicker.swift b/apple/aesel/Sources/AeselModelPicker.swift index 176ee96c00..48dee447c4 100644 --- a/apple/aesel/Sources/AeselModelPicker.swift +++ b/apple/aesel/Sources/AeselModelPicker.swift @@ -4,9 +4,10 @@ import SwiftUI struct AeselModelPicker: View { let session: Session let host: SessionHost + @Environment(\.paint) private var paint var body: some View { Text("Braincells · automatic model") - .font(Paint.font(16)).foregroundStyle(Paint.dim) + .font(Paint.font(16)).foregroundStyle(paint.dim) } } diff --git a/apple/aesel/Sources/AeselNotebook.swift b/apple/aesel/Sources/AeselNotebook.swift index bc69fb41af..789ce02af7 100644 --- a/apple/aesel/Sources/AeselNotebook.swift +++ b/apple/aesel/Sources/AeselNotebook.swift @@ -4,6 +4,8 @@ import WebKit /// Desktop rich replies, packaged locally; the agent host remains separate. struct AeselNotebook: UIViewRepresentable { let session: Session + var paint = Paint.base + @Binding var height: CGFloat var openLink: (URL) -> Void final class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler { @@ -12,6 +14,7 @@ struct AeselNotebook: UIViewRepresentable { var payload = "{}" var lastPayload = "" var openLink: (URL) -> Void + var reportHeight: (CGFloat) -> Void = { _ in } init(openLink: @escaping (URL) -> Void) { self.openLink = openLink } func render(_ view: WKWebView) { guard ready, payload != lastPayload else { return } @@ -26,7 +29,12 @@ struct AeselNotebook: UIViewRepresentable { func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { guard message.frameInfo.isMainFrame, message.webView?.url?.scheme == "aesel-bundle", - let body = message.body as? [String: Any], let text = body["url"] as? String, + let body = message.body as? [String: Any] else { return } + if let height = body["height"] as? Double, height.isFinite, height >= 0, height < 200_000 { + reportHeight(CGFloat(height)) + return + } + guard let text = body["url"] as? String, let url = URL(string: text), ["http", "https"].contains(url.scheme?.lowercased() ?? "") else { return } openLink(url) } @@ -47,6 +55,7 @@ struct AeselNotebook: UIViewRepresentable { view.backgroundColor = .clear view.scrollView.backgroundColor = .clear view.scrollView.isScrollEnabled = false + view.scrollView.contentInsetAdjustmentBehavior = .never view.load(URLRequest(url: URL(string: "aesel-bundle://app/easel/phone/notebook.html")!)) return view } @@ -62,11 +71,16 @@ struct AeselNotebook: UIViewRepresentable { guard value.count == 7, let rgb = UInt32(value.dropFirst(), radix: 16) else { return nil } return [Int((rgb >> 16) & 255), Int((rgb >> 8) & 255), Int(rgb & 255)] } - let payload: [String: Any] = ["entries": entries, "handle": session.handle, "colors": colors] + let payload: [String: Any] = ["entries": entries, "handle": session.handle, "colors": colors, "theme": paint.css, + "busy": session.busy, "activity": session.busy ? session.status : ""] if let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]), let json = String(data: data, encoding: .utf8) { context.coordinator.payload = json context.coordinator.openLink = openLink + let height = $height + context.coordinator.reportHeight = { value in + DispatchQueue.main.async { if height.wrappedValue != value { height.wrappedValue = value } } + } context.coordinator.render(view) } } diff --git a/apple/aesel/Sources/AeselPieceView.swift b/apple/aesel/Sources/AeselPieceView.swift index 792ed29a4a..f94eea2188 100644 --- a/apple/aesel/Sources/AeselPieceView.swift +++ b/apple/aesel/Sources/AeselPieceView.swift @@ -8,6 +8,7 @@ struct PieceView: View { let source: String @State private var failure: String? @State private var attempt = 0 + @Environment(\.paint) private var paint var body: some View { ZStack { PieceWebView(url: url, source: source, failure: $failure).id(attempt) @@ -18,9 +19,9 @@ struct PieceView: View { Text("Preview could not load").font(Paint.font(20)) Text(failure).font(Paint.font(16)).multilineTextAlignment(.center) Button("/retry") { self.failure = nil; attempt += 1 } - .font(Paint.font(18)).foregroundStyle(Paint.you).buttonStyle(.plain) + .font(Paint.font(18)).foregroundStyle(paint.you).buttonStyle(.plain) }.padding().frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Paint.deep) + .background(paint.deep) } } } diff --git a/apple/aesel/Sources/AeselScene.swift b/apple/aesel/Sources/AeselScene.swift index 31cabb8e5d..fd647eff08 100644 --- a/apple/aesel/Sources/AeselScene.swift +++ b/apple/aesel/Sources/AeselScene.swift @@ -2,27 +2,190 @@ import SwiftUI import UIKit // The desktop's palette, typefaces and mascot, shared without redraws. -// Title lettering is the desktop's Comic Sans MS Bold; Chalkboard SE Bold is -// the fallback its own CSS names on Apple platforms, so nothing ships. -enum Paint { - static let bg = Color(rgb: 0x463264) - static let deep = Color(rgb: 0x241d35) - static let ink = Color.white - static let dim = Color(rgb: 0xb394ce) - static let rule = Color(rgb: 0x9b72b8) - static let you = Color(rgb: 0xff0088) - static let ac = Color(rgb: 0xff64ff) - static let edit = Color(rgb: 0x77ff55) - static let bad = Color(rgb: 0xff7777) - static let accent = Color(rgb: 0xff78b2) +// Title lettering is the desktop's Comic Sans MS Bold. iOS has no Comic Sans, +// so the app bundles Comic Relief Bold, its metric-compatible SIL OFL twin +// (Resources/COMIC-RELIEF-OFL-1.1.txt); Chalkboard SE Bold is the last resort. +// +// Colours follow Slab's theme-by-status: the desktop reads the palettes Slab +// publishes to ~/.local/share/slab/state/theme.json, and the phone carries a +// snapshot of that file (Resources/slab-theme.json) so both wear the same +// cloth for the same status without Slab having to be nearby. +struct Paint: Equatable { + var bg = Color(rgb: 0x463264) + var deep = Color(rgb: 0x241d35) + var ink = Color.white + var dim = Color(rgb: 0xb394ce) + var rule = Color.white.opacity(0.12) + var you = Color(rgb: 0xff0088) + var ac = Color(rgb: 0xff64ff) + var edit = Color(rgb: 0x77ff55) + var bad = Color(rgb: 0xff7777) + var accent = Color(rgb: 0xff78b2) // The preview frame: --aesel-background 75% + accent, edged 65% toward white. - static let frame = Color(rgb: 0x744478) - static let frameEdge = Color(rgb: 0xa485a7) + var frame = Color(rgb: 0x744478) + var frameEdge = Color(rgb: 0xa485a7) + // The desktop's --user-ink: foreground 65% toward #c12b87. + var userInk = Color(rgb: 0xe9b5d5) + // What the notebook web view is told, as CSS hex. + var css: [String: String] = ["background": "#463264", "foreground": "#ffffff", "userInk": "#e9b5d5"] + + static let base = Paint() static func font(_ size: CGFloat = 17) -> Font { .custom("Helvetica", size: size, relativeTo: .body) } - static func title(_ size: CGFloat = 28) -> Font { - .custom("ChalkboardSE-Bold", size: size, relativeTo: .title) + static func title(_ size: CGFloat = 20) -> Font { + Font(Rock.font(size)) + } + + // MARK: Slab theme-by-status + + private typealias RGB = [Double] + private struct Palette { var background, foreground, bold, cursor: RGB } + private static let palettes: [String: Palette] = { + guard let url = Bundle.main.url(forResource: "slab-theme", withExtension: "json"), + let data = try? Data(contentsOf: url), + let document = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + document["version"] as? Int == 1, document["enabled"] as? Bool == true, + let entries = document["palettes"] as? [String: [String: [Double]]] else { return [:] } + var result: [String: Palette] = [:] + for (status, value) in entries { + guard let background = value["background"], let foreground = value["foreground"], + let bold = value["bold"], let cursor = value["cursor"], + [background, foreground, bold, cursor].allSatisfy({ $0.count == 3 }) else { continue } + result[status] = Palette(background: background, foreground: foreground, bold: bold, cursor: cursor) + } + return result + }() + + /// Slab's state names for the phone's session words; the desktop gets + /// these from Slab directly, the phone derives them from its own turn. + static func slabStatus(_ status: String, busy: Bool, failed: Bool) -> String { + if failed { return "stale" } + if busy { return status == "publishing" || status == "rendering" ? "rendering" : "working" } + switch status { + case "ready", "live", "complete", "done": return "complete" + case "stopped", "interrupted", "cancelled": return "interrupted" + case "failed", "stale", "error", "not published": return "stale" + case "awaiting", "waiting": return "awaiting" + default: return "blank" + } + } + + static func slab(status: String, busy: Bool, failed: Bool) -> Paint { + let name = slabStatus(status, busy: busy, failed: failed) + guard let palette = palettes[name] ?? palettes["blank"] else { return base } + var paint = base + let bg = palette.background, fg = palette.foreground + paint.bg = color(bg) + paint.deep = color(mix(bg, [0, 0, 0], 0.45)) + paint.ink = color(fg) + paint.rule = color(fg).opacity(0.12) + paint.accent = color(palette.cursor) + paint.you = color(palette.cursor) + paint.dim = color(readable([170, 150, 205], on: bg)) + paint.ac = color(readable([255, 100, 255], on: bg)) + paint.edit = color(readable([0, 255, 0], on: bg)) + paint.bad = color(readable([255, 90, 90], on: bg)) + let frame = mix(bg, palette.cursor, 0.25) + paint.frame = color(frame) + paint.frameEdge = color(mix(frame, [255, 255, 255], 0.65)) + let userInk = mix(fg, [193, 43, 135], 0.35) + paint.userInk = color(userInk) + paint.css = ["background": hex(bg), "foreground": hex(fg), "userInk": hex(userInk)] + return paint + } + + private static func color(_ rgb: RGB) -> Color { + Color(red: rgb[0] / 255, green: rgb[1] / 255, blue: rgb[2] / 255) + } + private static func hex(_ rgb: RGB) -> String { + "#" + rgb.map { String(format: "%02x", Int($0.rounded())) }.joined() + } + private static func mix(_ a: RGB, _ b: RGB, _ amount: Double) -> RGB { + zip(a, b).map { $0 + ($1 - $0) * amount } + } + private static func luminance(_ rgb: RGB) -> Double { + let linear = rgb.map { channel -> Double in + let c = channel / 255 + return c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4) + } + return linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722 + } + private static func contrast(_ a: RGB, _ b: RGB) -> Double { + let x = luminance(a), y = luminance(b) + return (max(x, y) + 0.05) / (min(x, y) + 0.05) + } + // The desktop's `readable`: keep the hue, move only as far toward black or + // white as WCAG AA (4.5:1) against the background needs. + private static func readable(_ color: RGB, on background: RGB) -> RGB { + if contrast(color, background) >= 4.5 { return color } + let target: Double = contrast([0, 0, 0], background) > contrast([255, 255, 255], background) ? 0 : 255 + for step in 1...100 { + let candidate = color.map { ($0 + (target - $0) * Double(step) / 100).rounded() } + if contrast(candidate, background) >= 4.5 { return candidate } + } + return [target, target, target] + } +} + +private struct PaintKey: EnvironmentKey { static let defaultValue = Paint.base } +extension EnvironmentValues { + var paint: Paint { + get { self[PaintKey.self] } + set { self[PaintKey.self] = newValue } + } +} + +/// The desktop's native Prox lettering (easel/desktop/native/credit-label.swift), +/// drawn with UIKit: cyan, purple and pink echoes under a thin-outlined face +/// with a hard pink shadow, one cached image per letter so the notebook can +/// tilt and sway each one on its own. +enum Rock { + static let inset: CGFloat = 9 + private static var cache: [String: (UIImage, CGSize)] = [:] + + static func font(_ size: CGFloat) -> UIFont { + for name in ["ComicRelief-Bold", "ComicSansMS-Bold", "ChalkboardSE-Bold"] { + if let font = UIFont(name: name, size: size) { return font } + } + return .systemFont(ofSize: size, weight: .heavy) + } + + /// A letter's image plus its advance box; the image overhangs the box by + /// `inset` on every side so the echoes and shadow are not clipped. + static func glyph(_ text: String, size: CGFloat, face: UIColor = .white) -> (image: UIImage, advance: CGSize) { + let key = "\(text)|\(size)|\(face)" + if let hit = cache[key] { return hit } + let font = font(size) + let advance = (text as NSString).size(withAttributes: [.font: font]) + let bounds = CGSize(width: ceil(advance.width + inset * 2), height: ceil(advance.height + inset * 2)) + let format = UIGraphicsImageRendererFormat.default() + format.opaque = false + let image = UIGraphicsImageRenderer(size: bounds, format: format).image { _ in + let echoes: [(UIColor, CGFloat, CGFloat)] = [ + (.systemCyan.withAlphaComponent(0.28), 4.5, 3), + (.systemPurple.withAlphaComponent(0.40), 3, 2), + (.systemPink.withAlphaComponent(0.65), 1.5, 1.5), + ] + for (color, x, y) in echoes { + NSAttributedString(string: text, attributes: [.font: font, .foregroundColor: color]) + .draw(at: CGPoint(x: inset + x, y: inset + y)) + } + let shadow = NSShadow() + shadow.shadowColor = UIColor.systemPink.withAlphaComponent(0.5) + shadow.shadowBlurRadius = 0 + shadow.shadowOffset = CGSize(width: 1.5, height: 1.5) + NSAttributedString(string: text, attributes: [ + .font: font, + .foregroundColor: face.withAlphaComponent(0.94), + .strokeColor: UIColor(white: 0.08, alpha: 1), + .strokeWidth: -3.5, + .shadow: shadow, + ]).draw(at: CGPoint(x: inset, y: inset)) + } + if cache.count > 256 { cache.removeAll() } + cache[key] = (image, advance) + return (image, advance) } } @@ -39,39 +202,65 @@ extension Color { } } +extension UIColor { + // "#rrggbb" from the account palette, or nil. + convenience init?(hex: String) { + guard hex.count == 7, let rgb = UInt32(hex.dropFirst(), radix: 16) else { return nil } + self.init(red: CGFloat((rgb >> 16) & 255) / 255, green: CGFloat((rgb >> 8) & 255) / 255, blue: CGFloat(rgb & 255) / 255, alpha: 1) + } +} + struct AeselCloth: View { - var body: some View { Paint.bg.accessibilityHidden(true) } + @Environment(\.paint) private var paint + var body: some View { paint.bg.accessibilityHidden(true) } +} + +/// The desktop's ruled sheet: one faint line per 24px row, from 10px in to +/// the trailing edge, drawn behind everything so title, prose and the draft +/// all sit on the same paper. +struct AeselRuling: View { + var spacing: CGFloat = 24 + @Environment(\.paint) private var paint + var body: some View { + Canvas { context, size in + var y = spacing - 0.5 + var path = Path() + while y < size.height { + path.move(to: CGPoint(x: 10, y: y)) + path.addLine(to: CGPoint(x: size.width, y: y)) + y += spacing + } + context.stroke(path, with: .color(paint.rule), lineWidth: 1) + } + .accessibilityHidden(true) + } } struct AeselWordmark: View { var text = "aesel" - private let colors: [Color] = [.orange, Paint.ac, Paint.edit, Paint.dim, Paint.you] + @Environment(\.paint) private var paint var body: some View { + let colors: [Color] = [.orange, paint.ac, paint.edit, paint.dim, paint.you] HStack(spacing: 0) { ForEach(Array(text.enumerated()), id: \.offset) { index, letter in Text(String(letter)).foregroundStyle(colors[index % colors.count]) } } - .font(Paint.title()) + .font(Paint.title(28)) .accessibilityElement(children: .ignore) .accessibilityLabel("aesel") } } -/// The desktop's piece title: handle letters in the account palette, a -/// status-coloured shadow, and every letter leaning by the FNV hash Slab uses, -/// so the phone and the rock agree on each letter's tilt. +/// The desktop's piece title: Prox lettering with handle letters tinted by the +/// account palette, every letter leaning by the FNV hash Slab uses (so the +/// phone and the rock agree on each letter's tilt) and swaying on its own +/// 1.8s beat like the desktop's qr-name-wiggle. struct AeselTitle: View { let text: String var colors: [String] = [] - var status = "" - var shadow: Color? - var size: CGFloat = 28 - private static let shadows: [String: UInt32] = [ - "working": 0x36f175, "thinking": 0x36f175, "rendering": 0xe1469c, "awaiting": 0xffb327, - "complete": 0x5d9cf9, "ready": 0x5d9cf9, "interrupted": 0x9c56e9, "stopped": 0x9c56e9, - "stale": 0xff4e4e, "failed": 0xff4e4e, - ] + var size: CGFloat = 20 + private static func fnv(_ text: String) -> UInt32 { var hash: UInt32 = 2166136261 for byte in text.utf8 { hash = (hash ^ UInt32(byte)) &* 16777619 } @@ -79,29 +268,55 @@ struct AeselTitle: View { } // "@handle" letters wear the account palette; the piece name stays white. - private func ink(_ index: Int, handle: Int) -> Color { - guard index < handle, colors.indices.contains(index), let color = Color(hex: colors[index]) else { return Paint.ink } + private func face(_ index: Int, handle: Int) -> UIColor { + guard index < handle, colors.indices.contains(index), let color = UIColor(hex: colors[index]) else { return .white } return color } var body: some View { let handle = text.hasPrefix("@") ? text.prefix(while: { $0 != "/" }).count : 0 - HStack(spacing: 0) { + HStack(alignment: .top, spacing: 0) { ForEach(Array(text.enumerated()), id: \.offset) { index, letter in let hash = Self.fnv("rock\(index)\(text)") - Text(String(letter)) - .foregroundStyle(ink(index, handle: handle)) + let face = face(index, handle: handle) + RockLetter(text: String(letter), size: size, face: face, beat: Double(index) * 0.12) + // A new letter is a new view, so the sway never crossfades old ink into new. + .id("\(index)|\(letter)|\(face)") .rotationEffect(.degrees(-Double(Int((hash >> 8) % 9) - 4) * 0.9)) .offset(y: -(CGFloat(hash % 5) / 2 - 1)) } } - .font(Paint.title(size)) - .shadow(color: shadow ?? Color(rgb: Self.shadows[status] ?? 0x808080), radius: 0, x: size * 0.07, y: size * 0.07) .accessibilityElement(children: .ignore) .accessibilityLabel(text) } } +/// One swaying letter: its image overhangs the advance box by `Rock.inset` +/// so the echoes and shadow stay unclipped, and it rocks on its own beat. +private struct RockLetter: View { + let text: String + let size: CGFloat + let face: UIColor + let beat: Double + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var swaying = false + + var body: some View { + let glyph = Rock.glyph(text, size: size, face: face) + Color.clear + .frame(width: glyph.advance.width, height: glyph.advance.height) + .overlay(alignment: .topLeading) { + Image(uiImage: glyph.image).offset(x: -Rock.inset, y: -Rock.inset) + } + .rotationEffect(.degrees(swaying ? 0.8 : -1.2)) + .offset(y: swaying ? 0.8 : -1.2) + .onAppear { + guard !reduceMotion else { return } + withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true).delay(beat)) { swaying = true } + } + } +} + /// The desktop's pencil companion: eight 512px cells on a 4×2 sheet, copied in /// by bundle-session.sh, drawn smooth and running only while a turn is live. struct AeselDonkey: View { @@ -135,12 +350,13 @@ struct AeselDonkey: View { struct AeselHandle: View { let handle: String let colors: [String] + @Environment(\.paint) private var paint private var label: AttributedString { var result = AttributedString() for (index, character) in ("@" + handle).enumerated() { var letter = AttributedString(String(character)) - letter.foregroundColor = colors.indices.contains(index) ? Color(hex: colors[index]) ?? Paint.ac : Paint.ac + letter.foregroundColor = colors.indices.contains(index) ? Color(hex: colors[index]) ?? paint.ac : paint.ac result += letter } return result diff --git a/apple/aesel/Sources/Braincells.swift b/apple/aesel/Sources/Braincells.swift new file mode 100644 index 0000000000..f15ae9363b --- /dev/null +++ b/apple/aesel/Sources/Braincells.swift @@ -0,0 +1,103 @@ +import Foundation +import Observation +import StoreKit + +/// Braincells bought through the App Store. StoreKit 2 signs each purchase; +/// the app hands that signed transaction to AC (`/api/easel-iap`), which +/// verifies it with Apple and credits the account, and only then is the +/// transaction finished. Anything unfinished is retried on the next launch. +@Observable +@MainActor +final class Braincells { + static let productID = "computer.aesthetic.easel.braincells.1m" + static let redeemURL = URL(string: "https://aesthetic.computer/api/easel-iap")! + + var product: Product? + var busy = false + var notice = "" + var storeStatus = "Loading the App Store…" + + private var token: () -> String? = { nil } + private var credited: () -> Void = {} + private var listener: Task? + + func start(token: @escaping () -> String?, credited: @escaping () -> Void) { + self.token = token + self.credited = credited + guard listener == nil else { return } + listener = Task { [weak self] in + for await result in Transaction.updates { + await self?.settle(result) + } + } + Task { await load() } + } + + var price: String { product?.displayPrice ?? "$4.99" } + + func load() async { + do { + let products = try await Product.products(for: [Self.productID]) + product = products.first + storeStatus = product == nil ? "Braincells are not for sale here yet." : "" + } catch { + storeStatus = "App Store unavailable: \(error.localizedDescription)" + } + for await result in Transaction.unfinished { await settle(result) } + } + + func buy() async { + guard !busy else { return } + guard let product else { await load(); return } + guard token() != nil else { notice = "Sign in to AC before adding braincells."; return } + busy = true + notice = "" + defer { busy = false } + do { + switch try await product.purchase() { + case .success(let result): await settle(result) + case .userCancelled: break + case .pending: notice = "Purchase pending approval." + @unknown default: break + } + } catch { + notice = error.localizedDescription + } + } + + /// Redeem a verified transaction with AC, then finish it. + private func settle(_ result: VerificationResult) async { + guard case .verified(let transaction) = result else { return } + guard transaction.productID == Self.productID, transaction.revocationDate == nil else { + await transaction.finish() + return + } + guard let token = token() else { + notice = "Sign in to AC to add the braincells you bought." + return + } + var request = URLRequest(url: Self.redeemURL) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.httpBody = try? JSONSerialization.data(withJSONObject: ["jws": result.jwsRepresentation]) + do { + let (data, response) = try await URLSession.shared.data(for: request) + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + let body = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + switch status { + case 200: + await transaction.finish() + notice = body?["credited"] as? Bool == true ? "1,000,000 braincells added." : "Braincells already added." + credited() + case 400, 401: + // Apple would not verify it for this account; keep it for a retry after sign-in. + notice = body?["error"] as? String ?? "Could not add braincells." + default: + notice = body?["error"] as? String ?? "AC is busy; the purchase will be retried." + } + } catch { + notice = "Could not reach AC; the purchase will be retried." + } + } +} diff --git a/apple/aesel/Sources/ContentView.swift b/apple/aesel/Sources/ContentView.swift index 550b6ef86a..d552ef3216 100644 --- a/apple/aesel/Sources/ContentView.swift +++ b/apple/aesel/Sources/ContentView.swift @@ -5,35 +5,63 @@ struct ContentView: View { let session: Session let host: SessionHost @State private var draft = "" - @State private var showHome = true + // Straight onto the notebook; /new or Settings opens the chooser. + @State private var showHome = false @State private var showHelp = false @State private var showSettings = false - private enum SettingsDestination { case signIn, help } - @State private var settingsDestination: SettingsDestination? + @State private var braincells = Braincells() @State private var expandedPreview = false @State private var previewHidden = false + @State private var notebookHeight: CGFloat = 24 + /// Slab's "complete" is an attention colour that clears once you return to + /// the window; here, once you touch the draft again. + @State private var attended = true @FocusState private var writing: Bool @Environment(\.openURL) private var openURL + /// One ruled row of the sheet, as the desktop's --notebook-line-height. + private let row: CGFloat = 24 + /// The desktop's #artifact-shell: a 178×119.33 piece in a 4pt frame, 14pt + /// in from the top-right corner. The prose starts on the first ruled row + /// clear of it (the desktop reserves the full width when the page is this + /// narrow). + private let previewSize = CGSize(width: 178, height: 119.33) + private let previewInset: CGFloat = 14 + private var previewVisible: Bool { session.previewURL != nil && !previewHidden } + private var previewBlockHeight: CGFloat { previewVisible ? 168 : row } + + /// Slab's palette for what the session is doing right now. + private var paint: Paint { + let status = attended && session.status == "ready" ? "blank" : session.status + return Paint.slab(status: status, busy: session.busy, failed: session.health == .failed || session.fatal != nil) + } + var body: some View { - VStack(spacing: 0) { - if !expandedPreview { title } - if session.previewURL != nil && !previewHidden { preview } - if !expandedPreview { - transcript - shelf - } + Group { + if expandedPreview { expandedPiece } else { sheet } } + .blur(radius: showSettings ? 4 : 0) .overlay { if showHome { AeselHomeView(session: session, host: host) { showHome = false } } } + .overlay(alignment: .bottomTrailing) { + if !expandedPreview && !showHome && !showSettings { versionLabel } + } + .overlay { if showSettings { settingsPane } } .font(Paint.font()) - .foregroundStyle(Paint.ink) + .foregroundStyle(paint.ink) .background { AeselCloth().ignoresSafeArea() } .background(HostCarrier(host: host).frame(width: 0, height: 0)) - .onChange(of: session.currentSessionID) { draft = ""; previewHidden = false; expandedPreview = false } + .environment(\.paint, paint) + .animation(.easeInOut(duration: 0.5), value: paint) + .onChange(of: session.currentSessionID) { draft = ""; previewHidden = false; expandedPreview = false; attended = true } + .onChange(of: session.busy) { if session.busy { attended = false } } + .onChange(of: writing) { if writing { attended = true } } + .onChange(of: draft) { attended = true } + .onChange(of: session.signedIn) { if session.signedIn { Task { await braincells.load() } } } + .task { braincells.start(token: { host.accessToken() }, credited: { host.refreshCredits() }) } .onAppear { #if DEBUG if ProcessInfo.processInfo.environment["AESEL_NOTEBOOK_PREVIEW"] == "1" { @@ -43,172 +71,336 @@ struct ContentView: View { #endif } .sheet(isPresented: $showHelp) { help } - .sheet(isPresented: $showSettings, onDismiss: { - let destination = settingsDestination - settingsDestination = nil - switch destination { - case .signIn: host.signIn() - case .help: showHelp = true - case nil: break - } - }) { settings } .sheet(isPresented: Binding(get: { session.showSignIn }, set: { session.showSignIn = $0 })) { VStack(spacing: 0) { HStack { AeselWordmark(text: "Aesel") Spacer() Button("/close") { session.showSignIn = false } - .font(Paint.font()).foregroundStyle(Paint.dim) - }.padding(16).background(Paint.bg) + .font(Paint.font()).foregroundStyle(paint.dim) + }.padding(16).background(paint.bg) ZStack { SignInCarrier(host: host) if session.signInLoading { ProgressView("Opening AC sign-in…") - .font(Paint.font()).tint(Paint.ac) + .font(Paint.font()).tint(paint.ac) .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Paint.bg) + .background(paint.bg) } if let error = session.signInError { VStack(spacing: 18) { Text(error).multilineTextAlignment(.center) - Button("/retry") { host.signIn() }.foregroundStyle(Paint.you) + Button("/retry") { host.signIn() }.foregroundStyle(paint.you) } .font(Paint.font()).padding(24) .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Paint.bg) + .background(paint.bg) } } } } + } + + // MARK: - The sheet + /// The desktop's notebook: one scrolling ruled sheet holding the preview, + /// the prose and the draft, with the title fixed on the first row and the + /// version label parked at the bottom-right corner. + private var sheet: some View { + GeometryReader { geometry in + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 0) { + ZStack(alignment: .topTrailing) { + Color.clear.frame(height: previewBlockHeight) + if previewVisible { previewBox } + } + AeselNotebook(session: session, paint: paint, height: $notebookHeight) { openURL($0) } + .frame(height: max(row, notebookHeight)) + if session.fatal != nil { + Button("/retry") { host.restore() } + .foregroundStyle(paint.you).frame(height: row).padding(.horizontal, 10) + } + Spacer(minLength: 0) + prompt.id("prompt") + } + .frame(minHeight: geometry.size.height, alignment: .top) + .background(alignment: .top) { AeselRuling(spacing: row) } + } + .scrollDismissesKeyboard(.interactively) + .overlay(alignment: .topLeading) { + let clear = previewVisible ? previewSize.width + 8 + 2 + previewInset + 16 : 0 + title.frame(maxWidth: max(60, geometry.size.width - 10 - clear), alignment: .leading).clipped() + } + .onChange(of: notebookHeight) { if session.busy || writing { proxy.scrollTo("prompt", anchor: .bottom) } } + .onChange(of: writing) { if writing { withAnimation { proxy.scrollTo("prompt", anchor: .bottom) } } } + .onChange(of: session.busy) { if session.busy { withAnimation { proxy.scrollTo("prompt", anchor: .bottom) } } } + } + } } private var title: some View { - HStack(spacing: 12) { - Button { - if let url = session.shareURL { openURL(url) } - } label: { - AeselTitle(text: session.route.isEmpty ? "new piece" : session.route, - colors: session.handleColors, status: session.status) - .lineLimit(1).minimumScaleFactor(0.6) - } - .buttonStyle(.plain) - .disabled(session.shareURL == nil) - .accessibilityLabel("Open piece in browser") - Spacer(minLength: 0) + Button { + if let url = session.shareURL { openURL(url) } + } label: { + AeselTitle(text: session.route.isEmpty ? "new piece" : session.route, colors: session.handleColors) } - .padding(.horizontal, 16).padding(.vertical, 8) + .buttonStyle(.plain) + .disabled(session.shareURL == nil) + .accessibilityLabel("Open piece in browser") + .frame(height: row) + .padding(.leading, 10) } - private var preview: some View { - ZStack(alignment: .bottomTrailing) { + /// The little piece at the top-right, in the desktop's tinted frame with + /// its accent shadows and the 24pt fullscreen control in its corner. + private var previewBox: some View { + ZStack(alignment: .topTrailing) { if let url = session.previewURL { PieceView(url: url, source: session.source) - .frame(maxWidth: .infinity, maxHeight: expandedPreview ? .infinity : writing ? 140 : 240) + .frame(width: previewSize.width, height: previewSize.height) .clipped() } - Button { expandedPreview.toggle(); writing = false } label: { - Image(systemName: expandedPreview ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right") - .font(.system(size: 14)).padding(10).background(Paint.deep.opacity(0.85)) + Button { expandedPreview = true; writing = false } label: { + Image(systemName: "arrow.up.left.and.arrow.down.right") + .font(.system(size: 12)).frame(width: 24, height: 24) + .background(Color(rgb: 0x1c1728).opacity(0.9)) + .overlay { RoundedRectangle(cornerRadius: 4).stroke(Color.white.opacity(0.4), lineWidth: 1) } + .clipShape(RoundedRectangle(cornerRadius: 4)) } - .accessibilityLabel(expandedPreview ? "Return to aesel" : "Expand piece") + .buttonStyle(.plain).foregroundStyle(.white).padding(5) + .accessibilityLabel("Expand piece") } - .padding(expandedPreview ? 0 : 5) - .background(Paint.frame) - .overlay { Rectangle().stroke(Paint.frameEdge, lineWidth: 1) } - .shadow(color: Paint.accent, radius: 0, x: 3, y: 3) - .shadow(color: Paint.accent.opacity(0.45), radius: 0, x: 5, y: 5) - .padding(.horizontal, expandedPreview ? 0 : 16).padding(.top, expandedPreview ? 0 : 12) + .padding(4) + .background(paint.frame) + .overlay { Rectangle().stroke(paint.frameEdge, lineWidth: 1) } + .shadow(color: Color(rgb: 0x282130), radius: 0, x: 1, y: 1) + .shadow(color: paint.accent, radius: 0, x: 3, y: 3) + .shadow(color: paint.accent.opacity(0.45), radius: 0, x: 5, y: 5) + .padding(.top, previewInset).padding(.trailing, previewInset) } - private var transcript: some View { - VStack(spacing: 0) { - AeselNotebook(session: session) { openURL($0) } - if session.fatal != nil { - Button("/retry") { host.restore() }.padding(8) + private var expandedPiece: some View { + ZStack(alignment: .topTrailing) { + if let url = session.previewURL { + PieceView(url: url, source: session.source) + .frame(maxWidth: .infinity, maxHeight: .infinity) } + Button { expandedPreview = false } label: { + Image(systemName: "arrow.down.right.and.arrow.up.left") + .font(.system(size: 14)).padding(10).background(paint.deep.opacity(0.85)) + } + .buttonStyle(.plain).padding(5) + .accessibilityLabel("Return to aesel") } - .frame(maxHeight: .infinity) } private var appVersion: String { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "—" } - private var shelf: some View { - HStack(alignment: .bottom, spacing: 8) { + /// The draft, written on the sheet's last rows in the user's ink like the + /// desktop's prose prompt; the trailing 100pt stays clear for the version. + private var prompt: some View { + HStack(alignment: .top, spacing: 8) { TextField("make something…", text: $draft, axis: .vertical) - .font(Paint.font()).lineLimit(1...4).textFieldStyle(.plain) - .tint(Paint.you).focused($writing).submitLabel(.send) + .font(Paint.font(16)).foregroundStyle(paint.userInk).tint(paint.userInk) + .lineLimit(1...6).textFieldStyle(.plain) + .focused($writing).submitLabel(.send) .onSubmit { send() }.autocorrectionDisabled() - .padding(.vertical, 10) - Button { session.busy ? host.stop() : send() } label: { - Text(session.busy ? "■" : "↵") - .font(Paint.font(28)).frame(width: 44, height: 44) - .foregroundStyle(Paint.you) - } - .disabled(!session.busy && draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - .accessibilityLabel(session.busy ? "Stop" : "Send") - Button { writing = false; showSettings = true; host.refreshCredits() } label: { - AeselTitle(text: "v\(appVersion)", shadow: Paint.accent, size: 16) - .frame(minHeight: 44) + .frame(minHeight: row) + if session.busy { + Button { host.stop() } label: { + Text("■").font(Paint.font(16)).foregroundStyle(paint.you).frame(width: row, height: row) + } + .accessibilityLabel("Stop") + } else if !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Button { send() } label: { + Text("↵").font(Paint.font(20)).foregroundStyle(paint.you).frame(width: row, height: row) + } + .accessibilityLabel("Send") } - .accessibilityLabel("Version \(appVersion). Settings") } - .buttonStyle(.plain).padding(.leading, 16).padding(.trailing, 8) + .buttonStyle(.plain) + .padding(.leading, 10).padding(.trailing, 100).padding(.bottom, 8) + } + + /// The desktop's #credit-label: the version in Prox lettering, 10pt from + /// the right and 8pt up, and the way into settings. + private var versionLabel: some View { + Button { openSettings() } label: { + AeselTitle(text: "v\(appVersion)", size: 16).padding(9) + } + .buttonStyle(.plain) + .padding(.trailing, 1) + .accessibilityLabel("Version \(appVersion). Settings") + } + + // MARK: - Settings + + private func openSettings() { + writing = false + braincells.notice = "" + host.refreshCredits() + withAnimation(.easeOut(duration: 0.18)) { showSettings = true } + } + + private func closeSettings(then action: (() -> Void)? = nil) { + withAnimation(.easeIn(duration: 0.15)) { showSettings = false } + if let action { DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: action) } + } + + private var modelName: String { + let served = session.reportedModel.isEmpty ? session.model : session.reportedModel + return served.isEmpty ? "choosing…" : served } - private var settings: some View { - NavigationStack { - List { - Section { + private func usd(_ value: Double) -> String { value.formatted(.currency(code: "USD")) } + + /// The desktop's #provider-menu: a centred card over the blurred sheet, + /// 15pt Helvetica, hairline sections, with the account, the automatic + /// model, braincells and the way to buy more. + private var settingsPane: some View { + ZStack { + Color.black.opacity(0.4).ignoresSafeArea() + .onTapGesture { closeSettings() } + ScrollView { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center) { + if session.signedIn { + AeselHandle(handle: session.handle, colors: session.handleColors).font(Paint.title(20)) + } else { + AeselWordmark(text: "aesel") + } + Spacer() + Button { closeSettings() } label: { + Text("×").font(Paint.font(24)).frame(width: 30, height: 30) + } + .accessibilityLabel("Close settings") + } + .padding(.bottom, 6) + + settingsField("Provider") { + HStack(spacing: 9) { + Image("braincell").resizable().scaledToFit().frame(width: 25, height: 25) + Text("Braincells") + } + } + settingsField("Automatic model") { + Text(modelName).lineLimit(1).truncationMode(.middle) + } + .accessibilityLabel("Automatic model, \(modelName)") + + Text(["Remote inference", session.status].joined(separator: " · ")) + .padding(.top, 14) + if let url = session.shareURL { + Text("Auto-publish on · every save goes to \(url.absoluteString)") + .padding(.top, 10) + } + if session.signedIn { - AeselHandle(handle: session.handle, colors: session.handleColors) - Button { host.refreshCredits() } label: { - VStack(alignment: .leading, spacing: 4) { - if let dollars = session.braincellDollars { - Text(dollars, format: .currency(code: "USD")) - .font(.system(size: 28, weight: .medium, design: .rounded)) - } - if let balance = session.braincells { - Text("\(balance.formatted(.number.precision(.fractionLength(0)))) braincells") - } else { Text(session.creditsStatus) } - if let free = session.freeDollars, let paid = session.purchasedDollars { - Text("\(free.formatted(.currency(code: "USD"))) daily · \(paid.formatted(.currency(code: "USD"))) purchased") - .font(.footnote).foregroundStyle(Paint.dim) - } + HStack(spacing: 9) { + Image("braincell").resizable().scaledToFit().frame(width: 25, height: 25) + if let dollars = session.braincellDollars, let balance = session.braincells { + Text("\(usd(dollars)) · \(balance.formatted(.number.precision(.fractionLength(0)))) braincells") + } else if let balance = session.braincells { + Text("\(balance.formatted(.number.precision(.fractionLength(0)))) braincells") + } else { + Text(session.creditsStatus) } } - .buttonStyle(.plain).accessibilityHint("Refresh balance") + .padding(.top, 14) + .onTapGesture { host.refreshCredits() } + .accessibilityHint("Refresh balance") + if let free = session.freeDollars, let paid = session.purchasedDollars { + Text("\(usd(free)) daily · \(usd(paid)) purchased") + .font(Paint.font(13)).foregroundStyle(paint.dim).padding(.top, 4) + } + buyButton.padding(.top, 14) + if !braincells.notice.isEmpty { + Text(braincells.notice).font(Paint.font(13)).foregroundStyle(paint.dim).padding(.top, 8) + } else if !braincells.storeStatus.isEmpty { + Text(braincells.storeStatus).font(Paint.font(13)).foregroundStyle(paint.dim).padding(.top, 8) + } } else { - Button("Sign in") { settingsDestination = .signIn; showSettings = false } - } - Text("Braincells · automatic model") - Text(session.status).foregroundStyle(Paint.dim) - } footer: { - Text("Dollar equivalent at $5 per million braincells. Daily allowance resets at midnight UTC.") - } - Section { - Button("New piece / threads") { showSettings = false; showHome = true } - Button("Publish") { host.publish() } - .disabled(!session.signedIn || session.busy) - Button("Open in browser") { if let url = session.shareURL { openURL(url) } } - .disabled(session.shareURL == nil) - if session.previewURL != nil { - Toggle("Show preview", isOn: Binding(get: { !previewHidden }, set: { previewHidden = !$0 })) + settingsItem("Sign in to AC") { closeSettings { host.signIn() } } + .padding(.top, 14) } - Button("Help") { settingsDestination = .help; showSettings = false } - if session.signedIn { - Button("Sign out") { showSettings = false; host.signOut() } + + settingsDivider + HStack { + Text("Aesel").fontWeight(.semibold) + Spacer() + Text("v\(appVersion)").foregroundStyle(paint.dim) } + .font(Paint.font(13)) + } + .padding(EdgeInsets(top: 20, leading: 24, bottom: 20, trailing: 24)) + } + .frame(maxWidth: 560) + .frame(maxHeight: UIScreen.main.bounds.height * 0.85) + .fixedSize(horizontal: false, vertical: true) + .background(paint.bg) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .overlay { RoundedRectangle(cornerRadius: 16).stroke(paint.ink.opacity(0.18), lineWidth: 1) } + .shadow(color: .black.opacity(0.2), radius: 30, y: 20) + .padding(24) + } + .font(Paint.font(15)) + .buttonStyle(.plain) + .transition(.opacity) + } + + /// The App Store purchase: one big green button with Apple's price. + private var buyButton: some View { + Button { + Task { await braincells.buy() } + } label: { + HStack(spacing: 10) { + Image("braincell").resizable().scaledToFit().frame(width: 28, height: 28) + VStack(alignment: .leading, spacing: 2) { + Text(braincells.busy ? "Opening the App Store…" : "Add 1,000,000 braincells") + .font(Paint.font(18)).fontWeight(.semibold) + Text(braincells.busy ? "" : "\(braincells.price) · App Store").font(Paint.font(13)).opacity(0.85) } + Spacer() } - .scrollContentBackground(.hidden) - .background { AeselCloth().ignoresSafeArea() } - .navigationTitle("Settings").navigationBarTitleDisplayMode(.inline) - .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { showSettings = false } } } + .padding(EdgeInsets(top: 14, leading: 16, bottom: 14, trailing: 16)) + .frame(maxWidth: .infinity, minHeight: 64) + .foregroundStyle(.white) + .background(Color(rgb: 0x1f9d55)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay { RoundedRectangle(cornerRadius: 10).stroke(Color.white.opacity(0.25), lineWidth: 1) } + } + .disabled(braincells.busy || braincells.product == nil) + .opacity(braincells.product == nil ? 0.55 : 1) + .accessibilityLabel("Add one million braincells for \(braincells.price) through the App Store") + } + + private func settingsField(_ label: String, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 5) { + Text(label).font(Paint.font(13)) + content() + .frame(maxWidth: .infinity, minHeight: 36, alignment: .leading) + .padding(.horizontal, 8) + .overlay { RoundedRectangle(cornerRadius: 6).stroke(paint.ink.opacity(0.25), lineWidth: 1) } + } + .padding(.top, 14) + } + + private func settingsItem(_ label: String, enabled: Bool = true, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(label).frame(maxWidth: .infinity, alignment: .leading) + .padding(EdgeInsets(top: 9, leading: 10, bottom: 9, trailing: 10)) + .contentShape(Rectangle()) } - .tint(Paint.you).presentationDetents([.medium, .large]) + .disabled(!enabled) + .opacity(enabled ? 1 : 0.5) + } + + private var settingsDivider: some View { + Rectangle().fill(paint.ink.opacity(0.18)).frame(height: 1) + .padding(.top, 16).padding(.bottom, 12) } private var help: some View { @@ -216,8 +408,8 @@ struct ContentView: View { AeselWordmark() Text("describe a picture, a sound, or a little world. aesel writes it while you watch.") Text("/new start a piece\n/publish share your piece\n/open open in your browser\n/login your AC account") - .foregroundStyle(Paint.dim) - Button("/close") { showHelp = false }.foregroundStyle(Paint.you) + .foregroundStyle(paint.dim) + Button("/close") { showHelp = false }.foregroundStyle(paint.you) Spacer() } .font(Paint.font(22)).padding(24) @@ -236,13 +428,12 @@ struct ContentView: View { case "/login": host.signIn() case "/logout": host.signOut() case "/open": if let url = session.shareURL { openURL(url) } - case "/settings": writing = false; showSettings = true; host.refreshCredits() + case "/settings": openSettings() + case "/buy": openSettings(); Task { await braincells.buy() } case "/help": showHelp = true default: host.ask(text) } } - - } /// Puts the hidden session webview in the hierarchy without drawing it. diff --git a/apple/aesel/Sources/SessionHost.swift b/apple/aesel/Sources/SessionHost.swift index c03a2f1a89..48ef078a40 100644 --- a/apple/aesel/Sources/SessionHost.swift +++ b/apple/aesel/Sources/SessionHost.swift @@ -97,6 +97,7 @@ final class SessionHost: NSObject { func adopt(token: String) { call("void aesel.adoptToken(\(quote(token)));") } func restore() { call("void aesel.restore();") } func refreshCredits() { call("void aesel.refreshCredits();") } + func accessToken() -> String? { store.token() } var signInView: WKWebView { if let loginWebView { return loginWebView } @@ -348,6 +349,17 @@ final class SessionStore { func clearToken() { SecItemDelete(tokenQuery as CFDictionary) } + /// The signed-in AC access token, for the app's own calls to AC. + func token() -> String? { + var query = tokenQuery + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data, let token = String(data: data, encoding: .utf8), !token.isEmpty else { return nil } + return token + } + private func saveToken(_ token: String) { clearToken() guard !token.isEmpty else { return } diff --git a/apple/aesel/bundle-session.sh b/apple/aesel/bundle-session.sh index 478e8d41e5..da43cca500 100755 --- a/apple/aesel/bundle-session.sh +++ b/apple/aesel/bundle-session.sh @@ -8,7 +8,7 @@ done # Use the desktop's sanitized rich-text renderer on iPhone as well. mkdir -p Resources/Session/easel/desktop/vendor rsync -a --delete ../../easel/desktop/vendor/rich/ Resources/Session/easel/desktop/vendor/rich/ -cp ../../easel/desktop/{transcript,reply-details}.js Resources/Session/easel/desktop/ +cp ../../easel/desktop/{transcript,reply-details,notebook-donkey}.js Resources/Session/easel/desktop/ cp ../../easel/desktop/rich.css Resources/Session/easel/desktop/ # The desktop's pencil companion sheet, drawn natively by AeselDonkey. mkdir -p Resources/Session/easel/desktop/assets diff --git a/easel/phone/credits.mjs b/easel/phone/credits.mjs index 1df4c2c835..b81836cc83 100644 --- a/easel/phone/credits.mjs +++ b/easel/phone/credits.mjs @@ -31,5 +31,48 @@ export function createCredits({ token, emit, site, fetch: request = (...args) => } } finally { clearTimeout(timer); } } - return { refresh, clear }; + // The desktop's checkout: one Stripe session per request id, reused until it + // is paid or expires, and credited only once the server has seen the payment. + let pending = null, opening = false; + async function call(body) { + const credential = token(); + if (!credential) throw new Error("Sign in to AC before buying braincells."); + const response = await request(`${site}/api/easel-checkout`, { + method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${credential}` }, + body: JSON.stringify(body), + }); + const value = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(value.error || "Checkout unavailable"); + return value; + } + async function buy() { + if (opening) return; + opening = true; + try { + if (pending?.sessionId) { + const status = await call({ sessionId: pending.sessionId }); + if (status.paid || status.status === "expired") pending = null; + } + if (!pending) pending = { requestId: crypto.randomUUID() }; + const checkout = await call({ pack: "luna-1m-v1", requestId: pending.requestId }); + const url = new URL(checkout.url); + if (url.protocol !== "https:" || !["checkout.stripe.com", "pay.aesthetic.computer"].includes(url.hostname)) throw new Error("Invalid checkout address"); + pending.sessionId = checkout.sessionId; + emit({ type: "checkout", url: url.href, sessionId: checkout.sessionId }); + } catch (error) { + emit({ type: "checkout", error: error.message || "Checkout unavailable" }); + } finally { opening = false; } + } + async function check() { + if (!pending?.sessionId) return; + try { + const status = await call({ sessionId: pending.sessionId }); + if (status.paid || status.status === "expired") { + pending = null; + await refresh(); + emit({ type: "checkout", paid: !!status.paid, done: true }); + } + } catch {} + } + return { refresh, clear: () => { pending = null; clear(); }, buy, check }; } diff --git a/easel/phone/notebook.css b/easel/phone/notebook.css index 83f0dcd494..a012932006 100644 --- a/easel/phone/notebook.css +++ b/easel/phone/notebook.css @@ -1,9 +1,11 @@ -/* The desktop notebook's metrics: 16px Helvetica prose on a 24px ruling. */ +/* The desktop notebook's metrics: 16px Helvetica prose on a 24px ruling. + The page is one pane of the app's own scrolling sheet, which draws the + ruling and owns the scroll; this document only grows to fit its prose. */ :root{color-scheme:dark;--aesel-background:#463264;--aesel-foreground:#fff;--user-ink:#e9b5d5;--number-ink:#ffd17c;--notebook-line-height:24px;--prose-font-size:16px} *{box-sizing:border-box} -html,body{margin:0;height:100%;overflow:hidden;background:transparent;color:var(--aesel-foreground);font:var(--prose-font-size)/var(--notebook-line-height) Arial,Helvetica,sans-serif} -#conversation{position:absolute;inset:0;overflow:auto;padding:0 14px;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;line-height:var(--notebook-line-height)} -#notebook-page{background:repeating-linear-gradient(to bottom,transparent 0,transparent 23px,#ffffff1f 23px,#ffffff1f 24px);padding-bottom:24px;overflow-wrap:anywhere} +html,body{margin:0;overflow:hidden;background:transparent;color:var(--aesel-foreground);font:var(--prose-font-size)/var(--notebook-line-height) Arial,Helvetica,sans-serif} +#conversation{padding:0 10px;line-height:var(--notebook-line-height)} +#notebook-page{overflow-wrap:anywhere} article{margin:0 0 24px;white-space:pre-wrap} article[data-kind=user]{color:var(--user-ink)} article[data-kind=error]{color:#ff9898} @@ -12,4 +14,12 @@ pre{max-width:100%;overflow:auto;white-space:pre;margin:24px 0;font:14px/24px Me code{font:.88em Menlo,monospace} a{color:inherit;text-decoration:underline;text-underline-offset:3px} .user-handle{font:inherit;opacity:.5} -@media(prefers-contrast:more){#notebook-page{background:none}} +/* The desktop's pencil companion: two rows tall beside the latest user prose, behind the ink. */ +#notebook-activity{white-space:nowrap} +#notebook-activity[hidden]{display:none} +#prompt-feedback{position:relative;display:inline-block;width:calc(clamp(36px,var(--prose-font-size,16px)*2.8,64px) + 12px);height:calc(var(--notebook-line-height,24px)*2);vertical-align:middle} +#prompt-feedback[hidden]{display:none} +#activity-caption{display:inline;font:italic .75em/1.3 Helvetica,Arial,sans-serif;opacity:.62;overflow-wrap:anywhere;white-space:normal;pointer-events:none} +#activity-caption[hidden]{display:none} +#notebook-thinking-donkey{position:fixed;z-index:3;pointer-events:none;opacity:.3;filter:saturate(.8)} +#notebook-thinking-donkey[hidden]{display:none} diff --git a/easel/phone/notebook.html b/easel/phone/notebook.html index 3893633e48..7aaeaf4873 100644 --- a/easel/phone/notebook.html +++ b/easel/phone/notebook.html @@ -10,5 +10,6 @@ + diff --git a/easel/phone/notebook.js b/easel/phone/notebook.js index 44da6bd33b..426f8a004f 100644 --- a/easel/phone/notebook.js +++ b/easel/phone/notebook.js @@ -3,6 +3,38 @@ window.aesel = {openLink(url) { if (/^https?:\/\//i.test(url)) window.webkit?.messageHandlers?.notebook?.postMessage({url}); }}; window.updatePhoneNotebook = value => { + for (const [name, property] of [['background','--aesel-background'],['foreground','--aesel-foreground'],['userInk','--user-ink']]) { + const color = value.theme?.[name]; + if (/^#[0-9a-f]{6}$/i.test(color || '')) document.documentElement.style.setProperty(property, color); + } window.setNotebookHandle(value.handle || '', value.colors || []); window.updateConversation({entries:value.entries}); + placeActivity(!!value.busy, value.activity || ''); + reportHeight(); }; +// The desktop's pencil companion and activity caption, following the latest +// user prose while a turn runs (renderer.js placeNotebookActivity). +const feedback = document.createElement('span'); feedback.id = 'prompt-feedback'; feedback.hidden = true; feedback.setAttribute('role', 'img'); +const caption = document.createElement('span'); caption.id = 'activity-caption'; caption.hidden = true; +const activity = document.createElement('span'); activity.id = 'notebook-activity'; activity.hidden = true; activity.append(feedback, caption); +document.body.append(activity); +window.installNotebookDonkey?.(feedback); +function placeActivity(busy, text) { + const last = Array.from(document.querySelectorAll('#notebook-page article[data-kind="user"]')).at(-1); + const target = last?.lastElementChild || last; + if (target && activity.parentElement !== target) target.append(activity); + activity.hidden = feedback.hidden = !busy || !target; + feedback.setAttribute('aria-label', busy ? text || 'working' : 'Idle'); + caption.hidden = activity.hidden || !text; + caption.textContent = caption.hidden ? '' : text; +} +// The app sizes this pane to its prose, so the sheet scrolls as one page. +let reported = 0; +const reportHeight = () => { + const height = Math.ceil(document.documentElement.scrollHeight); + if (height === reported) return; + reported = height; + window.webkit?.messageHandlers?.notebook?.postMessage({height}); +}; +new ResizeObserver(reportHeight).observe(document.documentElement); +document.fonts?.ready.then(reportHeight); diff --git a/easel/phone/session.mjs b/easel/phone/session.mjs index 5c5083e637..d987af3175 100644 --- a/easel/phone/session.mjs +++ b/easel/phone/session.mjs @@ -472,6 +472,8 @@ export function createSession({ storage = memoryStore(), emit = () => {} } = {}) setModel, history, refreshCredits: credits.refresh, + buyCredits: credits.buy, + checkCheckout: credits.check, route, pieceUrl, }; diff --git a/system/backend/easel-paid-credits.mjs b/system/backend/easel-paid-credits.mjs index 8cf30d71e3..860671f227 100644 --- a/system/backend/easel-paid-credits.mjs +++ b/system/backend/easel-paid-credits.mjs @@ -2,6 +2,8 @@ import { randomUUID } from 'node:crypto'; import { connect } from './database.mjs'; export const CREDIT_PACK = Object.freeze({ id:'braincells-1m-v1', amount:500, currency:'usd', credits:1_000_000, unit:'braincells' }); +// The same pack sold through Apple in-app purchase; Apple keeps the price. +export const IAP_PRODUCTS = Object.freeze({ 'computer.aesthetic.easel.braincells.1m': Object.freeze({ credits:1_000_000, unit:'braincells' }) }); const COLLECTION='ac-credit-wallets'; export async function withWallets(fn) { const connection=await connect(); @@ -23,14 +25,27 @@ export function paidCheckout(session, {live}={}) { if(![CREDIT_PACK.id,'luna-1m-v1'].includes(m.pack)||!m.userSub||session.client_reference_id!==m.userSub||session.amount_total!==CREDIT_PACK.amount||session.currency!==CREDIT_PACK.currency||typeof session.id!=='string'||!session.id.startsWith('cs_')||(live!==undefined&&session.livemode!==live))throw new Error('Credit checkout does not match the server offer'); return {user:m.userSub,id:session.id,credits:CREDIT_PACK.credits}; } -export async function fulfillCheckout(session,wallets,options) { - const grant=paidCheckout(session,options); if(!grant)return false; +// A single-document conditional increment makes webhook retries and a +// simultaneous checkout-return reconciliation safe across server processes. +export async function fulfillGrant(grant,wallets) { + if(!grant?.user||typeof grant.id!=='string'||!Number.isSafeInteger(grant.credits)||grant.credits<1)return false; await ensureWallet(grant.user,wallets); - // A single-document conditional increment makes webhook retries and a - // simultaneous checkout-return reconciliation safe across server processes. const result=await wallets.updateOne({_id:grant.user,grants:{$ne:grant.id}},{$inc:{balance:grant.credits},$addToSet:{grants:grant.id},$set:{updatedAt:new Date()}}); return result.modifiedCount===1; } +export async function fulfillCheckout(session,wallets,options) { + const grant=paidCheckout(session,options); if(!grant)return false; + return fulfillGrant(grant,wallets); +} +// Take back a granted pack (an Apple refund). Cumulative per grant, so a +// redelivered notification converges; spent credit becomes debt. +export async function revokeGrant(grant,wallets) { + if(!grant?.user||typeof grant.id!=='string'||!Number.isSafeInteger(grant.credits)||grant.credits<1)return false; + await fulfillGrant(grant,wallets); + const path=`refunds.${grant.id}`; + const result=await wallets.updateOne({_id:grant.user},[{$set:{balance:{$subtract:['$balance',{$max:[0,{$subtract:[grant.credits,{$ifNull:[`$${path}`,0]}]}]}]},[path]:{$max:[grant.credits,{$ifNull:[`$${path}`,0]}]},updatedAt:'$$NOW'}}]); + return result.modifiedCount===1; +} export function reservationSize(body,maxTokens,{validateMedia=true}={}) { // UTF-8 bytes upper-bound text tokens, including JSON tool definitions. // Unsupported media is rejected below; it cannot hide unbounded token cost. diff --git a/system/netlify/functions/easel-iap.mjs b/system/netlify/functions/easel-iap.mjs new file mode 100644 index 0000000000..e8e96cf6b6 --- /dev/null +++ b/system/netlify/functions/easel-iap.mjs @@ -0,0 +1,105 @@ +// Apple in-app purchases of braincells for Aesel on iPhone. +// +// The app sends the StoreKit 2 signed transaction (`jwsRepresentation`); we +// verify Apple's signature and certificate chain here, never the client's +// word, and credit the same wallet the Stripe checkout fills. The grant id is +// the Apple transaction id, so a retried redemption or a duplicate +// notification cannot credit twice. App Store Server Notifications (REFUND) +// arrive at the same address with a `signedPayload` and take the pack back. +import { readFileSync, readdirSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { SignedDataVerifier, Environment } from '@apple/app-store-server-library'; +import { authorize } from '../../backend/authorization.mjs'; +import { IAP_PRODUCTS, withWallets, fulfillGrant, revokeGrant } from '../../backend/easel-paid-credits.mjs'; + +export const BUNDLE_ID='computer.aesthetic.easel'; +export const APP_APPLE_ID=6812823093; +const headers={'Content-Type':'application/json','Cache-Control':'no-store','Access-Control-Allow-Origin':'*','Access-Control-Allow-Headers':'Authorization, Content-Type','Access-Control-Allow-Methods':'POST, OPTIONS'}; +const reply=(statusCode,value)=>({statusCode,headers,body:JSON.stringify(value)}); + +function appleRoots(){ + const dir=join(dirname(fileURLToPath(import.meta.url)),'../../backend/apple-roots'); + return readdirSync(dir).filter(name=>name.endsWith('.cer')).map(name=>readFileSync(join(dir,name))); +} +export function makeVerifiers({roots=appleRoots(),sandbox=process.env.AC_IAP_ALLOW_SANDBOX==='true',online=true}={}){ + const list=[new SignedDataVerifier(roots,online,Environment.PRODUCTION,BUNDLE_ID,APP_APPLE_ID)]; + if(sandbox)list.push(new SignedDataVerifier(roots,online,Environment.SANDBOX,BUNDLE_ID,APP_APPLE_ID)); + return list; +} +async function decodeWith(verifiers,method,jws){ + let failure; + for(const verifier of verifiers){ + try{return {payload:await verifier[method](jws),environment:verifier.environment};} + catch(error){failure=error;} + } + throw failure||new Error('Unverifiable'); +} +// What a verified transaction is worth, or why it is worth nothing. +export function grantFor(transaction,user){ + const product=IAP_PRODUCTS[transaction?.productId]; + if(!product)return {error:'Unknown product'}; + if(transaction.bundleId!==BUNDLE_ID)return {error:'Wrong app'}; + if(transaction.type!=='Consumable')return {error:'Not a consumable'}; + if(transaction.revocationDate)return {error:'Refunded'}; + const quantity=Number.isSafeInteger(transaction.quantity)&&transaction.quantity>0?transaction.quantity:1; + if(quantity>10)return {error:'Quantity too large'}; + if(typeof transaction.transactionId!=='string'||!/^\d{1,30}$/.test(transaction.transactionId))return {error:'Invalid transaction id'}; + return {user,id:`apple:${transaction.transactionId}`,credits:product.credits*quantity,quantity,transactionId:transaction.transactionId}; +} + +export function createHandler({verifiers=makeVerifiers(),verifyUser=authorize,wallets=fn=>withWallets(fn),purchases=null,now=()=>new Date()}={}){ + const record=async doc=>{ if(purchases)await purchases(doc); }; + return async event=>{ + if(event.httpMethod==='OPTIONS')return reply(204,null); + if(event.httpMethod!=='POST')return reply(405,{error:'POST only'}); + let body;try{body=JSON.parse(event.body||'{}');}catch{return reply(400,{error:'Invalid request'});} + + // App Store Server Notifications V2: unauthenticated, signed by Apple. + if(typeof body.signedPayload==='string'){ + let note;try{note=(await decodeWith(verifiers,'verifyAndDecodeNotification',body.signedPayload));}catch{return reply(401,{error:'Invalid notification'});} + const {payload,environment}=note; + try{ + if(['REFUND','REVOKE'].includes(payload.notificationType)&&payload.data?.signedTransactionInfo){ + const {payload:transaction}=await decodeWith(verifiers,'verifyAndDecodeTransaction',payload.data.signedTransactionInfo); + const owner=purchases?await purchases({find:`apple:${transaction.transactionId}`}):null; + const grant=grantFor({...transaction,revocationDate:undefined},owner?.user); + if(grant.error)return reply(200,{received:true,ignored:grant.error}); + if(!owner?.user)return reply(200,{received:true,ignored:'Unknown purchase'}); + await wallets(w=>revokeGrant(grant,w)); + await record({id:grant.id,user:grant.user,refundedAt:now(),environment,notificationUUID:payload.notificationUUID}); + } + return reply(200,{received:true}); + }catch(error){console.error('IAP notification failed:',error.message);return reply(500,{error:'Notification pending'});} + } + + // A purchase the app just made, redeemed for the signed-in account. + let user;try{user=await verifyUser(event.headers||{});}catch{return reply(401,{error:'Sign in to add braincells'});} + if(!user?.sub)return reply(401,{error:'Sign in to add braincells'}); + if(typeof body.jws!=='string'||body.jws.length>16384)return reply(400,{error:'Missing transaction'}); + let verified;try{verified=await decodeWith(verifiers,'verifyAndDecodeTransaction',body.jws);}catch{return reply(401,{error:'Apple could not verify this purchase'});} + const grant=grantFor(verified.payload,user.sub); + if(grant.error)return reply(400,{error:grant.error}); + try{ + const credited=await wallets(w=>fulfillGrant(grant,w)); + await record({id:grant.id,user:user.sub,environment:verified.environment,productId:verified.payload.productId,quantity:grant.quantity,credits:grant.credits, + originalTransactionId:verified.payload.originalTransactionId,purchaseDate:verified.payload.purchaseDate,appAccountToken:verified.payload.appAccountToken,redeemedAt:now()}); + return reply(200,{credited,transactionId:grant.transactionId,credits:grant.credits,environment:verified.environment}); + }catch(error){console.error('IAP fulfillment failed:',error.message);return reply(503,{error:'Could not add braincells yet. The purchase is kept and will be retried.'});} + }; +} + +// Purchases are remembered by grant id so a refund can find its owner. +async function purchaseLedger(doc){ + const { connect } = await import('../../backend/database.mjs'); + const connection=await connect(); + try{ + const purchases=connection.db.collection('ac-credit-purchases'); + if(doc.find)return purchases.findOne({_id:doc.find}); + const {id,...rest}=doc; + await purchases.updateOne({_id:id},{$set:rest,$setOnInsert:{createdAt:new Date()}},{upsert:true}); + }finally{await connection.disconnect();} +} +export async function handler(event){ + return createHandler({purchases:purchaseLedger})(event); +} diff --git a/system/package-lock.json b/system/package-lock.json index 3a7beaf45b..ff871fbf86 100644 --- a/system/package-lock.json +++ b/system/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@apple/app-store-server-library": "^3.1.0", "@atproto/api": "^0.20.28", "@aws-sdk/client-s3": "^3.1020.0", "@aws-sdk/s3-request-presigner": "^3.1020.0", @@ -71,6 +72,9 @@ "three": "^0.182.0", "typescript": "^5.9.3", "web3": "^4.16.0" + }, + "engines": { + "node": ">=24.18.1 <25" } }, "node_modules/@adraffy/ens-normalize": { @@ -80,6 +84,37 @@ "dev": true, "license": "MIT" }, + "node_modules/@apple/app-store-server-library": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@apple/app-store-server-library/-/app-store-server-library-3.1.0.tgz", + "integrity": "sha512-d26SICRz8BwCV2qPR0BSXBMxmw0NEvJLwsdREcBmCrus8NmyHw2XgOOP0fiFjcctPn/JXtmSDuGVnaHe+dqO+A==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "^9.0.5", + "@types/jsrsasign": "^10.5.12", + "@types/node": "^25.4.0", + "@types/node-fetch": "^2.6.13", + "base64url": "^3.0.1", + "jsonwebtoken": "^9.0.2", + "jsrsasign": "^11.0.0", + "node-fetch": "^2.7.0" + } + }, + "node_modules/@apple/app-store-server-library/node_modules/@types/node": { + "version": "25.9.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.6.tgz", + "integrity": "sha512-JR6Q/PV5DKFvjrGFVqQJdeG0qvsqQQLDa3TzFrqVwhqRXqwNaxPo2KYCtCQXpOdIulCouKwT7a6in9nFthBAzw==", + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@apple/app-store-server-library/node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "license": "MIT" + }, "node_modules/@atproto/api": { "version": "0.20.28", "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.20.28.tgz", @@ -10228,6 +10263,28 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/jsrsasign": { + "version": "10.5.15", + "resolved": "https://registry.npmjs.org/@types/jsrsasign/-/jsrsasign-10.5.15.tgz", + "integrity": "sha512-3stUTaSRtN09PPzVWR6aySD9gNnuymz+WviNHoTb85dKu+BjaV4uBbWWGykBBJkfwPtcNZVfTn2lbX00U+yhpQ==", + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.2.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz", @@ -10237,6 +10294,16 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", @@ -11655,6 +11722,12 @@ "dev": true, "license": "MIT" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -11929,6 +12002,15 @@ ], "license": "MIT" }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.43", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", @@ -12321,7 +12403,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -12723,6 +12804,18 @@ "node": ">=12.20" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -13222,6 +13315,15 @@ "dev": true, "license": "MIT" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -13560,7 +13662,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -13678,18 +13779,6 @@ "dev": true, "license": "MIT" }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -13771,7 +13860,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -13781,7 +13869,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -13798,7 +13885,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -13807,6 +13893,21 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -14802,6 +14903,22 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/form-data-encoder": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-4.1.0.tgz", @@ -14811,6 +14928,27 @@ "node": ">= 18" } }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -14849,7 +14987,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -14964,7 +15101,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -15019,7 +15155,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -15340,7 +15475,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -15450,7 +15584,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -15463,7 +15596,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -15487,10 +15619,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -15722,7 +15853,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -15898,17 +16029,6 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 12" - } - }, "node_modules/ipaddr.js": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", @@ -17725,7 +17845,6 @@ "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", - "dev": true, "license": "MIT", "dependencies": { "jws": "^4.0.1", @@ -17744,6 +17863,13 @@ "npm": ">=6" } }, + "node_modules/jsrsasign": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/jsrsasign/-/jsrsasign-11.1.5.tgz", + "integrity": "sha512-i6mAjey0/4UQmlOaNS2vROl8EkWV8Ei436reJAby2OPQ3lF6uXSs1VYpnM8hndmmCMFIzNc04Uw2GF+0JVLFbg==", + "deprecated": "This package is no longer maintained.", + "license": "MIT" + }, "node_modules/jszip": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", @@ -18134,14 +18260,12 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", - "dev": true, "license": "MIT" }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", - "dev": true, "license": "MIT" }, "node_modules/lodash.isequal": { @@ -18156,35 +18280,30 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", - "dev": true, "license": "MIT" }, "node_modules/lodash.isnumber": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", - "dev": true, "license": "MIT" }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true, "license": "MIT" }, "node_modules/lodash.isstring": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", - "dev": true, "license": "MIT" }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", - "dev": true, "license": "MIT" }, "node_modules/log-process-errors": { @@ -18445,7 +18564,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -22990,7 +23108,6 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "dev": true, "license": "MIT", "dependencies": { "whatwg-url": "^5.0.0" @@ -23018,21 +23135,18 @@ "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true, "license": "MIT" }, "node_modules/node-fetch/node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true, "license": "BSD-2-Clause" }, "node_modules/node-fetch/node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dev": true, "license": "MIT", "dependencies": { "tr46": "~0.0.3", @@ -25303,7 +25417,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/sax": { @@ -25644,34 +25758,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks": { - "version": "2.8.7", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", - "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "ip-address": "^10.0.1", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, "node_modules/sonic-boom": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-2.8.0.tgz", diff --git a/system/package.json b/system/package.json index 364132b5b3..8432530eb2 100644 --- a/system/package.json +++ b/system/package.json @@ -53,6 +53,7 @@ "homepage": "https://github.com/whistlegraph/system#readme", "comment:dependencies": "Only packages imported by server code (backend/, netlify/functions/, and the lith + oven runtimes). Production installs with `npm install --omit=dev`, so anything the browser gets from a vendored copy in public/aesthetic.computer/dep/ belongs in devDependencies, not here.", "dependencies": { + "@apple/app-store-server-library": "^3.1.0", "@atproto/api": "^0.20.28", "@aws-sdk/client-s3": "^3.1020.0", "@aws-sdk/s3-request-presigner": "^3.1020.0", diff --git a/system/tests/easel-iap.test.mjs b/system/tests/easel-iap.test.mjs new file mode 100644 index 0000000000..5f5a8b3b12 --- /dev/null +++ b/system/tests/easel-iap.test.mjs @@ -0,0 +1,84 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createHandler, grantFor } from "../netlify/functions/easel-iap.mjs"; + +const transaction = { transactionId: "2000000123", originalTransactionId: "2000000123", bundleId: "computer.aesthetic.easel", + productId: "computer.aesthetic.easel.braincells.1m", type: "Consumable", quantity: 1, purchaseDate: 1789750000000 }; +const verifier = (environment, payloads) => ({ environment, + verifyAndDecodeTransaction: async jws => { if (!payloads[jws]) throw new Error("bad"); return payloads[jws]; }, + verifyAndDecodeNotification: async jws => { if (!payloads[jws]) throw new Error("bad"); return payloads[jws]; } }); +function wallet() { + const doc = { balance: 0, grants: [], refunds: {} }; + const wallets = { + async updateOne(filter, update) { + if (Array.isArray(update)) { // revoke pipeline + const id = Object.keys(update[0].$set).find(k => k.startsWith("refunds."))?.slice(8); + const credits = update[0].$set["refunds." + id].$max[0]; + const already = doc.refunds[id] || 0; + doc.balance -= Math.max(0, credits - already); doc.refunds[id] = Math.max(credits, already); + return { modifiedCount: 1 }; + } + if (update.$setOnInsert) return { matchedCount: 1 }; + if (filter.grants?.$ne && doc.grants.includes(filter.grants.$ne)) return { modifiedCount: 0 }; + doc.balance += update.$inc.balance; doc.grants.push(update.$addToSet.grants); + return { modifiedCount: 1 }; + }, + }; + return { doc, wallets }; +} +const post = (body, auth = "Bearer t") => ({ httpMethod: "POST", headers: auth ? { authorization: auth } : {}, body: JSON.stringify(body) }); + +test("grantFor accepts only this app's consumable", () => { + assert.equal(grantFor(transaction, "u").id, "apple:2000000123"); + assert.equal(grantFor({ ...transaction, productId: "other" }, "u").error, "Unknown product"); + assert.equal(grantFor({ ...transaction, bundleId: "x" }, "u").error, "Wrong app"); + assert.equal(grantFor({ ...transaction, revocationDate: 1 }, "u").error, "Refunded"); + assert.equal(grantFor({ ...transaction, quantity: 3 }, "u").credits, 3_000_000); +}); + +test("a verified purchase credits once, and a retry reports it already credited", async () => { + const { doc, wallets } = wallet(); + const ledger = []; + const handler = createHandler({ verifiers: [verifier("Production", { good: transaction })], + verifyUser: async () => ({ sub: "user" }), wallets: fn => fn(wallets), purchases: async d => { ledger.push(d); } }); + let result = await handler(post({ jws: "good" })); + assert.equal(result.statusCode, 200); + assert.deepEqual(JSON.parse(result.body), { credited: true, transactionId: "2000000123", credits: 1_000_000, environment: "Production" }); + assert.equal(doc.balance, 1_000_000); + result = await handler(post({ jws: "good" })); + assert.equal(JSON.parse(result.body).credited, false); + assert.equal(doc.balance, 1_000_000); + assert.equal(ledger[0].user, "user"); +}); + +test("an unverifiable or unsigned-in purchase grants nothing", async () => { + const { doc, wallets } = wallet(); + const handler = createHandler({ verifiers: [verifier("Production", { good: transaction })], + verifyUser: async h => { if (!h.authorization) throw new Error("no"); return { sub: "user" }; }, wallets: fn => fn(wallets) }); + assert.equal((await handler(post({ jws: "forged" }))).statusCode, 401); + assert.equal((await handler(post({ jws: "good" }, null))).statusCode, 401); + assert.equal(doc.balance, 0); +}); + +test("sandbox transactions only count when a sandbox verifier is configured", async () => { + const { doc, wallets } = wallet(); + const handler = createHandler({ verifiers: [verifier("Production", {})], verifyUser: async () => ({ sub: "user" }), wallets: fn => fn(wallets) }); + assert.equal((await handler(post({ jws: "sandbox" }))).statusCode, 401); + assert.equal(doc.balance, 0); +}); + +test("an Apple refund notification takes the pack back from its owner", async () => { + const { doc, wallets } = wallet(); + const owners = { "apple:2000000123": { user: "user" } }; + const handler = createHandler({ + verifiers: [verifier("Production", { good: transaction, refund: { notificationType: "REFUND", notificationUUID: "n1", data: { signedTransactionInfo: "good" } } })], + verifyUser: async () => ({ sub: "user" }), wallets: fn => fn(wallets), + purchases: async d => d.find ? owners[d.find] : undefined }); + await handler(post({ jws: "good" })); + assert.equal(doc.balance, 1_000_000); + const result = await handler({ httpMethod: "POST", headers: {}, body: JSON.stringify({ signedPayload: "refund" }) }); + assert.equal(result.statusCode, 200); + assert.equal(doc.balance, 0); + await handler({ httpMethod: "POST", headers: {}, body: JSON.stringify({ signedPayload: "refund" }) }); + assert.equal(doc.balance, 0, "a redelivered refund converges"); +});