mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-09-01 19:49:54 +02:00
Compare commits
1158 Commits
3.3.10-bet
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09a0c302d9 | ||
|
|
a44c35af90 | ||
|
|
01e4a639a4 | ||
|
|
e7dcea395b | ||
|
|
5a179a4dfa | ||
|
|
2f3e5cb5cf | ||
|
|
575cf24492 | ||
|
|
e0dc45c6cc | ||
|
|
65b5652fa6 | ||
|
|
0915450830 | ||
|
|
62d2b5a9d5 | ||
|
|
bedb0732a2 | ||
|
|
06a3982f39 | ||
|
|
549e0b202d | ||
|
|
28accaf30e | ||
|
|
6efa67fe3b | ||
|
|
81561098c7 | ||
|
|
7c23f668ef | ||
|
|
5f3b6326a0 | ||
|
|
4c702af1ca | ||
|
|
725df6f6f7 | ||
|
|
c9c4936d9e | ||
|
|
9e251d6f9e | ||
|
|
c3fb01c61b | ||
|
|
6522997e62 | ||
|
|
9190a1de3f | ||
|
|
5d03d3033f | ||
|
|
d14da4b3d5 | ||
|
|
bed993466a | ||
|
|
db8f02a585 | ||
|
|
0fd305a259 | ||
|
|
b855266293 | ||
|
|
b4227a8c43 | ||
|
|
39887f81a2 | ||
|
|
e47ef061fd | ||
|
|
360cd5431d | ||
|
|
c3aff365f1 | ||
|
|
dd3ced17de | ||
|
|
ee419e514c | ||
|
|
a3d672362f | ||
|
|
04f9ff41a0 | ||
|
|
54c97584cf | ||
|
|
db72716234 | ||
|
|
72351960fa | ||
|
|
85458932de | ||
|
|
4e1acc292d | ||
|
|
20b4585361 | ||
|
|
dc3f38f67d | ||
|
|
a40e11f2d4 | ||
|
|
ccc152b561 | ||
|
|
279c776e45 | ||
|
|
87aa98b85e | ||
|
|
b7777a2a19 | ||
|
|
52d6044e73 | ||
|
|
8d56564221 | ||
|
|
d647eb8187 | ||
|
|
316951c2bf | ||
|
|
9ce5b76265 | ||
|
|
a82bb4cc32 | ||
|
|
b181655edc | ||
|
|
67ffcb163c | ||
|
|
982cea7aa6 | ||
|
|
adab66f285 | ||
|
|
1740ba9e10 | ||
|
|
9e5ff04692 | ||
|
|
08bb216334 | ||
|
|
01da7cbb19 | ||
|
|
e326b912fd | ||
|
|
8bc072a543 | ||
|
|
79086108a5 | ||
|
|
1070831b63 | ||
|
|
efaffc8729 | ||
|
|
b8a02640d6 | ||
|
|
31dbbe5e9e | ||
|
|
696f57423b | ||
|
|
427dcbbb20 | ||
|
|
d3d21aa835 | ||
|
|
d60c8be423 | ||
|
|
4d97f7033f | ||
|
|
516a598b2b | ||
|
|
966d57dfa2 | ||
|
|
7a3111c6b3 | ||
|
|
f858e3471f | ||
|
|
57113e0896 | ||
|
|
28f2ffdbf7 | ||
|
|
02c60d14c7 | ||
|
|
41cdc882c9 | ||
|
|
a1879dae17 | ||
|
|
4765657423 | ||
|
|
eef56916de | ||
|
|
f7c755f2d6 | ||
|
|
10a08d5719 | ||
|
|
6e10ce8d8e | ||
|
|
7f5215403b | ||
|
|
8e1d59d016 | ||
|
|
9833c7636b | ||
|
|
71cae07dc0 | ||
|
|
77e56f9da1 | ||
|
|
b5140d9947 | ||
|
|
2288b946ad | ||
|
|
5fd43ae08e | ||
|
|
729c410602 | ||
|
|
bb30336f5c | ||
|
|
0dc89ba945 | ||
|
|
bb51244fa3 | ||
|
|
b5ac41c29b | ||
|
|
aafb6049b2 | ||
|
|
b066fac692 | ||
|
|
8d2e1c65bc | ||
|
|
93337b6560 | ||
|
|
70fd3096d2 | ||
|
|
1889b13459 | ||
|
|
314c484ea0 | ||
|
|
6b5e6f62c5 | ||
|
|
55b51b81e3 | ||
|
|
b2c7f732a0 | ||
|
|
88eb3ab714 | ||
|
|
942c9287fe | ||
|
|
343d2b0131 | ||
|
|
32070327f9 | ||
|
|
9b29503b17 | ||
|
|
0939f0f805 | ||
|
|
771a8bb4e3 | ||
|
|
e4fa0e9f0b | ||
|
|
97749729ad | ||
|
|
ca19fd9230 | ||
|
|
c435ab46d1 | ||
|
|
62ecc5f7fb | ||
|
|
3e2847f844 | ||
|
|
c3c9b1fbf4 | ||
|
|
a40814a3ab | ||
|
|
180c202c4e | ||
|
|
64943647f8 | ||
|
|
4f816b47ba | ||
|
|
1071249baa | ||
|
|
d4775ad340 | ||
|
|
0d8c855cd0 | ||
|
|
d63ce5a213 | ||
|
|
a1d6bb91e5 | ||
|
|
a20f6960d7 | ||
|
|
6b2b224f41 | ||
|
|
054a023142 | ||
|
|
441a6396be | ||
|
|
987671e59c | ||
|
|
0fc705ce1e | ||
|
|
b3805534d6 | ||
|
|
77c009e83d | ||
|
|
c9f1cba702 | ||
|
|
637cf914ee | ||
|
|
64877c519b | ||
|
|
229b620473 | ||
|
|
658e915589 | ||
|
|
d4658aa032 | ||
|
|
489c793806 | ||
|
|
eeae993736 | ||
|
|
c8cf4ce52a | ||
|
|
f6c31d19d8 | ||
|
|
a94e8d6852 | ||
|
|
2edbc3bf2e | ||
|
|
e4a7d63e07 | ||
|
|
76fd61af4f | ||
|
|
98386a6d71 | ||
|
|
539a630f77 | ||
|
|
6b65f8031d | ||
|
|
3c85cddfe7 | ||
|
|
e426426468 | ||
|
|
109e46bd06 | ||
|
|
281a973c37 | ||
|
|
2738207c20 | ||
|
|
1aa73c1f23 | ||
|
|
9ec22724e9 | ||
|
|
4a7aa27bd9 | ||
|
|
6051614d96 | ||
|
|
295a461dda | ||
|
|
a4cbe83211 | ||
|
|
afd63db043 | ||
|
|
aadb6e3a01 | ||
|
|
7c3fdab6ee | ||
|
|
14f727d6e6 | ||
|
|
688a646181 | ||
|
|
7a7f0db941 | ||
|
|
96be35c105 | ||
|
|
36bacce3ac | ||
|
|
c89e333c11 | ||
|
|
d521eb3bd8 | ||
|
|
20bf090c7e | ||
|
|
6cc9b8d1e1 | ||
|
|
e931423eba | ||
|
|
743d47e9e9 | ||
|
|
37c96177a7 | ||
|
|
834479d095 | ||
|
|
643515c6c4 | ||
|
|
0b0a4f1b6f | ||
|
|
48d88069c0 | ||
|
|
9d7754f1e1 | ||
|
|
b2ed115011 | ||
|
|
3447e8133c | ||
|
|
4473d8ec67 | ||
|
|
08cf21b05a | ||
|
|
10dccba5b6 | ||
|
|
23c9a98013 | ||
|
|
473fae4a30 | ||
|
|
e9808c08f0 | ||
|
|
4bb46ea149 | ||
|
|
01811372fc | ||
|
|
6d2097259b | ||
|
|
4c3e065928 | ||
|
|
e45b72a8c4 | ||
|
|
aceb6db8b8 | ||
|
|
5a0ca0bed9 | ||
|
|
8dd5779308 | ||
|
|
dc958098ea | ||
|
|
bf536eb183 | ||
|
|
eb3eea031e | ||
|
|
d376e00a1e | ||
|
|
aca36b60b7 | ||
|
|
033666795c | ||
|
|
c0872c98a8 | ||
|
|
f2173a42d9 | ||
|
|
cbddea4518 | ||
|
|
c69304781a | ||
|
|
412631bf67 | ||
|
|
8c2be00920 | ||
|
|
553e2060be | ||
|
|
7603de25c6 | ||
|
|
146216949d | ||
|
|
c4d8abef16 | ||
|
|
baf823264b | ||
|
|
7f0a517ee1 | ||
|
|
da48cf0bd1 | ||
|
|
4c41e13c5d | ||
|
|
82e2953bc8 | ||
|
|
fc244b1196 | ||
|
|
98ecad3450 | ||
|
|
54dcbd9be6 | ||
|
|
d26c99c487 | ||
|
|
0922d9d833 | ||
|
|
0cdeee80fa | ||
|
|
3aa23d3215 | ||
|
|
936b6cb4aa | ||
|
|
e0b9932aba | ||
|
|
f9f974c9f0 | ||
|
|
e3816234ff | ||
|
|
8b36ae6dee | ||
|
|
0bd030dc78 | ||
|
|
63eb42eb9e | ||
|
|
eb75df360f | ||
|
|
89f3a7945b | ||
|
|
7af1387691 | ||
|
|
1d292d740c | ||
|
|
4f884a8411 | ||
|
|
3122e8a379 | ||
|
|
528812d69d | ||
|
|
ab5391fd6e | ||
|
|
770d290af1 | ||
|
|
3db6c2b1fd | ||
|
|
19a11dae26 | ||
|
|
07d7a537e4 | ||
|
|
20f39b82c6 | ||
|
|
9e5ed67eb3 | ||
|
|
6ddd5078ac | ||
|
|
579ae294b6 | ||
|
|
6418476ed6 | ||
|
|
dfc01fa558 | ||
|
|
b4f3593966 | ||
|
|
c0eff80bd4 | ||
|
|
fd55eddf73 | ||
|
|
80a964af67 | ||
|
|
8c3c011ad0 | ||
|
|
632d923c69 | ||
|
|
ce03e01bf2 | ||
|
|
e2aa57909f | ||
|
|
6c9eb57f3d | ||
|
|
e68d60588e | ||
|
|
49aa7860f8 | ||
|
|
9c6cfa0b27 | ||
|
|
4d75a55f33 | ||
|
|
e1e2aec014 | ||
|
|
e82b141aaf | ||
|
|
db53a1e311 | ||
|
|
3216c33fe6 | ||
|
|
d2ca64d7c9 | ||
|
|
91a56058bb | ||
|
|
4e057fa788 | ||
|
|
229edaae55 | ||
|
|
eb265ea89e | ||
|
|
16ee34aceb | ||
|
|
8dd39e6a68 | ||
|
|
d8bc12287c | ||
|
|
5ba3233055 | ||
|
|
ffecb2f9cb | ||
|
|
bfd3b0736d | ||
|
|
26b9dc1149 | ||
|
|
1a6c752c57 | ||
|
|
c7e0225535 | ||
|
|
0cb527f97b | ||
|
|
9c103ea0e3 | ||
|
|
c89a9fad50 | ||
|
|
251e635ccb | ||
|
|
46d5bc83c8 | ||
|
|
9c69514a74 | ||
|
|
a6a74b924e | ||
|
|
8c8f085b16 | ||
|
|
f75ce74c6b | ||
|
|
d7e92346e5 | ||
|
|
17bbaa2323 | ||
|
|
2f47146ae2 | ||
|
|
2dd6f94f41 | ||
|
|
5f88375bec | ||
|
|
3f9bea1d6f | ||
|
|
97d3162d20 | ||
|
|
a12e6ba008 | ||
|
|
dfb1dc7e57 | ||
|
|
c27e8916a1 | ||
|
|
5fe6331c1f | ||
|
|
68d1a1bc34 | ||
|
|
7adc5fbf1e | ||
|
|
46b6888ca3 | ||
|
|
9eec90e333 | ||
|
|
e49291c55e | ||
|
|
70147d4332 | ||
|
|
f8d2649f4d | ||
|
|
44ffe0adf6 | ||
|
|
14cb815e34 | ||
|
|
ca4368a08e | ||
|
|
537c514562 | ||
|
|
d9720ba7b8 | ||
|
|
16e968dc57 | ||
|
|
03fafcbd15 | ||
|
|
b7bd06c9f3 | ||
|
|
766a8f28d4 | ||
|
|
86512b5ce7 | ||
|
|
cdef17eb8d | ||
|
|
4256003e1c | ||
|
|
657f0e428a | ||
|
|
59b003d1f8 | ||
|
|
50d21e8787 | ||
|
|
a820fcde53 | ||
|
|
5ddf1eddf5 | ||
|
|
4237d2eb4a | ||
|
|
f67cd503d6 | ||
|
|
14039d6a1e | ||
|
|
d1fed9072e | ||
|
|
b003a3ffed | ||
|
|
55e10e4755 | ||
|
|
7c1dd0f79a | ||
|
|
229cf195a8 | ||
|
|
38da6ac08b | ||
|
|
597dc587cd | ||
|
|
5c76b92789 | ||
|
|
8418d5fa9c | ||
|
|
cd071f8f58 | ||
|
|
967374d28e | ||
|
|
32ddc3fa40 | ||
|
|
68d07a7862 | ||
|
|
2aa5162fc9 | ||
|
|
adaaabe46c | ||
|
|
cf5565d09d | ||
|
|
f129e0c411 | ||
|
|
bb43d80e05 | ||
|
|
4e4d3866df | ||
|
|
b2088ef782 | ||
|
|
07db14f6bf | ||
|
|
82d37701c5 | ||
|
|
b48ab3c85d | ||
|
|
52d7d90b68 | ||
|
|
bb7ff4f376 | ||
|
|
43b35917ef | ||
|
|
bcfa34ca05 | ||
|
|
8b2f5901fe | ||
|
|
d64d2e44d9 | ||
|
|
4f8c9eb76c | ||
|
|
ba0cfbf44a | ||
|
|
b43374c53f | ||
|
|
f2c08c89b0 | ||
|
|
bd04427e03 | ||
|
|
27ca64694f | ||
|
|
42f229ab03 | ||
|
|
aab6a36067 | ||
|
|
c7e0cd35e7 | ||
|
|
bd3edc535a | ||
|
|
1dff7a121d | ||
|
|
3f3467e3a7 | ||
|
|
39bb9afa0e | ||
|
|
886e757715 | ||
|
|
f1124c4112 | ||
|
|
a1575ab5ea | ||
|
|
d31eaef6d4 | ||
|
|
930e31d109 | ||
|
|
2a6cc90592 | ||
|
|
a13962f8c9 | ||
|
|
5c93645969 | ||
|
|
cad13856af | ||
|
|
a4c8259a02 | ||
|
|
07defdfa78 | ||
|
|
fc93a39164 | ||
|
|
9b588c50bf | ||
|
|
27b00d4ef9 | ||
|
|
3248c82c5c | ||
|
|
691a0a62ea | ||
|
|
2c578c9695 | ||
|
|
ee48990c90 | ||
|
|
436c635889 | ||
|
|
51b226226e | ||
|
|
7deb095aca | ||
|
|
b615dfcd87 | ||
|
|
833368e225 | ||
|
|
9221e21393 | ||
|
|
91e0437554 | ||
|
|
0476d3652a | ||
|
|
4dcf14fce1 | ||
|
|
8f8ac25d20 | ||
|
|
b594d58d31 | ||
|
|
e4c132a8cc | ||
|
|
ba9877b3dd | ||
|
|
20921df3f1 | ||
|
|
43d3036525 | ||
|
|
f725499039 | ||
|
|
cd743a92cb | ||
|
|
a545c9cfac | ||
|
|
ea0550cb05 | ||
|
|
fa03ec5ebe | ||
|
|
79ecbf6752 | ||
|
|
d9d5cb2168 | ||
|
|
69e509da1c | ||
|
|
2d97445f26 | ||
|
|
6ba1fda2f2 | ||
|
|
93efecd6c3 | ||
|
|
70d1119cf0 | ||
|
|
3f11b76811 | ||
|
|
d912c93f81 | ||
|
|
e635bf628a | ||
|
|
8048c90a89 | ||
|
|
8093f44a70 | ||
|
|
d0bfc21b49 | ||
|
|
f67f90cecd | ||
|
|
ff4e2631cb | ||
|
|
9779685102 | ||
|
|
f2925725b8 | ||
|
|
9ca9fe3f99 | ||
|
|
8e5039fa7f | ||
|
|
2f47fe331d | ||
|
|
238abee126 | ||
|
|
a8d0fa3b94 | ||
|
|
27b3cfcd23 | ||
|
|
d3f1690dca | ||
|
|
d4d7eac240 | ||
|
|
e7fb246c25 | ||
|
|
03defb3a48 | ||
|
|
73c44d476b | ||
|
|
61aaaba452 | ||
|
|
9992bf8c32 | ||
|
|
b8b7d37799 | ||
|
|
0aaabfab62 | ||
|
|
14d680c092 | ||
|
|
28f95b0061 | ||
|
|
419dd555f4 | ||
|
|
1312ce7d50 | ||
|
|
d16cd510b3 | ||
|
|
af08ed5174 | ||
|
|
365e1d7029 | ||
|
|
80b75fc28c | ||
|
|
6de4b4ac2d | ||
|
|
39bfff6b14 | ||
|
|
e381b62e5c | ||
|
|
ffef5affce | ||
|
|
780276b30e | ||
|
|
e023f11cd9 | ||
|
|
276239dd2b | ||
|
|
5ef3bdcad4 | ||
|
|
63076d37f5 | ||
|
|
724ad58824 | ||
|
|
4f805910c4 | ||
|
|
7fcdb3b55d | ||
|
|
47ba40d7cd | ||
|
|
e6a75c1f8a | ||
|
|
0801e73242 | ||
|
|
c82487983c | ||
|
|
2ad39048da | ||
|
|
3421627f31 | ||
|
|
cf6262e995 | ||
|
|
8a325bf7c0 | ||
|
|
883667b411 | ||
|
|
06b88e74ae | ||
|
|
7800a25775 | ||
|
|
01d0de3909 | ||
|
|
d61f9472c8 | ||
|
|
2465337148 | ||
|
|
af4638ac50 | ||
|
|
5e6be09aab | ||
|
|
f626953e0a | ||
|
|
4d4bce301d | ||
|
|
d45b18afd6 | ||
|
|
4f212453a6 | ||
|
|
2c1b9081bf | ||
|
|
7c5dc1615c | ||
|
|
3fb07369bb | ||
|
|
9b480950da | ||
|
|
9f20d243fa | ||
|
|
13e6573342 | ||
|
|
f37869cb5a | ||
|
|
46d802548b | ||
|
|
2bf42c207d | ||
|
|
65e42e5d8b | ||
|
|
c85007f0dd | ||
|
|
36058145b5 | ||
|
|
a376a6b497 | ||
|
|
cd62d51e45 | ||
|
|
3f6fcbacbc | ||
|
|
2a2bf67fea | ||
|
|
b8fca5cd98 | ||
|
|
4d87155fdd | ||
|
|
ed38bb6299 | ||
|
|
7c9e3e79df | ||
|
|
43b90070a9 | ||
|
|
04f6e1068b | ||
|
|
9bea4adcf9 | ||
|
|
a5790de23c | ||
|
|
6489dda4c8 | ||
|
|
eb4edeea8f | ||
|
|
e3ccd504da | ||
|
|
4627d7d5df | ||
|
|
f216c1b65d | ||
|
|
4692ba7183 | ||
|
|
543825918c | ||
|
|
9f995b861b | ||
|
|
9963a6c655 | ||
|
|
1696ec5c72 | ||
|
|
7c95faad21 | ||
|
|
ca80441c14 | ||
|
|
9c497fd2bb | ||
|
|
ef5e5e5b4c | ||
|
|
ede900fdbd | ||
|
|
1758f03f93 | ||
|
|
f18db748e0 | ||
|
|
2fba2af126 | ||
|
|
0b1aa3315b | ||
|
|
1ef3d712e1 | ||
|
|
405adff8f4 | ||
|
|
9a21ca1cf4 | ||
|
|
49252e66ce | ||
|
|
4ba2e900c5 | ||
|
|
ebfc554cd7 | ||
|
|
b6714b559e | ||
|
|
a6c1757cf6 | ||
|
|
3d847ea00c | ||
|
|
63453942ee | ||
|
|
cb5a7904d3 | ||
|
|
f2c088fb18 | ||
|
|
cf3f2fc3ce | ||
|
|
a62fea1a4b | ||
|
|
f83e894758 | ||
|
|
131f6c6d5f | ||
|
|
4e2fd9dde6 | ||
|
|
534cf883be | ||
|
|
92c1ca7b6f | ||
|
|
5fcad35cdc | ||
|
|
8a83a1af23 | ||
|
|
2b11fcea41 | ||
|
|
690c30eed3 | ||
|
|
3f0c24d27b | ||
|
|
71088fca99 | ||
|
|
d6f7f0c0be | ||
|
|
4044b68701 | ||
|
|
610ced00af | ||
|
|
8cc86284ee | ||
|
|
e4f1f2bfa7 | ||
|
|
1f7a64a42d | ||
|
|
2b1cd537e2 | ||
|
|
f9fecda1f1 | ||
|
|
769d92fa0c | ||
|
|
f6d5d3cebc | ||
|
|
74921ae1b3 | ||
|
|
f3ef277a52 | ||
|
|
d30ec4a19d | ||
|
|
40f2c88ead | ||
|
|
7fe410ae89 | ||
|
|
4cbdd085d6 | ||
|
|
24556dab83 | ||
|
|
206157b68b | ||
|
|
dd8116eba8 | ||
|
|
ae82a8c0a0 | ||
|
|
418e4517a5 | ||
|
|
9bd0f0ceb1 | ||
|
|
af4dc5e9ea | ||
|
|
71b03a90dd | ||
|
|
728e50cd13 | ||
|
|
c8e8935b34 | ||
|
|
61a56662eb | ||
|
|
21f4b906d7 | ||
|
|
d49d42b736 | ||
|
|
6efd1af0e9 | ||
|
|
267fe4e453 | ||
|
|
8189b2ead8 | ||
|
|
257ac8962c | ||
|
|
faea6635c6 | ||
|
|
537521d568 | ||
|
|
e3ed75461e | ||
|
|
f6b59eeb99 | ||
|
|
b58db58e13 | ||
|
|
195dc18a09 | ||
|
|
187086460d | ||
|
|
84655cec40 | ||
|
|
413ca61ef5 | ||
|
|
49b1dd46d1 | ||
|
|
76e14d4434 | ||
|
|
f828ab0314 | ||
|
|
8d3d173762 | ||
|
|
12644fce28 | ||
|
|
358b499d32 | ||
|
|
17864d3325 | ||
|
|
7b6165c8f0 | ||
|
|
02b96470bc | ||
|
|
c90a2f5179 | ||
|
|
7234128f78 | ||
|
|
27e070e555 | ||
|
|
539950bdc5 | ||
|
|
83280e7cee | ||
|
|
cee0275565 | ||
|
|
87bddf0037 | ||
|
|
e736e3d810 | ||
|
|
47b2569b29 | ||
|
|
692975d4a0 | ||
|
|
6a843a6457 | ||
|
|
a139bdad1e | ||
|
|
b5dc298d7d | ||
|
|
561477fb1a | ||
|
|
092f15406c | ||
|
|
543766d564 | ||
|
|
befcd0f112 | ||
|
|
8b92936d05 | ||
|
|
aec135de83 | ||
|
|
f9738168e5 | ||
|
|
9789edad4c | ||
|
|
b46e02753a | ||
|
|
5f74fcad0c | ||
|
|
e873b88dbd | ||
|
|
214f5b8a98 | ||
|
|
70cf17fdd5 | ||
|
|
7d557810cb | ||
|
|
ed24282703 | ||
|
|
e2cf3e81af | ||
|
|
28fa133b6d | ||
|
|
37893ced80 | ||
|
|
34b5005591 | ||
|
|
900c48292c | ||
|
|
302ae5ddb4 | ||
|
|
24fca08fdc | ||
|
|
2b0a1c6a78 | ||
|
|
91077dc132 | ||
|
|
fcba5f9c91 | ||
|
|
5909025976 | ||
|
|
273bbea938 | ||
|
|
36c44ad201 | ||
|
|
46f5a2d7a7 | ||
|
|
c2702efc7f | ||
|
|
c96d13d416 | ||
|
|
3d52a307ca | ||
|
|
9b359a91cc | ||
|
|
0af831d50b | ||
|
|
3efaeaeeb9 | ||
|
|
00a20dda48 | ||
|
|
ec45160b27 | ||
|
|
eda284ca25 | ||
|
|
1ed331b4bb | ||
|
|
ee74bb4fbf | ||
|
|
df104669a3 | ||
|
|
9621b43bf2 | ||
|
|
503c1a5c8d | ||
|
|
a2fa1d4d78 | ||
|
|
6c6ad9a766 | ||
|
|
fc8807f74d | ||
|
|
edec48f9d4 | ||
|
|
ce98551057 | ||
|
|
74a2cc5b19 | ||
|
|
9628a940e9 | ||
|
|
d48643cbb9 | ||
|
|
558beaf216 | ||
|
|
6cc5d2452b | ||
|
|
e934ef7ffd | ||
|
|
e2d8032ef4 | ||
|
|
81e6101fb5 | ||
|
|
070354920b | ||
|
|
32c825463f | ||
|
|
404168fb86 | ||
|
|
d5fa9fd02b | ||
|
|
6cc66d91be | ||
|
|
1172a8c67b | ||
|
|
0191e5be00 | ||
|
|
7f367389bb | ||
|
|
e94c757d5b | ||
|
|
20a3b3b95e | ||
|
|
9145ea4234 | ||
|
|
0fa79433bd | ||
|
|
5185273b38 | ||
|
|
eadd23de4d | ||
|
|
71d9738980 | ||
|
|
29e213ebe2 | ||
|
|
80328af888 | ||
|
|
5e83da5575 | ||
|
|
521afe90ca | ||
|
|
ee3b7b5d7d | ||
|
|
95a9775c26 | ||
|
|
93d1319b25 | ||
|
|
b2bac3808b | ||
|
|
ebf58e68cf | ||
|
|
dc3e909dfe | ||
|
|
bf09e9615a | ||
|
|
b2d30d47d6 | ||
|
|
b1296fd24f | ||
|
|
62d8c89300 | ||
|
|
80c44eee89 | ||
|
|
153eb126fc | ||
|
|
f1fc166713 | ||
|
|
dda36bb036 | ||
|
|
843ad08813 | ||
|
|
a0e3c923e4 | ||
|
|
3095b03b09 | ||
|
|
8eb7663f37 | ||
|
|
ea5a0e0a7c | ||
|
|
2b4ec5b03b | ||
|
|
59f7add065 | ||
|
|
9c7bfc2347 | ||
|
|
ef90f40be4 | ||
|
|
bca6a4f22c | ||
|
|
1d3e2b56b2 | ||
|
|
a93a20ed5b | ||
|
|
971a80c183 | ||
|
|
125b60facc | ||
|
|
ef99b47178 | ||
|
|
d65f870213 | ||
|
|
5b3f1c9de2 | ||
|
|
4afb090cb1 | ||
|
|
8cf6e65ed8 | ||
|
|
c85c51ec08 | ||
|
|
dbeabea6c5 | ||
|
|
dfbe2d0f0f | ||
|
|
ea2277ea4e | ||
|
|
ad78105a6e | ||
|
|
36584175e2 | ||
|
|
084b1fdfd2 | ||
|
|
bed091e20f | ||
|
|
4420663687 | ||
|
|
677ae82ffa | ||
|
|
48a3fac9e0 | ||
|
|
e797d37b01 | ||
|
|
ffafd2e787 | ||
|
|
8500c971eb | ||
|
|
46a67a56fb | ||
|
|
5dc62e6df2 | ||
|
|
4479d6cf56 | ||
|
|
1faaf1f6b1 | ||
|
|
1f33ef191a | ||
|
|
d5bf8a6e3c | ||
|
|
5c252c7843 | ||
|
|
d2e10ccaf6 | ||
|
|
89d2b6870f | ||
|
|
26a9086d96 | ||
|
|
9fc8e6b1e5 | ||
|
|
6718650619 | ||
|
|
a622949f23 | ||
|
|
5a2df5b2c1 | ||
|
|
ae751cdd65 | ||
|
|
0d00bb2866 | ||
|
|
4299965064 | ||
|
|
6f3fc8db3d | ||
|
|
db731856f8 | ||
|
|
217f1dd6d4 | ||
|
|
cf87929476 | ||
|
|
d343a0720e | ||
|
|
51bd58edb2 | ||
|
|
f778d83316 | ||
|
|
b169206c68 | ||
|
|
4cea69e71d | ||
|
|
04d422b411 | ||
|
|
49dec52aa0 | ||
|
|
b83a62814d | ||
|
|
ce5449f25a | ||
|
|
aa769a8921 | ||
|
|
a8d4e6c754 | ||
|
|
f2402cf086 | ||
|
|
07a481f002 | ||
|
|
f41638db6d | ||
|
|
82baf41d8e | ||
|
|
e60370d4dc | ||
|
|
3e412323ee | ||
|
|
d635c1a056 | ||
|
|
f02db2a024 | ||
|
|
8c74324ff8 | ||
|
|
f2230eb39b | ||
|
|
ab900ae5da | ||
|
|
1cf28c56c2 | ||
|
|
08cd33a0cf | ||
|
|
7b0d2a239d | ||
|
|
5d922d934c | ||
|
|
5bab9ea914 | ||
|
|
da06d7e636 | ||
|
|
aece140473 | ||
|
|
656f87e3c6 | ||
|
|
36a95616d9 | ||
|
|
60bdc4715d | ||
|
|
7da168ab52 | ||
|
|
b20ad48b31 | ||
|
|
8b17495f0e | ||
|
|
e113e1b59c | ||
|
|
6bd4924c0b | ||
|
|
a2939aa183 | ||
|
|
effa726b65 | ||
|
|
20d9bf1f23 | ||
|
|
09e5d014df | ||
|
|
add1b8a68a | ||
|
|
fe143f66b7 | ||
|
|
75c8ca29ef | ||
|
|
c52779df23 | ||
|
|
b7b77646e6 | ||
|
|
1157c6b7cd | ||
|
|
ab824d7f66 | ||
|
|
702e28a6e6 | ||
|
|
0e0ab58959 | ||
|
|
e5cdc9ea3d | ||
|
|
d3c9744e65 | ||
|
|
6b02144b30 | ||
|
|
91cbc5ddf0 | ||
|
|
93aa1ca5aa | ||
|
|
6ae13ebb2f | ||
|
|
73cdbf64c7 | ||
|
|
0aeea965c0 | ||
|
|
a63b15637f | ||
|
|
5153b43bc6 | ||
|
|
9ea7d09722 | ||
|
|
b7b474b245 | ||
|
|
e2184a8bdf | ||
|
|
9ea84ca9ae | ||
|
|
50136e3689 | ||
|
|
e87f5e5f89 | ||
|
|
d44792d132 | ||
|
|
50b01e0b1b | ||
|
|
122c3a96c7 | ||
|
|
4d407bd646 | ||
|
|
16b6b37b94 | ||
|
|
8c766ef4f0 | ||
|
|
b7d2865435 | ||
|
|
8d9df5030c | ||
|
|
4a158b2547 | ||
|
|
af014cf8c1 | ||
|
|
1ddd7b1e6e | ||
|
|
9ef0179566 | ||
|
|
38690d18d4 | ||
|
|
e381e54bb7 | ||
|
|
7001f98353 | ||
|
|
a836642d74 | ||
|
|
a3d3add48f | ||
|
|
994060cce2 | ||
|
|
75103dc706 | ||
|
|
cf63c2fdc5 | ||
|
|
c07f5393f6 | ||
|
|
406c63721d | ||
|
|
97f3e02f59 | ||
|
|
5c6564f00b | ||
|
|
605e75efb3 | ||
|
|
496f9ba340 | ||
|
|
f98283f2dd | ||
|
|
2d32e85a64 | ||
|
|
1a56a22a33 | ||
|
|
c85204d0c1 | ||
|
|
1d8a958eaf | ||
|
|
b52f70d57d | ||
|
|
12687dae7b | ||
|
|
c53c3167d8 | ||
|
|
f15b4cf634 | ||
|
|
6430611e3e | ||
|
|
afe57e41ce | ||
|
|
86f3f90143 | ||
|
|
d3ea51cc01 | ||
|
|
60df472d53 | ||
|
|
67fe25bc8b | ||
|
|
94dbec4486 | ||
|
|
44a7aed308 | ||
|
|
f22937e1eb | ||
|
|
c816f8b15b | ||
|
|
fa4f4e7bea | ||
|
|
076cea6d6d | ||
|
|
d951b97bed | ||
|
|
bd888e57e5 | ||
|
|
3ba1234056 | ||
|
|
4c5a668114 | ||
|
|
06d1891389 | ||
|
|
c97a228ec8 | ||
|
|
d11a200ebb | ||
|
|
bfedc1f43e | ||
|
|
b148083750 | ||
|
|
cdfd7333cd | ||
|
|
2ed32efefb | ||
|
|
e3331e6dee | ||
|
|
b3406be1ad | ||
|
|
751d0ca861 | ||
|
|
a614693dfa | ||
|
|
c4bbceef67 | ||
|
|
d39e7c990d | ||
|
|
e248dbfa5b | ||
|
|
29f789493e | ||
|
|
2b0e5c41c8 | ||
|
|
e241b2fe72 | ||
|
|
a91983340c | ||
|
|
2aa3aaf2f8 | ||
|
|
6b2bf369ff | ||
|
|
fea5818951 | ||
|
|
e6eb71a484 | ||
|
|
1e023500be | ||
|
|
2c078de2c0 | ||
|
|
b373881b8b | ||
|
|
6b37cf0dda | ||
|
|
1025c4ec6e | ||
|
|
c44f9f685d | ||
|
|
5633d4504f | ||
|
|
c7aa49e541 | ||
|
|
ee139463d2 | ||
|
|
2cf7f0cc27 | ||
|
|
210549445e | ||
|
|
5b85158a7c | ||
|
|
09cba1a1df | ||
|
|
d75e2e85c9 | ||
|
|
966673dfd1 | ||
|
|
b8ad9265b9 | ||
|
|
b2f1fda641 | ||
|
|
1544b656e0 | ||
|
|
d97314c484 | ||
|
|
1432553897 | ||
|
|
f585fb0381 | ||
|
|
98557be52d | ||
|
|
e7da0a35d3 | ||
|
|
965a7f91ed | ||
|
|
2b1c973177 | ||
|
|
7e87cf7fdc | ||
|
|
c8cd5a4dc8 | ||
|
|
d00f7f6255 | ||
|
|
e8690e3419 | ||
|
|
72ff45c722 | ||
|
|
22a6271423 | ||
|
|
a6032ffd72 | ||
|
|
69ea9992ce | ||
|
|
36b4d69c2d | ||
|
|
2129533aa9 | ||
|
|
e635ace338 | ||
|
|
e729086015 | ||
|
|
3fbb7ce548 | ||
|
|
e0c31d6fdd | ||
|
|
d19c5b9775 | ||
|
|
f01b88083f | ||
|
|
d3ab1d1d2a | ||
|
|
7855442d33 | ||
|
|
bbaa6c76b4 | ||
|
|
cf71809f6c | ||
|
|
3b7d0ddb36 | ||
|
|
482fea91f3 | ||
|
|
4bace10bab | ||
|
|
603b7da9e6 | ||
|
|
bf4d168c96 | ||
|
|
756bb82fb7 | ||
|
|
81b1005b02 | ||
|
|
7ba52d1358 | ||
|
|
fcfb2c7e01 | ||
|
|
fa0a5afa63 | ||
|
|
b881b236a0 | ||
|
|
ec9c40bd64 | ||
|
|
f0f7857355 | ||
|
|
9c59cbbc40 | ||
|
|
6dce41a070 | ||
|
|
dffeb05f86 | ||
|
|
ecfbfd83b9 | ||
|
|
0aea1deea0 | ||
|
|
f041b92360 | ||
|
|
64074030dd | ||
|
|
3387dda22b | ||
|
|
534b810350 | ||
|
|
50666c1307 | ||
|
|
200c9bd9eb | ||
|
|
2f93c6fdc8 | ||
|
|
5c21c0a498 | ||
|
|
93c07d6842 | ||
|
|
5d9f18ed77 | ||
|
|
313d47d9ff | ||
|
|
304a9f3218 | ||
|
|
a160725acb | ||
|
|
05535b50da | ||
|
|
70fdcf2b44 | ||
|
|
67d47c283e | ||
|
|
dd59aab328 | ||
|
|
509b308419 | ||
|
|
7a12fd701e | ||
|
|
9396a06a89 | ||
|
|
9d9c3b7db5 | ||
|
|
4cfb10db7c | ||
|
|
50024b62cb | ||
|
|
f31084baa7 | ||
|
|
2688becb05 | ||
|
|
8298900f76 | ||
|
|
b0c18a8ece | ||
|
|
9659a12d70 | ||
|
|
6abc25f58a | ||
|
|
a164ac67ce | ||
|
|
255677d98e | ||
|
|
2a92ea4efd | ||
|
|
608b4f3009 | ||
|
|
a69cb1bf9c | ||
|
|
be40b3d160 | ||
|
|
51147b17cd | ||
|
|
5d0c2ae0b9 | ||
|
|
8435e69cb5 | ||
|
|
1d6a33e3be | ||
|
|
3f4d78af7d | ||
|
|
f94012d184 | ||
|
|
ca73c0c2ce | ||
|
|
742bf31038 | ||
|
|
1369b64cb1 | ||
|
|
bc3488512f | ||
|
|
51f87911e1 | ||
|
|
4d4af5c278 | ||
|
|
a6fcff4d86 | ||
|
|
50b7f9eb68 | ||
|
|
a81c534258 | ||
|
|
67cd2e6504 | ||
|
|
ed5c84b069 | ||
|
|
f6c442ba88 | ||
|
|
19e59b778d | ||
|
|
0643a74ade | ||
|
|
1babbb567c | ||
|
|
11899501f2 | ||
|
|
9982d7b7b4 | ||
|
|
22497199c5 | ||
|
|
873b180dab | ||
|
|
ca8e505cd0 | ||
|
|
465e1af838 | ||
|
|
2f66ee04cc | ||
|
|
a829594fad | ||
|
|
1427518e4b | ||
|
|
ecf75a07e4 | ||
|
|
41094c80d5 | ||
|
|
09b12c6d7e | ||
|
|
c560685285 | ||
|
|
48d2099308 | ||
|
|
ca3604129d | ||
|
|
583a68cabe | ||
|
|
924eaf8486 | ||
|
|
9c1a02dbd5 | ||
|
|
637a19ee21 | ||
|
|
896420064f | ||
|
|
f6140e8396 | ||
|
|
b59a3fb5de | ||
|
|
0a7f8631b6 | ||
|
|
de37cb0da1 | ||
|
|
50d0827972 | ||
|
|
a0d4be2379 | ||
|
|
e4eb5d1f0f | ||
|
|
9ae4d3df55 | ||
|
|
95b6b45cc4 | ||
|
|
9280d0ef7e | ||
|
|
5171b2e5e2 | ||
|
|
8455ff876a | ||
|
|
167ce97dd9 | ||
|
|
0503af6819 | ||
|
|
a218ee1c02 | ||
|
|
fbaba8ebe8 | ||
|
|
64c70f51c9 | ||
|
|
946da6e274 | ||
|
|
f42489ecb5 | ||
|
|
abd5fa00ed | ||
|
|
9450849b9d | ||
|
|
d66bda7adc | ||
|
|
461c4acfb7 | ||
|
|
ccb0840771 | ||
|
|
b5a706a6a9 | ||
|
|
a45b54dd85 | ||
|
|
a4d29f3e7a | ||
|
|
2a8ec8245f | ||
|
|
d85f32e1f2 | ||
|
|
adc7bb1d9e | ||
|
|
2dafde0117 | ||
|
|
7582a01b3f | ||
|
|
9081012673 | ||
|
|
bf83dfbd28 | ||
|
|
903b96e629 | ||
|
|
3e9007806e | ||
|
|
b6ec0091f0 | ||
|
|
726b4f5c0d | ||
|
|
2fdd3a2a85 | ||
|
|
4fbb39efd0 | ||
|
|
88ecb58153 | ||
|
|
d084da2914 | ||
|
|
716de6f9eb | ||
|
|
dfc4cbe01f | ||
|
|
3a5b4c48f5 | ||
|
|
08b0fd498e | ||
|
|
3f365e7ee1 | ||
|
|
ebb5c078c9 | ||
|
|
f536def5fa | ||
|
|
2918b8f04b | ||
|
|
bb3b05aab0 | ||
|
|
2cdfe2c158 | ||
|
|
d1dfafe1da | ||
|
|
144febb3cc | ||
|
|
22b2d50139 | ||
|
|
121e521eb7 | ||
|
|
7ada9cacda | ||
|
|
5585d69dcf | ||
|
|
f5d73a0c8f | ||
|
|
4563739f6e | ||
|
|
79ed081c99 | ||
|
|
dc1c9a9d37 | ||
|
|
25c90537c8 | ||
|
|
b7aadf4b17 | ||
|
|
b9587e0fd5 | ||
|
|
38e529f5bf | ||
|
|
81e2d7738f | ||
|
|
380a681cc1 | ||
|
|
8c2a557618 | ||
|
|
7b98d2ede2 | ||
|
|
64c544a86e | ||
|
|
a048125a14 | ||
|
|
4c37e16e92 | ||
|
|
c448bc151b | ||
|
|
efcbefe1c8 | ||
|
|
339a15803c | ||
|
|
582a06c05a | ||
|
|
852f1ec31a | ||
|
|
2ca142566c | ||
|
|
9a13d112cb | ||
|
|
bc9cbbc1f6 | ||
|
|
129bf49204 | ||
|
|
a29acb6f30 | ||
|
|
c957094b1e | ||
|
|
8e5743f417 | ||
|
|
4ff42f1ae4 | ||
|
|
06c8f5367e | ||
|
|
f74d7cf37e | ||
|
|
b971b4d07a | ||
|
|
09fd5d8642 | ||
|
|
97447e8d6c | ||
|
|
c388045c68 | ||
|
|
db4d998705 | ||
|
|
011cc992e8 | ||
|
|
2c1d8b0eea | ||
|
|
65ae22e70e | ||
|
|
81da60333d | ||
|
|
aa572febd4 | ||
|
|
6060cb457d | ||
|
|
6ac0107274 | ||
|
|
95de4855c0 | ||
|
|
bcfff70e78 | ||
|
|
11e7d34961 | ||
|
|
e48e523b90 | ||
|
|
4689b4290e | ||
|
|
f9af8d116c | ||
|
|
1f9339e5ab | ||
|
|
eae6b058fc | ||
|
|
c6d471cec9 | ||
|
|
f7914c57c7 | ||
|
|
6dd16f6791 |
30
.github/pull_request_template.md
vendored
Normal file
30
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
## Description
|
||||||
|
<!-- Add a detailed summary of what this feature/bugfix does -->
|
||||||
|
|
||||||
|
## Type of Change
|
||||||
|
- [ ] Bug fix
|
||||||
|
- [ ] Feature
|
||||||
|
|
||||||
|
## Visuals
|
||||||
|
- [ ] Attached relevant screenshots / screen recording / GIF
|
||||||
|
- [ ] N/A (not a feature or no UI changes)
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
- [ ] Ran all E2E tests
|
||||||
|
- [ ] Ran all integration tests
|
||||||
|
- [ ] Added/updated tests for this change (if needed)
|
||||||
|
- [ ] N/A (tests not needed — explanation provided below)
|
||||||
|
|
||||||
|
### If tests were not added, explain why
|
||||||
|
<!-- explanation -->
|
||||||
|
|
||||||
|
## Platform
|
||||||
|
<!-- Describe which platforms this PR is related to -->
|
||||||
|
|
||||||
|
- [ ] Web
|
||||||
|
- [ ] Mobile
|
||||||
|
- [ ] Desktop
|
||||||
|
|
||||||
|
## Sign-off
|
||||||
|
- [ ] QA passed
|
||||||
|
- [ ] UI/UX passed
|
||||||
2
.github/workflows/android.e2e.yml
vendored
2
.github/workflows/android.e2e.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Enable KVM
|
- name: Enable KVM
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
104
.github/workflows/android.preview.build.yml
vendored
Normal file
104
.github/workflows/android.preview.build.yml
vendored
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
name: Notesnook Android Preview Build
|
||||||
|
|
||||||
|
# UNTRUSTED stage. Runs on `pull_request`, so fork code is checked out and
|
||||||
|
# compiled with a read-only GITHUB_TOKEN and NO repository secrets. The release
|
||||||
|
# APK is built without any signing secret (the same as before). Firebase
|
||||||
|
# distribution and the PR comment happen in android.preview.publish.yml, which
|
||||||
|
# runs in the trusted `workflow_run` context and never executes fork code.
|
||||||
|
# Because no secrets are exposed here, the build runs automatically for every
|
||||||
|
# PR (including forks) with no authorization gate.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, reopened, synchronize]
|
||||||
|
branches: [master, beta]
|
||||||
|
paths:
|
||||||
|
- "apps/mobile/**"
|
||||||
|
- "packages/**"
|
||||||
|
- ".github/workflows/android.preview.build.yml"
|
||||||
|
- ".github/workflows/android.preview.publish.yml"
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: android-preview-${{ github.event.pull_request.number }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
env:
|
||||||
|
STAGING_BUILD: true
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout PR code
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: ./.github/actions/setup-node-with-cache
|
||||||
|
|
||||||
|
- name: Use specific Java version for the builds
|
||||||
|
uses: joschi/setup-jdk@v2
|
||||||
|
with:
|
||||||
|
java-version: "17"
|
||||||
|
architecture: "x64"
|
||||||
|
|
||||||
|
- name: Free Disk Space (Ubuntu)
|
||||||
|
uses: jlumbroso/free-disk-space@main
|
||||||
|
with:
|
||||||
|
tool-cache: false
|
||||||
|
android: false
|
||||||
|
dotnet: true
|
||||||
|
haskell: true
|
||||||
|
large-packages: true
|
||||||
|
docker-images: true
|
||||||
|
swap-storage: true
|
||||||
|
|
||||||
|
- name: Install node modules
|
||||||
|
run: |
|
||||||
|
npm ci --ignore-scripts --prefer-offline --no-audit
|
||||||
|
npm run bootstrap -- --scope=mobile
|
||||||
|
|
||||||
|
- name: Make Gradlew Executable
|
||||||
|
run: cd apps/mobile/android && chmod +x ./gradlew
|
||||||
|
|
||||||
|
- name: Get build number
|
||||||
|
id: build-number
|
||||||
|
run: echo "timestamp=$(($(date +%s) - 1774851180 ))" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Check for typescript errors
|
||||||
|
run: |
|
||||||
|
npm run tx mobile:build
|
||||||
|
cd apps/mobile
|
||||||
|
npx tsc --noEmit
|
||||||
|
|
||||||
|
- name: Build arm64-v8a apk
|
||||||
|
run: |
|
||||||
|
cd apps/mobile/android
|
||||||
|
./gradlew assembleRelease -PreactNativeArchitectures=arm64-v8a -PstagingReleaseBuild=true -PprBuildNumber=${{ steps.build-number.outputs.timestamp }}
|
||||||
|
|
||||||
|
- name: Stage build artifact
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p "$RUNNER_TEMP/artifact"
|
||||||
|
cp apps/mobile/android/app/build/outputs/apk/release/app-arm64-v8a-release.apk \
|
||||||
|
"$RUNNER_TEMP/artifact/app-preview.apk"
|
||||||
|
{
|
||||||
|
echo "PR_NUMBER=${{ github.event.pull_request.number }}"
|
||||||
|
echo "HEAD_SHA=${{ github.event.pull_request.head.sha }}"
|
||||||
|
} > "$RUNNER_TEMP/artifact/pr-meta.env"
|
||||||
|
|
||||||
|
- name: Upload build artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: android-preview-build
|
||||||
|
path: ${{ runner.temp }}/artifact
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
- name: Upload sourcemaps
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: sourcemaps
|
||||||
|
path: |
|
||||||
|
apps/mobile/android/app/build/generated/sourcemaps/**/*.map
|
||||||
72
.github/workflows/android.preview.publish.yml
vendored
Normal file
72
.github/workflows/android.preview.publish.yml
vendored
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
name: Notesnook Android Preview Publish
|
||||||
|
|
||||||
|
# TRUSTED stage. Runs via `workflow_run` after the build workflow finishes, so
|
||||||
|
# it has the base repo's secrets and a write-scoped token. It downloads the APK
|
||||||
|
# the build produced and distributes it + posts the PR comment. It never checks
|
||||||
|
# out or executes fork code.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: ["Notesnook Android Preview Build"]
|
||||||
|
types: [completed]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
if: github.event.workflow_run.conclusion == 'success'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download build artifact
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: android-preview-build
|
||||||
|
path: ${{ runner.temp }}/artifact
|
||||||
|
run-id: ${{ github.event.workflow_run.id }}
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Load PR metadata
|
||||||
|
run: cat "$RUNNER_TEMP/artifact/pr-meta.env" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Publish to Firebase
|
||||||
|
id: firebase-output
|
||||||
|
uses: wzieba/Firebase-Distribution-Github-Action@v1
|
||||||
|
with:
|
||||||
|
appId: ${{ secrets.FIREBASE_APP_ID }}
|
||||||
|
serviceCredentialsFileContent: ${{ secrets.QA_SERVICE_ACCOUNT }}
|
||||||
|
groups: testers
|
||||||
|
file: ${{ runner.temp }}/artifact/app-preview.apk
|
||||||
|
releaseNotes: Preview for https://github.com/${{ github.repository }}/pull/${{ env.PR_NUMBER }}
|
||||||
|
|
||||||
|
- name: Post or update PR comment
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
env:
|
||||||
|
preview_url: ${{ steps.firebase-output.outputs.TESTING_URI }}
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const marker = '<!-- android-preview-comment -->';
|
||||||
|
const prNumber = Number(process.env.PR_NUMBER);
|
||||||
|
if (!prNumber) return;
|
||||||
|
const previewUrl = process.env.preview_url || '';
|
||||||
|
const body = `${marker}\n**Android App Preview**\n\n${previewUrl || 'Preview URL unavailable — check workflow logs.'}\n\nCommit: ${process.env.HEAD_SHA}\n`;
|
||||||
|
const { data: comments } = await github.rest.issues.listComments({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: prNumber,
|
||||||
|
});
|
||||||
|
const existing = comments.find(c => c.body && c.body.includes(marker));
|
||||||
|
if (existing) {
|
||||||
|
await github.rest.issues.updateComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
comment_id: existing.id,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: prNumber,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: ./.github/actions/setup-node-with-cache
|
uses: ./.github/actions/setup-node-with-cache
|
||||||
@@ -113,7 +113,7 @@ jobs:
|
|||||||
keyStorePassword: ${{ secrets.KEY_PASSWORD }}
|
keyStorePassword: ${{ secrets.KEY_PASSWORD }}
|
||||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||||
env:
|
env:
|
||||||
BUILD_TOOLS_VERSION: "33.0.0"
|
BUILD_TOOLS_VERSION: "36.0.0"
|
||||||
|
|
||||||
- name: Build apks for Github release
|
- name: Build apks for Github release
|
||||||
run: yarn release:android
|
run: yarn release:android
|
||||||
@@ -128,7 +128,7 @@ jobs:
|
|||||||
keyStorePassword: ${{ secrets.PUBLIC_KEY_PASSWORD }}
|
keyStorePassword: ${{ secrets.PUBLIC_KEY_PASSWORD }}
|
||||||
keyPassword: ${{ secrets.PUBLIC_KEY_PASSWORD }}
|
keyPassword: ${{ secrets.PUBLIC_KEY_PASSWORD }}
|
||||||
env:
|
env:
|
||||||
BUILD_TOOLS_VERSION: "33.0.0"
|
BUILD_TOOLS_VERSION: "36.0.0"
|
||||||
|
|
||||||
- name: Rename apk files
|
- name: Rename apk files
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
26
.github/workflows/android.publish.yml
vendored
26
.github/workflows/android.publish.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: ./.github/actions/setup-node-with-cache
|
uses: ./.github/actions/setup-node-with-cache
|
||||||
@@ -24,6 +24,22 @@ jobs:
|
|||||||
java-version: "17"
|
java-version: "17"
|
||||||
architecture: "x64"
|
architecture: "x64"
|
||||||
|
|
||||||
|
- name: Free Disk Space (Ubuntu)
|
||||||
|
uses: jlumbroso/free-disk-space@main
|
||||||
|
with:
|
||||||
|
# this might remove tools that are actually needed,
|
||||||
|
# if set to "true" but frees about 6 GB
|
||||||
|
tool-cache: false
|
||||||
|
|
||||||
|
# all of these default to true, but feel free to set to
|
||||||
|
# "false" if necessary for your workflow
|
||||||
|
android: false
|
||||||
|
dotnet: true
|
||||||
|
haskell: true
|
||||||
|
large-packages: true
|
||||||
|
docker-images: true
|
||||||
|
swap-storage: true
|
||||||
|
|
||||||
- name: Install node modules
|
- name: Install node modules
|
||||||
run: |
|
run: |
|
||||||
npm ci --ignore-scripts --prefer-offline --no-audit
|
npm ci --ignore-scripts --prefer-offline --no-audit
|
||||||
@@ -97,7 +113,7 @@ jobs:
|
|||||||
keyStorePassword: ${{ secrets.KEY_PASSWORD }}
|
keyStorePassword: ${{ secrets.KEY_PASSWORD }}
|
||||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||||
env:
|
env:
|
||||||
BUILD_TOOLS_VERSION: "33.0.0"
|
BUILD_TOOLS_VERSION: "36.0.0"
|
||||||
|
|
||||||
- name: Build apks for Github release
|
- name: Build apks for Github release
|
||||||
run: yarn release:android
|
run: yarn release:android
|
||||||
@@ -112,7 +128,7 @@ jobs:
|
|||||||
keyStorePassword: ${{ secrets.PUBLIC_KEY_PASSWORD }}
|
keyStorePassword: ${{ secrets.PUBLIC_KEY_PASSWORD }}
|
||||||
keyPassword: ${{ secrets.PUBLIC_KEY_PASSWORD }}
|
keyPassword: ${{ secrets.PUBLIC_KEY_PASSWORD }}
|
||||||
env:
|
env:
|
||||||
BUILD_TOOLS_VERSION: "33.0.0"
|
BUILD_TOOLS_VERSION: "36.0.0"
|
||||||
|
|
||||||
- name: Rename apk files
|
- name: Rename apk files
|
||||||
run: |
|
run: |
|
||||||
@@ -143,8 +159,8 @@ jobs:
|
|||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v1
|
||||||
with:
|
with:
|
||||||
draft: true
|
draft: true
|
||||||
tag_name: ${{ steps.package-version.outputs.current-version}}-android
|
tag_name: ${{ steps.package-version.outputs.version}}-android
|
||||||
name: Notesnook Android v${{ steps.package-version.outputs.current-version}}
|
name: Notesnook Android v${{ steps.package-version.outputs.version}}
|
||||||
repository: streetwriters/notesnook
|
repository: streetwriters/notesnook
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
files: |
|
files: |
|
||||||
|
|||||||
2
.github/workflows/core.tests.yml
vendored
2
.github/workflows/core.tests.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
|||||||
needs: authorize
|
needs: authorize
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.ref }}
|
ref: ${{ github.event.pull_request.head.sha || github.ref }}
|
||||||
|
|
||||||
|
|||||||
303
.github/workflows/desktop.preview.yml
vendored
Normal file
303
.github/workflows/desktop.preview.yml
vendored
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
name: Preview @notesnook/desktop
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, reopened, synchronize]
|
||||||
|
branches: [master, beta]
|
||||||
|
paths:
|
||||||
|
- "apps/desktop/**"
|
||||||
|
- "apps/web/**"
|
||||||
|
- "packages/**"
|
||||||
|
# re-run workflow if workflow file changes
|
||||||
|
- ".github/workflows/desktop.preview.yml"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
if: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
|
||||||
|
name: Build
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: ./.github/actions/setup-node-with-cache
|
||||||
|
|
||||||
|
- name: Install packages
|
||||||
|
run: |
|
||||||
|
npm ci --ignore-scripts --prefer-offline --no-audit
|
||||||
|
npm run bootstrap -- --scope=web
|
||||||
|
npm run bootstrap -- --scope=desktop
|
||||||
|
|
||||||
|
- name: Install sqlite-better-trigram for all arch
|
||||||
|
run: |
|
||||||
|
npm i --cpu arm64 sqlite-better-trigram
|
||||||
|
npm i --cpu x64 sqlite-better-trigram
|
||||||
|
working-directory: ./apps/desktop
|
||||||
|
|
||||||
|
- name: Generate desktop build (stable)
|
||||||
|
run: npm run tx @notesnook/web:build:desktop
|
||||||
|
|
||||||
|
- name: Build desktop bundle
|
||||||
|
working-directory: ./apps/desktop
|
||||||
|
run: npm run bundle
|
||||||
|
|
||||||
|
- name: Archive build artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: build
|
||||||
|
path: apps/web/build/**/*
|
||||||
|
|
||||||
|
build-macos:
|
||||||
|
name: Build for macOS
|
||||||
|
needs: build
|
||||||
|
runs-on: macos-14
|
||||||
|
outputs:
|
||||||
|
macos-artifact-url: ${{ steps.artifact-upload-step.outputs.artifact-url }}
|
||||||
|
steps:
|
||||||
|
- name: Check out Git repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: ./.github/actions/setup-node-with-cache
|
||||||
|
|
||||||
|
- name: Install setuptools
|
||||||
|
run: brew install python-setuptools
|
||||||
|
|
||||||
|
- name: Setup notarization
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/private_keys/
|
||||||
|
echo '${{ secrets.api_key }}' > ~/private_keys/AuthKey_${{ secrets.api_key_id }}.p8
|
||||||
|
|
||||||
|
- name: Collect app metadata
|
||||||
|
id: app_metadata
|
||||||
|
working-directory: ./apps/desktop
|
||||||
|
run: |
|
||||||
|
echo ::set-output name=apple_app_id::$(cat package.json | jq -r .appAppleId)
|
||||||
|
echo ::set-output name=app_bundle_id::$(cat package.json | jq -r .build.appId)
|
||||||
|
echo ::set-output name=app_version::$(cat package.json | jq -r .version)
|
||||||
|
echo ::set-output name=bundle_version::$(cat package.json | jq -r .build.mac.bundleVersion)
|
||||||
|
|
||||||
|
- name: Download build
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: build
|
||||||
|
path: ./apps/web/build
|
||||||
|
|
||||||
|
- name: Install packages
|
||||||
|
run: |
|
||||||
|
npm ci --ignore-scripts --prefer-offline --no-audit
|
||||||
|
npm run bootstrap -- --scope=desktop
|
||||||
|
|
||||||
|
- name: Install sqlite-better-trigram for all arch
|
||||||
|
run: |
|
||||||
|
npm i --cpu arm64 sqlite-better-trigram
|
||||||
|
npm i --cpu x64 sqlite-better-trigram
|
||||||
|
working-directory: ./apps/desktop
|
||||||
|
|
||||||
|
- name: Install provisioning profile
|
||||||
|
run: echo "${{ secrets.MAC_PROVISIONING_PROFILE }}" | base64 --decode > embedded.provisionprofile
|
||||||
|
working-directory: ./apps/desktop
|
||||||
|
|
||||||
|
- name: Build dmg
|
||||||
|
env:
|
||||||
|
CSC_LINK: ${{ secrets.mac_certs }}
|
||||||
|
CSC_KEY_PASSWORD: ${{ secrets.mac_certs_password }}
|
||||||
|
APPLE_API_KEY: ~/private_keys/AuthKey_${{ secrets.api_key_id }}.p8
|
||||||
|
APPLE_API_KEY_ID: ${{ secrets.api_key_id }}
|
||||||
|
APPLE_API_ISSUER: ${{ secrets.api_key_issuer_id }}
|
||||||
|
CSC_FOR_PULL_REQUEST: true
|
||||||
|
run: |
|
||||||
|
npm run tx @notesnook/desktop:release
|
||||||
|
cd apps/desktop
|
||||||
|
yarn electron-builder --config=electron-builder.config.js --mac dmg --arm64 -p never
|
||||||
|
|
||||||
|
- name: Upload dmg artifact
|
||||||
|
id: artifact-upload-step
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: macos-build
|
||||||
|
path: apps/desktop/output/*.dmg
|
||||||
|
|
||||||
|
build-linux-x64:
|
||||||
|
name: Build for Linux x64
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
outputs:
|
||||||
|
linux-x64-artifact-url: ${{ steps.artifact-upload-step.outputs.artifact-url }}
|
||||||
|
steps:
|
||||||
|
- name: Check out Git repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: ./.github/actions/setup-node-with-cache
|
||||||
|
|
||||||
|
- name: Download build
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: build
|
||||||
|
path: ./apps/web/build
|
||||||
|
|
||||||
|
- name: Install packages
|
||||||
|
run: |
|
||||||
|
npm ci --ignore-scripts --prefer-offline --no-audit
|
||||||
|
npm run bootstrap -- --scope=desktop
|
||||||
|
|
||||||
|
- name: Install sqlite-better-trigram for all arch
|
||||||
|
run: |
|
||||||
|
npm i --cpu x64 sqlite-better-trigram
|
||||||
|
npm i --cpu x64 sqlite3-fts5-html
|
||||||
|
working-directory: ./apps/desktop
|
||||||
|
|
||||||
|
- name: Build Electron wrapper
|
||||||
|
run: npm run tx @notesnook/desktop:release
|
||||||
|
|
||||||
|
- name: Build AppImage
|
||||||
|
run: yarn electron-builder --config=electron-builder.config.js --linux AppImage:x64 -p never
|
||||||
|
working-directory: ./apps/desktop
|
||||||
|
|
||||||
|
- name: Upload AppImage artifact
|
||||||
|
id: artifact-upload-step
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: linux-x64-build
|
||||||
|
path: apps/desktop/output/*.AppImage
|
||||||
|
|
||||||
|
build-linux-arm64:
|
||||||
|
name: Build for Linux arm64
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-22.04-arm
|
||||||
|
outputs:
|
||||||
|
linux-arm64-artifact-url: ${{ steps.artifact-upload-step.outputs.artifact-url }}
|
||||||
|
steps:
|
||||||
|
- name: Check out Git repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: ./.github/actions/setup-node-with-cache
|
||||||
|
|
||||||
|
- name: Download build
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: build
|
||||||
|
path: ./apps/web/build
|
||||||
|
|
||||||
|
- name: Install packages
|
||||||
|
run: |
|
||||||
|
npm ci --ignore-scripts --prefer-offline --no-audit
|
||||||
|
npm run bootstrap -- --scope=desktop
|
||||||
|
|
||||||
|
- name: Install sqlite-better-trigram for all arch
|
||||||
|
run: |
|
||||||
|
npm i --cpu arm64 sqlite-better-trigram
|
||||||
|
npm i --cpu arm64 sqlite3-fts5-html
|
||||||
|
working-directory: ./apps/desktop
|
||||||
|
|
||||||
|
- name: Build Electron wrapper
|
||||||
|
run: npm run tx @notesnook/desktop:release
|
||||||
|
|
||||||
|
- name: Build AppImage
|
||||||
|
run: yarn electron-builder --config=electron-builder.config.js --linux AppImage:arm64 -p never
|
||||||
|
working-directory: ./apps/desktop
|
||||||
|
|
||||||
|
- name: Upload AppImage artifact
|
||||||
|
id: artifact-upload-step
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: linux-arm64-build
|
||||||
|
path: apps/desktop/output/*.AppImage
|
||||||
|
|
||||||
|
build-windows:
|
||||||
|
name: Build for Windows
|
||||||
|
needs: build
|
||||||
|
runs-on: windows-2022
|
||||||
|
outputs:
|
||||||
|
windows-artifact-url: ${{ steps.artifact-upload-step.outputs.artifact-url }}
|
||||||
|
steps:
|
||||||
|
- name: Check out Git repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: ./.github/actions/setup-node-with-cache
|
||||||
|
|
||||||
|
- name: Download build
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: build
|
||||||
|
path: ./apps/web/build
|
||||||
|
|
||||||
|
- name: Install packages
|
||||||
|
run: |
|
||||||
|
npm ci --ignore-scripts --prefer-offline --no-audit
|
||||||
|
npm run bootstrap -- --scope=desktop
|
||||||
|
|
||||||
|
- name: Install sqlite-better-trigram for all arch
|
||||||
|
run: |
|
||||||
|
npm i --cpu arm64 sqlite-better-trigram
|
||||||
|
npm i --cpu x64 sqlite-better-trigram
|
||||||
|
|
||||||
|
npm i --cpu arm64 sqlite3-fts5-html
|
||||||
|
npm i --cpu x64 sqlite3-fts5-html
|
||||||
|
working-directory: ./apps/desktop
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: node scripts/execute.mjs @notesnook/desktop:release
|
||||||
|
|
||||||
|
- name: Publish
|
||||||
|
env:
|
||||||
|
NOTESNOOK_STAGING: true
|
||||||
|
run: yarn electron-builder --config=electron-builder.config.js --win nsis:x64 --publish never
|
||||||
|
working-directory: ./apps/desktop
|
||||||
|
|
||||||
|
- name: Upload exe artifact
|
||||||
|
id: artifact-upload-step
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: windows-build
|
||||||
|
path: apps/desktop/output/*.exe
|
||||||
|
|
||||||
|
post-pr-comment:
|
||||||
|
name: Post PR comment with preview URLs
|
||||||
|
needs:
|
||||||
|
[build, build-macos, build-linux-x64, build-linux-arm64, build-windows]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Post or update PR comment
|
||||||
|
uses: actions/github-script@v6
|
||||||
|
env:
|
||||||
|
macos_artifact_url: ${{ needs.build-macos.outputs.macos-artifact-url }}
|
||||||
|
linux_x64_artifact_url: ${{ needs.build-linux-x64.outputs.linux-x64-artifact-url }}
|
||||||
|
linux_arm64_artifact_url: ${{ needs.build-linux-arm64.outputs.linux-arm64-artifact-url }}
|
||||||
|
windows_artifact_url: ${{ needs.build-windows.outputs.windows-artifact-url }}
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const marker = '<!-- desktop-preview-comment -->';
|
||||||
|
const prNumber = context.issue.number;
|
||||||
|
const previewUrl = [
|
||||||
|
{ platform: 'macOS', url: process.env.macos_artifact_url },
|
||||||
|
{ platform: 'Linux x64', url: process.env.linux_x64_artifact_url },
|
||||||
|
{ platform: 'Linux arm64', url: process.env.linux_arm64_artifact_url },
|
||||||
|
{ platform: 'Windows x64', url: process.env.windows_artifact_url },
|
||||||
|
].filter(u => u.url).map(u => `- [${u.platform}](${u.url})`).join('\n');
|
||||||
|
const body = `${marker}\n**Desktop Previews**\n\n${previewUrl || 'Preview URL unavailable — check workflow logs.'}\n\nCommit: ${process.env.GITHUB_SHA}\n`;
|
||||||
|
const { data: comments } = await github.rest.issues.listComments({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: prNumber,
|
||||||
|
});
|
||||||
|
const existing = comments.find(c => c.body && c.body.includes(marker));
|
||||||
|
if (existing) {
|
||||||
|
await github.rest.issues.updateComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
comment_id: existing.id,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: prNumber,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
25
.github/workflows/desktop.publish.yml
vendored
25
.github/workflows/desktop.publish.yml
vendored
@@ -48,7 +48,7 @@ jobs:
|
|||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: ./.github/actions/setup-node-with-cache
|
uses: ./.github/actions/setup-node-with-cache
|
||||||
@@ -107,7 +107,7 @@ jobs:
|
|||||||
target_commitish: ${{ github.ref }}
|
target_commitish: ${{ github.ref }}
|
||||||
|
|
||||||
- name: Generate flatpak sources
|
- name: Generate flatpak sources
|
||||||
if: inputs.publish-github && inputs.build-linux
|
if: inputs.build-linux
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y --quiet flatpak flatpak-builder pipx
|
sudo apt-get install -y --quiet flatpak flatpak-builder pipx
|
||||||
@@ -127,6 +127,13 @@ jobs:
|
|||||||
files: ./generated-sources.json
|
files: ./generated-sources.json
|
||||||
prerelease: ${{ endsWith(steps.app_metadata.outputs.app_version, '-beta') }}
|
prerelease: ${{ endsWith(steps.app_metadata.outputs.app_version, '-beta') }}
|
||||||
|
|
||||||
|
- name: Upload generated-sources.json as artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: inputs.build-linux
|
||||||
|
with:
|
||||||
|
name: generated-sources
|
||||||
|
path: ./generated-sources.json
|
||||||
|
|
||||||
build-macos:
|
build-macos:
|
||||||
name: Build for macOS
|
name: Build for macOS
|
||||||
needs: build
|
needs: build
|
||||||
@@ -135,7 +142,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out Git repository
|
- name: Check out Git repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: ./.github/actions/setup-node-with-cache
|
uses: ./.github/actions/setup-node-with-cache
|
||||||
@@ -182,7 +189,9 @@ jobs:
|
|||||||
- name: Install sqlite-better-trigram for all arch
|
- name: Install sqlite-better-trigram for all arch
|
||||||
run: |
|
run: |
|
||||||
npm i --cpu arm64 sqlite-better-trigram
|
npm i --cpu arm64 sqlite-better-trigram
|
||||||
|
npm i --cpu arm64 sqlite3-fts5-html
|
||||||
npm i --cpu x64 sqlite-better-trigram
|
npm i --cpu x64 sqlite-better-trigram
|
||||||
|
npm i --cpu x64 sqlite3-fts5-html
|
||||||
working-directory: ./apps/desktop
|
working-directory: ./apps/desktop
|
||||||
|
|
||||||
- name: Install provisioning profile
|
- name: Install provisioning profile
|
||||||
@@ -237,7 +246,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out Git repository
|
- name: Check out Git repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: ./.github/actions/setup-node-with-cache
|
uses: ./.github/actions/setup-node-with-cache
|
||||||
@@ -264,6 +273,7 @@ jobs:
|
|||||||
- name: Install sqlite-better-trigram for all arch
|
- name: Install sqlite-better-trigram for all arch
|
||||||
run: |
|
run: |
|
||||||
npm i --cpu x64 sqlite-better-trigram
|
npm i --cpu x64 sqlite-better-trigram
|
||||||
|
npm i --cpu x64 sqlite3-fts5-html
|
||||||
working-directory: ./apps/desktop
|
working-directory: ./apps/desktop
|
||||||
|
|
||||||
- name: Build Electron wrapper
|
- name: Build Electron wrapper
|
||||||
@@ -304,7 +314,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out Git repository
|
- name: Check out Git repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: ./.github/actions/setup-node-with-cache
|
uses: ./.github/actions/setup-node-with-cache
|
||||||
@@ -331,6 +341,7 @@ jobs:
|
|||||||
- name: Install sqlite-better-trigram for all arch
|
- name: Install sqlite-better-trigram for all arch
|
||||||
run: |
|
run: |
|
||||||
npm i --cpu arm64 sqlite-better-trigram
|
npm i --cpu arm64 sqlite-better-trigram
|
||||||
|
npm i --cpu arm64 sqlite3-fts5-html
|
||||||
working-directory: ./apps/desktop
|
working-directory: ./apps/desktop
|
||||||
|
|
||||||
- name: Build Electron wrapper
|
- name: Build Electron wrapper
|
||||||
@@ -363,11 +374,11 @@ jobs:
|
|||||||
name: Build for Windows
|
name: Build for Windows
|
||||||
needs: build
|
needs: build
|
||||||
if: inputs.build-windows
|
if: inputs.build-windows
|
||||||
runs-on: windows-latest
|
runs-on: windows-2022
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out Git repository
|
- name: Check out Git repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Setup .NET Core SDK
|
- name: Setup .NET Core SDK
|
||||||
uses: actions/setup-dotnet@v2
|
uses: actions/setup-dotnet@v2
|
||||||
|
|||||||
115
.github/workflows/desktop.tests.yml
vendored
115
.github/workflows/desktop.tests.yml
vendored
@@ -18,12 +18,13 @@ on:
|
|||||||
- ".github/workflows/desktop.tests.yml"
|
- ".github/workflows/desktop.tests.yml"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
test-macos-x64:
|
||||||
name: Build
|
name: Test macOS x64
|
||||||
runs-on: ubuntu-22.04
|
runs-on: macos-15-intel
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Check out Git repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: ./.github/actions/setup-node-with-cache
|
uses: ./.github/actions/setup-node-with-cache
|
||||||
@@ -33,44 +34,10 @@ jobs:
|
|||||||
npm ci --ignore-scripts --prefer-offline --no-audit
|
npm ci --ignore-scripts --prefer-offline --no-audit
|
||||||
npm run bootstrap -- --scope=web
|
npm run bootstrap -- --scope=web
|
||||||
|
|
||||||
- name: Generate desktop build
|
- name: Install sqlite-better-trigram for all arch
|
||||||
run: npm run tx @notesnook/web:build:desktop
|
|
||||||
|
|
||||||
- name: Archive build artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: build
|
|
||||||
path: apps/web/build/**/*
|
|
||||||
|
|
||||||
test-macos-x64:
|
|
||||||
name: Test macOS x64
|
|
||||||
needs: build
|
|
||||||
runs-on: macos-13
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Check out Git repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: ./.github/actions/setup-node-with-cache
|
|
||||||
|
|
||||||
- name: Download build
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: build
|
|
||||||
path: ./apps/web/build
|
|
||||||
|
|
||||||
- name: Install packages
|
|
||||||
run: |
|
run: |
|
||||||
npm ci --ignore-scripts --prefer-offline --no-audit
|
npm i --cpu x64 sqlite-better-trigram
|
||||||
npm run bootstrap -- --scope=desktop
|
npm i --cpu x64 sqlite3-fts5-html
|
||||||
|
|
||||||
- name: Build Electron wrapper
|
|
||||||
run: npm run tx @notesnook/desktop:release
|
|
||||||
|
|
||||||
- name: Build app
|
|
||||||
run: |
|
|
||||||
yarn electron-builder --config=electron-builder.config.js --mac --dir --x64
|
|
||||||
working-directory: ./apps/desktop
|
working-directory: ./apps/desktop
|
||||||
|
|
||||||
- name: Run tests x64
|
- name: Run tests x64
|
||||||
@@ -87,33 +54,24 @@ jobs:
|
|||||||
|
|
||||||
test-macos:
|
test-macos:
|
||||||
name: Test macOS
|
name: Test macOS
|
||||||
needs: build
|
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out Git repository
|
- name: Check out Git repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: ./.github/actions/setup-node-with-cache
|
uses: ./.github/actions/setup-node-with-cache
|
||||||
|
|
||||||
- name: Download build
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: build
|
|
||||||
path: ./apps/web/build
|
|
||||||
|
|
||||||
- name: Install packages
|
- name: Install packages
|
||||||
run: |
|
run: |
|
||||||
npm ci --ignore-scripts --prefer-offline --no-audit
|
npm ci --ignore-scripts --prefer-offline --no-audit
|
||||||
npm run bootstrap -- --scope=desktop
|
npm run bootstrap -- --scope=web
|
||||||
|
|
||||||
- name: Build Electron wrapper
|
- name: Install sqlite-better-trigram for all arch
|
||||||
run: npm run tx @notesnook/desktop:release
|
|
||||||
|
|
||||||
- name: Build app
|
|
||||||
run: |
|
run: |
|
||||||
yarn electron-builder --config=electron-builder.config.js --mac --dir --arm64
|
npm i --cpu arm64 sqlite-better-trigram
|
||||||
|
npm i --cpu arm64 sqlite3-fts5-html
|
||||||
working-directory: ./apps/desktop
|
working-directory: ./apps/desktop
|
||||||
|
|
||||||
- name: Run tests arm64
|
- name: Run tests arm64
|
||||||
@@ -130,39 +88,24 @@ jobs:
|
|||||||
|
|
||||||
test-linux:
|
test-linux:
|
||||||
name: Test for Linux
|
name: Test for Linux
|
||||||
needs: build
|
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out Git repository
|
- name: Check out Git repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: ./.github/actions/setup-node-with-cache
|
uses: ./.github/actions/setup-node-with-cache
|
||||||
|
|
||||||
- name: Download build
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: build
|
|
||||||
path: ./apps/web/build
|
|
||||||
|
|
||||||
- name: Install packages
|
- name: Install packages
|
||||||
run: |
|
run: |
|
||||||
npm ci --ignore-scripts --prefer-offline --no-audit
|
npm ci --ignore-scripts --prefer-offline --no-audit
|
||||||
npm run bootstrap -- --scope=desktop
|
npm run bootstrap -- --scope=web
|
||||||
|
|
||||||
- name: Install sqlite-better-trigram for all arch
|
- name: Install sqlite-better-trigram for all arch
|
||||||
run: |
|
run: |
|
||||||
npm i --cpu arm64 sqlite-better-trigram
|
|
||||||
npm i --cpu x64 sqlite-better-trigram
|
npm i --cpu x64 sqlite-better-trigram
|
||||||
working-directory: ./apps/desktop
|
npm i --cpu x64 sqlite3-fts5-html
|
||||||
|
|
||||||
- name: Build Electron wrapper
|
|
||||||
run: npm run tx @notesnook/desktop:release
|
|
||||||
|
|
||||||
- name: Build app
|
|
||||||
run: |
|
|
||||||
yarn electron-builder --config=electron-builder.config.js --linux --dir --arm64 --x64
|
|
||||||
working-directory: ./apps/desktop
|
working-directory: ./apps/desktop
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
@@ -179,44 +122,26 @@ jobs:
|
|||||||
|
|
||||||
test-windows:
|
test-windows:
|
||||||
name: Test for Windows
|
name: Test for Windows
|
||||||
needs: build
|
runs-on: windows-2022
|
||||||
runs-on: windows-latest
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out Git repository
|
- name: Check out Git repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: ./.github/actions/setup-node-with-cache
|
uses: ./.github/actions/setup-node-with-cache
|
||||||
|
|
||||||
- name: Download build
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: build
|
|
||||||
path: ./apps/web/build
|
|
||||||
|
|
||||||
- name: Install packages
|
- name: Install packages
|
||||||
run: |
|
run: |
|
||||||
npm ci --ignore-scripts --prefer-offline --no-audit
|
npm ci --ignore-scripts --prefer-offline --no-audit
|
||||||
npm run bootstrap -- --scope=desktop
|
npm run bootstrap -- --scope=web
|
||||||
|
|
||||||
- name: Install sqlite-better-trigram for all arch
|
- name: Install sqlite-better-trigram for all arch
|
||||||
run: |
|
run: |
|
||||||
npm i --cpu arm64 sqlite-better-trigram
|
|
||||||
npm i --cpu x64 sqlite-better-trigram
|
npm i --cpu x64 sqlite-better-trigram
|
||||||
|
|
||||||
npm i --cpu arm64 sqlite3-fts5-html
|
|
||||||
npm i --cpu x64 sqlite3-fts5-html
|
npm i --cpu x64 sqlite3-fts5-html
|
||||||
working-directory: ./apps/desktop
|
working-directory: ./apps/desktop
|
||||||
|
|
||||||
- name: Build Electron wrapper
|
|
||||||
run: node scripts/execute.mjs @notesnook/desktop:release
|
|
||||||
|
|
||||||
- name: Build app
|
|
||||||
run: |
|
|
||||||
npx cross-env NOTESNOOK_STAGING=true yarn electron-builder --config=electron-builder.config.js --win --dir --arm64 --x64
|
|
||||||
working-directory: ./apps/desktop
|
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: npm run test
|
run: npm run test
|
||||||
working-directory: ./apps/desktop
|
working-directory: ./apps/desktop
|
||||||
|
|||||||
2
.github/workflows/editor.tests.yml
vendored
2
.github/workflows/editor.tests.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
|||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: ./.github/actions/setup-node-with-cache
|
uses: ./.github/actions/setup-node-with-cache
|
||||||
|
|||||||
79
.github/workflows/help.preview.yml
vendored
Normal file
79
.github/workflows/help.preview.yml
vendored
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
name: Notesnook Help PR Preview
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, reopened, synchronize]
|
||||||
|
branches: [master, beta]
|
||||||
|
paths:
|
||||||
|
- "docs/help/**"
|
||||||
|
# re-run workflow if workflow file changes
|
||||||
|
- ".github/workflows/help.preview.yml"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
if: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: ./.github/actions/setup-node-with-cache
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
npm ci --ignore-scripts --prefer-offline --no-audit
|
||||||
|
npm run bootstrap -- --scope=help
|
||||||
|
|
||||||
|
- name: Build help
|
||||||
|
run: npm run build:help
|
||||||
|
|
||||||
|
- name: Deploy to Cloudflare
|
||||||
|
id: deploy
|
||||||
|
working-directory: ./docs/help
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
DEPLOY_OUT=$(npx --yes wrangler versions upload 2>&1) || { echo "$DEPLOY_OUT"; exit 1; }
|
||||||
|
echo "$DEPLOY_OUT"
|
||||||
|
PREVIEW_URL=$(printf "%s" "$DEPLOY_OUT" | grep -Eo 'https?://[^ ]+' | head -1 || true)
|
||||||
|
if [ -z "$PREVIEW_URL" ]; then
|
||||||
|
echo "WARNING: could not parse preview URL from wrangler output"
|
||||||
|
fi
|
||||||
|
echo "preview_url=$PREVIEW_URL" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Post or update PR comment
|
||||||
|
uses: actions/github-script@v6
|
||||||
|
env:
|
||||||
|
preview_url: ${{ env.preview_url }}
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const marker = '<!-- docs-pages-preview-comment -->';
|
||||||
|
const prNumber = context.issue.number;
|
||||||
|
const previewUrl = process.env.preview_url || '';
|
||||||
|
const body = `${marker}\n**Cloudflare Pages Docs Preview**\n\n${previewUrl || 'Preview URL unavailable — check workflow logs.'}\n\nCommit: ${process.env.GITHUB_SHA}\n`;
|
||||||
|
const { data: comments } = await github.rest.issues.listComments({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: prNumber,
|
||||||
|
});
|
||||||
|
const existing = comments.find(c => c.body && c.body.includes(marker));
|
||||||
|
if (existing) {
|
||||||
|
await github.rest.issues.updateComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
comment_id: existing.id,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: prNumber,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
25
.github/workflows/help.publish.yml
vendored
25
.github/workflows/help.publish.yml
vendored
@@ -2,11 +2,6 @@ name: Publish Notesnook Help
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- "master"
|
|
||||||
paths:
|
|
||||||
- "docs/help/**"
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -15,16 +10,20 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- uses: actions-rs/toolchain@v1
|
|
||||||
with:
|
with:
|
||||||
toolchain: stable
|
# VitePress reads git history to show the last updated date per page.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Install docgen
|
- name: Setup Node
|
||||||
run: cargo install --git https://github.com/thecodrr/docgen
|
uses: ./.github/actions/setup-node-with-cache
|
||||||
|
|
||||||
- name: Build site
|
- name: Install dependencies
|
||||||
run: docgen build --release
|
run: |
|
||||||
working-directory: docs/help
|
npm ci --ignore-scripts --prefer-offline --no-audit
|
||||||
|
npm run bootstrap -- --scope=help
|
||||||
|
|
||||||
|
- name: Build help
|
||||||
|
run: npm run build:help
|
||||||
|
|
||||||
- name: Setup environment
|
- name: Setup environment
|
||||||
run: |
|
run: |
|
||||||
@@ -32,4 +31,4 @@ jobs:
|
|||||||
echo "CLOUDFLARE_API_TOKEN=${{ secrets.CLOUDFLARE_API_TOKEN }}" >> $GITHUB_ENV
|
echo "CLOUDFLARE_API_TOKEN=${{ secrets.CLOUDFLARE_API_TOKEN }}" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Publish on Cloudflare Pages
|
- name: Publish on Cloudflare Pages
|
||||||
run: npx --yes wrangler pages deploy --project-name notesnook-help ./docs/help/site/ --branch main
|
run: npx --yes wrangler deploy
|
||||||
|
|||||||
152
.github/workflows/ios.preview.build.yml
vendored
Normal file
152
.github/workflows/ios.preview.build.yml
vendored
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
name: Notesnook iOS Preview Build
|
||||||
|
|
||||||
|
# UNTRUSTED stage. Runs on `pull_request`, so fork code is checked out and
|
||||||
|
# compiled with a read-only GITHUB_TOKEN and NO repository secrets. It only
|
||||||
|
# produces an *unsigned* archive. Signing, Firebase distribution and PR
|
||||||
|
# comments happen in ios.preview.publish.yml, which runs in the trusted
|
||||||
|
# `workflow_run` context and never executes fork code. Because no secrets are
|
||||||
|
# exposed here, the build runs automatically for every PR (including forks)
|
||||||
|
# with no authorization gate.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, reopened, synchronize]
|
||||||
|
branches: [master, beta]
|
||||||
|
paths:
|
||||||
|
- "apps/mobile/**"
|
||||||
|
- "packages/**"
|
||||||
|
- ".github/workflows/ios.preview.build.yml"
|
||||||
|
- ".github/workflows/ios.preview.publish.yml"
|
||||||
|
|
||||||
|
# A fork that spams pushes shouldn't queue up macOS builds.
|
||||||
|
concurrency:
|
||||||
|
group: ios-preview-${{ github.event.pull_request.number }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: macos-26
|
||||||
|
timeout-minutes: 60
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout PR code
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: ./.github/actions/setup-node-with-cache
|
||||||
|
|
||||||
|
- name: Setup Xcode
|
||||||
|
uses: maxim-lobanov/setup-xcode@v1
|
||||||
|
with:
|
||||||
|
xcode-version: "26.1.1"
|
||||||
|
|
||||||
|
- name: Setup iOS Platform
|
||||||
|
run: |
|
||||||
|
# GitHub runs this as `/bin/bash -e`, so `set +e` is required -- without
|
||||||
|
# it the first failure aborts the step before any retry can happen.
|
||||||
|
# Both commands hit "Unable to connect to simulator" (exit 70) when
|
||||||
|
# CoreSimulatorService is wedged, so retry each and bounce it between.
|
||||||
|
set +e
|
||||||
|
DL_DIR="$RUNNER_TEMP/ios-platform"
|
||||||
|
|
||||||
|
retry() {
|
||||||
|
for i in 1 2 3; do
|
||||||
|
"$@" && return 0
|
||||||
|
echo "Attempt $i failed ($*); resetting CoreSimulator..."
|
||||||
|
sudo killall -9 com.apple.CoreSimulator.CoreSimulatorService simdiskimaged 2>/dev/null
|
||||||
|
sleep 20
|
||||||
|
xcrun simctl list runtimes >/dev/null 2>&1
|
||||||
|
done
|
||||||
|
echo "::error::Failed after 3 attempts: $*"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
xcrun simctl list runtimes >/dev/null 2>&1
|
||||||
|
retry xcodebuild -downloadPlatform iOS -exportPath "$DL_DIR"
|
||||||
|
|
||||||
|
# Find the dmg; its name embeds a build number that changes.
|
||||||
|
DMG="$(find "$DL_DIR" -name '*.dmg' | head -n1)"
|
||||||
|
[ -n "$DMG" ] || { echo "::error::No dmg in $DL_DIR"; exit 1; }
|
||||||
|
retry xcodebuild -importPlatform "$DMG"
|
||||||
|
|
||||||
|
- name: Install node modules
|
||||||
|
run: |
|
||||||
|
npm ci --ignore-scripts --prefer-offline --no-audit
|
||||||
|
npm run bootstrap -- --scope=mobile
|
||||||
|
|
||||||
|
- name: Build packages
|
||||||
|
run: npm run tx @notesnook/mobile:build
|
||||||
|
|
||||||
|
- name: Cache Pods
|
||||||
|
uses: actions/cache@v4
|
||||||
|
id: pods-cache
|
||||||
|
with:
|
||||||
|
path: apps/mobile/ios/Pods
|
||||||
|
key: ${{ runner.os }}-pods-${{ hashFiles('apps/mobile/ios/Podfile.lock') }}
|
||||||
|
|
||||||
|
- name: Install Pods
|
||||||
|
run: |
|
||||||
|
cd apps/mobile/ios
|
||||||
|
pod install
|
||||||
|
|
||||||
|
- name: Check for typescript errors
|
||||||
|
run: |
|
||||||
|
npm run tx mobile:build
|
||||||
|
cd apps/mobile
|
||||||
|
npx tsc --noEmit
|
||||||
|
|
||||||
|
- name: Get marketing version
|
||||||
|
id: marketing-version
|
||||||
|
run: echo "version=$(grep "IOS_MARKETING_VERSION" apps/mobile/ios/build-configs/ios-build.staging.xcconfig | awk -F'=' '{print $2}' | xargs)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Get build number
|
||||||
|
id: build-number
|
||||||
|
run: echo "timestamp=$(($(date +%s) - 1774851180 ))" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Make staging xcconfig active
|
||||||
|
run: |
|
||||||
|
cd apps/mobile/ios/build-configs
|
||||||
|
./use-ios-build-config.sh staging --marketing-version ${{steps.marketing-version.outputs.version}} --build-number ${{steps.build-number.outputs.timestamp}}
|
||||||
|
|
||||||
|
# Archive unsigned: this job has no certs/profiles (secrets), and the iOS
|
||||||
|
# device SDK forbids ad-hoc signing, so real signing happens in the trusted
|
||||||
|
# publish workflow (ios.preview.publish.yml). Do NOT pass
|
||||||
|
# CODE_SIGN_ENTITLEMENTS="" -- each target must keep its own entitlements so
|
||||||
|
# the archive records them; blanking them dropped the App Group and crashed
|
||||||
|
# MMKV on launch.
|
||||||
|
- name: Archive (unsigned)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
xcodebuild \
|
||||||
|
-workspace apps/mobile/ios/Notesnook.xcworkspace \
|
||||||
|
-scheme Notesnook \
|
||||||
|
-configuration Release \
|
||||||
|
-sdk iphoneos \
|
||||||
|
-destination 'generic/platform=iOS' \
|
||||||
|
-archivePath "$RUNNER_TEMP/Notesnook.xcarchive" \
|
||||||
|
CODE_SIGNING_ALLOWED=NO \
|
||||||
|
CODE_SIGNING_REQUIRED=NO \
|
||||||
|
CODE_SIGN_IDENTITY="" \
|
||||||
|
archive
|
||||||
|
|
||||||
|
- name: Stage build artifact
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p "$RUNNER_TEMP/artifact"
|
||||||
|
tar -czf "$RUNNER_TEMP/artifact/Notesnook.xcarchive.tar.gz" \
|
||||||
|
-C "$RUNNER_TEMP" Notesnook.xcarchive
|
||||||
|
# Carry the PR context forward; workflow_run cannot see it reliably for forks.
|
||||||
|
{
|
||||||
|
echo "PR_NUMBER=${{ github.event.pull_request.number }}"
|
||||||
|
echo "HEAD_SHA=${{ github.event.pull_request.head.sha }}"
|
||||||
|
} > "$RUNNER_TEMP/artifact/pr-meta.env"
|
||||||
|
|
||||||
|
- name: Upload build artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ios-preview-build
|
||||||
|
path: ${{ runner.temp }}/artifact
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
222
.github/workflows/ios.preview.publish.yml
vendored
Normal file
222
.github/workflows/ios.preview.publish.yml
vendored
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
name: Notesnook iOS Preview Publish
|
||||||
|
|
||||||
|
# TRUSTED stage. Runs via `workflow_run` after the build workflow finishes, so
|
||||||
|
# it has the base repo's secrets and a write-scoped token. It checks out the
|
||||||
|
# BASE repository (never fork code), downloads the unsigned archive the build
|
||||||
|
# produced, then signs + distributes it and posts the PR comment. Signing is
|
||||||
|
# done directly with `codesign` on the prebuilt archive -- no app build phases
|
||||||
|
# run here, so fork code is never executed in this trusted context.
|
||||||
|
#
|
||||||
|
# Split into two jobs on purpose: signing needs macOS, but the Firebase
|
||||||
|
# distribution action is a Docker container action and only runs on Linux.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: ["Notesnook iOS Preview Build"]
|
||||||
|
types: [completed]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
export:
|
||||||
|
# Only publish previews for builds that actually succeeded.
|
||||||
|
if: github.event.workflow_run.conclusion == 'success'
|
||||||
|
runs-on: macos-26
|
||||||
|
timeout-minutes: 30
|
||||||
|
outputs:
|
||||||
|
pr_number: ${{ steps.meta.outputs.PR_NUMBER }}
|
||||||
|
head_sha: ${{ steps.meta.outputs.HEAD_SHA }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout base repo (trusted)
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
# No `ref` -> checks out the default branch, i.e. trusted base code.
|
||||||
|
|
||||||
|
- name: Setup Xcode
|
||||||
|
uses: maxim-lobanov/setup-xcode@v1
|
||||||
|
with:
|
||||||
|
xcode-version: "26.1.1"
|
||||||
|
|
||||||
|
- name: Download build artifact
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ios-preview-build
|
||||||
|
path: ${{ runner.temp }}/artifact
|
||||||
|
run-id: ${{ github.event.workflow_run.id }}
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Load PR metadata & extract archive
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cat "$RUNNER_TEMP/artifact/pr-meta.env" >> "$GITHUB_OUTPUT"
|
||||||
|
tar -xzf "$RUNNER_TEMP/artifact/Notesnook.xcarchive.tar.gz" -C "$RUNNER_TEMP"
|
||||||
|
|
||||||
|
- name: Import signing certificate
|
||||||
|
uses: apple-actions/import-codesign-certs@v3
|
||||||
|
with:
|
||||||
|
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE_P12 }}
|
||||||
|
p12-password: ${{ secrets.APPLE_CERTIFICATE_P12_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Install provisioning profiles
|
||||||
|
env:
|
||||||
|
PROFILE_APP: ${{ secrets.APPLE_MOBILE_PROVISION_ADHOC_APP }}
|
||||||
|
PROFILE_SHARE: ${{ secrets.APPLE_MOBILE_PROVISION_ADHOC_SHARE }}
|
||||||
|
PROFILE_WIDGET: ${{ secrets.APPLE_MOBILE_PROVISION_ADHOC_WIDGET }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
PROFILE_DIR="$HOME/Library/MobileDevice/Provisioning Profiles"
|
||||||
|
mkdir -p "$PROFILE_DIR"
|
||||||
|
i=0
|
||||||
|
for p in "$PROFILE_APP" "$PROFILE_SHARE" "$PROFILE_WIDGET"; do
|
||||||
|
echo "$p" | base64 --decode > "$PROFILE_DIR/preview-$i.mobileprovision"
|
||||||
|
i=$((i + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
# Sign the unsigned archive by hand. `xcodebuild -exportArchive` re-signs
|
||||||
|
# from the archive's `archived-expanded-entitlements.xcent`, which an
|
||||||
|
# unsigned archive does not contain, so it dropped the App Group and the
|
||||||
|
# app crashed on launch. Instead we sign each bundle directly, taking the
|
||||||
|
# entitlements from each provisioning profile (exactly what Apple
|
||||||
|
# provisioned -- App Group, keychain groups with the right team prefix).
|
||||||
|
# Runs on macOS but executes NO fork build phases, so it stays trusted.
|
||||||
|
- name: Sign IPA
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
PROFILES="$HOME/Library/MobileDevice/Provisioning Profiles"
|
||||||
|
APP="$(find "$RUNNER_TEMP/Notesnook.xcarchive/Products/Applications" -maxdepth 1 -name '*.app' | head -n1)"
|
||||||
|
[ -n "$APP" ] || { echo "::error::No .app in archive"; exit 1; }
|
||||||
|
|
||||||
|
# Single Apple Distribution identity from the imported keychain.
|
||||||
|
IDENTITY="$(security find-identity -v -p codesigning | awk '/Apple Distribution/{print $2; exit}')"
|
||||||
|
[ -n "$IDENTITY" ] || { echo "::error::No Apple Distribution identity found"; exit 1; }
|
||||||
|
echo "Signing identity: $IDENTITY"
|
||||||
|
|
||||||
|
TMP="$RUNNER_TEMP/sign"; mkdir -p "$TMP"
|
||||||
|
|
||||||
|
# Decode a profile's bundle id (application-identifier minus team prefix).
|
||||||
|
profile_bundle_id() {
|
||||||
|
security cms -D -i "$1" -o "$TMP/_p.plist" 2>/dev/null
|
||||||
|
local appid; appid="$(plutil -extract Entitlements.application-identifier raw -o - "$TMP/_p.plist")"
|
||||||
|
echo "${appid#*.}"
|
||||||
|
}
|
||||||
|
# Find the profile matching a bundle id; echoes its path.
|
||||||
|
profile_for() {
|
||||||
|
local want="$1" p
|
||||||
|
for p in "$PROFILES"/*.mobileprovision; do
|
||||||
|
[ -e "$p" ] || continue
|
||||||
|
[ "$(profile_bundle_id "$p")" = "$want" ] && { echo "$p"; return 0; }
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
# Embed the matching profile and sign a bundle with the profile's entitlements.
|
||||||
|
sign_bundle() {
|
||||||
|
local bundle="$1" bid prof ent
|
||||||
|
bid="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$bundle/Info.plist")"
|
||||||
|
prof="$(profile_for "$bid")" || { echo "::error::No provisioning profile for $bid"; exit 1; }
|
||||||
|
ent="$TMP/ent-$bid.plist"
|
||||||
|
security cms -D -i "$prof" -o "$TMP/_p.plist"
|
||||||
|
plutil -extract Entitlements xml1 -o "$ent" "$TMP/_p.plist"
|
||||||
|
cp "$prof" "$bundle/embedded.mobileprovision"
|
||||||
|
echo "Signing $(basename "$bundle") ($bid)"
|
||||||
|
codesign --force --sign "$IDENTITY" --entitlements "$ent" "$bundle"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sign inside-out: nested code first, then extensions, then the app.
|
||||||
|
if [ -d "$APP/Frameworks" ]; then
|
||||||
|
find "$APP/Frameworks" -maxdepth 1 \( -name '*.framework' -o -name '*.dylib' \) -print0 \
|
||||||
|
| while IFS= read -r -d '' f; do codesign --force --sign "$IDENTITY" "$f"; done
|
||||||
|
fi
|
||||||
|
if [ -d "$APP/PlugIns" ]; then
|
||||||
|
for appex in "$APP/PlugIns"/*.appex; do
|
||||||
|
[ -e "$appex" ] || continue
|
||||||
|
if [ -d "$appex/Frameworks" ]; then
|
||||||
|
find "$appex/Frameworks" -maxdepth 1 \( -name '*.framework' -o -name '*.dylib' \) -print0 \
|
||||||
|
| while IFS= read -r -d '' f; do codesign --force --sign "$IDENTITY" "$f"; done
|
||||||
|
fi
|
||||||
|
sign_bundle "$appex"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
sign_bundle "$APP"
|
||||||
|
|
||||||
|
codesign --verify --deep --strict --verbose=2 "$APP"
|
||||||
|
|
||||||
|
# Package into an IPA.
|
||||||
|
rm -rf "$TMP/Payload"; mkdir -p "$TMP/Payload"
|
||||||
|
cp -R "$APP" "$TMP/Payload/"
|
||||||
|
( cd "$TMP" && zip -qry "$RUNNER_TEMP/Notesnook.ipa" Payload )
|
||||||
|
|
||||||
|
# Fail fast if the App Group didn't make it into the signed app -- its
|
||||||
|
# absence is the launch crash (MMKV nil group path). Never distribute
|
||||||
|
# a build without it.
|
||||||
|
echo "::group::Signed app entitlements"
|
||||||
|
codesign -d --entitlements :- "$APP" 2>/dev/null || true
|
||||||
|
echo "::endgroup::"
|
||||||
|
codesign -d --entitlements :- "$APP" 2>/dev/null | grep -q "group.org.streetwriters.notesnook" \
|
||||||
|
|| { echo "::error::Signed app is missing the App Group entitlement; it would crash on launch."; exit 1; }
|
||||||
|
echo "App Group entitlement present."
|
||||||
|
|
||||||
|
- name: Upload signed IPA
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ios-preview-ipa
|
||||||
|
path: ${{ runner.temp }}/Notesnook.ipa
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
# Must be Linux: wzieba/Firebase-Distribution-Github-Action is a Docker
|
||||||
|
# container action, which GitHub only supports on Linux runners.
|
||||||
|
distribute:
|
||||||
|
needs: export
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download signed IPA
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ios-preview-ipa
|
||||||
|
path: ${{ runner.temp }}
|
||||||
|
|
||||||
|
- name: Publish to Firebase
|
||||||
|
id: firebase-output
|
||||||
|
uses: wzieba/Firebase-Distribution-Github-Action@v1
|
||||||
|
with:
|
||||||
|
appId: ${{ secrets.FIREBASE_IOS_APP_ID }}
|
||||||
|
serviceCredentialsFileContent: ${{ secrets.QA_SERVICE_ACCOUNT }}
|
||||||
|
groups: testers
|
||||||
|
file: ${{ runner.temp }}/Notesnook.ipa
|
||||||
|
releaseNotes: Preview for https://github.com/${{ github.repository }}/pull/${{ needs.export.outputs.pr_number }}
|
||||||
|
|
||||||
|
- name: Post or update PR comment
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
env:
|
||||||
|
preview_url: ${{ steps.firebase-output.outputs.TESTING_URI }}
|
||||||
|
PR_NUMBER: ${{ needs.export.outputs.pr_number }}
|
||||||
|
HEAD_SHA: ${{ needs.export.outputs.head_sha }}
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const marker = '<!-- ios-preview-comment -->';
|
||||||
|
const prNumber = Number(process.env.PR_NUMBER);
|
||||||
|
if (!prNumber) return;
|
||||||
|
const previewUrl = process.env.preview_url || '';
|
||||||
|
const body = `${marker}\n**iOS App Preview**\n\n${previewUrl || 'Preview URL unavailable — check workflow logs.'}\n\nCommit: ${process.env.HEAD_SHA}\n`;
|
||||||
|
const { data: comments } = await github.rest.issues.listComments({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: prNumber,
|
||||||
|
});
|
||||||
|
const existing = comments.find(c => c.body && c.body.includes(marker));
|
||||||
|
if (existing) {
|
||||||
|
await github.rest.issues.updateComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
comment_id: existing.id,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: prNumber,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
31
.github/workflows/ios.publish.yml
vendored
31
.github/workflows/ios.publish.yml
vendored
@@ -4,12 +4,12 @@ on: workflow_dispatch
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: macos-14
|
runs-on: macos-26
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: ./.github/actions/setup-node-with-cache
|
uses: ./.github/actions/setup-node-with-cache
|
||||||
@@ -17,7 +17,12 @@ jobs:
|
|||||||
- name: Setup Xcode
|
- name: Setup Xcode
|
||||||
uses: maxim-lobanov/setup-xcode@v1
|
uses: maxim-lobanov/setup-xcode@v1
|
||||||
with:
|
with:
|
||||||
xcode-version: "16.1"
|
xcode-version: "26.1.1"
|
||||||
|
|
||||||
|
- name: Setup iOS Platform
|
||||||
|
run: |
|
||||||
|
xcodebuild -downloadPlatform iOS -exportPath ~/Downloads
|
||||||
|
xcodebuild -importPlatform ~/Downloads/iphonesimulator_26.1_23B86.dmg
|
||||||
|
|
||||||
- name: Install node modules
|
- name: Install node modules
|
||||||
run: |
|
run: |
|
||||||
@@ -30,7 +35,7 @@ jobs:
|
|||||||
- name: ccache
|
- name: ccache
|
||||||
uses: hendrikmuhs/ccache-action@v1.2.11
|
uses: hendrikmuhs/ccache-action@v1.2.11
|
||||||
with:
|
with:
|
||||||
key: ${{ runner.os }}-ccache-${{ hashFiles(format('apps/mobile/native/ios/Podfile.lock')) }}
|
key: ${{ runner.os }}-ccache-${{ hashFiles(format('apps/mobile/ios/Podfile.lock')) }}
|
||||||
max-size: 1500M
|
max-size: 1500M
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-ccache-
|
${{ runner.os }}-ccache-
|
||||||
@@ -55,14 +60,13 @@ jobs:
|
|||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
id: pods-cache
|
id: pods-cache
|
||||||
with:
|
with:
|
||||||
path: apps/mobile/native/ios/Pods
|
path: apps/mobile/ios/Pods
|
||||||
key: ${{ runner.os }}-pods-${{ hashFiles('apps/mobile/native/ios/Podfile.lock') }}
|
key: ${{ runner.os }}-pods-${{ hashFiles('apps/mobile/ios/Podfile.lock') }}
|
||||||
|
|
||||||
- name: Install Pods
|
- name: Install Pods
|
||||||
run: |
|
run: |
|
||||||
cd apps/mobile/native/ios
|
cd apps/mobile/ios
|
||||||
bundle install
|
pod install
|
||||||
RCT_NEW_ARCH_ENABLED=0 bundle exec pod install
|
|
||||||
|
|
||||||
- name: Check for typescript errors
|
- name: Check for typescript errors
|
||||||
run: |
|
run: |
|
||||||
@@ -79,9 +83,9 @@ jobs:
|
|||||||
bundle-identifier: org.streetwriters.notesnook
|
bundle-identifier: org.streetwriters.notesnook
|
||||||
scheme: Notesnook
|
scheme: Notesnook
|
||||||
configuration: "Release"
|
configuration: "Release"
|
||||||
export-options: apps/mobile/native/ios/ExportOptions.plist
|
export-options: apps/mobile/ios/ExportOptions.plist
|
||||||
project-path: apps/mobile/native/ios/Notesnook.xcodeproj
|
project-path: apps/mobile/ios/Notesnook.xcodeproj
|
||||||
workspace-path: apps/mobile/native/ios/Notesnook.xcworkspace
|
workspace-path: apps/mobile/ios/Notesnook.xcworkspace
|
||||||
update-targets: |
|
update-targets: |
|
||||||
Notesnook
|
Notesnook
|
||||||
Make Note
|
Make Note
|
||||||
@@ -112,10 +116,11 @@ jobs:
|
|||||||
api-private-key: ${{ secrets.API_KEY }}
|
api-private-key: ${{ secrets.API_KEY }}
|
||||||
|
|
||||||
- name: Upload Notesnook.ipa to Github
|
- name: Upload Notesnook.ipa to Github
|
||||||
|
continue-on-error: true
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: Notesnook.zip
|
name: Notesnook.zip
|
||||||
path: |
|
path: |
|
||||||
Notesnook.ipa
|
Notesnook.ipa
|
||||||
apps/mobile/native/ios/**/*.map
|
apps/mobile/ios/**/*.map
|
||||||
packages/editor-mobile/sourcemaps/*.map
|
packages/editor-mobile/sourcemaps/*.map
|
||||||
|
|||||||
2
.github/workflows/monograph.publish.yml
vendored
2
.github/workflows/monograph.publish.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
|||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- name: Check out the repo
|
- name: Check out the repo
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: ./.github/actions/setup-node-with-cache
|
uses: ./.github/actions/setup-node-with-cache
|
||||||
|
|||||||
2
.github/workflows/theme-builder.publish.yml
vendored
2
.github/workflows/theme-builder.publish.yml
vendored
@@ -8,7 +8,7 @@ jobs:
|
|||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: ./.github/actions/setup-node-with-cache
|
uses: ./.github/actions/setup-node-with-cache
|
||||||
|
|||||||
78
.github/workflows/themes.publish.yml
vendored
Normal file
78
.github/workflows/themes.publish.yml
vendored
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# This workflow uses actions that are not certified by GitHub.
|
||||||
|
# They are provided by a third-party and are governed by
|
||||||
|
# separate terms of service, privacy policy, and support
|
||||||
|
# documentation.
|
||||||
|
|
||||||
|
# GitHub recommends pinning actions to a commit SHA.
|
||||||
|
# To get a newer version, you will need to update the SHA.
|
||||||
|
# You can also reference a tag or branch, but the action may change without warning.
|
||||||
|
|
||||||
|
name: Publish @notesnook/themes-server
|
||||||
|
|
||||||
|
on: workflow_dispatch
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
push_to_registry:
|
||||||
|
name: Push Docker image to Docker Hub
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
permissions:
|
||||||
|
packages: write
|
||||||
|
contents: read
|
||||||
|
attestations: write
|
||||||
|
id-token: write
|
||||||
|
steps:
|
||||||
|
- name: Check out the repo
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Collect package metadata
|
||||||
|
id: package_metadata
|
||||||
|
working-directory: ./servers/themes
|
||||||
|
run: |
|
||||||
|
echo ::set-output name=app_version::$(cat package.json | jq -r .version)
|
||||||
|
|
||||||
|
# Setup Buildx
|
||||||
|
- name: Docker Setup Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
ecr: auto
|
||||||
|
logout: true
|
||||||
|
|
||||||
|
# Pull previous image from docker hub to use it as cache to improve the image build time.
|
||||||
|
- name: docker pull cache image
|
||||||
|
continue-on-error: true
|
||||||
|
run: docker pull streetwriters/themes-server:latest
|
||||||
|
|
||||||
|
# Setup QEMU
|
||||||
|
# - name: Set up QEMU
|
||||||
|
# uses: docker/setup-qemu-action@v2
|
||||||
|
|
||||||
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: streetwriters/themes-server
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
id: push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: servers/themes/Dockerfile
|
||||||
|
push: true
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
tags: streetwriters/themes-server:${{ steps.package_metadata.outputs.app_version }},streetwriters/themes-server:latest
|
||||||
|
cache-from: streetwriters/themes-server:latest
|
||||||
|
|
||||||
|
- name: Generate artifact attestation
|
||||||
|
uses: actions/attest-build-provenance@v1
|
||||||
|
with:
|
||||||
|
subject-name: index.docker.io/streetwriters/themes-server
|
||||||
|
subject-digest: ${{ steps.push.outputs.digest }}
|
||||||
|
push-to-registry: true
|
||||||
2
.github/workflows/vericrypt.publish.yml
vendored
2
.github/workflows/vericrypt.publish.yml
vendored
@@ -8,7 +8,7 @@ jobs:
|
|||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: ./.github/actions/setup-node-with-cache
|
uses: ./.github/actions/setup-node-with-cache
|
||||||
|
|||||||
40
.github/workflows/web.benchmarks.yml
vendored
40
.github/workflows/web.benchmarks.yml
vendored
@@ -1,40 +0,0 @@
|
|||||||
name: Benchmark @notesnook/web
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- "master"
|
|
||||||
paths:
|
|
||||||
- "apps/web/**"
|
|
||||||
# re-run workflow if workflow file changes
|
|
||||||
- ".github/workflows/web.benchmarks.yml"
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
bench:
|
|
||||||
name: Bench
|
|
||||||
runs-on: ubuntu-22.04
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: ./.github/actions/setup-node-with-cache
|
|
||||||
|
|
||||||
- name: Install packages
|
|
||||||
run: |
|
|
||||||
npm ci --ignore-scripts --prefer-offline --no-audit
|
|
||||||
npm run bootstrap -- --scope=web
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: npm run build:web
|
|
||||||
|
|
||||||
- name: Install Playwright Browsers
|
|
||||||
run: npx playwright install chromium --with-deps
|
|
||||||
working-directory: apps/web
|
|
||||||
|
|
||||||
- name: Run benchmarks
|
|
||||||
id: benchmark
|
|
||||||
run: node __bench__/app.bench.mjs
|
|
||||||
working-directory: apps/web
|
|
||||||
82
.github/workflows/web.preview.yml
vendored
Normal file
82
.github/workflows/web.preview.yml
vendored
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
name: Notesnook Web PR Preview
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, reopened, synchronize]
|
||||||
|
branches: [master, beta]
|
||||||
|
paths:
|
||||||
|
- "apps/web/**"
|
||||||
|
- "packages/**"
|
||||||
|
# re-run workflow if workflow file changes
|
||||||
|
- ".github/workflows/web.preview.yml"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
if: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: ./.github/actions/setup-node-with-cache
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
npm ci --ignore-scripts --prefer-offline --no-audit
|
||||||
|
npm run bootstrap -- --scope=web
|
||||||
|
|
||||||
|
- name: Build web
|
||||||
|
run: npm run build:web
|
||||||
|
|
||||||
|
- name: Deploy to Cloudflare Pages
|
||||||
|
id: deploy
|
||||||
|
working-directory: ./apps/web
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
BRANCH=pr-${{ github.event.number }}-$(echo "${{ github.sha }}" | cut -c1-7)
|
||||||
|
echo "Deploying branch: $BRANCH"
|
||||||
|
DEPLOY_OUT=$(npx --yes wrangler pages deploy --project-name=notesnook-app --branch="$BRANCH" ./build 2>&1) || { echo "$DEPLOY_OUT"; exit 1; }
|
||||||
|
echo "$DEPLOY_OUT"
|
||||||
|
PREVIEW_URL=$(printf "%s" "$DEPLOY_OUT" | grep -Eo 'https?://[^ ]+' | head -1 || true)
|
||||||
|
if [ -z "$PREVIEW_URL" ]; then
|
||||||
|
echo "WARNING: could not parse preview URL from wrangler output"
|
||||||
|
fi
|
||||||
|
echo "preview_url=$PREVIEW_URL" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Post or update PR comment
|
||||||
|
uses: actions/github-script@v6
|
||||||
|
env:
|
||||||
|
preview_url: ${{ env.preview_url }}
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const marker = '<!-- pages-preview-comment -->';
|
||||||
|
const prNumber = context.issue.number;
|
||||||
|
const previewUrl = process.env.preview_url || '';
|
||||||
|
const body = `${marker}\n**Cloudflare Pages Preview**\n\n${previewUrl || 'Preview URL unavailable — check workflow logs.'}\n\nCommit: ${process.env.GITHUB_SHA}\n`;
|
||||||
|
const { data: comments } = await github.rest.issues.listComments({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: prNumber,
|
||||||
|
});
|
||||||
|
const existing = comments.find(c => c.body && c.body.includes(marker));
|
||||||
|
if (existing) {
|
||||||
|
await github.rest.issues.updateComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
comment_id: existing.id,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: prNumber,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
2
.github/workflows/web.publish.yml
vendored
2
.github/workflows/web.publish.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
|||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: ./.github/actions/setup-node-with-cache
|
uses: ./.github/actions/setup-node-with-cache
|
||||||
|
|||||||
4
.github/workflows/web.tests.yml
vendored
4
.github/workflows/web.tests.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
|||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.ref }}
|
ref: ${{ github.event.pull_request.head.sha || github.ref }}
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.ref }}
|
ref: ${{ github.event.pull_request.head.sha || github.ref }}
|
||||||
|
|
||||||
|
|||||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -11,5 +11,5 @@
|
|||||||
"cache": true,
|
"cache": true,
|
||||||
"cacheStrategy": "content"
|
"cacheStrategy": "content"
|
||||||
},
|
},
|
||||||
"git.rebaseWhenSync": true
|
"git.rebaseWhenSync": false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ We take all queries, issues and bug reports that you might have. Feel free to as
|
|||||||
|
|
||||||
## Additional Resources
|
## Additional Resources
|
||||||
|
|
||||||
- [Migrating & Importing your data from other apps — Importer](https://help.notesnook.com/importing-notes)
|
- [Migrating & Importing your data from other apps — Importer](https://notesnook.com/help/importing-notes)
|
||||||
- [Privacy policy](https://notesnook.com/privacy) & [Terms of service](https://notesnook.com/terms)
|
- [Privacy policy](https://notesnook.com/privacy) & [Terms of service](https://notesnook.com/terms)
|
||||||
- [Verify Notesnook encryption claims yourself — Vericrypt](https://vericrypt.notesnook.com/)
|
- [Verify Notesnook encryption claims yourself — Vericrypt](https://vericrypt.notesnook.com/)
|
||||||
- [Why Notesnook requires an email address?](https://blog.notesnook.com/why-notesnook-requires-an-email-address/)
|
- [Why Notesnook requires an email address?](https://blog.notesnook.com/why-notesnook-requires-an-email-address/)
|
||||||
|
|||||||
@@ -17,155 +17,91 @@ You should have received a copy of the GNU General Public License
|
|||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { testCleanup, test } from "./test-override.js";
|
import { test, expect } from "@nn/test";
|
||||||
import { writeFile } from "fs/promises";
|
|
||||||
import { Page } from "playwright";
|
|
||||||
import { gt, lt } from "semver";
|
import { gt, lt } from "semver";
|
||||||
import { describe } from "vitest";
|
import { AppModel } from "../../web/__e2e__/models/app.model.js";
|
||||||
|
|
||||||
test("update starts downloading if version is outdated", async ({
|
test.extend({ options: { version: "3.0.0" } })(
|
||||||
ctx: { page },
|
"update starts downloading if version is outdated",
|
||||||
expect,
|
async ({ page }) => {
|
||||||
onTestFinished
|
await page.waitForSelector(".ProseMirror");
|
||||||
}) => {
|
|
||||||
onTestFinished(testCleanup);
|
|
||||||
|
|
||||||
await page.waitForSelector("#authForm");
|
await page
|
||||||
expect(
|
.locator(".theme-scope-statusBar")
|
||||||
await page.getByRole("button", { name: "Create account" }).isVisible()
|
.getByRole("button", { name: /updating/i })
|
||||||
).toBe(true);
|
.waitFor({ state: "attached" });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
await page
|
test.extend({
|
||||||
.getByRole("button", { name: "Skip & go directly to the app" })
|
options: {
|
||||||
.click();
|
version: "3.0.0",
|
||||||
|
config: {
|
||||||
await skipDialog(page);
|
|
||||||
|
|
||||||
await page.waitForSelector(".ProseMirror");
|
|
||||||
|
|
||||||
await page
|
|
||||||
.locator(".theme-scope-statusBar")
|
|
||||||
.getByRole("button", { name: /updating/i })
|
|
||||||
.waitFor({ state: "attached" });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("update is only shown if version is outdated and auto updates are disabled", async ({
|
|
||||||
ctx,
|
|
||||||
expect,
|
|
||||||
onTestFinished
|
|
||||||
}) => {
|
|
||||||
onTestFinished(testCleanup);
|
|
||||||
|
|
||||||
await ctx.app.close();
|
|
||||||
await writeFile(
|
|
||||||
ctx.configPath,
|
|
||||||
JSON.stringify({
|
|
||||||
automaticUpdates: false
|
automaticUpdates: false
|
||||||
})
|
}
|
||||||
);
|
}
|
||||||
|
})(
|
||||||
|
"update is only shown if version is outdated and auto updates are disabled",
|
||||||
|
async ({ page }) => {
|
||||||
|
await page.waitForSelector(".ProseMirror");
|
||||||
|
|
||||||
await ctx.relaunch();
|
const app = new AppModel(page);
|
||||||
|
const settings = await app.goToSettings();
|
||||||
|
|
||||||
const { page } = ctx;
|
await settings.checkForUpdates();
|
||||||
|
await settings.close();
|
||||||
|
|
||||||
await page.waitForSelector("#authForm");
|
await page
|
||||||
|
.locator(".theme-scope-statusBar")
|
||||||
expect(
|
.getByRole("button", { name: /available/i })
|
||||||
await page.getByRole("button", { name: "Create account" }).isVisible()
|
.waitFor({ state: "attached" });
|
||||||
).toBe(true);
|
}
|
||||||
|
);
|
||||||
await page
|
|
||||||
.getByRole("button", { name: "Skip & go directly to the app" })
|
|
||||||
.click();
|
|
||||||
|
|
||||||
await skipDialog(page);
|
|
||||||
|
|
||||||
|
test.extend({
|
||||||
|
options: {
|
||||||
|
version: "3.0.0-beta.0",
|
||||||
|
config: {
|
||||||
|
automaticUpdates: false,
|
||||||
|
releaseTrack: "beta"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})("update to stable if it is newer", async ({ page }) => {
|
||||||
await page.waitForSelector(".ProseMirror");
|
await page.waitForSelector(".ProseMirror");
|
||||||
|
|
||||||
await page
|
const app = new AppModel(page);
|
||||||
|
const settings = await app.goToSettings();
|
||||||
|
|
||||||
|
await settings.checkForUpdates();
|
||||||
|
await settings.close();
|
||||||
|
|
||||||
|
const updateButton = page
|
||||||
.locator(".theme-scope-statusBar")
|
.locator(".theme-scope-statusBar")
|
||||||
.getByRole("button", { name: /available/i })
|
.getByRole("button", { name: /available/i });
|
||||||
.waitFor({ state: "attached" });
|
await updateButton.waitFor({ state: "visible" });
|
||||||
|
const content = await updateButton.textContent();
|
||||||
|
const version = content?.split(" ")?.[0] || "";
|
||||||
|
expect(gt(version, "3.0.0-beta.0")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("update to stable if it is newer", () => {
|
test.extend({
|
||||||
test.scoped({ options: { version: "3.0.0-beta.0" } });
|
options: {
|
||||||
test("test", async ({ ctx, expect, onTestFinished }) => {
|
version: "99.0.0-beta.0",
|
||||||
onTestFinished(testCleanup);
|
config: {
|
||||||
|
automaticUpdates: false,
|
||||||
await ctx.app.close();
|
releaseTrack: "beta"
|
||||||
await writeFile(
|
}
|
||||||
ctx.configPath,
|
}
|
||||||
JSON.stringify({
|
})(
|
||||||
automaticUpdates: false,
|
"update is not available if it latest stable version is older",
|
||||||
releaseTrack: "beta"
|
async ({ page }) => {
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
await ctx.relaunch();
|
|
||||||
|
|
||||||
const { page } = ctx;
|
|
||||||
|
|
||||||
await page.waitForSelector("#authForm");
|
|
||||||
|
|
||||||
expect(
|
|
||||||
await page.getByRole("button", { name: "Create account" }).isVisible()
|
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
await page
|
|
||||||
.getByRole("button", { name: "Skip & go directly to the app" })
|
|
||||||
.click();
|
|
||||||
|
|
||||||
await skipDialog(page);
|
|
||||||
|
|
||||||
await page.waitForSelector(".ProseMirror");
|
await page.waitForSelector(".ProseMirror");
|
||||||
|
|
||||||
const updateButton = page
|
const app = new AppModel(page);
|
||||||
.locator(".theme-scope-statusBar")
|
const settings = await app.goToSettings();
|
||||||
.getByRole("button", { name: /available/i });
|
|
||||||
await updateButton.waitFor({ state: "visible" });
|
|
||||||
const content = await updateButton.textContent();
|
|
||||||
const version = content?.split(" ")?.[0] || "";
|
|
||||||
expect(gt(version, "3.0.0-beta.0")).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("update is not available if it latest stable version is older", () => {
|
await settings.checkForUpdates();
|
||||||
test.scoped({ options: { version: "99.0.0-beta.0" } });
|
await settings.close();
|
||||||
test("test", async ({ ctx, expect, onTestFinished }) => {
|
|
||||||
onTestFinished(testCleanup);
|
|
||||||
|
|
||||||
await ctx.app.close();
|
|
||||||
await writeFile(
|
|
||||||
ctx.configPath,
|
|
||||||
JSON.stringify({
|
|
||||||
automaticUpdates: false,
|
|
||||||
releaseTrack: "beta"
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
await ctx.relaunch();
|
|
||||||
|
|
||||||
const { page } = ctx;
|
|
||||||
|
|
||||||
await page.waitForSelector("#authForm");
|
|
||||||
|
|
||||||
expect(
|
|
||||||
await page.getByRole("button", { name: "Create account" }).isVisible()
|
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
await page
|
|
||||||
.getByRole("button", { name: "Skip & go directly to the app" })
|
|
||||||
.click();
|
|
||||||
|
|
||||||
await skipDialog(page);
|
|
||||||
|
|
||||||
await page.waitForSelector(".ProseMirror");
|
|
||||||
|
|
||||||
await page
|
|
||||||
.locator(".theme-scope-statusBar")
|
|
||||||
.getByRole("button", { name: /checking for updates/i })
|
|
||||||
.waitFor({ state: "hidden" });
|
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await page
|
await page
|
||||||
@@ -173,45 +109,24 @@ describe("update is not available if it latest stable version is older", () => {
|
|||||||
.getByRole("button", { name: /available/i })
|
.getByRole("button", { name: /available/i })
|
||||||
.isHidden()
|
.isHidden()
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
}
|
||||||
});
|
);
|
||||||
|
|
||||||
describe("downgrade to stable on switching to stable release track", () => {
|
|
||||||
test.scoped({ options: { version: "99.0.0-beta.0" } });
|
|
||||||
test("test", async ({ ctx, expect, onTestFinished }) => {
|
|
||||||
onTestFinished(testCleanup);
|
|
||||||
|
|
||||||
await ctx.app.close();
|
|
||||||
await writeFile(
|
|
||||||
ctx.configPath,
|
|
||||||
JSON.stringify({
|
|
||||||
automaticUpdates: false,
|
|
||||||
releaseTrack: "stable"
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
await ctx.relaunch();
|
|
||||||
|
|
||||||
const { page } = ctx;
|
|
||||||
|
|
||||||
await page.waitForSelector("#authForm");
|
|
||||||
|
|
||||||
expect(
|
|
||||||
await page.getByRole("button", { name: "Create account" }).isVisible()
|
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
await page
|
|
||||||
.getByRole("button", { name: "Skip & go directly to the app" })
|
|
||||||
.click();
|
|
||||||
|
|
||||||
await skipDialog(page);
|
|
||||||
|
|
||||||
|
test.extend({
|
||||||
|
options: {
|
||||||
|
version: "99.0.0-beta.0",
|
||||||
|
config: { automaticUpdates: false, releaseTrack: "stable" }
|
||||||
|
}
|
||||||
|
})(
|
||||||
|
"downgrade to stable on switching to stable release track",
|
||||||
|
async ({ page }) => {
|
||||||
await page.waitForSelector(".ProseMirror");
|
await page.waitForSelector(".ProseMirror");
|
||||||
|
|
||||||
await page
|
const app = new AppModel(page);
|
||||||
.locator(".theme-scope-statusBar")
|
const settings = await app.goToSettings();
|
||||||
.getByRole("button", { name: /checking for updates/i })
|
|
||||||
.waitFor({ state: "hidden" });
|
await settings.checkForUpdates();
|
||||||
|
await settings.close();
|
||||||
|
|
||||||
const updateButton = page
|
const updateButton = page
|
||||||
.locator(".theme-scope-statusBar")
|
.locator(".theme-scope-statusBar")
|
||||||
@@ -220,23 +135,5 @@ describe("downgrade to stable on switching to stable release track", () => {
|
|||||||
const content = await updateButton.textContent();
|
const content = await updateButton.textContent();
|
||||||
const version = content?.split(" ")?.[0] || "";
|
const version = content?.split(" ")?.[0] || "";
|
||||||
expect(lt(version, "99.0.0-beta.0")).toBe(true);
|
expect(lt(version, "99.0.0-beta.0")).toBe(true);
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
async function skipDialog(page: Page) {
|
|
||||||
try {
|
|
||||||
const dialog = page.locator(".ReactModal__Content");
|
|
||||||
const positiveButton = dialog.locator(
|
|
||||||
"button[data-role='positive-button']"
|
|
||||||
);
|
|
||||||
const negativeButton = dialog.locator(
|
|
||||||
"button[data-role='negative-button']"
|
|
||||||
);
|
|
||||||
if (await positiveButton.isVisible())
|
|
||||||
await positiveButton.click({ timeout: 1000 });
|
|
||||||
else if (await negativeButton.isVisible())
|
|
||||||
await negativeButton.click({ timeout: 1000 });
|
|
||||||
} catch (e) {
|
|
||||||
// ignore error
|
|
||||||
}
|
}
|
||||||
}
|
);
|
||||||
|
|||||||
63
apps/desktop/__tests__/electron-test/base-test.ts
Normal file
63
apps/desktop/__tests__/electron-test/base-test.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/*
|
||||||
|
This file is part of the Notesnook project (https://notesnook.com/)
|
||||||
|
|
||||||
|
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { mergeTests, test } from "@playwright/test";
|
||||||
|
import type { CommonFixtures, CommonWorkerFixtures } from "./common-fixtures";
|
||||||
|
import { commonFixtures } from "./common-fixtures";
|
||||||
|
import { platformTest } from "./platform-fixtures";
|
||||||
|
import { testModeTest } from "./test-mode-fixtures";
|
||||||
|
|
||||||
|
export const base = test;
|
||||||
|
|
||||||
|
export const baseTest = mergeTests(base, platformTest, testModeTest).extend<
|
||||||
|
CommonFixtures,
|
||||||
|
CommonWorkerFixtures
|
||||||
|
>(commonFixtures);
|
||||||
|
|
||||||
|
export function step<
|
||||||
|
This extends NonNullable<unknown>,
|
||||||
|
Args extends any[],
|
||||||
|
Return
|
||||||
|
>(
|
||||||
|
target: (this: This, ...args: Args) => Promise<Return>,
|
||||||
|
context: ClassMethodDecoratorContext<
|
||||||
|
This,
|
||||||
|
(this: This, ...args: Args) => Promise<Return>
|
||||||
|
>
|
||||||
|
) {
|
||||||
|
function replacementMethod(this: This, ...args: Args): Promise<Return> {
|
||||||
|
const name =
|
||||||
|
this.constructor.name +
|
||||||
|
"." +
|
||||||
|
(context.name as string) +
|
||||||
|
"(" +
|
||||||
|
args.map((a) => JSON.stringify(a)).join(",") +
|
||||||
|
")";
|
||||||
|
return test.step(name, async () => {
|
||||||
|
return await target.call(this, ...args);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return replacementMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
// declare global {
|
||||||
|
// interface Window {
|
||||||
|
// builtins: Builtins;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
347
apps/desktop/__tests__/electron-test/common-fixtures.ts
Normal file
347
apps/desktop/__tests__/electron-test/common-fixtures.ts
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
/* eslint-disable header/header */
|
||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Fixtures } from "@playwright/test";
|
||||||
|
import type { ChildProcess } from "child_process";
|
||||||
|
import { execSync, spawn } from "child_process";
|
||||||
|
import net from "net";
|
||||||
|
import fs from "fs";
|
||||||
|
import { stripAnsi } from "./playwright-utils";
|
||||||
|
|
||||||
|
type TestChildParams = {
|
||||||
|
command: string[];
|
||||||
|
cwd?: string;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
shell?: boolean;
|
||||||
|
onOutput?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
import childProcess from "child_process";
|
||||||
|
|
||||||
|
type ProcessData = {
|
||||||
|
pid: number; // process ID
|
||||||
|
pgrp: number; // process group ID
|
||||||
|
children: Set<ProcessData>; // direct children of the process
|
||||||
|
};
|
||||||
|
|
||||||
|
function readAllProcessesLinux(): {
|
||||||
|
pid: number;
|
||||||
|
ppid: number;
|
||||||
|
pgrp: number;
|
||||||
|
}[] {
|
||||||
|
const result: { pid: number; ppid: number; pgrp: number }[] = [];
|
||||||
|
for (const dir of fs.readdirSync("/proc")) {
|
||||||
|
const pid = +dir;
|
||||||
|
if (isNaN(pid)) continue;
|
||||||
|
try {
|
||||||
|
const statFile = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
|
||||||
|
// Format of /proc/*/stat is described https://man7.org/linux/man-pages/man5/proc.5.html
|
||||||
|
const match = statFile.match(
|
||||||
|
/^(?<pid>\d+)\s+\((?<comm>.*)\)\s+(?<state>R|S|D|Z|T|t|W|X|x|K|W|P)\s+(?<ppid>\d+)\s+(?<pgrp>\d+)/
|
||||||
|
);
|
||||||
|
if (match && match.groups) {
|
||||||
|
result.push({
|
||||||
|
pid: +match.groups.pid,
|
||||||
|
ppid: +match.groups.ppid,
|
||||||
|
pgrp: +match.groups.pgrp
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// We don't have access to some /proc/<pid>/stat file.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAllProcessesMacOS(): {
|
||||||
|
pid: number;
|
||||||
|
ppid: number;
|
||||||
|
pgrp: number;
|
||||||
|
}[] {
|
||||||
|
const result: { pid: number; ppid: number; pgrp: number }[] = [];
|
||||||
|
const processTree = childProcess.spawnSync("ps", ["-eo", "pid,pgid,ppid"]);
|
||||||
|
const lines = processTree.stdout.toString().trim().split("\n");
|
||||||
|
for (const line of lines) {
|
||||||
|
const [pid, pgrp, ppid] = line
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((token) => +token);
|
||||||
|
// On linux, the very first line of `ps` is the header with "PID PGID PPID".
|
||||||
|
if (isNaN(pid) || isNaN(pgrp) || isNaN(ppid)) continue;
|
||||||
|
result.push({ pid, ppid, pgrp });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProcessTreePosix(pid: number): ProcessData | undefined {
|
||||||
|
// Certain Linux distributions might not have `ps` installed.
|
||||||
|
const allProcesses =
|
||||||
|
process.platform === "darwin"
|
||||||
|
? readAllProcessesMacOS()
|
||||||
|
: readAllProcessesLinux();
|
||||||
|
const pidToProcess = new Map<number, ProcessData>();
|
||||||
|
for (const { pid, pgrp } of allProcesses)
|
||||||
|
pidToProcess.set(pid, { pid, pgrp, children: new Set() });
|
||||||
|
for (const { pid, ppid } of allProcesses) {
|
||||||
|
const parent = pidToProcess.get(ppid);
|
||||||
|
const child = pidToProcess.get(pid);
|
||||||
|
// On POSIX, certain processes might not have parent (e.g. PID=1 and occasionally PID=2)
|
||||||
|
// or we might not have access to it proc info.
|
||||||
|
if (parent && child) parent.children.add(child);
|
||||||
|
}
|
||||||
|
return pidToProcess.get(pid);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TestChildProcess {
|
||||||
|
params: TestChildParams;
|
||||||
|
process: ChildProcess;
|
||||||
|
output = "";
|
||||||
|
stdout = "";
|
||||||
|
stderr = "";
|
||||||
|
fullOutput = "";
|
||||||
|
onOutput?: (chunk: string | Buffer) => void;
|
||||||
|
exited: Promise<{ exitCode: number | null; signal: string | null }>;
|
||||||
|
exitCode: Promise<number | null>;
|
||||||
|
|
||||||
|
private _outputCallbacks = new Set<() => void>();
|
||||||
|
|
||||||
|
constructor(params: TestChildParams) {
|
||||||
|
this.params = params;
|
||||||
|
// See https://nodejs.org/api/deprecations.html#DEP0190
|
||||||
|
const command = params.shell ? params.command.join(" ") : params.command[0];
|
||||||
|
const args = params.shell ? [] : params.command.slice(1);
|
||||||
|
this.process = spawn(command, args, {
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
...params.env
|
||||||
|
},
|
||||||
|
cwd: params.cwd,
|
||||||
|
shell: params.shell,
|
||||||
|
// On non-windows platforms, `detached: true` makes child process a leader of a new
|
||||||
|
// process group, making it possible to kill child process tree with `.kill(-pid)` command.
|
||||||
|
// @see https://nodejs.org/api/child_process.html#child_process_options_detached
|
||||||
|
detached: process.platform !== "win32"
|
||||||
|
});
|
||||||
|
if (process.env.PWTEST_DEBUG)
|
||||||
|
process.stdout.write(`\n\nLaunching ${params.command.join(" ")}\n`);
|
||||||
|
this.onOutput = params.onOutput;
|
||||||
|
|
||||||
|
const appendChunk = (type: "stdout" | "stderr", chunk: string | Buffer) => {
|
||||||
|
this.output += String(chunk);
|
||||||
|
if (type === "stderr") this.stderr += String(chunk);
|
||||||
|
else this.stdout += String(chunk);
|
||||||
|
if (process.env.PWTEST_DEBUG) process.stdout.write(String(chunk));
|
||||||
|
else this.fullOutput += String(chunk);
|
||||||
|
this.onOutput?.(chunk);
|
||||||
|
for (const cb of this._outputCallbacks) cb();
|
||||||
|
this._outputCallbacks.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
this.process.stderr!.on("data", appendChunk.bind(null, "stderr"));
|
||||||
|
this.process.stdout!.on("data", appendChunk.bind(null, "stdout"));
|
||||||
|
|
||||||
|
const killProcessGroup = this._killProcessTree.bind(this, "SIGKILL");
|
||||||
|
process.on("exit", killProcessGroup);
|
||||||
|
this.exited = new Promise((f) => {
|
||||||
|
this.process.on("exit", (exitCode, signal) => f({ exitCode, signal }));
|
||||||
|
process.off("exit", killProcessGroup);
|
||||||
|
});
|
||||||
|
this.exitCode = this.exited.then((r) => r.exitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
outputLines(): string[] {
|
||||||
|
const strippedOutput = stripAnsi(this.output);
|
||||||
|
return strippedOutput
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.startsWith("%%"))
|
||||||
|
.map((line) => line.substring(2).trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
async kill(signal: "SIGINT" | "SIGKILL" = "SIGKILL") {
|
||||||
|
this._killProcessTree(signal);
|
||||||
|
return this.exited;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _killProcessTree(signal: "SIGINT" | "SIGKILL") {
|
||||||
|
if (!this.process.pid || !this.process.kill(0)) return;
|
||||||
|
killProcessGroup(this.process.pid, signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
async cleanExit() {
|
||||||
|
const r = await this.exited;
|
||||||
|
if (r.exitCode)
|
||||||
|
throw new Error(
|
||||||
|
`Process failed with exit code ${r.exitCode}. Output:\n${this.output}`
|
||||||
|
);
|
||||||
|
if (r.signal)
|
||||||
|
throw new Error(
|
||||||
|
`Process received signal: ${r.signal}. Output:\n${this.output}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForOutput(substring: string, count = 1) {
|
||||||
|
while (countTimes(stripAnsi(this.output), substring) < count)
|
||||||
|
await new Promise<void>((f) => this._outputCallbacks.add(f));
|
||||||
|
}
|
||||||
|
|
||||||
|
clearOutput() {
|
||||||
|
this.output = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
write(chars: string) {
|
||||||
|
this.process.stdin!.write(chars);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function killProcessGroup(
|
||||||
|
pid: number,
|
||||||
|
signal: "SIGINT" | "SIGKILL" = "SIGKILL"
|
||||||
|
) {
|
||||||
|
// On Windows, we always call `taskkill` no matter signal.
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
try {
|
||||||
|
execSync(`taskkill /pid ${pid} /T /F /FI "MEMUSAGE gt 0"`, {
|
||||||
|
stdio: "ignore"
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// the process might have already stopped
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In case of POSIX and `SIGINT` signal, send it to the main process group only.
|
||||||
|
if (signal === "SIGINT") {
|
||||||
|
try {
|
||||||
|
process.kill(-pid, "SIGINT");
|
||||||
|
} catch (e) {
|
||||||
|
// the process might have already stopped
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In case of POSIX and `SIGKILL` signal, we should send it to all descendant process groups.
|
||||||
|
const rootProcess = buildProcessTreePosix(pid);
|
||||||
|
if (!rootProcess) return;
|
||||||
|
|
||||||
|
const descendantProcessGroups = (function flatten(
|
||||||
|
processData: ProcessData,
|
||||||
|
result: Set<number> = new Set()
|
||||||
|
) {
|
||||||
|
// Process can nullify its own process group with `setpgid`. Use its PID instead.
|
||||||
|
result.add(processData.pgrp || processData.pid);
|
||||||
|
processData.children.forEach((child) => flatten(child, result));
|
||||||
|
return result;
|
||||||
|
})(rootProcess);
|
||||||
|
for (const pgrp of descendantProcessGroups) {
|
||||||
|
try {
|
||||||
|
process.kill(-pgrp, "SIGKILL");
|
||||||
|
} catch (e) {
|
||||||
|
// the process might have already stopped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CommonFixtures = {
|
||||||
|
childProcess: (params: TestChildParams) => TestChildProcess;
|
||||||
|
waitForPort: (port: number) => Promise<void>;
|
||||||
|
findFreePort: () => Promise<number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CommonWorkerFixtures = {
|
||||||
|
daemonProcess: (params: TestChildParams) => TestChildProcess;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const commonFixtures: Fixtures<CommonFixtures, CommonWorkerFixtures> = {
|
||||||
|
childProcess: async ({}, use, testInfo) => {
|
||||||
|
const processes: TestChildProcess[] = [];
|
||||||
|
await use((params) => {
|
||||||
|
const process = new TestChildProcess(params);
|
||||||
|
processes.push(process);
|
||||||
|
return process;
|
||||||
|
});
|
||||||
|
await Promise.all(processes.map(async (child) => child.kill()));
|
||||||
|
if (
|
||||||
|
testInfo.status !== "passed" &&
|
||||||
|
testInfo.status !== "skipped" &&
|
||||||
|
!process.env.PWTEST_DEBUG
|
||||||
|
) {
|
||||||
|
for (const process of processes) {
|
||||||
|
console.log("====== " + process.params.command.join(" "));
|
||||||
|
console.log(process.fullOutput.replace(/\x1Bc/g, ""));
|
||||||
|
console.log("=========================================");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
daemonProcess: [
|
||||||
|
async ({}, use) => {
|
||||||
|
const processes: TestChildProcess[] = [];
|
||||||
|
await use((params) => {
|
||||||
|
const process = new TestChildProcess(params);
|
||||||
|
processes.push(process);
|
||||||
|
return process;
|
||||||
|
});
|
||||||
|
await Promise.all(processes.map((child) => child.kill("SIGINT")));
|
||||||
|
},
|
||||||
|
{ scope: "worker" }
|
||||||
|
],
|
||||||
|
|
||||||
|
waitForPort: async ({}, use) => {
|
||||||
|
const token = { canceled: false };
|
||||||
|
await use(async (port) => {
|
||||||
|
while (!token.canceled) {
|
||||||
|
const promise = new Promise<boolean>((resolve) => {
|
||||||
|
const conn = net
|
||||||
|
.connect(port, "127.0.0.1")
|
||||||
|
.on("error", () => resolve(false))
|
||||||
|
.on("connect", () => {
|
||||||
|
conn.end();
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (await promise) return;
|
||||||
|
await new Promise((x) => setTimeout(x, 100));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
token.canceled = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
findFreePort: async ({}, use) => {
|
||||||
|
await use(async () => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.listen(0, "127.0.0.1", () => {
|
||||||
|
const { port } = server.address() as net.AddressInfo;
|
||||||
|
server.close(() => resolve(port));
|
||||||
|
});
|
||||||
|
server.on("error", reject);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function countTimes(s: string, sub: string): number {
|
||||||
|
let result = 0;
|
||||||
|
for (let index = 0; index !== -1; ) {
|
||||||
|
index = s.indexOf(sub, index);
|
||||||
|
if (index !== -1) {
|
||||||
|
result++;
|
||||||
|
index += sub.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||||||
|
|
||||||
import { buildApp } from "./utils";
|
import { buildApp } from "./utils";
|
||||||
|
|
||||||
export default async function setup() {
|
export default async function globalSetup() {
|
||||||
|
console.log("Building the app...");
|
||||||
await buildApp();
|
await buildApp();
|
||||||
}
|
}
|
||||||
152
apps/desktop/__tests__/electron-test/index.ts
Normal file
152
apps/desktop/__tests__/electron-test/index.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
/*
|
||||||
|
This file is part of the Notesnook project (https://notesnook.com/)
|
||||||
|
|
||||||
|
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
/* eslint-disable no-empty-pattern */
|
||||||
|
|
||||||
|
import { AppContext, buildAndLaunchApp, TestOptions } from "./utils";
|
||||||
|
import path from "path";
|
||||||
|
import { version } from "../../package.json";
|
||||||
|
import type { ElectronApplication, Page } from "@playwright/test";
|
||||||
|
import type { TraceViewerFixtures } from "./trace-viewer-fixtures";
|
||||||
|
import { traceViewerFixtures } from "./trace-viewer-fixtures";
|
||||||
|
import { PageTestFixtures, PageWorkerFixtures } from "./page-test-api";
|
||||||
|
import fs from "fs";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import { baseTest } from "./base-test";
|
||||||
|
|
||||||
|
export type ElectronTestFixtures = PageTestFixtures & {
|
||||||
|
electronApp: ElectronApplication;
|
||||||
|
launchElectronApp: (options?: TestOptions) => Promise<ElectronApplication>;
|
||||||
|
createUserDataDir: () => Promise<string>;
|
||||||
|
options: TestOptions;
|
||||||
|
newPage: () => Promise<Page>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type { Page, Browser } from "@playwright/test";
|
||||||
|
export { expect } from "@playwright/test";
|
||||||
|
export const test = baseTest
|
||||||
|
.extend<TraceViewerFixtures>(traceViewerFixtures)
|
||||||
|
.extend<ElectronTestFixtures, PageWorkerFixtures>({
|
||||||
|
browserVersion: [
|
||||||
|
({}, use) => use(process.env.ELECTRON_CHROMIUM_VERSION!),
|
||||||
|
{ scope: "worker" }
|
||||||
|
],
|
||||||
|
browserMajorVersion: [
|
||||||
|
({}, use) =>
|
||||||
|
use(Number(process.env.ELECTRON_CHROMIUM_VERSION!.split(".")[0])),
|
||||||
|
{ scope: "worker" }
|
||||||
|
],
|
||||||
|
electronMajorVersion: [
|
||||||
|
({}, use) =>
|
||||||
|
use(
|
||||||
|
parseInt(require("electron/package.json").version.split(".")[0], 10)
|
||||||
|
),
|
||||||
|
{ scope: "worker" }
|
||||||
|
],
|
||||||
|
isBidi: [false, { scope: "worker" }],
|
||||||
|
isAndroid: [false, { scope: "worker" }],
|
||||||
|
isElectron: [true, { scope: "worker" }],
|
||||||
|
isHeadlessShell: [false, { scope: "worker" }],
|
||||||
|
isFrozenWebkit: [false, { scope: "worker" }],
|
||||||
|
|
||||||
|
createUserDataDir: async ({}, run) => {
|
||||||
|
const dirs: string[] = [];
|
||||||
|
// We do not put user data dir in testOutputPath,
|
||||||
|
// because we do not want to upload them as test result artifacts.
|
||||||
|
await run(async () => {
|
||||||
|
const dir = await fs.promises.mkdtemp(
|
||||||
|
path.join(tmpdir(), "playwright-test-")
|
||||||
|
);
|
||||||
|
dirs.push(dir);
|
||||||
|
return dir;
|
||||||
|
});
|
||||||
|
await removeFolders(dirs);
|
||||||
|
},
|
||||||
|
|
||||||
|
launchElectronApp: async ({ createUserDataDir }, use) => {
|
||||||
|
// This env prevents 'Electron Security Policy' console message.
|
||||||
|
process.env["ELECTRON_DISABLE_SECURITY_WARNINGS"] = "true";
|
||||||
|
const apps: AppContext[] = [];
|
||||||
|
await use(async (options?: TestOptions) => {
|
||||||
|
const userDataDir = await createUserDataDir();
|
||||||
|
const ctx = await buildAndLaunchApp(
|
||||||
|
userDataDir,
|
||||||
|
options || {
|
||||||
|
version,
|
||||||
|
config: {}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
apps.push(ctx);
|
||||||
|
return ctx.app;
|
||||||
|
});
|
||||||
|
for (const ctx of apps) {
|
||||||
|
await ctx.app.close();
|
||||||
|
}
|
||||||
|
await removeFolders(apps.map((ctx) => ctx.outputDir));
|
||||||
|
},
|
||||||
|
|
||||||
|
electronApp: async ({ launchElectronApp, options }, use) => {
|
||||||
|
await use(await launchElectronApp(options));
|
||||||
|
},
|
||||||
|
|
||||||
|
page: async ({ electronApp, viewport }, run) => {
|
||||||
|
const page = await electronApp.firstWindow();
|
||||||
|
if (viewport) {
|
||||||
|
await page.setViewportSize(viewport);
|
||||||
|
await electronApp.evaluate((p, viewport) => {
|
||||||
|
const mainWindow = p.BrowserWindow.getAllWindows()[0];
|
||||||
|
mainWindow.setSize(viewport.width, viewport.height);
|
||||||
|
}, viewport);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
page.context().app = electronApp;
|
||||||
|
await run(page);
|
||||||
|
},
|
||||||
|
|
||||||
|
context: async ({ electronApp }, run) => {
|
||||||
|
await run(electronApp.context());
|
||||||
|
},
|
||||||
|
|
||||||
|
newPage: async ({ launchElectronApp, options }, use) => {
|
||||||
|
await use(async () => {
|
||||||
|
const app = await launchElectronApp(options);
|
||||||
|
return app.firstWindow();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
options: async ({}, use) => {
|
||||||
|
await use({
|
||||||
|
version,
|
||||||
|
config: {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function removeFolders(folders: string[]) {
|
||||||
|
await Promise.all(
|
||||||
|
folders.map((folder) =>
|
||||||
|
fs.promises.rm(folder, {
|
||||||
|
force: true,
|
||||||
|
recursive: true,
|
||||||
|
maxRetries: 3,
|
||||||
|
retryDelay: 5000
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
58
apps/desktop/__tests__/electron-test/page-test-api.ts
Normal file
58
apps/desktop/__tests__/electron-test/page-test-api.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/* eslint-disable header/header */
|
||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Browser, Page, ViewportSize } from "playwright-core";
|
||||||
|
import type {
|
||||||
|
PageScreenshotOptions,
|
||||||
|
ScreenshotMode,
|
||||||
|
VideoMode
|
||||||
|
} from "@playwright/test";
|
||||||
|
export { expect } from "@playwright/test";
|
||||||
|
|
||||||
|
// Page test does not guarantee an isolated context, just a new page (because Android).
|
||||||
|
export type PageTestFixtures = {
|
||||||
|
page: Page;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PageWorkerFixtures = {
|
||||||
|
headless: boolean;
|
||||||
|
channel: string | undefined;
|
||||||
|
screenshot:
|
||||||
|
| ScreenshotMode
|
||||||
|
| ({ mode: ScreenshotMode } & Pick<
|
||||||
|
PageScreenshotOptions,
|
||||||
|
"fullPage" | "omitBackground"
|
||||||
|
>);
|
||||||
|
trace:
|
||||||
|
| "off"
|
||||||
|
| "on"
|
||||||
|
| "retain-on-failure"
|
||||||
|
| "on-first-retry"
|
||||||
|
| "retain-on-first-failure"
|
||||||
|
| "on-all-retries"
|
||||||
|
| /** deprecated */ "retry-with-trace";
|
||||||
|
video: VideoMode | { mode: VideoMode; size: ViewportSize };
|
||||||
|
browserName: "chromium" | "firefox" | "webkit";
|
||||||
|
browserVersion: string;
|
||||||
|
browserMajorVersion: number;
|
||||||
|
electronMajorVersion: number;
|
||||||
|
isBidi: boolean;
|
||||||
|
isAndroid: boolean;
|
||||||
|
isElectron: boolean;
|
||||||
|
isHeadlessShell: boolean;
|
||||||
|
isFrozenWebkit: boolean;
|
||||||
|
};
|
||||||
47
apps/desktop/__tests__/electron-test/platform-fixtures.ts
Normal file
47
apps/desktop/__tests__/electron-test/platform-fixtures.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/* eslint-disable header/header */
|
||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test } from "@playwright/test";
|
||||||
|
import os from "os";
|
||||||
|
|
||||||
|
export type PlatformWorkerFixtures = {
|
||||||
|
platform: "win32" | "darwin" | "linux";
|
||||||
|
isWindows: boolean;
|
||||||
|
isMac: boolean;
|
||||||
|
isLinux: boolean;
|
||||||
|
macVersion: number; // major only, 11 or later, zero if not mac
|
||||||
|
};
|
||||||
|
|
||||||
|
function platform(): "win32" | "darwin" | "linux" {
|
||||||
|
if (process.env.PLAYWRIGHT_SERVICE_OS === "linux") return "linux";
|
||||||
|
if (process.env.PLAYWRIGHT_SERVICE_OS === "windows") return "win32";
|
||||||
|
if (process.env.PLAYWRIGHT_SERVICE_OS === "macos") return "darwin";
|
||||||
|
return process.platform as "win32" | "darwin" | "linux";
|
||||||
|
}
|
||||||
|
|
||||||
|
function macVersion() {
|
||||||
|
if (process.platform !== "darwin") return 0;
|
||||||
|
return +os.release().split(".")[0] - 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const platformTest = test.extend<{}, PlatformWorkerFixtures>({
|
||||||
|
platform: [platform(), { scope: "worker" }],
|
||||||
|
isWindows: [platform() === "win32", { scope: "worker" }],
|
||||||
|
isMac: [platform() === "darwin", { scope: "worker" }],
|
||||||
|
isLinux: [platform() === "linux", { scope: "worker" }],
|
||||||
|
macVersion: [macVersion(), { scope: "worker" }]
|
||||||
|
});
|
||||||
26
apps/desktop/__tests__/electron-test/playwright-utils.ts
Normal file
26
apps/desktop/__tests__/electron-test/playwright-utils.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/*
|
||||||
|
This file is part of the Notesnook project (https://notesnook.com/)
|
||||||
|
|
||||||
|
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ansiRegex = new RegExp(
|
||||||
|
"[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))",
|
||||||
|
"g"
|
||||||
|
);
|
||||||
|
export function stripAnsi(str: string): string {
|
||||||
|
return str.replace(ansiRegex, "");
|
||||||
|
}
|
||||||
75
apps/desktop/__tests__/electron-test/test-mode-fixtures.ts
Normal file
75
apps/desktop/__tests__/electron-test/test-mode-fixtures.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/*
|
||||||
|
This file is part of the Notesnook project (https://notesnook.com/)
|
||||||
|
|
||||||
|
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test } from "@playwright/test";
|
||||||
|
import type { TestModeName } from "./test-mode";
|
||||||
|
import { DefaultTestMode, DriverTestMode } from "./test-mode";
|
||||||
|
|
||||||
|
export type TestModeWorkerOptions = {
|
||||||
|
mode: TestModeName;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TestModeTestFixtures = {
|
||||||
|
toImpl: (rpcObject?: any) => any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TestModeWorkerFixtures = {
|
||||||
|
toImplInWorkerScope: (rpcObject?: any) => any;
|
||||||
|
playwright: typeof import("@playwright/test");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const testModeTest = test.extend<
|
||||||
|
TestModeTestFixtures,
|
||||||
|
TestModeWorkerOptions & TestModeWorkerFixtures
|
||||||
|
>({
|
||||||
|
mode: ["default", { scope: "worker", option: true }],
|
||||||
|
playwright: [
|
||||||
|
async ({ mode }, run) => {
|
||||||
|
const testMode = {
|
||||||
|
default: new DefaultTestMode(),
|
||||||
|
service: new DefaultTestMode(),
|
||||||
|
service2: new DefaultTestMode(),
|
||||||
|
"service-grid": new DefaultTestMode(),
|
||||||
|
wsl: new DefaultTestMode(),
|
||||||
|
driver: new DriverTestMode()
|
||||||
|
}[mode];
|
||||||
|
const playwright = await testMode.setup();
|
||||||
|
await run(playwright);
|
||||||
|
await testMode.teardown();
|
||||||
|
},
|
||||||
|
{ scope: "worker" }
|
||||||
|
],
|
||||||
|
|
||||||
|
toImplInWorkerScope: [
|
||||||
|
async ({ playwright }, use) => {
|
||||||
|
await use((playwright as any)._connection.toImpl);
|
||||||
|
},
|
||||||
|
{ scope: "worker" }
|
||||||
|
],
|
||||||
|
|
||||||
|
toImpl: async (
|
||||||
|
{ toImplInWorkerScope: toImplWorker, mode },
|
||||||
|
use,
|
||||||
|
testInfo
|
||||||
|
) => {
|
||||||
|
if (mode !== "default" || process.env.PW_TEST_REUSE_CONTEXT)
|
||||||
|
testInfo.skip();
|
||||||
|
await use(toImplWorker);
|
||||||
|
}
|
||||||
|
});
|
||||||
52
apps/desktop/__tests__/electron-test/test-mode.ts
Normal file
52
apps/desktop/__tests__/electron-test/test-mode.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/* eslint-disable header/header */
|
||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
/// @ts-ignore
|
||||||
|
import { oop, client } from "playwright-core/lib/coreBundle";
|
||||||
|
|
||||||
|
export type TestModeName = "default" | "driver";
|
||||||
|
|
||||||
|
const { start } = oop;
|
||||||
|
|
||||||
|
interface TestMode {
|
||||||
|
setup(): Promise<client.Playwright>;
|
||||||
|
teardown(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DriverTestMode implements TestMode {
|
||||||
|
private _impl: { playwright: client.Playwright; stop: () => Promise<void> };
|
||||||
|
|
||||||
|
async setup() {
|
||||||
|
this._impl = await start({
|
||||||
|
NODE_OPTIONS: undefined // Hide driver process while debugging.
|
||||||
|
});
|
||||||
|
return this._impl.playwright;
|
||||||
|
}
|
||||||
|
|
||||||
|
async teardown() {
|
||||||
|
await this._impl.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DefaultTestMode implements TestMode {
|
||||||
|
async setup() {
|
||||||
|
return require("playwright-core");
|
||||||
|
}
|
||||||
|
|
||||||
|
async teardown() {}
|
||||||
|
}
|
||||||
243
apps/desktop/__tests__/electron-test/trace-viewer-fixtures.ts
Normal file
243
apps/desktop/__tests__/electron-test/trace-viewer-fixtures.ts
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
/* eslint-disable header/header */
|
||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Fixtures,
|
||||||
|
FrameLocator,
|
||||||
|
Locator,
|
||||||
|
Page,
|
||||||
|
Browser,
|
||||||
|
BrowserContext
|
||||||
|
} from "@playwright/test";
|
||||||
|
import { step } from "./base-test";
|
||||||
|
import path from "path";
|
||||||
|
import { CommonFixtures, TestChildProcess } from "./common-fixtures";
|
||||||
|
|
||||||
|
type BaseTestFixtures = CommonFixtures & {
|
||||||
|
context: BrowserContext;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BaseWorkerFixtures = {
|
||||||
|
headless: boolean;
|
||||||
|
browser: Browser;
|
||||||
|
browserName: "chromium" | "firefox" | "webkit";
|
||||||
|
playwright: typeof import("@playwright/test");
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TraceViewerFixtures = {
|
||||||
|
showTraceViewer: (
|
||||||
|
trace: string | undefined,
|
||||||
|
options?: { host?: string; port?: number; stdin?: boolean }
|
||||||
|
) => Promise<TraceViewerPage>;
|
||||||
|
runAndTrace: (
|
||||||
|
body: () => Promise<void>,
|
||||||
|
optsOverrides?: Parameters<BrowserContext["tracing"]["start"]>[0]
|
||||||
|
) => Promise<TraceViewerPage>;
|
||||||
|
};
|
||||||
|
|
||||||
|
class TraceViewerPage {
|
||||||
|
actionTitles: Locator;
|
||||||
|
actionsTree: Locator;
|
||||||
|
callLines: Locator;
|
||||||
|
consoleLines: Locator;
|
||||||
|
logLines: Locator;
|
||||||
|
errorMessages: Locator;
|
||||||
|
consoleLineMessages: Locator;
|
||||||
|
consoleStacks: Locator;
|
||||||
|
networkRequests: Locator;
|
||||||
|
metadataTab: Locator;
|
||||||
|
snapshotContainer: Locator;
|
||||||
|
sourceCodeTab: Locator;
|
||||||
|
networkTab: Locator;
|
||||||
|
|
||||||
|
settingsDialog: Locator;
|
||||||
|
themeSetting: Locator;
|
||||||
|
displayCanvasContentSetting: Locator;
|
||||||
|
|
||||||
|
constructor(public page: Page, public process: TestChildProcess) {
|
||||||
|
this.actionTitles = page.locator(".action-title");
|
||||||
|
this.actionsTree = page.getByTestId("actions-tree");
|
||||||
|
this.callLines = page.locator(".call-tab .call-line");
|
||||||
|
this.logLines = page
|
||||||
|
.getByRole("list", { name: "Log entries" })
|
||||||
|
.getByRole("listitem");
|
||||||
|
this.consoleLines = page
|
||||||
|
.getByRole("tabpanel", { name: "Console" })
|
||||||
|
.getByRole("listitem");
|
||||||
|
this.consoleLineMessages = page.locator(".console-line-message");
|
||||||
|
this.errorMessages = page.locator(".error-message");
|
||||||
|
this.consoleStacks = page.locator(".console-stack");
|
||||||
|
this.networkRequests = page
|
||||||
|
.getByRole("list", { name: "Network requests" })
|
||||||
|
.getByRole("listitem");
|
||||||
|
this.snapshotContainer = page.locator(
|
||||||
|
".snapshot-container iframe.snapshot-visible[name=snapshot]"
|
||||||
|
);
|
||||||
|
this.metadataTab = page.getByRole("tabpanel", { name: "Metadata" });
|
||||||
|
this.sourceCodeTab = page.getByRole("tabpanel", { name: "Source" });
|
||||||
|
this.networkTab = page.getByRole("tabpanel", { name: "Network" });
|
||||||
|
|
||||||
|
this.settingsDialog = page.getByTestId("settings-toolbar-dialog");
|
||||||
|
this.themeSetting = this.settingsDialog.getByRole("combobox", {
|
||||||
|
name: "Theme"
|
||||||
|
});
|
||||||
|
this.displayCanvasContentSetting = page
|
||||||
|
.locator(".setting")
|
||||||
|
.getByText("Display canvas content");
|
||||||
|
}
|
||||||
|
|
||||||
|
@step
|
||||||
|
async showAllActions() {
|
||||||
|
await this.page.getByRole("button", { name: "Filter actions" }).click();
|
||||||
|
await this.page.locator(".setting").getByText("Network routes").click();
|
||||||
|
await this.page.locator(".setting").getByText("Getters").click();
|
||||||
|
await this.page.locator(".setting").getByText("Configuration").click();
|
||||||
|
await this.page.getByRole("button", { name: "Filter actions" }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
stackFrames(options: { selected?: boolean } = {}) {
|
||||||
|
const entry = this.page
|
||||||
|
.getByRole("list", { name: "Stack trace" })
|
||||||
|
.getByRole("listitem");
|
||||||
|
if (options.selected) return entry.locator(":scope.selected");
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
actionIconsText(action: string) {
|
||||||
|
const entry = this.actionsTree.getByRole("treeitem", { name: action });
|
||||||
|
return entry.locator(".action-icon-value").filter({ visible: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
actionIcons(action: string) {
|
||||||
|
return this.actionsTree
|
||||||
|
.getByRole("treeitem", { name: action })
|
||||||
|
.locator(".action-icons")
|
||||||
|
.filter({ visible: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
@step
|
||||||
|
async expandAction(title: string) {
|
||||||
|
await this.actionsTree
|
||||||
|
.getByRole("treeitem", { name: title })
|
||||||
|
.locator(".codicon-chevron-right")
|
||||||
|
.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
@step
|
||||||
|
async selectAction(title: string, ordinal: number = 0) {
|
||||||
|
await this.actionsTree.getByTitle(title).nth(ordinal).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
@step
|
||||||
|
async hoverAction(title: string, ordinal: number = 0) {
|
||||||
|
await this.actionsTree
|
||||||
|
.getByRole("treeitem", { name: title })
|
||||||
|
.nth(ordinal)
|
||||||
|
.hover();
|
||||||
|
}
|
||||||
|
|
||||||
|
@step
|
||||||
|
async selectSnapshot(name: string) {
|
||||||
|
await this.page.getByRole("tab", { name }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async showErrorsTab() {
|
||||||
|
await this.page.getByRole("tab", { name: "Errors" }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async showConsoleTab() {
|
||||||
|
await this.page.getByRole("tab", { name: "Console" }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async showSourceTab() {
|
||||||
|
await this.page.getByRole("tab", { name: "Source" }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async showNetworkTab() {
|
||||||
|
await this.page.getByRole("tab", { name: "Network" }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async showMetadataTab() {
|
||||||
|
await this.page.getByRole("tab", { name: "Metadata" }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async showSettings() {
|
||||||
|
await this.page.getByRole("button", { name: "Settings" }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
@step
|
||||||
|
async snapshotFrame(
|
||||||
|
actionName: string,
|
||||||
|
ordinal: number = 0,
|
||||||
|
hasSubframe: boolean = false
|
||||||
|
): Promise<FrameLocator> {
|
||||||
|
await this.selectAction(actionName, ordinal);
|
||||||
|
while (this.page.frames().length < (hasSubframe ? 4 : 3))
|
||||||
|
await this.page.waitForEvent("frameattached");
|
||||||
|
return this.page.frameLocator("iframe.snapshot-visible[name=snapshot]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const traceViewerFixtures: Fixtures<
|
||||||
|
TraceViewerFixtures,
|
||||||
|
{},
|
||||||
|
BaseTestFixtures,
|
||||||
|
BaseWorkerFixtures
|
||||||
|
> = {
|
||||||
|
showTraceViewer: async ({ playwright, childProcess, browserName }, use) => {
|
||||||
|
const browsers: Browser[] = [];
|
||||||
|
await use(async (trace: string | undefined, { host, port, stdin } = {}) => {
|
||||||
|
const command = [
|
||||||
|
"node",
|
||||||
|
path.join(__dirname, "../../node_modules/playwright-core/cli.js"),
|
||||||
|
"show-trace",
|
||||||
|
"--port",
|
||||||
|
"" + (port ?? "0")
|
||||||
|
];
|
||||||
|
if (host) command.push("--host", host);
|
||||||
|
if (stdin) command.push("--stdin");
|
||||||
|
if (trace) command.push(trace);
|
||||||
|
const cp = childProcess({ command });
|
||||||
|
await cp.waitForOutput("Listening on");
|
||||||
|
const browser = await playwright.chromium.launch({
|
||||||
|
...(browserName === "chromium" ? {} : { channel: "chromium" }),
|
||||||
|
executablePath: process.env.CRPATH // without this, setting FFPATH makes us launch Firefox with Chromium args
|
||||||
|
});
|
||||||
|
browsers.push(browser);
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const url = cp.output.match(/Listening on (http:\/\/[^\s]+)/)![1];
|
||||||
|
await page.goto(url);
|
||||||
|
return new TraceViewerPage(page, cp);
|
||||||
|
});
|
||||||
|
for (const browser of browsers) await browser.close();
|
||||||
|
},
|
||||||
|
|
||||||
|
runAndTrace: async ({ context, showTraceViewer }, use, testInfo) => {
|
||||||
|
await use(async (body: () => Promise<void>, optsOverrides = {}) => {
|
||||||
|
const traceFile = testInfo.outputPath("trace.zip");
|
||||||
|
await context.tracing.start({
|
||||||
|
snapshots: true,
|
||||||
|
screenshots: true,
|
||||||
|
sources: true,
|
||||||
|
...optsOverrides
|
||||||
|
});
|
||||||
|
await body();
|
||||||
|
await context.tracing.stop({ path: traceFile });
|
||||||
|
return showTraceViewer(traceFile);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -19,21 +19,22 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||||||
|
|
||||||
import { execSync } from "child_process";
|
import { execSync } from "child_process";
|
||||||
import { cp } from "fs/promises";
|
import { cp } from "fs/promises";
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import path, { join, resolve } from "path";
|
import path, { join, resolve } from "path";
|
||||||
import { _electron as electron } from "playwright";
|
import {
|
||||||
|
_electron as electron,
|
||||||
|
ElectronApplication,
|
||||||
|
Page
|
||||||
|
} from "@playwright/test";
|
||||||
import { existsSync } from "fs";
|
import { existsSync } from "fs";
|
||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = path.dirname(__filename);
|
|
||||||
const IS_DEBUG = process.env.NN_DEBUG === "true" || process.env.CI === "true";
|
const IS_DEBUG = process.env.NN_DEBUG === "true" || process.env.CI === "true";
|
||||||
const productName = `NotesnookTestHarness`;
|
const productName = `NotesnookTestHarness`;
|
||||||
const SOURCE_DIR = resolve("output", productName);
|
const root = path.resolve(__dirname, "..", "..");
|
||||||
|
const SOURCE_DIR = resolve(root, "output", productName);
|
||||||
|
|
||||||
export interface AppContext {
|
export interface AppContext {
|
||||||
app: import("playwright").ElectronApplication;
|
app: ElectronApplication;
|
||||||
page: import("playwright").Page;
|
|
||||||
configPath: string;
|
|
||||||
userDataDir: string;
|
userDataDir: string;
|
||||||
outputDir: string;
|
outputDir: string;
|
||||||
relaunch: () => Promise<void>;
|
relaunch: () => Promise<void>;
|
||||||
@@ -41,6 +42,8 @@ export interface AppContext {
|
|||||||
|
|
||||||
export interface TestOptions {
|
export interface TestOptions {
|
||||||
version: string;
|
version: string;
|
||||||
|
args?: string[];
|
||||||
|
config?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Fixtures {
|
export interface Fixtures {
|
||||||
@@ -49,35 +52,43 @@ export interface Fixtures {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function buildAndLaunchApp(
|
export async function buildAndLaunchApp(
|
||||||
|
userDataDir: string,
|
||||||
options?: TestOptions
|
options?: TestOptions
|
||||||
): Promise<AppContext> {
|
): Promise<AppContext> {
|
||||||
|
await buildApp(options?.version);
|
||||||
|
|
||||||
const productName = `notesnooktest${makeid(10)}`;
|
const productName = `notesnooktest${makeid(10)}`;
|
||||||
const outputDir = path.join("test-artifacts", `${productName}-output`);
|
const outputDir = path.join(root, "test-artifacts", `${productName}-output`);
|
||||||
const executablePath = await copyBuild({
|
const executablePath = await copyBuild({
|
||||||
...options,
|
...options,
|
||||||
outputDir
|
outputDir
|
||||||
});
|
});
|
||||||
const { app, page, configPath, userDataDir } = await launchApp(
|
|
||||||
|
const configPath = path.join(userDataDir, "UserData", "config.json");
|
||||||
|
if (options?.config) {
|
||||||
|
await mkdir(path.dirname(configPath), { recursive: true });
|
||||||
|
await writeFile(configPath, JSON.stringify(options.config));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { app } = await launchApp(
|
||||||
executablePath,
|
executablePath,
|
||||||
productName,
|
userDataDir,
|
||||||
options?.version
|
options?.version,
|
||||||
|
options?.args
|
||||||
);
|
);
|
||||||
const ctx: AppContext = {
|
const ctx: AppContext = {
|
||||||
app,
|
app,
|
||||||
page,
|
|
||||||
configPath,
|
|
||||||
userDataDir,
|
userDataDir,
|
||||||
outputDir,
|
outputDir,
|
||||||
relaunch: async () => {
|
relaunch: async () => {
|
||||||
const { app, page, configPath, userDataDir } = await launchApp(
|
const { app } = await launchApp(
|
||||||
executablePath,
|
executablePath,
|
||||||
productName,
|
userDataDir,
|
||||||
options?.version
|
options?.version,
|
||||||
|
options?.args
|
||||||
);
|
);
|
||||||
ctx.app = app;
|
ctx.app = app;
|
||||||
ctx.page = page;
|
|
||||||
ctx.userDataDir = userDataDir;
|
ctx.userDataDir = userDataDir;
|
||||||
ctx.configPath = configPath;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return ctx;
|
return ctx;
|
||||||
@@ -85,19 +96,15 @@ export async function buildAndLaunchApp(
|
|||||||
|
|
||||||
async function launchApp(
|
async function launchApp(
|
||||||
executablePath: string,
|
executablePath: string,
|
||||||
packageName: string,
|
userDataDir: string,
|
||||||
version?: string
|
version?: string,
|
||||||
|
args: string[] = []
|
||||||
) {
|
) {
|
||||||
const userDataDir = resolve(
|
|
||||||
__dirname,
|
|
||||||
"..",
|
|
||||||
"test-artifacts",
|
|
||||||
"user_data_dirs",
|
|
||||||
packageName
|
|
||||||
);
|
|
||||||
const app = await electron.launch({
|
const app = await electron.launch({
|
||||||
executablePath,
|
executablePath,
|
||||||
args: IS_DEBUG ? [] : ["--hidden"],
|
args: IS_DEBUG ? [...args] : ["--hidden", ...args],
|
||||||
|
baseURL: "https://app.notesnook.com",
|
||||||
|
acceptDownloads: true,
|
||||||
env: {
|
env: {
|
||||||
...(process.platform === "linux"
|
...(process.platform === "linux"
|
||||||
? {
|
? {
|
||||||
@@ -114,20 +121,28 @@ async function launchApp(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const page = await app.firstWindow();
|
|
||||||
|
|
||||||
const configPath = path.join(userDataDir, "UserData", "config.json");
|
|
||||||
return {
|
return {
|
||||||
app,
|
app,
|
||||||
page,
|
|
||||||
configPath,
|
|
||||||
userDataDir
|
userDataDir
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getAppFromPage(page: Page) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
return page.context().app as ElectronApplication;
|
||||||
|
}
|
||||||
|
|
||||||
let MAX_RETRIES = 3;
|
let MAX_RETRIES = 3;
|
||||||
export async function buildApp(version?: string) {
|
export async function buildApp(version?: string) {
|
||||||
if (!existsSync(SOURCE_DIR)) {
|
if (!existsSync(SOURCE_DIR)) {
|
||||||
|
execSync(
|
||||||
|
`node ${path.join(root, "scripts", "build.mjs")} --test --rebuild`,
|
||||||
|
{
|
||||||
|
stdio: IS_DEBUG ? "inherit" : "ignore"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const args = [
|
const args = [
|
||||||
"electron-builder",
|
"electron-builder",
|
||||||
"--dir",
|
"--dir",
|
||||||
@@ -143,6 +158,7 @@ export async function buildApp(version?: string) {
|
|||||||
stdio: IS_DEBUG ? "inherit" : "ignore",
|
stdio: IS_DEBUG ? "inherit" : "ignore",
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
|
CSC_IDENTITY_AUTO_DISCOVERY: "false",
|
||||||
NOTESNOOK_STAGING: "true",
|
NOTESNOOK_STAGING: "true",
|
||||||
NN_PRODUCT_NAME: productName,
|
NN_PRODUCT_NAME: productName,
|
||||||
NN_APP_ID: `com.notesnook.test.${productName}`,
|
NN_APP_ID: `com.notesnook.test.${productName}`,
|
||||||
@@ -151,7 +167,7 @@ export async function buildApp(version?: string) {
|
|||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (--MAX_RETRIES) {
|
if (--MAX_RETRIES) {
|
||||||
console.log("retrying...");
|
console.log("retrying...", e);
|
||||||
return await buildApp(version);
|
return await buildApp(version);
|
||||||
} else throw e;
|
} else throw e;
|
||||||
}
|
}
|
||||||
@@ -189,8 +205,7 @@ async function makeBuildCopyMacOS(outputDir: string, productName: string) {
|
|||||||
const platformDir = process.arch === "arm64" ? "mac-arm64" : "mac";
|
const platformDir = process.arch === "arm64" ? "mac-arm64" : "mac";
|
||||||
const appDir = await makeBuildCopy(outputDir, platformDir);
|
const appDir = await makeBuildCopy(outputDir, platformDir);
|
||||||
return resolve(
|
return resolve(
|
||||||
__dirname,
|
root,
|
||||||
"..",
|
|
||||||
appDir,
|
appDir,
|
||||||
`${productName}.app`,
|
`${productName}.app`,
|
||||||
"Contents",
|
"Contents",
|
||||||
@@ -17,24 +17,65 @@ You should have received a copy of the GNU General Public License
|
|||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { testCleanup, test } from "./test-override.js";
|
import { test, expect } from "@nn/test";
|
||||||
|
import type { ElectronApplication } from "@playwright/test";
|
||||||
|
|
||||||
test("make sure app loads", async ({
|
async function getMainWindowState(app: ElectronApplication) {
|
||||||
ctx: { page },
|
return await app.evaluate((window) => {
|
||||||
expect,
|
const { BrowserWindow } = window;
|
||||||
onTestFinished
|
const mainWindow = BrowserWindow.getAllWindows()[0];
|
||||||
}) => {
|
if (!mainWindow) throw new Error("Main window not found");
|
||||||
onTestFinished(testCleanup);
|
return {
|
||||||
|
isMinimized: mainWindow.isMinimized(),
|
||||||
await page.waitForSelector("#authForm");
|
isVisible: mainWindow.isVisible()
|
||||||
|
};
|
||||||
expect(
|
});
|
||||||
await page.getByRole("button", { name: "Create account" }).isVisible()
|
}
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
await page
|
|
||||||
.getByRole("button", { name: "Skip & go directly to the app" })
|
|
||||||
.click();
|
|
||||||
|
|
||||||
|
test("make sure app loads", async ({ page }) => {
|
||||||
await page.waitForSelector(".ProseMirror");
|
await page.waitForSelector(".ProseMirror");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("hidden launch minimizes when tray is disabled", async ({
|
||||||
|
launchElectronApp,
|
||||||
|
options
|
||||||
|
}) => {
|
||||||
|
const app = await launchElectronApp({
|
||||||
|
version: options.version,
|
||||||
|
args: ["--hidden"],
|
||||||
|
config: {
|
||||||
|
desktopSettings: {
|
||||||
|
minimizeToSystemTray: false,
|
||||||
|
closeToSystemTray: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const page = await app.firstWindow();
|
||||||
|
await page.waitForSelector(".ProseMirror");
|
||||||
|
|
||||||
|
const state = await getMainWindowState(app);
|
||||||
|
expect(state.isMinimized).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hidden launch does not minimize when close-to-tray is enabled", async ({
|
||||||
|
launchElectronApp,
|
||||||
|
options
|
||||||
|
}) => {
|
||||||
|
const app = await launchElectronApp({
|
||||||
|
version: options.version,
|
||||||
|
args: ["--hidden"],
|
||||||
|
config: {
|
||||||
|
desktopSettings: {
|
||||||
|
minimizeToSystemTray: false,
|
||||||
|
closeToSystemTray: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const page = await app.firstWindow();
|
||||||
|
await page.waitForSelector(".ProseMirror");
|
||||||
|
|
||||||
|
const state = await getMainWindowState(app);
|
||||||
|
expect(state.isMinimized).toBe(false);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
/*
|
|
||||||
This file is part of the Notesnook project (https://notesnook.com/)
|
|
||||||
|
|
||||||
Copyright (C) 2023 Streetwriters (Private) Limited
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
(at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { test as vitestTest, TestContext } from "vitest";
|
|
||||||
import { buildAndLaunchApp, Fixtures, TestOptions } from "./utils";
|
|
||||||
import { mkdir, rm } from "fs/promises";
|
|
||||||
import path from "path";
|
|
||||||
import slugify from "slugify";
|
|
||||||
|
|
||||||
export const test = vitestTest.extend<Fixtures>({
|
|
||||||
options: { version: "3.0.0" } as TestOptions,
|
|
||||||
ctx: async ({ options }, use) => {
|
|
||||||
const ctx = await buildAndLaunchApp(options);
|
|
||||||
await use(ctx);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function testCleanup(context: TestContext) {
|
|
||||||
const ctx = (context.task.context as unknown as Fixtures).ctx;
|
|
||||||
if (context.task.result?.state === "fail") {
|
|
||||||
await mkdir("test-results", { recursive: true });
|
|
||||||
await ctx.page.screenshot({
|
|
||||||
path: path.join(
|
|
||||||
"test-results",
|
|
||||||
`${slugify(context.task.name)}-${process.platform}-${
|
|
||||||
process.arch
|
|
||||||
}-error.png`
|
|
||||||
)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await ctx.app.close();
|
|
||||||
await rm(ctx.userDataDir, {
|
|
||||||
force: true,
|
|
||||||
recursive: true,
|
|
||||||
maxRetries: 3,
|
|
||||||
retryDelay: 5000
|
|
||||||
}).catch(() => {
|
|
||||||
/*ignore */
|
|
||||||
});
|
|
||||||
await rm(ctx.outputDir, {
|
|
||||||
force: true,
|
|
||||||
recursive: true,
|
|
||||||
maxRetries: 3,
|
|
||||||
retryDelay: 5000
|
|
||||||
}).catch(() => {
|
|
||||||
/*ignore */
|
|
||||||
});
|
|
||||||
}
|
|
||||||
11
apps/desktop/__tests__/tsconfig.json
Normal file
11
apps/desktop/__tests__/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"lib": ["ESNext"],
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"paths": {
|
||||||
|
"@nn/test": ["./electron-test/index.ts"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -96,6 +96,7 @@ module.exports = {
|
|||||||
"node_modules/sodium-native/package.json"
|
"node_modules/sodium-native/package.json"
|
||||||
],
|
],
|
||||||
afterPack: "./scripts/removeLocales.js",
|
afterPack: "./scripts/removeLocales.js",
|
||||||
|
protocols: [{ name: "Notesnook", schemes: ["nn"] }],
|
||||||
mac: {
|
mac: {
|
||||||
bundleVersion: "240",
|
bundleVersion: "240",
|
||||||
minimumSystemVersion: "10.12.0",
|
minimumSystemVersion: "10.12.0",
|
||||||
@@ -181,6 +182,7 @@ module.exports = {
|
|||||||
icon: "assets/icons/app.icns",
|
icon: "assets/icons/app.icns",
|
||||||
description: "Your private note taking space",
|
description: "Your private note taking space",
|
||||||
executableName: linuxExecutableName,
|
executableName: linuxExecutableName,
|
||||||
|
mimeTypes: ["x-scheme-handler/nn"],
|
||||||
desktop: {
|
desktop: {
|
||||||
desktopActions: {
|
desktopActions: {
|
||||||
"new-note": {
|
"new-note": {
|
||||||
@@ -198,11 +200,15 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
snap: {
|
toolsets: {
|
||||||
autoStart: false,
|
appimage: "1.0.2"
|
||||||
confinement: "strict",
|
},
|
||||||
allowNativeWayland: true,
|
snapcraft: {
|
||||||
base: "core22"
|
base: "core24",
|
||||||
|
core24: {
|
||||||
|
confinement: "strict",
|
||||||
|
autoStart: false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
extraResources: ["app-update.yml", "./assets/**"],
|
extraResources: ["app-update.yml", "./assets/**"],
|
||||||
extraMetadata: {
|
extraMetadata: {
|
||||||
|
|||||||
3766
apps/desktop/package-lock.json
generated
3766
apps/desktop/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
|||||||
"name": "@notesnook/desktop",
|
"name": "@notesnook/desktop",
|
||||||
"productName": "Notesnook",
|
"productName": "Notesnook",
|
||||||
"description": "Your private note taking space",
|
"description": "Your private note taking space",
|
||||||
"version": "3.3.6-beta.0",
|
"version": "3.4.7",
|
||||||
"appAppleId": "1544027013",
|
"appAppleId": "1544027013",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "./dist/cjs/index.js",
|
"main": "./dist/cjs/index.js",
|
||||||
@@ -21,6 +21,17 @@
|
|||||||
"types": "./dist/types/index.d.ts",
|
"types": "./dist/types/index.d.ts",
|
||||||
"default": "./dist/esm/index.js"
|
"default": "./dist/esm/index.js"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"./testutils": "./__tests__/test-override.ts",
|
||||||
|
"./*": {
|
||||||
|
"require": {
|
||||||
|
"types": "./dist/types/*",
|
||||||
|
"default": "./dist/cjs/*"
|
||||||
|
},
|
||||||
|
"import": {
|
||||||
|
"types": "./dist/types/*",
|
||||||
|
"default": "./dist/esm/*"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"homepage": "https://notesnook.com/",
|
"homepage": "https://notesnook.com/",
|
||||||
@@ -36,23 +47,25 @@
|
|||||||
"electron-trpc": "0.7.1",
|
"electron-trpc": "0.7.1",
|
||||||
"electron-updater": "^6.6.2",
|
"electron-updater": "^6.6.2",
|
||||||
"icojs": "^0.19.5",
|
"icojs": "^0.19.5",
|
||||||
"sqlite-better-trigram": "0.0.3",
|
"sqlite-better-trigram": "0.0.5",
|
||||||
"sqlite3-fts5-html": "^0.0.4",
|
"sqlite3-fts5-html": "^0.0.6",
|
||||||
"typed-emitter": "^2.1.0",
|
"typed-emitter": "^2.1.0",
|
||||||
"yargs": "^17.7.2",
|
"yargs": "^17.7.2",
|
||||||
"zod": "3.24.3"
|
"zod": "3.24.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "1.61.1",
|
||||||
"@streetwriters/kysely": "^0.27.4",
|
"@streetwriters/kysely": "^0.27.4",
|
||||||
"@types/node": "22.15.3",
|
"@types/node": "22.15.3",
|
||||||
"@types/yargs": "^17.0.33",
|
"@types/yargs": "^17.0.33",
|
||||||
"chokidar": "^4.0.3",
|
"chokidar": "^4.0.3",
|
||||||
"electron": "^37.0.0",
|
"electron": "^37.10.3",
|
||||||
"electron-builder": "^26.0.12",
|
"electron-builder": "^26.8.1",
|
||||||
"esbuild": "0.21.5",
|
"esbuild": "0.21.5",
|
||||||
|
"fkill": "^10.0.3",
|
||||||
"node-abi": "^4.5.0",
|
"node-abi": "^4.5.0",
|
||||||
"node-gyp-build": "^4.8.4",
|
"node-gyp-build": "^4.8.4",
|
||||||
"playwright": "1.48.2",
|
"playwright-core": "1.61.1",
|
||||||
"prebuildify": "^6.0.1",
|
"prebuildify": "^6.0.1",
|
||||||
"slugify": "1.6.6",
|
"slugify": "1.6.6",
|
||||||
"tree-kill": "^1.2.2",
|
"tree-kill": "^1.2.2",
|
||||||
@@ -73,7 +86,7 @@
|
|||||||
"bundle": "esbuild electron=./src/main.ts ./src/preload.ts --external:electron --external:fsevents --external:better-sqlite3-multiple-ciphers --external:sodium-native --bundle --outdir=./build --platform=node --tsconfig=tsconfig.json --define:MAC_APP_STORE=false --define:RELEASE=true",
|
"bundle": "esbuild electron=./src/main.ts ./src/preload.ts --external:electron --external:fsevents --external:better-sqlite3-multiple-ciphers --external:sodium-native --bundle --outdir=./build --platform=node --tsconfig=tsconfig.json --define:MAC_APP_STORE=false --define:RELEASE=true",
|
||||||
"bundle:mas": "esbuild electron=./src/main.ts ./src/preload.ts --minify --external:electron --external:fsevents --bundle --outdir=./build --platform=node --tsconfig=tsconfig.json --define:MAC_APP_STORE=true --define:RELEASE=true",
|
"bundle:mas": "esbuild electron=./src/main.ts ./src/preload.ts --minify --external:electron --external:fsevents --bundle --outdir=./build --platform=node --tsconfig=tsconfig.json --define:MAC_APP_STORE=true --define:RELEASE=true",
|
||||||
"postinstall": "patch-package",
|
"postinstall": "patch-package",
|
||||||
"test": "vitest run"
|
"test": "playwright test --tsconfig __tests__/tsconfig.json --project notesnook-desktop"
|
||||||
},
|
},
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Streetwriters (Private) Limited",
|
"name": "Streetwriters (Private) Limited",
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
diff --git a/node_modules/@playwright/test/node_modules/playwright-core/lib/coreBundle.js b/node_modules/@playwright/test/node_modules/playwright-core/lib/coreBundle.js
|
||||||
|
index 7d8468f..ab252ef 100644
|
||||||
|
--- a/node_modules/@playwright/test/node_modules/playwright-core/lib/coreBundle.js
|
||||||
|
+++ b/node_modules/@playwright/test/node_modules/playwright-core/lib/coreBundle.js
|
||||||
|
@@ -16010,6 +16010,7 @@ var init_validator = __esm({
|
||||||
|
args: tOptional(tArray(tString)),
|
||||||
|
chromiumSandbox: tOptional(tBoolean),
|
||||||
|
cwd: tOptional(tString),
|
||||||
|
+ baseURL: tOptional(tString),
|
||||||
|
env: tOptional(tArray(tType("NameValue"))),
|
||||||
|
timeout: tFloat,
|
||||||
|
acceptDownloads: tOptional(tEnum(["accept", "deny", "internal-browser-default"])),
|
||||||
|
diff --git a/node_modules/@playwright/test/node_modules/playwright-core/types/types.d.ts b/node_modules/@playwright/test/node_modules/playwright-core/types/types.d.ts
|
||||||
|
index 0bff9d3..d195697 100644
|
||||||
|
--- a/node_modules/@playwright/test/node_modules/playwright-core/types/types.d.ts
|
||||||
|
+++ b/node_modules/@playwright/test/node_modules/playwright-core/types/types.d.ts
|
||||||
|
@@ -21813,6 +21813,7 @@ export interface Electron {
|
||||||
|
* Additional arguments to pass to the application when launching. You typically pass the main script name here.
|
||||||
|
*/
|
||||||
|
args?: Array<string>;
|
||||||
|
+ baseURL?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If specified, artifacts (traces, videos, downloads, HAR files, etc.) are saved into this directory. The directory
|
||||||
92
apps/desktop/playwright.config.ts
Normal file
92
apps/desktop/playwright.config.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
/* eslint-disable header/header */
|
||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Config,
|
||||||
|
PlaywrightTestOptions,
|
||||||
|
PlaywrightWorkerOptions
|
||||||
|
} from "@playwright/test";
|
||||||
|
import * as path from "path";
|
||||||
|
|
||||||
|
process.env.PWPAGE_IMPL = "electron";
|
||||||
|
process.env.TEST_DESKTOP = "true";
|
||||||
|
const outputDir = path.join(__dirname, "test-results");
|
||||||
|
const testDir = path.join(__dirname, "__tests__");
|
||||||
|
const config: Config<PlaywrightWorkerOptions & PlaywrightTestOptions> = {
|
||||||
|
testDir,
|
||||||
|
outputDir,
|
||||||
|
expect: {
|
||||||
|
timeout: 10000
|
||||||
|
},
|
||||||
|
use: {
|
||||||
|
acceptDownloads: true,
|
||||||
|
trace: "retain-on-failure",
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
video: "retry-with-video",
|
||||||
|
viewport: {
|
||||||
|
width: 1920,
|
||||||
|
height: 1080
|
||||||
|
}
|
||||||
|
},
|
||||||
|
timeout: 60000,
|
||||||
|
globalTimeout: 5400000,
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 1 : 0,
|
||||||
|
reporter: process.env.CI
|
||||||
|
? [["dot"], ["json", { outputFile: path.join(outputDir, "report.json") }]]
|
||||||
|
: "line",
|
||||||
|
projects: [],
|
||||||
|
globalSetup: "./__tests__/electron-test/global-setup.ts"
|
||||||
|
};
|
||||||
|
|
||||||
|
const metadata = {
|
||||||
|
platform: process.platform,
|
||||||
|
headless: "headed",
|
||||||
|
browserName: "electron",
|
||||||
|
channel: undefined,
|
||||||
|
mode: "default",
|
||||||
|
video: false
|
||||||
|
};
|
||||||
|
|
||||||
|
config.projects?.push({
|
||||||
|
name: "notesnook-desktop",
|
||||||
|
// Share screenshots with chromium.
|
||||||
|
snapshotPathTemplate:
|
||||||
|
"{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}-electron{ext}",
|
||||||
|
use: {
|
||||||
|
browserName: "chromium",
|
||||||
|
headless: false
|
||||||
|
},
|
||||||
|
testDir: "__tests__",
|
||||||
|
metadata
|
||||||
|
});
|
||||||
|
|
||||||
|
config.projects?.push({
|
||||||
|
name: "notesnook-web",
|
||||||
|
// Share screenshots with chromium.
|
||||||
|
snapshotPathTemplate:
|
||||||
|
"{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}-electron{ext}",
|
||||||
|
use: {
|
||||||
|
browserName: "chromium",
|
||||||
|
headless: false
|
||||||
|
},
|
||||||
|
testDir: path.resolve(__dirname, "../web/__e2e__"),
|
||||||
|
metadata
|
||||||
|
});
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -33,6 +33,7 @@ const __filename = fileURLToPath(import.meta.url);
|
|||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
const root = args.root || path.join(__dirname, "..");
|
const root = args.root || path.join(__dirname, "..");
|
||||||
const skipTscBuild = args.skipTscBuild || false;
|
const skipTscBuild = args.skipTscBuild || false;
|
||||||
|
const buildForTesting = args.test || false;
|
||||||
|
|
||||||
const webAppPath = path.resolve(path.join(__dirname, "..", "..", "web"));
|
const webAppPath = path.resolve(path.join(__dirname, "..", "..", "web"));
|
||||||
|
|
||||||
@@ -51,7 +52,11 @@ console.log("removed build folder");
|
|||||||
if (args.rebuild || !existsSync(path.join(webAppPath, "build"))) {
|
if (args.rebuild || !existsSync(path.join(webAppPath, "build"))) {
|
||||||
console.log("rebuilding...");
|
console.log("rebuilding...");
|
||||||
await exec(
|
await exec(
|
||||||
"node scripts/execute.mjs @notesnook/web:build:desktop",
|
`node scripts/execute.mjs ${
|
||||||
|
buildForTesting
|
||||||
|
? "@notesnook/web:build:test:desktop"
|
||||||
|
: "@notesnook/web:build:desktop"
|
||||||
|
}`,
|
||||||
path.join(__dirname, "..", "..", "..")
|
path.join(__dirname, "..", "..", "..")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import crypto from "crypto";
|
|||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
|
const root = path.join(__dirname, "..");
|
||||||
const RUNNING_PROCESSES = [];
|
const RUNNING_PROCESSES = [];
|
||||||
const RESTARTABLE_PROCESSES = [];
|
const RESTARTABLE_PROCESSES = [];
|
||||||
let lastBundleHash = null;
|
let lastBundleHash = null;
|
||||||
@@ -36,7 +37,7 @@ const ENV = {
|
|||||||
FORCE_COLOR: "false",
|
FORCE_COLOR: "false",
|
||||||
COLOR: "0"
|
COLOR: "0"
|
||||||
};
|
};
|
||||||
process.chdir(path.join(__dirname, ".."));
|
process.chdir(root);
|
||||||
|
|
||||||
await onChange(true);
|
await onChange(true);
|
||||||
|
|
||||||
@@ -55,13 +56,16 @@ async function onChange(first) {
|
|||||||
if (first) {
|
if (first) {
|
||||||
await fs.rm("./build/", { force: true, recursive: true });
|
await fs.rm("./build/", { force: true, recursive: true });
|
||||||
|
|
||||||
await exec("npm rebuild electron --verbose --foreground-scripts");
|
await exec(
|
||||||
|
"npm run postinstall --verbose",
|
||||||
|
path.join(root, "node_modules", "electron")
|
||||||
|
);
|
||||||
|
|
||||||
await exec("yarn electron-builder install-app-deps");
|
await exec("yarn electron-builder install-app-deps", root);
|
||||||
}
|
}
|
||||||
|
|
||||||
await exec(`yarn run bundle`);
|
await exec(`yarn run bundle`, root);
|
||||||
await exec(`yarn run build`);
|
await exec(`yarn run build`, root);
|
||||||
|
|
||||||
if (await isBundleSame()) {
|
if (await isBundleSame()) {
|
||||||
console.log("Bundle is same. Doing nothing.");
|
console.log("Bundle is same. Doing nothing.");
|
||||||
@@ -111,7 +115,7 @@ function spawnAndWaitUntil(cmd, cwd, predicate) {
|
|||||||
|
|
||||||
async function exec(cmd, cwd) {
|
async function exec(cmd, cwd) {
|
||||||
try {
|
try {
|
||||||
console.log(">", cmd);
|
console.log(">", cmd, cwd);
|
||||||
|
|
||||||
return execSync(cmd, {
|
return execSync(cmd, {
|
||||||
env: ENV,
|
env: ENV,
|
||||||
|
|||||||
108
apps/desktop/src/api/backups.ts
Normal file
108
apps/desktop/src/api/backups.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/*
|
||||||
|
This file is part of the Notesnook project (https://notesnook.com/)
|
||||||
|
|
||||||
|
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { initTRPC } from "@trpc/server";
|
||||||
|
import { createWriteStream, mkdirSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { config } from "../utils/config";
|
||||||
|
import { resolvePath } from "../utils/resolve-path";
|
||||||
|
import { app } from "electron";
|
||||||
|
|
||||||
|
const t = initTRPC.create();
|
||||||
|
|
||||||
|
const activeStreams = new Map<string, NodeJS.WritableStream>();
|
||||||
|
|
||||||
|
function generateId() {
|
||||||
|
return Math.random().toString(36).slice(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStreamOrThrow(id: string, operation: "write" | "close") {
|
||||||
|
const stream = activeStreams.get(id);
|
||||||
|
if (!stream) {
|
||||||
|
throw new Error(
|
||||||
|
`Backup stream not found during ${operation}. The stream may have already been closed or was never opened. Stream id: ${id}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const backupsRouter = t.router({
|
||||||
|
open: t.procedure
|
||||||
|
.input(z.object({ filename: z.string() }))
|
||||||
|
.mutation(({ input }) => {
|
||||||
|
const { filename } = input;
|
||||||
|
if (!filename.trim()) {
|
||||||
|
throw new Error("Invalid backup filename: filename cannot be empty.");
|
||||||
|
}
|
||||||
|
if (filename.includes(path.sep) || filename.includes("\\"))
|
||||||
|
throw new Error(
|
||||||
|
`Invalid backup filename: expected a plain file name without path separators, received "${filename}".`
|
||||||
|
);
|
||||||
|
const resolvedBackupDir = resolvePath(config.backupDirectory);
|
||||||
|
const backupPath = path.resolve(resolvedBackupDir, filename);
|
||||||
|
const relativeBackupPath = path.relative(resolvedBackupDir, backupPath);
|
||||||
|
if (
|
||||||
|
relativeBackupPath.startsWith("..") ||
|
||||||
|
path.isAbsolute(relativeBackupPath)
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
`Invalid backup filename: resolved path "${backupPath}" is outside the configured backup directory "${resolvedBackupDir}". The configured backup directory may be invalid.`
|
||||||
|
);
|
||||||
|
|
||||||
|
mkdirSync(resolvedBackupDir, { recursive: true });
|
||||||
|
const stream = createWriteStream(backupPath, { encoding: "utf-8" });
|
||||||
|
const id = generateId();
|
||||||
|
activeStreams.set(id, stream);
|
||||||
|
return id;
|
||||||
|
}),
|
||||||
|
|
||||||
|
write: t.procedure
|
||||||
|
.input(z.object({ id: z.string(), chunk: z.string() }))
|
||||||
|
.mutation(({ input }) => {
|
||||||
|
const stream = getStreamOrThrow(input.id, "write");
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
stream.write(Buffer.from(input.chunk, "base64"), (err) =>
|
||||||
|
err ? reject(err) : resolve()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
close: t.procedure
|
||||||
|
.input(z.object({ id: z.string() }))
|
||||||
|
.mutation(({ input }) => {
|
||||||
|
const stream = getStreamOrThrow(input.id, "close");
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
stream.end(() => {
|
||||||
|
activeStreams.delete(input.id);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on("before-quit", () => {
|
||||||
|
try {
|
||||||
|
for (const stream of activeStreams.values()) {
|
||||||
|
stream.end();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -24,21 +24,43 @@ import TypedEventEmitter from "typed-emitter";
|
|||||||
|
|
||||||
export type AppEvents = {
|
export type AppEvents = {
|
||||||
onCreateItem(name: "note" | "notebook" | "reminder"): void;
|
onCreateItem(name: "note" | "notebook" | "reminder"): void;
|
||||||
|
onOpenLink(url: string): void;
|
||||||
|
bridgeReady(): void;
|
||||||
};
|
};
|
||||||
|
let isBridgeReady = false;
|
||||||
const emitter = new EventEmitter();
|
const pendingEvents: { name: string; args: unknown[] }[] = [];
|
||||||
const typedEmitter = emitter as TypedEventEmitter<AppEvents>;
|
const _emitter = new EventEmitter();
|
||||||
|
const emitter = _emitter as TypedEventEmitter<AppEvents>;
|
||||||
const t = initTRPC.create();
|
const t = initTRPC.create();
|
||||||
|
|
||||||
export const bridgeRouter = t.router({
|
export const bridgeRouter = t.router({
|
||||||
onCreateItem: createSubscription("onCreateItem")
|
onCreateItem: createSubscription("onCreateItem"),
|
||||||
|
onOpenLink: createSubscription("onOpenLink"),
|
||||||
|
ready: t.procedure.query(() => {
|
||||||
|
isBridgeReady = true;
|
||||||
|
if (pendingEvents.length > 0) {
|
||||||
|
console.log(
|
||||||
|
"Emitting pending events",
|
||||||
|
pendingEvents.map((e) => e.name)
|
||||||
|
);
|
||||||
|
pendingEvents.forEach((event) => {
|
||||||
|
emitter.emit(event.name as any, ...(event.args as any[]));
|
||||||
|
});
|
||||||
|
pendingEvents.length = 0;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
export const bridge: AppEvents = new Proxy({} as AppEvents, {
|
export const bridge: AppEvents = new Proxy({} as AppEvents, {
|
||||||
get(_t, name) {
|
get(_t, name) {
|
||||||
if (typeof name === "symbol") return;
|
if (typeof name === "symbol") return;
|
||||||
return (...args: unknown[]) => {
|
return (...args: unknown[]) => {
|
||||||
emitter.emit(name, ...args);
|
if (!isBridgeReady) {
|
||||||
|
pendingEvents.push({ name, args });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_emitter.emit(name, ...args);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -49,9 +71,9 @@ function createSubscription<TName extends keyof AppEvents>(eventName: TName) {
|
|||||||
const listener: AppEvents[TName] = (...args: any[]) => {
|
const listener: AppEvents[TName] = (...args: any[]) => {
|
||||||
emit.next(args[0]);
|
emit.next(args[0]);
|
||||||
};
|
};
|
||||||
typedEmitter.addListener(eventName, listener);
|
emitter.addListener(eventName, listener);
|
||||||
return () => {
|
return () => {
|
||||||
typedEmitter.removeListener(eventName, listener);
|
emitter.removeListener(eventName, listener);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import { updaterRouter } from "./updater";
|
|||||||
import { bridgeRouter } from "./bridge";
|
import { bridgeRouter } from "./bridge";
|
||||||
import { safeStorageRouter } from "./safe-storage";
|
import { safeStorageRouter } from "./safe-storage";
|
||||||
import { windowRouter } from "./window";
|
import { windowRouter } from "./window";
|
||||||
|
import { sqliteRouter } from "./sqlite-kysely";
|
||||||
|
import { backupsRouter } from "./backups";
|
||||||
|
|
||||||
const t = initTRPC.create();
|
const t = initTRPC.create();
|
||||||
|
|
||||||
@@ -35,10 +37,13 @@ export const router = t.router({
|
|||||||
updater: updaterRouter,
|
updater: updaterRouter,
|
||||||
bridge: bridgeRouter,
|
bridge: bridgeRouter,
|
||||||
safeStorage: safeStorageRouter,
|
safeStorage: safeStorageRouter,
|
||||||
window: windowRouter
|
window: windowRouter,
|
||||||
|
sqlite: sqliteRouter,
|
||||||
|
backups: backupsRouter
|
||||||
});
|
});
|
||||||
|
|
||||||
export const api = router.createCaller({});
|
const createCaller = t.createCallerFactory(router);
|
||||||
|
export const api = createCaller({});
|
||||||
|
|
||||||
// Export type router type signature,
|
// Export type router type signature,
|
||||||
// NOT the router itself.
|
// NOT the router itself.
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import {
|
|||||||
dialog,
|
dialog,
|
||||||
Menu,
|
Menu,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
nativeImage,
|
|
||||||
nativeTheme,
|
nativeTheme,
|
||||||
Notification,
|
Notification,
|
||||||
shell
|
shell
|
||||||
@@ -33,16 +32,16 @@ import { AutoLaunch } from "../utils/autolaunch";
|
|||||||
import { config, DesktopIntegration } from "../utils/config";
|
import { config, DesktopIntegration } from "../utils/config";
|
||||||
import { bringToFront } from "../utils/bring-to-front";
|
import { bringToFront } from "../utils/bring-to-front";
|
||||||
import { getTheme, setTheme, Theme } from "../utils/theme";
|
import { getTheme, setTheme, Theme } from "../utils/theme";
|
||||||
import { mkdirSync, writeFileSync } from "fs";
|
import { existsSync } from "fs";
|
||||||
import { dirname } from "path";
|
import { normalizePathString, resolvePath } from "../utils/resolve-path";
|
||||||
import { resolvePath } from "../utils/resolve-path";
|
|
||||||
import { observable } from "@trpc/server/observable";
|
import { observable } from "@trpc/server/observable";
|
||||||
import { AssetManager } from "../utils/asset-manager";
|
import { AssetManager } from "../utils/asset-manager";
|
||||||
import { isFlatpak, isSnap } from "../utils";
|
import { isFlatpak, isPortable, isSnap } from "../utils";
|
||||||
import { setupDesktopIntegration } from "../utils/desktop-integration";
|
import { setupDesktopIntegration } from "../utils/desktop-integration";
|
||||||
import { rm } from "fs/promises";
|
|
||||||
import { disableCustomDns, enableCustomDns } from "../utils/custom-dns";
|
import { disableCustomDns, enableCustomDns } from "../utils/custom-dns";
|
||||||
import type { MenuItem as NNMenuItem } from "@notesnook/ui";
|
import type { MenuItem as NNMenuItem } from "@notesnook/ui";
|
||||||
|
import { platform } from "os";
|
||||||
|
import { strings } from "@notesnook/intl";
|
||||||
|
|
||||||
const t = initTRPC.create();
|
const t = initTRPC.create();
|
||||||
|
|
||||||
@@ -60,6 +59,14 @@ const NotificationOptions = z.object({
|
|||||||
export const osIntegrationRouter = t.router({
|
export const osIntegrationRouter = t.router({
|
||||||
isFlatpak: t.procedure.query(() => isFlatpak()),
|
isFlatpak: t.procedure.query(() => isFlatpak()),
|
||||||
isSnap: t.procedure.query(() => isSnap()),
|
isSnap: t.procedure.query(() => isSnap()),
|
||||||
|
isPortable: t.procedure.query(() => isPortable()),
|
||||||
|
backupDirectory: t.procedure.query(() => {
|
||||||
|
const backupDirectory = normalizePathString(config.backupDirectory);
|
||||||
|
if (backupDirectory !== config.backupDirectory) {
|
||||||
|
config.backupDirectory = backupDirectory;
|
||||||
|
}
|
||||||
|
return backupDirectory;
|
||||||
|
}),
|
||||||
|
|
||||||
zoomFactor: t.procedure.query(() => config.zoomFactor),
|
zoomFactor: t.procedure.query(() => config.zoomFactor),
|
||||||
setZoomFactor: t.procedure.input(z.number()).mutation(({ input: factor }) => {
|
setZoomFactor: t.procedure.input(z.number()).mutation(({ input: factor }) => {
|
||||||
@@ -116,53 +123,19 @@ export const osIntegrationRouter = t.router({
|
|||||||
setupDesktopIntegration(settings);
|
setupDesktopIntegration(settings);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
selectDirectory: t.procedure
|
selectBackupDirectory: t.procedure.input(z.undefined()).query(async () => {
|
||||||
.input(
|
if (!globalThis.window) return undefined;
|
||||||
z.object({
|
|
||||||
title: z.string().optional(),
|
|
||||||
buttonLabel: z.string().optional(),
|
|
||||||
defaultPath: z.string().optional()
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.query(async ({ input }) => {
|
|
||||||
if (!globalThis.window) return undefined;
|
|
||||||
|
|
||||||
const { title, buttonLabel, defaultPath } = input;
|
const result = await dialog.showOpenDialog(globalThis.window, {
|
||||||
|
title: strings.selectBackupDir(),
|
||||||
|
buttonLabel: strings.select(),
|
||||||
|
properties: ["openDirectory"],
|
||||||
|
defaultPath: config.backupDirectory && resolvePath(config.backupDirectory)
|
||||||
|
});
|
||||||
|
if (result.canceled) return undefined;
|
||||||
|
|
||||||
const result = await dialog.showOpenDialog(globalThis.window, {
|
config.backupDirectory = result.filePaths[0];
|
||||||
title,
|
|
||||||
buttonLabel,
|
|
||||||
properties: ["openDirectory"],
|
|
||||||
defaultPath: defaultPath && resolvePath(defaultPath)
|
|
||||||
});
|
|
||||||
if (result.canceled) return undefined;
|
|
||||||
|
|
||||||
return result.filePaths[0];
|
|
||||||
}),
|
|
||||||
|
|
||||||
saveFile: t.procedure
|
|
||||||
.input(z.object({ data: z.string(), filePath: z.string() }))
|
|
||||||
.query(({ input }) => {
|
|
||||||
const { data, filePath } = input;
|
|
||||||
if (!data || !filePath) return;
|
|
||||||
|
|
||||||
const resolvedPath = resolvePath(filePath);
|
|
||||||
|
|
||||||
mkdirSync(dirname(resolvedPath), { recursive: true });
|
|
||||||
writeFileSync(resolvedPath, data);
|
|
||||||
}),
|
|
||||||
|
|
||||||
resolvePath: t.procedure
|
|
||||||
.input(z.object({ filePath: z.string() }))
|
|
||||||
.query(({ input }) => {
|
|
||||||
const { filePath } = input;
|
|
||||||
return resolvePath(filePath);
|
|
||||||
}),
|
|
||||||
|
|
||||||
deleteFile: t.procedure.input(z.string()).query(async ({ input }) => {
|
|
||||||
await rm(input);
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
restart: t.procedure.query(() => {
|
restart: t.procedure.query(() => {
|
||||||
app.relaunch();
|
app.relaunch();
|
||||||
app.exit();
|
app.exit();
|
||||||
@@ -178,7 +151,9 @@ export const osIntegrationRouter = t.router({
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
notification.show();
|
notification.show();
|
||||||
if (input.urgency === "critical") {
|
if (input.urgency === "critical" && process.platform !== "linux") {
|
||||||
|
// due to an Electron bug in versions below 40.x, shell.beep() causes a segfault on Linux.
|
||||||
|
// TODO: Remove when migrating to Electron 40.x or newer.
|
||||||
shell.beep();
|
shell.beep();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,9 +164,38 @@ export const osIntegrationRouter = t.router({
|
|||||||
}),
|
}),
|
||||||
openPath: t.procedure
|
openPath: t.procedure
|
||||||
.input(z.object({ type: z.literal("path"), link: z.string() }))
|
.input(z.object({ type: z.literal("path"), link: z.string() }))
|
||||||
.query(({ input }) => {
|
.query(async ({ input }) => {
|
||||||
|
if (isFlatpak()) return;
|
||||||
|
|
||||||
const { type, link } = input;
|
const { type, link } = input;
|
||||||
if (type === "path") return shell.openPath(resolvePath(link));
|
if (type !== "path") return;
|
||||||
|
|
||||||
|
const path = decodeURIComponent(new URL(link).pathname);
|
||||||
|
const resolvedPath = resolvePath(
|
||||||
|
// remove leading slash from path on windows
|
||||||
|
platform() === "win32" ? path.slice(1) : path
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existsSync(resolvedPath)) {
|
||||||
|
if (globalThis.window) {
|
||||||
|
await dialog.showMessageBox(globalThis.window, {
|
||||||
|
type: "error",
|
||||||
|
title: "Path not found",
|
||||||
|
message: `The path does not exist:\n${wrapPath(resolvedPath)}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await dialog.showMessageBox(globalThis.window!, {
|
||||||
|
message: strings.openingLocalFileDesc(resolvedPath),
|
||||||
|
title: strings.openingLocalFile(),
|
||||||
|
buttons: [strings.cancel(), strings.open()],
|
||||||
|
defaultId: 1,
|
||||||
|
cancelId: 0,
|
||||||
|
type: "question"
|
||||||
|
});
|
||||||
|
result.response === 1 && (await shell.openPath(resolvedPath));
|
||||||
}),
|
}),
|
||||||
bringToFront: t.procedure.query(() => bringToFront()),
|
bringToFront: t.procedure.query(() => bringToFront()),
|
||||||
changeTheme: t.procedure
|
changeTheme: t.procedure
|
||||||
@@ -252,6 +256,7 @@ export const osIntegrationRouter = t.router({
|
|||||||
if (menuItem) menu.append(menuItem);
|
if (menuItem) menu.append(menuItem);
|
||||||
}
|
}
|
||||||
if (menu.items.length > 0) menu.popup();
|
if (menu.items.length > 0) menu.popup();
|
||||||
|
menu.on("menu-will-close", () => emit.next([]));
|
||||||
return () => {
|
return () => {
|
||||||
menu.removeAllListeners();
|
menu.removeAllListeners();
|
||||||
menu.closePopup();
|
menu.closePopup();
|
||||||
@@ -295,3 +300,7 @@ function toMenuItem(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function wrapPath(path: string, maxLineLength = 100): string {
|
||||||
|
return path.replace(new RegExp(`(.{${maxLineLength}})`, "g"), "$1\n");
|
||||||
|
}
|
||||||
|
|||||||
@@ -84,35 +84,55 @@ const LANGUAGES: Record<string, string> = {
|
|||||||
|
|
||||||
type Language = { code: string; name: string };
|
type Language = { code: string; name: string };
|
||||||
|
|
||||||
|
const LANGUAGE_REDIRECT_MAP: Record<string, string> = {
|
||||||
|
es: "es-MX",
|
||||||
|
"es-419": "es-MX",
|
||||||
|
"es-ES": "es-AR"
|
||||||
|
};
|
||||||
|
|
||||||
export const spellCheckerRouter = t.router({
|
export const spellCheckerRouter = t.router({
|
||||||
isEnabled: t.procedure.query(() => config.isSpellCheckerEnabled),
|
isEnabled: t.procedure.query(() => config.isSpellCheckerEnabled),
|
||||||
languages: t.procedure.query(
|
languages: t.procedure.query(() => {
|
||||||
() =>
|
const available =
|
||||||
<Language[]>(
|
globalThis.window?.webContents.session.availableSpellCheckerLanguages ||
|
||||||
globalThis.window?.webContents.session.availableSpellCheckerLanguages.map(
|
[];
|
||||||
(code) => ({
|
|
||||||
code,
|
return <Language[]>available
|
||||||
name: LANGUAGES[code]
|
.map((code) => ({
|
||||||
})
|
code,
|
||||||
)
|
name: LANGUAGES[code] || code
|
||||||
)
|
}))
|
||||||
),
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
enabledLanguages: t.procedure.query(
|
}),
|
||||||
() =>
|
|
||||||
<Language[]>(
|
enabledLanguages: t.procedure.query(() => {
|
||||||
globalThis.window?.webContents.session
|
const enabled =
|
||||||
.getSpellCheckerLanguages()
|
globalThis.window?.webContents.session.getSpellCheckerLanguages() || [];
|
||||||
.map((code) => ({
|
const available =
|
||||||
code,
|
globalThis.window?.webContents.session.availableSpellCheckerLanguages ||
|
||||||
name: LANGUAGES[code]
|
[];
|
||||||
}))
|
|
||||||
)
|
const resolved = enabled
|
||||||
),
|
.map((code) => resolveLanguage(code, available))
|
||||||
setLanguages: t.procedure
|
.filter(Boolean) as string[];
|
||||||
.input(z.array(z.string()))
|
|
||||||
.mutation(({ input: languages }) =>
|
return <Language[]>resolved.map((code) => ({
|
||||||
globalThis.window?.webContents.session.setSpellCheckerLanguages(languages)
|
code,
|
||||||
),
|
name: LANGUAGES[code] || code
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
|
||||||
|
setLanguages: t.procedure.input(z.array(z.string())).mutation(({ input }) => {
|
||||||
|
const available =
|
||||||
|
globalThis.window?.webContents.session.availableSpellCheckerLanguages ||
|
||||||
|
[];
|
||||||
|
|
||||||
|
const resolved = input
|
||||||
|
.map((code) => resolveLanguage(code, available))
|
||||||
|
.filter(Boolean) as string[];
|
||||||
|
|
||||||
|
globalThis.window?.webContents.session.setSpellCheckerLanguages(resolved);
|
||||||
|
}),
|
||||||
toggle: t.procedure
|
toggle: t.procedure
|
||||||
.input(z.object({ enabled: z.boolean() }))
|
.input(z.object({ enabled: z.boolean() }))
|
||||||
.mutation(({ input: { enabled } }) => {
|
.mutation(({ input: { enabled } }) => {
|
||||||
@@ -128,3 +148,16 @@ export const spellCheckerRouter = t.router({
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function resolveLanguage(code: string, available: string[]) {
|
||||||
|
if (LANGUAGE_REDIRECT_MAP[code]) {
|
||||||
|
const working = LANGUAGE_REDIRECT_MAP[code];
|
||||||
|
return available.includes(working) ? working : code;
|
||||||
|
}
|
||||||
|
const fallback = code.split("-")[0];
|
||||||
|
return available.includes(code)
|
||||||
|
? code
|
||||||
|
: available.includes(fallback)
|
||||||
|
? fallback
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||||||
|
|
||||||
import type { Database, Statement } from "better-sqlite3-multiple-ciphers";
|
import type { Database, Statement } from "better-sqlite3-multiple-ciphers";
|
||||||
import type { QueryResult } from "@streetwriters/kysely";
|
import type { QueryResult } from "@streetwriters/kysely";
|
||||||
|
import { app } from "electron";
|
||||||
|
import path from "node:path";
|
||||||
|
import { initTRPC } from "@trpc/server";
|
||||||
|
|
||||||
type SQLiteCompatibleType =
|
type SQLiteCompatibleType =
|
||||||
| number
|
| number
|
||||||
@@ -28,25 +31,32 @@ type SQLiteCompatibleType =
|
|||||||
| bigint
|
| bigint
|
||||||
| null;
|
| null;
|
||||||
|
|
||||||
export class SQLite {
|
class SQLite {
|
||||||
sqlite?: Database;
|
sqlite?: Database;
|
||||||
initialized = false;
|
initialized = false;
|
||||||
preparedStatements: Map<string, Statement<unknown[]>> = new Map();
|
preparedStatements: Map<string, Statement<unknown[]>> = new Map();
|
||||||
retryCounter: Record<string, number> = {};
|
retryCounter: Record<string, number> = {};
|
||||||
extensionsLoaded = false;
|
extensionsLoaded = false;
|
||||||
|
private filePath?: string;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
console.log("new sqlite worker");
|
console.log("new sqlite worker");
|
||||||
}
|
}
|
||||||
|
|
||||||
async open(filePath: string) {
|
async open(filename: string) {
|
||||||
if (this.sqlite) {
|
if (this.sqlite) {
|
||||||
console.error("Database is already initialized");
|
console.error("Database is already initialized");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.filePath =
|
||||||
|
filename === ":memory:"
|
||||||
|
? filename
|
||||||
|
: path.join(app.getPath("userData"), filename) + ".sql";
|
||||||
|
if (!isPathAllowed(this.filePath))
|
||||||
|
throw new Error("Database path is not allowed: " + this.filePath);
|
||||||
this.sqlite = require("better-sqlite3-multiple-ciphers")(
|
this.sqlite = require("better-sqlite3-multiple-ciphers")(
|
||||||
filePath
|
this.filePath
|
||||||
).unsafeMode(true);
|
).unsafeMode(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +123,8 @@ export class SQLite {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Error) e.message += ` (query: ${sql})`;
|
if (e instanceof Error)
|
||||||
|
throw rewriteError(e, `${e.message} (query: ${sql})`);
|
||||||
throw e;
|
throw e;
|
||||||
} finally {
|
} finally {
|
||||||
// Since SQLite 3.48.0 (SQLite3MC v2.0.2) it's not possible to load fts5
|
// Since SQLite 3.48.0 (SQLite3MC v2.0.2) it's not possible to load fts5
|
||||||
@@ -154,9 +165,11 @@ export class SQLite {
|
|||||||
this.sqlite = undefined;
|
this.sqlite = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(filePath: string) {
|
async delete() {
|
||||||
|
if (!this.filePath) return;
|
||||||
|
|
||||||
await this.close();
|
await this.close();
|
||||||
await require("fs/promises").rm(filePath, {
|
await require("node:fs/promises").rm(this.filePath, {
|
||||||
force: true,
|
force: true,
|
||||||
maxRetries: 5,
|
maxRetries: 5,
|
||||||
retryDelay: 500
|
retryDelay: 500
|
||||||
@@ -212,3 +225,75 @@ function getExtensionPath(extensionName: string, entryPoint: string) {
|
|||||||
}
|
}
|
||||||
return loadablePath;
|
return loadablePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rewriteError(e: Error, message: string) {
|
||||||
|
const error = new Error(message);
|
||||||
|
error.stack = e.stack;
|
||||||
|
error.name = e.name;
|
||||||
|
error.cause = e.cause;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPathAllowed(databasePath: string) {
|
||||||
|
if (databasePath === ":memory:") return true;
|
||||||
|
const base = app.getPath("userData");
|
||||||
|
const resolved = path.resolve(databasePath);
|
||||||
|
return resolved.startsWith(base + path.sep);
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = initTRPC.create();
|
||||||
|
|
||||||
|
const databases: Record<string, SQLite> = {};
|
||||||
|
|
||||||
|
export const sqliteRouter = t.router({
|
||||||
|
open: t.procedure
|
||||||
|
.input((v) => v)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const { filePath } = input as { filePath: string };
|
||||||
|
if (databases[filePath]) return filePath;
|
||||||
|
const sqlite = new SQLite();
|
||||||
|
await sqlite.open(filePath);
|
||||||
|
databases[filePath] = sqlite;
|
||||||
|
return filePath;
|
||||||
|
}),
|
||||||
|
run: t.procedure
|
||||||
|
.input((v) => v)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const { id, sql, parameters } = input as {
|
||||||
|
id: string;
|
||||||
|
sql: string;
|
||||||
|
parameters?: SQLiteCompatibleType[];
|
||||||
|
};
|
||||||
|
const sqlite = databases[id];
|
||||||
|
if (!sqlite) throw new Error("Database not found for id: " + id);
|
||||||
|
return await sqlite.run(sql, parameters);
|
||||||
|
}),
|
||||||
|
close: t.procedure
|
||||||
|
.input((v) => v)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const { id } = input as { id: string };
|
||||||
|
const sqlite = databases[id];
|
||||||
|
if (!sqlite) throw new Error("Database not found for id: " + id);
|
||||||
|
await sqlite.close();
|
||||||
|
delete databases[id];
|
||||||
|
}),
|
||||||
|
delete: t.procedure
|
||||||
|
.input((v) => v)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const { id } = input as { id: string };
|
||||||
|
const sqlite = databases[id];
|
||||||
|
if (!sqlite) throw new Error("Database not found for id: " + id);
|
||||||
|
await sqlite.delete();
|
||||||
|
delete databases[id];
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on("before-quit", async () => {
|
||||||
|
for (const db of Object.values(databases)) {
|
||||||
|
try {
|
||||||
|
await db.close();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error closing database:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import type { AppUpdaterEvents } from "electron-updater/out/AppUpdater";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { config } from "../utils/config";
|
import { config } from "../utils/config";
|
||||||
import { app } from "electron";
|
import { app } from "electron";
|
||||||
|
import { isFlatpak, isPortable, isSnap } from "../utils";
|
||||||
|
|
||||||
type UpdateInfo = { version: string };
|
type UpdateInfo = { version: string };
|
||||||
type Progress = { percent: number };
|
type Progress = { percent: number };
|
||||||
@@ -31,13 +32,15 @@ type Progress = { percent: number };
|
|||||||
const t = initTRPC.create();
|
const t = initTRPC.create();
|
||||||
let cancellationToken: CancellationToken | undefined = undefined;
|
let cancellationToken: CancellationToken | undefined = undefined;
|
||||||
let downloadTimeout: NodeJS.Timeout | undefined = undefined;
|
let downloadTimeout: NodeJS.Timeout | undefined = undefined;
|
||||||
|
const updatesSupported = !isFlatpak() && !isSnap() && !isPortable();
|
||||||
export const updaterRouter = t.router({
|
export const updaterRouter = t.router({
|
||||||
autoUpdates: t.procedure.query(() => config.automaticUpdates),
|
autoUpdates: t.procedure.query(
|
||||||
|
() => updatesSupported && config.automaticUpdates
|
||||||
|
),
|
||||||
releaseTrack: t.procedure.query(() => config.releaseTrack),
|
releaseTrack: t.procedure.query(() => config.releaseTrack),
|
||||||
install: t.procedure.query(() => autoUpdater.quitAndInstall()),
|
install: t.procedure.query(() => autoUpdater.quitAndInstall()),
|
||||||
download: t.procedure.query(async () => {
|
download: t.procedure.query(async () => {
|
||||||
if (cancellationToken) return;
|
if (!updatesSupported || cancellationToken) return;
|
||||||
clearTimeout(downloadTimeout);
|
clearTimeout(downloadTimeout);
|
||||||
await new Promise<string[]>((resolve, reject) => {
|
await new Promise<string[]>((resolve, reject) => {
|
||||||
downloadTimeout = setTimeout(async () => {
|
downloadTimeout = setTimeout(async () => {
|
||||||
@@ -52,7 +55,7 @@ export const updaterRouter = t.router({
|
|||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
check: t.procedure.query(async () => {
|
check: t.procedure.query(async () => {
|
||||||
if (cancellationToken) return;
|
if (!updatesSupported || cancellationToken) return;
|
||||||
clearTimeout(downloadTimeout);
|
clearTimeout(downloadTimeout);
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
downloadTimeout = setTimeout(async () => {
|
downloadTimeout = setTimeout(async () => {
|
||||||
|
|||||||
@@ -34,6 +34,17 @@ export const windowRouter = t.router({
|
|||||||
}),
|
}),
|
||||||
maximized: t.procedure.query(() => globalThis.window?.isMaximized()),
|
maximized: t.procedure.query(() => globalThis.window?.isMaximized()),
|
||||||
fullscreen: t.procedure.query(() => globalThis.window?.isFullScreen()),
|
fullscreen: t.procedure.query(() => globalThis.window?.isFullScreen()),
|
||||||
|
onClose: t.procedure.subscription(() => {
|
||||||
|
return observable<void>((emit) => {
|
||||||
|
function listener() {
|
||||||
|
emit.next();
|
||||||
|
}
|
||||||
|
globalThis.window?.addListener("close", listener);
|
||||||
|
return () => {
|
||||||
|
globalThis.window?.removeListener("close", listener);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}),
|
||||||
onWindowStateChanged: t.procedure.subscription(() => {
|
onWindowStateChanged: t.procedure.subscription(() => {
|
||||||
return observable<{ maximized: boolean; fullscreen: boolean }>((emit) => {
|
return observable<{ maximized: boolean; fullscreen: boolean }>((emit) => {
|
||||||
function listener() {
|
function listener() {
|
||||||
|
|||||||
@@ -21,4 +21,3 @@ export { PATHS } from "./constants";
|
|||||||
export type { AppRouter } from "./api";
|
export type { AppRouter } from "./api";
|
||||||
export { type UpdateInfo } from "builder-util-runtime";
|
export { type UpdateInfo } from "builder-util-runtime";
|
||||||
export { type DesktopIntegration } from "./utils/config";
|
export { type DesktopIntegration } from "./utils/config";
|
||||||
export { SQLite } from "./api/sqlite-kysely.js";
|
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ import { setupDesktopIntegration } from "./utils/desktop-integration";
|
|||||||
import { disableCustomDns, enableCustomDns } from "./utils/custom-dns";
|
import { disableCustomDns, enableCustomDns } from "./utils/custom-dns";
|
||||||
import { Messages, setI18nGlobal } from "@notesnook/intl";
|
import { Messages, setI18nGlobal } from "@notesnook/intl";
|
||||||
import { i18n } from "@lingui/core";
|
import { i18n } from "@lingui/core";
|
||||||
|
import { PATHS } from "./constants";
|
||||||
|
import { normalizePathString } from "./utils/resolve-path";
|
||||||
|
|
||||||
const locale =
|
const locale =
|
||||||
process.env.NODE_ENV === "development"
|
process.env.NODE_ENV === "development"
|
||||||
@@ -52,6 +54,13 @@ locale.then(({ default: locale }) => {
|
|||||||
});
|
});
|
||||||
setI18nGlobal(i18n);
|
setI18nGlobal(i18n);
|
||||||
|
|
||||||
|
const appHostnames = isDevelopment()
|
||||||
|
? ["localhost", "127.0.0.1"]
|
||||||
|
: ["app.notesnook.com"];
|
||||||
|
// Pending nn:// link to open once the window is ready (used on Windows/Linux
|
||||||
|
// when the app is launched via the nn:// protocol for the first time).
|
||||||
|
let pendingNNLink: string | undefined = findNNLink(process.argv);
|
||||||
|
|
||||||
// only run a single instance
|
// only run a single instance
|
||||||
if (!MAC_APP_STORE && !app.requestSingleInstanceLock()) {
|
if (!MAC_APP_STORE && !app.requestSingleInstanceLock()) {
|
||||||
console.log("Another instance is already running!");
|
console.log("Another instance is already running!");
|
||||||
@@ -83,6 +92,17 @@ async function createWindow() {
|
|||||||
const cliOptions = await parseArguments(process.argv);
|
const cliOptions = await parseArguments(process.argv);
|
||||||
setTheme(getTheme());
|
setTheme(getTheme());
|
||||||
|
|
||||||
|
// this workaround is necessary because macos doesn't support
|
||||||
|
// the --hidden flag when launching the app on startup
|
||||||
|
if (
|
||||||
|
process.platform === "darwin" &&
|
||||||
|
app.getLoginItemSettings().wasOpenedAtLogin &&
|
||||||
|
config.desktopSettings.autoStart &&
|
||||||
|
config.desktopSettings.startMinimized
|
||||||
|
) {
|
||||||
|
cliOptions.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
const mainWindowState = new WindowState({});
|
const mainWindowState = new WindowState({});
|
||||||
const mainWindow = new BrowserWindow({
|
const mainWindow = new BrowserWindow({
|
||||||
show: !cliOptions.hidden,
|
show: !cliOptions.hidden,
|
||||||
@@ -122,9 +142,6 @@ async function createWindow() {
|
|||||||
|
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
zoomFactor: config.zoomFactor,
|
zoomFactor: config.zoomFactor,
|
||||||
nodeIntegration: true,
|
|
||||||
contextIsolation: false,
|
|
||||||
nodeIntegrationInWorker: true,
|
|
||||||
spellcheck: config.isSpellCheckerEnabled,
|
spellcheck: config.isSpellCheckerEnabled,
|
||||||
preload: __dirname + "/preload.js"
|
preload: __dirname + "/preload.js"
|
||||||
}
|
}
|
||||||
@@ -135,7 +152,13 @@ async function createWindow() {
|
|||||||
mainWindow.setMenuBarVisibility(false);
|
mainWindow.setMenuBarVisibility(false);
|
||||||
mainWindowState.manage(mainWindow);
|
mainWindowState.manage(mainWindow);
|
||||||
|
|
||||||
if (cliOptions.hidden && !config.desktopSettings.minimizeToSystemTray)
|
if (
|
||||||
|
cliOptions.hidden &&
|
||||||
|
!(
|
||||||
|
config.desktopSettings.minimizeToSystemTray ||
|
||||||
|
config.desktopSettings.closeToSystemTray
|
||||||
|
)
|
||||||
|
)
|
||||||
mainWindow.minimize();
|
mainWindow.minimize();
|
||||||
|
|
||||||
await mainWindow.webContents.loadURL(`${createURL(cliOptions, "/")}`);
|
await mainWindow.webContents.loadURL(`${createURL(cliOptions, "/")}`);
|
||||||
@@ -158,6 +181,14 @@ async function createWindow() {
|
|||||||
);
|
);
|
||||||
mainWindow.webContents.session.setProxy({ proxyRules: config.proxyRules });
|
mainWindow.webContents.session.setProxy({ proxyRules: config.proxyRules });
|
||||||
|
|
||||||
|
mainWindow.on("show", () =>
|
||||||
|
/**
|
||||||
|
* We may set `skipTaskbar` to true at startup.
|
||||||
|
* This also removes the window from the Alt-Tab switcher.
|
||||||
|
* To fix that, whenever the app is shown, we set `skipTaskbar` to false.
|
||||||
|
*/
|
||||||
|
mainWindow.setSkipTaskbar(false)
|
||||||
|
);
|
||||||
mainWindow.once("closed", () => {
|
mainWindow.once("closed", () => {
|
||||||
globalThis.window = null;
|
globalThis.window = null;
|
||||||
});
|
});
|
||||||
@@ -173,10 +204,28 @@ async function createWindow() {
|
|||||||
return { action: "deny" };
|
return { action: "deny" };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
mainWindow.webContents.on("will-navigate", (event, url) => {
|
||||||
|
try {
|
||||||
|
const parsedUrl = new URL(url);
|
||||||
|
if (!appHostnames.includes(parsedUrl.hostname)) {
|
||||||
|
event.preventDefault();
|
||||||
|
shell.openExternal(url);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("will-navigate: failed to parse URL", url, e);
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
nativeTheme.on("updated", () => {
|
nativeTheme.on("updated", () => {
|
||||||
setupTray();
|
setupTray();
|
||||||
setupJumplist();
|
setupJumplist();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (pendingNNLink) {
|
||||||
|
bridge.onOpenLink(pendingNNLink);
|
||||||
|
pendingNNLink = undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
app.once("ready", async () => {
|
app.once("ready", async () => {
|
||||||
@@ -196,9 +245,12 @@ app.once("ready", async () => {
|
|||||||
if (config.customDns) enableCustomDns();
|
if (config.customDns) enableCustomDns();
|
||||||
else disableCustomDns();
|
else disableCustomDns();
|
||||||
|
|
||||||
|
if (!MAC_APP_STORE) app.setAsDefaultProtocolClient("nn");
|
||||||
|
|
||||||
if (!isDevelopment()) registerProtocol();
|
if (!isDevelopment()) registerProtocol();
|
||||||
await createWindow();
|
await createWindow();
|
||||||
configureAutoUpdater();
|
await migrateBackupDirectory();
|
||||||
|
await configureAutoUpdater();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.once("window-all-closed", () => {
|
app.once("window-all-closed", () => {
|
||||||
@@ -209,6 +261,12 @@ app.once("window-all-closed", () => {
|
|||||||
|
|
||||||
app.on("second-instance", async (_ev, argv) => {
|
app.on("second-instance", async (_ev, argv) => {
|
||||||
if (!globalThis.window) return;
|
if (!globalThis.window) return;
|
||||||
|
const nnLink = findNNLink(argv);
|
||||||
|
if (nnLink) {
|
||||||
|
bridge.onOpenLink(nnLink);
|
||||||
|
bringToFront();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const cliOptions = await parseArguments(argv);
|
const cliOptions = await parseArguments(argv);
|
||||||
if (cliOptions.note) bridge.onCreateItem("note");
|
if (cliOptions.note) bridge.onCreateItem("note");
|
||||||
if (cliOptions.notebook) bridge.onCreateItem("notebook");
|
if (cliOptions.notebook) bridge.onCreateItem("notebook");
|
||||||
@@ -216,12 +274,29 @@ app.on("second-instance", async (_ev, argv) => {
|
|||||||
bringToFront();
|
bringToFront();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// macOS opens URLs via this event. The app may or may not be fully loaded yet.
|
||||||
|
app.on("open-url", (event, url) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!url.startsWith("nn://")) return;
|
||||||
|
if (globalThis.window) {
|
||||||
|
bridge.onOpenLink(url);
|
||||||
|
bringToFront();
|
||||||
|
} else {
|
||||||
|
// Window not ready yet — store for when createWindow finishes loading.
|
||||||
|
pendingNNLink = url;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.on("activate", () => {
|
app.on("activate", () => {
|
||||||
if (globalThis.window === null) {
|
if (globalThis.window === null) {
|
||||||
createWindow();
|
createWindow();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function findNNLink(argv: string[]): string | undefined {
|
||||||
|
return argv.find((arg) => arg.startsWith("nn://"));
|
||||||
|
}
|
||||||
|
|
||||||
function createURL(options: CLIOptions, path = "/") {
|
function createURL(options: CLIOptions, path = "/") {
|
||||||
const url = new URL(isDevelopment() ? "http://localhost:3000" : PROTOCOL_URL);
|
const url = new URL(isDevelopment() ? "http://localhost:3000" : PROTOCOL_URL);
|
||||||
|
|
||||||
@@ -232,7 +307,31 @@ function createURL(options: CLIOptions, path = "/") {
|
|||||||
else if (typeof options.note === "string")
|
else if (typeof options.note === "string")
|
||||||
url.hash = `/notes/${options.note}/edit`;
|
url.hash = `/notes/${options.note}/edit`;
|
||||||
else if (typeof options.notebook === "string")
|
else if (typeof options.notebook === "string")
|
||||||
url.hash = `/notebooks/${options.notebook}`;
|
url.pathname = `/notebooks/${options.notebook}`;
|
||||||
|
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function migrateBackupDirectory() {
|
||||||
|
if (!globalThis.window) return;
|
||||||
|
try {
|
||||||
|
if (config.backupDirectory !== PATHS.backupsDirectory) return;
|
||||||
|
const oldPath = await globalThis.window?.webContents.executeJavaScript(
|
||||||
|
`localStorage.getItem("backupStorageLocation")`
|
||||||
|
);
|
||||||
|
if (!oldPath || oldPath === PATHS.backupsDirectory) return;
|
||||||
|
config.backupDirectory = normalizePathString(oldPath);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to migrate backup directory", e);
|
||||||
|
const pressedButton = dialog.showMessageBoxSync(globalThis.window, {
|
||||||
|
message:
|
||||||
|
"Failed to migrate backup directory. It has been reset to default.",
|
||||||
|
title: "Backup Directory Migration Failed",
|
||||||
|
type: "error",
|
||||||
|
buttons: ["Set backup directory", "Ignore"]
|
||||||
|
});
|
||||||
|
if (pressedButton === 0) {
|
||||||
|
await api.integration.selectBackupDirectory();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,25 +19,21 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||||||
/* eslint-disable no-var */
|
/* eslint-disable no-var */
|
||||||
|
|
||||||
import { ELECTRON_TRPC_CHANNEL } from "electron-trpc/main";
|
import { ELECTRON_TRPC_CHANNEL } from "electron-trpc/main";
|
||||||
// import type { NNCrypto } from "@notesnook/crypto";
|
import { ipcRenderer, contextBridge } from "electron";
|
||||||
import { ipcRenderer } from "electron";
|
|
||||||
import { platform } from "os";
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
var os: () => "mas" | ReturnType<typeof platform>;
|
var os: () => "mas" | typeof process.platform;
|
||||||
var electronTRPC: any;
|
var electronTRPC: any;
|
||||||
// var NativeNNCrypto: (new () => NNCrypto) | undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
process.once("loaded", async () => {
|
const electronTRPC = {
|
||||||
const electronTRPC = {
|
sendMessage: (operation: any) =>
|
||||||
sendMessage: (operation: any) =>
|
ipcRenderer.send(ELECTRON_TRPC_CHANNEL, operation),
|
||||||
ipcRenderer.send(ELECTRON_TRPC_CHANNEL, operation),
|
onMessage: (callback: any) =>
|
||||||
onMessage: (callback: any) =>
|
ipcRenderer.on(ELECTRON_TRPC_CHANNEL, (_event, args) => callback(args))
|
||||||
ipcRenderer.on(ELECTRON_TRPC_CHANNEL, (_event, args) => callback(args))
|
};
|
||||||
};
|
|
||||||
globalThis.electronTRPC = electronTRPC;
|
|
||||||
});
|
|
||||||
|
|
||||||
// globalThis.NativeNNCrypto = require("@notesnook/crypto").NNCrypto;
|
const os = () => (MAC_APP_STORE ? "mas" : process.platform);
|
||||||
globalThis.os = () => (MAC_APP_STORE ? "mas" : platform());
|
|
||||||
|
contextBridge.exposeInMainWorld("electronTRPC", electronTRPC);
|
||||||
|
contextBridge.exposeInMainWorld("os", os);
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const LINUX_AUTOSTART_DIRECTORY_PATH = path.join(
|
|||||||
"autostart"
|
"autostart"
|
||||||
);
|
);
|
||||||
|
|
||||||
const STARTUP_ARGS = ["--hidden"];
|
const HIDDEN_ARG = "--hidden";
|
||||||
|
|
||||||
export class AutoLaunch {
|
export class AutoLaunch {
|
||||||
static enable(hidden: boolean) {
|
static enable(hidden: boolean) {
|
||||||
@@ -54,13 +54,13 @@ export class AutoLaunch {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const loginItemSettings = app.getLoginItemSettings({
|
const loginItemSettings = app.getLoginItemSettings({
|
||||||
args: STARTUP_ARGS
|
args: hidden ? [HIDDEN_ARG] : undefined
|
||||||
});
|
});
|
||||||
if (loginItemSettings.openAtLogin) return;
|
if (loginItemSettings.openAtLogin) return;
|
||||||
app.setLoginItemSettings({
|
app.setLoginItemSettings({
|
||||||
openAtLogin: true,
|
openAtLogin: true,
|
||||||
openAsHidden: hidden,
|
openAsHidden: hidden,
|
||||||
args: STARTUP_ARGS
|
args: hidden ? [HIDDEN_ARG] : undefined
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,11 @@ async function configureAutoUpdater() {
|
|||||||
config.releaseTrack === "stable" &&
|
config.releaseTrack === "stable" &&
|
||||||
autoUpdater.currentVersion.prerelease.length > 0;
|
autoUpdater.currentVersion.prerelease.length > 0;
|
||||||
autoUpdater.allowPrerelease = false;
|
autoUpdater.allowPrerelease = false;
|
||||||
autoUpdater.autoInstallOnAppQuit = true;
|
// Do NOT auto-install on quit. On Windows, if the system shuts down while
|
||||||
|
// the NSIS installer is running, it first removes all old files and then
|
||||||
|
// gets killed before copying new ones — leaving an empty install directory.
|
||||||
|
// Updates should only be installed when the user explicitly triggers it.
|
||||||
|
autoUpdater.autoInstallOnAppQuit = false;
|
||||||
autoUpdater.disableWebInstaller = true;
|
autoUpdater.disableWebInstaller = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { nativeTheme } from "electron";
|
|||||||
import { JSONStorage } from "./json-storage";
|
import { JSONStorage } from "./json-storage";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { autoUpdater } from "electron-updater";
|
import { autoUpdater } from "electron-updater";
|
||||||
|
import { PATHS } from "../constants";
|
||||||
|
|
||||||
export const DesktopIntegration = z.object({
|
export const DesktopIntegration = z.object({
|
||||||
autoStart: z.boolean().optional(),
|
autoStart: z.boolean().optional(),
|
||||||
@@ -53,7 +54,8 @@ export const config = {
|
|||||||
|
|
||||||
backgroundColor: nativeTheme.themeSource === "dark" ? "#0f0f0f" : "#ffffff",
|
backgroundColor: nativeTheme.themeSource === "dark" ? "#0f0f0f" : "#ffffff",
|
||||||
windowControlsIconColor:
|
windowControlsIconColor:
|
||||||
nativeTheme.themeSource === "dark" ? "#ffffff" : "#000000"
|
nativeTheme.themeSource === "dark" ? "#ffffff" : "#000000",
|
||||||
|
backupDirectory: PATHS.backupsDirectory
|
||||||
};
|
};
|
||||||
|
|
||||||
type ConfigKey = keyof typeof config;
|
type ConfigKey = keyof typeof config;
|
||||||
|
|||||||
@@ -33,3 +33,7 @@ export function isFlatpak() {
|
|||||||
export function isSnap() {
|
export function isSnap() {
|
||||||
return process.env.SNAP !== undefined;
|
return process.env.SNAP !== undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isPortable() {
|
||||||
|
return process.env.PORTABLE_EXECUTABLE_DIR !== undefined;
|
||||||
|
}
|
||||||
|
|||||||
@@ -62,7 +62,17 @@ function registerProtocol() {
|
|||||||
headers: { "Content-Type": extensionToMimeType[fileExtension] }
|
headers: { "Content-Type": extensionToMimeType[fileExtension] }
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
return net.fetch(request, { bypassCustomProtocolHandlers: true });
|
const requestHeaders = new Headers(request.headers);
|
||||||
|
if (!requestHeaders.has("referer"))
|
||||||
|
requestHeaders.set("referer", PROTOCOL_URL);
|
||||||
|
return await net.fetch(
|
||||||
|
new Request(request, {
|
||||||
|
headers: requestHeaders
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
bypassCustomProtocolHandlers: true
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
console.info(`${SCHEME} protocol inteception "successful"`);
|
console.info(`${SCHEME} protocol inteception "successful"`);
|
||||||
|
|||||||
@@ -20,11 +20,30 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||||||
import { app } from "electron";
|
import { app } from "electron";
|
||||||
import { isAbsolute, join } from "path";
|
import { isAbsolute, join } from "path";
|
||||||
|
|
||||||
|
export function normalizePathString(_path: string) {
|
||||||
|
let normalizedPath = _path.trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedPath = JSON.parse(normalizedPath);
|
||||||
|
if (typeof parsedPath === "string") normalizedPath = parsedPath;
|
||||||
|
} catch {
|
||||||
|
// ignore invalid JSON and keep the original path string
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedPath === "~") return app.getPath("home");
|
||||||
|
if (/^~[\\/]/.test(normalizedPath)) {
|
||||||
|
return join(app.getPath("home"), normalizedPath.slice(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedPath;
|
||||||
|
}
|
||||||
|
|
||||||
export function resolvePath(_path: string) {
|
export function resolvePath(_path: string) {
|
||||||
if (isAbsolute(_path)) return _path;
|
const normalizedPath = normalizePathString(_path);
|
||||||
|
if (isAbsolute(normalizedPath)) return normalizedPath;
|
||||||
|
|
||||||
return join(
|
return join(
|
||||||
..._path.split("/").map((segment) => {
|
...normalizedPath.split("/").map((segment) => {
|
||||||
let resolved = segment;
|
let resolved = segment;
|
||||||
try {
|
try {
|
||||||
resolved = app.getPath(resolved as any);
|
resolved = app.getPath(resolved as any);
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig",
|
"extends": "../../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./dist/",
|
"outDir": "./dist/",
|
||||||
"lib": ["ESNext"],
|
"lib": ["ESNext"],
|
||||||
"moduleResolution": "Bundler"
|
"moduleResolution": "Bundler"
|
||||||
},
|
},
|
||||||
"include": ["src", "global.d.ts"]
|
"files": ["global.d.ts"],
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["__tests__"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ module.exports = {
|
|||||||
testBinaryPath:
|
testBinaryPath:
|
||||||
"android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk",
|
"android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk",
|
||||||
build:
|
build:
|
||||||
"cd android ; ENVFILE=.env.test ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug && cd ..",
|
"cd android ; ENVFILE=.env.test ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug -PreactNativeArchitectures=arm64-v8a && cd ..",
|
||||||
reversePorts: [8081]
|
reversePorts: [8081]
|
||||||
},
|
},
|
||||||
"android.release": {
|
"android.release": {
|
||||||
@@ -47,7 +47,7 @@ module.exports = {
|
|||||||
simulator: {
|
simulator: {
|
||||||
type: "ios.simulator",
|
type: "ios.simulator",
|
||||||
device: {
|
device: {
|
||||||
type: "iPhone 12"
|
type: "iPhone 17 Pro Max"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
attached: {
|
attached: {
|
||||||
@@ -59,7 +59,7 @@ module.exports = {
|
|||||||
emulator: {
|
emulator: {
|
||||||
type: "android.emulator",
|
type: "android.emulator",
|
||||||
device: {
|
device: {
|
||||||
avdName: "Pixel_5_API_34"
|
avdName: "Pixel_5_API_36"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
|
|||||||
1
apps/mobile/.gitignore
vendored
1
apps/mobile/.gitignore
vendored
@@ -1,4 +1,5 @@
|
|||||||
.yarnrc.yml
|
.yarnrc.yml
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
artifacts/
|
artifacts/
|
||||||
# OSX
|
# OSX
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -3,10 +3,8 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h1 align="center">Notesnook Mobile</h1>
|
<h1 align="center">Notesnook Mobile</h1>
|
||||||
<h3 align="center">The mobile app is built using React Native, Typescript & Javascript for both iOS & Android.</h3>
|
<h3 align="center">The mobile app is built with React Native for both iOS and Android.</h3>
|
||||||
<p align="center">
|
<p align="center"><a href="#build-instructions">Build instructions</a> | <a href="#developer-guide">Developer guide</a> | <a href="#running-e2e-tests-detox">E2E tests</a></p>
|
||||||
<a href="#developer-guide">Developer guide</a> | <a href="#build-instructions">How to build?</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://play.google.com/store/apps/details?id=com.streetwriters.notesnook">
|
<a href="https://play.google.com/store/apps/details?id=com.streetwriters.notesnook">
|
||||||
@@ -25,21 +23,21 @@
|
|||||||
|
|
||||||
Requirements:
|
Requirements:
|
||||||
|
|
||||||
1. [Node.js](https://nodejs.org/en/download/)
|
1. [Node.js](https://nodejs.org/en/download/) 20+ (the repo is pinned to Node `22.20.0` via Volta)
|
||||||
2. [git](https://git-scm.com/downloads)
|
2. [git](https://git-scm.com/downloads)
|
||||||
3. NPM (not yarn or pnpm)
|
3. `npm`
|
||||||
4. [React Native](https://reactnative.dev/docs/environment-setup)
|
4. [React Native environment setup](https://reactnative.dev/docs/set-up-your-environment)
|
||||||
|
|
||||||
To run the app locally, you will need to setup React Native on your system:
|
To run the app locally, first complete React Native native tooling setup:
|
||||||
|
|
||||||
1. Open the official [environment setup guide here](https://reactnative.dev/docs/environment-setup)
|
1. Open [React Native environment setup](https://reactnative.dev/docs/set-up-your-environment)
|
||||||
2. Select `React Native CLI Quickstart`
|
2. Select `React Native CLI Quickstart`
|
||||||
3. Select your OS & the platform to run the app on (iOS or Android)
|
3. Select your OS and target platform(s): iOS and/or Android
|
||||||
4. Follow the steps listed.
|
4. Follow the steps listed.
|
||||||
|
|
||||||
> Please keep in mind that **Expo is not supported**.
|
> Expo is not used in this project.
|
||||||
|
|
||||||
Once you have completed the setup, the first step is to `clone` the monorepo:
|
Clone the monorepo:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/streetwriters/notesnook.git
|
git clone https://github.com/streetwriters/notesnook.git
|
||||||
@@ -48,26 +46,27 @@ git clone https://github.com/streetwriters/notesnook.git
|
|||||||
cd notesnook
|
cd notesnook
|
||||||
```
|
```
|
||||||
|
|
||||||
Once you are inside the `./notesnook` directory, run the preparation step:
|
Install dependencies and bootstrap the mobile workspace:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# this might take a while to complete
|
# this might take a while to complete
|
||||||
npm install
|
npm install
|
||||||
|
npm run bootstrap -- --scope=mobile
|
||||||
```
|
```
|
||||||
|
|
||||||
### Running the app on Android
|
### Running the app on Android
|
||||||
|
|
||||||
[Setup an Android emulator from Android Studio](https://developer.android.com/studio/run/managing-avds) if you haven't already, and then run the following command to start the app in the Emulator:
|
[Set up an Android emulator from Android Studio](https://developer.android.com/studio/run/managing-avds) (or connect a physical device), then run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run start:android
|
npm run start:android
|
||||||
```
|
```
|
||||||
|
|
||||||
If you want to run the app on your phone, make sure to [enable USB debugging](https://developer.android.com/studio/debug/dev-options).
|
If you are using a physical device, enable [USB debugging](https://developer.android.com/studio/debug/dev-options).
|
||||||
|
|
||||||
### Running the app on iOS
|
### Running the app on iOS
|
||||||
|
|
||||||
To run the app on iOS:
|
Install CocoaPods dependencies first, then run the iOS app:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# this might take a while to complete
|
# this might take a while to complete
|
||||||
@@ -76,90 +75,87 @@ npm run prepare:ios
|
|||||||
npm run start:ios
|
npm run start:ios
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Useful development commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# start Metro only
|
||||||
|
npm run start:metro
|
||||||
|
|
||||||
|
# start Re.Pack bundler
|
||||||
|
npm run start:repack
|
||||||
|
```
|
||||||
|
|
||||||
## Developer guide
|
## Developer guide
|
||||||
|
|
||||||
> This project is in a transition state between Javascript & Typescript. We are gradually porting everything over to Typescript, so if you can help with that, it'd be great!
|
> The mobile app is a mixed TypeScript/JavaScript codebase.
|
||||||
|
|
||||||
### The tech stack
|
### The tech stack
|
||||||
|
|
||||||
We try to keep the stack as lean as possible:
|
We try to keep the stack as lean as possible:
|
||||||
|
|
||||||
1. React Native
|
1. React Native `0.82`
|
||||||
2. Typescript/Javascript
|
2. React `19`
|
||||||
3. Zustand: State management
|
3. TypeScript + JavaScript
|
||||||
4. Detox: Runs all our e2e tests
|
4. Zustand (state management)
|
||||||
5. React Native MMKV: Database & persistence
|
5. Detox (end-to-end testing)
|
||||||
6. libsodium: Encryption
|
6. libsodium (encryption)
|
||||||
|
|
||||||
### Project structure
|
### Project structure
|
||||||
|
|
||||||
The app codebase is distributed over two primary directories. `native/` and `app/`.
|
Top-level directories in `apps/mobile/`:
|
||||||
|
|
||||||
- `native/`: Includes `android/` and `ios/` folders and everything related to react native core functionality like bundling, development, and packaging. Any react-native dependency with native code, i.e., android & ios folders, is installed here.
|
- `app/`: Main React Native app source (`components`, `common`, `hooks`, `navigation`, `screens`, `services`, `stores`, `utils`, etc.)
|
||||||
|
- `android/`: Android native project
|
||||||
|
- `ios/`: iOS native project
|
||||||
|
- `e2e/`: Detox test suite and config
|
||||||
|
- `patches/`: `patch-package` patches
|
||||||
|
- `scripts/`: Mobile-specific scripts
|
||||||
|
|
||||||
- `app/`: Includes all the app code other than the native part. All JS-only dependencies are installed here.
|
## Running E2E tests (Detox)
|
||||||
- `components/`: Each component serves a specific purpose in the app UI. For example, the `Paragraph` component is used to render paragraphs in the app, and a `Header` component is used to render a `header` on all screens.
|
|
||||||
- `common/`: Features that are integral to the app's functionality. For example, the notesnook core is initialized here.
|
|
||||||
- `hooks/`: Hooks for different app logic
|
|
||||||
- `navigation/`: Includes app navigation-specific code. Here the app navigation, editor & side menu are rendered side by side in fluid tabs.
|
|
||||||
- `screens`: Navigator screens.
|
|
||||||
- `services`: Parts of code that do a specific function. For example, the `sync` service runs Sync from anywhere in the app.
|
|
||||||
- `stores`: We use `zustand` for global state management in the app. Multiple stores provide the state for different parts of the app.
|
|
||||||
- `utils`: General purpose stuff such as constant values, utility functions, etc.
|
|
||||||
|
|
||||||
There are several other folders at the root:
|
Detox device defaults in this repo:
|
||||||
|
|
||||||
- `share/`: Code for the iOS Share Extension and Android widget.
|
- Android emulator: `Pixel_5_API_36`
|
||||||
- `e2e/`: Detox End to end tests
|
- iOS simulator: `iPhone 17 Pro Max`
|
||||||
- `patches/`: Patches for various react native dependencies.
|
|
||||||
|
|
||||||
### Running the tests
|
|
||||||
|
|
||||||
When you are done making the required changes, you must run the tests to ensure you didn't break anything. We use Detox as the testing framework & the tests can be started as follows:
|
|
||||||
|
|
||||||
### Android
|
### Android
|
||||||
|
|
||||||
To run the tests on Android, you will need to create an emulator device on your system:
|
Build and run Android Detox tests:
|
||||||
|
|
||||||
```
|
```bash
|
||||||
$ANDROID_HOME/tools/bin/avdmanager create avd -n Pixel_5_API_31 -d pixel --package "system-images;android-31;default;x86_64"
|
|
||||||
```
|
|
||||||
|
|
||||||
If you face problems, follow the detailed guide in [Detox documentation](https://wix.github.io/Detox/docs/introduction/android-dev-env). Keep the emulator name set to `Pixel_5_API_31`.
|
|
||||||
|
|
||||||
Once you have created an emulator device, build the Android apks:
|
|
||||||
|
|
||||||
```
|
|
||||||
npm run build:android
|
npm run build:android
|
||||||
```
|
|
||||||
|
|
||||||
Finally, run the tests:
|
|
||||||
|
|
||||||
```
|
|
||||||
npm run test:android
|
npm run test:android
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For debug configuration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:android:debug
|
||||||
|
npm run start:metro
|
||||||
|
npm run test:android:debug
|
||||||
|
```
|
||||||
|
|
||||||
### iOS
|
### iOS
|
||||||
|
|
||||||
To run e2e tests on the iOS simulator, you must be on a Mac with XCode installed.
|
Build and run iOS Detox tests:
|
||||||
|
|
||||||
First, install [AppleSimulatorUtils](https://github.com/wix/AppleSimulatorUtils):
|
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:ios
|
||||||
|
npm run test:ios
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If simulator tooling is missing, install [AppleSimulatorUtils](https://github.com/wix/AppleSimulatorUtils):
|
||||||
|
|
||||||
|
```bash
|
||||||
brew tap wix/brew
|
brew tap wix/brew
|
||||||
brew install applesimutils
|
brew install applesimutils
|
||||||
```
|
```
|
||||||
|
|
||||||
Now build the iOS app for testing:
|
## Release commands
|
||||||
|
|
||||||
```
|
Android release helpers:
|
||||||
npm run build:ios
|
|
||||||
```
|
|
||||||
|
|
||||||
Finally, run the tests:
|
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run release:android
|
||||||
|
npm run release:android:bundle
|
||||||
```
|
```
|
||||||
npm run test:ios
|
|
||||||
```
|
|
||||||
|
|
||||||
All tests on iOS are configured to run on `iPhone 8` simulator.
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
apply plugin: "com.android.application"
|
apply plugin: "com.android.application"
|
||||||
apply plugin: "org.jetbrains.kotlin.android"
|
apply plugin: "org.jetbrains.kotlin.android"
|
||||||
apply plugin: "com.facebook.react"
|
apply plugin: "com.facebook.react"
|
||||||
|
apply plugin: 'kotlin-parcelize'
|
||||||
|
|
||||||
apply from: project(':react-native-config').projectDir.getPath() + "/dotenv.gradle"
|
apply from: project(':react-native-config').projectDir.getPath() + "/dotenv.gradle"
|
||||||
|
|
||||||
|
|
||||||
import com.android.build.OutputFile
|
import com.android.build.OutputFile
|
||||||
import groovy.json.JsonSlurper
|
import groovy.json.JsonSlurper
|
||||||
import org.apache.tools.ant.taskdefs.condition.Os
|
import org.apache.tools.ant.taskdefs.condition.Os
|
||||||
@@ -93,6 +95,18 @@ def fdroidBuild() {
|
|||||||
return project.hasProperty("fdroidBuild") && project.fdroidBuild == "true"
|
return project.hasProperty("fdroidBuild") && project.fdroidBuild == "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def prBuildNumber() {
|
||||||
|
def value = project.getProperties().get("prBuildNumber")
|
||||||
|
return value ? value : "1000"
|
||||||
|
}
|
||||||
|
|
||||||
|
def stagingReleaseBuild() {
|
||||||
|
// Enable with STAGING_BUILDtrue or -PstagingReleaseBuild=true
|
||||||
|
def fromEnv = System.getenv("STAGING_BUILD") == "true"
|
||||||
|
def fromProp = project.hasProperty("stagingReleaseBuild") && project.stagingReleaseBuild == "true"
|
||||||
|
return fromEnv || fromProp
|
||||||
|
}
|
||||||
|
|
||||||
def getNpmVersion() {
|
def getNpmVersion() {
|
||||||
def inputFile = file("$rootDir/../package.json")
|
def inputFile = file("$rootDir/../package.json")
|
||||||
def jsonPackage = new JsonSlurper().parseText(inputFile.text)
|
def jsonPackage = new JsonSlurper().parseText(inputFile.text)
|
||||||
@@ -117,14 +131,17 @@ android {
|
|||||||
sourceCompatibility JavaVersion.VERSION_1_8
|
sourceCompatibility JavaVersion.VERSION_1_8
|
||||||
targetCompatibility JavaVersion.VERSION_1_8
|
targetCompatibility JavaVersion.VERSION_1_8
|
||||||
}
|
}
|
||||||
|
|
||||||
namespace "com.streetwriters.notesnook"
|
namespace "com.streetwriters.notesnook"
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "com.streetwriters.notesnook"
|
applicationId "com.streetwriters.notesnook"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
multiDexEnabled true
|
multiDexEnabled true
|
||||||
versionCode 3080
|
if (project.hasProperty("prBuildNumber")) {
|
||||||
|
versionCode Integer.parseInt(prBuildNumber())
|
||||||
|
} else {
|
||||||
|
versionCode 3116
|
||||||
|
}
|
||||||
versionName getNpmVersion()
|
versionName getNpmVersion()
|
||||||
testBuildType System.getProperty('testBuildType', 'debug')
|
testBuildType System.getProperty('testBuildType', 'debug')
|
||||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||||
@@ -191,6 +208,8 @@ android {
|
|||||||
output.versionCodeOverride =
|
output.versionCodeOverride =
|
||||||
defaultConfig.versionCode * 5 + versionCodes.get(abi)
|
defaultConfig.versionCode * 5 + versionCodes.get(abi)
|
||||||
println("Fdroid Version code: ${output.versionCodeOverride} for abi ${versionCodes.get(abi)}");
|
println("Fdroid Version code: ${output.versionCodeOverride} for abi ${versionCodes.get(abi)}");
|
||||||
|
} else if (stagingReleaseBuild()) {
|
||||||
|
output.versionCodeOverride = defaultConfig.versionCode + versionCodes.get(abi)
|
||||||
} else {
|
} else {
|
||||||
if (isBuildingAAB) {
|
if (isBuildingAAB) {
|
||||||
output.versionCodeOverride = 4 * 1048576 + defaultConfig.versionCode
|
output.versionCodeOverride = 4 * 1048576 + defaultConfig.versionCode
|
||||||
@@ -214,6 +233,9 @@ dependencies {
|
|||||||
// The version of react-native is set by the React Native Gradle Plugin
|
// The version of react-native is set by the React Native Gradle Plugin
|
||||||
implementation("com.facebook.react:react-android")
|
implementation("com.facebook.react:react-android")
|
||||||
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.0.0")
|
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.0.0")
|
||||||
|
// Lets the widgets push their list data straight into the RemoteViews on every API level,
|
||||||
|
// instead of the deprecated RemoteViewsService adapter (which needs API 31 to do natively).
|
||||||
|
implementation("androidx.core:core-remoteviews:1.0.0")
|
||||||
implementation("androidx.core:core-splashscreen:1.0.0")
|
implementation("androidx.core:core-splashscreen:1.0.0")
|
||||||
|
|
||||||
implementation 'androidx.multidex:multidex:2.0.1'
|
implementation 'androidx.multidex:multidex:2.0.1'
|
||||||
@@ -232,7 +254,6 @@ dependencies {
|
|||||||
} else {
|
} else {
|
||||||
implementation jscFlavor
|
implementation jscFlavor
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
5
apps/mobile/android/app/proguard-rules.pro
vendored
5
apps/mobile/android/app/proguard-rules.pro
vendored
@@ -63,4 +63,7 @@
|
|||||||
-keepattributes Signature
|
-keepattributes Signature
|
||||||
-keep class com.google.gson.reflect.TypeToken { *; }
|
-keep class com.google.gson.reflect.TypeToken { *; }
|
||||||
-keep class * extends com.google.gson.reflect.TypeToken
|
-keep class * extends com.google.gson.reflect.TypeToken
|
||||||
-keep class com.streetwriters.notesnook.datatypes.* { *; }
|
-keep class com.streetwriters.notesnook.datatypes.* { *; }
|
||||||
|
|
||||||
|
-keep class net.gotev.uploadservice.* { *; }
|
||||||
|
-keep class kotlinx.parcelize.* { *; }
|
||||||
@@ -86,8 +86,9 @@
|
|||||||
android:name=".NotePreviewWidget"
|
android:name=".NotePreviewWidget"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:label="@string/note">
|
android:label="@string/note">
|
||||||
<intent-filter>"
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_RESTORED" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<meta-data
|
<meta-data
|
||||||
@@ -95,6 +96,15 @@
|
|||||||
android:resource="@xml/note_widget_info" />
|
android:resource="@xml/note_widget_info" />
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".WidgetTimeChangeReceiver"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.TIME_SET" />
|
||||||
|
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".ReminderWidgetProvider"
|
android:name=".ReminderWidgetProvider"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
@@ -155,6 +165,15 @@
|
|||||||
<data android:scheme="notesnook" />
|
<data android:scheme="notesnook" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<intent-filter android:label="Notesnook">
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
|
||||||
|
<data android:scheme="nn" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
</activity>
|
</activity>
|
||||||
<activity
|
<activity
|
||||||
android:name="com.facebook.react.devsupport.DevSettingsActivity"
|
android:name="com.facebook.react.devsupport.DevSettingsActivity"
|
||||||
@@ -199,11 +218,7 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<service
|
|
||||||
android:name=".OnClearFromRecentService"
|
|
||||||
android:enabled="true"
|
|
||||||
android:exported="true"
|
|
||||||
android:stopWithTask="false" />
|
|
||||||
<service android:name="com.asterinet.react.bgactions.RNBackgroundActionsTask" />
|
<service android:name="com.asterinet.react.bgactions.RNBackgroundActionsTask" />
|
||||||
|
|
||||||
<service
|
<service
|
||||||
@@ -225,11 +240,6 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
<service
|
|
||||||
android:name=".ReminderViewsService"
|
|
||||||
android:exported="true"
|
|
||||||
android:permission="android.permission.BIND_REMOTEVIEWS" />
|
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.provider"
|
android:authorities="${applicationId}.provider"
|
||||||
|
|||||||
@@ -39,28 +39,32 @@ public class BootTaskService extends HeadlessJsTaskService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
|
try {
|
||||||
NotificationChannel channel = new NotificationChannel("com.streetwriters.notesnook",
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
|
||||||
"Default",
|
NotificationChannel channel = new NotificationChannel("com.streetwriters.notesnook",
|
||||||
NotificationManager.IMPORTANCE_DEFAULT);
|
"Default",
|
||||||
|
NotificationManager.IMPORTANCE_DEFAULT);
|
||||||
|
|
||||||
|
((NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE)).createNotificationChannel(channel);
|
||||||
|
notification = new NotificationCompat.Builder(this, channel.getId())
|
||||||
|
.setContentTitle("Sync on boot")
|
||||||
|
.setContentText("")
|
||||||
|
.setSmallIcon(R.drawable.ic_stat_name)
|
||||||
|
.build();
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= 34) {
|
||||||
|
this.startForeground(
|
||||||
|
1,
|
||||||
|
notification,
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE);
|
||||||
|
} else {
|
||||||
|
this.startForeground(
|
||||||
|
1,
|
||||||
|
notification);
|
||||||
|
}
|
||||||
|
|
||||||
((NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE)).createNotificationChannel(channel);
|
|
||||||
notification = new NotificationCompat.Builder(this, channel.getId())
|
|
||||||
.setContentTitle("Sync on boot")
|
|
||||||
.setContentText("")
|
|
||||||
.setSmallIcon(R.drawable.ic_stat_name)
|
|
||||||
.build();
|
|
||||||
if (android.os.Build.VERSION.SDK_INT >= 34) {
|
|
||||||
this.startForeground(
|
|
||||||
1,
|
|
||||||
notification,
|
|
||||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE);
|
|
||||||
} else {
|
|
||||||
this.startForeground(
|
|
||||||
1,
|
|
||||||
notification);
|
|
||||||
}
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
stopSelf(startId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return super.onStartCommand(intent, flags, startId);
|
return super.onStartCommand(intent, flags, startId);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import com.zoontek.rnbootsplash.RNBootSplash;
|
|||||||
|
|
||||||
public class MainActivity extends ReactActivity {
|
public class MainActivity extends ReactActivity {
|
||||||
@Override
|
@Override
|
||||||
protected void onCreate(Bundle savedInstanceState) {
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
RNBootSplash.init(this, R.style.BootTheme);
|
RNBootSplash.init(this, R.style.BootTheme);
|
||||||
|
|
||||||
super.onCreate(null);
|
super.onCreate(null);
|
||||||
@@ -28,23 +28,6 @@ public class MainActivity extends ReactActivity {
|
|||||||
WebView.setWebContentsDebuggingEnabled(true);
|
WebView.setWebContentsDebuggingEnabled(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
startService(new Intent(getBaseContext(), OnClearFromRecentService.class));
|
|
||||||
} catch (Exception ignored) {}
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= 35) {
|
|
||||||
final View rootView = findViewById(android.R.id.content);
|
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(rootView, (v, insets) -> {
|
|
||||||
Insets innerPadding = insets.getInsets(WindowInsetsCompat.Type.ime());
|
|
||||||
rootView.setPadding(
|
|
||||||
innerPadding.left,
|
|
||||||
innerPadding.top,
|
|
||||||
innerPadding.right,
|
|
||||||
innerPadding.bottom
|
|
||||||
);
|
|
||||||
return insets;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,20 +43,20 @@ public class MainActivity extends ReactActivity {
|
|||||||
DefaultNewArchitectureEntryPoint.getFabricEnabled());
|
DefaultNewArchitectureEntryPoint.getFabricEnabled());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onNewIntent(Intent intent) {
|
public void onNewIntent(Intent intent) {
|
||||||
super.onNewIntent(intent);
|
super.onNewIntent(intent);
|
||||||
setIntent(intent);
|
setIntent(intent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onConfigurationChanged(Configuration newConfig) {
|
public void onConfigurationChanged(Configuration newConfig) {
|
||||||
super.onConfigurationChanged(newConfig);
|
super.onConfigurationChanged(newConfig);
|
||||||
getReactInstanceManager().onConfigurationChanged(this, newConfig);
|
getReactHost().onConfigurationChanged(this);
|
||||||
Intent intent = new Intent("onConfigurationChanged");
|
Intent intent = new Intent("onConfigurationChanged");
|
||||||
intent.putExtra("newConfig", newConfig);
|
intent.putExtra("newConfig", newConfig);
|
||||||
this.sendBroadcast(intent);
|
this.sendBroadcast(intent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected String getMainComponentName() {
|
protected String getMainComponentName() {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import com.facebook.react.ReactApplication
|
|||||||
import com.facebook.react.ReactHost
|
import com.facebook.react.ReactHost
|
||||||
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
|
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
|
||||||
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
|
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
|
||||||
|
import org.wonday.orientation.OrientationActivityLifecycle;
|
||||||
class MainApplication : Application(), ReactApplication {
|
class MainApplication : Application(), ReactApplication {
|
||||||
override val reactHost: ReactHost by lazy {
|
override val reactHost: ReactHost by lazy {
|
||||||
getDefaultReactHost(
|
getDefaultReactHost(
|
||||||
@@ -22,6 +22,7 @@ class MainApplication : Application(), ReactApplication {
|
|||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
registerActivityLifecycleCallbacks(OrientationActivityLifecycle.getInstance());
|
||||||
loadReactNative(this)
|
loadReactNative(this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,15 +5,11 @@ import android.appwidget.AppWidgetManager;
|
|||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.util.Log;
|
|
||||||
import android.widget.RemoteViews;
|
|
||||||
|
|
||||||
import com.facebook.react.ReactActivity;
|
import com.facebook.react.ReactActivity;
|
||||||
import com.facebook.react.ReactActivityDelegate;
|
import com.facebook.react.ReactActivityDelegate;
|
||||||
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint;
|
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint;
|
||||||
import com.facebook.react.defaults.DefaultReactActivityDelegate;
|
import com.facebook.react.defaults.DefaultReactActivityDelegate;
|
||||||
import com.google.gson.Gson;
|
|
||||||
import com.streetwriters.notesnook.datatypes.Note;
|
|
||||||
|
|
||||||
public class NotePreviewConfigureActivity extends ReactActivity {
|
public class NotePreviewConfigureActivity extends ReactActivity {
|
||||||
|
|
||||||
@@ -40,18 +36,32 @@ public class NotePreviewConfigureActivity extends ReactActivity {
|
|||||||
@Override
|
@Override
|
||||||
protected void onCreate(Bundle savedInstanceState) {
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
super.onCreate(null);
|
super.onCreate(null);
|
||||||
Intent intent = getIntent();
|
activity = this;
|
||||||
Bundle extras = intent.getExtras();
|
readAppWidgetId(getIntent());
|
||||||
int appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID;
|
}
|
||||||
if (extras != null) {
|
|
||||||
appWidgetId = extras.getInt(
|
/**
|
||||||
AppWidgetManager.EXTRA_APPWIDGET_ID,
|
* We launch as singleTask, so configuring a second widget while this screen is still alive
|
||||||
AppWidgetManager.INVALID_APPWIDGET_ID);
|
* arrives here rather than in onCreate(). Without this the activity would keep writing to
|
||||||
NotePreviewConfigureActivity.appWidgetId = appWidgetId;
|
* whichever widget it happened to be opened for first.
|
||||||
}
|
*/
|
||||||
|
@Override
|
||||||
|
public void onNewIntent(Intent intent) {
|
||||||
|
super.onNewIntent(intent);
|
||||||
|
setIntent(intent);
|
||||||
|
activity = this;
|
||||||
|
readAppWidgetId(intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void readAppWidgetId(Intent intent) {
|
||||||
|
Bundle extras = intent != null ? intent.getExtras() : null;
|
||||||
|
int appWidgetId = extras != null
|
||||||
|
? extras.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
|
||||||
|
: AppWidgetManager.INVALID_APPWIDGET_ID;
|
||||||
|
|
||||||
|
NotePreviewConfigureActivity.appWidgetId = appWidgetId;
|
||||||
Intent resultValue = new Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
|
Intent resultValue = new Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
|
||||||
setResult(Activity.RESULT_CANCELED, resultValue);
|
setResult(Activity.RESULT_CANCELED, resultValue);
|
||||||
activity = this;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void saveAndFinish(Context context) {
|
public static void saveAndFinish(Context context) {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.streetwriters.notesnook;
|
package com.streetwriters.notesnook;
|
||||||
|
|
||||||
import android.app.ActivityOptions;
|
|
||||||
import android.app.PendingIntent;
|
import android.app.PendingIntent;
|
||||||
import android.appwidget.AppWidgetManager;
|
import android.appwidget.AppWidgetManager;
|
||||||
import android.appwidget.AppWidgetProvider;
|
import android.appwidget.AppWidgetProvider;
|
||||||
@@ -8,51 +7,125 @@ import android.content.Context;
|
|||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.content.SharedPreferences;
|
import android.content.SharedPreferences;
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
import android.os.Build;
|
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
|
import android.view.View;
|
||||||
import android.widget.RemoteViews;
|
import android.widget.RemoteViews;
|
||||||
import com.google.gson.Gson;
|
|
||||||
import com.streetwriters.notesnook.datatypes.Note;
|
import com.streetwriters.notesnook.datatypes.Note;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
|
||||||
public class NotePreviewWidget extends AppWidgetProvider {
|
public class NotePreviewWidget extends AppWidgetProvider {
|
||||||
static String OpenNoteId = "com.streetwriters.notesnook.OpenNoteId";
|
static String OpenNoteId = "com.streetwriters.notesnook.OpenNoteId";
|
||||||
static void updateAppWidget(Context context, AppWidgetManager appWidgetManager,
|
static void updateAppWidget(Context context, AppWidgetManager appWidgetManager,
|
||||||
int appWidgetId) {
|
int appWidgetId) {
|
||||||
String data = context.getSharedPreferences("appPreview", Context.MODE_PRIVATE).getString(String.valueOf(appWidgetId), "");
|
String data = context.getSharedPreferences(WidgetUtils.PREFERENCES, Context.MODE_PRIVATE).getString(String.valueOf(appWidgetId), "");
|
||||||
if (data.isEmpty()) {
|
RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.note_widget);
|
||||||
|
|
||||||
|
Note note = WidgetUtils.parseNote(data);
|
||||||
|
if (note == null) {
|
||||||
|
// Either the widget was never configured, or we lost the note it pointed at (ids
|
||||||
|
// reassigned, data cleared). Point it back at the picker rather than leaving the user
|
||||||
|
// with an inert widget they can only fix by deleting and re-adding it.
|
||||||
|
views.setTextViewText(R.id.widget_title, context.getString(R.string.widget_note_unconfigured_title));
|
||||||
|
views.setTextViewText(R.id.widget_body, context.getString(R.string.widget_note_unconfigured_body));
|
||||||
|
views.setOnClickPendingIntent(R.id.open_note, getConfigurePendingIntent(context, appWidgetId));
|
||||||
|
appWidgetManager.updateAppWidget(appWidgetId, views);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Gson gson = new Gson();
|
|
||||||
Note note = gson.fromJson(data, Note.class);
|
|
||||||
RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.note_widget);
|
|
||||||
views.setTextViewText(R.id.widget_title, note.getTitle());
|
views.setTextViewText(R.id.widget_title, note.getTitle());
|
||||||
views.setTextViewText(R.id.widget_body, note.getHeadline());
|
views.setTextViewText(R.id.widget_body, note.getHeadline());
|
||||||
|
// Once the user shrinks the widget down to a single row there is no room for the preview
|
||||||
|
// text, and a clipped half-line of it looks like a rendering glitch.
|
||||||
|
views.setViewVisibility(R.id.widget_body,
|
||||||
|
hasRoomForBody(appWidgetManager, appWidgetId) ? View.VISIBLE : View.GONE);
|
||||||
|
|
||||||
Intent intent = new Intent(context, MainActivity.class);
|
Intent intent = new Intent(context, MainActivity.class);
|
||||||
intent.putExtra(OpenNoteId, note.getId());
|
intent.putExtra(OpenNoteId, note.getId());
|
||||||
intent.setAction(Intent.ACTION_VIEW);
|
intent.setAction(Intent.ACTION_VIEW);
|
||||||
intent.putExtra(RCTNNativeModule.IntentType, "OpenNote");
|
intent.putExtra(RCTNNativeModule.IntentType, "OpenNote");
|
||||||
intent.setData(Uri.parse("https://app.notesnook.com/open_note?id=" + note.getId()));
|
intent.setData(Uri.parse("nn://note/" + note.getId()));
|
||||||
PendingIntent pendingIntent = PendingIntent.getActivity(context, appWidgetId, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE, getActivityOptionsBundle());
|
PendingIntent pendingIntent = PendingIntent.getActivity(context, appWidgetId, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE, WidgetUtils.getActivityOptionsBundle());
|
||||||
views.setOnClickPendingIntent(R.id.open_note, pendingIntent);
|
views.setOnClickPendingIntent(R.id.open_note, pendingIntent);
|
||||||
|
|
||||||
appWidgetManager.updateAppWidget(appWidgetId, views);
|
appWidgetManager.updateAppWidget(appWidgetId, views);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reopens the configure screen for this widget. The launcher's own "reconfigure" gesture is
|
||||||
|
* hard to discover and not offered by every launcher, so an unconfigured widget needs its own
|
||||||
|
* way back in.
|
||||||
|
*/
|
||||||
|
private static PendingIntent getConfigurePendingIntent(Context context, int appWidgetId) {
|
||||||
|
Intent intent = new Intent(context, NotePreviewConfigureActivity.class);
|
||||||
|
intent.setAction(AppWidgetManager.ACTION_APPWIDGET_CONFIGURE);
|
||||||
|
intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
|
||||||
|
// PendingIntent equality ignores extras, so the widget id has to be the request code for
|
||||||
|
// each widget to get its own.
|
||||||
|
return PendingIntent.getActivity(context, appWidgetId, intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
WidgetUtils.getActivityOptionsBundle());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Height below which the note preview text is dropped, leaving just the title.
|
||||||
|
*/
|
||||||
|
private static final int MIN_HEIGHT_FOR_BODY_DP = 70;
|
||||||
|
|
||||||
|
private static boolean hasRoomForBody(AppWidgetManager appWidgetManager, int appWidgetId) {
|
||||||
|
Bundle options = appWidgetManager.getAppWidgetOptions(appWidgetId);
|
||||||
|
if (options == null) return true;
|
||||||
|
|
||||||
|
int minHeight = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT);
|
||||||
|
// Not reported yet (the widget has just been placed): assume there is room.
|
||||||
|
return minHeight <= 0 || minHeight >= MIN_HEIGHT_FOR_BODY_DP;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onAppWidgetOptionsChanged(Context context, AppWidgetManager appWidgetManager, int appWidgetId, Bundle newOptions) {
|
public void onAppWidgetOptionsChanged(Context context, AppWidgetManager appWidgetManager, int appWidgetId, Bundle newOptions) {
|
||||||
super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions);
|
super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions);
|
||||||
|
// This used to do nothing at all, so resizing the widget left it rendered for its old size.
|
||||||
|
updateAppWidget(context, appWidgetManager, appWidgetId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Bundle getActivityOptionsBundle() {
|
/**
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
* The note shown by each widget is stored in the "appPreview" preferences under its widget id.
|
||||||
ActivityOptions activityOptions = ActivityOptions.makeBasic();
|
* When the system restores our widgets it hands out fresh ids, so unless we move the stored
|
||||||
activityOptions.setPendingIntentCreatorBackgroundActivityStartMode(
|
* notes over to the new ids the widgets are left permanently blank with no way to recover
|
||||||
ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED);
|
* other than removing and re-adding them.
|
||||||
return activityOptions.toBundle();
|
*
|
||||||
} else
|
* AppWidgetProvider calls onUpdate() with the new ids right after this, which re-renders them.
|
||||||
return null;
|
*/
|
||||||
|
@Override
|
||||||
|
public void onRestored(Context context, int[] oldWidgetIds, int[] newWidgetIds) {
|
||||||
|
super.onRestored(context, oldWidgetIds, newWidgetIds);
|
||||||
|
if (oldWidgetIds == null || newWidgetIds == null) return;
|
||||||
|
|
||||||
|
int count = Math.min(oldWidgetIds.length, newWidgetIds.length);
|
||||||
|
SharedPreferences preferences = context.getSharedPreferences("appPreview", Context.MODE_PRIVATE);
|
||||||
|
|
||||||
|
// Read everything up front: an old id can collide with the new id of another widget.
|
||||||
|
String[] notes = new String[count];
|
||||||
|
Set<String> newKeys = new HashSet<>();
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
notes[i] = preferences.getString(String.valueOf(oldWidgetIds[i]), "");
|
||||||
|
newKeys.add(String.valueOf(newWidgetIds[i]));
|
||||||
|
}
|
||||||
|
|
||||||
|
SharedPreferences.Editor edit = preferences.edit();
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
String oldKey = String.valueOf(oldWidgetIds[i]);
|
||||||
|
if (!newKeys.contains(oldKey)) {
|
||||||
|
edit.remove(oldKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
if (notes[i].isEmpty()) continue;
|
||||||
|
edit.putString(String.valueOf(newWidgetIds[i]), notes[i]);
|
||||||
|
}
|
||||||
|
edit.apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
package com.streetwriters.notesnook;
|
package com.streetwriters.notesnook;
|
||||||
|
|
||||||
import android.app.ActivityOptions;
|
|
||||||
import android.app.PendingIntent;
|
import android.app.PendingIntent;
|
||||||
import android.appwidget.AppWidgetManager;
|
import android.appwidget.AppWidgetManager;
|
||||||
import android.appwidget.AppWidgetProvider;
|
import android.appwidget.AppWidgetProvider;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.os.Build;
|
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.widget.RemoteViews;
|
import android.widget.RemoteViews;
|
||||||
|
|
||||||
@@ -24,20 +22,10 @@ public class NoteWidget extends AppWidgetProvider {
|
|||||||
|
|
||||||
static void setClickIntent(Context context, RemoteViews views) {
|
static void setClickIntent(Context context, RemoteViews views) {
|
||||||
Intent intent = new Intent(context, ShareActivity.class);
|
Intent intent = new Intent(context, ShareActivity.class);
|
||||||
PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE, getActivityOptionsBundle());
|
PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE, WidgetUtils.getActivityOptionsBundle());
|
||||||
views.setOnClickPendingIntent(R.id.new_note, pendingIntent);
|
views.setOnClickPendingIntent(R.id.new_note, pendingIntent);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Bundle getActivityOptionsBundle() {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
||||||
ActivityOptions activityOptions = ActivityOptions.makeBasic();
|
|
||||||
activityOptions.setPendingIntentCreatorBackgroundActivityStartMode(
|
|
||||||
ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED);
|
|
||||||
return activityOptions.toBundle();
|
|
||||||
} else
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onAppWidgetOptionsChanged(Context context, AppWidgetManager appWidgetManager, int appWidgetId, Bundle newOptions) {
|
public void onAppWidgetOptionsChanged(Context context, AppWidgetManager appWidgetManager, int appWidgetId, Bundle newOptions) {
|
||||||
super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions);
|
super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions);
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
package com.streetwriters.notesnook;
|
|
||||||
import android.app.Service;
|
|
||||||
import android.content.Intent;
|
|
||||||
import android.content.SharedPreferences;
|
|
||||||
import android.os.IBinder;
|
|
||||||
import android.util.Log;
|
|
||||||
|
|
||||||
public class OnClearFromRecentService extends Service {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public IBinder onBind(Intent intent) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
|
||||||
return START_NOT_STICKY;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onDestroy() {
|
|
||||||
super.onDestroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onTaskRemoved(Intent rootIntent) {
|
|
||||||
try {
|
|
||||||
SharedPreferences appStateDetails = getApplicationContext().getSharedPreferences("appStateDetails", MODE_PRIVATE);
|
|
||||||
SharedPreferences.Editor edit = appStateDetails.edit();
|
|
||||||
edit.remove("appState");
|
|
||||||
edit.apply();
|
|
||||||
stopSelf();
|
|
||||||
} catch (UnsatisfiedLinkError | Exception e) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,13 @@ import android.content.ComponentName;
|
|||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.content.SharedPreferences;
|
import android.content.SharedPreferences;
|
||||||
|
import android.content.pm.ShortcutInfo;
|
||||||
|
import android.content.pm.ShortcutManager;
|
||||||
|
import android.graphics.Bitmap;
|
||||||
|
import android.graphics.Canvas;
|
||||||
import android.graphics.Color;
|
import android.graphics.Color;
|
||||||
|
import android.graphics.Paint;
|
||||||
|
import android.graphics.drawable.Icon;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.provider.Settings;
|
import android.provider.Settings;
|
||||||
@@ -22,13 +28,13 @@ import com.facebook.react.bridge.ReactApplicationContext;
|
|||||||
import com.facebook.react.bridge.ReactMethod;
|
import com.facebook.react.bridge.ReactMethod;
|
||||||
import com.facebook.react.bridge.WritableArray;
|
import com.facebook.react.bridge.WritableArray;
|
||||||
import com.facebook.react.bridge.WritableMap;
|
import com.facebook.react.bridge.WritableMap;
|
||||||
import com.google.gson.Gson;
|
|
||||||
import com.streetwriters.notesnook.datatypes.Note;
|
import com.streetwriters.notesnook.datatypes.Note;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
|
||||||
public class RCTNNativeModule extends ReactContextBaseJavaModule {
|
public class RCTNNativeModule extends ReactContextBaseJavaModule {
|
||||||
@@ -85,21 +91,6 @@ public class RCTNNativeModule extends ReactContextBaseJavaModule {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ReactMethod
|
|
||||||
public void setAppState(final String appState) {
|
|
||||||
SharedPreferences appStateDetails = getReactApplicationContext().getSharedPreferences("appStateDetails", Context.MODE_PRIVATE);
|
|
||||||
SharedPreferences.Editor edit = appStateDetails.edit();
|
|
||||||
edit.putString("appState", appState);
|
|
||||||
edit.apply();
|
|
||||||
}
|
|
||||||
|
|
||||||
@ReactMethod(isBlockingSynchronousMethod = true)
|
|
||||||
public String getAppState() {
|
|
||||||
SharedPreferences appStateDetails = getReactApplicationContext().getSharedPreferences("appStateDetails", Context.MODE_PRIVATE);
|
|
||||||
String appStateValue = appStateDetails.getString("appState", "");
|
|
||||||
return appStateValue.isEmpty() ? null : appStateValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ReactMethod(isBlockingSynchronousMethod = true)
|
@ReactMethod(isBlockingSynchronousMethod = true)
|
||||||
public int getWidgetId() {
|
public int getWidgetId() {
|
||||||
return NotePreviewConfigureActivity.appWidgetId;
|
return NotePreviewConfigureActivity.appWidgetId;
|
||||||
@@ -145,7 +136,7 @@ public class RCTNNativeModule extends ReactContextBaseJavaModule {
|
|||||||
if (Objects.equals(extras.getString(IntentType), "NewReminder")) {
|
if (Objects.equals(extras.getString(IntentType), "NewReminder")) {
|
||||||
map.putString(ReminderWidgetProvider.NewReminder, extras.getString(ReminderWidgetProvider.NewReminder));
|
map.putString(ReminderWidgetProvider.NewReminder, extras.getString(ReminderWidgetProvider.NewReminder));
|
||||||
} else if (Objects.equals(extras.getString(IntentType), "OpenReminder")) {
|
} else if (Objects.equals(extras.getString(IntentType), "OpenReminder")) {
|
||||||
map.putString(ReminderViewsService.OpenReminderId, extras.getString(ReminderViewsService.OpenReminderId));
|
map.putString(ReminderWidgetProvider.OpenReminderId, extras.getString(ReminderWidgetProvider.OpenReminderId));
|
||||||
} else if (Objects.equals(extras.getString(IntentType), "OpenNote")) {
|
} else if (Objects.equals(extras.getString(IntentType), "OpenNote")) {
|
||||||
map.putString(NotePreviewWidget.OpenNoteId, extras.getString(NotePreviewWidget.OpenNoteId));
|
map.putString(NotePreviewWidget.OpenNoteId, extras.getString(NotePreviewWidget.OpenNoteId));
|
||||||
}
|
}
|
||||||
@@ -163,14 +154,8 @@ public class RCTNNativeModule extends ReactContextBaseJavaModule {
|
|||||||
|
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
public void getWidgetNotes(Promise promise) {
|
public void getWidgetNotes(Promise promise) {
|
||||||
SharedPreferences pref = getReactApplicationContext().getSharedPreferences("appPreview", Context.MODE_PRIVATE);
|
|
||||||
Map<String, ?> map = pref.getAll();
|
|
||||||
WritableArray arr = Arguments.createArray();
|
WritableArray arr = Arguments.createArray();
|
||||||
for(Map.Entry<String,?> entry : map.entrySet()){
|
for (Note note : WidgetUtils.getWidgetNotes(getReactApplicationContext()).values()) {
|
||||||
if (entry.getKey().equals("remindersList")) continue;
|
|
||||||
String value = (String) entry.getValue();
|
|
||||||
Gson gson = new Gson();
|
|
||||||
Note note = gson.fromJson(value, Note.class);
|
|
||||||
arr.pushString(note.getId());
|
arr.pushString(note.getId());
|
||||||
}
|
}
|
||||||
promise.resolve(arr);
|
promise.resolve(arr);
|
||||||
@@ -178,36 +163,46 @@ public class RCTNNativeModule extends ReactContextBaseJavaModule {
|
|||||||
|
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
public void hasWidgetNote(final String noteId, Promise promise) {
|
public void hasWidgetNote(final String noteId, Promise promise) {
|
||||||
SharedPreferences pref = getReactApplicationContext().getSharedPreferences("appPreview", Context.MODE_PRIVATE);
|
|
||||||
Map<String, ?> map = pref.getAll();
|
|
||||||
boolean found = false;
|
boolean found = false;
|
||||||
for(Map.Entry<String,?> entry : map.entrySet()){
|
for (Note note : WidgetUtils.getWidgetNotes(getReactApplicationContext()).values()) {
|
||||||
String value = (String) entry.getValue();
|
if (note.getId().equals(noteId)) {
|
||||||
if (value.contains(noteId)) {
|
|
||||||
found = true;
|
found = true;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
promise.resolve(found);
|
promise.resolve(found);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
public void updateWidgetNote(final String noteId, final String data) {
|
public void updateWidgetNote(final String noteId, final String data) {
|
||||||
SharedPreferences pref = getReactApplicationContext().getSharedPreferences("appPreview", Context.MODE_PRIVATE);
|
SharedPreferences pref = getReactApplicationContext().getSharedPreferences(WidgetUtils.PREFERENCES, Context.MODE_PRIVATE);
|
||||||
Map<String, ?> map = pref.getAll();
|
|
||||||
SharedPreferences.Editor edit = pref.edit();
|
SharedPreferences.Editor edit = pref.edit();
|
||||||
ArrayList<String> ids = new ArrayList<>();
|
List<Integer> ids = new ArrayList<>();
|
||||||
for(Map.Entry<String,?> entry : map.entrySet()) {
|
|
||||||
String value = (String) entry.getValue();
|
// Match on the note's id, not on the raw JSON containing it somewhere: a note whose body
|
||||||
if (value.contains(noteId)) {
|
// happens to mention another note's id is not the same note.
|
||||||
edit.putString(entry.getKey(), data);
|
for (Map.Entry<Integer, Note> entry : WidgetUtils.getWidgetNotes(getReactApplicationContext()).entrySet()) {
|
||||||
ids.add(entry.getKey());
|
if (!noteId.equals(entry.getValue().getId())) continue;
|
||||||
}
|
edit.putString(String.valueOf(entry.getKey()), data);
|
||||||
|
ids.add(entry.getKey());
|
||||||
}
|
}
|
||||||
edit.apply();
|
edit.apply();
|
||||||
for (String id: ids) {
|
|
||||||
NotePreviewWidget.updateAppWidget(mContext, AppWidgetManager.getInstance(mContext), Integer.parseInt(id));
|
for (int id : ids) {
|
||||||
|
NotePreviewWidget.updateAppWidget(mContext, AppWidgetManager.getInstance(mContext), id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redraws every widget from scratch. Needed because the app can be stopped while its widgets
|
||||||
|
* stay on the home screen: clearing app data empties the store without the widgets ever being
|
||||||
|
* told, so they keep showing content that is gone until something forces a redraw.
|
||||||
|
*/
|
||||||
|
@ReactMethod
|
||||||
|
public void refreshWidgets() {
|
||||||
|
WidgetUtils.refreshAll(mContext);
|
||||||
|
}
|
||||||
|
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
public void updateReminderWidget() {
|
public void updateReminderWidget() {
|
||||||
AppWidgetManager wm = AppWidgetManager.getInstance(mContext);
|
AppWidgetManager wm = AppWidgetManager.getInstance(mContext);
|
||||||
@@ -215,8 +210,8 @@ public class RCTNNativeModule extends ReactContextBaseJavaModule {
|
|||||||
for (int id: ids) {
|
for (int id: ids) {
|
||||||
Log.d("Reminders", "Updating" + id);
|
Log.d("Reminders", "Updating" + id);
|
||||||
RemoteViews views = new RemoteViews(mContext.getPackageName(), R.layout.widget_reminders);
|
RemoteViews views = new RemoteViews(mContext.getPackageName(), R.layout.widget_reminders);
|
||||||
|
// The rows are part of this update, so there is nothing left to invalidate afterwards.
|
||||||
ReminderWidgetProvider.updateAppWidget(mContext, wm, id, views);
|
ReminderWidgetProvider.updateAppWidget(mContext, wm, id, views);
|
||||||
wm.notifyAppWidgetViewDataChanged(id, R.id.widget_list_view);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,6 +232,221 @@ public class RCTNNativeModule extends ReactContextBaseJavaModule {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ReactMethod
|
||||||
|
public void addShortcut(final String id, final String type, final String title, final String description, final String color, Promise promise) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||||
|
promise.reject("UNSUPPORTED", "Pinned launcher shortcuts require Android 8.0 or higher");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ShortcutManager shortcutManager = mContext.getSystemService(ShortcutManager.class);
|
||||||
|
if (shortcutManager == null) {
|
||||||
|
promise.reject("ERROR", "ShortcutManager not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String uri = "nn://" + type + "/" + id;
|
||||||
|
Intent intent = new Intent(Intent.ACTION_VIEW, android.net.Uri.parse(uri));
|
||||||
|
intent.setPackage(mContext.getPackageName());
|
||||||
|
|
||||||
|
Icon icon = createLetterIcon(type, title, color);
|
||||||
|
ShortcutInfo shortcut = new ShortcutInfo.Builder(mContext, id)
|
||||||
|
.setShortLabel(title)
|
||||||
|
.setLongLabel(description != null && !description.isEmpty() ? description : title)
|
||||||
|
.setIcon(icon)
|
||||||
|
.setCategories(Set.of(type))
|
||||||
|
.setIntent(intent)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
shortcutManager.requestPinShortcut(shortcut, null);
|
||||||
|
promise.resolve(true);
|
||||||
|
} catch (Exception e) {
|
||||||
|
promise.reject("ERROR", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ReactMethod
|
||||||
|
public void removeShortcut(final String id, Promise promise) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||||
|
promise.reject("UNSUPPORTED", "Pinned launcher shortcuts require Android 8.0 or higher");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ShortcutManager shortcutManager = mContext.getSystemService(ShortcutManager.class);
|
||||||
|
if (shortcutManager == null) {
|
||||||
|
promise.reject("ERROR", "ShortcutManager not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
shortcutManager.disableShortcuts(java.util.Collections.singletonList(id));
|
||||||
|
promise.resolve(true);
|
||||||
|
} catch (Exception e) {
|
||||||
|
promise.reject("ERROR", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ReactMethod
|
||||||
|
public void updateShortcut(final String id, final String type, final String title, final String description,final String color, Promise promise) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||||
|
promise.reject("UNSUPPORTED", "Pinned launcher shortcuts require Android 8.0 or higher");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ShortcutManager shortcutManager = mContext.getSystemService(ShortcutManager.class);
|
||||||
|
if (shortcutManager == null) {
|
||||||
|
promise.reject("ERROR", "ShortcutManager not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get existing shortcut to preserve icon and intent
|
||||||
|
List<ShortcutInfo> shortcuts = shortcutManager.getPinnedShortcuts();
|
||||||
|
ShortcutInfo existingShortcut = null;
|
||||||
|
for (ShortcutInfo s : shortcuts) {
|
||||||
|
if (s.getId().equals(id)) {
|
||||||
|
existingShortcut = s;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingShortcut == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Icon icon = createLetterIcon(type, title, color);
|
||||||
|
ShortcutInfo updatedShortcut = new ShortcutInfo.Builder(mContext, id)
|
||||||
|
.setShortLabel(title)
|
||||||
|
.setIcon(icon)
|
||||||
|
.setLongLabel(description != null && !description.isEmpty() ? description : title)
|
||||||
|
.setIntent(existingShortcut.getIntent())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
shortcutManager.updateShortcuts(java.util.Collections.singletonList(updatedShortcut));
|
||||||
|
promise.resolve(true);
|
||||||
|
} catch (Exception e) {
|
||||||
|
promise.reject("ERROR", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ReactMethod
|
||||||
|
public void removeAllShortcuts(Promise promise) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||||
|
promise.reject("UNSUPPORTED", "Pinned launcher shortcuts require Android 8.0 or higher");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ShortcutManager shortcutManager = mContext.getSystemService(ShortcutManager.class);
|
||||||
|
if (shortcutManager == null) {
|
||||||
|
promise.reject("ERROR", "ShortcutManager not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ShortcutInfo> pinnedShortcuts = shortcutManager.getPinnedShortcuts();
|
||||||
|
if (!pinnedShortcuts.isEmpty()) {
|
||||||
|
List<String> ids = new ArrayList<>();
|
||||||
|
for (ShortcutInfo shortcut : pinnedShortcuts) {
|
||||||
|
ids.add(shortcut.getId());
|
||||||
|
}
|
||||||
|
shortcutManager.disableShortcuts(ids);
|
||||||
|
}
|
||||||
|
promise.resolve(true);
|
||||||
|
} catch (Exception e) {
|
||||||
|
promise.reject("ERROR", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ReactMethod
|
||||||
|
public void getAllShortcuts(Promise promise) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ShortcutManager shortcutManager = mContext.getSystemService(ShortcutManager.class);
|
||||||
|
WritableArray shortcuts = Arguments.createArray();
|
||||||
|
List<ShortcutInfo> infos = shortcutManager.getPinnedShortcuts();
|
||||||
|
|
||||||
|
for (ShortcutInfo info: infos) {
|
||||||
|
WritableMap data = Arguments.createMap();
|
||||||
|
data.putString("id", info.getId());
|
||||||
|
if (info.getShortLabel() != null) {
|
||||||
|
data.putString("title", info.getShortLabel().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.getLongLabel() != null) {
|
||||||
|
data.putString("description", info.getLongLabel().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (!Objects.requireNonNull(info.getCategories()).isEmpty()) {
|
||||||
|
if (info.getCategories().contains("note")) {
|
||||||
|
data.putString("type", "note");
|
||||||
|
} else if (info.getCategories().contains("notebook")) {
|
||||||
|
data.putString("type", "notebook");
|
||||||
|
} else if (info.getCategories().contains("tag")) {
|
||||||
|
data.putString("type", "tag");
|
||||||
|
} else if (info.getCategories().contains("color")) {
|
||||||
|
data.putString("type", "color");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shortcuts.pushMap(data);
|
||||||
|
}
|
||||||
|
promise.resolve(shortcuts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Icon createLetterIcon(String type, String title, String colorCode) {
|
||||||
|
|
||||||
|
String letter = type.contains("tag") ? "#" : title != null && !title.isEmpty()
|
||||||
|
? title.substring(0, 1).toUpperCase()
|
||||||
|
: "?";
|
||||||
|
int color = type.equals("color") ? Color.parseColor(colorCode) : getColorForLetter(letter);
|
||||||
|
|
||||||
|
if (type.equals("notebook")) return Icon.createWithResource(mContext, R.drawable.ic_notebook);
|
||||||
|
// Use a larger canvas and fill it completely to avoid white borders from launcher masking.
|
||||||
|
int iconSize = 256;
|
||||||
|
Bitmap bitmap = Bitmap.createBitmap(iconSize, iconSize, Bitmap.Config.ARGB_8888);
|
||||||
|
Canvas canvas = new Canvas(bitmap);
|
||||||
|
|
||||||
|
if (type.equals("color")) {
|
||||||
|
Paint backgroundPaint = new Paint();
|
||||||
|
backgroundPaint.setColor(color);
|
||||||
|
backgroundPaint.setAntiAlias(true);
|
||||||
|
canvas.drawCircle(iconSize / 2, iconSize / 2, iconSize/2, backgroundPaint);
|
||||||
|
} else {
|
||||||
|
Paint textPaint = new Paint();
|
||||||
|
textPaint.setColor(color);
|
||||||
|
textPaint.setTextSize(130);
|
||||||
|
textPaint.setAntiAlias(true);
|
||||||
|
textPaint.setTextAlign(Paint.Align.CENTER);
|
||||||
|
textPaint.setTypeface(android.graphics.Typeface.create(android.graphics.Typeface.DEFAULT, android.graphics.Typeface.BOLD));
|
||||||
|
|
||||||
|
float x = iconSize / 2f;
|
||||||
|
float y = (iconSize / 2f) - ((textPaint.descent() + textPaint.ascent()) / 2f);
|
||||||
|
|
||||||
|
canvas.drawText(letter, x, y, textPaint);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Icon.createWithBitmap(bitmap);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int getColorForLetter(String letter) {
|
||||||
|
int[] colors = {
|
||||||
|
0xFF1976D2, // Blue
|
||||||
|
0xFFD32F2F, // Red
|
||||||
|
0xFF388E3C, // Green
|
||||||
|
0xFFF57C00, // Orange
|
||||||
|
0xFF7B1FA2, // Purple
|
||||||
|
0xFF0097A7, // Cyan
|
||||||
|
0xFFC2185B, // Pink
|
||||||
|
0xFF455A64, // Blue Grey
|
||||||
|
0xFF6A1B9A, // Deep Purple
|
||||||
|
0xFF00796B, // Teal
|
||||||
|
0xFF512DA8, // Indigo
|
||||||
|
0xFF1565C0 // Dark Blue
|
||||||
|
};
|
||||||
|
|
||||||
|
int hash = Math.abs(letter.hashCode());
|
||||||
|
return colors[hash % colors.length];
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
package com.streetwriters.notesnook;
|
|
||||||
|
|
||||||
import android.app.ActivityOptions;
|
|
||||||
import android.app.PendingIntent;
|
|
||||||
import android.content.Intent;
|
|
||||||
import android.content.SharedPreferences;
|
|
||||||
import android.net.Uri;
|
|
||||||
import android.os.Build;
|
|
||||||
import android.os.Bundle;
|
|
||||||
import android.util.Log;
|
|
||||||
import android.widget.RemoteViewsService;
|
|
||||||
import android.content.Context;
|
|
||||||
import android.widget.RemoteViews;
|
|
||||||
import com.google.gson.Gson;
|
|
||||||
import com.google.gson.reflect.TypeToken;
|
|
||||||
import com.streetwriters.notesnook.datatypes.Reminder;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public class ReminderViewsService extends RemoteViewsService {
|
|
||||||
static String OpenReminderId = "com.streetwriters.notesnook.OpenReminderId";
|
|
||||||
@Override
|
|
||||||
public RemoteViewsFactory onGetViewFactory(Intent intent) {
|
|
||||||
return new ReminderRemoteViewsFactory(this.getApplicationContext(), intent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ReminderRemoteViewsFactory implements RemoteViewsService.RemoteViewsFactory {
|
|
||||||
private Context context;
|
|
||||||
private List<Reminder> reminders;
|
|
||||||
|
|
||||||
public ReminderRemoteViewsFactory(Context context, Intent intent) {
|
|
||||||
this.context = context;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onCreate() {
|
|
||||||
// Initialize reminders list
|
|
||||||
reminders = new ArrayList<Reminder>();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onDataSetChanged() {
|
|
||||||
reminders.clear();
|
|
||||||
SharedPreferences preferences = context.getSharedPreferences("appPreview", Context.MODE_PRIVATE);
|
|
||||||
Gson gson = new Gson();
|
|
||||||
reminders = gson.fromJson(preferences.getString("remindersList","[]"), new TypeToken<List<Reminder>>(){}.getType());
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onDestroy() {
|
|
||||||
reminders.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int getCount() {
|
|
||||||
return reminders.size();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public RemoteViews getViewAt(int position) {
|
|
||||||
Reminder reminder = reminders.get(position);
|
|
||||||
|
|
||||||
boolean useMiniLayout = reminder.getDescription() == null || reminder.getDescription().isEmpty();
|
|
||||||
|
|
||||||
RemoteViews views = new RemoteViews(context.getPackageName(), useMiniLayout ? R.layout.widget_reminder_layout_small : R.layout.widget_reminder_layout);
|
|
||||||
|
|
||||||
views.setTextViewText(R.id.reminder_title, reminder.getTitle());
|
|
||||||
if (!useMiniLayout) {
|
|
||||||
views.setTextViewText(R.id.reminder_description, reminder.getDescription());
|
|
||||||
}
|
|
||||||
views.setTextViewText(R.id.reminder_time, reminder.getFormattedTime());
|
|
||||||
final Intent fillInIntent = new Intent();
|
|
||||||
final Bundle extras = new Bundle();
|
|
||||||
extras.putString(ReminderViewsService.OpenReminderId, reminder.getId());
|
|
||||||
fillInIntent.setData(Uri.parse("https://app.notesnook.com/open_reminder?id=" + reminder.getId()));
|
|
||||||
fillInIntent.putExtra(RCTNNativeModule.IntentType, "OpenReminder");
|
|
||||||
fillInIntent.putExtras(extras);
|
|
||||||
views.setOnClickFillInIntent(R.id.reminder_item_btn, fillInIntent);
|
|
||||||
return views;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public RemoteViews getLoadingView() {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int getViewTypeCount() {
|
|
||||||
return 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public long getItemId(int position) {
|
|
||||||
|
|
||||||
return position;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean hasStableIds() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +1,22 @@
|
|||||||
package com.streetwriters.notesnook;
|
package com.streetwriters.notesnook;
|
||||||
|
|
||||||
import android.app.ActivityOptions;
|
|
||||||
import android.app.PendingIntent;
|
import android.app.PendingIntent;
|
||||||
import android.appwidget.AppWidgetManager;
|
import android.appwidget.AppWidgetManager;
|
||||||
import android.appwidget.AppWidgetProvider;
|
import android.appwidget.AppWidgetProvider;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
import android.os.Build;
|
|
||||||
import android.os.Bundle;
|
|
||||||
import android.widget.RemoteViews;
|
import android.widget.RemoteViews;
|
||||||
|
|
||||||
|
import androidx.core.widget.RemoteViewsCompat;
|
||||||
|
|
||||||
|
import com.streetwriters.notesnook.datatypes.Reminder;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
public class ReminderWidgetProvider extends AppWidgetProvider {
|
public class ReminderWidgetProvider extends AppWidgetProvider {
|
||||||
static String NewReminder = "com.streetwriters.notesnook.NewReminder";
|
static String NewReminder = "com.streetwriters.notesnook.NewReminder";
|
||||||
|
static String OpenReminderId = "com.streetwriters.notesnook.OpenReminderId";
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
|
public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
|
||||||
@@ -23,20 +27,10 @@ public class ReminderWidgetProvider extends AppWidgetProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private static Bundle getActivityOptionsBundle() {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
||||||
ActivityOptions activityOptions = ActivityOptions.makeBasic();
|
|
||||||
activityOptions.setPendingIntentCreatorBackgroundActivityStartMode(
|
|
||||||
ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED);
|
|
||||||
return activityOptions.toBundle();
|
|
||||||
} else
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void updateAppWidget(Context context, AppWidgetManager appWidgetManager, int appWidgetId, RemoteViews views) {
|
static void updateAppWidget(Context context, AppWidgetManager appWidgetManager, int appWidgetId, RemoteViews views) {
|
||||||
Intent listview_intent_template = new Intent(context, MainActivity.class);
|
Intent listview_intent_template = new Intent(context, MainActivity.class);
|
||||||
listview_intent_template.setAction(Intent.ACTION_VIEW);
|
listview_intent_template.setAction(Intent.ACTION_VIEW);
|
||||||
PendingIntent pendingIntent = PendingIntent.getActivity(context, appWidgetId, listview_intent_template, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_MUTABLE, getActivityOptionsBundle());
|
PendingIntent pendingIntent = PendingIntent.getActivity(context, appWidgetId, listview_intent_template, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_MUTABLE, WidgetUtils.getActivityOptionsBundle());
|
||||||
views.setPendingIntentTemplate(R.id.widget_list_view, pendingIntent);
|
views.setPendingIntentTemplate(R.id.widget_list_view, pendingIntent);
|
||||||
|
|
||||||
Intent new_reminder_intent = new Intent(context, MainActivity.class);
|
Intent new_reminder_intent = new Intent(context, MainActivity.class);
|
||||||
@@ -44,13 +38,31 @@ public class ReminderWidgetProvider extends AppWidgetProvider {
|
|||||||
new_reminder_intent.setAction(Intent.ACTION_VIEW);
|
new_reminder_intent.setAction(Intent.ACTION_VIEW);
|
||||||
new_reminder_intent.putExtra(RCTNNativeModule.IntentType, "NewReminder");
|
new_reminder_intent.putExtra(RCTNNativeModule.IntentType, "NewReminder");
|
||||||
new_reminder_intent.setData(Uri.parse("https://app.notesnook.com/new_reminder"));
|
new_reminder_intent.setData(Uri.parse("https://app.notesnook.com/new_reminder"));
|
||||||
PendingIntent pendingIntent2 = PendingIntent.getActivity(context, appWidgetId, new_reminder_intent, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_IMMUTABLE, getActivityOptionsBundle());
|
PendingIntent pendingIntent2 = PendingIntent.getActivity(context, appWidgetId, new_reminder_intent, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_IMMUTABLE, WidgetUtils.getActivityOptionsBundle());
|
||||||
views.setOnClickPendingIntent(R.id.add_button, pendingIntent2);
|
views.setOnClickPendingIntent(R.id.add_button, pendingIntent2);
|
||||||
|
|
||||||
Intent list_remote_adapter_intent = new Intent(context, ReminderViewsService.class);
|
// The rows travel with the update itself, so there is no bound service to keep in sync and
|
||||||
list_remote_adapter_intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
|
// nothing to invalidate separately: every update redraws from the current data.
|
||||||
views.setRemoteAdapter(R.id.widget_list_view, list_remote_adapter_intent);
|
List<Reminder> reminders = WidgetUtils.getWidgetReminders(context);
|
||||||
|
RemoteViewsCompat.RemoteCollectionItems.Builder items =
|
||||||
|
new RemoteViewsCompat.RemoteCollectionItems.Builder();
|
||||||
|
for (Reminder reminder : reminders) {
|
||||||
|
items.addItem(getItemId(reminder), WidgetUtils.createReminderItem(context, reminder));
|
||||||
|
}
|
||||||
|
// Two, because a reminder without a description uses the compact row layout.
|
||||||
|
items.setViewTypeCount(2);
|
||||||
|
items.setHasStableIds(true);
|
||||||
|
|
||||||
|
RemoteViewsCompat.setRemoteAdapter(context, views, appWidgetId, R.id.widget_list_view, items.build());
|
||||||
views.setEmptyView(R.id.widget_list_view, R.id.empty_view);
|
views.setEmptyView(R.id.widget_list_view, R.id.empty_view);
|
||||||
appWidgetManager.updateAppWidget(appWidgetId, views);
|
appWidgetManager.updateAppWidget(appWidgetId, views);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/**
|
||||||
|
* Ties a row to its reminder rather than to its position, so rows keep their identity when the
|
||||||
|
* list shifts around them.
|
||||||
|
*/
|
||||||
|
private static long getItemId(Reminder reminder) {
|
||||||
|
return reminder.getId() == null ? 0 : reminder.getId().hashCode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.streetwriters.notesnook;
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redraws the widgets when the clock or timezone changes.
|
||||||
|
*
|
||||||
|
* Reminder rows are described relative to the current time ("Upcoming"/"Last", "Today"/"Tomorrow"),
|
||||||
|
* and a reminder that has passed drops off the list entirely. All of that is decided when the
|
||||||
|
* widget is drawn, so moving the clock leaves the previous drawing in place: a reminder can still
|
||||||
|
* read "Upcoming" long after its time has gone by.
|
||||||
|
*
|
||||||
|
* The app normally redraws the list when a reminder notification is delivered, but that does not
|
||||||
|
* happen if the reminder never fires, which is exactly the case when the clock jumps past it.
|
||||||
|
*
|
||||||
|
* Only TIME_SET and TIMEZONE_CHANGED are handled here: DATE_CHANGED is not exempt from the
|
||||||
|
* Android 8 limits on manifest-registered implicit broadcasts, so a receiver for it would never
|
||||||
|
* run. Crossing midnight is instead picked up by the widget's own periodic update.
|
||||||
|
*/
|
||||||
|
public class WidgetTimeChangeReceiver extends BroadcastReceiver {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onReceive(Context context, Intent intent) {
|
||||||
|
WidgetUtils.refreshAll(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
package com.streetwriters.notesnook;
|
||||||
|
|
||||||
|
import android.app.ActivityOptions;
|
||||||
|
import android.appwidget.AppWidgetManager;
|
||||||
|
import android.content.ComponentName;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.content.SharedPreferences;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.text.format.DateUtils;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.widget.RemoteViews;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.reflect.TypeToken;
|
||||||
|
import com.streetwriters.notesnook.datatypes.Note;
|
||||||
|
import com.streetwriters.notesnook.datatypes.Reminder;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Calendar;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared helpers for the home screen widgets.
|
||||||
|
*/
|
||||||
|
public class WidgetUtils {
|
||||||
|
|
||||||
|
static final String PREFERENCES = "appPreview";
|
||||||
|
static final String REMINDERS_KEY = "remindersList";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every row is serialized into the widget update itself, which has to fit inside a binder
|
||||||
|
* transaction, so the list cannot grow without bound. Far more than fits on screen anyway.
|
||||||
|
*/
|
||||||
|
private static final int MAX_REMINDERS = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redraws every widget that currently exists, and drops stored notes for widgets that no
|
||||||
|
* longer do.
|
||||||
|
*
|
||||||
|
* Everything else keys off what we have stored, which is fine while the app is running but
|
||||||
|
* leaves widgets showing content that no longer exists once the store is emptied underneath
|
||||||
|
* them (clearing app data) or a widget is removed while the app is stopped (onDeleted never
|
||||||
|
* arrives). Starting from the widgets the system knows about, rather than from our own data,
|
||||||
|
* is what makes this self-correcting.
|
||||||
|
*
|
||||||
|
* NoteWidget is left alone deliberately: it is a static button with no stored state, and its
|
||||||
|
* layout depends on the size it was last given.
|
||||||
|
*/
|
||||||
|
static void refreshAll(Context context) {
|
||||||
|
AppWidgetManager manager = AppWidgetManager.getInstance(context);
|
||||||
|
|
||||||
|
int[] noteWidgetIds = manager.getAppWidgetIds(
|
||||||
|
new ComponentName(context, NotePreviewWidget.class));
|
||||||
|
removeOrphanedNotes(context, noteWidgetIds);
|
||||||
|
for (int appWidgetId : noteWidgetIds) {
|
||||||
|
NotePreviewWidget.updateAppWidget(context, manager, appWidgetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int appWidgetId : manager.getAppWidgetIds(
|
||||||
|
new ComponentName(context, ReminderWidgetProvider.class))) {
|
||||||
|
RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget_reminders);
|
||||||
|
ReminderWidgetProvider.updateAppWidget(context, manager, appWidgetId, views);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops stored notes whose widget is gone, so the preferences file cannot grow forever.
|
||||||
|
*/
|
||||||
|
private static void removeOrphanedNotes(Context context, int[] liveWidgetIds) {
|
||||||
|
Set<String> live = new HashSet<>();
|
||||||
|
for (int appWidgetId : liveWidgetIds) live.add(String.valueOf(appWidgetId));
|
||||||
|
|
||||||
|
SharedPreferences preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE);
|
||||||
|
SharedPreferences.Editor edit = preferences.edit();
|
||||||
|
boolean changed = false;
|
||||||
|
|
||||||
|
for (String key : preferences.getAll().keySet()) {
|
||||||
|
// Leave anything that is not a widget id alone, the reminders list included.
|
||||||
|
if (parseWidgetId(key) == null || live.contains(key)) continue;
|
||||||
|
edit.remove(key);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (changed) edit.apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The note each note widget is showing, keyed by widget id.
|
||||||
|
*
|
||||||
|
* The preferences file mixes two things: one note per widget id, and the reminders list under
|
||||||
|
* its own key. Only numeric keys are widget notes, so anything else is skipped rather than
|
||||||
|
* being treated as a note.
|
||||||
|
*/
|
||||||
|
static Map<Integer, Note> getWidgetNotes(Context context) {
|
||||||
|
SharedPreferences preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE);
|
||||||
|
Map<Integer, Note> notes = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
for (Map.Entry<String, ?> entry : preferences.getAll().entrySet()) {
|
||||||
|
Integer widgetId = parseWidgetId(entry.getKey());
|
||||||
|
if (widgetId == null) continue;
|
||||||
|
if (!(entry.getValue() instanceof String)) continue;
|
||||||
|
|
||||||
|
Note note = parseNote((String) entry.getValue());
|
||||||
|
if (note == null || note.getId() == null) continue;
|
||||||
|
notes.put(widgetId, note);
|
||||||
|
}
|
||||||
|
return notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The widget id a preferences key refers to, or null if the key is not a widget id at all.
|
||||||
|
*/
|
||||||
|
private static Integer parseWidgetId(String key) {
|
||||||
|
try {
|
||||||
|
return Integer.valueOf(key);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Note parseNote(String data) {
|
||||||
|
if (data == null || data.isEmpty()) return null;
|
||||||
|
try {
|
||||||
|
return new Gson().fromJson(data, Note.class);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e("NotePreviewWidget", "Could not read a stored note", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reminders the app last wrote out, minus any that have now dropped out of view. Reading
|
||||||
|
* and filtering happens here so the provider can push the rows straight into the widget.
|
||||||
|
*/
|
||||||
|
static List<Reminder> getWidgetReminders(Context context) {
|
||||||
|
SharedPreferences preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE);
|
||||||
|
List<Reminder> stored = null;
|
||||||
|
try {
|
||||||
|
stored = new Gson().fromJson(preferences.getString(REMINDERS_KEY, "[]"),
|
||||||
|
new TypeToken<List<Reminder>>() {}.getType());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e("Reminders", "Could not read the stored reminders list", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Reminder> active = new ArrayList<>();
|
||||||
|
if (stored == null) return active;
|
||||||
|
|
||||||
|
for (Reminder reminder : stored) {
|
||||||
|
if (!isVisibleInWidget(reminder)) continue;
|
||||||
|
if (active.size() >= MAX_REMINDERS) {
|
||||||
|
Log.w("Reminders", "Widget list truncated to " + MAX_REMINDERS + " reminders");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
active.add(reminder);
|
||||||
|
}
|
||||||
|
return active;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a single row of the reminders list.
|
||||||
|
*/
|
||||||
|
static RemoteViews createReminderItem(Context context, Reminder reminder) {
|
||||||
|
boolean useMiniLayout = reminder.getDescription() == null || reminder.getDescription().isEmpty();
|
||||||
|
|
||||||
|
RemoteViews views = new RemoteViews(context.getPackageName(),
|
||||||
|
useMiniLayout ? R.layout.widget_reminder_layout_small : R.layout.widget_reminder_layout);
|
||||||
|
|
||||||
|
views.setTextViewText(R.id.reminder_title, reminder.getTitle());
|
||||||
|
if (!useMiniLayout) {
|
||||||
|
views.setTextViewText(R.id.reminder_description, reminder.getDescription());
|
||||||
|
}
|
||||||
|
views.setTextViewText(R.id.reminder_time, formatReminderTime(context, reminder));
|
||||||
|
|
||||||
|
Intent fillInIntent = new Intent();
|
||||||
|
fillInIntent.setData(Uri.parse("https://app.notesnook.com/open_reminder?id=" + reminder.getId()));
|
||||||
|
fillInIntent.putExtra(RCTNNativeModule.IntentType, "OpenReminder");
|
||||||
|
fillInIntent.putExtra(ReminderWidgetProvider.OpenReminderId, reminder.getId());
|
||||||
|
views.setOnClickFillInIntent(R.id.reminder_item_btn, fillInIntent);
|
||||||
|
return views;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options attached to the PendingIntents our widgets hand to the launcher, opting the creator
|
||||||
|
* (us) in to background activity starts so a tap on the widget can bring up an activity.
|
||||||
|
*
|
||||||
|
* MODE_BACKGROUND_ACTIVITY_START_ALLOWED is deprecated since API 36 and Android 17 extends the
|
||||||
|
* background activity launch restrictions to IntentSender, so on API 36+ we use the narrower
|
||||||
|
* MODE_BACKGROUND_ACTIVITY_START_ALLOW_IF_VISIBLE instead. That is enough for widgets: the
|
||||||
|
* sender is the launcher, which is visible whenever the user taps the widget.
|
||||||
|
*/
|
||||||
|
static Bundle getActivityOptionsBundle() {
|
||||||
|
ActivityOptions activityOptions;
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
|
||||||
|
activityOptions = ActivityOptions.makeBasic();
|
||||||
|
activityOptions.setPendingIntentCreatorBackgroundActivityStartMode(
|
||||||
|
ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOW_IF_VISIBLE);
|
||||||
|
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
activityOptions = ActivityOptions.makeBasic();
|
||||||
|
activityOptions.setPendingIntentCreatorBackgroundActivityStartMode(
|
||||||
|
ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return activityOptions.toBundle();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long a reminder keeps its place in the list after going off, so the user can see that it
|
||||||
|
* happened rather than watching it vanish. Must match RECENTLY_PASSED_WINDOW in
|
||||||
|
* services/notifications.ts, which decides what gets written out in the first place.
|
||||||
|
*/
|
||||||
|
private static final long RECENTLY_PASSED_WINDOW_MS = TimeUnit.HOURS.toMillis(3);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a reminder should still be drawn.
|
||||||
|
*
|
||||||
|
* We re-check here rather than trusting the stored list because that list is only rewritten
|
||||||
|
* while the app runs. This is what actually retires a reminder once its grace period is up:
|
||||||
|
* every redraw re-evaluates it against the current time.
|
||||||
|
*/
|
||||||
|
static boolean isVisibleInWidget(Reminder reminder) {
|
||||||
|
if (reminder == null) return false;
|
||||||
|
if (reminder.isDisabled()) return false;
|
||||||
|
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
if (reminder.getSnoozeUntil() > now) return true;
|
||||||
|
if (!"once".equals(reminder.getMode())) return true;
|
||||||
|
|
||||||
|
long triggerDate = reminder.getTriggerDate() > 0 ? reminder.getTriggerDate() : reminder.getDate();
|
||||||
|
return triggerDate > now - RECENTLY_PASSED_WINDOW_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the label shown under a reminder. The app sends us the absolute trigger time plus the
|
||||||
|
* parts that never change ("5:00 PM", "12-05-2026, 5:00 PM"); everything that depends on the
|
||||||
|
* current time is decided here so it stays right as the widget redraws.
|
||||||
|
*
|
||||||
|
* Falls back to the pre-formatted string for lists written by an older version of the app.
|
||||||
|
*/
|
||||||
|
static String formatReminderTime(Context context, Reminder reminder) {
|
||||||
|
long triggerDate = reminder.getTriggerDate();
|
||||||
|
String timeOfDay = reminder.getFormattedTimeOfDay();
|
||||||
|
if (triggerDate <= 0 || timeOfDay == null || timeOfDay.isEmpty()) {
|
||||||
|
return reminder.getFormattedTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("permanent".equals(reminder.getMode())) {
|
||||||
|
return context.getString(R.string.reminder_ongoing);
|
||||||
|
}
|
||||||
|
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
if (reminder.getSnoozeUntil() > now) {
|
||||||
|
return context.getString(R.string.reminder_snoozed_until, timeOfDay);
|
||||||
|
}
|
||||||
|
|
||||||
|
String text;
|
||||||
|
long dayOffset = daysFromToday(triggerDate, now);
|
||||||
|
if (dayOffset == 0) {
|
||||||
|
text = context.getString(R.string.reminder_today, timeOfDay);
|
||||||
|
} else if (dayOffset == 1) {
|
||||||
|
text = context.getString(R.string.reminder_tomorrow, timeOfDay);
|
||||||
|
} else if (dayOffset == -1) {
|
||||||
|
text = context.getString(R.string.reminder_yesterday, timeOfDay);
|
||||||
|
} else {
|
||||||
|
text = reminder.getFormattedDateTime();
|
||||||
|
if (text == null || text.isEmpty()) return reminder.getFormattedTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.getString(
|
||||||
|
triggerDate <= now ? R.string.reminder_last : R.string.reminder_upcoming, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calendar days between two instants. Compares midnights rather than subtracting the raw
|
||||||
|
* difference so that "tomorrow" is still tomorrow across a DST change or just before midnight.
|
||||||
|
*/
|
||||||
|
private static long daysFromToday(long time, long now) {
|
||||||
|
long target = startOfDay(time);
|
||||||
|
long today = startOfDay(now);
|
||||||
|
return Math.round((target - today) / (double) DateUtils.DAY_IN_MILLIS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long startOfDay(long time) {
|
||||||
|
Calendar calendar = Calendar.getInstance();
|
||||||
|
calendar.setTimeInMillis(time);
|
||||||
|
calendar.set(Calendar.HOUR_OF_DAY, 0);
|
||||||
|
calendar.set(Calendar.MINUTE, 0);
|
||||||
|
calendar.set(Calendar.SECOND, 0);
|
||||||
|
calendar.set(Calendar.MILLISECOND, 0);
|
||||||
|
return calendar.getTimeInMillis();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,13 +2,14 @@ package com.streetwriters.notesnook.datatypes;
|
|||||||
|
|
||||||
import androidx.annotation.Keep;
|
import androidx.annotation.Keep;
|
||||||
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
|
|
||||||
@Keep
|
@Keep
|
||||||
public class Reminder extends BaseItem {
|
public class Reminder extends BaseItem {
|
||||||
private String title;
|
private String title;
|
||||||
private String description;
|
private String description;
|
||||||
private String formattedTime;
|
private String formattedTime;
|
||||||
|
private String formattedTimeOfDay; // e.g. "5:00 PM"
|
||||||
|
private String formattedDateTime; // e.g. "12-05-2026, 5:00 PM"
|
||||||
|
private long triggerDate; // absolute time this reminder next fires
|
||||||
private String priority; // "silent", "vibrate", "urgent"
|
private String priority; // "silent", "vibrate", "urgent"
|
||||||
private long date;
|
private long date;
|
||||||
private String mode; // "repeat", "once", "permanent"
|
private String mode; // "repeat", "once", "permanent"
|
||||||
@@ -107,32 +108,27 @@ public class Reminder extends BaseItem {
|
|||||||
this.formattedTime = formattedTime;
|
this.formattedTime = formattedTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String formatTime(long timeInMillis) {
|
public String getFormattedTimeOfDay() {
|
||||||
long currentTime = System.currentTimeMillis();
|
return formattedTimeOfDay;
|
||||||
long diff = timeInMillis - currentTime;
|
}
|
||||||
|
|
||||||
if (diff < TimeUnit.MINUTES.toMillis(1)) {
|
public void setFormattedTimeOfDay(String formattedTimeOfDay) {
|
||||||
return "in " + (diff / 1000) + " seconds";
|
this.formattedTimeOfDay = formattedTimeOfDay;
|
||||||
} else if (diff < TimeUnit.HOURS.toMillis(1)) {
|
}
|
||||||
long minutes = TimeUnit.MILLISECONDS.toMinutes(diff);
|
|
||||||
return "in " + minutes + " minute" + (minutes > 1 ? "s" : "");
|
public String getFormattedDateTime() {
|
||||||
} else if (diff < TimeUnit.DAYS.toMillis(1)) {
|
return formattedDateTime;
|
||||||
long hours = TimeUnit.MILLISECONDS.toHours(diff);
|
}
|
||||||
return "in " + hours + " hour" + (hours > 1 ? "s" : "");
|
|
||||||
} else if (diff < TimeUnit.DAYS.toMillis(2)) {
|
public void setFormattedDateTime(String formattedDateTime) {
|
||||||
return "tomorrow";
|
this.formattedDateTime = formattedDateTime;
|
||||||
} else if (diff < TimeUnit.DAYS.toMillis(7)) {
|
}
|
||||||
long days = TimeUnit.MILLISECONDS.toDays(diff);
|
|
||||||
return "in " + days + " day" + (days > 1 ? "s" : "");
|
public long getTriggerDate() {
|
||||||
} else if (diff < TimeUnit.DAYS.toMillis(30)) {
|
return triggerDate;
|
||||||
long weeks = TimeUnit.MILLISECONDS.toDays(diff) / 7;
|
}
|
||||||
return "in " + weeks + " week" + (weeks > 1 ? "s" : "");
|
|
||||||
} else if (diff < TimeUnit.DAYS.toMillis(365)) {
|
public void setTriggerDate(long triggerDate) {
|
||||||
long months = TimeUnit.MILLISECONDS.toDays(diff) / 30;
|
this.triggerDate = triggerDate;
|
||||||
return "in " + months + " month" + (months > 1 ? "s" : "");
|
|
||||||
} else {
|
|
||||||
long years = TimeUnit.MILLISECONDS.toDays(diff) / 365;
|
|
||||||
return "in " + years + " year" + (years > 1 ? "s" : "");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960"
|
||||||
|
android:tint="#008837"
|
||||||
|
android:alpha="1">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M240,880Q207,880 183.5,856.5Q160,833 160,800L160,160Q160,127 183.5,103.5Q207,80 240,80L720,80Q753,80 776.5,103.5Q800,127 800,160L800,800Q800,833 776.5,856.5Q753,880 720,880L240,880ZM240,800L720,800Q720,800 720,800Q720,800 720,800L720,160Q720,160 720,160Q720,160 720,160L640,160L640,440L540,380L440,440L440,160L240,160Q240,160 240,160Q240,160 240,160L240,800Q240,800 240,800Q240,800 240,800ZM240,800Q240,800 240,800Q240,800 240,800L240,160Q240,160 240,160Q240,160 240,160L240,160Q240,160 240,160Q240,160 240,160L240,800Q240,800 240,800Q240,800 240,800ZM440,440L540,380L640,440L640,440L540,380L440,440Z"/>
|
||||||
|
</vector>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 284 B |
Binary file not shown.
|
After Width: | Height: | Size: 211 B |
Binary file not shown.
|
After Width: | Height: | Size: 440 B |
Binary file not shown.
|
After Width: | Height: | Size: 807 B |
@@ -2,6 +2,6 @@
|
|||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<solid android:color="@color/background"/>
|
<solid android:color="@color/background"/>
|
||||||
<stroke android:width="0dp" android:color="#B1BCBE" />
|
<stroke android:width="0dp" android:color="#B1BCBE" />
|
||||||
<corners android:radius="10dp"/>
|
<corners android:radius="@dimen/widget_background_radius"/>
|
||||||
<padding android:left="0dp" android:top="0dp" android:right="0dp" android:bottom="0dp" />
|
<padding android:left="0dp" android:top="0dp" android:right="0dp" android:bottom="0dp" />
|
||||||
</shape>
|
</shape>
|
||||||
@@ -2,8 +2,7 @@
|
|||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:background="@android:color/transparent"
|
android:background="@android:color/transparent">
|
||||||
android:theme="@style/ThemeOverlay.Notesnook.AppWidgetContainer">
|
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:background="@android:color/transparent"
|
android:background="@android:color/transparent">
|
||||||
android:theme="@style/ThemeOverlay.Notesnook.AppWidgetContainer">
|
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
@@ -25,7 +24,7 @@
|
|||||||
android:textColor="@color/text"
|
android:textColor="@color/text"
|
||||||
android:textSize="16sp"
|
android:textSize="16sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:text="Widget unconfigured" />
|
android:text="@string/widget_note_unconfigured_title" />
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_body"
|
android:id="@+id/widget_body"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
@@ -34,7 +33,7 @@
|
|||||||
android:layout_marginLeft="8dp"
|
android:layout_marginLeft="8dp"
|
||||||
android:textColor="@color/text"
|
android:textColor="@color/text"
|
||||||
android:textSize="14sp"
|
android:textSize="14sp"
|
||||||
android:text="Configure this widget to show a note here." />
|
android:text="@string/widget_note_unconfigured_body" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
</RelativeLayout>
|
</RelativeLayout>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
|
Shown in the widget picker only. Mirrors note_widget.xml, but with sample content, since the
|
||||||
|
real layout has nothing to show until the widget has been configured.
|
||||||
|
-->
|
||||||
|
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@android:color/transparent">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_centerHorizontal="true"
|
||||||
|
android:layout_centerVertical="true"
|
||||||
|
android:background="@drawable/layout_bg"
|
||||||
|
android:elevation="5dp"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingHorizontal="10dp"
|
||||||
|
android:paddingVertical="10dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center_vertical"
|
||||||
|
android:layout_marginLeft="8dp"
|
||||||
|
android:text="@string/widget_preview_note_title"
|
||||||
|
android:textColor="@color/text"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center_vertical"
|
||||||
|
android:layout_marginLeft="8dp"
|
||||||
|
android:text="@string/widget_preview_note_body"
|
||||||
|
android:textColor="@color/text"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</RelativeLayout>
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:padding="16dp"
|
|
||||||
android:text="No upcoming reminders"
|
|
||||||
android:textSize="16sp"
|
|
||||||
android:textColor="@android:color/darker_gray"
|
|
||||||
android:gravity="center" />
|
|
||||||
@@ -12,22 +12,23 @@
|
|||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
android:paddingHorizontal="12dp"
|
android:paddingHorizontal="12dp"
|
||||||
android:layout_gravity="center"
|
android:layout_gravity="center"
|
||||||
android:paddingTop="12dp"
|
android:paddingTop="8dp"
|
||||||
|
android:paddingBottom="8dp"
|
||||||
>
|
>
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_alignParentLeft="true"
|
android:layout_alignParentLeft="true"
|
||||||
android:layout_marginTop="2dp"
|
android:layout_marginTop="2dp"
|
||||||
android:textSize="16sp"
|
android:textSize="15sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:textColor="@color/text"
|
android:textColor="@color/text"
|
||||||
android:text="Upcoming Reminders"/>
|
android:text="Reminders"/>
|
||||||
|
|
||||||
<ImageButton
|
<ImageButton
|
||||||
android:layout_width="35dp"
|
android:layout_width="25dp"
|
||||||
android:id="@+id/add_button"
|
android:id="@+id/add_button"
|
||||||
android:layout_height="35dp"
|
android:layout_height="25dp"
|
||||||
android:layout_alignParentRight="true"
|
android:layout_alignParentRight="true"
|
||||||
android:background="@drawable/ic_newnote" />
|
android:background="@drawable/ic_newnote" />
|
||||||
</RelativeLayout>
|
</RelativeLayout>
|
||||||
@@ -57,7 +58,8 @@
|
|||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:textAlignment="center"
|
android:textAlignment="center"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:text="Tap on + to add reminder"/>
|
android:textColor="@color/text"
|
||||||
|
android:text="@string/widget_reminders_empty"/>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
|
Shown in the widget picker only. Mirrors widget_reminders.xml, but with the list replaced by
|
||||||
|
sample rows, since an adapter-backed list renders empty in the picker.
|
||||||
|
-->
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@drawable/layout_bg"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<RelativeLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:paddingHorizontal="12dp"
|
||||||
|
android:paddingTop="12dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_alignParentLeft="true"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:text="Reminders"
|
||||||
|
android:textColor="@color/text"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:layout_width="35dp"
|
||||||
|
android:layout_height="35dp"
|
||||||
|
android:layout_alignParentRight="true"
|
||||||
|
android:background="@drawable/ic_newnote"
|
||||||
|
android:contentDescription="@string/add_widget" />
|
||||||
|
</RelativeLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:background="@color/border" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingHorizontal="12dp"
|
||||||
|
android:paddingBottom="12dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/widget_preview_reminder_title"
|
||||||
|
android:textColor="@color/text"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="10dp"
|
||||||
|
android:text="@string/widget_preview_reminder_time"
|
||||||
|
android:textColor="@color/text"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/widget_preview_reminder_title_alt"
|
||||||
|
android:textColor="@color/text"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/widget_preview_reminder_time_alt"
|
||||||
|
android:textColor="@color/text"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user