Skip to content

Commit fa2793b

Browse files
authored
Merge pull request #69 from ZenNotes/release/1.1.21
Release 1.1.21: public core packages, transactional file operations, Cloud vault retirement (app core 2.51.0)
2 parents 67a63bc + a4a079f commit fa2793b

62 files changed

Lines changed: 4069 additions & 1871 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/dependabot.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ updates:
1111
dependency-type: development
1212
ignore:
1313
# The shell's React (and its types) must match the pinned ZenNotes
14-
# source (.zennotes-commit); a second React or diverging @types/react
14+
# package candidates (vendor/zennotes/manifest.json); a second React or diverging @types/react
1515
# breaks the pinned-source typecheck. These move with the pin only.
1616
- dependency-name: react
1717
- dependency-name: react-dom

‎.github/workflows/ci.yml‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ concurrency:
1414

1515
jobs:
1616
verify:
17-
name: TypeScript, source pin, and Android build
17+
name: TypeScript, package boundary, and Android build
1818
runs-on: ubuntu-latest
1919
steps:
2020
- name: Check out repository
@@ -36,13 +36,12 @@ jobs:
3636
- name: Install mobile dependencies
3737
run: npm ci
3838

39-
- name: Prepare exact ZenNotes source
40-
run: npm run source:prepare
39+
- name: Verify installed core packages
40+
run: npm run boundaries:check
4141

4242
- name: Reject high-severity production advisories
4343
run: |
4444
npm audit --omit=dev --audit-level=high
45-
npm --prefix .zennotes-source audit --omit=dev --audit-level=high
4645
4746
- name: Test and typecheck bridges
4847
run: |

‎.gitignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,5 @@ docs/releases/*
2121
# Signing (local only — never commit)
2222
android/upload-keystore.jks
2323
android/keystore.properties
24+
25+
/dist-boundary-check/

‎.zennotes-commit‎

Lines changed: 0 additions & 1 deletion
This file was deleted.

‎README.md‎

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,15 @@ derived from the iPhone shell at `../zennotesiphone` — the two shells share th
88
same structure and bridge modules; platform-specific divergences are noted
99
below.
1010

11-
The zennotes repo is consumed **read-only at the exact commit in
12-
`.zennotes-commit`**. `npm run source:prepare` checks that commit out under the
13-
ignored `.zennotes-source/` directory and installs its locked dependencies.
14-
Every typecheck and release build verifies the pin; no ambient sibling checkout
15-
can silently change a mobile binary.
11+
The app consumes the public `@zennotes/app-core`, `@zennotes/bridge-contract`,
12+
and `@zennotes/shared-domain` packages. The exact archives are vendored under
13+
`vendor/zennotes/` with their source identity and checksums (`manifest.json`),
14+
and `package-lock.json` pins the complete install. No source checkout is used.
15+
The vendored set is the published desktop release
16+
[core-2.51.0-core.h49d73b531d346192](https://github.com/ZenNotes/zennotes/releases/tag/core-2.51.0-core.h49d73b531d346192)
17+
(desktop commit `8ff2cb86`, tag v2.51.0, clean tree). Run `npm run
18+
boundaries:check` to verify archives, installed versions, singleton
19+
editor/React peers, and imports.
1620

1721
## Architecture
1822

@@ -31,7 +35,7 @@ src/
3135
widgets.ts publishes it through the ZenWidgets plugin on every change
3236
ui-mobile/
3337
MobileShell.tsx bottom nav (capture ⊕ / search / sidebar / palette),
34-
phone drawer behavior via the shared Zustand store
38+
phone drawer behavior through public core APIs
3539
mobile.css safe areas, overlay drawers, keyboard handling
3640
widget-links.ts the zennotes:// links the widgets fire; deep-links.ts runs them
3741
android/ Capacitor-generated Gradle project (appId md.zennotes)
@@ -50,9 +54,10 @@ Key decisions (all forced by "don't modify the zennotes repo"):
5054
Every desktop-only affordance in app-core gates on `runtime === 'desktop'`,
5155
so `'web'` + the capability flags produces correct mobile behavior. When the
5256
contract gains `'mobile'` + the new capability flags (spec 02), flip it here.
53-
- **`platform: 'linux'`** (iOS shell reports `'darwin'`) — gives app-core
54-
Ctrl-based keymaps and hides Mac-only chrome; right for Android hardware
55-
keyboards.
57+
- **`platform: 'android'` + `hostKind: 'android'`** (iOS shell reports
58+
`'ios'`) — app-core only special-cases `'darwin'`, so Android still gets
59+
Ctrl-based keymaps and no Mac-only chrome; `hostKind` is how core tells the
60+
native shells apart (it replaced the earlier `'linux'` stand-in in 1.1.21).
5661
- **Vault location** — app-scoped external storage:
5762
`/Android/data/md.zennotes/files/ZenNotes/<vault>` (`Directory.External`),
5863
the spec-03 Android default tier: no permission prompt, works under scoped
@@ -117,7 +122,7 @@ Key decisions (all forced by "don't modify the zennotes repo"):
117122
the misspelling is intentional and load-bearing), same `.zennotes/`
118123
metadata (vault.json, workspace.json, comments/), same naming/collision
119124
rules, same NoteMeta extraction regexes, `systemFolderPaths` remaps honored
120-
via `@shared/system-folder-paths`.
125+
via `@zennotes/shared-domain/system-folder-paths`.
121126
- **Share sheet → quick capture**: Android needs no app extension — a
122127
`text/plain` `ACTION_SEND` intent-filter on MainActivity stashes captures in
123128
SharedPreferences; the app-local `ShareInbox` plugin (same `jsName` and
@@ -163,7 +168,7 @@ Key decisions (all forced by "don't modify the zennotes repo"):
163168
task rows, kanban cards, and calendar day cells), subtask rollups, archived
164169
notes retiring their tasks, inline mermaid while writing, text
165170
replacements, configurable tab size, manual kanban card order, and
166-
absence-aware remote reads (`@shared/remote-absence`).
171+
absence-aware remote reads (`@zennotes/shared-domain/remote-absence`).
167172

168173
## Build & run
169174

@@ -183,10 +188,12 @@ points at the SDK. Dev loop against a browser (no emulator): `npm run dev` —
183188
Capacitor plugins are absent in a plain browser, so vault I/O won't work; use
184189
the emulator for real testing.
185190

186-
`npm run upstream` verifies the generated checkout matches `.zennotes-commit`
187-
and typechecks the bridge against that exact source. To adopt a newer core,
188-
update the pin to a reviewed full commit SHA and commit it with the dependent
189-
mobile changes.
191+
`npm run upstream` verifies the package boundary and typechecks the host.
192+
To adopt a new core candidate, copy its three immutable archives into
193+
`vendor/zennotes/`, update the manifest and `file:` dependencies, then run
194+
`npm install`, the boundary check, tests, typecheck, production build, and native
195+
runtime checks together. Keep the previous validated package version available
196+
for rollback. A package update must not require private core imports.
190197

191198
## Boot-order gotcha (load-bearing)
192199

@@ -251,3 +258,5 @@ signed object upload, completion, manifest, and cleanup with a deterministic
251258
(`useSystemBackClose` is Android-only: iOS has no system back gesture).
252259
Underline stayed out on purpose: ZenNotes markdown has no underline
253260
construct on any platform — an upstream schema decision, not a shell one.
261+
262+
For device-level package checks, see [native boundary validation](docs/native-boundary-validation.md).

‎android/app/build.gradle‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ android {
1515
applicationId "md.zennotes"
1616
minSdkVersion rootProject.ext.minSdkVersion
1717
targetSdkVersion rootProject.ext.targetSdkVersion
18-
versionCode 22
19-
versionName "1.1.20"
18+
versionCode 23
19+
versionName "1.1.21"
2020
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
2121
aaptOptions {
2222
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
2+
<application>
3+
<!-- Installed only with the disposable instrumentation test APK. -->
4+
<provider android:name="md.zennotes.SafFixtureProvider"
5+
android:authorities="md.zennotes.test.saf-fixture"
6+
android:exported="true" android:grantUriPermissions="true" />
7+
</application>
8+
</manifest>
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package md.zennotes;
2+
3+
import android.database.Cursor;
4+
import android.database.MatrixCursor;
5+
import android.os.ParcelFileDescriptor;
6+
import android.provider.DocumentsContract.Document;
7+
import android.content.ContentProvider;
8+
import android.content.ContentValues;
9+
import android.net.Uri;
10+
import android.provider.DocumentsContract;
11+
import java.io.File;
12+
import java.io.FileNotFoundException;
13+
import java.nio.charset.StandardCharsets;
14+
import java.nio.file.Files;
15+
16+
/** A real Android resolver boundary with deliberate provider failure modes. */
17+
public class SafFixtureProvider extends ContentProvider {
18+
private static final String[] COLUMNS = { Document.COLUMN_DOCUMENT_ID, Document.COLUMN_DISPLAY_NAME,
19+
Document.COLUMN_MIME_TYPE, Document.COLUMN_SIZE, Document.COLUMN_LAST_MODIFIED };
20+
21+
@Override public String getType(Uri uri) { return "text/markdown"; }
22+
@Override public Uri insert(Uri uri, ContentValues values) { throw new UnsupportedOperationException(); }
23+
@Override public int update(Uri uri, ContentValues values, String selection, String[] args) { throw new UnsupportedOperationException(); }
24+
@Override public int delete(Uri uri, String selection, String[] args) { throw new UnsupportedOperationException(); }
25+
@Override public boolean onCreate() { return true; }
26+
@Override public Cursor query(Uri uri, String[] projection, String selection, String[] args, String sortOrder) {
27+
String parent = DocumentsContract.getTreeDocumentId(uri);
28+
if (parent.equals("null-list")) return null;
29+
if (parent.equals("denied")) throw new SecurityException("Provider access revoked");
30+
MatrixCursor cursor = new MatrixCursor(COLUMNS);
31+
cursor.addRow(new Object[] {"present", "present.md", "text/markdown", 0, 0});
32+
cursor.addRow(new Object[] {"directory", "directory", Document.MIME_TYPE_DIR, 0, 0});
33+
cursor.addRow(new Object[] {"unreadable", "unreadable.md", "text/markdown", 0, 0});
34+
return cursor;
35+
}
36+
@Override public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
37+
String id = DocumentsContract.getDocumentId(uri);
38+
if (!id.equals("present")) throw new FileNotFoundException("Provider cannot open this document");
39+
try {
40+
File file = new File(getContext().getCacheDir(), "saf-fixture.md");
41+
Files.write(file.toPath(), "Exact café 日本語. \n".getBytes(StandardCharsets.UTF_8));
42+
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
43+
} catch (Exception error) { throw new FileNotFoundException(error.getMessage()); }
44+
}
45+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package md.zennotes;
2+
3+
import static org.junit.Assert.*;
4+
import android.content.Context;
5+
import android.provider.DocumentsContract;
6+
import androidx.test.ext.junit.runners.AndroidJUnit4;
7+
import androidx.test.platform.app.InstrumentationRegistry;
8+
import com.getcapacitor.JSObject;
9+
import com.getcapacitor.PluginCall;
10+
import org.junit.Test;
11+
import org.junit.runner.RunWith;
12+
13+
@RunWith(AndroidJUnit4.class)
14+
public class SafReadInstrumentedTest {
15+
private static class Result extends PluginCall {
16+
String code, message;
17+
JSObject value;
18+
Result(String root, String path) {
19+
super(null, "SafFs", "fixture", "read", new JSObject().put("root", root).put("path", path));
20+
}
21+
@Override public void resolve(JSObject data) { value = data; }
22+
@Override public void reject(String message, String code, Exception error, JSObject data) {
23+
this.message = message; this.code = code;
24+
}
25+
}
26+
private Result read(String rootId, String path, boolean base64) {
27+
SafFsPlugin plugin = new SafFsPlugin() {
28+
@Override public Context getContext() { return InstrumentationRegistry.getInstrumentation().getContext(); }
29+
};
30+
String root = DocumentsContract.buildTreeDocumentUri("md.zennotes.test.saf-fixture", rootId).toString();
31+
Result result = new Result(root, path);
32+
if (base64) plugin.readBase64(result); else plugin.readText(result);
33+
return result;
34+
}
35+
@Test public void onlyVerifiedAbsenceReturnsNotFound() {
36+
for (boolean base64 : new boolean[] {false, true}) {
37+
assertEquals("ZN-SAF-NOT-FOUND", read("root", "missing.md", base64).code);
38+
assertEquals("ZN-SAF-IS-DIRECTORY", read("root", "directory", base64).code);
39+
for (String root : new String[] {"null-list", "denied"}) {
40+
Result failed = read(root, "missing.md", base64);
41+
assertNotNull(failed.message);
42+
assertNotEquals("ZN-SAF-NOT-FOUND", failed.code);
43+
}
44+
Result unavailable = read("root", "unreadable.md", base64);
45+
assertNotNull(unavailable.message);
46+
assertNotEquals("ZN-SAF-NOT-FOUND", unavailable.code);
47+
}
48+
}
49+
@Test public void nativeProviderReadsPreserveExactBytes() {
50+
Result text = read("root", "present.md", false);
51+
assertNull(text.message);
52+
assertEquals("Exact café 日本語. \n", text.value.getString("data"));
53+
Result binary = read("root", "present.md", true);
54+
assertNull(binary.message);
55+
assertArrayEquals("Exact café 日本語. \n".getBytes(java.nio.charset.StandardCharsets.UTF_8),
56+
android.util.Base64.decode(binary.value.getString("data"), android.util.Base64.DEFAULT));
57+
}
58+
}

‎android/app/src/main/java/md/zennotes/SafFsPlugin.java‎

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,8 @@ private Map<String, Entry> listChildren(Uri tree, String dirDocId) {
9393
},
9494
null, null, null
9595
)) {
96-
if (c != null) {
96+
if (c == null) throw new IllegalStateException("Document provider did not return a directory listing");
97+
{
9798
while (c.moveToNext()) {
9899
String id = c.getString(0);
99100
String name = c.getString(1);
@@ -249,7 +250,14 @@ public void readText(PluginCall call) {
249250
try {
250251
String rel = clean(call.getString("path"));
251252
Entry e = resolve(tree, rel);
252-
if (e == null || e.isDir) throw new FileNotFoundException(rel);
253+
if (e == null) {
254+
call.reject("File does not exist: " + rel, "ZN-SAF-NOT-FOUND");
255+
return;
256+
}
257+
if (e.isDir) {
258+
call.reject("Cannot read a directory: " + rel, "ZN-SAF-IS-DIRECTORY");
259+
return;
260+
}
253261
JSObject ret = new JSObject();
254262
ret.put("data", new String(readAll(docUri(tree, e.docId)), StandardCharsets.UTF_8));
255263
call.resolve(ret);
@@ -265,7 +273,14 @@ public void readBase64(PluginCall call) {
265273
try {
266274
String rel = clean(call.getString("path"));
267275
Entry e = resolve(tree, rel);
268-
if (e == null || e.isDir) throw new FileNotFoundException(rel);
276+
if (e == null) {
277+
call.reject("File does not exist: " + rel, "ZN-SAF-NOT-FOUND");
278+
return;
279+
}
280+
if (e.isDir) {
281+
call.reject("Cannot read a directory: " + rel, "ZN-SAF-IS-DIRECTORY");
282+
return;
283+
}
269284
JSObject ret = new JSObject();
270285
ret.put("data", Base64.encodeToString(readAll(docUri(tree, e.docId)), Base64.NO_WRAP));
271286
call.resolve(ret);
@@ -385,8 +400,21 @@ public void rename(PluginCall call) {
385400
String movedId = DocumentsContract.getDocumentId(moved);
386401
String targetName = baseName(to);
387402
if (!baseName(from).equals(targetName)) {
388-
if (DocumentsContract.renameDocument(resolver(), docUri(tree, movedId), targetName) == null) {
389-
throw new Exception("Rename after move refused");
403+
try {
404+
if (DocumentsContract.renameDocument(resolver(), docUri(tree, movedId), targetName) == null) {
405+
throw new Exception("Rename after move refused");
406+
}
407+
} catch (Exception renameError) {
408+
// A rename promise must not reject after silently changing parents.
409+
try {
410+
Uri restored = DocumentsContract.moveDocument(resolver(), docUri(tree, movedId),
411+
docUri(tree, toParentId), docUri(tree, fromParent.docId));
412+
if (restored == null) throw new Exception("Rollback move refused");
413+
} catch (Exception rollbackError) {
414+
throw new Exception("FOLDER_STATE_UNCERTAIN: Rename failed and could not be restored: "
415+
+ rollbackError.getMessage(), renameError);
416+
}
417+
throw renameError;
390418
}
391419
}
392420
}
@@ -395,6 +423,7 @@ public void rename(PluginCall call) {
395423
if (src.isDir) listings.remove(cacheKey(tree, src.docId));
396424
call.resolve();
397425
} catch (Exception e) {
426+
listings.clear();
398427
call.reject("rename failed: " + e.getMessage());
399428
}
400429
}

0 commit comments

Comments
 (0)