LauncherRootView.swift 42 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160
  1. import SwiftUI
  2. import AppKit
  3. import UniformTypeIdentifiers
  4. import WebKit
  5. struct LauncherRootView: View {
  6. enum LayoutMode {
  7. case grid
  8. case panel
  9. }
  10. @EnvironmentObject private var premiumStore: PremiumStore
  11. @State private var query = ""
  12. @State private var selectedApp: LauncherApp?
  13. @State private var showingPremiumScreen = false
  14. @State private var showingCreateAppSheet = false
  15. @State private var previouslyPremiumUnlocked = false
  16. @State private var startupPaywallTask: Task<Void, Never>?
  17. @State private var ratingPromptTask: Task<Void, Never>?
  18. @State private var customApps: [LauncherApp] = []
  19. @State private var appOrder: [UUID] = []
  20. @State private var draggedAppID: UUID?
  21. @State private var lastDropDestinationAppID: UUID?
  22. @State private var layoutMode: LayoutMode = .grid
  23. @AppStorage("customLauncherAppsData") private var customAppsData = ""
  24. @AppStorage("launcherAppOrderData") private var appOrderData = ""
  25. @AppStorage("pinnedTileIDsData") private var pinnedTileIDsData = ""
  26. @AppStorage("statusBarAppIDsData") private var statusBarAppIDsData = ""
  27. @AppStorage("widgetAppIDsData") private var widgetAppIDsData = ""
  28. @AppStorage("hiddenLauncherAppIDsData") private var hiddenLauncherAppIDsData = ""
  29. private var apps: [LauncherApp] {
  30. let hiddenIDs = decodeUUIDSet(from: hiddenLauncherAppIDsData)
  31. let visibleApps = (LauncherApp.sampleApps + customApps).filter { !hiddenIDs.contains($0.id) }
  32. return visibleApps + [LauncherApp.createNewTile]
  33. }
  34. private var columns: [GridItem] {
  35. [GridItem(.adaptive(minimum: 120, maximum: 145), spacing: 22)]
  36. }
  37. private var filteredApps: [LauncherApp] {
  38. let q = query.trimmingCharacters(in: .whitespacesAndNewlines)
  39. guard !q.isEmpty else { return orderedApps }
  40. return orderedApps.filter { $0.name.localizedCaseInsensitiveContains(q) }
  41. }
  42. private var orderedApps: [LauncherApp] {
  43. guard !apps.isEmpty else { return [] }
  44. let defaultIndexByID = Dictionary(
  45. uniqueKeysWithValues: apps.enumerated().map { ($0.element.id, $0.offset) }
  46. )
  47. let rankByID = Dictionary(
  48. uniqueKeysWithValues: appOrder.enumerated().map { ($0.element, $0.offset) }
  49. )
  50. let pinnedIDs = decodeUUIDSet(from: pinnedTileIDsData)
  51. return apps.sorted { lhs, rhs in
  52. if lhs.isCreateNew != rhs.isCreateNew {
  53. return rhs.isCreateNew
  54. }
  55. let leftPinned = pinnedIDs.contains(lhs.id)
  56. let rightPinned = pinnedIDs.contains(rhs.id)
  57. if leftPinned != rightPinned {
  58. return leftPinned
  59. }
  60. let leftRank = rankByID[lhs.id] ?? Int.max
  61. let rightRank = rankByID[rhs.id] ?? Int.max
  62. if leftRank != rightRank {
  63. return leftRank < rightRank
  64. }
  65. return (defaultIndexByID[lhs.id] ?? Int.max) < (defaultIndexByID[rhs.id] ?? Int.max)
  66. }
  67. }
  68. private var hiddenApps: [LauncherApp] {
  69. let hiddenIDs = decodeUUIDSet(from: hiddenLauncherAppIDsData)
  70. guard !hiddenIDs.isEmpty else { return [] }
  71. let allApps = LauncherApp.sampleApps + customApps
  72. let sortedHiddenApps = allApps
  73. .filter { hiddenIDs.contains($0.id) }
  74. .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
  75. var seenLinkKeys = Set<String>()
  76. return sortedHiddenApps.filter { app in
  77. guard let webURL = app.webURL else { return true }
  78. let linkKey = webURL.absoluteString.lowercased()
  79. guard !seenLinkKeys.contains(linkKey) else { return false }
  80. seenLinkKeys.insert(linkKey)
  81. return true
  82. }
  83. }
  84. var body: some View {
  85. ZStack {
  86. VisualEffectBlur(material: .hudWindow, blendingMode: .behindWindow)
  87. .ignoresSafeArea()
  88. Color.black.opacity(0.22)
  89. .ignoresSafeArea()
  90. VStack(spacing: 16) {
  91. SearchHeader(
  92. query: $query,
  93. isPanelMode: layoutMode == .panel,
  94. hiddenApps: hiddenApps,
  95. onToggleLayout: {
  96. layoutMode = layoutMode == .grid ? .panel : .grid
  97. },
  98. onOpenPremium: {
  99. showingPremiumScreen = true
  100. },
  101. onOpenGoogle: {
  102. if let url = URL(string: "https://www.google.com") {
  103. InAppBrowserWindowManager.shared.open(url: url, title: "Google")
  104. }
  105. },
  106. onRestoreApp: { appID in
  107. restoreHiddenApp(id: appID)
  108. },
  109. onRestoreAll: {
  110. restoreAllHiddenApps()
  111. }
  112. )
  113. .padding(.top, 12)
  114. PromoBanner(
  115. isPremiumUnlocked: premiumStore.isPremiumUnlocked,
  116. activePremiumProductID: premiumStore.activePremiumProductID,
  117. onCloseTap: {
  118. if !premiumStore.isPremiumUnlocked {
  119. showingPremiumScreen = true
  120. }
  121. },
  122. onUpgradeTap: { showingPremiumScreen = true },
  123. onManageSubscriptionTap: { openManageSubscriptions() }
  124. )
  125. .padding(.bottom, 8)
  126. .zIndex(1)
  127. appsPage
  128. }
  129. .padding(.horizontal, 10)
  130. .padding(.bottom, 8)
  131. }
  132. .sheet(item: $selectedApp) { app in
  133. AppDetailView(app: app)
  134. .frame(minWidth: 360, minHeight: 220)
  135. }
  136. .sheet(isPresented: $showingPremiumScreen) {
  137. PremiumFeaturesView()
  138. .environmentObject(premiumStore)
  139. .frame(minWidth: 560, minHeight: 690)
  140. }
  141. .sheet(isPresented: $showingCreateAppSheet) {
  142. CreateAppSheetView { input in
  143. addCustomApp(name: input.name, url: input.url)
  144. }
  145. .frame(minWidth: 760, minHeight: 420)
  146. }
  147. .task {
  148. await premiumStore.refreshEntitlements()
  149. }
  150. .onChange(of: showingPremiumScreen) { isShowing in
  151. guard !isShowing else { return }
  152. Task {
  153. await premiumStore.refreshEntitlements()
  154. }
  155. }
  156. .onChange(of: premiumStore.isPremiumUnlocked) { unlocked in
  157. if previouslyPremiumUnlocked && !unlocked {
  158. showingPremiumScreen = true
  159. }
  160. previouslyPremiumUnlocked = unlocked
  161. }
  162. .onAppear {
  163. previouslyPremiumUnlocked = premiumStore.isPremiumUnlocked
  164. loadCustomAppsFromStorage()
  165. loadAppOrderFromStorage()
  166. normalizeAppOrderAndPersist()
  167. DesktopWidgetWindowManager.shared.restore(appProvider: { id in
  168. orderedApps.first { $0.id == id }
  169. })
  170. NotificationCenter.default.addObserver(
  171. forName: .openWidgetsPage,
  172. object: nil,
  173. queue: .main
  174. ) { notification in
  175. let id = notification.userInfo?["appID"] as? UUID
  176. WidgetsWindowManager.shared.open(
  177. apps: orderedApps.filter { !$0.isCreateNew },
  178. selectedAppID: id
  179. )
  180. }
  181. startupPaywallTask?.cancel()
  182. startupPaywallTask = Task {
  183. try? await Task.sleep(nanoseconds: 2_000_000_000)
  184. guard !Task.isCancelled else { return }
  185. await premiumStore.refreshEntitlements()
  186. guard !premiumStore.isPremiumUnlocked else { return }
  187. guard !showingPremiumScreen else { return }
  188. showingPremiumScreen = true
  189. }
  190. ratingPromptTask?.cancel()
  191. ratingPromptTask = Task {
  192. try? await Task.sleep(nanoseconds: 5 * 60 * 1_000_000_000)
  193. guard !Task.isCancelled else { return }
  194. await MainActor.run {
  195. RatingPromptManager.shared.requestRatingIfNeeded()
  196. }
  197. }
  198. }
  199. .onDisappear {
  200. startupPaywallTask?.cancel()
  201. startupPaywallTask = nil
  202. ratingPromptTask?.cancel()
  203. ratingPromptTask = nil
  204. }
  205. }
  206. @ViewBuilder
  207. private var appsPage: some View {
  208. if filteredApps.isEmpty {
  209. VStack(spacing: 10) {
  210. Image(systemName: "magnifyingglass")
  211. .font(.system(size: 28, weight: .semibold))
  212. .foregroundStyle(.white.opacity(0.75))
  213. Text("No apps found")
  214. .font(.title3.weight(.semibold))
  215. .foregroundStyle(.white.opacity(0.9))
  216. Text("Try a different search term.")
  217. .font(.subheadline)
  218. .foregroundStyle(.white.opacity(0.7))
  219. }
  220. .frame(maxWidth: .infinity, maxHeight: .infinity)
  221. } else {
  222. if layoutMode == .panel {
  223. IconPanelView(
  224. apps: filteredApps,
  225. onAppTap: { app in
  226. handlePanelAppTap(app)
  227. },
  228. draggedAppID: $draggedAppID,
  229. lastDropDestinationAppID: $lastDropDestinationAppID,
  230. onMove: moveApp,
  231. onHideApp: hideApp
  232. )
  233. } else {
  234. ScrollView {
  235. LazyVGrid(columns: columns, spacing: 20) {
  236. ForEach(filteredApps) { app in
  237. AppTileView(
  238. app: app,
  239. onTap: { handleAppTap(app) },
  240. onHide: { hideApp(id: app.id) }
  241. )
  242. .onDrag {
  243. draggedAppID = app.id
  244. lastDropDestinationAppID = nil
  245. return NSItemProvider(object: app.id.uuidString as NSString)
  246. }
  247. .onDrop(
  248. of: [UTType.text],
  249. delegate: TileReorderDropDelegate(
  250. destinationAppID: app.id,
  251. draggedAppID: $draggedAppID,
  252. lastDropDestinationAppID: $lastDropDestinationAppID,
  253. onMove: moveApp
  254. )
  255. )
  256. }
  257. }
  258. .padding(.top, 10)
  259. .padding(.horizontal, 14)
  260. .padding(.bottom, 18)
  261. }
  262. }
  263. }
  264. }
  265. private func handleAppTap(_ app: LauncherApp) {
  266. if app.isCreateNew {
  267. showingCreateAppSheet = true
  268. return
  269. }
  270. if let webURL = app.webURL {
  271. InAppBrowserWindowManager.shared.open(url: webURL, title: app.name)
  272. return
  273. }
  274. selectedApp = app
  275. }
  276. /// Second (panel) view: tapping a row opens the Widgets window for that app.
  277. private func handlePanelAppTap(_ app: LauncherApp) {
  278. if app.isCreateNew {
  279. showingCreateAppSheet = true
  280. return
  281. }
  282. openWidgetsPage(for: app)
  283. }
  284. private func openWidgetsPage(for app: LauncherApp) {
  285. var updated = decodeUUIDSetGlobal(from: widgetAppIDsData)
  286. updated.insert(app.id)
  287. widgetAppIDsData = encodeUUIDSetGlobal(updated)
  288. NotificationCenter.default.post(
  289. name: .openWidgetsPage,
  290. object: nil,
  291. userInfo: ["appID": app.id]
  292. )
  293. }
  294. private func addCustomApp(name: String, url: URL) {
  295. let newApp = LauncherApp(
  296. name: name,
  297. assetIconName: "",
  298. fallbackSymbolName: "globe",
  299. description: "Custom app shortcut.",
  300. isCreateNew: false,
  301. webURL: url
  302. )
  303. customApps.append(newApp)
  304. saveCustomAppsToStorage()
  305. normalizeAppOrderAndPersist()
  306. }
  307. private func loadCustomAppsFromStorage() {
  308. guard !customAppsData.isEmpty else {
  309. customApps = []
  310. return
  311. }
  312. guard let data = customAppsData.data(using: .utf8) else {
  313. customApps = []
  314. return
  315. }
  316. do {
  317. let persisted = try JSONDecoder().decode([PersistedCustomApp].self, from: data)
  318. customApps = persisted.compactMap { item in
  319. guard let parsedURL = URL(string: item.urlString) else { return nil }
  320. return LauncherApp(
  321. id: item.id,
  322. name: item.name,
  323. assetIconName: "",
  324. fallbackSymbolName: "globe",
  325. description: "Custom app shortcut.",
  326. isCreateNew: false,
  327. webURL: parsedURL
  328. )
  329. }
  330. } catch {
  331. customApps = []
  332. }
  333. }
  334. private func saveCustomAppsToStorage() {
  335. let persisted = customApps.compactMap { app -> PersistedCustomApp? in
  336. guard !app.isCreateNew, let urlString = app.webURL?.absoluteString else { return nil }
  337. return PersistedCustomApp(id: app.id, name: app.name, urlString: urlString)
  338. }
  339. do {
  340. let encoded = try JSONEncoder().encode(persisted)
  341. customAppsData = String(decoding: encoded, as: UTF8.self)
  342. } catch {
  343. // Keep UI responsive even if persistence fails.
  344. }
  345. }
  346. private func moveApp(from sourceID: UUID, to destinationID: UUID) {
  347. guard sourceID != destinationID else { return }
  348. guard let sourceIndex = appOrder.firstIndex(of: sourceID),
  349. let destinationIndex = appOrder.firstIndex(of: destinationID) else {
  350. return
  351. }
  352. var updated = appOrder
  353. let movedID = updated.remove(at: sourceIndex)
  354. let insertionIndex = destinationIndex > sourceIndex ? destinationIndex - 1 : destinationIndex
  355. updated.insert(movedID, at: insertionIndex)
  356. appOrder = updated
  357. saveAppOrderToStorage()
  358. }
  359. private func hideApp(id: UUID) {
  360. var hiddenIDs = decodeUUIDSet(from: hiddenLauncherAppIDsData)
  361. guard hiddenIDs.insert(id).inserted else { return }
  362. hiddenLauncherAppIDsData = encodeUUIDSetGlobal(hiddenIDs)
  363. removeIDFromLinkedCollections(id)
  364. }
  365. private func restoreHiddenApp(id: UUID) {
  366. var hiddenIDs = decodeUUIDSet(from: hiddenLauncherAppIDsData)
  367. guard hiddenIDs.remove(id) != nil else { return }
  368. hiddenLauncherAppIDsData = encodeUUIDSetGlobal(hiddenIDs)
  369. }
  370. private func restoreAllHiddenApps() {
  371. hiddenLauncherAppIDsData = ""
  372. }
  373. private func removeIDFromLinkedCollections(_ id: UUID) {
  374. var pinnedIDs = decodeUUIDSet(from: pinnedTileIDsData)
  375. pinnedIDs.remove(id)
  376. pinnedTileIDsData = encodeUUIDSetGlobal(pinnedIDs)
  377. var statusBarIDs = decodeUUIDSet(from: statusBarAppIDsData)
  378. if statusBarIDs.remove(id) != nil {
  379. statusBarAppIDsData = encodeUUIDSetGlobal(statusBarIDs)
  380. LauncherApp.notifyStatusBarShortcutIdentifiersChanged()
  381. }
  382. var widgetIDs = decodeUUIDSet(from: widgetAppIDsData)
  383. widgetIDs.remove(id)
  384. widgetAppIDsData = encodeUUIDSetGlobal(widgetIDs)
  385. }
  386. private func loadAppOrderFromStorage() {
  387. guard !appOrderData.isEmpty else {
  388. appOrder = []
  389. return
  390. }
  391. guard let data = appOrderData.data(using: .utf8) else {
  392. appOrder = []
  393. return
  394. }
  395. do {
  396. let decoded = try JSONDecoder().decode([String].self, from: data)
  397. appOrder = decoded.compactMap { UUID(uuidString: $0) }
  398. } catch {
  399. appOrder = []
  400. }
  401. }
  402. private func normalizeAppOrderAndPersist() {
  403. let currentIDs = apps.map(\.id)
  404. var seen = Set<UUID>()
  405. var normalized: [UUID] = []
  406. for id in appOrder where currentIDs.contains(id) {
  407. if seen.insert(id).inserted {
  408. normalized.append(id)
  409. }
  410. }
  411. for id in currentIDs where seen.insert(id).inserted {
  412. normalized.append(id)
  413. }
  414. appOrder = normalized
  415. saveAppOrderToStorage()
  416. }
  417. private func saveAppOrderToStorage() {
  418. do {
  419. let payload = appOrder.map(\.uuidString)
  420. let encoded = try JSONEncoder().encode(payload)
  421. appOrderData = String(decoding: encoded, as: UTF8.self)
  422. } catch {
  423. // Ignore persistence failure and keep local order.
  424. }
  425. }
  426. private func decodeUUIDSet(from dataString: String) -> Set<UUID> {
  427. guard !dataString.isEmpty, let data = dataString.data(using: .utf8) else { return [] }
  428. do {
  429. let strings = try JSONDecoder().decode([String].self, from: data)
  430. return Set(strings.compactMap(UUID.init(uuidString:)))
  431. } catch {
  432. return []
  433. }
  434. }
  435. private func openManageSubscriptions() {
  436. let appStoreURL = URL(string: "macappstore://apps.apple.com/account/subscriptions")!
  437. if NSWorkspace.shared.open(appStoreURL) {
  438. return
  439. }
  440. let webURL = URL(string: "https://apps.apple.com/account/subscriptions")!
  441. _ = NSWorkspace.shared.open(webURL)
  442. }
  443. }
  444. private struct TileReorderDropDelegate: DropDelegate {
  445. let destinationAppID: UUID
  446. @Binding var draggedAppID: UUID?
  447. @Binding var lastDropDestinationAppID: UUID?
  448. let onMove: (UUID, UUID) -> Void
  449. func dropEntered(info: DropInfo) {
  450. guard let draggedAppID else { return }
  451. guard draggedAppID != destinationAppID else { return }
  452. // SwiftUI may call `dropEntered` multiple times while hovering the same tile.
  453. // Avoid reordering repeatedly to keep drag-to-reorder stable.
  454. guard lastDropDestinationAppID != destinationAppID else { return }
  455. lastDropDestinationAppID = destinationAppID
  456. onMove(draggedAppID, destinationAppID)
  457. }
  458. func performDrop(info: DropInfo) -> Bool {
  459. draggedAppID = nil
  460. lastDropDestinationAppID = nil
  461. return true
  462. }
  463. }
  464. private struct SearchHeader: View {
  465. @Binding var query: String
  466. let isPanelMode: Bool
  467. let hiddenApps: [LauncherApp]
  468. let onToggleLayout: () -> Void
  469. let onOpenPremium: () -> Void
  470. let onOpenGoogle: () -> Void
  471. let onRestoreApp: (UUID) -> Void
  472. let onRestoreAll: () -> Void
  473. var body: some View {
  474. HStack {
  475. HStack(spacing: 10) {
  476. Image(systemName: "magnifyingglass")
  477. .foregroundStyle(.white.opacity(0.82))
  478. TextField("Search", text: $query)
  479. .textFieldStyle(.plain)
  480. .foregroundStyle(.white.opacity(0.94))
  481. }
  482. .padding(.horizontal, 12)
  483. .padding(.vertical, 8)
  484. .background(
  485. RoundedRectangle(cornerRadius: 15, style: .continuous)
  486. .fill(Color.black.opacity(0.28))
  487. )
  488. .overlay(
  489. RoundedRectangle(cornerRadius: 15, style: .continuous)
  490. .stroke(Color.white.opacity(0.08), lineWidth: 1)
  491. )
  492. Spacer(minLength: 8)
  493. HStack(spacing: 12) {
  494. Button(action: onToggleLayout) {
  495. Image(systemName: isPanelMode ? "square.grid.2x2" : "list.bullet")
  496. }
  497. .buttonStyle(.plain)
  498. Menu {
  499. Button("Clear Search") {
  500. query = ""
  501. }
  502. Button("Open Premium") {
  503. onOpenPremium()
  504. }
  505. Button("Open Google") {
  506. onOpenGoogle()
  507. }
  508. if !hiddenApps.isEmpty {
  509. Divider()
  510. Menu("Manage Hidden Apps") {
  511. ForEach(hiddenApps) { app in
  512. Button("Add Back \(app.name)") {
  513. onRestoreApp(app.id)
  514. }
  515. }
  516. Divider()
  517. Button("Restore All Hidden Apps") {
  518. onRestoreAll()
  519. }
  520. }
  521. }
  522. } label: {
  523. Image(systemName: "ellipsis")
  524. }
  525. .menuStyle(.borderlessButton)
  526. }
  527. .font(.system(size: 15, weight: .semibold))
  528. .foregroundStyle(.white.opacity(0.82))
  529. .padding(.trailing, 4)
  530. }
  531. }
  532. }
  533. private struct IconPanelView: View {
  534. let apps: [LauncherApp]
  535. let onAppTap: (LauncherApp) -> Void
  536. @Binding var draggedAppID: UUID?
  537. @Binding var lastDropDestinationAppID: UUID?
  538. let onMove: (UUID, UUID) -> Void
  539. let onHideApp: (UUID) -> Void
  540. var body: some View {
  541. ScrollView {
  542. VStack(alignment: .leading, spacing: 12) {
  543. ForEach(apps) { app in
  544. PanelAppRowView(
  545. app: app,
  546. onAppTap: onAppTap,
  547. draggedAppID: $draggedAppID,
  548. lastDropDestinationAppID: $lastDropDestinationAppID,
  549. onMove: onMove,
  550. onHideApp: onHideApp
  551. )
  552. .frame(maxWidth: .infinity, alignment: .leading)
  553. }
  554. }
  555. .frame(maxWidth: .infinity, alignment: .leading)
  556. .padding(.bottom, 18)
  557. }
  558. }
  559. }
  560. private struct PanelAppRowView: View {
  561. let app: LauncherApp
  562. let onAppTap: (LauncherApp) -> Void
  563. @Binding var draggedAppID: UUID?
  564. @Binding var lastDropDestinationAppID: UUID?
  565. let onMove: (UUID, UUID) -> Void
  566. let onHideApp: (UUID) -> Void
  567. @ObservedObject private var premiumStore = PremiumStore.shared
  568. @AppStorage("pinnedTileIDsData") private var pinnedTileIDsData = ""
  569. @AppStorage("statusBarAppIDsData") private var statusBarAppIDsData = ""
  570. @AppStorage("widgetAppIDsData") private var widgetAppIDsData = ""
  571. @State private var isHovering = false
  572. private var quickActions: [WidgetAction] {
  573. let variants = WidgetTemplates.variants(for: app)
  574. let chosen = variants.first(where: { $0.size == .medium }) ??
  575. variants.first(where: { $0.size == .small }) ??
  576. variants.first
  577. guard let chosen else { return [] }
  578. let maxActions: Int
  579. switch chosen.size {
  580. case .small: maxActions = 3
  581. case .medium: maxActions = 4
  582. case .large: maxActions = 8
  583. }
  584. return Array(chosen.actions.prefix(maxActions))
  585. }
  586. private var shouldShowQuickActions: Bool {
  587. !app.isCreateNew && app.webURL != nil && !quickActions.isEmpty
  588. }
  589. private var pinnedIDs: Set<UUID> {
  590. decodeUUIDSetGlobal(from: pinnedTileIDsData)
  591. }
  592. private var isPinned: Bool {
  593. pinnedIDs.contains(app.id)
  594. }
  595. private var statusBarIDs: Set<UUID> {
  596. decodeUUIDSetGlobal(from: statusBarAppIDsData)
  597. }
  598. private var isInStatusBar: Bool {
  599. statusBarIDs.contains(app.id)
  600. }
  601. var body: some View {
  602. ZStack(alignment: .trailing) {
  603. HStack(spacing: 14) {
  604. AppIconGlyph(app: app)
  605. .scaleEffect(isHovering ? 1.05 : 1.0, anchor: .leading)
  606. .animation(.spring(response: 0.22, dampingFraction: 0.82), value: isHovering)
  607. Text(app.name)
  608. .font(.system(size: 18, weight: .medium))
  609. .foregroundStyle(.white.opacity(0.94))
  610. .lineLimit(1)
  611. .truncationMode(.tail)
  612. Spacer(minLength: 0)
  613. }
  614. .frame(maxWidth: .infinity, alignment: .leading)
  615. .contentShape(Rectangle())
  616. .onTapGesture { onAppTap(app) }
  617. if isPinned && !app.isCreateNew {
  618. Image(systemName: "pin.fill")
  619. .font(.system(size: 12, weight: .semibold))
  620. .foregroundStyle(.white.opacity(0.92))
  621. .frame(width: 26, height: 26)
  622. .background(
  623. Circle()
  624. .fill(Color.black.opacity(isHovering ? 0.35 : 0.2))
  625. )
  626. .overlay(
  627. Circle()
  628. .stroke(Color.white.opacity(isHovering ? 0.18 : 0.12), lineWidth: 1)
  629. )
  630. .shadow(color: .black.opacity(0.22), radius: 10, x: 0, y: 6)
  631. .padding(8)
  632. .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
  633. .allowsHitTesting(false)
  634. }
  635. if shouldShowQuickActions {
  636. HStack(spacing: 10) {
  637. ForEach(quickActions) { action in
  638. PanelQuickActionButton(
  639. action: action,
  640. onTap: { openWidgetAction(action) }
  641. )
  642. }
  643. }
  644. .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing)
  645. .padding(.trailing, 34)
  646. }
  647. if !app.isCreateNew {
  648. Menu {
  649. Button(isPinned ? "Unpin" : "Pin") { togglePin() }
  650. Divider()
  651. if isInStatusBar {
  652. Button("Remove from StatusBar") { removeFromStatusBar() }
  653. } else {
  654. Button("Add to StatusBar") { addToStatusBar() }
  655. .disabled(!premiumStore.isPremiumUnlocked)
  656. }
  657. Button("Add to desktop") { addToDesktop() }
  658. .disabled(!premiumStore.isPremiumUnlocked)
  659. Button("Add Widget") { addWidget() }
  660. Divider()
  661. Button("Remove from View") { onHideApp(app.id) }
  662. } label: {
  663. Image(systemName: "ellipsis")
  664. .font(.system(size: 13, weight: .semibold))
  665. .rotationEffect(.degrees(90))
  666. .foregroundStyle(.white.opacity(isHovering ? 0.92 : 0.75))
  667. .frame(width: 26, height: 26)
  668. .background(
  669. Circle()
  670. .fill(Color.black.opacity(isHovering ? 0.35 : 0.2))
  671. )
  672. .overlay(
  673. Circle()
  674. .stroke(Color.white.opacity(isHovering ? 0.18 : 0.12), lineWidth: 1)
  675. )
  676. .shadow(color: .black.opacity(0.25), radius: 10, x: 0, y: 6)
  677. }
  678. .menuStyle(.borderlessButton)
  679. .modifier(HideMenuIndicatorIfAvailable())
  680. .padding(8)
  681. .opacity(isHovering ? 1 : 0.0)
  682. .animation(.easeOut(duration: 0.12), value: isHovering)
  683. .frame(maxHeight: .infinity, alignment: .center)
  684. }
  685. }
  686. .padding(.vertical, 4)
  687. .padding(.horizontal, 10)
  688. .frame(maxWidth: .infinity, minHeight: 60, alignment: .leading)
  689. .background {
  690. RoundedRectangle(cornerRadius: 12, style: .continuous)
  691. .fill(Color.white.opacity(isHovering ? 0.18 : 0))
  692. .overlay(
  693. RoundedRectangle(cornerRadius: 12, style: .continuous)
  694. .stroke(
  695. LinearGradient(
  696. colors: [
  697. Color.white.opacity(isHovering ? 0.38 : 0),
  698. Color.white.opacity(isHovering ? 0.14 : 0),
  699. ],
  700. startPoint: .topLeading,
  701. endPoint: .bottomTrailing
  702. ),
  703. lineWidth: 1
  704. )
  705. )
  706. .shadow(color: Color.black.opacity(isHovering ? 0.35 : 0), radius: isHovering ? 14 : 0, x: 0, y: 4)
  707. .animation(.easeOut(duration: 0.14), value: isHovering)
  708. }
  709. .contentShape(Rectangle())
  710. .onHover { hovering in
  711. isHovering = hovering
  712. }
  713. .contextMenu {
  714. if !app.isCreateNew {
  715. Button(isPinned ? "Unpin" : "Pin") { togglePin() }
  716. Divider()
  717. if isInStatusBar {
  718. Button("Remove from StatusBar") { removeFromStatusBar() }
  719. } else {
  720. Button("Add to StatusBar") { addToStatusBar() }
  721. .disabled(!premiumStore.isPremiumUnlocked)
  722. }
  723. Button("Add to desktop") { addToDesktop() }
  724. .disabled(!premiumStore.isPremiumUnlocked)
  725. Button("Add Widget") { addWidget() }
  726. Divider()
  727. Button("Remove from View") { onHideApp(app.id) }
  728. }
  729. }
  730. .padding(.vertical, 3)
  731. .onDrag {
  732. draggedAppID = app.id
  733. lastDropDestinationAppID = nil
  734. return NSItemProvider(object: app.id.uuidString as NSString)
  735. }
  736. .onDrop(
  737. of: [UTType.text],
  738. delegate: TileReorderDropDelegate(
  739. destinationAppID: app.id,
  740. draggedAppID: $draggedAppID,
  741. lastDropDestinationAppID: $lastDropDestinationAppID,
  742. onMove: onMove
  743. )
  744. )
  745. }
  746. private func togglePin() {
  747. var updated = pinnedIDs
  748. if updated.contains(app.id) {
  749. updated.remove(app.id)
  750. } else {
  751. updated.insert(app.id)
  752. }
  753. pinnedTileIDsData = encodeUUIDSetGlobal(updated)
  754. }
  755. private func addToStatusBar() {
  756. var updated = decodeUUIDSetGlobal(from: statusBarAppIDsData)
  757. guard updated.insert(app.id).inserted else {
  758. showAlert(title: "Already added", message: "“\(app.name)” is already in the menu bar.")
  759. return
  760. }
  761. statusBarAppIDsData = encodeUUIDSetGlobal(updated)
  762. LauncherApp.notifyStatusBarShortcutIdentifiersChanged()
  763. showAlert(
  764. title: "Added to Status Bar",
  765. message: "“\(app.name)” now has its own icon in the menu bar. Click it to open, or right‑click to remove."
  766. )
  767. }
  768. private func removeFromStatusBar() {
  769. var updated = decodeUUIDSetGlobal(from: statusBarAppIDsData)
  770. guard updated.remove(app.id) != nil else { return }
  771. statusBarAppIDsData = encodeUUIDSetGlobal(updated)
  772. LauncherApp.notifyStatusBarShortcutIdentifiersChanged()
  773. }
  774. private func addWidget() {
  775. var updated = decodeUUIDSetGlobal(from: widgetAppIDsData)
  776. updated.insert(app.id)
  777. widgetAppIDsData = encodeUUIDSetGlobal(updated)
  778. NotificationCenter.default.post(
  779. name: .openWidgetsPage,
  780. object: nil,
  781. userInfo: ["appID": app.id]
  782. )
  783. }
  784. private func openWidgetAction(_ action: WidgetAction) {
  785. guard let base = app.webURL else { return }
  786. let target = resolvedURL(base: base, actionPath: action.urlPath)
  787. InAppBrowserWindowManager.shared.open(url: target, title: app.name)
  788. }
  789. private func resolvedURL(base: URL, actionPath: String?) -> URL {
  790. guard let actionPath, !actionPath.isEmpty else { return base }
  791. if actionPath.hasPrefix("http://") || actionPath.hasPrefix("https://") {
  792. return URL(string: actionPath) ?? base
  793. }
  794. if actionPath.hasPrefix("/") {
  795. var c = URLComponents(url: base, resolvingAgainstBaseURL: false)
  796. c?.path = actionPath
  797. return c?.url ?? base
  798. }
  799. return URL(string: actionPath, relativeTo: base)?.absoluteURL ?? base
  800. }
  801. private func addToDesktop() {
  802. guard let webURL = app.webURL else {
  803. showAlert(title: "Unavailable", message: "Desktop shortcuts need a web address.")
  804. return
  805. }
  806. DesktopWeblocShortcutPresenter.beginSavePanel(forAppNamed: app.name, webURL: webURL)
  807. }
  808. private func showAlert(title: String, message: String) {
  809. let alert = NSAlert()
  810. alert.messageText = title
  811. alert.informativeText = message
  812. alert.addButton(withTitle: "OK")
  813. alert.alertStyle = .informational
  814. alert.runModal()
  815. }
  816. }
  817. private struct PanelQuickActionButton: View {
  818. private static let iconPointSize: CGFloat = 15
  819. private static let circleDiameter: CGFloat = 32
  820. let action: WidgetAction
  821. let onTap: () -> Void
  822. @State private var isHovering = false
  823. private var highlight: Bool { isHovering }
  824. var body: some View {
  825. Button(action: onTap) {
  826. Image(systemName: action.systemImage)
  827. .font(.system(size: Self.iconPointSize, weight: .semibold))
  828. .foregroundStyle(.white.opacity(0.82))
  829. .frame(width: Self.circleDiameter, height: Self.circleDiameter)
  830. .background(
  831. Circle()
  832. .fill(Color.black.opacity(highlight ? 0.48 : 0.22))
  833. )
  834. .overlay(
  835. Circle()
  836. .stroke(Color.white.opacity(highlight ? 0.18 : 0.10), lineWidth: 1)
  837. )
  838. .shadow(color: .black.opacity(highlight ? 0.28 : 0.20), radius: highlight ? 12 : 8, x: 0, y: 4)
  839. }
  840. .buttonStyle(.plain)
  841. .animation(.spring(response: 0.28, dampingFraction: 0.85), value: highlight)
  842. .overlay(alignment: .top) {
  843. Text(action.title)
  844. .font(.system(size: 10, weight: .semibold))
  845. .foregroundStyle(.white.opacity(0.96))
  846. .lineLimit(1)
  847. .fixedSize(horizontal: true, vertical: false)
  848. .padding(.horizontal, 7)
  849. .padding(.vertical, 3)
  850. .background(
  851. Capsule(style: .continuous)
  852. .fill(Color.black.opacity(0.78))
  853. )
  854. .overlay(
  855. Capsule(style: .continuous)
  856. .stroke(Color.white.opacity(0.12), lineWidth: 1)
  857. )
  858. .shadow(color: .black.opacity(0.32), radius: 7, x: 0, y: 3)
  859. .offset(y: -(Self.circleDiameter / 2 + 14))
  860. .opacity(isHovering ? 1 : 0)
  861. .scaleEffect(isHovering ? 1 : 0.88, anchor: .bottom)
  862. .offset(y: isHovering ? 0 : 5)
  863. .animation(.spring(response: 0.28, dampingFraction: 0.82), value: isHovering)
  864. .allowsHitTesting(false)
  865. }
  866. .onHover { isHovering = $0 }
  867. .accessibilityLabel(action.title)
  868. }
  869. }
  870. private struct AppIconGlyph: View {
  871. let app: LauncherApp
  872. var body: some View {
  873. ZStack {
  874. RoundedRectangle(cornerRadius: 12, style: .continuous)
  875. .fill(Color.white.opacity(0.08))
  876. .frame(width: 48, height: 48)
  877. if let webURL = app.webURL {
  878. WebSiteFaviconView(
  879. webURL: webURL,
  880. size: 32,
  881. assetIconName: app.assetIconName,
  882. fallbackSymbolName: app.fallbackSymbolName,
  883. iconPaddingFactor: 0.08
  884. )
  885. } else if let icon = NSImage(named: app.assetIconName) {
  886. Image(nsImage: icon)
  887. .resizable()
  888. .scaledToFit()
  889. .frame(width: 32, height: 32)
  890. } else {
  891. Image(systemName: app.fallbackSymbolName)
  892. .font(.system(size: 20, weight: .semibold))
  893. .foregroundStyle(.white)
  894. }
  895. }
  896. }
  897. }
  898. // MARK: - Pinned app persistence helpers (panel mode)
  899. private func decodeUUIDSetGlobal(from dataString: String) -> Set<UUID> {
  900. guard !dataString.isEmpty, let data = dataString.data(using: .utf8) else { return [] }
  901. do {
  902. let strings = try JSONDecoder().decode([String].self, from: data)
  903. return Set(strings.compactMap(UUID.init(uuidString:)))
  904. } catch {
  905. return []
  906. }
  907. }
  908. private func encodeUUIDSetGlobal(_ set: Set<UUID>) -> String {
  909. do {
  910. let strings = set.map(\.uuidString).sorted()
  911. let data = try JSONEncoder().encode(strings)
  912. return String(decoding: data, as: UTF8.self)
  913. } catch {
  914. return ""
  915. }
  916. }
  917. private struct PromoBanner: View {
  918. let isPremiumUnlocked: Bool
  919. let activePremiumProductID: PremiumProductID?
  920. let onCloseTap: () -> Void
  921. let onUpgradeTap: () -> Void
  922. let onManageSubscriptionTap: () -> Void
  923. var body: some View {
  924. HStack(spacing: 10) {
  925. Button(action: onCloseTap) {
  926. Image(systemName: "xmark")
  927. .foregroundStyle(.blue.opacity(0.95))
  928. .font(.system(size: 14, weight: .semibold))
  929. }
  930. .buttonStyle(.plain)
  931. Text(isPremiumUnlocked ? "Premium is active. Manage your subscription any time." : "Update to Premium to unlock all features and remove ads")
  932. .font(.system(size: 13, weight: .medium))
  933. .foregroundStyle(.blue.opacity(0.95))
  934. .lineLimit(1)
  935. Spacer()
  936. HStack(spacing: 8) {
  937. if isPremiumUnlocked && activePremiumProductID == .yearly {
  938. Button(action: onUpgradeTap) {
  939. Text("Upgrade Plan")
  940. .font(.system(size: 13, weight: .semibold))
  941. .foregroundStyle(.white)
  942. .padding(.horizontal, 12)
  943. .padding(.vertical, 6)
  944. .background(
  945. Capsule(style: .continuous)
  946. .fill(Color(red: 1.0, green: 0.67, blue: 0.2).opacity(0.95))
  947. )
  948. }
  949. .buttonStyle(.plain)
  950. Button(action: onManageSubscriptionTap) {
  951. Text("Manage Subscription")
  952. .font(.system(size: 13, weight: .semibold))
  953. .foregroundStyle(.white)
  954. .padding(.horizontal, 12)
  955. .padding(.vertical, 6)
  956. .background(
  957. Capsule(style: .continuous)
  958. .fill(Color.blue.opacity(0.92))
  959. )
  960. }
  961. .buttonStyle(.plain)
  962. } else {
  963. Button(action: isPremiumUnlocked ? onManageSubscriptionTap : onUpgradeTap) {
  964. Text(isPremiumUnlocked ? "Manage Subscription" : "Upgrade")
  965. .font(.system(size: 13, weight: .semibold))
  966. .foregroundStyle(.white)
  967. .padding(.horizontal, 12)
  968. .padding(.vertical, 6)
  969. .background(
  970. Capsule(style: .continuous)
  971. .fill(Color.blue.opacity(0.92))
  972. )
  973. }
  974. .buttonStyle(.plain)
  975. }
  976. }
  977. }
  978. .padding(.horizontal, 11)
  979. .padding(.vertical, 8)
  980. .background(
  981. RoundedRectangle(cornerRadius: 12, style: .continuous)
  982. .fill(Color.white.opacity(0.03))
  983. )
  984. .overlay(
  985. RoundedRectangle(cornerRadius: 12, style: .continuous)
  986. .stroke(Color.white.opacity(0.05), lineWidth: 1)
  987. )
  988. }
  989. }
  990. private struct ManageSubscriptionSheet: View {
  991. @Environment(\.dismiss) private var dismiss
  992. let activePremiumProductID: PremiumProductID?
  993. let onUpgradeTap: () -> Void
  994. private let url = URL(string: "https://apps.apple.com/account/subscriptions")!
  995. var body: some View {
  996. VStack(spacing: 0) {
  997. HStack {
  998. Text("Manage Subscription")
  999. .font(.system(size: 15, weight: .semibold))
  1000. .foregroundStyle(.white.opacity(0.92))
  1001. Spacer()
  1002. Button("Done") { dismiss() }
  1003. .buttonStyle(.borderedProminent)
  1004. .controlSize(.small)
  1005. }
  1006. .padding(12)
  1007. .background(Color.black.opacity(0.25))
  1008. if activePremiumProductID == .yearly {
  1009. HStack(spacing: 10) {
  1010. Text("You are on the yearly plan. Upgrade to Lifetime for permanent access.")
  1011. .font(.system(size: 12.5, weight: .medium))
  1012. .foregroundStyle(.white.opacity(0.9))
  1013. Spacer()
  1014. Button("Upgrade Package") {
  1015. onUpgradeTap()
  1016. }
  1017. .buttonStyle(.borderedProminent)
  1018. .controlSize(.small)
  1019. }
  1020. .padding(.horizontal, 12)
  1021. .padding(.vertical, 10)
  1022. .background(Color.blue.opacity(0.18))
  1023. }
  1024. SubscriptionWebView(url: url)
  1025. }
  1026. .background(Color.black.opacity(0.3))
  1027. }
  1028. }
  1029. private struct SubscriptionWebView: NSViewRepresentable {
  1030. let url: URL
  1031. func makeNSView(context: Context) -> WKWebView {
  1032. let webView = WKWebView()
  1033. webView.allowsBackForwardNavigationGestures = true
  1034. webView.load(URLRequest(url: url))
  1035. return webView
  1036. }
  1037. func updateNSView(_ webView: WKWebView, context: Context) {}
  1038. }
  1039. private struct HideMenuIndicatorIfAvailable: ViewModifier {
  1040. func body(content: Content) -> some View {
  1041. if #available(macOS 13.0, *) {
  1042. content.menuIndicator(.hidden)
  1043. } else {
  1044. content
  1045. }
  1046. }
  1047. }
  1048. #Preview {
  1049. LauncherRootView()
  1050. .environmentObject(PremiumStore.shared)
  1051. }