fix(outlinekit): correct preferences wire keys to the real server shape

All preference toggles showed as off regardless of server state, and
saving notificationBadge 400'd with "notificationBadge: Invalid Input" —
the guessed wire keys/values from the earlier speculative commit were
wrong. Fixed against a live network capture of Outline's own web app
toggling every one of these settings:

- separateEditing -> seamlessEdit, and inverted (seamlessEdit is
  separate editing's negation, confirmed by toggling it live)
- showCommentMarker -> commentsInGutter
- smartText -> enableSmartText
- notificationBadge values -> "disabled"/"indicator"/"count", not the
  guessed "none"/"unread"/"all"
- rememberLastPath/useCursorPointer/codeBlockLineNumbers were already
  correct

Also preserves fullWidthDocuments (a real preference this app has no UI
for) on round-trip, since the client always sends the whole preferences
object back on save — dropping an unrecognized key during decode would
otherwise silently clear it the next time any toggle here gets saved.
This commit is contained in:
2026-08-17 21:35:17 +01:00
parent 7e34d58a81
commit 564cce0637
3 changed files with 118 additions and 15 deletions
@@ -1,15 +1,28 @@
import Foundation import Foundation
/// `User.preferences` a free-form JSON blob on Outline's own `User` row, /// `User.preferences` a free-form JSON blob on Outline's own `User` row,
/// not a fixed-shape API resource. Field names here are best-effort against /// not a fixed-shape API resource. Wire key names below are confirmed
/// Outline's known preference keys, same "expect a correction round once /// against a live server's own web app traffic (captured toggling every
/// tested" treatment as `OutlineDocumentMember`/`OutlinePin`: an unknown key /// Preferences setting one at a time), not guessed see the `CodingKeys`
/// doesn't break the request (the column round-trips whatever JSON it's /// mapping for the two that don't match this struct's own property names.
/// given), but a wrong key silently fails to affect Outline's own behavior ///
/// for that toggle server-side even though it round-trips fine within this /// The server also validates `preferences` against a known key allowlist
/// app alone. /// and rejects the whole `users.update` call (not just the bad field) if
/// any key it doesn't recognize is present confirmed live via a
/// `"notificationBadge: Invalid Input"` error when an earlier, wrong value
/// was sent. That's also why `fullWidthDocuments` is kept here even though
/// this app has no UI for it yet: this app always sends the *whole*
/// preferences object back on every save (see
/// `UpdateUserPreferencesRequest`), so silently dropping an unknown key
/// during decode would permanently clear it the next time any other
/// preference here gets saved.
public struct OutlineUserPreferences: Codable, Hashable, Sendable { public struct OutlineUserPreferences: Codable, Hashable, Sendable {
public var rememberLastPath: Bool? public var rememberLastPath: Bool?
/// App-facing polarity: `true` means "separate editing mode is on"
/// the opposite of the wire's own `seamlessEdit` (seamless editing and
/// separate editing modes are each other's negation), inverted in
/// `init(from:)`/`encode(to:)` so nothing outside this file has to
/// remember that.
public var separateEditing: Bool? public var separateEditing: Bool?
public var useCursorPointer: Bool? public var useCursorPointer: Bool?
public var codeBlockLineNumbers: Bool? public var codeBlockLineNumbers: Bool?
@@ -17,6 +30,9 @@ public struct OutlineUserPreferences: Codable, Hashable, Sendable {
public var smartText: Bool? public var smartText: Bool?
/// One of `NotificationBadgeStyle`'s raw values. /// One of `NotificationBadgeStyle`'s raw values.
public var notificationBadge: String? public var notificationBadge: String?
/// No UI in this app yet preserved purely so saving any other
/// preference here doesn't clobber it. See the type doc comment.
public var fullWidthDocuments: Bool?
public init( public init(
rememberLastPath: Bool? = nil, rememberLastPath: Bool? = nil,
@@ -25,7 +41,8 @@ public struct OutlineUserPreferences: Codable, Hashable, Sendable {
codeBlockLineNumbers: Bool? = nil, codeBlockLineNumbers: Bool? = nil,
showCommentMarker: Bool? = nil, showCommentMarker: Bool? = nil,
smartText: Bool? = nil, smartText: Bool? = nil,
notificationBadge: String? = nil notificationBadge: String? = nil,
fullWidthDocuments: Bool? = nil
) { ) {
self.rememberLastPath = rememberLastPath self.rememberLastPath = rememberLastPath
self.separateEditing = separateEditing self.separateEditing = separateEditing
@@ -34,15 +51,51 @@ public struct OutlineUserPreferences: Codable, Hashable, Sendable {
self.showCommentMarker = showCommentMarker self.showCommentMarker = showCommentMarker
self.smartText = smartText self.smartText = smartText
self.notificationBadge = notificationBadge self.notificationBadge = notificationBadge
self.fullWidthDocuments = fullWidthDocuments
}
private enum CodingKeys: String, CodingKey {
case rememberLastPath
case seamlessEdit
case useCursorPointer
case codeBlockLineNumbers
case commentsInGutter
case enableSmartText
case notificationBadge
case fullWidthDocuments
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
rememberLastPath = try container.decodeIfPresent(Bool.self, forKey: .rememberLastPath)
separateEditing = try container.decodeIfPresent(Bool.self, forKey: .seamlessEdit).map { !$0 }
useCursorPointer = try container.decodeIfPresent(Bool.self, forKey: .useCursorPointer)
codeBlockLineNumbers = try container.decodeIfPresent(Bool.self, forKey: .codeBlockLineNumbers)
showCommentMarker = try container.decodeIfPresent(Bool.self, forKey: .commentsInGutter)
smartText = try container.decodeIfPresent(Bool.self, forKey: .enableSmartText)
notificationBadge = try container.decodeIfPresent(String.self, forKey: .notificationBadge)
fullWidthDocuments = try container.decodeIfPresent(Bool.self, forKey: .fullWidthDocuments)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(rememberLastPath, forKey: .rememberLastPath)
try container.encodeIfPresent(separateEditing.map { !$0 }, forKey: .seamlessEdit)
try container.encodeIfPresent(useCursorPointer, forKey: .useCursorPointer)
try container.encodeIfPresent(codeBlockLineNumbers, forKey: .codeBlockLineNumbers)
try container.encodeIfPresent(showCommentMarker, forKey: .commentsInGutter)
try container.encodeIfPresent(smartText, forKey: .enableSmartText)
try container.encodeIfPresent(notificationBadge, forKey: .notificationBadge)
try container.encodeIfPresent(fullWidthDocuments, forKey: .fullWidthDocuments)
} }
} }
/// App-icon unread indicator style. Values are a guess at Outline's own /// App-icon unread indicator style. Wire values confirmed live (captured
/// wire strings, not confirmed against a live server. /// setting all three from Outline's own web app).
public enum NotificationBadgeStyle: String, CaseIterable, Identifiable, Sendable { public enum NotificationBadgeStyle: String, CaseIterable, Identifiable, Sendable {
case none case none = "disabled"
case unreadIndicator = "unread" case unreadIndicator = "indicator"
case all case unreadCount = "count"
public var id: String { rawValue } public var id: String { rawValue }
@@ -50,7 +103,7 @@ public enum NotificationBadgeStyle: String, CaseIterable, Identifiable, Sendable
switch self { switch self {
case .none: return "None" case .none: return "None"
case .unreadIndicator: return "Unread Indicator" case .unreadIndicator: return "Unread Indicator"
case .all: return "Unread Count" case .unreadCount: return "Unread Count"
} }
} }
} }
@@ -1128,6 +1128,56 @@ final class LiveOutlineAPIClientTests: XCTestCase {
XCTAssertEqual(sentPreferences?["useCursorPointer"] as? Bool, true) XCTAssertEqual(sentPreferences?["useCursorPointer"] as? Bool, true)
} }
/// Locks in the wire mapping confirmed against a live server's own web
/// app traffic: `seamlessEdit`/`commentsInGutter`/`enableSmartText` are
/// the real keys (not `separateEditing`/`showCommentMarker`/`smartText`
/// this struct exposes), and `seamlessEdit` is the *negation* of this
/// app's `separateEditing`.
func testOutlineUserPreferencesDecodesRealWireKeys() throws {
let json = """
{
"seamlessEdit": false,
"commentsInGutter": true,
"enableSmartText": true,
"rememberLastPath": true,
"useCursorPointer": true,
"codeBlockLineNumbers": false,
"notificationBadge": "indicator",
"fullWidthDocuments": true
}
""".data(using: .utf8)!
let preferences = try JSONDecoder().decode(OutlineUserPreferences.self, from: json)
XCTAssertEqual(preferences.separateEditing, true, "seamlessEdit: false means separate editing is ON")
XCTAssertEqual(preferences.showCommentMarker, true)
XCTAssertEqual(preferences.smartText, true)
XCTAssertEqual(preferences.rememberLastPath, true)
XCTAssertEqual(preferences.useCursorPointer, true)
XCTAssertEqual(preferences.codeBlockLineNumbers, false)
XCTAssertEqual(preferences.notificationBadge, "indicator")
XCTAssertEqual(preferences.fullWidthDocuments, true)
}
func testOutlineUserPreferencesEncodesRealWireKeysAndInvertsSeparateEditing() throws {
var preferences = OutlineUserPreferences()
preferences.separateEditing = true
preferences.showCommentMarker = false
preferences.smartText = true
preferences.fullWidthDocuments = true
let data = try JSONEncoder().encode(preferences)
let object = try JSONSerialization.jsonObject(with: data) as? [String: Any]
XCTAssertEqual(object?["seamlessEdit"] as? Bool, false, "separateEditing: true must encode as seamlessEdit: false")
XCTAssertEqual(object?["commentsInGutter"] as? Bool, false)
XCTAssertEqual(object?["enableSmartText"] as? Bool, true)
XCTAssertEqual(object?["fullWidthDocuments"] as? Bool, true)
XCTAssertNil(object?["separateEditing"], "must not leak this app's own field name onto the wire")
XCTAssertNil(object?["showCommentMarker"])
XCTAssertNil(object?["smartText"])
}
func testDeleteAccountSendsRequest() async throws { func testDeleteAccountSendsRequest() async throws {
let httpClient = MockHTTPClient() let httpClient = MockHTTPClient()
httpClient.responseData = """ httpClient.responseData = """
+1 -1
View File
@@ -437,7 +437,7 @@ struct SettingsView: View {
private var notificationBadgeBinding: Binding<NotificationBadgeStyle> { private var notificationBadgeBinding: Binding<NotificationBadgeStyle> {
Binding( Binding(
get: { get: {
session.userPreferences?.notificationBadge.flatMap(NotificationBadgeStyle.init(rawValue:)) ?? .all session.userPreferences?.notificationBadge.flatMap(NotificationBadgeStyle.init(rawValue:)) ?? .unreadCount
}, },
set: { newValue in Task { await savePreference { $0.notificationBadge = newValue.rawValue } } } set: { newValue in Task { await savePreference { $0.notificationBadge = newValue.rawValue } } }
) )