How to use Core Data

Issue #785

Core Data

  • Responding to changes in a managed object context

    Calling mergeChanges on a managed object context will automatically refresh any managed objects that have changed. This ensures that your context always contains all the latest information. Note that you don’t have to call mergeChanges on a viewContext when you set its automaticallyMergesChangesFromParent property to true. In that case, Core Data will handle the merge on your behalf.

  • Using Core Data in the Background

    Managed objects retrieved from a context are bound to the same queue that the context is bound to.

  • The Laws of Core Data

    The ability to have a child MOC is neat in some corner cases. Let’s review the core functionality of MOCs in order to understand those cases:

  • MOCs load objects from its source (a PSC or another MOC)
  • MOCs save objects to its source (a PSC or another MOC)
  • MOCs enforce graph integrity when they save

Use Core Data as a local cache

When you save changes in a context, the changes are only committed “one store up.” If you save a child context, changes are pushed to its parent. Changes are not saved to the persistent store until the root context is saved. (A root managed object context is one whose parent context is nil.)

  • NSManagedObjectContext fetch

    An object that meets the criteria specified by request (it is an instance of the entity specified by the request, and it matches the request’s predicate if there is one) and that has been inserted into a context but which is not yet saved to a persistent store, is retrieved if the fetch request is executed on that context.

  • NSBatchDeleteRequest mergeChanges

    Changes are not reflected in the context”. So what you’re seeing is normal. Batch updates work directly on the persistent store file instead of going through the managed object context, so the context doesn’t know about them. When you delete the objects by fetching and then deleting, you’re working through the context, so it knows about the changes you’re making (in fact it’s performing those changes for you).

  • Does modifying a managed object in a parent context propagate down to child contexts when performing a child fetch in Core Data?

mainContext would fetch all the way to the persistent store. Fetches and objectWithID: only go as many levels as they need to.
It’s important to remember that when a context is created it’s a “snapshot” of the state of it’s parent. Subsequent changes to the parent will not be visible in the child unless the child is somehow invalidated

CloudKit

NSFetchedResultsController

How to fetch with background context

TLDR: Use NSFetchedResultsController in backgroundContext

  • If use Core Data as cache: convert to struct
  • If use NSManagedObject: use FetchRequest on viewContext

Approach 1: Treat Core Data as cache

Pros

  • backgroundContext.automaticallyMergesChangesFromParent
  • Use 1 shared background context to load and updates changes
  • Convert NSManagedObject to struct
  • Our struct can have additional logic and properties
  • Does not need to care about faulting

Cons

  • Need to update objectId When context.save, NSManagedObject ‘s objecteId is updated
  • Need to reload when underlying NSManagedObject changes
  • Need to handle relationship

Instead of plain NSFetchRequest, can use NSFetchedResultsController on background context. In its controllerDidChangeContent convert to struct

Approach 2: viewContext to persistent coordinator

1
viewContext -> persistent coordinator <- backgroundContext

Pros

  • viewContext.automaticallyMergesChangesFromParent
  • viewContext and background Context stems from persistent container
  • background context changes object
  • viewContext read only

Cons

  • viewContext blocks main thread

Approach 3: viewContext to background context

1
viewContext -> backgroundContext -> persistent coordinator

Pros

  • viewContext.automaticallyMergesChangesFromParent
  • viewContext reads from backgroundContext, no main queue block
  • backgroundContext changes

Cons

  • ???

Read more

Approach 4: background context -> mainContext -> backgroundContext -> persistent coordinator

https://stackoverflow.com/questions/35961936/child-nsmanagedobjectcontext-update-from-parent

1
2
3
RootContext (private queue) - saves to persistent store
MainContext (main queue) child of RootContext - use for UI (FRC)
WorkerContext (private queue) - child of MainContext - use for updates & inserts

Updated at 2021-02-28 07:58:23

How to tune performance with ButtonBehavior in SwiftUI

Issue #779

With Xcode 12.4, macOS 11.0 app. Every time we switch the system dark and light mode, the CPU goes up to 100%. Instruments show that there’s an increasing number of ButtonBehavior

Screenshot 2021-02-24 at 10 12 05

Suspect State in a row in LazyVStack

Every cell has its own toggle state

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct Cell: View {
enum ToggleState {
case general
case request
case response
}

let item: Item
@State
private var toggleState: ToggleState = .general

func toggleButton(text: String, state: ToggleState) -> some View {
Button(action: { self.toggleState = state }) {
Text(text)
.foregroundColor(state == toggleState ? Color.label : Color.secondary)
.fontWeight(state == toggleState ? .bold : .regular)
}
.buttonStyle(BorderlessButtonStyle())
}
}

Removing the buttons fix the problem. The workaround is to use Text with onTapGesture

1
2
3
4
5
6
Text(text)
.foregroundColor(state == toggleState ? Color.label : Color.secondary)
.fontWeight(state == toggleState ? .bold : .regular)
.onTapGesture {
self.toggleState = state
}

Updated at 2021-02-24 10:10:41

How to use GroupBox in SwiftUI

Issue #778

For now using GroupBox has these issues in macOS

  • Prevents dragging scroll indicator to scroll
  • Switch from light to dark mode may cause 100% CPU usage

Updated at 2021-02-23 22:02:25

How to show modal window in SwiftUI for macOS

Issue #768

Use custom NSWindow, set level in becomeKey and call NSApp.runModal to show modal

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
final class ModalWindow: NSWindow {
override func becomeKey() {
super.becomeKey()

level = .statusBar
}

override func close() {
super.close()

NSApp.stopModal()
}
}

let window = ModalWindow(
contentRect: .zero,
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)

window.titlebarAppearsTransparent = true
window.title = "Manage collections"

window.center()
window.isReleasedWhenClosed = false
self.window = window
let view = CollectionSettingsView(store: Store.shared)
.padding()
.frame(
width: Constants.settingsViewWidth,
height: 350,
alignment: .topLeading
)
let hosting = NSHostingView(rootView: view)
window.contentView = hosting
hosting.autoresizingMask = [.width, .height]

NSApp.runModal(for: window)

How to handle keyDown in SwiftUI for macOS

Issue #764

Use a custom KeyAwareView that uses an NSView that checks for keyDown method. In case we can’t handle certain keys, call super.keyDown(with: event)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import SwiftUI
import KeyboardShortcuts

struct KeyAwareView: NSViewRepresentable {
let onEvent: (Event) -> Void

func makeNSView(context: Context) -> NSView {
let view = KeyView()
view.onEvent = onEvent
DispatchQueue.main.async {
view.window?.makeFirstResponder(view)
}
return view
}

func updateNSView(_ nsView: NSView, context: Context) {}
}

extension KeyAwareView {
enum Event {
case upArrow
case downArrow
case leftArrow
case rightArrow
case space
case delete
case cmdC
}
}

private class KeyView: NSView {
var onEvent: (KeyAwareView.Event) -> Void = { _ in }

override var acceptsFirstResponder: Bool { true }
override func keyDown(with event: NSEvent) {
switch Int(event.keyCode) {
case KeyboardShortcuts.Key.delete.rawValue:
onEvent(.delete)
case KeyboardShortcuts.Key.upArrow.rawValue:
onEvent(.upArrow)
case KeyboardShortcuts.Key.downArrow.rawValue:
onEvent(.downArrow)
case KeyboardShortcuts.Key.leftArrow.rawValue:
onEvent(.leftArrow)
case KeyboardShortcuts.Key.rightArrow.rawValue:
onEvent(.rightArrow)
case KeyboardShortcuts.Key.space.rawValue:
onEvent(.space)
case KeyboardShortcuts.Key.c.rawValue where event.modifierFlags.contains(.command):
onEvent(.cmdC)
default:
super.keyDown(with: event)
}
}
}

Then we can place this as a background

1
2
3
4
LazyVStack {

}
.background(KeyAwareView(onEvent: {}))

Updated at 2021-01-29 20:55:39

How to use Sparkle for macOS app

Issue #762

Install Sparkle

  • For now, the latest stable version is 1.24.0 which supports CocoaPods OK, but still, have issues with SPM. Support non sandboxed apps
  • Version 2.0.0 is in beta and supports sandboxed apps

To install, use CocoaPods

1
2
3
4
5
6
7
8
9
platform :osx, '11.0'

target 'MyApp' do
# Comment the next line if you don't want to use dynamic frameworks
use_frameworks!

pod 'Sparkle'

end

Sign

In your target, choose Signing & Capability tab, change Signing Certificate from Locally to Development for code sign to work for embedded frameworks

Specify SUUpdater

Follow Sparkle documentation, let’s add an Object to Application Scene in Main.storyboard and specify SUUpdater class

In AppDelegate

1
2
3
4
@IBAction func checkForUpdates(_ sender: Any) {
let updater = SUUpdater.shared()
updater?.checkForUpdates(self)
}

Additionally, we can add an NSMenuItem and drag it to our SUUpdater object checkForUpdates method

In Info.plist, add SUFeedURL with a link to your cast file

1
2
<key>SUFeedURL</key>
<string>https://onmyway133.com/MyAppCast.xml</string>

Configure cast file

I usually upload my cast to S3 or GitHub. For GitHub with raw URL, remember that there is 5 minutes cache by default

If you follow sample cast file https://sparkle-project.org/files/sparkletestcast.xml

Notice that sparkle:version="2.0" is CFBundleVersion which is your build number. You need to also specify sparkle:shortVersionString which is CFBundleShortVersionString your version number

1
sparkle:shortVersionString="2.0.1"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
<title>Sparkle Test App Changelog</title>
<link>http://sparkle-project.org/files/sparkletestcast.xml</link>
<description>Most recent changes with links to updates.</description>
<language>en</language>
<item>
<title>Version 2.0</title>
<description>
<![CDATA[
<ul>
<li>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</li>
<li>Suspendisse sed felis ac ante ultrices rhoncus. Etiam quis elit vel nibh placerat facilisis in id leo.</li>
<li>Vestibulum nec tortor odio, nec malesuada libero. Cras vel convallis nunc.</li>
<li>Suspendisse tristique massa eget velit consequat tincidunt. Praesent sodales hendrerit pretium.</li>
</ul>
]]>
</description>
<pubDate>Sat, 26 Jul 2014 15:20:11 +0000</pubDate>
<enclosure url="https://sparkle-project.org/files/Sparkle%20Test%20App.zip" sparkle:version="2.0" length="107758" type="application/octet-stream" sparkle:dsaSignature="MCwCFCdoW13VBGJWIfIklKxQVyetgxE7AhQTVuY9uQT0KOV1UEk21epBsGZMPg==" />
</item>
</channel>
</rss>

Updated at 2021-01-26 06:34:38

How to handle keyDown in NSResponder

Issue #760

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import AppKit
import Omnia

class MyWindow: NSWindow {
override func keyDown(with event: NSEvent) {
super.keyDown(with: event)

if isKey(NSDeleteCharacter, event: event) {
NotificationCenter.default.post(Notification(name: .didKeyboardDeleteItem))
} else if isKey(NSUpArrowFunctionKey, event: event) {
print("up")
} else if isKey(NSDownArrowFunctionKey, event: event) {
print("down")
} else if isKey(NSLeftArrowFunctionKey, event: event) {
print("left")
} else if isKey(NSRightArrowFunctionKey, event: event) {
print("right")
}
}

private func isKey(_ key: Int, event: NSEvent) -> Bool {
if let scalar = UnicodeScalar(key) {
return event.characters == String(scalar)
} else {
return false
}
}
}

Another way is to listen to

1
2
3
4
5
6
NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
if handled(with: $0) {
return nil
}
return event
}

Or create our own inspector

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
class MyWindow: NSWindow {
override func keyDown(with event: NSEvent) {
if !EventService.shared.inspect(event) {
super.keyDown(with: event)
}
}

override var canBecomeKey: Bool { true }
override var canBecomeMain: Bool { true }
override var acceptsFirstResponder: Bool { true }
override func becomeFirstResponder() -> Bool { true }
}

import AppKit
import Omnia
import KeyboardShortcuts

final class EventService {
static let shared = EventService()

private var combos: [Combo] = []
private let debouncer = Debouncer(delay: 0.1)

func register(_ combo: Combo) {
combos.append(combo)
}

func inspect(_ event: NSEvent) -> Bool {
for combo in combos {
if combo.match(event) {
self.debouncer.run {
combo.action()
}
return true
}
}

return false
}
}

extension EventService {
struct Combo {
let modifier: NSEvent.ModifierFlags?
let keyCode: KeyboardShortcuts.Key
let action: () -> Void

func match(_ event: NSEvent) -> Bool {
if let modifier = modifier,
!event.modifierFlags.contains(modifier) {
return false
}

if event.keyCode != keyCode.rawValue {
return false
}

return true
}
}
}

Updated at 2021-01-25 20:05:08

How to handle NSSearchToolbarItem in macOS 11

Issue #758

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
extension NSToolbarItem.Identifier {
static let searchItem: NSToolbarItem.Identifier = NSToolbarItem.Identifier("SearchItem")
}

let searchItem = NSSearchToolbarItem(itemIdentifier: .searchItem)

extension AppDelegate: NSToolbarDelegate {
func toolbar(
_ toolbar: NSToolbar,
itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier,
willBeInsertedIntoToolbar flag: Bool
) -> NSToolbarItem? {
switch itemIdentifier {
case .searchItem:
searchItem.searchField.delegate = self
return searchItem
}
}


extension AppDelegate: NSSearchFieldDelegate {
func control(
_ control: NSControl,
textView: NSTextView,
doCommandBy commandSelector: Selector
) -> Bool {
if (commandSelector == #selector(NSResponder.insertNewline(_:))) {
print("enter")
return true
}

return false
}
}

How to do launch at login for macOS apps

Issue #757

  • Use SMLoginItemSetEnabled from Service Management framework
  • Use a helper background app that checks and invokes our main application
  • Copy our helper app into Library/LoginItems
1
helper_dir="$BUILT_PRODUCTS_DIR/$CONTENTS_FOLDER_PATH/Library/LoginItems"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
let bundleId = Bundle.main.bundleIdentifier!
// TODO: Make this more strict by only replacing at the end
let mainBundleId = bundleId.replacingOccurrences(of: "-LaunchAtLoginHelper", with: "")

// Ensure the app is not already running
guard NSRunningApplication.runningApplications(withBundleIdentifier: mainBundleId).isEmpty else {
NSApp.terminate(nil)
return
}

let pathComponents = (Bundle.main.bundlePath as NSString).pathComponents
let mainPath = NSString.path(withComponents: Array(pathComponents[0...(pathComponents.count - 5)]))
NSWorkspace.shared.launchApplication(mainPath)
NSApp.terminate(nil)
}
}

Read more

How to create and notarize dmg file

Issue #753

  • Archive and export app from Xcode
  • Create dmg
  • Use create-dmg
    It is said that we don’t need to notarize the app, we can just notarize the whole dmg
  • Send dmg to notarize

This takes a while

1
xcrun altool -t osx -f PastePal.dmg --primary-bundle-id com.onmyway133.PastePal --notarize-app -u onmyway133@gmail.com -p APP_SPECIFIC_PASSWORD --asc-provider "T78DK947F2"

If wrong password, you will get error

Error: code -1011 (Failed to authenticate for session: (
“Error Domain=ITunesConnectionAuthenticationErrorDomain Code=-22938 "Sign in with the app-specific password you generated. If you forgot the app-specific password or need to create a new one, go to appleid.apple.com" UserInfo={NSLocalizedRecoverySuggestion=Sign in with the app-specific password you generated. If you forgot the app-specific password or need to create a new one, go to appleid.apple.com, NSLocalizedDescription=Sign in with the app-specific password you generated. If you forgot the app-specific password or need to create a new one, go to appleid.apple.com, NSLocalizedFailureReason=App Store operation failed.}”
) Unable to upload your app for notarization.)

It succeeds, you will get ticket ID

1
Downloaded ticket has been stored at file:///var/folders/mn/whdh16qj6jldpcdpgyjhp35m0000gn/T/51e3b8bb-1b86-4cd6-abcd-bca469561234.ticket.
  • Check notarization info
1
xcrun altool –-notarization-info TICKET_ID -u "onmyway133@gmail.com"
  • Staple the dmg

Once we have the dmg notarized, we need to staple it

1
xcrun stapler staple -v PastePal.dmg

Read more


Updated at 2021-02-12 23:17:28

How to open downloaded app from browser in Big Sur

Issue #750

Recently when distributing staging releases of my app PastePal via GitHub release or Google Drive, people had hard time opening it

Screenshot 2021-01-15 at 10 26 26

The displayed error is

You do not have permission to open the application

The more verbose error when using open command in Terminal is

The application cannot be opened for an unexpected reason, error=Error Domain=NSOSStatusErrorDomain Code=-10826 “kLSNoLaunchPermissionErr: User doesn’t have permission to launch the app (managed networks)” UserInfo={_LSFunction=_LSLaunchWithRunningboard, _LSLine=2508, NSUnderlyingError=0x7fa9c750d850 {Error Domain=RBSRequestErrorDomain Code=5 “Launch failed.” UserInfo={NSLocalizedFailureReason=Launch failed., NSUnderlyingError=0x7fa9c750e010 {Error Domain=NSPOSIXErrorDomain Code=153 “Unknown error: 153” UserInfo={NSLocalizedDescription=Launchd job spawn failed with error: 153}}}}}

From further investigation. This is restriction of Big Sur for downloaded zip file from browser

  • When decompressing the .zip, the application contents didn’t retain the execute bits. Add it back with sudo chmod -R 755 /path/to/app
  • Since the application was downloaded by a web browser the quarantine bits are set on the decompressed files. Remove that with sudo xattr -dr com.apple.quarantine /path/to/app

It still didn’t work despite the fact that I enabled apps from identified developers and that I also notarized my app.

Screenshot 2021-01-15 at 10 29 49

The reason was I zip my PastePal.app before uploading to Google Drive. Now I just upload the bare PastePal.app, upon downloading Google Drive still zip the app but when I apply the chmod and xattr it works now 🎉

Read more


Updated at 2021-01-15 09:31:23

How to make popup button in SwiftUI for macOS

Issue #748

There is said to be PopUpButtonPickerStyle and MenuPickerStyle but these don’t seem to work.

There’s Menu button it shows a dropdown style. We fake it by fading this and overlay with a button. allowsHitTesting does not work, but disabled seems to do the trick

1
2
3
4
5
6
7
8
9
10
11
12
13
Menu {
Button("About", action: ActionService.onAbout)
Button("Quit", action: ActionService.onQuit)
} label: {
Text("")
}
.frame(width: 24)
.opacity(0.01)
.overlay(
makeButton(action: {}, "gearshape.fill")
.disabled(true)
.foregroundColor(Color.secondaryLabel)
)

Follow pika

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
struct ColorMenu: View {
var eyedropper: Eyedropper

var body: some View {
if #available(OSX 11.0, *) {
Menu {
ColorMenuItems(eyedropper: eyedropper)
} label: {
Image(systemName: "ellipsis.circle")
}
.menuStyle(BorderlessButtonMenuStyle(showsMenuIndicator: false))
} else {
MenuButton(label: IconImage(name: "ellipsis.circle"), content: {
ColorMenuItems(eyedropper: eyedropper)
})
.menuButtonStyle(BorderlessButtonMenuButtonStyle())
}
}
}

struct ColorMenuItems: View {
var eyedropper: Eyedropper
let pasteboard = NSPasteboard.general

var body: some View {
VStack(alignment: .leading, spacing: 0.0) {
Text(eyedropper.title)
Divider()
}
Button(action: {
pasteboard.clearContents()
pasteboard.setString(eyedropper.color.toHex, forType: .string)
}, label: { Text("Copy color hex") })
Button(action: {
pasteboard.clearContents()
pasteboard.setString(eyedropper.color.toRGB, forType: .string)
}, label: { Text("Copy RGB values") })
Button(action: {
pasteboard.clearContents()
pasteboard.setString(eyedropper.color.toHSB, forType: .string)
}, label: { Text("Copy HSB values") })
}
}

Need to specify .fixedSize() for menu rows to hug content. Can also use opacity to reduce Menu button color

1
2
3
Menu
.fixedSize()
.opacity(0.8)

Updated at 2021-01-21 09:12:48

How to use Apple keyboard key symbols

Issue #743

•  = Apple logo
• ⌘ = Command
• ⇧ = Shift
• ⌫ = Backspace/Delete
• ⇪ = Caps lock
• ⌥ = Option/Alt
• ⌃ = Control
• ⎋ = Escape
• ←↑→↓ = Arrow Keys

• ↩ = Return

Updated at 2021-01-05 21:50:39

How to disable scrolling in NSTextView for macOS

Issue #733

NSTextView has this handy method to make scrollable NSTextView NSTextView.scrollableTextView(). The solution is to get to the responder outside enclosing NSScrollView, in my case it is the SwiftUI hosting view

1
2
3
4
5
6
7
8
9
class DisabledScrollTextView: NSTextView {
override func scrollWheel(with event: NSEvent)
{
// 1st nextResponder is NSClipView
// 2nd nextResponder is NSScrollView
// 3rd nextResponder is NSResponder SwiftUIPlatformViewHost
self.nextResponder?.nextResponder?.nextResponder?.scrollWheel(with: event)
}
}

Then we can construct with our new DisabledScrollTextView.scrollableTextView

Updated at 2020-12-31 07:45:19

How to make attributed string Text in SwiftUI for macOS

Issue #730

Use NSTextField with maximumNumberOfLines

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import AppKit
import SwiftUI

struct AttributedText: NSViewRepresentable {

let attributedString: NSAttributedString

init(_ attributedString: NSAttributedString) {
self.attributedString = attributedString
}

func makeNSView(context: Context) -> NSTextField {
let textField = NSTextField()

textField.lineBreakMode = .byClipping
textField.maximumNumberOfLines = 0
textField.isBordered = false

return textField
}

func updateNSView(_ nsView: NSTextField, context: Context) {
nsView.attributedStringValue = attributedString
}
}

TextField has problem with wrapping, we can use TextView

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
struct AttributedTextView: NSViewRepresentable {
typealias NSViewType = NSScrollView

let attributedText: NSAttributedString?
let isSelectable: Bool
var insetSize: CGSize = .zero

func makeNSView(context: Context) -> NSViewType {
let scrollView = NSTextView.scrollableTextView()

let textView = scrollView.documentView as! NSTextView
textView.drawsBackground = false
textView.textColor = .controlTextColor
textView.textContainerInset = insetSize

return scrollView
}

func updateNSView(_ nsView: NSViewType, context: Context) {
let textView = (nsView.documentView as! NSTextView)
textView.isSelectable = isSelectable

if let attributedText = attributedText,
attributedText != textView.attributedString() {
textView.textStorage?.setAttributedString(attributedText)
}

if let lineLimit = context.environment.lineLimit {
textView.textContainer?.maximumNumberOfLines = lineLimit
}
}
}

Updated at 2020-12-31 05:51:42

How to do copy paste delete in Swift for macOS

Issue #729

1
2
3
4
5
6
7
8
9
10
11
@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {
@IBAction func copy(_ sender: Any) {
print("copy", sender)
}


@IBAction func paste(_ sender: Any) {
print("paste", sender)
}
}

For delete, we can listen to keyDown in NSWindow

1
2
3
4
5
6
7
8
9
10
11
12
class MyWindow: NSWindow {
override func keyDown(with event: NSEvent) {
super.keyDown(with: event)

guard
let deleteScalar = UnicodeScalar(NSDeleteCharacter),
event.charactersIgnoringModifiers == String(deleteScalar)
else { return }

NotificationCenter.default.post(Notification(name: .didKeyboardDeleteItem))
}
}

Updated at 2020-12-30 05:48:29

How to make visual effect blur in SwiftUI for macOS

Issue #724

We can use .blur modifier, but with VisualEffectView gives us more options for material and blending mode.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public struct VisualEffectView: NSViewRepresentable {
let material: NSVisualEffectView.Material
let blendingMode: NSVisualEffectView.BlendingMode

public init(
material: NSVisualEffectView.Material = .contentBackground,
blendingMode: NSVisualEffectView.BlendingMode = .withinWindow
) {
self.material = material
self.blendingMode = blendingMode
}

public func makeNSView(context: Context) -> NSVisualEffectView {
let visualEffectView = NSVisualEffectView()
visualEffectView.material = material
visualEffectView.blendingMode = blendingMode
visualEffectView.state = NSVisualEffectView.State.active
return visualEffectView
}

public func updateNSView(_ visualEffectView: NSVisualEffectView, context: Context) {
visualEffectView.material = material
visualEffectView.blendingMode = blendingMode
}
}

How to force set frame explicitly for NSWindow

Issue #721

For setFrame to take effect

1
mainWindow.setFrame(rect, display: true)

we can remove auto save frame flag

1
mainWindow.setFrameAutosaveName("MyApp.MainWindow")

How to rotate NSStatusItem

Issue #720

NSStatusItem is backed by NSButton, we can animate this inner button. We need to specify position and anchorPoint for button’s layer so it rotates around its center point

1
2
3
4
5
6
7
8
9
10
11
12
guard
let button = statusItem.button
else { return }

let animation = CABasicAnimation(keyPath: "transform.rotation.z")
animation.fromValue = 0
animation.toValue = CGFloat.pi * 2
animation.duration = 0.25
animation.repeatCount = 1
button.layer?.position = NSPoint(x: NSMidX(button.frame), y: NSMidY(button.frame))
button.layer?.anchorPoint = NSPoint(x: 0.5, y: 0.5)
button.layer?.add(animation, forKey: "rotate")

Updated at 2020-12-26 21:21:00

How to show image and text in menu item in SwiftUI for macOS

Issue #719

From SwiftUI 2 for macOS 11.0, we have access to Menu for creating menu and submenu. Usually we use Button for interactive menu items and Text for disabled menu items.

The easy way to customize menu with image is to call Menu with content and label. Pay attention to how we use Button and Label inside Content to create interactive menu items

1
2
3
4
5
6
7
8
9
10
11
12
13
Menu(
content: {
ForEach(collections) { collection in
Button(action: {) {
Label(collection.name, systemImage: SFSymbol.star.rawValue)
}
}
},
label: {
Image(systemName: SFSymbol.bookmarkFill.rawValue)
Text("Add to collection")
}
)

We can also use Image and Text separately. By default SwiftUI wraps these inside HStack automatically for us. For now, color has no effect in Menu, but it works on Text

1
2
3
4
Image(systemName: SFSymbol.bookmarkFill.rawValue)
.foregroundColor(Color.red)
Text(collection.name)
.foregroundColor(Color.green)

One way to mitigate this is to use Text with icon font. Here I use my FontAwesomeSwiftUI

There’s a problem that only the first Text is shown

1
2
3
4
Text(collection.icon)
.font(.awesome(style: .solid, size: 18))
.foregroundColor(Color.red)
Text(collection.name)

The solution is to concatenate Text. In SwiftUI, Text has + operator that allows us to make cool attributed texts

Screenshot 2020-12-23 at 07 34 40
1
2
3
4
Text(collection.icon)
.font(.awesome(style: .solid, size: 18))
.foregroundColor(Color.red)
+ Text(collection.name)

Updated at 2020-12-23 06:35:31

How to make stepper with plus and minus buttons in SwiftUI for macOS

Issue #717

Try to use predefined system colors in Human Interface Guidelines for macOS

Here we use this color unemphasizedSelectedTextBackgroundColor for button background

Screenshot 2020-12-21 at 06 24 16
1
2
3
4
5
6
7
8
9
10
11
12
13
14
HStack(spacing: 1) {
makeUnderListButton(action: {}, icon: .plus)
makeUnderListButton(action: {}, icon: .minus)
}
.background(Color(NSColor.unemphasizedSelectedTextBackgroundColor))
.cornerRadius(4)

func makeUnderListButton(action: @escaping () -> Void, icon: AwesomeIcon) -> some View {
Button(action: action) {
Text(icon.rawValue)
.font(.awesome(style: .solid, size: 14))
}
.buttonStyle(HighlightButtonStyle(h: 8, v: 6, cornerRadius: 4))
}

Another thing is List, where we have selected and alternative background colors. We should also use dynamic system colors selectedContentBackgroundColor and alternatingContentBackgroundColors

1
2
3
4
5
6
7
8
9
10
11
12
13
VStack {
ForEach(apps.enumerated().map({ $0 }), id: \.element) { index, app in
makeRow(app: app)
.onTapGesture {
self.selected = app
}
.background(
self.selected == app
? Color(NSColor.selectedContentBackgroundColor)
: Color(NSColor.alternatingContentBackgroundColors[index % 2])
)
}
}

Read more


Updated at 2020-12-21 05:56:28

How to fix Picker not showing selection in SwiftUI

Issue #716

I have an enum that conforms to CaseIterable that I want to show in Picker

1
2
3
4
5
6
7
8
9
10
11
12
13
enum Position: String, Codable, CaseIterable, Identifiable {
var id: String { rawValue }
case left
case right
case bottom
case top
}

Picker(selection: $preference.position, label: Text("Position")) {
ForEach(Preference.Position.allCases) { position in
Text(position.rawValue)
}
}

It compiles and runs just fine, but Picker does not show current selection regardless of any Picker style I choose. It does not update Binding at all.

The fix is to specify id, it looks redundant because of enum conforms to Identifiable, but it fixes the problem

1
2
3
4
5
Picker(selection: $preference.position, label: Text("Position")) {
ForEach(Preference.Position.allCases, id: \.self) { position in
Text(position.rawValue)
}
}

Mismatch between rawValue and enum case itself

Reading ForEach once again

1
init(_ data: Data, content: @escaping (Data.Element) -> Content)

Available when Data conforms to RandomAccessCollection, ID is Data.Element.ID, Content conforms to View, and Data.Element conforms to Identifiable.

So in our case, we use rawValue as id for Identifiable, so there’s mismatch between our selection being enum case and items in ForEach, which uses rawValue to uniquely identifies items. So our fix is to explicitly state that we want to use the enum case itself \.self as idfor ForEach

What we can also do is to declare enum case itself as id

1
2
3
4
5
6
7
enum Position: String, Codable, CaseIterable, Identifiable {
var id: Position { self }
case left
case right
case bottom
case top
}

The lesson learned here is we need to ensure the underlying type of selection in List and id used in ForEach are the same

Updated at 2020-12-23 06:05:08

How to add toolbar programatically in macOS

Issue #713

To setup toolbar, we need to implement NSToolbarDelegate that provides toolbar items. This delegate is responsible for many things

  • Set visible and allowed items with toolbarDefaultItemIdentifiers
  • Provide item with itemForItemIdentifier
  • Being notified with toolbarWillAddItem and toolbarDidRemoveItem
1
2
3
4
5
6
7
8
window.toolbarStyle = .unifiedCompact

let toolbar = NSToolbar(identifier: "Toolbar")
toolbar.displayMode = .iconAndLabel
toolbar.delegate = (NSApp.delegate as! AppDelegate)
toolbar.insertItem(withItemIdentifier: .add, at: 0)
toolbar.insertItem(withItemIdentifier: .settings, at: 1)
window.toolbar = toolbar
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
extension NSToolbarItem.Identifier {
static let add = NSToolbarItem.Identifier(rawValue: "Add")
static let settings = NSToolbarItem.Identifier(rawValue: "Settings")
}

extension AppDelegate: NSToolbarDelegate {
func toolbar(
_ toolbar: NSToolbar,
itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier,
willBeInsertedIntoToolbar flag: Bool
) -> NSToolbarItem? {
switch itemIdentifier {
case .add:
let item = NSToolbarItem(itemIdentifier: itemIdentifier)
item.label = "Add"
item.image = NSImage(named: NSImage.Name("add"))
let menuItem: NSMenuItem = NSMenuItem()
menuItem.submenu = nil
menuItem.title = "Add"
item.menuFormRepresentation = menuItem
item.toolTip = "Click here to add new entry"
return item
case .settings:
let item = NSToolbarItem(itemIdentifier: itemIdentifier)
item.label = "Settings"
let button = NSButton(image: NSImage(named: NSImage.Name("gear"))!, target: nil, action: nil)
button.bezelStyle = .texturedRounded
item.view = button
return item
default:
return nil
}
}

func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
[.add, .settings]
}

func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
[.add, .settings]
}
}

Read more

How to show sidebar in SwiftUI for macOS

Issue #710

Starting from macOS 11, we can use List with SidebarListStyle inside NavigationView to declare master detail view. The SidebarListStyle makes list translucent. It automatically handles selection and marks selected row in list with accent color.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
struct MainView: some View {
var body: some View {
NavigationView {
sidebar
ContentView()
}
}

private var sidebar: some View {
List {
Group {
Text("Categories")
.foregroundColor(.gray)
ForEach(categories) { category in
NavigationLink(destination: ContentView(category: category)) {
Label(category.name, systemImage: "message")
}
}
}

Divider()
NavigationLink(destination: SettingsView()) {
Label("Settings", systemImage: "gear")
}
}
.listStyle(SidebarListStyle())
}
}

If we use Section instead of just Group we get a nice dropdown arrow button to expand and collapse section

1
2
3
4
5
List {
Section(header: Text("Categories")) {
ForEach
}
}

Show and hide side bar

To toggle side bar, we can use toggleSidebar selector since for now, sidebar is backed by NSSplitViewController

1
mainWindow.firstResponder?.tryToPerform(#selector(NSSplitViewController.toggleSidebar(_:)), with: nil)

We can specify tool bar items on either sidebar or content.

1
2
3
4
5
6
7
8
.toolbar{
//Toggle Sidebar Button
ToolbarItem(placement: .navigation){
Button(action: toggleSidebar) {
Image(systemName: "sidebar.left")
})
}
}

For tool bar to work, we must use App and embed views inside WindowGroup

1
2
3
4
5
6
7
8
@main
struct AppWithSidebarAndToolbar: App {
var body: some Scene {
WindowGroup {
MainView()
}
}
}

Updated at 2021-01-06 20:43:40

How to support right click menu to NSStatusItem

Issue #707

The trick is to set the button oinside of statusItem to send actions on both leftMouseUp and rightMouseUp.

Another thing to note is we use popUpMenu on NSStatusItem, although it is marked as deprecated on macOS 10.14. We can set menu but that overrides left click.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import Omnia

private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
private let statusItemMenuHandler = MenuHandler()

func setupStatusMenu() {
if let button = statusItem.button {
button.image = NSImage(named: NSImage.Name("statusMenuIcon"))
button.contentTintColor = NSColor.black
button.action = #selector(statusMenuButtonTouched(_:))
button.sendAction(on: [.leftMouseUp, .rightMouseUp]) // This is important

statusItemMenuHandler.add(title: "About", action: {
NSWorkspace.shared.open(URL(string: "https://onmyway133.com/pushhero")!)
})
}
}

@objc
private func statusMenuButtonTouched(_ sender: NSStatusBarButton) {
guard let event = NSApp.currentEvent else { return }
switch event.type {
case .rightMouseUp:
statusItem.popUpMenu(statusItemMenuHandler.menu)
// statusItem.menu = statusItemMenuHandler.menu // this overrides left click
default:
popover.toggle()
}
}

Updated at 2020-12-08 05:11:24

How to convert from paid to free with IAP

Issue #703

What is receipt

Read When to refresh a receipt vs restore purchases in iOS?

From iOS 7, every app downloaded from the store has a receipt (for downloading/buying the app) at appStoreReceiptURL. When users purchases something via In App Purchase, the content at appStoreReceiptURL is updated with purchases information. Most of the cases, you just need to refresh the receipt (at appStoreReceiptURL) so that you know which transactions users have made.

Note

  • Receipt is generated and bundled with your app when user download the app, whether it is free or paid
  • When user makes IAP, receipt is updated with IAP information
  • When user downloads an app (download free, or purchase paid app), they get future updates (whether free or paid) forever.
  • Call SKReceiptRefreshRequest or SKPaymentQueue.restoreCompletedTransactions asks for Appstore credential
  • When we build the app from Xcode or download from Testflight, receipt is not bundled within the app since the app is not downloaded from AppStore. We can use SKReceiptRefreshRequest to download receipt from sandbox Appstore
  • restoreCompletedTransactions updates app receipt
  • Receipt is stored locally on device, so when user uninstalls and reinstalls your app, there’s no in app purchases information, this is when you should refresh receipt or restoreCompletedTransactions

Users restore transactions to maintain access to content they’ve already purchased. For example, when they upgrade to a new phone, they don’t lose all of the items they purchased on the old phone. Include some mechanism in your app to let the user restore their purchases, such as a Restore Purchases button. Restoring purchases prompts for the user’s App Store credentials, which interrupts the flow of your app: because of this, don’t automatically restore purchases, especially not every time your app is launched.

In most cases, all your app needs to do is refresh its receipt and deliver the products in its receipt. The refreshed receipt contains a record of the user’s purchases in this app, on this device or any other device. However, some apps need to take an alternate approach for one of the following reasons:

  • If you use Apple-hosted content, restoring completed transactions gives your app the transaction objects it uses to download the content. If you need to support versions of iOS earlier than iOS 7, where the app receipt isn’t available, restore completed transactions instead.
  • Refreshing the receipt asks the App Store for the latest copy of the receipt. Refreshing a receipt does not create any new transactions.
  • Restoring completed transactions creates a new transaction for every completed transaction the user made, essentially replaying history for your transaction queue observer.

More about receipt, from WWDC 2017, What’s new in StoreKit session https://developer.apple.com/videos/play/wwdc2017/303/

receipt

You can also watch WWDC 2017, session Advanced StoreKit for more detail https://developer.apple.com/videos/play/wwdc2017/305/

receipt tips

Restoring Purchased Products

Read Restoring Purchased Products

Users sometimes need to restore purchased content, such as when they upgrade to a new phone.

Don’t automatically restore purchases, especially when your app is launched. Restoring purchases prompts for the user’s App Store credentials, which interrupts the flow of your app

In most cases, you only need to refresh the app receipt and deliver the products listed on the receipt. The refreshed receipt contains a record of the user’s purchases in this app, from any device the user’s App Store account is logged into

Refreshing a receipt doesn’t create new transactions; it requests the latest copy of the receipt from the App Store

Restoring completed transactions creates a new transaction for every transaction previously completed, essentially replaying history for your transaction queue observer. Your app maintains its own state to keep track of why it’s restoring completed transactions and how to handle them.

What are the different IAP types

From AppStore

Consumable (pay everytime)

A consumable In-App Purchase must be purchased every time the user downloads it. One-time services, such as fish food in a fishing app, are usually implemented as consumables.

Non-Consumable (one time payment)

Non-consumable In-App Purchases only need to be purchased once by users. Services that do not expire or decrease with use are usually implemented as non-consumables, such as new race tracks for a game app.

Auto-Renewable Subscriptions (will deduct money from your credit card on a cycle complete)

Auto-renewable Subscriptions allow the user to purchase updating and dynamic content for a set duration of time. Subscriptions renew automatically unless the user opts out, such as magazine subscriptions.

Free Subscription (no payment and is still visible even you did not submitted your account detail to itunes connect)

Free subscriptions are a way for developers to put free subscription content in Newsstand. Once a user signs up for a free subscription, it will be available on all devices associated with the user’s Apple ID. Note that free subscriptions do not expire and can only be offered in Newsstand-enabled apps.

Non-Renewing (need to renew manually)

Subscription Non-Renewing Subscriptions allow the sale of services with a limited duration. Non-Renewing Subscriptions must be used for In-App Purchases that offer time-based access to static content. Examples include a one week subscription to voice guidance feature within a navigation app or an annual subscription to online catalog of archived video or audio.

When is app receipt missing

Read SKReceiptRefreshRequest

Use this API to request a new receipt if the receipt is invalid or missing

Receipt is stored locally on device. It can be missing in case user sync or restore device.

Watch WWDC 2014 - 305 Preventing Unauthorized Purchases with Receipts

How to check receipt existence

1
2
Bundle.main.appStoreReceiptURL
checkResourceIsReachable

How to read receipt

Read In-App Purchases: Receipt Validation Tutorial

The receipt consists of a single file in the app bundle. The file is in a format called PKCS #7. The payload consists of a set of receipt attributes in a cross-platform format called ASN.1

1
2
3
4
5
6
7
8
9
10
case 12: // Receipt Creation Date
var dateStartPtr = ptr
receiptCreationDate = readASN1Date(ptr: &dateStartPtr, maxLength: length)

case 17: // IAP Receipt
print("IAP Receipt.")

case 19: // Original App Version
var stringStartPtr = ptr
originalAppVersion = readASN1String(ptr: &stringStartPtr, maxLength: length)

Use TPInAppReceipt which includes certificates.

1
try InAppReceipt.localReceipt()

Check Receipt Fields

1
2
3
4
Original Application Version
The version of the app that was originally purchased.
ASN.1 Field Type 19
ASN.1 Field Value UTF8STRING

Note

This corresponds to the value of CFBundleVersion (in iOS) or CFBundleShortVersionString (in macOS) in the Info.plist file when the purchase was originally made

CFBundleVersion is build number, and CFBundleShortVersionString is app version

1
2
3
In-App Purchase Receipt
The receipt for an in-app purchase.
ASN.1 Field Type 17

Read Validating Receipts with the App Store

Sample verifyReceipt json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
{
"receipt": {
"receipt_type": "ProductionSandbox",
"adam_id": 0,
"app_item_id": 0,
"bundle_id": "com.example.app.ios",
"application_version": "3",
"download_id": 0,
"version_external_identifier": 0,
"receipt_creation_date": "2018-11-13 16:46:31 Etc/GMT",
"receipt_creation_date_ms": "1542127591000",
"receipt_creation_date_pst": "2018-11-13 08:46:31 America/Los_Angeles",
"request_date": "2018-11-13 17:10:31 Etc/GMT",
"request_date_ms": "1542129031280",
"request_date_pst": "2018-11-13 09:10:31 America/Los_Angeles",
"original_purchase_date": "2013-08-01 07:00:00 Etc/GMT",
"original_purchase_date_ms": "1375340400000",
"original_purchase_date_pst": "2013-08-01 00:00:00 America/Los_Angeles",
"original_application_version": "1.0",
"in_app": [{
"quantity": "1",
"product_id": "test2",
"transaction_id": "1000000472106082",
"original_transaction_id": "1000000472106082",
"purchase_date": "2018-11-13 16:46:31 Etc/GMT",
"purchase_date_ms": "1542127591000",
"purchase_date_pst": "2018-11-13 08:46:31 America/Los_Angeles",
"original_purchase_date": "2018-11-13 16:46:31 Etc/GMT",
"original_purchase_date_ms": "1542127591000",
"original_purchase_date_pst": "2018-11-13 08:46:31 America/Los_Angeles",
"is_trial_period": "false"
}]
},
"status": 0,
"environment": "Sandbox"
}

Verify your receipt first with the production URL; then verify with the sandbox URL if you receive a 21007 status code. This approach ensures you do not have to switch between URLs while your application is tested, reviewed by App Review, or live in the App Store.

Show me the code

Let’s use enum to represent possible states for each resource. Here’s simple case where we only have 1 non consumable IAP product.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
enum IAPError: Error {
case failedToRefreshReceipt
case failedToRequestProduct
case failedToPurchase
case receiptNotFound
}

enum IAPResourceState<T> {
case notAsked
case loading
case success(T)
case failure(IAPError)
}

final class PricingPlan: ObservableObject {
static let pro = (Bundle.main.bundleIdentifier ?? "") + ".pro"

@Published
var isPro: Bool = false
@Published
var product: IAPResourceState<SKProduct> = .notAsked
@Published
var purchase: IAPResourceState<SKPayment> = .notAsked
@Published
var receipt: IAPResourceState<InAppReceipt> = .notAsked
}

Let’s have a central place for managing all IAP operations, called IAPManager, it can update our ObservableObject PricingPlan hence triggers update to SwiftUI.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import StoreKit
import TPInAppReceipt
import Version

final class IAPManager: NSObject {
private let pricingPlan: PricingPlan
private let paymentQueue: SKPaymentQueue

init(pricingPlan: PricingPlan) {
self.pricingPlan = pricingPlan
self.paymentQueue = SKPaymentQueue.default()

super.init()

self.paymentQueue.add(self)
}

func requestProducts() {
let identifiers = PricingPlan.pro
let request = SKProductsRequest(productIdentifiers: Set(arrayLiteral: identifiers))
request.delegate = self
pricingPlan.product = .loading
request.start()
}

func purchase(product: SKProduct) {
guard SKPaymentQueue.canMakePayments() else {
showAlert(text: "You are not allowed to make payment. Please check device settings.")
return
}

pricingPlan.purchase = .loading
let payment = SKPayment(product: product)
paymentQueue.add(payment)
}

func refreshReceipt() {
let request = SKReceiptRefreshRequest()
request.delegate = self
request.start()
}

func restorePurchase() {
paymentQueue.restoreCompletedTransactions()
}
}

Refresh receipt

You can use restoreCompletedTransactions if you simply finishTransaction and grant user pro feature, like in this simple tutorial In-App Purchase Tutorial: Getting Started, search for SKPaymentTransactionObserver. restoreCompletedTransactions also updates receipt.

Otherwise refreshing receipt is a better idea. It serves both case when receipt is not there locally and when you want to restore transactions. With receipt refreshing, no restored transactions are created and SKPaymentTransactionObserver is not called, so we need to check receipt proactively.

Either restoreCompletedTransactions or SKReceiptRefreshRequest asks for AppStore credential so you should present a button there and ask user.

Check local receipt

Try to locate local receipt and examine it.

  • If it is not there (missing, corrupted), refresh receipt
  • If it’s there, check if it was from a version when the app was still as paid. Notice the difference in meaning of originalAppVersion in macOS and iOS
  • If it is not paid, check if this receipt contains In App Purchase information for our product

In practice, we need to perform some basic checks on receipt, like bundle id, app version, device id. Read In-App Purchases: Receipt Validation Tutorial, search for Validating the Receipt. TPInAppReceipt also has some handy verify functions

Besides verifying receipt locally, it is advisable to call verifyreceipt either on device, or better on serve to let Apple verify receipt and returns you a human readable json for receipt information.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
func checkReceipt() {
DispatchQueue.main.async {
do {
let receipt = try InAppReceipt.localReceipt()
self.pricingPlan.receipt = .success(receipt)
if self.isPaid(receipt: receipt) {
self.pricingPlan.isPro = true
} else if receipt.containsPurchase(ofProductIdentifier: PricingPlan.pro) {
self.pricingPlan.isPro = true
}
} catch {
self.pricingPlan.receipt = .failure(.receiptNotFound)
}
}
}

private func isPaid(receipt: InAppReceipt) -> Bool {
#if os(macOS)
// originalAppVersion is CFBundleShortVersionString
if let version = Version(receipt.originalAppVersion) {
return version < versionToIAP
}
#else
// originalAppVersion is CFBundleVersion
if let buildNumber = Int(receipt.originalAppVersion) {
return buildNumber < buildNumberToIAP
}
#endif
return false
}

Finally, observe SKProductsRequestDelegate which also conforms to SKRequestDelegate for both product and receipt refresh request

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
extension IAPManager: SKProductsRequestDelegate {
func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
DispatchQueue.main.async {
guard let product = response.products.first else {
self.pricingPlan.product = .failure(IAPError.failedToRequestProduct)
return
}

self.pricingPlan.product = .success(product)
}
}

func request(_ request: SKRequest, didFailWithError error: Error) {
DispatchQueue.main.async {
switch request {
case is SKProductsRequest:
self.pricingPlan.product = .failure(IAPError.failedToRequestProduct)
case is SKReceiptRefreshRequest:
self.pricingPlan.receipt = .failure(IAPError.failedToRefreshReceipt)
default:
break
}

}
}

func requestDidFinish(_ request: SKRequest) {
switch request {
case is SKReceiptRefreshRequest:
checkReceipt()
default:
break
}
}
}

Updated at 2020-12-04 06:58:17