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>
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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`
|
||||||
|
|
||||||
@@ -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 & 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 & 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 & 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 & 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 <media@waterschurch.org></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 — <weekday, month, day></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 & 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>
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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>
|
||||||
@@ -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}"
|
||||||
@@ -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"
|
||||||
@@ -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 & 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 & 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 <media@waterschurch.org></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 & 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 & 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>
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/build/*
|
||||||
|
!/build/.npmkeep
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 262 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 262 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
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>
|
||||||
|
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>
|
||||||
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 240 B |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 988 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 157 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 176 B |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 308 B |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 444 B |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 608 B |
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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" "$@"
|
||||||
@@ -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
|
||||||
@@ -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'
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 936 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 906 KiB |
|
After Width: | Height: | Size: 573 KiB |