Bring the checklist app under version control

The project has lived only as a folder on the Mac Studio and, briefly, as a
transfer tarball. This is the first commit: the Express server and its web
front end, the Capacitor iOS and Android wrappers, and the release and backup
scripts.

The repo is the source, not a backup of the machine. Left out on purpose, by
the .gitignore files that were already in the tree: node_modules, build output,
mobile/www (regenerated from server/public on every build), and server/data,
which holds the live checklists and belongs on the NAS rather than in git.

Every credential is excluded too — server/.env, the Play upload keystore and
its properties file, the App Store Connect key and ios-signing.config. The one
secret that was not in an ignored file was the NAS SSH password, hard-coded in
build-deploy.sh and grepped back out of it by backup-to-drive.sh. Both now read
it from the login keychain (service checklist-nas, account Christian), falling
back to NAS_PASS in the environment, so rotating it is one keychain entry
rather than a history rewrite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-24 13:00:57 -04:00
co-authored by Claude Opus 5
commit 469db5bc60
177 changed files with 33247 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "checklist",
"runtimeExecutable": "bash",
"runtimeArgs": ["-lc", "cd /Users/christian/Documents/checklist-transfer/recurring-checklist-app/server && ADMIN_PASSWORD=changeme REPORT_EMAIL='' PORT=4998 node server.js"],
"port": 4998
}
]
}
+20
View File
@@ -0,0 +1,20 @@
node_modules/
dist/
*.log
.DS_Store
*.ico
build/icon.ico
build/icon.png
*.exe
# ── Deploy / release artifacts ──────────────────────────────────────────────
checklist-deploy.tgz
*.tgz
dist-release/
# ── Secrets (belt and braces; the real rules are in mobile/ and server/) ────
.env
*.p8
*.jks
*.keystore
+41
View File
@@ -0,0 +1,41 @@
# Building Instructions
## Quick Start
1. **Install Dependencies**
```bash
npm install
```
2. **Test the Application**
```bash
npm start
```
This will launch the app in development mode.
3. **Build Windows Installer**
```bash
npm run build:win
```
This will create a Windows installer in the `dist` folder.
## Building for Distribution
After running `npm run build:win`, you'll find:
- An installer executable in the `dist` folder (e.g., `Recurring Checklist Setup 1.0.0.exe`)
- This installer can be copied to any Windows machine and run to install the application
## Notes
- The first build may take several minutes as it downloads Electron binaries
- Each Windows machine will have its own independent checklist data
- The app stores data locally using electron-store
- No internet connection required after installation
## Troubleshooting
If you encounter issues building:
1. Make sure Node.js is installed (version 14 or higher recommended)
2. Try deleting `node_modules` and running `npm install` again
3. On Windows, you may need to install build tools: `npm install --global windows-build-tools`
+324
View File
@@ -0,0 +1,324 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Recurring Checklist — Deployment Guide</title>
<style>
@page { margin: 0.7in; }
html, body {
font-family: -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
color: #1a1a1a;
line-height: 1.55;
font-size: 11pt;
background: #fff;
margin: 0;
padding: 32px 40px 56px;
max-width: 820px;
}
h1, h2, h3, h4 { color: #1f2937; line-height: 1.25; }
h1 { font-size: 26pt; margin: 0 0 4px; }
h2 { font-size: 16pt; margin: 28px 0 8px; padding-bottom: 4px; border-bottom: 2px solid #3498db; }
h3 { font-size: 13pt; margin: 18px 0 6px; color: #2c3e50; }
h4 { font-size: 11pt; margin: 12px 0 4px; color: #2c3e50; }
p { margin: 6px 0 8px; }
ul, ol { margin: 6px 0 10px 22px; padding: 0; }
li { margin: 3px 0; }
code {
background: #f1f3f5;
padding: 1px 5px;
border-radius: 3px;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 10pt;
color: #c7254e;
}
pre {
background: #1f2937;
color: #f8f9fa;
padding: 12px 14px;
border-radius: 6px;
overflow-x: auto;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 9.5pt;
line-height: 1.4;
page-break-inside: avoid;
}
pre code { background: transparent; color: inherit; padding: 0; font-size: inherit; }
table { width: 100%; border-collapse: collapse; margin: 8px 0 14px; font-size: 10pt; }
th, td { border: 1px solid #d1d5db; padding: 6px 9px; text-align: left; vertical-align: top; }
th { background: #f3f4f6; font-weight: 600; }
.lede { color: #4b5563; font-size: 12pt; margin: 4px 0 14px; }
.meta { color: #6b7280; font-size: 9pt; margin-top: 2px; }
.callout {
border-left: 4px solid #3498db;
background: #ecf6fd;
padding: 8px 12px;
margin: 10px 0 12px;
border-radius: 0 4px 4px 0;
font-size: 10pt;
}
.callout.warn { border-color: #f39c12; background: #fff7e6; }
.callout.danger { border-color: #e74c3c; background: #fdecea; }
.callout strong { display: block; margin-bottom: 2px; }
hr { border: 0; border-top: 1px solid #e5e7eb; margin: 28px 0; }
.toc { background: #f9fafb; border: 1px solid #e5e7eb; padding: 14px 18px; border-radius: 6px; margin: 16px 0 24px; }
.toc h3 { margin-top: 0; }
.toc ol { margin: 6px 0 0 22px; }
section { page-break-inside: auto; }
h2 { page-break-after: avoid; }
pre, table, .callout { page-break-inside: avoid; }
.footer { margin-top: 36px; padding-top: 12px; border-top: 1px solid #e5e7eb; color: #6b7280; font-size: 9pt; }
</style>
</head>
<body>
<h1>Recurring Checklist</h1>
<p class="lede">Self-hosted deployment guide — Synology + Docker + Gmail SMTP</p>
<p class="meta">Document version 1 · Generated for IT handoff · <em>Edit any value in this document to match your environment.</em></p>
<div class="toc">
<h3>Contents</h3>
<ol>
<li>What this is &amp; what was deployed</li>
<li>Network architecture</li>
<li>Deployment steps (already completed)</li>
<li>How users access it</li>
<li>How admin manages it</li>
<li>Weekly email digest</li>
<li>Backups (Hyper Backup → Google Drive)</li>
<li>Updating the app code</li>
<li>Troubleshooting / common ops</li>
<li>Credentials &amp; references</li>
</ol>
</div>
<hr/>
<h2>1. What this is</h2>
<p>A small Node.js web application running in a Docker container on the Synology NAS named <strong>Chronicles-NA</strong>. It hosts a multi-location checklist that staff complete each Sunday for startup and shutdown procedures. Every Sunday at 3:00 PM the server emails a single digest to <code>media@waterschurch.org</code> summarising what each location checked off and what they missed, then resets the list for the next week.</p>
<h3>Components</h3>
<table>
<tr><th>Piece</th><th>Where</th><th>Purpose</th></tr>
<tr><td>Web app (Node.js + Express)</td><td>Container on Chronicles-NA, port 3000</td><td>Serves the UI and stores check states</td></tr>
<tr><td>JSON state file</td><td><code>/volume1/docker/recurring-checklist/data/checklists.json</code></td><td>Source of truth for lists, items, and per-location checks</td></tr>
<tr><td>Reverse proxy</td><td>Boba-Fett (DSM → Control Panel → Login Portal → Advanced → Reverse Proxy)</td><td>Terminates HTTPS for the friendly URL and forwards to Chronicles-NA</td></tr>
<tr><td>Let’s Encrypt certificate</td><td>Boba-Fett (DSM → Control Panel → Security → Certificate)</td><td>Issued for <code>check.watersmedia.org</code>; auto-renews every 90 days</td></tr>
<tr><td>Scheduler (node-cron)</td><td>Inside the container</td><td>Fires <code>0 15 * * 0</code> in <code>America/New_York</code> to send the digest and reset state</td></tr>
<tr><td>Hyper Backup task</td><td>Chronicles-NA → Google Drive</td><td>Daily 23:30 backup of the <code>docker</code> shared folder</td></tr>
</table>
<hr/>
<h2>2. Network architecture</h2>
<pre>
Internet
│
▼ (https://check.watersmedia.org → public IP)
Router ─ port 443 forwarded to ─► Boba-Fett (10.1.1.4)
│
│ Reverse-proxy rule:
│ check.watersmedia.org
│ → http://10.1.1.1:3000
▼
Chronicles-NA (10.1.1.1)
│
│ Docker container "recurring-checklist"
│ listens on :3000
▼
Node.js / Express
│
├─► /volume1/docker/recurring-checklist/data/ (state file)
└─► SMTP smtp.gmail.com:587 (digest)
</pre>
<h3>Hosts &amp; URLs</h3>
<table>
<tr><th>Hostname</th><th>Role</th></tr>
<tr><td><code>https://check.watersmedia.org</code></td><td>Public URL for the app (TLS via Boba-Fett)</td></tr>
<tr><td><code>http://10.1.1.1:3000</code></td><td>Direct LAN access to the app (bypasses reverse proxy)</td></tr>
<tr><td><code>chronicles-na</code> (10.1.1.1)</td><td>Synology that runs the container</td></tr>
<tr><td><code>boba-fett</code> (10.1.1.4)</td><td>Synology that owns the public IP &amp; reverse proxy</td></tr>
</table>
<hr/>
<h2>3. Deployment steps (already completed)</h2>
<p>The system is already deployed. The steps below document exactly how it was built so it can be reproduced on another NAS if needed.</p>
<h3>3.1 Prepare the source bundle</h3>
<p>The application source lives in this repository under <code>server/</code>. A deploy tarball is produced from that folder (excluding <code>node_modules</code>):</p>
<pre><code>tar --exclude='node_modules' --exclude='.DS_Store' \
-czf checklist-deploy.tgz -C server .</code></pre>
<h3>3.2 Upload to the NAS</h3>
<ol>
<li>Open <strong>File Station</strong> on Chronicles-NA.</li>
<li>Navigate to the <code>docker</code> shared folder (created automatically when Container Manager is installed).</li>
<li>Create folder <code>recurring-checklist</code> inside <code>docker</code>.</li>
<li>Upload <code>checklist-deploy.tgz</code> into the new folder.</li>
<li>Right-click → <strong>Extract</strong> → Extract here. Synology unpacks <code>.tgz</code> in two passes (it leaves an intermediate <code>.tar</code>) — right-click that <code>.tar</code> and extract again.</li>
<li>Delete <code>checklist-deploy.tgz</code>, <code>checklist-deploy.tar</code>, and the <code>PaxHeader</code> folder.</li>
</ol>
<h3>3.3 Configure <code>.env</code></h3>
<p>The bundle ships a working <code>.env</code>. The values that matter are listed below. Edit through File Station → right-click → <strong>Open with → Text Editor</strong> (or any text editor).</p>
<table>
<tr><th>Key</th><th>Value used</th><th>Notes</th></tr>
<tr><td><code>PORT</code></td><td>3000</td><td>Container-internal port. Don’t change unless also editing <code>docker-compose.yml</code>.</td></tr>
<tr><td><code>ADMIN_PASSWORD</code></td><td>(set in <code>.env</code>)</td><td>Password used to enter “Admin” mode in the web UI.</td></tr>
<tr><td><code>REPORT_EMAIL</code></td><td><code>media@waterschurch.org</code></td><td>Recipient of the Sunday digest.</td></tr>
<tr><td><code>REPORT_CRON</code></td><td><code>0 15 * * 0</code></td><td>Sunday at 3:00 PM in <code>REPORT_TZ</code>.</td></tr>
<tr><td><code>REPORT_TZ</code></td><td><code>America/New_York</code></td><td>IANA timezone for the cron.</td></tr>
<tr><td><code>RESET_AFTER_DIGEST</code></td><td><code>true</code></td><td>Server clears all check states after the digest is sent.</td></tr>
<tr><td><code>SMTP_HOST</code></td><td><code>smtp.gmail.com</code></td><td></td></tr>
<tr><td><code>SMTP_PORT</code></td><td><code>587</code></td><td>STARTTLS port.</td></tr>
<tr><td><code>SMTP_USER</code></td><td><code>media@waterschurch.org</code></td><td>Sender — a Google Workspace account.</td></tr>
<tr><td><code>SMTP_PASS</code></td><td><em>Gmail App Password (16 chars)</em></td><td>Generated at <code>myaccount.google.com/apppasswords</code>. NOT the regular Gmail password — requires 2-Step Verification.</td></tr>
<tr><td><code>SMTP_FROM</code></td><td><code>Recurring Checklist &lt;media@waterschurch.org&gt;</code></td><td></td></tr>
</table>
<h3>3.4 Build and start the container</h3>
<ol>
<li>Install <strong>Container Manager</strong> from Package Center on Chronicles-NA (creates the <code>docker</code> shared folder automatically).</li>
<li>Open Container Manager → left sidebar <strong>Project</strong> → <strong>Create</strong>.</li>
<li>Project name: <code>recurring-checklist</code>. Path: <code>/volume1/docker/recurring-checklist</code>. Source: “Use existing docker-compose.yml”.</li>
<li>Skip the “Set up web portal via Web Station” option (not needed).</li>
<li>Click Done. Container Manager runs <code>docker compose up</code>; first build takes ~60 seconds. Final log line should be <code>Exit Code: 0</code>.</li>
<li>Verify: <code>http://10.1.1.1:3000</code> serves the app.</li>
</ol>
<h3>3.5 Public hostname + TLS</h3>
<ol>
<li><strong>DDNS</strong> (Chronicles-NA → Control Panel → External Access → DDNS → Add): Synology provider, hostname <code>check.watersmedia.org</code>, External Address Auto. Status should be Normal.</li>
<li><strong>Reverse proxy</strong> on <em>Boba-Fett</em> (Control Panel → Login Portal → Advanced → Reverse Proxy → Create):
<ul>
<li>Source: <code>HTTPS</code> · <code>check.watersmedia.org</code> · port <code>443</code></li>
<li>Destination: <code>HTTP</code> · <code>10.1.1.1</code> · port <code>3000</code></li>
</ul>
</li>
<li><strong>Let’s Encrypt</strong> on Boba-Fett (Control Panel → Security → Certificate → Add → Get a certificate from Let’s Encrypt):
<ul>
<li>Domain: <code>check.watersmedia.org</code></li>
<li>Email: <code>media@waterschurch.org</code></li>
</ul>
</li>
<li>Bind the cert: <strong>Settings</strong> button in the Certificate tab → set the <code>check.watersmedia.org</code> row to use the new cert → OK.</li>
</ol>
<div class="callout">
<strong>Why does the reverse proxy live on Boba-Fett, not on Chronicles-NA?</strong>
The router forwards port 443 to Boba-Fett. Putting the proxy there avoids any router changes. Boba-Fett receives <code>check.watersmedia.org</code> traffic, terminates TLS with the Let’s Encrypt cert, and forwards plain HTTP to Chronicles-NA:3000 over the LAN.
</div>
<hr/>
<h2>4. How users access it</h2>
<p>Any browser on any device:</p>
<pre><code>https://check.watersmedia.org</code></pre>
<p>First visit on each device asks for a name. That name is the <strong>location identifier</strong> (e.g. <code>Woonsocket</code>, <code>North Attleboro</code>). Case and whitespace are normalised — <code>woonsocket</code>, <code>WOONSOCKET</code>, and <code>Woonsocket</code> all collapse to the same canonical name. The name is stored in the browser’s <code>localStorage</code> and can be changed by clicking the name pill in the top-right.</p>
<p>Each location ticks off items independently. Unassigned lists are visible to all locations but checks are per-location. Lists with a specific location are only visible to (and writeable by) that location.</p>
<hr/>
<h2>5. How admin manages it</h2>
<ol>
<li>Click the <strong>🔒 Admin</strong> button in the top-right and enter <code>ADMIN_PASSWORD</code>.</li>
<li>A horizontal “Menu” bar appears under the list tabs. Click or swipe it to expand:
<ul>
<li><strong>+ Add List</strong> / <strong>Rename List</strong> / <strong>Delete List</strong> — each list has an optional “Assign to location” field. Leave blank to show the list to every location.</li>
<li><strong>Export JSON</strong> — downloads the current state file as a backup snapshot.</li>
<li><strong>Import JSON</strong> — appends imported lists into the current state, auto-tagged with the current logged-in location.</li>
<li><strong>Send Digest Now</strong> — sends a one-off (manual) copy of the Sunday report immediately. Useful to verify SMTP. Does <em>not</em> reset state.</li>
</ul>
</li>
<li>Within a list (admin mode), each item has Edit / Move / Delete buttons. Drag tabs sideways to reorder lists; drag items up/down within the To Do or Done column to reorder tasks.</li>
<li>Click the yellow <strong>🔓 Admin (on)</strong> button again to leave admin mode. All management UI disappears for non-admins.</li>
</ol>
<hr/>
<h2>6. Weekly email digest</h2>
<p>A <code>node-cron</code> job fires at <code>0 15 * * 0</code> in <code>America/New_York</code> (Sunday 3:00 PM local). It:</p>
<ol>
<li>Reads the current state from <code>checklists.json</code>.</li>
<li>Builds a per-location report: one section per location, each list within shown as a green/red table of ✓/✗ items with the who+when stamp.</li>
<li>Sends the email via <code>smtp.gmail.com:587</code> using the configured app password, to <code>REPORT_EMAIL</code> only. The subject is <code>Checklist Report — &lt;weekday, month, day&gt;</code>.</li>
<li>If <code>RESET_AFTER_DIGEST=true</code>, the entire check-states map is cleared so the next week starts clean. Items themselves are preserved.</li>
</ol>
<p>To change the schedule, edit <code>REPORT_CRON</code> in <code>.env</code> and rebuild the container (see §8).</p>
<hr/>
<h2>7. Backups</h2>
<p>Hyper Backup task <strong>“Recurring Checklist”</strong> on Chronicles-NA:</p>
<ul>
<li>Destination: Google Drive (signed in as <code>media@waterschurch.org</code>).</li>
<li>Source: <code>docker</code> shared folder (includes everything under <code>/volume1/docker</code>).</li>
<li>Schedule: Daily 23:30; integrity check Saturday 01:30.</li>
<li>Compression on. Client-side encryption off.</li>
</ul>
<p>Restoring an entire run is as simple as: open Hyper Backup → select task → Restore Data → pick a version → restore to <code>/volume1/docker/recurring-checklist/data/checklists.json</code>. The container picks up the new file on next request (no restart needed).</p>
<hr/>
<h2>8. Updating the app code</h2>
<p>The state file (<code>data/checklists.json</code>) and the configuration file (<code>.env</code>) are preserved between updates because they live in a Docker bind-mount and on disk. The update procedure is:</p>
<ol>
<li>Developer produces a new <code>checklist-deploy.tgz</code> from the latest <code>server/</code> source.</li>
<li>Upload the new tarball into <code>/volume1/docker/recurring-checklist/</code> via File Station.</li>
<li><strong>Important:</strong> before extracting, rename or move the existing <code>.env</code> aside so it’s not overwritten (the bundle ships an example <code>.env</code>, but if the deploy script excludes it, this step is unnecessary — confirm with the developer per release).</li>
<li>Extract the new tarball (in two passes, as in §3.2). Replace existing files when prompted.</li>
<li>Delete the leftover <code>.tgz</code> / <code>.tar</code> / <code>PaxHeader</code>.</li>
<li>Open Container Manager → Project → <strong>recurring-checklist</strong> → <strong>Action</strong> menu → <strong>Build</strong> (or Stop, then Start with Build). This rebuilds the image with the new code and restarts the container.</li>
<li>Verify: <code>http://10.1.1.1:3000</code> still loads, and any new functionality from the release is visible.</li>
</ol>
<p>Rollback: keep the previous <code>checklist-deploy.tgz</code> archived. To roll back, re-extract the older bundle, rebuild. State file is untouched.</p>
<hr/>
<h2>9. Troubleshooting / common ops</h2>
<table>
<tr><th>Symptom</th><th>Where to look</th><th>Fix</th></tr>
<tr><td>App URL times out</td><td>Container Manager → recurring-checklist → status</td><td>If stopped: Action → Start. If crashing: open container Log, look for errors.</td></tr>
<tr><td>Digest email never arrived</td><td>Container Manager → container Log, search <code>[digest]</code></td><td>Look for <code>Failed: ...</code>. Most often Gmail app password revoked or rotated; regenerate at <code>myaccount.google.com/apppasswords</code> and update <code>SMTP_PASS</code> in <code>.env</code>, then rebuild.</td></tr>
<tr><td>Cert renewal failed</td><td>Boba-Fett → Control Panel → Security → Certificate</td><td>Right-click the cert → Renew. Requires port 80 reachable on Boba-Fett.</td></tr>
<tr><td>State got corrupted / accidental wipe</td><td>Hyper Backup → Recurring Checklist task</td><td>Restore Data → pick latest known-good version → restore the <code>data/</code> folder only.</td></tr>
<tr><td>“Wrong password” on admin login</td><td><code>.env</code> on the NAS</td><td>Compare <code>ADMIN_PASSWORD</code>. If changed, rebuild container (env vars are read on container start).</td></tr>
<tr><td>Reverse proxy returns Boba-Fett DSM instead of the app</td><td>Boba-Fett → Control Panel → Login Portal → Advanced → Reverse Proxy</td><td>Confirm rule for <code>check.watersmedia.org</code> exists and routes to <code>10.1.1.1:3000</code>. Cert must be bound (see §3.5).</td></tr>
</table>
<h3>Useful one-liners (SSH into Chronicles-NA)</h3>
<pre><code># See the running container
sudo docker ps | grep recurring
# Tail logs
sudo docker logs -f recurring-checklist
# Manually restart the container
sudo docker compose -f /volume1/docker/recurring-checklist/docker-compose.yml restart
# Look at the live state file
cat /volume1/docker/recurring-checklist/data/checklists.json | head</code></pre>
<hr/>
<h2>10. Credentials &amp; references</h2>
<ul>
<li><strong>Admin password</strong>: set in <code>.env</code> on the NAS (key <code>ADMIN_PASSWORD</code>). Rotate by editing <code>.env</code> and rebuilding the container.</li>
<li><strong>Gmail app password</strong>: generated at <code>https://myaccount.google.com/apppasswords</code> using the <code>media@waterschurch.org</code> Google Workspace account. Stored in <code>.env</code> as <code>SMTP_PASS</code>. To rotate: revoke the existing app password in Google, generate a new one, paste into <code>.env</code>, rebuild.</li>
<li><strong>Synology DDNS</strong>: <code>check.watersmedia.org</code> tied to the Synology Account used during DDNS setup. Renewal is automatic.</li>
<li><strong>Let’s Encrypt cert</strong>: 90-day validity, auto-renewal handled by DSM 7. Confirm under Control Panel → Security → Certificate.</li>
<li><strong>Source repository</strong>: the application source is the <code>server/</code> directory of <code>recurring-checklist-app</code>, maintained by the application developer.</li>
</ul>
<div class="callout warn">
<strong>Security notes</strong>
The app uses a shared password for admin actions (no per-user accounts). Anyone on the open URL can check items off using any name, so don’t treat the contents of the checklist as confidential. The admin password is the only gate for adding/editing/deleting lists.
</div>
<div class="footer">
Recurring Checklist deployment runbook · Generated for Waters Church IT handoff.
</div>
</body>
</html>
Binary file not shown.
+107
View File
@@ -0,0 +1,107 @@
# Moving checklist development to another Mac
The project folder is most of it, but three things live outside it, and one thing should
deliberately **stay** on the Mac Studio.
---
## What to copy
`checklist-app-transfer.tgz` — the whole project minus `node_modules`, build output and
secrets (all of which are either regenerated or restored separately). Unpack it wherever you
like; nothing depends on the path except the two config files noted below.
```bash
mkdir -p ~/Documents && tar -xzf checklist-app-transfer.tgz -C ~/Documents
```
## What the laptop needs installed
| | How |
|---|---|
| **Xcode** | Mac App Store. Then `sudo xcodebuild -license accept` and open it once. |
| **Node 20+** | [nodejs.org](https://nodejs.org) installer |
| **JDK 21** | see below |
| **Android SDK** | see below |
No Homebrew and no CocoaPods are needed — iOS uses Swift Package Manager deliberately,
because the system Ruby is too old for CocoaPods.
```bash
# JDK 21
curl -L -o /tmp/jdk.tar.gz 'https://api.adoptium.net/v3/binary/latest/21/ga/mac/aarch64/jdk/hotspot/normal/eclipse'
mkdir -p ~/.jdks && tar -xzf /tmp/jdk.tar.gz -C ~/.jdks && mv ~/.jdks/jdk-21* ~/.jdks/temurin-21
# Android command-line tools + SDK
curl -L -o /tmp/cmdtools.zip 'https://dl.google.com/android/repository/commandlinetools-mac-13114758_latest.zip'
mkdir -p ~/Library/Android/sdk/cmdline-tools && unzip -q /tmp/cmdtools.zip -d /tmp
mv /tmp/cmdline-tools ~/Library/Android/sdk/cmdline-tools/latest
yes | ~/Library/Android/sdk/cmdline-tools/latest/bin/sdkmanager --sdk_root="$HOME/Library/Android/sdk" --licenses
~/Library/Android/sdk/cmdline-tools/latest/bin/sdkmanager --sdk_root="$HOME/Library/Android/sdk" \
"platform-tools" "platforms;android-36" "build-tools;36.0.0"
# Project dependencies
cd ~/Documents/recurring-checklist-app/mobile && npm install
cd ../server && npm install
```
Then edit `mobile/android/local.properties` so `sdk.dir` points at the new machine's home
directory — it is machine-specific and will be wrong after a copy.
## Restore the secrets
They are **not** in the transfer bundle on purpose. They are already in
`checklist-signing-backup.dmg` (Google Drive → `Apps/Backups`). Open it and:
| From the DMG | Goes to |
|---|---|
| `android/upload-keystore.jks` | `<project>/mobile/keystore/upload-keystore.jks` |
| `android/keystore.properties` | `<project>/mobile/keystore.properties` |
| `ios/AuthKey_NXR7767SYK.p8` | `~/.appstoreconnect/private_keys/` |
| `ios/ios-signing.config` | `<project>/mobile/ios-signing.config` |
| — | `<project>/server/.env` — copy from the Studio; it is not in the DMG |
```bash
chmod 600 ~/.appstoreconnect/private_keys/AuthKey_NXR7767SYK.p8
```
Then fix the absolute path inside `keystore.properties`:
```
storeFile=/Users/<you>/Documents/recurring-checklist-app/mobile/keystore/upload-keystore.jks
```
**The iOS distribution certificate is not copied.** Sign into Xcode ▸ Settings ▸ Accounts
with the same Apple ID and run `./release.sh --ios`; `-allowProvisioningUpdates` mints a new
certificate for the new machine automatically. Nothing needs exporting from the old keychain.
## Check it works
```bash
./mobile/scripts/verify-asc-key.sh # authenticates against App Store Connect
cd mobile && node scripts/sync-web.js # rebuilds www/ from server/public
cd android && . ../scripts/env.sh && ./gradlew :app:bundleRelease --no-daemon
```
A signed `.aab` means Android is fully restored. `./release.sh --ios` proves the Apple side.
---
## What must STAY on the Mac Studio
**The nightly off-site backup.** `backup-to-drive.sh` plus the LaunchAgent
`org.waterschurch.checklist-drive-backup` should keep running here, not on the laptop:
- a LaunchAgent only fires while the machine is awake and logged in — a laptop that sleeps or
goes home silently stops backing up, and the failure is invisible
- it needs the LAN to reach the NAS at `10.1.1.5`
Leave both in place on the Studio. If the laptop ever becomes the only machine, move the
script and reinstall the LaunchAgent there — and check the log afterwards.
## The other thing that needs the LAN
`build-deploy.sh` (and therefore `./release.sh`) pushes to the NAS at `10.1.1.5`. **Web
deploys only work on the church network.** Off-site you can still build and upload the iOS
and Android apps — those only need the internet — but `./release.sh --web` will fail until
you are back on the LAN or on a VPN into it.
+100
View File
@@ -0,0 +1,100 @@
# Recurring Checklist Application
A simple Windows desktop application for managing recurring checklists. Perfect for tasks that need to be completed on a regular schedule, similar to Planning Center checklists but dedicated and simplified.
## Features
- ✅ Add checklist items with titles and detailed descriptions
- ✅ Mark items as complete (moves to "Done" section)
- ✅ Set items to recur daily, weekly, or monthly
- ✅ Automatic reset of recurring items based on schedule
- ✅ Dark theme interface matching Planning Center style
- ✅ Card-based layout with clear visibility
- ✅ Data persistence across sessions
- ✅ Easy to package and distribute to multiple machines
## Installation
### Development Setup
1. Install Node.js (if not already installed) - Download from [nodejs.org](https://nodejs.org/)
2. Navigate to the project directory:
```bash
cd recurring-checklist-app
```
3. Install dependencies:
```bash
npm install
```
4. Run the application:
```bash
npm start
```
### Building for Windows Distribution
To create a Windows executable installer:
```bash
npm run build:win
```
This will create an installer in the `dist` folder that you can distribute to other machines. The installer will be named something like `Recurring Checklist Setup 1.0.0.exe`.
## Usage
1. **Add Tasks**:
- Enter a title (required) and optional description
- Descriptions support multiple lines and can include detailed instructions
- Click "Add Task" or press Enter
2. **Set Recurrence**:
- Check "Recurring Task" checkbox
- Select frequency: Daily, Weekly, or Monthly
- For weekly: Choose the day of the week (e.g., Sunday)
- For monthly: Choose the day of the month (1-31)
3. **Complete Tasks**:
- Check the checkbox next to an item to mark it complete
- Completed items automatically move to the "Done" section
4. **Recurring Reset**:
- Recurring items automatically reset based on their schedule
- Daily items reset each day
- Weekly items reset on their specified day
- Monthly items reset on their specified day of the month
5. **Delete Tasks**:
- Click the "Delete" button on any task to remove it permanently
## Recurrence Options
- **Daily**: Item resets every day at midnight
- **Weekly**: Item resets on a specific day of the week (e.g., every Sunday)
- **Monthly**: Item resets on a specific day of the month (e.g., day 1)
## Data Storage
All checklist data is stored locally on your machine using `electron-store`. Data persists between application sessions and is stored in your user data directory.
## Distribution
The built application can be easily copied to other Windows machines:
1. Build the installer using `npm run build:win`
2. Copy the installer file from the `dist` folder
3. Run the installer on each Windows machine where you want to use the app
4. Each installation will have its own independent data storage
## Requirements
- Windows 7 or later
- Node.js (for development and building only)
## Notes
- The application checks for recurring items every minute
- Recurring items reset based on the current date/time
- Each machine will have its own separate checklist data
- The app uses a dark theme similar to Planning Center for better visibility
+147
View File
@@ -0,0 +1,147 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Updating the Recurring Checklist server</title>
<style>
@page { margin: 0.7in; }
html, body {
font-family: -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
color: #1a1a1a;
line-height: 1.6;
font-size: 12pt;
background: #fff;
margin: 0;
padding: 36px 44px;
max-width: 820px;
}
h1 { font-size: 24pt; margin: 0 0 4px; color: #1f2937; }
h2 { font-size: 14pt; margin: 24px 0 6px; color: #1f2937; padding-bottom: 4px; border-bottom: 2px solid #3498db; }
h3 { font-size: 12pt; margin: 16px 0 4px; color: #2c3e50; }
p { margin: 6px 0 8px; }
ol, ul { margin: 6px 0 12px 22px; padding: 0; }
li { margin: 5px 0; }
code {
background: #f1f3f5;
padding: 1px 5px;
border-radius: 3px;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 10.5pt;
color: #c7254e;
}
pre {
background: #1f2937; color: #f8f9fa;
padding: 10px 14px; border-radius: 6px;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 10pt; line-height: 1.4;
}
.lede { color: #4b5563; font-size: 12pt; margin: 4px 0 14px; }
.callout {
border-left: 4px solid #3498db;
background: #ecf6fd;
padding: 8px 12px;
margin: 12px 0;
border-radius: 0 4px 4px 0;
font-size: 10.5pt;
}
.callout.warn { border-color: #f39c12; background: #fff7e6; }
.step { margin: 10px 0 18px; padding-left: 0; }
.step-num {
display: inline-block;
background: #3498db;
color: #fff;
width: 26px; height: 26px;
line-height: 26px;
text-align: center;
border-radius: 50%;
font-weight: 600;
margin-right: 8px;
vertical-align: middle;
}
</style>
</head>
<body>
<h1>Updating the Recurring Checklist server</h1>
<p class="lede">5-minute guide for pushing new app versions to the Synology NAS</p>
<h2>Before you start</h2>
<ul>
<li>The developer will give you a file called <code>checklist-deploy.tgz</code>.</li>
<li>Your data (everything people have ticked off) and your settings (<code>.env</code>) are kept on the NAS and never touched by an update. Only the app code changes.</li>
<li>You only need access to <strong>File Station</strong> and <strong>Container Manager</strong> on the Chronicles-NA Synology.</li>
</ul>
<h2>The 5 steps</h2>
<div class="step">
<p><span class="step-num">1</span><strong>Open File Station</strong> on Chronicles-NA and navigate to:</p>
<pre>docker → recurring-checklist</pre>
</div>
<div class="step">
<p><span class="step-num">2</span><strong>Drag the new <code>checklist-deploy.tgz</code></strong> into that folder. If File Station asks about overwriting, choose <em>Overwrite</em>.</p>
</div>
<div class="step">
<p><span class="step-num">3</span><strong>Extract it</strong> — Synology does this in two passes:</p>
<ol>
<li>Right-click <code>checklist-deploy.tgz</code> → <strong>Extract</strong> → <strong>Extract here</strong>. A <code>checklist-deploy.tar</code> appears next to it.</li>
<li>Right-click <code>checklist-deploy.tar</code> → <strong>Extract</strong> → <strong>Extract here</strong>. When it asks about overwriting files, say <strong>“Overwrite all”</strong> or <strong>“Yes to all”</strong>.</li>
</ol>
<div class="callout">
<strong>Don’t worry about overwriting.</strong> The tarball is built so it can’t touch <code>.env</code> or <code>data/</code>. The only files that change are the app code: <code>server.js</code>, <code>public/*</code>, <code>Dockerfile</code>, etc.
</div>
</div>
<div class="step">
<p><span class="step-num">4</span><strong>Clean up the leftovers</strong>. Delete:</p>
<ul>
<li><code>checklist-deploy.tgz</code></li>
<li><code>checklist-deploy.tar</code></li>
<li>The <code>PaxHeader</code> folder (it’s just tar metadata)</li>
</ul>
</div>
<div class="step">
<p><span class="step-num">5</span><strong>Rebuild the container</strong> in Container Manager:</p>
<ol>
<li>Open <strong>Container Manager</strong>.</li>
<li>Click <strong>Project</strong> in the left sidebar.</li>
<li>Click the row <strong>recurring-checklist</strong> to select it.</li>
<li>Click the <strong>Action</strong> dropdown above the list → choose <strong>Build</strong>.</li>
<li>A log window pops up. Wait ~30–60 seconds until you see <code>Exit Code: 0</code> at the bottom. Click <strong>Close</strong>.</li>
</ol>
</div>
<h2>Verify it worked</h2>
<ol>
<li>Open <code>https://check.watersmedia.org</code> in a browser.</li>
<li>Hard-refresh with <strong>Cmd + Shift + R</strong> (or Ctrl + Shift + R on Windows). This clears any cached old JavaScript.</li>
<li>The page should load normally, with whatever new feature the developer described in the release.</li>
</ol>
<h2>If something looks broken</h2>
<ol>
<li><strong>Check container logs.</strong> Container Manager → <strong>Container</strong> tab → <code>recurring-checklist</code> → <strong>Details</strong> → <strong>Log</strong> tab. Look for red error lines near the bottom.</li>
<li><strong>Restart the container.</strong> Container Manager → Project → <strong>recurring-checklist</strong> → Action → <strong>Stop</strong>, then Action → <strong>Start</strong>.</li>
<li><strong>Restore your data</strong> if a release accidentally corrupted state: Hyper Backup → <em>Recurring Checklist</em> task → <strong>Restore Data</strong> → pick a recent backup → restore just the <code>data/</code> folder.</li>
<li><strong>Roll back.</strong> Keep the previous release’s <code>.tgz</code> archived. To roll back, extract it over the current code (overwriting), then rebuild. Your data stays intact.</li>
</ol>
<div class="callout warn">
<strong>Always keep the previous release’s <code>.tgz</code> for at least a week.</strong> If a new release introduces a bug, you can roll back in under a minute by extracting the old one and rebuilding.
</div>
<h2>Quick reference</h2>
<ul>
<li><strong>Code location on NAS:</strong> <code>/volume1/docker/recurring-checklist/</code></li>
<li><strong>Data file:</strong> <code>/volume1/docker/recurring-checklist/data/checklists.json</code></li>
<li><strong>Settings file:</strong> <code>/volume1/docker/recurring-checklist/.env</code></li>
<li><strong>Public URL:</strong> <code>https://check.watersmedia.org</code></li>
<li><strong>Direct LAN URL:</strong> <code>http://10.1.1.1:3000</code></li>
<li><strong>Backup destination:</strong> Google Drive, signed in as <code>media@waterschurch.org</code></li>
</ul>
</body>
</html>
BIN
View File
Binary file not shown.
+1093
View File
File diff suppressed because it is too large Load Diff
+122
View File
@@ -0,0 +1,122 @@
#!/bin/bash
# backup-to-drive.sh — copy the checklist's nightly backups off the NAS into Google Drive.
#
# WHY THIS EXISTS
# The NAS writes data/backups/checklists-<date>.json every night at 03:30 and keeps 30.
# That is a good backup of the data and a poor backup of the building: if the NAS dies,
# every one of those 30 files dies with it. The Google Drive folder was meant to be the
# off-site copy, but it was hand-made — and on 2026-09-16 it was discovered to have
# stopped in July, ten weeks earlier, with nobody noticing. This closes that loop.
#
# Run by a LaunchAgent at 04:15 daily (after the NAS job). Also safe to run by hand.
#
# WHAT IT DOES NOT DO
# It never writes to the NAS and never deletes anything there. The only thing it removes
# is old copies in Drive beyond KEEP, and only ones matching the backup naming pattern.
set -uo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
DRIVE_DIR="$HOME/Library/CloudStorage/GoogleDrive-media@waterschurch.org/Shared drives/Waters Creative/Apps/Backups/Checklist Backups"
REMOTE_DIR="/volume2/docker/recurring-checklist/data/backups"
NAS_HOST="10.1.1.5"
NAS_USER="Christian"
LOG="$HOME/Library/Logs/checklist-drive-backup.log"
MAX_PER_RUN=10 # so a first run with a big gap can't hang for ages; it catches up next time
mkdir -p "$(dirname "$LOG")"
log() { printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$LOG"; }
say() { log "$*"; echo "$*"; }
say "── run start ──"
# The SSH password comes from the login keychain — the same entry build-deploy.sh reads, so
# there is still one copy of the credential to rotate, and it is not in the project at all.
# Store it with: security add-generic-password -U -s checklist-nas -a Christian -w
NAS_PASS="${NAS_PASS:-$(security find-generic-password -s checklist-nas -a Christian -w 2>/dev/null || true)}"
if [ -z "$NAS_PASS" ]; then
say "✗ no NAS password in the keychain (service checklist-nas, account Christian)"
say " store it with: security add-generic-password -U -s checklist-nas -a Christian -w"
exit 1
fi
# Google Drive streams files on demand; if the app is signed out or not running the path
# simply is not there. Writing to a path Drive is not watching would look like it worked and
# sync nothing, so refuse rather than pretend.
if [ ! -d "$DRIVE_DIR" ]; then
say "✗ Drive folder not mounted: $DRIVE_DIR"
say " Is Google Drive for Desktop running and signed in as media@waterschurch.org?"
exit 1
fi
run_remote() { # $1 = remote command; prints stdout, strips the pty's CRs
local cmd="$1"
expect <<EXP 2>/dev/null | tr -d '\r'
set timeout 240
log_user 1
spawn bash -c "ssh -o StrictHostKeyChecking=no -o ConnectTimeout=20 -o PreferredAuthentications=password -o PubkeyAuthentication=no -o NumberOfPasswordPrompts=1 $NAS_USER@$NAS_HOST \"$cmd\""
expect {
-nocase "password:" { send "$NAS_PASS\r"; exp_continue }
eof
}
EXP
}
# 1. What does the NAS have?
REMOTE_LIST="$(run_remote "ls -1 $REMOTE_DIR/checklists-*.json 2>/dev/null | xargs -n1 basename" \
| grep -E '^checklists-[0-9]{4}-[0-9]{2}-[0-9]{2}\.json$' | sort)"
if [ -z "$REMOTE_LIST" ]; then say "✗ no backups found on the NAS (or SSH failed)"; exit 1; fi
say "NAS has $(echo "$REMOTE_LIST" | wc -l | tr -d ' ') backup(s)"
# 2. Which are missing from Drive?
MISSING=""
while IFS= read -r f; do
[ -s "$DRIVE_DIR/$f" ] || MISSING="$MISSING$f"$'\n'
done <<< "$REMOTE_LIST"
MISSING="$(printf '%s' "$MISSING" | sed '/^$/d')"
if [ -z "$MISSING" ]; then
say "✓ Drive already has every NAS backup — nothing to do"
else
COUNT="$(echo "$MISSING" | wc -l | tr -d ' ')"
say "$COUNT missing from Drive; copying up to $MAX_PER_RUN"
n=0
while IFS= read -r f; do
[ -z "$f" ] && continue
n=$((n+1)); [ $n -gt $MAX_PER_RUN ] && { say "· stopping at $MAX_PER_RUN this run"; break; }
TMP="$(mktemp -t checklist-backup)"; OUT="$(mktemp -t checklist-backup-json)"
run_remote "cat $REMOTE_DIR/$f" > "$TMP"
# expect echoes its own `spawn …` banner and the password prompt onto stdout before the
# file's contents, so the capture is never pure JSON. Slice from the first { to the last }
# and only then validate — a truncated or half-read file that *looks* like a backup is
# worse than an obviously missing one.
if node -e '
const fs=require("fs");
const t=fs.readFileSync(process.argv[1],"utf8");
const a=t.indexOf("{"), b=t.lastIndexOf("}");
if(a<0||b<=a) process.exit(1);
const j=JSON.parse(t.slice(a,b+1)); // throws => exit non-zero
if(!Array.isArray(j.lists)) process.exit(1); // shape check, not just valid JSON
fs.writeFileSync(process.argv[2], JSON.stringify(j));
' "$TMP" "$OUT" 2>/dev/null; then
mv "$OUT" "$DRIVE_DIR/$f"; rm -f "$TMP"
say " ✓ $f ($(du -k "$DRIVE_DIR/$f" | cut -f1) KB)"
else
rm -f "$TMP" "$OUT"
say " ✗ $f — no valid checklist JSON in the response, skipped"
fi
done <<< "$MISSING"
fi
# 3. Report only. This script DELIBERATELY DELETES NOTHING in Drive.
#
# An earlier version trimmed Drive to the newest 30 to mirror the NAS, and promptly destroyed
# the June snapshots — which existed ONLY in Drive, because the NAS had already rotated them
# out. That is backwards: the whole value of the off-site copy is that it outlives the NAS's
# 30-day window. Each file is ~260 KB, so a decade of them is under a gigabyte. If it ever
# genuinely needs trimming, do it by hand, and never below what the NAS still holds.
cd "$DRIVE_DIR" || exit 1
TOTAL="$(ls -1 checklists-????-??-??.json 2>/dev/null | wc -l | tr -d ' ')"
OLDEST="$(ls -1 checklists-????-??-??.json 2>/dev/null | sort | head -1)"
NEWEST="$(ls -1 checklists-????-??-??.json 2>/dev/null | sort | tail -1)"
say "✓ done — $TOTAL copies off-site, ${OLDEST:-none} → ${NEWEST:-none}"
+124
View File
@@ -0,0 +1,124 @@
#!/bin/bash
# Build a deploy tarball from server/ and push it to the production NAS.
# Excludes anything that should NEVER be overwritten on the server:
# - data/ (user accounts, check states, list content)
# - .env (admin password, Gmail credentials)
# - node_modules (rebuilt by Docker)
#
# Usage: ./build-deploy.sh
#
# Requires:
# - /tmp/sshstdin.exp (created on demand)
# - /tmp/deploy.sh (created on demand)
# - Christian's SSH password, from the login keychain (see below)
set -e
ROOT="$(cd "$(dirname "$0")" && pwd)"
TARBALL="$ROOT/checklist-deploy.tgz"
NAS_HOST="10.1.1.5"
NAS_USER="Christian"
NAS_PATH="/volume2/docker/recurring-checklist"
# The NAS password is NOT stored in this file — it would end up in the repo history,
# where rotating it means rewriting history rather than changing one keychain entry.
# It is read from the login keychain, or from NAS_PASS in the environment for a machine
# that has no keychain (CI, a Linux box).
#
# To store it, or after a password change:
# security add-generic-password -U -s checklist-nas -a Christian -w
# (omit the value and security prompts for it, so it never reaches your shell history)
NAS_PASS="${NAS_PASS:-$(security find-generic-password -s checklist-nas -a Christian -w 2>/dev/null || true)}"
if [ -z "$NAS_PASS" ]; then
echo "No NAS password available." >&2
echo "Store it once with: security add-generic-password -U -s checklist-nas -a Christian -w" >&2
echo "or export NAS_PASS before running this script." >&2
exit 1
fi
echo "==> Building tarball (excluding data/, .env, node_modules)"
tar \
--exclude='./node_modules' \
--exclude='./data' \
--exclude='./data-smoke' \
--exclude='./.DS_Store' \
--exclude='./.env' \
-czf "$TARBALL" -C "$ROOT/server" .
echo "==> Sanity check: tarball must not contain data/ or .env"
if tar -tzf "$TARBALL" | grep -E '^\./?(data|\.env)(/|$)' >/dev/null; then
echo "REFUSING to deploy — tarball contains data/ or .env"
tar -tzf "$TARBALL" | grep -E '^\./?(data|\.env)(/|$)'
exit 1
fi
echo " OK ($(ls -lh "$TARBALL" | awk '{print $5}'))"
# Re-create helper scripts if missing
if [ ! -f /tmp/sshstdin.exp ]; then
cat > /tmp/sshstdin.exp <<'EXP'
#!/usr/bin/expect -f
set timeout 300
log_user 1
set host [lindex $argv 0]
set user [lindex $argv 1]
set pass [lindex $argv 2]
set cmd [lindex $argv 3]
set localfile [lindex $argv 4]
spawn bash -c "ssh -o StrictHostKeyChecking=no -o ConnectTimeout=20 -o PreferredAuthentications=password -o PubkeyAuthentication=no -o GSSAPIAuthentication=no -o NumberOfPasswordPrompts=1 $user@$host \"$cmd\" < $localfile"
expect {
-nocase "password:" { send "$pass\r"; exp_continue }
eof
}
catch wait result
exit [lindex $result 3]
EXP
fi
cat > /tmp/deploy.sh <<'DEPLOY'
#!/bin/sh
set -e
cd /volume2/docker/recurring-checklist
echo "--- DEPLOY-SIDE SANITY CHECK ---"
if tar -tzf checklist-deploy.tgz | grep -E '^\./?(data|\.env)(/|$)' >/dev/null; then
echo "ABORT: incoming tarball contains data/ or .env — refusing to extract"
exit 1
fi
echo "--- EXTRACT (data/ and .env are protected because tarball doesn't include them) ---"
tar -xzf checklist-deploy.tgz
rm -f checklist-deploy.tgz
rm -rf PaxHeader
find . -name "._*" -delete
echo "--- VERIFY DATA UNTOUCHED ---"
ls -la data/checklists.json 2>/dev/null && echo "data/checklists.json preserved" || echo "(no checklists.json yet)"
ls -la .env 2>/dev/null && echo ".env preserved" || echo "(no .env)"
echo "--- DEPLOY: recreate in place (NO prior 'down') ---"
# Deliberately NO 'docker-compose down' first. 'down' removes the container AND the network, so a
# deploy interrupted between 'down' and 'up' leaves the whole stack torn down (caused an outage once).
# 'up -d --build --remove-orphans' rebuilds the image and recreates ONLY the app container when its
# image changed, in place — the network and the data volume persist. If interrupted, the worst case is
# the old container still running, never a fully removed stack. --remove-orphans clears stale containers.
echo "$REMOTE_PASS" | sudo -S /usr/local/bin/docker-compose -f /volume2/docker/recurring-checklist/docker-compose.yml up -d --build --remove-orphans 2>&1 | tail -14
echo "--- STATUS ---"
echo "$REMOTE_PASS" | sudo -S /usr/local/bin/docker ps --filter name=recurring --format "{{.Names}} {{.Status}}"
echo "--- HEALTHCHECK ---"
sleep 4
curl -sS -o /dev/null -w "GET /api/me => HTTP %{http_code}\n" http://127.0.0.1:3000/api/me
DEPLOY
echo "==> Uploading to $NAS_USER@$NAS_HOST:$NAS_PATH"
expect /tmp/sshstdin.exp "$NAS_HOST" "$NAS_USER" "$NAS_PASS" \
"cat > $NAS_PATH/checklist-deploy.tgz && echo UPLOAD_OK" \
"$TARBALL" 2>&1 | tail -3
echo "==> Running deploy script on NAS"
expect /tmp/sshstdin.exp "$NAS_HOST" "$NAS_USER" "$NAS_PASS" \
"REMOTE_PASS='$NAS_PASS' bash -s" \
/tmp/deploy.sh 2>&1 | tail -15
echo "==> Done"
+448
View File
@@ -0,0 +1,448 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Recurring Checklist — Deployment & Maintenance Guide</title>
<style>
:root {
--ink: #1a1a1a;
--muted: #555;
--accent: #2c5d9c;
--accent-2: #f1c40f;
--bg: #ffffff;
--code-bg: #f5f7fa;
--rule: #d9dde3;
}
* { box-sizing: border-box; }
html, body { background: var(--bg); color: var(--ink); }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.55;
font-size: 12pt;
margin: 0;
padding: 24pt 28pt;
max-width: 7.5in;
}
h1 {
font-size: 24pt;
margin: 0 0 4pt;
color: var(--ink);
}
h1 + .subtitle {
color: var(--muted);
margin: 0 0 18pt;
font-size: 12pt;
}
h2 {
font-size: 16pt;
margin: 28pt 0 8pt;
padding-bottom: 4pt;
border-bottom: 2px solid var(--accent);
color: var(--accent);
page-break-after: avoid;
}
h3 {
font-size: 13pt;
margin: 18pt 0 6pt;
color: var(--ink);
page-break-after: avoid;
}
h4 {
font-size: 12pt;
margin: 14pt 0 4pt;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 600;
}
p, ul, ol, table { margin: 6pt 0; }
ul, ol { padding-left: 22pt; }
li { margin-bottom: 3pt; }
code, pre {
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 10.5pt;
}
code {
background: var(--code-bg);
padding: 1pt 5pt;
border-radius: 3pt;
border: 1px solid var(--rule);
}
pre {
background: var(--code-bg);
border: 1px solid var(--rule);
border-radius: 4pt;
padding: 10pt 12pt;
overflow-x: auto;
white-space: pre-wrap;
word-wrap: break-word;
}
pre code { background: transparent; border: none; padding: 0; }
table {
border-collapse: collapse;
width: 100%;
margin: 8pt 0;
}
th, td {
border: 1px solid var(--rule);
padding: 6pt 9pt;
text-align: left;
vertical-align: top;
font-size: 11pt;
}
th {
background: var(--code-bg);
font-weight: 600;
}
hr {
border: none;
border-top: 1px solid var(--rule);
margin: 18pt 0;
}
.callout {
border-left: 4px solid var(--accent-2);
background: #fff8db;
padding: 8pt 12pt;
margin: 10pt 0;
border-radius: 0 4pt 4pt 0;
}
.callout.note { border-left-color: var(--accent); background: #eaf2fb; }
.callout.warn { border-left-color: #d04a4a; background: #fcecec; }
.callout strong { display: block; margin-bottom: 4pt; }
.toc {
background: var(--code-bg);
border: 1px solid var(--rule);
border-radius: 6pt;
padding: 12pt 18pt;
margin: 12pt 0 24pt;
}
.toc ol { margin: 0; padding-left: 18pt; }
.toc li { margin-bottom: 2pt; }
.toc a { color: var(--ink); text-decoration: none; }
.kicker {
display: inline-block;
background: var(--accent);
color: white;
padding: 2pt 8pt;
font-size: 10pt;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
border-radius: 3pt;
margin-bottom: 8pt;
}
.small { font-size: 10pt; color: var(--muted); }
@media print {
body { padding: 0.6in 0.6in; max-width: none; }
h2 { page-break-after: avoid; }
h3 { page-break-after: avoid; }
pre, table, .callout { page-break-inside: avoid; }
}
</style>
</head>
<body>
<span class="kicker">Internal documentation</span>
<h1>Recurring Checklist</h1>
<p class="subtitle">Deployment &amp; maintenance guide — self-hosted web app on Synology DSM.</p>
<div class="toc">
<strong>Contents</strong>
<ol>
<li><a href="#overview">Overview</a></li>
<li><a href="#environment">Environment summary</a></li>
<li><a href="#deploy">Initial deployment</a></li>
<li><a href="#updates">Pushing app updates</a></li>
<li><a href="#backup">Backups</a></li>
<li><a href="#dns">DDNS, reverse proxy &amp; HTTPS</a></li>
<li><a href="#troubleshoot">Troubleshooting</a></li>
<li><a href="#references">Reference</a></li>
</ol>
</div>
<h2 id="overview">1. Overview</h2>
<p>
The Recurring Checklist is a small Node.js web application that lives in a Docker container on the
Synology NAS. Multi-location operators (Woonsocket, North Attleboro, etc.) open it in a web browser,
identify themselves once per device, and tick items off through the week. Every Sunday at 3:00 PM the
server emails a digest of who completed what (and what was missed) to a single recipient, then resets
all checkboxes for the following week.
</p>
<h4>Architecture</h4>
<pre><code>Users (LAN or internet)
│
▼
https://check.watersmedia.org (Boba-Fett DSM, port 443)
│
│ reverse proxy
▼
http://10.1.1.1:3000 (Chronicles-NA DSM, Docker container)
│
▼
node:20-alpine + Express + node-cron + nodemailer
│
├── data volume → /volume1/docker/recurring-checklist/data/checklists.json
│ (backed up nightly to Google Drive via Hyper Backup)
│
└── Sunday 3:00 PM cron → Gmail SMTP → media@waterschurch.org</code></pre>
<h2 id="environment">2. Environment summary</h2>
<table>
<tr><th>Item</th><th>Value</th></tr>
<tr><td>App URL (external)</td><td><code>https://check.watersmedia.org</code></td></tr>
<tr><td>App URL (LAN, direct)</td><td><code>http://10.1.1.1:3000</code></td></tr>
<tr><td>Public TLS host</td><td>Boba-Fett (10.1.1.4), DSM reverse proxy + Let's Encrypt cert</td></tr>
<tr><td>Container host</td><td>Chronicles-NA (10.1.1.1), Container Manager project <code>recurring-checklist</code></td></tr>
<tr><td>Project folder on NAS</td><td><code>/volume1/docker/recurring-checklist/</code></td></tr>
<tr><td>Persisted data file</td><td><code>/volume1/docker/recurring-checklist/data/checklists.json</code></td></tr>
<tr><td>Backups</td><td>Hyper Backup → Google Drive (<code>recurring-checklist_server-backup/Chronicles-NA_1</code>), daily 23:30</td></tr>
<tr><td>Email digest schedule</td><td>Sunday 15:00 America/New_York</td></tr>
<tr><td>Email recipient</td><td><code>media@waterschurch.org</code></td></tr>
<tr><td>SMTP relay</td><td><code>smtp.gmail.com:587</code> (Google Workspace app password)</td></tr>
<tr><td>Admin password</td><td>Set in <code>.env</code> as <code>ADMIN_PASSWORD</code></td></tr>
</table>
<h2 id="deploy">3. Initial deployment</h2>
<p>The container was created using the following procedure. Use this section as the disaster-recovery /
rebuild reference.</p>
<h3>3.1 Prerequisites</h3>
<ul>
<li>Synology DSM 7.2 or newer on the host NAS (Chronicles-NA)</li>
<li>Container Manager package installed</li>
<li>SSH or File Station access to <code>/volume1/docker/</code></li>
<li>Google Workspace account with an App Password (for the digest sender)</li>
<li>The deployment tarball: <code>checklist-deploy.tgz</code> (produced from the project's <code>server/</code> directory)</li>
</ul>
<h3>3.2 Upload the bundle</h3>
<ol>
<li>Open File Station on the NAS.</li>
<li>Inside <code>/volume1/docker/</code>, create a folder named <code>recurring-checklist</code>.</li>
<li>Upload <code>checklist-deploy.tgz</code> into it.</li>
<li>Right-click → Extract → Extract here. Repeat once on the resulting <code>.tar</code> (Synology's
File Station unwraps in two passes).</li>
<li>Delete the <code>.tgz</code>, <code>.tar</code>, and the <code>PaxHeader</code> folder that gets produced. The
folder should be left with:
<pre><code>server.js public/ Dockerfile
package.json data/ docker-compose.yml
package-lock.json README.md .env .env.example .dockerignore .gitignore</code></pre>
</li>
</ol>
<h3>3.3 Configure environment</h3>
<p>The deployment ships with a working <code>.env</code> file. Verify the following keys are populated:</p>
<table>
<tr><th>Variable</th><th>Purpose</th></tr>
<tr><td><code>ADMIN_PASSWORD</code></td><td>Required for any list-management operation in the UI.</td></tr>
<tr><td><code>REPORT_EMAIL</code></td><td>Where the Sunday digest is delivered.</td></tr>
<tr><td><code>REPORT_CRON</code></td><td>cron schedule for the digest. Default <code>0 15 * * 0</code> (Sun 3:00 PM).</td></tr>
<tr><td><code>REPORT_TZ</code></td><td>IANA timezone for the cron (default <code>America/New_York</code>).</td></tr>
<tr><td><code>RESET_AFTER_DIGEST</code></td><td><code>true</code> = clear all checks once the digest is sent. <code>false</code> to disable.</td></tr>
<tr><td><code>SMTP_HOST</code></td><td><code>smtp.gmail.com</code></td></tr>
<tr><td><code>SMTP_PORT</code></td><td><code>587</code></td></tr>
<tr><td><code>SMTP_USER</code></td><td>Gmail / Google Workspace address that sends the digest.</td></tr>
<tr><td><code>SMTP_PASS</code></td><td>16-character app password from <code>myaccount.google.com/apppasswords</code>.</td></tr>
<tr><td><code>SMTP_FROM</code></td><td>Friendly <code>From:</code> header, e.g. <code>Recurring Checklist &lt;media@waterschurch.org&gt;</code></td></tr>
</table>
<div class="callout warn">
<strong>Security note</strong>
The <code>.env</code> file contains credentials. Do not place it in a publicly-shared folder. Hyper
Backup is encrypted in transit; client-side encryption is not enabled because it complicates restore.
</div>
<h3>3.4 Create the Container Manager project</h3>
<ol>
<li>Open Container Manager → <strong>Project</strong> → <strong>Create</strong>.</li>
<li><strong>Project name:</strong> <code>recurring-checklist</code></li>
<li><strong>Path:</strong> browse to <code>/volume1/docker/recurring-checklist</code> → Select.</li>
<li><strong>Source:</strong> "Use existing docker-compose.yml" (auto-selected). Click <strong>OK</strong>.</li>
<li>On the <em>Web portal settings</em> screen, leave the checkbox unchecked. Click <strong>Next</strong>.</li>
<li>Confirm the summary and click <strong>Done</strong>.</li>
</ol>
<p>Container Manager will build the image (~60–90 seconds on first run) and start the container. The
project should report <code>Exit Code: 0</code> at the end of the build log and the project status indicator
should turn green.</p>
<h3>3.5 Verify</h3>
<ol>
<li>From a browser on the LAN, open <code>http://10.1.1.1:3000</code>. The "Who are you?" prompt should appear.</li>
<li>Click <strong>Admin</strong> (top right) and authenticate with the value of <code>ADMIN_PASSWORD</code>.</li>
<li>Use <strong>Send Digest Now</strong> in the admin drawer to verify SMTP connectivity. A test email should arrive within a few seconds.</li>
</ol>
<h2 id="updates">4. Pushing app updates</h2>
<p>Updates are delivered as a refreshed deployment tarball. Application data is mounted from the host's
<code>data/</code> directory, so rebuilding the image preserves all checklists, locations, and check states.</p>
<h3>4.1 Receive the update</h3>
<p>The developer produces a fresh <code>checklist-deploy.tgz</code> from the latest project tree. It will
arrive as a single file (currently ~35 KB).</p>
<h3>4.2 Apply on the NAS</h3>
<ol>
<li>Open File Station → navigate to <code>/volume1/docker/recurring-checklist/</code>.</li>
<li>Upload the new <code>checklist-deploy.tgz</code> into this folder.</li>
<li>Right-click → Extract → Extract here. Choose <strong>Overwrite</strong> when prompted (this replaces
<code>server.js</code>, the <code>public/</code> folder, etc.; it does <em>not</em> touch <code>data/</code>).</li>
<li>Repeat the extract on the resulting <code>.tar</code>.</li>
<li>Delete the <code>.tgz</code>, <code>.tar</code>, and <code>PaxHeader</code> when done.</li>
</ol>
<h3>4.3 Rebuild the container</h3>
<ol>
<li>Open Container Manager → <strong>Project</strong> → click the <code>recurring-checklist</code> row.</li>
<li>Click <strong>Action</strong> → <strong>Build</strong> (rebuilds the image from the updated source).</li>
<li>Wait for the build log to finish with <code>Exit Code: 0</code>.</li>
<li>Click <strong>Action</strong> → <strong>Stop</strong>, then <strong>Action</strong> → <strong>Start</strong> if the build did not auto-restart.</li>
</ol>
<div class="callout note">
<strong>What gets preserved across updates</strong>
Everything in <code>./data/</code> is volume-mounted. Lists, items, per-location check states, and
<code>lastCheckDate</code> values are persisted. Configuration in <code>.env</code> is also untouched by an update unless
the new tarball explicitly ships a replacement.
</div>
<h3>4.4 Rollback</h3>
<p>If an update misbehaves:</p>
<ol>
<li>Recover the previous <code>checklist-deploy.tgz</code> (kept by the developer) or restore from Hyper Backup.</li>
<li>Replace the files in <code>/volume1/docker/recurring-checklist/</code> as in §4.2.</li>
<li>Rebuild the project as in §4.3.</li>
</ol>
<h2 id="backup">5. Backups</h2>
<h3>5.1 What's backed up</h3>
<p>A Hyper Backup task named <strong>Recurring Checklist</strong> backs up the entire <code>docker</code>
shared folder nightly at 23:30 to Google Drive. Destination:</p>
<pre><code>Google Drive: recurring-checklist_server-backup / Chronicles-NA_1</code></pre>
<p>This captures both the application source (in <code>recurring-checklist/</code>) and its data folder.</p>
<h3>5.2 Restoring</h3>
<p>To restore the data file only:</p>
<ol>
<li>Open Hyper Backup → Recurring Checklist task → <strong>Restore</strong> → <strong>Data</strong>.</li>
<li>Pick a version. Browse into
<code>recurring-checklist/data/</code> and restore <code>checklists.json</code>.</li>
<li>Container Manager → Project → Stop, then Start the <code>recurring-checklist</code> project to pick up the restored file.</li>
</ol>
<h3>5.3 Backup verification</h3>
<p>An integrity check runs weekly on Saturday at 01:30. Failed integrity checks generate a DSM notification.</p>
<h2 id="dns">6. DDNS, reverse proxy &amp; HTTPS</h2>
<h3>6.1 DDNS record</h3>
<ul>
<li><strong>Hostname:</strong> <code>check.watersmedia.org</code></li>
<li><strong>Provider:</strong> Synology (free)</li>
<li><strong>Account:</strong> Registered to the Synology account on Chronicles-NA.</li>
</ul>
<h3>6.2 Reverse proxy rule (on Boba-Fett, 10.1.1.4)</h3>
<p>Located at <em>Control Panel → Login Portal → Advanced → Reverse Proxy → "Recurring Checklist"</em>.</p>
<table>
<tr><th>Side</th><th>Protocol</th><th>Hostname</th><th>Port</th></tr>
<tr><td>Source</td><td>HTTPS</td><td><code>check.watersmedia.org</code></td><td>443</td></tr>
<tr><td>Destination</td><td>HTTP</td><td><code>10.1.1.1</code></td><td>3000</td></tr>
</table>
<h3>6.3 TLS certificate</h3>
<ul>
<li><strong>Type:</strong> Let's Encrypt</li>
<li><strong>Bound to:</strong> the <code>check.watersmedia.org</code> service in Control Panel → Security → Certificate → Settings</li>
<li><strong>Auto-renewal:</strong> handled by DSM. The cert renews ~30 days before expiry as long as
ports 80/443 inbound continue to reach Boba-Fett.</li>
</ul>
<h3>6.4 Router (UniFi)</h3>
<p>Existing port-forward of TCP 443 to Boba-Fett (10.1.1.4) is the only inbound rule required. No
new forwarding is needed for the Recurring Checklist app.</p>
<h2 id="troubleshoot">7. Troubleshooting</h2>
<table>
<tr><th>Symptom</th><th>Likely cause &amp; fix</th></tr>
<tr>
<td>App returns 502 / Bad Gateway from <code>check.watersmedia.org</code></td>
<td>The Docker container on Chronicles-NA is stopped. Open Container Manager → Project → click
<code>recurring-checklist</code> → <strong>Action → Start</strong>. If it fails to start, open
the build log for errors (commonly a malformed <code>.env</code>).</td>
</tr>
<tr>
<td>Browser shows "Not Secure" on the public URL</td>
<td>Certificate either expired or not bound. Check Control Panel → Security → Certificate on Boba-Fett.
The <code>check.watersmedia.org</code> cert should be green and bound under
<strong>Settings</strong>.</td>
</tr>
<tr>
<td>Sunday email did not arrive</td>
<td>Open Container Manager → Container → <code>recurring-checklist</code> → <strong>Details → Log</strong>.
Look for lines beginning with <code>[digest]</code>. Common causes: expired Google App Password,
Google Workspace 2FA disabled on the sending account, outbound TCP 587 blocked by firewall.</td>
</tr>
<tr>
<td>"This list is reserved for X" error</td>
<td>Expected. The list is location-scoped; a user signed in as a different location cannot tick it
off. Either change the user's location to match the list or remove the list's location to make it
shared.</td>
</tr>
<tr>
<td>Checks reappear after a user signs out and back in</td>
<td>Per-location checks are stored on the server keyed by the user's typed name (case-insensitive,
whitespace-collapsed). If a user enters a different name, they are treated as a different
location and see different state.</td>
</tr>
<tr>
<td>Container won't build</td>
<td>Usually a stale or partial extraction. Re-upload <code>checklist-deploy.tgz</code> and extract
both <code>.tgz</code> and the intermediate <code>.tar</code> step. Confirm <code>server.js</code> and the <code>public/</code> folder
are present at the project root.</td>
</tr>
</table>
<h2 id="references">8. Reference</h2>
<h3>8.1 Files in <code>/volume1/docker/recurring-checklist/</code></h3>
<table>
<tr><th>Path</th><th>Purpose</th></tr>
<tr><td><code>server.js</code></td><td>Express app + scheduler + email logic.</td></tr>
<tr><td><code>public/</code></td><td>Static front-end (HTML, CSS, JS).</td></tr>
<tr><td><code>Dockerfile</code></td><td>Image definition (node:20-alpine).</td></tr>
<tr><td><code>docker-compose.yml</code></td><td>Container Manager project definition.</td></tr>
<tr><td><code>package.json</code> / <code>package-lock.json</code></td><td>npm manifest, locked dependency versions.</td></tr>
<tr><td><code>.env</code></td><td>Runtime config (passwords, SMTP, schedule). Not in version control.</td></tr>
<tr><td><code>.env.example</code></td><td>Template for a fresh deploy.</td></tr>
<tr><td><code>data/checklists.json</code></td><td>Persistent state. Volume-mounted; backed up nightly.</td></tr>
</table>
<h3>8.2 Useful container actions</h3>
<table>
<tr><th>Action</th><th>Where</th></tr>
<tr><td>View live logs</td><td>Container Manager → Container → <code>recurring-checklist</code> → Details → Log</td></tr>
<tr><td>Restart container</td><td>Container Manager → Project → recurring-checklist → Action → Stop, then Start</td></tr>
<tr><td>Rebuild image after update</td><td>Container Manager → Project → recurring-checklist → Action → Build</td></tr>
<tr><td>Edit env vars</td><td>Edit <code>/volume1/docker/recurring-checklist/.env</code> via File Station, then restart the project</td></tr>
</table>
<h3>8.3 Server endpoints (for diagnostics)</h3>
<table>
<tr><th>Method</th><th>Path</th><th>Purpose</th></tr>
<tr><td>GET</td><td><code>/api/state</code></td><td>Returns current lists, items, and per-location check map (read-only).</td></tr>
<tr><td>POST</td><td><code>/api/admin/verify</code></td><td>Validates the admin password (used by the UI).</td></tr>
<tr><td>POST</td><td><code>/api/admin/state</code></td><td>Replaces full state. Requires <code>X-Admin-Password</code>.</td></tr>
<tr><td>POST</td><td><code>/api/admin/send-digest</code></td><td>Manually triggers the email digest. Requires admin.</td></tr>
<tr><td>POST</td><td><code>/api/check</code></td><td>Toggle a single item's checked state for a location.</td></tr>
</table>
<hr>
<p class="small">
Document version 1 · Generated for the Recurring Checklist deployment on Chronicles-NA / Boba-Fett.
Maintained alongside the project source.
</p>
</body>
</html>
Binary file not shown.
+183
View File
@@ -0,0 +1,183 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Recurring Checklist</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div id="loadingOverlay" class="loading-overlay">
<div class="loading-spinner"></div>
<div class="loading-text">Loading checklist...</div>
</div>
<div id="listModal" class="modal-overlay" style="display: none;">
<div class="modal">
<h3 id="listModalTitle"></h3>
<div id="listModalMessage" class="modal-message" style="display:none;"></div>
<input id="listModalInput" type="text" placeholder="List name" />
<div class="modal-actions">
<button id="listModalCancel" class="btn btn-secondary btn-small" type="button">Cancel</button>
<button id="listModalSave" class="btn btn-primary btn-small" type="button">Save</button>
</div>
</div>
</div>
<div id="editTaskModal" class="modal-overlay" style="display: none;">
<div class="modal wide">
<h3>Edit Task</h3>
<div class="modal-body">
<div class="form-group">
<label for="editTitle">Title</label>
<input id="editTitle" type="text" placeholder="Task title..." />
</div>
<div class="form-group">
<label for="editDescription">Description</label>
<textarea id="editDescription" placeholder="Description"></textarea>
</div>
<div class="form-group">
<label class="checkbox-label"><input type="checkbox" id="editIsRecurring" /> Recurring Task</label>
</div>
<div class="form-group" id="editRecurrenceOptions" style="display: none;">
<label for="editRecurrenceType">Repeat:</label>
<select id="editRecurrenceType">
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</select>
<div id="editWeeklyOptions" style="display: none; margin-top: 8px;">
<label>Day of week:</label>
<select id="editDayOfWeek">
<option value="0">Sunday</option>
<option value="1">Monday</option>
<option value="2">Tuesday</option>
<option value="3">Wednesday</option>
<option value="4">Thursday</option>
<option value="5">Friday</option>
<option value="6">Saturday</option>
</select>
</div>
<div id="editMonthlyOptions" style="display: none; margin-top: 8px;">
<label>Day of month:</label>
<input type="number" id="editDayOfMonth" min="1" max="31" value="1" />
</div>
</div>
</div>
<div class="modal-actions">
<button id="editModalCancel" class="btn btn-secondary btn-small" type="button">Cancel</button>
<button id="editModalSave" class="btn btn-primary btn-small" type="button">Save</button>
</div>
</div>
</div>
<div id="moveItemModal" class="modal-overlay" style="display: none;">
<div class="modal">
<h3>Move Task</h3>
<div class="modal-body">
<div class="form-group">
<label for="moveItemSelect">Select destination list</label>
<select id="moveItemSelect"></select>
</div>
</div>
<div class="modal-actions">
<button id="moveItemCancel" class="btn btn-secondary btn-small" type="button">Cancel</button>
<button id="moveItemSave" class="btn btn-primary btn-small" type="button">Move</button>
</div>
</div>
</div>
<div class="app-container">
<header>
<div class="header-left">
<div class="list-icon"></div>
<h1>Recurring Checklist</h1>
</div>
<div class="app-tabs">
<button class="tab-button active" data-tab="tasks" id="tasksTabBtn">Tasks</button>
<button class="tab-button" data-tab="add" id="addTabBtn">Add Task</button>
</div>
</header>
<div class="main-content">
<div id="tasksView" class="tab-view active">
<div class="list-tabs-row">
<div id="listTabs" class="list-tabs"></div>
</div>
<div id="listMenu" class="list-menu">
<div id="listMenuHandle" class="list-menu-handle" role="button" tabindex="0" aria-label="Toggle list menu" aria-expanded="false">
<div class="list-menu-grip"></div>
</div>
<div id="listMenuPanel" class="list-menu-panel">
<button id="exportJsonBtn" class="btn btn-secondary btn-small">Export JSON</button>
<button id="importJsonBtn" class="btn btn-secondary btn-small">Import JSON</button>
<button id="addListBtn" class="btn btn-primary btn-small">+ Add List</button>
<button id="renameListBtn" class="btn btn-secondary btn-small">Rename List</button>
<button id="deleteListBtn" class="btn btn-danger btn-small">Delete List</button>
</div>
</div>
<div class="checklists-section">
<div class="checklist-column">
<h2>To Do</h2>
<div id="todoList" class="checklist-list"></div>
</div>
<div class="checklist-column">
<h2>Done</h2>
<div id="doneList" class="checklist-list"></div>
</div>
</div>
</div>
<div id="addView" class="tab-view">
<div class="add-item-section">
<div class="add-header">
<h2>Add New Task</h2>
<div class="add-list-target">
<label for="addListSelect">Add to list:</label>
<select id="addListSelect"></select>
</div>
</div>
<div class="form-group">
<input type="text" id="itemTitle" placeholder="Task title..." />
</div>
<div class="form-group">
<textarea id="itemDescription" placeholder="Description (optional) - You can add multiple lines or bullet points"></textarea>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="isRecurring" />
Recurring Task
</label>
</div>
<div class="form-group" id="recurrenceOptions" style="display: none;">
<label for="recurrenceType">Repeat:</label>
<select id="recurrenceType">
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</select>
<div id="weeklyOptions" style="display: none;">
<label>Day of week:</label>
<select id="dayOfWeek">
<option value="0">Sunday</option>
<option value="1">Monday</option>
<option value="2">Tuesday</option>
<option value="3">Wednesday</option>
<option value="4">Thursday</option>
<option value="5">Friday</option>
<option value="6">Saturday</option>
</select>
</div>
<div id="monthlyOptions" style="display: none;">
<label>Day of month:</label>
<input type="number" id="dayOfMonth" min="1" max="31" value="1" />
</div>
</div>
<button id="addItemBtn" class="btn btn-primary">Add Task</button>
</div>
</div>
</div>
</div>
<script src="app.js"></script>
</body>
</html>
+125
View File
@@ -0,0 +1,125 @@
const { app, BrowserWindow, Menu, dialog, ipcMain } = require('electron');
const path = require('path');
const fs = require('fs/promises');
const APP_VERSION = require('./package.json').version;
let mainWindow;
function createWindow() {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
show: false, // avoid white flash; show when ready
backgroundColor: '#121212',
webPreferences: {
nodeIntegration: true,
contextIsolation: false
},
icon: path.join(__dirname, 'build', 'icon.ico')
});
// Use absolute path so packaged app can load index.html reliably
mainWindow.loadFile(path.join(__dirname, 'index.html'));
buildMenu();
mainWindow.once('ready-to-show', () => {
mainWindow.show();
});
// Open DevTools in development (comment out for production)
// mainWindow.webContents.openDevTools();
}
ipcMain.handle('export-checklists', async (event, data) => {
try {
const parentWindow = BrowserWindow.fromWebContents(event.sender);
const { canceled, filePath } = await dialog.showSaveDialog(parentWindow, {
title: 'Save checklists',
defaultPath: path.join(app.getPath('documents'), 'recurring-checklists.json'),
filters: [{ name: 'JSON Files', extensions: ['json'] }]
});
if (canceled || !filePath) {
return { canceled: true };
}
await fs.writeFile(filePath, data, 'utf-8');
return { filePath };
} catch (error) {
return { error: error.message };
}
});
ipcMain.handle('import-checklists', async (event) => {
try {
const parentWindow = BrowserWindow.fromWebContents(event.sender);
const { canceled, filePaths } = await dialog.showOpenDialog(parentWindow, {
title: 'Open checklist file',
defaultPath: app.getPath('documents'),
filters: [{ name: 'JSON Files', extensions: ['json'] }],
properties: ['openFile']
});
if (canceled || !filePaths || filePaths.length === 0) {
return { canceled: true };
}
const filePath = filePaths[0];
const data = await fs.readFile(filePath, 'utf-8');
return { filePath, data };
} catch (error) {
return { error: error.message };
}
});
function buildMenu() {
const template = [
{
label: 'Help',
submenu: [
{
label: 'About',
click: () => {
dialog.showMessageBox({
type: 'info',
title: 'About',
message: `Recurring Checklist — Build ${APP_VERSION}`,
detail: [
`Version ${APP_VERSION}`,
'',
'Copyright & Non-Distribution Disclaimer',
'',
'© Christian Bridges. All rights reserved.',
'',
'This material is protected by copyright law. No part of this work may be copied, reproduced, distributed, shared, published, uploaded, or transmitted in any form or by any means—electronic, mechanical, or otherwise—without the prior written permission of Christian Bridges.',
'',
'Unauthorized use, duplication, or distribution of this material is strictly prohibited.'
].join('\n')
});
}
}
]
}
];
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
}
app.whenReady().then(() => {
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
+47
View File
@@ -0,0 +1,47 @@
# ── Secrets: never commit, never tarball ────────────────────────────────────────────
# The upload key is the app's permanent identity on Play. Back both up off this Mac
# (password manager / encrypted external drive) — not into a repo.
keystore.properties
ios-signing.config
play-service-account.json
keystore/
*.jks
*.keystore
*.p12
*.mobileprovision
ExportOptions.plist
# ── Generated: rebuilt by `npm run sync:web` on every release ───────────────────────
# www/ is a disposable copy of ../server/public. Editing it is always a mistake — the
# next sync wipes it. Change the web app instead.
www/
node_modules/
# ── Build output ────────────────────────────────────────────────────────────────────
build/
dist/
*.ipa
*.aab
*.apk
*.dSYM.zip
*.xcarchive
# ── Machine-specific ────────────────────────────────────────────────────────────────
android/local.properties
android/.gradle/
android/build/
android/app/build/
android/capacitor-cordova-android-plugins/build/
.gradle/
# ── Xcode / SPM ─────────────────────────────────────────────────────────────────────
ios/App/build/
ios/App/Pods/
ios/App/App/public/
ios/App/DerivedData/
ios/.spm-cache/
**/xcuserdata/
**/*.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
.swiftpm/
.DS_Store
+218
View File
@@ -0,0 +1,218 @@
# Waters Checklist — native iOS & Android apps
The checklist app now ships to **three** targets, all built from the same front-end files in
`../server/public/`:
| Target | Where | Updates when |
|---|---|---|
| Website | `check.watersmedia.org` | you deploy — immediately |
| iOS app | App Store (unlisted) | a new build is uploaded **and released** |
| Android app | Play Store (unlisted track) | a new AAB is uploaded **and released** |
> **The one thing that matters:** the store apps bundle a *copy* of the front-end inside the
> app binary. They have no mechanism to notice a web deploy — not on relaunch, not on
> sign-out, not ever. Deploy the website alone and phones keep the UI they were last built
> with, silently, while the website moves on. So after any front-end change, run:
>
> ```bash
> ./release.sh
> ```
>
> from the project root (not from `mobile/`). That's the whole workflow.
---
## Everyday release
```bash
cd /Users/Studio/Documents/recurring-checklist-app
./release.sh # all three targets, bumps both build numbers
./release.sh --version 1.1.0 # …and set the version users see
./release.sh --web # website only — for a server/cron/email change
```
Once both API keys are configured (below), that one command deploys the website, uploads the
iOS build to App Store Connect and releases the Android build to the internal track — no
consoles, no dragging files. Builds also land in `dist-release/` either way.
**What "fully automated" can and cannot mean.** The website is live the second it deploys.
The stores are not, and no automation changes that: Play takes a few minutes to process an
internal-track release, and Apple takes ~15 minutes to process a build before TestFlight
testers can install it. Anything beyond TestFlight — a public App Store release — waits on
human review. The upload is automated; the stores' clocks are not.
---
## Architecture, in one screen
The apps are **not** a browser pointed at the website — Apple rejects that under Guideline
4.2. They bundle the front-end and treat the server as a JSON API.
That creates exactly one problem: on the web, front-end and API share an origin, so
`/api/state` and `/equip-images/x.jpg` just work. Inside an app the page is served from
`capacitor://localhost` (iOS) or `https://localhost` (Android), where those paths resolve
against the app bundle and 404. Two files solve it:
- **`../server/public/native-bridge.js`** — shared by *all three* targets, loaded before
`app.js`. In a browser it is a deliberate no-op. In an app it patches `window.fetch` to
re-point server-absolute paths, exposes `resolveUrl()` for the few `<img>`/`<a>` sites,
routes `navigator.vibrate` → native haptics, and opens external links in the system
browser instead of blanking the WebView.
- **`src/native-shell.js`** — native only, esbuild-bundled and injected into app bundles
only. Publishes `window.CapPlugins` synchronously, then handles splash handover, status
bar, Android's hardware Back button, foreground resume, and online/offline.
`server.js` carries a matching **`NATIVE_CORS_ORIGINS`** allowlist (an allowlist, not `*`;
auth is a Bearer token with no cookies, so `Allow-Credentials` is deliberately omitted).
`www/` is generated. It is wiped and rebuilt from `../server/public` on every sync — editing
it is always a mistake.
---
## Toolchain (already installed, no Homebrew on this Mac)
| | |
|---|---|
| JDK 21 | `~/.jdks/temurin-21` |
| Android SDK | `~/Library/Android/sdk` (API 36 + 35, build-tools, platform-tools) |
| iOS deps | **Swift Package Manager, not CocoaPods** — system Ruby is 2.6, too old for CocoaPods |
None of it is on the default `PATH`, so **every Android command must source the env first**:
```bash
. mobile/scripts/env.sh
```
Skip that and Gradle fails with the misleading *"Unable to locate a Java Runtime."*
`release.sh` does it for you.
To rebuild the toolchain on a new Mac, `scripts/env.sh` carries the exact reinstall commands
in its error messages.
---
## iOS one-time setup
**Signing is DONE** (2026-09-14). Team **LIVING WATERS CHURCH**, Team ID `M87US675B3`, in
`mobile/ios-signing.config`. `release.sh --ios` produces a signed App Store `.ipa`
unattended — `-allowProvisioningUpdates` created the distribution certificate, the
provisioning profile and the App ID registration on its own.
If you ever set this up again (new Mac, new team): sign into Xcode ▸ Settings ▸ Accounts,
then put the Team ID in `ios-signing.config` and run `./release.sh --ios`. Xcode does the
rest. Finding the Team ID is the fiddly part — Xcode 26 shows your teams but never prints
their IDs, and caches nothing readable. Decode any profile you already have instead:
```bash
security cms -D -i ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles/*.mobileprovision \
| grep -A1 -E "TeamIdentifier|TeamName"
```
Still to do in the portals:
1. **App Store Connect** → Apps → **+** → New App, bundle ID `org.waterschurch.checklist`.
Needed before a build can be uploaded.
2. For unlisted distribution, request it at
[Apple's unlisted app request form](https://developer.apple.com/contact/request/unlisted-app-distribution/).
Apple approves it manually — allow a few days. Until then use **TestFlight**, which works
immediately and is fine for volunteers.
### Automated uploads (App Store Connect API key)
With a key configured, `release.sh` validates the build against Apple's acceptance checks and
uploads it — no Organizer, no Transporter. Without one it just hands you the `.ipa`.
Creating the key needs your Apple ID and 2FA, so it's yours to do:
1. [appstoreconnect.apple.com](https://appstoreconnect.apple.com) → **Users and Access** →
**Integrations** → **App Store Connect API** → **Team Keys**.
2. **+** → name it (e.g. `Checklist release`) → Access: **App Manager** → Generate.
*(App Manager is the minimum that can upload builds. Developer is not enough.)*
3. **Download the .p8 — you get exactly one chance.** Apple never shows it again; a lost key
can only be revoked and replaced.
4. Move it where `altool` looks, keeping Apple's filename exactly:
```bash
mv ~/Downloads/AuthKey_*.p8 ~/.appstoreconnect/private_keys/
```
5. Copy two values off that page into `ios-signing.config`:
- **KEY ID** — 10 chars, in the key's own row
- **Issuer ID** — a UUID shown *above the table*, shared by every key on the team
(a classic mix-up: the Issuer ID is not the key's ID)
```
ASC_KEY_ID=XXXXXXXXXX
ASC_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
```
6. Confirm it works — authenticates only, uploads nothing:
```bash
./mobile/scripts/verify-asc-key.sh
```
The `.p8` is a credential that can ship builds as your team. It lives outside the repo in
`~/.appstoreconnect/private_keys/` (chmod 600), and `ios-signing.config` is gitignored. Back
the `.p8` up next to the Android keystore — losing it is recoverable (revoke and regenerate),
but it costs you the round trip.
## Android one-time setup
Already done — the upload key exists and `release.sh` produces a signed AAB.
⚠️ **Back up `mobile/keystore/upload-keystore.jks` and `mobile/keystore.properties` off this
Mac** (password manager / encrypted drive). Play identifies every update by that key. Both
files are gitignored and excluded from the deploy tarball.
Remaining: **Play Console** → Create app → bundle ID `org.waterschurch.checklist` → set up
an internal testing track → upload the AAB → add testers by email.
### Android uploads (Play Developer API)
With a service-account key present, `release.sh` uploads the `.aab` and releases it to the
internal track by itself. Without one it just leaves the file in `dist-release/`.
**Play requires the very first bundle of a new app to go through the Console UI by hand.**
The API can only take over from the second release onward — so do one manual upload first,
then this works forever after.
1. **Play Console** → *Users and permissions* → note you'll come back here in step 5.
2. [console.cloud.google.com](https://console.cloud.google.com) → create (or pick) a project →
**APIs & Services** → enable **Google Play Android Developer API**.
3. **IAM & Admin → Service Accounts → Create**. Name it `play-release`. No Cloud roles are
needed — permission comes from Play, not from Cloud IAM.
4. On that service account → **Keys → Add key → Create new key → JSON**. Save it as:
```
mobile/play-service-account.json
```
5. Back in **Play Console → Users and permissions → Invite user**, paste the service
account's `client_email` (ends `.iam.gserviceaccount.com`). Grant it, for this app:
*Release to testing tracks* and *View app information*. Nothing more.
6. Confirm it works — opens an edit and abandons it, changing nothing:
```bash
cd mobile && node scripts/upload-play.js --dry-run
```
Pick a different track with `PLAY_TRACK=alpha ./release.sh`. Default is `internal`.
The JSON key can ship releases as you — it's gitignored, and belongs in your password manager
alongside the upload keystore.
---
## Useful commands
```bash
. mobile/scripts/env.sh # required before any Android command
cd mobile
npm run sync:web # rebuild www/ from ../server/public
npm run sync # …and push it into both native projects
npm run open:ios # open in Xcode
npm run open:android # open in Android Studio (if installed)
npm run assets # regenerate icons/splash from assets/*.png
cd mobile/android && ./gradlew :app:assembleDebug # debug APK, for a device or emulator
```
**If you regenerate assets**, re-apply the fix in
`android/app/src/main/res/mipmap-anydpi-v26/ic_launcher*.xml` — `@capacitor/assets` insets
both adaptive-icon layers by 16.7%, which stops the gradient background reaching the
launcher's mask. The files carry a comment explaining it.
+101
View File
@@ -0,0 +1,101 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml
+2
View File
@@ -0,0 +1,2 @@
/build/*
!/build/.npmkeep
+91
View File
@@ -0,0 +1,91 @@
apply plugin: 'com.android.application'
// Version numbers live in mobile/release.config.json, shared with the iOS build, so a
// release bumps ONE file instead of drifting between the two stores. Play permanently
// reserves every versionCode it has ever seen — a duplicate is rejected and the number can
// never be reused, even after deleting the release — so this must always come from the
// file that release.sh increments.
def releaseConfig = new groovy.json.JsonSlurper().parseText(file('../../release.config.json').getText('UTF-8'))
// Signing credentials are read from mobile/keystore.properties, which is gitignored and
// never checked in. Absent (a fresh clone, a CI box), release builds simply come out
// unsigned rather than failing the whole Gradle configuration phase.
// NOTE the '../': rootProject here is mobile/android, not mobile. Pointing at
// 'keystore.properties' silently finds nothing, and an unsigned AAB builds happily and is
// only rejected once you upload it to Play — so this path is load-bearing.
def keystorePropsFile = rootProject.file('../keystore.properties')
def keystoreProps = new Properties()
if (keystorePropsFile.exists()) {
keystorePropsFile.withInputStream { keystoreProps.load(it) }
}
android {
namespace = "org.waterschurch.checklist"
compileSdk = rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "org.waterschurch.checklist"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode releaseConfig.androidVersionCode
versionName releaseConfig.version
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
signingConfigs {
release {
if (keystorePropsFile.exists()) {
storeFile file(keystoreProps['storeFile'])
storePassword keystoreProps['storePassword']
keyAlias keystoreProps['keyAlias']
keyPassword keystoreProps['keyPassword']
}
}
}
buildTypes {
release {
if (keystorePropsFile.exists()) {
signingConfig signingConfigs.release
}
// minifyEnabled stays false on purpose. All the app's logic is JavaScript in
// assets/public/, which R8 never touches; the only Java here is Capacitor's
// bridge and its plugins, where shrinking buys a couple hundred KB in exchange
// for reflection-based plugin registration breaking in ways that only show up
// in a signed release build.
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
}
apply from: 'capacitor.build.gradle'
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
}
+25
View File
@@ -0,0 +1,25 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-browser')
implementation project(':capacitor-haptics')
implementation project(':capacitor-keyboard')
implementation project(':capacitor-network')
implementation project(':capacitor-splash-screen')
implementation project(':capacitor-status-bar')
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,26 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import android.content.Context;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() throws Exception {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("com.getcapacitor.app", appContext.getPackageName());
}
}
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8" ?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- The equipment-photo screen captures straight from the camera via getUserMedia
(app.js → "In-app camera capture"). Capacitor's WebChromeClient turns the WebView's
permission request into this runtime permission, so without it the capture screen
opens to a black frame. Photos can also come from the gallery through
<input type="file">, which uses the system picker and needs no permission. -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- required=false: a tablet or Chromebook with no camera must still be able to
install the app and use the file picker instead. -->
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
</manifest>
@@ -0,0 +1,5 @@
package org.waterschurch.checklist;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

@@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1" />
</vector>
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillColor="#26A69A"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
</vector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<WebView
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Hand-corrected after @capacitor/assets generated this.
The generator insets BOTH layers by 16.7%. That's wrong for a full-bleed background:
inset, the gradient stops short of the launcher's mask and the corners show through as
bare edges. The background must cover the whole 108dp canvas. The foreground needs no
inset either — assets/icon-foreground.png already draws the check at ~51% of the
canvas, comfortably inside the 66% safe zone, and insetting shrank it to ~34%.
monochrome backs Android 13+ themed icons; the system uses only this layer's alpha. -->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Hand-corrected after @capacitor/assets generated this.
The generator insets BOTH layers by 16.7%. That's wrong for a full-bleed background:
inset, the gradient stops short of the launcher's mask and the corners show through as
bare edges. The background must cover the whole 108dp canvas. The foreground needs no
inset either — assets/icon-foreground.png already draws the check at ~51% of the
canvas, comfortably inside the 66% safe zone, and insetting shrank it to ~34%.
monochrome backs Android 13+ themed icons; the system uses only this layer's alpha. -->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 988 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 608 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Kept in step with server/public/styles.css. If the web app's palette changes,
change these too or the native chrome stops matching the page. -->
<color name="checklistHeader">#1d2026</color> <!-- styles.css header { background } -->
<color name="checklistBackground">#14161b</color> <!-- styles.css body { background } -->
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>
@@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">Waters Checklist</string>
<string name="title_activity_main">Waters Checklist</string>
<string name="package_name">org.waterschurch.checklist</string>
<string name="custom_url_scheme">org.waterschurch.checklist</string>
</resources>
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<!-- Parent is the DARK AppCompat theme, not DayNight, and windowBackground is set
explicitly. Why: the web app is dark-only (styles.css sets color-scheme: dark), but
the generated theme inherited DayNight — so on a phone in light mode the window
background was #fafafa. Android 15+ enforces edge-to-edge and ignores
android:statusBarColor, and Capacitor insets the WebView below the status bar, so
that white window background showed through as a bright unreadable strip behind the
white status-bar icons. Painting it the header colour makes the strip read as a
continuation of the app's own header, matching how iOS looks.
Chosen over overlaysWebView:true because minSdk is 24 — old System WebView builds
report env(safe-area-inset-top) as 0, which would drop the header UNDER the clock. -->
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
<item name="android:windowBackground">@color/checklistHeader</item>
<item name="android:navigationBarColor">@color/checklistBackground</item>
<!-- false = light glyphs, for the dark strip above -->
<item name="android:windowLightStatusBar">false</item>
<item name="android:windowLightNavigationBar">false</item>
</style>
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
</style>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>
@@ -0,0 +1,18 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import org.junit.Test;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() throws Exception {
assertEquals(4, 2 + 2);
}
}
+29
View File
@@ -0,0 +1,29 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.13.0'
classpath 'com.google.gms:google-services:4.4.4'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
apply from: "variables.gradle"
allprojects {
repositories {
google()
mavenCentral()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}
+24
View File
@@ -0,0 +1,24 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
include ':capacitor-browser'
project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/browser/android')
include ':capacitor-haptics'
project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/haptics/android')
include ':capacitor-keyboard'
project(':capacitor-keyboard').projectDir = new File('../node_modules/@capacitor/keyboard/android')
include ':capacitor-network'
project(':capacitor-network').projectDir = new File('../node_modules/@capacitor/network/android')
include ':capacitor-splash-screen'
project(':capacitor-splash-screen').projectDir = new File('../node_modules/@capacitor/splash-screen/android')
include ':capacitor-status-bar'
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
+22
View File
@@ -0,0 +1,22 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
Binary file not shown.
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# 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
#
# https://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.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+5
View File
@@ -0,0 +1,5 @@
include ':app'
include ':capacitor-cordova-android-plugins'
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
apply from: 'capacitor.settings.gradle'
+16
View File
@@ -0,0 +1,16 @@
ext {
minSdkVersion = 24
compileSdkVersion = 36
targetSdkVersion = 36
androidxActivityVersion = '1.11.0'
androidxAppCompatVersion = '1.7.1'
androidxCoordinatorLayoutVersion = '1.3.0'
androidxCoreVersion = '1.17.0'
androidxFragmentVersion = '1.8.9'
coreSplashScreenVersion = '1.2.0'
androidxWebkitVersion = '1.14.0'
junitVersion = '4.13.2'
androidxJunitVersion = '1.3.0'
androidxEspressoCoreVersion = '3.7.0'
cordovaAndroidVersion = '14.0.1'
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 936 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 906 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 573 KiB

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