docs: add getting started guide for Swift (#19044)

* docs: getting started guide for Swift

* Apply suggestions from code review

Co-authored-by: Charis <26616127+charislam@users.noreply.github.com>

* Add new tutorial to menu

* Add correct image for tutorial

* style: format with-swift tutorial

* docs: add example to the repository

---------

Co-authored-by: Charis <26616127+charislam@users.noreply.github.com>
Co-authored-by: Thor 雷神 Schaeff <5748289+thorwebdev@users.noreply.github.com>
This commit is contained in:
Guilherme Souza
2023-11-18 08:50:53 -03:00
committed by GitHub
parent ee18b5ac1f
commit a2cd408ed2
20 changed files with 1174 additions and 0 deletions

View File

@@ -318,6 +318,10 @@ export const gettingstarted: NavMenuConstant = {
name: 'Ionic Angular',
url: '/guides/getting-started/tutorials/with-ionic-angular',
},
{
name: 'Swift',
url: '/guides/getting-started/tutorials/with-swift',
},
],
},
],

View File

@@ -307,6 +307,44 @@ In certain auth scenarios you will need to handle linking back into your applica
</TabPanel>
</Tabs>
</TabPanel>
<TabPanel id="swift" label="Swift">
### Deep link config
1. Go to your [auth settings](https://supabase.com/dashboard/project/_/auth/url-configuration) page.
2. Enter your app redirect URL in the `Additional Redirect URLs` field. This is the URL that the user gets redirected to after clicking a magic link.
The redirect callback url should have the format `[YOUR_SCHEME]://[YOUR_HOSTNAME]`. Here, `io.supabase.user-management://login-callback` is just an example. You can choose whatever you would like for `YOUR_SCHEME` and `YOUR_HOSTNAME` as long as the scheme is unique across the user's device. For this reason, typically a reverse domain of your website is used.
![Supabase console deep link setting](/docs/img/deeplink-setting.png)
Now add a cutom URL to your application, so the OS knows how to redirect back your application once the user clicks the magic link.
You have the option to use Xcode's Target Info Editor following [official Apple documentation](https://developer.apple.com/documentation/xcode/defining-a-custom-url-scheme-for-your-app#Register-your-URL-scheme).
Or, declare the URL scheme manually in your `Info.plist` file.
```xml Info.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- other tags -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>io.supabase.user-management</string>
</array>
</dict>
</array>
</dict>
</plist>
```
</TabPanel>
</Tabs>

View File

@@ -0,0 +1,527 @@
import Layout from '~/layouts/DefaultGuideLayout'
export const meta = {
title: 'Build a User Management App with Swift and SwiftUI',
description: 'Learn how to use Supabase in your SwiftUI App.',
}
<QuickstartIntro />
![Supabase User Management example](/docs/img/supabase-swift-demo.png)
<Admonition type="note">
If you get stuck while working through this guide, refer to the [full example on GitHub](https://github.com/supabase/supabase/tree/master/examples/user-management/swift-user-management).
</Admonition>
<ProjectSetup />
## Building the App
Let's start building the SwiftUI app from scratch.
### Create a SwiftUI app in Xcode
Open Xcode and create a new SwiftUI project.
Add the [supabase-swift](https://github.com/supabase-community/supabase-swift) dependency.
Add the `https://github.com/supabase-community/supabase-swift` package to your app. For instructions, see the [Apple tutorial on adding package dependencies](https://developer.apple.com/documentation/xcode/adding-package-dependencies-to-your-app).
Create a helper file to initialize the Supabase client.
You need the API URL and the `anon` key that you copied [earlier](#get-the-api-keys).
These variables will be exposed on the application, and that's completely fine since you have
[Row Level Security](/docs/guides/auth#row-level-security) enabled on your database.
```swift Supabase.swift
import Supabase
let supabase = SupabaseClient(
supabaseURL: URL(string: "YOUR_SUPABASE_URL")!,
supabaseKey: "YOUR_SUPABASE_ANON_KEY"
)
```
### Set up a Login view
Set up a SwiftUI view to manage logins and sign ups.
Users should be able to sign in using a magic link.
```swift AuthView.swift
import SwiftUI
import Supabase
struct AuthView: View {
@State var email = ""
@State var isLoading = false
@State var result: Result<Void, Error>?
var body: some View {
Form {
Section {
TextField("Email", text: $email)
.textContentType(.emailAddress)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
}
Section {
Button("Sign in") {
signInButtonTapped()
}
if isLoading {
ProgressView()
}
}
if let result {
Section {
switch result {
case .success:
Text("Check your inbox.")
case .failure(let error):
Text(error.localizedDescription).foregroundStyle(.red)
}
}
}
}
.onOpenURL(perform: { url in
Task {
do {
try await supabase.auth.session(from: url)
} catch {
self.result = .failure(error)
}
}
})
}
func signInButtonTapped() {
Task {
isLoading = true
defer { isLoading = false }
do {
try await supabase.auth.signInWithOTP(
email: email,
redirectTo: URL(string: "io.supabase.user-management://login-callback")
)
result = .success(())
} catch {
result = .failure(error)
}
}
}
}
```
<Admonition type="note">
The example uses a custom `redirectTo` URL. For this to work, add a custom redirect URL to Supabase and a custom URL scheme to your SwiftUI application. Follow the guide on [implementing deep link handling](/docs/guides/auth/native-mobile-deep-linking?platform=swift).
</Admonition>
### Account view
After a user is signed in, you can allow them to edit their profile details and manage their account.
Create a new view for that called `ProfileView.swift`.
```swift ProfileView.swift
struct ProfileView: View {
@State var username = ""
@State var fullName = ""
@State var website = ""
@State var isLoading = false
var body: some View {
NavigationStack {
Form {
Section {
TextField("Username", text: $username)
.textContentType(.username)
.textInputAutocapitalization(.never)
TextField("Full name", text: $fullName)
.textContentType(.name)
TextField("Website", text: $website)
.textContentType(.URL)
.textInputAutocapitalization(.never)
}
Section {
Button("Update profile") {
updateProfileButtonTapped()
}
.bold()
if isLoading {
ProgressView()
}
}
}
.navigationTitle("Profile")
.toolbar(content: {
ToolbarItem(placement: .topBarLeading){
Button("Sign out", role: .destructive) {
Task {
try? await supabase.auth.signOut()
}
}
}
})
}
.task {
await getInitialProfile()
}
}
func getInitialProfile() async {
do {
let currentUser = try await supabase.auth.session.user
let profile: Profile = try await supabase.database
.from("profiles")
.select()
.eq("id", value: currentUser.id)
.single()
.execute()
.value
self.username = profile.username ?? ""
self.fullName = profile.fullName ?? ""
self.website = profile.website ?? ""
} catch {
debugPrint(error)
}
}
func updateProfileButtonTapped() {
Task {
isLoading = true
defer { isLoading = false }
do {
let currentUser = try await supabase.auth.session.user
try await supabase.database
.from("profiles")
.update(
UpdateProfileParams(
username: username,
fullName: fullName,
website: website
)
)
.eq("id", value: currentUser.id)
.execute()
} catch {
debugPrint(error)
}
}
}
}
```
### Models
In `ProfileView.swift`, you used 2 model types for deserializing the response and serializing the request to Supabase. Add those in a new `Models.swift` file.
```swift Models.swift
struct Profile: Decodable {
let username: String?
let fullName: String?
let website: String?
enum CodingKeys: String, CodingKey {
case username
case fullName = "full_name"
case website
}
}
struct UpdateProfileParams: Encodable {
let username: String
let fullName: String
let website: String
enum CodingKeys: String, CodingKey {
case username
case fullName = "full_name"
case website
}
}
```
### Launch!
Now that you've created all the views, add an entry point for the application. This will verify if the user has a valid session and route them to the authenticated or non-authenticated state.
Add a new `AppView.swift` file.
```swift AppView.swift
struct AppView: View {
@State var isAuthenticated = false
var body: some View {
Group {
if isAuthenticated {
ProfileView()
} else {
AuthView()
}
}
.task {
for await state in await supabase.auth.onAuthStateChange() {
if [.initialSession, .signedIn, .signedOut].contains(state.event) {
isAuthenticated = state.session != nil
}
}
}
}
}
```
Update the entry point to the newly created `AppView`. Run in Xcode to launch your application in the simulator.
## Bonus: Profile photos
Every Supabase project is configured with [Storage](/docs/guides/storage) for managing large files like
photos and videos.
### Add PhotosPicker
Let's add support for the user to pick an image from the library and upload it.
Start by creating a new type to hold the picked avatar image:
```swift AvatarImage.swift
import SwiftUI
struct AvatarImage: Transferable, Equatable {
let image: Image
let data: Data
static var transferRepresentation: some TransferRepresentation {
DataRepresentation(importedContentType: .image) { data in
guard let image = AvatarImage(data: data) else {
throw TransferError.importFailed
}
return image
}
}
}
extension AvatarImage {
init?(data: Data) {
guard let uiImage = UIImage(data: data) else {
return nil
}
let image = Image(uiImage: uiImage)
self.init(image: image, data: data)
}
}
enum TransferError: Error {
case importFailed
}
```
#### Add PhotosPicker to Profile page
```swift ProfileView.swift
struct ProfileView: View {
@State var username = ""
@State var fullName = ""
@State var website = ""
@State var isLoading = false
+ @State var imageSelection: PhotosPickerItem?
+ @State var avatarImage: AvatarImage?
var body: some View {
NavigationStack {
Form {
+ Section {
+ HStack {
+ Group {
+ if let avatarImage {
+ avatarImage.image.resizable()
+ } else {
+ Color.clear
+ }
+ }
+ .scaledToFit()
+ .frame(width: 80, height: 80)
+
+ Spacer()
+
+ PhotosPicker(selection: $imageSelection, matching: .images) {
+ Image(systemName: "pencil.circle.fill")
+ .symbolRenderingMode(.multicolor)
+ .font(.system(size: 30))
+ .foregroundColor(.accentColor)
+ }
+ }
+ }
Section {
TextField("Username", text: $username)
.textContentType(.username)
.textInputAutocapitalization(.never)
TextField("Full name", text: $fullName)
.textContentType(.name)
TextField("Website", text: $website)
.textContentType(.URL)
.textInputAutocapitalization(.never)
}
Section {
Button("Update profile") {
updateProfileButtonTapped()
}
.bold()
if isLoading {
ProgressView()
}
}
}
.navigationTitle("Profile")
.toolbar(content: {
ToolbarItem {
Button("Sign out", role: .destructive) {
Task {
try? await supabase.auth.signOut()
}
}
}
})
+ .onChange(of: imageSelection) { _, newValue in
+ guard let newValue else { return }
+ loadTransferable(from: newValue)
+ }
}
.task {
await getInitialProfile()
}
}
func getInitialProfile() async {
do {
let currentUser = try await supabase.auth.session.user
let profile: Profile = try await supabase.database
.from("profiles")
.select()
.eq("id", value: currentUser.id)
.single()
.execute()
.value
username = profile.username ?? ""
fullName = profile.fullName ?? ""
website = profile.website ?? ""
+ if let avatarURL = profile.avatarURL, !avatarURL.isEmpty {
+ try await downloadImage(path: avatarURL)
+ }
} catch {
debugPrint(error)
}
}
func updateProfileButtonTapped() {
Task {
isLoading = true
defer { isLoading = false }
do {
+ let imageURL = try await uploadImage()
let currentUser = try await supabase.auth.session.user
let updatedProfile = Profile(
username: username,
fullName: fullName,
website: website,
+ avatarURL: imageURL
)
try await supabase.database
.from("profiles")
.update(updatedProfile)
.eq("id", value: currentUser.id)
.execute()
} catch {
debugPrint(error)
}
}
}
+ private func loadTransferable(from imageSelection: PhotosPickerItem) {
+ Task {
+ do {
+ avatarImage = try await imageSelection.loadTransferable(type: AvatarImage.self)
+ } catch {
+ debugPrint(error)
+ }
+ }
+ }
+
+ private func downloadImage(path: String) async throws {
+ let data = try await supabase.storage.from("avatars").download(path: path)
+ avatarImage = AvatarImage(data: data)
+ }
+
+ private func uploadImage() async throws -> String? {
+ guard let data = avatarImage?.data else { return nil }
+
+ let filePath = "\(UUID().uuidString).jpeg"
+
+ try await supabase.storage
+ .from("avatars")
+ .upload(
+ path: filePath,
+ file: data,
+ options: FileOptions(contentType: "image/jpeg")
+ )
+
+ return filePath
+ }
}
```
Finally, update your Models.
```swift Models.swift
struct Profile: Codable {
let username: String?
let fullName: String?
let website: String?
let avatarURL: String?
enum CodingKeys: String, CodingKey {
case username
case fullName = "full_name"
case website
case avatarURL = "avatar_url"
}
}
```
You no longer need the `UpdateProfileParams` struct, as you can now reuse the `Profile` struct for both request and response calls.
### Storage management
<StorageManagement />
At this stage you have a fully functional application!
export const Page = ({ children }) => <Layout meta={meta} children={children} />
export default Page

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View File

@@ -0,0 +1,35 @@
//
// AppView.swift
// UserManagement
//
// Created by Guilherme Souza on 17/11/23.
//
import SwiftUI
struct AppView: View {
@State var isAuthenticated = false
var body: some View {
Group {
if isAuthenticated {
ProfileView()
} else {
AuthView()
}
}
.task {
for await state in await supabase.auth.onAuthStateChange() {
if [.initialSession, .signedIn, .signedOut].contains(state.event) {
isAuthenticated = state.session != nil
}
}
}
}
}
#if swift(>=5.9)
#Preview {
AppView()
}
#endif

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,63 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,80 @@
//
// AuthView.swift
// UserManagement
//
// Created by Guilherme Souza on 17/11/23.
//
import Supabase
import SwiftUI
struct AuthView: View {
@State var email = ""
@State var isLoading = false
@State var result: Result<Void, Error>?
var body: some View {
Form {
Section {
TextField("Email", text: $email)
.textContentType(.emailAddress)
.autocorrectionDisabled()
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
}
Section {
Button("Sign in") {
signInButtonTapped()
}
if isLoading {
ProgressView()
}
}
if let result {
Section {
switch result {
case .success: Text("Check you inbox.")
case let .failure(error): Text(error.localizedDescription).foregroundStyle(.red)
}
}
}
}
.onMac { $0.padding() }
.onOpenURL(perform: { url in
Task {
do {
try await supabase.auth.session(from: url)
} catch {
result = .failure(error)
}
}
})
}
func signInButtonTapped() {
Task {
isLoading = true
defer { isLoading = false }
do {
try await supabase.auth.signInWithOTP(
email: email,
redirectTo: URL(string: "io.supabase.user-management://login-callback")
)
result = .success(())
} catch {
result = .failure(error)
}
}
}
}
#if swift(>=5.9)
#Preview {
AuthView()
}
#endif

View File

@@ -0,0 +1,55 @@
//
// AvatarImage.swift
// UserManagement
//
// Created by Guilherme Souza on 17/11/23.
//
import SwiftUI
#if canImport(UIKit)
typealias PlatformImage = UIImage
extension Image {
init(platformImage: PlatformImage) {
self.init(uiImage: platformImage)
}
}
#elseif canImport(AppKit)
typealias PlatformImage = NSImage
extension Image {
init(platformImage: PlatformImage) {
self.init(nsImage: platformImage)
}
}
#endif
struct AvatarImage: Transferable, Equatable {
let image: Image
let data: Data
static var transferRepresentation: some TransferRepresentation {
DataRepresentation(importedContentType: .image) { data in
guard let image = AvatarImage(data: data) else {
throw TransferError.importFailed
}
return image
}
}
}
extension AvatarImage {
init?(data: Data) {
guard let uiImage = PlatformImage(data: data) else {
return nil
}
let image = Image(platformImage: uiImage)
self.init(image: image, data: data)
}
}
enum TransferError: Error {
case importFailed
}

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>io.supabase.user-management</string>
</array>
</dict>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,22 @@
//
// Models.swift
// UserManagement
//
// Created by Guilherme Souza on 17/11/23.
//
import Foundation
struct Profile: Codable {
let username: String?
let fullName: String?
let website: String?
let avatarURL: String?
enum CodingKeys: String, CodingKey {
case username
case fullName = "full_name"
case website
case avatarURL = "avatar_url"
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,183 @@
//
// ProfileView.swift
// UserManagement
//
// Created by Guilherme Souza on 17/11/23.
//
import PhotosUI
import Supabase
import SwiftUI
struct ProfileView: View {
@State var username = ""
@State var fullName = ""
@State var website = ""
@State var isLoading = false
@State var imageSelection: PhotosPickerItem?
@State var avatarImage: AvatarImage?
var body: some View {
NavigationStack {
Form {
Section {
HStack {
Group {
if let avatarImage {
avatarImage.image.resizable()
} else {
Color.clear
}
}
.scaledToFit()
.frame(width: 80, height: 80)
Spacer()
PhotosPicker(selection: $imageSelection, matching: .images) {
Image(systemName: "pencil.circle.fill")
.symbolRenderingMode(.multicolor)
.font(.system(size: 30))
.foregroundColor(.accentColor)
}
}
}
Section {
TextField("Username", text: $username)
.textContentType(.username)
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
TextField("Full name", text: $fullName)
.textContentType(.name)
TextField("Website", text: $website)
.textContentType(.URL)
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
}
Section {
Button("Update profile") {
updateProfileButtonTapped()
}
.bold()
if isLoading {
ProgressView()
}
}
}
.onMac { $0.padding() }
.navigationTitle("Profile")
.toolbar(content: {
ToolbarItem {
Button("Sign out", role: .destructive) {
Task {
try? await supabase.auth.signOut()
}
}
}
})
.onChange(of: imageSelection) { _, newValue in
guard let newValue else { return }
loadTransferable(from: newValue)
}
}
.task {
await getInitialProfile()
}
}
func getInitialProfile() async {
do {
let currentUser = try await supabase.auth.session.user
let profile: Profile = try await supabase.database
.from("profiles")
.select()
.eq("id", value: currentUser.id)
.single()
.execute()
.value
username = profile.username ?? ""
fullName = profile.fullName ?? ""
website = profile.website ?? ""
if let avatarURL = profile.avatarURL, !avatarURL.isEmpty {
try await downloadImage(path: avatarURL)
}
} catch {
debugPrint(error)
}
}
func updateProfileButtonTapped() {
Task {
isLoading = true
defer { isLoading = false }
do {
let imageURL = try await uploadImage()
let currentUser = try await supabase.auth.session.user
let updatedProfile = Profile(
username: username,
fullName: fullName,
website: website,
avatarURL: imageURL
)
try await supabase.database
.from("profiles")
.update(updatedProfile)
.eq("id", value: currentUser.id)
.execute()
} catch {
debugPrint(error)
}
}
}
private func loadTransferable(from imageSelection: PhotosPickerItem) {
Task {
do {
avatarImage = try await imageSelection.loadTransferable(type: AvatarImage.self)
} catch {
debugPrint(error)
}
}
}
private func downloadImage(path: String) async throws {
let data = try await supabase.storage.from("avatars").download(path: path)
avatarImage = AvatarImage(data: data)
}
private func uploadImage() async throws -> String? {
guard let data = avatarImage?.data else { return nil }
let filePath = "\(UUID().uuidString).jpeg"
try await supabase.storage
.from("avatars")
.upload(
path: filePath,
file: data,
options: FileOptions(contentType: "image/jpeg")
)
return filePath
}
}
#if swift(>=5.9)
#Preview {
ProfileView()
}
#endif

View File

@@ -0,0 +1,66 @@
# Supabase Swift User Management
This repo is a quick sample of how you can get started building apps using Swift and Supabase. You can find a step by step guide of how to build out this app in the [Quickstart: Swift guide](https://supabase.io/docs/guides/with-swift).
This repo will demonstrate how to:
- Sign users in with Supabase Auth using [magic link](https://supabase.io/docs/reference/dart/auth-signin#sign-in-with-magic-link)
- Store and retrieve data with [Supabase database](https://supabase.io/docs/guides/database)
- Store image files in [Supabase storage](https://supabase.io/docs/guides/storage)
![Supabase User Management example](supabase-swift-demo.png)
## Getting Started
Before running this app, you need to create a Supabase project and copy [your credentials](https://supabase.io/docs/guides/with-flutter#get-the-api-keys) to `Supabase.swift`.
Run the application in a device or simulator using Xcode.
## Database Schema
```sql
-- Create a table for public "profiles"
create table profiles (
id uuid references auth.users not null,
updated_at timestamp with time zone,
username text unique,
avatar_url text,
website text,
primary key (id),
unique(username),
constraint username_length check (char_length(username) >= 3)
);
alter table profiles enable row level security;
create policy "Public profiles are viewable by everyone."
on profiles for select
using ( true );
create policy "Users can insert their own profile."
on profiles for insert
with check ( auth.uid() = id );
create policy "Users can update own profile."
on profiles for update
using ( auth.uid() = id );
-- Set up Realtime!
begin;
drop publication if exists supabase_realtime;
create publication supabase_realtime;
commit;
alter publication supabase_realtime add table profiles;
-- Set up Storage!
insert into storage.buckets (id, name)
values ('avatars', 'avatars');
create policy "Avatar images are publicly accessible."
on storage.objects for select
using ( bucket_id = 'avatars' );
create policy "Anyone can upload an avatar."
on storage.objects for insert
with check ( bucket_id = 'avatars' );
```

View File

@@ -0,0 +1,14 @@
//
// Supabase.swift
// UserManagement
//
// Created by Guilherme Souza on 17/11/23.
//
import Foundation
import Supabase
let supabase = SupabaseClient(
supabaseURL: URL(string: "https://PROJECT_ID.supabase.co")!,
supabaseKey: "YOUR_SUPABASE_ANON_KEY"
)

View File

@@ -0,0 +1,18 @@
//
// SwiftUIHelpers.swift
// UserManagement
//
// Created by Guilherme Souza on 17/11/23.
//
import SwiftUI
extension View {
func onMac(_ block: (Self) -> some View) -> some View {
#if os(macOS)
return block(self)
#else
return self
#endif
}
}

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,17 @@
//
// UserManagementApp.swift
// UserManagement
//
// Created by Guilherme Souza on 17/11/23.
//
import SwiftUI
@main
struct UserManagementApp: App {
var body: some Scene {
WindowGroup {
AppView()
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB