Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 26 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,19 @@ by a local-first vault on the device filesystem. Implements the architecture in
`docs/specs/mobile/` (Phase 0 + the on-device parts of Phase 1).

The shell consumes immutable, compiled `@zennotes/app-core`,
`@zennotes/bridge-contract`, and `@zennotes/shared-domain` archives. The current
local candidates live in `vendor/zennotes`; its manifest records their source
identity and checksums. A clean checkout installs them with `npm ci`, without a
source clone or sibling repository. They have not been published.
`@zennotes/bridge-contract`, and `@zennotes/shared-domain` archives. The exact
archives are vendored under `vendor/zennotes/` with their source identity and
checksums (`manifest.json`), and `package-lock.json` pins the complete install.
A clean checkout installs them with `npm ci`, without a source clone or sibling
repository. The vendored set is the core release
[core-2.54.1-core.h75d82a521571dc20](https://github.com/ZenNotes/zennotes/releases/tag/core-2.54.1-core.h75d82a521571dc20)
(desktop commit `0285443b`, clean tree).

`npm run boundaries:check` verifies the pins, installed versions, singleton
React/CodeMirror peers, and public export usage. Native storage, iCloud, stable
vault identities, keyboard behavior, sync, and preferences remain in this repo.
React/CodeMirror peers, and public export usage. `npm run core:adopt -- <core-tag>`
swaps in a new release (see "Adopting a newer core" below). Native storage,
iCloud, stable vault identities, keyboard behavior, sync, and preferences remain
in this repo.

## Architecture

Expand Down Expand Up @@ -93,10 +98,21 @@ Dev loop against a browser (no simulator): `npm run dev` — note Capacitor
plugins are absent in a plain browser, so vault I/O won't work; use the
simulator for real testing.

To adopt a newer core, copy the reviewed package archives and portable manifest
into `vendor/zennotes`, update the three exact dependencies, and refresh the lockfile.
Run the boundary check, tests, typecheck, and native build before changing the pin.
Retain the previous artifacts for rollback. Never resolve a mutable branch at build time.
## Adopting a newer core

`npm run core:adopt -- <core-tag> --source <desktop-commit>` downloads a core
release from the desktop repo (`core-X.Y.Z-core.h…`, drafts work with an
authenticated `gh`), verifies every archive's SHA-256 and SHA-512 against its
provenance, the packed package versions, that all three archives record the
same clean source commit, then swaps the archives into `vendor/zennotes`,
rewrites `manifest.json`, the three `file:` dependencies, and the sentence above,
runs `npm install`, and runs the boundary check. Add `--dry-run` to verify and
print the plan without writing anything, or `--from <dir>` for a local
`prepare-boundary-release.mjs core` build. The script runs no git command:
review the diff, then stage `package.json`, `package-lock.json`, `vendor/zennotes`,
and `README.md` by path. Run `npm run upstream`, `npm test`, `npm run sync`, and
a simulator pass before changing the pin. Never resolve a mutable branch at
build time.

## What works today (verified on the iPhone 17 Pro simulator)

Expand Down
28 changes: 16 additions & 12 deletions ios/App/App.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
283DF466CD667CDB34C0DE25 /* DeepLinkUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC30A5CDEA5991955FBC0360 /* DeepLinkUITests.swift */; };
283DF467CD667CDB34C0DE25 /* FavoriteUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC30A5CEEA5991955FBC0360 /* FavoriteUITests.swift */; };
283DF468CD667CDB34C0DE25 /* MoveDirectoryUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC30A5CFEA5991955FBC0360 /* MoveDirectoryUITests.swift */; };
283DF469CD667CDB34C0DE25 /* SearchCreateUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC30A5D0EA5991955FBC0360 /* SearchCreateUITests.swift */; };
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
3322BE59AC62263A587F4D6B /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 85847153B31EF561562A9281 /* SwiftUI.framework */; };
4444E1477FBE380A04E0BFE0 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3A7702CA7CC6312CAD599CD7 /* Foundation.framework */; };
Expand Down Expand Up @@ -120,6 +121,7 @@
CC30A5CDEA5991955FBC0360 /* DeepLinkUITests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeepLinkUITests.swift; sourceTree = "<group>"; };
CC30A5CEEA5991955FBC0360 /* FavoriteUITests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FavoriteUITests.swift; sourceTree = "<group>"; };
CC30A5CFEA5991955FBC0360 /* MoveDirectoryUITests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MoveDirectoryUITests.swift; sourceTree = "<group>"; };
CC30A5D0EA5991955FBC0360 /* SearchCreateUITests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SearchCreateUITests.swift; sourceTree = "<group>"; };
D61B685B388CE720F2588065 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
D956FF4D03CEB97B048B0860 /* ShareExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = "<group>"; };
DB10756BA75711FC150E34FD /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
Expand Down Expand Up @@ -264,6 +266,7 @@
CC30A5CDEA5991955FBC0360 /* DeepLinkUITests.swift */,
CC30A5CEEA5991955FBC0360 /* FavoriteUITests.swift */,
CC30A5CFEA5991955FBC0360 /* MoveDirectoryUITests.swift */,
CC30A5D0EA5991955FBC0360 /* SearchCreateUITests.swift */,
);
name = AppUITests;
path = AppUITests;
Expand Down Expand Up @@ -523,6 +526,7 @@
283DF466CD667CDB34C0DE25 /* DeepLinkUITests.swift in Sources */,
283DF467CD667CDB34C0DE25 /* FavoriteUITests.swift in Sources */,
283DF468CD667CDB34C0DE25 /* MoveDirectoryUITests.swift in Sources */,
283DF469CD667CDB34C0DE25 /* SearchCreateUITests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -592,12 +596,12 @@
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = ZenWidgets/ZenWidgets.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 25;
CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_TEAM = WYY7PK57DM;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = ZenWidgets/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.11.0;
MARKETING_VERSION = 1.12.0;
PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ZenWidgets;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
Expand Down Expand Up @@ -721,12 +725,12 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 25;
CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_TEAM = WYY7PK57DM;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.11.0;
MARKETING_VERSION = 1.12.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = md.zennotes;
PRODUCT_NAME = "$(TARGET_NAME)";
Expand All @@ -743,12 +747,12 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 25;
CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_TEAM = WYY7PK57DM;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.11.0;
MARKETING_VERSION = 1.12.0;
PRODUCT_BUNDLE_IDENTIFIER = md.zennotes;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
Expand All @@ -763,12 +767,12 @@
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 25;
CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_TEAM = WYY7PK57DM;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = ShareExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.11.0;
MARKETING_VERSION = 1.12.0;
PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
Expand All @@ -785,12 +789,12 @@
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = ZenWidgets/ZenWidgets.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 25;
CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_TEAM = WYY7PK57DM;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = ZenWidgets/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.11.0;
MARKETING_VERSION = 1.12.0;
PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ZenWidgets;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
Expand Down Expand Up @@ -823,12 +827,12 @@
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 25;
CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_TEAM = WYY7PK57DM;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = ShareExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.11.0;
MARKETING_VERSION = 1.12.0;
PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
Expand Down
207 changes: 207 additions & 0 deletions ios/App/AppUITests/SearchCreateUITests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import XCTest

/// Core 2.54.0 gives note search a Create row for a name that matches no
/// note, and Enter or a tap on it swaps the results for a New note form
/// inside the same palette (zennotes#826). On the phone the palette is a
/// full-screen takeover with a shell-injected Cancel at its top right
/// (mobile.css), the corner where the form's header puts its destination
/// label ("in Inbox"). Until 1.12.0 the two overlapped: Cancel was drawn
/// across the faded label. The header now ends before Cancel begins.
///
/// The test opens the form for a name no vault holds, requires the label
/// and Cancel to occupy separate space, and leaves through Cancel, so no
/// note is written to the simulator's vault.
final class SearchCreateUITests: XCTestCase {
override func setUpWithError() throws {
continueAfterFailure = false
}

func testNewNoteFormHeaderLeavesRoomForCancel() throws {
let app = XCUIApplication()
app.launch()
XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 10))
guard app.windows.firstMatch.frame.width < 768 else {
throw XCTSkip("phone shell only: the iPad shows the desktop palette card, which has no injected Cancel")
}

let openMenu = app.buttons["Open menu"]
XCTAssertTrue(openMenu.waitForExistence(timeout: 10))
openMenu.tap()
try hittable(label: "Search", in: app, failure: "the ensō menu did not open").tap()

let search = app.textFields
.matching(NSPredicate(format: "placeholderValue BEGINSWITH 'Search notes'"))
.firstMatch
waitUntilHittable(search, failure: "the search palette did not open")
search.tap()
let name = "Create row check \(Int(Date().timeIntervalSince1970) % 1_000_000)"
app.typeText(name)

let createRow = try XCTUnwrap(
hittableMatch(NSPredicate(format: "label BEGINSWITH %@", "Create \"\(name)\""), in: app),
"no Create row for a name that matches no note"
)
createRow.tap()

let heading = app.staticTexts["New note"]
XCTAssertTrue(heading.waitForExistence(timeout: 5), "the New note form did not open")
let cancel = try button(label: "Cancel", in: app, failure: "the shell did not inject Cancel into the form")
// The label reads "in <folder>" and sits on the heading's row; the
// status line under the fields also names the folder, but as part
// of a longer sentence, so the prefix and the row tell them apart.
let destination = try XCTUnwrap(
staticText(prefix: "in ", onRowOf: heading, in: app),
"the form header names no destination"
)
attachScreenshot("search-create-form", of: app)

let labelFrame = destination.frame
let cancelFrame = cancel.frame
XCTAssertFalse(
labelFrame.intersects(cancelFrame),
"the destination label \(labelFrame) runs under Cancel \(cancelFrame)"
)
XCTAssertLessThanOrEqual(
labelFrame.maxX, cancelFrame.minX,
"the destination label \(labelFrame) ends after Cancel \(cancelFrame) begins"
)

// Focus returns to the editor with the keyboard still up, and the
// ensō hides under a raised keyboard, so the form's own elements are
// the honest signal that the palette closed, not "Open menu".
cancel.tap()
waitUntilGone(heading, failure: "Cancel did not close the palette: the New note heading is still there")
XCTAssertFalse(cancel.exists, "Cancel outlived the palette it belongs to")
}

/// Core 2.54.0 lost the first tap on Create while a tag was still typed
/// in the Tags field: the button took focus, the field blurred and
/// committed the word as a chip, the suggestion row under the fields
/// went away and the footer moved before the tap completed (found on
/// this shell, fixed in core 2.54.1). One tap must create the note.
/// This test writes one note into the simulator's vault.
func testFirstTapOnCreateLandsWhileATagIsTyped() throws {
let app = XCUIApplication()
app.launch()
XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 10))

let openMenu = app.buttons["Open menu"]
XCTAssertTrue(openMenu.waitForExistence(timeout: 10))
openMenu.tap()
try hittable(label: "Search", in: app, failure: "the ensō menu did not open").tap()

let search = app.textFields
.matching(NSPredicate(format: "placeholderValue BEGINSWITH 'Search notes'"))
.firstMatch
waitUntilHittable(search, failure: "the search palette did not open")
search.tap()
// The suffix keeps the name and the tag new on every run: the note
// stays in the simulator's vault, and a tag it already holds would be
// offered as an existing tag rather than a new one.
let suffix = Int(Date().timeIntervalSince1970) % 1_000_000
let name = "Tag tap check \(suffix)"
let tag = "tap\(suffix)"
app.typeText(name)
try XCTUnwrap(
hittableMatch(NSPredicate(format: "label BEGINSWITH %@", "Create \"\(name)\""), in: app),
"no Create row for a name that matches no note"
).tap()

let heading = app.staticTexts["New note"]
XCTAssertTrue(heading.waitForExistence(timeout: 5), "the New note form did not open")
let tags = app.textFields
.matching(NSPredicate(format: "placeholderValue BEGINSWITH 'Add tags'"))
.firstMatch
waitUntilHittable(tags, failure: "the form has no Tags field")
tags.tap()
app.typeText(tag)
// The word is still text, not a chip: the row offering it as a new
// tag is what the blur used to take away.
XCTAssertTrue(
app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", "Add #\(tag)")).firstMatch
.waitForExistence(timeout: 5),
"typing in Tags did not offer the word as a new tag"
)

let create = try button(label: "Create", in: app, failure: "the form's Create button is not on screen")
attachScreenshot("search-create-typed-tag", of: app)
create.tap()
waitUntilGone(heading, failure: "one tap on Create left the form open: the typed tag took the tap")
XCTAssertTrue(
app.staticTexts.matching(NSPredicate(format: "label CONTAINS %@", "#\(tag)")).firstMatch
.waitForExistence(timeout: 5),
"the created note does not show the #\(tag) tag"
)
attachScreenshot("search-create-note-open", of: app)
}

private func waitUntilGone(_ element: XCUIElement, timeout: TimeInterval = 5, failure: String) {
let deadline = Date().addingTimeInterval(timeout)
while element.exists && Date() < deadline {
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
}
XCTAssertFalse(element.exists, failure)
}

/// The static text with the given prefix whose vertical centre lies in
/// the row of `anchor`. WebKit exposes the whole document, including
/// text under the palette, so the row is what pins the header's label.
private func staticText(prefix: String, onRowOf anchor: XCUIElement, in app: XCUIApplication) -> XCUIElement? {
let row = anchor.frame
let matches = app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", prefix))
for index in 0..<matches.count {
let candidate = matches.element(boundBy: index)
let frame = candidate.frame
if frame.midY >= row.minY && frame.midY <= row.maxY {
return candidate
}
}
return nil
}

private func waitUntilHittable(_ element: XCUIElement, timeout: TimeInterval = 10, failure: String) {
let deadline = Date().addingTimeInterval(timeout)
while !element.isHittable && Date() < deadline {
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
}
XCTAssertTrue(element.isHittable, failure)
}

/// Kept on success too, so a run's xcresult shows the form on that simulator.
private func attachScreenshot(_ name: String, of app: XCUIApplication) {
let attachment = XCTAttachment(screenshot: app.screenshot())
attachment.name = name
attachment.lifetime = .keepAlways
add(attachment)
}

private func button(label: String, in app: XCUIApplication, failure: String) throws -> XCUIElement {
try XCTUnwrap(hittableMatch(NSPredicate(format: "label == %@", label), in: app), failure)
}

/// Any element type: the ensō menu's items are role=menuitem, and the
/// open note can carry the same word earlier in the tree.
private func hittable(label: String, in app: XCUIApplication, failure: String) throws -> XCUIElement {
try XCTUnwrap(hittableMatch(NSPredicate(format: "label == %@", label), in: app, type: .any), failure)
}

/// Polls for an element that is on screen and tappable: the palette and
/// the form mount a moment after the tap that opens them, and hidden
/// matches (note text under the palette) must not win.
private func hittableMatch(
_ predicate: NSPredicate,
in app: XCUIApplication,
type: XCUIElement.ElementType = .button,
timeout: TimeInterval = 10
) -> XCUIElement? {
let deadline = Date().addingTimeInterval(timeout)
repeat {
let matches = app.descendants(matching: type).matching(predicate)
for index in 0..<matches.count where matches.element(boundBy: index).isHittable {
return matches.element(boundBy: index)
}
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
} while Date() < deadline
return nil
}
}
Loading
Loading