Before anyone rants and raves about checking documentation - I have spent the last 4 hours trying to solve this issue on my own before asking for help. Coding in Swift is VERY new for me and I'm banging my head against the wall trying to teach myself. I am very humbly asking for help. If you refer me to documentation, that's fine but I need examples or it's going to go right over my head. Teaching myself is hard, please don't make it more difficult.
I have ONE swift file with everything in it.
import Foundation
import Cocoa
import Observation
class GlobalString: ObservableObject {
@Published var apiKey = ""
@Published var link = ""
}
struct ContentView: View {
@EnvironmentObject var globalString: GlobalString
var body: some View {
Form {
Section(header: Text("WallTaker for macOS").font(.title)) {
TextField(
"Link ID:",
text: $globalString.link
)
.disableAutocorrection(true)
TextField(
"API Key:",
text: $globalString.apiKey
)
.disableAutocorrection(true)
Button("Take My Wallpaper!") {
}
}
.padding()
}
.task {
await Wallpaper().fetchLink()
}
}
}
@main
struct WallTaker_for_macOSApp: App {
@AppStorage("showMenuBarExtra") private var showMenuBarExtra = true
@EnvironmentObject var globalString: GlobalString
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(GlobalString())
}
// MenuBarExtra("WallTaker for macOS", systemImage: "WarrenHead.png", isInserted: $showMenuBarExtra) {
// Button("Refresh") {
//// currentNumber = "1"
// }
// Button("Love It!") {
//// currentNumber = "2"
// }
// Button("Hate It!") {
//// currentNumber = "3"
// }
// Button("EXPLOSION!") {
// // currentNumber = "3"
// }
////
// }
}
}
class Wallpaper {
var url: URL? = nil
var lastPostUrl: URL? = nil
let mainMonitor: NSScreen
init() {
mainMonitor = NSScreen.main!
}
struct LinkResponse: Codable {
var post_url: String?
var set_by: String?
var updated_at: String
}
struct Link {
var postUrl: URL?
var setBy: String
var updatedAt: Date
}
func parseIsoDate(timestamp: String) -> Date? {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
return formatter.date(from: timestamp)
}
func fetchLink() async {
do {
url = URL(string: GlobalString().link)
let (data, _) = try await URLSession.shared.data(from: url!)
let decoder = JSONDecoder()
let linkResponse = try decoder.decode(LinkResponse.self, from: data)
let postUrl: URL? = linkResponse.post_url != nil ? URL(string: linkResponse.post_url!) : nil
let date = parseIsoDate(timestamp: linkResponse.updated_at)
let link = Link(
postUrl: postUrl,
setBy: linkResponse.set_by ?? "anon",
updatedAt: date ?? Date()
)
try update(link: link)
} catch {
}
}
func update(link: Link) throws {
guard let newPostUrl = link.postUrl else {
return
}
if (newPostUrl != lastPostUrl) {
lastPostUrl = newPostUrl
let tempFilePath = try getTempFilePath()
try downloadImageTo(sourceURL: newPostUrl, destinationURL: tempFilePath)
try applyWallpaper(url: tempFilePath)
} else {
}
}
private func applyWallpaper(url: URL) throws {
try NSWorkspace.shared.setDesktopImageURL(url, for: mainMonitor, options: [:])
}
private func getTempFilePath() throws -> URL {
let directory = NSTemporaryDirectory()
let fileName = NSUUID().uuidString
let fullURL = NSURL.fileURL(withPathComponents: [directory, fileName])!
return fullURL
}
private func downloadImageTo(sourceURL: URL, destinationURL: URL) throws {
let data = try Data(contentsOf: sourceURL)
try data.write(to: destinationURL)
}
}
The 'fetchLink' function is where things explode, specifically when setting the URL. I do not know what I'm doing wrong.
So, let's start with the error message. "Unexpectedly found nil while unwrapping an Optional value" means that you used the postfix !
operator to force-unwrap an optional, but the optional was nil
. I'm guessing it's specifically complaining about the !
in this line:
let (data, _) = try await URLSession.shared.data(from: url!)
So, let's start looking backwards from there. Where was url
set? We find this on the line above:
url = URL(string: GlobalString().link)
So, this line must be setting url
to nil
. Why?
URL.init(string:)
is a "failable initializer", meaning that it returns an optional URL
. If you look at its documentation, you'll see that it says:
This initializer returns
nil
if the string doesn’t represent a valid URL even after encoding invalid characters.
So that suggests the problem is that GlobalString().link
is returning a string that isn't a valid URL. To figure out what that string is and why you're getting it, it might make sense to break up that line into several steps:
let globalStringObject = GlobalString()
let linkFromGlobalStringObject = globalStringObject.link
url = URL(string: linkFromGlobalStringObject)
And then use your favorite debugging technique to look at the values of linkFromGlobalStringObject
and globalStringObject
. (You might set a breakpoint on the line with the URL(string:)
call so you can inspect the value of the variable before you pass it, for instance, or you might log or print the variables.)
If you do, what I believe you'll find is that linkFromGlobalStringObject
is an empty string (which, indeed, is not a valid URL!) and globalStringObject
has empty apiKey
and link
properties. And if you read the code closely, that actually makes sense, because GlobalString()
calls the initializer on the GlobalString
class, and that initializer sets the apiKey
and link
fields to empty. You probably wanted to use the GlobalString
object that's in the environment object, not create a brand new one!
Wallpaper
isn't a SwiftUI view, so it can't access the environment object directly; instead, you'll have to pass the GlobalString
object in from the view. I'd recommend adding a new property to Wallpaper
and making the initializer take a value for it:
class Wallpaper {
var url: URL? = nil
var lastPostUrl: URL? = nil
let mainMonitor: NSScreen
let globalString: GlobalString // *** NEW ***
init(globalString: GlobalString) { // *** CHANGED ***
mainMonitor = NSScreen.main!
self.globalString = globalString // *** NEW ***
}
// ...intervening code should be unchanged...
func fetchLink() async {
do {
url = URL(string: globalString.link) // *** CHANGED ***
And then modify the view to pass its globalString
in when it creates the Wallpaper
object:
.task {
await Wallpaper(globalString: globalString).fetchLink() // *** CHANGED ***
}
Now, if you try this again…actually, it'll still crash, and the link
field will still be an empty string. But you might notice that it crashed before you filled in the Link ID field. The problem is that .task
calls fetchLink()
as soon as the view loads, which isn't really what you wanted it to do! You wanted it to run fetchLink()
when you clicked the Button
, right?
So remove the whole .task { ... }
modifier and instead, change the button's action like so:
Button("Take My Wallpaper!") {
Task { // *** NEW ***
await Wallpaper(globalString: globalString).fetchLink() // *** NEW ***
} // *** NEW ***
}
Now it'll start fetching the link only once the button is pressed, so if you put a valid URL in the Link ID field and hit the button, it should successfully fetch the wallpaper (or at least it will get past the place where it crashed before).
But you might notice that fetchLink()
does still crash if you leave the Link ID blank when you hit the button, or if you fill it in with garbage instead of a valid URL. Ultimately, it is normal and expected for URL.init(string:)
to return nil
when the URL is invalid, and you need to detect when that happens and handle it gracefully. Perhaps fetchLink()
could throw an error or return a Bool
so the view can detect that it needs to show an error message; perhaps you could disable the button until the Link ID is valid (to check that, you could speculatively call URL.init(string:)
in the view and see if it gives you a non-nil result). There are a lot of different options, so I'll stop this post here and let you decide how to proceed.