Skip to content
C.W.K.
Lesson 04 of 05 · published

Account Shortcuts and the Identity Menu

~13 min · identities, appkit, ux, data-removal

Level 0New Tab
0 XP0/44 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

Making an identity

Identities live in Settings, in a section of their own. Each row has a colour picker, the name typed in place, a "Default" mark or a "Make Default" link, and a remove button. Under the rows is a field whose placeholder reads "New identity (Work, Family, a second account…)" and an Add button. A new identity takes its colour from a palette of seven, picked by how many identities already exist, and Dad can change it afterwards. The footnote says what an identity is for: "Each identity keeps its own cookies and sign-ins."

Renaming and recolouring touch only the book. The id never changes, so the store never changes, and every sign-in survives a rename.

The identity menu

In every normal window the toolbar has a person button tinted with the colour of the front tab's identity. It opens one menu:

  • "Open This Site as …", one item per identity, when the front tab shows an http or https page.
  • "New Tab as …", one item per identity, each with a round swatch of its colour and a check beside the current one.
  • "Save Account Shortcut…", for the front page in its identity.
  • "Manage Accounts and Identities…", which opens Settings.

None of these changes the identity of a live tab. Every item in the two lists opens a new tab; "Save Account Shortcut…" opens a sheet that asks for the shortcut's title, and the last item opens Settings. That is invariant 7's "a tab has exactly one" made visible: a tab is born in its identity and dies in it.

Account shortcuts on Home

The native Home has a section called "Account shortcuts". A shortcut is three things, a title, a URL and an existing identity id, stored in workspace.json. Its type's comment calls it "A user-owned launch preference, not a credential or a family registry."

WorkspaceModel.save refuses a shortcut with an empty title, one for the private identity, one whose scheme is not http or https, one with no host, and one whose URL carries a user name or password. Saving the same URL in the same identity again only retitles it. Opening one is just as strict: never in a private window, never for an identity that no longer exists, and always as a new tab in the shortcut's own identity. The UX review states the intent: a shortcut "cannot silently substitute a missing account, change a live tab's identity, copy cookies, or use a private identity." A shortcut whose identity is gone shows "Account unavailable" and does nothing.

The test shortcutsKeepIndependentStoresAndDoNotGuessMissingAccounts saves two shortcuts for the same https://same.example/ in two identities, reloads the file, opens both, and expects two tabs in two identities. It also checks the refusals: an unknown identity, the private one, a URL with a password in it, and a javascript: address. Home's tab search matches on identity names too, so typing "second" finds the tabs of an identity called Second.

One more sentence from the review: "User-entered names do not establish a subscription tier." A shortcut titled for a paid plan is a label Dad typed. Nothing in Voyager reads plans from labels.

The mark on the tab

Invariant 7 ends with "The identity is always visible on the tab." The code is more careful. The tab strip draws a 3-point bar down a tab's leading edge, in its identity's colour, only when Dad has more than one identity and the window is not private. The comment on that field gives the reason: the colour is drawn only when "the mark therefore tells him something." With a single identity every tab would wear the same grey bar and say nothing. The toolbar's person button, though, is there in every normal window, even with one identity. The doc says always; the code says when it informs. Here the code is right.

Removing an identity

Removal sits behind a confirmation that does not soften it: "Every cookie and sign-in held by this identity is deleted. Tabs of this identity must be closed first." removeIdentity then refuses three cases before it touches anything: an identity already being removed, the last one left (counting those mid-removal as gone), and one with a tab open in any window.

The order after that is the lesson. The store goes first. If WebKit cannot remove it, the entry is kept and the reason is shown. If a tab of that identity opened while WebKit was working, the entry is kept too. Only then does the entry leave the book, and if the removed identity was the default, the default moves to the first identity left. Any other default stays where it was. Why that way round? An entry whose store is gone is harmless: its id would simply mint an empty store. A store whose entry is gone has lost its only name (lesson 1), and nothing in Voyager could find it again.

Afterwards, nothing guesses. A saved session tab whose identity is gone reopens in the default identity without its page state, and a shortcut to it shows "Account unavailable".

Clearing one site is a smaller tool beside it. The site information popover clears the data WebKit files under that site in the tab's identity only, and the same site in another identity keeps its sign-in.

Principle: When you delete a resource that has a name and a body, remove the body first and the name last. A name with no body is recoverable; a body with no name is lost.

Code

cwkVoyager, Sources/VoyagerCore/BrowserWindowModel.swift: removing an identity, store first and entry last (verbatim, lines 154-177)·swift
    /// Removes an identity and its whole store (every cookie and sign-in).
    /// Refused for the last identity and for one with a tab open; the
    /// default moves to the first identity left. Returns why, when refused.
    public func removeIdentity(_ id: IdentityID) async -> String? {
        guard let identity = identities.identity(id) else { return nil }
        guard !removingIdentities.contains(id) else { return "This account is already being removed." }
        guard identities.identities.filter({ !removingIdentities.contains($0.id) }).count > 1 else { return "The last identity cannot be removed." }
        guard !identityInUse(id) else { return "Close every tab of “\(identity.name)” first." }
        removingIdentities.insert(id)
        defer { removingIdentities.remove(id) }
        do {
            try await engine.removeData(of: id)
        } catch {
            return "“\(identity.name)” was kept because its site data could not be removed: \(error.localizedDescription)"
        }
        // Keep the entry if a new tab opened while WebKit was removing data.
        // Its storage operation has completed, but a live tab must retain its identity.
        guard !identityInUse(id) else { return "Site data was cleared, but a tab reopened “\(identity.name)”. Close it before removing the account." }
        identities.identities.removeAll { $0.id == id }
        if identities.defaultIdentity == id, let first = identities.identities.first { identities.defaultIdentity = first.id }
        saveIdentities()
        if let identityProblem { return "Site data was cleared, but the account list could not be saved: \(identityProblem)" }
        return nil
    }
cwkVoyager, Sources/VoyagerCore/Workspace.swift: what an account shortcut refuses to hold (verbatim, lines 65-77)·swift
    @discardableResult public func save(title: String, url: URL, identity: IdentityID) -> Bool {
        let name = title.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !name.isEmpty, identity != .privateSession,
              ["https", "http"].contains(url.scheme?.lowercased() ?? ""), url.host != nil,
              url.user == nil, url.password == nil else { return false }
        if let index = book.shortcuts.firstIndex(where: { $0.url == url && $0.identity == identity }) {
            book.shortcuts[index].title = name
        } else {
            book.shortcuts.append(AccountShortcut(title: name, url: url, identity: identity))
        }
        persist()
        return true
    }
cwkVoyager, Sources/VoyagerApp/BrowserWindowController.swift: the tab's identity mark, drawn only when it tells something (verbatim, lines 323-331)·swift
        let marksIdentity = services.identities.identities.count > 1 && !model.isPrivate
        tabStrip.update(
            items: model.tabs.map { tab in
                TabStripItem(
                    id: tab.id.raw, title: tab.displayTitle, isLoading: tab.status.isLoading, isAsleep: tab.isAsleep,
                    isPinned: tab.isPinned, favicon: icon(of: tab), faviconTone: favicons.tone(for: tab.iconAddress),
                    identityColor: marksIdentity ? services.identities.identity(tab.identity).flatMap { NSColor(hex: $0.color) } : nil,
                    audio: tab.isMuted ? .muted : (tab.status.isAudible ? .playing : .silent))
            },

External links

Exercise

Pick something in your own software that has a name in one place and a body in another: a user profile with a storage folder, a workspace with a database file, an account with a keychain item. Write its removal as one function that refuses the cases that must not proceed (the last one, one in use, one already being removed), deletes the body first, re-checks that nothing started using it meanwhile, and only then removes the name. Write a test for each refusal and one that injects a failure into the body's deletion and proves the name survived.
Hint
The re-check after an await is the line most removal code forgets. While the slow deletion ran, the user could have opened the thing again; Voyager asks the windows a second time for exactly that reason. Return the reason as text when you refuse, so the screen can say it.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.