LauncherRootView.swift 45 KB

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