LauncherRootView.swift 29 KB

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