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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ 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.55.0-core.h0743bd3af2a4204a](https://github.com/ZenNotes/zennotes/releases/tag/core-2.55.0-core.h0743bd3af2a4204a)
(desktop commit `00db7036`, clean tree).
[core-2.56.1-core.hc872c08b6872aec4](https://github.com/ZenNotes/zennotes/releases/tag/core-2.56.1-core.hc872c08b6872aec4)
(desktop commit `f8b24c09`, clean tree).

`npm run boundaries:check` verifies the pins, installed versions, singleton
React/CodeMirror peers, and public export usage. `npm run core:adopt -- <core-tag>`
Expand Down
24 changes: 12 additions & 12 deletions ios/App/App.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -596,12 +596,12 @@
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = ZenWidgets/ZenWidgets.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 27;
CURRENT_PROJECT_VERSION = 28;
DEVELOPMENT_TEAM = WYY7PK57DM;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = ZenWidgets/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.13.0;
MARKETING_VERSION = 1.14.0;
PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ZenWidgets;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
Expand Down Expand Up @@ -725,12 +725,12 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 27;
CURRENT_PROJECT_VERSION = 28;
DEVELOPMENT_TEAM = WYY7PK57DM;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.13.0;
MARKETING_VERSION = 1.14.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = md.zennotes;
PRODUCT_NAME = "$(TARGET_NAME)";
Expand All @@ -747,12 +747,12 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 27;
CURRENT_PROJECT_VERSION = 28;
DEVELOPMENT_TEAM = WYY7PK57DM;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.13.0;
MARKETING_VERSION = 1.14.0;
PRODUCT_BUNDLE_IDENTIFIER = md.zennotes;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
Expand All @@ -767,12 +767,12 @@
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 27;
CURRENT_PROJECT_VERSION = 28;
DEVELOPMENT_TEAM = WYY7PK57DM;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = ShareExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.13.0;
MARKETING_VERSION = 1.14.0;
PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
Expand All @@ -789,12 +789,12 @@
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = ZenWidgets/ZenWidgets.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 27;
CURRENT_PROJECT_VERSION = 28;
DEVELOPMENT_TEAM = WYY7PK57DM;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = ZenWidgets/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.13.0;
MARKETING_VERSION = 1.14.0;
PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ZenWidgets;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
Expand Down Expand Up @@ -827,12 +827,12 @@
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 27;
CURRENT_PROJECT_VERSION = 28;
DEVELOPMENT_TEAM = WYY7PK57DM;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = ShareExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.13.0;
MARKETING_VERSION = 1.14.0;
PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
Expand Down
30 changes: 15 additions & 15 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,9 @@
"vscode-oniguruma": "^2.0.1",
"vscode-textmate": "^9.3.2",
"zustand": "^5.0.15",
"@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.55.0-core.h0743bd3af2a4204a.tgz",
"@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.55.0-boundaries.hd2f617179eb16ef2.tgz",
"@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.55.0-boundaries.hd2f617179eb16ef2.tgz",
"@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.56.1-core.hc872c08b6872aec4.tgz",
"@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.56.1-boundaries.h3434125ac49b85c7.tgz",
"@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.56.1-boundaries.h3434125ac49b85c7.tgz",
"@lezer/common": "^1.5.2"
},
"devDependencies": {
Expand Down
84 changes: 70 additions & 14 deletions src/bridge/mobile-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import type {
VaultTextSearchMatch
} from '@zennotes/shared-domain/ipc'
import { createDatabaseOps } from '@zennotes/shared-domain/database-ops'
import { normalizeVaultDisplayName, resolveVaultName } from '@zennotes/shared-domain/vault-display-name'
import type {
CustomCodeLanguage,
CustomCodeLanguageInstallInput,
Expand All @@ -58,7 +59,7 @@ import type {
McpServerRuntime
} from '@zennotes/shared-domain/mcp-clients'
import { MobileVault } from './vault-fs'
import { listVaultDirs, VAULTS_DIR } from './native-fs'
import { listVaultDirs, readVaultDisplayName, readVaultDisplayNameAtUrl, VAULTS_DIR } from './native-fs'
import { randomUUID } from './uuid'
import { Directory, Filesystem } from '@capacitor/filesystem'
import {
Expand Down Expand Up @@ -166,7 +167,12 @@ export const EXTERNAL_VAULT_ROOT = 'zn://external-vault'

export interface MobileVaultEntry {
root: string
/** The folder's name on disk: what rename and delete act on, and what the
* open vault's `folderName` is compared with to mark it current. */
name: string
/** The name the vault goes by when its vault.json carries one (ZenNotes
* #692), normalized; the sheet shows it in place of the folder name. */
displayName?: string
tier: 'local' | 'icloud' | 'external'
}

Expand Down Expand Up @@ -210,17 +216,25 @@ async function looksLikeVaultDir(url: string): Promise<boolean> {
export async function listSwitchableVaults(): Promise<MobileVaultEntry[]> {
const out: MobileVaultEntry[] = []
for (const d of await listVaultDirs()) {
out.push({ root: `${VAULT_ROOT_PREFIX}${d.name}`, name: d.name, tier: 'local' })
const displayName = normalizeVaultDisplayName(await readVaultDisplayName(d.name))
out.push({
root: `${VAULT_ROOT_PREFIX}${d.name}`,
name: d.name,
tier: 'local',
...(displayName ? { displayName } : {})
})
}
const status = await icloudStatus().catch(() => null)
if (status?.available && status.rootUrl) {
for (const name of filterCloudVaultNames(status.vaults ?? [])) {
const url = `${status.rootUrl}/${encodeURIComponent(name)}`
if (!(await looksLikeVaultDir(url))) continue
const displayName = normalizeVaultDisplayName(await readVaultDisplayNameAtUrl(url))
out.push({
root: `${ICLOUD_VAULT_ROOT_PREFIX}${encodeURIComponent(name)}`,
name,
tier: 'icloud'
tier: 'icloud',
...(displayName ? { displayName } : {})
})
}
}
Expand Down Expand Up @@ -417,7 +431,45 @@ function currentVaultInfo(): VaultInfo | null {
const remote = remoteVaultInfo()
if (remote) return remote
if (!vault) return null
return { root: friendlyVaultRoot(vault), name: vault.name }
// The root is a label, not a path, so the core cannot read the folder's
// name off it; folderName is what the display name falls back to and what
// the Vault name field shows as its placeholder (ZenNotes #692).
return { root: friendlyVaultRoot(vault), name: vault.name, folderName: vault.name }
}

/**
* The open vault as the app names it: the display name from its vault.json
* when it has one (ZenNotes #692), else the folder name. Desktop resolves
* this in main (describeVault) before the renderer ever sees a VaultInfo,
* and the renderer only re-derives the name after a settings save or an
* external vault.json change, so a shell that handed over the folder name
* would show it until the first save. Every bridge method that returns the
* opened vault goes through here; a remote workspace's name comes from its
* server and passes untouched. The settings read is cached after the first
* open, and a vault whose vault.json cannot be read keeps its folder name,
* the way it always did.
*/
/**
* The open local vault's folder name, null in a remote workspace or before
* boot. The Vaults sheet lists folders and must mark the one that is open
* whatever the vault is called (rename and delete act on the folder), and
* the core's shell snapshot carries the vault's shown name, not its folder,
* so the sheet asks the bridge, which holds the MobileVault by folder name.
*/
export function currentVaultFolderName(): string | null {
if (remoteVaultInfo()) return null
return vault?.name ?? null
}

async function describeCurrentVault(): Promise<VaultInfo | null> {
const info = currentVaultInfo()
if (!info || remoteVaultInfo() || !vault) return info
try {
const settings = await vault.getVaultSettings()
return { ...info, name: resolveVaultName(settings.displayName, info.name) }
} catch {
return info
}
}

/**
Expand Down Expand Up @@ -463,7 +515,7 @@ async function openVaultByName(name: string, cloudRootUri: string | null = null)
await next.open()
vault = next
localStorage.setItem(CURRENT_VAULT_KEY, name)
return currentVaultInfo() as VaultInfo
return (await describeCurrentVault()) as VaultInfo
}

/**
Expand Down Expand Up @@ -913,25 +965,29 @@ export const mobileBridge: ZenBridge = {
await disconnectRemote()
// Reopen the remembered local tier so the app lands somewhere real.
await openLocalVaultTier()
return currentVaultInfo()
return describeCurrentVault()
},
// Mobile never boots into a broken workspace (an unreachable remote falls
// back to the local vault in bootVault), so "retry" simply reports where
// the app already landed.
retryWorkspaceBoot: async () => currentVaultInfo(),
retryWorkspaceBoot: () => describeCurrentVault(),
listRemoteWorkspaceProfiles: () => listProfiles(),
saveRemoteWorkspaceProfile: (input) => saveProfile(input),
deleteRemoteWorkspaceProfile: (id) => deleteProfile(id),
connectRemoteWorkspaceProfile: (id) => connectRemoteProfile(id),

getCurrentVault: async () => currentVaultInfo(),
getCurrentVault: () => describeCurrentVault(),
listLocalVaults: async (): Promise<LocalVaultEntry[]> => {
const dirs = await listVaultDirs()
return dirs.map((d) => ({
root: `${VAULT_ROOT_PREFIX}${d.name}`,
name: d.name,
lastOpenedAt: d.mtime
}))
// The switcher shows each vault by the name it goes by (#692): one small
// vault.json read per folder, the folder name when there is none.
return Promise.all(
dirs.map(async (d) => ({
root: `${VAULT_ROOT_PREFIX}${d.name}`,
name: resolveVaultName(await readVaultDisplayName(d.name), d.name),
lastOpenedAt: d.mtime
}))
)
},
openLocalVault: async (root: string) => {
// One entry point for switching to any device-reachable vault: local
Expand Down Expand Up @@ -959,7 +1015,7 @@ export const mobileBridge: ZenBridge = {
setStoragePref('local')
return await openVaultByName(vaultNameFromRoot(root))
},
closeVault: async () => currentVaultInfo(),
closeVault: () => describeCurrentVault(),
pickVault: async () => {
const picked = await pickExternalVault()
if (!picked) return null
Expand Down
35 changes: 35 additions & 0 deletions src/bridge/native-fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,3 +305,38 @@ export async function listVaultDirs(): Promise<{ name: string; mtime: number }[]
return []
}
}

/**
* The raw `displayName` a local vault carries in its own vault.json
* (ZenNotes #692), or null when the vault has none or the file cannot be
* read. For the vault switcher, which lists vaults that are not open and so
* have no MobileVault to ask; the caller normalizes and falls back to the
* folder name, the way desktop's describeVault does.
*/
export async function readVaultDisplayName(vaultName: string): Promise<string | null> {
return readDisplayNameFrom({
path: `${VAULTS_DIR}/${vaultName}/.zennotes/vault.json`,
directory: Directory.Documents
})
}

/**
* The same for a vault addressed by a file URL, which is how the iCloud tier
* lists its vaults (see looksLikeVaultDir). One read attempt, no download
* wait: an evicted vault.json answers null and the vault keeps its folder
* name in the switcher, rather than every listing waiting on iCloud.
*/
export async function readVaultDisplayNameAtUrl(url: string): Promise<string | null> {
return readDisplayNameFrom({ path: `${url}/.zennotes/vault.json` })
}

async function readDisplayNameFrom(loc: { path: string; directory?: Directory }): Promise<string | null> {
try {
const res = await Filesystem.readFile({ ...loc, encoding: Encoding.UTF8 })
const parsed: unknown = JSON.parse(typeof res.data === 'string' ? res.data : '')
const name = (parsed as { displayName?: unknown } | null)?.displayName
return typeof name === 'string' ? name : null
} catch {
return null
}
}
17 changes: 17 additions & 0 deletions src/bridge/vault-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,20 @@ test('native absent-file reads propagate provider failures instead of allowing s
const permission = new Error('Permission denied')
await assert.rejects(NativeFs.prototype.readTextOrNull.call({ readText: async () => { throw permission } }, 'schema.json'), permission)
})
// ZenNotes #692: the desktop writes the vault's display name into vault.json.
// The phone's settings pass every key through, so a save from this device
// (toggling a favorite is enough) must keep the name, and a read must hand
// it to the core, which names the vault by it.
test('a vault display name written by desktop survives a phone settings save (ZenNotes #692)', async () => {
const { fs, vault } = fixture()
fs.files.set('.zennotes/vault.json', JSON.stringify({
...structuredClone(DEFAULT_VAULT_SETTINGS), displayName: 'Acme API docs'
}))
Object.assign(vault, { settingsCache: null })
const settings = await vault.getVaultSettings()
assert.equal(settings.displayName, 'Acme API docs')
await vault.setVaultSettings({ ...settings, favorites: ['Notes/Work/One.md'] })
const written = JSON.parse(fs.files.get('.zennotes/vault.json')!)
assert.equal(written.displayName, 'Acme API docs')
assert.deepEqual(written.favorites, ['Notes/Work/One.md'])
})
Loading