LauncherRootView.swift 40 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117
  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. handleAppTap(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. private func addCustomApp(name: String, url: URL) {
  277. let newApp = LauncherApp(
  278. name: name,
  279. assetIconName: "",
  280. fallbackSymbolName: "globe",
  281. description: "Custom app shortcut.",
  282. isCreateNew: false,
  283. webURL: url
  284. )
  285. customApps.append(newApp)
  286. saveCustomAppsToStorage()
  287. normalizeAppOrderAndPersist()
  288. }
  289. private func loadCustomAppsFromStorage() {
  290. guard !customAppsData.isEmpty else {
  291. customApps = []
  292. return
  293. }
  294. guard let data = customAppsData.data(using: .utf8) else {
  295. customApps = []
  296. return
  297. }
  298. do {
  299. let persisted = try JSONDecoder().decode([PersistedCustomApp].self, from: data)
  300. customApps = persisted.compactMap { item in
  301. guard let parsedURL = URL(string: item.urlString) else { return nil }
  302. return LauncherApp(
  303. id: item.id,
  304. name: item.name,
  305. assetIconName: "",
  306. fallbackSymbolName: "globe",
  307. description: "Custom app shortcut.",
  308. isCreateNew: false,
  309. webURL: parsedURL
  310. )
  311. }
  312. } catch {
  313. customApps = []
  314. }
  315. }
  316. private func saveCustomAppsToStorage() {
  317. let persisted = customApps.compactMap { app -> PersistedCustomApp? in
  318. guard !app.isCreateNew, let urlString = app.webURL?.absoluteString else { return nil }
  319. return PersistedCustomApp(id: app.id, name: app.name, urlString: urlString)
  320. }
  321. do {
  322. let encoded = try JSONEncoder().encode(persisted)
  323. customAppsData = String(decoding: encoded, as: UTF8.self)
  324. } catch {
  325. // Keep UI responsive even if persistence fails.
  326. }
  327. }
  328. private func moveApp(from sourceID: UUID, to destinationID: UUID) {
  329. guard sourceID != destinationID else { return }
  330. guard let sourceIndex = appOrder.firstIndex(of: sourceID),
  331. let destinationIndex = appOrder.firstIndex(of: destinationID) else {
  332. return
  333. }
  334. var updated = appOrder
  335. let movedID = updated.remove(at: sourceIndex)
  336. let insertionIndex = destinationIndex > sourceIndex ? destinationIndex - 1 : destinationIndex
  337. updated.insert(movedID, at: insertionIndex)
  338. appOrder = updated
  339. saveAppOrderToStorage()
  340. }
  341. private func hideApp(id: UUID) {
  342. var hiddenIDs = decodeUUIDSet(from: hiddenLauncherAppIDsData)
  343. guard hiddenIDs.insert(id).inserted else { return }
  344. hiddenLauncherAppIDsData = encodeUUIDSetGlobal(hiddenIDs)
  345. removeIDFromLinkedCollections(id)
  346. }
  347. private func restoreHiddenApp(id: UUID) {
  348. var hiddenIDs = decodeUUIDSet(from: hiddenLauncherAppIDsData)
  349. guard hiddenIDs.remove(id) != nil else { return }
  350. hiddenLauncherAppIDsData = encodeUUIDSetGlobal(hiddenIDs)
  351. }
  352. private func restoreAllHiddenApps() {
  353. hiddenLauncherAppIDsData = ""
  354. }
  355. private func removeIDFromLinkedCollections(_ id: UUID) {
  356. var pinnedIDs = decodeUUIDSet(from: pinnedTileIDsData)
  357. pinnedIDs.remove(id)
  358. pinnedTileIDsData = encodeUUIDSetGlobal(pinnedIDs)
  359. var statusBarIDs = decodeUUIDSet(from: statusBarAppIDsData)
  360. if statusBarIDs.remove(id) != nil {
  361. statusBarAppIDsData = encodeUUIDSetGlobal(statusBarIDs)
  362. LauncherApp.notifyStatusBarShortcutIdentifiersChanged()
  363. }
  364. var widgetIDs = decodeUUIDSet(from: widgetAppIDsData)
  365. widgetIDs.remove(id)
  366. widgetAppIDsData = encodeUUIDSetGlobal(widgetIDs)
  367. }
  368. private func loadAppOrderFromStorage() {
  369. guard !appOrderData.isEmpty else {
  370. appOrder = []
  371. return
  372. }
  373. guard let data = appOrderData.data(using: .utf8) else {
  374. appOrder = []
  375. return
  376. }
  377. do {
  378. let decoded = try JSONDecoder().decode([String].self, from: data)
  379. appOrder = decoded.compactMap { UUID(uuidString: $0) }
  380. } catch {
  381. appOrder = []
  382. }
  383. }
  384. private func normalizeAppOrderAndPersist() {
  385. let currentIDs = apps.map(\.id)
  386. var seen = Set<UUID>()
  387. var normalized: [UUID] = []
  388. for id in appOrder where currentIDs.contains(id) {
  389. if seen.insert(id).inserted {
  390. normalized.append(id)
  391. }
  392. }
  393. for id in currentIDs where seen.insert(id).inserted {
  394. normalized.append(id)
  395. }
  396. appOrder = normalized
  397. saveAppOrderToStorage()
  398. }
  399. private func saveAppOrderToStorage() {
  400. do {
  401. let payload = appOrder.map(\.uuidString)
  402. let encoded = try JSONEncoder().encode(payload)
  403. appOrderData = String(decoding: encoded, as: UTF8.self)
  404. } catch {
  405. // Ignore persistence failure and keep local order.
  406. }
  407. }
  408. private func decodeUUIDSet(from dataString: String) -> Set<UUID> {
  409. guard !dataString.isEmpty, let data = dataString.data(using: .utf8) else { return [] }
  410. do {
  411. let strings = try JSONDecoder().decode([String].self, from: data)
  412. return Set(strings.compactMap(UUID.init(uuidString:)))
  413. } catch {
  414. return []
  415. }
  416. }
  417. private func openManageSubscriptions() {
  418. let appStoreURL = URL(string: "macappstore://apps.apple.com/account/subscriptions")!
  419. if NSWorkspace.shared.open(appStoreURL) {
  420. return
  421. }
  422. let webURL = URL(string: "https://apps.apple.com/account/subscriptions")!
  423. _ = NSWorkspace.shared.open(webURL)
  424. }
  425. }
  426. private struct TileReorderDropDelegate: DropDelegate {
  427. let destinationAppID: UUID
  428. @Binding var draggedAppID: UUID?
  429. @Binding var lastDropDestinationAppID: UUID?
  430. let onMove: (UUID, UUID) -> Void
  431. func dropEntered(info: DropInfo) {
  432. guard let draggedAppID else { return }
  433. guard draggedAppID != destinationAppID else { return }
  434. // SwiftUI may call `dropEntered` multiple times while hovering the same tile.
  435. // Avoid reordering repeatedly to keep drag-to-reorder stable.
  436. guard lastDropDestinationAppID != destinationAppID else { return }
  437. lastDropDestinationAppID = destinationAppID
  438. onMove(draggedAppID, destinationAppID)
  439. }
  440. func performDrop(info: DropInfo) -> Bool {
  441. draggedAppID = nil
  442. lastDropDestinationAppID = nil
  443. return true
  444. }
  445. }
  446. private struct SearchHeader: View {
  447. @Binding var query: String
  448. let isPanelMode: Bool
  449. let hiddenApps: [LauncherApp]
  450. let onToggleLayout: () -> Void
  451. let onOpenPremium: () -> Void
  452. let onOpenGoogle: () -> Void
  453. let onRestoreApp: (UUID) -> Void
  454. let onRestoreAll: () -> Void
  455. var body: some View {
  456. HStack {
  457. HStack(spacing: 10) {
  458. Image(systemName: "magnifyingglass")
  459. .foregroundStyle(.white.opacity(0.82))
  460. TextField("Search", text: $query)
  461. .textFieldStyle(.plain)
  462. .foregroundStyle(.white.opacity(0.94))
  463. }
  464. .padding(.horizontal, 12)
  465. .padding(.vertical, 8)
  466. .background(
  467. RoundedRectangle(cornerRadius: 15, style: .continuous)
  468. .fill(Color.black.opacity(0.28))
  469. )
  470. .overlay(
  471. RoundedRectangle(cornerRadius: 15, style: .continuous)
  472. .stroke(Color.white.opacity(0.08), lineWidth: 1)
  473. )
  474. Spacer(minLength: 8)
  475. HStack(spacing: 12) {
  476. Button(action: onToggleLayout) {
  477. Image(systemName: isPanelMode ? "square.grid.2x2" : "list.bullet")
  478. }
  479. .buttonStyle(.plain)
  480. Menu {
  481. Button("Clear Search") {
  482. query = ""
  483. }
  484. Button("Open Premium") {
  485. onOpenPremium()
  486. }
  487. Button("Open Google") {
  488. onOpenGoogle()
  489. }
  490. if !hiddenApps.isEmpty {
  491. Divider()
  492. Menu("Manage Hidden Apps") {
  493. ForEach(hiddenApps) { app in
  494. Button("Add Back \(app.name)") {
  495. onRestoreApp(app.id)
  496. }
  497. }
  498. Divider()
  499. Button("Restore All Hidden Apps") {
  500. onRestoreAll()
  501. }
  502. }
  503. }
  504. } label: {
  505. Image(systemName: "ellipsis")
  506. }
  507. .menuStyle(.borderlessButton)
  508. }
  509. .font(.system(size: 15, weight: .semibold))
  510. .foregroundStyle(.white.opacity(0.82))
  511. .padding(.trailing, 4)
  512. }
  513. }
  514. }
  515. private struct IconPanelView: View {
  516. let apps: [LauncherApp]
  517. let onAppTap: (LauncherApp) -> Void
  518. @Binding var draggedAppID: UUID?
  519. @Binding var lastDropDestinationAppID: UUID?
  520. let onMove: (UUID, UUID) -> Void
  521. let onHideApp: (UUID) -> Void
  522. var body: some View {
  523. ScrollView {
  524. VStack(alignment: .leading, spacing: 12) {
  525. ForEach(apps) { app in
  526. PanelAppRowView(
  527. app: app,
  528. onAppTap: onAppTap,
  529. draggedAppID: $draggedAppID,
  530. lastDropDestinationAppID: $lastDropDestinationAppID,
  531. onMove: onMove,
  532. onHideApp: onHideApp
  533. )
  534. }
  535. }
  536. .padding(.horizontal, 10)
  537. .padding(.bottom, 18)
  538. }
  539. }
  540. }
  541. private struct PanelAppRowView: View {
  542. let app: LauncherApp
  543. let onAppTap: (LauncherApp) -> Void
  544. @Binding var draggedAppID: UUID?
  545. @Binding var lastDropDestinationAppID: UUID?
  546. let onMove: (UUID, UUID) -> Void
  547. let onHideApp: (UUID) -> Void
  548. @ObservedObject private var premiumStore = PremiumStore.shared
  549. @AppStorage("pinnedTileIDsData") private var pinnedTileIDsData = ""
  550. @AppStorage("statusBarAppIDsData") private var statusBarAppIDsData = ""
  551. @AppStorage("widgetAppIDsData") private var widgetAppIDsData = ""
  552. @State private var isHovering = false
  553. private var quickActions: [WidgetAction] {
  554. let variants = WidgetTemplates.variants(for: app)
  555. let chosen = variants.first(where: { $0.size == .medium }) ??
  556. variants.first(where: { $0.size == .small }) ??
  557. variants.first
  558. guard let chosen else { return [] }
  559. let maxActions: Int
  560. switch chosen.size {
  561. case .small: maxActions = 3
  562. case .medium: maxActions = 4
  563. case .large: maxActions = 8
  564. }
  565. return Array(chosen.actions.prefix(maxActions))
  566. }
  567. private var shouldShowQuickActions: Bool {
  568. !app.isCreateNew && app.webURL != nil && !quickActions.isEmpty
  569. }
  570. private var pinnedIDs: Set<UUID> {
  571. decodeUUIDSetGlobal(from: pinnedTileIDsData)
  572. }
  573. private var isPinned: Bool {
  574. pinnedIDs.contains(app.id)
  575. }
  576. private var statusBarIDs: Set<UUID> {
  577. decodeUUIDSetGlobal(from: statusBarAppIDsData)
  578. }
  579. private var isInStatusBar: Bool {
  580. statusBarIDs.contains(app.id)
  581. }
  582. var body: some View {
  583. ZStack(alignment: .topTrailing) {
  584. HStack(spacing: 14) {
  585. AppIconGlyph(app: app)
  586. Text(app.name)
  587. .font(.system(size: 18, weight: .medium))
  588. .foregroundStyle(.white.opacity(0.94))
  589. .lineLimit(1)
  590. .truncationMode(.tail)
  591. Spacer(minLength: 0)
  592. }
  593. .frame(maxWidth: .infinity, alignment: .leading)
  594. .contentShape(Rectangle())
  595. .onTapGesture { onAppTap(app) }
  596. if isPinned && !app.isCreateNew {
  597. Image(systemName: "pin.fill")
  598. .font(.system(size: 12, weight: .semibold))
  599. .foregroundStyle(.white.opacity(0.92))
  600. .frame(width: 26, height: 26)
  601. .background(
  602. Circle()
  603. .fill(Color.black.opacity(isHovering ? 0.35 : 0.2))
  604. )
  605. .overlay(
  606. Circle()
  607. .stroke(Color.white.opacity(isHovering ? 0.18 : 0.12), lineWidth: 1)
  608. )
  609. .shadow(color: .black.opacity(0.22), radius: 10, x: 0, y: 6)
  610. .padding(8)
  611. .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
  612. .allowsHitTesting(false)
  613. }
  614. if shouldShowQuickActions {
  615. HStack(spacing: 8) {
  616. ForEach(quickActions) { action in
  617. PanelQuickActionButton(
  618. action: action,
  619. isRowHovering: isHovering,
  620. onTap: { openWidgetAction(action) }
  621. )
  622. }
  623. }
  624. .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing)
  625. .padding(.trailing, 34)
  626. .padding(.top, 18)
  627. }
  628. if !app.isCreateNew {
  629. Menu {
  630. Button(isPinned ? "Unpin" : "Pin") { togglePin() }
  631. Divider()
  632. if isInStatusBar {
  633. Button("Remove from StatusBar") { removeFromStatusBar() }
  634. } else {
  635. Button("Add to StatusBar") { addToStatusBar() }
  636. .disabled(!premiumStore.isPremiumUnlocked)
  637. }
  638. Button("Add to desktop") { addToDesktop() }
  639. .disabled(!premiumStore.isPremiumUnlocked)
  640. Button("Add Widget") { addWidget() }
  641. Divider()
  642. Button("Remove from View") { onHideApp(app.id) }
  643. } label: {
  644. Image(systemName: "ellipsis")
  645. .font(.system(size: 13, weight: .semibold))
  646. .rotationEffect(.degrees(90))
  647. .foregroundStyle(.white.opacity(isHovering ? 0.92 : 0.75))
  648. .frame(width: 26, height: 26)
  649. .background(
  650. Circle()
  651. .fill(Color.black.opacity(isHovering ? 0.35 : 0.2))
  652. )
  653. .overlay(
  654. Circle()
  655. .stroke(Color.white.opacity(isHovering ? 0.18 : 0.12), lineWidth: 1)
  656. )
  657. .shadow(color: .black.opacity(0.25), radius: 10, x: 0, y: 6)
  658. }
  659. .menuStyle(.borderlessButton)
  660. .modifier(HideMenuIndicatorIfAvailable())
  661. .padding(8)
  662. // Match the quick-action icon row vertical alignment.
  663. .offset(y: shouldShowQuickActions ? 18 : 0)
  664. .opacity(isHovering ? 1 : 0.0)
  665. .animation(.easeOut(duration: 0.12), value: isHovering)
  666. }
  667. }
  668. .onHover { hovering in
  669. isHovering = hovering
  670. }
  671. .contextMenu {
  672. if !app.isCreateNew {
  673. Button(isPinned ? "Unpin" : "Pin") { togglePin() }
  674. Divider()
  675. if isInStatusBar {
  676. Button("Remove from StatusBar") { removeFromStatusBar() }
  677. } else {
  678. Button("Add to StatusBar") { addToStatusBar() }
  679. .disabled(!premiumStore.isPremiumUnlocked)
  680. }
  681. Button("Add to desktop") { addToDesktop() }
  682. .disabled(!premiumStore.isPremiumUnlocked)
  683. Button("Add Widget") { addWidget() }
  684. Divider()
  685. Button("Remove from View") { onHideApp(app.id) }
  686. }
  687. }
  688. .padding(.vertical, 3)
  689. .onDrag {
  690. draggedAppID = app.id
  691. lastDropDestinationAppID = nil
  692. return NSItemProvider(object: app.id.uuidString as NSString)
  693. }
  694. .onDrop(
  695. of: [UTType.text],
  696. delegate: TileReorderDropDelegate(
  697. destinationAppID: app.id,
  698. draggedAppID: $draggedAppID,
  699. lastDropDestinationAppID: $lastDropDestinationAppID,
  700. onMove: onMove
  701. )
  702. )
  703. }
  704. private func togglePin() {
  705. var updated = pinnedIDs
  706. if updated.contains(app.id) {
  707. updated.remove(app.id)
  708. } else {
  709. updated.insert(app.id)
  710. }
  711. pinnedTileIDsData = encodeUUIDSetGlobal(updated)
  712. }
  713. private func addToStatusBar() {
  714. var updated = decodeUUIDSetGlobal(from: statusBarAppIDsData)
  715. guard updated.insert(app.id).inserted else {
  716. showAlert(title: "Already added", message: "“\(app.name)” is already in the menu bar.")
  717. return
  718. }
  719. statusBarAppIDsData = encodeUUIDSetGlobal(updated)
  720. LauncherApp.notifyStatusBarShortcutIdentifiersChanged()
  721. showAlert(
  722. title: "Added to Status Bar",
  723. message: "“\(app.name)” now has its own icon in the menu bar. Click it to open, or right‑click to remove."
  724. )
  725. }
  726. private func removeFromStatusBar() {
  727. var updated = decodeUUIDSetGlobal(from: statusBarAppIDsData)
  728. guard updated.remove(app.id) != nil else { return }
  729. statusBarAppIDsData = encodeUUIDSetGlobal(updated)
  730. LauncherApp.notifyStatusBarShortcutIdentifiersChanged()
  731. }
  732. private func addWidget() {
  733. var updated = decodeUUIDSetGlobal(from: widgetAppIDsData)
  734. updated.insert(app.id)
  735. widgetAppIDsData = encodeUUIDSetGlobal(updated)
  736. NotificationCenter.default.post(
  737. name: .openWidgetsPage,
  738. object: nil,
  739. userInfo: ["appID": app.id]
  740. )
  741. }
  742. private func openWidgetAction(_ action: WidgetAction) {
  743. guard let base = app.webURL else { return }
  744. let target = resolvedURL(base: base, actionPath: action.urlPath)
  745. InAppBrowserWindowManager.shared.open(url: target, title: app.name)
  746. }
  747. private func resolvedURL(base: URL, actionPath: String?) -> URL {
  748. guard let actionPath, !actionPath.isEmpty else { return base }
  749. if actionPath.hasPrefix("http://") || actionPath.hasPrefix("https://") {
  750. return URL(string: actionPath) ?? base
  751. }
  752. if actionPath.hasPrefix("/") {
  753. var c = URLComponents(url: base, resolvingAgainstBaseURL: false)
  754. c?.path = actionPath
  755. return c?.url ?? base
  756. }
  757. return URL(string: actionPath, relativeTo: base)?.absoluteURL ?? base
  758. }
  759. private func addToDesktop() {
  760. guard let webURL = app.webURL else {
  761. showAlert(title: "Unavailable", message: "Desktop shortcuts need a web address.")
  762. return
  763. }
  764. DesktopWeblocShortcutPresenter.beginSavePanel(forAppNamed: app.name, webURL: webURL)
  765. }
  766. private func showAlert(title: String, message: String) {
  767. let alert = NSAlert()
  768. alert.messageText = title
  769. alert.informativeText = message
  770. alert.addButton(withTitle: "OK")
  771. alert.alertStyle = .informational
  772. alert.runModal()
  773. }
  774. }
  775. private struct PanelQuickActionButton: View {
  776. let action: WidgetAction
  777. let isRowHovering: Bool
  778. let onTap: () -> Void
  779. @State private var isHovering = false
  780. // Only highlight the hovered icon (not the whole row).
  781. private var highlight: Bool { isHovering }
  782. var body: some View {
  783. Button(action: onTap) {
  784. Image(systemName: action.systemImage)
  785. .font(.system(size: 12, weight: .semibold))
  786. .foregroundStyle(.white.opacity(0.82))
  787. .frame(width: 24, height: 24)
  788. .background(
  789. Circle()
  790. .fill(Color.black.opacity(highlight ? 0.48 : 0.22))
  791. )
  792. .overlay(
  793. Circle()
  794. .stroke(Color.white.opacity(highlight ? 0.18 : 0.10), lineWidth: 1)
  795. )
  796. .shadow(color: .black.opacity(highlight ? 0.28 : 0.20), radius: highlight ? 12 : 8, x: 0, y: 4)
  797. }
  798. .buttonStyle(.plain)
  799. .scaleEffect(highlight ? 1.08 : 1.0)
  800. .animation(.spring(response: 0.28, dampingFraction: 0.85), value: highlight)
  801. .overlay(alignment: .top) {
  802. Text(action.title)
  803. .font(.system(size: 10, weight: .semibold))
  804. .foregroundStyle(.white.opacity(0.96))
  805. .lineLimit(1)
  806. .fixedSize(horizontal: true, vertical: false)
  807. .padding(.horizontal, 7)
  808. .padding(.vertical, 3)
  809. .background(
  810. Capsule(style: .continuous)
  811. .fill(Color.black.opacity(0.78))
  812. )
  813. .overlay(
  814. Capsule(style: .continuous)
  815. .stroke(Color.white.opacity(0.12), lineWidth: 1)
  816. )
  817. .shadow(color: .black.opacity(0.32), radius: 7, x: 0, y: 3)
  818. // Keep the title close to the icon.
  819. .offset(y: -22)
  820. .opacity(isHovering ? 1 : 0)
  821. .scaleEffect(isHovering ? 1 : 0.88, anchor: .bottom)
  822. .offset(y: isHovering ? 0 : 5)
  823. .animation(.spring(response: 0.28, dampingFraction: 0.82), value: isHovering)
  824. .allowsHitTesting(false)
  825. }
  826. .onHover { isHovering = $0 }
  827. .accessibilityLabel(action.title)
  828. }
  829. }
  830. private struct AppIconGlyph: View {
  831. let app: LauncherApp
  832. var body: some View {
  833. ZStack {
  834. RoundedRectangle(cornerRadius: 12, style: .continuous)
  835. .fill(Color.white.opacity(0.08))
  836. .frame(width: 48, height: 48)
  837. if let webURL = app.webURL {
  838. WebSiteFaviconView(
  839. webURL: webURL,
  840. size: 32,
  841. assetIconName: app.assetIconName,
  842. fallbackSymbolName: app.fallbackSymbolName,
  843. iconPaddingFactor: 0.08
  844. )
  845. } else if let icon = NSImage(named: app.assetIconName) {
  846. Image(nsImage: icon)
  847. .resizable()
  848. .scaledToFit()
  849. .frame(width: 32, height: 32)
  850. } else {
  851. Image(systemName: app.fallbackSymbolName)
  852. .font(.system(size: 20, weight: .semibold))
  853. .foregroundStyle(.white)
  854. }
  855. }
  856. }
  857. }
  858. // MARK: - Pinned app persistence helpers (panel mode)
  859. private func decodeUUIDSetGlobal(from dataString: String) -> Set<UUID> {
  860. guard !dataString.isEmpty, let data = dataString.data(using: .utf8) else { return [] }
  861. do {
  862. let strings = try JSONDecoder().decode([String].self, from: data)
  863. return Set(strings.compactMap(UUID.init(uuidString:)))
  864. } catch {
  865. return []
  866. }
  867. }
  868. private func encodeUUIDSetGlobal(_ set: Set<UUID>) -> String {
  869. do {
  870. let strings = set.map(\.uuidString).sorted()
  871. let data = try JSONEncoder().encode(strings)
  872. return String(decoding: data, as: UTF8.self)
  873. } catch {
  874. return ""
  875. }
  876. }
  877. private struct PromoBanner: View {
  878. let isPremiumUnlocked: Bool
  879. let activePremiumProductID: PremiumProductID?
  880. let onCloseTap: () -> Void
  881. let onUpgradeTap: () -> Void
  882. let onManageSubscriptionTap: () -> Void
  883. var body: some View {
  884. HStack(spacing: 10) {
  885. Button(action: onCloseTap) {
  886. Image(systemName: "xmark")
  887. .foregroundStyle(.blue.opacity(0.95))
  888. .font(.system(size: 14, weight: .semibold))
  889. }
  890. .buttonStyle(.plain)
  891. Text(isPremiumUnlocked ? "Premium is active. Manage your subscription any time." : "Update to Premium to unlock all features and remove ads")
  892. .font(.system(size: 13, weight: .medium))
  893. .foregroundStyle(.blue.opacity(0.95))
  894. .lineLimit(1)
  895. Spacer()
  896. HStack(spacing: 8) {
  897. if isPremiumUnlocked && activePremiumProductID == .yearly {
  898. Button(action: onUpgradeTap) {
  899. Text("Upgrade Plan")
  900. .font(.system(size: 13, weight: .semibold))
  901. .foregroundStyle(.white)
  902. .padding(.horizontal, 12)
  903. .padding(.vertical, 6)
  904. .background(
  905. Capsule(style: .continuous)
  906. .fill(Color(red: 1.0, green: 0.67, blue: 0.2).opacity(0.95))
  907. )
  908. }
  909. .buttonStyle(.plain)
  910. Button(action: onManageSubscriptionTap) {
  911. Text("Manage Subscription")
  912. .font(.system(size: 13, weight: .semibold))
  913. .foregroundStyle(.white)
  914. .padding(.horizontal, 12)
  915. .padding(.vertical, 6)
  916. .background(
  917. Capsule(style: .continuous)
  918. .fill(Color.blue.opacity(0.92))
  919. )
  920. }
  921. .buttonStyle(.plain)
  922. } else {
  923. Button(action: isPremiumUnlocked ? onManageSubscriptionTap : onUpgradeTap) {
  924. Text(isPremiumUnlocked ? "Manage Subscription" : "Upgrade")
  925. .font(.system(size: 13, weight: .semibold))
  926. .foregroundStyle(.white)
  927. .padding(.horizontal, 12)
  928. .padding(.vertical, 6)
  929. .background(
  930. Capsule(style: .continuous)
  931. .fill(Color.blue.opacity(0.92))
  932. )
  933. }
  934. .buttonStyle(.plain)
  935. }
  936. }
  937. }
  938. .padding(.horizontal, 11)
  939. .padding(.vertical, 8)
  940. .background(
  941. RoundedRectangle(cornerRadius: 12, style: .continuous)
  942. .fill(Color.white.opacity(0.03))
  943. )
  944. .overlay(
  945. RoundedRectangle(cornerRadius: 12, style: .continuous)
  946. .stroke(Color.white.opacity(0.05), lineWidth: 1)
  947. )
  948. }
  949. }
  950. private struct ManageSubscriptionSheet: View {
  951. @Environment(\.dismiss) private var dismiss
  952. let activePremiumProductID: PremiumProductID?
  953. let onUpgradeTap: () -> Void
  954. private let url = URL(string: "https://apps.apple.com/account/subscriptions")!
  955. var body: some View {
  956. VStack(spacing: 0) {
  957. HStack {
  958. Text("Manage Subscription")
  959. .font(.system(size: 15, weight: .semibold))
  960. .foregroundStyle(.white.opacity(0.92))
  961. Spacer()
  962. Button("Done") { dismiss() }
  963. .buttonStyle(.borderedProminent)
  964. .controlSize(.small)
  965. }
  966. .padding(12)
  967. .background(Color.black.opacity(0.25))
  968. if activePremiumProductID == .yearly {
  969. HStack(spacing: 10) {
  970. Text("You are on the yearly plan. Upgrade to Lifetime for permanent access.")
  971. .font(.system(size: 12.5, weight: .medium))
  972. .foregroundStyle(.white.opacity(0.9))
  973. Spacer()
  974. Button("Upgrade Package") {
  975. onUpgradeTap()
  976. }
  977. .buttonStyle(.borderedProminent)
  978. .controlSize(.small)
  979. }
  980. .padding(.horizontal, 12)
  981. .padding(.vertical, 10)
  982. .background(Color.blue.opacity(0.18))
  983. }
  984. SubscriptionWebView(url: url)
  985. }
  986. .background(Color.black.opacity(0.3))
  987. }
  988. }
  989. private struct SubscriptionWebView: NSViewRepresentable {
  990. let url: URL
  991. func makeNSView(context: Context) -> WKWebView {
  992. let webView = WKWebView()
  993. webView.allowsBackForwardNavigationGestures = true
  994. webView.load(URLRequest(url: url))
  995. return webView
  996. }
  997. func updateNSView(_ webView: WKWebView, context: Context) {}
  998. }
  999. private struct HideMenuIndicatorIfAvailable: ViewModifier {
  1000. func body(content: Content) -> some View {
  1001. if #available(macOS 13.0, *) {
  1002. content.menuIndicator(.hidden)
  1003. } else {
  1004. content
  1005. }
  1006. }
  1007. }
  1008. #Preview {
  1009. LauncherRootView()
  1010. .environmentObject(PremiumStore.shared)
  1011. }