Compare commits

..

16 Commits

Author SHA1 Message Date
Ammar Ahmed
12a7b36562 ci: disable ts check 2026-04-03 20:29:44 +05:00
Ammar Ahmed
3ea4d0326d ci: test workflow 2026-04-03 20:29:44 +05:00
Ammar Ahmed
d781ebb52a mobile: test workflow 2026-04-03 20:29:44 +05:00
Abdullah Atta
e3213c6d21 core: fix getInboxKeys 2026-04-03 20:29:44 +05:00
Abdullah Atta
fc9c029752 intl: update strings 2026-04-03 20:29:44 +05:00
Ammar Ahmed
8c8af1b3f7 mobile: add inbox api settings 2026-04-03 20:29:44 +05:00
01zulfi
fd25621aa7 web: use pgp keys for inbox
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-04-03 20:29:44 +05:00
Ammar Ahmed
37361c575a mobile: fix editor stays open when opening notebook/tag/color link. 2026-04-03 20:29:44 +05:00
Ammar Ahmed
2596a672d1 mobile: add support for internal links 2026-04-03 20:29:44 +05:00
Abdullah Atta
6c45cfb681 web: fix events missed from desktop side on startup 2026-04-03 20:29:44 +05:00
Abdullah Atta
072cb9b4d2 desktop: wait for bridge to be ready before sending events 2026-04-03 20:29:44 +05:00
Abdullah Atta
c9467bad07 web: support copying notebook/tag/color internal link 2026-04-03 20:29:44 +05:00
Abdullah Atta
8d2329898a desktop: add handler for nn:// urls 2026-04-03 20:29:44 +05:00
sahil
c581e706d2 mobile: change icon
Signed-off-by: Sahil Aslam <sahil.aslam011@gmail.com>
2026-04-03 20:29:44 +05:00
sahil
76ea082536 mobile: fix icon and function name
Signed-off-by: Sahil Aslam <sahil.aslam011@gmail.com>
2026-04-03 20:29:44 +05:00
sahil
cd2e6d152b mobile: add close button in editor all tab bottom sheet #6059
Signed-off-by: Sahil Aslam <sahil.aslam011@gmail.com>
2026-04-03 20:29:44 +05:00
550 changed files with 17642 additions and 26637 deletions

View File

@@ -10,7 +10,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Enable KVM
run: |

View File

@@ -1,104 +0,0 @@
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

View File

@@ -0,0 +1,166 @@
name: Notesnook Android Preview
on:
pull_request_target:
types: [opened, reopened, synchronize]
branches: [master, beta]
paths:
- "apps/mobile/**"
- "packages/**"
- ".github/workflows/android.preview.firebase.yml"
jobs:
authorize:
environment: ${{ github.event_name == 'pull_request_target' &&
github.event.pull_request.head.repo.full_name != github.repository &&
'external' || 'internal' }}
runs-on: ubuntu-latest
steps:
- run: echo true
build:
needs: authorize
runs-on: ubuntu-22.04
env:
STAGING_BUILD: true
steps:
- name: Checkout
uses: actions/checkout@v2
with:
ref: ${{ github.event.pull_request.head.sha || github.ref }}
- 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:
# 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
run: |
npm ci --ignore-scripts --prefer-offline --no-audit
npm run bootstrap -- --scope=mobile
- name: Generate dependency hash
id: dep-hash
run: |
DEPS_HASH=$(node -e "
const pkg = require('./apps/mobile/package.json');
const crypto = require('crypto');
const depsStr = JSON.stringify(pkg.dependencies);
const hash = crypto.createHash('md5').update(depsStr).digest('hex');
console.log(hash);
")
echo "hash=$DEPS_HASH" >> $GITHUB_OUTPUT
- name: Cache Android build files
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
apps/mobile/android/.gradle
apps/mobile/android/build
apps/mobile/android/app/build
apps/mobile/android/app/.cxx
node_modules/**/android/build
node_modules/**/android/.cxx
node_modules/**/android/generated
node_modules/**/.gradle
node_modules/**/.cxx
key: android-build-${{ runner.os }}-deps-${{ steps.dep-hash.outputs.hash }}
restore-keys: |
android-build-${{ runner.os }}-
- 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: Get app version
id: package-version
uses: saionaro/extract-package-version@master
with:
path: apps/mobile
- name: Publish to firebase CLI
id: firebase-output
uses: wzieba/Firebase-Distribution-Github-Action@v1
with:
appId: ${{secrets.FIREBASE_APP_ID}}
serviceCredentialsFileContent: ${{ secrets.QA_SERVICE_ACCOUNT }}
groups: testers
file: apps/mobile/android/app/build/outputs/apk/release/app-arm64-v8a-release.apk
releaseNotes: Preview for https://github.com/streetwriters/notesnook/pull/${{github.event.number}}
- name: Post or update PR comment
uses: actions/github-script@v6
env:
preview_url: ${{ steps.firebase-output.outputs.TESTING_URI }}
with:
script: |
const marker = '<!-- android-preview-comment -->';
const prNumber = context.issue.number;
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.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,
});
}
- name: Upload sourcemaps
uses: actions/upload-artifact@v4
with:
name: sourcemaps
path: |
apps/mobile/android/app/build/generated/sourcemaps/**/*.map

View File

@@ -0,0 +1,155 @@
name: Notesnook Android Preview
on:
pull_request:
types: [opened, reopened, synchronize]
branches: [master, beta]
paths:
- "apps/mobile/**"
- "packages/**"
# re-run workflow if workflow file changes
- ".github/workflows/android.preview.firebase.yml"
jobs:
build:
runs-on: ubuntu-22.04
env:
STAGING_BUILD: true
steps:
- name: Checkout
uses: actions/checkout@v2
- 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:
# 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
run: |
npm ci --ignore-scripts --prefer-offline --no-audit
npm run bootstrap -- --scope=mobile
- name: Generate dependency hash
id: dep-hash
run: |
DEPS_HASH=$(node -e "
const pkg = require('./apps/mobile/package.json');
const crypto = require('crypto');
const depsStr = JSON.stringify(pkg.dependencies);
const hash = crypto.createHash('md5').update(depsStr).digest('hex');
console.log(hash);
")
echo "hash=$DEPS_HASH" >> $GITHUB_OUTPUT
- name: Cache Android build files
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
apps/mobile/android/.gradle
apps/mobile/android/build
apps/mobile/android/app/build
apps/mobile/android/app/.cxx
node_modules/**/android/build
node_modules/**/android/.cxx
node_modules/**/android/generated
node_modules/**/.gradle
node_modules/**/.cxx
key: android-build-${{ runner.os }}-deps-${{ steps.dep-hash.outputs.hash }}
restore-keys: |
android-build-${{ runner.os }}-
- 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: Get app version
# id: package-version
# uses: saionaro/extract-package-version@master
# with:
# path: apps/mobile
# - name: Publish to firebase CLI
# id: firebase-output
# uses: wzieba/Firebase-Distribution-Github-Action@v1
# with:
# appId: ${{secrets.FIREBASE_APP_ID}}
# serviceCredentialsFileContent: ${{ secrets.QA_SERVICE_ACCOUNT }}
# groups: testers
# file: apps/mobile/android/app/build/outputs/apk/release/app-arm64-v8a-release.apk
# releaseNotes: Preview for https://github.com/streetwriters/notesnook/pull/${{github.event.number}}
# - name: Post or update PR comment
# uses: actions/github-script@v6
# env:
# preview_url: ${{ steps.firebase-output.outputs.TESTING_URI }}
# with:
# script: |
# const marker = '<!-- android-preview-comment -->';
# const prNumber = context.issue.number;
# 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.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,
# });
# }
# - name: Upload sourcemaps
# uses: actions/upload-artifact@v4
# with:
# name: sourcemaps
# path: |
# apps/mobile/android/app/build/generated/sourcemaps/**/*.map

View File

@@ -1,72 +0,0 @@
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,
});
}

View File

@@ -13,7 +13,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v2
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache

View File

@@ -13,7 +13,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v2
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache

View File

@@ -35,7 +35,7 @@ jobs:
needs: authorize
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.ref }}

View File

@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
@@ -56,7 +56,7 @@ jobs:
macos-artifact-url: ${{ steps.artifact-upload-step.outputs.artifact-url }}
steps:
- name: Check out Git repository
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
@@ -101,12 +101,12 @@ jobs:
- name: Build dmg
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
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
@@ -127,7 +127,7 @@ jobs:
linux-x64-artifact-url: ${{ steps.artifact-upload-step.outputs.artifact-url }}
steps:
- name: Check out Git repository
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
@@ -146,13 +146,14 @@ jobs:
- 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
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: yarn electron-builder --config=electron-builder.config.js --linux AppImage:x64 -p never
working-directory: ./apps/desktop
@@ -163,59 +164,15 @@ jobs:
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
runs-on: windows-latest
outputs:
windows-artifact-url: ${{ steps.artifact-upload-step.outputs.artifact-url }}
steps:
- name: Check out Git repository
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
@@ -258,8 +215,7 @@ jobs:
post-pr-comment:
name: Post PR comment with preview URLs
needs:
[build, build-macos, build-linux-x64, build-linux-arm64, build-windows]
needs: [build, build-macos, build-linux-x64, build-windows]
runs-on: ubuntu-latest
steps:
- name: Post or update PR comment
@@ -267,7 +223,6 @@ jobs:
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: |
@@ -276,7 +231,6 @@ jobs:
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`;

View File

@@ -48,7 +48,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
@@ -107,7 +107,7 @@ jobs:
target_commitish: ${{ github.ref }}
- name: Generate flatpak sources
if: inputs.build-linux
if: inputs.publish-github && inputs.build-linux
run: |
sudo apt-get update
sudo apt-get install -y --quiet flatpak flatpak-builder pipx
@@ -127,13 +127,6 @@ jobs:
files: ./generated-sources.json
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:
name: Build for macOS
needs: build
@@ -142,7 +135,7 @@ jobs:
steps:
- name: Check out Git repository
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
@@ -189,9 +182,7 @@ jobs:
- name: Install sqlite-better-trigram for all arch
run: |
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 sqlite3-fts5-html
working-directory: ./apps/desktop
- name: Install provisioning profile
@@ -246,7 +237,7 @@ jobs:
steps:
- name: Check out Git repository
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
@@ -273,7 +264,6 @@ jobs:
- 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
@@ -314,7 +304,7 @@ jobs:
steps:
- name: Check out Git repository
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
@@ -341,7 +331,6 @@ jobs:
- 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
@@ -374,11 +363,11 @@ jobs:
name: Build for Windows
needs: build
if: inputs.build-windows
runs-on: windows-2022
runs-on: windows-latest
steps:
- name: Check out Git repository
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup .NET Core SDK
uses: actions/setup-dotnet@v2

View File

@@ -18,13 +18,12 @@ on:
- ".github/workflows/desktop.tests.yml"
jobs:
test-macos-x64:
name: Test macOS x64
runs-on: macos-15-intel
build:
name: Build
runs-on: ubuntu-22.04
steps:
- name: Check out Git repository
uses: actions/checkout@v5
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
@@ -34,10 +33,44 @@ jobs:
npm ci --ignore-scripts --prefer-offline --no-audit
npm run bootstrap -- --scope=web
- name: Install sqlite-better-trigram for all arch
- name: Generate desktop build
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-15-intel
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: |
npm i --cpu x64 sqlite-better-trigram
npm i --cpu x64 sqlite3-fts5-html
npm ci --ignore-scripts --prefer-offline --no-audit
npm run bootstrap -- --scope=desktop
- 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
- name: Run tests x64
@@ -54,24 +87,33 @@ jobs:
test-macos:
name: Test macOS
needs: build
runs-on: macos-latest
steps:
- name: Check out Git repository
uses: actions/checkout@v5
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: |
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
- name: Build Electron wrapper
run: npm run tx @notesnook/desktop:release
- name: Build app
run: |
npm i --cpu arm64 sqlite-better-trigram
npm i --cpu arm64 sqlite3-fts5-html
yarn electron-builder --config=electron-builder.config.js --mac --dir --arm64
working-directory: ./apps/desktop
- name: Run tests arm64
@@ -88,24 +130,39 @@ jobs:
test-linux:
name: Test for Linux
needs: build
runs-on: ubuntu-22.04
steps:
- name: Check out Git repository
uses: actions/checkout@v5
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: |
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
npm i --cpu x64 sqlite3-fts5-html
working-directory: ./apps/desktop
- 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
- name: Run tests
@@ -122,26 +179,44 @@ jobs:
test-windows:
name: Test for Windows
runs-on: windows-2022
needs: build
runs-on: windows-latest
steps:
- name: Check out Git repository
uses: actions/checkout@v5
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: |
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
npm i --cpu arm64 sqlite3-fts5-html
npm i --cpu x64 sqlite3-fts5-html
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
run: npm run test
working-directory: ./apps/desktop

View File

@@ -28,7 +28,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache

View File

@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- uses: actions-rs/toolchain@v1
with:
toolchain: stable

View File

@@ -1,152 +0,0 @@
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

View File

@@ -0,0 +1,168 @@
name: Notesnook iOS Preview
on:
pull_request_target:
types: [opened, reopened, synchronize]
branches: [master, beta]
paths:
- "apps/mobile/**"
- "packages/**"
- ".github/workflows/ios.preview.firebase.yml"
jobs:
authorize:
environment: ${{ github.event_name == 'pull_request_target' &&
github.event.pull_request.head.repo.full_name != github.repository &&
'external' || 'internal' }}
runs-on: ubuntu-latest
steps:
- run: echo true
build:
needs: authorize
runs-on: macos-26
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.sha || github.ref }}
- 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: 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@v3
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}}
- name: Build iOS App
uses: ammarahm-ed/ios-build-action@master
with:
bundle-identifier: org.streetwriters.notesnook
scheme: Notesnook
configuration: "Release"
export-options: apps/mobile/ios/ExportOptionsStaging.plist
export-method: "ad-hoc"
project-path: apps/mobile/ios/Notesnook.xcodeproj
workspace-path: apps/mobile/ios/Notesnook.xcworkspace
update-targets: |
Notesnook
Make Note
NotesWidgetExtension
disable-targets: Notesnook-tvOS,Notesnook-tvOSTests,NotesnookTests
code-signing-identity: Apple Distribution
team-id: ${{ secrets.APPLE_TEAM_ID }}
p12-base64: ${{ secrets.APPLE_CERTIFICATE_P12 }}
certificate-password: ${{ secrets.APPLE_CERTIFICATE_P12_PASSWORD }}
app-store-connect-api-key-issuer-id: ${{ secrets.API_KEY_ISSUER_ID }}
app-store-connect-api-key-id: ${{ secrets.APPSTORE_KEY_ID }}
app-store-connect-api-key-base64: ${{ secrets.APPSTORE_CONNECT_API_KEY_BASE64 }}
output-path: Notesnook.ipa
mobileprovision-base64: |
${{ secrets.APPLE_MOBILE_PROVISION_ADHOC_APP }}
${{ secrets.APPLE_MOBILE_PROVISION_ADHOC_SHARE }}
${{ secrets.APPLE_MOBILE_PROVISION_ADHOC_WIDGET }}
- name: Upload IPA artifact
uses: actions/upload-artifact@v4
with:
name: ios-preview-ipa
path: Notesnook.ipa
if-no-files-found: error
retention-days: 1
publish:
runs-on: ubuntu-latest
needs: build
timeout-minutes: 20
steps:
- name: Download IPA artifact
uses: actions/download-artifact@v4
with:
name: ios-preview-ipa
path: .
- name: Publish to firebase CLI
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: Notesnook.ipa
releaseNotes: Preview for https://github.com/streetwriters/notesnook/pull/${{github.event.number}}
- name: Post or update PR comment
uses: actions/github-script@v6
env:
preview_url: ${{ steps.firebase-output.outputs.TESTING_URI }}
with:
script: |
const marker = '<!-- ios-preview-comment -->';
const prNumber = context.issue.number;
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.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,
});
}

View File

@@ -1,222 +0,0 @@
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,
});
}

View File

@@ -9,7 +9,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v3
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
@@ -19,11 +19,6 @@ jobs:
with:
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
run: |
npm ci --ignore-scripts --prefer-offline --no-audit
@@ -116,7 +111,6 @@ jobs:
api-private-key: ${{ secrets.API_KEY }}
- name: Upload Notesnook.ipa to Github
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: Notesnook.zip

View File

@@ -22,7 +22,7 @@ jobs:
id-token: write
steps:
- name: Check out the repo
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache

View File

@@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache

View File

@@ -22,7 +22,7 @@ jobs:
id-token: write
steps:
- name: Check out the repo
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Collect package metadata
id: package_metadata

View File

@@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache

View File

@@ -19,7 +19,7 @@ jobs:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 0

View File

@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache

View File

@@ -37,7 +37,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.ref }}
@@ -66,7 +66,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.ref }}

2
.npmrc
View File

@@ -1,2 +0,0 @@
min-release-age=7 # days
# ignore-scripts=true

View File

@@ -17,91 +17,155 @@ 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, expect } from "@nn/test";
import { testCleanup, test } from "./test-override.js";
import { writeFile } from "fs/promises";
import { Page } from "playwright";
import { gt, lt } from "semver";
import { AppModel } from "../../web/__e2e__/models/app.model.js";
import { describe } from "vitest";
test.extend({ options: { version: "3.0.0" } })(
"update starts downloading if version is outdated",
async ({ page }) => {
await page.waitForSelector(".ProseMirror");
test("update starts downloading if version is outdated", async ({
ctx: { page },
expect,
onTestFinished
}) => {
onTestFinished(testCleanup);
await page
.locator(".theme-scope-statusBar")
.getByRole("button", { name: /updating/i })
.waitFor({ state: "attached" });
}
);
await page.waitForSelector("#authForm");
expect(
await page.getByRole("button", { name: "Create account" }).isVisible()
).toBe(true);
test.extend({
options: {
version: "3.0.0",
config: {
automaticUpdates: false
}
}
})(
"update is only shown if version is outdated and auto updates are disabled",
async ({ page }) => {
await page.waitForSelector(".ProseMirror");
await page
.getByRole("button", { name: "Skip & go directly to the app" })
.click();
const app = new AppModel(page);
const settings = await app.goToSettings();
await skipDialog(page);
await settings.checkForUpdates();
await settings.close();
await page
.locator(".theme-scope-statusBar")
.getByRole("button", { name: /available/i })
.waitFor({ state: "attached" });
}
);
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");
const app = new AppModel(page);
const settings = await app.goToSettings();
await settings.checkForUpdates();
await settings.close();
const updateButton = page
await page
.locator(".theme-scope-statusBar")
.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);
.getByRole("button", { name: /updating/i })
.waitFor({ state: "attached" });
});
test.extend({
options: {
version: "99.0.0-beta.0",
config: {
automaticUpdates: false,
releaseTrack: "beta"
}
}
})(
"update is not available if it latest stable version is older",
async ({ page }) => {
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
})
);
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: /available/i })
.waitFor({ state: "attached" });
});
describe("update to stable if it is newer", () => {
test.scoped({ options: { version: "3.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: "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");
const app = new AppModel(page);
const settings = await app.goToSettings();
const updateButton = page
.locator(".theme-scope-statusBar")
.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);
});
});
await settings.checkForUpdates();
await settings.close();
describe("update is not available if it latest stable version is older", () => {
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: "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(
await page
@@ -109,24 +173,45 @@ test.extend({
.getByRole("button", { name: /available/i })
.isHidden()
).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");
const app = new AppModel(page);
const settings = await app.goToSettings();
await settings.checkForUpdates();
await settings.close();
await page
.locator(".theme-scope-statusBar")
.getByRole("button", { name: /checking for updates/i })
.waitFor({ state: "hidden" });
const updateButton = page
.locator(".theme-scope-statusBar")
@@ -135,5 +220,23 @@ test.extend({
const content = await updateButton.textContent();
const version = content?.split(" ")?.[0] || "";
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
}
);
}

View File

@@ -1,63 +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 { 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;
// }
// }

View File

@@ -1,347 +0,0 @@
/* 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;
}

View File

@@ -1,152 +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/>.
*/
/* 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
})
)
);
}

View File

@@ -1,58 +0,0 @@
/* 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;
};

View File

@@ -1,47 +0,0 @@
/* 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" }]
});

View File

@@ -1,75 +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 } 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);
}
});

View File

@@ -1,52 +0,0 @@
/* 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() {}
}

View File

@@ -1,243 +0,0 @@
/* 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);
});
}
};

View File

@@ -19,7 +19,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { buildApp } from "./utils";
export default async function globalSetup() {
console.log("Building the app...");
export default async function setup() {
await buildApp();
}

View File

@@ -17,65 +17,24 @@ 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, expect } from "@nn/test";
import type { ElectronApplication } from "@playwright/test";
import { testCleanup, test } from "./test-override.js";
async function getMainWindowState(app: ElectronApplication) {
return await app.evaluate((window) => {
const { BrowserWindow } = window;
const mainWindow = BrowserWindow.getAllWindows()[0];
if (!mainWindow) throw new Error("Main window not found");
return {
isMinimized: mainWindow.isMinimized(),
isVisible: mainWindow.isVisible()
};
});
}
test("make sure app loads", async ({ page }) => {
await page.waitForSelector(".ProseMirror");
});
test("hidden launch minimizes when tray is disabled", async ({
launchElectronApp,
options
test("make sure app loads", async ({
ctx: { page },
expect,
onTestFinished
}) => {
const app = await launchElectronApp({
version: options.version,
args: ["--hidden"],
config: {
desktopSettings: {
minimizeToSystemTray: false,
closeToSystemTray: false
}
}
});
onTestFinished(testCleanup);
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();
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);
});

View File

@@ -0,0 +1,64 @@
/*
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 */
});
}

View File

@@ -1,11 +0,0 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"lib": ["ESNext"],
"moduleResolution": "Bundler",
"paths": {
"@nn/test": ["./electron-test/index.ts"]
}
}
}

View File

@@ -19,22 +19,21 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { execSync } from "child_process";
import { cp } from "fs/promises";
import { fileURLToPath } from "node:url";
import path, { join, resolve } from "path";
import {
_electron as electron,
ElectronApplication,
Page
} from "@playwright/test";
import { _electron as electron } from "playwright";
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 productName = `NotesnookTestHarness`;
const root = path.resolve(__dirname, "..", "..");
const SOURCE_DIR = resolve(root, "output", productName);
const SOURCE_DIR = resolve("output", productName);
export interface AppContext {
app: ElectronApplication;
app: import("playwright").ElectronApplication;
page: import("playwright").Page;
configPath: string;
userDataDir: string;
outputDir: string;
relaunch: () => Promise<void>;
@@ -42,8 +41,6 @@ export interface AppContext {
export interface TestOptions {
version: string;
args?: string[];
config?: Record<string, unknown>;
}
export interface Fixtures {
@@ -52,43 +49,35 @@ export interface Fixtures {
}
export async function buildAndLaunchApp(
userDataDir: string,
options?: TestOptions
): Promise<AppContext> {
await buildApp(options?.version);
const productName = `notesnooktest${makeid(10)}`;
const outputDir = path.join(root, "test-artifacts", `${productName}-output`);
const outputDir = path.join("test-artifacts", `${productName}-output`);
const executablePath = await copyBuild({
...options,
outputDir
});
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(
const { app, page, configPath, userDataDir } = await launchApp(
executablePath,
userDataDir,
options?.version,
options?.args
productName,
options?.version
);
const ctx: AppContext = {
app,
page,
configPath,
userDataDir,
outputDir,
relaunch: async () => {
const { app } = await launchApp(
const { app, page, configPath, userDataDir } = await launchApp(
executablePath,
userDataDir,
options?.version,
options?.args
productName,
options?.version
);
ctx.app = app;
ctx.page = page;
ctx.userDataDir = userDataDir;
ctx.configPath = configPath;
}
};
return ctx;
@@ -96,15 +85,19 @@ export async function buildAndLaunchApp(
async function launchApp(
executablePath: string,
userDataDir: string,
version?: string,
args: string[] = []
packageName: string,
version?: string
) {
const userDataDir = resolve(
__dirname,
"..",
"test-artifacts",
"user_data_dirs",
packageName
);
const app = await electron.launch({
executablePath,
args: IS_DEBUG ? [...args] : ["--hidden", ...args],
baseURL: "https://app.notesnook.com",
acceptDownloads: true,
args: IS_DEBUG ? [] : ["--hidden"],
env: {
...(process.platform === "linux"
? {
@@ -121,28 +114,20 @@ async function launchApp(
}
});
const page = await app.firstWindow();
const configPath = path.join(userDataDir, "UserData", "config.json");
return {
app,
page,
configPath,
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;
export async function buildApp(version?: string) {
if (!existsSync(SOURCE_DIR)) {
execSync(
`node ${path.join(root, "scripts", "build.mjs")} --test --rebuild`,
{
stdio: IS_DEBUG ? "inherit" : "ignore"
}
);
const args = [
"electron-builder",
"--dir",
@@ -158,7 +143,6 @@ export async function buildApp(version?: string) {
stdio: IS_DEBUG ? "inherit" : "ignore",
env: {
...process.env,
CSC_IDENTITY_AUTO_DISCOVERY: "false",
NOTESNOOK_STAGING: "true",
NN_PRODUCT_NAME: productName,
NN_APP_ID: `com.notesnook.test.${productName}`,
@@ -167,7 +151,7 @@ export async function buildApp(version?: string) {
});
} catch (e) {
if (--MAX_RETRIES) {
console.log("retrying...", e);
console.log("retrying...");
return await buildApp(version);
} else throw e;
}
@@ -205,7 +189,8 @@ async function makeBuildCopyMacOS(outputDir: string, productName: string) {
const platformDir = process.arch === "arm64" ? "mac-arm64" : "mac";
const appDir = await makeBuildCopy(outputDir, platformDir);
return resolve(
root,
__dirname,
"..",
appDir,
`${productName}.app`,
"Contents",

View File

@@ -200,15 +200,11 @@ module.exports = {
}
}
},
toolsets: {
appimage: "1.0.2"
},
snapcraft: {
base: "core24",
core24: {
confinement: "strict",
autoStart: false
}
snap: {
autoStart: false,
confinement: "strict",
allowNativeWayland: true,
base: "core22"
},
extraResources: ["app-update.yml", "./assets/**"],
extraMetadata: {

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
"name": "@notesnook/desktop",
"productName": "Notesnook",
"description": "Your private note taking space",
"version": "3.4.5",
"version": "3.3.12",
"appAppleId": "1544027013",
"private": true,
"main": "./dist/cjs/index.js",
@@ -21,17 +21,6 @@
"types": "./dist/types/index.d.ts",
"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/",
@@ -54,18 +43,16 @@
"zod": "3.24.3"
},
"devDependencies": {
"@playwright/test": "1.61.1",
"@streetwriters/kysely": "^0.27.4",
"@types/node": "22.15.3",
"@types/yargs": "^17.0.33",
"chokidar": "^4.0.3",
"electron": "^37.10.3",
"electron-builder": "^26.8.1",
"electron": "^37.0.0",
"electron-builder": "^26.0.12",
"esbuild": "0.21.5",
"fkill": "^10.0.3",
"node-abi": "^4.5.0",
"node-gyp-build": "^4.8.4",
"playwright-core": "1.61.1",
"playwright": "1.48.2",
"prebuildify": "^6.0.1",
"slugify": "1.6.6",
"tree-kill": "^1.2.2",
@@ -86,7 +73,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: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",
"test": "playwright test --tsconfig __tests__/tsconfig.json --project notesnook-desktop"
"test": "vitest run"
},
"author": {
"name": "Streetwriters (Private) Limited",

View File

@@ -1,24 +0,0 @@
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

View File

@@ -1,92 +0,0 @@
/* 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;

View File

@@ -33,7 +33,6 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const root = args.root || path.join(__dirname, "..");
const skipTscBuild = args.skipTscBuild || false;
const buildForTesting = args.test || false;
const webAppPath = path.resolve(path.join(__dirname, "..", "..", "web"));
@@ -52,11 +51,7 @@ console.log("removed build folder");
if (args.rebuild || !existsSync(path.join(webAppPath, "build"))) {
console.log("rebuilding...");
await exec(
`node scripts/execute.mjs ${
buildForTesting
? "@notesnook/web:build:test:desktop"
: "@notesnook/web:build:desktop"
}`,
"node scripts/execute.mjs @notesnook/web:build:desktop",
path.join(__dirname, "..", "..", "..")
);
}

View File

@@ -27,7 +27,6 @@ import crypto from "crypto";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const root = path.join(__dirname, "..");
const RUNNING_PROCESSES = [];
const RESTARTABLE_PROCESSES = [];
let lastBundleHash = null;
@@ -37,7 +36,7 @@ const ENV = {
FORCE_COLOR: "false",
COLOR: "0"
};
process.chdir(root);
process.chdir(path.join(__dirname, ".."));
await onChange(true);
@@ -56,16 +55,13 @@ async function onChange(first) {
if (first) {
await fs.rm("./build/", { force: true, recursive: true });
await exec(
"npm run postinstall --verbose",
path.join(root, "node_modules", "electron")
);
await exec("npm rebuild electron --verbose --foreground-scripts");
await exec("yarn electron-builder install-app-deps", root);
await exec("yarn electron-builder install-app-deps");
}
await exec(`yarn run bundle`, root);
await exec(`yarn run build`, root);
await exec(`yarn run bundle`);
await exec(`yarn run build`);
if (await isBundleSame()) {
console.log("Bundle is same. Doing nothing.");
@@ -115,7 +111,7 @@ function spawnAndWaitUntil(cmd, cwd, predicate) {
async function exec(cmd, cwd) {
try {
console.log(">", cmd, cwd);
console.log(">", cmd);
return execSync(cmd, {
env: ENV,

View File

@@ -1,108 +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 { 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
}
});

View File

@@ -25,8 +25,6 @@ import { updaterRouter } from "./updater";
import { bridgeRouter } from "./bridge";
import { safeStorageRouter } from "./safe-storage";
import { windowRouter } from "./window";
import { sqliteRouter } from "./sqlite-kysely";
import { backupsRouter } from "./backups";
const t = initTRPC.create();
@@ -37,13 +35,10 @@ export const router = t.router({
updater: updaterRouter,
bridge: bridgeRouter,
safeStorage: safeStorageRouter,
window: windowRouter,
sqlite: sqliteRouter,
backups: backupsRouter
window: windowRouter
});
const createCaller = t.createCallerFactory(router);
export const api = createCaller({});
export const api = router.createCaller({});
// Export type router type signature,
// NOT the router itself.

View File

@@ -24,6 +24,7 @@ import {
dialog,
Menu,
MenuItem,
nativeImage,
nativeTheme,
Notification,
shell
@@ -32,16 +33,16 @@ import { AutoLaunch } from "../utils/autolaunch";
import { config, DesktopIntegration } from "../utils/config";
import { bringToFront } from "../utils/bring-to-front";
import { getTheme, setTheme, Theme } from "../utils/theme";
import { existsSync } from "fs";
import { normalizePathString, resolvePath } from "../utils/resolve-path";
import { mkdirSync, writeFileSync } from "fs";
import { dirname } from "path";
import { resolvePath } from "../utils/resolve-path";
import { observable } from "@trpc/server/observable";
import { AssetManager } from "../utils/asset-manager";
import { isFlatpak, isPortable, isSnap } from "../utils";
import { isFlatpak, isSnap } from "../utils";
import { setupDesktopIntegration } from "../utils/desktop-integration";
import { rm } from "fs/promises";
import { disableCustomDns, enableCustomDns } from "../utils/custom-dns";
import type { MenuItem as NNMenuItem } from "@notesnook/ui";
import { platform } from "os";
import { strings } from "@notesnook/intl";
const t = initTRPC.create();
@@ -59,14 +60,6 @@ const NotificationOptions = z.object({
export const osIntegrationRouter = t.router({
isFlatpak: t.procedure.query(() => isFlatpak()),
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),
setZoomFactor: t.procedure.input(z.number()).mutation(({ input: factor }) => {
@@ -123,19 +116,53 @@ export const osIntegrationRouter = t.router({
setupDesktopIntegration(settings);
}),
selectBackupDirectory: t.procedure.input(z.undefined()).query(async () => {
if (!globalThis.window) return undefined;
selectDirectory: t.procedure
.input(
z.object({
title: z.string().optional(),
buttonLabel: z.string().optional(),
defaultPath: z.string().optional()
})
)
.query(async ({ input }) => {
if (!globalThis.window) return undefined;
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 { title, buttonLabel, defaultPath } = input;
config.backupDirectory = result.filePaths[0];
const result = await dialog.showOpenDialog(globalThis.window, {
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(() => {
app.relaunch();
app.exit();
@@ -151,9 +178,7 @@ export const osIntegrationRouter = t.router({
})
});
notification.show();
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.
if (input.urgency === "critical") {
shell.beep();
}
@@ -164,38 +189,9 @@ export const osIntegrationRouter = t.router({
}),
openPath: t.procedure
.input(z.object({ type: z.literal("path"), link: z.string() }))
.query(async ({ input }) => {
if (isFlatpak()) return;
.query(({ input }) => {
const { type, link } = input;
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));
if (type === "path") return shell.openPath(resolvePath(link));
}),
bringToFront: t.procedure.query(() => bringToFront()),
changeTheme: t.procedure
@@ -300,7 +296,3 @@ function toMenuItem(
}
}
}
function wrapPath(path: string, maxLineLength = 100): string {
return path.replace(new RegExp(`(.{${maxLineLength}})`, "g"), "$1\n");
}

View File

@@ -84,55 +84,35 @@ const LANGUAGES: Record<string, 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({
isEnabled: t.procedure.query(() => config.isSpellCheckerEnabled),
languages: t.procedure.query(() => {
const available =
globalThis.window?.webContents.session.availableSpellCheckerLanguages ||
[];
return <Language[]>available
.map((code) => ({
code,
name: LANGUAGES[code] || code
}))
.sort((a, b) => a.name.localeCompare(b.name));
}),
enabledLanguages: t.procedure.query(() => {
const enabled =
globalThis.window?.webContents.session.getSpellCheckerLanguages() || [];
const available =
globalThis.window?.webContents.session.availableSpellCheckerLanguages ||
[];
const resolved = enabled
.map((code) => resolveLanguage(code, available))
.filter(Boolean) as string[];
return <Language[]>resolved.map((code) => ({
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);
}),
languages: t.procedure.query(
() =>
<Language[]>(
globalThis.window?.webContents.session.availableSpellCheckerLanguages.map(
(code) => ({
code,
name: LANGUAGES[code]
})
)
)
),
enabledLanguages: t.procedure.query(
() =>
<Language[]>(
globalThis.window?.webContents.session
.getSpellCheckerLanguages()
.map((code) => ({
code,
name: LANGUAGES[code]
}))
)
),
setLanguages: t.procedure
.input(z.array(z.string()))
.mutation(({ input: languages }) =>
globalThis.window?.webContents.session.setSpellCheckerLanguages(languages)
),
toggle: t.procedure
.input(z.object({ enabled: z.boolean() }))
.mutation(({ input: { enabled } }) => {
@@ -148,16 +128,3 @@ 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;
}

View File

@@ -19,9 +19,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import type { Database, Statement } from "better-sqlite3-multiple-ciphers";
import type { QueryResult } from "@streetwriters/kysely";
import { app } from "electron";
import path from "node:path";
import { initTRPC } from "@trpc/server";
type SQLiteCompatibleType =
| number
@@ -31,32 +28,25 @@ type SQLiteCompatibleType =
| bigint
| null;
class SQLite {
export class SQLite {
sqlite?: Database;
initialized = false;
preparedStatements: Map<string, Statement<unknown[]>> = new Map();
retryCounter: Record<string, number> = {};
extensionsLoaded = false;
private filePath?: string;
constructor() {
console.log("new sqlite worker");
}
async open(filename: string) {
async open(filePath: string) {
if (this.sqlite) {
console.error("Database is already initialized");
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.filePath
filePath
).unsafeMode(true);
}
@@ -165,11 +155,9 @@ class SQLite {
this.sqlite = undefined;
}
async delete() {
if (!this.filePath) return;
async delete(filePath: string) {
await this.close();
await require("node:fs/promises").rm(this.filePath, {
await require("fs/promises").rm(filePath, {
force: true,
maxRetries: 5,
retryDelay: 500
@@ -233,67 +221,3 @@ function rewriteError(e: Error, message: string) {
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);
}
}
});

View File

@@ -24,7 +24,6 @@ import type { AppUpdaterEvents } from "electron-updater/out/AppUpdater";
import { z } from "zod";
import { config } from "../utils/config";
import { app } from "electron";
import { isFlatpak, isPortable, isSnap } from "../utils";
type UpdateInfo = { version: string };
type Progress = { percent: number };
@@ -32,15 +31,13 @@ type Progress = { percent: number };
const t = initTRPC.create();
let cancellationToken: CancellationToken | undefined = undefined;
let downloadTimeout: NodeJS.Timeout | undefined = undefined;
const updatesSupported = !isFlatpak() && !isSnap() && !isPortable();
export const updaterRouter = t.router({
autoUpdates: t.procedure.query(
() => updatesSupported && config.automaticUpdates
),
autoUpdates: t.procedure.query(() => config.automaticUpdates),
releaseTrack: t.procedure.query(() => config.releaseTrack),
install: t.procedure.query(() => autoUpdater.quitAndInstall()),
download: t.procedure.query(async () => {
if (!updatesSupported || cancellationToken) return;
if (cancellationToken) return;
clearTimeout(downloadTimeout);
await new Promise<string[]>((resolve, reject) => {
downloadTimeout = setTimeout(async () => {
@@ -55,7 +52,7 @@ export const updaterRouter = t.router({
});
}),
check: t.procedure.query(async () => {
if (!updatesSupported || cancellationToken) return;
if (cancellationToken) return;
clearTimeout(downloadTimeout);
await new Promise<void>((resolve) => {
downloadTimeout = setTimeout(async () => {

View File

@@ -21,3 +21,4 @@ export { PATHS } from "./constants";
export type { AppRouter } from "./api";
export { type UpdateInfo } from "builder-util-runtime";
export { type DesktopIntegration } from "./utils/config";
export { SQLite } from "./api/sqlite-kysely.js";

View File

@@ -39,8 +39,6 @@ import { setupDesktopIntegration } from "./utils/desktop-integration";
import { disableCustomDns, enableCustomDns } from "./utils/custom-dns";
import { Messages, setI18nGlobal } from "@notesnook/intl";
import { i18n } from "@lingui/core";
import { PATHS } from "./constants";
import { normalizePathString } from "./utils/resolve-path";
const locale =
process.env.NODE_ENV === "development"
@@ -92,17 +90,6 @@ async function createWindow() {
const cliOptions = await parseArguments(process.argv);
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 mainWindow = new BrowserWindow({
show: !cliOptions.hidden,
@@ -142,6 +129,9 @@ async function createWindow() {
webPreferences: {
zoomFactor: config.zoomFactor,
nodeIntegration: true,
contextIsolation: false,
nodeIntegrationInWorker: true,
spellcheck: config.isSpellCheckerEnabled,
preload: __dirname + "/preload.js"
}
@@ -152,13 +142,7 @@ async function createWindow() {
mainWindow.setMenuBarVisibility(false);
mainWindowState.manage(mainWindow);
if (
cliOptions.hidden &&
!(
config.desktopSettings.minimizeToSystemTray ||
config.desktopSettings.closeToSystemTray
)
)
if (cliOptions.hidden && !config.desktopSettings.minimizeToSystemTray)
mainWindow.minimize();
await mainWindow.webContents.loadURL(`${createURL(cliOptions, "/")}`);
@@ -181,14 +165,6 @@ async function createWindow() {
);
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", () => {
globalThis.window = null;
});
@@ -249,8 +225,7 @@ app.once("ready", async () => {
if (!isDevelopment()) registerProtocol();
await createWindow();
await migrateBackupDirectory();
await configureAutoUpdater();
configureAutoUpdater();
});
app.once("window-all-closed", () => {
@@ -311,27 +286,3 @@ function createURL(options: CLIOptions, path = "/") {
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();
}
}
}

View File

@@ -19,21 +19,25 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/* eslint-disable no-var */
import { ELECTRON_TRPC_CHANNEL } from "electron-trpc/main";
import { ipcRenderer, contextBridge } from "electron";
// import type { NNCrypto } from "@notesnook/crypto";
import { ipcRenderer } from "electron";
import { platform } from "os";
declare global {
var os: () => "mas" | typeof process.platform;
var os: () => "mas" | ReturnType<typeof platform>;
var electronTRPC: any;
// var NativeNNCrypto: (new () => NNCrypto) | undefined;
}
const electronTRPC = {
sendMessage: (operation: any) =>
ipcRenderer.send(ELECTRON_TRPC_CHANNEL, operation),
onMessage: (callback: any) =>
ipcRenderer.on(ELECTRON_TRPC_CHANNEL, (_event, args) => callback(args))
};
process.once("loaded", async () => {
const electronTRPC = {
sendMessage: (operation: any) =>
ipcRenderer.send(ELECTRON_TRPC_CHANNEL, operation),
onMessage: (callback: any) =>
ipcRenderer.on(ELECTRON_TRPC_CHANNEL, (_event, args) => callback(args))
};
globalThis.electronTRPC = electronTRPC;
});
const os = () => (MAC_APP_STORE ? "mas" : process.platform);
contextBridge.exposeInMainWorld("electronTRPC", electronTRPC);
contextBridge.exposeInMainWorld("os", os);
// globalThis.NativeNNCrypto = require("@notesnook/crypto").NNCrypto;
globalThis.os = () => (MAC_APP_STORE ? "mas" : platform());

View File

@@ -37,11 +37,7 @@ async function configureAutoUpdater() {
config.releaseTrack === "stable" &&
autoUpdater.currentVersion.prerelease.length > 0;
autoUpdater.allowPrerelease = false;
// 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.autoInstallOnAppQuit = true;
autoUpdater.disableWebInstaller = true;
}

View File

@@ -21,7 +21,6 @@ import { nativeTheme } from "electron";
import { JSONStorage } from "./json-storage";
import { z } from "zod";
import { autoUpdater } from "electron-updater";
import { PATHS } from "../constants";
export const DesktopIntegration = z.object({
autoStart: z.boolean().optional(),
@@ -54,8 +53,7 @@ export const config = {
backgroundColor: nativeTheme.themeSource === "dark" ? "#0f0f0f" : "#ffffff",
windowControlsIconColor:
nativeTheme.themeSource === "dark" ? "#ffffff" : "#000000",
backupDirectory: PATHS.backupsDirectory
nativeTheme.themeSource === "dark" ? "#ffffff" : "#000000"
};
type ConfigKey = keyof typeof config;

View File

@@ -33,7 +33,3 @@ export function isFlatpak() {
export function isSnap() {
return process.env.SNAP !== undefined;
}
export function isPortable() {
return process.env.PORTABLE_EXECUTABLE_DIR !== undefined;
}

View File

@@ -20,30 +20,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { app } from "electron";
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) {
const normalizedPath = normalizePathString(_path);
if (isAbsolute(normalizedPath)) return normalizedPath;
if (isAbsolute(_path)) return _path;
return join(
...normalizedPath.split("/").map((segment) => {
..._path.split("/").map((segment) => {
let resolved = segment;
try {
resolved = app.getPath(resolved as any);

View File

@@ -1,11 +1,9 @@
{
"extends": "../../tsconfig.json",
"extends": "../../tsconfig",
"compilerOptions": {
"outDir": "./dist/",
"lib": ["ESNext"],
"moduleResolution": "Bundler"
},
"files": ["global.d.ts"],
"include": ["src"],
"exclude": ["__tests__"]
"include": ["src", "global.d.ts"]
}

View File

@@ -17,34 +17,26 @@ 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 { databaseTest } from "./utils/index.js";
import { test, expect } from "vitest";
import { defineConfig } from "vitest/config";
test("remove all vaults", () =>
databaseTest().then(async (db) => {
const key = {
format: "base64" as const,
alg: "aes-256-gcm",
cipher: "key",
iv: "iv",
salt: "salt",
length: 16
};
await db.vaults.add({
title: "Vault 1",
key
});
await db.vaults.add({
title: "Vault 2",
key
});
expect(await db.vaults.all.count()).toBe(2);
expect(await db.vaults.default()).toBeDefined();
await db.vaults.removeAll();
expect(await db.vaults.all.count()).toBe(0);
expect(await db.vaults.default()).toBeUndefined();
}));
export default defineConfig({
test: {
testTimeout: 120 * 1000,
hookTimeout: 120 * 1000,
sequence: {
concurrent: true,
shuffle: true
},
globalSetup: "./__tests__/global-setup.ts",
dir: "./__tests__/",
exclude: [
"**/node_modules/**",
"**/dist/**",
"**/cypress/**",
"**/.{idea,git,cache,output,temp}/**",
"**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*",
"**/test-results/**",
"**/test-artifacts/**"
]
}
});

View File

@@ -30,7 +30,7 @@ module.exports = {
testBinaryPath:
"android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk",
build:
"cd android ; ENVFILE=.env.test ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug -PreactNativeArchitectures=arm64-v8a && cd ..",
"cd android ; ENVFILE=.env.test ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug && cd ..",
reversePorts: [8081]
},
"android.release": {

View File

@@ -13,7 +13,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v2
with:
persist-credentials: false

View File

@@ -13,7 +13,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v2
with:
persist-credentials: false

View File

@@ -1,5 +1,4 @@
.yarnrc.yml
tsconfig.tsbuildinfo
artifacts/
# OSX
#

View File

@@ -140,7 +140,7 @@ android {
if (project.hasProperty("prBuildNumber")) {
versionCode Integer.parseInt(prBuildNumber())
} else {
versionCode 3114
versionCode 3098
}
versionName getNpmVersion()
testBuildType System.getProperty('testBuildType', 'debug')
@@ -233,9 +233,6 @@ dependencies {
// The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android")
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.multidex:multidex:2.0.1'

View File

@@ -86,9 +86,8 @@
android:name=".NotePreviewWidget"
android:exported="false"
android:label="@string/note">
<intent-filter>
<intent-filter>"
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="android.appwidget.action.APPWIDGET_RESTORED" />
</intent-filter>
<meta-data
@@ -96,15 +95,6 @@
android:resource="@xml/note_widget_info" />
</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
android:name=".ReminderWidgetProvider"
android:exported="false"
@@ -218,7 +208,11 @@
</intent-filter>
</activity>
<service
android:name=".OnClearFromRecentService"
android:enabled="true"
android:exported="true"
android:stopWithTask="false" />
<service android:name="com.asterinet.react.bgactions.RNBackgroundActionsTask" />
<service
@@ -240,6 +234,11 @@
</intent-filter>
</service>
<service
android:name=".ReminderViewsService"
android:exported="true"
android:permission="android.permission.BIND_REMOTEVIEWS" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"

View File

@@ -28,6 +28,10 @@ public class MainActivity extends ReactActivity {
WebView.setWebContentsDebuggingEnabled(true);
}
try {
startService(new Intent(getBaseContext(), OnClearFromRecentService.class));
} catch (Exception ignored) {}
}
/**

View File

@@ -5,11 +5,15 @@ import android.appwidget.AppWidgetManager;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.util.Log;
import android.widget.RemoteViews;
import com.facebook.react.ReactActivity;
import com.facebook.react.ReactActivityDelegate;
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint;
import com.facebook.react.defaults.DefaultReactActivityDelegate;
import com.google.gson.Gson;
import com.streetwriters.notesnook.datatypes.Note;
public class NotePreviewConfigureActivity extends ReactActivity {
@@ -36,32 +40,18 @@ public class NotePreviewConfigureActivity extends ReactActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(null);
activity = this;
readAppWidgetId(getIntent());
}
/**
* We launch as singleTask, so configuring a second widget while this screen is still alive
* arrives here rather than in onCreate(). Without this the activity would keep writing to
* 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 intent = getIntent();
Bundle extras = intent.getExtras();
int appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID;
if (extras != null) {
appWidgetId = extras.getInt(
AppWidgetManager.EXTRA_APPWIDGET_ID,
AppWidgetManager.INVALID_APPWIDGET_ID);
NotePreviewConfigureActivity.appWidgetId = appWidgetId;
}
Intent resultValue = new Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
setResult(Activity.RESULT_CANCELED, resultValue);
activity = this;
}
public static void saveAndFinish(Context context) {

View File

@@ -1,5 +1,6 @@
package com.streetwriters.notesnook;
import android.app.ActivityOptions;
import android.app.PendingIntent;
import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProvider;
@@ -7,125 +8,51 @@ 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.view.View;
import android.widget.RemoteViews;
import com.google.gson.Gson;
import com.streetwriters.notesnook.datatypes.Note;
import java.util.HashSet;
import java.util.Set;
public class NotePreviewWidget extends AppWidgetProvider {
static String OpenNoteId = "com.streetwriters.notesnook.OpenNoteId";
static void updateAppWidget(Context context, AppWidgetManager appWidgetManager,
int appWidgetId) {
String data = context.getSharedPreferences(WidgetUtils.PREFERENCES, Context.MODE_PRIVATE).getString(String.valueOf(appWidgetId), "");
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);
String data = context.getSharedPreferences("appPreview", Context.MODE_PRIVATE).getString(String.valueOf(appWidgetId), "");
if (data.isEmpty()) {
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_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.putExtra(OpenNoteId, note.getId());
intent.setAction(Intent.ACTION_VIEW);
intent.putExtra(RCTNNativeModule.IntentType, "OpenNote");
intent.setData(Uri.parse("nn://note/" + note.getId()));
PendingIntent pendingIntent = PendingIntent.getActivity(context, appWidgetId, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE, WidgetUtils.getActivityOptionsBundle());
PendingIntent pendingIntent = PendingIntent.getActivity(context, appWidgetId, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE, getActivityOptionsBundle());
views.setOnClickPendingIntent(R.id.open_note, pendingIntent);
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
public void onAppWidgetOptionsChanged(Context context, AppWidgetManager appWidgetManager, int appWidgetId, Bundle 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);
}
/**
* The note shown by each widget is stored in the "appPreview" preferences under its widget id.
* When the system restores our widgets it hands out fresh ids, so unless we move the stored
* notes over to the new ids the widgets are left permanently blank with no way to recover
* other than removing and re-adding them.
*
* AppWidgetProvider calls onUpdate() with the new ids right after this, which re-renders them.
*/
@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();
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

View File

@@ -1,10 +1,12 @@
package com.streetwriters.notesnook;
import android.app.ActivityOptions;
import android.app.PendingIntent;
import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProvider;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.os.Bundle;
import android.widget.RemoteViews;
@@ -22,10 +24,20 @@ public class NoteWidget extends AppWidgetProvider {
static void setClickIntent(Context context, RemoteViews views) {
Intent intent = new Intent(context, ShareActivity.class);
PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE, WidgetUtils.getActivityOptionsBundle());
PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE, getActivityOptionsBundle());
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
public void onAppWidgetOptionsChanged(Context context, AppWidgetManager appWidgetManager, int appWidgetId, Bundle newOptions) {
super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions);

View File

@@ -0,0 +1,36 @@
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) {
}
}
}

View File

@@ -12,6 +12,7 @@ import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.RectF;
import android.graphics.drawable.Icon;
import android.os.Build;
import android.os.Bundle;
@@ -28,6 +29,7 @@ import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.WritableArray;
import com.facebook.react.bridge.WritableMap;
import com.google.gson.Gson;
import com.streetwriters.notesnook.datatypes.Note;
import java.util.ArrayList;
@@ -91,6 +93,21 @@ 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)
public int getWidgetId() {
return NotePreviewConfigureActivity.appWidgetId;
@@ -136,7 +153,7 @@ public class RCTNNativeModule extends ReactContextBaseJavaModule {
if (Objects.equals(extras.getString(IntentType), "NewReminder")) {
map.putString(ReminderWidgetProvider.NewReminder, extras.getString(ReminderWidgetProvider.NewReminder));
} else if (Objects.equals(extras.getString(IntentType), "OpenReminder")) {
map.putString(ReminderWidgetProvider.OpenReminderId, extras.getString(ReminderWidgetProvider.OpenReminderId));
map.putString(ReminderViewsService.OpenReminderId, extras.getString(ReminderViewsService.OpenReminderId));
} else if (Objects.equals(extras.getString(IntentType), "OpenNote")) {
map.putString(NotePreviewWidget.OpenNoteId, extras.getString(NotePreviewWidget.OpenNoteId));
}
@@ -154,8 +171,14 @@ public class RCTNNativeModule extends ReactContextBaseJavaModule {
@ReactMethod
public void getWidgetNotes(Promise promise) {
SharedPreferences pref = getReactApplicationContext().getSharedPreferences("appPreview", Context.MODE_PRIVATE);
Map<String, ?> map = pref.getAll();
WritableArray arr = Arguments.createArray();
for (Note note : WidgetUtils.getWidgetNotes(getReactApplicationContext()).values()) {
for(Map.Entry<String,?> entry : map.entrySet()){
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());
}
promise.resolve(arr);
@@ -163,46 +186,36 @@ public class RCTNNativeModule extends ReactContextBaseJavaModule {
@ReactMethod
public void hasWidgetNote(final String noteId, Promise promise) {
SharedPreferences pref = getReactApplicationContext().getSharedPreferences("appPreview", Context.MODE_PRIVATE);
Map<String, ?> map = pref.getAll();
boolean found = false;
for (Note note : WidgetUtils.getWidgetNotes(getReactApplicationContext()).values()) {
if (note.getId().equals(noteId)) {
for(Map.Entry<String,?> entry : map.entrySet()){
String value = (String) entry.getValue();
if (value.contains(noteId)) {
found = true;
break;
}
}
promise.resolve(found);
}
@ReactMethod
public void updateWidgetNote(final String noteId, final String data) {
SharedPreferences pref = getReactApplicationContext().getSharedPreferences(WidgetUtils.PREFERENCES, Context.MODE_PRIVATE);
SharedPreferences pref = getReactApplicationContext().getSharedPreferences("appPreview", Context.MODE_PRIVATE);
Map<String, ?> map = pref.getAll();
SharedPreferences.Editor edit = pref.edit();
List<Integer> ids = new ArrayList<>();
// Match on the note's id, not on the raw JSON containing it somewhere: a note whose body
// happens to mention another note's id is not the same note.
for (Map.Entry<Integer, Note> entry : WidgetUtils.getWidgetNotes(getReactApplicationContext()).entrySet()) {
if (!noteId.equals(entry.getValue().getId())) continue;
edit.putString(String.valueOf(entry.getKey()), data);
ids.add(entry.getKey());
ArrayList<String> ids = new ArrayList<>();
for(Map.Entry<String,?> entry : map.entrySet()) {
String value = (String) entry.getValue();
if (value.contains(noteId)) {
edit.putString(entry.getKey(), data);
ids.add(entry.getKey());
}
}
edit.apply();
for (int id : ids) {
NotePreviewWidget.updateAppWidget(mContext, AppWidgetManager.getInstance(mContext), id);
for (String id: ids) {
NotePreviewWidget.updateAppWidget(mContext, AppWidgetManager.getInstance(mContext), Integer.parseInt(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
public void updateReminderWidget() {
AppWidgetManager wm = AppWidgetManager.getInstance(mContext);
@@ -210,8 +223,8 @@ public class RCTNNativeModule extends ReactContextBaseJavaModule {
for (int id: ids) {
Log.d("Reminders", "Updating" + id);
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);
wm.notifyAppWidgetViewDataChanged(id, R.id.widget_list_view);
}
}

View File

@@ -0,0 +1,106 @@
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;
}
}

View File

@@ -1,22 +1,18 @@
package com.streetwriters.notesnook;
import android.app.ActivityOptions;
import android.app.PendingIntent;
import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProvider;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.widget.RemoteViews;
import androidx.core.widget.RemoteViewsCompat;
import com.streetwriters.notesnook.datatypes.Reminder;
import java.util.List;
public class ReminderWidgetProvider extends AppWidgetProvider {
static String NewReminder = "com.streetwriters.notesnook.NewReminder";
static String OpenReminderId = "com.streetwriters.notesnook.OpenReminderId";
@Override
public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
@@ -27,10 +23,20 @@ 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) {
Intent listview_intent_template = new Intent(context, MainActivity.class);
listview_intent_template.setAction(Intent.ACTION_VIEW);
PendingIntent pendingIntent = PendingIntent.getActivity(context, appWidgetId, listview_intent_template, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_MUTABLE, WidgetUtils.getActivityOptionsBundle());
PendingIntent pendingIntent = PendingIntent.getActivity(context, appWidgetId, listview_intent_template, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_MUTABLE, getActivityOptionsBundle());
views.setPendingIntentTemplate(R.id.widget_list_view, pendingIntent);
Intent new_reminder_intent = new Intent(context, MainActivity.class);
@@ -38,31 +44,13 @@ public class ReminderWidgetProvider extends AppWidgetProvider {
new_reminder_intent.setAction(Intent.ACTION_VIEW);
new_reminder_intent.putExtra(RCTNNativeModule.IntentType, "NewReminder");
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, WidgetUtils.getActivityOptionsBundle());
PendingIntent pendingIntent2 = PendingIntent.getActivity(context, appWidgetId, new_reminder_intent, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_IMMUTABLE, getActivityOptionsBundle());
views.setOnClickPendingIntent(R.id.add_button, pendingIntent2);
// The rows travel with the update itself, so there is no bound service to keep in sync and
// nothing to invalidate separately: every update redraws from the current data.
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());
Intent list_remote_adapter_intent = new Intent(context, ReminderViewsService.class);
list_remote_adapter_intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
views.setRemoteAdapter(R.id.widget_list_view, list_remote_adapter_intent);
views.setEmptyView(R.id.widget_list_view, R.id.empty_view);
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();
}
}
}

View File

@@ -1,28 +0,0 @@
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);
}
}

View File

@@ -1,299 +0,0 @@
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();
}
}

View File

@@ -2,14 +2,13 @@ package com.streetwriters.notesnook.datatypes;
import androidx.annotation.Keep;
import java.util.concurrent.TimeUnit;
@Keep
public class Reminder extends BaseItem {
private String title;
private String description;
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 long date;
private String mode; // "repeat", "once", "permanent"
@@ -108,27 +107,32 @@ public class Reminder extends BaseItem {
this.formattedTime = formattedTime;
}
public String getFormattedTimeOfDay() {
return formattedTimeOfDay;
}
public String formatTime(long timeInMillis) {
long currentTime = System.currentTimeMillis();
long diff = timeInMillis - currentTime;
public void setFormattedTimeOfDay(String formattedTimeOfDay) {
this.formattedTimeOfDay = formattedTimeOfDay;
}
public String getFormattedDateTime() {
return formattedDateTime;
}
public void setFormattedDateTime(String formattedDateTime) {
this.formattedDateTime = formattedDateTime;
}
public long getTriggerDate() {
return triggerDate;
}
public void setTriggerDate(long triggerDate) {
this.triggerDate = triggerDate;
if (diff < TimeUnit.MINUTES.toMillis(1)) {
return "in " + (diff / 1000) + " seconds";
} else if (diff < TimeUnit.HOURS.toMillis(1)) {
long minutes = TimeUnit.MILLISECONDS.toMinutes(diff);
return "in " + minutes + " minute" + (minutes > 1 ? "s" : "");
} else if (diff < TimeUnit.DAYS.toMillis(1)) {
long hours = TimeUnit.MILLISECONDS.toHours(diff);
return "in " + hours + " hour" + (hours > 1 ? "s" : "");
} else if (diff < TimeUnit.DAYS.toMillis(2)) {
return "tomorrow";
} else if (diff < TimeUnit.DAYS.toMillis(7)) {
long days = TimeUnit.MILLISECONDS.toDays(diff);
return "in " + days + " day" + (days > 1 ? "s" : "");
} else if (diff < TimeUnit.DAYS.toMillis(30)) {
long weeks = TimeUnit.MILLISECONDS.toDays(diff) / 7;
return "in " + weeks + " week" + (weeks > 1 ? "s" : "");
} else if (diff < TimeUnit.DAYS.toMillis(365)) {
long months = TimeUnit.MILLISECONDS.toDays(diff) / 30;
return "in " + months + " month" + (months > 1 ? "s" : "");
} else {
long years = TimeUnit.MILLISECONDS.toDays(diff) / 365;
return "in " + years + " year" + (years > 1 ? "s" : "");
}
}
}

View File

@@ -2,6 +2,6 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/background"/>
<stroke android:width="0dp" android:color="#B1BCBE" />
<corners android:radius="@dimen/widget_background_radius"/>
<corners android:radius="10dp"/>
<padding android:left="0dp" android:top="0dp" android:right="0dp" android:bottom="0dp" />
</shape>

View File

@@ -2,7 +2,8 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent">
android:background="@android:color/transparent"
android:theme="@style/ThemeOverlay.Notesnook.AppWidgetContainer">
<LinearLayout
android:layout_width="match_parent"

View File

@@ -2,7 +2,8 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent">
android:background="@android:color/transparent"
android:theme="@style/ThemeOverlay.Notesnook.AppWidgetContainer">
<LinearLayout
android:layout_width="match_parent"
@@ -24,7 +25,7 @@
android:textColor="@color/text"
android:textSize="16sp"
android:textStyle="bold"
android:text="@string/widget_note_unconfigured_title" />
android:text="Widget unconfigured" />
<TextView
android:id="@+id/widget_body"
android:layout_width="wrap_content"
@@ -33,7 +34,7 @@
android:layout_marginLeft="8dp"
android:textColor="@color/text"
android:textSize="14sp"
android:text="@string/widget_note_unconfigured_body" />
android:text="Configure this widget to show a note here." />
</LinearLayout>
</RelativeLayout>

View File

@@ -1,41 +0,0 @@
<?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>

View File

@@ -0,0 +1,8 @@
<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" />

View File

@@ -12,23 +12,22 @@
android:orientation="horizontal"
android:paddingHorizontal="12dp"
android:layout_gravity="center"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:paddingTop="12dp"
>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentLeft="true"
android:layout_marginTop="2dp"
android:textSize="15sp"
android:textSize="16sp"
android:textStyle="bold"
android:textColor="@color/text"
android:text="Reminders"/>
android:text="Upcoming Reminders"/>
<ImageButton
android:layout_width="25dp"
android:layout_width="35dp"
android:id="@+id/add_button"
android:layout_height="25dp"
android:layout_height="35dp"
android:layout_alignParentRight="true"
android:background="@drawable/ic_newnote" />
</RelativeLayout>
@@ -58,8 +57,7 @@
android:layout_height="match_parent"
android:textAlignment="center"
android:gravity="center"
android:textColor="@color/text"
android:text="@string/widget_reminders_empty"/>
android:text="Tap on + to add reminder"/>
</LinearLayout>

View File

@@ -1,82 +0,0 @@
<?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>

View File

@@ -1,5 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="light_blue_50">#FFE1F5FE</color>
<color name="light_blue_200">#FF81D4FA</color>
<color name="light_blue_600">#FF039BE5</color>
<color name="light_blue_900">#FF01579B</color>
<color name="bootsplash_background">#1f1f1f</color>
<color name="background">#1D1D1D</color>
<color name="border">#2E2E2E</color>

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Android 12 onwards the launcher tells us what radius widgets should use, so ours line up
with every other widget on the home screen instead of being a fixed 10dp. -->
<dimen name="widget_background_radius">@android:dimen/system_app_widget_background_radius</dimen>
</resources>

View File

@@ -0,0 +1,6 @@
<resources>
<declare-styleable name="AppWidgetAttrs">
<attr name="appWidgetBackgroundColor" format="color" />
<attr name="appWidgetTextColor" format="color" />
</declare-styleable>
</resources>

View File

@@ -1,5 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="light_blue_50">#FFE1F5FE</color>
<color name="light_blue_200">#FF81D4FA</color>
<color name="light_blue_600">#FF039BE5</color>
<color name="light_blue_900">#FF01579B</color>
<color name="bootsplash_background">#FFFFFF</color>
<color name="background">#DCEDEDED</color>
<color name="border">#BFBFBF</color>

View File

@@ -7,8 +7,4 @@ http://developer.android.com/guide/topics/appwidgets/index.html#CreatingLayout
-->
<dimen name="widget_margin">0dp</dimen>
<!-- Overridden in values-v31 with the platform's own widget radius, so our widgets match the
rest of the home screen. This is the fallback for older versions. -->
<dimen name="widget_background_radius">10dp</dimen>
</resources>

View File

@@ -4,28 +4,9 @@
<string name="appwidget_text">EXAMPLE</string>
<string name="add_widget">Add widget</string>
<string name="take_a_quick_note">Take a quick note.</string>
<string name="reminders">Quick overview of reminders</string>
<string name="reminders">Quick overview of upcoming reminders</string>
<string name="reminders_title">Reminders</string>
<string name="note">Note</string>
<string name="note_description">Add a note to home screen</string>
<string name="quick_note">Quick note</string>
<string name="widget_reminders_empty">Tap + to add a reminder</string>
<!-- Sample content, only ever shown in the widget picker's preview. -->
<string name="widget_preview_note_title">Meeting notes</string>
<string name="widget_preview_note_body">Discuss the roadmap and agree on timelines.</string>
<string name="widget_preview_reminder_title">Take a walk</string>
<string name="widget_preview_reminder_time">Upcoming: Today, 5:00 PM</string>
<string name="widget_preview_reminder_title_alt">Call the dentist</string>
<string name="widget_preview_reminder_time_alt">Upcoming: Tomorrow, 9:00 AM</string>
<string name="widget_note_unconfigured_title">Tap to choose a note</string>
<string name="widget_note_unconfigured_body">Pick the note you want shown here.</string>
<string name="reminder_ongoing">Ongoing</string>
<string name="reminder_snoozed_until">Snoozed until %1$s</string>
<string name="reminder_today">Today, %1$s</string>
<string name="reminder_tomorrow">Tomorrow, %1$s</string>
<string name="reminder_yesterday">Yesterday, %1$s</string>
<string name="reminder_upcoming">Upcoming: %1$s</string>
<string name="reminder_last">Last: %1$s</string>
</resources>

View File

@@ -0,0 +1,7 @@
<resources>
<style name="ThemeOverlay.Notesnook.AppWidgetContainer" parent="">
<item name="appWidgetBackgroundColor">@color/light_blue_600</item>
<item name="appWidgetTextColor">@color/light_blue_50</item>
</style>
</resources>

View File

@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!--
Keep this in sync with res/xml/note_widget_info.xml. It exists only to add not_keyguard: from
Android 16 QPR1 widgets are lock screen eligible by default, and this one renders the note's
title and preview text, which should not be readable on a locked device.
-->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialKeyguardLayout="@layout/note_widget"
android:initialLayout="@layout/note_widget"
android:configure="com.streetwriters.notesnook.NotePreviewConfigureActivity"
android:widgetFeatures="reconfigurable"
android:minResizeWidth="100dp"
android:minResizeHeight="50dp"
android:minWidth="400dp"
android:description="@string/note_description"
android:minHeight="50dp"
android:targetCellWidth="5"
android:targetCellHeight="1"
android:previewImage="@drawable/note_widget_preview"
android:previewLayout="@layout/note_widget_preview"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="86400000"
android:widgetCategory="home_screen|not_keyguard"/>

View File

@@ -1,15 +0,0 @@
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/widget_reminders"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="180dp"
android:minResizeHeight="110dp"
android:description="@string/reminders"
android:targetCellWidth="5"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:previewImage="@drawable/reminder_preview"
android:previewLayout="@layout/widget_reminders_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen|not_keyguard"
/>

View File

@@ -10,7 +10,6 @@
android:targetCellWidth="5"
android:targetCellHeight="1"
android:previewImage="@drawable/widget_preview"
android:previewLayout="@layout/new_note_widget"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="86400000"
android:widgetCategory="home_screen"/>

View File

@@ -12,7 +12,6 @@
android:targetCellWidth="5"
android:targetCellHeight="1"
android:previewImage="@drawable/note_widget_preview"
android:previewLayout="@layout/note_widget_preview"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="86400000"
android:widgetCategory="home_screen"/>

View File

@@ -1,15 +1,14 @@
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/widget_reminders"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="180dp"
android:minResizeHeight="110dp"
android:minWidth="400dp"
android:minHeight="100dp"
android:minResizeWidth="400dp"
android:minResizeHeight="50dp"
android:description="@string/reminders"
android:targetCellWidth="5"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:previewImage="@drawable/reminder_preview"
android:previewLayout="@layout/widget_reminders_preview"
android:updatePeriodMillis="1800000"
android:updatePeriodMillis="1024"
android:widgetCategory="home_screen"
/>

View File

@@ -1,6 +1,3 @@
- Added sync status icon in sidebar
- Added new reminder shortcut in app icon context menu
- Improved editor saving reliability
- Minor bug fixes and improvements
- Minor bug fixes and performance improvements
Thank you for using Notesnook!

View File

@@ -23,7 +23,7 @@ import {
THEME_COMPATIBILITY_VERSION,
useThemeEngineStore
} from "@notesnook/theme";
import React, { PropsWithChildren, useEffect, useState } from "react";
import React, { useEffect } from "react";
import { Appearance, I18nManager, Linking, StatusBar } from "react-native";
import "react-native-gesture-handler";
import { GestureHandlerRootView } from "react-native-gesture-handler";
@@ -44,42 +44,28 @@ import { useUserStore } from "./stores/use-user-store";
import RNBootSplash from "react-native-bootsplash";
import AppLocked from "./components/app-lock";
import { useSettingStore } from "./stores/use-setting-store";
import {
initShortcutListener,
launchNewNoteTab,
registerAppShortcuts
} from "./hooks/use-shortcut-manager";
import Shortcuts from "react-native-actions-shortcuts";
I18nManager.allowRTL(false);
I18nManager.forceRTL(false);
I18nManager.swapLeftAndRightInRTL(false);
const { appLockEnabled, appLockMode } = SettingsService.get();
if (appLockEnabled || appLockMode !== "none") {
useUserStore.getState().lockApp(true);
}
RNBootSplash.hide({
fade: true
});
Linking.getInitialURL().then((url) => {
useSettingStore.setState({
initialUrl: url
});
});
const App = (props: { configureMode: "note-preview" }) => {
useAppEvents();
//@ts-ignore
globalThis["IS_MAIN_APP_RUNNING"] = true;
const introCompleted = useSettingStore(
(state) => state.settings.introCompleted
);
useEffect(() => {
if (introCompleted) {
registerAppShortcuts();
}
}, [introCompleted]);
useEffect(() => {
RNBootSplash.hide({ fade: true });
SettingsService.onFirstLaunch();
changeSystemBarColors();
SettingsService.setPrivacyScreen(
SettingsService.getProperty("privacyScreen")
);
setTimeout(async () => {
await Notifications.get();
if (SettingsService.get().notifNotes) {
@@ -120,10 +106,8 @@ let currTheme =
: SettingsService.getProperty("lighTheme");
useThemeEngineStore.getState().setTheme(currTheme);
export const withTheme = (
Element: (props: PropsWithChildren) => JSX.Element
) => {
return function AppWithThemeProvider(props: PropsWithChildren) {
export const withTheme = (Element: (props: any) => JSX.Element) => {
return function AppWithThemeProvider(props: any) {
const [colorScheme, darkTheme, lightTheme] = useThemeStore((state) => [
state.colorScheme,
state.darkTheme,
@@ -187,41 +171,4 @@ export const withTheme = (
};
};
export const withStartupBoundry = (
Element: (props: PropsWithChildren) => JSX.Element
) => {
return function AppWithStartupBoundary(props: PropsWithChildren) {
const [ready, setReady] = useState(false);
useEffect(() => {
async function init() {
try {
const [url, shortcut] = await Promise.all([
Linking.getInitialURL(),
Shortcuts.getInitialShortcut()
]);
console.log(url, shortcut);
if (shortcut?.type === "notesnook.action.newnote") {
launchNewNoteTab();
}
useSettingStore.setState({
initialUrl: url,
pendingShortcut: shortcut ?? null
});
initShortcutListener();
} finally {
setReady(true);
}
}
init();
}, []);
if (!ready) return null;
return <Element {...props} />;
};
};
export default withStartupBoundry(withTheme(withErrorBoundry(App, "App")));
export default withTheme(withErrorBoundry(App, "App"));

View File

@@ -25,6 +25,7 @@ import * as Keychain from "react-native-keychain";
import { MMKVLoader, ProcessingModes } from "react-native-mmkv-storage";
import { generateSecureRandom } from "react-native-securerandom";
import { DatabaseLogger } from ".";
import { ToastManager } from "../../services/event-manager";
import { MMKV } from "./mmkv";
// Database key cipher is persisted across different user sessions hence it has
@@ -204,8 +205,9 @@ export async function getDatabaseKey(appLockPassword?: string) {
}
if (await Keychain.hasInternetCredentials("notesnook")) {
const userKeyCredentials =
await Keychain.getInternetCredentials("notesnook");
const userKeyCredentials = await Keychain.getInternetCredentials(
"notesnook"
);
if (userKeyCredentials) {
const userKeyCipher: Cipher = (await encrypt(

Some files were not shown because too many files have changed in this diff Show More