LauncherRootView.swift 33 KB

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