Skip to content

Commit 4eae087

Browse files
authored
Persist root page across launcher presentations with 30s expiry (#5)
* Persist root page across launcher presentations with 30s expiry prepareForPresentation() zeroed currentPage on every open, so reopening seconds after paging to page 2 jumped back to page 1. Persist the last-viewed root-level page in the com.arichyx.Lunchpad defaults suite with a 30-second expiry and restore it on the next show, clamping to the current root page count. - RootPageStore: injectable UserDefaults + clock, two scalar keys (rootPageIndex, rootPageSavedAt), 30s expiry, restoredPage clamps and treats missing/expired/future-dated/negative data as page 0. - RootPageSelection: pure "which page to save" logic (folder-open returns the pre-folder root page; search-active returns 0 to match the existing search-resets-to-page-zero rule). - Restore clamps against rootPageCount (from allItems), not the filtered pageCount, so a just-closed single-page folder cannot shrink the root. - Save on close(), restore on show(); folder and search result pages stay transient. Tests: RootPageStore (within/after expiry, clamp, missing page/timestamp, negative, future-dated, zero count) and RootPageSelection branches. swift test 61/61, git diff --check clean. OpenSpec change: openspec/changes/persist-root-page * Archive persist-root-page and sync launcher-interface spec Move the landed change to openspec/changes/archive/2026-07-17-persist-root-page/ and apply its ADDED 'Root page persistence across presentations' requirement to the main launcher-interface spec. Two manual smoke-tests (3.1, 3.3) remain unchecked in the archived tasks.
1 parent d24979d commit 4eae087

11 files changed

Lines changed: 591 additions & 7 deletions

File tree

Sources/Lunchpad/AppDelegate.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
6565
return count
6666
}
6767
print("扫描完成:\(appCount) 个应用,\(folderCount) 个文件夹")
68-
window = LunchpadWindow(items: presentedItems(from: items), localizer: localizer)
68+
window = LunchpadWindow(
69+
items: presentedItems(from: items),
70+
localizer: localizer,
71+
rootPageStore: RootPageStore()
72+
)
6973

7074
installStatusItem()
7175
installApplicationMenu()

Sources/Lunchpad/IconGridView.swift

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -317,20 +317,42 @@ final class IconGridView: NSView {
317317
max(1, Int(ceil(Double(filteredItems.count) / Double(Layout.pageCapacity))))
318318
}
319319

320+
/// Page count of the root level, independent of the current folder or search view.
321+
///
322+
/// `pageCount` is derived from `filteredItems`, which still reflects a just-closed folder or search
323+
/// at `show()` time. Restore must clamp against the root count (from `allItems`) instead, so a
324+
/// single-page folder cannot shrink the restored multi-page root page.
325+
var rootPageCount: Int {
326+
max(1, Int(ceil(Double(allItems.count) / Double(Layout.pageCapacity))))
327+
}
328+
329+
/// The root-level page to persist when the launcher is hidden.
330+
var rootPageForPersistence: Int {
331+
let searchActive = !searchField.stringValue
332+
.trimmingCharacters(in: .whitespacesAndNewlines)
333+
.isEmpty
334+
return RootPageSelection.rootPageToSave(
335+
folderOpen: currentFolder != nil,
336+
searchActive: searchActive,
337+
currentPage: currentPage,
338+
rootPageBeforeEnteringFolder: rootPageBeforeEnteringFolder
339+
)
340+
}
341+
320342
private var itemsOnCurrentPage: ArraySlice<LunchpadItem> {
321343
let start = min(currentPage * Layout.pageCapacity, filteredItems.count)
322344
let end = min(start + Layout.pageCapacity, filteredItems.count)
323345
return filteredItems[start..<end]
324346
}
325347

326-
func prepareForPresentation() {
348+
func prepareForPresentation(restoredRootPage: Int) {
327349
currentFolder = nil
328350
rootPageBeforeEnteringFolder = 0
329351
searchField.stringValue = ""
330352
searchField.isHidden = false
331353
folderTitleLabel.isHidden = true
332354
filteredItems = allItems
333-
currentPage = 0
355+
currentPage = max(0, restoredRootPage)
334356
reloadPage(animated: false)
335357
}
336358

Sources/Lunchpad/LunchpadWindow.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,12 +116,14 @@ final class LunchpadWindow: NSWindow {
116116
private let menuBarGradientView = MenuBarGradientView()
117117
private let menuBarDockCornerWindow = MenuBarDockCornerWindow()
118118
private let gridView: IconGridView
119+
private let rootPageStore: RootPageStore
119120
private var isAnimatingClose = false
120121
private var presentationGeneration = 0
121122
private var menuBarGradientHeightConstraint: NSLayoutConstraint!
122123

123-
init(items: [LunchpadItem], localizer: AppLocalizer) {
124+
init(items: [LunchpadItem], localizer: AppLocalizer, rootPageStore: RootPageStore) {
124125
gridView = IconGridView(items: items, localizer: localizer)
126+
self.rootPageStore = rootPageStore
125127
super.init(
126128
contentRect: NSScreen.main?.frame ?? NSRect(x: 0, y: 0, width: 1440, height: 900),
127129
styleMask: .borderless,
@@ -201,7 +203,10 @@ final class LunchpadWindow: NSWindow {
201203
menuBarGradientHeightConstraint.constant
202204
gridView.updateScreenInsets(insets, availableHeight: contentFrame.height)
203205
}
204-
gridView.prepareForPresentation()
206+
let restoredRootPage = rootPageStore.restoredPage(
207+
availablePageCount: gridView.rootPageCount
208+
)
209+
gridView.prepareForPresentation(restoredRootPage: restoredRootPage)
205210
isAnimatingClose = false
206211
presentationGeneration &+= 1
207212
let generation = presentationGeneration
@@ -272,6 +277,7 @@ final class LunchpadWindow: NSWindow {
272277

273278
override func close() {
274279
guard isVisible, !isAnimatingClose else { return }
280+
rootPageStore.save(page: gridView.rootPageForPersistence)
275281
isAnimatingClose = true
276282
presentationGeneration &+= 1
277283

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import Foundation
2+
3+
/// Pure selection of which root-level page to persist when the launcher is hidden.
4+
///
5+
/// Extracted from `IconGridView` so the branching is unit-testable without AppKit. The grid feeds its
6+
/// private state into this function; it owns no state of its own.
7+
enum RootPageSelection {
8+
/// Returns the root-level page to persist.
9+
///
10+
/// - A folder is open: the root page the user was on before entering the folder, so closing inside
11+
/// a folder restores that root page rather than the folder's internal page.
12+
/// - A search query is active: `0`, matching the existing launcher-interface rule that entering a
13+
/// query resets to the first page. Persisting the search-results page would jump the user to the
14+
/// wrong root page on reopen.
15+
/// - Otherwise: the current root page.
16+
static func rootPageToSave(
17+
folderOpen: Bool,
18+
searchActive: Bool,
19+
currentPage: Int,
20+
rootPageBeforeEnteringFolder: Int
21+
) -> Int {
22+
if folderOpen { return max(0, rootPageBeforeEnteringFolder) }
23+
if searchActive { return 0 }
24+
return max(0, currentPage)
25+
}
26+
}
27+
28+
/// Persists the last-viewed root-level page index and the time it was saved, with a short expiry.
29+
///
30+
/// Backed by the `com.arichyx.Lunchpad` defaults suite (the same plist as the other preferences).
31+
/// Two plain scalar keys are used: the page index as an `Int` and the save time as a `Date`.
32+
/// `UserDefaults` and the clock are injectable so tests can drive expiry without real time.
33+
@MainActor
34+
final class RootPageStore {
35+
/// Restoring a page saved more than this long ago is treated as "no saved page".
36+
static let expiry: TimeInterval = 30
37+
38+
private enum Key {
39+
static let page = "rootPageIndex"
40+
static let savedAt = "rootPageSavedAt"
41+
}
42+
43+
private let defaults: UserDefaults
44+
private let clock: () -> Date
45+
46+
init(defaults: UserDefaults? = nil, clock: @escaping () -> Date = { Date() }) {
47+
self.defaults = defaults
48+
?? UserDefaults(suiteName: LunchpadPreferences.domain)
49+
?? .standard
50+
self.clock = clock
51+
}
52+
53+
/// Writes the given root page and the current clock time.
54+
func save(page: Int) {
55+
defaults.set(max(0, page), forKey: Key.page)
56+
defaults.set(clock(), forKey: Key.savedAt)
57+
}
58+
59+
/// Returns the saved root page clamped to the available page count, or `0` when no fresh, valid
60+
/// saved page exists (missing timestamp, expired, future-dated, or negative page index).
61+
///
62+
/// The caller passes the **root** page count (`rootPageCount` from `allItems`), never the
63+
/// filtered `pageCount`, so a just-closed single-page folder cannot shrink the restored root page.
64+
func restoredPage(availablePageCount: Int) -> Int {
65+
guard let savedAt = defaults.object(forKey: Key.savedAt) as? Date else {
66+
return 0
67+
}
68+
let now = clock()
69+
guard savedAt <= now, now.timeIntervalSince(savedAt) <= Self.expiry else {
70+
return 0
71+
}
72+
let savedPage = defaults.integer(forKey: Key.page)
73+
guard savedPage >= 0 else { return 0 }
74+
let lastValidPage = max(0, availablePageCount - 1)
75+
return min(savedPage, lastValidPage)
76+
}
77+
}
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import XCTest
2+
@testable import Lunchpad
3+
4+
@MainActor
5+
final class RootPageStoreTests: XCTestCase {
6+
private var suiteName: String!
7+
private var defaults: UserDefaults!
8+
private var now: Date!
9+
10+
override func setUp() {
11+
super.setUp()
12+
suiteName = "LunchpadTests.\(UUID().uuidString)"
13+
defaults = UserDefaults(suiteName: suiteName)
14+
defaults.removePersistentDomain(forName: suiteName)
15+
now = Date(timeIntervalSince1970: 1_700_000_000)
16+
}
17+
18+
override func tearDown() {
19+
defaults.removePersistentDomain(forName: suiteName)
20+
defaults = nil
21+
suiteName = nil
22+
now = nil
23+
super.tearDown()
24+
}
25+
26+
private func makeStore() -> RootPageStore {
27+
RootPageStore(defaults: defaults, clock: { self.now })
28+
}
29+
30+
func testRestoreWithinExpiryReturnsSavedPage() {
31+
let store = makeStore()
32+
store.save(page: 2)
33+
now = now.addingTimeInterval(10) // 10s < 30s
34+
XCTAssertEqual(store.restoredPage(availablePageCount: 5), 2)
35+
}
36+
37+
func testRestoreAfterExpiryReturnsZero() {
38+
let store = makeStore()
39+
store.save(page: 2)
40+
now = now.addingTimeInterval(31) // > 30s
41+
XCTAssertEqual(store.restoredPage(availablePageCount: 5), 0)
42+
}
43+
44+
func testClampWhenSavedPageExceedsPageCount() {
45+
let store = makeStore()
46+
store.save(page: 4) // fresh (no time advance)
47+
XCTAssertEqual(store.restoredPage(availablePageCount: 2), 1) // min(4, 2-1) = 1
48+
}
49+
50+
func testMissingSavedPageReturnsZero() {
51+
let store = makeStore()
52+
// Nothing saved.
53+
XCTAssertEqual(store.restoredPage(availablePageCount: 5), 0)
54+
}
55+
56+
func testMissingTimestampReturnsZero() {
57+
let store = makeStore()
58+
store.save(page: 2)
59+
defaults.removeObject(forKey: "rootPageSavedAt")
60+
// A page of unknown age is never restored.
61+
XCTAssertEqual(store.restoredPage(availablePageCount: 5), 0)
62+
}
63+
64+
func testNegativeSavedPageReturnsZero() {
65+
let store = makeStore()
66+
store.save(page: 2)
67+
defaults.set(-3, forKey: "rootPageIndex") // simulate external tampering
68+
XCTAssertEqual(store.restoredPage(availablePageCount: 5), 0)
69+
}
70+
71+
func testFutureDatedSaveTimeReturnsZero() {
72+
let store = makeStore()
73+
store.save(page: 2)
74+
now = now.addingTimeInterval(-100) // clock moved back; savedAt is now in the future
75+
XCTAssertEqual(store.restoredPage(availablePageCount: 5), 0)
76+
}
77+
78+
func testZeroPageCountReturnsZero() {
79+
let store = makeStore()
80+
store.save(page: 2) // fresh
81+
XCTAssertEqual(store.restoredPage(availablePageCount: 0), 0)
82+
}
83+
84+
func testSavedPageZeroIsRestored() {
85+
let store = makeStore()
86+
store.save(page: 0)
87+
XCTAssertEqual(store.restoredPage(availablePageCount: 5), 0)
88+
// The timestamp must still be present and fresh for this to be a real save, not a missing one.
89+
XCTAssertNotNil(defaults.object(forKey: "rootPageSavedAt"))
90+
}
91+
}
92+
93+
final class RootPageSelectionTests: XCTestCase {
94+
func testFolderOpenReturnsRootPageBeforeEnteringFolder() {
95+
XCTAssertEqual(
96+
RootPageSelection.rootPageToSave(
97+
folderOpen: true,
98+
searchActive: false,
99+
currentPage: 1,
100+
rootPageBeforeEnteringFolder: 3
101+
),
102+
3
103+
)
104+
}
105+
106+
func testSearchActiveReturnsZeroRegardlessOfCurrentPage() {
107+
XCTAssertEqual(
108+
RootPageSelection.rootPageToSave(
109+
folderOpen: false,
110+
searchActive: true,
111+
currentPage: 2,
112+
rootPageBeforeEnteringFolder: 0
113+
),
114+
0
115+
)
116+
}
117+
118+
func testDefaultReturnsCurrentPage() {
119+
XCTAssertEqual(
120+
RootPageSelection.rootPageToSave(
121+
folderOpen: false,
122+
searchActive: false,
123+
currentPage: 4,
124+
rootPageBeforeEnteringFolder: 0
125+
),
126+
4
127+
)
128+
}
129+
130+
func testFolderOpenTakesPrecedenceOverSearch() {
131+
// Inside a folder the search field is cleared, so this state is unreachable in practice;
132+
// the function still picks the folder branch deterministically.
133+
XCTAssertEqual(
134+
RootPageSelection.rootPageToSave(
135+
folderOpen: true,
136+
searchActive: true,
137+
currentPage: 1,
138+
rootPageBeforeEnteringFolder: 3
139+
),
140+
3
141+
)
142+
}
143+
144+
func testNegativeInputsAreClampedToZero() {
145+
XCTAssertEqual(
146+
RootPageSelection.rootPageToSave(
147+
folderOpen: false,
148+
searchActive: false,
149+
currentPage: -1,
150+
rootPageBeforeEnteringFolder: -2
151+
),
152+
0
153+
)
154+
}
155+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-07-17

0 commit comments

Comments
 (0)