LauncherRootView.swift 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565
  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 layoutMode: LayoutMode = .grid
  17. @AppStorage("customLauncherAppsData") private var customAppsData = ""
  18. @AppStorage("launcherAppOrderData") private var appOrderData = ""
  19. @AppStorage("pinnedTileIDsData") private var pinnedTileIDsData = ""
  20. private var apps: [LauncherApp] {
  21. LauncherApp.sampleApps + customApps + [LauncherApp.createNewTile]
  22. }
  23. private var columns: [GridItem] {
  24. [GridItem(.adaptive(minimum: 120, maximum: 145), spacing: 22)]
  25. }
  26. private var filteredApps: [LauncherApp] {
  27. let q = query.trimmingCharacters(in: .whitespacesAndNewlines)
  28. guard !q.isEmpty else { return orderedApps }
  29. return orderedApps.filter { $0.name.localizedCaseInsensitiveContains(q) }
  30. }
  31. private var orderedApps: [LauncherApp] {
  32. guard !apps.isEmpty else { return [] }
  33. let defaultIndexByID = Dictionary(
  34. uniqueKeysWithValues: apps.enumerated().map { ($0.element.id, $0.offset) }
  35. )
  36. let rankByID = Dictionary(
  37. uniqueKeysWithValues: appOrder.enumerated().map { ($0.element, $0.offset) }
  38. )
  39. let pinnedIDs = decodeUUIDSet(from: pinnedTileIDsData)
  40. return apps.sorted { lhs, rhs in
  41. if lhs.isCreateNew != rhs.isCreateNew {
  42. return rhs.isCreateNew
  43. }
  44. let leftPinned = pinnedIDs.contains(lhs.id)
  45. let rightPinned = pinnedIDs.contains(rhs.id)
  46. if leftPinned != rightPinned {
  47. return leftPinned
  48. }
  49. let leftRank = rankByID[lhs.id] ?? Int.max
  50. let rightRank = rankByID[rhs.id] ?? Int.max
  51. if leftRank != rightRank {
  52. return leftRank < rightRank
  53. }
  54. return (defaultIndexByID[lhs.id] ?? Int.max) < (defaultIndexByID[rhs.id] ?? Int.max)
  55. }
  56. }
  57. var body: some View {
  58. ZStack {
  59. VisualEffectBlur(material: .hudWindow, blendingMode: .behindWindow)
  60. .ignoresSafeArea()
  61. Color.black.opacity(0.22)
  62. .ignoresSafeArea()
  63. VStack(spacing: 16) {
  64. SearchHeader(
  65. query: $query,
  66. isPanelMode: layoutMode == .panel,
  67. onToggleLayout: {
  68. layoutMode = layoutMode == .grid ? .panel : .grid
  69. },
  70. onOpenPremium: {
  71. showingPremiumScreen = true
  72. },
  73. onOpenGoogle: {
  74. if let url = URL(string: "https://www.google.com") {
  75. InAppBrowserWindowManager.shared.open(url: url, title: "Google")
  76. }
  77. }
  78. )
  79. .padding(.top, 12)
  80. PromoBanner(
  81. onCloseTap: { showingPremiumScreen = true },
  82. onUpgradeTap: { showingPremiumScreen = true }
  83. )
  84. .padding(.bottom, 8)
  85. .zIndex(1)
  86. if filteredApps.isEmpty {
  87. VStack(spacing: 10) {
  88. Image(systemName: "magnifyingglass")
  89. .font(.system(size: 28, weight: .semibold))
  90. .foregroundStyle(.white.opacity(0.75))
  91. Text("No apps found")
  92. .font(.title3.weight(.semibold))
  93. .foregroundStyle(.white.opacity(0.9))
  94. Text("Try a different search term.")
  95. .font(.subheadline)
  96. .foregroundStyle(.white.opacity(0.7))
  97. }
  98. .frame(maxWidth: .infinity, maxHeight: .infinity)
  99. } else {
  100. if layoutMode == .panel {
  101. IconPanelView(apps: filteredApps) { app in
  102. handleAppTap(app)
  103. }
  104. } else {
  105. ScrollView {
  106. LazyVGrid(columns: columns, spacing: 20) {
  107. ForEach(filteredApps) { app in
  108. AppTileView(app: app) {
  109. handleAppTap(app)
  110. }
  111. .onDrag {
  112. draggedAppID = app.id
  113. return NSItemProvider(object: app.id.uuidString as NSString)
  114. }
  115. .onDrop(
  116. of: [UTType.text],
  117. delegate: TileReorderDropDelegate(
  118. destinationAppID: app.id,
  119. draggedAppID: $draggedAppID,
  120. onMove: moveApp
  121. )
  122. )
  123. }
  124. }
  125. .padding(.top, 10)
  126. .padding(.horizontal, 14)
  127. .padding(.bottom, 18)
  128. }
  129. }
  130. }
  131. }
  132. .padding(.horizontal, 10)
  133. .padding(.bottom, 8)
  134. }
  135. .sheet(item: $selectedApp) { app in
  136. AppDetailView(app: app)
  137. .frame(minWidth: 360, minHeight: 220)
  138. }
  139. .sheet(isPresented: $showingPremiumScreen) {
  140. PremiumFeaturesView()
  141. .frame(minWidth: 560, minHeight: 690)
  142. }
  143. .sheet(isPresented: $showingCreateAppSheet) {
  144. CreateAppSheetView { input in
  145. addCustomApp(name: input.name, url: input.url)
  146. }
  147. .frame(minWidth: 760, minHeight: 420)
  148. }
  149. .onAppear {
  150. loadCustomAppsFromStorage()
  151. loadAppOrderFromStorage()
  152. normalizeAppOrderAndPersist()
  153. }
  154. }
  155. private func handleAppTap(_ app: LauncherApp) {
  156. if app.isCreateNew {
  157. showingCreateAppSheet = true
  158. return
  159. }
  160. if let webURL = app.webURL {
  161. InAppBrowserWindowManager.shared.open(url: webURL, title: app.name)
  162. return
  163. }
  164. selectedApp = app
  165. }
  166. private func addCustomApp(name: String, url: URL) {
  167. let newApp = LauncherApp(
  168. name: name,
  169. assetIconName: "",
  170. fallbackSymbolName: "globe",
  171. description: "Custom app shortcut.",
  172. isCreateNew: false,
  173. webURL: url
  174. )
  175. customApps.append(newApp)
  176. saveCustomAppsToStorage()
  177. normalizeAppOrderAndPersist()
  178. }
  179. private func loadCustomAppsFromStorage() {
  180. guard !customAppsData.isEmpty else {
  181. customApps = []
  182. return
  183. }
  184. guard let data = customAppsData.data(using: .utf8) else {
  185. customApps = []
  186. return
  187. }
  188. do {
  189. let persisted = try JSONDecoder().decode([PersistedCustomApp].self, from: data)
  190. customApps = persisted.compactMap { item in
  191. guard let parsedURL = URL(string: item.urlString) else { return nil }
  192. return LauncherApp(
  193. id: item.id,
  194. name: item.name,
  195. assetIconName: "",
  196. fallbackSymbolName: "globe",
  197. description: "Custom app shortcut.",
  198. isCreateNew: false,
  199. webURL: parsedURL
  200. )
  201. }
  202. } catch {
  203. customApps = []
  204. }
  205. }
  206. private func saveCustomAppsToStorage() {
  207. let persisted = customApps.compactMap { app -> PersistedCustomApp? in
  208. guard !app.isCreateNew, let urlString = app.webURL?.absoluteString else { return nil }
  209. return PersistedCustomApp(id: app.id, name: app.name, urlString: urlString)
  210. }
  211. do {
  212. let encoded = try JSONEncoder().encode(persisted)
  213. customAppsData = String(decoding: encoded, as: UTF8.self)
  214. } catch {
  215. // Keep UI responsive even if persistence fails.
  216. }
  217. }
  218. private func moveApp(from sourceID: UUID, to destinationID: UUID) {
  219. guard sourceID != destinationID else { return }
  220. guard let sourceIndex = appOrder.firstIndex(of: sourceID),
  221. let destinationIndex = appOrder.firstIndex(of: destinationID) else {
  222. return
  223. }
  224. var updated = appOrder
  225. let movedID = updated.remove(at: sourceIndex)
  226. let insertionIndex = destinationIndex > sourceIndex ? destinationIndex - 1 : destinationIndex
  227. updated.insert(movedID, at: insertionIndex)
  228. appOrder = updated
  229. saveAppOrderToStorage()
  230. }
  231. private func loadAppOrderFromStorage() {
  232. guard !appOrderData.isEmpty else {
  233. appOrder = []
  234. return
  235. }
  236. guard let data = appOrderData.data(using: .utf8) else {
  237. appOrder = []
  238. return
  239. }
  240. do {
  241. let decoded = try JSONDecoder().decode([String].self, from: data)
  242. appOrder = decoded.compactMap { UUID(uuidString: $0) }
  243. } catch {
  244. appOrder = []
  245. }
  246. }
  247. private func normalizeAppOrderAndPersist() {
  248. let currentIDs = apps.map(\.id)
  249. var seen = Set<UUID>()
  250. var normalized: [UUID] = []
  251. for id in appOrder where currentIDs.contains(id) {
  252. if seen.insert(id).inserted {
  253. normalized.append(id)
  254. }
  255. }
  256. for id in currentIDs where seen.insert(id).inserted {
  257. normalized.append(id)
  258. }
  259. appOrder = normalized
  260. saveAppOrderToStorage()
  261. }
  262. private func saveAppOrderToStorage() {
  263. do {
  264. let payload = appOrder.map(\.uuidString)
  265. let encoded = try JSONEncoder().encode(payload)
  266. appOrderData = String(decoding: encoded, as: UTF8.self)
  267. } catch {
  268. // Ignore persistence failure and keep local order.
  269. }
  270. }
  271. private func decodeUUIDSet(from dataString: String) -> Set<UUID> {
  272. guard !dataString.isEmpty, let data = dataString.data(using: .utf8) else { return [] }
  273. do {
  274. let strings = try JSONDecoder().decode([String].self, from: data)
  275. return Set(strings.compactMap(UUID.init(uuidString:)))
  276. } catch {
  277. return []
  278. }
  279. }
  280. }
  281. private struct PersistedCustomApp: Codable {
  282. let id: UUID
  283. let name: String
  284. let urlString: String
  285. }
  286. private struct TileReorderDropDelegate: DropDelegate {
  287. let destinationAppID: UUID
  288. @Binding var draggedAppID: UUID?
  289. let onMove: (UUID, UUID) -> Void
  290. func dropEntered(info: DropInfo) {
  291. guard let draggedAppID else { return }
  292. guard draggedAppID != destinationAppID else { return }
  293. onMove(draggedAppID, destinationAppID)
  294. }
  295. func performDrop(info: DropInfo) -> Bool {
  296. draggedAppID = nil
  297. return true
  298. }
  299. }
  300. private struct SearchHeader: View {
  301. @Binding var query: String
  302. let isPanelMode: Bool
  303. let onToggleLayout: () -> Void
  304. let onOpenPremium: () -> Void
  305. let onOpenGoogle: () -> Void
  306. var body: some View {
  307. HStack {
  308. HStack(spacing: 10) {
  309. Image(systemName: "magnifyingglass")
  310. .foregroundStyle(.white.opacity(0.82))
  311. TextField("Search", text: $query)
  312. .textFieldStyle(.plain)
  313. .foregroundStyle(.white.opacity(0.94))
  314. }
  315. .padding(.horizontal, 12)
  316. .padding(.vertical, 8)
  317. .background(
  318. RoundedRectangle(cornerRadius: 15, style: .continuous)
  319. .fill(Color.black.opacity(0.28))
  320. )
  321. .overlay(
  322. RoundedRectangle(cornerRadius: 15, style: .continuous)
  323. .stroke(Color.white.opacity(0.08), lineWidth: 1)
  324. )
  325. Spacer(minLength: 8)
  326. HStack(spacing: 12) {
  327. Button(action: onToggleLayout) {
  328. Image(systemName: isPanelMode ? "square.grid.2x2" : "list.bullet")
  329. }
  330. .buttonStyle(.plain)
  331. Menu {
  332. Button("Clear Search") {
  333. query = ""
  334. }
  335. Button("Open Premium") {
  336. onOpenPremium()
  337. }
  338. Button("Open Google") {
  339. onOpenGoogle()
  340. }
  341. } label: {
  342. Image(systemName: "ellipsis")
  343. }
  344. .menuStyle(.borderlessButton)
  345. }
  346. .font(.system(size: 15, weight: .semibold))
  347. .foregroundStyle(.white.opacity(0.82))
  348. .padding(.trailing, 4)
  349. }
  350. }
  351. }
  352. private struct IconPanelView: View {
  353. let apps: [LauncherApp]
  354. let onAppTap: (LauncherApp) -> Void
  355. private let actions = [
  356. "house", "magnifyingglass", "target", "map", "heart", "person.2", "clock",
  357. "star", "exclamationmark.circle", "trash", "paperplane", "doc", "lock",
  358. ]
  359. var body: some View {
  360. ScrollView {
  361. HStack(alignment: .top, spacing: 20) {
  362. VStack(alignment: .leading, spacing: 12) {
  363. ForEach(Array(apps.prefix(10))) { app in
  364. Button(action: { onAppTap(app) }) {
  365. HStack(spacing: 14) {
  366. AppIconGlyph(app: app)
  367. Text(app.name)
  368. .font(.system(size: 18, weight: .medium))
  369. .foregroundStyle(.white.opacity(0.94))
  370. .lineLimit(1)
  371. .truncationMode(.tail)
  372. Spacer(minLength: 0)
  373. }
  374. .padding(.vertical, 3)
  375. }
  376. .buttonStyle(.plain)
  377. }
  378. Spacer(minLength: 0)
  379. }
  380. .frame(width: 320, alignment: .topLeading)
  381. VStack(alignment: .trailing, spacing: 14) {
  382. ForEach(Array(apps.prefix(10).enumerated()), id: \.offset) { index, _ in
  383. HStack(spacing: 12) {
  384. ForEach(0..<8, id: \.self) { col in
  385. let symbol = actions[(index * 3 + col) % actions.count]
  386. ActionBubble(symbol: symbol, highlighted: col == 0 && index % 3 == 0)
  387. }
  388. Image(systemName: "info.circle")
  389. .foregroundStyle(.white.opacity(0.6))
  390. .font(.system(size: 17))
  391. .frame(width: 20)
  392. }
  393. }
  394. }
  395. .frame(maxWidth: .infinity, alignment: .topTrailing)
  396. }
  397. .padding(.horizontal, 10)
  398. .padding(.bottom, 18)
  399. }
  400. }
  401. }
  402. private struct AppIconGlyph: View {
  403. let app: LauncherApp
  404. var body: some View {
  405. ZStack {
  406. RoundedRectangle(cornerRadius: 12, style: .continuous)
  407. .fill(Color.white.opacity(0.08))
  408. .frame(width: 48, height: 48)
  409. if let webURL = app.webURL {
  410. WebSiteFaviconView(
  411. webURL: webURL,
  412. size: 32,
  413. assetIconName: app.assetIconName,
  414. fallbackSymbolName: app.fallbackSymbolName,
  415. iconPaddingFactor: 0.08
  416. )
  417. } else if let icon = NSImage(named: app.assetIconName) {
  418. Image(nsImage: icon)
  419. .resizable()
  420. .scaledToFit()
  421. .frame(width: 32, height: 32)
  422. } else {
  423. Image(systemName: app.fallbackSymbolName)
  424. .font(.system(size: 20, weight: .semibold))
  425. .foregroundStyle(.white)
  426. }
  427. }
  428. }
  429. }
  430. private struct ActionBubble: View {
  431. let symbol: String
  432. let highlighted: Bool
  433. var body: some View {
  434. Circle()
  435. .fill(highlighted ? Color.cyan.opacity(0.22) : Color.white.opacity(0.08))
  436. .overlay(
  437. Image(systemName: symbol)
  438. .foregroundStyle(.white.opacity(0.9))
  439. .font(.system(size: 15, weight: .medium))
  440. )
  441. .frame(width: 42, height: 42)
  442. }
  443. }
  444. private struct PromoBanner: View {
  445. let onCloseTap: () -> Void
  446. let onUpgradeTap: () -> Void
  447. var body: some View {
  448. HStack(spacing: 10) {
  449. Button(action: onCloseTap) {
  450. Image(systemName: "xmark")
  451. .foregroundStyle(.blue.opacity(0.95))
  452. .font(.system(size: 14, weight: .semibold))
  453. }
  454. .buttonStyle(.plain)
  455. Text("Update to Premium to unlock all features and remove ads")
  456. .font(.system(size: 13, weight: .medium))
  457. .foregroundStyle(.blue.opacity(0.95))
  458. .lineLimit(1)
  459. Spacer()
  460. Button(action: onUpgradeTap) {
  461. Text("Upgrade")
  462. .font(.system(size: 13, weight: .semibold))
  463. .foregroundStyle(.white)
  464. .padding(.horizontal, 12)
  465. .padding(.vertical, 6)
  466. .background(
  467. Capsule(style: .continuous)
  468. .fill(Color.blue.opacity(0.92))
  469. )
  470. }
  471. .buttonStyle(.plain)
  472. }
  473. .padding(.horizontal, 11)
  474. .padding(.vertical, 8)
  475. .background(
  476. RoundedRectangle(cornerRadius: 12, style: .continuous)
  477. .fill(Color.white.opacity(0.03))
  478. )
  479. .overlay(
  480. RoundedRectangle(cornerRadius: 12, style: .continuous)
  481. .stroke(Color.white.opacity(0.05), lineWidth: 1)
  482. )
  483. }
  484. }
  485. #Preview {
  486. LauncherRootView()
  487. }
  488. private struct VisualEffectBlur: NSViewRepresentable {
  489. let material: NSVisualEffectView.Material
  490. let blendingMode: NSVisualEffectView.BlendingMode
  491. func makeNSView(context: Context) -> NSVisualEffectView {
  492. let view = NSVisualEffectView()
  493. view.state = .active
  494. view.material = material
  495. view.blendingMode = blendingMode
  496. return view
  497. }
  498. func updateNSView(_ nsView: NSVisualEffectView, context: Context) {
  499. nsView.material = material
  500. nsView.blendingMode = blendingMode
  501. }
  502. }